akm-opencode 0.5.0 → 0.5.2

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,13 @@
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))
10
+ const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/
7
11
 
8
12
  const AKM_AUTO_FEEDBACK = (process.env.AKM_AUTO_FEEDBACK ?? "1") !== "0"
9
13
  const AKM_AUTO_MEMORY = (process.env.AKM_AUTO_MEMORY ?? "1") !== "0"
@@ -12,11 +16,24 @@ const AKM_AUTO_HINTS = (process.env.AKM_AUTO_HINTS ?? "1") !== "0"
12
16
  const AKM_CURATE_LIMIT = Math.max(1, Number(process.env.AKM_CURATE_LIMIT ?? "5") || 5)
13
17
  const AKM_CURATE_MIN_CHARS = Math.max(1, Number(process.env.AKM_CURATE_MIN_CHARS ?? "16") || 16)
14
18
  const AKM_CURATE_TIMEOUT_MS = Math.max(1_000, (Number(process.env.AKM_CURATE_TIMEOUT ?? "8") || 8) * 1_000)
19
+ const AKM_MEMORY_CHECKPOINT_EVERY = Math.max(1, Number(process.env.AKM_MEMORY_CHECKPOINT_EVERY ?? "8") || 8)
20
+ const AKM_CURATOR_CONTEXT_MAX_CHARS = Math.max(500, Number(process.env.AKM_CURATOR_CONTEXT_MAX_CHARS ?? "4000") || 4000)
21
+ const SESSION_DATE_TAG_LENGTH = 8
22
+ const CHECKPOINT_DATE_TAG_LENGTH = 15
23
+ const AKM_RETROSPECTIVE_FEEDBACK_RE = createRetrospectiveFeedbackRegex()
24
+ const PLUGIN_VERSION = readPackageVersion()
15
25
 
16
26
  // Per-session state that drives the compound-engineering loop.
17
27
  // These maps are keyed by OpenCode sessionID.
18
28
  const sessionHints = new Map<string, string>()
19
29
  const sessionCurated = new Map<string, string>()
30
+ const sessionWorkflow = new Map<string, string>()
31
+ const sessionCuratorReport = new Map<string, string>()
32
+ const sessionContextEpoch = new Map<string, number>()
33
+ const sessionContextInjectedEpoch = new Map<string, number>()
34
+ const sessionCuratedVersion = new Map<string, number>()
35
+ const sessionCuratedInjectedVersion = new Map<string, number>()
36
+ type ParsedSemver = { core: [number, number, number]; prerelease: Array<string | number> | null }
20
37
  type SessionBufferEntry = {
21
38
  timestamp: string
22
39
  kind: "memory-intent" | "tool-ref"
@@ -24,35 +41,60 @@ type SessionBufferEntry = {
24
41
  ref?: string
25
42
  status?: "positive" | "negative" | "unknown"
26
43
  note?: string
44
+ checkpointed?: boolean
27
45
  }
28
46
  const sessionBuffer = new Map<string, SessionBufferEntry[]>()
29
- const sessionMemoryCaptured = new Set<string>()
47
+ const sessionFinalMemoryCaptured = new Set<string>()
48
+ const sessionSuccessfulAssetTouchCount = new Map<string, number>()
49
+ let cachedAkmStashDir: string | undefined
30
50
 
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
51
+ // Asset-ref grammar matching the stash skill: [origin//]type:name.
52
+ // We validate normalized tokens individually instead of running a global regex
53
+ // over arbitrary tool output to keep extraction predictable and ReDoS-safe.
54
+ const AKM_REF_PATTERN = /^(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|vault|wiki):[A-Za-z0-9._/\-]+$/
33
55
 
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.
56
+ function readPackageVersion(): string {
57
+ try {
58
+ const raw = readFileSync(path.join(moduleDir, "package.json"), "utf8")
59
+ const parsed = JSON.parse(raw) as { version?: unknown }
60
+ return typeof parsed.version === "string" && parsed.version ? parsed.version : "0.0.0"
61
+ } catch {
62
+ return "0.0.0"
63
+ }
64
+ }
65
+
66
+ function createRetrospectiveFeedbackRegex(): RegExp {
67
+ const pattern = process.env.AKM_RETROSPECTIVE_FEEDBACK_PATTERN ?? "\\b(thanks|perfect|worked)\\b"
68
+ try {
69
+ return new RegExp(pattern, "i")
70
+ } catch {
71
+ return /\b(thanks|perfect|worked)\b/i
72
+ }
73
+ }
74
+
75
+ 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
76
 
36
77
  Inputs you should inspect:
37
78
  1. OpenCode app logs that include the "akm-opencode" service (feedback, memory, tool invocations).
38
79
  2. Session-summary memories named memory:opencode-session-*.
39
- 3. The live stash: call akm_list, akm_search "" --limit 50, and akm_show <ref>.
80
+ 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.
81
+ 4. Parent-session context via akm_parent_messages when this session was dispatched as a child.
40
82
 
41
83
  Signals to act on:
42
84
  - Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
43
85
  - 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.
86
+ - 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
87
  - 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.
88
+ - 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
89
  - 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
90
  - 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.
91
+ - Never touch vaults: do not call akm_vault show or load unless the user explicitly asks. Vault values must never appear in reports.
50
92
 
51
93
  Rules of engagement:
52
94
  - Never apply destructive changes without explicit user approval.
53
95
  - Report findings as a prioritized action list of concrete akm_* tool calls the user can run.
54
96
  - 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.
97
+ - 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
98
  - When finished, persist your own summary with akm_remember (name: curator-run-<timestamp>) so the next curator run can build on yours.
57
99
 
58
100
  Output shape: end every run with a markdown report that has these sections:
@@ -79,6 +121,23 @@ Output shape: end every run with a markdown report that has these sections:
79
121
  - stale memories, reindex needs, config tweaks
80
122
  `
81
123
 
124
+ function loadCuratorAgentPrompt(): string {
125
+ try {
126
+ const raw = readFileSync(path.join(moduleDir, "agent", "akm-curator.md"), "utf8").trim()
127
+ let body = raw
128
+ const lines = raw.split(/\r?\n/)
129
+ if (lines[0] === "---") {
130
+ const closingIndex = lines.indexOf("---", 1)
131
+ if (closingIndex > 0) body = lines.slice(closingIndex + 1).join("\n").trim()
132
+ }
133
+ return body || CURATOR_AGENT_PROMPT_FALLBACK
134
+ } catch {
135
+ return CURATOR_AGENT_PROMPT_FALLBACK
136
+ }
137
+ }
138
+
139
+ const CURATOR_AGENT_PROMPT = loadCuratorAgentPrompt()
140
+
82
141
  type LogLevel = "debug" | "info" | "warn" | "error"
83
142
 
84
143
  type LogCapableClient = {
@@ -148,6 +207,11 @@ function nowIso(): string {
148
207
  return new Date().toISOString()
149
208
  }
150
209
 
210
+ function buildDateTag(options?: { includeTime?: boolean }): string {
211
+ const compactIso = new Date().toISOString().replace(/[-:]/g, "")
212
+ return compactIso.slice(0, options?.includeTime ? CHECKPOINT_DATE_TAG_LENGTH : SESSION_DATE_TAG_LENGTH)
213
+ }
214
+
151
215
  function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBufferEntry, "timestamp">) {
152
216
  if (!sessionID) return
153
217
  const buf = sessionBuffer.get(sessionID) ?? []
@@ -155,6 +219,26 @@ function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBuffer
155
219
  sessionBuffer.set(sessionID, buf)
156
220
  }
157
221
 
222
+ function markContextEpochDirty(sessionID: string) {
223
+ sessionContextEpoch.set(sessionID, (sessionContextEpoch.get(sessionID) ?? 0) + 1)
224
+ }
225
+
226
+ function bumpCuratedVersion(sessionID: string) {
227
+ sessionCuratedVersion.set(sessionID, (sessionCuratedVersion.get(sessionID) ?? 0) + 1)
228
+ }
229
+
230
+ function isAkmRef(value: string): boolean {
231
+ return AKM_REF_PATTERN.test(value)
232
+ }
233
+
234
+ function parseMaybeJson(value: string): unknown {
235
+ try {
236
+ return JSON.parse(value)
237
+ } catch {
238
+ return undefined
239
+ }
240
+ }
241
+
158
242
  // Synchronous CLI invocation used by the lifecycle hooks — the plugin host does
159
243
  // not await these in a hot path, but we still cap execution time so a slow
160
244
  // stash never wedges the session loop.
@@ -202,6 +286,92 @@ function runHintsForSession(): string | null {
202
286
  return body || null
203
287
  }
204
288
 
289
+ function summarizeWorkflowList(value: unknown): string | null {
290
+ if (Array.isArray(value)) {
291
+ const lines = value
292
+ .map((item) => {
293
+ if (!item || typeof item !== "object") return null
294
+ const record = item as Record<string, unknown>
295
+ const id = typeof record.runId === "string"
296
+ ? record.runId
297
+ : typeof record.id === "string"
298
+ ? record.id
299
+ : null
300
+ const ref = typeof record.ref === "string"
301
+ ? record.ref
302
+ : typeof record.workflowRef === "string"
303
+ ? record.workflowRef
304
+ : null
305
+ const state = typeof record.state === "string" ? record.state : typeof record.status === "string" ? record.status : null
306
+ const step = typeof record.step === "string"
307
+ ? record.step
308
+ : typeof record.currentStep === "string"
309
+ ? record.currentStep
310
+ : null
311
+ if (!id && !ref && !state && !step) return null
312
+ return `- ${ref ?? "workflow"} (${id ?? "run"})${state ? ` — ${state}` : ""}${step ? ` — next: ${step}` : ""}`
313
+ })
314
+ .filter((line): line is string => !!line)
315
+ return lines.length > 0 ? lines.join("\n") : null
316
+ }
317
+ return null
318
+ }
319
+
320
+ function runWorkflowSummaryForSession(): string | null {
321
+ const result = runCliSyncRaw(["--format", "json", "-q", "workflow", "list", "--active"], AKM_CURATE_TIMEOUT_MS)
322
+ if (!result.ok) return null
323
+ const parsed = parseMaybeJson(result.stdout)
324
+ const summary = summarizeWorkflowList(
325
+ Array.isArray(parsed)
326
+ ? parsed
327
+ : (parsed && typeof parsed === "object" && Array.isArray((parsed as { runs?: unknown }).runs))
328
+ ? (parsed as { runs: unknown[] }).runs
329
+ : (parsed && typeof parsed === "object" && Array.isArray((parsed as { items?: unknown }).items))
330
+ ? (parsed as { items: unknown[] }).items
331
+ : [],
332
+ )
333
+ return summary
334
+ }
335
+
336
+ function formatWorkflowContext(summary: string): string {
337
+ return `# AKM active workflows\n${summary}`
338
+ }
339
+
340
+ function formatCuratorReportContext(report: string): string {
341
+ return `# AKM curator report\n${report}`
342
+ }
343
+
344
+ function summarizeCuratorReportForContext(report: string): string {
345
+ if (report.length <= AKM_CURATOR_CONTEXT_MAX_CHARS) return report
346
+ return `${report.slice(0, AKM_CURATOR_CONTEXT_MAX_CHARS).trimEnd()}\n\n[truncated for context]`
347
+ }
348
+
349
+ function getAkmStashDir(): string | undefined {
350
+ if (cachedAkmStashDir !== undefined) return cachedAkmStashDir || undefined
351
+ const result = runCliSyncRaw(["--format", "json", "-q", "config", "get", "stashDir"], AKM_CURATE_TIMEOUT_MS)
352
+ if (!result.ok) {
353
+ cachedAkmStashDir = ""
354
+ return undefined
355
+ }
356
+ const parsed = parseMaybeJson(result.stdout)
357
+ if (typeof parsed === "string" && parsed.trim()) {
358
+ cachedAkmStashDir = parsed.trim()
359
+ return cachedAkmStashDir
360
+ }
361
+ if (parsed && typeof parsed === "object") {
362
+ for (const key of ["value", "path", "stashDir"]) {
363
+ const value = (parsed as Record<string, unknown>)[key]
364
+ if (typeof value === "string" && value.trim()) {
365
+ cachedAkmStashDir = value.trim()
366
+ return cachedAkmStashDir
367
+ }
368
+ }
369
+ }
370
+ const raw = result.stdout.trim()
371
+ cachedAkmStashDir = raw || ""
372
+ return cachedAkmStashDir || undefined
373
+ }
374
+
205
375
  function warmIndexInBackground(): void {
206
376
  const command = resolveAkmCommand()
207
377
  if (typeof command !== "string") return
@@ -214,31 +384,106 @@ function warmIndexInBackground(): void {
214
384
  }
215
385
  }
216
386
 
217
- function recordFeedbackSync(ref: string, sentiment: "positive" | "negative", note: string): boolean {
218
- const result = runCliSyncRaw(
219
- [
220
- "--format",
221
- "json",
222
- "-q",
223
- "feedback",
387
+ function queueFeedback(
388
+ client: LogCapableClient,
389
+ ref: string,
390
+ sentiment: "positive" | "negative",
391
+ note: string,
392
+ meta: CliLogMeta,
393
+ dedupe?: Set<string>,
394
+ ): boolean {
395
+ const dedupeKey = `${ref}:${sentiment}`
396
+ if (dedupe?.has(dedupeKey)) return true
397
+ dedupe?.add(dedupeKey)
398
+
399
+ const command = resolveAkmCommand()
400
+ if (typeof command !== "string") {
401
+ void writePluginLog(client, "warn", "AKM auto-feedback skipped", {
402
+ subsystem: "feedback",
403
+ toolName: meta.toolName,
404
+ sessionID: meta.sessionID,
405
+ directory: meta.directory,
224
406
  ref,
225
- sentiment === "positive" ? "--positive" : "--negative",
226
- "--note",
227
- note,
228
- ],
229
- AKM_CURATE_TIMEOUT_MS,
230
- )
231
- return result.ok
407
+ sentiment,
408
+ error: command.error,
409
+ })
410
+ return false
411
+ }
412
+
413
+ try {
414
+ const child = spawn(
415
+ command,
416
+ [
417
+ "--format",
418
+ "json",
419
+ "-q",
420
+ "feedback",
421
+ ref,
422
+ sentiment === "positive" ? "--positive" : "--negative",
423
+ "--note",
424
+ note,
425
+ ],
426
+ {
427
+ detached: true,
428
+ stdio: "ignore",
429
+ },
430
+ )
431
+ child.on("error", (error) => {
432
+ void writePluginLog(client, "warn", "AKM auto-feedback failed", {
433
+ subsystem: "feedback",
434
+ toolName: meta.toolName,
435
+ sessionID: meta.sessionID,
436
+ directory: meta.directory,
437
+ ref,
438
+ sentiment,
439
+ error: formatCliError(error),
440
+ })
441
+ })
442
+ child.unref()
443
+ return true
444
+ } catch (error: unknown) {
445
+ void writePluginLog(client, "warn", "AKM auto-feedback failed", {
446
+ subsystem: "feedback",
447
+ toolName: meta.toolName,
448
+ sessionID: meta.sessionID,
449
+ directory: meta.directory,
450
+ ref,
451
+ sentiment,
452
+ error: formatCliError(error),
453
+ })
454
+ return false
455
+ }
232
456
  }
233
457
 
234
- function captureSessionMemory(sessionID: string, reason: string): string | null {
458
+ function rememberTextAsMemory(name: string, body: string): string | null {
459
+ const command = resolveAkmCommand()
460
+ if (typeof command !== "string") return null
461
+ try {
462
+ execFileSync(command, ["--format", "json", "-q", "remember", "--name", name, "--force"], {
463
+ encoding: "utf8",
464
+ timeout: AKM_CURATE_TIMEOUT_MS * 2,
465
+ input: body,
466
+ })
467
+ return `memory:${name}`
468
+ } catch {
469
+ return null
470
+ }
471
+ }
472
+
473
+ function captureSessionMemory(
474
+ sessionID: string,
475
+ reason: string,
476
+ options?: { checkpoint?: boolean },
477
+ ): string | null {
235
478
  if (!AKM_AUTO_MEMORY) return null
236
479
  if (!sessionID) return null
237
- if (sessionMemoryCaptured.has(sessionID)) return null
480
+ const isCheckpoint = options?.checkpoint === true
481
+ if (!isCheckpoint && sessionFinalMemoryCaptured.has(sessionID)) return null
238
482
  const entries = sessionBuffer.get(sessionID) ?? []
483
+ const pendingEntries = isCheckpoint ? entries.filter((entry) => !entry.checkpointed) : entries
239
484
  // Require at least two observations before persisting — single events are noise.
240
- if (entries.length < 2) {
241
- sessionBuffer.delete(sessionID)
485
+ if (pendingEntries.length < 2) {
486
+ if (!isCheckpoint) sessionBuffer.delete(sessionID)
242
487
  return null
243
488
  }
244
489
 
@@ -247,7 +492,7 @@ function captureSessionMemory(sessionID: string, reason: string): string | null
247
492
  lines.push(`Reason: ${reason}`)
248
493
  lines.push(`Session: ${sessionID}`)
249
494
  lines.push("")
250
- for (const entry of entries) {
495
+ for (const entry of pendingEntries) {
251
496
  if (entry.kind === "memory-intent") {
252
497
  lines.push(`## ${entry.timestamp} — user memory intent`)
253
498
  if (entry.note) lines.push(entry.note)
@@ -261,38 +506,74 @@ function captureSessionMemory(sessionID: string, reason: string): string | null
261
506
  }
262
507
  const body = lines.join("\n")
263
508
 
264
- const dateTag = new Date().toISOString().replace(/[-:]/g, "").slice(0, 8)
509
+ const dateTag = buildDateTag({ includeTime: isCheckpoint })
265
510
  const shortSid = sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
266
- const name = `opencode-session-${dateTag}-${shortSid}`
511
+ const name = isCheckpoint
512
+ ? `opencode-checkpoint-${dateTag}-${shortSid}`
513
+ : `opencode-session-${dateTag}-${shortSid}`
267
514
 
268
- const command = resolveAkmCommand()
269
- if (typeof command !== "string") {
270
- sessionMemoryCaptured.add(sessionID)
271
- sessionBuffer.delete(sessionID)
515
+ const ref = rememberTextAsMemory(name, body)
516
+ if (!ref) {
517
+ if (!isCheckpoint) {
518
+ sessionFinalMemoryCaptured.add(sessionID)
519
+ sessionBuffer.delete(sessionID)
520
+ }
272
521
  return null
273
522
  }
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
523
+
524
+ if (isCheckpoint) {
525
+ for (const entry of entries) {
526
+ if (!entry.checkpointed) entry.checkpointed = true
527
+ }
528
+ sessionSuccessfulAssetTouchCount.set(sessionID, 0)
529
+ sessionBuffer.set(sessionID, entries)
530
+ return ref
531
+ }
532
+
533
+ sessionFinalMemoryCaptured.add(sessionID)
534
+ sessionBuffer.delete(sessionID)
535
+ return ref
536
+ }
537
+
538
+ function maybeCheckpointSessionMemory(sessionID: string): string | null {
539
+ const count = sessionSuccessfulAssetTouchCount.get(sessionID) ?? 0
540
+ if (count < AKM_MEMORY_CHECKPOINT_EVERY) return null
541
+ const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true })
542
+ if (!captured) {
543
+ sessionSuccessfulAssetTouchCount.set(sessionID, 0)
287
544
  }
545
+ return captured
546
+ }
547
+
548
+ const AKM_REF_EDGE_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "'", "\"", "`"])
549
+
550
+ function normalizeExtractedRef(ref: string): string {
551
+ let start = 0
552
+ let end = ref.length
553
+ while (start < end && AKM_REF_EDGE_PUNCTUATION.has(ref[start] ?? "")) start += 1
554
+ while (end > start && AKM_REF_EDGE_PUNCTUATION.has(ref[end - 1] ?? "")) end -= 1
555
+ return ref.slice(start, end)
288
556
  }
289
557
 
290
- function extractToolRefs(toolName: string, args: Record<string, unknown>, output: unknown): string[] {
558
+ function extractRefsFromText(value: string): string[] {
291
559
  const refs = new Set<string>()
560
+ for (const token of value.split(/\s+/)) {
561
+ const normalized = normalizeExtractedRef(token)
562
+ if (normalized && isAkmRef(normalized)) refs.add(normalized)
563
+ }
564
+ return [...refs]
565
+ }
566
+
567
+ function extractToolRefs(
568
+ toolName: string,
569
+ args: Record<string, unknown>,
570
+ output: unknown,
571
+ ): { refs: string[]; positiveOnlyRefs: string[] } {
572
+ const refs = new Set<string>()
573
+ const positiveOnlyRefs = new Set<string>()
292
574
  const addMatches = (value: unknown) => {
293
575
  if (typeof value !== "string") return
294
- const matches = value.match(AKM_REF_PATTERN)
295
- if (matches) for (const ref of matches) refs.add(ref)
576
+ for (const ref of extractRefsFromText(value)) refs.add(ref)
296
577
  }
297
578
 
298
579
  for (const key of ["ref", "package_ref"]) {
@@ -313,9 +594,18 @@ function extractToolRefs(toolName: string, args: Record<string, unknown>, output
313
594
  }
314
595
  }
315
596
  if (toolName === "akm_remember" && typeof o.ref === "string") addMatches(o.ref)
597
+ if (
598
+ (toolName === "akm_agent" || toolName === "akm_cmd" || toolName === "akm_evolve")
599
+ && typeof o.text === "string"
600
+ ) {
601
+ for (const ref of extractRefsFromText(o.text)) {
602
+ refs.add(ref)
603
+ positiveOnlyRefs.add(ref)
604
+ }
605
+ }
316
606
  }
317
607
 
318
- return [...refs]
608
+ return { refs: [...refs], positiveOnlyRefs: [...positiveOnlyRefs] }
319
609
  }
320
610
 
321
611
  const AKM_HINTS_PREFIX = [
@@ -327,6 +617,98 @@ const AKM_HINTS_PREFIX = [
327
617
  const AKM_CURATED_HEADER = "# AKM stash — assets relevant to this prompt"
328
618
  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
619
 
620
+ // Curated quick-reference for the long-tail of `akm` CLI verbs that no longer
621
+ // have a dedicated tool wrapper. Surfaced through akm_help so agents can
622
+ // always find the right invocation without polluting default context.
623
+ type AkmHelpEntry = {
624
+ task: string
625
+ command: string
626
+ notes?: string
627
+ keywords: string[]
628
+ }
629
+
630
+ const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
631
+ {
632
+ task: "Install a kit or register an external source (npm, GitHub, git, URL, local dir)",
633
+ command: "akm add <package-ref> [--name <n>] [--type wiki] [--writable] [--trust] [--provider <p>] [--max-pages N] [--max-depth N]",
634
+ notes: "Confirm with the user before passing --trust or registering a website crawler.",
635
+ keywords: ["add", "install", "register", "kit", "source", "github", "npm"],
636
+ },
637
+ {
638
+ task: "Commit (and optionally push) pending stash changes",
639
+ command: "akm save [<source-name>] [-m <msg>] [--push]",
640
+ notes: "Add --push only when the stash is writable; review the diff first.",
641
+ keywords: ["save", "commit", "push", "publish", "git"],
642
+ },
643
+ {
644
+ task: "Import a file (or stdin) into the stash as a typed asset",
645
+ command: "akm import <path|-> [--name <name>] [--force]",
646
+ notes: "Use `-` and pipe content via stdin to import a string.",
647
+ keywords: ["import", "ingest", "upload", "stdin"],
648
+ },
649
+ {
650
+ task: "Clone an asset from any source for editing",
651
+ command: "akm clone <ref> [--name <new>] [--dest <dir>] [--force]",
652
+ notes: "Type subdirectory is appended automatically; ref may include origin (e.g. npm:@scope/pkg//script:foo).",
653
+ keywords: ["clone", "copy", "fork", "edit"],
654
+ },
655
+ {
656
+ task: "Update a managed source (or all of them)",
657
+ command: "akm update [<package_ref>|--all] [--force]",
658
+ keywords: ["update", "upgrade kit", "refresh", "pull"],
659
+ },
660
+ {
661
+ task: "Remove a configured source and reindex",
662
+ command: "akm remove <id|ref|path|url|name>",
663
+ notes: "Destructive — confirm intent before running.",
664
+ keywords: ["remove", "uninstall", "delete source"],
665
+ },
666
+ {
667
+ task: "List configured sources (local dirs, kits, remotes)",
668
+ command: "akm list",
669
+ keywords: ["list", "sources", "kits", "show sources"],
670
+ },
671
+ {
672
+ task: "Search the registry only (skip local stash)",
673
+ command: "akm registry search <query> [--limit N] [--assets]",
674
+ notes: "akm_search with source='registry' covers most cases; this is the explicit form.",
675
+ keywords: ["registry", "search registry", "installable", "discover kit"],
676
+ },
677
+ {
678
+ task: "Build or rebuild the stash search index",
679
+ command: "akm index",
680
+ notes: "Rarely needed — the index refreshes implicitly after writes.",
681
+ keywords: ["index", "reindex", "rebuild"],
682
+ },
683
+ {
684
+ task: "View or update akm config (get/set/list/unset/path)",
685
+ command: "akm config <action> [<key>] [<value>] [--all]",
686
+ notes: "`akm config path --all` prints config, stash, cache, and index paths.",
687
+ keywords: ["config", "settings", "configure", "path"],
688
+ },
689
+ {
690
+ task: "Check for or install an akm CLI update",
691
+ command: "akm upgrade [--check] [--force]",
692
+ keywords: ["upgrade cli", "update cli", "self-upgrade"],
693
+ },
694
+ {
695
+ task: "Run a stash script end-to-end (resolve → show → run)",
696
+ command: "akm show <script-ref> # then exec the printed `run` command",
697
+ notes: "Or `akm --format json -q show <ref>` and pipe `.run` into your shell.",
698
+ keywords: ["run", "execute", "script", "exec"],
699
+ },
700
+ ]
701
+
702
+ function lookupAkmHelpHint(topic: string): AkmHelpEntry[] {
703
+ const needle = topic.toLowerCase().trim()
704
+ if (!needle) return []
705
+ return AKM_HELP_QUICK_REFERENCE.filter((entry) =>
706
+ entry.keywords.some((kw) => needle.includes(kw))
707
+ || entry.task.toLowerCase().includes(needle)
708
+ || entry.command.toLowerCase().includes(needle),
709
+ )
710
+ }
711
+
330
712
  function extractSessionIdFromEvent(payload: unknown): string | undefined {
331
713
  if (!payload || typeof payload !== "object") return undefined
332
714
  const p = payload as Record<string, unknown>
@@ -362,6 +744,72 @@ function getCommandStatus(command: string): "ok" | "missing" | "error" {
362
744
  }
363
745
  }
364
746
 
747
+ function extractFirstSemverMatch(value: string): string | null {
748
+ return value.match(SEMVER_PATTERN)?.[0] ?? null
749
+ }
750
+
751
+ function getCommandVersion(command: string): string | null {
752
+ try {
753
+ const version = execFileSync(command, ["--version"], {
754
+ encoding: "utf8",
755
+ timeout: 10_000,
756
+ })
757
+ return extractFirstSemverMatch(version)
758
+ } catch {
759
+ return null
760
+ }
761
+ }
762
+
763
+ function parseSemver(version: string): ParsedSemver | null {
764
+ const normalized = extractFirstSemverMatch(version)
765
+ if (!normalized) return null
766
+
767
+ const [withoutBuildMetadata] = normalized.split("+", 1)
768
+ const [release, prereleaseText] = withoutBuildMetadata.split("-", 2)
769
+ const parts = release.split(".").map((part) => Number(part))
770
+ if (parts.length !== 3 || parts.some((part) => !Number.isInteger(part) || part < 0)) {
771
+ return null
772
+ }
773
+ const core = [parts[0], parts[1], parts[2]] as [number, number, number]
774
+
775
+ return {
776
+ core,
777
+ prerelease: prereleaseText
778
+ ? prereleaseText.split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : part))
779
+ : null,
780
+ }
781
+ }
782
+
783
+ function compareSemver(left: string, right: string): number {
784
+ const leftParsed = parseSemver(left)
785
+ const rightParsed = parseSemver(right)
786
+ if (!leftParsed || !rightParsed) return left.localeCompare(right)
787
+
788
+ for (let index = 0; index < leftParsed.core.length; index += 1) {
789
+ const delta = leftParsed.core[index] - rightParsed.core[index]
790
+ if (delta !== 0) return delta
791
+ }
792
+
793
+ if (!leftParsed.prerelease && !rightParsed.prerelease) return 0
794
+ if (!leftParsed.prerelease) return 1
795
+ if (!rightParsed.prerelease) return -1
796
+
797
+ const length = Math.max(leftParsed.prerelease.length, rightParsed.prerelease.length)
798
+ for (let index = 0; index < length; index += 1) {
799
+ const leftPart = leftParsed.prerelease[index]
800
+ const rightPart = rightParsed.prerelease[index]
801
+ if (leftPart === undefined) return -1
802
+ if (rightPart === undefined) return 1
803
+ if (leftPart === rightPart) continue
804
+ if (typeof leftPart === "number" && typeof rightPart === "number") return leftPart - rightPart
805
+ if (typeof leftPart === "number") return -1
806
+ if (typeof rightPart === "number") return 1
807
+ return leftPart.localeCompare(rightPart)
808
+ }
809
+
810
+ return 0
811
+ }
812
+
365
813
  function getBunGlobalAkmCommand(): string | null {
366
814
  try {
367
815
  const globalBin = execFileSync("bun", ["pm", "bin", "-g"], {
@@ -377,6 +825,39 @@ function getBunGlobalAkmCommand(): string | null {
377
825
  }
378
826
  }
379
827
 
828
+ function getInstalledAkmDetails(): { command: string; version: string } | null {
829
+ const candidates = [resolvedAkmCommand, getBunGlobalAkmCommand(), "akm"]
830
+ const seen = new Set<string>()
831
+ for (const candidate of candidates) {
832
+ if (!candidate || seen.has(candidate)) continue
833
+ seen.add(candidate)
834
+ const version = getCommandVersion(candidate)
835
+ if (version) return { command: candidate, version }
836
+ }
837
+ return null
838
+ }
839
+
840
+ async function getLatestNpmPackageVersion(packageName: string): Promise<string | null> {
841
+ if (typeof fetch !== "function") return null
842
+
843
+ const controller = new AbortController()
844
+ const timeout = setTimeout(() => controller.abort(), 10_000)
845
+ try {
846
+ const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
847
+ headers: { accept: "application/json" },
848
+ signal: controller.signal,
849
+ })
850
+ if (!response.ok) return null
851
+ const body = await response.json()
852
+ if (!body || typeof body !== "object") return null
853
+ return typeof body.version === "string" ? extractFirstSemverMatch(body.version) : null
854
+ } catch {
855
+ return null
856
+ } finally {
857
+ clearTimeout(timeout)
858
+ }
859
+ }
860
+
380
861
  async function ensureLatestAkmInstalled(client: LogCapableClient): Promise<void> {
381
862
  try {
382
863
  execFileSync("bun", ["--version"], {
@@ -392,6 +873,39 @@ async function ensureLatestAkmInstalled(client: LogCapableClient): Promise<void>
392
873
  return
393
874
  }
394
875
 
876
+ const installedAkm = getInstalledAkmDetails()
877
+ const latestStable = await getLatestNpmPackageVersion("akm-cli")
878
+
879
+ if (installedAkm) {
880
+ if (!latestStable) {
881
+ resolvedAkmCommand = installedAkm.command
882
+ await writePluginLog(client, "info", "AKM auto-install skipped", {
883
+ subsystem: "akm",
884
+ installer: "bun",
885
+ package: autoInstallPackageRef,
886
+ command: resolvedAkmCommand,
887
+ installedVersion: installedAkm.version,
888
+ latestStable,
889
+ reason: "latest_version_unavailable",
890
+ })
891
+ return
892
+ }
893
+
894
+ if (compareSemver(installedAkm.version, latestStable) >= 0) {
895
+ resolvedAkmCommand = installedAkm.command
896
+ await writePluginLog(client, "info", "AKM auto-install skipped", {
897
+ subsystem: "akm",
898
+ installer: "bun",
899
+ package: autoInstallPackageRef,
900
+ command: resolvedAkmCommand,
901
+ installedVersion: installedAkm.version,
902
+ latestStable,
903
+ reason: "installed_version_not_older",
904
+ })
905
+ return
906
+ }
907
+ }
908
+
395
909
  try {
396
910
  execFileSync("bun", ["install", "-g", autoInstallPackageRef], {
397
911
  encoding: "utf8",
@@ -411,6 +925,8 @@ async function ensureLatestAkmInstalled(client: LogCapableClient): Promise<void>
411
925
  installer: "bun",
412
926
  package: autoInstallPackageRef,
413
927
  command: resolvedAkmCommand,
928
+ installedVersion: installedAkm?.version ?? null,
929
+ latestStable,
414
930
  })
415
931
  } catch (error: unknown) {
416
932
  await writePluginLog(client, "warn", "AKM auto-install failed", {
@@ -460,7 +976,7 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
460
976
  return JSON.stringify(command)
461
977
  }
462
978
 
463
- const fullArgs = [...args, "--format", "json"]
979
+ const fullArgs = args.includes("--format") ? [...args] : [...args, "--format", "json"]
464
980
 
465
981
  try {
466
982
  const stdout = execFileSync(command, fullArgs, {
@@ -619,6 +1135,12 @@ function parseCliJson<T>(raw: string): T | CliError {
619
1135
  }
620
1136
  }
621
1137
 
1138
+ function blockedToolResponse(args: Record<string, unknown>): string | null {
1139
+ return typeof args.__akmBlocked === "string"
1140
+ ? JSON.stringify({ ok: false, error: args.__akmBlocked })
1141
+ : null
1142
+ }
1143
+
622
1144
  function isCliError(value: unknown): value is CliError {
623
1145
  return !!value
624
1146
  && typeof value === "object"
@@ -736,33 +1258,45 @@ function truncateLogText(value: string, limit = 1_000): string {
736
1258
  return value.length > limit ? `${value.slice(0, limit)}…` : value
737
1259
  }
738
1260
 
739
- async function resolveRefInput(
1261
+ async function searchRef(
740
1262
  client: LogCapableClient,
741
- input: { ref?: string; query?: string },
742
- type: AssetType,
1263
+ query: string,
1264
+ type: AssetType | "any",
743
1265
  meta: CliLogMeta,
744
1266
  ): 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)
1267
+ const raw = await runCli(
1268
+ client,
1269
+ ["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "stash"],
1270
+ meta,
1271
+ )
755
1272
  const parsed = parseCliJson<SearchResponse>(raw)
756
1273
  if (isCliError(parsed)) return parsed
757
-
758
1274
  const ref = parsed.hits?.[0]?.ref
759
1275
  if (!ref) {
760
- return { ok: false, error: `No ${type} match found for query '${query}'.` }
1276
+ return {
1277
+ ok: false,
1278
+ error: `No stash ref matched '${query}'. Use akm_search to disambiguate, then retry with an exact ref.`,
1279
+ }
761
1280
  }
762
-
763
1281
  return { ok: true, ref }
764
1282
  }
765
1283
 
1284
+ async function resolveRefOrQueryInput(
1285
+ client: LogCapableClient,
1286
+ input: { ref?: string; query?: string },
1287
+ type: AssetType | "any",
1288
+ meta: CliLogMeta,
1289
+ ): Promise<{ ok: true; ref: string } | CliError> {
1290
+ const explicitRef = input.ref?.trim()
1291
+ if (explicitRef) return { ok: true, ref: explicitRef }
1292
+
1293
+ const query = input.query?.trim()
1294
+ if (!query) {
1295
+ return { ok: false, error: "Provide either 'ref' or 'query'." }
1296
+ }
1297
+ return searchRef(client, query, type, meta)
1298
+ }
1299
+
766
1300
  async function ensureTargetSessionID(input: {
767
1301
  useSubtask: boolean
768
1302
  context: { sessionID: string; directory: string }
@@ -875,6 +1409,65 @@ async function promptTargetSession(input: {
875
1409
  }
876
1410
  }
877
1411
 
1412
+ async function resolveDispatchAgent(
1413
+ client: PluginClient,
1414
+ requestedAgent: string,
1415
+ directory: string,
1416
+ ): Promise<string> {
1417
+ if (requestedAgent !== "akm-curator") return requestedAgent
1418
+ try {
1419
+ const agents = await client.app.agents({ query: { directory } })
1420
+ if (agents.error) return "general"
1421
+ const hasCurator = (agents.data ?? []).some((agent) => agent?.name === "akm-curator")
1422
+ return hasCurator ? "akm-curator" : "general"
1423
+ } catch {
1424
+ return "general"
1425
+ }
1426
+ }
1427
+
1428
+ function summarizeSessionMessages(
1429
+ sessionID: string,
1430
+ messages: Array<{ info?: Record<string, unknown>; parts?: unknown }>,
1431
+ ) {
1432
+ return {
1433
+ ok: true,
1434
+ sessionID,
1435
+ messages: messages.map((message) => {
1436
+ const info = message.info ?? {}
1437
+ const role = typeof info.role === "string" ? info.role : "unknown"
1438
+ const agent = typeof info.agent === "string"
1439
+ ? info.agent
1440
+ : typeof info.mode === "string"
1441
+ ? info.mode
1442
+ : null
1443
+ return {
1444
+ role,
1445
+ agent,
1446
+ text: extractText(message.parts),
1447
+ }
1448
+ }),
1449
+ }
1450
+ }
1451
+
1452
+ async function getParentSessionID(
1453
+ client: PluginClient,
1454
+ sessionID: string,
1455
+ directory: string,
1456
+ ): Promise<{ ok: true; parentID: string } | CliError> {
1457
+ try {
1458
+ const result = await client.session.get({
1459
+ path: { id: sessionID },
1460
+ query: { directory },
1461
+ })
1462
+ if (result.error || !result.data?.parentID) {
1463
+ return { ok: false, error: "This session does not have a parent session." }
1464
+ }
1465
+ return { ok: true, parentID: result.data.parentID }
1466
+ } catch (error: unknown) {
1467
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
1468
+ }
1469
+ }
1470
+
878
1471
  function splitArguments(raw: string): string[] {
879
1472
  if (!raw.trim()) return []
880
1473
  const args: string[] = []
@@ -921,17 +1514,31 @@ type PluginClient = {
921
1514
  create: (input: {
922
1515
  body: { parentID: string; title: string }
923
1516
  }) => Promise<{ data?: { id?: string }; error?: unknown }>
1517
+ get: (input: {
1518
+ path: { id: string }
1519
+ query?: { directory?: string }
1520
+ }) => Promise<{ data?: { id?: string; parentID?: string }; error?: unknown }>
1521
+ messages: (input: {
1522
+ path: { id: string }
1523
+ query?: { directory?: string }
1524
+ }) => Promise<{ data?: Array<{ info?: Record<string, unknown>; parts?: unknown }>; error?: unknown }>
924
1525
  prompt: (input: {
925
1526
  path: { id: string }
926
1527
  body: SessionPromptBody
927
1528
  }) => Promise<{ data?: { parts?: unknown }; error?: unknown }>
928
1529
  }
1530
+ app: {
1531
+ agents: (input?: {
1532
+ query?: { directory?: string }
1533
+ }) => Promise<{ data?: Array<{ name?: string }>; error?: unknown }>
1534
+ }
929
1535
  }
930
1536
 
931
- export const AkmPlugin: Plugin = async ({ client }) => {
1537
+ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
932
1538
  await ensureLatestAkmInstalled(client as unknown as LogCapableClient)
933
1539
 
934
1540
  const logClient = client as unknown as LogCapableClient
1541
+ const sdkClient = client as unknown as PluginClient
935
1542
 
936
1543
  return {
937
1544
  // Events cover the lifecycle boundaries that Claude Code exposes as
@@ -944,11 +1551,15 @@ export const AkmPlugin: Plugin = async ({ client }) => {
944
1551
  const sid = extractSessionIdFromEvent(event) ?? extractSessionIdFromEvent((event as { properties?: unknown }).properties)
945
1552
  if (type === "session.created" || type === "session.updated") {
946
1553
  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)
1554
+ if (!sessionContextEpoch.has(sid)) sessionContextEpoch.set(sid, 0)
1555
+ if (type === "session.created") warmIndexInBackground()
1556
+ if (AKM_AUTO_HINTS && !sessionHints.has(sid)) {
1557
+ const hints = runHintsForSession()
1558
+ if (hints) sessionHints.set(sid, hints)
1559
+ }
1560
+ if (!sessionWorkflow.has(sid)) {
1561
+ sessionWorkflow.set(sid, runWorkflowSummaryForSession() ?? "")
1562
+ }
952
1563
  } else if (type === "session.compacted" || type === "session.idle" || type === "session.deleted") {
953
1564
  if (!sid) return
954
1565
  const captured = captureSessionMemory(sid, type)
@@ -966,7 +1577,14 @@ export const AkmPlugin: Plugin = async ({ client }) => {
966
1577
  if (type === "session.deleted") {
967
1578
  sessionHints.delete(sid)
968
1579
  sessionCurated.delete(sid)
969
- sessionMemoryCaptured.delete(sid)
1580
+ sessionWorkflow.delete(sid)
1581
+ sessionCuratorReport.delete(sid)
1582
+ sessionContextEpoch.delete(sid)
1583
+ sessionContextInjectedEpoch.delete(sid)
1584
+ sessionCuratedVersion.delete(sid)
1585
+ sessionCuratedInjectedVersion.delete(sid)
1586
+ sessionFinalMemoryCaptured.delete(sid)
1587
+ sessionSuccessfulAssetTouchCount.delete(sid)
970
1588
  sessionBuffer.delete(sid)
971
1589
  }
972
1590
  }
@@ -995,6 +1613,24 @@ export const AkmPlugin: Plugin = async ({ client }) => {
995
1613
  // Best-effort only.
996
1614
  }
997
1615
  },
1616
+ "experimental.session.compacting": async (input, output) => {
1617
+ try {
1618
+ const sid = input.sessionID
1619
+ if (!sid) return
1620
+ if (!Array.isArray(output.context)) return
1621
+ markContextEpochDirty(sid)
1622
+ const hints = sessionHints.get(sid)
1623
+ if (hints) output.context.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
1624
+ const curated = sessionCurated.get(sid)
1625
+ if (curated) output.context.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
1626
+ const workflow = sessionWorkflow.get(sid)
1627
+ if (workflow) output.context.push(formatWorkflowContext(workflow))
1628
+ const curatorReport = sessionCuratorReport.get(sid)
1629
+ if (curatorReport) output.context.push(formatCuratorReportContext(curatorReport))
1630
+ } catch {
1631
+ // Never break compaction because of plugin context.
1632
+ }
1633
+ },
998
1634
  // experimental.chat.system.transform is how OpenCode exposes the
999
1635
  // additionalContext channel. We append the cached hints (once per session)
1000
1636
  // and the curated assets (once per turn) so the next LLM call sees them.
@@ -1005,21 +1641,56 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1005
1641
  try {
1006
1642
  if (!output || !Array.isArray(output.system)) return
1007
1643
  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)
1644
+ const epoch = sessionContextEpoch.get(sid) ?? 0
1645
+ const injectedEpoch = sessionContextInjectedEpoch.get(sid)
1646
+ if (sid && injectedEpoch !== epoch) {
1647
+ const hints = sessionHints.get(sid)
1648
+ if (hints) output.system.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
1649
+ const workflow = sessionWorkflow.get(sid)
1650
+ if (workflow) output.system.push(formatWorkflowContext(workflow))
1651
+ const curatorReport = sessionCuratorReport.get(sid)
1652
+ if (curatorReport) output.system.push(formatCuratorReportContext(curatorReport))
1653
+ sessionContextInjectedEpoch.set(sid, epoch)
1013
1654
  }
1014
1655
  const curated = sid ? sessionCurated.get(sid) : undefined
1656
+ const curatedVersion = sessionCuratedVersion.get(sid) ?? 0
1015
1657
  if (curated) {
1016
- output.system.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
1017
- sessionCurated.delete(sid)
1658
+ if (sessionCuratedInjectedVersion.get(sid) !== curatedVersion) {
1659
+ output.system.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
1660
+ sessionCuratedInjectedVersion.set(sid, curatedVersion)
1661
+ }
1018
1662
  }
1019
1663
  } catch {
1020
1664
  // Never break the turn because of a transform failure.
1021
1665
  }
1022
1666
  },
1667
+ "tool.execute.before": async (input, output) => {
1668
+ try {
1669
+ if (!input.tool.startsWith("akm_")) return
1670
+ const args = output.args && typeof output.args === "object" ? output.args as Record<string, unknown> : {}
1671
+ const confirm = args.confirm === true
1672
+ if (input.tool === "akm_vault" && (args.action === "show" || args.action === "unset") && !confirm) {
1673
+ output.args = {
1674
+ ...args,
1675
+ __akmBlocked: `akm_vault action='${String(args.action)}' requires confirm:true to avoid accidental secret exposure or deletion.`,
1676
+ }
1677
+ return
1678
+ }
1679
+ output.args = args
1680
+ } catch {
1681
+ // Never break tool execution from the pre-hook.
1682
+ }
1683
+ },
1684
+ "shell.env": async (_input, output) => {
1685
+ try {
1686
+ output.env.AKM_PROJECT = worktree
1687
+ output.env.AKM_PLUGIN_VERSION = PLUGIN_VERSION
1688
+ const stashDir = getAkmStashDir()
1689
+ if (stashDir) output.env.AKM_STASH_DIR = stashDir
1690
+ } catch {
1691
+ // Best-effort only.
1692
+ }
1693
+ },
1023
1694
  "chat.message": async (input, output) => {
1024
1695
  const text = extractText(output.parts).trim()
1025
1696
  if (!text) return
@@ -1036,7 +1707,10 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1036
1707
  // stash the result so experimental.chat.system.transform can inject it.
1037
1708
  if (AKM_AUTO_CURATE && input.sessionID) {
1038
1709
  const curated = runCurateForPrompt(text)
1039
- if (curated) sessionCurated.set(input.sessionID, curated)
1710
+ if (curated) {
1711
+ sessionCurated.set(input.sessionID, curated)
1712
+ bumpCuratedVersion(input.sessionID)
1713
+ }
1040
1714
  }
1041
1715
 
1042
1716
  // Track explicit memory intents so capture-memory has something durable
@@ -1047,6 +1721,21 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1047
1721
  note: truncateLogText(text, 500),
1048
1722
  })
1049
1723
  }
1724
+
1725
+ if (input.sessionID && AKM_AUTO_FEEDBACK && AKM_RETROSPECTIVE_FEEDBACK_RE.test(text)) {
1726
+ const recentRefs = (sessionBuffer.get(input.sessionID) ?? [])
1727
+ .filter((entry) => entry.kind === "tool-ref" && !!entry.ref)
1728
+ .map((entry) => entry.ref!)
1729
+ .filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("vault:") && refs.indexOf(ref) === index)
1730
+ .slice(-3)
1731
+ const dedupe = new Set<string>()
1732
+ for (const ref of recentRefs) {
1733
+ queueFeedback(logClient, ref, "positive", "opencode retrospective: user confirmed it worked", {
1734
+ toolName: "chat.message",
1735
+ sessionID: input.sessionID,
1736
+ }, dedupe)
1737
+ }
1738
+ }
1050
1739
  },
1051
1740
  "tool.execute.after": async (input, output) => {
1052
1741
  if (!input.tool.startsWith("akm_")) return
@@ -1082,9 +1771,9 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1082
1771
  // Auto-feedback + session buffering: record every asset ref the tool
1083
1772
  // touched so the stash ranking improves over time and so Stop/Compact
1084
1773
  // 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) {
1774
+ const refResult = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
1775
+ if (refResult.refs.length > 0 && input.sessionID) {
1776
+ for (const ref of refResult.refs) {
1088
1777
  addBufferEntry(input.sessionID, {
1089
1778
  kind: "tool-ref",
1090
1779
  toolName: input.tool,
@@ -1092,30 +1781,53 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1092
1781
  status: feedback ?? "unknown",
1093
1782
  })
1094
1783
  }
1784
+ if (feedback === "positive") {
1785
+ sessionSuccessfulAssetTouchCount.set(
1786
+ input.sessionID,
1787
+ (sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1,
1788
+ )
1789
+ const checkpointRef = maybeCheckpointSessionMemory(input.sessionID)
1790
+ if (checkpointRef) {
1791
+ await writePluginLog(logClient, "info", "AKM checkpoint memory captured", {
1792
+ subsystem: "memory",
1793
+ actor: "system",
1794
+ sessionID: input.sessionID,
1795
+ reason: "checkpoint",
1796
+ ref: checkpointRef,
1797
+ })
1798
+ }
1799
+ }
1095
1800
  }
1096
1801
 
1097
1802
  if (
1098
1803
  AKM_AUTO_FEEDBACK
1099
1804
  && feedback
1100
1805
  && input.tool !== "akm_feedback"
1101
- && allRefs.length > 0
1806
+ && refResult.refs.length > 0
1102
1807
  ) {
1808
+ const dedupe = new Set<string>()
1809
+ const feedbackRefs = feedback === "positive"
1810
+ ? refResult.refs
1811
+ : refResult.refs.filter((ref) => !refResult.positiveOnlyRefs.includes(ref))
1103
1812
  const note = feedback === "positive"
1104
1813
  ? `opencode auto: ${input.tool} succeeded`
1105
1814
  : `opencode auto: ${input.tool} failed`
1106
- for (const ref of allRefs) {
1815
+ for (const ref of feedbackRefs) {
1107
1816
  // Memories and vault refs are not first-class feedback targets —
1108
1817
  // memories do not accept feedback, and vault values never surface in
1109
1818
  // JSON so automatic usage signals would be misleading.
1110
1819
  if (ref.startsWith("memory:") || ref.startsWith("vault:")) continue
1111
- const ok = recordFeedbackSync(ref, feedback, note)
1820
+ const ok = queueFeedback(logClient, ref, feedback, note, {
1821
+ toolName: input.tool,
1822
+ sessionID: input.sessionID,
1823
+ }, dedupe)
1112
1824
  if (!ok) break
1113
1825
  }
1114
1826
  }
1115
1827
  },
1116
1828
  tool: {
1117
1829
  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.",
1830
+ 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
1831
  args: {
1120
1832
  query: tool.schema.string().describe("Case-insensitive substring search."),
1121
1833
  type: tool.schema
@@ -1132,41 +1844,6 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1132
1844
  return runCli(client as unknown as LogCapableClient, createSearchArgs({ query, type, limit, source }), { toolName: "akm_search" })
1133
1845
  },
1134
1846
  }),
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
1847
  akm_show: tool({
1171
1848
  description: "Show a stash asset by ref. For knowledge assets, use view_mode to retrieve specific content (toc, section, lines, frontmatter).",
1172
1849
  args: {
@@ -1195,92 +1872,6 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1195
1872
  return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_show" })
1196
1873
  },
1197
1874
  }),
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
1875
  akm_remember: tool({
1285
1876
  description: "Record a memory in the default AKM stash so it can be searched and shown later.",
1286
1877
  args: {
@@ -1332,20 +1923,21 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1332
1923
  },
1333
1924
  }),
1334
1925
  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).",
1926
+ 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
1927
  args: {
1337
1928
  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'."),
1929
+ 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
1930
  as_subtask: tool.schema.boolean().optional().describe("Run in a child session with parent context. Defaults to true."),
1340
1931
  },
1341
1932
  async execute({ focus, dispatch_agent, as_subtask }, context) {
1342
1933
  const useSubtask = as_subtask ?? true
1343
- const targetAgent = dispatch_agent ?? "general"
1934
+ const requestedAgent = dispatch_agent ?? "akm-curator"
1935
+ const targetAgent = await resolveDispatchAgent(sdkClient, requestedAgent, context.directory)
1344
1936
  const targetSession = await ensureTargetSessionID({
1345
1937
  useSubtask,
1346
1938
  context: { sessionID: context.sessionID, directory: context.directory },
1347
1939
  title: "akm:curator",
1348
- client: client as unknown as PluginClient,
1940
+ client: sdkClient,
1349
1941
  logClient,
1350
1942
  toolName: "akm_evolve",
1351
1943
  })
@@ -1356,7 +1948,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1356
1948
  : "Review recent AKM activity and produce the prioritized action list described in the system prompt."
1357
1949
 
1358
1950
  const promptResponse = await promptTargetSession({
1359
- client: client as unknown as PluginClient,
1951
+ client: sdkClient,
1360
1952
  logClient,
1361
1953
  toolName: "akm_evolve",
1362
1954
  context: { sessionID: context.sessionID, directory: context.directory },
@@ -1364,22 +1956,73 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1364
1956
  failureMessage: "Failed to dispatch curator",
1365
1957
  promptBody: {
1366
1958
  agent: targetAgent,
1367
- system: CURATOR_AGENT_PROMPT,
1959
+ system: targetAgent === "akm-curator" ? undefined : CURATOR_AGENT_PROMPT,
1368
1960
  parts: [{ type: "text", text: task }],
1369
1961
  },
1370
1962
  })
1371
1963
  if (!promptResponse.ok) return JSON.stringify(promptResponse)
1372
1964
 
1965
+ const fullText = extractText(promptResponse.data.parts)
1966
+ sessionCuratorReport.set(context.sessionID, summarizeCuratorReportForContext(fullText))
1967
+ markContextEpochDirty(context.sessionID)
1968
+ const dateTag = buildDateTag()
1969
+ const shortSid = context.sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
1970
+ const curatorMemoryRef = fullText
1971
+ ? rememberTextAsMemory(`akm-curator-${dateTag}-${shortSid}`, fullText)
1972
+ : null
1973
+
1373
1974
  return JSON.stringify({
1374
1975
  ok: true,
1375
1976
  dispatchAgent: targetAgent,
1376
1977
  usedSubtask: useSubtask,
1377
1978
  sessionID: targetSession.sessionID,
1378
1979
  focus: focus ?? null,
1379
- text: extractText(promptResponse.data.parts),
1980
+ curatorMemoryRef,
1981
+ text: fullText,
1380
1982
  })
1381
1983
  },
1382
1984
  }),
1985
+ akm_parent_messages: tool({
1986
+ description: "Read compact text summaries of the parent session's messages so a dispatched AKM subagent can inherit upstream context.",
1987
+ args: {},
1988
+ async execute(_input, context) {
1989
+ const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
1990
+ if (!parent.ok) return JSON.stringify(parent)
1991
+ const messages = await sdkClient.session.messages({
1992
+ path: { id: parent.parentID },
1993
+ query: { directory: context.directory },
1994
+ })
1995
+ if (messages.error || !messages.data) {
1996
+ return JSON.stringify({ ok: false, error: "Failed to read parent session messages." })
1997
+ }
1998
+ return JSON.stringify(summarizeSessionMessages(parent.parentID, messages.data))
1999
+ },
2000
+ }),
2001
+ akm_session_messages: tool({
2002
+ 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.",
2003
+ args: {
2004
+ session_id: tool.schema.string().describe("OpenCode session ID to inspect."),
2005
+ },
2006
+ async execute({ session_id }, context) {
2007
+ const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
2008
+ const allowedSessionIDs = new Set<string>([context.sessionID])
2009
+ if (parent.ok) allowedSessionIDs.add(parent.parentID)
2010
+ if (context.agent !== "akm-curator" && !allowedSessionIDs.has(session_id)) {
2011
+ return JSON.stringify({
2012
+ ok: false,
2013
+ error: "akm_session_messages only allows arbitrary session IDs for the akm-curator agent. Use akm_parent_messages for parent context.",
2014
+ })
2015
+ }
2016
+ const messages = await sdkClient.session.messages({
2017
+ path: { id: session_id },
2018
+ query: { directory: context.directory },
2019
+ })
2020
+ if (messages.error || !messages.data) {
2021
+ return JSON.stringify({ ok: false, error: `Failed to read messages for session '${session_id}'.` })
2022
+ }
2023
+ return JSON.stringify(summarizeSessionMessages(session_id, messages.data))
2024
+ },
2025
+ }),
1383
2026
  akm_agent: tool({
1384
2027
  description: "Dispatch a stash agent by ref into a child OpenCode session, applying the agent prompt and metadata from akm_show.",
1385
2028
  args: {
@@ -1395,7 +2038,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1395
2038
  directory: context.directory,
1396
2039
  sessionID: context.sessionID,
1397
2040
  }
1398
- const resolved = await resolveRefInput(client as unknown as LogCapableClient, { ref, query }, "agent", logMeta)
2041
+ const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "agent", logMeta)
1399
2042
  if (!resolved.ok) return JSON.stringify(resolved)
1400
2043
 
1401
2044
  const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
@@ -1481,7 +2124,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1481
2124
  directory: context.directory,
1482
2125
  sessionID: context.sessionID,
1483
2126
  }
1484
- const resolved = await resolveRefInput(client as unknown as LogCapableClient, { ref, query }, "command", logMeta)
2127
+ const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "command", logMeta)
1485
2128
  if (!resolved.ok) return JSON.stringify(resolved)
1486
2129
 
1487
2130
  const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
@@ -1539,137 +2182,21 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1539
2182
  })
1540
2183
  },
1541
2184
  }),
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
2185
  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.",
2186
+ 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
2187
  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."),
2188
+ 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."),
2189
+ 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
2190
  name: tool.schema.string().optional().describe("Vault name when action is 'create' (e.g. 'prod' → vaults/prod.env)."),
1668
2191
  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
2192
  value: tool.schema.string().optional().describe("Value to store. Never echoed back."),
1670
2193
  comment: tool.schema.string().optional().describe("Optional inline '# comment' written above the key for 'set'."),
2194
+ confirm: tool.schema.boolean().optional().describe("Must be true for sensitive actions like show and unset."),
1671
2195
  },
1672
- async execute({ action, ref, name, key, value, comment }) {
2196
+ async execute(input) {
2197
+ const blocked = blockedToolResponse(input as Record<string, unknown>)
2198
+ if (blocked) return blocked
2199
+ const { action, ref, name, key, value, comment } = input
1673
2200
  const logMeta = { toolName: "akm_vault" }
1674
2201
  switch (action) {
1675
2202
  case "list": {
@@ -1698,8 +2225,8 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1698
2225
  if (!key) return JSON.stringify({ ok: false, error: "'key' is required for action='unset'." })
1699
2226
  return runCli(client as unknown as LogCapableClient, ["vault", "unset", ref, key], logMeta)
1700
2227
  }
1701
- case "shell_snippet": {
1702
- if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='shell_snippet'." })
2228
+ case "load": {
2229
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='load'." })
1703
2230
  // `vault load` emits raw shell — not JSON. Return the snippet verbatim
1704
2231
  // so the caller can hand it to a shell via eval. Never parse values.
1705
2232
  const command = resolveAkmCommand()
@@ -1734,7 +2261,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1734
2261
  ]).describe("Wiki subcommand."),
1735
2262
  name: tool.schema.string().optional().describe("Wiki name (required for every action except 'list')."),
1736
2263
  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)."),
2264
+ 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
2265
  trust: tool.schema.boolean().optional().describe("Bypass install-audit blocking for this registration only."),
1739
2266
  max_pages: tool.schema.number().optional().describe("Crawler page cap when registering a website (default 50)."),
1740
2267
  max_depth: tool.schema.number().optional().describe("Crawler depth cap when registering a website (default 3)."),
@@ -1935,17 +2462,35 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1935
2462
  }
1936
2463
  },
1937
2464
  }),
1938
- akm_upgrade: tool({
1939
- description: "Check for or install akm CLI updates.",
2465
+ akm_help: tool({
2466
+ 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
2467
  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."),
2468
+ 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."),
2469
+ 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
2470
  },
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" })
2471
+ async execute({ topic, command }) {
2472
+ const cliCommand = resolveAkmCommand()
2473
+ if (typeof cliCommand !== "string") return JSON.stringify(cliCommand)
2474
+ const helpArgs = command && command.trim()
2475
+ ? [command.trim(), "--help"]
2476
+ : ["--help"]
2477
+ let helpText = ""
2478
+ try {
2479
+ helpText = execFileSync(cliCommand, helpArgs, {
2480
+ encoding: "utf8",
2481
+ timeout: 30_000,
2482
+ }).toString().trim()
2483
+ } catch (error: unknown) {
2484
+ return JSON.stringify({ ok: false, error: formatCliError(error) })
2485
+ }
2486
+ return JSON.stringify({
2487
+ ok: true,
2488
+ command: command ?? null,
2489
+ topic: topic ?? null,
2490
+ hints: topic ? lookupAkmHelpHint(topic) : [],
2491
+ quickReference: AKM_HELP_QUICK_REFERENCE,
2492
+ help: helpText,
2493
+ })
1949
2494
  },
1950
2495
  }),
1951
2496
  },