akm-opencode 0.5.0 → 0.5.1

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,9 +1,12 @@
1
1
  import { type Plugin, tool } from "@opencode-ai/plugin"
2
- import { execFileSync, execSync } from "node:child_process"
2
+ import { execFileSync, execSync, spawn } from "node:child_process"
3
+ import { readFileSync } from "node:fs"
3
4
  import path from "node:path"
5
+ import { fileURLToPath } from "node:url"
4
6
 
5
7
  let resolvedAkmCommand = "akm"
6
8
  const autoInstallPackageRef = "akm-cli@latest"
9
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url))
7
10
 
8
11
  const AKM_AUTO_FEEDBACK = (process.env.AKM_AUTO_FEEDBACK ?? "1") !== "0"
9
12
  const AKM_AUTO_MEMORY = (process.env.AKM_AUTO_MEMORY ?? "1") !== "0"
@@ -12,11 +15,23 @@ const AKM_AUTO_HINTS = (process.env.AKM_AUTO_HINTS ?? "1") !== "0"
12
15
  const AKM_CURATE_LIMIT = Math.max(1, Number(process.env.AKM_CURATE_LIMIT ?? "5") || 5)
13
16
  const AKM_CURATE_MIN_CHARS = Math.max(1, Number(process.env.AKM_CURATE_MIN_CHARS ?? "16") || 16)
14
17
  const AKM_CURATE_TIMEOUT_MS = Math.max(1_000, (Number(process.env.AKM_CURATE_TIMEOUT ?? "8") || 8) * 1_000)
18
+ const AKM_MEMORY_CHECKPOINT_EVERY = Math.max(1, Number(process.env.AKM_MEMORY_CHECKPOINT_EVERY ?? "8") || 8)
19
+ const AKM_CURATOR_CONTEXT_MAX_CHARS = Math.max(500, Number(process.env.AKM_CURATOR_CONTEXT_MAX_CHARS ?? "4000") || 4000)
20
+ const SESSION_DATE_TAG_LENGTH = 8
21
+ const CHECKPOINT_DATE_TAG_LENGTH = 15
22
+ const AKM_RETROSPECTIVE_FEEDBACK_RE = createRetrospectiveFeedbackRegex()
23
+ const PLUGIN_VERSION = readPackageVersion()
15
24
 
16
25
  // Per-session state that drives the compound-engineering loop.
17
26
  // These maps are keyed by OpenCode sessionID.
18
27
  const sessionHints = new Map<string, string>()
19
28
  const sessionCurated = new Map<string, string>()
29
+ const sessionWorkflow = new Map<string, string>()
30
+ const sessionCuratorReport = new Map<string, string>()
31
+ const sessionContextEpoch = new Map<string, number>()
32
+ const sessionContextInjectedEpoch = new Map<string, number>()
33
+ const sessionCuratedVersion = new Map<string, number>()
34
+ const sessionCuratedInjectedVersion = new Map<string, number>()
20
35
  type SessionBufferEntry = {
21
36
  timestamp: string
22
37
  kind: "memory-intent" | "tool-ref"
@@ -24,35 +39,60 @@ type SessionBufferEntry = {
24
39
  ref?: string
25
40
  status?: "positive" | "negative" | "unknown"
26
41
  note?: string
42
+ checkpointed?: boolean
27
43
  }
28
44
  const sessionBuffer = new Map<string, SessionBufferEntry[]>()
29
- const sessionMemoryCaptured = new Set<string>()
45
+ const sessionFinalMemoryCaptured = new Set<string>()
46
+ const sessionSuccessfulAssetTouchCount = new Map<string, number>()
47
+ let cachedAkmStashDir: string | undefined
30
48
 
31
- // Asset-ref grammar matching the stash skill: [origin//]type:name
32
- const AKM_REF_PATTERN = /(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|vault|wiki):[A-Za-z0-9._/\-]+/g
49
+ // Asset-ref grammar matching the stash skill: [origin//]type:name.
50
+ // We validate normalized tokens individually instead of running a global regex
51
+ // over arbitrary tool output to keep extraction predictable and ReDoS-safe.
52
+ const AKM_REF_PATTERN = /^(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|vault|wiki):[A-Za-z0-9._/\-]+$/
33
53
 
34
- const CURATOR_AGENT_PROMPT = `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.
54
+ function readPackageVersion(): string {
55
+ try {
56
+ const raw = readFileSync(path.join(moduleDir, "package.json"), "utf8")
57
+ const parsed = JSON.parse(raw) as { version?: unknown }
58
+ return typeof parsed.version === "string" && parsed.version ? parsed.version : "0.0.0"
59
+ } catch {
60
+ return "0.0.0"
61
+ }
62
+ }
63
+
64
+ function createRetrospectiveFeedbackRegex(): RegExp {
65
+ const pattern = process.env.AKM_RETROSPECTIVE_FEEDBACK_PATTERN ?? "\\b(thanks|perfect|worked)\\b"
66
+ try {
67
+ return new RegExp(pattern, "i")
68
+ } catch {
69
+ return /\b(thanks|perfect|worked)\b/i
70
+ }
71
+ }
72
+
73
+ 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.
35
74
 
36
75
  Inputs you should inspect:
37
76
  1. OpenCode app logs that include the "akm-opencode" service (feedback, memory, tool invocations).
38
77
  2. Session-summary memories named memory:opencode-session-*.
39
- 3. The live stash: call akm_list, akm_search "" --limit 50, and akm_show <ref>.
78
+ 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.
79
+ 4. Parent-session context via akm_parent_messages when this session was dispatched as a child.
40
80
 
41
81
  Signals to act on:
42
82
  - Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
43
83
  - Cold refs: assets tied to failures or user complaints. Record akm_feedback <ref> negative --note "<excerpt>" and open the asset for review.
44
- - 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 with akm_index.
84
+ - 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").
45
85
  - Duplicates / drift: near-identical descriptions or overlapping responsibilities. Propose a consolidation.
46
- - Stale memories: session summaries that never get recalled. Propose akm_remove memory:<name> once distilled into a durable knowledge doc or wiki page.
86
+ - 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.
47
87
  - 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.
48
88
  - 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.
49
- - Never touch vaults: do not call akm_vault show or shell_snippet unless the user explicitly asks. Vault values must never appear in reports.
89
+ - Never touch vaults: do not call akm_vault show or load unless the user explicitly asks. Vault values must never appear in reports.
50
90
 
51
91
  Rules of engagement:
52
92
  - Never apply destructive changes without explicit user approval.
53
93
  - Report findings as a prioritized action list of concrete akm_* tool calls the user can run.
54
94
  - Prefer small, reversible edits: promote via positive feedback, draft a candidate skill, or clone and tweak.
55
- - When drafting new assets, write them into the working stash directory (akm_config get stashDir) under skills/, commands/, agents/, knowledge/, or scripts/. Call akm_index when finished.
95
+ - 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.
56
96
  - When finished, persist your own summary with akm_remember (name: curator-run-<timestamp>) so the next curator run can build on yours.
57
97
 
58
98
  Output shape: end every run with a markdown report that has these sections:
@@ -79,6 +119,23 @@ Output shape: end every run with a markdown report that has these sections:
79
119
  - stale memories, reindex needs, config tweaks
80
120
  `
81
121
 
122
+ function loadCuratorAgentPrompt(): string {
123
+ try {
124
+ const raw = readFileSync(path.join(moduleDir, "agent", "akm-curator.md"), "utf8").trim()
125
+ let body = raw
126
+ const lines = raw.split(/\r?\n/)
127
+ if (lines[0] === "---") {
128
+ const closingIndex = lines.indexOf("---", 1)
129
+ if (closingIndex > 0) body = lines.slice(closingIndex + 1).join("\n").trim()
130
+ }
131
+ return body || CURATOR_AGENT_PROMPT_FALLBACK
132
+ } catch {
133
+ return CURATOR_AGENT_PROMPT_FALLBACK
134
+ }
135
+ }
136
+
137
+ const CURATOR_AGENT_PROMPT = loadCuratorAgentPrompt()
138
+
82
139
  type LogLevel = "debug" | "info" | "warn" | "error"
83
140
 
84
141
  type LogCapableClient = {
@@ -148,6 +205,11 @@ function nowIso(): string {
148
205
  return new Date().toISOString()
149
206
  }
150
207
 
208
+ function buildDateTag(options?: { includeTime?: boolean }): string {
209
+ const compactIso = new Date().toISOString().replace(/[-:]/g, "")
210
+ return compactIso.slice(0, options?.includeTime ? CHECKPOINT_DATE_TAG_LENGTH : SESSION_DATE_TAG_LENGTH)
211
+ }
212
+
151
213
  function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBufferEntry, "timestamp">) {
152
214
  if (!sessionID) return
153
215
  const buf = sessionBuffer.get(sessionID) ?? []
@@ -155,6 +217,26 @@ function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBuffer
155
217
  sessionBuffer.set(sessionID, buf)
156
218
  }
157
219
 
220
+ function markContextEpochDirty(sessionID: string) {
221
+ sessionContextEpoch.set(sessionID, (sessionContextEpoch.get(sessionID) ?? 0) + 1)
222
+ }
223
+
224
+ function bumpCuratedVersion(sessionID: string) {
225
+ sessionCuratedVersion.set(sessionID, (sessionCuratedVersion.get(sessionID) ?? 0) + 1)
226
+ }
227
+
228
+ function isAkmRef(value: string): boolean {
229
+ return AKM_REF_PATTERN.test(value)
230
+ }
231
+
232
+ function parseMaybeJson(value: string): unknown {
233
+ try {
234
+ return JSON.parse(value)
235
+ } catch {
236
+ return undefined
237
+ }
238
+ }
239
+
158
240
  // Synchronous CLI invocation used by the lifecycle hooks — the plugin host does
159
241
  // not await these in a hot path, but we still cap execution time so a slow
160
242
  // stash never wedges the session loop.
@@ -202,6 +284,92 @@ function runHintsForSession(): string | null {
202
284
  return body || null
203
285
  }
204
286
 
287
+ function summarizeWorkflowList(value: unknown): string | null {
288
+ if (Array.isArray(value)) {
289
+ const lines = value
290
+ .map((item) => {
291
+ if (!item || typeof item !== "object") return null
292
+ const record = item as Record<string, unknown>
293
+ const id = typeof record.runId === "string"
294
+ ? record.runId
295
+ : typeof record.id === "string"
296
+ ? record.id
297
+ : null
298
+ const ref = typeof record.ref === "string"
299
+ ? record.ref
300
+ : typeof record.workflowRef === "string"
301
+ ? record.workflowRef
302
+ : null
303
+ const state = typeof record.state === "string" ? record.state : typeof record.status === "string" ? record.status : null
304
+ const step = typeof record.step === "string"
305
+ ? record.step
306
+ : typeof record.currentStep === "string"
307
+ ? record.currentStep
308
+ : null
309
+ if (!id && !ref && !state && !step) return null
310
+ return `- ${ref ?? "workflow"} (${id ?? "run"})${state ? ` — ${state}` : ""}${step ? ` — next: ${step}` : ""}`
311
+ })
312
+ .filter((line): line is string => !!line)
313
+ return lines.length > 0 ? lines.join("\n") : null
314
+ }
315
+ return null
316
+ }
317
+
318
+ function runWorkflowSummaryForSession(): string | null {
319
+ const result = runCliSyncRaw(["--format", "json", "-q", "workflow", "list", "--active"], AKM_CURATE_TIMEOUT_MS)
320
+ if (!result.ok) return null
321
+ const parsed = parseMaybeJson(result.stdout)
322
+ const summary = summarizeWorkflowList(
323
+ Array.isArray(parsed)
324
+ ? parsed
325
+ : (parsed && typeof parsed === "object" && Array.isArray((parsed as { runs?: unknown }).runs))
326
+ ? (parsed as { runs: unknown[] }).runs
327
+ : (parsed && typeof parsed === "object" && Array.isArray((parsed as { items?: unknown }).items))
328
+ ? (parsed as { items: unknown[] }).items
329
+ : [],
330
+ )
331
+ return summary
332
+ }
333
+
334
+ function formatWorkflowContext(summary: string): string {
335
+ return `# AKM active workflows\n${summary}`
336
+ }
337
+
338
+ function formatCuratorReportContext(report: string): string {
339
+ return `# AKM curator report\n${report}`
340
+ }
341
+
342
+ function summarizeCuratorReportForContext(report: string): string {
343
+ if (report.length <= AKM_CURATOR_CONTEXT_MAX_CHARS) return report
344
+ return `${report.slice(0, AKM_CURATOR_CONTEXT_MAX_CHARS).trimEnd()}\n\n[truncated for context]`
345
+ }
346
+
347
+ function getAkmStashDir(): string | undefined {
348
+ if (cachedAkmStashDir !== undefined) return cachedAkmStashDir || undefined
349
+ const result = runCliSyncRaw(["--format", "json", "-q", "config", "get", "stashDir"], AKM_CURATE_TIMEOUT_MS)
350
+ if (!result.ok) {
351
+ cachedAkmStashDir = ""
352
+ return undefined
353
+ }
354
+ const parsed = parseMaybeJson(result.stdout)
355
+ if (typeof parsed === "string" && parsed.trim()) {
356
+ cachedAkmStashDir = parsed.trim()
357
+ return cachedAkmStashDir
358
+ }
359
+ if (parsed && typeof parsed === "object") {
360
+ for (const key of ["value", "path", "stashDir"]) {
361
+ const value = (parsed as Record<string, unknown>)[key]
362
+ if (typeof value === "string" && value.trim()) {
363
+ cachedAkmStashDir = value.trim()
364
+ return cachedAkmStashDir
365
+ }
366
+ }
367
+ }
368
+ const raw = result.stdout.trim()
369
+ cachedAkmStashDir = raw || ""
370
+ return cachedAkmStashDir || undefined
371
+ }
372
+
205
373
  function warmIndexInBackground(): void {
206
374
  const command = resolveAkmCommand()
207
375
  if (typeof command !== "string") return
@@ -214,31 +382,106 @@ function warmIndexInBackground(): void {
214
382
  }
215
383
  }
216
384
 
217
- function recordFeedbackSync(ref: string, sentiment: "positive" | "negative", note: string): boolean {
218
- const result = runCliSyncRaw(
219
- [
220
- "--format",
221
- "json",
222
- "-q",
223
- "feedback",
385
+ function queueFeedback(
386
+ client: LogCapableClient,
387
+ ref: string,
388
+ sentiment: "positive" | "negative",
389
+ note: string,
390
+ meta: CliLogMeta,
391
+ dedupe?: Set<string>,
392
+ ): boolean {
393
+ const dedupeKey = `${ref}:${sentiment}`
394
+ if (dedupe?.has(dedupeKey)) return true
395
+ dedupe?.add(dedupeKey)
396
+
397
+ const command = resolveAkmCommand()
398
+ if (typeof command !== "string") {
399
+ void writePluginLog(client, "warn", "AKM auto-feedback skipped", {
400
+ subsystem: "feedback",
401
+ toolName: meta.toolName,
402
+ sessionID: meta.sessionID,
403
+ directory: meta.directory,
224
404
  ref,
225
- sentiment === "positive" ? "--positive" : "--negative",
226
- "--note",
227
- note,
228
- ],
229
- AKM_CURATE_TIMEOUT_MS,
230
- )
231
- return result.ok
405
+ sentiment,
406
+ error: command.error,
407
+ })
408
+ return false
409
+ }
410
+
411
+ try {
412
+ const child = spawn(
413
+ command,
414
+ [
415
+ "--format",
416
+ "json",
417
+ "-q",
418
+ "feedback",
419
+ ref,
420
+ sentiment === "positive" ? "--positive" : "--negative",
421
+ "--note",
422
+ note,
423
+ ],
424
+ {
425
+ detached: true,
426
+ stdio: "ignore",
427
+ },
428
+ )
429
+ child.on("error", (error) => {
430
+ void writePluginLog(client, "warn", "AKM auto-feedback failed", {
431
+ subsystem: "feedback",
432
+ toolName: meta.toolName,
433
+ sessionID: meta.sessionID,
434
+ directory: meta.directory,
435
+ ref,
436
+ sentiment,
437
+ error: formatCliError(error),
438
+ })
439
+ })
440
+ child.unref()
441
+ return true
442
+ } catch (error: unknown) {
443
+ void writePluginLog(client, "warn", "AKM auto-feedback failed", {
444
+ subsystem: "feedback",
445
+ toolName: meta.toolName,
446
+ sessionID: meta.sessionID,
447
+ directory: meta.directory,
448
+ ref,
449
+ sentiment,
450
+ error: formatCliError(error),
451
+ })
452
+ return false
453
+ }
232
454
  }
233
455
 
234
- function captureSessionMemory(sessionID: string, reason: string): string | null {
456
+ function rememberTextAsMemory(name: string, body: string): string | null {
457
+ const command = resolveAkmCommand()
458
+ if (typeof command !== "string") return null
459
+ try {
460
+ execFileSync(command, ["--format", "json", "-q", "remember", "--name", name, "--force"], {
461
+ encoding: "utf8",
462
+ timeout: AKM_CURATE_TIMEOUT_MS * 2,
463
+ input: body,
464
+ })
465
+ return `memory:${name}`
466
+ } catch {
467
+ return null
468
+ }
469
+ }
470
+
471
+ function captureSessionMemory(
472
+ sessionID: string,
473
+ reason: string,
474
+ options?: { checkpoint?: boolean },
475
+ ): string | null {
235
476
  if (!AKM_AUTO_MEMORY) return null
236
477
  if (!sessionID) return null
237
- if (sessionMemoryCaptured.has(sessionID)) return null
478
+ const isCheckpoint = options?.checkpoint === true
479
+ if (!isCheckpoint && sessionFinalMemoryCaptured.has(sessionID)) return null
238
480
  const entries = sessionBuffer.get(sessionID) ?? []
481
+ const pendingEntries = isCheckpoint ? entries.filter((entry) => !entry.checkpointed) : entries
239
482
  // Require at least two observations before persisting — single events are noise.
240
- if (entries.length < 2) {
241
- sessionBuffer.delete(sessionID)
483
+ if (pendingEntries.length < 2) {
484
+ if (!isCheckpoint) sessionBuffer.delete(sessionID)
242
485
  return null
243
486
  }
244
487
 
@@ -247,7 +490,7 @@ function captureSessionMemory(sessionID: string, reason: string): string | null
247
490
  lines.push(`Reason: ${reason}`)
248
491
  lines.push(`Session: ${sessionID}`)
249
492
  lines.push("")
250
- for (const entry of entries) {
493
+ for (const entry of pendingEntries) {
251
494
  if (entry.kind === "memory-intent") {
252
495
  lines.push(`## ${entry.timestamp} — user memory intent`)
253
496
  if (entry.note) lines.push(entry.note)
@@ -261,38 +504,74 @@ function captureSessionMemory(sessionID: string, reason: string): string | null
261
504
  }
262
505
  const body = lines.join("\n")
263
506
 
264
- const dateTag = new Date().toISOString().replace(/[-:]/g, "").slice(0, 8)
507
+ const dateTag = buildDateTag({ includeTime: isCheckpoint })
265
508
  const shortSid = sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
266
- const name = `opencode-session-${dateTag}-${shortSid}`
509
+ const name = isCheckpoint
510
+ ? `opencode-checkpoint-${dateTag}-${shortSid}`
511
+ : `opencode-session-${dateTag}-${shortSid}`
267
512
 
268
- const command = resolveAkmCommand()
269
- if (typeof command !== "string") {
270
- sessionMemoryCaptured.add(sessionID)
271
- sessionBuffer.delete(sessionID)
513
+ const ref = rememberTextAsMemory(name, body)
514
+ if (!ref) {
515
+ if (!isCheckpoint) {
516
+ sessionFinalMemoryCaptured.add(sessionID)
517
+ sessionBuffer.delete(sessionID)
518
+ }
272
519
  return null
273
520
  }
274
- try {
275
- execFileSync(command, ["--format", "json", "-q", "remember", "--name", name, "--force"], {
276
- encoding: "utf8",
277
- timeout: AKM_CURATE_TIMEOUT_MS * 2,
278
- input: body,
279
- })
280
- sessionMemoryCaptured.add(sessionID)
281
- sessionBuffer.delete(sessionID)
282
- return `memory:${name}`
283
- } catch {
284
- sessionMemoryCaptured.add(sessionID)
285
- sessionBuffer.delete(sessionID)
286
- return null
521
+
522
+ if (isCheckpoint) {
523
+ for (const entry of entries) {
524
+ if (!entry.checkpointed) entry.checkpointed = true
525
+ }
526
+ sessionSuccessfulAssetTouchCount.set(sessionID, 0)
527
+ sessionBuffer.set(sessionID, entries)
528
+ return ref
287
529
  }
530
+
531
+ sessionFinalMemoryCaptured.add(sessionID)
532
+ sessionBuffer.delete(sessionID)
533
+ return ref
288
534
  }
289
535
 
290
- function extractToolRefs(toolName: string, args: Record<string, unknown>, output: unknown): string[] {
536
+ function maybeCheckpointSessionMemory(sessionID: string): string | null {
537
+ const count = sessionSuccessfulAssetTouchCount.get(sessionID) ?? 0
538
+ if (count < AKM_MEMORY_CHECKPOINT_EVERY) return null
539
+ const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true })
540
+ if (!captured) {
541
+ sessionSuccessfulAssetTouchCount.set(sessionID, 0)
542
+ }
543
+ return captured
544
+ }
545
+
546
+ const AKM_REF_EDGE_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "'", "\"", "`"])
547
+
548
+ function normalizeExtractedRef(ref: string): string {
549
+ let start = 0
550
+ let end = ref.length
551
+ while (start < end && AKM_REF_EDGE_PUNCTUATION.has(ref[start] ?? "")) start += 1
552
+ while (end > start && AKM_REF_EDGE_PUNCTUATION.has(ref[end - 1] ?? "")) end -= 1
553
+ return ref.slice(start, end)
554
+ }
555
+
556
+ function extractRefsFromText(value: string): string[] {
291
557
  const refs = new Set<string>()
558
+ for (const token of value.split(/\s+/)) {
559
+ const normalized = normalizeExtractedRef(token)
560
+ if (normalized && isAkmRef(normalized)) refs.add(normalized)
561
+ }
562
+ return [...refs]
563
+ }
564
+
565
+ function extractToolRefs(
566
+ toolName: string,
567
+ args: Record<string, unknown>,
568
+ output: unknown,
569
+ ): { refs: string[]; positiveOnlyRefs: string[] } {
570
+ const refs = new Set<string>()
571
+ const positiveOnlyRefs = new Set<string>()
292
572
  const addMatches = (value: unknown) => {
293
573
  if (typeof value !== "string") return
294
- const matches = value.match(AKM_REF_PATTERN)
295
- if (matches) for (const ref of matches) refs.add(ref)
574
+ for (const ref of extractRefsFromText(value)) refs.add(ref)
296
575
  }
297
576
 
298
577
  for (const key of ["ref", "package_ref"]) {
@@ -313,9 +592,18 @@ function extractToolRefs(toolName: string, args: Record<string, unknown>, output
313
592
  }
314
593
  }
315
594
  if (toolName === "akm_remember" && typeof o.ref === "string") addMatches(o.ref)
595
+ if (
596
+ (toolName === "akm_agent" || toolName === "akm_cmd" || toolName === "akm_evolve")
597
+ && typeof o.text === "string"
598
+ ) {
599
+ for (const ref of extractRefsFromText(o.text)) {
600
+ refs.add(ref)
601
+ positiveOnlyRefs.add(ref)
602
+ }
603
+ }
316
604
  }
317
605
 
318
- return [...refs]
606
+ return { refs: [...refs], positiveOnlyRefs: [...positiveOnlyRefs] }
319
607
  }
320
608
 
321
609
  const AKM_HINTS_PREFIX = [
@@ -327,6 +615,98 @@ const AKM_HINTS_PREFIX = [
327
615
  const AKM_CURATED_HEADER = "# AKM stash — assets relevant to this prompt"
328
616
  const AKM_CURATED_TAIL = "\n\nTip: call `akm_show <ref>` to fetch full content, and record `akm_feedback <ref> positive|negative` once you know whether the asset helped."
329
617
 
618
+ // Curated quick-reference for the long-tail of `akm` CLI verbs that no longer
619
+ // have a dedicated tool wrapper. Surfaced through akm_help so agents can
620
+ // always find the right invocation without polluting default context.
621
+ type AkmHelpEntry = {
622
+ task: string
623
+ command: string
624
+ notes?: string
625
+ keywords: string[]
626
+ }
627
+
628
+ const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
629
+ {
630
+ task: "Install a kit or register an external source (npm, GitHub, git, URL, local dir)",
631
+ command: "akm add <package-ref> [--name <n>] [--type wiki] [--writable] [--trust] [--provider <p>] [--max-pages N] [--max-depth N]",
632
+ notes: "Confirm with the user before passing --trust or registering a website crawler.",
633
+ keywords: ["add", "install", "register", "kit", "source", "github", "npm"],
634
+ },
635
+ {
636
+ task: "Commit (and optionally push) pending stash changes",
637
+ command: "akm save [<source-name>] [-m <msg>] [--push]",
638
+ notes: "Add --push only when the stash is writable; review the diff first.",
639
+ keywords: ["save", "commit", "push", "publish", "git"],
640
+ },
641
+ {
642
+ task: "Import a file (or stdin) into the stash as a typed asset",
643
+ command: "akm import <path|-> [--name <name>] [--force]",
644
+ notes: "Use `-` and pipe content via stdin to import a string.",
645
+ keywords: ["import", "ingest", "upload", "stdin"],
646
+ },
647
+ {
648
+ task: "Clone an asset from any source for editing",
649
+ command: "akm clone <ref> [--name <new>] [--dest <dir>] [--force]",
650
+ notes: "Type subdirectory is appended automatically; ref may include origin (e.g. npm:@scope/pkg//script:foo).",
651
+ keywords: ["clone", "copy", "fork", "edit"],
652
+ },
653
+ {
654
+ task: "Update a managed source (or all of them)",
655
+ command: "akm update [<package_ref>|--all] [--force]",
656
+ keywords: ["update", "upgrade kit", "refresh", "pull"],
657
+ },
658
+ {
659
+ task: "Remove a configured source and reindex",
660
+ command: "akm remove <id|ref|path|url|name>",
661
+ notes: "Destructive — confirm intent before running.",
662
+ keywords: ["remove", "uninstall", "delete source"],
663
+ },
664
+ {
665
+ task: "List configured sources (local dirs, kits, remotes)",
666
+ command: "akm list",
667
+ keywords: ["list", "sources", "kits", "show sources"],
668
+ },
669
+ {
670
+ task: "Search the registry only (skip local stash)",
671
+ command: "akm registry search <query> [--limit N] [--assets]",
672
+ notes: "akm_search with source='registry' covers most cases; this is the explicit form.",
673
+ keywords: ["registry", "search registry", "installable", "discover kit"],
674
+ },
675
+ {
676
+ task: "Build or rebuild the stash search index",
677
+ command: "akm index",
678
+ notes: "Rarely needed — the index refreshes implicitly after writes.",
679
+ keywords: ["index", "reindex", "rebuild"],
680
+ },
681
+ {
682
+ task: "View or update akm config (get/set/list/unset/path)",
683
+ command: "akm config <action> [<key>] [<value>] [--all]",
684
+ notes: "`akm config path --all` prints config, stash, cache, and index paths.",
685
+ keywords: ["config", "settings", "configure", "path"],
686
+ },
687
+ {
688
+ task: "Check for or install an akm CLI update",
689
+ command: "akm upgrade [--check] [--force]",
690
+ keywords: ["upgrade cli", "update cli", "self-upgrade"],
691
+ },
692
+ {
693
+ task: "Run a stash script end-to-end (resolve → show → run)",
694
+ command: "akm show <script-ref> # then exec the printed `run` command",
695
+ notes: "Or `akm --format json -q show <ref>` and pipe `.run` into your shell.",
696
+ keywords: ["run", "execute", "script", "exec"],
697
+ },
698
+ ]
699
+
700
+ function lookupAkmHelpHint(topic: string): AkmHelpEntry[] {
701
+ const needle = topic.toLowerCase().trim()
702
+ if (!needle) return []
703
+ return AKM_HELP_QUICK_REFERENCE.filter((entry) =>
704
+ entry.keywords.some((kw) => needle.includes(kw))
705
+ || entry.task.toLowerCase().includes(needle)
706
+ || entry.command.toLowerCase().includes(needle),
707
+ )
708
+ }
709
+
330
710
  function extractSessionIdFromEvent(payload: unknown): string | undefined {
331
711
  if (!payload || typeof payload !== "object") return undefined
332
712
  const p = payload as Record<string, unknown>
@@ -460,7 +840,7 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
460
840
  return JSON.stringify(command)
461
841
  }
462
842
 
463
- const fullArgs = [...args, "--format", "json"]
843
+ const fullArgs = args.includes("--format") ? [...args] : [...args, "--format", "json"]
464
844
 
465
845
  try {
466
846
  const stdout = execFileSync(command, fullArgs, {
@@ -619,6 +999,12 @@ function parseCliJson<T>(raw: string): T | CliError {
619
999
  }
620
1000
  }
621
1001
 
1002
+ function blockedToolResponse(args: Record<string, unknown>): string | null {
1003
+ return typeof args.__akmBlocked === "string"
1004
+ ? JSON.stringify({ ok: false, error: args.__akmBlocked })
1005
+ : null
1006
+ }
1007
+
622
1008
  function isCliError(value: unknown): value is CliError {
623
1009
  return !!value
624
1010
  && typeof value === "object"
@@ -736,33 +1122,45 @@ function truncateLogText(value: string, limit = 1_000): string {
736
1122
  return value.length > limit ? `${value.slice(0, limit)}…` : value
737
1123
  }
738
1124
 
739
- async function resolveRefInput(
1125
+ async function searchRef(
740
1126
  client: LogCapableClient,
741
- input: { ref?: string; query?: string },
742
- type: AssetType,
1127
+ query: string,
1128
+ type: AssetType | "any",
743
1129
  meta: CliLogMeta,
744
1130
  ): Promise<{ ok: true; ref: string } | CliError> {
745
- if (input.ref && input.ref.trim()) {
746
- return { ok: true, ref: input.ref.trim() }
747
- }
748
-
749
- const query = input.query?.trim()
750
- if (!query) {
751
- return { ok: false, error: "Provide either 'ref' or 'query'." }
752
- }
753
-
754
- const raw = await runCli(client, ["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "stash"], meta)
1131
+ const raw = await runCli(
1132
+ client,
1133
+ ["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "stash"],
1134
+ meta,
1135
+ )
755
1136
  const parsed = parseCliJson<SearchResponse>(raw)
756
1137
  if (isCliError(parsed)) return parsed
757
-
758
1138
  const ref = parsed.hits?.[0]?.ref
759
1139
  if (!ref) {
760
- return { ok: false, error: `No ${type} match found for query '${query}'.` }
1140
+ return {
1141
+ ok: false,
1142
+ error: `No stash ref matched '${query}'. Use akm_search to disambiguate, then retry with an exact ref.`,
1143
+ }
761
1144
  }
762
-
763
1145
  return { ok: true, ref }
764
1146
  }
765
1147
 
1148
+ async function resolveRefOrQueryInput(
1149
+ client: LogCapableClient,
1150
+ input: { ref?: string; query?: string },
1151
+ type: AssetType | "any",
1152
+ meta: CliLogMeta,
1153
+ ): Promise<{ ok: true; ref: string } | CliError> {
1154
+ const explicitRef = input.ref?.trim()
1155
+ if (explicitRef) return { ok: true, ref: explicitRef }
1156
+
1157
+ const query = input.query?.trim()
1158
+ if (!query) {
1159
+ return { ok: false, error: "Provide either 'ref' or 'query'." }
1160
+ }
1161
+ return searchRef(client, query, type, meta)
1162
+ }
1163
+
766
1164
  async function ensureTargetSessionID(input: {
767
1165
  useSubtask: boolean
768
1166
  context: { sessionID: string; directory: string }
@@ -875,6 +1273,65 @@ async function promptTargetSession(input: {
875
1273
  }
876
1274
  }
877
1275
 
1276
+ async function resolveDispatchAgent(
1277
+ client: PluginClient,
1278
+ requestedAgent: string,
1279
+ directory: string,
1280
+ ): Promise<string> {
1281
+ if (requestedAgent !== "akm-curator") return requestedAgent
1282
+ try {
1283
+ const agents = await client.app.agents({ query: { directory } })
1284
+ if (agents.error) return "general"
1285
+ const hasCurator = (agents.data ?? []).some((agent) => agent?.name === "akm-curator")
1286
+ return hasCurator ? "akm-curator" : "general"
1287
+ } catch {
1288
+ return "general"
1289
+ }
1290
+ }
1291
+
1292
+ function summarizeSessionMessages(
1293
+ sessionID: string,
1294
+ messages: Array<{ info?: Record<string, unknown>; parts?: unknown }>,
1295
+ ) {
1296
+ return {
1297
+ ok: true,
1298
+ sessionID,
1299
+ messages: messages.map((message) => {
1300
+ const info = message.info ?? {}
1301
+ const role = typeof info.role === "string" ? info.role : "unknown"
1302
+ const agent = typeof info.agent === "string"
1303
+ ? info.agent
1304
+ : typeof info.mode === "string"
1305
+ ? info.mode
1306
+ : null
1307
+ return {
1308
+ role,
1309
+ agent,
1310
+ text: extractText(message.parts),
1311
+ }
1312
+ }),
1313
+ }
1314
+ }
1315
+
1316
+ async function getParentSessionID(
1317
+ client: PluginClient,
1318
+ sessionID: string,
1319
+ directory: string,
1320
+ ): Promise<{ ok: true; parentID: string } | CliError> {
1321
+ try {
1322
+ const result = await client.session.get({
1323
+ path: { id: sessionID },
1324
+ query: { directory },
1325
+ })
1326
+ if (result.error || !result.data?.parentID) {
1327
+ return { ok: false, error: "This session does not have a parent session." }
1328
+ }
1329
+ return { ok: true, parentID: result.data.parentID }
1330
+ } catch (error: unknown) {
1331
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
1332
+ }
1333
+ }
1334
+
878
1335
  function splitArguments(raw: string): string[] {
879
1336
  if (!raw.trim()) return []
880
1337
  const args: string[] = []
@@ -921,17 +1378,31 @@ type PluginClient = {
921
1378
  create: (input: {
922
1379
  body: { parentID: string; title: string }
923
1380
  }) => Promise<{ data?: { id?: string }; error?: unknown }>
1381
+ get: (input: {
1382
+ path: { id: string }
1383
+ query?: { directory?: string }
1384
+ }) => Promise<{ data?: { id?: string; parentID?: string }; error?: unknown }>
1385
+ messages: (input: {
1386
+ path: { id: string }
1387
+ query?: { directory?: string }
1388
+ }) => Promise<{ data?: Array<{ info?: Record<string, unknown>; parts?: unknown }>; error?: unknown }>
924
1389
  prompt: (input: {
925
1390
  path: { id: string }
926
1391
  body: SessionPromptBody
927
1392
  }) => Promise<{ data?: { parts?: unknown }; error?: unknown }>
928
1393
  }
1394
+ app: {
1395
+ agents: (input?: {
1396
+ query?: { directory?: string }
1397
+ }) => Promise<{ data?: Array<{ name?: string }>; error?: unknown }>
1398
+ }
929
1399
  }
930
1400
 
931
- export const AkmPlugin: Plugin = async ({ client }) => {
1401
+ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
932
1402
  await ensureLatestAkmInstalled(client as unknown as LogCapableClient)
933
1403
 
934
1404
  const logClient = client as unknown as LogCapableClient
1405
+ const sdkClient = client as unknown as PluginClient
935
1406
 
936
1407
  return {
937
1408
  // Events cover the lifecycle boundaries that Claude Code exposes as
@@ -944,11 +1415,15 @@ export const AkmPlugin: Plugin = async ({ client }) => {
944
1415
  const sid = extractSessionIdFromEvent(event) ?? extractSessionIdFromEvent((event as { properties?: unknown }).properties)
945
1416
  if (type === "session.created" || type === "session.updated") {
946
1417
  if (!sid) return
947
- if (!AKM_AUTO_HINTS) return
948
- if (sessionHints.has(sid)) return
949
- warmIndexInBackground()
950
- const hints = runHintsForSession()
951
- if (hints) sessionHints.set(sid, hints)
1418
+ if (!sessionContextEpoch.has(sid)) sessionContextEpoch.set(sid, 0)
1419
+ if (type === "session.created") warmIndexInBackground()
1420
+ if (AKM_AUTO_HINTS && !sessionHints.has(sid)) {
1421
+ const hints = runHintsForSession()
1422
+ if (hints) sessionHints.set(sid, hints)
1423
+ }
1424
+ if (!sessionWorkflow.has(sid)) {
1425
+ sessionWorkflow.set(sid, runWorkflowSummaryForSession() ?? "")
1426
+ }
952
1427
  } else if (type === "session.compacted" || type === "session.idle" || type === "session.deleted") {
953
1428
  if (!sid) return
954
1429
  const captured = captureSessionMemory(sid, type)
@@ -966,7 +1441,14 @@ export const AkmPlugin: Plugin = async ({ client }) => {
966
1441
  if (type === "session.deleted") {
967
1442
  sessionHints.delete(sid)
968
1443
  sessionCurated.delete(sid)
969
- sessionMemoryCaptured.delete(sid)
1444
+ sessionWorkflow.delete(sid)
1445
+ sessionCuratorReport.delete(sid)
1446
+ sessionContextEpoch.delete(sid)
1447
+ sessionContextInjectedEpoch.delete(sid)
1448
+ sessionCuratedVersion.delete(sid)
1449
+ sessionCuratedInjectedVersion.delete(sid)
1450
+ sessionFinalMemoryCaptured.delete(sid)
1451
+ sessionSuccessfulAssetTouchCount.delete(sid)
970
1452
  sessionBuffer.delete(sid)
971
1453
  }
972
1454
  }
@@ -995,6 +1477,24 @@ export const AkmPlugin: Plugin = async ({ client }) => {
995
1477
  // Best-effort only.
996
1478
  }
997
1479
  },
1480
+ "experimental.session.compacting": async (input, output) => {
1481
+ try {
1482
+ const sid = input.sessionID
1483
+ if (!sid) return
1484
+ if (!Array.isArray(output.context)) return
1485
+ markContextEpochDirty(sid)
1486
+ const hints = sessionHints.get(sid)
1487
+ if (hints) output.context.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
1488
+ const curated = sessionCurated.get(sid)
1489
+ if (curated) output.context.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
1490
+ const workflow = sessionWorkflow.get(sid)
1491
+ if (workflow) output.context.push(formatWorkflowContext(workflow))
1492
+ const curatorReport = sessionCuratorReport.get(sid)
1493
+ if (curatorReport) output.context.push(formatCuratorReportContext(curatorReport))
1494
+ } catch {
1495
+ // Never break compaction because of plugin context.
1496
+ }
1497
+ },
998
1498
  // experimental.chat.system.transform is how OpenCode exposes the
999
1499
  // additionalContext channel. We append the cached hints (once per session)
1000
1500
  // and the curated assets (once per turn) so the next LLM call sees them.
@@ -1005,21 +1505,56 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1005
1505
  try {
1006
1506
  if (!output || !Array.isArray(output.system)) return
1007
1507
  const sid = extractSessionIdFromEvent(input) ?? ""
1008
- const hints = sid ? sessionHints.get(sid) : undefined
1009
- if (hints) {
1010
- output.system.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
1011
- // Only inject hints on the first transform of the session.
1012
- sessionHints.delete(sid)
1508
+ const epoch = sessionContextEpoch.get(sid) ?? 0
1509
+ const injectedEpoch = sessionContextInjectedEpoch.get(sid)
1510
+ if (sid && injectedEpoch !== epoch) {
1511
+ const hints = sessionHints.get(sid)
1512
+ if (hints) output.system.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
1513
+ const workflow = sessionWorkflow.get(sid)
1514
+ if (workflow) output.system.push(formatWorkflowContext(workflow))
1515
+ const curatorReport = sessionCuratorReport.get(sid)
1516
+ if (curatorReport) output.system.push(formatCuratorReportContext(curatorReport))
1517
+ sessionContextInjectedEpoch.set(sid, epoch)
1013
1518
  }
1014
1519
  const curated = sid ? sessionCurated.get(sid) : undefined
1520
+ const curatedVersion = sessionCuratedVersion.get(sid) ?? 0
1015
1521
  if (curated) {
1016
- output.system.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
1017
- sessionCurated.delete(sid)
1522
+ if (sessionCuratedInjectedVersion.get(sid) !== curatedVersion) {
1523
+ output.system.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
1524
+ sessionCuratedInjectedVersion.set(sid, curatedVersion)
1525
+ }
1018
1526
  }
1019
1527
  } catch {
1020
1528
  // Never break the turn because of a transform failure.
1021
1529
  }
1022
1530
  },
1531
+ "tool.execute.before": async (input, output) => {
1532
+ try {
1533
+ if (!input.tool.startsWith("akm_")) return
1534
+ const args = output.args && typeof output.args === "object" ? output.args as Record<string, unknown> : {}
1535
+ const confirm = args.confirm === true
1536
+ if (input.tool === "akm_vault" && (args.action === "show" || args.action === "unset") && !confirm) {
1537
+ output.args = {
1538
+ ...args,
1539
+ __akmBlocked: `akm_vault action='${String(args.action)}' requires confirm:true to avoid accidental secret exposure or deletion.`,
1540
+ }
1541
+ return
1542
+ }
1543
+ output.args = args
1544
+ } catch {
1545
+ // Never break tool execution from the pre-hook.
1546
+ }
1547
+ },
1548
+ "shell.env": async (_input, output) => {
1549
+ try {
1550
+ output.env.AKM_PROJECT = worktree
1551
+ output.env.AKM_PLUGIN_VERSION = PLUGIN_VERSION
1552
+ const stashDir = getAkmStashDir()
1553
+ if (stashDir) output.env.AKM_STASH_DIR = stashDir
1554
+ } catch {
1555
+ // Best-effort only.
1556
+ }
1557
+ },
1023
1558
  "chat.message": async (input, output) => {
1024
1559
  const text = extractText(output.parts).trim()
1025
1560
  if (!text) return
@@ -1036,7 +1571,10 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1036
1571
  // stash the result so experimental.chat.system.transform can inject it.
1037
1572
  if (AKM_AUTO_CURATE && input.sessionID) {
1038
1573
  const curated = runCurateForPrompt(text)
1039
- if (curated) sessionCurated.set(input.sessionID, curated)
1574
+ if (curated) {
1575
+ sessionCurated.set(input.sessionID, curated)
1576
+ bumpCuratedVersion(input.sessionID)
1577
+ }
1040
1578
  }
1041
1579
 
1042
1580
  // Track explicit memory intents so capture-memory has something durable
@@ -1047,6 +1585,21 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1047
1585
  note: truncateLogText(text, 500),
1048
1586
  })
1049
1587
  }
1588
+
1589
+ if (input.sessionID && AKM_AUTO_FEEDBACK && AKM_RETROSPECTIVE_FEEDBACK_RE.test(text)) {
1590
+ const recentRefs = (sessionBuffer.get(input.sessionID) ?? [])
1591
+ .filter((entry) => entry.kind === "tool-ref" && !!entry.ref)
1592
+ .map((entry) => entry.ref!)
1593
+ .filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("vault:") && refs.indexOf(ref) === index)
1594
+ .slice(-3)
1595
+ const dedupe = new Set<string>()
1596
+ for (const ref of recentRefs) {
1597
+ queueFeedback(logClient, ref, "positive", "opencode retrospective: user confirmed it worked", {
1598
+ toolName: "chat.message",
1599
+ sessionID: input.sessionID,
1600
+ }, dedupe)
1601
+ }
1602
+ }
1050
1603
  },
1051
1604
  "tool.execute.after": async (input, output) => {
1052
1605
  if (!input.tool.startsWith("akm_")) return
@@ -1082,9 +1635,9 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1082
1635
  // Auto-feedback + session buffering: record every asset ref the tool
1083
1636
  // touched so the stash ranking improves over time and so Stop/Compact
1084
1637
  // has material to flush into a session summary memory.
1085
- const allRefs = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
1086
- if (allRefs.length > 0 && input.sessionID) {
1087
- for (const ref of allRefs) {
1638
+ const refResult = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
1639
+ if (refResult.refs.length > 0 && input.sessionID) {
1640
+ for (const ref of refResult.refs) {
1088
1641
  addBufferEntry(input.sessionID, {
1089
1642
  kind: "tool-ref",
1090
1643
  toolName: input.tool,
@@ -1092,30 +1645,53 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1092
1645
  status: feedback ?? "unknown",
1093
1646
  })
1094
1647
  }
1648
+ if (feedback === "positive") {
1649
+ sessionSuccessfulAssetTouchCount.set(
1650
+ input.sessionID,
1651
+ (sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1,
1652
+ )
1653
+ const checkpointRef = maybeCheckpointSessionMemory(input.sessionID)
1654
+ if (checkpointRef) {
1655
+ await writePluginLog(logClient, "info", "AKM checkpoint memory captured", {
1656
+ subsystem: "memory",
1657
+ actor: "system",
1658
+ sessionID: input.sessionID,
1659
+ reason: "checkpoint",
1660
+ ref: checkpointRef,
1661
+ })
1662
+ }
1663
+ }
1095
1664
  }
1096
1665
 
1097
1666
  if (
1098
1667
  AKM_AUTO_FEEDBACK
1099
1668
  && feedback
1100
1669
  && input.tool !== "akm_feedback"
1101
- && allRefs.length > 0
1670
+ && refResult.refs.length > 0
1102
1671
  ) {
1672
+ const dedupe = new Set<string>()
1673
+ const feedbackRefs = feedback === "positive"
1674
+ ? refResult.refs
1675
+ : refResult.refs.filter((ref) => !refResult.positiveOnlyRefs.includes(ref))
1103
1676
  const note = feedback === "positive"
1104
1677
  ? `opencode auto: ${input.tool} succeeded`
1105
1678
  : `opencode auto: ${input.tool} failed`
1106
- for (const ref of allRefs) {
1679
+ for (const ref of feedbackRefs) {
1107
1680
  // Memories and vault refs are not first-class feedback targets —
1108
1681
  // memories do not accept feedback, and vault values never surface in
1109
1682
  // JSON so automatic usage signals would be misleading.
1110
1683
  if (ref.startsWith("memory:") || ref.startsWith("vault:")) continue
1111
- const ok = recordFeedbackSync(ref, feedback, note)
1684
+ const ok = queueFeedback(logClient, ref, feedback, note, {
1685
+ toolName: input.tool,
1686
+ sessionID: input.sessionID,
1687
+ }, dedupe)
1112
1688
  if (!ok) break
1113
1689
  }
1114
1690
  }
1115
1691
  },
1116
1692
  tool: {
1117
1693
  akm_search: tool({
1118
- description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, memories, workflows, vaults, and wikis. Use source='registry' or akm_registry_search for installable community kits.",
1694
+ description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, memories, workflows, vaults, and wikis. Use source='registry' for installable community kits.",
1119
1695
  args: {
1120
1696
  query: tool.schema.string().describe("Case-insensitive substring search."),
1121
1697
  type: tool.schema
@@ -1132,41 +1708,6 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1132
1708
  return runCli(client as unknown as LogCapableClient, createSearchArgs({ query, type, limit, source }), { toolName: "akm_search" })
1133
1709
  },
1134
1710
  }),
1135
- akm_registry_search: tool({
1136
- description: "Search configured akm registries only. Use this when you want installable kits without mixing in local stash results.",
1137
- args: {
1138
- query: tool.schema.string().describe("Search query for installable registry kits."),
1139
- type: tool.schema
1140
- .enum(ASSET_TYPES as unknown as [string, ...string[]])
1141
- .optional()
1142
- .describe("Optional asset type filter. Defaults to 'any'."),
1143
- limit: tool.schema.number().optional().describe("Maximum number of registry hits to return. Defaults to 20."),
1144
- assets: tool.schema.boolean().optional().describe("Include asset-level results from registry index v2 payloads."),
1145
- },
1146
- async execute({ query, type, limit, assets }) {
1147
- const args = ["registry", "search", query]
1148
- if (limit) args.push("--limit", String(limit))
1149
- const assetTypeFilter = type && type !== "any" ? type : undefined
1150
- if (assets || assetTypeFilter) args.push("--assets")
1151
-
1152
- const raw = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_registry_search" })
1153
- if (!assetTypeFilter) return raw
1154
-
1155
- const parsed = parseCliJson<{
1156
- hits?: SearchHit[]
1157
- assetHits?: Array<SearchHit & { assetType?: AssetType }>
1158
- warnings?: string[]
1159
- query?: string
1160
- }>(raw)
1161
- if (isCliError(parsed)) return JSON.stringify(parsed)
1162
-
1163
- return JSON.stringify({
1164
- ...parsed,
1165
- hits: [],
1166
- assetHits: (parsed.assetHits ?? []).filter((hit) => hit.assetType === assetTypeFilter),
1167
- })
1168
- },
1169
- }),
1170
1711
  akm_show: tool({
1171
1712
  description: "Show a stash asset by ref. For knowledge assets, use view_mode to retrieve specific content (toc, section, lines, frontmatter).",
1172
1713
  args: {
@@ -1195,92 +1736,6 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1195
1736
  return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_show" })
1196
1737
  },
1197
1738
  }),
1198
- akm_index: tool({
1199
- description: "Build or rebuild the akm stash index. Scans stash directories, generates missing .stash.json metadata, and builds a semantic search index.",
1200
- args: {},
1201
- async execute() {
1202
- return runCli(client as unknown as LogCapableClient, ["index"], { toolName: "akm_index" })
1203
- },
1204
- }),
1205
- akm_add: tool({
1206
- description: "Install a kit or register an external source from npm, GitHub, another git host, a URL, or a local directory. Use type='wiki' to register a wiki source instead of a stash kit.",
1207
- args: {
1208
- package_ref: tool.schema.string().describe("Package reference such as npm:@scope/kit, github:<owner>/<repo>, git+https://host/repo, https://url, or ./local/kit."),
1209
- type: tool.schema.enum(["wiki"]).optional().describe("Route the add through a typed registrar. 'wiki' registers an external wiki source."),
1210
- name: tool.schema.string().optional().describe("Optional name to register the source under."),
1211
- writable: tool.schema.boolean().optional().describe("Mark a git-backed source as push-writable (used by akm save)."),
1212
- trust: tool.schema.boolean().optional().describe("Bypass install-audit blocking for this registration only."),
1213
- provider: tool.schema.string().optional().describe("Provider hint (required for raw URL refs, e.g. 'github', 'website')."),
1214
- options: tool.schema.string().optional().describe("JSON string of provider-specific options."),
1215
- max_pages: tool.schema.number().optional().describe("Cap for website crawlers (default 50)."),
1216
- max_depth: tool.schema.number().optional().describe("Depth cap for website crawlers (default 3)."),
1217
- },
1218
- async execute({ package_ref, type, name, writable, trust, provider, options, max_pages, max_depth }) {
1219
- const args = ["add", package_ref]
1220
- if (type) args.push("--type", type)
1221
- if (name) args.push("--name", name)
1222
- if (writable) args.push("--writable")
1223
- if (trust) args.push("--trust")
1224
- if (provider) args.push("--provider", provider)
1225
- if (options) args.push("--options", options)
1226
- if (max_pages != null) args.push("--max-pages", String(max_pages))
1227
- if (max_depth != null) args.push("--max-depth", String(max_depth))
1228
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_add" })
1229
- },
1230
- }),
1231
- akm_list: tool({
1232
- description: "List all configured AKM sources, including local directories, managed kits, and remote providers.",
1233
- args: {},
1234
- async execute() {
1235
- return runCli(client as unknown as LogCapableClient, ["list"], { toolName: "akm_list" })
1236
- },
1237
- }),
1238
- akm_remove: tool({
1239
- description: "Remove a configured AKM source by id, ref, path, URL, or name and reindex the stash.",
1240
- args: {
1241
- package_ref: tool.schema.string().describe("Source id, ref, path, URL, or name, such as npm:@scope/kit, owner/repo, or ~/.claude/skills."),
1242
- },
1243
- async execute({ package_ref }) {
1244
- return runCli(client as unknown as LogCapableClient, ["remove", package_ref], { toolName: "akm_remove" })
1245
- },
1246
- }),
1247
- akm_update: tool({
1248
- description: "Update one managed AKM source or all managed sources to the latest available version.",
1249
- args: {
1250
- package_ref: tool.schema.string().optional().describe("Managed source id or ref to update."),
1251
- all: tool.schema.boolean().optional().describe("Update all installed kits."),
1252
- force: tool.schema.boolean().optional().describe("Force a fresh download even if the version is unchanged."),
1253
- },
1254
- async execute({ package_ref, all, force }) {
1255
- const args = ["update"]
1256
- const packageRef = package_ref?.trim()
1257
- if (all) {
1258
- args.push("--all")
1259
- } else if (packageRef) {
1260
- args.push(packageRef)
1261
- } else {
1262
- return JSON.stringify({ ok: false, error: "Provide 'package_ref' or set 'all' to true." })
1263
- }
1264
- if (force) args.push("--force")
1265
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_update" })
1266
- },
1267
- }),
1268
- akm_clone: tool({
1269
- description: "Clone an asset from any source into the working stash or a custom destination for editing.",
1270
- args: {
1271
- ref: tool.schema.string().describe("Asset ref to clone, including optional origin such as npm:@scope/pkg//script:deploy.sh."),
1272
- name: tool.schema.string().optional().describe("Optional new asset name."),
1273
- dest: tool.schema.string().optional().describe("Optional destination directory. The type subdirectory is appended automatically by akm."),
1274
- force: tool.schema.boolean().optional().describe("Overwrite the destination if it already exists."),
1275
- },
1276
- async execute({ ref, name, dest, force }) {
1277
- const args = ["clone", ref]
1278
- if (name) args.push("--name", name)
1279
- if (dest) args.push("--dest", dest)
1280
- if (force) args.push("--force")
1281
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_clone" })
1282
- },
1283
- }),
1284
1739
  akm_remember: tool({
1285
1740
  description: "Record a memory in the default AKM stash so it can be searched and shown later.",
1286
1741
  args: {
@@ -1332,20 +1787,21 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1332
1787
  },
1333
1788
  }),
1334
1789
  akm_evolve: tool({
1335
- description: "Dispatch the AKM curator agent to review recent session activity and propose stash improvements (promote hot assets, flag cold ones, draft missing coverage).",
1790
+ 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.",
1336
1791
  args: {
1337
1792
  focus: tool.schema.string().optional().describe("Optional focus area or theme to weight the review toward."),
1338
- dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the curator with. Defaults to 'general'."),
1793
+ 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."),
1339
1794
  as_subtask: tool.schema.boolean().optional().describe("Run in a child session with parent context. Defaults to true."),
1340
1795
  },
1341
1796
  async execute({ focus, dispatch_agent, as_subtask }, context) {
1342
1797
  const useSubtask = as_subtask ?? true
1343
- const targetAgent = dispatch_agent ?? "general"
1798
+ const requestedAgent = dispatch_agent ?? "akm-curator"
1799
+ const targetAgent = await resolveDispatchAgent(sdkClient, requestedAgent, context.directory)
1344
1800
  const targetSession = await ensureTargetSessionID({
1345
1801
  useSubtask,
1346
1802
  context: { sessionID: context.sessionID, directory: context.directory },
1347
1803
  title: "akm:curator",
1348
- client: client as unknown as PluginClient,
1804
+ client: sdkClient,
1349
1805
  logClient,
1350
1806
  toolName: "akm_evolve",
1351
1807
  })
@@ -1356,7 +1812,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1356
1812
  : "Review recent AKM activity and produce the prioritized action list described in the system prompt."
1357
1813
 
1358
1814
  const promptResponse = await promptTargetSession({
1359
- client: client as unknown as PluginClient,
1815
+ client: sdkClient,
1360
1816
  logClient,
1361
1817
  toolName: "akm_evolve",
1362
1818
  context: { sessionID: context.sessionID, directory: context.directory },
@@ -1364,20 +1820,71 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1364
1820
  failureMessage: "Failed to dispatch curator",
1365
1821
  promptBody: {
1366
1822
  agent: targetAgent,
1367
- system: CURATOR_AGENT_PROMPT,
1823
+ system: targetAgent === "akm-curator" ? undefined : CURATOR_AGENT_PROMPT,
1368
1824
  parts: [{ type: "text", text: task }],
1369
1825
  },
1370
1826
  })
1371
1827
  if (!promptResponse.ok) return JSON.stringify(promptResponse)
1372
1828
 
1829
+ const fullText = extractText(promptResponse.data.parts)
1830
+ sessionCuratorReport.set(context.sessionID, summarizeCuratorReportForContext(fullText))
1831
+ markContextEpochDirty(context.sessionID)
1832
+ const dateTag = buildDateTag()
1833
+ const shortSid = context.sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
1834
+ const curatorMemoryRef = fullText
1835
+ ? rememberTextAsMemory(`akm-curator-${dateTag}-${shortSid}`, fullText)
1836
+ : null
1837
+
1373
1838
  return JSON.stringify({
1374
1839
  ok: true,
1375
1840
  dispatchAgent: targetAgent,
1376
1841
  usedSubtask: useSubtask,
1377
1842
  sessionID: targetSession.sessionID,
1378
1843
  focus: focus ?? null,
1379
- text: extractText(promptResponse.data.parts),
1844
+ curatorMemoryRef,
1845
+ text: fullText,
1846
+ })
1847
+ },
1848
+ }),
1849
+ akm_parent_messages: tool({
1850
+ description: "Read compact text summaries of the parent session's messages so a dispatched AKM subagent can inherit upstream context.",
1851
+ args: {},
1852
+ async execute(_input, context) {
1853
+ const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
1854
+ if (!parent.ok) return JSON.stringify(parent)
1855
+ const messages = await sdkClient.session.messages({
1856
+ path: { id: parent.parentID },
1857
+ query: { directory: context.directory },
1858
+ })
1859
+ if (messages.error || !messages.data) {
1860
+ return JSON.stringify({ ok: false, error: "Failed to read parent session messages." })
1861
+ }
1862
+ return JSON.stringify(summarizeSessionMessages(parent.parentID, messages.data))
1863
+ },
1864
+ }),
1865
+ akm_session_messages: tool({
1866
+ 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.",
1867
+ args: {
1868
+ session_id: tool.schema.string().describe("OpenCode session ID to inspect."),
1869
+ },
1870
+ async execute({ session_id }, context) {
1871
+ const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
1872
+ const allowedSessionIDs = new Set<string>([context.sessionID])
1873
+ if (parent.ok) allowedSessionIDs.add(parent.parentID)
1874
+ if (context.agent !== "akm-curator" && !allowedSessionIDs.has(session_id)) {
1875
+ return JSON.stringify({
1876
+ ok: false,
1877
+ error: "akm_session_messages only allows arbitrary session IDs for the akm-curator agent. Use akm_parent_messages for parent context.",
1878
+ })
1879
+ }
1880
+ const messages = await sdkClient.session.messages({
1881
+ path: { id: session_id },
1882
+ query: { directory: context.directory },
1380
1883
  })
1884
+ if (messages.error || !messages.data) {
1885
+ return JSON.stringify({ ok: false, error: `Failed to read messages for session '${session_id}'.` })
1886
+ }
1887
+ return JSON.stringify(summarizeSessionMessages(session_id, messages.data))
1381
1888
  },
1382
1889
  }),
1383
1890
  akm_agent: tool({
@@ -1395,7 +1902,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1395
1902
  directory: context.directory,
1396
1903
  sessionID: context.sessionID,
1397
1904
  }
1398
- const resolved = await resolveRefInput(client as unknown as LogCapableClient, { ref, query }, "agent", logMeta)
1905
+ const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "agent", logMeta)
1399
1906
  if (!resolved.ok) return JSON.stringify(resolved)
1400
1907
 
1401
1908
  const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
@@ -1481,7 +1988,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1481
1988
  directory: context.directory,
1482
1989
  sessionID: context.sessionID,
1483
1990
  }
1484
- const resolved = await resolveRefInput(client as unknown as LogCapableClient, { ref, query }, "command", logMeta)
1991
+ const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "command", logMeta)
1485
1992
  if (!resolved.ok) return JSON.stringify(resolved)
1486
1993
 
1487
1994
  const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
@@ -1539,137 +2046,21 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1539
2046
  })
1540
2047
  },
1541
2048
  }),
1542
- akm_config: tool({
1543
- description: "View or update akm configuration settings.",
1544
- args: {
1545
- action: tool.schema.enum(["get", "set", "list", "unset", "path"]).describe("Config action: get, set, list, unset, or path."),
1546
- key: tool.schema.string().optional().describe("Config key (required for get/set)."),
1547
- value: tool.schema.string().optional().describe("Config value (required for set)."),
1548
- all: tool.schema.boolean().optional().describe("When action is 'path', include config, stash, cache, and index paths."),
1549
- },
1550
- async execute({ action, key, value, all }) {
1551
- const args = ["config", action]
1552
- if (key) args.push(key)
1553
- if (value) args.push(value)
1554
- if (action === "path" && all) args.push("--all")
1555
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_config" })
1556
- },
1557
- }),
1558
- akm_run: tool({
1559
- description: "Execute a stash script by ref. Resolves via search, fetches metadata via show, and runs the run command.",
1560
- args: {
1561
- ref: tool.schema.string().optional().describe("Script ref from akm_search (e.g. script:deploy.sh)."),
1562
- query: tool.schema.string().optional().describe("If ref is omitted, resolve best matching stash script for this query."),
1563
- args: tool.schema.string().optional().describe("Arguments to append to the run command."),
1564
- },
1565
- async execute({ ref, query, args: runArgs }) {
1566
- const resolved = await resolveRefInput(client as unknown as LogCapableClient, { ref, query }, "script", { toolName: "akm_run" })
1567
- if (!resolved.ok) return JSON.stringify(resolved)
1568
-
1569
- const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], { toolName: "akm_run" })
1570
- const shown = parseCliJson<ShowToolResponse | { type: string }>(shownRaw)
1571
- if (isCliError(shown)) return JSON.stringify(shown)
1572
-
1573
- if (!isShowToolResponse(shown)) {
1574
- return JSON.stringify({
1575
- ok: false,
1576
- error: `Ref ${resolved.ref} is not a script payload from akm_show.`,
1577
- })
1578
- }
1579
-
1580
- if (!shown.run || !shown.run.trim()) {
1581
- return JSON.stringify({
1582
- ok: false,
1583
- error: `Script ${shown.name} is missing run command.`,
1584
- })
1585
- }
1586
-
1587
- let cmd = shown.run
1588
- if (runArgs && runArgs.trim()) {
1589
- cmd = `${cmd} ${runArgs.trim()}`
1590
- }
1591
-
1592
- try {
1593
- const output = execSync(cmd, {
1594
- encoding: "utf8",
1595
- timeout: 120_000,
1596
- })
1597
- return JSON.stringify({
1598
- ok: true,
1599
- ref: resolved.ref,
1600
- script: shown.name,
1601
- run: cmd,
1602
- output,
1603
- })
1604
- } catch (error: unknown) {
1605
- const message = error instanceof Error ? error.message : String(error)
1606
- return JSON.stringify({
1607
- ok: false,
1608
- error: `Failed to execute run command for ${shown.name}: ${message}`,
1609
- })
1610
- }
1611
- },
1612
- }),
1613
- akm_sources: tool({
1614
- description: "List all configured AKM sources. Kept as a backward-compatible alias for the older sources command.",
1615
- args: {},
1616
- async execute() {
1617
- return runCli(client as unknown as LogCapableClient, ["list"], { toolName: "akm_sources" })
1618
- },
1619
- }),
1620
- akm_save: tool({
1621
- description: "Commit (and push, when writable) pending changes in a git-backed stash. No-op for non-git stashes.",
1622
- args: {
1623
- name: tool.schema.string().optional().describe("Optional stash source name. Defaults to the primary stash."),
1624
- message: tool.schema.string().optional().describe("Commit message. Defaults to an auto-generated summary."),
1625
- },
1626
- async execute({ name, message }) {
1627
- const args = ["save"]
1628
- if (name) args.push(name)
1629
- if (message) args.push("-m", message)
1630
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_save" })
1631
- },
1632
- }),
1633
- akm_import: tool({
1634
- description: "Import a file (or stdin) into the stash as a typed asset. Pass '-' as source to read from a string via content.",
1635
- args: {
1636
- source: tool.schema.string().describe("Path to the source file, or '-' to read from stdin (use 'content' to provide it)."),
1637
- name: tool.schema.string().optional().describe("Optional asset name override."),
1638
- force: tool.schema.boolean().optional().describe("Overwrite an existing asset with the same name."),
1639
- content: tool.schema.string().optional().describe("Raw content to feed on stdin when source is '-'."),
1640
- },
1641
- async execute({ source, name, force, content }) {
1642
- const args = ["import", source]
1643
- if (name) args.push("--name", name)
1644
- if (force) args.push("--force")
1645
- const command = resolveAkmCommand()
1646
- if (typeof command !== "string") return JSON.stringify(command)
1647
- if (source === "-" && content) {
1648
- try {
1649
- const stdout = execFileSync(command, [...args, "--format", "json"], {
1650
- encoding: "utf8",
1651
- timeout: 60_000,
1652
- input: content,
1653
- })
1654
- return stdout
1655
- } catch (error: unknown) {
1656
- return JSON.stringify({ ok: false, error: formatCliError(error) })
1657
- }
1658
- }
1659
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_import" })
1660
- },
1661
- }),
1662
2049
  akm_vault: tool({
1663
- description: "Manage encrypted-at-rest vaults of KEY=VALUE pairs. Values never surface in any output channel — 'show'/'list' return key names only, 'set'/'unset' never echo the value. Use 'shell_snippet' to get a shell-eval snippet that loads values into the process.",
2050
+ description: "Manage encrypted-at-rest vaults of KEY=VALUE pairs. Values never surface in any output channel — 'show'/'list' return key names only, 'set'/'unset' never echo the value. Use 'load' to get a shell-eval snippet that loads values into the process. action='show' and action='unset' require confirm:true.",
1664
2051
  args: {
1665
- action: tool.schema.enum(["list", "show", "create", "set", "unset", "shell_snippet"]).describe("Vault subcommand. 'shell_snippet' wraps 'vault load' — treat its output as opaque shell text meant for eval."),
1666
- ref: tool.schema.string().optional().describe("Vault ref such as vault:prod or vault:team/prod. Required for show/set/unset/shell_snippet; optional for list."),
2052
+ action: tool.schema.enum(["list", "show", "create", "set", "unset", "load"]).describe("Vault subcommand. 'load' wraps `akm vault load` — treat its output as opaque shell text meant for eval."),
2053
+ ref: tool.schema.string().optional().describe("Vault ref such as vault:prod or vault:team/prod. Required for show/set/unset/load; optional for list."),
1667
2054
  name: tool.schema.string().optional().describe("Vault name when action is 'create' (e.g. 'prod' → vaults/prod.env)."),
1668
2055
  key: tool.schema.string().optional().describe("Variable name for set/unset. May include '=' to pass KEY=VALUE in one field when value is omitted."),
1669
2056
  value: tool.schema.string().optional().describe("Value to store. Never echoed back."),
1670
2057
  comment: tool.schema.string().optional().describe("Optional inline '# comment' written above the key for 'set'."),
2058
+ confirm: tool.schema.boolean().optional().describe("Must be true for sensitive actions like show and unset."),
1671
2059
  },
1672
- async execute({ action, ref, name, key, value, comment }) {
2060
+ async execute(input) {
2061
+ const blocked = blockedToolResponse(input as Record<string, unknown>)
2062
+ if (blocked) return blocked
2063
+ const { action, ref, name, key, value, comment } = input
1673
2064
  const logMeta = { toolName: "akm_vault" }
1674
2065
  switch (action) {
1675
2066
  case "list": {
@@ -1698,8 +2089,8 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1698
2089
  if (!key) return JSON.stringify({ ok: false, error: "'key' is required for action='unset'." })
1699
2090
  return runCli(client as unknown as LogCapableClient, ["vault", "unset", ref, key], logMeta)
1700
2091
  }
1701
- case "shell_snippet": {
1702
- if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='shell_snippet'." })
2092
+ case "load": {
2093
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='load'." })
1703
2094
  // `vault load` emits raw shell — not JSON. Return the snippet verbatim
1704
2095
  // so the caller can hand it to a shell via eval. Never parse values.
1705
2096
  const command = resolveAkmCommand()
@@ -1734,7 +2125,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1734
2125
  ]).describe("Wiki subcommand."),
1735
2126
  name: tool.schema.string().optional().describe("Wiki name (required for every action except 'list')."),
1736
2127
  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."),
1737
- writable: tool.schema.boolean().optional().describe("When registering a git-backed source, mark it as push-writable (used by akm_save)."),
2128
+ writable: tool.schema.boolean().optional().describe("When registering a git-backed source, mark it as push-writable (used by `akm save`; see akm_help topic='save')."),
1738
2129
  trust: tool.schema.boolean().optional().describe("Bypass install-audit blocking for this registration only."),
1739
2130
  max_pages: tool.schema.number().optional().describe("Crawler page cap when registering a website (default 50)."),
1740
2131
  max_depth: tool.schema.number().optional().describe("Crawler depth cap when registering a website (default 3)."),
@@ -1935,17 +2326,35 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1935
2326
  }
1936
2327
  },
1937
2328
  }),
1938
- akm_upgrade: tool({
1939
- description: "Check for or install akm CLI updates.",
2329
+ akm_help: tool({
2330
+ 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.",
1940
2331
  args: {
1941
- check: tool.schema.boolean().optional().describe("Only check for updates without installing."),
1942
- force: tool.schema.boolean().optional().describe("Force upgrade even if already on latest version."),
2332
+ 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."),
2333
+ 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."),
1943
2334
  },
1944
- async execute({ check, force }) {
1945
- const args = ["upgrade"]
1946
- if (check) args.push("--check")
1947
- if (force) args.push("--force")
1948
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_upgrade" })
2335
+ async execute({ topic, command }) {
2336
+ const cliCommand = resolveAkmCommand()
2337
+ if (typeof cliCommand !== "string") return JSON.stringify(cliCommand)
2338
+ const helpArgs = command && command.trim()
2339
+ ? [command.trim(), "--help"]
2340
+ : ["--help"]
2341
+ let helpText = ""
2342
+ try {
2343
+ helpText = execFileSync(cliCommand, helpArgs, {
2344
+ encoding: "utf8",
2345
+ timeout: 30_000,
2346
+ }).toString().trim()
2347
+ } catch (error: unknown) {
2348
+ return JSON.stringify({ ok: false, error: formatCliError(error) })
2349
+ }
2350
+ return JSON.stringify({
2351
+ ok: true,
2352
+ command: command ?? null,
2353
+ topic: topic ?? null,
2354
+ hints: topic ? lookupAkmHelpHint(topic) : [],
2355
+ quickReference: AKM_HELP_QUICK_REFERENCE,
2356
+ help: helpText,
2357
+ })
1949
2358
  },
1950
2359
  }),
1951
2360
  },