mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-08 08:53:43 +08:00
feat(cli): extend the OpenCode plugin to every documented context surface
The generated OpenCode adapter covered only the grep/glob graph lookup, so OpenCode sessions missed the read-coverage and lifecycle context other clients get through their hook configuration. Extend the plugin to the surfaces OpenCode documents: - unwrap hook-augment's additionalContext envelope so plain text, not raw JSON, lands in the tool output - post-read index-coverage notes via tool.execute.after, mapping OpenCode's filePath argument to the file_path key the default dialect reads - session-start tier routing injected once per sessionID on the first tool result, since OpenCode documents no context-output lifecycle hook - post-compaction reinjection through the documented experimental session.compacting surface (output.context) Every failure path resolves to the empty string: the adapter can degrade to a no-op but never blocks a tool call. Pin the new surfaces in test_agent_clients (31/31 pass; the #616 payload guard stays green) and record the wider surface in the README client matrix. Signed-off-by: jc <jcvi007@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -448,7 +448,7 @@ overwrite user-modified agents.
|
||||
| Codex CLI | Detected | `$CODEX_HOME/config.toml` | `AGENTS.md`, skill, three read-only agents; `SessionStart` + `SubagentStart` |
|
||||
| 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 |
|
||||
| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents |
|
||||
| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection |
|
||||
| Antigravity | Detected | `.gemini/config/mcp_config.json` | `.gemini/GEMINI.md` |
|
||||
| Aider | Detected | — | `CONVENTIONS.md` via `.aider.conf.yml` |
|
||||
| KiloCode | Detected | `.config/kilo/kilo.jsonc` | Rule + three graph-tool subagents with deny-by-default permissions |
|
||||
|
||||
+81
-33
@@ -188,46 +188,94 @@ char *cbm_client_adapter_opencode(const char *binary_path) {
|
||||
|
||||
adapter_sb_t sb = {0};
|
||||
emit_header(&sb, "OpenCode");
|
||||
sb_append(&sb, "// OpenCode already reaches every tool over MCP; this adds only the\n"
|
||||
"// automatic graph lookup before a grep/glob, which other clients get\n"
|
||||
"// through their own hook configuration.\n");
|
||||
sb_append(&sb,
|
||||
"// OpenCode already reaches every tool over MCP; this module adds the\n"
|
||||
"// context surfaces other clients get through hook configuration: graph\n"
|
||||
"// lookup after grep/glob, index-coverage notes after read, session-start\n"
|
||||
"// tier routing (carried on the first tool result of each session, since\n"
|
||||
"// OpenCode documents no context-output lifecycle hook), and reinjection\n"
|
||||
"// after compaction via the documented experimental surface.\n");
|
||||
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
|
||||
sb_append(&sb, "const BIN = '");
|
||||
sb_append(&sb, bin);
|
||||
sb_append(&sb, "';\n\n");
|
||||
|
||||
/* hook-augment requires hook_event_name and accepts Grep/Glob only under
|
||||
* PreToolUse; omitting it makes the whole hook a silent no-op. */
|
||||
sb_append(&sb, "function augment(tool, args) {\n"
|
||||
" return new Promise((resolve) => {\n"
|
||||
" const child = spawn(BIN, ['hook-augment'], {\n"
|
||||
" stdio: ['pipe', 'pipe', 'ignore'],\n"
|
||||
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
|
||||
" });\n"
|
||||
" let out = '';\n"
|
||||
" child.stdout.on('data', (d) => (out += d.toString()));\n"
|
||||
" child.on('error', () => resolve(''));\n"
|
||||
" child.on('close', () => resolve(out));\n"
|
||||
" child.stdin.end(JSON.stringify({\n"
|
||||
" hook_event_name: 'PreToolUse',\n"
|
||||
" tool_name: tool,\n"
|
||||
" tool_input: args ?? {},\n"
|
||||
" }));\n"
|
||||
" });\n"
|
||||
"}\n\n");
|
||||
/* hook-augment requires hook_event_name; its default dialect accepts
|
||||
* Grep/Glob under PreToolUse, Read under PostToolUse, and the
|
||||
* SessionStart lifecycle event — and emits the Claude JSON envelope, so
|
||||
* the plugin unwraps additionalContext instead of pasting raw JSON into
|
||||
* the tool output. Every failure path resolves to '' (fail open). */
|
||||
sb_append(&sb,
|
||||
"function augment(payload) {\n"
|
||||
" return new Promise((resolve) => {\n"
|
||||
" const child = spawn(BIN, ['hook-augment'], {\n"
|
||||
" stdio: ['pipe', 'pipe', 'ignore'],\n"
|
||||
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
|
||||
" });\n"
|
||||
" let out = '';\n"
|
||||
" child.stdout.on('data', (d) => (out += d.toString()));\n"
|
||||
" child.on('error', () => resolve(''));\n"
|
||||
" child.on('close', () => {\n"
|
||||
" try {\n"
|
||||
" const ctx = JSON.parse(out)?.hookSpecificOutput?.additionalContext;\n"
|
||||
" resolve(typeof ctx === 'string' ? ctx : '');\n"
|
||||
" } catch { resolve(''); }\n"
|
||||
" });\n"
|
||||
" child.stdin.end(JSON.stringify(payload));\n"
|
||||
" });\n"
|
||||
"}\n\n");
|
||||
|
||||
sb_append(&sb,
|
||||
"export const CodebaseMemory = async () => ({\n"
|
||||
" 'tool.execute.after': async (input, output) => {\n"
|
||||
" const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' "
|
||||
": null;\n"
|
||||
" if (!tool) return;\n"
|
||||
" const extra = await augment(tool, output?.args);\n"
|
||||
" if (extra && typeof output?.output === 'string') {\n"
|
||||
" output.output += '\\n' + extra;\n"
|
||||
" }\n"
|
||||
" },\n"
|
||||
"});\n");
|
||||
"export const CodebaseMemory = async (ctx) => {\n"
|
||||
" const dir = ctx?.directory;\n"
|
||||
" const seen = new Set();\n"
|
||||
" const lifecycle = () =>\n"
|
||||
" augment({ hook_event_name: 'SessionStart', cwd: dir });\n"
|
||||
" return {\n"
|
||||
" 'tool.execute.after': async (input, output) => {\n"
|
||||
" if (typeof output?.output !== 'string') return;\n"
|
||||
" const pieces = [];\n"
|
||||
" const sid = input?.sessionID;\n"
|
||||
" if (typeof sid === 'string' && !seen.has(sid)) {\n"
|
||||
" seen.add(sid);\n"
|
||||
" pieces.push(await lifecycle());\n"
|
||||
" }\n"
|
||||
" const args = output?.args ?? {};\n"
|
||||
" const search =\n"
|
||||
" input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n"
|
||||
" if (search) {\n"
|
||||
" pieces.push(await augment({\n"
|
||||
" hook_event_name: 'PreToolUse',\n"
|
||||
" tool_name: search,\n"
|
||||
" tool_input: args,\n"
|
||||
" cwd: dir,\n"
|
||||
" }));\n"
|
||||
" } else if (input?.tool === 'read') {\n"
|
||||
" const filePath = args.filePath ?? args.file_path ?? args.path;\n"
|
||||
" if (typeof filePath === 'string' && filePath) {\n"
|
||||
" pieces.push(await augment({\n"
|
||||
" hook_event_name: 'PostToolUse',\n"
|
||||
" tool_name: 'Read',\n"
|
||||
" tool_input: { file_path: filePath },\n"
|
||||
" cwd: dir,\n"
|
||||
" }));\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" const extra = pieces.filter(Boolean).join('\\n');\n"
|
||||
" if (extra) {\n"
|
||||
" output.output += '\\n' + extra;\n"
|
||||
" }\n"
|
||||
" },\n"
|
||||
" // Documented (experimental) compaction surface: output.context is the\n"
|
||||
" // mutable array of context strings for the rebuilt session.\n"
|
||||
" 'experimental.session.compacting': async (_input, output) => {\n"
|
||||
" const note = await lifecycle();\n"
|
||||
" if (note && Array.isArray(output?.context)) {\n"
|
||||
" output.context.push(note);\n"
|
||||
" }\n"
|
||||
" },\n"
|
||||
" };\n"
|
||||
"};\n");
|
||||
|
||||
if (sb.failed) {
|
||||
free(sb.buf);
|
||||
|
||||
@@ -1148,6 +1148,28 @@ TEST(client_adapter_opencode_sends_the_required_hook_event) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* The richer OpenCode adapter carries every context surface the plugin API
|
||||
* documents: session-start tier routing on the first tool result of each
|
||||
* session, post-read coverage notes, and post-compaction reinjection through
|
||||
* the documented experimental surface. It must unwrap hook-augment's Claude
|
||||
* JSON envelope so plain text — not raw JSON — reaches the model. */
|
||||
TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) {
|
||||
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
|
||||
ASSERT_NOT_NULL(js);
|
||||
ASSERT_NOT_NULL(strstr(js, "hook_event_name: 'SessionStart'"));
|
||||
ASSERT_NOT_NULL(strstr(js, "hook_event_name: 'PostToolUse'"));
|
||||
ASSERT_NOT_NULL(strstr(js, "tool_name: 'Read'"));
|
||||
ASSERT_NOT_NULL(strstr(js, "'experimental.session.compacting'"));
|
||||
ASSERT_NOT_NULL(strstr(js, "additionalContext"));
|
||||
/* file_path is the key hook-augment's default dialect reads; OpenCode's
|
||||
* read tool argues filePath, so the adapter must map it. */
|
||||
ASSERT_NOT_NULL(strstr(js, "file_path: filePath"));
|
||||
/* Session context may only be injected once per session id. */
|
||||
ASSERT_NOT_NULL(strstr(js, "seen.has(sid)"));
|
||||
free(js);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Empty/NULL inputs must not produce a module at all. */
|
||||
TEST(client_adapter_rejects_missing_binary_path) {
|
||||
ASSERT_NULL(cbm_client_adapter_pi(NULL));
|
||||
@@ -1187,5 +1209,6 @@ SUITE(agent_clients) {
|
||||
RUN_TEST(client_adapter_pi_registers_every_registry_tool);
|
||||
RUN_TEST(client_adapter_escapes_windows_paths_and_quotes);
|
||||
RUN_TEST(client_adapter_opencode_sends_the_required_hook_event);
|
||||
RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction);
|
||||
RUN_TEST(client_adapter_rejects_missing_binary_path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user