diff --git a/Makefile.cbm b/Makefile.cbm index de6ad40ff..56f72daa8 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -184,6 +184,8 @@ CYPHER_SRCS = src/cypher/cypher.c # MCP server module (new) MCP_SRCS = src/mcp/mcp.c +MEMORY_SRCS = src/personal_memory/memory.c + # Discover module (new) DISCOVER_SRCS = \ src/discover/language.c \ @@ -304,7 +306,7 @@ TRE_CFLAGS = -std=c11 -g -O1 -w -Ivendored/tre YYJSON_SRC = vendored/yyjson/yyjson.c # All production sources -PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC) +PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(MEMORY_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC) EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \ $(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) @@ -692,7 +694,7 @@ SYSROOT = $(shell xcrun --show-sdk-path 2>/dev/null) SYSROOT_FLAG = $(if $(SYSROOT),-isysroot $(SYSROOT),) # Our source files (excluding vendored, grammars, tree-sitter runtime) -LINT_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) \ +LINT_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(MEMORY_SRCS) \ $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) \ $(TRACES_SRCS) $(WATCHER_SRCS) $(CLI_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) \ $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(MAIN_SRC) diff --git a/README.md b/README.md index c1c17f9f8..1c6557469 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ **The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. Ships as a single static binary for macOS, Linux, and Windows — download, run `install`, done. -High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 158 languages, enhanced with [**Hybrid LSP** semantic type resolution](#hybrid-lsp) for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, and Rust — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 14 MCP tools. Zero dependencies. Plug and play across 11 coding agents. +High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 158 languages, enhanced with [**Hybrid LSP** semantic type resolution](#hybrid-lsp) for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, and Rust — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 15 MCP tools. Zero dependencies. Plug and play across 11 coding agents. > **Research** — The design and benchmarks behind this project are described in the preprint [*Codebase-Memory: Tree-Sitter-Based Knowledge Graphs for LLM Code Exploration via MCP*](https://arxiv.org/abs/2603.27277) (arXiv:2603.27277). Evaluated across 31 real-world repositories: 83% answer quality, 10× fewer tokens, 2.1× fewer tool calls vs. file-by-file exploration. @@ -37,7 +37,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si - **11 agents, one command** — `install` auto-detects Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, and Kiro — configures MCP entries, instruction files, and pre-tool hooks for each. - **Built-in graph visualization** — 3D interactive UI at `localhost:9749` (optional UI binary variant). - **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources. -- **14 MCP tools** — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more. +- **15 MCP tools** — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR and personal memory management, and more. ## Quick Start @@ -136,7 +136,7 @@ Removes all agent configs, skills, hooks, and instructions. Does not remove the ### Graph & analysis - **Architecture overview**: `get_architecture` returns languages, packages, entry points, routes, hotspots, boundaries, layers, and clusters in a single call -- **Architecture Decision Records**: `manage_adr` persists architectural decisions across sessions +- **Architecture Decision Records**: `manage_adr` persists project ADRs; `manage_memory` stores personal repo memory locally outside source repos - **Louvain community detection**: Discovers functional modules by clustering call edges - **Git diff impact mapping**: `detect_changes` maps uncommitted changes to affected symbols with risk classification - **Call graph**: Resolves function calls across files and packages (import-aware, type-inferred) @@ -176,6 +176,7 @@ Removes all agent configs, skills, hooks, and instructions. Does not remove the ### Distribution & operation - **Single static binary, zero infrastructure**: SQLite-backed, persists to `~/.cache/codebase-memory-mcp/` +- **Personal memory**: Local repo knowledge lives in `manage_memory` under the user data dir; it is never written to the repo unless you explicitly export artifacts - **Auto-sync**: Background watcher detects file changes and re-indexes automatically - **Route nodes**: REST endpoints are first-class graph entities - **CLI mode**: `codebase-memory-mcp cli search_graph '{"name_pattern": ".*Handler.*"}'` @@ -197,6 +198,20 @@ Commit a single compressed file to your repo and your teammates skip the reindex The result is similar in spirit to graphify's `graphify-out/` directory, but as a single compressed file with explicit two-tier export, integrity-checked import, and zero merge friction. +## Personal Repo Memory + +Use `manage_memory` when you want ADR-style knowledge to stay local and private. + +- **Storage**: local SQLite `memory.db` under the user data dir, separate from repo files +- **Override**: set `CBM_MEMORY_DIR=/path/to/private/memory` +- **No upload**: `manage_memory` never writes `.codebase-memory/` or changes tracked files +- **Branch aware**: memory is keyed by repo identity, branch, and document type; feature branches can keep overlays while `main` keeps base memory +- **LLM workflow**: call `manage_memory(mode="get")` at session start, investigate with graph tools if empty/stale, then update with `manage_memory(mode="update", content="...")` +- **Branch promotion**: after branch work, copy branch memory into base with `manage_memory(mode="promote", branch="feature")` +- **Maintenance**: list entries with `manage_memory(mode="list")`, remove one branch/doc with `manage_memory(mode="delete", branch="...")`, inspect paths with `manage_memory(mode="settings")` + +`manage_adr(scope="personal", ...)` is also accepted as a compatibility path to the same personal store. + ## How It Works codebase-memory-mcp is a **structural analysis backend** — it builds and queries the knowledge graph. It does **not** include an LLM. Instead, it relies on your MCP client (Claude Code, or any MCP-compatible agent) to be the intelligence layer. @@ -349,7 +364,7 @@ Add to `~/.claude/.mcp.json` (global) or project `.mcp.json`: } ``` -Restart your agent. Verify with `/mcp` — you should see `codebase-memory-mcp` with 14 tools. +Restart your agent. Verify with `/mcp` — you should see `codebase-memory-mcp` with 15 tools. @@ -420,6 +435,7 @@ codebase-memory-mcp cli --raw search_graph '{"label": "Function"}' | jq '.result | `get_architecture` | Codebase overview: languages, packages, routes, hotspots, clusters, ADR. | | `search_code` | Grep-like text search within indexed project files. | | `manage_adr` | CRUD for Architecture Decision Records. | +| `manage_memory` | Local personal repo memory outside source repos. | | `ingest_traces` | Ingest runtime traces to validate HTTP_CALLS edges. | ## Graph Data Model @@ -458,14 +474,39 @@ Layered: hardcoded patterns (`.git`, `node_modules`, etc.) → `.gitignore` hier codebase-memory-mcp config list # show all settings codebase-memory-mcp config set auto_index true # auto-index on session start codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index +codebase-memory-mcp config set auto_update false # disable startup update checks +codebase-memory-mcp config get memory_dir # local personal memory directory +codebase-memory-mcp config set memory_enabled true # opt in to local per-repo memory +codebase-memory-mcp config set memory_dir ~/private/cbm # override personal memory dir codebase-memory-mcp config reset auto_index # reset to default ``` +Default memory config: +- `memory_enabled=false`: `manage_memory` storage is opt-in. When not configured, existing project ADR/index behavior is unchanged. +- `memory_default_scope=project`: LLM workflows keep project-scoped behavior unless local personal memory is explicitly enabled. +- `memory_dir=`: global user-home memory DB location, override with config or `CBM_MEMORY_DIR`. The path must be absolute and outside the source repo. + +### Personal memory privacy and storage boundaries + +`manage_memory` is opt-in, local-only personal storage. It is intentionally separate from project indexes and source repos. Enable it with `codebase-memory-mcp config set memory_enabled true` or by passing an external config with `memory_enabled=true`: + +- **Where data lives:** one SQLite DB named `memory.db` under `memory_dir`. Defaults are macOS `~/Library/Application Support/codebase-memory-mcp`, Linux `~/.local/share/codebase-memory-mcp`, Windows `%LOCALAPPDATA%/codebase-memory-mcp`. `CBM_MEMORY_DIR` or `config set memory_dir ...` can override this with an absolute path outside the repo. +- **Repo boundary:** write/read/delete operations are rejected when `memory_dir` is relative or inside the current project root. The tool reports `storage_boundary_error` and does not create `memory.db`. +- **No external transmission:** `manage_memory` does not upload memory, does not write to git, does not call network APIs, and does not sync to the repo. Responses redact repo identity and storage keys. Paths are redacted unless `reveal_paths=true` is passed to `mode="settings"`. +- **No sensitive-data logging:** memory content, storage keys, repo IDs, and memory paths are not logged by the memory tool. Content is returned only to the caller for explicit `get`/`sections` requests. +- **Delete semantics:** `mode="delete"` removes only the selected repo/branch/doc row from `memory.db`. It does not delete other branches, other repos, the DB file, SQLite free pages, filesystem backups, or user copies. +- **Promote/sync semantics:** `mode="promote"` is a local-only copy from the current branch doc to the base branch doc in the same `memory.db`. `mode="sync"` is disabled and returns `sync_disabled`; there is no network sync feature. + +Default update config: +- `auto_update=true`: MCP startup checks GitHub for newer releases and shows a one-shot notice. +- Set `auto_update=false` to disable network update checks. Manual `codebase-memory-mcp update` still works. + ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the database storage directory. All project indexes and config are stored here. | +| `CBM_MEMORY_DIR` | macOS: `~/Library/Application Support/codebase-memory-mcp`; Linux: `~/.local/share/codebase-memory-mcp`; Windows: `%LOCALAPPDATA%/codebase-memory-mcp` | Override local personal memory storage with an absolute path outside the repo. `manage_memory` writes `memory.db` here and does not touch the repo. | | `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable periodic diagnostics output to `/tmp/cbm-diagnostics-.json`. | | `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. | | `CBM_LOG_LEVEL` | `info` | Set the minimum log level. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none` — or their numeric equivalents `0`–`4` matching the internal enum. Logs go to stderr; stdout is reserved for MCP JSON-RPC. | @@ -475,6 +516,9 @@ codebase-memory-mcp config reset auto_index # reset to default ```bash # Store indexes in a custom directory export CBM_CACHE_DIR=~/my-projects/cbm-data + +# Store personal memory in a private directory +export CBM_MEMORY_DIR=~/private/cbm-memory ``` ## Custom File Extensions @@ -497,7 +541,7 @@ Project config overrides global for conflicting extensions. Unknown language val ## Persistence -SQLite databases stored at `~/.cache/codebase-memory-mcp/`. Persists across restarts (WAL mode, ACID-safe). To reset: `rm -rf ~/.cache/codebase-memory-mcp/`. +Project index SQLite databases are stored at `~/.cache/codebase-memory-mcp/`. Personal memory is stored separately in the user data dir or an absolute, repo-external `CBM_MEMORY_DIR`. Both persist across restarts (WAL mode, ACID-safe). To reset indexes: `rm -rf ~/.cache/codebase-memory-mcp/`. To reset personal memory, remove `memory.db` from `manage_memory(mode="settings", reveal_paths=true)` output. ## Troubleshooting @@ -556,7 +600,7 @@ Also supported (not yet benchmarked): Ada, Agda, Apex, Assembly (NASM), Astro, A ``` src/ main.c Entry point (MCP stdio server + CLI + install/update/config) - mcp/ MCP server (14 tools, JSON-RPC 2.0, session detection, auto-index) + mcp/ MCP server (15 tools, JSON-RPC 2.0, session detection, auto-index) cli/ Install/uninstall/update/config (10 agents, hooks, instructions) store/ SQLite graph storage (nodes, edges, traversal, search, Louvain) pipeline/ Multi-pass indexing (structure → definitions → calls → HTTP links → config → tests) diff --git a/docs/index.html b/docs/index.html index 7e732406b..1de5b0804 100644 --- a/docs/index.html +++ b/docs/index.html @@ -56,7 +56,7 @@ "featureList": [ "Indexes 158 programming languages via vendored tree-sitter grammars", "Hybrid LSP semantic type resolution for Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust", - "14 MCP tools for structural search, call-path tracing, and Cypher graph queries", + "15 MCP tools for structural search, call-path tracing, and Cypher graph queries", "Semantic vector code search via bundled nomic-embed-code embeddings (no API key, fully local)", "Semantic graph edges (SEMANTICALLY_RELATED) and near-clone detection (SIMILAR_TO, MinHash + LSH)", "Cross-service linking for HTTP, gRPC, GraphQL, tRPC, and pub/sub channels with confidence scoring", @@ -699,8 +699,8 @@

3D graph visualization

An optional UI binary serves an interactive 3D graph at localhost:9749 to explore nodes, edges, and clusters visually.

-

14 MCP tools

-

search_graph, trace_path, detect_changes, query_graph (Cypher), get_architecture, get_code_snippet, manage_adr, and 7 more.

+

15 MCP tools

+

search_graph, trace_path, detect_changes, query_graph (Cypher), get_architecture, get_code_snippet, manage_adr, manage_memory, and 7 more.

Cypher graph queries

@@ -728,7 +728,7 @@

Change-impact analysis

Architecture Decision Records

-

manage_adr persists architectural decisions alongside the graph, so design rationale survives across sessions and teammates.

+

manage_adr persists project ADRs; manage_memory is opt-in and stores personal repo memory in a local memory.db under the user data directory or CBM_MEMORY_DIR. Personal memory paths must be absolute and outside the source repo; the tool does not upload, sync to the network, write to git, or log memory content.

diff --git a/docs/llms.txt b/docs/llms.txt index c680c15a7..bc1ccd364 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -7,7 +7,7 @@ - License: MIT, open source. - Languages: 158 (158 vendored tree-sitter grammars compiled into the binary). - Hybrid LSP type resolution: 9 language families (Python, TypeScript/JavaScript/JSX/TSX, PHP, C#, Go, C/C++, Java, Kotlin, Rust) — a lightweight C implementation of language type-resolution algorithms, structurally inspired by and compatible with major language servers including tsserver, pyright, gopls, Roslyn, Eclipse JDT, and rust-analyzer. -- MCP tools: 14 (search_graph incl. semantic_query vector search, trace_path (alias: trace_call_path), query_graph (Cypher), detect_changes, get_architecture, get_code_snippet, manage_adr, and more). +- MCP tools: 15 (search_graph incl. semantic_query vector search, trace_path (alias: trace_call_path), query_graph (Cypher), detect_changes, get_architecture, get_code_snippet, manage_adr, manage_memory, and more). - Semantic search: natural-language code discovery via bundled nomic-embed-code embeddings (768-dim, compiled into the binary); 11-signal combined scoring; fully local, no API key. - Semantic & similarity edges: SEMANTICALLY_RELATED (vocabulary-mismatch matches) and SIMILAR_TO (MinHash + LSH near-clone / duplicate detection). - Cross-repo intelligence: CROSS_* edges link nodes across multiple repos indexed in one store; multi-galaxy 3D layout and cross-repo architecture summary. diff --git a/scripts/smoke-invariants.sh b/scripts/smoke-invariants.sh index fc35e0d2f..465542af5 100755 --- a/scripts/smoke-invariants.sh +++ b/scripts/smoke-invariants.sh @@ -446,9 +446,9 @@ inv_mcp_initialize() { } # ── Invariant 4: tools/list returns all expected tools ───────────────────── -# Cross-check against the canonical 14-tool list (TOOLS[] in src/mcp/mcp.c). -EXPECTED_TOOLS="index_repository search_graph query_graph trace_path get_code_snippet get_graph_schema get_architecture search_code list_projects delete_project index_status detect_changes manage_adr ingest_traces" -EXPECTED_TOOL_COUNT=14 +# Cross-check against the canonical 15-tool list (TOOLS[] in src/mcp/mcp.c). +EXPECTED_TOOLS="index_repository search_graph query_graph trace_path get_code_snippet get_graph_schema get_architecture search_code list_projects delete_project index_status detect_changes manage_adr manage_memory ingest_traces" +EXPECTED_TOOL_COUNT=15 inv_tools_list() { if ! mcp_alive; then fail "tools-list" "server not alive" diff --git a/src/cli/cli.c b/src/cli/cli.c index 5930d317d..2f38ec0c3 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -475,6 +475,7 @@ static const char skill_content[] = "| Dead code | `search_graph(max_degree=0, exclude_entry_points=true)` |\n" "| Cross-service edges | `query_graph` with Cypher |\n" "| Impact of local changes | `detect_changes()` |\n" + "| Opt-in personal repo memory | `manage_memory(mode=\"settings\")`, then enable if wanted |\n" "| Risk-classified trace | `trace_path(risk_labels=true)` |\n" "| Text search | `search_code` or Grep |\n" "\n" @@ -496,11 +497,30 @@ static const char skill_content[] = "- High fan-in: `search_graph(min_degree=10, relationship=\"CALLS\", " "direction=\"inbound\")`\n" "\n" - "## 14 MCP Tools\n" + "## Personal Memory Workflow (opt-in)\n" + "1. Keep default project memory behavior unless the user enabled local memory with " + "`memory_enabled=true` or explicitly asks for personal/local memory.\n" + "2. If enabled, call `manage_memory(mode=\"get\")` to lookup/reference " + "previous local learnings for this repo and branch.\n" + "3. If empty or stale, inspect with `get_architecture`, `get_graph_schema`, and " + "`search_graph`; draft compact ADR sections.\n" + "4. Store new learnings with `manage_memory(mode=\"update\", content=\"...\")`. " + "This writes local user storage only, not the repo.\n" + " Store codebase knowledge: purpose, architecture, stack, module map, conventions, " + "gotchas, decisions, workflows, test/build commands, and branch-specific notes.\n" + "5. After branch work, update CHANGELOG/DECISIONS/learnings via " + "`manage_memory(mode=\"update\")`.\n" + "6. Use `manage_memory(mode=\"promote\", branch=\"feature\")` after merge-worthy branch work.\n" + "7. Use `manage_memory(mode=\"list\")`, `settings`, and `delete` for maintenance.\n" + "Config defaults: `memory_enabled=false`, `memory_default_scope=project`, " + "`memory_dir=`. Enable with `codebase-memory-mcp config set memory_enabled " + "true`.\n" + "\n" + "## 15 MCP Tools\n" "`index_repository`, `index_status`, `list_projects`, `delete_project`,\n" "`search_graph`, `search_code`, `trace_path`, `detect_changes`,\n" "`query_graph`, `get_graph_schema`, `get_code_snippet`, `get_architecture`,\n" - "`manage_adr`, `ingest_traces`\n" + "`manage_adr`, `manage_memory`, `ingest_traces`\n" "\n" "## Edge Types\n" "CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD,\n" @@ -2807,6 +2827,35 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key) { return rc; } +static const char *cbm_config_default_value(const char *key) { + if (!key) { + return ""; + } + if (strcmp(key, CBM_CONFIG_AUTO_INDEX) == 0) { + return "false"; + } + if (strcmp(key, CBM_CONFIG_AUTO_INDEX_LIMIT) == 0) { + return "50000"; + } + if (strcmp(key, CBM_CONFIG_UI_LANG) == 0) { + return "auto"; + } + if (strcmp(key, CBM_CONFIG_AUTO_UPDATE) == 0) { + return "true"; + } + if (strcmp(key, CBM_CONFIG_MEMORY_ENABLED) == 0) { + return "false"; + } + if (strcmp(key, CBM_CONFIG_MEMORY_DIR) == 0) { + const char *dir = cbm_resolve_memory_dir(); + return dir ? dir : ""; + } + if (strcmp(key, CBM_CONFIG_MEMORY_DEFAULT_SCOPE) == 0) { + return "project"; + } + return ""; +} + /* ── Config CLI subcommand ────────────────────────────────────── */ int cbm_cmd_config(int argc, char **argv) { @@ -2824,6 +2873,14 @@ int cbm_cmd_config(int argc, char **argv) { "Max files for auto-indexing new projects"); printf(" %-25s default=%-10s %s\n", CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"); + printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_UPDATE, "true", + "Check for newer releases on MCP startup"); + printf(" %-25s default=%-10s %s\n", CBM_CONFIG_MEMORY_ENABLED, "false", + "Enable opt-in local personal repo memory"); + printf(" %-25s default=%-10s %s\n", CBM_CONFIG_MEMORY_DIR, "user-data", + "Directory for local personal memory.db (overridden by CBM_MEMORY_DIR)"); + printf(" %-25s default=%-10s %s\n", CBM_CONFIG_MEMORY_DEFAULT_SCOPE, "project", + "Default memory scope for LLM workflows"); return 0; } @@ -2849,14 +2906,28 @@ int cbm_cmd_config(int argc, char **argv) { cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX, "false")); printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_INDEX_LIMIT, cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "50000")); - printf(" %-25s = %-10s\n", CBM_CONFIG_UI_LANG, - cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto")); + printf( + " %-25s = %-10s\n", CBM_CONFIG_UI_LANG, + cbm_config_get(cfg, CBM_CONFIG_UI_LANG, cbm_config_default_value(CBM_CONFIG_UI_LANG))); + printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_UPDATE, + cbm_config_get(cfg, CBM_CONFIG_AUTO_UPDATE, + cbm_config_default_value(CBM_CONFIG_AUTO_UPDATE))); + printf(" %-25s = %-10s\n", CBM_CONFIG_MEMORY_ENABLED, + cbm_config_get(cfg, CBM_CONFIG_MEMORY_ENABLED, + cbm_config_default_value(CBM_CONFIG_MEMORY_ENABLED))); + printf(" %-25s = %-10s\n", CBM_CONFIG_MEMORY_DIR, + cbm_config_get(cfg, CBM_CONFIG_MEMORY_DIR, + cbm_config_default_value(CBM_CONFIG_MEMORY_DIR))); + printf(" %-25s = %-10s\n", CBM_CONFIG_MEMORY_DEFAULT_SCOPE, + cbm_config_get(cfg, CBM_CONFIG_MEMORY_DEFAULT_SCOPE, + cbm_config_default_value(CBM_CONFIG_MEMORY_DEFAULT_SCOPE))); } else if (strcmp(argv[0], "get") == 0) { if (argc < MIN_ARGC_GET) { (void)fprintf(stderr, "Usage: config get \n"); rc = CLI_TRUE; } else { - printf("%s\n", cbm_config_get(cfg, argv[CLI_SKIP_ONE], "")); + printf("%s\n", cbm_config_get(cfg, argv[CLI_SKIP_ONE], + cbm_config_default_value(argv[CLI_SKIP_ONE]))); } } else if (strcmp(argv[0], "set") == 0) { if (argc < MIN_ARGC_CMD) { diff --git a/src/cli/cli.h b/src/cli/cli.h index 783a24bbe..4ff38bda3 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -298,6 +298,10 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key); #define CBM_CONFIG_AUTO_INDEX "auto_index" #define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit" #define CBM_CONFIG_UI_LANG "ui-lang" +#define CBM_CONFIG_MEMORY_ENABLED "memory_enabled" +#define CBM_CONFIG_MEMORY_DIR "memory_dir" +#define CBM_CONFIG_MEMORY_DEFAULT_SCOPE "memory_default_scope" +#define CBM_CONFIG_AUTO_UPDATE "auto_update" /* ── Subcommands (wired from main.c) ─────────────────────────── */ diff --git a/src/foundation/platform.c b/src/foundation/platform.c index 5b533c042..6ad0227e9 100644 --- a/src/foundation/platform.c +++ b/src/foundation/platform.c @@ -417,3 +417,42 @@ const char *cbm_resolve_cache_dir(void) { snprintf(buf, sizeof(buf), "%s/.cache/codebase-memory-mcp", home); return buf; } + +const char *cbm_resolve_memory_dir(void) { + static char buf[CBM_SZ_1K]; + char tmp[CBM_SZ_256] = ""; + cbm_safe_getenv("CBM_MEMORY_DIR", tmp, sizeof(tmp), NULL); + if (tmp[0]) { + snprintf(buf, sizeof(buf), "%s", tmp); + cbm_normalize_path_sep(buf); + return buf; + } + +#ifdef _WIN32 + const char *local = cbm_app_local_dir(); + if (!local) { + return NULL; + } + snprintf(buf, sizeof(buf), "%s/codebase-memory-mcp", local); + return buf; +#elif defined(__APPLE__) + const char *home = cbm_get_home_dir(); + if (!home) { + return NULL; + } + snprintf(buf, sizeof(buf), "%s/Library/Application Support/codebase-memory-mcp", home); + return buf; +#else + cbm_safe_getenv("XDG_DATA_HOME", tmp, sizeof(tmp), NULL); + if (tmp[0]) { + snprintf(buf, sizeof(buf), "%s/codebase-memory-mcp", tmp); + return buf; + } + const char *home = cbm_get_home_dir(); + if (!home) { + return NULL; + } + snprintf(buf, sizeof(buf), "%s/.local/share/codebase-memory-mcp", home); + return buf; +#endif +} diff --git a/src/foundation/platform.h b/src/foundation/platform.h index 2511a060e..eb077db98 100644 --- a/src/foundation/platform.h +++ b/src/foundation/platform.h @@ -146,6 +146,13 @@ const char *cbm_app_local_dir(void); * Returns static buffer or NULL if home is unavailable. */ const char *cbm_resolve_cache_dir(void); +/* Resolve the personal memory directory. Long-lived user memory lives here, + * separate from cache DBs. manage_memory rejects relative paths and paths inside + * the active source repo even when CBM_MEMORY_DIR points there. + * Priority: CBM_MEMORY_DIR env var > platform data dir default. + * Returns static buffer or NULL if home is unavailable. */ +const char *cbm_resolve_memory_dir(void); + /* ── File system ───────────────────────────────────────────────── */ /* Check if a path exists. */ diff --git a/src/main.c b/src/main.c index 7d0ddcf6a..f76c148db 100644 --- a/src/main.c +++ b/src/main.c @@ -432,7 +432,7 @@ static void print_help(void) { printf("\nTools: index_repository, search_graph, query_graph, trace_path,\n"); printf(" get_code_snippet, get_graph_schema, get_architecture, search_code,\n"); printf(" list_projects, delete_project, index_status, detect_changes,\n"); - printf(" manage_adr, ingest_traces\n"); + printf(" manage_adr, manage_memory, ingest_traces\n"); } /* ── Main ───────────────────────────────────────────────────────── */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d7ee3b63b..383129878 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1,5 +1,5 @@ /* - * mcp.c — MCP server: JSON-RPC 2.0 over stdio with 14 graph tools. + * mcp.c — MCP server: JSON-RPC 2.0 over stdio with graph tools. * * Uses yyjson for fast JSON parsing/building. * Single-threaded event loop: read line → parse → dispatch → respond. @@ -58,6 +58,7 @@ enum { #include "foundation/dump_verify.h" #include "foundation/compat_regex.h" #include "pipeline/artifact.h" +#include "personal_memory/memory.h" #ifdef _WIN32 #include @@ -487,10 +488,24 @@ static const tool_def_t TOOLS[] = { {"manage_adr", "Manage ADR", "Create or update Architecture Decision Records", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"mode\":{\"type\":" - "\"string\",\"enum\":[\"get\",\"update\",\"sections\"]},\"content\":{\"type\":\"string\"}," - "\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"project\"]" + "\"string\",\"enum\":[\"get\",\"update\",\"sections\"]},\"scope\":{\"type\":\"string\"," + "\"enum\":[\"project\",\"personal\"],\"default\":\"project\"},\"branch\":{\"type\":\"string\"}" + "," + "\"content\":{\"type\":\"string\"},\"sections\":{\"type\":\"array\",\"items\":{\"type\":" + "\"string\"}}}," + "\"required\":[\"project\"]" "}"}, + {"manage_memory", "Manage personal repo memory", + "Create or update local personal memory stored outside source repos", + "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"mode\":{\"type\":" + "\"string\",\"enum\":[\"get\",\"update\",\"sections\",\"settings\",\"bootstrap\",\"delete\"," + "\"list\",\"promote\",\"sync\"]}," + "\"doc_type\":{\"type\":\"string\",\"default\":\"adr\"},\"branch\":{\"type\":\"string\"}," + "\"content\":{\"type\":\"string\"},\"reveal_paths\":{\"type\":\"boolean\",\"default\":false}," + "\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}}," + "\"required\":[\"project\"]}"}, + {"ingest_traces", "Ingest traces", "Ingest runtime traces to enhance the knowledge graph", "{\"type\":\"object\",\"properties\":{\"traces\":{\"type\":\"array\",\"items\":{\"type\":" "\"object\",\"properties\":{\"caller\":{\"type\":\"string\"},\"callee\":{\"type\":\"string\"}," @@ -5058,10 +5073,267 @@ static char *adr_read_legacy_file(const char *root_path) { "then draft and store. Sections: PURPOSE, STACK, ARCHITECTURE, " \ "PATTERNS, TRADEOFFS, PHILOSOPHY." +#define MEMORY_EMPTY_HINT \ + "No personal memory yet. Explore with get_architecture/search_graph, then " \ + "store a compact ADR with manage_memory(mode='update', content='...')." + +static void memory_add_privacy_json(yyjson_mut_doc *doc, yyjson_mut_val *root) { + yyjson_mut_obj_add_str(doc, root, "storage", "personal"); + yyjson_mut_obj_add_bool(doc, root, "local_only", true); + yyjson_mut_obj_add_bool(doc, root, "external_transmission", false); + yyjson_mut_obj_add_str(doc, root, "network_sync", "disabled"); + yyjson_mut_obj_add_str(doc, root, "repo_upload", "disabled"); + yyjson_mut_obj_add_str(doc, root, "sensitive_data_logging", "disabled"); +} + +static char *handle_manage_memory(cbm_mcp_server_t *srv, const char *args) { + char *project = get_project_arg(args); + char *mode_str = cbm_mcp_get_string_arg(args, "mode"); + char *content = cbm_mcp_get_string_arg(args, "content"); + char *doc_type = cbm_mcp_get_string_arg(args, "doc_type"); + char *branch_arg = cbm_mcp_get_string_arg(args, "branch"); + bool reveal_paths = cbm_mcp_get_bool_arg(args, "reveal_paths"); + + if (!mode_str) { + mode_str = heap_strdup("get"); + } + if (!doc_type) { + doc_type = heap_strdup("adr"); + } + + if (!cbm_memory_enabled(srv->config) && strcmp(mode_str, "settings") != 0) { + free(project); + free(mode_str); + free(content); + free(doc_type); + free(branch_arg); + return cbm_mcp_text_result("personal memory disabled by config memory_enabled=false", true); + } + + char *root_path = get_project_root(srv, project); + if (!root_path) { + free(project); + free(mode_str); + free(content); + free(doc_type); + free(branch_arg); + return cbm_mcp_text_result("project not found", true); + } + + cbm_git_context_t ctx = {0}; + (void)cbm_git_context_resolve(root_path, &ctx); + const char *current_branch = + branch_arg && branch_arg[0] ? branch_arg : cbm_memory_current_branch(&ctx); + const char *base_branch = cbm_memory_default_branch(&ctx); + char *repo_id = cbm_memory_repo_id(project, root_path, &ctx); + char *key = cbm_memory_doc_key(repo_id, current_branch, doc_type); + char *base_key = cbm_memory_doc_key(repo_id, base_branch, doc_type); + if (!repo_id || !key || !base_key) { + free(root_path); + free(repo_id); + free(key); + free(base_key); + free(project); + free(mode_str); + free(content); + free(doc_type); + free(branch_arg); + cbm_git_context_free(&ctx); + return cbm_mcp_text_result("personal memory store unavailable", true); + } + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root_obj = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root_obj); + yyjson_mut_obj_add_str(doc, root_obj, "repo_id", ""); + yyjson_mut_obj_add_bool(doc, root_obj, "repo_id_redacted", true); + yyjson_mut_obj_add_strcpy(doc, root_obj, "branch", current_branch); + yyjson_mut_obj_add_strcpy(doc, root_obj, "base_branch", base_branch); + yyjson_mut_obj_add_strcpy(doc, root_obj, "doc_type", doc_type); + + char storage_boundary[CBM_SZ_256]; + bool storage_allowed = cbm_memory_storage_allowed(srv->config, root_path, storage_boundary, + sizeof(storage_boundary)); + yyjson_mut_obj_add_strcpy(doc, root_obj, "storage_boundary", storage_boundary); + yyjson_mut_obj_add_bool(doc, root_obj, "storage_boundary_enforced", true); + if (strcmp(mode_str, "settings") != 0) { + memory_add_privacy_json(doc, root_obj); + } + + bool is_error = false; + if (!storage_allowed && strcmp(mode_str, "settings") != 0 && + strcmp(mode_str, "bootstrap") != 0) { + yyjson_mut_obj_add_str(doc, root_obj, "status", "storage_boundary_error"); + yyjson_mut_obj_add_strcpy(doc, root_obj, "error", storage_boundary); + yyjson_mut_obj_add_str( + doc, root_obj, "error_hint", + "Set memory_dir/CBM_MEMORY_DIR to an absolute path outside the source repo."); + is_error = true; + } else if (strcmp(mode_str, "settings") == 0) { + char *db_path = cbm_memory_db_path(srv->config, false); + cbm_memory_add_settings_json(srv->config, doc, root_obj, db_path, reveal_paths); + free(db_path); + } else if (strcmp(mode_str, "list") == 0) { + char *db_path = NULL; + cbm_store_t *store = cbm_memory_open_query(srv->config, &db_path); + cbm_memory_add_list_json(store, repo_id, doc, root_obj); + if (store) { + cbm_store_close(store); + } + free(db_path); + } else if ((strcmp(mode_str, "update") == 0 || strcmp(mode_str, "store") == 0) && content) { + char *db_path = NULL; + cbm_store_t *store = cbm_memory_open(srv->config, &db_path); + if (!store) { + yyjson_mut_obj_add_str(doc, root_obj, "status", "write_error"); + is_error = true; + } else if (cbm_store_adr_store(store, key, content) == CBM_STORE_OK) { + yyjson_mut_obj_add_str(doc, root_obj, "status", "updated"); + yyjson_mut_obj_add_bool(doc, root_obj, "storage_key_redacted", true); + } else { + yyjson_mut_obj_add_str(doc, root_obj, "status", "write_error"); + is_error = true; + } + if (store) { + cbm_store_close(store); + } + free(db_path); + } else if (strcmp(mode_str, "update") == 0 || strcmp(mode_str, "store") == 0) { + yyjson_mut_obj_add_str(doc, root_obj, "status", "missing_content"); + yyjson_mut_obj_add_str(doc, root_obj, "error", "content is required for update/store"); + is_error = true; + } else if (strcmp(mode_str, "sync") == 0) { + yyjson_mut_obj_add_str(doc, root_obj, "status", "sync_disabled"); + yyjson_mut_obj_add_str(doc, root_obj, "sync_semantics", "disabled; no network sync exists"); + yyjson_mut_obj_add_str(doc, root_obj, "promote_hint", + "Use mode=promote only for local branch-to-base memory copy."); + is_error = true; + } else if (strcmp(mode_str, "promote") == 0) { + char *db_path = NULL; + cbm_store_t *store = cbm_memory_open(srv->config, &db_path); + if (!store || strcmp(current_branch, base_branch) == 0) { + yyjson_mut_obj_add_str(doc, root_obj, "status", store ? "already_base" : "write_error"); + is_error = !store; + } else { + cbm_adr_t branch_doc; + memset(&branch_doc, 0, sizeof(branch_doc)); + if (cbm_store_adr_get(store, key, &branch_doc) == CBM_STORE_OK && branch_doc.content) { + if (cbm_store_adr_store(store, base_key, branch_doc.content) == CBM_STORE_OK) { + yyjson_mut_obj_add_str(doc, root_obj, "status", "promoted"); + yyjson_mut_obj_add_str(doc, root_obj, "promote_semantics", + "local branch doc copy to base branch doc"); + yyjson_mut_obj_add_bool(doc, root_obj, "from_key_redacted", true); + yyjson_mut_obj_add_bool(doc, root_obj, "to_key_redacted", true); + } else { + yyjson_mut_obj_add_str(doc, root_obj, "status", "write_error"); + is_error = true; + } + cbm_store_adr_free(&branch_doc); + } else { + yyjson_mut_obj_add_str(doc, root_obj, "status", "no_branch_memory"); + is_error = true; + } + } + if (store) { + cbm_store_close(store); + } + free(db_path); + } else if (strcmp(mode_str, "delete") == 0) { + char *db_path = NULL; + cbm_store_t *store = cbm_memory_open_existing(srv->config, &db_path); + int delete_rc = store ? cbm_store_adr_delete(store, key) : CBM_STORE_NOT_FOUND; + if (delete_rc == CBM_STORE_OK) { + yyjson_mut_obj_add_str(doc, root_obj, "status", "deleted"); + yyjson_mut_obj_add_str(doc, root_obj, "delete_semantics", + "selected repo/branch/doc row only"); + yyjson_mut_obj_add_bool(doc, root_obj, "database_removed", false); + yyjson_mut_obj_add_bool(doc, root_obj, "storage_key_redacted", true); + } else if (delete_rc == CBM_STORE_NOT_FOUND) { + yyjson_mut_obj_add_str(doc, root_obj, "status", "not_found"); + yyjson_mut_obj_add_str(doc, root_obj, "delete_semantics", + "selected repo/branch/doc row only"); + yyjson_mut_obj_add_bool(doc, root_obj, "database_removed", false); + yyjson_mut_obj_add_bool(doc, root_obj, "storage_key_redacted", true); + } else { + yyjson_mut_obj_add_str(doc, root_obj, "status", "delete_error"); + is_error = true; + } + if (store) { + cbm_store_close(store); + } + free(db_path); + } else if (strcmp(mode_str, "bootstrap") == 0) { + yyjson_mut_obj_add_str( + doc, root_obj, "content", + "## PURPOSE\nCapture project purpose, main users, and operational scope.\n\n" + "## STACK\nList languages, frameworks, storage, APIs, infra.\n\n" + "## ARCHITECTURE\nDescribe major modules, entry points, data flow, boundaries.\n\n" + "## DECISIONS\nRecord accepted technical decisions and rationale.\n\n" + "## FEATURES\nTrack important capabilities and product behavior.\n\n" + "## CHANGELOG\nAppend branch/pull changes that affect architecture or behavior.\n"); + yyjson_mut_obj_add_str(doc, root_obj, "status", "template"); + } else { + char *db_path = NULL; + cbm_store_t *store = cbm_memory_open_query(srv->config, &db_path); + cbm_adr_t adr; + memset(&adr, 0, sizeof(adr)); + bool have = store && (cbm_store_adr_get(store, key, &adr) == CBM_STORE_OK); + if (!have && strcmp(current_branch, base_branch) != 0) { + have = store && (cbm_store_adr_get(store, base_key, &adr) == CBM_STORE_OK); + if (have) { + yyjson_mut_obj_add_bool(doc, root_obj, "inherited_from_base", true); + } + } + if (strcmp(mode_str, "sections") == 0) { + adr_list_sections_from_content(doc, root_obj, have ? adr.content : NULL); + } else if (have && adr.content) { + yyjson_mut_obj_add_strcpy(doc, root_obj, "content", adr.content); + } else { + yyjson_mut_obj_add_str(doc, root_obj, "content", ""); + yyjson_mut_obj_add_str(doc, root_obj, "status", "no_memory"); + yyjson_mut_obj_add_str(doc, root_obj, "memory_hint", MEMORY_EMPTY_HINT); + } + if (have) { + cbm_store_adr_free(&adr); + } + if (store) { + cbm_store_close(store); + } + free(db_path); + } + + char *json = yy_doc_to_str(doc); + yyjson_mut_doc_free(doc); + free(root_path); + free(repo_id); + free(key); + free(base_key); + free(project); + free(mode_str); + free(content); + free(doc_type); + free(branch_arg); + cbm_git_context_free(&ctx); + + char *result = cbm_mcp_text_result(json, is_error); + free(json); + return result; +} + static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); char *mode_str = cbm_mcp_get_string_arg(args, "mode"); char *content = cbm_mcp_get_string_arg(args, "content"); + char *scope = cbm_mcp_get_string_arg(args, "scope"); + + if (scope && strcmp(scope, "personal") == 0) { + free(project); + free(mode_str); + free(content); + free(scope); + return handle_manage_memory(srv, args); + } + free(scope); if (!mode_str) { mode_str = heap_strdup("get"); @@ -5246,6 +5518,9 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch if (strcmp(tool_name, "manage_adr") == 0) { return handle_manage_adr(srv, args_json); } + if (strcmp(tool_name, "manage_memory") == 0) { + return handle_manage_memory(srv, args_json); + } if (strcmp(tool_name, "ingest_traces") == 0) { return handle_ingest_traces(srv, args_json); } @@ -5447,6 +5722,11 @@ static void start_update_check(cbm_mcp_server_t *srv) { if (srv->update_checked) { return; } + if (srv->config && !cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_UPDATE, true)) { + srv->update_checked = true; + cbm_log_info("update.check.skip", "reason", "auto_update_disabled"); + return; + } srv->update_checked = true; /* prevent double-launch */ if (cbm_thread_create(&srv->update_tid, 0, update_check_thread, srv) == 0) { srv->update_thread_active = true; diff --git a/src/personal_memory/memory.c b/src/personal_memory/memory.c new file mode 100644 index 000000000..ceb08dd9f --- /dev/null +++ b/src/personal_memory/memory.c @@ -0,0 +1,303 @@ +#include "personal_memory/memory.h" + +#include "cli/cli.h" +#include "foundation/compat_fs.h" +#include "foundation/constants.h" +#include "foundation/platform.h" +#include +#include +#include +#include +#ifndef _WIN32 +#include +#endif + +enum { MEMORY_DIR_PERMS = 0700 }; + +bool cbm_memory_enabled(struct cbm_config *cfg) { + if (!cfg) { + return false; + } + return cbm_config_get_bool(cfg, CBM_CONFIG_MEMORY_ENABLED, false); +} + +const char *cbm_memory_resolve_dir(struct cbm_config *cfg) { + if (cfg) { + const char *configured = cbm_config_get(cfg, CBM_CONFIG_MEMORY_DIR, ""); + if (configured && configured[0]) { + return configured; + } + } + return cbm_resolve_memory_dir(); +} + +char *cbm_memory_db_path(struct cbm_config *cfg, bool create_dir) { + const char *dir = cbm_memory_resolve_dir(cfg); + if (!dir || !dir[0]) { + return NULL; + } + if (create_dir) { + if (!cbm_mkdir_p(dir, MEMORY_DIR_PERMS)) { + return NULL; + } +#ifndef _WIN32 + /* Personal memory may contain user notes, paths, and operational context. + * Keep the storage directory private even when it already existed with + * broader permissions from an older build or user-created path. */ + (void)chmod(dir, MEMORY_DIR_PERMS); +#endif + } + int n = snprintf(NULL, 0, "%s/memory.db", dir); + if (n < 0) { + return NULL; + } + char *path = malloc((size_t)n + 1); + if (!path) { + return NULL; + } + snprintf(path, (size_t)n + 1, "%s/memory.db", dir); + return path; +} + +static void memory_copy_norm(char *dst, size_t dst_sz, const char *src) { + if (!dst || dst_sz == 0) { + return; + } + snprintf(dst, dst_sz, "%s", src ? src : ""); + cbm_normalize_path_sep(dst); + size_t len = strlen(dst); + while (len > 1 && dst[len - 1] == '/') { + dst[len - 1] = '\0'; + len--; + } +} + +static bool memory_path_is_absolute(const char *path) { + if (!path || !path[0]) { + return false; + } + if (path[0] == '/') { + return true; + } + if ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) { + return path[1] == ':' && (path[2] == '/' || path[2] == '\\'); + } + return false; +} + +static bool memory_path_contains_dir(const char *root, const char *path) { + char r[CBM_SZ_1K]; + char p[CBM_SZ_1K]; + memory_copy_norm(r, sizeof(r), root); + memory_copy_norm(p, sizeof(p), path); + size_t rlen = strlen(r); + if (rlen == 0) { + return false; + } + if (strcmp(r, p) == 0) { + return true; + } + return strncmp(p, r, rlen) == 0 && (r[rlen - 1] == '/' || p[rlen] == '/'); +} + +bool cbm_memory_storage_allowed(struct cbm_config *cfg, const char *root_path, char *reason, + size_t reason_sz) { + const char *dir = cbm_memory_resolve_dir(cfg); + const char *status = "outside_repo"; + bool allowed = true; + if (!dir || !dir[0]) { + status = "memory_dir_unavailable"; + allowed = false; + } else if (!memory_path_is_absolute(dir)) { + status = "memory_dir_must_be_absolute"; + allowed = false; + } else if (root_path && root_path[0] && memory_path_contains_dir(root_path, dir)) { + status = "memory_dir_inside_repo"; + allowed = false; + } + if (reason && reason_sz > 0) { + snprintf(reason, reason_sz, "%s", status); + } + return allowed; +} + +cbm_store_t *cbm_memory_open(struct cbm_config *cfg, char **out_path) { + char *path = cbm_memory_db_path(cfg, true); + if (!path) { + return NULL; + } + cbm_store_t *store = cbm_store_open_path(path); + if (out_path) { + *out_path = path; + } else { + free(path); + } + return store; +} + +cbm_store_t *cbm_memory_open_existing(struct cbm_config *cfg, char **out_path) { + char *path = cbm_memory_db_path(cfg, false); + if (!path) { + return NULL; + } + if (!cbm_file_exists(path)) { + if (out_path) { + *out_path = path; + } else { + free(path); + } + return NULL; + } + cbm_store_t *store = cbm_store_open_path(path); + if (out_path) { + *out_path = path; + } else { + free(path); + } + return store; +} + +cbm_store_t *cbm_memory_open_query(struct cbm_config *cfg, char **out_path) { + char *path = cbm_memory_db_path(cfg, false); + if (!path) { + return NULL; + } + cbm_store_t *store = cbm_store_open_path_query(path); + if (out_path) { + *out_path = path; + } else { + free(path); + } + return store; +} + +const char *cbm_memory_default_branch(const cbm_git_context_t *ctx) { + if (ctx && ctx->branch && ctx->branch[0] && strcmp(ctx->branch, "DETACHED") != 0 && + (strcmp(ctx->branch, "main") == 0 || strcmp(ctx->branch, "master") == 0)) { + return ctx->branch; + } + return "main"; +} + +const char *cbm_memory_current_branch(const cbm_git_context_t *ctx) { + if (ctx && ctx->branch && ctx->branch[0]) { + return ctx->branch; + } + return "working-tree"; +} + +char *cbm_memory_repo_id(const char *project, const char *root_path, const cbm_git_context_t *ctx) { + const char *identity = NULL; + if (ctx && ctx->is_git && ctx->canonical_root && ctx->canonical_root[0]) { + identity = ctx->canonical_root; + } else if (root_path && root_path[0]) { + identity = root_path; + } else { + identity = project && project[0] ? project : "project"; + } + int n = snprintf(NULL, 0, "repo:%s", identity); + if (n < 0) { + return NULL; + } + char *out = malloc((size_t)n + 1); + if (!out) { + return NULL; + } + snprintf(out, (size_t)n + 1, "repo:%s", identity); + return out; +} + +char *cbm_memory_doc_key(const char *repo_id, const char *branch, const char *doc_type) { + const char *rid = repo_id && repo_id[0] ? repo_id : "repo:unknown"; + const char *br = branch && branch[0] ? branch : "working-tree"; + const char *dt = doc_type && doc_type[0] ? doc_type : "adr"; + int n = snprintf(NULL, 0, "%s::branch:%s::doc:%s", rid, br, dt); + if (n < 0) { + return NULL; + } + char *out = malloc((size_t)n + 1); + if (!out) { + return NULL; + } + snprintf(out, (size_t)n + 1, "%s::branch:%s::doc:%s", rid, br, dt); + return out; +} + +void cbm_memory_add_settings_json(struct cbm_config *cfg, yyjson_mut_doc *doc, yyjson_mut_val *root, + const char *db_path, bool reveal_paths) { + const char *cache_dir_resolved = cbm_resolve_cache_dir(); + const char *memory_dir_resolved = cbm_memory_resolve_dir(cfg); + char memory_dir_buf[CBM_SZ_1K]; + snprintf(memory_dir_buf, sizeof(memory_dir_buf), "%s", + memory_dir_resolved ? memory_dir_resolved : ""); + const char *default_scope = + cfg ? cbm_config_get(cfg, CBM_CONFIG_MEMORY_DEFAULT_SCOPE, "project") : "project"; + char default_scope_buf[CBM_SZ_256]; + snprintf(default_scope_buf, sizeof(default_scope_buf), "%s", + default_scope ? default_scope : ""); + yyjson_mut_obj_add_str(doc, root, "storage", "personal"); + yyjson_mut_obj_add_bool(doc, root, "local_only", true); + yyjson_mut_obj_add_bool(doc, root, "external_transmission", false); + yyjson_mut_obj_add_str(doc, root, "network_sync", "disabled"); + yyjson_mut_obj_add_str(doc, root, "repo_upload", "disabled"); + yyjson_mut_obj_add_str(doc, root, "sensitive_data_logging", "disabled"); + yyjson_mut_obj_add_str(doc, root, "delete_semantics", "selected repo/branch/doc row only"); + yyjson_mut_obj_add_str(doc, root, "promote_semantics", + "local branch doc copy to base branch doc"); + yyjson_mut_obj_add_str(doc, root, "sync_semantics", "disabled; no network sync exists"); + yyjson_mut_obj_add_bool(doc, root, "enabled", cbm_memory_enabled(cfg)); + yyjson_mut_obj_add_strcpy(doc, root, "default_scope", default_scope_buf); + yyjson_mut_obj_add_str(doc, root, "cache_env", "CBM_CACHE_DIR"); + yyjson_mut_obj_add_str(doc, root, "memory_env", "CBM_MEMORY_DIR"); + yyjson_mut_obj_add_str(doc, root, "dir_mode", "0700"); + yyjson_mut_obj_add_bool(doc, root, "paths_redacted", !reveal_paths); + if (reveal_paths) { + yyjson_mut_obj_add_str(doc, root, "cache_dir", + cache_dir_resolved ? cache_dir_resolved : ""); + yyjson_mut_obj_add_strcpy(doc, root, "memory_dir", memory_dir_buf); + yyjson_mut_obj_add_strcpy(doc, root, "memory_db", db_path ? db_path : ""); + } else { + yyjson_mut_obj_add_str(doc, root, "cache_dir", ""); + yyjson_mut_obj_add_str(doc, root, "memory_dir", ""); + yyjson_mut_obj_add_str(doc, root, "memory_db", ""); + yyjson_mut_obj_add_str(doc, root, "path_hint", + "pass reveal_paths=true to show local filesystem paths"); + } +} + +void cbm_memory_add_list_json(cbm_store_t *store, const char *repo_id, yyjson_mut_doc *doc, + yyjson_mut_val *root) { + yyjson_mut_val *items = yyjson_mut_arr(doc); + int count = 0; + if (store && repo_id) { + sqlite3 *db = cbm_store_get_db(store); + sqlite3_stmt *stmt = NULL; + const char *sql = "SELECT project, updated_at FROM project_summaries WHERE project LIKE ?1 " + "ORDER BY updated_at DESC"; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) { + char pattern[CBM_SZ_2K]; + snprintf(pattern, sizeof(pattern), "%s::branch:%%::doc:%%", repo_id); + sqlite3_bind_text(stmt, 1, pattern, -1, SQLITE_TRANSIENT); + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char *key = (const char *)sqlite3_column_text(stmt, 0); + const char *updated_at = (const char *)sqlite3_column_text(stmt, 1); + const char *branch = key ? strstr(key, "::branch:") : NULL; + const char *doc_type = key ? strstr(key, "::doc:") : NULL; + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_bool(doc, item, "key_redacted", true); + if (branch && doc_type && doc_type > branch) { + branch += strlen("::branch:"); + yyjson_mut_obj_add_strncpy(doc, item, "branch", branch, + (size_t)(doc_type - branch)); + yyjson_mut_obj_add_strcpy(doc, item, "doc_type", doc_type + strlen("::doc:")); + } + yyjson_mut_obj_add_strcpy(doc, item, "updated_at", updated_at ? updated_at : ""); + yyjson_mut_arr_add_val(items, item); + count++; + } + } + sqlite3_finalize(stmt); + } + yyjson_mut_obj_add_val(doc, root, "items", items); + yyjson_mut_obj_add_int(doc, root, "count", count); +} diff --git a/src/personal_memory/memory.h b/src/personal_memory/memory.h new file mode 100644 index 000000000..082e6f3b2 --- /dev/null +++ b/src/personal_memory/memory.h @@ -0,0 +1,31 @@ +#ifndef CBM_PERSONAL_MEMORY_H +#define CBM_PERSONAL_MEMORY_H + +#include "git/git_context.h" +#include "store/store.h" +#include +#include +#include + +struct cbm_config; + +bool cbm_memory_enabled(struct cbm_config *cfg); +const char *cbm_memory_resolve_dir(struct cbm_config *cfg); +char *cbm_memory_db_path(struct cbm_config *cfg, bool create_dir); +bool cbm_memory_storage_allowed(struct cbm_config *cfg, const char *root_path, char *reason, + size_t reason_sz); +cbm_store_t *cbm_memory_open(struct cbm_config *cfg, char **out_path); +cbm_store_t *cbm_memory_open_existing(struct cbm_config *cfg, char **out_path); +cbm_store_t *cbm_memory_open_query(struct cbm_config *cfg, char **out_path); + +const char *cbm_memory_current_branch(const cbm_git_context_t *ctx); +const char *cbm_memory_default_branch(const cbm_git_context_t *ctx); +char *cbm_memory_repo_id(const char *project, const char *root_path, const cbm_git_context_t *ctx); +char *cbm_memory_doc_key(const char *repo_id, const char *branch, const char *doc_type); + +void cbm_memory_add_settings_json(struct cbm_config *cfg, yyjson_mut_doc *doc, yyjson_mut_val *root, + const char *db_path, bool reveal_paths); +void cbm_memory_add_list_json(cbm_store_t *store, const char *repo_id, yyjson_mut_doc *doc, + yyjson_mut_val *root); + +#endif /* CBM_PERSONAL_MEMORY_H */ diff --git a/tests/test_cli.c b/tests/test_cli.c index af300ab51..d734a3cc5 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -568,7 +568,8 @@ TEST(cli_skill_files_content) { /* Reference capabilities */ ASSERT(strstr(sk[0].content, "query_graph") != NULL); ASSERT(strstr(sk[0].content, "Cypher") != NULL); - ASSERT(strstr(sk[0].content, "14 MCP Tools") != NULL); + ASSERT(strstr(sk[0].content, "15 MCP Tools") != NULL); + ASSERT(strstr(sk[0].content, "manage_memory") != NULL); /* Gotchas section */ ASSERT(strstr(sk[0].content, "Gotchas") != NULL); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 8d263c570..46fbcaf75 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -7,6 +7,7 @@ #include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */ #include "../src/foundation/constants.h" #include "../src/foundation/log.h" +#include "../src/foundation/platform.h" #include "test_framework.h" #include #include @@ -236,7 +237,7 @@ TEST(mcp_initialize_response) { TEST(mcp_tools_list) { char *json = cbm_mcp_tools_list(); ASSERT_NOT_NULL(json); - /* Should contain all 14 tools */ + /* Should contain all 15 tools */ ASSERT_NOT_NULL(strstr(json, "index_repository")); ASSERT_NOT_NULL(strstr(json, "search_graph")); ASSERT_NOT_NULL(strstr(json, "query_graph")); @@ -250,6 +251,7 @@ TEST(mcp_tools_list) { ASSERT_NOT_NULL(strstr(json, "index_status")); ASSERT_NOT_NULL(strstr(json, "detect_changes")); ASSERT_NOT_NULL(strstr(json, "manage_adr")); + ASSERT_NOT_NULL(strstr(json, "manage_memory")); ASSERT_NOT_NULL(strstr(json, "ingest_traces")); free(json); PASS(); @@ -2070,6 +2072,273 @@ TEST(tool_detect_changes_not_found_rich_error) { PASS(); } +TEST(tool_manage_memory_personal_store) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-memory-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char repo_dir[512]; + snprintf(repo_dir, sizeof(repo_dir), "%s/repo", tmp_dir); + ASSERT_TRUE(cbm_mkdir_p(repo_dir, 0700)); + char memory_dir[512]; + snprintf(memory_dir, sizeof(memory_dir), "%s/memory", tmp_dir); + ASSERT_EQ(cbm_setenv("CBM_MEMORY_DIR", memory_dir, 1), 0); + char cache_dir[512]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmp_dir); + cbm_config_t *cfg = cbm_config_open(cache_dir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_MEMORY_ENABLED, "true"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_MEMORY_DEFAULT_SCOPE, "personal"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + cbm_store_upsert_project(st, "memory-proj", repo_dir); + cbm_mcp_server_set_project(srv, "memory-proj"); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":130,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"settings\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "CBM_MEMORY_DIR")); + ASSERT_NOT_NULL(strstr(resp, "repo_upload")); + ASSERT_NOT_NULL(strstr(resp, "local_only")); + ASSERT_NOT_NULL(strstr(resp, "network_sync")); + ASSERT_NOT_NULL(strstr(resp, "paths_redacted")); + ASSERT_NOT_NULL(strstr(resp, "external_transmission")); + ASSERT_NOT_NULL(strstr(resp, "sensitive_data_logging")); + ASSERT_NOT_NULL(strstr(resp, "storage_boundary")); + ASSERT_NOT_NULL(strstr(resp, "")); + ASSERT_NULL(strstr(resp, memory_dir)); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1300,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"settings\",\"reveal_paths\":true}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, memory_dir)); + ASSERT_NOT_NULL(strstr(resp, "\"paths_redacted\":false")); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1301,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"update\",\"branch\":\"main\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "missing_content")); + free(resp); + + resp = cbm_mcp_server_handle( + srv, + "{\"jsonrpc\":\"2.0\",\"id\":131,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"update\",\"branch\":\"main\",\"content\":\"## PURPOSE\\nLocal only.\\n\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + ASSERT_NOT_NULL(strstr(resp, "storage_key_redacted")); + ASSERT_NULL(strstr(resp, tmp_dir)); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":132,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"get\",\"branch\":\"main\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Local only.")); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":135,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"list\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"count\":1")); + ASSERT_NOT_NULL(strstr(resp, "\"branch\":\"main\"")); + ASSERT_NOT_NULL(strstr(resp, "key_redacted")); + ASSERT_NULL(strstr(resp, tmp_dir)); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1351,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"sync\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "sync_disabled")); + ASSERT_NOT_NULL(strstr(resp, "no network sync exists")); + ASSERT_NULL(strstr(resp, tmp_dir)); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":136,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"update\",\"branch\":\"feature\",\"content\":\"## PURPOSE\\nFeature " + "memory.\\n\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":137,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"promote\",\"branch\":\"feature\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "promoted")); + ASSERT_NOT_NULL(strstr(resp, "local_only")); + ASSERT_NOT_NULL(strstr(resp, "network_sync")); + ASSERT_NOT_NULL(strstr(resp, "from_key_redacted")); + ASSERT_NOT_NULL(strstr(resp, "to_key_redacted")); + ASSERT_NULL(strstr(resp, tmp_dir)); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":138,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"get\",\"branch\":\"main\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Feature memory.")); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":133,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"delete\",\"branch\":\"main\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "deleted")); + ASSERT_NOT_NULL(strstr(resp, "delete_semantics")); + ASSERT_NOT_NULL(strstr(resp, "database_removed")); + ASSERT_NOT_NULL(strstr(resp, "storage_key_redacted")); + ASSERT_NULL(strstr(resp, tmp_dir)); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1331,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"delete\",\"branch\":\"main\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "not_found")); + ASSERT_NULL(strstr(resp, "delete_error")); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":134,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{\"project\":\"memory-proj\"," + "\"mode\":\"get\",\"branch\":\"main\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "no_memory")); + free(resp); + + char memory_db[512]; + snprintf(memory_db, sizeof(memory_db), "%s/memory.db", memory_dir); + ASSERT_TRUE(cbm_file_exists(memory_db)); +#ifndef _WIN32 + struct stat memory_st; + ASSERT_EQ(stat(memory_dir, &memory_st), 0); + ASSERT_EQ((int)(memory_st.st_mode & 0777), 0700); +#endif + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + cbm_unsetenv("CBM_MEMORY_DIR"); + remove(memory_db); + rmdir(memory_dir); + char config_db[512]; + snprintf(config_db, sizeof(config_db), "%s/_config.db", cache_dir); + remove(config_db); + rmdir(cache_dir); + rmdir(repo_dir); + rmdir(tmp_dir); + + PASS(); +} + +TEST(tool_manage_memory_disabled_by_default) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-memory-disabled-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char memory_dir[512]; + snprintf(memory_dir, sizeof(memory_dir), "%s/memory", tmp_dir); + ASSERT_EQ(cbm_setenv("CBM_MEMORY_DIR", memory_dir, 1), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + cbm_store_upsert_project(st, "memory-disabled-proj", tmp_dir); + cbm_mcp_server_set_project(srv, "memory-disabled-proj"); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":2300,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{" + "\"project\":\"memory-disabled-proj\",\"mode\":\"update\"," + "\"branch\":\"main\",\"content\":\"secret\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "memory_enabled=false")); + ASSERT_FALSE(cbm_file_exists(memory_dir)); + ASSERT_NULL(strstr(resp, tmp_dir)); + free(resp); + + cbm_mcp_server_free(srv); + cbm_unsetenv("CBM_MEMORY_DIR"); + rmdir(tmp_dir); + + PASS(); +} + +TEST(tool_manage_memory_rejects_repo_local_storage) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-memory-boundary-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char memory_dir[512]; + snprintf(memory_dir, sizeof(memory_dir), "%s/.cbm-memory", tmp_dir); + ASSERT_EQ(cbm_setenv("CBM_MEMORY_DIR", memory_dir, 1), 0); + char cache_dir[512]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmp_dir); + cbm_config_t *cfg = cbm_config_open(cache_dir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_MEMORY_ENABLED, "true"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + cbm_store_upsert_project(st, "memory-boundary-proj", tmp_dir); + cbm_mcp_server_set_project(srv, "memory-boundary-proj"); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":2301,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_memory\",\"arguments\":{" + "\"project\":\"memory-boundary-proj\",\"mode\":\"update\"," + "\"branch\":\"main\",\"content\":\"secret\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "storage_boundary_error")); + ASSERT_NOT_NULL(strstr(resp, "memory_dir_inside_repo")); + ASSERT_NOT_NULL(strstr(resp, "external_transmission")); + ASSERT_FALSE(cbm_file_exists(memory_dir)); + ASSERT_NULL(strstr(resp, tmp_dir)); + free(resp); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + cbm_unsetenv("CBM_MEMORY_DIR"); + char config_db[512]; + snprintf(config_db, sizeof(config_db), "%s/_config.db", cache_dir); + remove(config_db); + rmdir(cache_dir); + rmdir(tmp_dir); + + PASS(); +} + TEST(tool_ingest_traces_basic) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -3823,6 +4092,9 @@ SUITE(mcp) { RUN_TEST(tool_manage_adr_get_accepts_abs_path); RUN_TEST(tool_manage_adr_get_accepts_symlink_path); RUN_TEST(tool_detect_changes_not_found_rich_error); + RUN_TEST(tool_manage_memory_personal_store); + RUN_TEST(tool_manage_memory_disabled_by_default); + RUN_TEST(tool_manage_memory_rejects_repo_local_storage); RUN_TEST(tool_ingest_traces_basic); RUN_TEST(tool_ingest_traces_empty);