Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
Expand Down
34 changes: 34 additions & 0 deletions claude/hooks/akm-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,38 @@ function preToolAgent(): string {
})
}

/**
* SessionEnd → event-driven extraction. Fires `akm extract --type claude-code
* --session-id <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<Record<string, unknown>>(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<string> {
switch (COMMAND) {
case "ensure-akm":
Expand Down Expand Up @@ -1591,6 +1623,8 @@ async function main(): Promise<string> {
return postCompact()
case "session-end":
return sessionEnd()
case "extract-session":
return extractSession()
case "post-tool":
recordPostTool()
return ""
Expand Down
67 changes: 67 additions & 0 deletions opencode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ type SessionBufferEntry = {
}
const sessionBuffer = new Map<string, SessionBufferEntry[]>()
const sessionFinalMemoryCaptured = new Set<string>()
// 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<string, number>()
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<string, number>()
const pendingProposalSummaryCache = new Map<string, { count: number; expiresAt: number; unsupported?: boolean }>()
const retrospectiveState = new Map<string, { recentRefs: string[]; lastNegativeSignalAt?: number }>()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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",
Expand All @@ -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) {
Expand Down
22 changes: 21 additions & 1 deletion tests/akm-version-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"],
})

Expand Down
30 changes: 29 additions & 1 deletion tests/claude-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,19 @@ function runHook(args: string[], options?: { input?: string; env?: Record<string
stdin = Bun.file(inputPath)
}

// Strip inherited AKM_* and XDG_* vars so the sandbox is hermetic — the hook
// must depend only on what each test sets, not the ambient/CI env. CI sets
// AKM_PLUGIN_NO_AUTO_DEFAULT=1 (flips first-run auto-default) and may set
// XDG_CONFIG_HOME to a config that already has defaults.agent (getAkmConfigPath
// reads it, suppressing the first-run write): both are green locally, red in CI.
// With XDG_* stripped, unset-by-test XDG dirs fall back to the sandbox HOME.
const baseEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) => !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"],
Expand Down Expand Up @@ -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"')
Expand Down Expand Up @@ -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")
Expand Down
26 changes: 26 additions & 0 deletions tests/opencode-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
Loading