diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index b4eeba9..42381e0 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -350,6 +350,11 @@ "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/akm-hook.sh\" session-end", "timeout": 30 + }, + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/akm-hook.sh\" extract-session", + "timeout": 10 } ] } diff --git a/claude/hooks/akm-hook.ts b/claude/hooks/akm-hook.ts index 4759ab2..0a8a6c8 100644 --- a/claude/hooks/akm-hook.ts +++ b/claude/hooks/akm-hook.ts @@ -1548,6 +1548,38 @@ function preToolAgent(): string { }) } +/** + * SessionEnd → event-driven extraction. Fires `akm extract --type claude-code + * --session-id ` for the just-ended session so its durable insights reach + * the proposal queue in seconds instead of waiting for the periodic extract cron. + * + * Safe + idempotent: `--session-id` respects the content-hash ledger (akm + * #602 / the beta.33 fix), so a re-fire or the cron later is a cheap skip with + * zero LLM calls — no `--force`. Detached + unref'd so it never blocks session + * close. Skipped on transient terminations (`clear`/`resume`) where the session + * isn't really done; the cron remains the backstop for crashes that fire no hook. + */ +function extractSession(): string { + const raw = readStdin() + const sid = extractSessionId(raw) + if (!sid) return "" + const reason = safeJsonParse>(raw)?.reason + if (reason === "clear" || reason === "resume") return "" + const akm = resolveAkmCommandSpec() + if (!akm) return "" + try { + const child = spawn( + akm.command, + [...akm.argsPrefix, "extract", "--type", "claude-code", "--session-id", sid], + { detached: true, stdio: "ignore" }, + ) + child.unref() + } catch { + // Best-effort: a failed spawn must never block session close; the cron backstop covers it. + } + return "" +} + async function main(): Promise { switch (COMMAND) { case "ensure-akm": @@ -1591,6 +1623,8 @@ async function main(): Promise { return postCompact() case "session-end": return sessionEnd() + case "extract-session": + return extractSession() case "post-tool": recordPostTool() return "" diff --git a/opencode/index.ts b/opencode/index.ts index 1e89e69..dba3c41 100644 --- a/opencode/index.ts +++ b/opencode/index.ts @@ -90,6 +90,17 @@ type SessionBufferEntry = { } const sessionBuffer = new Map() const sessionFinalMemoryCaptured = new Set() +// Event-driven extraction (opencode): opencode has no true "session end" event +// and `session.idle` fires after EVERY turn. To avoid flooding extract while a +// session is actively worked, we min-interval-gate per session — at most one +// extract per AKM_EXTRACT_MIN_INTERVAL_MS. The akm content-hash ledger +// (akm-cli #602 / ≥0.9.0-beta.33) further no-ops unchanged content for free. +// The */30 extract cron remains the backstop for the final delta after the last turn. +const sessionLastExtractAt = new Map() +const AKM_EXTRACT_MIN_INTERVAL_MS = (() => { + const raw = Number(process.env.AKM_EXTRACT_MIN_INTERVAL_MS) + return Number.isFinite(raw) && raw >= 0 ? raw : 10 * 60 * 1000 // default 10 min +})() const sessionSuccessfulAssetTouchCount = new Map() const pendingProposalSummaryCache = new Map() const retrospectiveState = new Map() @@ -1646,6 +1657,56 @@ function extractSessionIdFromEvent(payload: unknown): string | undefined { return undefined } +/** + * Event-driven extraction trigger for opencode (Option #3: min-interval gate). + * Called on `session.idle` (which fires after every turn). Extracts the session + * into the proposal queue at most once per AKM_EXTRACT_MIN_INTERVAL_MS, so a + * burst of turns collapses to a single periodic checkpoint instead of flooding. + * `extract --session-id` respects the content-hash ledger, so an extract landing + * on unchanged content is a free no-op. Fire-and-forget (detached + unref'd) so + * it never stalls the turn; the periodic extract cron remains the backstop for the final delta. + */ +function maybeExtractSessionOnIdle(client: LogCapableClient, sid: string, directory: string | undefined): void { + const now = Date.now() + const last = sessionLastExtractAt.get(sid) ?? 0 + if (now - last < AKM_EXTRACT_MIN_INTERVAL_MS) return + const command = resolveAkmCommand() + if (typeof command === "object" && "ok" in command) return // akm unavailable — cron backstop covers it + sessionLastExtractAt.set(sid, now) + try { + const child = spawn(command.command, [...command.argsPrefix, "extract", "--type", "opencode", "--session-id", sid], { + detached: true, + stdio: "ignore", + }) + child.on("exit", (code, signal) => { + if ((typeof code === "number" && code !== 0) || signal) { + void writePluginLog(client, "warn", "AKM extract failed", { + subsystem: "extract", + sessionID: sid, + directory, + error: signal ? `akm extract exited via signal ${signal}` : `akm extract exited with code ${code}`, + }) + } + }) + child.on("error", (error) => { + void writePluginLog(client, "warn", "AKM extract failed", { + subsystem: "extract", + sessionID: sid, + directory, + error: formatCliError(error), + }) + }) + child.unref() + } catch (error: unknown) { + void writePluginLog(client, "warn", "AKM extract failed", { + subsystem: "extract", + sessionID: sid, + directory, + error: formatCliError(error), + }) + } +} + function extractFirstSemverMatch(value: string): string | null { return value.match(SEMVER_PATTERN)?.[0] ?? null } @@ -2668,6 +2729,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { // Auto-signal so improve picks up this session memory on the next run. runCliSyncRaw(["feedback", captured, "--positive", "--reason", "session checkpoint: auto-signal for improve eligibility"], AKM_CURATE_TIMEOUT_MS) } + // Event-driven extraction: only on session.idle (per-turn quiescence), + // min-interval-gated so it doesn't flood. Not on compacted/deleted. + if (type === "session.idle") { + maybeExtractSessionOnIdle(logClient, sid, directory) + } if (type === "session.compacted") { writeStructuredEvent({ event: "post_compact_summary", @@ -2692,6 +2758,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { sessionFinalMemoryCaptured.delete(sid) sessionSuccessfulAssetTouchCount.delete(sid) sessionBuffer.delete(sid) + sessionLastExtractAt.delete(sid) } } } catch (error: unknown) { diff --git a/tests/akm-version-check.test.ts b/tests/akm-version-check.test.ts index 12f9b4d..78911dd 100644 --- a/tests/akm-version-check.test.ts +++ b/tests/akm-version-check.test.ts @@ -46,9 +46,15 @@ function runHookSandboxed(args: string[], opts: { const tempDir = makeTempDir() const binDir = path.join(tempDir, "bin") const stateDir = path.join(tempDir, "state") + const configDir = path.join(tempDir, "config") + const dataDir = path.join(tempDir, "data") + const cacheDir = path.join(tempDir, "cache") const installLog = path.join(tempDir, "install.log") mkdirSync(binDir, { recursive: true }) mkdirSync(stateDir, { recursive: true }) + mkdirSync(configDir, { recursive: true }) + mkdirSync(dataDir, { recursive: true }) + mkdirSync(cacheDir, { recursive: true }) if (opts.akmVersion) { const fakeAkm = path.join(binDir, "akm") @@ -90,16 +96,30 @@ exit 0 // and akm cannot leak into the test. `/usr/bin:/bin` is appended because // bun (the runtime that executes the hook script) needs basic shell utils, // but bun/npm/akm are NOT on `/usr/bin` on any reasonable dev box. + // Pin EVERY XDG base dir into the sandbox. getAkmConfigPath() reads + // XDG_CONFIG_HOME — if the CI runner sets it to a config that already has + // defaults.agent, the first-run auto-default never fires (green locally where + // XDG_CONFIG_HOME is unset, red in CI). Pinning all of them makes config/data + // reads hermetic regardless of the ambient environment. const env = { HOME: tempDir, PATH: `${binDir}:/usr/bin:/bin`, XDG_STATE_HOME: stateDir, + XDG_CONFIG_HOME: configDir, + XDG_DATA_HOME: dataDir, + XDG_CACHE_HOME: cacheDir, ...opts.env, } + // Strip inherited AKM_* vars so the sandbox is truly hermetic: the hook's + // behaviour (e.g. the AKM_PLUGIN_NO_AUTO_DEFAULT opt-out) must depend ONLY on + // what this sandbox sets, not on the ambient/CI environment. (CI runners set + // AKM_PLUGIN_NO_AUTO_DEFAULT=1, which previously leaked in and flipped the + // first-run auto-default off — green locally, red in CI.) + const baseEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("AKM_"))) const result = Bun.spawnSync([process.execPath, hookScript, ...args], { cwd: repoRoot, - env: { ...process.env, ...env }, + env: { ...baseEnv, ...env }, stdio: ["ignore", "pipe", "pipe"], }) diff --git a/tests/claude-plugin.test.ts b/tests/claude-plugin.test.ts index 67231eb..3381bd5 100644 --- a/tests/claude-plugin.test.ts +++ b/tests/claude-plugin.test.ts @@ -42,10 +42,19 @@ function runHook(args: string[], options?: { input?: string; env?: Record !key.startsWith("AKM_") && !key.startsWith("XDG_")), + ) const result = Bun.spawnSync([process.execPath, hookScript, ...args], { cwd: repoRoot, env: { - ...process.env, + ...baseEnv, ...options?.env, }, stdio: [stdin, "pipe", "pipe"], @@ -154,6 +163,8 @@ describe("Claude plugin metadata", () => { expect(plugin.hooks.PostCompact[0].hooks[0].command as string).toContain("post-compact") expect(plugin.hooks.PostToolBatch[0].hooks[0].command as string).toContain("post-tool-batch") expect(plugin.hooks.SessionEnd[0].hooks[0].command as string).toContain("session-end") + // SessionEnd also fires event-driven extraction for the just-ended session. + expect(plugin.hooks.SessionEnd[0].hooks[1].command as string).toContain("extract-session") expect(hookSource).toContain('from "../shared/feedback-signals"') expect(hookSource).toContain('from "../shared/memory-candidates"') @@ -342,6 +353,23 @@ exit 0 expect(getFirstLogEntry(stateDir, "session.log")).toContain("0.8.3") }) + it("extract-session dispatches cleanly and returns no output (fire-and-forget)", () => { + const tempDir = makeTempDir() + // No `akm` on PATH → resolveAkmCommandSpec() is null → handler is a clean no-op. + const env = { HOME: tempDir, PATH: "/usr/bin:/bin", XDG_STATE_HOME: path.join(tempDir, "state") } + // Normal termination: dispatch must succeed and emit nothing on stdout. + expect( + runHook(["extract-session"], { + input: JSON.stringify({ session_id: "abc-123", reason: "other", transcript_path: "/dev/null" }), + env, + }), + ).toBe("") + // Transient terminations (clear/resume) are skipped — also clean + empty. + expect(runHook(["extract-session"], { input: JSON.stringify({ session_id: "abc-123", reason: "clear" }), env })).toBe( + "", + ) + }) + it("records user feedback and memory intent from prompt submissions", () => { const tempDir = makeTempDir() const stateDir = path.join(tempDir, "state") diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index 26fcb6c..3d25d12 100644 --- a/tests/opencode-plugin.test.ts +++ b/tests/opencode-plugin.test.ts @@ -4030,4 +4030,30 @@ describe("akm-opencode plugin", () => { ).toBe(false) }) }) + + describe("event-driven extraction (session.idle, min-interval gate)", () => { + const extractSpawnCalls = () => + mockSpawn.mock.calls.filter( + (c: unknown[]) => + Array.isArray(c[1]) && (c[1] as string[]).includes("extract") && (c[1] as string[]).includes("--session-id"), + ) + + it("extracts on session.idle once, then min-interval-gates a rapid second idle", async () => { + mockSpawn.mockClear() + const hooks = await AkmPlugin(createPluginInput()) + const idle = (sid: string) => + hooks.event!({ event: { type: "session.idle", properties: { sessionID: sid } } } as any) + + await idle("sess-extract-1") + // First idle → exactly one `extract --type opencode --session-id` spawn. + expect(extractSpawnCalls().length).toBe(1) + const args = extractSpawnCalls()[0][1] as string[] + expect(args).toContain("opencode") + expect(args[args.indexOf("--session-id") + 1]).toBe("sess-extract-1") + + // A rapid second idle within the min-interval must NOT spawn another extract. + await idle("sess-extract-1") + expect(extractSpawnCalls().length).toBe(1) + }) + }) })