From b0232a9113a56fa19e3d03809763b23ad982566d Mon Sep 17 00:00:00 2001 From: Frank W <2604571+frankyw@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:54:48 -0400 Subject: [PATCH 1/2] fix(opencode): canonicalize project scope --- plugin/opencode/agentmemory-capture.ts | 84 +++++++++++++---- test/opencode-project-scope.test.ts | 119 +++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 test/opencode-project-scope.test.ts diff --git a/plugin/opencode/agentmemory-capture.ts b/plugin/opencode/agentmemory-capture.ts index 056d53d38..fd1636299 100644 --- a/plugin/opencode/agentmemory-capture.ts +++ b/plugin/opencode/agentmemory-capture.ts @@ -1,7 +1,9 @@ import type { Plugin } from "@opencode-ai/plugin"; +import { execFileSync } from "node:child_process"; +import { basename } from "node:path"; const API = process.env.AGENTMEMORY_URL || "http://localhost:3111"; -const FILE_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]); +const FILE_TOOLS = new Set(["read", "write", "edit", "apply_patch", "glob", "grep"]); const FILE_KEYS = ["filePath", "file_path", "path", "file", "pattern"]; const MAX_STASHED_FILES = 20; @@ -47,19 +49,26 @@ async function observe( hookType: string, data: Record, ): Promise { + const scope = scopeFor(sessionId); await post("/observe", { hookType, sessionId, - project: projectPath, - cwd: projectPath, + project: scope.project, + cwd: scope.cwd, timestamp: new Date().toISOString(), data, }); } +interface ProjectScope { + project: string; + cwd: string; +} + let activeSessionId: string | null = null; let pendingConfig: Record | null = null; -let projectPath: string | null = null; +let defaultScope: ProjectScope = resolveScope(process.cwd()); +const sessionScopes = new Map(); const stashedFiles = new Map>(); const seenSubtaskIds = new Map>(); const seenToolCallIds = new Map>(); @@ -90,17 +99,55 @@ function toolCallSetFor(sid: string): Set { } function pruneSessionMaps(sid: string): void { + sessionScopes.delete(sid); stashedFiles.delete(sid); seenSubtaskIds.delete(sid); seenToolCallIds.delete(sid); } +function resolveScope(cwd: string): ProjectScope { + const normalizedCwd = cwd.trim() || process.cwd(); + const explicitProject = process.env.AGENTMEMORY_PROJECT?.trim(); + if (explicitProject) return { project: explicitProject, cwd: normalizedCwd }; + + try { + const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: normalizedCwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 500, + }).trim(); + if (root) return { project: basename(root), cwd: normalizedCwd }; + } catch {} + + return { project: basename(normalizedCwd) || normalizedCwd, cwd: normalizedCwd }; +} + +function scopeFor(sessionId: string): ProjectScope { + return sessionScopes.get(sessionId) || defaultScope; +} + +function updateSessionScope(sessionId: string, cwd: unknown): void { + if (typeof cwd !== "string" || cwd.trim().length === 0) return; + sessionScopes.set(sessionId, resolveScope(cwd)); +} + function safeSlice(v: unknown, max: number): string { if (typeof v === "string") return v.slice(0, max); if (v == null) return ""; try { return JSON.stringify(v).slice(0, max); } catch { return ""; } } +function safeToolInput(v: unknown): unknown { + if (!v || typeof v !== "object" || Array.isArray(v)) return safeSlice(v, 4000); + const input = v as Record; + const compact: Record = { summary: safeSlice(v, 3000) }; + for (const key of FILE_KEYS) { + if (typeof input[key] === "string") compact[key] = input[key]; + } + return compact; +} + const AGENTMEMORY_INSTRUCTIONS = ` You have access to agentmemory for persistent cross-session memory. Use these tools proactively. @@ -168,7 +215,8 @@ function extractErrorMessage(err: unknown): string { } export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { - projectPath = ctx.worktree || ctx.project?.id || process.cwd(); + const directory = (ctx as { directory?: string }).directory || ctx.worktree || process.cwd(); + defaultScope = resolveScope(directory); return { event: async ({ event }) => { @@ -180,6 +228,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { const info = props.info as Record | undefined; activeSessionId = (info?.id as string) || props.sessionID || null; if (!activeSessionId) return; + updateSessionScope(activeSessionId, info?.directory || defaultScope.cwd); stashedFiles.set(activeSessionId, new Set()); seenSubtaskIds.delete(activeSessionId); seenToolCallIds.delete(activeSessionId); @@ -188,13 +237,14 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { // and another `session.created` event during the await could // rebind it, causing context to be cached against the wrong key. const sessionId = activeSessionId; + const scope = scopeFor(sessionId); const startResult = await postJson("/session/start", { sessionId, title: info?.title ?? null, parentID: info?.parentID ?? null, version: info?.version ?? null, - project: projectPath, - cwd: projectPath, + project: scope.project, + cwd: scope.cwd, }); // cache the context returned at session/start so the // chat.system.transform hook injects it without a second fetch. @@ -239,6 +289,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { const info = props.info as Record | undefined; const sid = (info?.id as string) || props.sessionID || activeSessionId; if (!sid) return; + updateSessionScope(sid, info?.directory); await observe(sid, "session_updated", { title: info?.title ?? null, parentID: info?.parentID ?? null, @@ -272,10 +323,8 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { post("/crystals/auto", { olderThanDays: 7 }, 30000); post("/consolidate-pipeline", { tier: "all", force: true }, 30000); if (sid === activeSessionId) activeSessionId = null; - stashedFiles.delete(sid); + pruneSessionMaps(sid); startContextCache.delete(sid); - seenSubtaskIds.delete(sid); - seenToolCallIds.delete(sid); contextInjectedSessions.delete(sid); } @@ -299,6 +348,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { if (info.role === "assistant") { const sid = props.sessionID || (info.sessionID as string) || activeSessionId; if (!sid) return; + updateSessionScope(sid, (info.path as Record | undefined)?.cwd); const tokens = info.tokens as Record | undefined; const error = info.error ? extractErrorMessage(info.error) : null; await observe(sid, "assistant_message", { @@ -374,7 +424,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { await observe(sid, "post_tool_use", { tool_name: toolName, call_id: callId, - tool_input: safeSlice(st.input, 4000), + tool_input: safeToolInput(st.input), tool_output: safeSlice(st.output, 8000), title: st.title ?? null, metadata: st.metadata || {}, @@ -394,7 +444,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { await observe(sid, "post_tool_failure", { tool_name: toolName, call_id: callId, - tool_input: safeSlice(st.input, 4000), + tool_input: safeToolInput(st.input), tool_output: safeSlice(st.error, 8000), duration_ms: (startTime != null && endTime != null) ? endTime - startTime : null, }); @@ -579,9 +629,13 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { }); }, + "shell.env": async (input) => { + if (input.sessionID) updateSessionScope(input.sessionID, input.cwd); + }, + // ── tool.execute.before ── "tool.execute.before": async (input, output) => { - if (!FILE_TOOLS.has(input.tool)) return; + if (!FILE_TOOLS.has(input.tool.toLowerCase())) return; const sid = input.sessionID || activeSessionId; if (!sid) return; const args = output.args as Record | undefined; @@ -612,7 +666,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { if (typeof ctx !== "string" || ctx.length === 0) { const result = await postJson("/context", { sessionId: sid, - project: projectPath, + project: scopeFor(sid).project, }); ctx = (result as any)?.context; } else { @@ -650,7 +704,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { const result = await postJson("/context", { sessionId: sid, - project: projectPath, + project: scopeFor(sid).project, }); const ctx = (result as any)?.context; if (typeof ctx === "string" && ctx.length > 0) { diff --git a/test/opencode-project-scope.test.ts b/test/opencode-project-scope.test.ts new file mode 100644 index 000000000..5d0cd8a0d --- /dev/null +++ b/test/opencode-project-scope.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { join } from "node:path"; + +interface CapturedRequest { + path: string; + body: Record; +} + +async function setupPlugin(directory: string) { + vi.resetModules(); + const requests: CapturedRequest[] = []; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + requests.push({ + path: new URL(url).pathname, + body: init?.body ? JSON.parse(String(init.body)) : {}, + }); + return new Response(JSON.stringify({ context: "" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + })); + + const { AgentmemoryCapturePlugin } = await import( + "../plugin/opencode/agentmemory-capture.ts" + ); + const hooks = await AgentmemoryCapturePlugin({ + directory, + worktree: "/", + project: { id: "opaque-project-id" }, + } as never); + + return { hooks: hooks as Record Promise>, requests }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.AGENTMEMORY_PROJECT; +}); + +describe("OpenCode project scoping", () => { + it("uses the repository basename for project and preserves the full cwd", async () => { + const cwd = join(process.cwd(), "plugin"); + const { hooks, requests } = await setupPlugin(cwd); + + await hooks.event({ + event: { + type: "session.created", + properties: { info: { id: "session-1", directory: cwd } }, + }, + }); + + const start = requests.find((request) => request.path === "/agentmemory/session/start"); + expect(start?.body.project).toBe("agentmemory"); + expect(start?.body.cwd).toBe(cwd); + }); + + it("keeps structured file arguments in tool observations", async () => { + const cwd = join(process.cwd(), "plugin"); + const { hooks, requests } = await setupPlugin(cwd); + + await hooks.event({ + event: { + type: "session.created", + properties: { info: { id: "session-2", directory: cwd } }, + }, + }); + await hooks.event({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + sessionID: "session-2", + callID: "call-1", + tool: "read", + state: { + status: "completed", + input: { filePath: join(cwd, "opencode", "agentmemory-capture.ts") }, + output: "contents", + }, + }, + }, + }, + }); + + const observations = requests.filter((request) => request.path === "/agentmemory/observe"); + const toolObservation = observations.find( + (request) => (request.body.data as Record)?.tool_name === "read", + ); + const data = toolObservation?.body.data as Record; + expect(data.tool_input).toMatchObject({ + filePath: join(cwd, "opencode", "agentmemory-capture.ts"), + }); + }); + + it("recognizes lowercase OpenCode file tool names for enrichment", async () => { + const cwd = join(process.cwd(), "plugin"); + const filePath = join(cwd, "opencode", "agentmemory-capture.ts"); + const { hooks, requests } = await setupPlugin(cwd); + + await hooks.event({ + event: { + type: "session.created", + properties: { info: { id: "session-3", directory: cwd } }, + }, + }); + await hooks["tool.execute.before"]( + { sessionID: "session-3", tool: "read" }, + { args: { filePath } }, + ); + await hooks["experimental.chat.system.transform"]( + { sessionID: "session-3" }, + { system: [] }, + ); + + const enrich = requests.find((request) => request.path === "/agentmemory/enrich"); + expect(enrich?.body.files).toEqual([filePath]); + }); +}); From 2a15888bec4f28ea45d191506444a87dc643458c Mon Sep 17 00:00:00 2001 From: Frank W <2604571+frankyw@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:24:11 -0400 Subject: [PATCH 2/2] test(opencode): derive expected project basename --- test/opencode-project-scope.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/opencode-project-scope.test.ts b/test/opencode-project-scope.test.ts index 5d0cd8a0d..59cfc55ff 100644 --- a/test/opencode-project-scope.test.ts +++ b/test/opencode-project-scope.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { basename, join } from "node:path"; interface CapturedRequest { path: string; @@ -40,6 +41,10 @@ afterEach(() => { describe("OpenCode project scoping", () => { it("uses the repository basename for project and preserves the full cwd", async () => { const cwd = join(process.cwd(), "plugin"); + const repositoryRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf8", + }).trim(); const { hooks, requests } = await setupPlugin(cwd); await hooks.event({ @@ -50,7 +55,7 @@ describe("OpenCode project scoping", () => { }); const start = requests.find((request) => request.path === "/agentmemory/session/start"); - expect(start?.body.project).toBe("agentmemory"); + expect(start?.body.project).toBe(basename(repositoryRoot)); expect(start?.body.cwd).toBe(cwd); });