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/README.md +27 -34
- package/agent/akm-curator.md +58 -0
- package/index.ts +902 -357
- package/package.json +2 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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 =
|
|
509
|
+
const dateTag = buildDateTag({ includeTime: isCheckpoint })
|
|
265
510
|
const shortSid = sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
|
|
266
|
-
const name =
|
|
511
|
+
const name = isCheckpoint
|
|
512
|
+
? `opencode-checkpoint-${dateTag}-${shortSid}`
|
|
513
|
+
: `opencode-session-${dateTag}-${shortSid}`
|
|
267
514
|
|
|
268
|
-
const
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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
|
|
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
|
|
1261
|
+
async function searchRef(
|
|
740
1262
|
client: LogCapableClient,
|
|
741
|
-
|
|
742
|
-
type: AssetType,
|
|
1263
|
+
query: string,
|
|
1264
|
+
type: AssetType | "any",
|
|
743
1265
|
meta: CliLogMeta,
|
|
744
1266
|
): Promise<{ ok: true; ref: string } | CliError> {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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 {
|
|
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 (!
|
|
948
|
-
if (
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
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
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
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)
|
|
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
|
|
1086
|
-
if (
|
|
1087
|
-
for (const ref of
|
|
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
|
-
&&
|
|
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
|
|
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 =
|
|
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'
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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 '
|
|
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", "
|
|
1666
|
-
ref: tool.schema.string().optional().describe("Vault ref such as vault:prod or vault:team/prod. Required for show/set/unset/
|
|
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(
|
|
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 "
|
|
1702
|
-
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='
|
|
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
|
|
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
|
-
|
|
1939
|
-
description: "
|
|
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
|
-
|
|
1942
|
-
|
|
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({
|
|
1945
|
-
const
|
|
1946
|
-
if (
|
|
1947
|
-
|
|
1948
|
-
|
|
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
|
},
|