akm-opencode 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,31 +1,22 @@
1
1
  import { type Plugin, tool } from "@opencode-ai/plugin"
2
- import { execFileSync, execSync, spawn } from "node:child_process"
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ // @ts-expect-error akm-cli does not publish declarations for this in-process entrypoint.
3
+ import { akmCurate } from "akm-cli/dist/commands/read/curate.js"
4
+ // @ts-expect-error akm-cli does not publish declarations for this in-process entrypoint.
5
+ import { akmSearch } from "akm-cli/dist/commands/read/search.js"
6
+ // @ts-expect-error akm-cli does not publish declarations for this in-process entrypoint.
7
+ import { akmShowUnified } from "akm-cli/dist/commands/read/show.js"
8
+ import { execFileSync, spawn } from "node:child_process"
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
4
10
  import os from "node:os"
5
11
  import path from "node:path"
6
12
  import { fileURLToPath } from "node:url"
7
13
  import { classifyFeedbackSignal, shouldSubmitAutomaticFeedback } from "./shared/feedback-signals"
8
- import { appendCandidates, extractCandidatesFromText, getCandidateLogPath, readCandidates, updateCandidateStatus } from "./shared/memory-candidates"
9
- import { appendMemoryEvent, getEventLogPath, readJsonl, type AkmMemoryEvent } from "./shared/memory-events"
14
+ import { appendCandidates, extractCandidatesFromText, getCandidateLogPath } from "./shared/memory-candidates"
15
+ import { appendMemoryEvent, getEventLogPath, type AkmMemoryEvent } from "./shared/memory-events"
16
+ import { AKM_VERSION_RANGE, satisfiesAkmVersionRange } from "./shared/akm-version"
10
17
  import { shouldRecall } from "./shared/recall-policy"
11
- import { redactObject, redactSecrets } from "./shared/redaction"
12
- import { extractAkmRefsFromString } from "./shared/ref-extraction"
13
-
14
- // Quote-aware shell tokenizer. Splits on whitespace but respects single,
15
- // double, and backtick quotes. Used by renderCommandTemplate() to fill
16
- // `$1`/`$2` positional placeholders from raw `$ARGUMENTS` strings. Was
17
- // previously co-located with the risky-command assessor in
18
- // shared/risky-command.ts; inlined here when that gate was removed.
19
- function splitArguments(raw: string): string[] {
20
- if (!raw.trim()) return []
21
- const args: string[] = []
22
- const re = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g
23
- let match: RegExpExecArray | null
24
- while ((match = re.exec(raw)) !== null) {
25
- args.push(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "")
26
- }
27
- return args
28
- }
18
+ import { redactObject } from "./shared/redaction"
19
+ import { extractAkmRefsFromString, validateRefCandidates } from "./shared/ref-extraction"
29
20
 
30
21
  let resolvedAkmCommand = "akm"
31
22
 
@@ -40,11 +31,16 @@ export function __resetResolvedAkmForTests(): void {
40
31
 
41
32
  const moduleDir = path.dirname(fileURLToPath(import.meta.url))
42
33
  const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/
43
- // Note: satisfiesAkmVersionRange() implements a custom matcher that accepts
44
- // any 0.8.x release including prereleases (0.8.0-rc.5, 0.8.0-beta.2, etc.).
45
- // Strict semver `^0.8.0` would NOT match prereleases, so the constant string
46
- // is widened to honestly reflect what the matcher accepts.
47
- const AKM_REQUIRED_VERSION_RANGE = "^0.8.0 || ^0.8.0-rc0"
34
+ // The version contract lives in the shared module (also used by the Claude
35
+ // hook). satisfiesAkmVersionRange() routes through the same vendored semver
36
+ // matcher; AKM_REQUIRED_VERSION_RANGE is just the display alias used in the
37
+ // diagnostics below.
38
+ const AKM_REQUIRED_VERSION_RANGE = AKM_VERSION_RANGE
39
+ // The consent banner's "install this" recommendation is deliberately a single
40
+ // version floor rather than the full AKM_VERSION_RANGE (which is an
41
+ // OR-list of accepted ranges, not a valid single npm install specifier).
42
+ // Keep it in sync with the lowest currently-recommended stable 0.9.x release.
43
+ const AKM_RECOMMENDED_INSTALL_REF = "akm-cli@^0.9.0"
48
44
 
49
45
  const AKM_AUTO_FEEDBACK = (process.env.AKM_AUTO_FEEDBACK ?? "1") !== "0"
50
46
  const AKM_AUTO_MEMORY = (process.env.AKM_AUTO_MEMORY ?? "1") !== "0"
@@ -54,15 +50,19 @@ const AKM_PENDING_PROPOSAL_TIMEOUT_MS = Math.max(500, (Number(process.env.AKM_PE
54
50
  const AKM_CURATE_LIMIT = Math.max(1, Number(process.env.AKM_CURATE_LIMIT ?? "5") || 5)
55
51
  const AKM_CURATE_MIN_CHARS = Math.max(1, Number(process.env.AKM_CURATE_MIN_CHARS ?? "16") || 16)
56
52
  const AKM_CURATE_TIMEOUT_MS = Math.max(1_000, (Number(process.env.AKM_CURATE_TIMEOUT ?? "8") || 8) * 1_000)
57
- const AKM_MEMORY_CHECKPOINT_EVERY = Math.max(1, Number(process.env.AKM_MEMORY_CHECKPOINT_EVERY ?? "8") || 8)
58
- const AKM_CURATOR_CONTEXT_MAX_CHARS = Math.max(500, Number(process.env.AKM_CURATOR_CONTEXT_MAX_CHARS ?? "4000") || 4000)
59
- const SESSION_DATE_TAG_LENGTH = 8
60
- const CHECKPOINT_DATE_TAG_LENGTH = 15
53
+ // 13: "Memory leaks" — sessionBuffer previously grew without bound for the
54
+ // life of a session (a long-running session accumulates one entry per
55
+ // observed tool ref / memory intent). Cap it drop-oldest, matching the
56
+ // `.slice(-8)` cap style already used by retrospectiveState.recentRefs.
57
+ const AKM_SESSION_BUFFER_MAX_ENTRIES = Math.max(1, Number(process.env.AKM_SESSION_BUFFER_MAX_ENTRIES ?? "200") || 200)
58
+ // Best-effort sweep age for orphaned curated tmp files (os.tmpdir()/akm-opencode/curated).
59
+ // A session that ends without ever firing session.deleted (host crash, forced
60
+ // kill) would otherwise leak its curated file on disk forever.
61
+ const CURATED_FILE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
61
62
  const AKM_RETROSPECTIVE_FEEDBACK_RE = createRetrospectiveFeedbackRegex()
62
63
  const AKM_RETROSPECTIVE_NEGATIVE_RE = createRetrospectiveNegativeRegex()
63
64
  const AKM_EXPLICIT_CORRECTION_RE = createExplicitCorrectionRegex()
64
65
  const PLUGIN_VERSION = readPackageVersion()
65
- const PLUGIN_INSTALL_LOCATION = moduleDir
66
66
  const OPENCODE_EVENT_LOG = getEventLogPath("opencode")
67
67
  const OPENCODE_CANDIDATE_LOG = getCandidateLogPath("opencode")
68
68
 
@@ -88,20 +88,32 @@ type SessionBufferEntry = {
88
88
  checkpointed?: boolean
89
89
  }
90
90
  const sessionBuffer = new Map<string, SessionBufferEntry[]>()
91
- const sessionFinalMemoryCaptured = new Set<string>()
92
- const sessionSuccessfulAssetTouchCount = new Map<string, number>()
91
+ // Event-driven extraction (opencode): opencode has no true "session end" event
92
+ // and `session.idle` fires after EVERY turn. To avoid flooding extract while a
93
+ // session is actively worked, we min-interval-gate per session — at most one
94
+ // extract per AKM_EXTRACT_MIN_INTERVAL_MS. The akm content-hash ledger
95
+ // (akm-cli #602 / ≥0.9.0-beta.33) further no-ops unchanged content for free.
96
+ // The hourly `akm improve` extract pass (the periodic backstop) catches the
97
+ // final delta after the last turn.
98
+ const sessionLastExtractAt = new Map<string, number>()
99
+ const AKM_EXTRACT_MIN_INTERVAL_MS = (() => {
100
+ const raw = Number(process.env.AKM_EXTRACT_MIN_INTERVAL_MS)
101
+ return Number.isFinite(raw) && raw >= 0 ? raw : 10 * 60 * 1000 // default 10 min
102
+ })()
93
103
  const pendingProposalSummaryCache = new Map<string, { count: number; expiresAt: number; unsupported?: boolean }>()
94
104
  const retrospectiveState = new Map<string, { recentRefs: string[]; lastNegativeSignalAt?: number }>()
95
- let cachedAkmStashDir: string | undefined
96
- let agentSetupPromise: Promise<boolean> | null = null
97
-
98
- // Asset-ref grammar matching the stash skill: [origin//]type:name.
99
- // We validate normalized tokens individually instead of running a global regex
100
- // over arbitrary tool output to keep extraction predictable and ReDoS-safe.
101
- const AKM_REF_PATTERN = /^(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|task|env|secret|wiki|lesson):[A-Za-z0-9._/\-]+$/
105
+ let cachedAkmBundleDir: string | undefined
106
+
107
+ // Passive ref observation (narrower than explicit show/search input, so
108
+ // ordinary repository paths cannot become automatic feedback targets) lives in
109
+ // claude/shared/ref-extraction.ts. This module deliberately keeps no local copy
110
+ // of the concept-root regex: a second copy silently drifted from the canonical
111
+ // AKM 0.9 root list (it still matched `wikis/` and never matched `facts/`,
112
+ // `instructions/`, or `sessions/`). extractAkmRefsFromString() is the shared
113
+ // whitespace-token extractor and is the single source of truth here.
102
114
  const PROPOSED_QUALITY_WARNING = "Do not treat proposed assets as curated until accepted."
103
115
  const AKM_WORKFLOW_INSTRUCTION = [
104
- "# AKM workflow (v0.8.0)",
116
+ "# AKM workflow (v0.9)",
105
117
  "",
106
118
  "Use AKM as a reusable knowledge and workflow stash.",
107
119
  "",
@@ -109,11 +121,8 @@ const AKM_WORKFLOW_INSTRUCTION = [
109
121
  "1. Use `akm_curate` with a query that includes the current project name/domain (primary discovery). Fall back to `akm_search` only when you already know an asset exists and need its exact ref.",
110
122
  "2. Use `akm_show <ref>` before relying on an asset.",
111
123
  "3. Record `akm_feedback` after the result is known.",
112
- "4. Use the dedicated v0.8.0 tools for the proposal flow: `akm_proposal` (list/show/diff/accept/reject/drain), `akm_improve`, and `akm_propose`. Fall back to `akm_help` for any verb without a dedicated tool.",
113
- "5. Treat `lesson:*` as first-class durable learning assets — they are produced through `akm_improve` and accepted via `akm_proposal action=accept`.",
114
- "6. Use `akm_init` when you need to create the working stash or persist `stashDir`. Do not use `akm setup` from an agent; it is interactive and human-facing.",
115
- `7. ${PROPOSED_QUALITY_WARNING}`,
116
- "8. Never accept or reject proposals, push saves, remove sources, or access env values / secret material without explicit user approval.",
124
+ "4. Use `akm_remember` to preserve durable project knowledge.",
125
+ `5. ${PROPOSED_QUALITY_WARNING}`,
117
126
  ].join("\n")
118
127
 
119
128
  function readPackageVersion(): string {
@@ -148,80 +157,6 @@ function createExplicitCorrectionRegex(): RegExp {
148
157
  return /\b(this was wrong|that was wrong|you were wrong|incorrect|not correct)\b/i
149
158
  }
150
159
 
151
- const CURATOR_AGENT_PROMPT_FALLBACK = `You are the AKM curator — a compound-engineering agent that keeps the user's AKM stash improving every time the main agent finishes a task.
152
-
153
- Inputs you should inspect:
154
- 1. OpenCode app logs that include the "akm-opencode" service (feedback, memory, tool invocations).
155
- 2. Session-summary memories named memory:opencode-session-*.
156
- 3. The live stash: call akm_search "" --limit 50 (and akm_show <ref>) to enumerate assets; reach for akm_help topic="list sources" if you need the configured-sources view.
157
- 4. Parent-session context via akm_parent_messages when this session was dispatched as a child.
158
-
159
- Signals to act on:
160
- - Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
161
- - Cold refs: assets tied to failures or user complaints. Record akm_feedback <ref> negative --note "<excerpt>" and open the asset for review.
162
- - Lesson candidates: repeated memories or failures that should become a proposed lesson. Use akm_improve or akm_help topic="improve" before raw CLI improvement commands.
163
- - Missing coverage: recurring user prompts with no matching asset. Draft a new skill, command, knowledge doc, wiki page, or workflow in the working stash and reindex via the akm CLI (see akm_help topic="reindex").
164
- - Pending proposals: list or diff them via akm_help topic="proposal" and recommend accept, reject, or revise. Never accept or reject without explicit user approval.
165
- - Duplicates / drift: near-identical descriptions or overlapping responsibilities. Propose a consolidation.
166
- - Stale memories: session summaries that never get recalled. Propose removal (see akm_help topic="remove") once distilled into a durable knowledge doc or wiki page.
167
- - Wiki hygiene: for each wiki returned by akm_wiki list, run akm_wiki lint <name> and report orphans, broken xrefs, uncited raws, and stale indexes as fix candidates.
168
- - Stuck workflows: run akm_workflow list --active and surface any runs in blocked or failed state with their step ids. Propose whether to resume or escalate.
169
- - Never touch env or secret values: do not call akm_env run or akm_secret path unless the user explicitly asks. Env values and secret material must never appear in reports.
170
-
171
- Rules of engagement:
172
- - Never apply destructive changes without explicit user approval.
173
- - Report findings as a prioritized action list of concrete akm_* tool calls the user can run.
174
- - Prefer small, reversible edits: promote via positive feedback, draft a candidate skill, or clone and tweak.
175
- - When drafting new assets, write them into the working stash directory under skills/, commands/, agents/, knowledge/, or scripts/. Use akm_help (topic="config" / topic="reindex") to look up the right CLI invocation when you need the stash path or want to force a reindex.
176
- - When finished, persist your own summary with akm_remember (name: curator-run-<timestamp>) so the next curator run can build on yours.
177
-
178
- Output shape: end every run with a markdown report that has these sections:
179
-
180
- ## Hot assets (promote)
181
- - <ref> — why it helped — command to run
182
-
183
- ## Cold assets (investigate)
184
- - <ref> — failure signal — proposed fix
185
-
186
- ## Lesson candidates
187
- - <theme> — evidence refs — improve or propose command to run
188
-
189
- ## Coverage gaps
190
- - <theme> — proposed asset (type, name, one-line description)
191
-
192
- ## Pending proposals
193
- - <proposal id> — summary — accept/reject/revise recommendation
194
-
195
- ## Duplicates / drift
196
- - <ref a> vs <ref b> — consolidation proposal
197
-
198
- ## Wiki health
199
- - <wiki> — lint findings (orphan, broken-xref, uncited-raw, stale-index) with suggested fix
200
-
201
- ## Workflow health
202
- - <workflow|runId> — blocked/failed state — resume or escalate
203
-
204
- ## Housekeeping
205
- - stale memories, reindex needs, config tweaks
206
- `
207
-
208
- function loadCuratorAgentPrompt(): string {
209
- try {
210
- const raw = readFileSync(path.join(moduleDir, "agent", "akm-curator.md"), "utf8").trim()
211
- let body = raw
212
- const lines = raw.split(/\r?\n/)
213
- if (lines[0] === "---") {
214
- const closingIndex = lines.indexOf("---", 1)
215
- if (closingIndex > 0) body = lines.slice(closingIndex + 1).join("\n").trim()
216
- }
217
- return body || CURATOR_AGENT_PROMPT_FALLBACK
218
- } catch {
219
- return CURATOR_AGENT_PROMPT_FALLBACK
220
- }
221
- }
222
-
223
- const CURATOR_AGENT_PROMPT = loadCuratorAgentPrompt()
224
-
225
160
  type LogLevel = "debug" | "info" | "warn" | "error"
226
161
 
227
162
  type LogCapableClient = {
@@ -247,20 +182,6 @@ type CliLogMeta = {
247
182
  channel?: string
248
183
  }
249
184
 
250
- type SessionPromptBody = {
251
- agent: string
252
- parts: Array<{ type: "text"; text: string }>
253
- system?: string
254
- model?: { providerID: string; modelID: string }
255
- tools?: Record<string, boolean>
256
- }
257
-
258
- type DispatchTarget = {
259
- agent: string
260
- model?: { providerID: string; modelID: string }
261
- requested: string
262
- }
263
-
264
185
  function formatCliError(error: unknown): string {
265
186
  if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") {
266
187
  return "The 'akm' CLI was not found on PATH. Install it first from https://github.com/itlackey/akm."
@@ -274,138 +195,8 @@ function needsAgentSetup(message: string): boolean {
274
195
 
275
196
  function addAgentSetupGuidance(message: string): string {
276
197
  if (!needsAgentSetup(message)) return message
277
- if (/\bakm init\b|\bakm_init\b|\bakm setup\b/i.test(message)) return message
278
- return `${message}. Agents should not run akm setup because it is interactive. The plugin should set the AKM default agent to the current platform when it is missing; if interactive configuration is still needed, ask the user to run akm setup manually. Use akm_init for agent-safe stash initialization.`
279
- }
280
-
281
- function getAkmConfigPath(): string {
282
- const configHome = process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? ".", ".config")
283
- return path.join(configHome, "akm", "config.json")
284
- }
285
-
286
- function readAkmConfig(): Record<string, unknown> {
287
- try {
288
- const raw = readFileSync(getAkmConfigPath(), "utf8")
289
- const parsed = JSON.parse(raw)
290
- return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {}
291
- } catch {
292
- return {}
293
- }
294
- }
295
-
296
- function readConfiguredAgentDefault(): string {
297
- const config = readAkmConfig()
298
- // 0.8.0 canonical shape: defaults.agent. Fall back to the legacy agent.default
299
- // slot when running against a pre-0.8 config that has not been migrated yet.
300
- const defaults = config.defaults
301
- if (defaults && typeof defaults === "object") {
302
- const value = (defaults as Record<string, unknown>).agent
303
- if (typeof value === "string" && value.trim()) return value.trim()
304
- }
305
- const agent = config.agent
306
- if (agent && typeof agent === "object") {
307
- const value = (agent as Record<string, unknown>).default
308
- if (typeof value === "string" && value.trim()) return value.trim()
309
- }
310
- return ""
311
- }
312
-
313
- function writeConfiguredAgentDefault(platform: string): boolean {
314
- if (!platform.trim()) return false
315
- // #463: route through `akm config set` so akm's schema-walker / validator
316
- // is the single source of truth for the on-disk shape. Direct JSON writes
317
- // here would bypass strict-mode validation and the 5-backup ring buffer,
318
- // and historically clobbered nearby keys when the legacy `agent.default`
319
- // slot triggered an auto-migration.
320
- const command = resolveAkmCommand()
321
- if (typeof command === "object" && "ok" in command) return false
322
- try {
323
- // #463: --silent --layer user (akm-cli 0.8.0+) pins writes to the user
324
- // config layer regardless of merged-read scope and silences hook-driven
325
- // CLI output. Without them, hook writes could race with a project-layer
326
- // override or pollute the parent process stdout.
327
- execFileSync(
328
- command.command,
329
- [
330
- ...command.argsPrefix,
331
- "config",
332
- "set",
333
- "--silent",
334
- "--layer",
335
- "user",
336
- `profiles.agent.${platform}`,
337
- JSON.stringify({ platform }),
338
- ],
339
- { stdio: "ignore" },
340
- )
341
- execFileSync(
342
- command.command,
343
- [...command.argsPrefix, "config", "set", "--silent", "--layer", "user", "defaults.agent", platform],
344
- { stdio: "ignore" },
345
- )
346
- return true
347
- } catch {
348
- return false
349
- }
350
- }
351
-
352
- async function ensurePlatformAgentDefault(
353
- client: LogCapableClient,
354
- meta: { directory?: string; sessionID?: string; trigger: string; platform: string },
355
- ): Promise<boolean> {
356
- const command = resolveAkmCommand()
357
- if (typeof command === "object" && "ok" in command) {
358
- await writePluginLog(client, "warn", "AKM agent default check skipped", {
359
- subsystem: "akm",
360
- trigger: meta.trigger,
361
- sessionID: meta.sessionID,
362
- directory: meta.directory,
363
- error: command.error,
364
- })
365
- return false
366
- }
367
-
368
- try {
369
- const current = readConfiguredAgentDefault()
370
- if (current) return true
371
-
372
- if (!writeConfiguredAgentDefault(meta.platform)) {
373
- throw new Error(`Failed to write ${getAkmConfigPath()}`)
374
- }
375
- await writePluginLog(client, "info", "AKM agent default initialized", {
376
- subsystem: "akm",
377
- trigger: meta.trigger,
378
- sessionID: meta.sessionID,
379
- directory: meta.directory,
380
- agentDefault: meta.platform,
381
- })
382
- return true
383
- } catch (error: unknown) {
384
- await writePluginLog(client, "warn", "AKM agent default initialization failed", {
385
- subsystem: "akm",
386
- trigger: meta.trigger,
387
- sessionID: meta.sessionID,
388
- directory: meta.directory,
389
- platform: meta.platform,
390
- error: addAgentSetupGuidance(formatCliError(error)),
391
- stdout: toLogString((error as { stdout?: unknown }).stdout) ?? "",
392
- stderr: toLogString((error as { stderr?: unknown }).stderr) ?? "",
393
- })
394
- return false
395
- }
396
- }
397
-
398
- async function ensureAgentSetup(
399
- client: LogCapableClient,
400
- meta: { directory?: string; sessionID?: string; trigger: string; platform: string },
401
- ): Promise<boolean> {
402
- if (agentSetupPromise) return agentSetupPromise
403
- const promise = ensurePlatformAgentDefault(client, meta)
404
- const trackedPromise = promise.finally(() => {
405
- if (agentSetupPromise === trackedPromise) agentSetupPromise = null
406
- })
407
- agentSetupPromise = trackedPromise
408
- return trackedPromise
198
+ if (/\bakm setup\b/i.test(message)) return message
199
+ return `${message}. Ask the user to run akm setup manually when interactive configuration is needed.`
409
200
  }
410
201
 
411
202
  function isNotIndexedFeedbackError(message: string): boolean {
@@ -524,15 +315,12 @@ function shouldIndexOnSessionEnd(): boolean {
524
315
  return (process.env.AKM_INDEX_ON_SESSION_END ?? "0") === "1"
525
316
  }
526
317
 
527
- function buildDateTag(options?: { includeTime?: boolean }): string {
528
- const compactIso = new Date().toISOString().replace(/[-:]/g, "")
529
- return compactIso.slice(0, options?.includeTime ? CHECKPOINT_DATE_TAG_LENGTH : SESSION_DATE_TAG_LENGTH)
530
- }
531
-
532
318
  function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBufferEntry, "timestamp">) {
533
319
  if (!sessionID) return
534
320
  const buf = sessionBuffer.get(sessionID) ?? []
535
321
  buf.push({ timestamp: nowIso(), ...entry })
322
+ // Drop-oldest cap (13: "Memory leaks" — sessionBuffer was uncapped).
323
+ if (buf.length > AKM_SESSION_BUFFER_MAX_ENTRIES) buf.splice(0, buf.length - AKM_SESSION_BUFFER_MAX_ENTRIES)
536
324
  sessionBuffer.set(sessionID, buf)
537
325
  }
538
326
 
@@ -554,9 +342,110 @@ function writeCuratedFile(sessionID: string, content: string): string {
554
342
  return filePath
555
343
  }
556
344
 
557
-
558
- function isAkmRef(value: string): boolean {
559
- return AKM_REF_PATTERN.test(value)
345
+ // 13: "Memory leaks" — session.deleted cleanup only cleared sessionHints,
346
+ // sessionCurated, sessionWorkflow, sessionCuratorReport, the epoch/version
347
+ // tracking pairs, and sessionBuffer. It missed retrospectiveState,
348
+ // sessionRecallAudit, and the per-session pendingProposalSummaryCache entry
349
+ // (cacheKey is the sessionID — see getPendingProposalCount), and never
350
+ // deleted the session's curated tmp file. clearSessionState() is the single
351
+ // place every session-keyed Map/tmp-file is torn down, so a future new map
352
+ // would only need one line added here instead of another hand-maintained list
353
+ // at the session.deleted call site.
354
+ function clearSessionState(sessionID: string): void {
355
+ sessionHints.delete(sessionID)
356
+ sessionCurated.delete(sessionID)
357
+ const curatedFile = sessionCuratedFile.get(sessionID)
358
+ if (curatedFile) {
359
+ try {
360
+ rmSync(curatedFile, { force: true })
361
+ } catch {
362
+ // Best-effort: a failed tmp-file cleanup must not block session teardown.
363
+ }
364
+ }
365
+ sessionCuratedFile.delete(sessionID)
366
+ sessionWorkflow.delete(sessionID)
367
+ sessionCuratorReport.delete(sessionID)
368
+ sessionContextEpoch.delete(sessionID)
369
+ sessionContextInjectedEpoch.delete(sessionID)
370
+ sessionCuratedVersion.delete(sessionID)
371
+ sessionCuratedInjectedVersion.delete(sessionID)
372
+ sessionBuffer.delete(sessionID)
373
+ sessionLastExtractAt.delete(sessionID)
374
+ sessionRecallAudit.delete(sessionID)
375
+ pendingProposalSummaryCache.delete(sessionID)
376
+ retrospectiveState.delete(sessionID)
377
+ }
378
+
379
+ // Test-only: snapshot which session-keyed Maps still hold an entry for `sid`,
380
+ // plus the current sessionBuffer contents. Lets tests assert clearSessionState()
381
+ // actually emptied every map (13: "Memory leaks") without exporting the maps
382
+ // themselves. Mirrors the __resetResolvedAkmForTests test-only export above.
383
+ export function __sessionStateSnapshotForTests(sessionID: string): {
384
+ sessionHints: boolean
385
+ sessionCurated: boolean
386
+ sessionCuratedFile: boolean
387
+ sessionWorkflow: boolean
388
+ sessionCuratorReport: boolean
389
+ sessionContextEpoch: boolean
390
+ sessionContextInjectedEpoch: boolean
391
+ sessionCuratedVersion: boolean
392
+ sessionCuratedInjectedVersion: boolean
393
+ sessionBuffer: boolean
394
+ sessionBufferLength: number
395
+ sessionBufferRefs: string[]
396
+ sessionLastExtractAt: boolean
397
+ sessionRecallAudit: boolean
398
+ pendingProposalSummaryCache: boolean
399
+ retrospectiveState: boolean
400
+ } {
401
+ return {
402
+ sessionHints: sessionHints.has(sessionID),
403
+ sessionCurated: sessionCurated.has(sessionID),
404
+ sessionCuratedFile: sessionCuratedFile.has(sessionID),
405
+ sessionWorkflow: sessionWorkflow.has(sessionID),
406
+ sessionCuratorReport: sessionCuratorReport.has(sessionID),
407
+ sessionContextEpoch: sessionContextEpoch.has(sessionID),
408
+ sessionContextInjectedEpoch: sessionContextInjectedEpoch.has(sessionID),
409
+ sessionCuratedVersion: sessionCuratedVersion.has(sessionID),
410
+ sessionCuratedInjectedVersion: sessionCuratedInjectedVersion.has(sessionID),
411
+ sessionBuffer: sessionBuffer.has(sessionID),
412
+ sessionBufferLength: sessionBuffer.get(sessionID)?.length ?? 0,
413
+ sessionBufferRefs: (sessionBuffer.get(sessionID) ?? []).flatMap((entry) => (entry.ref ? [entry.ref] : [])),
414
+ sessionLastExtractAt: sessionLastExtractAt.has(sessionID),
415
+ sessionRecallAudit: sessionRecallAudit.has(sessionID),
416
+ pendingProposalSummaryCache: pendingProposalSummaryCache.has(sessionID),
417
+ retrospectiveState: retrospectiveState.has(sessionID),
418
+ }
419
+ }
420
+
421
+ // Test-only: expose the curated tmp-file directory so tests can assert file
422
+ // existence/absence without hardcoding os.tmpdir() path construction twice.
423
+ export function __curatedDirForTests(): string {
424
+ return CURATED_DIR
425
+ }
426
+
427
+ // Best-effort sweep of orphaned curated tmp files (13: "tmp-file cleanup").
428
+ // clearSessionState() handles the normal session.deleted path; this covers
429
+ // sessions that never fire it (host crash, forced kill). Async and fully
430
+ // error-trapped internally so a failed sweep never surfaces as an unhandled
431
+ // rejection or blocks the session.created path that triggers it.
432
+ async function pruneStaleCuratedFiles(): Promise<void> {
433
+ let entries: string[]
434
+ try {
435
+ entries = readdirSync(CURATED_DIR)
436
+ } catch {
437
+ return
438
+ }
439
+ const now = Date.now()
440
+ for (const name of entries) {
441
+ try {
442
+ const filePath = path.join(CURATED_DIR, name)
443
+ const info = statSync(filePath)
444
+ if (now - info.mtimeMs > CURATED_FILE_MAX_AGE_MS) rmSync(filePath, { force: true })
445
+ } catch {
446
+ // Best-effort per-file: a single stat/rm failure must not abort the sweep.
447
+ }
448
+ }
560
449
  }
561
450
 
562
451
  function parseMaybeJson(value: string): unknown {
@@ -585,10 +474,6 @@ function runCliSyncRaw(args: string[], timeoutMs: number): { ok: true; stdout: s
585
474
  }
586
475
  }
587
476
 
588
- function appendRunScopeArgs(args: string[], sessionID: string | undefined): string[] {
589
- return sessionID ? [...args, "--run", sessionID] : args
590
- }
591
-
592
477
  function getScopeFields(): Array<"user" | "agent" | "run" | "channel"> {
593
478
  const configured = process.env.AKM_SCOPE_KEYS?.split(",").map((part) => part.trim()).filter(Boolean)
594
479
  const values = configured && configured.length > 0 ? configured : ["user", "agent", "run", "channel"]
@@ -621,125 +506,10 @@ function buildScopedArgs(context: Record<string, unknown> | undefined): string[]
621
506
  return args
622
507
  }
623
508
 
624
- function getHarnessStatePaths(): { stateDir: string; eventLog: string; candidateLog: string; opencodeLogDir: string } {
625
- const stateDir = path.dirname(OPENCODE_EVENT_LOG)
626
- return {
627
- stateDir,
628
- eventLog: OPENCODE_EVENT_LOG,
629
- candidateLog: OPENCODE_CANDIDATE_LOG,
630
- opencodeLogDir: path.join(process.env.XDG_DATA_HOME ?? path.join(process.env.HOME ?? ".", ".local", "share"), "opencode", "log"),
631
- }
632
- }
633
-
634
- function formatPathBullet(label: string, filePath: string): string {
635
- return `- ${label}: ${filePath}`
636
- }
637
-
638
- function countByValue(values: string[]): Array<[string, number]> {
639
- const counts = new Map<string, number>()
640
- for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1)
641
- return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
642
- }
643
-
644
- function uniqueRecent<T>(values: T[], key: (value: T) => string, limit: number): T[] {
645
- const selected: T[] = []
646
- const seen = new Set<string>()
647
- for (let index = values.length - 1; index >= 0 && selected.length < limit; index -= 1) {
648
- const value = values[index]
649
- const id = key(value)
650
- if (!id || seen.has(id)) continue
651
- seen.add(id)
652
- selected.push(value)
653
- }
654
- return selected.reverse()
655
- }
656
-
657
- function sessionHasPendingCheckpointEvidence(sessionID: string | undefined): boolean {
658
- if (!sessionID) return false
659
- return (sessionBuffer.get(sessionID) ?? []).some((entry) => !entry.checkpointed)
660
- }
661
-
662
- async function ensureFreshProposalCheckpoint(
663
- client: LogCapableClient,
664
- context: { sessionID?: string; directory?: string; agent?: string },
665
- reason: string,
666
- ): Promise<string | null> {
667
- if (!context.sessionID || !sessionHasPendingCheckpointEvidence(context.sessionID)) return null
668
- const ref = captureSessionMemory(context.sessionID, reason, { checkpoint: true })
669
- if (!ref) return null
670
- await writePluginLog(client, "info", "AKM proposal checkpoint captured", {
671
- subsystem: "memory",
672
- actor: "system",
673
- sessionID: context.sessionID,
674
- directory: context.directory,
675
- agent: context.agent,
676
- reason,
677
- ref,
678
- })
679
- const indexResult = runCliSyncRaw(["index"], AKM_CURATE_TIMEOUT_MS)
680
- if (!indexResult.ok) {
681
- await writePluginLog(client, "warn", "AKM proposal checkpoint indexing failed", {
682
- subsystem: "memory",
683
- actor: "system",
684
- sessionID: context.sessionID,
685
- directory: context.directory,
686
- agent: context.agent,
687
- reason,
688
- ref,
689
- error: indexResult.error,
690
- })
691
- }
692
- return ref
693
- }
694
-
695
509
  function truncateLine(value: string, maxChars = 220): string {
696
510
  return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1)}...`
697
511
  }
698
512
 
699
- function formatEvidenceSummary(events: AkmMemoryEvent[], candidates: ReturnType<typeof readCandidates>, entries: SessionBufferEntry[]): string[] {
700
- const lines: string[] = []
701
- const toolRefs = entries.filter((entry) => entry.kind === "tool-ref" && entry.ref)
702
- const touchedRefs = countByValue(toolRefs.map((entry) => entry.ref!))
703
- const toolNames = countByValue(toolRefs.map((entry) => entry.toolName ?? "tool"))
704
- const statuses = countByValue(toolRefs.map((entry) => entry.status ?? "unknown"))
705
- const eventTypes = countByValue(events.map((event) => event.event))
706
- const candidateTypes = countByValue(candidates.map((candidate) => candidate.type))
707
-
708
- lines.push("## Evidence aggregates")
709
- lines.push(`- buffered observations: ${entries.length}`)
710
- if (toolRefs.length > 0) lines.push(`- asset-touch observations: ${toolRefs.length}`)
711
- if (touchedRefs.length > 0) lines.push(`- top refs: ${touchedRefs.slice(0, 5).map(([ref, count]) => `${ref} (${count})`).join(", ")}`)
712
- if (toolNames.length > 0) lines.push(`- tools involved: ${toolNames.slice(0, 5).map(([name, count]) => `${name} (${count})`).join(", ")}`)
713
- if (statuses.length > 0) lines.push(`- statuses: ${statuses.map(([status, count]) => `${status} (${count})`).join(", ")}`)
714
- if (eventTypes.length > 0) lines.push(`- event types: ${eventTypes.slice(0, 6).map(([event, count]) => `${event} (${count})`).join(", ")}`)
715
- if (candidateTypes.length > 0) lines.push(`- candidate types: ${candidateTypes.map(([type, count]) => `${type} (${count})`).join(", ")}`)
716
- lines.push("")
717
-
718
- const notableEvents = uniqueRecent(events, (event) => `${event.timestamp}:${event.event}:${(event.refs ?? []).join(",")}`, 5)
719
- if (notableEvents.length > 0) {
720
- lines.push("## Notable recent events")
721
- for (const event of notableEvents) {
722
- const status = event.outcome?.status ?? "unknown"
723
- const refs = Array.isArray(event.refs) && event.refs.length > 0 ? ` refs=${event.refs.join(", ")}` : ""
724
- const warning = event.outcome?.warnings?.[0] ? ` warning=${truncateLine(event.outcome.warnings[0], 120)}` : ""
725
- lines.push(`- ${event.timestamp} ${event.event} (${status})${refs}${warning}`)
726
- }
727
- lines.push("")
728
- }
729
-
730
- const notableCandidates = uniqueRecent(candidates, (candidate) => candidate.id, 5)
731
- if (notableCandidates.length > 0) {
732
- lines.push("## Candidate highlights")
733
- for (const candidate of notableCandidates) {
734
- const target = candidate.targetRef ? ` target=${candidate.targetRef}` : ""
735
- lines.push(`- [${candidate.status}] ${candidate.type}/${candidate.scope}${target} :: ${truncateLine(candidate.content, 180)}`)
736
- }
737
- lines.push("")
738
- }
739
-
740
- return lines
741
- }
742
-
743
513
  function runCurate(args: string[]): string | null {
744
514
  const result = runCliSyncRaw(args, AKM_CURATE_TIMEOUT_MS)
745
515
  if (!result.ok) return null
@@ -761,20 +531,17 @@ async function runCurateLogged(
761
531
  async function runCurateForPrompt(client: LogCapableClient, text: string, sessionID?: string): Promise<string | null> {
762
532
  if (!text || text.length < AKM_CURATE_MIN_CHARS) return null
763
533
  return runCurateLogged(client,
764
- appendRunScopeArgs(
765
- [
766
- "--shape",
767
- "agent",
768
- "--format",
769
- "text",
770
- "-q",
771
- "curate",
772
- text,
773
- "--limit",
774
- String(AKM_CURATE_LIMIT),
775
- ],
776
- sessionID,
777
- ),
534
+ [
535
+ "--shape",
536
+ "agent",
537
+ "--format",
538
+ "text",
539
+ "-q",
540
+ "curate",
541
+ text,
542
+ "--limit",
543
+ String(AKM_CURATE_LIMIT),
544
+ ],
778
545
  { toolName: "chat.message", sessionID, operation: "prompt-curate" },
779
546
  )
780
547
  }
@@ -791,7 +558,7 @@ async function runCurateForSession(client: LogCapableClient, sessionID: string,
791
558
  if (query) args.push(query)
792
559
  args.push("--limit", String(AKM_CURATE_LIMIT))
793
560
  return runCurateLogged(client,
794
- appendRunScopeArgs(args, sessionID),
561
+ args,
795
562
  { toolName: "session.start", sessionID, operation: "session-curate" },
796
563
  )
797
564
  }
@@ -822,11 +589,15 @@ function summarizeWorkflowList(value: unknown): string | null {
822
589
  ? record.workflowRef
823
590
  : null
824
591
  const state = typeof record.state === "string" ? record.state : typeof record.status === "string" ? record.status : null
825
- const step = typeof record.step === "string"
826
- ? record.step
827
- : typeof record.currentStep === "string"
828
- ? record.currentStep
829
- : null
592
+ // akm 0.9.0 run summaries carry `currentStepId`; `step`/`currentStep`
593
+ // are retained as fallbacks for older envelope shapes.
594
+ const step = typeof record.currentStepId === "string"
595
+ ? record.currentStepId
596
+ : typeof record.step === "string"
597
+ ? record.step
598
+ : typeof record.currentStep === "string"
599
+ ? record.currentStep
600
+ : null
830
601
  if (!id && !ref && !state && !step) return null
831
602
  return `- ${ref ?? "workflow"} (${id ?? "run"})${state ? ` — ${state}` : ""}${step ? ` — next: ${step}` : ""}`
832
603
  })
@@ -871,55 +642,59 @@ function formatPendingProposalContext(count: number): string {
871
642
  "# AKM pending proposals",
872
643
  "",
873
644
  summaryLine,
874
- "Use `/akm-review-proposals` or `akm_help topic=proposal` to review them.",
645
+ "Use the AKM CLI to review them; mutating proposal actions require explicit user approval.",
875
646
  PROPOSED_QUALITY_WARNING,
876
647
  ].join("\n")
877
648
  }
878
649
 
879
- function summarizeCuratorReportForContext(report: string): string {
880
- if (report.length <= AKM_CURATOR_CONTEXT_MAX_CHARS) return report
881
- return `${report.slice(0, AKM_CURATOR_CONTEXT_MAX_CHARS).trimEnd()}\n\n[truncated for context]`
882
- }
883
-
884
- async function getAkmStashDir(client?: LogCapableClient): Promise<string | undefined> {
885
- if (cachedAkmStashDir !== undefined) return cachedAkmStashDir || undefined
650
+ async function getAkmBundleDir(client?: LogCapableClient): Promise<string | undefined> {
651
+ const override = process.env.AKM_BUNDLE_DIR?.trim()
652
+ if (override) return override
653
+ if (cachedAkmBundleDir !== undefined) return cachedAkmBundleDir || undefined
886
654
  const raw = client
887
- ? await runCliSyncBestEffort(client, ["--format", "json", "-q", "config", "get", "stashDir"], AKM_CURATE_TIMEOUT_MS, {
655
+ ? await runCliSyncBestEffort(client, ["info", "--format", "json", "-q"], AKM_CURATE_TIMEOUT_MS, {
888
656
  toolName: "shell.env",
889
- subsystem: "config",
890
- operation: "get-stash-dir",
657
+ subsystem: "info",
658
+ operation: "get-bundle-dir",
891
659
  })
892
- : runCurate(["--format", "json", "-q", "config", "get", "stashDir"])
660
+ : runCurate(["info", "--format", "json", "-q"])
893
661
  if (!raw) {
894
- cachedAkmStashDir = ""
662
+ cachedAkmBundleDir = ""
895
663
  return undefined
896
664
  }
897
665
  const parsed = parseMaybeJson(raw)
898
- if (typeof parsed === "string" && parsed.trim()) {
899
- cachedAkmStashDir = parsed.trim()
900
- return cachedAkmStashDir
901
- }
902
666
  if (parsed && typeof parsed === "object") {
903
- for (const key of ["value", "path", "stashDir"]) {
904
- const value = (parsed as Record<string, unknown>)[key]
905
- if (typeof value === "string" && value.trim()) {
906
- cachedAkmStashDir = value.trim()
907
- return cachedAkmStashDir
908
- }
667
+ const value = (parsed as Record<string, unknown>).bundleDir
668
+ if (typeof value === "string" && value.trim()) {
669
+ cachedAkmBundleDir = value.trim()
670
+ return cachedAkmBundleDir
909
671
  }
910
672
  }
911
- cachedAkmStashDir = raw || ""
912
- return cachedAkmStashDir || undefined
673
+ cachedAkmBundleDir = ""
674
+ return undefined
913
675
  }
914
676
 
915
677
  function warmIndexInBackground(): void {
916
678
  const command = resolveAkmCommand()
917
679
  if (typeof command === "object" && "ok" in command) return
918
680
  try {
919
- // Fire and forget — execSync with a timeout would block, so spawn via the
920
- // shell and detach. Errors here are never surfaced to the session.
921
- const shellArgs = [command.command, ...command.argsPrefix, "index"].map((part) => JSON.stringify(part)).join(" ")
922
- execSync(`${shellArgs} >/dev/null 2>&1 &`, { timeout: 2_000 })
681
+ // Fire and forget, no shell: detached + stdio "ignore" + unref() gives the
682
+ // same "start it and walk away" semantics the old `… &` shell string had,
683
+ // without any quoting concerns. (The previous version quoted argv with
684
+ // JSON.stringify, which is not POSIX shell quoting — a resolved bunx/binary
685
+ // path containing a backslash, newline, or embedded quote would have been
686
+ // mis-parsed by the shell.) Matches maybeExtractSessionOnIdle/queueFeedback.
687
+ // Errors here are never surfaced to the session.
688
+ const child = spawn(command.command, [...command.argsPrefix, "index"], {
689
+ detached: true,
690
+ stdio: "ignore",
691
+ })
692
+ // Required: an unhandled 'error' event (e.g. ENOENT) would otherwise throw
693
+ // asynchronously, outside the try/catch below.
694
+ child.on("error", () => {
695
+ // Intentionally ignore — warming is best-effort.
696
+ })
697
+ child.unref()
923
698
  } catch {
924
699
  // Intentionally ignore — warming is best-effort.
925
700
  }
@@ -1031,7 +806,7 @@ async function recordRetrospectiveFeedback(client: LogCapableClient, sessionID:
1031
806
  }
1032
807
 
1033
808
  const targetRef = recentRefs[recentRefs.length - 1]
1034
- const raw = await runCli(client, ["feedback", targetRef, "--negative", "--note", text.slice(0, 280), ...buildScopedArgs({ sessionID })], {
809
+ const raw = await runCli(client, ["feedback", targetRef, "--negative", "--reason", text.slice(0, 280)], {
1035
810
  toolName: "akm_feedback",
1036
811
  sessionID,
1037
812
  })
@@ -1079,21 +854,14 @@ function queueFeedback(
1079
854
  command.command,
1080
855
  [
1081
856
  ...command.argsPrefix,
1082
- "--format",
1083
- "json",
1084
- "-q",
1085
857
  "feedback",
1086
858
  ref,
1087
859
  sentiment === "positive" ? "--positive" : "--negative",
1088
- "--note",
860
+ "--reason",
1089
861
  note,
1090
- ...buildScopedArgs({
1091
- sessionID: meta.sessionID,
1092
- directory: meta.directory,
1093
- agent: meta.agent,
1094
- userID: meta.userID,
1095
- channel: meta.channel,
1096
- }),
862
+ "--format",
863
+ "json",
864
+ "-q",
1097
865
  ],
1098
866
  {
1099
867
  detached: true,
@@ -1142,203 +910,6 @@ function queueFeedback(
1142
910
  }
1143
911
  }
1144
912
 
1145
- function rememberTextAsMemory(name: string, body: string, context?: Record<string, unknown>): string | null {
1146
- const command = resolveAkmCommand()
1147
- if (typeof command === "object" && "ok" in command) return null
1148
- try {
1149
- execResolvedAkm(command, ["--format", "json", "-q", "remember", "--name", name, "--force", ...buildScopedArgs(context)], {
1150
- encoding: "utf8",
1151
- timeout: AKM_CURATE_TIMEOUT_MS * 2,
1152
- input: redactSecrets(body).text,
1153
- })
1154
- return `memory:${name}`
1155
- } catch {
1156
- return null
1157
- }
1158
- }
1159
-
1160
- function captureSessionMemory(
1161
- sessionID: string,
1162
- reason: string,
1163
- options?: { checkpoint?: boolean; directory?: string; agent?: string; channel?: string; userID?: string; user?: string },
1164
- ): string | null {
1165
- if (!AKM_AUTO_MEMORY) return null
1166
- if (!sessionID) return null
1167
- const isCheckpoint = options?.checkpoint === true
1168
- if (!isCheckpoint && sessionFinalMemoryCaptured.has(sessionID)) return null
1169
- const entries = sessionBuffer.get(sessionID) ?? []
1170
- const pendingEntries = isCheckpoint ? entries.filter((entry) => !entry.checkpointed) : entries
1171
- // Require at least two observations before persisting — single events are noise.
1172
- if (pendingEntries.length < 2) {
1173
- if (!isCheckpoint) sessionBuffer.delete(sessionID)
1174
- return null
1175
- }
1176
-
1177
- const relatedEvents = readJsonl<AkmMemoryEvent>(OPENCODE_EVENT_LOG)
1178
- .filter((event) => event.sessionId === sessionID)
1179
- .slice(-12)
1180
- const relatedCandidates = readCandidates(OPENCODE_CANDIDATE_LOG)
1181
- .filter((candidate) => candidate.sessionId === sessionID)
1182
- .slice(-8)
1183
- const harnessPaths = getHarnessStatePaths()
1184
-
1185
- const lines: string[] = []
1186
- lines.push("---")
1187
- lines.push("akm_memory_kind: session_checkpoint")
1188
- lines.push("harness: opencode")
1189
- lines.push(`session_id: ${sessionID}`)
1190
- lines.push(`reason: ${reason}`)
1191
- lines.push("---")
1192
- lines.push("")
1193
- lines.push(`# Session summary (${nowIso()})`)
1194
- lines.push(`Reason: ${reason}`)
1195
- lines.push(`Session: ${sessionID}`)
1196
- lines.push("")
1197
- lines.push("## Full-detail evidence files")
1198
- lines.push(formatPathBullet("OpenCode state dir", harnessPaths.stateDir))
1199
- lines.push(formatPathBullet("Structured event log", harnessPaths.eventLog))
1200
- lines.push(formatPathBullet("Memory candidate log", harnessPaths.candidateLog))
1201
- lines.push(formatPathBullet("OpenCode host log dir", harnessPaths.opencodeLogDir))
1202
- const harnessLog = process.env.AKM_EVAL_HARNESS_LOG?.trim()
1203
- if (harnessLog) lines.push(formatPathBullet("Harness log", harnessLog))
1204
- lines.push("")
1205
- lines.push(...formatEvidenceSummary(relatedEvents, relatedCandidates, pendingEntries))
1206
- for (const entry of pendingEntries) {
1207
- if (entry.kind === "memory-intent") {
1208
- lines.push(`## ${entry.timestamp} — user memory intent`)
1209
- if (entry.note) lines.push(entry.note)
1210
- lines.push("")
1211
- } else {
1212
- lines.push(`## ${entry.timestamp} — ${entry.toolName ?? "tool"} ${entry.status ?? "unknown"}`)
1213
- if (entry.ref) lines.push(`- ref: ${entry.ref}`)
1214
- if (entry.note) lines.push(`- note: ${entry.note}`)
1215
- lines.push("")
1216
- }
1217
- }
1218
- if (relatedEvents.length > 0) {
1219
- lines.push("## Plugin event summary")
1220
- for (const event of relatedEvents) {
1221
- const status = event.outcome?.status ?? "unknown"
1222
- const refs = Array.isArray(event.refs) && event.refs.length > 0 ? ` — refs: ${event.refs.join(", ")}` : ""
1223
- lines.push(`- ${event.timestamp} — ${event.event} (${status})${refs}`)
1224
- }
1225
- lines.push("")
1226
- }
1227
- if (relatedCandidates.length > 0) {
1228
- lines.push("## Memory candidates observed")
1229
- for (const candidate of relatedCandidates) {
1230
- lines.push(`- [${candidate.status}] ${candidate.type}/${candidate.scope}: ${candidate.content}`)
1231
- }
1232
- lines.push("")
1233
- }
1234
- const body = lines.join("\n")
1235
-
1236
- const dateTag = buildDateTag({ includeTime: isCheckpoint })
1237
- const shortSid = sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
1238
- const name = isCheckpoint
1239
- ? `opencode-checkpoint-${dateTag}-${shortSid}`
1240
- : `opencode-session-${dateTag}-${shortSid}`
1241
-
1242
- const ref = rememberTextAsMemory(name, body, {
1243
- sessionID,
1244
- run: sessionID,
1245
- directory: options?.directory,
1246
- agent: options?.agent,
1247
- channel: options?.channel,
1248
- userID: options?.userID,
1249
- user: options?.user,
1250
- })
1251
- if (!ref) {
1252
- if (!isCheckpoint) {
1253
- sessionFinalMemoryCaptured.add(sessionID)
1254
- sessionBuffer.delete(sessionID)
1255
- }
1256
- return null
1257
- }
1258
-
1259
- if (isCheckpoint) {
1260
- void writeStructuredEvent({
1261
- event: "pre_compact_checkpoint",
1262
- sessionId: sessionID,
1263
- scope: buildEventScope(sessionID),
1264
- memory: { ref, reason, kind: "session_checkpoint" },
1265
- refs: [ref],
1266
- outcome: { status: "ok" },
1267
- })
1268
- const targetRefHints = pendingEntries.flatMap((entry) => entry.ref ? [entry.ref] : [])
1269
- const candidates = extractCandidatesFromText({
1270
- harness: "opencode",
1271
- sessionId: sessionID,
1272
- text: body,
1273
- evidence: [ref, reason, ...targetRefHints],
1274
- sourcePaths: [harnessPaths.eventLog, harnessPaths.candidateLog],
1275
- targetRefHints,
1276
- })
1277
- if (candidates.length > 0) {
1278
- appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
1279
- void writeStructuredEvent({
1280
- event: "candidate_extracted",
1281
- sessionId: sessionID,
1282
- scope: buildEventScope(sessionID),
1283
- memory: { sourceRef: ref, count: candidates.length },
1284
- refs: [ref],
1285
- outcome: { status: "ok" },
1286
- })
1287
- }
1288
- for (const entry of entries) {
1289
- if (!entry.checkpointed) entry.checkpointed = true
1290
- }
1291
- sessionSuccessfulAssetTouchCount.set(sessionID, 0)
1292
- sessionBuffer.set(sessionID, entries)
1293
- return ref
1294
- }
1295
-
1296
- sessionFinalMemoryCaptured.add(sessionID)
1297
- sessionBuffer.delete(sessionID)
1298
- void writeStructuredEvent({
1299
- event: "session_ended",
1300
- sessionId: sessionID,
1301
- scope: buildEventScope(sessionID),
1302
- memory: { ref, reason, kind: "session_checkpoint" },
1303
- refs: [ref],
1304
- outcome: { status: "ok" },
1305
- })
1306
- const targetRefHints = entries.flatMap((entry) => entry.ref ? [entry.ref] : [])
1307
- const candidates = extractCandidatesFromText({
1308
- harness: "opencode",
1309
- sessionId: sessionID,
1310
- text: body,
1311
- evidence: [ref, reason, ...targetRefHints],
1312
- sourcePaths: [harnessPaths.eventLog, harnessPaths.candidateLog],
1313
- targetRefHints,
1314
- })
1315
- if (candidates.length > 0) {
1316
- appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
1317
- void writeStructuredEvent({
1318
- event: "candidate_extracted",
1319
- sessionId: sessionID,
1320
- scope: buildEventScope(sessionID),
1321
- memory: { sourceRef: ref, count: candidates.length },
1322
- refs: [ref],
1323
- outcome: { status: "ok" },
1324
- })
1325
- }
1326
- return ref
1327
- }
1328
-
1329
- function maybeCheckpointSessionMemory(
1330
- sessionID: string,
1331
- options?: { directory?: string; agent?: string; channel?: string; userID?: string; user?: string },
1332
- ): string | null {
1333
- const count = sessionSuccessfulAssetTouchCount.get(sessionID) ?? 0
1334
- if (count < AKM_MEMORY_CHECKPOINT_EVERY) return null
1335
- const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true, ...options })
1336
- if (!captured) {
1337
- sessionSuccessfulAssetTouchCount.set(sessionID, 0)
1338
- }
1339
- return captured
1340
- }
1341
-
1342
913
  async function maybeIndexSessionMemory(
1343
914
  client: LogCapableClient,
1344
915
  sessionID: string,
@@ -1358,23 +929,57 @@ async function maybeIndexSessionMemory(
1358
929
  })
1359
930
  }
1360
931
 
1361
- const AKM_REF_EDGE_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "'", "\"", "`"])
1362
-
1363
- function normalizeExtractedRef(ref: string): string {
1364
- let start = 0
1365
- let end = ref.length
1366
- while (start < end && AKM_REF_EDGE_PUNCTUATION.has(ref[start] ?? "")) start += 1
1367
- while (end > start && AKM_REF_EDGE_PUNCTUATION.has(ref[end - 1] ?? "")) end -= 1
1368
- return ref.slice(start, end)
1369
- }
1370
-
1371
- function extractRefsFromText(value: string): string[] {
1372
- const refs = new Set<string>()
1373
- for (const token of value.split(/\s+/)) {
1374
- const normalized = normalizeExtractedRef(token)
1375
- if (normalized && isAkmRef(normalized)) refs.add(normalized)
932
+ // 03-R1/06-M1 kept half: the session_checkpoint `remember --force` write is
933
+ // gone, but the memory-candidate pipeline still harvests explicit
934
+ // "remember ..." intents from the session buffer at session end. There is no
935
+ // stash write and, as of 0.9, no in-product consumer either: the
936
+ // /akm-memory-promote slash command that used to review these was deleted in
937
+ // this release. Candidates are appended to the harness candidate log
938
+ // (getCandidateLogPath("opencode") -> …/akm-opencode/memory-candidates.jsonl)
939
+ // alongside a `candidate_extracted` structured event, and are read
940
+ // out-of-band. Whether to retire the pipeline or re-home the review step is a
941
+ // deliberately open maintainer decision, so the harvest stays wired up.
942
+ // Deleting the buffer after extraction is the de-dup: a re-fired lifecycle
943
+ // event finds an empty buffer and no-ops.
944
+ function maybeExtractSessionCandidates(sessionID: string, reason: string): void {
945
+ if (!AKM_AUTO_MEMORY) return
946
+ if (!sessionID) return
947
+ const entries = sessionBuffer.get(sessionID) ?? []
948
+ // Require at least two observations before persisting — single events are noise.
949
+ if (entries.length < 2) {
950
+ // Below the noise floor, only the terminal session.deleted event may
951
+ // discard the buffer (the old discard-noise-at-session-end behavior).
952
+ // Non-terminal events (session.idle fires at every turn's quiescence,
953
+ // session.compacted mid-session) must KEEP a below-floor buffer so a
954
+ // lone "remember ..." intent from one turn survives to pair with an
955
+ // observation from a later turn instead of being wiped at each idle.
956
+ if (reason === "session.deleted") sessionBuffer.delete(sessionID)
957
+ return
1376
958
  }
1377
- return [...refs]
959
+ const targetRefHints = entries.flatMap((entry) => (entry.ref ? [entry.ref] : []))
960
+ const text = entries
961
+ .filter((entry) => entry.kind === "memory-intent" && entry.note)
962
+ .map((entry) => entry.note as string)
963
+ .join("\n")
964
+ const candidates = extractCandidatesFromText({
965
+ harness: "opencode",
966
+ sessionId: sessionID,
967
+ text,
968
+ evidence: [reason, ...targetRefHints],
969
+ sourcePaths: [OPENCODE_EVENT_LOG, OPENCODE_CANDIDATE_LOG],
970
+ targetRefHints,
971
+ })
972
+ if (candidates.length > 0) {
973
+ appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
974
+ void writeStructuredEvent({
975
+ event: "candidate_extracted",
976
+ sessionId: sessionID,
977
+ scope: buildEventScope(sessionID),
978
+ memory: { count: candidates.length },
979
+ outcome: { status: "ok" },
980
+ })
981
+ }
982
+ sessionBuffer.delete(sessionID)
1378
983
  }
1379
984
 
1380
985
  function extractToolRefs(
@@ -1386,7 +991,7 @@ function extractToolRefs(
1386
991
  const positiveOnlyRefs = new Set<string>()
1387
992
  const addMatches = (value: unknown) => {
1388
993
  if (typeof value !== "string") return
1389
- for (const ref of extractRefsFromText(value)) refs.add(ref)
994
+ for (const ref of extractAkmRefsFromString(value)) refs.add(ref)
1390
995
  }
1391
996
 
1392
997
  for (const key of ["ref", "package_ref"]) {
@@ -1407,15 +1012,6 @@ function extractToolRefs(
1407
1012
  }
1408
1013
  }
1409
1014
  if (toolName === "akm_remember" && typeof o.ref === "string") addMatches(o.ref)
1410
- if (
1411
- (toolName === "akm_agent" || toolName === "akm_cmd" || toolName === "akm_evolve")
1412
- && typeof o.text === "string"
1413
- ) {
1414
- for (const ref of extractRefsFromText(o.text)) {
1415
- refs.add(ref)
1416
- positiveOnlyRefs.add(ref)
1417
- }
1418
- }
1419
1015
  }
1420
1016
 
1421
1017
  return { refs: [...refs], positiveOnlyRefs: [...positiveOnlyRefs] }
@@ -1442,7 +1038,7 @@ const AKM_HINTS_PREFIX = [
1442
1038
  "",
1443
1039
  "**Choosing the right lookup command:**",
1444
1040
  "",
1445
- "- **`akm_curate`** — use this when starting any new task, looking for patterns, docs, skills, or workflows. This is the PRIMARY lookup command. v0.8.0 automatically boosts assets that match the current project (cwd-anchored project-context ranking), so an explicit project name in the query is no longer required for ranking — but it still helps the reranker frame intent.",
1041
+ "- **`akm_curate`** — use this when starting any new task, looking for patterns, docs, skills, or workflows. This is the PRIMARY lookup command. akm automatically boosts assets that match the current project (cwd-anchored project-context ranking), so an explicit project name in the query is not required for ranking — but it still helps the reranker frame intent.",
1446
1042
  ' - Good: `akm_curate("akm CLI improve command performance analysis")` (explicit framing, still ideal)',
1447
1043
  ' - Bad: `akm_curate("improve performance analysis")` (too generic — the reranker has less to work with even with auto-boost)',
1448
1044
  "- **`akm_search` (known name)** — use ONLY when you already know an asset exists (e.g. after `akm_show` returned \"not found\") and need to locate its exact ref. Do not use as a discovery tool.",
@@ -1491,140 +1087,6 @@ function applyContextBudget(blocks: string[]): string[] {
1491
1087
  return injected
1492
1088
  }
1493
1089
 
1494
- // Curated quick-reference for the long-tail of `akm` CLI verbs that no longer
1495
- // have a dedicated tool wrapper. Surfaced through akm_help so agents can
1496
- // always find the right invocation without polluting default context.
1497
- type AkmHelpEntry = {
1498
- task: string
1499
- command: string
1500
- notes?: string
1501
- keywords: string[]
1502
- }
1503
-
1504
- const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
1505
- {
1506
- task: "Review pending proposals and decide whether to accept, reject, or revise them",
1507
- command: "akm proposal list --status pending --format json; akm proposal show <id>; akm proposal diff <id>",
1508
- notes: "Accept/reject requires explicit user approval.",
1509
- keywords: ["proposal", "review proposals", "pending proposals", "accept proposal", "reject proposal"],
1510
- },
1511
- {
1512
- task: "Bulk-triage the standing pending proposal backlog by policy",
1513
- command: "akm proposal drain --policy <personal-stash|conservative|manual> --dry-run",
1514
- notes: "Mutating: promotes/rejects in bulk and commits to git (no batch revert). Preview with --dry-run, then --promote --yes after explicit approval. Supersedes the old manual proposal-management agent session; also runs as the processes.triage improve pre-pass.",
1515
- keywords: ["proposal", "drain", "triage", "backlog", "bulk accept", "bulk reject"],
1516
- },
1517
- {
1518
- task: "Improve existing assets or distill repeated evidence into proposals",
1519
- command: "akm improve [<type>|<ref>] [--task \"...\"]",
1520
- notes: "Improve owns the former reflect/distill flow; proposed assets are not curated until accepted. Profiles add a processes.triage pre-pass and end-of-run sync.",
1521
- keywords: ["improve", "lesson", "reflect", "distill", "drift", "failure"],
1522
- },
1523
- {
1524
- task: "Manage scheduled task assets via the OS scheduler",
1525
- command: "akm tasks <add|list|show|remove|enable|disable|run|history|sync|doctor> ...",
1526
- notes: "Tasks are first-class in v0.8.0 but remain a long-tail CLI surface in this plugin.",
1527
- keywords: ["tasks", "scheduled task", "cron", "launchd", "schtasks"],
1528
- },
1529
- {
1530
- task: "Create a proposed asset for a coverage gap",
1531
- command: "akm propose <type> <name> --task \"...\"",
1532
- notes: PROPOSED_QUALITY_WARNING,
1533
- keywords: ["propose", "coverage gap", "proposed asset"],
1534
- },
1535
- {
1536
- task: "Search including proposed-quality assets",
1537
- command: "akm search <query> --include-proposed",
1538
- notes: PROPOSED_QUALITY_WARNING,
1539
- keywords: ["include-proposed", "proposed quality", "lesson"],
1540
- },
1541
- {
1542
- task: "Install a kit or register an external source (npm, GitHub, git, URL, local dir)",
1543
- command: "akm add <package-ref> [--name <n>] [--type wiki] [--writable] [--provider <p>] [--max-pages N] [--max-depth N] [--allow-insecure]",
1544
- notes: "Confirm with the user before registering a website crawler or passing --allow-insecure.",
1545
- keywords: ["add", "install", "register", "kit", "source", "github", "npm"],
1546
- },
1547
- {
1548
- task: "Commit and push pending stash changes",
1549
- command: "akm sync [<source-name>] [-m <msg>] [--no-push]",
1550
- notes: "For writable git-backed sources, sync commits and pushes by default (pass --no-push to skip); review the diff first.",
1551
- keywords: ["sync", "save", "commit", "push", "publish", "git"],
1552
- },
1553
- {
1554
- task: "Import a file (or stdin) into the stash as a typed asset",
1555
- command: "akm import <path|-> [--name <name>] [--force]",
1556
- notes: "Use `-` and pipe content via stdin to import a string.",
1557
- keywords: ["import", "ingest", "upload", "stdin"],
1558
- },
1559
- {
1560
- task: "Clone an asset from any source for editing",
1561
- command: "akm clone <ref> [--name <new>] [--dest <dir>] [--force]",
1562
- notes: "Type subdirectory is appended automatically; ref may include origin (e.g. npm:@scope/pkg//script:foo).",
1563
- keywords: ["clone", "copy", "fork", "edit"],
1564
- },
1565
- {
1566
- task: "Update a managed source (or all of them)",
1567
- command: "akm update [<package_ref>|--all] [--force]",
1568
- keywords: ["update", "upgrade kit", "refresh", "pull"],
1569
- },
1570
- {
1571
- task: "Remove a configured source and reindex",
1572
- command: "akm remove <id|ref|path|url|name>",
1573
- notes: "Destructive — confirm intent before running.",
1574
- keywords: ["remove", "uninstall", "delete source"],
1575
- },
1576
- {
1577
- task: "List configured sources (local dirs, kits, remotes)",
1578
- command: "akm list",
1579
- keywords: ["list", "sources", "kits", "show sources"],
1580
- },
1581
- {
1582
- task: "Search the registry only (skip local stash)",
1583
- command: "akm registry search <query> [--limit N] [--assets]",
1584
- notes: "akm_search with source='registry' covers most cases; this is the explicit form.",
1585
- keywords: ["registry", "search registry", "installable", "discover kit"],
1586
- },
1587
- {
1588
- task: "Build or rebuild the stash search index",
1589
- command: "akm index",
1590
- notes: "Rarely needed — the index refreshes implicitly after writes.",
1591
- keywords: ["index", "reindex", "rebuild"],
1592
- },
1593
- {
1594
- task: "Manage whole-file secrets outside the chat-safe read surface",
1595
- command: "akm secret <set|run|remove> ...",
1596
- notes: "Use the first-class akm_secret tool for list/path. Secret values must never be pasted back into chat; `set` reads from stdin/--from-file/--from-env and `run` injects into a child process only.",
1597
- keywords: ["secret", "docker secret", "pem", "token", "_FILE"],
1598
- },
1599
- {
1600
- task: "View or update akm config (get/set/list/unset/path)",
1601
- command: "akm config <action> [<key>] [<value>] [--all]",
1602
- notes: "`akm config path --all` prints config, stash, cache, and index paths.",
1603
- keywords: ["config", "settings", "configure", "path"],
1604
- },
1605
- {
1606
- task: "Check for or install an akm CLI update",
1607
- command: "akm upgrade [--check] [--force]",
1608
- keywords: ["upgrade cli", "update cli", "self-upgrade"],
1609
- },
1610
- {
1611
- task: "Run a stash script end-to-end (resolve → show → run)",
1612
- command: "akm show <script-ref> # then exec the printed `run` command",
1613
- notes: "Or `akm --format json -q show <ref>` and pipe `.run` into your shell.",
1614
- keywords: ["run", "execute", "script", "exec"],
1615
- },
1616
- ]
1617
-
1618
- function lookupAkmHelpHint(topic: string): AkmHelpEntry[] {
1619
- const needle = topic.toLowerCase().trim()
1620
- if (!needle) return []
1621
- return AKM_HELP_QUICK_REFERENCE.filter((entry) =>
1622
- entry.keywords.some((kw) => needle.includes(kw))
1623
- || entry.task.toLowerCase().includes(needle)
1624
- || entry.command.toLowerCase().includes(needle),
1625
- )
1626
- }
1627
-
1628
1090
  function extractSessionIdFromEvent(payload: unknown): string | undefined {
1629
1091
  if (!payload || typeof payload !== "object") return undefined
1630
1092
  const p = payload as Record<string, unknown>
@@ -1645,6 +1107,123 @@ function extractSessionIdFromEvent(payload: unknown): string | undefined {
1645
1107
  return undefined
1646
1108
  }
1647
1109
 
1110
+ // Cap on how much of the extract child's stdout/stderr we retain for logging.
1111
+ // The envelope we care about is a few hundred bytes; anything past this is
1112
+ // dropped so a chatty/looping child can never grow the buffer unbounded.
1113
+ const AKM_EXTRACT_OUTPUT_MAX_CHARS = 2_000
1114
+
1115
+ // `unref()` exists on the net.Socket that node hands back for a piped child
1116
+ // stream, but not on the `Readable` the @types/node signature advertises.
1117
+ // Unref'ing keeps the piped fds from holding the host's event loop open, which
1118
+ // is what preserves the fire-and-forget contract now that stdio is captured.
1119
+ function unrefChildStream(stream: unknown): void {
1120
+ const handle = stream as { unref?: () => void } | null | undefined
1121
+ if (handle && typeof handle.unref === "function") handle.unref()
1122
+ }
1123
+
1124
+ /**
1125
+ * Event-driven extraction trigger for opencode (Option #3: min-interval gate).
1126
+ * Called on `session.idle` (which fires after every turn). Extracts the session
1127
+ * into the proposal queue at most once per AKM_EXTRACT_MIN_INTERVAL_MS, so a
1128
+ * burst of turns collapses to a single periodic checkpoint instead of flooding.
1129
+ * `extract --session-id` respects the content-hash ledger, so an extract landing
1130
+ * on unchanged content is a free no-op. Fire-and-forget (detached + unref'd) so
1131
+ * it never stalls the turn; the hourly `akm improve` extract pass remains the backstop for the final delta.
1132
+ *
1133
+ * The outcome is reported through the normal plugin log + telemetry channels.
1134
+ * This is the only remaining memory-harvest path in 0.9, and on a default
1135
+ * install it does not work: `akm proposal extract` needs an LLM engine, and
1136
+ * without one it answers `{ ok: false, code: "LLM_NOT_CONFIGURED", … }`. Real
1137
+ * akm prints that envelope on stderr and exits non-zero, but the shape is not
1138
+ * guaranteed across builds (the fake in evals/lib/fake-akm.ts models a build
1139
+ * that returns the same `ok: false` body while exiting 0). Discarding the
1140
+ * child's output — the previous `stdio: "ignore"` — therefore turned the most
1141
+ * likely failure in the whole feature into a silent no-op with nothing to
1142
+ * grep for. We now capture the envelope and treat `ok: false` as a failure
1143
+ * regardless of exit status, so the actionable code/hint reaches the log.
1144
+ */
1145
+ function maybeExtractSessionOnIdle(client: LogCapableClient, sid: string, directory: string | undefined): void {
1146
+ const now = Date.now()
1147
+ const last = sessionLastExtractAt.get(sid) ?? 0
1148
+ if (now - last < AKM_EXTRACT_MIN_INTERVAL_MS) return
1149
+ const command = resolveAkmCommand()
1150
+ if (typeof command === "object" && "ok" in command) return // akm unavailable — cron backstop covers it
1151
+ sessionLastExtractAt.set(sid, now)
1152
+
1153
+ const reportExtractFailure = (error: string, extra?: Record<string, unknown>): void => {
1154
+ void writePluginLog(client, "warn", "AKM extract failed", {
1155
+ subsystem: "extract",
1156
+ sessionID: sid,
1157
+ directory,
1158
+ error,
1159
+ ...extra,
1160
+ })
1161
+ void emitWorkflowTelemetry(client, "warn", "akm.extract.failed", {
1162
+ sessionID: sid,
1163
+ directory,
1164
+ toolName: "session.idle",
1165
+ outcome: "error",
1166
+ reason: error,
1167
+ ...extra,
1168
+ })
1169
+ }
1170
+
1171
+ try {
1172
+ const child = spawn(
1173
+ command.command,
1174
+ [...command.argsPrefix, "proposal", "extract", "--type", "opencode", "--session-id", sid, "--format", "json", "-q"],
1175
+ {
1176
+ detached: true,
1177
+ // Piped rather than ignored so the `ok:false` envelope is observable;
1178
+ // both pipes are unref'd below so this stays fire-and-forget.
1179
+ stdio: ["ignore", "pipe", "pipe"],
1180
+ },
1181
+ )
1182
+ let output = ""
1183
+ for (const stream of [child.stdout, child.stderr]) {
1184
+ if (!stream) continue
1185
+ unrefChildStream(stream)
1186
+ stream.setEncoding("utf8")
1187
+ stream.on("data", (chunk: string) => {
1188
+ if (output.length < AKM_EXTRACT_OUTPUT_MAX_CHARS) output += chunk
1189
+ })
1190
+ // A pipe torn down with the detached child must not raise here.
1191
+ stream.on("error", () => {})
1192
+ }
1193
+ // "close" rather than "exit": it fires once the piped stdio has also been
1194
+ // drained, so `output` is complete when we inspect the envelope.
1195
+ child.on("close", (code, signal) => {
1196
+ const body = output.trim().slice(0, AKM_EXTRACT_OUTPUT_MAX_CHARS)
1197
+ const envelope = safeJsonParse<{ ok?: boolean; error?: string; code?: string; hint?: string }>(body)
1198
+ const exitFailed = (typeof code === "number" && code !== 0) || !!signal
1199
+ if (envelope?.ok === false || exitFailed) {
1200
+ const reason = envelope?.error
1201
+ ?? (signal ? `akm extract exited via signal ${signal}` : `akm extract exited with code ${code}`)
1202
+ reportExtractFailure(reason, {
1203
+ akmCode: envelope?.code,
1204
+ hint: envelope?.hint,
1205
+ exitCode: code,
1206
+ // Only fall back to the raw body when it was not parseable JSON —
1207
+ // otherwise the structured fields above already carry everything.
1208
+ output: envelope ? undefined : truncateLogText(body, 400) || undefined,
1209
+ })
1210
+ return
1211
+ }
1212
+ void writePluginLog(client, "info", "AKM extract completed", {
1213
+ subsystem: "extract",
1214
+ sessionID: sid,
1215
+ directory,
1216
+ })
1217
+ })
1218
+ child.on("error", (error) => {
1219
+ reportExtractFailure(formatCliError(error))
1220
+ })
1221
+ child.unref()
1222
+ } catch (error: unknown) {
1223
+ reportExtractFailure(formatCliError(error))
1224
+ }
1225
+ }
1226
+
1648
1227
  function extractFirstSemverMatch(value: string): string | null {
1649
1228
  return value.match(SEMVER_PATTERN)?.[0] ?? null
1650
1229
  }
@@ -1691,8 +1270,19 @@ function getLocalBuildAkmCommand(): ResolvedAkmCommand | null {
1691
1270
  }
1692
1271
  }
1693
1272
 
1694
- function execResolvedAkm(command: ResolvedAkmCommand, args: string[], options?: Parameters<typeof execFileSync>[2]) {
1695
- return execFileSync(command.command, [...command.argsPrefix, ...args], options)
1273
+ // Every call site passes `encoding: "utf8"`, so the real runtime return value
1274
+ // is always a string — but `execFileSync`'s overloads resolve on the exact
1275
+ // shape of the options argument, and forwarding a loosely-typed `options`
1276
+ // parameter defeats that resolution, leaving the inferred return type
1277
+ // `string | Buffer`. Pin the options type to require `encoding: "utf8"` and
1278
+ // assert the (already-guaranteed) string return so callers get real string
1279
+ // typing without changing behavior.
1280
+ type ExecResolvedAkmOptions = Omit<NonNullable<Parameters<typeof execFileSync>[2]>, "encoding"> & {
1281
+ encoding: "utf8"
1282
+ }
1283
+
1284
+ function execResolvedAkm(command: ResolvedAkmCommand, args: string[], options: ExecResolvedAkmOptions): string {
1285
+ return execFileSync(command.command, [...command.argsPrefix, ...args], options) as string
1696
1286
  }
1697
1287
 
1698
1288
  function probeCommand(command: ResolvedAkmCommand): CommandProbe {
@@ -1707,9 +1297,19 @@ function probeCommand(command: ResolvedAkmCommand): CommandProbe {
1707
1297
  return { ...command, exists: false, version: null, failureReason: "command_not_on_disk" }
1708
1298
  }
1709
1299
  try {
1300
+ // Pipe (don't inherit) the child's stderr. Some akm builds validate the
1301
+ // user's config on EVERY invocation — including `--version` — and a version
1302
+ // skew (e.g. a bundled akm-cli@0.8.x against a config written by 0.9.x, whose
1303
+ // improve process keys 0.8 rejects) makes `--version` exit non-zero and print
1304
+ // an INVALID_CONFIG_FILE blob to stderr. With the default inherited stderr
1305
+ // that blob leaks to OpenCode's console on every plugin load, reading as a
1306
+ // plugin failure even though resolution correctly falls through to a
1307
+ // compatible akm. Capturing it keeps the probe silent; the structured
1308
+ // resolution trail / consent banner still surfaces a genuine no-akm case.
1710
1309
  const version = execResolvedAkm(command, ["--version"], {
1711
1310
  encoding: "utf8",
1712
1311
  timeout: 10_000,
1312
+ stdio: ["ignore", "pipe", "pipe"],
1713
1313
  })
1714
1314
  const parsed = extractFirstSemverMatch(version)
1715
1315
  if (!parsed) {
@@ -1725,13 +1325,6 @@ function probeCommand(command: ResolvedAkmCommand): CommandProbe {
1725
1325
  }
1726
1326
  }
1727
1327
 
1728
- function satisfiesAkmVersionRange(version: string | null): boolean {
1729
- if (typeof version !== "string") return false
1730
- const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/)
1731
- if (!match) return false
1732
- return Number(match[1]) === 0 && Number(match[2]) === 8
1733
- }
1734
-
1735
1328
  function getBundledAkmCommand(): string | null {
1736
1329
  const packagePath = path.join(moduleDir, "node_modules", "akm-cli", "package.json")
1737
1330
  const fallback = path.join(moduleDir, "node_modules", ".bin", process.platform === "win32" ? "akm.cmd" : "akm")
@@ -1786,18 +1379,28 @@ let lastAkmResolutionTrail: AkmResolutionTrail = []
1786
1379
 
1787
1380
  function getResolvedAkmDetails(): { command: string; argsPrefix: string[]; displayCommand: string; version: string; source: "bundled" | "path" | "local_build" } | null {
1788
1381
  const candidates: Array<{ command: string; argsPrefix: string[]; displayCommand: string; source: "bundled" | "path" | "local_build" }> = []
1789
- // Opt-out (default off): force the plugin to ignore its bundled akm-cli and
1790
- // fall through to AKM_LOCAL_BUILD_CLI / PATH. Used by the eval harness so a
1791
- // deterministic fake `akm` on PATH wins over the real bundled dependency;
1792
- // also a useful escape hatch when a bundled dep is broken.
1793
- const ignoreBundled = process.env.AKM_OPENCODE_IGNORE_BUNDLED_CLI === "1"
1794
- const bundled = ignoreBundled ? null : getBundledAkmCommand()
1795
- if (bundled) candidates.push({ command: bundled, argsPrefix: [], displayCommand: bundled, source: "bundled" })
1382
+ // Resolution precedence — first compatible candidate wins:
1383
+ // 1. AKM_LOCAL_BUILD_CLI — explicit dev override
1384
+ // 2. PATH / user installs — the akm the user actually installed, which wrote
1385
+ // their config and has its native deps (e.g. embeddings) built
1386
+ // 3. bundled akm-cli — last-resort fallback for users with no akm
1387
+ // The bundled CLI is deliberately LAST: preferring it over the user's own
1388
+ // install would ignore a newer user akm that understands a newer config and
1389
+ // route through a bundled copy whose native postinstalls may be unbuilt. A
1390
+ // config-INCOMPATIBLE candidate fails its `--version` probe — older akm builds
1391
+ // validate config on every invocation and exit non-zero — so it is skipped
1392
+ // silently and the version probe doubles as a config-compatibility gate.
1796
1393
  const localBuild = getLocalBuildAkmCommand()
1797
1394
  if (localBuild) candidates.push({ ...localBuild, source: "local_build" })
1798
1395
  for (const command of getPathAkmCandidates()) {
1799
1396
  candidates.push({ command, argsPrefix: [], displayCommand: command, source: "path" })
1800
1397
  }
1398
+ // Opt-out (default off): drop the bundled fallback entirely. Used by the eval
1399
+ // harness so a deterministic fake `akm` on PATH is the only candidate; also a
1400
+ // useful escape hatch when the bundled dep is broken.
1401
+ const ignoreBundled = process.env.AKM_OPENCODE_IGNORE_BUNDLED_CLI === "1"
1402
+ const bundled = ignoreBundled ? null : getBundledAkmCommand()
1403
+ if (bundled) candidates.push({ command: bundled, argsPrefix: [], displayCommand: bundled, source: "bundled" })
1801
1404
 
1802
1405
  const trail: AkmResolutionTrail = []
1803
1406
  const seen = new Set<string>()
@@ -1829,10 +1432,11 @@ function getResolvedAkmDetails(): { command: string; argsPrefix: string[]; displ
1829
1432
  // The OpenCode plugin has never silently auto-installed akm-cli — it relies on
1830
1433
  // the bundled binary that ships with the plugin, falling back to PATH. When
1831
1434
  // neither path produces a compatible akm we log a warn-level event to the host
1832
- // AND write a stderr banner so the human running OpenCode actually sees the
1833
- // problem. The banner mirrors the Claude plugin's wording: install must be
1834
- // user-driven, never automatic. The recommended consent point is `akm setup`
1835
- // (or the host-specific akm setup slash command if one exists).
1435
+ // AND emit a consent banner through the host's structured logging channel so
1436
+ // the human running OpenCode actually sees the problem. The banner mirrors
1437
+ // the Claude plugin's wording: install must be user-driven, never automatic.
1438
+ // The recommended consent point is `akm setup` (or the host-specific akm
1439
+ // setup slash command if one exists).
1836
1440
  async function ensureSupportedAkmResolved(client: LogCapableClient): Promise<void> {
1837
1441
  const installedAkm = getResolvedAkmDetails()
1838
1442
  if (!installedAkm) {
@@ -1844,7 +1448,7 @@ async function ensureSupportedAkmResolved(client: LogCapableClient): Promise<voi
1844
1448
  reason: "no_supported_command",
1845
1449
  trail: lastAkmResolutionTrail,
1846
1450
  })
1847
- writeAkmConsentBanner({
1451
+ await writeAkmConsentBanner(client, {
1848
1452
  detected: getCommandVersion("akm") ?? undefined,
1849
1453
  bundled: getBundledAkmCommand(),
1850
1454
  trail: lastAkmResolutionTrail,
@@ -1862,7 +1466,7 @@ async function ensureSupportedAkmResolved(client: LogCapableClient): Promise<voi
1862
1466
  })
1863
1467
  }
1864
1468
 
1865
- function writeAkmConsentBanner(info: { detected?: string; bundled?: string | null; trail?: AkmResolutionTrail }) {
1469
+ async function writeAkmConsentBanner(client: LogCapableClient, info: { detected?: string; bundled?: string | null; trail?: AkmResolutionTrail }) {
1866
1470
  const detectedLabel = info.detected ?? "(not found on PATH)"
1867
1471
  const bundledLabel = info.bundled ?? "(none)"
1868
1472
  const trailLines: string[] = []
@@ -1884,16 +1488,23 @@ function writeAkmConsentBanner(info: { detected?: string; bundled?: string | nul
1884
1488
  "",
1885
1489
  "Reinstall or update the akm-opencode plugin so OpenCode/Bun",
1886
1490
  "installs the dependency, or install akm-cli manually:",
1887
- " bun install -g akm-cli@^0.8.0",
1888
- " npm install -g akm-cli@^0.8.0",
1491
+ ` bun install -g ${AKM_RECOMMENDED_INSTALL_REF}`,
1492
+ ` npm install -g ${AKM_RECOMMENDED_INSTALL_REF}`,
1889
1493
  "Then run `akm setup` interactively to configure the stash.",
1890
1494
  "─".repeat(60),
1891
1495
  ].join("\n")
1892
- try {
1893
- process.stderr.write(banner + "\n")
1894
- } catch {
1895
- // best-effort; never crash the plugin over a banner
1896
- }
1496
+ // AGENTS.md forbids plugin runtime code from writing to
1497
+ // console.*/stdout/stderr; route the banner through the host's structured
1498
+ // logging channel (client.app.log) instead of process.stderr.write so it
1499
+ // still reaches the user without violating that rule.
1500
+ await writePluginLog(client, "warn", "akm CLI not installed or wrong version", {
1501
+ subsystem: "akm",
1502
+ detected: detectedLabel,
1503
+ bundled: bundledLabel,
1504
+ required: AKM_REQUIRED_VERSION_RANGE,
1505
+ installRef: AKM_RECOMMENDED_INSTALL_REF,
1506
+ banner,
1507
+ })
1897
1508
  }
1898
1509
 
1899
1510
  function resolveAkmCommand(): ResolvedAkmCommand | CliError {
@@ -1937,9 +1548,9 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
1937
1548
  return JSON.stringify(command)
1938
1549
  }
1939
1550
 
1940
- // `akm improve` hard-rejects --format in 0.8.0 (cli.ts:4131-4137); never auto-inject it there.
1941
- const skipFormatInjection = args[0] === "improve"
1942
- const fullArgs = skipFormatInjection || args.includes("--format") ? [...args] : [...args, "--format", "json"]
1551
+ // --format is a global flag on every 0.9.0 verb (the 0.8.0-era `akm improve`
1552
+ // hard-reject is gone), so it is safe to auto-inject unconditionally.
1553
+ const fullArgs = args.includes("--format") ? [...args] : [...args, "--format", "json"]
1943
1554
  const proposalId = args[0] === "proposal" && typeof args[2] === "string" ? args[2] : null
1944
1555
 
1945
1556
  const recordSuccess = async (stdout: string): Promise<string> => {
@@ -1956,8 +1567,8 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
1956
1567
  })
1957
1568
  const parsed = safeJsonParse<SearchResponse>(stdout)
1958
1569
  const refs = args[0] === "search" || args[0] === "curate"
1959
- ? [...new Set([...(parsed?.hits?.flatMap((hit) => hit.ref ? [hit.ref] : []) ?? []), ...extractRefsFromText(stdout)])]
1960
- : extractRefsFromText(stdout)
1570
+ ? [...new Set([...(parsed?.hits?.flatMap((hit) => hit.ref ? [hit.ref] : []) ?? []), ...extractAkmRefsFromString(stdout)])]
1571
+ : extractAkmRefsFromString(stdout)
1961
1572
  noteRecentRefs(meta.sessionID, refs)
1962
1573
  if (meta.toolName === "akm_search") {
1963
1574
  await emitWorkflowTelemetry(client, "info", "akm.search.invoked", {
@@ -2018,9 +1629,6 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
2018
1629
  return recordSuccess(stdout)
2019
1630
  } catch (error: unknown) {
2020
1631
  let message = formatCliError(error)
2021
- if (["akm_improve", "akm_propose"].includes(meta.toolName) && needsAgentSetup(message)) {
2022
- message = addAgentSetupGuidance(message)
2023
- }
2024
1632
  message = addAgentSetupGuidance(message)
2025
1633
  await writePluginLog(client, "error", "AKM command failed", {
2026
1634
  subsystem: "akm",
@@ -2047,64 +1655,89 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
2047
1655
  }
2048
1656
  }
2049
1657
 
1658
+ async function runInProcess(
1659
+ client: LogCapableClient,
1660
+ operation: "search" | "show" | "curate",
1661
+ input: Record<string, unknown>,
1662
+ meta: CliLogMeta,
1663
+ ): Promise<string> {
1664
+ try {
1665
+ const result = operation === "search"
1666
+ ? await akmSearch(input as Parameters<typeof akmSearch>[0])
1667
+ : operation === "show"
1668
+ ? await akmShowUnified(input as Parameters<typeof akmShowUnified>[0])
1669
+ : await akmCurate(input as Parameters<typeof akmCurate>[0])
1670
+ const output = JSON.stringify(result)
1671
+ const refs = extractAkmRefsFromString(output)
1672
+ noteRecentRefs(meta.sessionID, refs)
1673
+ await writePluginLog(client, "info", "AKM in-process call completed", {
1674
+ subsystem: "akm",
1675
+ toolName: meta.toolName,
1676
+ sessionID: meta.sessionID,
1677
+ directory: meta.directory,
1678
+ operation,
1679
+ refs,
1680
+ })
1681
+ await emitWorkflowTelemetry(client, "info", `akm.${operation}.invoked`, {
1682
+ sessionID: meta.sessionID,
1683
+ toolName: meta.toolName,
1684
+ assetRef: operation === "show" ? String(input.ref ?? refs[0] ?? "") || null : refs[0] ?? null,
1685
+ outcome: "success",
1686
+ directory: meta.directory,
1687
+ })
1688
+ return output
1689
+ } catch (error: unknown) {
1690
+ const message = formatCliError(error)
1691
+ await writePluginLog(client, "error", "AKM in-process call failed", {
1692
+ subsystem: "akm",
1693
+ toolName: meta.toolName,
1694
+ sessionID: meta.sessionID,
1695
+ directory: meta.directory,
1696
+ operation,
1697
+ error: message,
1698
+ })
1699
+ await emitWorkflowTelemetry(client, "warn", `${meta.toolName}.failed`, {
1700
+ sessionID: meta.sessionID,
1701
+ toolName: meta.toolName,
1702
+ outcome: "error",
1703
+ reason: message,
1704
+ directory: meta.directory,
1705
+ })
1706
+ return JSON.stringify({ ok: false, error: message })
1707
+ }
1708
+ }
1709
+
2050
1710
  type CliError = { ok: false; error: string }
2051
- type AssetType =
2052
- | "agent"
2053
- | "command"
2054
- | "knowledge"
2055
- | "lesson"
2056
- | "memory"
2057
- | "script"
2058
- | "skill"
2059
- | "task"
2060
- | "workflow"
2061
- | "env"
2062
- | "secret"
2063
- | "wiki"
2064
1711
 
1712
+ // The AKM 0.9 asset-type vocabulary, in the singular form `--type` accepts.
1713
+ // This is exactly `akm info --format json` -> .assetTypes, sorted; keep the two
1714
+ // in step when akm adds a type. Note there is no `wiki` type in 0.9 — the entry
1715
+ // that used to be here made `type: "wiki"` a selectable enum value that akm
1716
+ // answers with an empty hit list rather than an error, i.e. a silent dead end,
1717
+ // while `instruction`, `session`, and `fact` could not be filtered for at all.
1718
+ // `any` is a tool-surface sentinel, not an akm type: it means "no filter" and is
1719
+ // stripped before the value reaches akm, so it sorts last.
2065
1720
  const ASSET_TYPES = [
2066
1721
  "agent",
2067
1722
  "command",
1723
+ "env",
1724
+ "fact",
1725
+ "instruction",
2068
1726
  "knowledge",
2069
1727
  "lesson",
2070
1728
  "memory",
2071
1729
  "script",
1730
+ "secret",
1731
+ "session",
2072
1732
  "skill",
2073
1733
  "task",
2074
1734
  "workflow",
2075
- "env",
2076
- "secret",
2077
- "wiki",
2078
1735
  "any",
2079
1736
  ] as const
2080
1737
 
2081
- type ShowAgentResponse = {
2082
- type: "agent"
2083
- name: string
2084
- path: string
2085
- description?: string
2086
- prompt?: string
2087
- toolPolicy?: unknown
2088
- modelHint?: unknown
2089
- editable?: boolean
2090
- origin?: string | null
2091
- action?: string
2092
- editHint?: string
2093
- }
2094
-
2095
- type ShowCommandResponse = {
2096
- type: "command"
2097
- name: string
2098
- path: string
2099
- description?: string
2100
- template?: string
2101
- editable?: boolean
2102
- agent?: string
2103
- origin?: string | null
2104
- action?: string
2105
- parameters?: string[]
2106
- editHint?: string
2107
- }
1738
+ // Derived from ASSET_TYPES so the enum published on the akm_search/akm_curate
1739
+ // tool surface and the type carried by search hits cannot drift apart again.
1740
+ type AssetType = Exclude<(typeof ASSET_TYPES)[number], "any">
2108
1741
 
2109
1742
  type ShowToolResponse = {
2110
1743
  type: "tool" | "script"
@@ -2154,46 +1787,6 @@ function isShowToolResponse(value: unknown): value is ShowToolResponse {
2154
1787
  && ((value as { type?: unknown }).type === "tool" || (value as { type?: unknown }).type === "script")
2155
1788
  }
2156
1789
 
2157
- function isShowAgentResponse(value: unknown): value is ShowAgentResponse {
2158
- return !!value
2159
- && typeof value === "object"
2160
- && (value as { type?: unknown }).type === "agent"
2161
- }
2162
-
2163
- function isShowCommandResponse(value: unknown): value is ShowCommandResponse {
2164
- return !!value
2165
- && typeof value === "object"
2166
- && (value as { type?: unknown }).type === "command"
2167
- }
2168
-
2169
- function parseCliJson<T>(raw: string): T | CliError {
2170
- try {
2171
- return JSON.parse(raw) as T
2172
- } catch {
2173
- return {
2174
- ok: false,
2175
- error: "akm CLI returned non-JSON output",
2176
- }
2177
- }
2178
- }
2179
-
2180
- function formatAkmInfoResponse(raw: string): string {
2181
- const parsed = parseCliJson<Record<string, unknown>>(raw)
2182
- if (isCliError(parsed)) return JSON.stringify(parsed)
2183
- return JSON.stringify({
2184
- ok: true,
2185
- pluginVersion: PLUGIN_VERSION,
2186
- pluginInstallLocation: PLUGIN_INSTALL_LOCATION,
2187
- akmInfo: parsed,
2188
- })
2189
- }
2190
-
2191
- function blockedToolResponse(args: Record<string, unknown>): string | null {
2192
- return typeof args.__akmBlocked === "string"
2193
- ? JSON.stringify({ ok: false, error: args.__akmBlocked })
2194
- : null
2195
- }
2196
-
2197
1790
  function isCliError(value: unknown): value is CliError {
2198
1791
  return !!value
2199
1792
  && typeof value === "object"
@@ -2202,62 +1795,6 @@ function isCliError(value: unknown): value is CliError {
2202
1795
  && "error" in value
2203
1796
  }
2204
1797
 
2205
- function parseModelHint(modelHint: unknown): { providerID: string; modelID: string } | undefined {
2206
- if (typeof modelHint !== "string") return undefined
2207
- const [providerID, ...modelParts] = modelHint.split("/")
2208
- const modelID = modelParts.join("/")
2209
- if (!providerID || !modelID) return undefined
2210
- return { providerID, modelID }
2211
- }
2212
-
2213
- function resolveDispatchTarget(requested: string | undefined, fallbackAgent: string): DispatchTarget {
2214
- const trimmed = requested?.trim()
2215
- if (!trimmed) {
2216
- return { agent: fallbackAgent, requested: fallbackAgent }
2217
- }
2218
- const model = parseModelHint(trimmed)
2219
- if (model) {
2220
- return { agent: fallbackAgent, model, requested: trimmed }
2221
- }
2222
- return { agent: trimmed, requested: trimmed }
2223
- }
2224
-
2225
- function parseToolPolicy(toolPolicy: unknown): Record<string, boolean> | undefined {
2226
- const result: Record<string, boolean> = {}
2227
-
2228
- const assign = (key: string, value: unknown) => {
2229
- const normalizedKey = key.trim().toLowerCase()
2230
- if (!normalizedKey) return
2231
- if (typeof value === "boolean") {
2232
- result[normalizedKey] = value
2233
- return
2234
- }
2235
- if (typeof value === "string") {
2236
- if (value === "allow") result[normalizedKey] = true
2237
- if (value === "deny") result[normalizedKey] = false
2238
- }
2239
- }
2240
-
2241
- if (typeof toolPolicy === "string") {
2242
- assign(toolPolicy, true)
2243
- return Object.keys(result).length > 0 ? result : undefined
2244
- }
2245
-
2246
- if (Array.isArray(toolPolicy)) {
2247
- for (const item of toolPolicy) {
2248
- if (typeof item === "string") assign(item, true)
2249
- }
2250
- return Object.keys(result).length > 0 ? result : undefined
2251
- }
2252
-
2253
- if (!toolPolicy || typeof toolPolicy !== "object") return undefined
2254
-
2255
- for (const [key, value] of Object.entries(toolPolicy as Record<string, unknown>)) {
2256
- assign(key, value)
2257
- }
2258
- return Object.keys(result).length > 0 ? result : undefined
2259
- }
2260
-
2261
1798
  function extractText(parts: unknown): string {
2262
1799
  if (!Array.isArray(parts)) return ""
2263
1800
  const segments: string[] = []
@@ -2294,7 +1831,7 @@ function extractMemoryRefs(toolName: string, args: Record<string, unknown>, valu
2294
1831
  if (parsed?.type === "memory") {
2295
1832
  if (typeof parsed.ref === "string" && parsed.ref) refs.add(parsed.ref)
2296
1833
  if (typeof args.ref === "string" && args.ref) refs.add(args.ref)
2297
- if (refs.size === 0 && typeof parsed.name === "string" && parsed.name) refs.add(`memory:${parsed.name}`)
1834
+ if (refs.size === 0 && typeof parsed.name === "string" && parsed.name) refs.add(`memories/${parsed.name}`)
2298
1835
  }
2299
1836
 
2300
1837
  if (Array.isArray(parsed?.hits)) {
@@ -2335,282 +1872,10 @@ function truncateLogText(value: string, limit = 1_000): string {
2335
1872
  return value.length > limit ? `${value.slice(0, limit)}…` : value
2336
1873
  }
2337
1874
 
2338
- async function searchRef(
2339
- client: LogCapableClient,
2340
- query: string,
2341
- type: AssetType | "any",
2342
- meta: CliLogMeta,
2343
- ): Promise<{ ok: true; ref: string } | CliError> {
2344
- const raw = await runCli(
2345
- client,
2346
- ["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "stash"],
2347
- meta,
2348
- )
2349
- const parsed = parseCliJson<SearchResponse>(raw)
2350
- if (isCliError(parsed)) return parsed
2351
- const ref = parsed.hits?.[0]?.ref
2352
- if (!ref) {
2353
- return {
2354
- ok: false,
2355
- error: `No stash ref matched '${query}'. Use akm_search to disambiguate, then retry with an exact ref.`,
2356
- }
2357
- }
2358
- return { ok: true, ref }
2359
- }
2360
-
2361
- async function resolveRefOrQueryInput(
2362
- client: LogCapableClient,
2363
- input: { ref?: string; query?: string },
2364
- type: AssetType | "any",
2365
- meta: CliLogMeta,
2366
- ): Promise<{ ok: true; ref: string } | CliError> {
2367
- const explicitRef = input.ref?.trim()
2368
- if (explicitRef) return { ok: true, ref: explicitRef }
2369
-
2370
- const query = input.query?.trim()
2371
- if (!query) {
2372
- return { ok: false, error: "Provide either 'ref' or 'query'." }
2373
- }
2374
- return searchRef(client, query, type, meta)
2375
- }
2376
-
2377
- async function ensureTargetSessionID(input: {
2378
- useSubtask: boolean
2379
- context: { sessionID: string; directory: string }
2380
- title: string
2381
- client: PluginClient
2382
- logClient: LogCapableClient
2383
- toolName: string
2384
- }): Promise<{ ok: true; sessionID: string } | CliError> {
2385
- if (!input.useSubtask) return { ok: true, sessionID: input.context.sessionID }
2386
-
2387
- try {
2388
- const created = await input.client.session.create({
2389
- body: { parentID: input.context.sessionID, title: input.title },
2390
- })
2391
- if (created.error || !created.data?.id) {
2392
- const reason = created.error ? JSON.stringify(created.error) : "missing child session id"
2393
- await writePluginLog(input.logClient, "error", "AKM dispatch child session failed", {
2394
- subsystem: "dispatch",
2395
- toolName: input.toolName,
2396
- sessionID: input.context.sessionID,
2397
- directory: input.context.directory,
2398
- title: input.title,
2399
- error: reason,
2400
- })
2401
- return { ok: false, error: `Failed to create child session: ${reason}` }
2402
- }
2403
- await writePluginLog(input.logClient, "info", "AKM dispatch child session created", {
2404
- subsystem: "dispatch",
2405
- toolName: input.toolName,
2406
- sessionID: input.context.sessionID,
2407
- directory: input.context.directory,
2408
- childSessionID: created.data.id,
2409
- title: input.title,
2410
- })
2411
- return { ok: true, sessionID: created.data.id }
2412
- } catch (error: unknown) {
2413
- const reason = error instanceof Error ? error.message : String(error)
2414
- await writePluginLog(input.logClient, "error", "AKM dispatch child session threw", {
2415
- subsystem: "dispatch",
2416
- toolName: input.toolName,
2417
- sessionID: input.context.sessionID,
2418
- directory: input.context.directory,
2419
- title: input.title,
2420
- error: reason,
2421
- })
2422
- return { ok: false, error: `Failed to create child session: ${reason}` }
2423
- }
2424
- }
2425
-
2426
- async function promptTargetSession(input: {
2427
- client: PluginClient
2428
- logClient: LogCapableClient
2429
- toolName: string
2430
- context: { sessionID: string; directory: string }
2431
- targetSessionID: string
2432
- promptBody: SessionPromptBody
2433
- failureMessage: string
2434
- ref?: string
2435
- }): Promise<{ ok: true; data: { parts?: unknown } } | CliError> {
2436
- try {
2437
- const promptResponse = await input.client.session.prompt({
2438
- path: { id: input.targetSessionID },
2439
- body: input.promptBody,
2440
- })
2441
-
2442
- if (promptResponse.error || !promptResponse.data) {
2443
- const reason = promptResponse.error ? JSON.stringify(promptResponse.error) : "empty response"
2444
- await writePluginLog(input.logClient, "error", "AKM dispatch prompt failed", {
2445
- subsystem: "dispatch",
2446
- toolName: input.toolName,
2447
- sessionID: input.context.sessionID,
2448
- directory: input.context.directory,
2449
- targetSessionID: input.targetSessionID,
2450
- dispatchAgent: input.promptBody.agent,
2451
- dispatchModel: input.promptBody.model ?? null,
2452
- ref: input.ref,
2453
- error: reason,
2454
- })
2455
- return {
2456
- ok: false,
2457
- error: `${input.failureMessage}: ${reason}`,
2458
- }
2459
- }
2460
-
2461
- await writePluginLog(input.logClient, "info", "AKM dispatch prompt completed", {
2462
- subsystem: "dispatch",
2463
- toolName: input.toolName,
2464
- sessionID: input.context.sessionID,
2465
- directory: input.context.directory,
2466
- targetSessionID: input.targetSessionID,
2467
- dispatchAgent: input.promptBody.agent,
2468
- dispatchModel: input.promptBody.model ?? null,
2469
- ref: input.ref,
2470
- })
2471
- return { ok: true, data: promptResponse.data }
2472
- } catch (error: unknown) {
2473
- const reason = error instanceof Error ? error.message : String(error)
2474
- await writePluginLog(input.logClient, "error", "AKM dispatch prompt threw", {
2475
- subsystem: "dispatch",
2476
- toolName: input.toolName,
2477
- sessionID: input.context.sessionID,
2478
- directory: input.context.directory,
2479
- targetSessionID: input.targetSessionID,
2480
- dispatchAgent: input.promptBody.agent,
2481
- dispatchModel: input.promptBody.model ?? null,
2482
- ref: input.ref,
2483
- error: reason,
2484
- })
2485
- return {
2486
- ok: false,
2487
- error: `${input.failureMessage}: ${reason}`,
2488
- }
2489
- }
2490
- }
2491
-
2492
- async function resolveDispatchAgent(
2493
- client: PluginClient,
2494
- requestedAgent: string,
2495
- directory: string,
2496
- ): Promise<string> {
2497
- if (requestedAgent !== "akm-curator") return requestedAgent
2498
- try {
2499
- const agents = await client.app.agents({ query: { directory } })
2500
- if (agents.error) return "general"
2501
- const hasCurator = (agents.data ?? []).some((agent) => agent?.name === "akm-curator")
2502
- return hasCurator ? "akm-curator" : "general"
2503
- } catch {
2504
- return "general"
2505
- }
2506
- }
2507
-
2508
- function summarizeSessionMessages(
2509
- sessionID: string,
2510
- messages: Array<{ info?: Record<string, unknown>; parts?: unknown }>,
2511
- ) {
2512
- return {
2513
- ok: true,
2514
- sessionID,
2515
- messages: messages.map((message) => {
2516
- const info = message.info ?? {}
2517
- const role = typeof info.role === "string" ? info.role : "unknown"
2518
- const agent = typeof info.agent === "string"
2519
- ? info.agent
2520
- : typeof info.mode === "string"
2521
- ? info.mode
2522
- : null
2523
- return {
2524
- role,
2525
- agent,
2526
- text: extractText(message.parts),
2527
- }
2528
- }),
2529
- }
2530
- }
2531
-
2532
- async function getParentSessionID(
2533
- client: PluginClient,
2534
- sessionID: string,
2535
- directory: string,
2536
- ): Promise<{ ok: true; parentID: string } | CliError> {
2537
- try {
2538
- const result = await client.session.get({
2539
- path: { id: sessionID },
2540
- query: { directory },
2541
- })
2542
- if (result.error || !result.data?.parentID) {
2543
- return { ok: false, error: "This session does not have a parent session." }
2544
- }
2545
- return { ok: true, parentID: result.data.parentID }
2546
- } catch (error: unknown) {
2547
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
2548
- }
2549
- }
2550
-
2551
- function renderCommandTemplate(template: string, rawArguments: string): string {
2552
- const args = splitArguments(rawArguments)
2553
- return template
2554
- .replace(/\$ARGUMENTS/g, rawArguments)
2555
- .replace(/\$(\d+)/g, (_m, index: string) => args[Number(index) - 1] ?? "")
2556
- }
2557
-
2558
- function normalizeSearchSource(source: "local" | "stash" | "registry" | "both"): "stash" | "registry" | "both" {
2559
- return source === "local" ? "stash" : source
2560
- }
2561
-
2562
- function createSearchArgs(input: {
2563
- query: string
2564
- type?: AssetType | "any" | string
2565
- limit?: number
2566
- source?: "local" | "stash" | "registry" | "both"
2567
- defaultSource?: "local" | "stash" | "registry" | "both"
2568
- includeProposed?: boolean
2569
- }): string[] {
2570
- const args = ["search", input.query]
2571
- if (input.type) args.push("--type", input.type)
2572
- if (input.limit) args.push("--limit", String(input.limit))
2573
- if (input.source) {
2574
- args.push("--source", normalizeSearchSource(input.source))
2575
- } else if (input.defaultSource) {
2576
- args.push("--source", normalizeSearchSource(input.defaultSource))
2577
- }
2578
- if (input.includeProposed) args.push("--include-proposed")
2579
- args.push("--detail", "normal")
2580
- return args
2581
- }
2582
-
2583
- type PluginClient = {
2584
- session: {
2585
- create: (input: {
2586
- body: { parentID: string; title: string }
2587
- }) => Promise<{ data?: { id?: string }; error?: unknown }>
2588
- get: (input: {
2589
- path: { id: string }
2590
- query?: { directory?: string }
2591
- }) => Promise<{ data?: { id?: string; parentID?: string }; error?: unknown }>
2592
- messages: (input: {
2593
- path: { id: string }
2594
- query?: { directory?: string }
2595
- }) => Promise<{ data?: Array<{ info?: Record<string, unknown>; parts?: unknown }>; error?: unknown }>
2596
- prompt: (input: {
2597
- path: { id: string }
2598
- body: SessionPromptBody
2599
- }) => Promise<{ data?: { parts?: unknown }; error?: unknown }>
2600
- }
2601
- app: {
2602
- agents: (input?: {
2603
- query?: { directory?: string }
2604
- }) => Promise<{ data?: Array<{ name?: string }>; error?: unknown }>
2605
- }
2606
- }
2607
-
2608
1875
  export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2609
1876
  await ensureSupportedAkmResolved(client as unknown as LogCapableClient)
2610
1877
 
2611
1878
  const logClient = client as unknown as LogCapableClient
2612
- const sdkClient = client as unknown as PluginClient
2613
-
2614
1879
  return {
2615
1880
  // Events cover the lifecycle boundaries that Claude Code exposes as
2616
1881
  // SessionStart / Stop / PreCompact. We use them to warm the stash, capture
@@ -2631,12 +1896,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2631
1896
  })
2632
1897
  if (!sessionContextEpoch.has(sid)) sessionContextEpoch.set(sid, 0)
2633
1898
  if (type === "session.created") {
2634
- await ensureAgentSetup(logClient, {
2635
- directory,
2636
- sessionID: sid,
2637
- trigger: "session.created",
2638
- platform: "opencode",
2639
- })
1899
+ // Best-effort, fire-and-forget, fully error-trapped internally —
1900
+ // must never block or fail session.created (13: "tmp-file cleanup").
1901
+ void pruneStaleCuratedFiles()
2640
1902
  warmIndexInBackground()
2641
1903
  if (AKM_AUTO_CURATE && !sessionCurated.has(sid)) {
2642
1904
  const cwdContext = gatherCwdContext(directory)
@@ -2661,73 +1923,49 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2661
1923
  }
2662
1924
  } else if (type === "session.compacted" || type === "session.idle" || type === "session.deleted") {
2663
1925
  if (!sid) return
2664
- const captured = captureSessionMemory(sid, type, { directory })
2665
- if (captured) {
2666
- await writePluginLog(logClient, "info", "AKM session memory captured", {
2667
- subsystem: "memory",
2668
- actor: "system",
2669
- sessionID: sid,
2670
- reason: type,
2671
- ref: captured,
2672
- })
2673
- await maybeIndexSessionMemory(logClient, sid, type, captured)
2674
- // Auto-signal so improve picks up this session memory on the next run.
2675
- runCliSyncRaw(["feedback", captured, "--positive", "--note", "session checkpoint: auto-signal for improve eligibility"], AKM_CURATE_TIMEOUT_MS)
1926
+ // 03-R1/06-M1: the session_checkpoint `remember --force` write is
1927
+ // removed. Keep the freshness reindex on session-end events so
1928
+ // upstream inference/graph passes still run.
1929
+ await maybeIndexSessionMemory(logClient, sid, type, "")
1930
+ // `stop` is not a real OpenCode hook (not in the Hooks contract), so
1931
+ // the memory-candidate harvest has to ride real lifecycle events
1932
+ // instead: idle (per-turn quiescence), compacted, and deleted. This
1933
+ // is safe to fire on all three — a harvest always clears the buffer,
1934
+ // so a later event finds it empty and no-ops rather than
1935
+ // double-harvesting. A below-noise-floor buffer is kept across
1936
+ // non-terminal events (so intents accumulate across turns) and only
1937
+ // discarded on the terminal session.deleted.
1938
+ maybeExtractSessionCandidates(sid, type)
1939
+ // Event-driven extraction: only on session.idle (per-turn quiescence),
1940
+ // min-interval-gated so it doesn't flood. Not on compacted/deleted.
1941
+ if (type === "session.idle") {
1942
+ maybeExtractSessionOnIdle(logClient, sid, directory)
2676
1943
  }
2677
1944
  if (type === "session.compacted") {
1945
+ // 03-R1/06-M1: the session_checkpoint capture that used to feed
1946
+ // `memory.ref` here was removed along with the `remember --force`
1947
+ // write. Record the event as an explicit no-capture so
1948
+ // post_compact_summary consumers see a skipped outcome instead of
1949
+ // a dangling/undefined ref.
2678
1950
  writeStructuredEvent({
2679
1951
  event: "post_compact_summary",
2680
1952
  sessionId: sid,
2681
1953
  scope: buildEventScope(sid, directory),
2682
- memory: { ref: captured ?? null, reason: type },
2683
- outcome: { status: captured ? "ok" : "skipped" },
1954
+ memory: { ref: null, reason: type },
1955
+ outcome: { status: "skipped" },
2684
1956
  })
2685
1957
  }
2686
- // Drop per-session state so a re-created session does not inherit
2687
- // stale hints/curation.
1958
+ // Drop per-session state (every session-keyed Map, plus the curated
1959
+ // tmp file) so a re-created session does not inherit stale
1960
+ // hints/curation and the tmp file does not leak (13: "Memory leaks").
2688
1961
  if (type === "session.deleted") {
2689
- sessionHints.delete(sid)
2690
- sessionCurated.delete(sid)
2691
- sessionCuratedFile.delete(sid)
2692
- sessionWorkflow.delete(sid)
2693
- sessionCuratorReport.delete(sid)
2694
- sessionContextEpoch.delete(sid)
2695
- sessionContextInjectedEpoch.delete(sid)
2696
- sessionCuratedVersion.delete(sid)
2697
- sessionCuratedInjectedVersion.delete(sid)
2698
- sessionFinalMemoryCaptured.delete(sid)
2699
- sessionSuccessfulAssetTouchCount.delete(sid)
2700
- sessionBuffer.delete(sid)
1962
+ clearSessionState(sid)
2701
1963
  }
2702
1964
  }
2703
1965
  } catch (error: unknown) {
2704
1966
  await logHookFailure(logClient, "event", error)
2705
1967
  }
2706
1968
  },
2707
- // Stop is the closest analogue to Claude's Stop/SubagentStop — the user or
2708
- // agent halted the active run. Flush the session buffer so learnings are
2709
- // preserved even if the session.idle event does not fire.
2710
- stop: async (input: unknown) => {
2711
- try {
2712
- const sid = extractSessionIdFromEvent(input)
2713
- if (!sid) return
2714
- const captured = captureSessionMemory(sid, "stop", { directory })
2715
- if (captured) {
2716
- await writePluginLog(logClient, "info", "AKM session memory captured", {
2717
- subsystem: "memory",
2718
- actor: "system",
2719
- sessionID: sid,
2720
- reason: "stop",
2721
- ref: captured,
2722
- })
2723
- await maybeIndexSessionMemory(logClient, sid, "stop", captured)
2724
- // Auto-signal so improve picks up this session memory on the next run.
2725
- runCliSyncRaw(["feedback", captured, "--positive", "--note", "session checkpoint: auto-signal for improve eligibility"], AKM_CURATE_TIMEOUT_MS)
2726
- }
2727
- } catch (error: unknown) {
2728
- await logHookFailure(logClient, "stop", error)
2729
- }
2730
- },
2731
1969
  // experimental.chat.system.transform is how OpenCode exposes the
2732
1970
  // additionalContext channel. We append the cached hints (once per session)
2733
1971
  // and the curated file reference (once per turn) so the next LLM call
@@ -2772,53 +2010,12 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2772
2010
  await logHookFailure(logClient, "experimental.chat.system.transform", error)
2773
2011
  }
2774
2012
  },
2775
- "tool.execute.before": async (input, output) => {
2776
- try {
2777
- if (!input.tool.startsWith("akm_")) return
2778
- const args = output.args && typeof output.args === "object" ? output.args as Record<string, unknown> : {}
2779
- const confirm = args.confirm === true
2780
- if (input.tool === "akm_env" && (args.action === "path" || args.action === "run") && !confirm) {
2781
- output.args = {
2782
- ...args,
2783
- __akmBlocked: `akm_env action='${String(args.action)}' requires confirm:true because env reads are sensitive.`,
2784
- }
2785
- return
2786
- }
2787
- if (input.tool === "akm_secret" && args.action === "path" && !confirm) {
2788
- output.args = {
2789
- ...args,
2790
- __akmBlocked: "akm_secret action='path' requires confirm:true because secret paths are sensitive.",
2791
- }
2792
- return
2793
- }
2794
- if (input.tool === "akm_memory" && ["promote", "reject"].includes(String(args.action ?? "")) && !confirm) {
2795
- output.args = {
2796
- ...args,
2797
- __akmBlocked: `akm_memory action='${String(args.action)}' requires confirm:true because it mutates candidate or memory state.`,
2798
- }
2799
- return
2800
- }
2801
- if (input.tool === "akm_proposal" && ["accept", "reject", "drain"].includes(String(args.action ?? "")) && !confirm) {
2802
- output.args = {
2803
- ...args,
2804
- __akmBlocked: `akm_proposal action='${String(args.action)}' requires confirm:true because it mutates the proposal queue.`,
2805
- }
2806
- return
2807
- }
2808
- output.args = args
2809
- } catch (error: unknown) {
2810
- await logHookFailure(logClient, "tool.execute.before", error, {
2811
- toolName: input?.tool,
2812
- sessionID: input?.sessionID,
2813
- })
2814
- }
2815
- },
2816
2013
  "shell.env": async (_input, output) => {
2817
2014
  try {
2818
2015
  output.env.AKM_PROJECT = worktree
2819
2016
  output.env.AKM_PLUGIN_VERSION = PLUGIN_VERSION
2820
- const stashDir = await getAkmStashDir(logClient)
2821
- if (stashDir) output.env.AKM_STASH_DIR = stashDir
2017
+ const bundleDir = await getAkmBundleDir(logClient)
2018
+ if (bundleDir) output.env.AKM_BUNDLE_DIR = bundleDir
2822
2019
  } catch (error: unknown) {
2823
2020
  await logHookFailure(logClient, "shell.env", error)
2824
2021
  }
@@ -2850,8 +2047,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2850
2047
  const directorySnapshot = directory
2851
2048
  const agentSnapshot = input.agent
2852
2049
  const previewText = text
2853
- // Record the audit row immediately so callers (akm_memory audit)
2854
- // see the decision; the `injectedRefs` field is populated lazily
2050
+ // Record the audit row immediately; the `injectedRefs` field is populated lazily
2855
2051
  // when the background curate resolves.
2856
2052
  sessionRecallAudit.set(sessionID, {
2857
2053
  shouldRecall: true,
@@ -2864,7 +2060,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2864
2060
  void (async () => {
2865
2061
  try {
2866
2062
  const curated = await runCurateForPrompt(logClient, decision.query, sessionID)
2867
- const refs = [...new Set((curated ?? "").match(/(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|lesson|script|workflow|task|env|secret|wiki):[A-Za-z0-9._/-]+/g) ?? [])]
2063
+ // Shared 0.9 concept-ID extractor. The inline regex this
2064
+ // replaced still matched the pre-0.9 `type:slug` ref form
2065
+ // (`skill:code-review`, plus a `wiki:` type that no longer
2066
+ // exists), so against real 0.9 curate output it matched nothing
2067
+ // and both the recall audit and the prompt_recall event
2068
+ // recorded an empty ref list on every turn.
2069
+ const refs = extractAkmRefsFromString(curated ?? "")
2868
2070
  const prior = sessionRecallAudit.get(sessionID)
2869
2071
  if (prior) {
2870
2072
  sessionRecallAudit.set(sessionID, {
@@ -2951,7 +2153,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2951
2153
  const recentRefs = (sessionBuffer.get(input.sessionID) ?? [])
2952
2154
  .filter((entry) => entry.kind === "tool-ref" && !!entry.ref)
2953
2155
  .map((entry) => entry.ref!)
2954
- .filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("env:") && !ref.startsWith("secret:") && refs.indexOf(ref) === index)
2156
+ .filter((ref, index, refs) => !/^(?:.*\/\/)?(?:memories|env|secrets)\//.test(ref) && refs.indexOf(ref) === index)
2955
2157
  .slice(-3)
2956
2158
  const dedupe = new Set<string>()
2957
2159
  for (const ref of recentRefs) {
@@ -2995,7 +2197,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2995
2197
 
2996
2198
  const allArgRefs = extractAkmRefsFromAllArgs(input.args as Record<string, unknown>)
2997
2199
  const allOutputRefs = extractAkmRefsFromString(output.output)
2998
- const allRefs = [...new Set([...allArgRefs, ...allOutputRefs])]
2200
+ const candidateRefs = [...new Set([...allArgRefs, ...allOutputRefs])]
2201
+ const parsedForRefs = isAkmTool ? parseToolOutput(output.output) : null
2202
+ const allRefs = isAkmTool && parsedForRefs
2203
+ ? extractToolRefs(input.tool, input.args as Record<string, unknown>, parsedForRefs).refs
2204
+ : validateRefCandidates(candidateRefs, [await getAkmBundleDir(logClient) ?? ""])
2999
2205
 
3000
2206
  if (allRefs.length > 0) {
3001
2207
  writeStructuredEvent({
@@ -3065,25 +2271,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3065
2271
  status: feedback ?? "unknown",
3066
2272
  })
3067
2273
  }
3068
- if (feedback === "positive") {
3069
- sessionSuccessfulAssetTouchCount.set(
3070
- input.sessionID,
3071
- (sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1,
3072
- )
3073
- const checkpointRef = maybeCheckpointSessionMemory(input.sessionID, {
3074
- directory,
3075
- agent: input.tool,
3076
- })
3077
- if (checkpointRef) {
3078
- await writePluginLog(logClient, "info", "AKM checkpoint memory captured", {
3079
- subsystem: "memory",
3080
- actor: "system",
3081
- sessionID: input.sessionID,
3082
- reason: "checkpoint",
3083
- ref: checkpointRef,
3084
- })
3085
- }
3086
- }
3087
2274
  }
3088
2275
 
3089
2276
  if (
@@ -3101,10 +2288,10 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3101
2288
  : `opencode auto: ${input.tool} failed`
3102
2289
  for (const ref of feedbackRefs) {
3103
2290
  // Skip refs that should never receive auto-feedback. Matches the
3104
- // claude-side hook: memory/env/secret/lesson are excluded, including
3105
- // origin-qualified forms like `local//lesson:foo`. Lessons take
2291
+ // claude-side hook: memories/env/secrets/lessons are excluded, including
2292
+ // bundle-qualified forms like `local//lessons/foo`. Lessons take
3106
2293
  // feedback through the proposal queue, not via direct akm feedback.
3107
- if (/^(?:.*\/\/)?(?:memory|env|secret|lesson):/.test(ref)) continue
2294
+ if (/^(?:.*\/\/)?(?:memories|env|secrets|lessons)\//.test(ref)) continue
3108
2295
  const directInput = Object.values(input.args as Record<string, unknown>).some((value) => typeof value === "string" && value.includes(ref))
3109
2296
  const signal = classifyFeedbackSignal({
3110
2297
  ref,
@@ -3156,1094 +2343,137 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3156
2343
  }
3157
2344
  },
3158
2345
  tool: {
3159
- akm_info: tool({
3160
- description: "Show `akm info` output plus the installed akm-opencode plugin version and install location.",
3161
- args: {},
3162
- async execute() {
3163
- const raw = await runCli(client as unknown as LogCapableClient, ["info"], { toolName: "akm_info" })
3164
- return formatAkmInfoResponse(raw)
3165
- },
3166
- }),
3167
- akm_search: tool({
3168
- description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, memories, lessons, tasks, workflows, env configs, secrets, and wikis. Use source='registry' for installable community kits.",
3169
- args: {
3170
- query: tool.schema.string().describe("Case-insensitive substring search."),
3171
- type: tool.schema
3172
- .enum(ASSET_TYPES as unknown as [string, ...string[]])
3173
- .optional()
3174
- .describe("Optional type filter. Defaults to 'any'."),
3175
- limit: tool.schema.number().optional().describe("Maximum number of hits to return. Defaults to 20."),
3176
- source: tool.schema
3177
- .enum(["local", "stash", "registry", "both"])
3178
- .optional()
3179
- .describe("Search source. 'stash' searches local stash directories, 'registry' searches registries, and 'both' searches all sources. 'local' remains a backward-compatible alias for 'stash'."),
3180
- include_proposed: tool.schema.boolean().optional().describe("Include proposed-quality results. Proposed assets are not curated until accepted."),
3181
- },
3182
- async execute({ query, type, limit, source, include_proposed }, context) {
3183
- const raw = await runCli(
3184
- client as unknown as LogCapableClient,
3185
- createSearchArgs({ query, type, limit, source, includeProposed: include_proposed }),
3186
- { toolName: "akm_search", sessionID: context.sessionID, directory: context.directory },
3187
- )
3188
- return withProposedWarnings(raw)
3189
- },
3190
- }),
3191
- akm_show: tool({
3192
- description: "Show a stash asset by ref. For knowledge assets, use view_mode to retrieve specific content (toc, section, lines, frontmatter).",
3193
- args: {
3194
- ref: tool.schema.string().describe("Asset reference returned by akm_search."),
3195
- view_mode: tool.schema
3196
- .enum(["full", "toc", "frontmatter", "section", "lines"])
3197
- .optional()
3198
- .describe("View mode for knowledge assets. Defaults to 'full'. Ignored for other types."),
3199
- heading: tool.schema.string().optional()
3200
- .describe("Section heading to extract (required when view_mode is 'section')."),
3201
- start_line: tool.schema.number().optional()
3202
- .describe("Start line number, 1-based (for view_mode 'lines')."),
3203
- end_line: tool.schema.number().optional()
3204
- .describe("End line number, 1-based inclusive (for view_mode 'lines')."),
3205
- },
3206
- async execute({ ref, view_mode, heading, start_line, end_line }) {
3207
- const args = ["show", ref]
3208
- if (view_mode) {
3209
- args.push(view_mode)
3210
- if (view_mode === "section" && heading) args.push(heading)
3211
- if (view_mode === "lines") {
3212
- if (start_line != null) args.push(String(start_line))
3213
- if (end_line != null) args.push(String(end_line))
3214
- }
3215
- }
3216
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_show" })
3217
- },
3218
- }),
3219
- akm_remember: tool({
3220
- description: "Record a memory in the default AKM stash so it can be searched and shown later.",
3221
- args: {
3222
- content: tool.schema.string().describe("Memory content to store."),
3223
- name: tool.schema.string().optional().describe("Optional memory name."),
3224
- force: tool.schema.boolean().optional().describe("Overwrite an existing memory with the same name."),
3225
- },
3226
- async execute({ content, name, force }, context) {
3227
- const args = ["remember", content]
3228
- if (name) args.push("--name", name)
3229
- if (force) args.push("--force")
3230
- args.push(...buildScopedArgs(context as unknown as Record<string, unknown>))
3231
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_remember", sessionID: context.sessionID, directory: context.directory })
3232
- },
3233
- }),
3234
- akm_feedback: tool({
3235
- description: "Record positive or negative feedback for a stash asset so AKM can improve future ranking.",
3236
- args: {
3237
- ref: tool.schema.string().describe("Asset ref to record feedback for."),
3238
- sentiment: tool.schema.enum(["positive", "negative"]).describe("Whether the feedback is positive or negative."),
3239
- note: tool.schema.string().optional().describe("Optional note to attach to the feedback."),
3240
- },
3241
- async execute({ ref, sentiment, note }, context) {
3242
- const args = ["feedback", ref, sentiment === "positive" ? "--positive" : "--negative"]
3243
- if (note) args.push("--note", note)
3244
- args.push(...buildScopedArgs(context as unknown as Record<string, unknown>))
3245
- const raw = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_feedback", sessionID: context.sessionID, directory: context.directory })
3246
- const parsed = safeJsonParse<{ ok?: boolean; error?: string }>(raw)
3247
- if (parsed?.ok === false) {
3248
- const error = parsed.error ?? "Unknown akm feedback error"
3249
- if (isNotIndexedFeedbackError(error)) {
3250
- await writePluginLog(logClient, "warn", "AKM feedback skipped", {
3251
- subsystem: "feedback",
3252
- toolName: "akm_feedback",
3253
- sessionID: context.sessionID,
3254
- directory: context.directory,
3255
- ref,
3256
- sentiment,
3257
- reason: "ref_not_indexed",
3258
- error,
3259
- })
3260
- await emitWorkflowTelemetry(logClient, "warn", "akm.feedback.skipped", {
3261
- sessionID: context.sessionID,
3262
- toolName: "akm_feedback",
3263
- assetRef: ref,
3264
- outcome: "skipped",
3265
- reason: "ref not indexed",
3266
- directory: context.directory,
3267
- })
3268
- return JSON.stringify({ ok: true, skipped: true, reason: "ref_not_indexed", ref, sentiment })
3269
- }
3270
- return raw
3271
- }
3272
- await emitWorkflowTelemetry(logClient, "info", "akm.feedback.recorded", {
3273
- sessionID: context.sessionID,
3274
- toolName: "akm_feedback",
3275
- assetRef: ref,
3276
- outcome: "success",
3277
- reason: sentiment,
3278
- directory: context.directory,
3279
- })
3280
- return raw
3281
- },
3282
- }),
3283
- akm_memory: tool({
3284
- description: "Audit AKM memory behavior, inspect pending candidates, and promote or reject candidates. Mutating actions require confirm:true.",
3285
- args: {
3286
- action: tool.schema.enum(["audit", "candidates", "promote", "reject", "checkpoint", "sync"]).describe("Memory action."),
3287
- candidate_id: tool.schema.string().optional().describe("Candidate id for promote/reject."),
3288
- reason: tool.schema.string().optional().describe("Reason for rejection."),
3289
- scope: tool.schema.enum(["last-prompt", "session", "refs", "safety"]).optional().describe("Audit scope."),
3290
- status: tool.schema.enum(["pending", "promoted", "rejected"]).optional().describe("Candidate status filter."),
3291
- confirm: tool.schema.boolean().optional().describe("Must be true for mutate actions."),
3292
- },
3293
- async execute(input, context) {
3294
- const blocked = blockedToolResponse(input as Record<string, unknown>)
3295
- if (blocked) return blocked
3296
- if (input.action === "audit") {
3297
- const events = readJsonl<AkmMemoryEvent>(OPENCODE_EVENT_LOG)
3298
- const sessionEvents = context.sessionID ? events.filter((event) => event.sessionId === context.sessionID) : events
3299
- const recall = sessionRecallAudit.get(context.sessionID)
3300
- const recentWrites = sessionEvents.filter((event) => ["durable_memory_written", "pre_compact_checkpoint", "session_ended"].includes(event.event)).slice(-5)
3301
- const recentSafetyBlocks = sessionEvents.filter((event) => event.event === "safety_blocked").slice(-5)
3302
- const refs = [...new Set(sessionEvents.flatMap((event) => event.refs ?? []))]
3303
- const audit = input.scope === "last-prompt"
3304
- ? { lastRecall: recall ?? null }
3305
- : input.scope === "refs"
3306
- ? { refs }
3307
- : input.scope === "safety"
3308
- ? { recentSafetyBlocks }
3309
- : input.scope === "session"
3310
- ? { lastRecall: recall ?? null, recentWrites, recentSafetyBlocks, refs }
3311
- : { lastRecall: recall ?? null, recentWrites, recentSafetyBlocks, refs }
3312
- return JSON.stringify({
3313
- ok: true,
3314
- audit,
3315
- })
3316
- }
3317
- if (input.action === "candidates") {
3318
- const candidates = readCandidates(OPENCODE_CANDIDATE_LOG)
3319
- .filter((candidate) => !input.status || candidate.status === input.status)
3320
- .filter((candidate) => !context.sessionID || candidate.sessionId === context.sessionID)
3321
- return JSON.stringify({ ok: true, candidates })
3322
- }
3323
- if (input.action === "checkpoint") {
3324
- const ref = captureSessionMemory(context.sessionID, "manual-checkpoint", {
3325
- checkpoint: true,
3326
- directory: context.directory,
3327
- agent: context.agent,
3328
- channel: (context as Record<string, unknown>).channel as string | undefined,
3329
- userID: (context as Record<string, unknown>).userID as string | undefined,
3330
- user: (context as Record<string, unknown>).user as string | undefined,
3331
- })
3332
- return JSON.stringify({ ok: true, ref })
3333
- }
3334
- if (input.action === "sync") {
3335
- return JSON.stringify({ ok: true, synced: true, events: readJsonl<AkmMemoryEvent>(OPENCODE_EVENT_LOG).length, candidates: readCandidates(OPENCODE_CANDIDATE_LOG).length })
3336
- }
3337
- if (!input.candidate_id) return JSON.stringify({ ok: false, error: "'candidate_id' is required for promote/reject." })
3338
- const candidates = readCandidates(OPENCODE_CANDIDATE_LOG)
3339
- const candidate = candidates.find((entry) => entry.id === input.candidate_id)
3340
- if (!candidate) return JSON.stringify({ ok: false, error: `Unknown candidate '${input.candidate_id}'.` })
3341
- if (input.action === "reject") {
3342
- const updated = updateCandidateStatus(OPENCODE_CANDIDATE_LOG, candidate.id, "rejected", input.reason)
3343
- writeStructuredEvent({
3344
- event: "candidate_rejected",
3345
- sessionId: context.sessionID,
3346
- scope: buildEventScope(context.sessionID, context.directory, context.agent),
3347
- memory: { candidateId: candidate.id, reason: input.reason ?? null },
3348
- outcome: { status: updated ? "ok" : "failed" },
3349
- })
3350
- return JSON.stringify({ ok: !!updated, candidate: updated ?? null })
3351
- }
3352
- if (input.action === "promote") {
3353
- let rawResult: string | null = null
3354
- if (candidate.recommendedAction === "remember") {
3355
- rawResult = await runCli(client as unknown as LogCapableClient, ["remember", candidate.content, "--name", `candidate-${candidate.id}`, "--force", ...buildScopedArgs(context as unknown as Record<string, unknown>)], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
3356
- } else if (candidate.recommendedAction === "feedback" && candidate.targetRef) {
3357
- rawResult = await runCli(client as unknown as LogCapableClient, ["feedback", candidate.targetRef, "--positive", "--note", candidate.content, ...buildScopedArgs(context as unknown as Record<string, unknown>)], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
3358
- } else if (candidate.recommendedAction === "distill" && candidate.targetRef) {
3359
- rawResult = await runCli(client as unknown as LogCapableClient, ["improve", candidate.targetRef], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
3360
- } else if (candidate.recommendedAction === "propose") {
3361
- rawResult = await runCli(client as unknown as LogCapableClient, ["propose", "knowledge", `candidate-${candidate.id}`, "--task", candidate.content], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
3362
- } else {
3363
- return JSON.stringify({ ok: false, error: "Candidate recommendedAction is 'ignore'; reject it instead." })
3364
- }
3365
- const parsedResult = rawResult ? parseMaybeJson(rawResult) : null
3366
- const resultOk = typeof parsedResult === "object" && parsedResult !== null && "ok" in parsedResult
3367
- ? parsedResult.ok !== false
3368
- : !!rawResult
3369
- if (!resultOk) {
3370
- writeStructuredEvent({
3371
- event: "candidate_promoted",
3372
- sessionId: context.sessionID,
3373
- scope: buildEventScope(context.sessionID, context.directory, context.agent),
3374
- memory: { candidateId: candidate.id, recommendedAction: candidate.recommendedAction },
3375
- outcome: { status: "failed", warnings: ["candidate promotion command did not succeed"] },
3376
- })
3377
- return JSON.stringify({ ok: false, candidate, result: parsedResult ?? rawResult })
3378
- }
3379
- const updated = updateCandidateStatus(OPENCODE_CANDIDATE_LOG, candidate.id, "promoted")
3380
- writeStructuredEvent({
3381
- event: "candidate_promoted",
3382
- sessionId: context.sessionID,
3383
- scope: buildEventScope(context.sessionID, context.directory, context.agent),
3384
- memory: { candidateId: candidate.id, recommendedAction: candidate.recommendedAction },
3385
- outcome: { status: updated ? "ok" : "failed" },
3386
- })
3387
- return JSON.stringify({ ok: !!updated, candidate: updated ?? null, result: parsedResult ?? rawResult })
3388
- }
3389
- return JSON.stringify({ ok: false, error: `Unsupported action '${String(input.action)}'.` })
3390
- },
3391
- }),
3392
- akm_curate: tool({
3393
- description: "Curate stash assets for a task or topic. Returns the top matches as a ranked list so the agent can inspect and use them.",
3394
- args: {
3395
- query: tool.schema.string().describe("Task, topic, or natural-language description of what you want to do."),
3396
- limit: tool.schema.number().optional().describe("Maximum number of curated matches to return. Defaults to 6."),
3397
- detail: tool.schema.enum(["summary", "normal", "full"]).optional().describe("Detail level for each match. Defaults to 'summary'."),
3398
- },
3399
- async execute({ query, limit, detail }, context) {
3400
- const args = ["curate", query, "--limit", String(limit ?? 6)]
3401
- if (detail) args.push("--detail", detail)
3402
- args.push(...buildScopedArgs(context as unknown as Record<string, unknown>))
3403
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_curate", sessionID: context.sessionID, directory: context.directory })
3404
- },
3405
- }),
3406
- akm_evolve: tool({
3407
- description: "Dispatch the AKM curator agent to review recent session activity and propose stash improvements (promote hot assets, flag cold ones, draft missing coverage). Persists the report as a memory and seeds the curator-context cache so it survives compaction.",
3408
- args: {
3409
- focus: tool.schema.string().optional().describe("Optional focus area or theme to weight the review toward."),
3410
- dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the curator with. Defaults to 'akm-curator', or falls back to 'general' when that agent is unavailable."),
3411
- as_subtask: tool.schema.boolean().optional().describe("Run in a child session with parent context. Defaults to true."),
3412
- },
3413
- async execute({ focus, dispatch_agent, as_subtask }, context) {
3414
- await ensureFreshProposalCheckpoint(logClient, {
3415
- sessionID: context.sessionID,
3416
- directory: context.directory,
3417
- agent: context.agent,
3418
- }, "pre-evolve")
3419
- const useSubtask = as_subtask ?? true
3420
- const requestedAgent = dispatch_agent ?? "akm-curator"
3421
- const targetAgent = await resolveDispatchAgent(sdkClient, requestedAgent, context.directory)
3422
- const targetSession = await ensureTargetSessionID({
3423
- useSubtask,
3424
- context: { sessionID: context.sessionID, directory: context.directory },
3425
- title: "akm:curator",
3426
- client: sdkClient,
3427
- logClient,
3428
- toolName: "akm_evolve",
3429
- })
3430
- if (!targetSession.ok) return JSON.stringify(targetSession)
3431
-
3432
- const task = focus && focus.trim()
3433
- ? `Review recent AKM activity with an emphasis on: ${focus.trim()}. Produce the prioritized action list described in the system prompt.`
3434
- : "Review recent AKM activity and produce the prioritized action list described in the system prompt."
3435
-
3436
- const promptResponse = await promptTargetSession({
3437
- client: sdkClient,
3438
- logClient,
3439
- toolName: "akm_evolve",
3440
- context: { sessionID: context.sessionID, directory: context.directory },
3441
- targetSessionID: targetSession.sessionID,
3442
- failureMessage: "Failed to dispatch curator",
3443
- promptBody: {
3444
- agent: targetAgent,
3445
- system: targetAgent === "akm-curator" ? undefined : CURATOR_AGENT_PROMPT,
3446
- parts: [{ type: "text", text: task }],
3447
- },
3448
- })
3449
- if (!promptResponse.ok) return JSON.stringify(promptResponse)
3450
-
3451
- const fullText = extractText(promptResponse.data.parts)
3452
- sessionCuratorReport.set(context.sessionID, summarizeCuratorReportForContext(fullText))
3453
- markContextEpochDirty(context.sessionID)
3454
- const dateTag = buildDateTag()
3455
- const shortSid = context.sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
3456
- const curatorMemoryRef = fullText
3457
- ? rememberTextAsMemory(`akm-curator-${dateTag}-${shortSid}`, fullText, context as unknown as Record<string, unknown>)
3458
- : null
3459
-
3460
- return JSON.stringify({
3461
- ok: true,
3462
- dispatchAgent: targetAgent,
3463
- usedSubtask: useSubtask,
3464
- sessionID: targetSession.sessionID,
3465
- focus: focus ?? null,
3466
- curatorMemoryRef,
3467
- text: fullText,
3468
- })
3469
- },
3470
- }),
3471
- akm_parent_messages: tool({
3472
- description: "Read compact text summaries of the parent session's messages so a dispatched AKM subagent can inherit upstream context.",
3473
- args: {},
3474
- async execute(_input, context) {
3475
- try {
3476
- const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
3477
- if (!parent.ok) return JSON.stringify(parent)
3478
- const messages = await sdkClient.session.messages({
3479
- path: { id: parent.parentID },
3480
- query: { directory: context.directory },
3481
- })
3482
- if (messages.error || !messages.data) {
3483
- return JSON.stringify({ ok: false, error: "Failed to read parent session messages." })
3484
- }
3485
- return JSON.stringify(summarizeSessionMessages(parent.parentID, messages.data))
3486
- } catch (error: unknown) {
3487
- await writePluginLog(logClient, "error", "AKM parent session read failed", {
3488
- subsystem: "session",
3489
- toolName: "akm_parent_messages",
3490
- sessionID: context.sessionID,
3491
- directory: context.directory,
3492
- error: formatCliError(error),
3493
- })
3494
- return JSON.stringify({ ok: false, error: "Failed to read parent session messages." })
3495
- }
3496
- },
3497
- }),
3498
- akm_session_messages: tool({
3499
- description: "Read compact text summaries for a specific OpenCode session. Arbitrary session IDs are restricted to the akm-curator agent; other agents may read only their current or parent session.",
3500
- args: {
3501
- session_id: tool.schema.string().describe("OpenCode session ID to inspect."),
3502
- },
3503
- async execute({ session_id }, context) {
3504
- try {
3505
- const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
3506
- const allowedSessionIDs = new Set<string>([context.sessionID])
3507
- if (parent.ok) allowedSessionIDs.add(parent.parentID)
3508
- if (context.agent !== "akm-curator" && !allowedSessionIDs.has(session_id)) {
3509
- return JSON.stringify({
3510
- ok: false,
3511
- error: "akm_session_messages only allows arbitrary session IDs for the akm-curator agent. Use akm_parent_messages for parent context.",
3512
- })
3513
- }
3514
- const messages = await sdkClient.session.messages({
3515
- path: { id: session_id },
3516
- query: { directory: context.directory },
3517
- })
3518
- if (messages.error || !messages.data) {
3519
- return JSON.stringify({ ok: false, error: `Failed to read messages for session '${session_id}'.` })
3520
- }
3521
- return JSON.stringify(summarizeSessionMessages(session_id, messages.data))
3522
- } catch (error: unknown) {
3523
- await writePluginLog(logClient, "error", "AKM session messages read failed", {
3524
- subsystem: "session",
3525
- toolName: "akm_session_messages",
3526
- sessionID: context.sessionID,
3527
- directory: context.directory,
3528
- targetSessionID: session_id,
3529
- error: formatCliError(error),
3530
- })
3531
- return JSON.stringify({ ok: false, error: `Failed to read messages for session '${session_id}'.` })
3532
- }
3533
- },
3534
- }),
3535
- akm_agent: tool({
3536
- description: "Dispatch a stash agent by ref into a child OpenCode session, applying the agent prompt and metadata from akm_show.",
3537
- args: {
3538
- ref: tool.schema.string().optional().describe("Agent ref from akm_search (e.g. agent:my-agent.md)."),
3539
- query: tool.schema.string().optional().describe("If ref is omitted, resolve best matching stash agent for this query."),
3540
- task_prompt: tool.schema.string().describe("Task prompt sent to the dispatched OpenCode agent."),
3541
- dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the task with, or a provider/model override like 'openai/gpt-5.3-codex'. Defaults to 'general'."),
3542
- as_subtask: tool.schema.boolean().optional().describe("Run in child session with parent context. Defaults to true."),
3543
- },
3544
- async execute({ ref, query, task_prompt, dispatch_agent, as_subtask }, context) {
3545
- const logMeta = {
3546
- toolName: "akm_agent",
3547
- directory: context.directory,
3548
- sessionID: context.sessionID,
3549
- }
3550
- const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "agent", logMeta)
3551
- if (!resolved.ok) return JSON.stringify(resolved)
3552
-
3553
- const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
3554
- const shown = parseCliJson<ShowAgentResponse | { type: string }>(shownRaw)
3555
- if (isCliError(shown)) {
3556
- return JSON.stringify(shown)
3557
- }
3558
-
3559
- if (!isShowAgentResponse(shown)) {
3560
- return JSON.stringify({
3561
- ok: false,
3562
- error: `Ref ${ref} is not an agent payload from akm_show.`,
3563
- })
3564
- }
3565
-
3566
- if (!shown.prompt || !shown.prompt.trim()) {
3567
- return JSON.stringify({
3568
- ok: false,
3569
- error: `Agent ${shown.name} is missing prompt content.`,
3570
- })
3571
- }
3572
-
3573
- const useSubtask = as_subtask ?? true
3574
- const dispatchTarget = resolveDispatchTarget(dispatch_agent, "general")
3575
- const hintedModel = parseModelHint(shown.modelHint)
3576
- const model = dispatchTarget.model ?? hintedModel
3577
- const tools = parseToolPolicy(shown.toolPolicy)
3578
- writeStructuredEvent({
3579
- event: "subagent_started",
3580
- sessionId: context.sessionID,
3581
- scope: buildEventScope(context.sessionID, context.directory, context.agent),
3582
- input: {
3583
- ref: resolved.ref,
3584
- stashAgent: shown.name,
3585
- dispatchAgent: dispatchTarget.agent,
3586
- requestedDispatchTarget: dispatchTarget.requested,
3587
- taskPrompt: task_prompt,
3588
- advisoryToolPolicy: shown.toolPolicy ?? null,
3589
- appliedToolPolicy: tools ?? null,
3590
- modelHint: shown.modelHint ?? null,
3591
- appliedModel: model ?? null,
3592
- },
3593
- refs: [resolved.ref],
3594
- outcome: { status: "ok" },
3595
- })
3596
-
3597
- const targetSession = await ensureTargetSessionID({
3598
- useSubtask,
3599
- context: { sessionID: context.sessionID, directory: context.directory },
3600
- title: `akm:${shown.name}`,
3601
- client: client as unknown as PluginClient,
3602
- logClient,
3603
- toolName: "akm_agent",
3604
- })
3605
- if (!targetSession.ok) return JSON.stringify(targetSession)
3606
-
3607
- const promptBody: SessionPromptBody = {
3608
- agent: dispatchTarget.agent,
3609
- system: shown.prompt,
3610
- parts: [{ type: "text", text: task_prompt }],
3611
- }
3612
- if (model) promptBody.model = model
3613
- if (tools) promptBody.tools = tools
3614
-
3615
- const promptResponse = await promptTargetSession({
3616
- client: client as unknown as PluginClient,
3617
- logClient,
3618
- toolName: "akm_agent",
3619
- context: { sessionID: context.sessionID, directory: context.directory },
3620
- targetSessionID: targetSession.sessionID,
3621
- promptBody,
3622
- failureMessage: `Failed to dispatch prompt for ${resolved.ref}`,
3623
- ref: resolved.ref,
3624
- })
3625
- if (!promptResponse.ok) return JSON.stringify(promptResponse)
3626
-
3627
- const childText = extractText(promptResponse.data.parts)
3628
- const candidates = extractCandidatesFromText({
3629
- harness: "opencode",
3630
- sessionId: targetSession.sessionID,
3631
- text: childText,
3632
- evidence: [resolved.ref, task_prompt],
3633
- sourcePaths: [OPENCODE_EVENT_LOG, OPENCODE_CANDIDATE_LOG],
3634
- targetRefHints: [resolved.ref],
3635
- })
3636
- if (candidates.length > 0) appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
3637
- writeStructuredEvent({
3638
- event: "subagent_completed",
3639
- sessionId: context.sessionID,
3640
- scope: buildEventScope(context.sessionID, context.directory, context.agent),
3641
- input: {
3642
- childSessionID: targetSession.sessionID,
3643
- stashAgent: shown.name,
3644
- dispatchAgent: dispatchTarget.agent,
3645
- requestedDispatchTarget: dispatchTarget.requested,
3646
- dispatchModel: model ?? null,
3647
- },
3648
- memory: { parentSessionID: context.sessionID, childSessionID: targetSession.sessionID, candidatesCreated: candidates.length },
3649
- refs: [resolved.ref],
3650
- outcome: { status: "ok" },
3651
- })
3652
-
3653
- return JSON.stringify({
3654
- ok: true,
3655
- ref: resolved.ref,
3656
- stashAgent: shown.name,
3657
- dispatchAgent: dispatchTarget.agent,
3658
- requestedDispatchTarget: dispatchTarget.requested,
3659
- usedSubtask: useSubtask,
3660
- sessionID: targetSession.sessionID,
3661
- model,
3662
- tools,
3663
- text: childText,
3664
- })
3665
- },
3666
- }),
3667
- akm_cmd: tool({
3668
- description: "Execute a stash command template through the OpenCode SDK in the current or child session.",
3669
- args: {
3670
- ref: tool.schema.string().optional().describe("Command ref from akm_search (e.g. command:review.md)."),
3671
- query: tool.schema.string().optional().describe("If ref is omitted, resolve best matching stash command for this query."),
3672
- arguments: tool.schema.string().optional().describe("Command arguments used for $ARGUMENTS and positional placeholders ($1, $2, ...)."),
3673
- dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the rendered command, or a provider/model override like 'openai/gpt-5.3-codex'. Defaults to current agent."),
3674
- as_subtask: tool.schema.boolean().optional().describe("Run in child session with parent context. Defaults to false."),
3675
- },
3676
- async execute({ ref, query, arguments: commandArguments, dispatch_agent, as_subtask }, context) {
3677
- const logMeta = {
3678
- toolName: "akm_cmd",
3679
- directory: context.directory,
3680
- sessionID: context.sessionID,
3681
- }
3682
- const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "command", logMeta)
3683
- if (!resolved.ok) return JSON.stringify(resolved)
3684
-
3685
- const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
3686
- const shown = parseCliJson<ShowCommandResponse | { type: string }>(shownRaw)
3687
- if (isCliError(shown)) return JSON.stringify(shown)
3688
- if (!isShowCommandResponse(shown)) {
3689
- return JSON.stringify({ ok: false, error: `Ref ${resolved.ref} is not a command payload from akm_show.` })
3690
- }
3691
-
3692
- const template = shown.template?.trim()
3693
- if (!template) {
3694
- return JSON.stringify({ ok: false, error: `Command ${shown.name} is missing template content.` })
3695
- }
3696
-
3697
- const argsText = commandArguments ?? ""
3698
- const rendered = renderCommandTemplate(template, argsText)
3699
- const useSubtask = as_subtask ?? false
3700
- const fallbackAgent = typeof context.agent === "string" && context.agent.trim() ? context.agent : "general"
3701
- const dispatchTarget = resolveDispatchTarget(dispatch_agent, fallbackAgent)
3702
-
3703
- const targetSession = await ensureTargetSessionID({
3704
- useSubtask,
3705
- context: { sessionID: context.sessionID, directory: context.directory },
3706
- title: `akm:cmd:${shown.name}`,
3707
- client: client as unknown as PluginClient,
3708
- logClient,
3709
- toolName: "akm_cmd",
3710
- })
3711
- if (!targetSession.ok) return JSON.stringify(targetSession)
3712
-
3713
- const promptResponse = await promptTargetSession({
3714
- client: client as unknown as PluginClient,
3715
- logClient,
3716
- toolName: "akm_cmd",
3717
- context: { sessionID: context.sessionID, directory: context.directory },
3718
- targetSessionID: targetSession.sessionID,
3719
- failureMessage: `Failed to execute command ${resolved.ref}`,
3720
- ref: resolved.ref,
3721
- promptBody: {
3722
- agent: dispatchTarget.agent,
3723
- model: dispatchTarget.model,
3724
- parts: [{ type: "text", text: rendered }],
3725
- },
3726
- })
3727
- if (!promptResponse.ok) return JSON.stringify(promptResponse)
3728
-
3729
- return JSON.stringify({
3730
- ok: true,
3731
- ref: resolved.ref,
3732
- stashCommand: shown.name,
3733
- dispatchAgent: dispatchTarget.agent,
3734
- requestedDispatchTarget: dispatchTarget.requested,
3735
- usedSubtask: useSubtask,
3736
- sessionID: targetSession.sessionID,
3737
- model: dispatchTarget.model,
3738
- arguments: argsText,
3739
- renderedTemplate: rendered,
3740
- text: extractText(promptResponse.data.parts),
3741
- })
3742
- },
3743
- }),
3744
- akm_env: tool({
3745
- description: "Access AKM env assets (.env files) without surfacing values. 'list' returns env refs. 'path' returns the on-disk file path. 'run' injects the env into a child command without values reaching stdout — the preferred agent-safe path. action='path' and action='run' require confirm:true.",
3746
- args: {
3747
- action: tool.schema.enum(["list", "path", "run"]).describe("Env subcommand. 'list' returns refs; 'path' returns the file path; 'run' injects env into a child command."),
3748
- ref: tool.schema.string().optional().describe("Env ref such as env:prod or env:myapp. Required for path/run."),
3749
- cmd: tool.schema.string().optional().describe("Command to run with env injected. Required for action='run'. Example: 'npm start'."),
3750
- confirm: tool.schema.boolean().optional().describe("Must be true for path and run actions."),
3751
- },
3752
- async execute(input) {
3753
- const blocked = blockedToolResponse(input as Record<string, unknown>)
3754
- if (blocked) return blocked
3755
- const { action, ref, cmd, confirm } = input
3756
- const logMeta = { toolName: "akm_env" }
3757
- switch (action) {
3758
- case "list": {
3759
- return runCli(client as unknown as LogCapableClient, ["env", "list"], logMeta)
3760
- }
3761
- case "path": {
3762
- if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_env action='path' requires confirm:true." })
3763
- if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='path'." })
3764
- return runCli(client as unknown as LogCapableClient, ["env", "path", ref], logMeta)
3765
- }
3766
- case "run": {
3767
- if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_env action='run' requires confirm:true." })
3768
- if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='run'." })
3769
- if (!cmd) return JSON.stringify({ ok: false, error: "'cmd' is required for action='run'." })
3770
- return runCli(client as unknown as LogCapableClient, ["env", "run", ref, "--", ...cmd.split(/\s+/)], logMeta)
3771
- }
3772
- }
3773
- },
3774
- }),
3775
- akm_secret: tool({
3776
- description: "Inspect whole-file AKM secrets without surfacing contents. 'list' returns secret refs only. 'path' returns the on-disk file path for `_FILE`-style consumers. action='path' requires confirm:true.",
3777
- args: {
3778
- action: tool.schema.enum(["list", "path"]).describe("Secret subcommand. 'path' returns the absolute file path without reading or echoing the secret bytes."),
3779
- ref: tool.schema.string().optional().describe("Secret ref such as secret:deploy-key. Required for action='path'."),
3780
- confirm: tool.schema.boolean().optional().describe("Must be true for sensitive actions like path."),
3781
- },
3782
- async execute(input) {
3783
- const blocked = blockedToolResponse(input as Record<string, unknown>)
3784
- if (blocked) return blocked
3785
- const { action, ref, confirm } = input
3786
- const logMeta = { toolName: "akm_secret" }
3787
- switch (action) {
3788
- case "list":
3789
- return runCli(client as unknown as LogCapableClient, ["secret", "list"], logMeta)
3790
- case "path": {
3791
- if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_secret action='path' requires confirm:true because secret paths are sensitive." })
3792
- if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='path'." })
3793
- const command = resolveAkmCommand()
3794
- if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
3795
- try {
3796
- const filePath = execResolvedAkm(command, ["secret", "path", ref], {
3797
- encoding: "utf8",
3798
- timeout: 30_000,
3799
- }).toString().trim()
3800
- return JSON.stringify({
3801
- ok: true,
2346
+ akm_search: tool({
2347
+ description: "Search configured AKM bundles or registries in process. Use source='registry' for installable community assets.",
2348
+ args: {
2349
+ query: tool.schema.string().optional().describe("Search query. Omit to browse all assets."),
2350
+ type: tool.schema
2351
+ .enum(ASSET_TYPES as unknown as [string, ...string[]])
2352
+ .optional()
2353
+ .describe("Optional type filter. Defaults to 'any'."),
2354
+ limit: tool.schema.number().optional().describe("Maximum number of hits to return. Defaults to 20."),
2355
+ source: tool.schema.string().optional().describe("Search source: 'local', 'registry', 'all', or a configured bundle name."),
2356
+ include_proposed: tool.schema.boolean().optional().describe("Include proposed-quality results. Proposed assets are not curated until accepted."),
2357
+ },
2358
+ async execute({ query, type, limit, source, include_proposed }, context) {
2359
+ const raw = await runInProcess(
2360
+ client as unknown as LogCapableClient,
2361
+ "search",
2362
+ {
2363
+ query: query ?? "",
2364
+ type: type === "any" ? undefined : type,
2365
+ limit,
2366
+ source,
2367
+ includeProposed: include_proposed,
2368
+ },
2369
+ { toolName: "akm_search", sessionID: context.sessionID, directory: context.directory },
2370
+ )
2371
+ return withProposedWarnings(raw)
2372
+ },
2373
+ }),
2374
+ akm_show: tool({
2375
+ description: "Show an AKM asset by [bundle//]conceptId[#fragment]. Markdown heading fragments select a section.",
2376
+ args: {
2377
+ ref: tool.schema.string().describe("Asset reference returned by akm_search, optionally with a #fragment."),
2378
+ detail: tool.schema.enum(["brief", "summary", "normal", "full"]).optional().describe("Response detail level. Defaults to 'normal'."),
2379
+ },
2380
+ async execute({ ref, detail }, context) {
2381
+ return runInProcess(
2382
+ client as unknown as LogCapableClient,
2383
+ "show",
2384
+ { ref, detail },
2385
+ { toolName: "akm_show", sessionID: context.sessionID, directory: context.directory },
2386
+ )
2387
+ },
2388
+ }),
2389
+ akm_remember: tool({
2390
+ description: "Record a memory in the default AKM stash so it can be searched and shown later.",
2391
+ args: {
2392
+ content: tool.schema.string().describe("Memory content to store."),
2393
+ name: tool.schema.string().optional().describe("Optional memory name."),
2394
+ force: tool.schema.boolean().optional().describe("Overwrite an existing memory with the same name."),
2395
+ },
2396
+ async execute({ content, name, force }, context) {
2397
+ const args = ["remember", content]
2398
+ if (name) args.push("--name", name)
2399
+ if (force) args.push("--force")
2400
+ args.push(...buildScopedArgs(context as unknown as Record<string, unknown>))
2401
+ return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_remember", sessionID: context.sessionID, directory: context.directory })
2402
+ },
2403
+ }),
2404
+ akm_feedback: tool({
2405
+ description: "Record positive or negative feedback for a stash asset so AKM can improve future ranking.",
2406
+ args: {
2407
+ ref: tool.schema.string().describe("Asset ref to record feedback for."),
2408
+ sentiment: tool.schema.enum(["positive", "negative"]).describe("Whether the feedback is positive or negative."),
2409
+ note: tool.schema.string().optional().describe("Optional note to attach to the feedback."),
2410
+ },
2411
+ async execute({ ref, sentiment, note }, context) {
2412
+ const args = ["feedback", ref, sentiment === "positive" ? "--positive" : "--negative"]
2413
+ if (note) args.push("--reason", note)
2414
+ const raw = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_feedback", sessionID: context.sessionID, directory: context.directory })
2415
+ const parsed = safeJsonParse<{ ok?: boolean; error?: string }>(raw)
2416
+ if (parsed?.ok === false) {
2417
+ const error = parsed.error ?? "Unknown akm feedback error"
2418
+ if (isNotIndexedFeedbackError(error)) {
2419
+ await writePluginLog(logClient, "warn", "AKM feedback skipped", {
2420
+ subsystem: "feedback",
2421
+ toolName: "akm_feedback",
2422
+ sessionID: context.sessionID,
2423
+ directory: context.directory,
3802
2424
  ref,
3803
- filePath,
3804
- usage: "Pass this file path to trusted `_FILE`-style consumers or shells. The plugin did not read or surface the secret contents.",
2425
+ sentiment,
2426
+ reason: "ref_not_indexed",
2427
+ error,
3805
2428
  })
3806
- } catch (error: unknown) {
3807
- await writePluginLog(logClient, "error", "AKM secret path failed", {
3808
- subsystem: "secret",
3809
- toolName: "akm_secret",
3810
- action: "path",
3811
- ref,
3812
- error: formatCliError(error),
2429
+ await emitWorkflowTelemetry(logClient, "warn", "akm.feedback.skipped", {
2430
+ sessionID: context.sessionID,
2431
+ toolName: "akm_feedback",
2432
+ assetRef: ref,
2433
+ outcome: "skipped",
2434
+ reason: "ref not indexed",
2435
+ directory: context.directory,
3813
2436
  })
3814
- return JSON.stringify({ ok: false, error: formatCliError(error) })
2437
+ return JSON.stringify({ ok: true, skipped: true, reason: "ref_not_indexed", ref, sentiment })
3815
2438
  }
3816
- }
3817
- }
3818
- },
3819
- }),
3820
- akm_wiki: tool({
3821
- description: "Manage AKM wikis — multi-wiki knowledge bases under <stashDir>/wikis/<name>/. Supports scaffolding, registering external sources, listing pages, scoped search, stashing raw sources, lint, and ingest workflow.",
3822
- args: {
3823
- action: tool.schema.enum([
3824
- "create",
3825
- "register",
3826
- "list",
3827
- "show",
3828
- "remove",
3829
- "pages",
3830
- "search",
3831
- "stash",
3832
- "lint",
3833
- "ingest",
3834
- ]).describe("Wiki subcommand."),
3835
- name: tool.schema.string().optional().describe("Wiki name (required for every action except 'list')."),
3836
- source_ref: tool.schema.string().optional().describe("Source ref to register (required for action='register'). Accepts directory paths, git URLs, github owner/repo, or https:// website roots."),
3837
- writable: tool.schema.boolean().optional().describe("When registering a git-backed source, mark it as push-writable (used by `akm sync`; see akm_help topic='sync')."),
3838
- max_pages: tool.schema.number().optional().describe("Crawler page cap when registering a website (default 50)."),
3839
- max_depth: tool.schema.number().optional().describe("Crawler depth cap when registering a website (default 3)."),
3840
- query: tool.schema.string().optional().describe("Query string for action='search'."),
3841
- limit: tool.schema.number().optional().describe("Result cap for action='search'."),
3842
- source: tool.schema.string().optional().describe("Source path (or '-' for stdin) for action='stash'."),
3843
- as_slug: tool.schema.string().optional().describe("Explicit slug for action='stash' (defaults to derived from source)."),
3844
- content: tool.schema.string().optional().describe("Raw content to feed stdin when stashing with source='-'."),
3845
- force: tool.schema.boolean().optional().describe("Required for action='remove'."),
3846
- with_sources: tool.schema.boolean().optional().describe("When removing, also delete the raw/ sources (default false)."),
3847
- },
3848
- async execute({ action, name, source_ref, writable, max_pages, max_depth, query, limit, source, as_slug, content, force, with_sources }) {
3849
- const logMeta = { toolName: "akm_wiki" }
3850
- const requireName = () => {
3851
- if (!name) return JSON.stringify({ ok: false, error: `'name' is required for action='${action}'.` })
3852
- return null
3853
- }
3854
- switch (action) {
3855
- case "list":
3856
- return runCli(client as unknown as LogCapableClient, ["wiki", "list"], logMeta)
3857
- case "create": {
3858
- const err = requireName(); if (err) return err
3859
- return runCli(client as unknown as LogCapableClient, ["wiki", "create", name!], logMeta)
3860
- }
3861
- case "show": {
3862
- const err = requireName(); if (err) return err
3863
- return runCli(client as unknown as LogCapableClient, ["wiki", "show", name!], logMeta)
3864
- }
3865
- case "pages": {
3866
- const err = requireName(); if (err) return err
3867
- return runCli(client as unknown as LogCapableClient, ["wiki", "pages", name!], logMeta)
3868
- }
3869
- case "ingest": {
3870
- const err = requireName(); if (err) return err
3871
- return runCli(client as unknown as LogCapableClient, ["wiki", "ingest", name!], logMeta)
3872
- }
3873
- case "lint": {
3874
- const err = requireName(); if (err) return err
3875
- // `wiki lint` exits 1 when findings exist, which runCli surfaces as
3876
- // an error envelope. That is still useful output — the JSON body is
3877
- // the lint report. Pass through either way.
3878
- return runCli(client as unknown as LogCapableClient, ["wiki", "lint", name!], logMeta)
3879
- }
3880
- case "register": {
3881
- const err = requireName(); if (err) return err
3882
- if (!source_ref) return JSON.stringify({ ok: false, error: "'source_ref' is required for action='register'." })
3883
- const args = ["wiki", "register", name!, source_ref]
3884
- if (writable) args.push("--writable")
3885
- if (max_pages != null) args.push("--max-pages", String(max_pages))
3886
- if (max_depth != null) args.push("--max-depth", String(max_depth))
3887
- return runCli(client as unknown as LogCapableClient, args, logMeta)
3888
- }
3889
- case "remove": {
3890
- const err = requireName(); if (err) return err
3891
- if (!force) return JSON.stringify({ ok: false, error: "'force' must be true to remove a wiki." })
3892
- const args = ["wiki", "remove", name!, "--force"]
3893
- if (with_sources) args.push("--with-sources")
3894
- return runCli(client as unknown as LogCapableClient, args, logMeta)
3895
- }
3896
- case "search": {
3897
- const err = requireName(); if (err) return err
3898
- if (!query) return JSON.stringify({ ok: false, error: "'query' is required for action='search'." })
3899
- const args = ["wiki", "search", name!, query]
3900
- if (limit != null) args.push("--limit", String(limit))
3901
- return runCli(client as unknown as LogCapableClient, args, logMeta)
3902
- }
3903
- case "stash": {
3904
- const err = requireName(); if (err) return err
3905
- if (!source) return JSON.stringify({ ok: false, error: "'source' is required for action='stash'." })
3906
- const args = ["wiki", "stash", name!, source]
3907
- if (as_slug) args.push("--as", as_slug)
3908
- if (source === "-" && content) {
3909
- const command = resolveAkmCommand()
3910
- if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
3911
- try {
3912
- const stdout = execResolvedAkm(command, [...args, "--format", "json"], {
3913
- encoding: "utf8",
3914
- timeout: 60_000,
3915
- input: content,
3916
- })
3917
- return stdout
3918
- } catch (error: unknown) {
3919
- await writePluginLog(logClient, "error", "AKM wiki stash failed", {
3920
- subsystem: "wiki",
3921
- toolName: "akm_wiki",
3922
- action: "stash",
3923
- name,
3924
- source,
3925
- as_slug,
3926
- error: formatCliError(error),
3927
- })
3928
- return JSON.stringify({ ok: false, error: formatCliError(error) })
3929
- }
3930
- }
3931
- return runCli(client as unknown as LogCapableClient, args, logMeta)
3932
- }
3933
- }
3934
- },
3935
- }),
3936
- akm_workflow: tool({
3937
- description: "Manage AKM workflow runs — stateful multi-step procedures defined as workflow:<name> assets. Use start/next/complete/resume to drive a run, status/list to inspect, create/template to author.",
3938
- args: {
3939
- action: tool.schema.enum([
3940
- "start",
3941
- "next",
3942
- "complete",
3943
- "status",
3944
- "list",
3945
- "create",
3946
- "template",
3947
- "resume",
3948
- ]).describe("Workflow subcommand."),
3949
- ref: tool.schema.string().optional().describe("Workflow ref (e.g. workflow:release). Required for start; accepted by next/status as a target."),
3950
- target: tool.schema.string().optional().describe("Run id or workflow ref for next/status. When a workflow ref is passed to 'next', a new run is auto-started."),
3951
- run_id: tool.schema.string().optional().describe("Workflow run id. Required for complete and resume."),
3952
- params: tool.schema.string().optional().describe("JSON object string of parameters for start/next."),
3953
- step: tool.schema.string().optional().describe("Step id to transition (required for action='complete')."),
3954
- state: tool.schema.enum(["completed", "blocked", "failed", "skipped"]).optional().describe("Step state for 'complete'. Defaults to 'completed'."),
3955
- notes: tool.schema.string().optional().describe("Freeform notes attached to the step transition."),
3956
- evidence: tool.schema.string().optional().describe("JSON object string of evidence attached to the step transition."),
3957
- name: tool.schema.string().optional().describe("Workflow name for action='create'."),
3958
- from: tool.schema.string().optional().describe("Path to a markdown template for action='create'."),
3959
- force: tool.schema.boolean().optional().describe("Overwrite an existing workflow on create (requires --from or --reset)."),
3960
- reset: tool.schema.boolean().optional().describe("Reset to the built-in template for action='create'."),
3961
- filter_ref: tool.schema.string().optional().describe("Restrict action='list' to runs of this workflow ref."),
3962
- active_only: tool.schema.boolean().optional().describe("Restrict action='list' to active (non-terminal) runs."),
3963
- },
3964
- async execute({
3965
- action,
3966
- ref,
3967
- target,
3968
- run_id,
3969
- params,
3970
- step,
3971
- state,
3972
- notes,
3973
- evidence,
3974
- name,
3975
- from,
3976
- force,
3977
- reset,
3978
- filter_ref,
3979
- active_only,
3980
- }) {
3981
- const logMeta = { toolName: "akm_workflow" }
3982
- switch (action) {
3983
- case "start": {
3984
- if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='start'." })
3985
- const args = ["workflow", "start", ref]
3986
- if (params) args.push("--params", params)
3987
- const raw = await runCli(client as unknown as LogCapableClient, args, logMeta)
3988
- void emitWorkflowTelemetry(logClient, "info", "workflow_started", { toolName: "akm_workflow", ref, params: params ?? null })
3989
- return raw
3990
- }
3991
- case "next": {
3992
- const picked = target ?? run_id ?? ref
3993
- if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='next'." })
3994
- const args = ["workflow", "next", picked]
3995
- if (params) args.push("--params", params)
3996
- const raw = await runCli(client as unknown as LogCapableClient, args, logMeta)
3997
- void emitWorkflowTelemetry(logClient, "info", "workflow_next_loaded", { toolName: "akm_workflow", target: picked, params: params ?? null })
3998
- return raw
3999
- }
4000
- case "complete": {
4001
- if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='complete'." })
4002
- if (!step) return JSON.stringify({ ok: false, error: "'step' is required for action='complete'." })
4003
- const args = ["workflow", "complete", run_id, "--step", step]
4004
- if (state) args.push("--state", state)
4005
- if (notes) args.push("--notes", notes)
4006
- if (evidence) args.push("--evidence", evidence)
4007
- const raw = await runCli(client as unknown as LogCapableClient, args, logMeta)
4008
- const eventType = state === "blocked"
4009
- ? "workflow_step_blocked"
4010
- : state === "failed"
4011
- ? "workflow_step_failed"
4012
- : state === "skipped"
4013
- ? "workflow_step_skipped"
4014
- : "workflow_step_completed"
4015
- void emitWorkflowTelemetry(logClient, "info", eventType, { toolName: "akm_workflow", runId: run_id, step, notes: notes ?? null, evidence: evidence ?? null })
4016
- return raw
4017
- }
4018
- case "status": {
4019
- const picked = target ?? run_id ?? ref
4020
- if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='status'." })
4021
- return runCli(client as unknown as LogCapableClient, ["workflow", "status", picked], logMeta)
4022
- }
4023
- case "list": {
4024
- const args = ["workflow", "list"]
4025
- if (filter_ref) args.push("--ref", filter_ref)
4026
- if (active_only) args.push("--active")
4027
- return runCli(client as unknown as LogCapableClient, args, logMeta)
4028
- }
4029
- case "create": {
4030
- if (!name) return JSON.stringify({ ok: false, error: "'name' is required for action='create'." })
4031
- const args = ["workflow", "create", name]
4032
- if (from) args.push("--from", from)
4033
- if (force) args.push("--force")
4034
- if (reset) args.push("--reset")
4035
- return runCli(client as unknown as LogCapableClient, args, logMeta)
4036
- }
4037
- case "template": {
4038
- // The workflow template is emitted as raw markdown, not JSON.
4039
- const command = resolveAkmCommand()
4040
- if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
4041
- try {
4042
- const stdout = execResolvedAkm(command, ["workflow", "template"], {
4043
- encoding: "utf8",
4044
- timeout: 30_000,
4045
- })
4046
- return JSON.stringify({ ok: true, template: stdout })
4047
- } catch (error: unknown) {
4048
- await writePluginLog(logClient, "error", "AKM workflow template failed", {
4049
- subsystem: "workflow",
4050
- toolName: "akm_workflow",
4051
- action: "template",
4052
- error: formatCliError(error),
4053
- })
4054
- return JSON.stringify({ ok: false, error: formatCliError(error) })
4055
- }
4056
- }
4057
- case "resume": {
4058
- if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='resume'." })
4059
- const raw = await runCli(client as unknown as LogCapableClient, ["workflow", "resume", run_id], logMeta)
4060
- void emitWorkflowTelemetry(logClient, "info", "workflow_resumed", { toolName: "akm_workflow", runId: run_id })
4061
2439
  return raw
4062
2440
  }
4063
- }
4064
- },
4065
- }),
4066
- akm_proposal: tool({
4067
- description: "Operate the AKM v0.8.0 proposal queue — list/show/diff/accept/reject pending drafts, or action='drain' for deterministic BULK triage of the whole backlog. All proposal-producing commands (improve, propose, plus plugin-emitted proposals) write through this queue. Acceptance runs full validation before promoting; rejection archives the draft. Always confirm with the user before action='accept', 'reject', or 'drain'. action='drain' clears the standing pending backlog by a deterministic policy (default queue/stage mode; pass promote:true to actually accept) — mutating, commits to git, no batch revert; always preview with dry_run:true first. This and the automatic improve `processes.triage` pre-pass supersede manual one-by-one queue management.",
4068
- args: {
4069
- action: tool.schema.enum(["list", "show", "diff", "accept", "reject", "drain"]).describe("Proposal subcommand."),
4070
- id: tool.schema.string().optional().describe("Proposal id. Required for show/diff/accept/reject."),
4071
- status: tool.schema.enum(["pending", "accepted", "rejected"]).optional().describe("Filter for action='list'."),
4072
- reason: tool.schema.string().optional().describe("Required for action='reject'. Recorded with the archived proposal."),
4073
- confirm: tool.schema.boolean().optional().describe("Must be true for action='accept', 'reject', and 'drain'."),
4074
- policy: tool.schema.string().optional().describe("action='drain' only: built-in preset (personal-stash|conservative|manual) or path to a policy file."),
4075
- promote: tool.schema.boolean().optional().describe("action='drain' only: promote (accept) matching proposals. Default is queue/stage mode (no writes to assets)."),
4076
- dry_run: tool.schema.boolean().optional().describe("action='drain' only: list the planned accept/reject/defer set without writing. Always preview first."),
4077
- max_accepts: tool.schema.number().optional().describe("action='drain' only: hard per-run accept ceiling."),
4078
- max_diff_lines: tool.schema.number().optional().describe("action='drain' only: defer accepts whose proposed content exceeds this many lines."),
4079
- older_than: tool.schema.number().optional().describe("action='drain' only: only consider proposals created more than this many days ago."),
4080
- judgment: tool.schema.boolean().optional().describe("action='drain' only: opt into the judgment tier for deferred items."),
4081
- profile: tool.schema.string().optional().describe("action='drain' only: read the triage block (policy, applyMode, ceilings, judgment) from this improve profile."),
4082
- },
4083
- async execute(input) {
4084
- const blocked = blockedToolResponse(input as Record<string, unknown>)
4085
- if (blocked) return blocked
4086
- const { action, id, status, reason, confirm, policy, promote, dry_run, max_accepts, max_diff_lines, older_than, judgment, profile } = input
4087
- const logMeta = { toolName: "akm_proposal" }
4088
- switch (action) {
4089
- case "list": {
4090
- const args = ["proposal", "list"]
4091
- if (status) args.push("--status", status)
4092
- return runCli(client as unknown as LogCapableClient, args, logMeta)
4093
- }
4094
- case "show": {
4095
- if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='show'." })
4096
- return runCli(client as unknown as LogCapableClient, ["proposal", "show", id], logMeta)
4097
- }
4098
- case "diff": {
4099
- if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='diff'." })
4100
- return runCli(client as unknown as LogCapableClient, ["proposal", "diff", id], logMeta)
4101
- }
4102
- case "accept": {
4103
- if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_proposal action='accept' requires confirm:true because it mutates the proposal queue." })
4104
- if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='accept'. Confirm with the user before accepting." })
4105
- const acceptResult = await runCli(client as unknown as LogCapableClient, ["proposal", "accept", id, "--yes"], logMeta)
4106
- // Invalidate the proposal-count cache so the next getPendingProposalCount() call
4107
- // reflects the updated queue immediately (WS-7a: no stale 60s TTL after mutations).
4108
- pendingProposalSummaryCache.clear()
4109
- return acceptResult
4110
- }
4111
- case "reject": {
4112
- if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_proposal action='reject' requires confirm:true because it mutates the proposal queue." })
4113
- if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='reject'. Confirm with the user before rejecting." })
4114
- if (!reason || !reason.trim()) return JSON.stringify({ ok: false, error: "'reason' is required for action='reject'. Ask the user why the proposal is being rejected." })
4115
- const rejectResult = await runCli(client as unknown as LogCapableClient, ["proposal", "reject", id, "--reason", reason, "--yes"], logMeta)
4116
- // Invalidate the proposal-count cache (WS-7a).
4117
- pendingProposalSummaryCache.clear()
4118
- return rejectResult
4119
- }
4120
- case "drain": {
4121
- if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_proposal action='drain' requires confirm:true because it bulk-mutates the proposal queue and commits to git." })
4122
- const args = ["proposal", "drain"]
4123
- if (policy) args.push("--policy", policy)
4124
- if (promote) args.push("--promote")
4125
- if (dry_run) args.push("--dry-run")
4126
- else args.push("--yes")
4127
- if (typeof max_accepts === "number") args.push("--max-accepts", String(max_accepts))
4128
- if (typeof max_diff_lines === "number") args.push("--max-diff-lines", String(max_diff_lines))
4129
- if (typeof older_than === "number") args.push("--older-than", String(older_than))
4130
- if (judgment) args.push("--judgment")
4131
- if (profile) args.push("--profile", profile)
4132
- const drainResult = await runCli(client as unknown as LogCapableClient, args, logMeta)
4133
- // A real drain (not a dry run) mutates the queue; invalidate the count cache (WS-7a).
4134
- if (!dry_run) pendingProposalSummaryCache.clear()
4135
- return drainResult
4136
- }
4137
- }
4138
- },
4139
- }),
4140
- akm_improve: tool({
4141
- description: "Generate AKM improvement proposals for an existing ref, an asset type, or the broader stash. This is the v0.8.0 replacement for the old reflect/distill flow. Output lands in the proposal queue only — never mutates live stash content. Requires `defaults.agent` (with a matching `profiles.agent.<name>` entry) to be set for agent-backed proposal generation; the legacy `agent.default` shape is auto-migrated on load.",
4142
- args: {
4143
- scope: tool.schema.string().optional().describe("Optional asset type or [origin//]type:name ref to improve. When omitted, improves the current stash scope."),
4144
- task: tool.schema.string().optional().describe("Optional extra guidance for this improvement pass."),
4145
- dry_run: tool.schema.boolean().optional().describe("Show planned actions without generating proposals."),
4146
- },
4147
- async execute({ scope, task, dry_run }, context) {
4148
- if (!dry_run) {
4149
- await ensureFreshProposalCheckpoint(logClient, {
2441
+ await emitWorkflowTelemetry(logClient, "info", "akm.feedback.recorded", {
4150
2442
  sessionID: context.sessionID,
2443
+ toolName: "akm_feedback",
2444
+ assetRef: ref,
2445
+ outcome: "success",
2446
+ reason: sentiment,
4151
2447
  directory: context.directory,
4152
- agent: context.agent,
4153
- }, "pre-improve")
4154
- }
4155
- const args = ["improve"]
4156
- if (scope) args.push(scope)
4157
- if (task) args.push("--task", task)
4158
- if (dry_run) args.push("--dry-run")
4159
- const improveResult = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_improve", sessionID: context.sessionID, directory: context.directory, agent: context.agent })
4160
- // Invalidate the proposal-count cache after improve (may have added proposals) (WS-7a).
4161
- if (!dry_run) pendingProposalSummaryCache.clear()
4162
- return improveResult
4163
- },
4164
- }),
4165
- akm_propose: tool({
4166
- description: "Generate a new-asset proposal via the configured agent CLI. The asset is drafted as `quality:\"proposed\"` and lands in the proposal queue — never directly into curated content. Requires `defaults.agent` (with a matching `profiles.agent.<name>` entry; run akm_setup first if missing). The legacy `agent.default` shape is auto-migrated on load.",
4167
- args: {
4168
- type: tool.schema.enum(["skill", "command", "agent", "knowledge", "lesson", "script", "workflow", "wiki"]).describe("Asset type for the new proposal."),
4169
- name: tool.schema.string().describe("Slug for the new asset (matches the standard ref grammar)."),
4170
- task: tool.schema.string().optional().describe("Optional inline task text describing what the asset should do."),
4171
- file: tool.schema.string().optional().describe("Optional UTF-8 file path to read the task text from. Exactly one of task/file is required."),
4172
- },
4173
- async execute({ type, name, task, file }, context) {
4174
- const hasTask = !!task?.trim()
4175
- const hasFile = !!file?.trim()
4176
- if (hasTask === hasFile) return JSON.stringify({ ok: false, error: "Exactly one of 'task' or 'file' is required for akm_propose." })
4177
- await ensureFreshProposalCheckpoint(logClient, {
4178
- sessionID: context.sessionID,
4179
- directory: context.directory,
4180
- agent: context.agent,
4181
- }, "pre-propose")
4182
- const args = ["propose", type, name]
4183
- if (hasTask) args.push("--task", task!.trim())
4184
- if (hasFile) args.push("--file", file!.trim())
4185
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_propose", sessionID: context.sessionID, directory: context.directory, agent: context.agent })
4186
- },
4187
- }),
4188
- akm_init: tool({
4189
- description: "Initialize AKM's working stash directory and persist `stashDir` in config. This is the agent-safe initialization path; do not use interactive `akm setup` from tools.",
4190
- args: {},
4191
- async execute() {
4192
- return runCli(client as unknown as LogCapableClient, ["init"], { toolName: "akm_init" })
4193
- },
4194
- }),
4195
- akm_help: tool({
4196
- description: "Discover the right `akm` CLI command and args for tasks not covered by a first-class tool — e.g. save/push, import, clone, update, remove, list sources, registry search, reindex, config, CLI upgrade, run script. Returns a curated quick-reference plus live `akm --help` output. Pass `command` to drill into a specific subcommand.",
4197
- args: {
4198
- topic: tool.schema.string().optional().describe("Natural-language description of the task (e.g. 'commit and push my stash', 'install a kit from github'). Returns curated hints if any keywords match."),
4199
- command: tool.schema.string().optional().describe("Specific akm subcommand to inspect (e.g. 'save', 'clone', 'config'). Runs `akm <command> --help` and returns the output verbatim."),
4200
- },
4201
- async execute({ topic, command }) {
4202
- const cliCommand = resolveAkmCommand()
4203
- if (typeof cliCommand === "object" && "ok" in cliCommand) return JSON.stringify(cliCommand)
4204
- const helpArgs = command && command.trim()
4205
- ? [command.trim(), "--help"]
4206
- : ["--help"]
4207
- let helpText = ""
4208
- try {
4209
- helpText = execResolvedAkm(cliCommand, helpArgs, {
4210
- encoding: "utf8",
4211
- timeout: 30_000,
4212
- }).toString().trim()
4213
- } catch (error: unknown) {
4214
- await writePluginLog(logClient, "error", "AKM help command failed", {
4215
- subsystem: "help",
4216
- toolName: "akm_help",
4217
- command: cliCommand,
4218
- args: helpArgs,
4219
- error: formatCliError(error),
4220
2448
  })
4221
- return JSON.stringify({ ok: false, error: formatCliError(error) })
4222
- }
4223
- return JSON.stringify({
4224
- ok: true,
4225
- command: command ?? null,
4226
- topic: topic ?? null,
4227
- hints: topic ? lookupAkmHelpHint(topic) : [],
4228
- quickReference: AKM_HELP_QUICK_REFERENCE,
4229
- help: helpText,
4230
- workflowTopics: [
4231
- "proposal",
4232
- "improve",
4233
- "propose",
4234
- "lesson",
4235
- "tasks",
4236
- "secret-safety",
4237
- "include-proposed",
4238
- "llm-features",
4239
- "env-safety",
4240
- ],
4241
- })
4242
- },
4243
- }),
2449
+ return raw
2450
+ },
2451
+ }),
2452
+ akm_curate: tool({
2453
+ description: "Curate stash assets for a task or topic. Returns the top matches as a ranked list so the agent can inspect and use them.",
2454
+ args: {
2455
+ query: tool.schema.string().describe("Task, topic, or natural-language description of what you want to do."),
2456
+ type: tool.schema.enum(ASSET_TYPES as unknown as [string, ...string[]]).optional().describe("Optional asset type filter."),
2457
+ limit: tool.schema.number().optional().describe("Maximum number of curated matches to return. Defaults to 4."),
2458
+ source: tool.schema.string().optional().describe("Search source: 'local', 'registry', 'all', or a configured bundle name."),
2459
+ },
2460
+ async execute({ query, type, limit, source }, context) {
2461
+ return runInProcess(
2462
+ client as unknown as LogCapableClient,
2463
+ "curate",
2464
+ { query, type: type === "any" ? undefined : type, limit, source },
2465
+ { toolName: "akm_curate", sessionID: context.sessionID, directory: context.directory },
2466
+ )
2467
+ },
2468
+ }),
4244
2469
  },
4245
2470
  }
4246
2471
  }
4247
-
4248
- export const server = AkmPlugin
4249
- export default { server, id: "akm-opencode" }
2472
+
2473
+ // A single named export only. The @opencode-ai/plugin loader initializes
2474
+ // every exported plugin function it finds in this module, so exporting the
2475
+ // same function again under a second name (`server`) or bundled into a
2476
+ // default export risks the host registering — and running — the plugin's
2477
+ // hooks twice (double auto-feedback, double session-start curates, etc.).
2478
+ // The SDK's own example plugin (dist/example.js) exports exactly one named
2479
+ // const with no default export; that is the blessed shape.