diff --git a/README.md b/README.md index 4162ae371..cdded2176 100644 --- a/README.md +++ b/README.md @@ -694,7 +694,7 @@ overwrite user-modified agents. | Agent | Activation | MCP config | Durable context / augmentation | |-------|------------|------------|--------------------------------| -| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, and post-`Read` coverage | +| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, post-`Read` coverage, and a drift note after repeated searches without a graph call | | Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart`, `PreToolUse` graph lookup for `Bash` searches | | Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` | | Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill | @@ -755,7 +755,10 @@ the requested tool call. Codex CLI's `PreToolUse` on `Bash` gives its shell searches the same context. GitHub Copilot CLI and VS Code's Copilot runtime get it after the search: their `preToolUse` can only decide permissions, so a `PostToolUse` entry for `Bash`, `Grep` and `Glob` appends the graph symbols to -the tool result. +the tool result. In Claude Code, once a session has run three searches with no +codebase-memory-mcp tool call, the next augmentation adds one line saying so; +the next note comes after six searches, then twelve. Claude's `PostToolUse` +entry also matches the graph tools, and a graph tool call resets the count. Claude Code, Codex CLI, Qwen Code, GitHub Copilot CLI, and VS Code's Copilot runtime receive paired session/subagent context where the vendor exposes a diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index fbb7e12c3..c42ccebb6 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1663,27 +1663,39 @@ typedef struct { TSNode node; const char *prefix; } yaml_walk_frame_t; -#define YAML_WALK_STACK_CAP CBM_SZ_256 +/* On-stack first chunk of the key-path walk; wider mappings spill to the heap + * through cbm_walk_stack_reserve instead of dropping frames (#2388). */ +#define YAML_WALK_STACK_INLINE CBM_SZ_256 -/* Push block_mapping children of a block_node/block_mapping value onto the walk stack. */ -static void push_yaml_block_children(TSNode val, const char *path, yaml_walk_frame_t *stack, - int *top) { +/* Push block_mapping children of a block_node/block_mapping value onto the walk + * stack, growing it as needed. False when the stack could not grow (logged by + * cbm_walk_stack_reserve as extract.walk_stack_alloc_failed). */ +static bool push_yaml_block_children(TSNode val, const char *path, yaml_walk_frame_t **stack, + int *cap, int *top, const yaml_walk_frame_t *inline_buf) { uint32_t vnc = ts_node_named_child_count(val); - for (int vi = (int)vnc - SKIP_ONE; vi >= 0 && *top < YAML_WALK_STACK_CAP; vi--) { + if (!cbm_walk_stack_reserve((void **)stack, cap, *top + (int)vnc, sizeof(yaml_walk_frame_t), + inline_buf, "yaml_key_paths")) { + return false; + } + for (int vi = (int)vnc - SKIP_ONE; vi >= 0; vi--) { TSNode vc = ts_node_named_child(val, (uint32_t)vi); const char *vctype = ts_node_type(vc); if (strcmp(vctype, "block_mapping") == 0 || strcmp(vctype, "block_mapping_pair") == 0) { - stack[(*top)++] = (yaml_walk_frame_t){vc, path}; + (*stack)[(*top)++] = (yaml_walk_frame_t){vc, path}; } } + return true; } static void walk_yaml_mapping(CBMExtractCtx *ctx, TSNode root, const char *root_prefix) { - yaml_walk_frame_t stack[YAML_WALK_STACK_CAP]; + yaml_walk_frame_t inline_stack[YAML_WALK_STACK_INLINE]; + yaml_walk_frame_t *stack = inline_stack; + int cap = YAML_WALK_STACK_INLINE; int top = 0; stack[top++] = (yaml_walk_frame_t){root, root_prefix}; + bool grown = true; - while (top > 0) { + while (grown && top > 0) { yaml_walk_frame_t frame = stack[--top]; TSNode node = frame.node; const char *prefix = frame.prefix; @@ -1709,12 +1721,16 @@ static void walk_yaml_mapping(CBMExtractCtx *ctx, TSNode root, const char *root_ } const char *vk = ts_node_type(val); if (strcmp(vk, "block_node") == 0 || strcmp(vk, "block_mapping") == 0) { - push_yaml_block_children(val, path, stack, &top); + grown = push_yaml_block_children(val, path, &stack, &cap, &top, inline_stack); + if (!grown) { + break; + } continue; } emit_yaml_leaf_value(ctx, val, path); } } + cbm_walk_stack_release(stack, inline_stack); } /* ── Infrastructure binding extraction ───────────────────────────── @@ -1873,9 +1889,13 @@ static void scan_mapping_for_bindings(CBMExtractCtx *ctx, TSNode mapping) { emit_infra_bindings(ctx, sources, source_keys, n_sources, targets, n_targets); } -#define INFRA_SCAN_STACK_CAP CBM_SZ_512 +/* On-stack first chunk of the binding scan; wider subtrees spill to the heap + * through cbm_walk_stack_reserve instead of dropping nodes (#2388). */ +#define INFRA_SCAN_STACK_INLINE CBM_SZ_512 static void scan_yaml_for_infra_bindings(CBMExtractCtx *ctx, TSNode root) { - TSNode stack[INFRA_SCAN_STACK_CAP]; + TSNode inline_stack[INFRA_SCAN_STACK_INLINE]; + TSNode *stack = inline_stack; + int cap = INFRA_SCAN_STACK_INLINE; int top = 0; stack[top++] = root; while (top > 0) { @@ -1884,10 +1904,15 @@ static void scan_yaml_for_infra_bindings(CBMExtractCtx *ctx, TSNode root) { scan_mapping_for_bindings(ctx, node); } uint32_t nc = ts_node_named_child_count(node); - for (int i = (int)nc - SKIP_ONE; i >= 0 && top < INFRA_SCAN_STACK_CAP; i--) { + if (!cbm_walk_stack_reserve((void **)&stack, &cap, top + (int)nc, sizeof(TSNode), + inline_stack, "yaml_infra_bindings")) { + break; + } + for (int i = (int)nc - SKIP_ONE; i >= 0; i--) { stack[top++] = ts_node_named_child(node, (uint32_t)i); } } + cbm_walk_stack_release(stack, inline_stack); } /* ── HCL infrastructure binding extraction ─────────────────────────── diff --git a/internal/cbm/extract_url_fold.c b/internal/cbm/extract_url_fold.c index 63f8b4bb5..ee3b438f8 100644 --- a/internal/cbm/extract_url_fold.c +++ b/internal/cbm/extract_url_fold.c @@ -401,10 +401,22 @@ static bool is_interpolated_string(TSNode node) { return false; } +/* A Python string whose opening token is more than one quote -- a prefix + * (r"", b"", u"", an f"" with no field) or a triple quote -- cannot go down + * the literal path: that strips exactly one quote from each end, so the + * prefix and the extra quotes stayed in the URL and it never matched a route + * (#523). Folding reads the content pieces instead. JS/TS strings have no + * string_start, so this is Python only. */ +static bool is_wrapped_string(TSNode node) { + TSNode start = ts_node_child(node, 0); + return !ts_node_is_null(start) && strcmp(ts_node_type(start), "string_start") == 0 && + ts_node_end_byte(start) - ts_node_start_byte(start) > SKIP_ONE; +} + static bool is_foldable_arg(TSNode arg) { const char *k = ts_node_type(arg); if (strcmp(k, "string") == 0) { - return is_interpolated_string(arg); + return is_interpolated_string(arg) || is_wrapped_string(arg); } return strcmp(k, "template_string") == 0 || strcmp(k, "binary_expression") == 0 || strcmp(k, "binary_operator") == 0 || strcmp(k, "identifier") == 0 || diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index eaf1e759c..6eeabfa44 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -13,7 +13,7 @@ internal/cbm/lsp/perl_lsp.c 21 internal/cbm/macro_table.c 1 internal/cbm/service_patterns.c 2 internal/cbm/sqlite_writer.c 74 -src/cli/activation_transaction.c 80 +src/cli/activation_transaction.c 78 src/cli/agent_clients.c 73 src/cli/agent_profiles.c 7 src/cli/cli.c 166 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index c1a3f3ec7..2a894ca55 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1697,7 +1697,8 @@ echo "OK 8c-i: Claude exact-tool graph subagent" # 8d: Claude Code hooks keep search augmentation and read-coverage reporting # separate: PreToolUse matches exactly Grep|Glob|Bash, while PostToolUse matches -# exactly Read. Neither hook may grow a Search or catch-all matcher. +# exactly Read plus the graph tools, whose calls reset the drift note. Neither +# hook may grow a Search or catch-all matcher. if ! cat "$FAKE_HOME/.claude/settings.json" 2>/dev/null | python3 -c " import json, sys d = json.load(sys.stdin) @@ -1705,14 +1706,15 @@ all_hooks = d.get('hooks', {}) pre = all_hooks.get('PreToolUse', []) post = all_hooks.get('PostToolUse', []) ok = (any(h.get('matcher') == 'Grep|Glob|Bash' for h in pre) and - any(h.get('matcher') == 'Read' for h in post)) -bad = any('Search' in str(h.get('matcher', '')) for h in pre + post) + any(h.get('matcher') == 'Read|mcp__.*codebase-memory-mcp__.*' for h in post)) +bad = any('Search' in str(h.get('matcher', '')) or h.get('matcher', '') in ('', '*') + for h in pre + post) sys.exit(0 if (ok and not bad) else 1) " 2>/dev/null; then echo "FAIL 8d: Claude search/read hook matchers are not exact" exit 1 fi -echo "OK 8d: Claude Code PreToolUse Grep|Glob|Bash + PostToolUse Read" +echo "OK 8d: Claude Code PreToolUse Grep|Glob|Bash + PostToolUse Read and graph tools" # 8e: Claude Code shim script — must be non-blocking augmenter, not a gate. # #929: Windows installs a .cmd script (extensionless bash shims triggered the diff --git a/src/cli/activation_transaction.c b/src/cli/activation_transaction.c index 18ed6dd14..f8af41530 100644 --- a/src/cli/activation_transaction.c +++ b/src/cli/activation_transaction.c @@ -899,6 +899,61 @@ static bool activation_posix_acl_empty(int descriptor) { return cbm_macos_extended_acl_fd_is_empty(descriptor); } +#ifdef __linux__ +/* Root of the per-user home tree and the account trusted to own it. Root and + * "/home" in production; the test seam substitutes a fixture tree and the test + * account, because the real layout needs root-owned entries to reproduce. */ +static const char *g_activation_home_root = "/home"; +static uid_t g_activation_home_owner = 0; + +#ifdef CBM_ENABLE_TEST_SEAMS +void cbm_activation_transaction_set_home_root_for_testing(const char *home_root, + unsigned long owner_uid) { + g_activation_home_root = home_root ? home_root : "/home"; + g_activation_home_owner = home_root ? (uid_t)owner_uid : 0; +} +#endif +#endif + +#if defined(__APPLE__) || defined(__linux__) +/* Map a trusted symlink `link` (already lstat'd) onto its resolved target and + * append `rest`. The link and the target must both belong to `owner`; with + * accept_own_target the target may instead belong to the current account. + * Returns NULL when either check fails or the mapped path does not fit. */ +static char *activation_alias_map(const char *link, const struct stat *link_status, uid_t owner, + bool accept_own_target, const char *rest) { + char resolved[4096]; + struct stat resolved_status; + if (link_status->st_uid != owner || !realpath(link, resolved) || + lstat(resolved, &resolved_status) != 0 || !S_ISDIR(resolved_status.st_mode)) { + return NULL; + } + if (resolved_status.st_uid != owner && + !(accept_own_target && resolved_status.st_uid == geteuid())) { + return NULL; + } + char mapped[4096]; + int written = snprintf(mapped, sizeof(mapped), "%s%s", resolved, rest); + if (written <= 0 || (size_t)written >= sizeof(mapped)) { + return NULL; + } + return activation_string_copy(mapped); +} + +#ifdef CBM_ENABLE_TEST_SEAMS +char *cbm_activation_transaction_map_alias_for_testing(const char *link, + unsigned long link_owner_uid, + unsigned long trusted_owner_uid, + bool accept_own_target, const char *rest) { + /* Only link ownership is synthetic; the shared production helper resolves + * and checks the real target so distinct-owner cases need no chown/root. */ + const struct stat link_status = {.st_uid = (uid_t)link_owner_uid}; + return activation_alias_map(link, &link_status, (uid_t)trusted_owner_uid, accept_own_target, + rest); +} +#endif +#endif + static char *activation_posix_walk_path(const char *directory) { #if defined(__APPLE__) || defined(__linux__) /* macOS and immutable Linux layouts can expose writable trees through @@ -906,7 +961,7 @@ static char *activation_posix_walk_path(const char *directory) { * Resolve only these trusted system aliases; arbitrary user symlinks must * still fail the O_NOFOLLOW walk below. */ #ifdef __linux__ - static const char *const aliases[] = {"/tmp", "/var", "/home"}; + const char *const aliases[] = {"/tmp", "/var", g_activation_home_root}; #else static const char *const aliases[] = {"/tmp", "/var"}; #endif @@ -917,33 +972,48 @@ static char *activation_posix_walk_path(const char *directory) { (directory[alias_length] != '\0' && directory[alias_length] != '/')) { continue; } + uid_t owner = 0; +#ifdef __linux__ + bool is_home = alias == g_activation_home_root; + if (is_home) { + owner = g_activation_home_owner; + } +#endif struct stat alias_status; - char resolved[4096]; if (lstat(alias, &alias_status) != 0) { continue; } - if (!S_ISLNK(alias_status.st_mode)) { + if (S_ISLNK(alias_status.st_mode)) { + return activation_alias_map(alias, &alias_status, owner, false, + directory + alias_length); + } +#ifdef __linux__ + /* Managed Linux hosts often keep /home a real directory and point the + * per-user entry elsewhere (/home/alice -> /local/home/alice, #2306). + * Trust that entry only inside a root-owned /home nobody else can + * write, and only when root owns the entry itself. Unlike the whole- + * alias case above, the target may belong to the current account: + * that is the normal shape of a relocated home, and an account can + * already write its own home, so it gains nothing it did not have. */ + const char *name = directory + alias_length; + size_t name_length = *name == '/' ? strcspn(name + 1, "/") : 0; + if (!is_home || !S_ISDIR(alias_status.st_mode) || alias_status.st_uid != owner || + (alias_status.st_mode & 0022) != 0 || name_length == 0) { continue; } - if (alias_status.st_uid != 0 || !realpath(alias, resolved)) { + char entry[4096]; + size_t entry_length = alias_length + 1U + name_length; + if (entry_length >= sizeof(entry)) { return NULL; } - struct stat resolved_status; - if (lstat(resolved, &resolved_status) != 0 || !S_ISDIR(resolved_status.st_mode) || - resolved_status.st_uid != 0) { - return NULL; + memcpy(entry, directory, entry_length); + entry[entry_length] = '\0'; + struct stat entry_status; + if (lstat(entry, &entry_status) == 0 && S_ISLNK(entry_status.st_mode)) { + return activation_alias_map(entry, &entry_status, owner, true, + directory + entry_length); } - size_t needed = strlen(resolved) + strlen(directory + alias_length) + 1U; - char *mapped = malloc(needed); - if (!mapped) { - return NULL; - } - int written = snprintf(mapped, needed, "%s%s", resolved, directory + alias_length); - if (written <= 0 || (size_t)written >= needed) { - free(mapped); - return NULL; - } - return mapped; +#endif } #endif return activation_string_copy(directory); diff --git a/src/cli/activation_transaction.h b/src/cli/activation_transaction.h index 3aa37e1e4..d52ba76de 100644 --- a/src/cli/activation_transaction.h +++ b/src/cli/activation_transaction.h @@ -104,6 +104,21 @@ const char *cbm_activation_transaction_refusal_note(void); #ifdef CBM_ENABLE_TEST_SEAMS void cbm_activation_transaction_note_refusal_for_testing(const char *predicate, unsigned long os_error); +/* Linux only: stand home_root in for /home and owner_uid in for root when + * deciding whether /home or a /home/ entry is a trusted alias. NULL + * restores the production values. Inert elsewhere. */ +void cbm_activation_transaction_set_home_root_for_testing(const char *home_root, + unsigned long owner_uid); + +#if defined(__APPLE__) || defined(__linux__) +/* Exercise the shared alias checks with synthetic link ownership and a real + * target. The caller frees the mapped path; NULL means the alias was refused. */ +char *cbm_activation_transaction_map_alias_for_testing(const char *link, + unsigned long link_owner_uid, + unsigned long trusted_owner_uid, + bool accept_own_target, const char *rest); +#endif + void cbm_activation_transaction_write_failures_set_for_testing(unsigned int count, int os_error); #endif diff --git a/src/cli/cli.c b/src/cli/cli.c index ba4ae520a..95beda3f4 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -4661,7 +4661,10 @@ static int cbm_remove_grok_mcp_owned(const char *binary_path, const char *config /* Search augmentation runs before Grep/Glob/Bash; exact coverage context runs after * Read. Both adapters are context-only and fail open. */ #define CMM_HOOK_SEARCH_MATCHER "Grep|Glob|Bash" -#define CMM_HOOK_READ_MATCHER "Read" +/* The PostToolUse entry also matches the graph tools (mcp____, + * or mcp__plugin____), whose calls reset the drift + * reminder's search count (#509). */ +#define CMM_HOOK_READ_MATCHER "Read|mcp__.*codebase-memory-mcp__.*" /* Basename only; the full command path is resolved at install time via * cbm_resolve_hook_command so $CLAUDE_CONFIG_DIR is honored. */ #ifdef _WIN32 @@ -4682,6 +4685,11 @@ static const char *const cmm_claude_exec_args[] = {"hook-augment"}; /* Old matcher values from previous versions — recognized during upgrade so * upsert/remove can clean them up before inserting the current matcher. * Per-agent lists (no shared global): each caller passes its own. */ +/* Installs up to v0.12 matched only Read in PostToolUse. */ +static const char *const cmm_claude_old_read_matchers[] = { + "Read", + NULL, +}; static const char *const cmm_claude_old_matchers[] = { "Grep|Glob|Read|Search", "Grep|Glob|Read", @@ -5459,7 +5467,7 @@ static int cbm_upsert_claude_hooks_with_binary(const char *settings_path, const if (cbm_remove_previous_claude_exec_hook(settings_path, "PreToolUse", CMM_HOOK_SEARCH_MATCHER, cmm_claude_old_matchers, binary_path) != CLI_OK || cbm_remove_previous_claude_exec_hook(settings_path, "PostToolUse", CMM_HOOK_READ_MATCHER, - NULL, binary_path) != CLI_OK) { + cmm_claude_old_read_matchers, binary_path) != CLI_OK) { return CLI_ERR; } int search_result = upsert_hooks_json((hooks_upsert_args_t){ @@ -5478,6 +5486,7 @@ static int cbm_upsert_claude_hooks_with_binary(const char *settings_path, const .settings_path = settings_path, .hook_event = "PostToolUse", .matcher_str = CMM_HOOK_READ_MATCHER, + .old_matchers = cmm_claude_old_read_matchers, .command_str = command, .command_args = command_args, .command_arg_count = command_arg_count, @@ -5532,6 +5541,7 @@ static int cbm_remove_claude_hooks_with_binary(const char *settings_path, const .settings_path = settings_path, .hook_event = "PostToolUse", .matcher_str = CMM_HOOK_READ_MATCHER, + .old_matchers = cmm_claude_old_read_matchers, .old_commands = old_commands, .match_command_exact = command, .match_args = command_args, diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index 83a404276..cfd2719c7 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -148,38 +148,36 @@ static void append_typebox_type(adapter_sb_t *sb, yyjson_val *val, const char *d if (type_str && strcmp(type_str, "string") == 0 && enum_val && yyjson_is_arr(enum_val) && yyjson_arr_size(enum_val) > 0) { - sb_append(sb, "Type.Union(["); size_t enum_idx = 0; size_t enum_max = yyjson_arr_size(enum_val); - bool first_enum = true; + size_t valid_count = 0; for (enum_idx = 0; enum_idx < enum_max; enum_idx++) { - yyjson_val *enum_item = yyjson_arr_get(enum_val, enum_idx); - const char *enum_str = yyjson_get_str(enum_item); - if (!enum_str) { - continue; + if (yyjson_get_str(yyjson_arr_get(enum_val, enum_idx))) { + valid_count++; } - if (!first_enum) { - sb_append(sb, ", "); - } - first_enum = false; - sb_append(sb, "Type.Literal("); - sb_append_js_string(sb, enum_str); - sb_append(sb, ")"); } - if (first_enum) { - sb_append(sb, "Type.String()"); - } else { + if (valid_count > 0) { + sb_append(sb, "Type.String({ enum: ["); + bool first_enum = true; + for (enum_idx = 0; enum_idx < enum_max; enum_idx++) { + const char *enum_str = yyjson_get_str(yyjson_arr_get(enum_val, enum_idx)); + if (!enum_str) { + continue; + } + if (!first_enum) { + sb_append(sb, ", "); + } + first_enum = false; + sb_append_js_string(sb, enum_str); + } sb_append(sb, "]"); if (include_description && description) { - sb_append(sb, ", { description: "); + sb_append(sb, ", description: "); sb_append_js_string(sb, description); - sb_append(sb, " }"); } - sb_append(sb, ")"); + sb_append(sb, " })"); return; } - sb_append(sb, ")"); - return; } const char *typebox_name = "Any"; diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 822846d4a..c161f1dbf 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -26,6 +26,8 @@ #include "yyjson/yyjson.h" #include +#include +#include #include #include #include @@ -1414,6 +1416,129 @@ static void ha_tool_call_free(ha_tool_call_t *call) { call->input = NULL; } +/* Drift reminder (#509). A Claude Code session that keeps searching with Grep, + * Glob or Bash and makes no codebase-memory-mcp tool call gets one factual line + * on its next augmentation once HA_DRIFT_FIRST searches have run; the threshold + * then doubles (3, 6, 12, ...), and a graph tool call resets the session. The + * managed PostToolUse hook matches the graph tools for that reset. The state + * lives in the process that runs hook requests, which is the daemon, keyed by + * the payload's session_id in a bounded table that evicts the least recently + * used session. Codex sends the same event payload, with a turn_id, but has no + * reset hook, so its sessions are not tracked. */ +enum { + HA_DRIFT_SESSIONS = 64, + HA_DRIFT_FIRST = 3, + HA_DRIFT_SESSION_MAX = 128, +}; + +typedef struct { + char session[HA_DRIFT_SESSION_MAX + 1]; + unsigned searches; + unsigned threshold; + uint64_t used; +} ha_drift_slot_t; + +static ha_drift_slot_t g_ha_drift[HA_DRIFT_SESSIONS]; +static uint64_t g_ha_drift_clock; +static atomic_flag g_ha_drift_spin = ATOMIC_FLAG_INIT; + +static void ha_drift_lock(void) { + while (atomic_flag_test_and_set_explicit(&g_ha_drift_spin, memory_order_acquire)) {} +} + +static void ha_drift_unlock(void) { + atomic_flag_clear_explicit(&g_ha_drift_spin, memory_order_release); +} + +static const char *ha_drift_session(yyjson_val *root, ha_lifecycle_dialect_t dialect) { + if (dialect != HA_DIALECT_EVENT || !root || yyjson_obj_get(root, "turn_id")) { + return NULL; + } + const char *session = ha_obj_str(root, "session_id"); + size_t length = session ? strlen(session) : 0U; + if (length == 0U || length > HA_DRIFT_SESSION_MAX) { + return NULL; + } + for (size_t i = 0U; i < length; i++) { + unsigned char ch = (unsigned char)session[i]; + if (!isalnum(ch) && ch != '-' && ch != '_' && ch != '.') { + return NULL; + } + } + return session; +} + +/* Claude names MCP tools mcp____, and a plugin's servers + * mcp__plugin____. */ +static bool ha_is_graph_tool(const char *tool) { + return tool && strncmp(tool, "mcp__", 5) == 0 && strstr(tool, "codebase-memory-mcp__"); +} + +/* The caller holds the lock. An unknown session takes an empty slot, or else + * the least recently used one. */ +static ha_drift_slot_t *ha_drift_find(const char *session, bool create) { + ha_drift_slot_t *victim = NULL; + for (size_t i = 0U; i < HA_DRIFT_SESSIONS; i++) { + ha_drift_slot_t *slot = &g_ha_drift[i]; + if (slot->session[0] && strcmp(slot->session, session) == 0) { + slot->used = ++g_ha_drift_clock; + return slot; + } + if (!victim || (victim->session[0] && (!slot->session[0] || slot->used < victim->used))) { + victim = slot; + } + } + if (!create) { + return NULL; + } + snprintf(victim->session, sizeof(victim->session), "%s", session); + victim->searches = 0U; + victim->threshold = HA_DRIFT_FIRST; + victim->used = ++g_ha_drift_clock; + return victim; +} + +static void ha_drift_reset(const char *session) { + ha_drift_lock(); + ha_drift_slot_t *slot = ha_drift_find(session, false); + if (slot) { + memset(slot, 0, sizeof(*slot)); + } + ha_drift_unlock(); +} + +/* Counts one search. The reminder is due when the count has reached the + * threshold and this search has an augmentation to carry it; delivering it + * raises the threshold past the count by doubling. */ +static bool ha_drift_note_search(const char *session, bool augmented, unsigned *searches) { + ha_drift_lock(); + ha_drift_slot_t *slot = ha_drift_find(session, true); + if (slot->searches < UINT_MAX) { + slot->searches++; + } + bool due = augmented && slot->searches >= slot->threshold; + while (due && slot->threshold <= slot->searches && slot->threshold <= UINT_MAX / 2U) { + slot->threshold *= 2U; + } + *searches = slot->searches; + ha_drift_unlock(); + return due; +} + +/* Writes the context followed by the drift line into out. Returns false when + * they do not fit, and the caller then sends the context alone. The context is + * at most 4096 bytes plus the follow-up line, so HA_DRIFT_CONTEXT_CAP fits it. */ +enum { HA_DRIFT_CONTEXT_CAP = 5120 }; + +static bool ha_drift_context(char *out, size_t out_size, const char *context, unsigned searches) { + int written = snprintf(out, out_size, + "%s\n[codebase-memory] This session has run %u searches since its last " + "codebase-memory-mcp tool call. Questions about callers, callees, call " + "paths and definitions are one graph call each.", + context, searches); + return written > 0 && (size_t)written < out_size; +} + static bool ha_tool_event_supported(ha_lifecycle_dialect_t dialect, const char *event, const char *tool, bool *coverage) { if (coverage) { @@ -1752,6 +1877,13 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const cha ha_tool_call_read(root, forced_event, dialect, &call); const char *event = call.event; const char *tool = call.tool; + const char *session = ha_drift_session(root, dialect); + if (session && event && strcmp(event, "PostToolUse") == 0 && ha_is_graph_tool(tool)) { + ha_drift_reset(session); + ha_tool_call_free(&call); + yyjson_doc_free(doc); + return NULL; + } bool coverage = false; if (!ha_tool_event_supported(dialect, event, tool, &coverage)) { ha_tool_call_free(&call); @@ -1809,10 +1941,17 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const cha } char *ctx = ha_resolve_and_query(srv, cwd, token); + unsigned searches = 0U; + char drift_context[HA_DRIFT_CONTEXT_CAP]; + const char *text = ctx; + if (session && ha_drift_note_search(session, ctx != NULL, &searches) && + ha_drift_context(drift_context, sizeof(drift_context), ctx, searches)) { + text = drift_context; + } char *output = NULL; - if (ctx) { - output = dialect == HA_DIALECT_COPILOT ? ha_build_copilot_json(ctx) - : ha_build_event_json(event, ctx); + if (text) { + output = dialect == HA_DIALECT_COPILOT ? ha_build_copilot_json(text) + : ha_build_event_json(event, text); } free(ctx); yyjson_doc_free(doc); diff --git a/src/discover/discover.c b/src/discover/discover.c index 92f02221a..150cddf9c 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -75,13 +75,23 @@ static const char *ALWAYS_SKIP_DIR_SUFFIXES[] = { "storage/framework/views", NULL}; static const char *FAST_SKIP_DIRS[] = { - "generated", "gen", "auto-generated", "fixtures", "testdata", "test_data", - "__tests__", "__mocks__", "__snapshots__", "__fixtures__", "__test__", "docs", - "doc", "documentation", "examples", "example", "samples", "sample", - "assets", "static", "public", "media", "third_party", "thirdparty", - "3rdparty", "external", "migrations", "seeds", "e2e", "integration", - "locale", "locales", "i18n", "l10n", "scripts", "tools", - "hack", "bin", "build", "out", NULL}; + /* Generated */ + "generated", "gen", "auto-generated", + /* Tests */ + "fixtures", "testdata", "test_data", "__tests__", "__mocks__", "__snapshots__", "__fixtures__", + "__test__", + /* Docs and samples */ + "docs", "doc", "documentation", "examples", "example", "samples", "sample", + /* Assets */ + "assets", "static", "public", "media", + /* Third-party */ + "third_party", "thirdparty", "3rdparty", + /* Data and integration tests */ + "migrations", "seeds", "e2e", "integration", + /* Localization */ + "locale", "locales", "i18n", "l10n", + /* Tooling and build output */ + "scripts", "tools", "hack", "bin", "build", "out", NULL}; /* ── Ignored suffixes ───────────────────────────────── */ diff --git a/src/foundation/mem_core.c b/src/foundation/mem_core.c index cc6e8f9d5..8dd30bef9 100644 --- a/src/foundation/mem_core.c +++ b/src/foundation/mem_core.c @@ -66,8 +66,9 @@ typedef struct { static mem_class_stats_t g_classes[CBM_MEM_CLASS_COUNT]; static const char *const g_class_names[CBM_MEM_CLASS_COUNT] = { - "other", "gbuf_node", "gbuf_edge", "gbuf_string", "gbuf_index", "extract", "arena", - "ts_tree", "semantic", "dump", "store", "hash_table", "dyn_array", "graph_cache", + "other", "gbuf_node", "gbuf_edge", "gbuf_string", "gbuf_index", + "extract", "arena", "ts_tree", "semantic", "dump", + "store", "hash_table", "dyn_array", "resolve", "graph_cache", }; const char *cbm_mem_class_name(cbm_mem_class_t cls) { diff --git a/src/foundation/mem_core.h b/src/foundation/mem_core.h index e61de900a..5de1ba7bd 100644 --- a/src/foundation/mem_core.h +++ b/src/foundation/mem_core.h @@ -66,6 +66,7 @@ typedef enum { CBM_MEM_CLASS_STORE, /* SQLite (bound mem methods) + store batches and row buffers */ CBM_MEM_CLASS_HASH_TABLE, /* CBMHashTable buckets/entries not claimed by an owner class */ CBM_MEM_CLASS_DYN_ARRAY, /* CBM_DYN_ARRAY item storage (every cbm_da_* user) */ + CBM_MEM_CLASS_RESOLVE, /* call resolution evidence and coverage capture */ /* The daemon's in-memory adjacency layers (store/graph_mem.h): resident * across requests, so they are subtracted from index-worker budgets and * must read apart from SQLite's request-scoped memory (STORE). */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index aca6c0aae..97ce06234 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -74,6 +74,7 @@ enum { #include "watcher/watcher.h" #include "foundation/mem.h" #include "foundation/mem_core.h" +#include "foundation/hash_table.h" #include "foundation/json_writer.h" #include "foundation/arena.h" #include "foundation/diagnostics.h" @@ -4012,6 +4013,7 @@ enum { BM25_BIND_EXACT = 8, BM25_BIND_KIND = 9, BM25_BIND_NS = 10, + BM25_BIND_LABEL_WEIGHT = 11, BM25_SQL_AUTO_LEN = -1, /* Inner FTS5 candidate cap. SQLite can early-terminate a plain FTS5 query * (no JOIN/WHERE on outer table) of the form: @@ -4020,6 +4022,11 @@ enum { * and then joining/filtering/re-ranking those, we bound all work to O(N) where * N = BM25_INNER_LIMIT rather than the full match set size. */ BM25_INNER_LIMIT = 2000, + /* Longest label spliced into the MATCH pre-filter; real labels are short + * identifiers, so anything longer simply skips the pre-filter. */ + BM25_LABEL_MAX = 64, + /* Room for "() AND label : \"