From b5d8ae6347e7cac4155852c00496013dea32cb01 Mon Sep 17 00:00:00 2001 From: itlackey Date: Sun, 5 Jul 2026 00:44:45 -0500 Subject: [PATCH 1/3] feat(opencode): delete the SessionEnd captureSessionMemory session_checkpoint write (03-R1/06-M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opencode plugin's parallel session_checkpoint `remember --force` write — the sibling of the Claude-plugin write deleted in #80. It fired on every stop/idle/compacted/deleted event + per-turn (every N positive touches) + before improve/propose/evolve + a manual akm_memory action, flooding the stash with below-every-gate write-only telemetry. Meta-review 03-R1/06-M1 retires it. Deleted (opencode/index.ts): `captureSessionMemory` (the session_checkpoint body-builder + `remember --force` write), `maybeCheckpointSessionMemory`, `ensureFreshProposalCheckpoint`, and their helpers (formatEvidenceSummary, formatPathBullet, countByValue, uniqueRecent, sessionHasPendingCheckpointEvidence, getHarnessStatePaths) + dead state (AKM_MEMORY_CHECKPOINT_EVERY, sessionFinalMemoryCaptured, sessionSuccessfulAssetTouchCount). Removed all call sites: stop/idle handlers, the per-turn positive-feedback checkpoint, the akm_memory `checkpoint` action (dropped from the enum too), and the pre-improve/ pre-propose/pre-evolve checkpoints. `akm index` on session-end survives — `maybeIndexSessionMemory` now runs decoupled from the deleted write. PRESERVED: the candidate → /akm-memory-promote pipeline (extractCandidatesFromText/appendCandidates), the SessionEnd `akm extract` path, and `rememberTextAsMemory` (kept — the akm-curator (akm_evolve) output write at line ~3545 still uses it; that is a deliberate curator artifact, not the session_checkpoint telemetry, and is out of 03-R1's scope). Tests: deleted 5 that asserted the checkpoint/stop write + migrated 1 to assert `akm index` still runs on stop with NO `remember` call. Gate: `bun test tests/` 314/0, `tsc --noEmit` clean, `tier2/runner.ts --baseline` exit 0. Net −542 LOC. (Done directly, in Opus — the opencode/gpt-5.4 agent stalled on an OpenCode Zen monthly usage-limit; small requests trickled through but the deletion task hung.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018YjU1mGiShdDP4qFW7p4mv --- opencode/index.ts | 383 +--------------------------------- tests/opencode-plugin.test.ts | 180 +--------------- 2 files changed, 12 insertions(+), 551 deletions(-) diff --git a/opencode/index.ts b/opencode/index.ts index d8857cc..2ec8b61 100644 --- a/opencode/index.ts +++ b/opencode/index.ts @@ -55,7 +55,6 @@ const AKM_PENDING_PROPOSAL_TIMEOUT_MS = Math.max(500, (Number(process.env.AKM_PE const AKM_CURATE_LIMIT = Math.max(1, Number(process.env.AKM_CURATE_LIMIT ?? "5") || 5) const AKM_CURATE_MIN_CHARS = Math.max(1, Number(process.env.AKM_CURATE_MIN_CHARS ?? "16") || 16) const AKM_CURATE_TIMEOUT_MS = Math.max(1_000, (Number(process.env.AKM_CURATE_TIMEOUT ?? "8") || 8) * 1_000) -const AKM_MEMORY_CHECKPOINT_EVERY = Math.max(1, Number(process.env.AKM_MEMORY_CHECKPOINT_EVERY ?? "8") || 8) const AKM_CURATOR_CONTEXT_MAX_CHARS = Math.max(500, Number(process.env.AKM_CURATOR_CONTEXT_MAX_CHARS ?? "4000") || 4000) const SESSION_DATE_TAG_LENGTH = 8 const CHECKPOINT_DATE_TAG_LENGTH = 15 @@ -89,7 +88,6 @@ type SessionBufferEntry = { checkpointed?: boolean } 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 @@ -102,7 +100,6 @@ 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() let cachedAkmStashDir: string | undefined @@ -634,125 +631,10 @@ function buildScopedArgs(context: Record | undefined): string[] return args } -function getHarnessStatePaths(): { stateDir: string; eventLog: string; candidateLog: string; opencodeLogDir: string } { - const stateDir = path.dirname(OPENCODE_EVENT_LOG) - return { - stateDir, - eventLog: OPENCODE_EVENT_LOG, - candidateLog: OPENCODE_CANDIDATE_LOG, - opencodeLogDir: path.join(process.env.XDG_DATA_HOME ?? path.join(process.env.HOME ?? ".", ".local", "share"), "opencode", "log"), - } -} - -function formatPathBullet(label: string, filePath: string): string { - return `- ${label}: ${filePath}` -} - -function countByValue(values: string[]): Array<[string, number]> { - const counts = new Map() - for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1) - return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) -} - -function uniqueRecent(values: T[], key: (value: T) => string, limit: number): T[] { - const selected: T[] = [] - const seen = new Set() - for (let index = values.length - 1; index >= 0 && selected.length < limit; index -= 1) { - const value = values[index] - const id = key(value) - if (!id || seen.has(id)) continue - seen.add(id) - selected.push(value) - } - return selected.reverse() -} - -function sessionHasPendingCheckpointEvidence(sessionID: string | undefined): boolean { - if (!sessionID) return false - return (sessionBuffer.get(sessionID) ?? []).some((entry) => !entry.checkpointed) -} - -async function ensureFreshProposalCheckpoint( - client: LogCapableClient, - context: { sessionID?: string; directory?: string; agent?: string }, - reason: string, -): Promise { - if (!context.sessionID || !sessionHasPendingCheckpointEvidence(context.sessionID)) return null - const ref = captureSessionMemory(context.sessionID, reason, { checkpoint: true }) - if (!ref) return null - await writePluginLog(client, "info", "AKM proposal checkpoint captured", { - subsystem: "memory", - actor: "system", - sessionID: context.sessionID, - directory: context.directory, - agent: context.agent, - reason, - ref, - }) - const indexResult = runCliSyncRaw(["index"], AKM_CURATE_TIMEOUT_MS) - if (!indexResult.ok) { - await writePluginLog(client, "warn", "AKM proposal checkpoint indexing failed", { - subsystem: "memory", - actor: "system", - sessionID: context.sessionID, - directory: context.directory, - agent: context.agent, - reason, - ref, - error: indexResult.error, - }) - } - return ref -} - function truncateLine(value: string, maxChars = 220): string { return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1)}...` } -function formatEvidenceSummary(events: AkmMemoryEvent[], candidates: ReturnType, entries: SessionBufferEntry[]): string[] { - const lines: string[] = [] - const toolRefs = entries.filter((entry) => entry.kind === "tool-ref" && entry.ref) - const touchedRefs = countByValue(toolRefs.map((entry) => entry.ref!)) - const toolNames = countByValue(toolRefs.map((entry) => entry.toolName ?? "tool")) - const statuses = countByValue(toolRefs.map((entry) => entry.status ?? "unknown")) - const eventTypes = countByValue(events.map((event) => event.event)) - const candidateTypes = countByValue(candidates.map((candidate) => candidate.type)) - - lines.push("## Evidence aggregates") - lines.push(`- buffered observations: ${entries.length}`) - if (toolRefs.length > 0) lines.push(`- asset-touch observations: ${toolRefs.length}`) - if (touchedRefs.length > 0) lines.push(`- top refs: ${touchedRefs.slice(0, 5).map(([ref, count]) => `${ref} (${count})`).join(", ")}`) - if (toolNames.length > 0) lines.push(`- tools involved: ${toolNames.slice(0, 5).map(([name, count]) => `${name} (${count})`).join(", ")}`) - if (statuses.length > 0) lines.push(`- statuses: ${statuses.map(([status, count]) => `${status} (${count})`).join(", ")}`) - if (eventTypes.length > 0) lines.push(`- event types: ${eventTypes.slice(0, 6).map(([event, count]) => `${event} (${count})`).join(", ")}`) - if (candidateTypes.length > 0) lines.push(`- candidate types: ${candidateTypes.map(([type, count]) => `${type} (${count})`).join(", ")}`) - lines.push("") - - const notableEvents = uniqueRecent(events, (event) => `${event.timestamp}:${event.event}:${(event.refs ?? []).join(",")}`, 5) - if (notableEvents.length > 0) { - lines.push("## Notable recent events") - for (const event of notableEvents) { - const status = event.outcome?.status ?? "unknown" - const refs = Array.isArray(event.refs) && event.refs.length > 0 ? ` refs=${event.refs.join(", ")}` : "" - const warning = event.outcome?.warnings?.[0] ? ` warning=${truncateLine(event.outcome.warnings[0], 120)}` : "" - lines.push(`- ${event.timestamp} ${event.event} (${status})${refs}${warning}`) - } - lines.push("") - } - - const notableCandidates = uniqueRecent(candidates, (candidate) => candidate.id, 5) - if (notableCandidates.length > 0) { - lines.push("## Candidate highlights") - for (const candidate of notableCandidates) { - const target = candidate.targetRef ? ` target=${candidate.targetRef}` : "" - lines.push(`- [${candidate.status}] ${candidate.type}/${candidate.scope}${target} :: ${truncateLine(candidate.content, 180)}`) - } - lines.push("") - } - - return lines -} - function runCurate(args: string[]): string | null { const result = runCliSyncRaw(args, AKM_CURATE_TIMEOUT_MS) if (!result.ok) return null @@ -1170,188 +1052,6 @@ function rememberTextAsMemory(name: string, body: string, context?: Record !entry.checkpointed) : entries - // Require at least two observations before persisting — single events are noise. - if (pendingEntries.length < 2) { - if (!isCheckpoint) sessionBuffer.delete(sessionID) - return null - } - - const relatedEvents = readJsonl(OPENCODE_EVENT_LOG) - .filter((event) => event.sessionId === sessionID) - .slice(-12) - const relatedCandidates = readCandidates(OPENCODE_CANDIDATE_LOG) - .filter((candidate) => candidate.sessionId === sessionID) - .slice(-8) - const harnessPaths = getHarnessStatePaths() - - const lines: string[] = [] - lines.push("---") - lines.push("akm_memory_kind: session_checkpoint") - lines.push("harness: opencode") - lines.push(`session_id: ${sessionID}`) - lines.push(`reason: ${reason}`) - lines.push("---") - lines.push("") - lines.push(`# Session summary (${nowIso()})`) - lines.push(`Reason: ${reason}`) - lines.push(`Session: ${sessionID}`) - lines.push("") - lines.push("## Full-detail evidence files") - lines.push(formatPathBullet("OpenCode state dir", harnessPaths.stateDir)) - lines.push(formatPathBullet("Structured event log", harnessPaths.eventLog)) - lines.push(formatPathBullet("Memory candidate log", harnessPaths.candidateLog)) - lines.push(formatPathBullet("OpenCode host log dir", harnessPaths.opencodeLogDir)) - const harnessLog = process.env.AKM_EVAL_HARNESS_LOG?.trim() - if (harnessLog) lines.push(formatPathBullet("Harness log", harnessLog)) - lines.push("") - lines.push(...formatEvidenceSummary(relatedEvents, relatedCandidates, pendingEntries)) - for (const entry of pendingEntries) { - if (entry.kind === "memory-intent") { - lines.push(`## ${entry.timestamp} — user memory intent`) - if (entry.note) lines.push(entry.note) - lines.push("") - } else { - lines.push(`## ${entry.timestamp} — ${entry.toolName ?? "tool"} ${entry.status ?? "unknown"}`) - if (entry.ref) lines.push(`- ref: ${entry.ref}`) - if (entry.note) lines.push(`- note: ${entry.note}`) - lines.push("") - } - } - if (relatedEvents.length > 0) { - lines.push("## Plugin event summary") - for (const event of relatedEvents) { - const status = event.outcome?.status ?? "unknown" - const refs = Array.isArray(event.refs) && event.refs.length > 0 ? ` — refs: ${event.refs.join(", ")}` : "" - lines.push(`- ${event.timestamp} — ${event.event} (${status})${refs}`) - } - lines.push("") - } - if (relatedCandidates.length > 0) { - lines.push("## Memory candidates observed") - for (const candidate of relatedCandidates) { - lines.push(`- [${candidate.status}] ${candidate.type}/${candidate.scope}: ${candidate.content}`) - } - lines.push("") - } - const body = lines.join("\n") - - const dateTag = buildDateTag({ includeTime: isCheckpoint }) - const shortSid = sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session" - const name = isCheckpoint - ? `opencode-checkpoint-${dateTag}-${shortSid}` - : `opencode-session-${dateTag}-${shortSid}` - - const ref = rememberTextAsMemory(name, body, { - sessionID, - run: sessionID, - directory: options?.directory, - agent: options?.agent, - channel: options?.channel, - userID: options?.userID, - user: options?.user, - }) - if (!ref) { - if (!isCheckpoint) { - sessionFinalMemoryCaptured.add(sessionID) - sessionBuffer.delete(sessionID) - } - return null - } - - if (isCheckpoint) { - void writeStructuredEvent({ - event: "pre_compact_checkpoint", - sessionId: sessionID, - scope: buildEventScope(sessionID), - memory: { ref, reason, kind: "session_checkpoint" }, - refs: [ref], - outcome: { status: "ok" }, - }) - const targetRefHints = pendingEntries.flatMap((entry) => entry.ref ? [entry.ref] : []) - const candidates = extractCandidatesFromText({ - harness: "opencode", - sessionId: sessionID, - text: body, - evidence: [ref, reason, ...targetRefHints], - sourcePaths: [harnessPaths.eventLog, harnessPaths.candidateLog], - targetRefHints, - }) - if (candidates.length > 0) { - appendCandidates(OPENCODE_CANDIDATE_LOG, candidates) - void writeStructuredEvent({ - event: "candidate_extracted", - sessionId: sessionID, - scope: buildEventScope(sessionID), - memory: { sourceRef: ref, count: candidates.length }, - refs: [ref], - outcome: { status: "ok" }, - }) - } - for (const entry of entries) { - if (!entry.checkpointed) entry.checkpointed = true - } - sessionSuccessfulAssetTouchCount.set(sessionID, 0) - sessionBuffer.set(sessionID, entries) - return ref - } - - sessionFinalMemoryCaptured.add(sessionID) - sessionBuffer.delete(sessionID) - void writeStructuredEvent({ - event: "session_ended", - sessionId: sessionID, - scope: buildEventScope(sessionID), - memory: { ref, reason, kind: "session_checkpoint" }, - refs: [ref], - outcome: { status: "ok" }, - }) - const targetRefHints = entries.flatMap((entry) => entry.ref ? [entry.ref] : []) - const candidates = extractCandidatesFromText({ - harness: "opencode", - sessionId: sessionID, - text: body, - evidence: [ref, reason, ...targetRefHints], - sourcePaths: [harnessPaths.eventLog, harnessPaths.candidateLog], - targetRefHints, - }) - if (candidates.length > 0) { - appendCandidates(OPENCODE_CANDIDATE_LOG, candidates) - void writeStructuredEvent({ - event: "candidate_extracted", - sessionId: sessionID, - scope: buildEventScope(sessionID), - memory: { sourceRef: ref, count: candidates.length }, - refs: [ref], - outcome: { status: "ok" }, - }) - } - return ref -} - -function maybeCheckpointSessionMemory( - sessionID: string, - options?: { directory?: string; agent?: string; channel?: string; userID?: string; user?: string }, -): string | null { - const count = sessionSuccessfulAssetTouchCount.get(sessionID) ?? 0 - if (count < AKM_MEMORY_CHECKPOINT_EVERY) return null - const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true, ...options }) - if (!captured) { - sessionSuccessfulAssetTouchCount.set(sessionID, 0) - } - return captured -} - async function maybeIndexSessionMemory( client: LogCapableClient, sessionID: string, @@ -2737,19 +2437,10 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { } } else if (type === "session.compacted" || type === "session.idle" || type === "session.deleted") { if (!sid) return - const captured = captureSessionMemory(sid, type, { directory }) - if (captured) { - await writePluginLog(logClient, "info", "AKM session memory captured", { - subsystem: "memory", - actor: "system", - sessionID: sid, - reason: type, - ref: captured, - }) - await maybeIndexSessionMemory(logClient, sid, type, captured) - // 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) - } + // 03-R1/06-M1: the session_checkpoint `remember --force` write is + // removed. Keep the freshness reindex on session-end events so + // upstream inference/graph passes still run. + await maybeIndexSessionMemory(logClient, sid, type, "") // 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") { @@ -2776,8 +2467,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { sessionContextInjectedEpoch.delete(sid) sessionCuratedVersion.delete(sid) sessionCuratedInjectedVersion.delete(sid) - sessionFinalMemoryCaptured.delete(sid) - sessionSuccessfulAssetTouchCount.delete(sid) sessionBuffer.delete(sid) sessionLastExtractAt.delete(sid) } @@ -2793,19 +2482,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { try { const sid = extractSessionIdFromEvent(input) if (!sid) return - const captured = captureSessionMemory(sid, "stop", { directory }) - if (captured) { - await writePluginLog(logClient, "info", "AKM session memory captured", { - subsystem: "memory", - actor: "system", - sessionID: sid, - reason: "stop", - ref: captured, - }) - await maybeIndexSessionMemory(logClient, sid, "stop", captured) - // 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) - } + // 03-R1/06-M1: session_checkpoint write removed; keep the reindex. + await maybeIndexSessionMemory(logClient, sid, "stop", "") } catch (error: unknown) { await logHookFailure(logClient, "stop", error) } @@ -3147,25 +2825,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { status: feedback ?? "unknown", }) } - if (feedback === "positive") { - sessionSuccessfulAssetTouchCount.set( - input.sessionID, - (sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1, - ) - const checkpointRef = maybeCheckpointSessionMemory(input.sessionID, { - directory, - agent: input.tool, - }) - if (checkpointRef) { - await writePluginLog(logClient, "info", "AKM checkpoint memory captured", { - subsystem: "memory", - actor: "system", - sessionID: input.sessionID, - reason: "checkpoint", - ref: checkpointRef, - }) - } - } } if ( @@ -3365,7 +3024,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { akm_memory: tool({ description: "Audit AKM memory behavior, inspect pending candidates, and promote or reject candidates. Mutating actions require confirm:true.", args: { - action: tool.schema.enum(["audit", "candidates", "promote", "reject", "checkpoint", "sync"]).describe("Memory action."), + action: tool.schema.enum(["audit", "candidates", "promote", "reject", "sync"]).describe("Memory action."), candidate_id: tool.schema.string().optional().describe("Candidate id for promote/reject."), reason: tool.schema.string().optional().describe("Reason for rejection."), scope: tool.schema.enum(["last-prompt", "session", "refs", "safety"]).optional().describe("Audit scope."), @@ -3402,17 +3061,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { .filter((candidate) => !context.sessionID || candidate.sessionId === context.sessionID) return JSON.stringify({ ok: true, candidates }) } - if (input.action === "checkpoint") { - const ref = captureSessionMemory(context.sessionID, "manual-checkpoint", { - checkpoint: true, - directory: context.directory, - agent: context.agent, - channel: (context as Record).channel as string | undefined, - userID: (context as Record).userID as string | undefined, - user: (context as Record).user as string | undefined, - }) - return JSON.stringify({ ok: true, ref }) - } if (input.action === "sync") { return JSON.stringify({ ok: true, synced: true, events: readJsonl(OPENCODE_EVENT_LOG).length, candidates: readCandidates(OPENCODE_CANDIDATE_LOG).length }) } @@ -3499,11 +3147,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { as_subtask: tool.schema.boolean().optional().describe("Run in a child session with parent context. Defaults to true."), }, async execute({ focus, dispatch_agent, as_subtask }, context) { - await ensureFreshProposalCheckpoint(logClient, { - sessionID: context.sessionID, - directory: context.directory, - agent: context.agent, - }, "pre-evolve") const useSubtask = as_subtask ?? true const requestedAgent = dispatch_agent ?? "akm-curator" const targetAgent = await resolveDispatchAgent(sdkClient, requestedAgent, context.directory) @@ -4233,13 +3876,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { dry_run: tool.schema.boolean().optional().describe("Show planned actions without generating proposals."), }, async execute({ scope, task, dry_run }, context) { - if (!dry_run) { - await ensureFreshProposalCheckpoint(logClient, { - sessionID: context.sessionID, - directory: context.directory, - agent: context.agent, - }, "pre-improve") - } const args = ["improve"] if (scope) args.push(scope) if (task) args.push("--task", task) @@ -4262,11 +3898,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { const hasTask = !!task?.trim() const hasFile = !!file?.trim() if (hasTask === hasFile) return JSON.stringify({ ok: false, error: "Exactly one of 'task' or 'file' is required for akm_propose." }) - await ensureFreshProposalCheckpoint(logClient, { - sessionID: context.sessionID, - directory: context.directory, - agent: context.agent, - }, "pre-propose") const args = ["propose", type, name] if (hasTask) args.push("--task", task!.trim()) if (hasFile) args.push("--file", file!.trim()) diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index f17d3b2..44892a9 100644 --- a/tests/opencode-plugin.test.ts +++ b/tests/opencode-plugin.test.ts @@ -2183,34 +2183,7 @@ describe("akm-opencode plugin", () => { expect(positiveRetroCalls).toEqual([]) }) - it("captures a session memory on stop when the buffer has enough entries", async () => { - const hooks = await AkmPlugin(createPluginInput()) - - // Seed the session buffer with two tool refs. - await hooks["tool.execute.after"]!( - { tool: "akm_show", sessionID: "session-capture-1", callID: "c1", args: { ref: "skill:alpha" } } as any, - { title: "show", output: JSON.stringify({ type: "skill", ref: "skill:alpha" }), metadata: {} } as any, - ) - await hooks["tool.execute.after"]!( - { tool: "akm_show", sessionID: "session-capture-1", callID: "c2", args: { ref: "skill:beta" } } as any, - { title: "show", output: JSON.stringify({ type: "skill", ref: "skill:beta" }), metadata: {} } as any, - ) - - mockExecFileSync.mockClear() - await hooks.stop!({ sessionID: "session-capture-1" } as any) - - const rememberCall = (mockExecFileSync.mock.calls as any[]).find( - ([, args]) => Array.isArray(args) && args.includes("remember"), - ) - expect(rememberCall).toBeDefined() - const rememberArgs = rememberCall![1] as string[] - const nameFlag = rememberArgs.indexOf("--name") - expect(nameFlag).toBeGreaterThan(-1) - expect(rememberArgs[nameFlag + 1]).toMatch(/^opencode-session-/) - expect(rememberArgs).toContain("--force") - }) - - it("runs akm index after capturing a session memory when enabled", async () => { + it("runs akm index on stop WITHOUT a session_checkpoint write (03-R1/06-M1)", async () => { await withEnvVar("AKM_INDEX_ON_SESSION_END", "1", async () => { const hooks = await AkmPlugin(createPluginInput()) @@ -2227,12 +2200,13 @@ describe("akm-opencode plugin", () => { await hooks.stop!({ sessionID: "session-index-1" } as any) const calls = mockExecFileSync.mock.calls as Array<[string, string[]]> - const rememberIndex = calls.findIndex(([, args]) => Array.isArray(args) && args.includes("remember")) + const rememberCalls = calls.filter(([, args]) => Array.isArray(args) && args.includes("remember")) const indexCalls = calls.filter(([, args]) => Array.isArray(args) && args.length === 1 && args[0] === "index") - expect(rememberIndex).toBeGreaterThan(-1) + // The session_checkpoint `remember --force` write is deleted... + expect(rememberCalls).toHaveLength(0) + // ...but the freshness reindex on session-end survives. expect(indexCalls).toHaveLength(1) - expect(calls.findIndex(([, args]) => Array.isArray(args) && args.length === 1 && args[0] === "index")).toBeGreaterThan(rememberIndex) }) }) @@ -2269,94 +2243,6 @@ describe("akm-opencode plugin", () => { }) }) - it("captures checkpoint memories mid-session without losing the final session summary", async () => { - const hooks = await AkmPlugin(createPluginInput()) - mockExecFileSync.mockClear() - - for (let index = 0; index < 8; index += 1) { - await hooks["tool.execute.after"]!( - { - tool: "akm_show", - sessionID: "session-checkpoint-1", - callID: `c${index}`, - args: { ref: `skill:asset-${index}` }, - } as any, - { - title: "show", - output: JSON.stringify({ type: "skill", ref: `skill:asset-${index}` }), - metadata: {}, - } as any, - ) - } - - const checkpointCall = (mockExecFileSync.mock.calls as any[]).find( - ([, args]) => Array.isArray(args) && args.includes("remember") && args.some((arg: string) => arg.startsWith("opencode-checkpoint-")), - ) - expect(checkpointCall).toBeDefined() - expect(checkpointCall?.[1]).toContain("--run") - expect(checkpointCall?.[1]).toContain("session-checkpoint-1") - - await hooks.stop!({ sessionID: "session-checkpoint-1" } as any) - - const finalCall = (mockExecFileSync.mock.calls as any[]).find( - ([, args]) => Array.isArray(args) && args.includes("remember") && args.some((arg: string) => arg.startsWith("opencode-session-")), - ) - expect(finalCall).toBeDefined() - const stopRememberInput = ((finalCall?.[2] as { input?: string } | undefined)?.input) ?? "" - expect(stopRememberInput).toContain("## Full-detail evidence files") - expect(stopRememberInput).toContain("events.jsonl") - expect(stopRememberInput).toContain("memory-candidates.jsonl") - expect(stopRememberInput).toContain("opencode/log") - expect(stopRememberInput).toContain("## Evidence aggregates") - }) - - it("does not re-capture already checkpointed entries until new successful refs arrive", async () => { - const hooks = await AkmPlugin(createPluginInput()) - mockExecFileSync.mockClear() - - for (let index = 0; index < 8; index += 1) { - await hooks["tool.execute.after"]!( - { - tool: "akm_show", - sessionID: "session-checkpoint-2", - callID: `c${index}`, - args: { ref: `skill:asset-${index}` }, - } as any, - { - title: "show", - output: JSON.stringify({ type: "skill", ref: `skill:asset-${index}` }), - metadata: {}, - } as any, - ) - } - - const checkpointCallsAfterFirstBatch = (mockExecFileSync.mock.calls as any[]).filter( - ([, args]) => Array.isArray(args) && args.includes("remember") && args.some((arg: string) => arg.startsWith("opencode-checkpoint-")), - ) - expect(checkpointCallsAfterFirstBatch).toHaveLength(1) - - for (let index = 8; index < 15; index += 1) { - await hooks["tool.execute.after"]!( - { - tool: "akm_show", - sessionID: "session-checkpoint-2", - callID: `c${index}`, - args: { ref: `skill:asset-${index}` }, - } as any, - { - title: "show", - output: JSON.stringify({ type: "skill", ref: `skill:asset-${index}` }), - metadata: {}, - } as any, - ) - } - - const checkpointCallsAfterSecondBatch = (mockExecFileSync.mock.calls as any[]).filter( - ([, args]) => Array.isArray(args) && args.includes("remember") && args.some((arg: string) => arg.startsWith("opencode-checkpoint-")), - ) - expect(checkpointCallsAfterSecondBatch).toHaveLength(1) - }) - it("injects AKM shell environment variables for bash tools", async () => { mockExecFileSync.mockImplementation((_cmd, args) => { if (Array.isArray(args) && args[0] === "--format" && args[3] === "config" && args[4] === "get" && args[5] === "stashDir") { @@ -3151,62 +3037,6 @@ describe("akm-opencode plugin", () => { ) }) - it("captures a fresh checkpoint before improve when there is uncheckpointed session evidence", async () => { - const hooks = await AkmPlugin(createPluginInput()) - mockExecFileSync.mockReturnValue("{\"ok\":true}") - - await hooks["tool.execute.after"]!( - { tool: "akm_show", sessionID: "session-improve-prep", callID: "c1", args: { ref: "skill:deploy" } } as any, - { title: "show", output: JSON.stringify({ type: "skill", ref: "skill:deploy" }), metadata: {} } as any, - ) - await hooks["tool.execute.after"]!( - { tool: "akm_show", sessionID: "session-improve-prep", callID: "c2", args: { ref: "skill:test" } } as any, - { title: "show", output: JSON.stringify({ type: "skill", ref: "skill:test" }), metadata: {} } as any, - ) - - mockExecFileSync.mockClear() - await hooks.tool!.akm_improve.execute( - { scope: "skill:deploy" } as any, - createToolContext({ sessionID: "session-improve-prep" }), - ) - - const calls = mockExecFileSync.mock.calls as Array<[string, string[]]> - const rememberIndex = calls.findIndex(([, args]) => Array.isArray(args) && args.includes("remember") && args.some((arg) => arg.startsWith("opencode-checkpoint-"))) - const indexIndex = calls.findIndex(([, args]) => Array.isArray(args) && args.length === 1 && args[0] === "index") - const improveIndex = calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "improve") - expect(rememberIndex).toBeGreaterThan(-1) - expect(indexIndex).toBeGreaterThan(rememberIndex) - expect(improveIndex).toBeGreaterThan(indexIndex) - }) - - it("captures a fresh checkpoint before propose when there is uncheckpointed session evidence", async () => { - const hooks = await AkmPlugin(createPluginInput()) - mockExecFileSync.mockReturnValue("{\"ok\":true}") - - await hooks["tool.execute.after"]!( - { tool: "akm_show", sessionID: "session-propose-prep", callID: "c1", args: { ref: "skill:deploy" } } as any, - { title: "show", output: JSON.stringify({ type: "skill", ref: "skill:deploy" }), metadata: {} } as any, - ) - await hooks["tool.execute.after"]!( - { tool: "akm_show", sessionID: "session-propose-prep", callID: "c2", args: { ref: "skill:test" } } as any, - { title: "show", output: JSON.stringify({ type: "skill", ref: "skill:test" }), metadata: {} } as any, - ) - - mockExecFileSync.mockClear() - await hooks.tool!.akm_propose.execute( - { type: "lesson", name: "release-checks", task: "capture release drift" } as any, - createToolContext({ sessionID: "session-propose-prep" }), - ) - - const calls = mockExecFileSync.mock.calls as Array<[string, string[]]> - const rememberIndex = calls.findIndex(([, args]) => Array.isArray(args) && args.includes("remember") && args.some((arg) => arg.startsWith("opencode-checkpoint-"))) - const indexIndex = calls.findIndex(([, args]) => Array.isArray(args) && args.length === 1 && args[0] === "index") - const proposeIndex = calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "propose") - expect(rememberIndex).toBeGreaterThan(-1) - expect(indexIndex).toBeGreaterThan(rememberIndex) - expect(proposeIndex).toBeGreaterThan(indexIndex) - }) - it("akm_propose returns init/setup guidance when agent config is missing", async () => { const hooks = await AkmPlugin(createPluginInput()) let proposeCalls = 0 From 999aeec07615734fde844c96c5dd2c489b6ae9d3 Mon Sep 17 00:00:00 2001 From: itlackey Date: Sun, 5 Jul 2026 00:59:35 -0500 Subject: [PATCH 2/3] fix(claude-hook): preserve Agent tool input when remapping model; add fable alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to the PreToolUse Agent-dispatch model-remap hook: 1. `preToolAgent()` returned `updatedInput: { model: resolved }`, which REPLACES the entire Agent tool input with just the model — dropping `prompt`, `subagent_type`, and every other param, so the dispatched Agent call was malformed. Spread `...toolInput` so only `model` is overridden. 2. Add `fable` to `CC_VALID_MODEL_ALIASES` — Fable 5 (`claude-fable-5`) is a real Claude Code model alias now, so it must pass through instead of being remapped to `sonnet`. Gate: tsc --noEmit 0 errors, bun test tests/ 314/0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018YjU1mGiShdDP4qFW7p4mv --- claude/hooks/akm-hook.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/claude/hooks/akm-hook.ts b/claude/hooks/akm-hook.ts index fba9e1e..b6e081d 100644 --- a/claude/hooks/akm-hook.ts +++ b/claude/hooks/akm-hook.ts @@ -21,7 +21,7 @@ const MODE = process.argv[3] ?? "" // `gpt-4o`, or full-ID prefixes like `anthropic/...` and `lab/...`) gets // remapped via MODEL_ALIAS_MAP or falls back to `sonnet` so the Agent tool // dispatch is never rejected upstream. -const CC_VALID_MODEL_ALIASES = new Set(["sonnet", "opus", "haiku", "inherit"]) +const CC_VALID_MODEL_ALIASES = new Set(["sonnet", "opus", "haiku", "fable", "inherit"]) const MODEL_ALIAS_MAP: Record = { balanced: "sonnet", fast: "haiku", @@ -1311,7 +1311,7 @@ function preToolAgent(): string { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", - updatedInput: { model: resolved }, + updatedInput: { ...toolInput, model: resolved }, }, }) } From 0842816dfb46d65bb46f3314f21b542b3e2dcef1 Mon Sep 17 00:00:00 2001 From: itlackey Date: Sun, 5 Jul 2026 01:08:18 -0500 Subject: [PATCH 3/3] fix(opencode): restore session-end memory-candidate harvest (over-deleted with 03-R1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit deleted `captureSessionMemory`, which did TWO things: the session_checkpoint `remember --force` write (correctly retired by 03-R1/06-M1) AND the memory-candidate extraction that feeds the owner-gated candidate -> /akm-memory-promote pipeline (a KEEP reference rung). The second went out with the first, so no candidates were harvested on session end. This passed `bun test tests/` locally only because three candidate tests read a persistent on-disk candidate log seeded by earlier runs under the same stable session IDs; CI's clean checkout had no such state and the tests failed (`expect(candidates.length).toBeGreaterThan(0)`). Fix: add `maybeExtractSessionCandidates(sessionID, reason)` — harvests explicit "remember ..." intents from the session buffer into the candidate log with NO stash write, wired into `stop` (the reliable terminal flush) and `session.deleted` (salvage before the buffer is discarded). Deleting the buffer after extraction is the de-dup that the removed `sessionFinalMemoryCaptured` Set used to provide. Candidate text is now sourced from memory-intent notes only (higher signal than the old full-checkpoint-body scan), which suits 03-R1's noise-reduction intent. Not wired into the recurring `session.idle`/`session.compacted` events — those fire per-turn and a per-turn buffer flush is exactly the churn 03-R1 retires; `session.idle` already runs the `akm extract` backstop. Gate (clean isolated HOME/XDG, CI-equivalent): tsc 0 errors, `bun test tests/` 314/0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018YjU1mGiShdDP4qFW7p4mv --- opencode/index.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/opencode/index.ts b/opencode/index.ts index 2ec8b61..723e3c7 100644 --- a/opencode/index.ts +++ b/opencode/index.ts @@ -1071,6 +1071,47 @@ async function maybeIndexSessionMemory( }) } +// 03-R1/06-M1 kept half: the session_checkpoint `remember --force` write is +// gone, but the memory-candidate pipeline (candidates -> /akm-memory-promote, +// an owner-gated reference rung) still harvests explicit "remember ..." intents +// from the session buffer at session end. No stash write — candidates land in +// the candidate log for review only. Deleting the buffer after extraction is +// the de-dup: a re-fired terminal event finds an empty buffer and no-ops. +function maybeExtractSessionCandidates(sessionID: string, reason: string): void { + if (!AKM_AUTO_MEMORY) return + if (!sessionID) return + const entries = sessionBuffer.get(sessionID) ?? [] + // Require at least two observations before persisting — single events are noise. + if (entries.length < 2) { + sessionBuffer.delete(sessionID) + return + } + const targetRefHints = entries.flatMap((entry) => (entry.ref ? [entry.ref] : [])) + const text = entries + .filter((entry) => entry.kind === "memory-intent" && entry.note) + .map((entry) => entry.note as string) + .join("\n") + const candidates = extractCandidatesFromText({ + harness: "opencode", + sessionId: sessionID, + text, + evidence: [reason, ...targetRefHints], + sourcePaths: [OPENCODE_EVENT_LOG, OPENCODE_CANDIDATE_LOG], + targetRefHints, + }) + if (candidates.length > 0) { + appendCandidates(OPENCODE_CANDIDATE_LOG, candidates) + void writeStructuredEvent({ + event: "candidate_extracted", + sessionId: sessionID, + scope: buildEventScope(sessionID), + memory: { count: candidates.length }, + outcome: { status: "ok" }, + }) + } + sessionBuffer.delete(sessionID) +} + const AKM_REF_EDGE_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "'", "\"", "`"]) function normalizeExtractedRef(ref: string): string { @@ -2458,6 +2499,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { // Drop per-session state so a re-created session does not inherit // stale hints/curation. if (type === "session.deleted") { + // Salvage explicit memory-candidate intents before the buffer is + // discarded (03-R1/06-M1: candidate harvest, no session_checkpoint write). + maybeExtractSessionCandidates(sid, type) sessionHints.delete(sid) sessionCurated.delete(sid) sessionCuratedFile.delete(sid) @@ -2482,7 +2526,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => { try { const sid = extractSessionIdFromEvent(input) if (!sid) return - // 03-R1/06-M1: session_checkpoint write removed; keep the reindex. + // 03-R1/06-M1: session_checkpoint write removed; keep the reindex and + // the owner-gated memory-candidate harvest (no stash write). + maybeExtractSessionCandidates(sid, "stop") await maybeIndexSessionMemory(logClient, sid, "stop", "") } catch (error: unknown) { await logHookFailure(logClient, "stop", error)