From 1019f0e17c6ae1c32b554d17f7e91291eb93b967 Mon Sep 17 00:00:00 2001 From: Kartik Date: Sat, 30 May 2026 00:18:46 +0530 Subject: [PATCH] feat(mem0-plugin): auto coding categories, global search, OpenCode parity (#5300) --- .claude-plugin/marketplace.json | 2 +- .cursor-plugin/marketplace.json | 2 +- mem0-plugin/.claude-plugin/plugin.json | 2 +- mem0-plugin/.codex-plugin/plugin.json | 2 +- mem0-plugin/.cursor-plugin/plugin.json | 2 +- mem0-plugin/.opencode-plugin/opencode-mem0.ts | 198 ++++++++++----- .../opencode-skills/onboard/SKILL.md | 56 ++--- .../opencode-skills/switch-project/SKILL.md | 63 ++++- mem0-plugin/.opencode-plugin/package.json | 2 +- mem0-plugin/CHANGELOG.md | 18 ++ mem0-plugin/README.md | 12 +- mem0-plugin/scripts/_identity.sh | 4 +- mem0-plugin/scripts/auto_setup_categories.py | 236 ++++++++++++++++++ .../scripts/enforce_metadata_defaults.sh | 8 +- mem0-plugin/scripts/load_settings.py | 1 + mem0-plugin/scripts/on_session_start.sh | 38 ++- mem0-plugin/skills/onboard/SKILL.md | 22 +- mem0-plugin/skills/switch-project/SKILL.md | 63 ++++- .../tests/test_auto_setup_categories.py | 153 ++++++++++++ 19 files changed, 747 insertions(+), 137 deletions(-) create mode 100644 mem0-plugin/scripts/auto_setup_categories.py create mode 100644 mem0-plugin/tests/test_auto_setup_categories.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bad5fbb9b4..ad3ed3f80a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.", - "version": "0.2.7" + "version": "0.2.8" } ] } diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 78541d76f9..0be75823ed 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.", - "version": "0.2.7" + "version": "0.2.8" } ] } diff --git a/mem0-plugin/.claude-plugin/plugin.json b/mem0-plugin/.claude-plugin/plugin.json index 30add9c393..f5a140aed4 100644 --- a/mem0-plugin/.claude-plugin/plugin.json +++ b/mem0-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.7", + "version": "0.2.8", "description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/mem0-plugin/.codex-plugin/plugin.json b/mem0-plugin/.codex-plugin/plugin.json index 9de0e2f048..52bbaa59d3 100644 --- a/mem0-plugin/.codex-plugin/plugin.json +++ b/mem0-plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.7", + "version": "0.2.8", "description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/mem0-plugin/.cursor-plugin/plugin.json b/mem0-plugin/.cursor-plugin/plugin.json index e9d3616008..588ca33a93 100644 --- a/mem0-plugin/.cursor-plugin/plugin.json +++ b/mem0-plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.7", + "version": "0.2.8", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.", "author": { "name": "Mem0", diff --git a/mem0-plugin/.opencode-plugin/opencode-mem0.ts b/mem0-plugin/.opencode-plugin/opencode-mem0.ts index e46f900497..4982722999 100644 --- a/mem0-plugin/.opencode-plugin/opencode-mem0.ts +++ b/mem0-plugin/.opencode-plugin/opencode-mem0.ts @@ -6,6 +6,9 @@ import { userInfo } from "os"; import { basename, resolve, dirname } from "path"; import { randomBytes } from "crypto"; import { existsSync, readdirSync, cpSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { createHash } from "crypto"; async function getUserId(): Promise { if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID; @@ -63,6 +66,68 @@ function redact(text: string): string { return out; } +function loadGlobalSearch(): boolean { + try { + const settingsPath = join(homedir(), ".mem0", "settings.json"); + if (!existsSync(settingsPath)) return false; + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); + return settings.global_search === true; + } catch {} + return false; +} + +const CODING_CATEGORIES = [ + "architecture_decisions", "api_design", "data_models", "algorithms", + "dependencies", "environment_setup", "testing_strategy", "debugging_notes", + "performance", "security", "deployment", "code_conventions", + "error_handling", "refactoring_history", "integrations", "onboarding", + "project_meta", +]; + +function categoriesFingerprint(): string { + const sorted = [...CODING_CATEGORIES].sort(); + return createHash("sha256").update(sorted.join("\n")).digest("hex").slice(0, 16); +} + +function apiKeyFingerprint(apiKey: string): string { + return createHash("sha256").update(apiKey).digest("hex").slice(0, 16); +} + +async function autoSetupCategories(mem0: MemoryClient, apiKey: string): Promise { + const stateDir = join(homedir(), ".mem0"); + const stateFile = join(stateDir, "categories_setup.json"); + const keyFp = apiKeyFingerprint(apiKey); + const catFp = categoriesFingerprint(); + + let state: Record = {}; + try { + if (existsSync(stateFile)) { + state = JSON.parse(readFileSync(stateFile, "utf8")); + } + } catch {} + + if (state[keyFp] === catFp) return; + + try { + const project = await mem0.getProject({ fields: ["customCategories"] }); + const existing: string[] = (project as any)?.custom_categories ?? (project as any)?.customCategories ?? []; + const sortedExisting = [...existing].sort(); + const sortedTarget = [...CODING_CATEGORIES].sort(); + if (JSON.stringify(sortedExisting) === JSON.stringify(sortedTarget)) { + state[keyFp] = catFp; + mkdirSync(stateDir, { recursive: true }); + writeFileSync(stateFile, JSON.stringify(state, null, 2) + "\n"); + return; + } + + await mem0.updateProject({ customCategories: CODING_CATEGORIES as any }); + + state[keyFp] = catFp; + mkdirSync(stateDir, { recursive: true }); + writeFileSync(stateFile, JSON.stringify(state, null, 2) + "\n"); + } catch {} +} + const NUDGE_RE = /\b(remember\s+(this|that)|memorize|save\s+this|note\s+(this|that)|don'?t\s+forget|always\s+remember|never\s+forget|keep\s+(this|that)\s+in\s+(mind|memory)|store\s+(this|that))\b/i; @@ -173,6 +238,7 @@ const Mem0Plugin: Plugin = async (ctx) => { const branch = await getBranch($); const stats = { adds: 0, searches: 0, messages: 0 }; const sessionId = generateSessionId(); + const globalSearch = loadGlobalSearch(); let initialized = false; let memoryCount = 0; @@ -180,6 +246,9 @@ const Mem0Plugin: Plugin = async (ctx) => { const systemContext: string[] = []; + // Auto-configure coding categories in background (idempotent, never blocks) + Promise.resolve().then(() => autoSetupCategories(mem0, apiKey)).catch(() => {}); + return { "chat.message": async (input: any, output: any) => { const userText = extractUserText(input, output); @@ -192,11 +261,13 @@ const Mem0Plugin: Plugin = async (ctx) => { if (!initialized) { initialized = true; + const searchFilters = globalSearch + ? { OR: [{ user_id: "*" }] } + : { AND: [{ user_id: userId }, { app_id: appId }] }; + try { const all = await mem0.getAll({ - filters: { - AND: [{ user_id: userId }, { app_id: appId }], - }, + filters: searchFilters, page: 1, pageSize: 1, }); @@ -205,9 +276,15 @@ const Mem0Plugin: Plugin = async (ctx) => { (all as any)?.results?.length ?? 0; - systemContext.push( - `Always include user_id="${userId}" and app_id="${appId}" in every search_memories filter and add_memory call.`, - ); + if (globalSearch) { + systemContext.push( + `Global search is ON — searches return all memories across all users and projects. Writes still use user_id="${userId}", app_id="${appId}".`, + ); + } else { + systemContext.push( + `Always include user_id="${userId}" and app_id="${appId}" in every search_memories filter and add_memory call.`, + ); + } if (memoryCount === 0) { systemContext.push( @@ -223,9 +300,7 @@ const Mem0Plugin: Plugin = async (ctx) => { const res = await mem0.search( "recent session state decisions and learnings", { - filters: { - AND: [{ user_id: userId }, { app_id: appId }], - }, + filters: searchFilters, topK: 5, }, ); @@ -265,25 +340,21 @@ const Mem0Plugin: Plugin = async (ctx) => { const hasResume = RESUME_RE.test(safeText); if (hasResume) { try { - const [stateRes, decisionsRes] = await Promise.all([ - mem0.search("session state current task", { - filters: { + const resumeFilters = globalSearch + ? { OR: [{ user_id: "*" }] } + : { AND: [ { user_id: userId }, { app_id: appId }, - { metadata: { type: "session_state" } }, ], - }, + }; + const [stateRes, decisionsRes] = await Promise.all([ + mem0.search("session state current task", { + filters: resumeFilters, topK: 3, }), mem0.search("recent decisions and learnings", { - filters: { - AND: [ - { user_id: userId }, - { app_id: appId }, - { metadata: { type: "decision" } }, - ], - }, + filters: resumeFilters, topK: 3, }), ]); @@ -309,8 +380,11 @@ const Mem0Plugin: Plugin = async (ctx) => { if (!hasResume && memoryCount > 0) { try { + const msgFilters = globalSearch + ? { OR: [{ user_id: "*" }] } + : { AND: [{ user_id: userId }, { app_id: appId }] }; const res = await mem0.search(safeText, { - filters: { AND: [{ user_id: userId }, { app_id: appId }] }, + filters: msgFilters, topK: 5, }); stats.searches++; @@ -401,32 +475,36 @@ const Mem0Plugin: Plugin = async (ctx) => { } if (isMem0SearchOrGet(toolName)) { - const existingFilters = output.args.filters; - if (existingFilters === undefined || existingFilters === null) { - output.args.filters = { - AND: [{ user_id: userId }, { app_id: appId }], - }; - } else if (typeof existingFilters === "object") { - const andClauses: any[] = existingFilters.AND; - if (Array.isArray(andClauses)) { - const hasUid = andClauses.some( - (c: any) => c && typeof c === "object" && "user_id" in c, - ); - const hasAid = andClauses.some( - (c: any) => c && typeof c === "object" && "app_id" in c, - ); - if (!hasUid) andClauses.push({ user_id: userId }); - if (!hasAid) andClauses.push({ app_id: appId }); - } else if (andClauses === undefined) { - const hasUid = "user_id" in existingFilters; - const hasAid = "app_id" in existingFilters; - if (!hasUid || !hasAid) { - const existing = Object.entries(existingFilters).map( - ([k, v]) => ({ [k]: v }), + if (globalSearch) { + output.args.filters = { OR: [{ user_id: "*" }] }; + } else { + const existingFilters = output.args.filters; + if (existingFilters === undefined || existingFilters === null) { + output.args.filters = { + AND: [{ user_id: userId }, { app_id: appId }], + }; + } else if (typeof existingFilters === "object") { + const andClauses: any[] = existingFilters.AND; + if (Array.isArray(andClauses)) { + const hasUid = andClauses.some( + (c: any) => c && typeof c === "object" && "user_id" in c, ); - if (!hasUid) existing.push({ user_id: userId }); - if (!hasAid) existing.push({ app_id: appId }); - output.args.filters = { AND: existing }; + const hasAid = andClauses.some( + (c: any) => c && typeof c === "object" && "app_id" in c, + ); + if (!hasUid) andClauses.push({ user_id: userId }); + if (!hasAid) andClauses.push({ app_id: appId }); + } else if (andClauses === undefined) { + const hasUid = "user_id" in existingFilters; + const hasAid = "app_id" in existingFilters; + if (!hasUid || !hasAid) { + const existing = Object.entries(existingFilters).map( + ([k, v]) => ({ [k]: v }), + ); + if (!hasUid) existing.push({ user_id: userId }); + if (!hasAid) existing.push({ app_id: appId }); + output.args.filters = { AND: existing }; + } } } } @@ -481,25 +559,21 @@ const Mem0Plugin: Plugin = async (ctx) => { const errorQuery = errorLine.slice(0, 80); if (errorQuery.length < 10) return; - const [antiPatternRes, bugFixRes] = await Promise.all([ - mem0.search(`error: ${errorQuery}`, { - filters: { + const errorFilters = globalSearch + ? { OR: [{ user_id: "*" }] } + : { AND: [ { user_id: userId }, { app_id: appId }, - { metadata: { type: "anti_pattern" } }, ], - }, + }; + const [antiPatternRes, bugFixRes] = await Promise.all([ + mem0.search(`error: ${errorQuery}`, { + filters: errorFilters, topK: 3, }), mem0.search(`error: ${errorQuery}`, { - filters: { - AND: [ - { user_id: userId }, - { app_id: appId }, - { metadata: { type: "bug_fix" } }, - ], - }, + filters: errorFilters, topK: 3, }), ]); @@ -554,8 +628,11 @@ const Mem0Plugin: Plugin = async (ctx) => { } catch {} }); + const compactFilters = globalSearch + ? { OR: [{ user_id: "*" }] } + : { AND: [{ user_id: userId }, { app_id: appId }] }; const res = await mem0.search("session state decisions learnings", { - filters: { AND: [{ user_id: userId }, { app_id: appId }] }, + filters: compactFilters, topK: 10, }); const memories = extractMemories(res); @@ -577,6 +654,7 @@ const Mem0Plugin: Plugin = async (ctx) => { output.env.MEM0_APP_ID = appId; output.env.MEM0_SESSION_ID = sessionId; output.env.MEM0_BRANCH = branch; + output.env.MEM0_GLOBAL_SEARCH = globalSearch ? "true" : "false"; } }, }; diff --git a/mem0-plugin/.opencode-plugin/opencode-skills/onboard/SKILL.md b/mem0-plugin/.opencode-plugin/opencode-skills/onboard/SKILL.md index 9cb8c484bc..1cd58898a1 100644 --- a/mem0-plugin/.opencode-plugin/opencode-skills/onboard/SKILL.md +++ b/mem0-plugin/.opencode-plugin/opencode-skills/onboard/SKILL.md @@ -128,58 +128,38 @@ If `add_memory` calls fail, print: - Project file import failed. Check API key and MCP connection, then retry with: /mem0:onboard ``` -## Step 5: Set up coding categories +## Step 5: Verify coding categories -Ask: "Install coding categories optimized for development workflows? [Y/n]" +Coding categories are now configured automatically in the background when the plugin starts. This step only verifies they are set up. -If the user says no or skips, print `- Coding categories skipped.` and proceed to Step 6. +Check if the categories are already configured by searching for a project_profile memory: -If yes, store a project profile memory that records the project and its active coding categories. Call `add_memory` once with: - -- `data`: a plain-text description of the project profile and the list of active coding categories (see below) -- `user_id`: the active user id -- `app_id`: the active project id -- `metadata`: `{"type": "project_profile", "source": "onboard"}` - -The standard set of 17 coding categories to include in the `data` field: +Call `search_memories` with `query="coding categories project profile"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `top_k=1`. +If a project_profile memory is found, print: ``` -architecture_decisions - High-level design choices and system structure -api_design - Interface contracts, REST/GraphQL/RPC conventions -data_models - Schemas, entity definitions, relationships -algorithms - Non-trivial logic, performance-sensitive routines -dependencies - Libraries, versions, upgrade notes -environment_setup - Dev environment, tooling, build system -testing_strategy - Test patterns, coverage targets, mocking approach -debugging_notes - Known issues, workarounds, gotchas -performance - Bottlenecks, profiling results, optimizations -security - Auth patterns, secret handling, threat notes -deployment - CI/CD pipelines, infra config, release process -code_conventions - Naming, formatting, style rules beyond the linter -error_handling - Error taxonomy, recovery patterns, logging approach -refactoring_history - Past rewrites, why changes were made -integrations - Third-party services, webhooks, external APIs -onboarding - New-contributor notes, repo orientation -project_meta - Goals, non-goals, stakeholder context +- Coding categories already configured (17 categories, auto-installed). ``` -Example `data` value: +If no project_profile memory is found, store one as a fallback. Call `add_memory` once with: -``` -Project profile for . -Active coding categories: architecture_decisions, api_design, data_models, -algorithms, dependencies, environment_setup, testing_strategy, debugging_notes, -performance, security, deployment, code_conventions, error_handling, -refactoring_history, integrations, onboarding, project_meta. -``` +- `data`: a plain-text description of the project profile and the list of active coding categories: + ``` + Project profile for . + Active coding categories: architecture_decisions, api_design, data_models, + algorithms, dependencies, environment_setup, testing_strategy, debugging_notes, + performance, security, deployment, code_conventions, error_handling, + refactoring_history, integrations, onboarding, project_meta. + ``` +- `user_id`: the active user id +- `app_id`: the active project id +- `metadata`: `{"type": "project_profile", "source": "onboard"}` After the `add_memory` call succeeds, print: ``` - Coding categories installed (17 categories). ``` -If `add_memory` fails, print the error and suggest re-running `/mem0:onboard`. - ## Step 6: Summary Print a summary: diff --git a/mem0-plugin/.opencode-plugin/opencode-skills/switch-project/SKILL.md b/mem0-plugin/.opencode-plugin/opencode-skills/switch-project/SKILL.md index 6ac75a3775..3cc6e1eb03 100644 --- a/mem0-plugin/.opencode-plugin/opencode-skills/switch-project/SKILL.md +++ b/mem0-plugin/.opencode-plugin/opencode-skills/switch-project/SKILL.md @@ -1,18 +1,75 @@ --- name: switch-project -description: Overrides the auto-detected project scope to read and write memories under a different project ID. Use when working across multiple projects, accessing memories from another repo, or when auto-detection resolves to the wrong project. +description: Overrides the auto-detected project scope to read and write memories under a different project ID, or enables global search to access all memories across all users and projects. Use when working across multiple projects, accessing memories from another repo, enabling team-wide memory access, or when auto-detection resolves to the wrong project. --- # Mem0 Switch Project -Override the automatic project_id detection for the current directory. +Override the automatic project_id detection for the current directory, or enable global search mode. ## Usage -The user provides a project name as an argument: `/mem0:switch-project ` +- `/mem0:switch-project ` — switch to a specific project scope +- `/mem0:switch-project --global` — enable global search (all memories, all users, all projects) +- `/mem0:switch-project --no-global` — disable global search and return to per-project scoping ## Execution +### If `--global` flag is provided: + +1. Set `global_search: true` in `~/.mem0/settings.json` using the Bash tool: + + ```bash + python3 -c " + import json, os + settings_file = os.path.expanduser('~/.mem0/settings.json') + settings = {} + if os.path.isfile(settings_file): + with open(settings_file) as f: + settings = json.load(f) + settings['global_search'] = True + with open(settings_file, 'w') as f: + json.dump(settings, f, indent=2) + print('Global search enabled') + " + ``` + +2. Print: + ``` + Global search enabled. + Searches now return all memories across all users and projects. + Writes still use the current user_id and app_id. + Restart the session for the change to take effect. + ``` + +### If `--no-global` flag is provided: + +1. Set `global_search: false` in `~/.mem0/settings.json` using the Bash tool: + + ```bash + python3 -c " + import json, os + settings_file = os.path.expanduser('~/.mem0/settings.json') + settings = {} + if os.path.isfile(settings_file): + with open(settings_file) as f: + settings = json.load(f) + settings['global_search'] = False + with open(settings_file, 'w') as f: + json.dump(settings, f, indent=2) + print('Global search disabled') + " + ``` + +2. Print: + ``` + Global search disabled. + Searches now return only memories scoped to the current project. + Restart the session for the change to take effect. + ``` + +### If a project name is provided (no flags): + 1. If no project name was given, ask: "What project_id should this directory use?" 2. Write the mapping to `~/.mem0/project_map.json` using the Bash tool: diff --git a/mem0-plugin/.opencode-plugin/package.json b/mem0-plugin/.opencode-plugin/package.json index 1f821e3ade..8ddba82357 100644 --- a/mem0-plugin/.opencode-plugin/package.json +++ b/mem0-plugin/.opencode-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/opencode-plugin", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "description": "Mem0 persistent memory plugin for OpenCode — add, search, and manage memories across sessions", "main": "dist/index.js", diff --git a/mem0-plugin/CHANGELOG.md b/mem0-plugin/CHANGELOG.md index 69e976e22e..72b1cc586b 100644 --- a/mem0-plugin/CHANGELOG.md +++ b/mem0-plugin/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to the Mem0 plugin will be documented in this file. +## 0.2.8 — Automatic coding categories & global search + +### Added + +- **Global search mode (`global_search` setting):** New `global_search` toggle in `~/.mem0/settings.json` (default: `false`). When enabled, `search_memories` and `get_memories` calls use `{"OR": [{"user_id": "*"}]}` instead of the per-user per-project `AND` filter — returning all memories across all users and all `app_id` scopes in the platform project. Writes (`add_memory`) still tag with the current `user_id` and `app_id`. Solves the team-shared-memory use case where multiple team members need access to all memories regardless of which repo or user created them. Works on Claude Code, Cursor, and Codex (not OpenCode, which has its own TypeScript identity logic). +- **`/mem0:switch-project --global` / `--no-global`:** Enables or disables global search via the switch-project skill. Persists to `~/.mem0/settings.json`. No manual config editing needed. +- **Session banner scope indicator:** Banner shows `scope=global` when global search is active instead of `project=`. +- **Global-aware memory count:** Session start memory count query uses the global filter when `global_search` is enabled. +- **Background coding-category setup (`scripts/auto_setup_categories.py`):** The coding-focused category taxonomy (17 categories tuned for development work) is now installed automatically in the background on session start — the same way `auto_import.py` imports `CLAUDE.md`/`AGENTS.md`. Users are no longer asked to configure it during onboarding. Mirrors the auto-import design: resolves the API key, holds a lock file (`~/.mem0/categories_setup.lock`), reuses the proven `setup_coding_categories.py` taxonomy + `project.update` path via the plugin venv, logs to stderr only, and always exits 0 so it can never block a session. +- **Per-account state gating (`~/.mem0/categories_setup.json`):** Keyed by a hash of the API key → a hash of the taxonomy. Categories are scoped to the mem0 project tied to the API key (not the local repo), so setup runs once per account and skips all network calls thereafter — re-applying only if the taxonomy itself changes. +- **`tests/test_auto_setup_categories.py`:** Covers fingerprint determinism/sensitivity, state-file load/save/gating, and idempotent apply via an injected fake client (no SDK, no network). + +### Changed + +- **`on_session_start.sh`:** Spawns `auto_setup_categories.py` in the background on `startup` (alongside `auto_import.py`), preferring the venv python so the SDK is available. Covers Claude Code, Cursor, and Codex, which all route SessionStart through this script. +- **`/mem0:onboard` Step 5 is no longer interactive:** Removed the `Install coding categories? [Y/n]` prompt. Categories now configure automatically in the background; the onboarding step only verifies status and applies them if the background run hasn't finished yet — mirroring how Step 4 (project-file import) already works. +- **Session-start "new project" hint** now notes that coding categories install automatically in the background. + ## 0.1.0 — OpenCode & Antigravity ### Added diff --git a/mem0-plugin/README.md b/mem0-plugin/README.md index 04fc68323d..3093c9ced3 100644 --- a/mem0-plugin/README.md +++ b/mem0-plugin/README.md @@ -268,19 +268,21 @@ Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read If reconnection still fails after a restart, check that `MEM0_API_KEY` is reachable in the new shell (`echo $MEM0_API_KEY`) and confirm you're using a key that starts with `m0-` (from https://app.mem0.ai/dashboard/api-keys, not a legacy token). -## Optional: tune categories for coding workflows +## Coding-tuned categories (automatic) -mem0 auto-tags every memory with one or more `categories` from a project-level list. The default list is consumer-oriented (`food`, `hobbies`, `music` …) — useful for chat assistants, less so for code. A one-shot script in this plugin replaces it with a coding-focused taxonomy: +mem0 auto-tags every memory with one or more `categories` from a project-level list. The default list is consumer-oriented (`food`, `hobbies`, `music` …) — useful for chat assistants, less so for code. **The plugin installs a coding-focused taxonomy automatically in the background on session start** — no prompt, no manual step. New memories then auto-tag against 17 development-oriented categories: `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`, `dependency_decisions`, `performance_findings`, `security_constraints`, `testing_patterns`, `data_model`, `api_contracts`, `deployment_runbook`, `team_norms`, `domain_glossary`, `experiment_results`. + +The background setup is idempotent and runs once per account (cached in `~/.mem0/categories_setup.json`); it re-applies only if the taxonomy itself changes. To preview the taxonomy or force a refresh manually: ```bash -# Dry-run first -- prints current vs proposed, no changes: +# Dry-run -- prints current vs proposed, no changes: python mem0-plugin/scripts/setup_coding_categories.py -# Actually write: +# Write explicitly: python mem0-plugin/scripts/setup_coding_categories.py --apply ``` -Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. New memories will then auto-tag against `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`. Re-run with a different list any time; `project.update(custom_categories=[...])` always replaces. +Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. `project.update(custom_categories=[...])` always replaces the full list. ## MCP Tools diff --git a/mem0-plugin/scripts/_identity.sh b/mem0-plugin/scripts/_identity.sh index 8a7d3762af..4a25b8afcc 100644 --- a/mem0-plugin/scripts/_identity.sh +++ b/mem0-plugin/scripts/_identity.sh @@ -70,6 +70,7 @@ if command -v python3 >/dev/null 2>&1; then MEM0_SEARCH_LIMIT=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('search_limit',10))" 2>/dev/null || echo "10") MEM0_RETENTION_SESSION_DAYS=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('retention_session_days',90))" 2>/dev/null || echo "90") MEM0_CONFIDENCE_THRESHOLD=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('confidence_threshold',0.3))" 2>/dev/null || echo "0.3") + MEM0_GLOBAL_SEARCH=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('global_search',False)).lower())" 2>/dev/null || echo "false") MEM0_DEBUG=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('debug',False)).lower())" 2>/dev/null || echo "false") else MEM0_AUTO_SAVE="true" @@ -77,9 +78,10 @@ else MEM0_SEARCH_LIMIT="10" MEM0_RETENTION_SESSION_DAYS="90" MEM0_CONFIDENCE_THRESHOLD="0.3" + MEM0_GLOBAL_SEARCH="false" MEM0_DEBUG="false" fi -export MEM0_AUTO_SAVE MEM0_AUTO_SEARCH MEM0_SEARCH_LIMIT MEM0_RETENTION_SESSION_DAYS MEM0_CONFIDENCE_THRESHOLD MEM0_DEBUG +export MEM0_AUTO_SAVE MEM0_AUTO_SEARCH MEM0_SEARCH_LIMIT MEM0_RETENTION_SESSION_DAYS MEM0_CONFIDENCE_THRESHOLD MEM0_GLOBAL_SEARCH MEM0_DEBUG # Also resolve project context . "$_SCRIPT_DIR/_project.sh" diff --git a/mem0-plugin/scripts/auto_setup_categories.py b/mem0-plugin/scripts/auto_setup_categories.py new file mode 100644 index 0000000000..a94ed00a51 --- /dev/null +++ b/mem0-plugin/scripts/auto_setup_categories.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Auto-configure mem0's coding-category taxonomy in the background. + +Runs from the SessionStart hook (startup only), exactly like auto_import.py. +mem0 auto-tags every memory with `categories`; by default that list is +consumer-oriented (food, hobbies, ...), which is useless for code. This script +replaces it with the coding-focused taxonomy defined in +``setup_coding_categories.CODING_CATEGORIES`` so search and retrieval are tuned +for development work — without the user ever being asked during onboarding. + +Design (mirrors auto_import.py): + - Resolve the API key; do nothing if it is absent. + - Gate on a state file (``~/.mem0/categories_setup.json``) keyed by a hash of + the API key -> a hash of the taxonomy. Categories are scoped to the mem0 + *project* tied to the API key (NOT to the local repo), so this only needs to + run once per account, and re-runs only if the taxonomy itself changes. + - Hold a lock file so concurrent sessions don't race. + - Reuse the proven SDK path (``client.project.update``) via the plugin venv. + - Always exit 0; log to stderr only. Must never block a session. + +Run with no arguments (background) or in the foreground for onboarding to print +a parseable status line. + +Requires MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_API_KEY) and the mem0ai SDK, +which ensure_deps.sh installs into ${CLAUDE_PLUGIN_DATA}/venv on session start. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +# Importing setup_coding_categories also injects the plugin venv's site-packages +# onto sys.path (its module-level bootstrap), so ``from mem0 import MemoryClient`` +# works even when this script is run with the system python3. +from _identity import resolve_api_key # noqa: E402 +from setup_coding_categories import CODING_CATEGORIES, _categories_match # noqa: E402 + +log = logging.getLogger("mem0-auto-categories") +log.setLevel(logging.DEBUG) +_handler = logging.StreamHandler(sys.stderr) +_handler.setFormatter(logging.Formatter("[mem0-auto-categories] %(message)s")) +log.addHandler(_handler) + +if os.environ.get("MEM0_DEBUG"): + _log_dir = os.path.expanduser("~/.mem0") + try: + os.makedirs(_log_dir, exist_ok=True) + _file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log")) + _file_handler.setFormatter(logging.Formatter("[mem0-auto-categories] %(asctime)s %(message)s")) + log.addHandler(_file_handler) + except OSError: + pass + +STATE_FILE = os.path.expanduser("~/.mem0/categories_setup.json") +LOCK_FILE = os.path.expanduser("~/.mem0/categories_setup.lock") + + +# --------------------------------------------------------------------------- # +# Fingerprints # +# --------------------------------------------------------------------------- # +def categories_fingerprint(categories: list = CODING_CATEGORIES) -> str: + """Stable, order-independent 16-hex digest of the category taxonomy. + + Reordering the categories yields the same fingerprint; adding, removing, or + editing a category changes it (so the taxonomy re-applies on upgrade). + """ + pairs = sorted( + (str(key), str(value)) + for entry in categories + if isinstance(entry, dict) + for key, value in entry.items() + ) + payload = json.dumps(pairs, sort_keys=True, ensure_ascii=False) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + +def apikey_fingerprint(api_key: str) -> str: + """Opaque 16-hex digest of the API key. Never stores the key itself.""" + return hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16] + + +# --------------------------------------------------------------------------- # +# State file # +# --------------------------------------------------------------------------- # +def load_state(path: str = STATE_FILE) -> dict: + """Load the apikey-fingerprint -> categories-fingerprint map; {} on any error.""" + if not os.path.isfile(path): + return {} + try: + with open(path) as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def save_state(state: dict, path: str = STATE_FILE) -> None: + """Persist the state map, creating the parent directory if needed.""" + parent = os.path.dirname(path) + if parent: + try: + os.makedirs(parent, exist_ok=True) + except OSError as e: + log.warning("Could not create state dir: %s", e) + return + try: + with open(path, "w") as f: + json.dump(state, f, indent=2) + except OSError as e: + log.warning("Could not save categories state: %s", e) + + +def is_applied(state: dict, key_fp: str, cat_fp: str) -> bool: + """True only when this API key has already had this exact taxonomy applied.""" + return state.get(key_fp) == cat_fp + + +# --------------------------------------------------------------------------- # +# SDK interaction (client injected for testability) # +# --------------------------------------------------------------------------- # +def make_client(): + """Construct a MemoryClient. Imported lazily so this module loads without the SDK.""" + from mem0 import MemoryClient + + return MemoryClient() + + +def fetch_current_categories(client) -> list | None: + """Return the project's current custom_categories, or None if unavailable.""" + current = client.project.get(fields=["custom_categories"]) + if isinstance(current, dict): + return current.get("custom_categories") + return None + + +def apply_categories(client, proposed: list = CODING_CATEGORIES) -> str: + """Install the coding taxonomy if it isn't already in place. + + Returns "already-configured" when the project already matches (no write), or + "applied" after a successful ``project.update``. Raises on API failure. + """ + current = fetch_current_categories(client) + if _categories_match(current, proposed): + return "already-configured" + client.project.update(custom_categories=proposed) + return "applied" + + +# --------------------------------------------------------------------------- # +# Lock (mirrors auto_import.py) # +# --------------------------------------------------------------------------- # +def _acquire_lock() -> bool: + """Try to acquire a file lock. Returns False if another instance is running.""" + try: + os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True) + fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + return True + except FileExistsError: + try: + if time.time() - os.path.getmtime(LOCK_FILE) > 120: + os.unlink(LOCK_FILE) + return _acquire_lock() + except OSError: + pass + return False + + +def _release_lock() -> None: + try: + os.unlink(LOCK_FILE) + except OSError: + pass + + +# --------------------------------------------------------------------------- # +# Entry point # +# --------------------------------------------------------------------------- # +def main() -> None: + api_key = resolve_api_key() + if not api_key: + log.debug("MEM0_API_KEY not set, skipping coding-categories setup") + return + + key_fp = apikey_fingerprint(api_key) + cat_fp = categories_fingerprint() + + state = load_state() + if is_applied(state, key_fp, cat_fp): + log.debug("Coding categories already configured for this account (cached); skipping") + return + + os.environ["MEM0_API_KEY"] = api_key + + try: + client = make_client() + except ImportError: + log.debug("mem0ai SDK not ready yet (venv installing?); will retry next session") + return + except Exception as e: + log.warning("Could not initialise MemoryClient: %s", e) + return + + try: + result = apply_categories(client) + except Exception as e: + log.warning("Could not configure coding categories: %s", e) + return + + state[key_fp] = cat_fp + save_state(state) + + if result == "applied": + log.info("Applied %d coding categories", len(CODING_CATEGORIES)) + else: + log.info("Coding categories already configured") + + +if __name__ == "__main__": + if not _acquire_lock(): + log.debug("Another auto_setup_categories instance is running — skipping") + sys.exit(0) + try: + main() + except Exception as e: # never block a session + log.error("Unexpected error: %s", e) + finally: + _release_lock() + sys.exit(0) diff --git a/mem0-plugin/scripts/enforce_metadata_defaults.sh b/mem0-plugin/scripts/enforce_metadata_defaults.sh index cb5c009d5e..e24b636486 100755 --- a/mem0-plugin/scripts/enforce_metadata_defaults.sh +++ b/mem0-plugin/scripts/enforce_metadata_defaults.sh @@ -45,6 +45,7 @@ trap 'rm -f "$_PATCH_OUT"' EXIT _MEM0_TOOL_INPUT="$TOOL_INPUT" \ _MEM0_USER_ID="${MEM0_RESOLVED_USER_ID:-}" \ _MEM0_APP_ID="${MEM0_PROJECT_ID:-}" \ +_MEM0_GLOBAL_SEARCH="${MEM0_GLOBAL_SEARCH:-false}" \ _MEM0_HANDLER="$HANDLER" \ python3 <<'PYEOF' > "$_PATCH_OUT" 2>/dev/null || true import json, os, sys @@ -58,6 +59,7 @@ except Exception: handler = os.environ.get("_MEM0_HANDLER", "") resolved_uid = os.environ.get("_MEM0_USER_ID", "") resolved_aid = os.environ.get("_MEM0_APP_ID", "") +global_search = os.environ.get("_MEM0_GLOBAL_SEARCH", "false") == "true" changed = False @@ -177,7 +179,11 @@ if handler == "add_memory": inp["metadata"] = meta elif handler in ("search_memories", "get_memories"): - changed = inject_filter_identity(inp, resolved_uid, resolved_aid) + if global_search: + inp["filters"] = {"OR": [{"user_id": "*"}]} + changed = True + else: + changed = inject_filter_identity(inp, resolved_uid, resolved_aid) elif handler == "delete_all": changed = inject_top_level_identity(inp, resolved_uid, resolved_aid) diff --git a/mem0-plugin/scripts/load_settings.py b/mem0-plugin/scripts/load_settings.py index 271cec245c..58a53b5692 100644 --- a/mem0-plugin/scripts/load_settings.py +++ b/mem0-plugin/scripts/load_settings.py @@ -16,6 +16,7 @@ "search_limit": 10, "retention_session_days": 90, "confidence_threshold": 0.3, + "global_search": False, "debug": False, } diff --git a/mem0-plugin/scripts/on_session_start.sh b/mem0-plugin/scripts/on_session_start.sh index 64a1d3f28d..a4300b9e60 100755 --- a/mem0-plugin/scripts/on_session_start.sh +++ b/mem0-plugin/scripts/on_session_start.sh @@ -76,6 +76,7 @@ import json, os, urllib.request, urllib.error api_key = os.environ.get('MEM0_API_KEY', '') user_id = os.environ.get('MEM0_RESOLVED_USER_ID', 'default') app_id = os.environ.get('MEM0_PROJECT_ID', '') +global_search = os.environ.get('MEM0_GLOBAL_SEARCH', 'false') == 'true' def get_count(filters): body = json.dumps({'filters': filters}).encode() @@ -93,8 +94,11 @@ def get_count(filters): return 0 try: - base = [{'user_id': user_id}, {'app_id': app_id}] - total = get_count({'AND': base}) + if global_search: + filters = {'OR': [{'user_id': '*'}]} + else: + filters = {'AND': [{'user_id': user_id}, {'app_id': app_id}]} + total = get_count(filters) print(total) except Exception: print('?') @@ -105,21 +109,30 @@ _UID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}" _ANN="${_MEM0_IDENTITY_ANNOTATION:-}" _PID="${MEM0_PROJECT_ID:-unknown}" _BR="${MEM0_BRANCH:-unknown}" +_GS="${MEM0_GLOBAL_SEARCH:-false}" + +if [ "$_GS" = "true" ]; then + _SCOPE_LABEL="scope=global" + _SCOPE_INSTR="Global search is ON — searches return all memories across all users and projects. Writes still use user_id: \`${_UID}\`, app_id: \`${_PID}\`." +else + _SCOPE_LABEL="project=${_PID}" + _SCOPE_INSTR="Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and \`add_memory\` call: +- user_id: \`${_UID}\` +- app_id: \`${_PID}\` (project scope — passed as top-level \`app_id\`, NOT in metadata)" +fi cat </dev/null & + # Configure the coding-category taxonomy in the background (idempotent, never blocks). + # Prefer the venv python since this path needs the mem0ai SDK. + _VENV_PY="${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}/venv/bin/python3" + if [ -x "$_VENV_PY" ]; then + MEM0_CWD="$MEM0_CWD_RESOLVED" "$_VENV_PY" "$SCRIPT_DIR/auto_setup_categories.py" 2>/dev/null & + else + MEM0_CWD="$MEM0_CWD_RESOLVED" python3 "$SCRIPT_DIR/auto_setup_categories.py" 2>/dev/null & + fi + elif [ "$SOURCE" = "resume" ]; then echo "Session resumed. Search mem0 for session_state and decision memories to pick up where you left off. Run 2 parallel searches." diff --git a/mem0-plugin/skills/onboard/SKILL.md b/mem0-plugin/skills/onboard/SKILL.md index f8ecfb9d90..896e917ecb 100644 --- a/mem0-plugin/skills/onboard/SKILL.md +++ b/mem0-plugin/skills/onboard/SKILL.md @@ -141,29 +141,27 @@ Parse the auto_import output and print a user-friendly summary: - Project file import failed. Check API key and retry with: /mem0:onboard ``` -## Step 5: Install coding categories +## Step 5: Coding categories (automatic) -The setup script is idempotent — it compares existing categories against the proposed set and skips the API call if they already match (tolerates order differences and extra API fields). Safe to re-run. +Coding categories optimized for development workflows are installed **automatically in the background on session start** — the same way project files are imported (Step 4). The user is no longer asked. This step only verifies they are in place and applies them if the background run hasn't finished yet. -Ask: "Install coding categories optimized for development workflows? [Y/n]" +The installer is idempotent and self-caching: it compares existing categories against the proposed set, skips the API call when they already match, and skips all network calls entirely once applied for this account (re-applying only if the taxonomy changes). Safe to run anytime. -If yes, run the setup script using the plugin's venv python: +Run it in the foreground to verify, using the plugin's venv python: ```bash VENV_PY="${CLAUDE_PLUGIN_DATA}/venv/bin/python3" if [ -x "${VENV_PY}" ]; then - "${VENV_PY}" "${CLAUDE_PLUGIN_ROOT}/scripts/setup_coding_categories.py" --apply + MEM0_DEBUG=1 "${VENV_PY}" "${CLAUDE_PLUGIN_ROOT}/scripts/auto_setup_categories.py" else - python3 "${CLAUDE_PLUGIN_ROOT}/scripts/setup_coding_categories.py" --apply + MEM0_DEBUG=1 python3 "${CLAUDE_PLUGIN_ROOT}/scripts/auto_setup_categories.py" fi ``` Parse the output: -- `"Categories already match -- skipping update."` → Print: `- Coding categories already installed. Skipped.` -- `"Done."` → Print: `- Coding categories installed ( categories).` -- Error → Print the error and suggest re-running `/mem0:onboard`. - -If the script fails with "mem0ai SDK not found", run the dependency installer first: +- contains `Applied coding categories` → Print: `- Coding categories installed ( categories).` +- contains `already configured` → Print: `- Coding categories already configured.` +- error or `SDK not ready` → run the dependency installer first, then retry: ```bash "${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh" ``` @@ -177,7 +175,7 @@ Print a summary: user_id: project_id: (app_id) files: found, imported - categories: + categories: Memory is now active for this project. Start working — mem0 will automatically search relevant context and capture learnings. diff --git a/mem0-plugin/skills/switch-project/SKILL.md b/mem0-plugin/skills/switch-project/SKILL.md index 706c202699..739e19bebf 100644 --- a/mem0-plugin/skills/switch-project/SKILL.md +++ b/mem0-plugin/skills/switch-project/SKILL.md @@ -1,18 +1,75 @@ --- name: switch-project -description: Overrides the auto-detected project scope to read and write memories under a different project ID. Use when working across multiple projects, accessing memories from another repo, or when auto-detection resolves to the wrong project. +description: Overrides the auto-detected project scope to read and write memories under a different project ID, or enables global search to access all memories across all users and projects. Use when working across multiple projects, accessing memories from another repo, enabling team-wide memory access, or when auto-detection resolves to the wrong project. --- # Mem0 Switch Project -Override the automatic project_id detection for the current directory. +Override the automatic project_id detection for the current directory, or enable global search mode. ## Usage -The user provides a project name as an argument: `/mem0:switch-project ` +- `/mem0:switch-project ` — switch to a specific project scope +- `/mem0:switch-project --global` — enable global search (all memories, all users, all projects) +- `/mem0:switch-project --no-global` — disable global search and return to per-project scoping ## Execution +### If `--global` flag is provided: + +1. Set `global_search: true` in `~/.mem0/settings.json` using the Bash tool: + + ```bash + python3 -c " + import json, os + settings_file = os.path.expanduser('~/.mem0/settings.json') + settings = {} + if os.path.isfile(settings_file): + with open(settings_file) as f: + settings = json.load(f) + settings['global_search'] = True + with open(settings_file, 'w') as f: + json.dump(settings, f, indent=2) + print('Global search enabled') + " + ``` + +2. Print: + ``` + Global search enabled. + Searches now return all memories across all users and projects. + Writes still use the current user_id and app_id. + Restart the session for the change to take effect. + ``` + +### If `--no-global` flag is provided: + +1. Set `global_search: false` in `~/.mem0/settings.json` using the Bash tool: + + ```bash + python3 -c " + import json, os + settings_file = os.path.expanduser('~/.mem0/settings.json') + settings = {} + if os.path.isfile(settings_file): + with open(settings_file) as f: + settings = json.load(f) + settings['global_search'] = False + with open(settings_file, 'w') as f: + json.dump(settings, f, indent=2) + print('Global search disabled') + " + ``` + +2. Print: + ``` + Global search disabled. + Searches now return only memories scoped to the current project. + Restart the session for the change to take effect. + ``` + +### If a project name is provided (no flags): + 1. If no project name was given, ask: "What project_id should this directory use?" 2. Write the mapping to `~/.mem0/project_map.json` using the Bash tool: diff --git a/mem0-plugin/tests/test_auto_setup_categories.py b/mem0-plugin/tests/test_auto_setup_categories.py new file mode 100644 index 0000000000..23562f2328 --- /dev/null +++ b/mem0-plugin/tests/test_auto_setup_categories.py @@ -0,0 +1,153 @@ +"""Tests for auto_setup_categories.py -- the background coding-categories installer. + +Covers the pure, network-free logic: + - fingerprints (api key + category taxonomy) + - state-file gating (load / save / is_applied) + - idempotent apply via an injected fake client (no SDK, no network) + - single source of truth for the category list (shared with setup_coding_categories) +""" + +from __future__ import annotations + +import os +import sys + +SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts")) +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +import auto_setup_categories as asc # noqa: E402 +from setup_coding_categories import CODING_CATEGORIES # noqa: E402 + + +# --------------------------------------------------------------------------- # +# Fake client (dependency injection — apply_categories takes a client) # +# --------------------------------------------------------------------------- # +class _FakeProject: + def __init__(self, current): + self._current = current + self.update_calls: list = [] + + def get(self, fields=None): + return {"custom_categories": self._current} + + def update(self, custom_categories=None, **kwargs): + self.update_calls.append(custom_categories) + return {"ok": True} + + +class _FakeClient: + def __init__(self, current): + self.project = _FakeProject(current) + + +# --------------------------------------------------------------------------- # +# Source-of-truth: the taxonomy is shared, not duplicated # +# --------------------------------------------------------------------------- # +def test_uses_shared_category_list(): + """auto_setup_categories must reuse setup_coding_categories' list, not fork it.""" + assert asc.CODING_CATEGORIES is CODING_CATEGORIES + assert len(asc.CODING_CATEGORIES) == 17 + + +# --------------------------------------------------------------------------- # +# Fingerprints # +# --------------------------------------------------------------------------- # +def test_categories_fingerprint_is_stable_hex(): + fp = asc.categories_fingerprint(CODING_CATEGORIES) + assert isinstance(fp, str) + assert len(fp) == 16 + assert all(c in "0123456789abcdef" for c in fp) + # deterministic across calls + assert fp == asc.categories_fingerprint(CODING_CATEGORIES) + + +def test_categories_fingerprint_changes_when_taxonomy_changes(): + base = asc.categories_fingerprint(CODING_CATEGORIES) + changed = asc.categories_fingerprint(CODING_CATEGORIES + [{"new_one": "desc"}]) + assert base != changed + + +def test_categories_fingerprint_order_independent(): + """Reordering the same categories must yield the same fingerprint.""" + reordered = list(reversed(CODING_CATEGORIES)) + assert asc.categories_fingerprint(CODING_CATEGORIES) == asc.categories_fingerprint(reordered) + + +def test_apikey_fingerprint_is_stable_and_opaque(): + key = "m0-supersecret-abc123" + fp = asc.apikey_fingerprint(key) + assert isinstance(fp, str) + assert len(fp) == 16 + assert fp == asc.apikey_fingerprint(key) + # never leak the raw key + assert "supersecret" not in fp + assert key not in fp + + +def test_apikey_fingerprint_differs_per_key(): + assert asc.apikey_fingerprint("m0-aaa") != asc.apikey_fingerprint("m0-bbb") + + +# --------------------------------------------------------------------------- # +# State file: load / save / gating # +# --------------------------------------------------------------------------- # +def test_load_state_missing_file_returns_empty(tmp_path): + assert asc.load_state(str(tmp_path / "does_not_exist.json")) == {} + + +def test_load_state_corrupt_file_returns_empty(tmp_path): + p = tmp_path / "categories_setup.json" + p.write_text("{not valid json") + assert asc.load_state(str(p)) == {} + + +def test_save_then_load_roundtrip_creates_parent_dir(tmp_path): + p = tmp_path / "nested" / "categories_setup.json" + asc.save_state({"abc123": "deadbeef00000000"}, str(p)) + assert p.is_file() + assert asc.load_state(str(p)) == {"abc123": "deadbeef00000000"} + + +def test_is_applied_true_only_on_exact_match(): + state = {"keyfp": "catfp"} + assert asc.is_applied(state, "keyfp", "catfp") is True + # stale taxonomy fingerprint + assert asc.is_applied(state, "keyfp", "OTHER") is False + # unknown api key + assert asc.is_applied(state, "other-key", "catfp") is False + # empty state + assert asc.is_applied({}, "keyfp", "catfp") is False + + +# --------------------------------------------------------------------------- # +# apply_categories: idempotent, network-free via fake client # +# --------------------------------------------------------------------------- # +def test_apply_skips_update_when_already_matching(): + client = _FakeClient(CODING_CATEGORIES) + result = asc.apply_categories(client, CODING_CATEGORIES) + assert result == "already-configured" + assert client.project.update_calls == [] # must NOT hit the write endpoint + + +def test_apply_updates_when_no_categories_set(): + client = _FakeClient(None) + result = asc.apply_categories(client, CODING_CATEGORIES) + assert result == "applied" + assert client.project.update_calls == [CODING_CATEGORIES] + + +def test_apply_updates_when_categories_differ(): + client = _FakeClient([{"food": "consumer default"}]) + result = asc.apply_categories(client, CODING_CATEGORIES) + assert result == "applied" + assert client.project.update_calls == [CODING_CATEGORIES] + + +def test_fetch_current_categories_handles_non_dict(): + """A non-dict project response must degrade to None, not raise.""" + + class Weird: + project = type("P", (), {"get": staticmethod(lambda fields=None: "unexpected")})() + + assert asc.fetch_current_categories(Weird()) is None