akm-opencode 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -32
- package/agent/akm-curator.md +58 -0
- package/index.ts +1060 -283
- package/package.json +3 -2
package/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { type Plugin, tool } from "@opencode-ai/plugin"
|
|
2
|
-
import { execFileSync, execSync } from "node:child_process"
|
|
2
|
+
import { execFileSync, execSync, spawn } from "node:child_process"
|
|
3
|
+
import { readFileSync } from "node:fs"
|
|
3
4
|
import path from "node:path"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
4
6
|
|
|
5
7
|
let resolvedAkmCommand = "akm"
|
|
6
8
|
const autoInstallPackageRef = "akm-cli@latest"
|
|
9
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url))
|
|
7
10
|
|
|
8
11
|
const AKM_AUTO_FEEDBACK = (process.env.AKM_AUTO_FEEDBACK ?? "1") !== "0"
|
|
9
12
|
const AKM_AUTO_MEMORY = (process.env.AKM_AUTO_MEMORY ?? "1") !== "0"
|
|
@@ -12,11 +15,23 @@ const AKM_AUTO_HINTS = (process.env.AKM_AUTO_HINTS ?? "1") !== "0"
|
|
|
12
15
|
const AKM_CURATE_LIMIT = Math.max(1, Number(process.env.AKM_CURATE_LIMIT ?? "5") || 5)
|
|
13
16
|
const AKM_CURATE_MIN_CHARS = Math.max(1, Number(process.env.AKM_CURATE_MIN_CHARS ?? "16") || 16)
|
|
14
17
|
const AKM_CURATE_TIMEOUT_MS = Math.max(1_000, (Number(process.env.AKM_CURATE_TIMEOUT ?? "8") || 8) * 1_000)
|
|
18
|
+
const AKM_MEMORY_CHECKPOINT_EVERY = Math.max(1, Number(process.env.AKM_MEMORY_CHECKPOINT_EVERY ?? "8") || 8)
|
|
19
|
+
const AKM_CURATOR_CONTEXT_MAX_CHARS = Math.max(500, Number(process.env.AKM_CURATOR_CONTEXT_MAX_CHARS ?? "4000") || 4000)
|
|
20
|
+
const SESSION_DATE_TAG_LENGTH = 8
|
|
21
|
+
const CHECKPOINT_DATE_TAG_LENGTH = 15
|
|
22
|
+
const AKM_RETROSPECTIVE_FEEDBACK_RE = createRetrospectiveFeedbackRegex()
|
|
23
|
+
const PLUGIN_VERSION = readPackageVersion()
|
|
15
24
|
|
|
16
25
|
// Per-session state that drives the compound-engineering loop.
|
|
17
26
|
// These maps are keyed by OpenCode sessionID.
|
|
18
27
|
const sessionHints = new Map<string, string>()
|
|
19
28
|
const sessionCurated = new Map<string, string>()
|
|
29
|
+
const sessionWorkflow = new Map<string, string>()
|
|
30
|
+
const sessionCuratorReport = new Map<string, string>()
|
|
31
|
+
const sessionContextEpoch = new Map<string, number>()
|
|
32
|
+
const sessionContextInjectedEpoch = new Map<string, number>()
|
|
33
|
+
const sessionCuratedVersion = new Map<string, number>()
|
|
34
|
+
const sessionCuratedInjectedVersion = new Map<string, number>()
|
|
20
35
|
type SessionBufferEntry = {
|
|
21
36
|
timestamp: string
|
|
22
37
|
kind: "memory-intent" | "tool-ref"
|
|
@@ -24,32 +39,60 @@ type SessionBufferEntry = {
|
|
|
24
39
|
ref?: string
|
|
25
40
|
status?: "positive" | "negative" | "unknown"
|
|
26
41
|
note?: string
|
|
42
|
+
checkpointed?: boolean
|
|
27
43
|
}
|
|
28
44
|
const sessionBuffer = new Map<string, SessionBufferEntry[]>()
|
|
29
|
-
const
|
|
45
|
+
const sessionFinalMemoryCaptured = new Set<string>()
|
|
46
|
+
const sessionSuccessfulAssetTouchCount = new Map<string, number>()
|
|
47
|
+
let cachedAkmStashDir: string | undefined
|
|
30
48
|
|
|
31
|
-
// Asset-ref grammar matching the stash skill: [origin//]type:name
|
|
32
|
-
|
|
49
|
+
// Asset-ref grammar matching the stash skill: [origin//]type:name.
|
|
50
|
+
// We validate normalized tokens individually instead of running a global regex
|
|
51
|
+
// over arbitrary tool output to keep extraction predictable and ReDoS-safe.
|
|
52
|
+
const AKM_REF_PATTERN = /^(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|vault|wiki):[A-Za-z0-9._/\-]+$/
|
|
33
53
|
|
|
34
|
-
|
|
54
|
+
function readPackageVersion(): string {
|
|
55
|
+
try {
|
|
56
|
+
const raw = readFileSync(path.join(moduleDir, "package.json"), "utf8")
|
|
57
|
+
const parsed = JSON.parse(raw) as { version?: unknown }
|
|
58
|
+
return typeof parsed.version === "string" && parsed.version ? parsed.version : "0.0.0"
|
|
59
|
+
} catch {
|
|
60
|
+
return "0.0.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createRetrospectiveFeedbackRegex(): RegExp {
|
|
65
|
+
const pattern = process.env.AKM_RETROSPECTIVE_FEEDBACK_PATTERN ?? "\\b(thanks|perfect|worked)\\b"
|
|
66
|
+
try {
|
|
67
|
+
return new RegExp(pattern, "i")
|
|
68
|
+
} catch {
|
|
69
|
+
return /\b(thanks|perfect|worked)\b/i
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const CURATOR_AGENT_PROMPT_FALLBACK = `You are the AKM curator — a compound-engineering agent that keeps the user's AKM stash improving every time the main agent finishes a task.
|
|
35
74
|
|
|
36
75
|
Inputs you should inspect:
|
|
37
76
|
1. OpenCode app logs that include the "akm-opencode" service (feedback, memory, tool invocations).
|
|
38
77
|
2. Session-summary memories named memory:opencode-session-*.
|
|
39
|
-
3. The live stash: call
|
|
78
|
+
3. The live stash: call akm_search "" --limit 50 (and akm_show <ref>) to enumerate assets; reach for akm_help topic="list sources" if you need the configured-sources view.
|
|
79
|
+
4. Parent-session context via akm_parent_messages when this session was dispatched as a child.
|
|
40
80
|
|
|
41
81
|
Signals to act on:
|
|
42
82
|
- Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
|
|
43
83
|
- Cold refs: assets tied to failures or user complaints. Record akm_feedback <ref> negative --note "<excerpt>" and open the asset for review.
|
|
44
|
-
- Missing coverage: recurring user prompts with no matching asset. Draft a new skill, command,
|
|
84
|
+
- Missing coverage: recurring user prompts with no matching asset. Draft a new skill, command, knowledge doc, wiki page, or workflow in the working stash and reindex via the akm CLI (see akm_help topic="reindex").
|
|
45
85
|
- Duplicates / drift: near-identical descriptions or overlapping responsibilities. Propose a consolidation.
|
|
46
|
-
- Stale memories: session summaries that never get recalled. Propose
|
|
86
|
+
- Stale memories: session summaries that never get recalled. Propose removal (see akm_help topic="remove") once distilled into a durable knowledge doc or wiki page.
|
|
87
|
+
- Wiki hygiene: for each wiki returned by akm_wiki list, run akm_wiki lint <name> and report orphans, broken xrefs, uncited raws, and stale indexes as fix candidates.
|
|
88
|
+
- Stuck workflows: run akm_workflow list --active and surface any runs in blocked or failed state with their step ids. Propose whether to resume or escalate.
|
|
89
|
+
- Never touch vaults: do not call akm_vault show or load unless the user explicitly asks. Vault values must never appear in reports.
|
|
47
90
|
|
|
48
91
|
Rules of engagement:
|
|
49
92
|
- Never apply destructive changes without explicit user approval.
|
|
50
93
|
- Report findings as a prioritized action list of concrete akm_* tool calls the user can run.
|
|
51
94
|
- Prefer small, reversible edits: promote via positive feedback, draft a candidate skill, or clone and tweak.
|
|
52
|
-
- When drafting new assets, write them into the working stash directory
|
|
95
|
+
- When drafting new assets, write them into the working stash directory under skills/, commands/, agents/, knowledge/, or scripts/. Use akm_help (topic="config" / topic="reindex") to look up the right CLI invocation when you need the stash path or want to force a reindex.
|
|
53
96
|
- When finished, persist your own summary with akm_remember (name: curator-run-<timestamp>) so the next curator run can build on yours.
|
|
54
97
|
|
|
55
98
|
Output shape: end every run with a markdown report that has these sections:
|
|
@@ -66,10 +109,33 @@ Output shape: end every run with a markdown report that has these sections:
|
|
|
66
109
|
## Duplicates / drift
|
|
67
110
|
- <ref a> vs <ref b> — consolidation proposal
|
|
68
111
|
|
|
112
|
+
## Wiki health
|
|
113
|
+
- <wiki> — lint findings (orphan, broken-xref, uncited-raw, stale-index) with suggested fix
|
|
114
|
+
|
|
115
|
+
## Workflow health
|
|
116
|
+
- <workflow|runId> — blocked/failed state — resume or escalate
|
|
117
|
+
|
|
69
118
|
## Housekeeping
|
|
70
119
|
- stale memories, reindex needs, config tweaks
|
|
71
120
|
`
|
|
72
121
|
|
|
122
|
+
function loadCuratorAgentPrompt(): string {
|
|
123
|
+
try {
|
|
124
|
+
const raw = readFileSync(path.join(moduleDir, "agent", "akm-curator.md"), "utf8").trim()
|
|
125
|
+
let body = raw
|
|
126
|
+
const lines = raw.split(/\r?\n/)
|
|
127
|
+
if (lines[0] === "---") {
|
|
128
|
+
const closingIndex = lines.indexOf("---", 1)
|
|
129
|
+
if (closingIndex > 0) body = lines.slice(closingIndex + 1).join("\n").trim()
|
|
130
|
+
}
|
|
131
|
+
return body || CURATOR_AGENT_PROMPT_FALLBACK
|
|
132
|
+
} catch {
|
|
133
|
+
return CURATOR_AGENT_PROMPT_FALLBACK
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const CURATOR_AGENT_PROMPT = loadCuratorAgentPrompt()
|
|
138
|
+
|
|
73
139
|
type LogLevel = "debug" | "info" | "warn" | "error"
|
|
74
140
|
|
|
75
141
|
type LogCapableClient = {
|
|
@@ -139,6 +205,11 @@ function nowIso(): string {
|
|
|
139
205
|
return new Date().toISOString()
|
|
140
206
|
}
|
|
141
207
|
|
|
208
|
+
function buildDateTag(options?: { includeTime?: boolean }): string {
|
|
209
|
+
const compactIso = new Date().toISOString().replace(/[-:]/g, "")
|
|
210
|
+
return compactIso.slice(0, options?.includeTime ? CHECKPOINT_DATE_TAG_LENGTH : SESSION_DATE_TAG_LENGTH)
|
|
211
|
+
}
|
|
212
|
+
|
|
142
213
|
function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBufferEntry, "timestamp">) {
|
|
143
214
|
if (!sessionID) return
|
|
144
215
|
const buf = sessionBuffer.get(sessionID) ?? []
|
|
@@ -146,6 +217,26 @@ function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBuffer
|
|
|
146
217
|
sessionBuffer.set(sessionID, buf)
|
|
147
218
|
}
|
|
148
219
|
|
|
220
|
+
function markContextEpochDirty(sessionID: string) {
|
|
221
|
+
sessionContextEpoch.set(sessionID, (sessionContextEpoch.get(sessionID) ?? 0) + 1)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function bumpCuratedVersion(sessionID: string) {
|
|
225
|
+
sessionCuratedVersion.set(sessionID, (sessionCuratedVersion.get(sessionID) ?? 0) + 1)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isAkmRef(value: string): boolean {
|
|
229
|
+
return AKM_REF_PATTERN.test(value)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parseMaybeJson(value: string): unknown {
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(value)
|
|
235
|
+
} catch {
|
|
236
|
+
return undefined
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
149
240
|
// Synchronous CLI invocation used by the lifecycle hooks — the plugin host does
|
|
150
241
|
// not await these in a hot path, but we still cap execution time so a slow
|
|
151
242
|
// stash never wedges the session loop.
|
|
@@ -193,6 +284,92 @@ function runHintsForSession(): string | null {
|
|
|
193
284
|
return body || null
|
|
194
285
|
}
|
|
195
286
|
|
|
287
|
+
function summarizeWorkflowList(value: unknown): string | null {
|
|
288
|
+
if (Array.isArray(value)) {
|
|
289
|
+
const lines = value
|
|
290
|
+
.map((item) => {
|
|
291
|
+
if (!item || typeof item !== "object") return null
|
|
292
|
+
const record = item as Record<string, unknown>
|
|
293
|
+
const id = typeof record.runId === "string"
|
|
294
|
+
? record.runId
|
|
295
|
+
: typeof record.id === "string"
|
|
296
|
+
? record.id
|
|
297
|
+
: null
|
|
298
|
+
const ref = typeof record.ref === "string"
|
|
299
|
+
? record.ref
|
|
300
|
+
: typeof record.workflowRef === "string"
|
|
301
|
+
? record.workflowRef
|
|
302
|
+
: null
|
|
303
|
+
const state = typeof record.state === "string" ? record.state : typeof record.status === "string" ? record.status : null
|
|
304
|
+
const step = typeof record.step === "string"
|
|
305
|
+
? record.step
|
|
306
|
+
: typeof record.currentStep === "string"
|
|
307
|
+
? record.currentStep
|
|
308
|
+
: null
|
|
309
|
+
if (!id && !ref && !state && !step) return null
|
|
310
|
+
return `- ${ref ?? "workflow"} (${id ?? "run"})${state ? ` — ${state}` : ""}${step ? ` — next: ${step}` : ""}`
|
|
311
|
+
})
|
|
312
|
+
.filter((line): line is string => !!line)
|
|
313
|
+
return lines.length > 0 ? lines.join("\n") : null
|
|
314
|
+
}
|
|
315
|
+
return null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function runWorkflowSummaryForSession(): string | null {
|
|
319
|
+
const result = runCliSyncRaw(["--format", "json", "-q", "workflow", "list", "--active"], AKM_CURATE_TIMEOUT_MS)
|
|
320
|
+
if (!result.ok) return null
|
|
321
|
+
const parsed = parseMaybeJson(result.stdout)
|
|
322
|
+
const summary = summarizeWorkflowList(
|
|
323
|
+
Array.isArray(parsed)
|
|
324
|
+
? parsed
|
|
325
|
+
: (parsed && typeof parsed === "object" && Array.isArray((parsed as { runs?: unknown }).runs))
|
|
326
|
+
? (parsed as { runs: unknown[] }).runs
|
|
327
|
+
: (parsed && typeof parsed === "object" && Array.isArray((parsed as { items?: unknown }).items))
|
|
328
|
+
? (parsed as { items: unknown[] }).items
|
|
329
|
+
: [],
|
|
330
|
+
)
|
|
331
|
+
return summary
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function formatWorkflowContext(summary: string): string {
|
|
335
|
+
return `# AKM active workflows\n${summary}`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function formatCuratorReportContext(report: string): string {
|
|
339
|
+
return `# AKM curator report\n${report}`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function summarizeCuratorReportForContext(report: string): string {
|
|
343
|
+
if (report.length <= AKM_CURATOR_CONTEXT_MAX_CHARS) return report
|
|
344
|
+
return `${report.slice(0, AKM_CURATOR_CONTEXT_MAX_CHARS).trimEnd()}\n\n[truncated for context]`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function getAkmStashDir(): string | undefined {
|
|
348
|
+
if (cachedAkmStashDir !== undefined) return cachedAkmStashDir || undefined
|
|
349
|
+
const result = runCliSyncRaw(["--format", "json", "-q", "config", "get", "stashDir"], AKM_CURATE_TIMEOUT_MS)
|
|
350
|
+
if (!result.ok) {
|
|
351
|
+
cachedAkmStashDir = ""
|
|
352
|
+
return undefined
|
|
353
|
+
}
|
|
354
|
+
const parsed = parseMaybeJson(result.stdout)
|
|
355
|
+
if (typeof parsed === "string" && parsed.trim()) {
|
|
356
|
+
cachedAkmStashDir = parsed.trim()
|
|
357
|
+
return cachedAkmStashDir
|
|
358
|
+
}
|
|
359
|
+
if (parsed && typeof parsed === "object") {
|
|
360
|
+
for (const key of ["value", "path", "stashDir"]) {
|
|
361
|
+
const value = (parsed as Record<string, unknown>)[key]
|
|
362
|
+
if (typeof value === "string" && value.trim()) {
|
|
363
|
+
cachedAkmStashDir = value.trim()
|
|
364
|
+
return cachedAkmStashDir
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const raw = result.stdout.trim()
|
|
369
|
+
cachedAkmStashDir = raw || ""
|
|
370
|
+
return cachedAkmStashDir || undefined
|
|
371
|
+
}
|
|
372
|
+
|
|
196
373
|
function warmIndexInBackground(): void {
|
|
197
374
|
const command = resolveAkmCommand()
|
|
198
375
|
if (typeof command !== "string") return
|
|
@@ -205,31 +382,106 @@ function warmIndexInBackground(): void {
|
|
|
205
382
|
}
|
|
206
383
|
}
|
|
207
384
|
|
|
208
|
-
function
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
385
|
+
function queueFeedback(
|
|
386
|
+
client: LogCapableClient,
|
|
387
|
+
ref: string,
|
|
388
|
+
sentiment: "positive" | "negative",
|
|
389
|
+
note: string,
|
|
390
|
+
meta: CliLogMeta,
|
|
391
|
+
dedupe?: Set<string>,
|
|
392
|
+
): boolean {
|
|
393
|
+
const dedupeKey = `${ref}:${sentiment}`
|
|
394
|
+
if (dedupe?.has(dedupeKey)) return true
|
|
395
|
+
dedupe?.add(dedupeKey)
|
|
396
|
+
|
|
397
|
+
const command = resolveAkmCommand()
|
|
398
|
+
if (typeof command !== "string") {
|
|
399
|
+
void writePluginLog(client, "warn", "AKM auto-feedback skipped", {
|
|
400
|
+
subsystem: "feedback",
|
|
401
|
+
toolName: meta.toolName,
|
|
402
|
+
sessionID: meta.sessionID,
|
|
403
|
+
directory: meta.directory,
|
|
215
404
|
ref,
|
|
216
|
-
sentiment
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
405
|
+
sentiment,
|
|
406
|
+
error: command.error,
|
|
407
|
+
})
|
|
408
|
+
return false
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const child = spawn(
|
|
413
|
+
command,
|
|
414
|
+
[
|
|
415
|
+
"--format",
|
|
416
|
+
"json",
|
|
417
|
+
"-q",
|
|
418
|
+
"feedback",
|
|
419
|
+
ref,
|
|
420
|
+
sentiment === "positive" ? "--positive" : "--negative",
|
|
421
|
+
"--note",
|
|
422
|
+
note,
|
|
423
|
+
],
|
|
424
|
+
{
|
|
425
|
+
detached: true,
|
|
426
|
+
stdio: "ignore",
|
|
427
|
+
},
|
|
428
|
+
)
|
|
429
|
+
child.on("error", (error) => {
|
|
430
|
+
void writePluginLog(client, "warn", "AKM auto-feedback failed", {
|
|
431
|
+
subsystem: "feedback",
|
|
432
|
+
toolName: meta.toolName,
|
|
433
|
+
sessionID: meta.sessionID,
|
|
434
|
+
directory: meta.directory,
|
|
435
|
+
ref,
|
|
436
|
+
sentiment,
|
|
437
|
+
error: formatCliError(error),
|
|
438
|
+
})
|
|
439
|
+
})
|
|
440
|
+
child.unref()
|
|
441
|
+
return true
|
|
442
|
+
} catch (error: unknown) {
|
|
443
|
+
void writePluginLog(client, "warn", "AKM auto-feedback failed", {
|
|
444
|
+
subsystem: "feedback",
|
|
445
|
+
toolName: meta.toolName,
|
|
446
|
+
sessionID: meta.sessionID,
|
|
447
|
+
directory: meta.directory,
|
|
448
|
+
ref,
|
|
449
|
+
sentiment,
|
|
450
|
+
error: formatCliError(error),
|
|
451
|
+
})
|
|
452
|
+
return false
|
|
453
|
+
}
|
|
223
454
|
}
|
|
224
455
|
|
|
225
|
-
function
|
|
456
|
+
function rememberTextAsMemory(name: string, body: string): string | null {
|
|
457
|
+
const command = resolveAkmCommand()
|
|
458
|
+
if (typeof command !== "string") return null
|
|
459
|
+
try {
|
|
460
|
+
execFileSync(command, ["--format", "json", "-q", "remember", "--name", name, "--force"], {
|
|
461
|
+
encoding: "utf8",
|
|
462
|
+
timeout: AKM_CURATE_TIMEOUT_MS * 2,
|
|
463
|
+
input: body,
|
|
464
|
+
})
|
|
465
|
+
return `memory:${name}`
|
|
466
|
+
} catch {
|
|
467
|
+
return null
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function captureSessionMemory(
|
|
472
|
+
sessionID: string,
|
|
473
|
+
reason: string,
|
|
474
|
+
options?: { checkpoint?: boolean },
|
|
475
|
+
): string | null {
|
|
226
476
|
if (!AKM_AUTO_MEMORY) return null
|
|
227
477
|
if (!sessionID) return null
|
|
228
|
-
|
|
478
|
+
const isCheckpoint = options?.checkpoint === true
|
|
479
|
+
if (!isCheckpoint && sessionFinalMemoryCaptured.has(sessionID)) return null
|
|
229
480
|
const entries = sessionBuffer.get(sessionID) ?? []
|
|
481
|
+
const pendingEntries = isCheckpoint ? entries.filter((entry) => !entry.checkpointed) : entries
|
|
230
482
|
// Require at least two observations before persisting — single events are noise.
|
|
231
|
-
if (
|
|
232
|
-
sessionBuffer.delete(sessionID)
|
|
483
|
+
if (pendingEntries.length < 2) {
|
|
484
|
+
if (!isCheckpoint) sessionBuffer.delete(sessionID)
|
|
233
485
|
return null
|
|
234
486
|
}
|
|
235
487
|
|
|
@@ -238,7 +490,7 @@ function captureSessionMemory(sessionID: string, reason: string): string | null
|
|
|
238
490
|
lines.push(`Reason: ${reason}`)
|
|
239
491
|
lines.push(`Session: ${sessionID}`)
|
|
240
492
|
lines.push("")
|
|
241
|
-
for (const entry of
|
|
493
|
+
for (const entry of pendingEntries) {
|
|
242
494
|
if (entry.kind === "memory-intent") {
|
|
243
495
|
lines.push(`## ${entry.timestamp} — user memory intent`)
|
|
244
496
|
if (entry.note) lines.push(entry.note)
|
|
@@ -252,38 +504,74 @@ function captureSessionMemory(sessionID: string, reason: string): string | null
|
|
|
252
504
|
}
|
|
253
505
|
const body = lines.join("\n")
|
|
254
506
|
|
|
255
|
-
const dateTag =
|
|
507
|
+
const dateTag = buildDateTag({ includeTime: isCheckpoint })
|
|
256
508
|
const shortSid = sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
|
|
257
|
-
const name =
|
|
509
|
+
const name = isCheckpoint
|
|
510
|
+
? `opencode-checkpoint-${dateTag}-${shortSid}`
|
|
511
|
+
: `opencode-session-${dateTag}-${shortSid}`
|
|
258
512
|
|
|
259
|
-
const
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
|
|
513
|
+
const ref = rememberTextAsMemory(name, body)
|
|
514
|
+
if (!ref) {
|
|
515
|
+
if (!isCheckpoint) {
|
|
516
|
+
sessionFinalMemoryCaptured.add(sessionID)
|
|
517
|
+
sessionBuffer.delete(sessionID)
|
|
518
|
+
}
|
|
263
519
|
return null
|
|
264
520
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
return `memory:${name}`
|
|
274
|
-
} catch {
|
|
275
|
-
sessionMemoryCaptured.add(sessionID)
|
|
276
|
-
sessionBuffer.delete(sessionID)
|
|
277
|
-
return null
|
|
521
|
+
|
|
522
|
+
if (isCheckpoint) {
|
|
523
|
+
for (const entry of entries) {
|
|
524
|
+
if (!entry.checkpointed) entry.checkpointed = true
|
|
525
|
+
}
|
|
526
|
+
sessionSuccessfulAssetTouchCount.set(sessionID, 0)
|
|
527
|
+
sessionBuffer.set(sessionID, entries)
|
|
528
|
+
return ref
|
|
278
529
|
}
|
|
530
|
+
|
|
531
|
+
sessionFinalMemoryCaptured.add(sessionID)
|
|
532
|
+
sessionBuffer.delete(sessionID)
|
|
533
|
+
return ref
|
|
279
534
|
}
|
|
280
535
|
|
|
281
|
-
function
|
|
536
|
+
function maybeCheckpointSessionMemory(sessionID: string): string | null {
|
|
537
|
+
const count = sessionSuccessfulAssetTouchCount.get(sessionID) ?? 0
|
|
538
|
+
if (count < AKM_MEMORY_CHECKPOINT_EVERY) return null
|
|
539
|
+
const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true })
|
|
540
|
+
if (!captured) {
|
|
541
|
+
sessionSuccessfulAssetTouchCount.set(sessionID, 0)
|
|
542
|
+
}
|
|
543
|
+
return captured
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const AKM_REF_EDGE_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "'", "\"", "`"])
|
|
547
|
+
|
|
548
|
+
function normalizeExtractedRef(ref: string): string {
|
|
549
|
+
let start = 0
|
|
550
|
+
let end = ref.length
|
|
551
|
+
while (start < end && AKM_REF_EDGE_PUNCTUATION.has(ref[start] ?? "")) start += 1
|
|
552
|
+
while (end > start && AKM_REF_EDGE_PUNCTUATION.has(ref[end - 1] ?? "")) end -= 1
|
|
553
|
+
return ref.slice(start, end)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function extractRefsFromText(value: string): string[] {
|
|
282
557
|
const refs = new Set<string>()
|
|
558
|
+
for (const token of value.split(/\s+/)) {
|
|
559
|
+
const normalized = normalizeExtractedRef(token)
|
|
560
|
+
if (normalized && isAkmRef(normalized)) refs.add(normalized)
|
|
561
|
+
}
|
|
562
|
+
return [...refs]
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function extractToolRefs(
|
|
566
|
+
toolName: string,
|
|
567
|
+
args: Record<string, unknown>,
|
|
568
|
+
output: unknown,
|
|
569
|
+
): { refs: string[]; positiveOnlyRefs: string[] } {
|
|
570
|
+
const refs = new Set<string>()
|
|
571
|
+
const positiveOnlyRefs = new Set<string>()
|
|
283
572
|
const addMatches = (value: unknown) => {
|
|
284
573
|
if (typeof value !== "string") return
|
|
285
|
-
const
|
|
286
|
-
if (matches) for (const ref of matches) refs.add(ref)
|
|
574
|
+
for (const ref of extractRefsFromText(value)) refs.add(ref)
|
|
287
575
|
}
|
|
288
576
|
|
|
289
577
|
for (const key of ["ref", "package_ref"]) {
|
|
@@ -304,9 +592,18 @@ function extractToolRefs(toolName: string, args: Record<string, unknown>, output
|
|
|
304
592
|
}
|
|
305
593
|
}
|
|
306
594
|
if (toolName === "akm_remember" && typeof o.ref === "string") addMatches(o.ref)
|
|
595
|
+
if (
|
|
596
|
+
(toolName === "akm_agent" || toolName === "akm_cmd" || toolName === "akm_evolve")
|
|
597
|
+
&& typeof o.text === "string"
|
|
598
|
+
) {
|
|
599
|
+
for (const ref of extractRefsFromText(o.text)) {
|
|
600
|
+
refs.add(ref)
|
|
601
|
+
positiveOnlyRefs.add(ref)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
307
604
|
}
|
|
308
605
|
|
|
309
|
-
return [...refs]
|
|
606
|
+
return { refs: [...refs], positiveOnlyRefs: [...positiveOnlyRefs] }
|
|
310
607
|
}
|
|
311
608
|
|
|
312
609
|
const AKM_HINTS_PREFIX = [
|
|
@@ -318,6 +615,98 @@ const AKM_HINTS_PREFIX = [
|
|
|
318
615
|
const AKM_CURATED_HEADER = "# AKM stash — assets relevant to this prompt"
|
|
319
616
|
const AKM_CURATED_TAIL = "\n\nTip: call `akm_show <ref>` to fetch full content, and record `akm_feedback <ref> positive|negative` once you know whether the asset helped."
|
|
320
617
|
|
|
618
|
+
// Curated quick-reference for the long-tail of `akm` CLI verbs that no longer
|
|
619
|
+
// have a dedicated tool wrapper. Surfaced through akm_help so agents can
|
|
620
|
+
// always find the right invocation without polluting default context.
|
|
621
|
+
type AkmHelpEntry = {
|
|
622
|
+
task: string
|
|
623
|
+
command: string
|
|
624
|
+
notes?: string
|
|
625
|
+
keywords: string[]
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
|
|
629
|
+
{
|
|
630
|
+
task: "Install a kit or register an external source (npm, GitHub, git, URL, local dir)",
|
|
631
|
+
command: "akm add <package-ref> [--name <n>] [--type wiki] [--writable] [--trust] [--provider <p>] [--max-pages N] [--max-depth N]",
|
|
632
|
+
notes: "Confirm with the user before passing --trust or registering a website crawler.",
|
|
633
|
+
keywords: ["add", "install", "register", "kit", "source", "github", "npm"],
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
task: "Commit (and optionally push) pending stash changes",
|
|
637
|
+
command: "akm save [<source-name>] [-m <msg>] [--push]",
|
|
638
|
+
notes: "Add --push only when the stash is writable; review the diff first.",
|
|
639
|
+
keywords: ["save", "commit", "push", "publish", "git"],
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
task: "Import a file (or stdin) into the stash as a typed asset",
|
|
643
|
+
command: "akm import <path|-> [--name <name>] [--force]",
|
|
644
|
+
notes: "Use `-` and pipe content via stdin to import a string.",
|
|
645
|
+
keywords: ["import", "ingest", "upload", "stdin"],
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
task: "Clone an asset from any source for editing",
|
|
649
|
+
command: "akm clone <ref> [--name <new>] [--dest <dir>] [--force]",
|
|
650
|
+
notes: "Type subdirectory is appended automatically; ref may include origin (e.g. npm:@scope/pkg//script:foo).",
|
|
651
|
+
keywords: ["clone", "copy", "fork", "edit"],
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
task: "Update a managed source (or all of them)",
|
|
655
|
+
command: "akm update [<package_ref>|--all] [--force]",
|
|
656
|
+
keywords: ["update", "upgrade kit", "refresh", "pull"],
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
task: "Remove a configured source and reindex",
|
|
660
|
+
command: "akm remove <id|ref|path|url|name>",
|
|
661
|
+
notes: "Destructive — confirm intent before running.",
|
|
662
|
+
keywords: ["remove", "uninstall", "delete source"],
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
task: "List configured sources (local dirs, kits, remotes)",
|
|
666
|
+
command: "akm list",
|
|
667
|
+
keywords: ["list", "sources", "kits", "show sources"],
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
task: "Search the registry only (skip local stash)",
|
|
671
|
+
command: "akm registry search <query> [--limit N] [--assets]",
|
|
672
|
+
notes: "akm_search with source='registry' covers most cases; this is the explicit form.",
|
|
673
|
+
keywords: ["registry", "search registry", "installable", "discover kit"],
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
task: "Build or rebuild the stash search index",
|
|
677
|
+
command: "akm index",
|
|
678
|
+
notes: "Rarely needed — the index refreshes implicitly after writes.",
|
|
679
|
+
keywords: ["index", "reindex", "rebuild"],
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
task: "View or update akm config (get/set/list/unset/path)",
|
|
683
|
+
command: "akm config <action> [<key>] [<value>] [--all]",
|
|
684
|
+
notes: "`akm config path --all` prints config, stash, cache, and index paths.",
|
|
685
|
+
keywords: ["config", "settings", "configure", "path"],
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
task: "Check for or install an akm CLI update",
|
|
689
|
+
command: "akm upgrade [--check] [--force]",
|
|
690
|
+
keywords: ["upgrade cli", "update cli", "self-upgrade"],
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
task: "Run a stash script end-to-end (resolve → show → run)",
|
|
694
|
+
command: "akm show <script-ref> # then exec the printed `run` command",
|
|
695
|
+
notes: "Or `akm --format json -q show <ref>` and pipe `.run` into your shell.",
|
|
696
|
+
keywords: ["run", "execute", "script", "exec"],
|
|
697
|
+
},
|
|
698
|
+
]
|
|
699
|
+
|
|
700
|
+
function lookupAkmHelpHint(topic: string): AkmHelpEntry[] {
|
|
701
|
+
const needle = topic.toLowerCase().trim()
|
|
702
|
+
if (!needle) return []
|
|
703
|
+
return AKM_HELP_QUICK_REFERENCE.filter((entry) =>
|
|
704
|
+
entry.keywords.some((kw) => needle.includes(kw))
|
|
705
|
+
|| entry.task.toLowerCase().includes(needle)
|
|
706
|
+
|| entry.command.toLowerCase().includes(needle),
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
|
|
321
710
|
function extractSessionIdFromEvent(payload: unknown): string | undefined {
|
|
322
711
|
if (!payload || typeof payload !== "object") return undefined
|
|
323
712
|
const p = payload as Record<string, unknown>
|
|
@@ -451,7 +840,7 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
|
|
|
451
840
|
return JSON.stringify(command)
|
|
452
841
|
}
|
|
453
842
|
|
|
454
|
-
const fullArgs = [...args, "--format", "json"]
|
|
843
|
+
const fullArgs = args.includes("--format") ? [...args] : [...args, "--format", "json"]
|
|
455
844
|
|
|
456
845
|
try {
|
|
457
846
|
const stdout = execFileSync(command, fullArgs, {
|
|
@@ -488,7 +877,29 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
|
|
|
488
877
|
}
|
|
489
878
|
|
|
490
879
|
type CliError = { ok: false; error: string }
|
|
491
|
-
type AssetType =
|
|
880
|
+
type AssetType =
|
|
881
|
+
| "agent"
|
|
882
|
+
| "command"
|
|
883
|
+
| "knowledge"
|
|
884
|
+
| "memory"
|
|
885
|
+
| "script"
|
|
886
|
+
| "skill"
|
|
887
|
+
| "workflow"
|
|
888
|
+
| "vault"
|
|
889
|
+
| "wiki"
|
|
890
|
+
|
|
891
|
+
const ASSET_TYPES = [
|
|
892
|
+
"agent",
|
|
893
|
+
"command",
|
|
894
|
+
"knowledge",
|
|
895
|
+
"memory",
|
|
896
|
+
"script",
|
|
897
|
+
"skill",
|
|
898
|
+
"workflow",
|
|
899
|
+
"vault",
|
|
900
|
+
"wiki",
|
|
901
|
+
"any",
|
|
902
|
+
] as const
|
|
492
903
|
|
|
493
904
|
type ShowAgentResponse = {
|
|
494
905
|
type: "agent"
|
|
@@ -588,6 +999,12 @@ function parseCliJson<T>(raw: string): T | CliError {
|
|
|
588
999
|
}
|
|
589
1000
|
}
|
|
590
1001
|
|
|
1002
|
+
function blockedToolResponse(args: Record<string, unknown>): string | null {
|
|
1003
|
+
return typeof args.__akmBlocked === "string"
|
|
1004
|
+
? JSON.stringify({ ok: false, error: args.__akmBlocked })
|
|
1005
|
+
: null
|
|
1006
|
+
}
|
|
1007
|
+
|
|
591
1008
|
function isCliError(value: unknown): value is CliError {
|
|
592
1009
|
return !!value
|
|
593
1010
|
&& typeof value === "object"
|
|
@@ -705,33 +1122,45 @@ function truncateLogText(value: string, limit = 1_000): string {
|
|
|
705
1122
|
return value.length > limit ? `${value.slice(0, limit)}…` : value
|
|
706
1123
|
}
|
|
707
1124
|
|
|
708
|
-
async function
|
|
1125
|
+
async function searchRef(
|
|
709
1126
|
client: LogCapableClient,
|
|
710
|
-
|
|
711
|
-
type: AssetType,
|
|
1127
|
+
query: string,
|
|
1128
|
+
type: AssetType | "any",
|
|
712
1129
|
meta: CliLogMeta,
|
|
713
1130
|
): Promise<{ ok: true; ref: string } | CliError> {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if (!query) {
|
|
720
|
-
return { ok: false, error: "Provide either 'ref' or 'query'." }
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
const raw = await runCli(client, ["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "stash"], meta)
|
|
1131
|
+
const raw = await runCli(
|
|
1132
|
+
client,
|
|
1133
|
+
["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "stash"],
|
|
1134
|
+
meta,
|
|
1135
|
+
)
|
|
724
1136
|
const parsed = parseCliJson<SearchResponse>(raw)
|
|
725
1137
|
if (isCliError(parsed)) return parsed
|
|
726
|
-
|
|
727
1138
|
const ref = parsed.hits?.[0]?.ref
|
|
728
1139
|
if (!ref) {
|
|
729
|
-
return {
|
|
1140
|
+
return {
|
|
1141
|
+
ok: false,
|
|
1142
|
+
error: `No stash ref matched '${query}'. Use akm_search to disambiguate, then retry with an exact ref.`,
|
|
1143
|
+
}
|
|
730
1144
|
}
|
|
731
|
-
|
|
732
1145
|
return { ok: true, ref }
|
|
733
1146
|
}
|
|
734
1147
|
|
|
1148
|
+
async function resolveRefOrQueryInput(
|
|
1149
|
+
client: LogCapableClient,
|
|
1150
|
+
input: { ref?: string; query?: string },
|
|
1151
|
+
type: AssetType | "any",
|
|
1152
|
+
meta: CliLogMeta,
|
|
1153
|
+
): Promise<{ ok: true; ref: string } | CliError> {
|
|
1154
|
+
const explicitRef = input.ref?.trim()
|
|
1155
|
+
if (explicitRef) return { ok: true, ref: explicitRef }
|
|
1156
|
+
|
|
1157
|
+
const query = input.query?.trim()
|
|
1158
|
+
if (!query) {
|
|
1159
|
+
return { ok: false, error: "Provide either 'ref' or 'query'." }
|
|
1160
|
+
}
|
|
1161
|
+
return searchRef(client, query, type, meta)
|
|
1162
|
+
}
|
|
1163
|
+
|
|
735
1164
|
async function ensureTargetSessionID(input: {
|
|
736
1165
|
useSubtask: boolean
|
|
737
1166
|
context: { sessionID: string; directory: string }
|
|
@@ -844,6 +1273,65 @@ async function promptTargetSession(input: {
|
|
|
844
1273
|
}
|
|
845
1274
|
}
|
|
846
1275
|
|
|
1276
|
+
async function resolveDispatchAgent(
|
|
1277
|
+
client: PluginClient,
|
|
1278
|
+
requestedAgent: string,
|
|
1279
|
+
directory: string,
|
|
1280
|
+
): Promise<string> {
|
|
1281
|
+
if (requestedAgent !== "akm-curator") return requestedAgent
|
|
1282
|
+
try {
|
|
1283
|
+
const agents = await client.app.agents({ query: { directory } })
|
|
1284
|
+
if (agents.error) return "general"
|
|
1285
|
+
const hasCurator = (agents.data ?? []).some((agent) => agent?.name === "akm-curator")
|
|
1286
|
+
return hasCurator ? "akm-curator" : "general"
|
|
1287
|
+
} catch {
|
|
1288
|
+
return "general"
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function summarizeSessionMessages(
|
|
1293
|
+
sessionID: string,
|
|
1294
|
+
messages: Array<{ info?: Record<string, unknown>; parts?: unknown }>,
|
|
1295
|
+
) {
|
|
1296
|
+
return {
|
|
1297
|
+
ok: true,
|
|
1298
|
+
sessionID,
|
|
1299
|
+
messages: messages.map((message) => {
|
|
1300
|
+
const info = message.info ?? {}
|
|
1301
|
+
const role = typeof info.role === "string" ? info.role : "unknown"
|
|
1302
|
+
const agent = typeof info.agent === "string"
|
|
1303
|
+
? info.agent
|
|
1304
|
+
: typeof info.mode === "string"
|
|
1305
|
+
? info.mode
|
|
1306
|
+
: null
|
|
1307
|
+
return {
|
|
1308
|
+
role,
|
|
1309
|
+
agent,
|
|
1310
|
+
text: extractText(message.parts),
|
|
1311
|
+
}
|
|
1312
|
+
}),
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function getParentSessionID(
|
|
1317
|
+
client: PluginClient,
|
|
1318
|
+
sessionID: string,
|
|
1319
|
+
directory: string,
|
|
1320
|
+
): Promise<{ ok: true; parentID: string } | CliError> {
|
|
1321
|
+
try {
|
|
1322
|
+
const result = await client.session.get({
|
|
1323
|
+
path: { id: sessionID },
|
|
1324
|
+
query: { directory },
|
|
1325
|
+
})
|
|
1326
|
+
if (result.error || !result.data?.parentID) {
|
|
1327
|
+
return { ok: false, error: "This session does not have a parent session." }
|
|
1328
|
+
}
|
|
1329
|
+
return { ok: true, parentID: result.data.parentID }
|
|
1330
|
+
} catch (error: unknown) {
|
|
1331
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
847
1335
|
function splitArguments(raw: string): string[] {
|
|
848
1336
|
if (!raw.trim()) return []
|
|
849
1337
|
const args: string[] = []
|
|
@@ -868,7 +1356,7 @@ function normalizeSearchSource(source: "local" | "stash" | "registry" | "both"):
|
|
|
868
1356
|
|
|
869
1357
|
function createSearchArgs(input: {
|
|
870
1358
|
query: string
|
|
871
|
-
type?: AssetType | "any"
|
|
1359
|
+
type?: AssetType | "any" | string
|
|
872
1360
|
limit?: number
|
|
873
1361
|
source?: "local" | "stash" | "registry" | "both"
|
|
874
1362
|
defaultSource?: "local" | "stash" | "registry" | "both"
|
|
@@ -890,17 +1378,31 @@ type PluginClient = {
|
|
|
890
1378
|
create: (input: {
|
|
891
1379
|
body: { parentID: string; title: string }
|
|
892
1380
|
}) => Promise<{ data?: { id?: string }; error?: unknown }>
|
|
1381
|
+
get: (input: {
|
|
1382
|
+
path: { id: string }
|
|
1383
|
+
query?: { directory?: string }
|
|
1384
|
+
}) => Promise<{ data?: { id?: string; parentID?: string }; error?: unknown }>
|
|
1385
|
+
messages: (input: {
|
|
1386
|
+
path: { id: string }
|
|
1387
|
+
query?: { directory?: string }
|
|
1388
|
+
}) => Promise<{ data?: Array<{ info?: Record<string, unknown>; parts?: unknown }>; error?: unknown }>
|
|
893
1389
|
prompt: (input: {
|
|
894
1390
|
path: { id: string }
|
|
895
1391
|
body: SessionPromptBody
|
|
896
1392
|
}) => Promise<{ data?: { parts?: unknown }; error?: unknown }>
|
|
897
1393
|
}
|
|
1394
|
+
app: {
|
|
1395
|
+
agents: (input?: {
|
|
1396
|
+
query?: { directory?: string }
|
|
1397
|
+
}) => Promise<{ data?: Array<{ name?: string }>; error?: unknown }>
|
|
1398
|
+
}
|
|
898
1399
|
}
|
|
899
1400
|
|
|
900
|
-
export const AkmPlugin: Plugin = async ({ client }) => {
|
|
1401
|
+
export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
901
1402
|
await ensureLatestAkmInstalled(client as unknown as LogCapableClient)
|
|
902
1403
|
|
|
903
1404
|
const logClient = client as unknown as LogCapableClient
|
|
1405
|
+
const sdkClient = client as unknown as PluginClient
|
|
904
1406
|
|
|
905
1407
|
return {
|
|
906
1408
|
// Events cover the lifecycle boundaries that Claude Code exposes as
|
|
@@ -913,11 +1415,15 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
913
1415
|
const sid = extractSessionIdFromEvent(event) ?? extractSessionIdFromEvent((event as { properties?: unknown }).properties)
|
|
914
1416
|
if (type === "session.created" || type === "session.updated") {
|
|
915
1417
|
if (!sid) return
|
|
916
|
-
if (!
|
|
917
|
-
if (
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1418
|
+
if (!sessionContextEpoch.has(sid)) sessionContextEpoch.set(sid, 0)
|
|
1419
|
+
if (type === "session.created") warmIndexInBackground()
|
|
1420
|
+
if (AKM_AUTO_HINTS && !sessionHints.has(sid)) {
|
|
1421
|
+
const hints = runHintsForSession()
|
|
1422
|
+
if (hints) sessionHints.set(sid, hints)
|
|
1423
|
+
}
|
|
1424
|
+
if (!sessionWorkflow.has(sid)) {
|
|
1425
|
+
sessionWorkflow.set(sid, runWorkflowSummaryForSession() ?? "")
|
|
1426
|
+
}
|
|
921
1427
|
} else if (type === "session.compacted" || type === "session.idle" || type === "session.deleted") {
|
|
922
1428
|
if (!sid) return
|
|
923
1429
|
const captured = captureSessionMemory(sid, type)
|
|
@@ -935,7 +1441,14 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
935
1441
|
if (type === "session.deleted") {
|
|
936
1442
|
sessionHints.delete(sid)
|
|
937
1443
|
sessionCurated.delete(sid)
|
|
938
|
-
|
|
1444
|
+
sessionWorkflow.delete(sid)
|
|
1445
|
+
sessionCuratorReport.delete(sid)
|
|
1446
|
+
sessionContextEpoch.delete(sid)
|
|
1447
|
+
sessionContextInjectedEpoch.delete(sid)
|
|
1448
|
+
sessionCuratedVersion.delete(sid)
|
|
1449
|
+
sessionCuratedInjectedVersion.delete(sid)
|
|
1450
|
+
sessionFinalMemoryCaptured.delete(sid)
|
|
1451
|
+
sessionSuccessfulAssetTouchCount.delete(sid)
|
|
939
1452
|
sessionBuffer.delete(sid)
|
|
940
1453
|
}
|
|
941
1454
|
}
|
|
@@ -964,6 +1477,24 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
964
1477
|
// Best-effort only.
|
|
965
1478
|
}
|
|
966
1479
|
},
|
|
1480
|
+
"experimental.session.compacting": async (input, output) => {
|
|
1481
|
+
try {
|
|
1482
|
+
const sid = input.sessionID
|
|
1483
|
+
if (!sid) return
|
|
1484
|
+
if (!Array.isArray(output.context)) return
|
|
1485
|
+
markContextEpochDirty(sid)
|
|
1486
|
+
const hints = sessionHints.get(sid)
|
|
1487
|
+
if (hints) output.context.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
|
|
1488
|
+
const curated = sessionCurated.get(sid)
|
|
1489
|
+
if (curated) output.context.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
|
|
1490
|
+
const workflow = sessionWorkflow.get(sid)
|
|
1491
|
+
if (workflow) output.context.push(formatWorkflowContext(workflow))
|
|
1492
|
+
const curatorReport = sessionCuratorReport.get(sid)
|
|
1493
|
+
if (curatorReport) output.context.push(formatCuratorReportContext(curatorReport))
|
|
1494
|
+
} catch {
|
|
1495
|
+
// Never break compaction because of plugin context.
|
|
1496
|
+
}
|
|
1497
|
+
},
|
|
967
1498
|
// experimental.chat.system.transform is how OpenCode exposes the
|
|
968
1499
|
// additionalContext channel. We append the cached hints (once per session)
|
|
969
1500
|
// and the curated assets (once per turn) so the next LLM call sees them.
|
|
@@ -974,21 +1505,56 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
974
1505
|
try {
|
|
975
1506
|
if (!output || !Array.isArray(output.system)) return
|
|
976
1507
|
const sid = extractSessionIdFromEvent(input) ?? ""
|
|
977
|
-
const
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1508
|
+
const epoch = sessionContextEpoch.get(sid) ?? 0
|
|
1509
|
+
const injectedEpoch = sessionContextInjectedEpoch.get(sid)
|
|
1510
|
+
if (sid && injectedEpoch !== epoch) {
|
|
1511
|
+
const hints = sessionHints.get(sid)
|
|
1512
|
+
if (hints) output.system.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
|
|
1513
|
+
const workflow = sessionWorkflow.get(sid)
|
|
1514
|
+
if (workflow) output.system.push(formatWorkflowContext(workflow))
|
|
1515
|
+
const curatorReport = sessionCuratorReport.get(sid)
|
|
1516
|
+
if (curatorReport) output.system.push(formatCuratorReportContext(curatorReport))
|
|
1517
|
+
sessionContextInjectedEpoch.set(sid, epoch)
|
|
982
1518
|
}
|
|
983
1519
|
const curated = sid ? sessionCurated.get(sid) : undefined
|
|
1520
|
+
const curatedVersion = sessionCuratedVersion.get(sid) ?? 0
|
|
984
1521
|
if (curated) {
|
|
985
|
-
|
|
986
|
-
|
|
1522
|
+
if (sessionCuratedInjectedVersion.get(sid) !== curatedVersion) {
|
|
1523
|
+
output.system.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
|
|
1524
|
+
sessionCuratedInjectedVersion.set(sid, curatedVersion)
|
|
1525
|
+
}
|
|
987
1526
|
}
|
|
988
1527
|
} catch {
|
|
989
1528
|
// Never break the turn because of a transform failure.
|
|
990
1529
|
}
|
|
991
1530
|
},
|
|
1531
|
+
"tool.execute.before": async (input, output) => {
|
|
1532
|
+
try {
|
|
1533
|
+
if (!input.tool.startsWith("akm_")) return
|
|
1534
|
+
const args = output.args && typeof output.args === "object" ? output.args as Record<string, unknown> : {}
|
|
1535
|
+
const confirm = args.confirm === true
|
|
1536
|
+
if (input.tool === "akm_vault" && (args.action === "show" || args.action === "unset") && !confirm) {
|
|
1537
|
+
output.args = {
|
|
1538
|
+
...args,
|
|
1539
|
+
__akmBlocked: `akm_vault action='${String(args.action)}' requires confirm:true to avoid accidental secret exposure or deletion.`,
|
|
1540
|
+
}
|
|
1541
|
+
return
|
|
1542
|
+
}
|
|
1543
|
+
output.args = args
|
|
1544
|
+
} catch {
|
|
1545
|
+
// Never break tool execution from the pre-hook.
|
|
1546
|
+
}
|
|
1547
|
+
},
|
|
1548
|
+
"shell.env": async (_input, output) => {
|
|
1549
|
+
try {
|
|
1550
|
+
output.env.AKM_PROJECT = worktree
|
|
1551
|
+
output.env.AKM_PLUGIN_VERSION = PLUGIN_VERSION
|
|
1552
|
+
const stashDir = getAkmStashDir()
|
|
1553
|
+
if (stashDir) output.env.AKM_STASH_DIR = stashDir
|
|
1554
|
+
} catch {
|
|
1555
|
+
// Best-effort only.
|
|
1556
|
+
}
|
|
1557
|
+
},
|
|
992
1558
|
"chat.message": async (input, output) => {
|
|
993
1559
|
const text = extractText(output.parts).trim()
|
|
994
1560
|
if (!text) return
|
|
@@ -1005,7 +1571,10 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1005
1571
|
// stash the result so experimental.chat.system.transform can inject it.
|
|
1006
1572
|
if (AKM_AUTO_CURATE && input.sessionID) {
|
|
1007
1573
|
const curated = runCurateForPrompt(text)
|
|
1008
|
-
if (curated)
|
|
1574
|
+
if (curated) {
|
|
1575
|
+
sessionCurated.set(input.sessionID, curated)
|
|
1576
|
+
bumpCuratedVersion(input.sessionID)
|
|
1577
|
+
}
|
|
1009
1578
|
}
|
|
1010
1579
|
|
|
1011
1580
|
// Track explicit memory intents so capture-memory has something durable
|
|
@@ -1016,6 +1585,21 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1016
1585
|
note: truncateLogText(text, 500),
|
|
1017
1586
|
})
|
|
1018
1587
|
}
|
|
1588
|
+
|
|
1589
|
+
if (input.sessionID && AKM_AUTO_FEEDBACK && AKM_RETROSPECTIVE_FEEDBACK_RE.test(text)) {
|
|
1590
|
+
const recentRefs = (sessionBuffer.get(input.sessionID) ?? [])
|
|
1591
|
+
.filter((entry) => entry.kind === "tool-ref" && !!entry.ref)
|
|
1592
|
+
.map((entry) => entry.ref!)
|
|
1593
|
+
.filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("vault:") && refs.indexOf(ref) === index)
|
|
1594
|
+
.slice(-3)
|
|
1595
|
+
const dedupe = new Set<string>()
|
|
1596
|
+
for (const ref of recentRefs) {
|
|
1597
|
+
queueFeedback(logClient, ref, "positive", "opencode retrospective: user confirmed it worked", {
|
|
1598
|
+
toolName: "chat.message",
|
|
1599
|
+
sessionID: input.sessionID,
|
|
1600
|
+
}, dedupe)
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1019
1603
|
},
|
|
1020
1604
|
"tool.execute.after": async (input, output) => {
|
|
1021
1605
|
if (!input.tool.startsWith("akm_")) return
|
|
@@ -1051,9 +1635,9 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1051
1635
|
// Auto-feedback + session buffering: record every asset ref the tool
|
|
1052
1636
|
// touched so the stash ranking improves over time and so Stop/Compact
|
|
1053
1637
|
// has material to flush into a session summary memory.
|
|
1054
|
-
const
|
|
1055
|
-
if (
|
|
1056
|
-
for (const ref of
|
|
1638
|
+
const refResult = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
|
|
1639
|
+
if (refResult.refs.length > 0 && input.sessionID) {
|
|
1640
|
+
for (const ref of refResult.refs) {
|
|
1057
1641
|
addBufferEntry(input.sessionID, {
|
|
1058
1642
|
kind: "tool-ref",
|
|
1059
1643
|
toolName: input.tool,
|
|
@@ -1061,32 +1645,57 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1061
1645
|
status: feedback ?? "unknown",
|
|
1062
1646
|
})
|
|
1063
1647
|
}
|
|
1648
|
+
if (feedback === "positive") {
|
|
1649
|
+
sessionSuccessfulAssetTouchCount.set(
|
|
1650
|
+
input.sessionID,
|
|
1651
|
+
(sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1,
|
|
1652
|
+
)
|
|
1653
|
+
const checkpointRef = maybeCheckpointSessionMemory(input.sessionID)
|
|
1654
|
+
if (checkpointRef) {
|
|
1655
|
+
await writePluginLog(logClient, "info", "AKM checkpoint memory captured", {
|
|
1656
|
+
subsystem: "memory",
|
|
1657
|
+
actor: "system",
|
|
1658
|
+
sessionID: input.sessionID,
|
|
1659
|
+
reason: "checkpoint",
|
|
1660
|
+
ref: checkpointRef,
|
|
1661
|
+
})
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1064
1664
|
}
|
|
1065
1665
|
|
|
1066
1666
|
if (
|
|
1067
1667
|
AKM_AUTO_FEEDBACK
|
|
1068
1668
|
&& feedback
|
|
1069
1669
|
&& input.tool !== "akm_feedback"
|
|
1070
|
-
&&
|
|
1670
|
+
&& refResult.refs.length > 0
|
|
1071
1671
|
) {
|
|
1672
|
+
const dedupe = new Set<string>()
|
|
1673
|
+
const feedbackRefs = feedback === "positive"
|
|
1674
|
+
? refResult.refs
|
|
1675
|
+
: refResult.refs.filter((ref) => !refResult.positiveOnlyRefs.includes(ref))
|
|
1072
1676
|
const note = feedback === "positive"
|
|
1073
1677
|
? `opencode auto: ${input.tool} succeeded`
|
|
1074
1678
|
: `opencode auto: ${input.tool} failed`
|
|
1075
|
-
for (const ref of
|
|
1076
|
-
// Memories
|
|
1077
|
-
|
|
1078
|
-
|
|
1679
|
+
for (const ref of feedbackRefs) {
|
|
1680
|
+
// Memories and vault refs are not first-class feedback targets —
|
|
1681
|
+
// memories do not accept feedback, and vault values never surface in
|
|
1682
|
+
// JSON so automatic usage signals would be misleading.
|
|
1683
|
+
if (ref.startsWith("memory:") || ref.startsWith("vault:")) continue
|
|
1684
|
+
const ok = queueFeedback(logClient, ref, feedback, note, {
|
|
1685
|
+
toolName: input.tool,
|
|
1686
|
+
sessionID: input.sessionID,
|
|
1687
|
+
}, dedupe)
|
|
1079
1688
|
if (!ok) break
|
|
1080
1689
|
}
|
|
1081
1690
|
}
|
|
1082
1691
|
},
|
|
1083
1692
|
tool: {
|
|
1084
1693
|
akm_search: tool({
|
|
1085
|
-
description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, and
|
|
1694
|
+
description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, memories, workflows, vaults, and wikis. Use source='registry' for installable community kits.",
|
|
1086
1695
|
args: {
|
|
1087
1696
|
query: tool.schema.string().describe("Case-insensitive substring search."),
|
|
1088
1697
|
type: tool.schema
|
|
1089
|
-
.enum(
|
|
1698
|
+
.enum(ASSET_TYPES as unknown as [string, ...string[]])
|
|
1090
1699
|
.optional()
|
|
1091
1700
|
.describe("Optional type filter. Defaults to 'any'."),
|
|
1092
1701
|
limit: tool.schema.number().optional().describe("Maximum number of hits to return. Defaults to 20."),
|
|
@@ -1099,41 +1708,6 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1099
1708
|
return runCli(client as unknown as LogCapableClient, createSearchArgs({ query, type, limit, source }), { toolName: "akm_search" })
|
|
1100
1709
|
},
|
|
1101
1710
|
}),
|
|
1102
|
-
akm_registry_search: tool({
|
|
1103
|
-
description: "Search configured akm registries only. Use this when you want installable kits without mixing in local stash results.",
|
|
1104
|
-
args: {
|
|
1105
|
-
query: tool.schema.string().describe("Search query for installable registry kits."),
|
|
1106
|
-
type: tool.schema
|
|
1107
|
-
.enum(["agent", "command", "knowledge", "memory", "script", "skill", "any"])
|
|
1108
|
-
.optional()
|
|
1109
|
-
.describe("Optional asset type filter. Defaults to 'any'."),
|
|
1110
|
-
limit: tool.schema.number().optional().describe("Maximum number of registry hits to return. Defaults to 20."),
|
|
1111
|
-
assets: tool.schema.boolean().optional().describe("Include asset-level results from registry index v2 payloads."),
|
|
1112
|
-
},
|
|
1113
|
-
async execute({ query, type, limit, assets }) {
|
|
1114
|
-
const args = ["registry", "search", query]
|
|
1115
|
-
if (limit) args.push("--limit", String(limit))
|
|
1116
|
-
const assetTypeFilter = type && type !== "any" ? type : undefined
|
|
1117
|
-
if (assets || assetTypeFilter) args.push("--assets")
|
|
1118
|
-
|
|
1119
|
-
const raw = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_registry_search" })
|
|
1120
|
-
if (!assetTypeFilter) return raw
|
|
1121
|
-
|
|
1122
|
-
const parsed = parseCliJson<{
|
|
1123
|
-
hits?: SearchHit[]
|
|
1124
|
-
assetHits?: Array<SearchHit & { assetType?: AssetType }>
|
|
1125
|
-
warnings?: string[]
|
|
1126
|
-
query?: string
|
|
1127
|
-
}>(raw)
|
|
1128
|
-
if (isCliError(parsed)) return JSON.stringify(parsed)
|
|
1129
|
-
|
|
1130
|
-
return JSON.stringify({
|
|
1131
|
-
...parsed,
|
|
1132
|
-
hits: [],
|
|
1133
|
-
assetHits: (parsed.assetHits ?? []).filter((hit) => hit.assetType === assetTypeFilter),
|
|
1134
|
-
})
|
|
1135
|
-
},
|
|
1136
|
-
}),
|
|
1137
1711
|
akm_show: tool({
|
|
1138
1712
|
description: "Show a stash asset by ref. For knowledge assets, use view_mode to retrieve specific content (toc, section, lines, frontmatter).",
|
|
1139
1713
|
args: {
|
|
@@ -1162,75 +1736,6 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1162
1736
|
return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_show" })
|
|
1163
1737
|
},
|
|
1164
1738
|
}),
|
|
1165
|
-
akm_index: tool({
|
|
1166
|
-
description: "Build or rebuild the akm stash index. Scans stash directories, generates missing .stash.json metadata, and builds a semantic search index.",
|
|
1167
|
-
args: {},
|
|
1168
|
-
async execute() {
|
|
1169
|
-
return runCli(client as unknown as LogCapableClient, ["index"], { toolName: "akm_index" })
|
|
1170
|
-
},
|
|
1171
|
-
}),
|
|
1172
|
-
akm_add: tool({
|
|
1173
|
-
description: "Install a kit from npm, GitHub, another git host, or a local directory. Installed kits become searchable alongside local assets.",
|
|
1174
|
-
args: {
|
|
1175
|
-
package_ref: tool.schema.string().describe("Package reference such as npm:@scope/kit, github:<owner>/<repo>, git+https://host/repo, or ./local/kit."),
|
|
1176
|
-
},
|
|
1177
|
-
async execute({ package_ref }) {
|
|
1178
|
-
return runCli(client as unknown as LogCapableClient, ["add", package_ref], { toolName: "akm_add" })
|
|
1179
|
-
},
|
|
1180
|
-
}),
|
|
1181
|
-
akm_list: tool({
|
|
1182
|
-
description: "List all configured AKM sources, including local directories, managed kits, and remote providers.",
|
|
1183
|
-
args: {},
|
|
1184
|
-
async execute() {
|
|
1185
|
-
return runCli(client as unknown as LogCapableClient, ["list"], { toolName: "akm_list" })
|
|
1186
|
-
},
|
|
1187
|
-
}),
|
|
1188
|
-
akm_remove: tool({
|
|
1189
|
-
description: "Remove a configured AKM source by id, ref, path, URL, or name and reindex the stash.",
|
|
1190
|
-
args: {
|
|
1191
|
-
package_ref: tool.schema.string().describe("Source id, ref, path, URL, or name, such as npm:@scope/kit, owner/repo, or ~/.claude/skills."),
|
|
1192
|
-
},
|
|
1193
|
-
async execute({ package_ref }) {
|
|
1194
|
-
return runCli(client as unknown as LogCapableClient, ["remove", package_ref], { toolName: "akm_remove" })
|
|
1195
|
-
},
|
|
1196
|
-
}),
|
|
1197
|
-
akm_update: tool({
|
|
1198
|
-
description: "Update one managed AKM source or all managed sources to the latest available version.",
|
|
1199
|
-
args: {
|
|
1200
|
-
package_ref: tool.schema.string().optional().describe("Managed source id or ref to update."),
|
|
1201
|
-
all: tool.schema.boolean().optional().describe("Update all installed kits."),
|
|
1202
|
-
force: tool.schema.boolean().optional().describe("Force a fresh download even if the version is unchanged."),
|
|
1203
|
-
},
|
|
1204
|
-
async execute({ package_ref, all, force }) {
|
|
1205
|
-
const args = ["update"]
|
|
1206
|
-
const packageRef = package_ref?.trim()
|
|
1207
|
-
if (all) {
|
|
1208
|
-
args.push("--all")
|
|
1209
|
-
} else if (packageRef) {
|
|
1210
|
-
args.push(packageRef)
|
|
1211
|
-
} else {
|
|
1212
|
-
return JSON.stringify({ ok: false, error: "Provide 'package_ref' or set 'all' to true." })
|
|
1213
|
-
}
|
|
1214
|
-
if (force) args.push("--force")
|
|
1215
|
-
return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_update" })
|
|
1216
|
-
},
|
|
1217
|
-
}),
|
|
1218
|
-
akm_clone: tool({
|
|
1219
|
-
description: "Clone an asset from any source into the working stash or a custom destination for editing.",
|
|
1220
|
-
args: {
|
|
1221
|
-
ref: tool.schema.string().describe("Asset ref to clone, including optional origin such as npm:@scope/pkg//script:deploy.sh."),
|
|
1222
|
-
name: tool.schema.string().optional().describe("Optional new asset name."),
|
|
1223
|
-
dest: tool.schema.string().optional().describe("Optional destination directory. The type subdirectory is appended automatically by akm."),
|
|
1224
|
-
force: tool.schema.boolean().optional().describe("Overwrite the destination if it already exists."),
|
|
1225
|
-
},
|
|
1226
|
-
async execute({ ref, name, dest, force }) {
|
|
1227
|
-
const args = ["clone", ref]
|
|
1228
|
-
if (name) args.push("--name", name)
|
|
1229
|
-
if (dest) args.push("--dest", dest)
|
|
1230
|
-
if (force) args.push("--force")
|
|
1231
|
-
return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_clone" })
|
|
1232
|
-
},
|
|
1233
|
-
}),
|
|
1234
1739
|
akm_remember: tool({
|
|
1235
1740
|
description: "Record a memory in the default AKM stash so it can be searched and shown later.",
|
|
1236
1741
|
args: {
|
|
@@ -1282,20 +1787,21 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1282
1787
|
},
|
|
1283
1788
|
}),
|
|
1284
1789
|
akm_evolve: tool({
|
|
1285
|
-
description: "Dispatch the AKM curator agent to review recent session activity and propose stash improvements (promote hot assets, flag cold ones, draft missing coverage).",
|
|
1790
|
+
description: "Dispatch the AKM curator agent to review recent session activity and propose stash improvements (promote hot assets, flag cold ones, draft missing coverage). Persists the report as a memory and seeds the curator-context cache so it survives compaction.",
|
|
1286
1791
|
args: {
|
|
1287
1792
|
focus: tool.schema.string().optional().describe("Optional focus area or theme to weight the review toward."),
|
|
1288
|
-
dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the curator with. Defaults to 'general'."),
|
|
1793
|
+
dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the curator with. Defaults to 'akm-curator', or falls back to 'general' when that agent is unavailable."),
|
|
1289
1794
|
as_subtask: tool.schema.boolean().optional().describe("Run in a child session with parent context. Defaults to true."),
|
|
1290
1795
|
},
|
|
1291
1796
|
async execute({ focus, dispatch_agent, as_subtask }, context) {
|
|
1292
1797
|
const useSubtask = as_subtask ?? true
|
|
1293
|
-
const
|
|
1798
|
+
const requestedAgent = dispatch_agent ?? "akm-curator"
|
|
1799
|
+
const targetAgent = await resolveDispatchAgent(sdkClient, requestedAgent, context.directory)
|
|
1294
1800
|
const targetSession = await ensureTargetSessionID({
|
|
1295
1801
|
useSubtask,
|
|
1296
1802
|
context: { sessionID: context.sessionID, directory: context.directory },
|
|
1297
1803
|
title: "akm:curator",
|
|
1298
|
-
client:
|
|
1804
|
+
client: sdkClient,
|
|
1299
1805
|
logClient,
|
|
1300
1806
|
toolName: "akm_evolve",
|
|
1301
1807
|
})
|
|
@@ -1306,7 +1812,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1306
1812
|
: "Review recent AKM activity and produce the prioritized action list described in the system prompt."
|
|
1307
1813
|
|
|
1308
1814
|
const promptResponse = await promptTargetSession({
|
|
1309
|
-
client:
|
|
1815
|
+
client: sdkClient,
|
|
1310
1816
|
logClient,
|
|
1311
1817
|
toolName: "akm_evolve",
|
|
1312
1818
|
context: { sessionID: context.sessionID, directory: context.directory },
|
|
@@ -1314,20 +1820,71 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1314
1820
|
failureMessage: "Failed to dispatch curator",
|
|
1315
1821
|
promptBody: {
|
|
1316
1822
|
agent: targetAgent,
|
|
1317
|
-
system: CURATOR_AGENT_PROMPT,
|
|
1823
|
+
system: targetAgent === "akm-curator" ? undefined : CURATOR_AGENT_PROMPT,
|
|
1318
1824
|
parts: [{ type: "text", text: task }],
|
|
1319
1825
|
},
|
|
1320
1826
|
})
|
|
1321
1827
|
if (!promptResponse.ok) return JSON.stringify(promptResponse)
|
|
1322
1828
|
|
|
1829
|
+
const fullText = extractText(promptResponse.data.parts)
|
|
1830
|
+
sessionCuratorReport.set(context.sessionID, summarizeCuratorReportForContext(fullText))
|
|
1831
|
+
markContextEpochDirty(context.sessionID)
|
|
1832
|
+
const dateTag = buildDateTag()
|
|
1833
|
+
const shortSid = context.sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
|
|
1834
|
+
const curatorMemoryRef = fullText
|
|
1835
|
+
? rememberTextAsMemory(`akm-curator-${dateTag}-${shortSid}`, fullText)
|
|
1836
|
+
: null
|
|
1837
|
+
|
|
1323
1838
|
return JSON.stringify({
|
|
1324
1839
|
ok: true,
|
|
1325
1840
|
dispatchAgent: targetAgent,
|
|
1326
1841
|
usedSubtask: useSubtask,
|
|
1327
1842
|
sessionID: targetSession.sessionID,
|
|
1328
1843
|
focus: focus ?? null,
|
|
1329
|
-
|
|
1844
|
+
curatorMemoryRef,
|
|
1845
|
+
text: fullText,
|
|
1846
|
+
})
|
|
1847
|
+
},
|
|
1848
|
+
}),
|
|
1849
|
+
akm_parent_messages: tool({
|
|
1850
|
+
description: "Read compact text summaries of the parent session's messages so a dispatched AKM subagent can inherit upstream context.",
|
|
1851
|
+
args: {},
|
|
1852
|
+
async execute(_input, context) {
|
|
1853
|
+
const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
|
|
1854
|
+
if (!parent.ok) return JSON.stringify(parent)
|
|
1855
|
+
const messages = await sdkClient.session.messages({
|
|
1856
|
+
path: { id: parent.parentID },
|
|
1857
|
+
query: { directory: context.directory },
|
|
1858
|
+
})
|
|
1859
|
+
if (messages.error || !messages.data) {
|
|
1860
|
+
return JSON.stringify({ ok: false, error: "Failed to read parent session messages." })
|
|
1861
|
+
}
|
|
1862
|
+
return JSON.stringify(summarizeSessionMessages(parent.parentID, messages.data))
|
|
1863
|
+
},
|
|
1864
|
+
}),
|
|
1865
|
+
akm_session_messages: tool({
|
|
1866
|
+
description: "Read compact text summaries for a specific OpenCode session. Arbitrary session IDs are restricted to the akm-curator agent; other agents may read only their current or parent session.",
|
|
1867
|
+
args: {
|
|
1868
|
+
session_id: tool.schema.string().describe("OpenCode session ID to inspect."),
|
|
1869
|
+
},
|
|
1870
|
+
async execute({ session_id }, context) {
|
|
1871
|
+
const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
|
|
1872
|
+
const allowedSessionIDs = new Set<string>([context.sessionID])
|
|
1873
|
+
if (parent.ok) allowedSessionIDs.add(parent.parentID)
|
|
1874
|
+
if (context.agent !== "akm-curator" && !allowedSessionIDs.has(session_id)) {
|
|
1875
|
+
return JSON.stringify({
|
|
1876
|
+
ok: false,
|
|
1877
|
+
error: "akm_session_messages only allows arbitrary session IDs for the akm-curator agent. Use akm_parent_messages for parent context.",
|
|
1878
|
+
})
|
|
1879
|
+
}
|
|
1880
|
+
const messages = await sdkClient.session.messages({
|
|
1881
|
+
path: { id: session_id },
|
|
1882
|
+
query: { directory: context.directory },
|
|
1330
1883
|
})
|
|
1884
|
+
if (messages.error || !messages.data) {
|
|
1885
|
+
return JSON.stringify({ ok: false, error: `Failed to read messages for session '${session_id}'.` })
|
|
1886
|
+
}
|
|
1887
|
+
return JSON.stringify(summarizeSessionMessages(session_id, messages.data))
|
|
1331
1888
|
},
|
|
1332
1889
|
}),
|
|
1333
1890
|
akm_agent: tool({
|
|
@@ -1345,7 +1902,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1345
1902
|
directory: context.directory,
|
|
1346
1903
|
sessionID: context.sessionID,
|
|
1347
1904
|
}
|
|
1348
|
-
const resolved = await
|
|
1905
|
+
const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "agent", logMeta)
|
|
1349
1906
|
if (!resolved.ok) return JSON.stringify(resolved)
|
|
1350
1907
|
|
|
1351
1908
|
const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
|
|
@@ -1431,7 +1988,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1431
1988
|
directory: context.directory,
|
|
1432
1989
|
sessionID: context.sessionID,
|
|
1433
1990
|
}
|
|
1434
|
-
const resolved = await
|
|
1991
|
+
const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "command", logMeta)
|
|
1435
1992
|
if (!resolved.ok) return JSON.stringify(resolved)
|
|
1436
1993
|
|
|
1437
1994
|
const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
|
|
@@ -1489,95 +2046,315 @@ export const AkmPlugin: Plugin = async ({ client }) => {
|
|
|
1489
2046
|
})
|
|
1490
2047
|
},
|
|
1491
2048
|
}),
|
|
1492
|
-
|
|
1493
|
-
description: "
|
|
2049
|
+
akm_vault: tool({
|
|
2050
|
+
description: "Manage encrypted-at-rest vaults of KEY=VALUE pairs. Values never surface in any output channel — 'show'/'list' return key names only, 'set'/'unset' never echo the value. Use 'load' to get a shell-eval snippet that loads values into the process. action='show' and action='unset' require confirm:true.",
|
|
1494
2051
|
args: {
|
|
1495
|
-
action: tool.schema.enum(["
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
2052
|
+
action: tool.schema.enum(["list", "show", "create", "set", "unset", "load"]).describe("Vault subcommand. 'load' wraps `akm vault load` — treat its output as opaque shell text meant for eval."),
|
|
2053
|
+
ref: tool.schema.string().optional().describe("Vault ref such as vault:prod or vault:team/prod. Required for show/set/unset/load; optional for list."),
|
|
2054
|
+
name: tool.schema.string().optional().describe("Vault name when action is 'create' (e.g. 'prod' → vaults/prod.env)."),
|
|
2055
|
+
key: tool.schema.string().optional().describe("Variable name for set/unset. May include '=' to pass KEY=VALUE in one field when value is omitted."),
|
|
2056
|
+
value: tool.schema.string().optional().describe("Value to store. Never echoed back."),
|
|
2057
|
+
comment: tool.schema.string().optional().describe("Optional inline '# comment' written above the key for 'set'."),
|
|
2058
|
+
confirm: tool.schema.boolean().optional().describe("Must be true for sensitive actions like show and unset."),
|
|
1499
2059
|
},
|
|
1500
|
-
async execute(
|
|
1501
|
-
const
|
|
1502
|
-
if (
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
2060
|
+
async execute(input) {
|
|
2061
|
+
const blocked = blockedToolResponse(input as Record<string, unknown>)
|
|
2062
|
+
if (blocked) return blocked
|
|
2063
|
+
const { action, ref, name, key, value, comment } = input
|
|
2064
|
+
const logMeta = { toolName: "akm_vault" }
|
|
2065
|
+
switch (action) {
|
|
2066
|
+
case "list": {
|
|
2067
|
+
const args = ["vault", "list"]
|
|
2068
|
+
if (ref) args.push(ref)
|
|
2069
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2070
|
+
}
|
|
2071
|
+
case "show": {
|
|
2072
|
+
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='show'." })
|
|
2073
|
+
return runCli(client as unknown as LogCapableClient, ["vault", "show", ref], logMeta)
|
|
2074
|
+
}
|
|
2075
|
+
case "create": {
|
|
2076
|
+
if (!name) return JSON.stringify({ ok: false, error: "'name' is required for action='create'." })
|
|
2077
|
+
return runCli(client as unknown as LogCapableClient, ["vault", "create", name], logMeta)
|
|
2078
|
+
}
|
|
2079
|
+
case "set": {
|
|
2080
|
+
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='set'." })
|
|
2081
|
+
if (!key) return JSON.stringify({ ok: false, error: "'key' is required for action='set'." })
|
|
2082
|
+
const args = ["vault", "set", ref, key]
|
|
2083
|
+
if (value != null) args.push(value)
|
|
2084
|
+
if (comment) args.push("--comment", comment)
|
|
2085
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2086
|
+
}
|
|
2087
|
+
case "unset": {
|
|
2088
|
+
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='unset'." })
|
|
2089
|
+
if (!key) return JSON.stringify({ ok: false, error: "'key' is required for action='unset'." })
|
|
2090
|
+
return runCli(client as unknown as LogCapableClient, ["vault", "unset", ref, key], logMeta)
|
|
2091
|
+
}
|
|
2092
|
+
case "load": {
|
|
2093
|
+
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='load'." })
|
|
2094
|
+
// `vault load` emits raw shell — not JSON. Return the snippet verbatim
|
|
2095
|
+
// so the caller can hand it to a shell via eval. Never parse values.
|
|
2096
|
+
const command = resolveAkmCommand()
|
|
2097
|
+
if (typeof command !== "string") return JSON.stringify(command)
|
|
2098
|
+
try {
|
|
2099
|
+
const stdout = execFileSync(command, ["vault", "load", ref], {
|
|
2100
|
+
encoding: "utf8",
|
|
2101
|
+
timeout: 30_000,
|
|
2102
|
+
})
|
|
2103
|
+
return JSON.stringify({ ok: true, ref, shell: stdout.trim() })
|
|
2104
|
+
} catch (error: unknown) {
|
|
2105
|
+
return JSON.stringify({ ok: false, error: formatCliError(error) })
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
1506
2109
|
},
|
|
1507
2110
|
}),
|
|
1508
|
-
|
|
1509
|
-
description: "
|
|
2111
|
+
akm_wiki: tool({
|
|
2112
|
+
description: "Manage AKM wikis — multi-wiki knowledge bases under <stashDir>/wikis/<name>/. Supports scaffolding, registering external sources, listing pages, scoped search, stashing raw sources, lint, and ingest workflow.",
|
|
1510
2113
|
args: {
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
2114
|
+
action: tool.schema.enum([
|
|
2115
|
+
"create",
|
|
2116
|
+
"register",
|
|
2117
|
+
"list",
|
|
2118
|
+
"show",
|
|
2119
|
+
"remove",
|
|
2120
|
+
"pages",
|
|
2121
|
+
"search",
|
|
2122
|
+
"stash",
|
|
2123
|
+
"lint",
|
|
2124
|
+
"ingest",
|
|
2125
|
+
]).describe("Wiki subcommand."),
|
|
2126
|
+
name: tool.schema.string().optional().describe("Wiki name (required for every action except 'list')."),
|
|
2127
|
+
source_ref: tool.schema.string().optional().describe("Source ref to register (required for action='register'). Accepts directory paths, git URLs, github owner/repo, or https:// website roots."),
|
|
2128
|
+
writable: tool.schema.boolean().optional().describe("When registering a git-backed source, mark it as push-writable (used by `akm save`; see akm_help topic='save')."),
|
|
2129
|
+
trust: tool.schema.boolean().optional().describe("Bypass install-audit blocking for this registration only."),
|
|
2130
|
+
max_pages: tool.schema.number().optional().describe("Crawler page cap when registering a website (default 50)."),
|
|
2131
|
+
max_depth: tool.schema.number().optional().describe("Crawler depth cap when registering a website (default 3)."),
|
|
2132
|
+
query: tool.schema.string().optional().describe("Query string for action='search'."),
|
|
2133
|
+
limit: tool.schema.number().optional().describe("Result cap for action='search'."),
|
|
2134
|
+
source: tool.schema.string().optional().describe("Source path (or '-' for stdin) for action='stash'."),
|
|
2135
|
+
as_slug: tool.schema.string().optional().describe("Explicit slug for action='stash' (defaults to derived from source)."),
|
|
2136
|
+
content: tool.schema.string().optional().describe("Raw content to feed stdin when stashing with source='-'."),
|
|
2137
|
+
force: tool.schema.boolean().optional().describe("Required for action='remove'."),
|
|
2138
|
+
with_sources: tool.schema.boolean().optional().describe("When removing, also delete the raw/ sources (default false)."),
|
|
1514
2139
|
},
|
|
1515
|
-
async execute({
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
const shown = parseCliJson<ShowToolResponse | { type: string }>(shownRaw)
|
|
1521
|
-
if (isCliError(shown)) return JSON.stringify(shown)
|
|
1522
|
-
|
|
1523
|
-
if (!isShowToolResponse(shown)) {
|
|
1524
|
-
return JSON.stringify({
|
|
1525
|
-
ok: false,
|
|
1526
|
-
error: `Ref ${resolved.ref} is not a script payload from akm_show.`,
|
|
1527
|
-
})
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
if (!shown.run || !shown.run.trim()) {
|
|
1531
|
-
return JSON.stringify({
|
|
1532
|
-
ok: false,
|
|
1533
|
-
error: `Script ${shown.name} is missing run command.`,
|
|
1534
|
-
})
|
|
2140
|
+
async execute({ action, name, source_ref, writable, trust, max_pages, max_depth, query, limit, source, as_slug, content, force, with_sources }) {
|
|
2141
|
+
const logMeta = { toolName: "akm_wiki" }
|
|
2142
|
+
const requireName = () => {
|
|
2143
|
+
if (!name) return JSON.stringify({ ok: false, error: `'name' is required for action='${action}'.` })
|
|
2144
|
+
return null
|
|
1535
2145
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
error
|
|
1559
|
-
|
|
2146
|
+
switch (action) {
|
|
2147
|
+
case "list":
|
|
2148
|
+
return runCli(client as unknown as LogCapableClient, ["wiki", "list"], logMeta)
|
|
2149
|
+
case "create": {
|
|
2150
|
+
const err = requireName(); if (err) return err
|
|
2151
|
+
return runCli(client as unknown as LogCapableClient, ["wiki", "create", name!], logMeta)
|
|
2152
|
+
}
|
|
2153
|
+
case "show": {
|
|
2154
|
+
const err = requireName(); if (err) return err
|
|
2155
|
+
return runCli(client as unknown as LogCapableClient, ["wiki", "show", name!], logMeta)
|
|
2156
|
+
}
|
|
2157
|
+
case "pages": {
|
|
2158
|
+
const err = requireName(); if (err) return err
|
|
2159
|
+
return runCli(client as unknown as LogCapableClient, ["wiki", "pages", name!], logMeta)
|
|
2160
|
+
}
|
|
2161
|
+
case "ingest": {
|
|
2162
|
+
const err = requireName(); if (err) return err
|
|
2163
|
+
return runCli(client as unknown as LogCapableClient, ["wiki", "ingest", name!], logMeta)
|
|
2164
|
+
}
|
|
2165
|
+
case "lint": {
|
|
2166
|
+
const err = requireName(); if (err) return err
|
|
2167
|
+
// `wiki lint` exits 1 when findings exist, which runCli surfaces as
|
|
2168
|
+
// an error envelope. That is still useful output — the JSON body is
|
|
2169
|
+
// the lint report. Pass through either way.
|
|
2170
|
+
return runCli(client as unknown as LogCapableClient, ["wiki", "lint", name!], logMeta)
|
|
2171
|
+
}
|
|
2172
|
+
case "register": {
|
|
2173
|
+
const err = requireName(); if (err) return err
|
|
2174
|
+
if (!source_ref) return JSON.stringify({ ok: false, error: "'source_ref' is required for action='register'." })
|
|
2175
|
+
const args = ["wiki", "register", name!, source_ref]
|
|
2176
|
+
if (writable) args.push("--writable")
|
|
2177
|
+
if (trust) args.push("--trust")
|
|
2178
|
+
if (max_pages != null) args.push("--max-pages", String(max_pages))
|
|
2179
|
+
if (max_depth != null) args.push("--max-depth", String(max_depth))
|
|
2180
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2181
|
+
}
|
|
2182
|
+
case "remove": {
|
|
2183
|
+
const err = requireName(); if (err) return err
|
|
2184
|
+
if (!force) return JSON.stringify({ ok: false, error: "'force' must be true to remove a wiki." })
|
|
2185
|
+
const args = ["wiki", "remove", name!, "--force"]
|
|
2186
|
+
if (with_sources) args.push("--with-sources")
|
|
2187
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2188
|
+
}
|
|
2189
|
+
case "search": {
|
|
2190
|
+
const err = requireName(); if (err) return err
|
|
2191
|
+
if (!query) return JSON.stringify({ ok: false, error: "'query' is required for action='search'." })
|
|
2192
|
+
const args = ["wiki", "search", name!, query]
|
|
2193
|
+
if (limit != null) args.push("--limit", String(limit))
|
|
2194
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2195
|
+
}
|
|
2196
|
+
case "stash": {
|
|
2197
|
+
const err = requireName(); if (err) return err
|
|
2198
|
+
if (!source) return JSON.stringify({ ok: false, error: "'source' is required for action='stash'." })
|
|
2199
|
+
const args = ["wiki", "stash", name!, source]
|
|
2200
|
+
if (as_slug) args.push("--as", as_slug)
|
|
2201
|
+
if (source === "-" && content) {
|
|
2202
|
+
const command = resolveAkmCommand()
|
|
2203
|
+
if (typeof command !== "string") return JSON.stringify(command)
|
|
2204
|
+
try {
|
|
2205
|
+
const stdout = execFileSync(command, [...args, "--format", "json"], {
|
|
2206
|
+
encoding: "utf8",
|
|
2207
|
+
timeout: 60_000,
|
|
2208
|
+
input: content,
|
|
2209
|
+
})
|
|
2210
|
+
return stdout
|
|
2211
|
+
} catch (error: unknown) {
|
|
2212
|
+
return JSON.stringify({ ok: false, error: formatCliError(error) })
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2216
|
+
}
|
|
1560
2217
|
}
|
|
1561
2218
|
},
|
|
1562
2219
|
}),
|
|
1563
|
-
|
|
1564
|
-
description: "
|
|
1565
|
-
args: {
|
|
1566
|
-
|
|
1567
|
-
|
|
2220
|
+
akm_workflow: tool({
|
|
2221
|
+
description: "Manage AKM workflow runs — stateful multi-step procedures defined as workflow:<name> assets. Use start/next/complete/resume to drive a run, status/list to inspect, create/template to author.",
|
|
2222
|
+
args: {
|
|
2223
|
+
action: tool.schema.enum([
|
|
2224
|
+
"start",
|
|
2225
|
+
"next",
|
|
2226
|
+
"complete",
|
|
2227
|
+
"status",
|
|
2228
|
+
"list",
|
|
2229
|
+
"create",
|
|
2230
|
+
"template",
|
|
2231
|
+
"resume",
|
|
2232
|
+
]).describe("Workflow subcommand."),
|
|
2233
|
+
ref: tool.schema.string().optional().describe("Workflow ref (e.g. workflow:release). Required for start; accepted by next/status as a target."),
|
|
2234
|
+
target: tool.schema.string().optional().describe("Run id or workflow ref for next/status. When a workflow ref is passed to 'next', a new run is auto-started."),
|
|
2235
|
+
run_id: tool.schema.string().optional().describe("Workflow run id. Required for complete and resume."),
|
|
2236
|
+
params: tool.schema.string().optional().describe("JSON object string of parameters for start/next."),
|
|
2237
|
+
step: tool.schema.string().optional().describe("Step id to transition (required for action='complete')."),
|
|
2238
|
+
state: tool.schema.enum(["completed", "blocked", "failed", "skipped"]).optional().describe("Step state for 'complete'. Defaults to 'completed'."),
|
|
2239
|
+
notes: tool.schema.string().optional().describe("Freeform notes attached to the step transition."),
|
|
2240
|
+
evidence: tool.schema.string().optional().describe("JSON object string of evidence attached to the step transition."),
|
|
2241
|
+
name: tool.schema.string().optional().describe("Workflow name for action='create'."),
|
|
2242
|
+
from: tool.schema.string().optional().describe("Path to a markdown template for action='create'."),
|
|
2243
|
+
force: tool.schema.boolean().optional().describe("Overwrite an existing workflow on create (requires --from or --reset)."),
|
|
2244
|
+
reset: tool.schema.boolean().optional().describe("Reset to the built-in template for action='create'."),
|
|
2245
|
+
filter_ref: tool.schema.string().optional().describe("Restrict action='list' to runs of this workflow ref."),
|
|
2246
|
+
active_only: tool.schema.boolean().optional().describe("Restrict action='list' to active (non-terminal) runs."),
|
|
2247
|
+
},
|
|
2248
|
+
async execute({
|
|
2249
|
+
action,
|
|
2250
|
+
ref,
|
|
2251
|
+
target,
|
|
2252
|
+
run_id,
|
|
2253
|
+
params,
|
|
2254
|
+
step,
|
|
2255
|
+
state,
|
|
2256
|
+
notes,
|
|
2257
|
+
evidence,
|
|
2258
|
+
name,
|
|
2259
|
+
from,
|
|
2260
|
+
force,
|
|
2261
|
+
reset,
|
|
2262
|
+
filter_ref,
|
|
2263
|
+
active_only,
|
|
2264
|
+
}) {
|
|
2265
|
+
const logMeta = { toolName: "akm_workflow" }
|
|
2266
|
+
switch (action) {
|
|
2267
|
+
case "start": {
|
|
2268
|
+
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='start'." })
|
|
2269
|
+
const args = ["workflow", "start", ref]
|
|
2270
|
+
if (params) args.push("--params", params)
|
|
2271
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2272
|
+
}
|
|
2273
|
+
case "next": {
|
|
2274
|
+
const picked = target ?? run_id ?? ref
|
|
2275
|
+
if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='next'." })
|
|
2276
|
+
const args = ["workflow", "next", picked]
|
|
2277
|
+
if (params) args.push("--params", params)
|
|
2278
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2279
|
+
}
|
|
2280
|
+
case "complete": {
|
|
2281
|
+
if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='complete'." })
|
|
2282
|
+
if (!step) return JSON.stringify({ ok: false, error: "'step' is required for action='complete'." })
|
|
2283
|
+
const args = ["workflow", "complete", run_id, "--step", step]
|
|
2284
|
+
if (state) args.push("--state", state)
|
|
2285
|
+
if (notes) args.push("--notes", notes)
|
|
2286
|
+
if (evidence) args.push("--evidence", evidence)
|
|
2287
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2288
|
+
}
|
|
2289
|
+
case "status": {
|
|
2290
|
+
const picked = target ?? run_id ?? ref
|
|
2291
|
+
if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='status'." })
|
|
2292
|
+
return runCli(client as unknown as LogCapableClient, ["workflow", "status", picked], logMeta)
|
|
2293
|
+
}
|
|
2294
|
+
case "list": {
|
|
2295
|
+
const args = ["workflow", "list"]
|
|
2296
|
+
if (filter_ref) args.push("--ref", filter_ref)
|
|
2297
|
+
if (active_only) args.push("--active")
|
|
2298
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2299
|
+
}
|
|
2300
|
+
case "create": {
|
|
2301
|
+
if (!name) return JSON.stringify({ ok: false, error: "'name' is required for action='create'." })
|
|
2302
|
+
const args = ["workflow", "create", name]
|
|
2303
|
+
if (from) args.push("--from", from)
|
|
2304
|
+
if (force) args.push("--force")
|
|
2305
|
+
if (reset) args.push("--reset")
|
|
2306
|
+
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
2307
|
+
}
|
|
2308
|
+
case "template": {
|
|
2309
|
+
// The workflow template is emitted as raw markdown, not JSON.
|
|
2310
|
+
const command = resolveAkmCommand()
|
|
2311
|
+
if (typeof command !== "string") return JSON.stringify(command)
|
|
2312
|
+
try {
|
|
2313
|
+
const stdout = execFileSync(command, ["workflow", "template"], {
|
|
2314
|
+
encoding: "utf8",
|
|
2315
|
+
timeout: 30_000,
|
|
2316
|
+
})
|
|
2317
|
+
return JSON.stringify({ ok: true, template: stdout })
|
|
2318
|
+
} catch (error: unknown) {
|
|
2319
|
+
return JSON.stringify({ ok: false, error: formatCliError(error) })
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
case "resume": {
|
|
2323
|
+
if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='resume'." })
|
|
2324
|
+
return runCli(client as unknown as LogCapableClient, ["workflow", "resume", run_id], logMeta)
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
1568
2327
|
},
|
|
1569
2328
|
}),
|
|
1570
|
-
|
|
1571
|
-
description: "
|
|
2329
|
+
akm_help: tool({
|
|
2330
|
+
description: "Discover the right `akm` CLI command and args for tasks not covered by a first-class tool — e.g. save/push, import, clone, update, remove, list sources, registry search, reindex, config, CLI upgrade, run script. Returns a curated quick-reference plus live `akm --help` output. Pass `command` to drill into a specific subcommand.",
|
|
1572
2331
|
args: {
|
|
1573
|
-
|
|
1574
|
-
|
|
2332
|
+
topic: tool.schema.string().optional().describe("Natural-language description of the task (e.g. 'commit and push my stash', 'install a kit from github'). Returns curated hints if any keywords match."),
|
|
2333
|
+
command: tool.schema.string().optional().describe("Specific akm subcommand to inspect (e.g. 'save', 'clone', 'config'). Runs `akm <command> --help` and returns the output verbatim."),
|
|
1575
2334
|
},
|
|
1576
|
-
async execute({
|
|
1577
|
-
const
|
|
1578
|
-
if (
|
|
1579
|
-
|
|
1580
|
-
|
|
2335
|
+
async execute({ topic, command }) {
|
|
2336
|
+
const cliCommand = resolveAkmCommand()
|
|
2337
|
+
if (typeof cliCommand !== "string") return JSON.stringify(cliCommand)
|
|
2338
|
+
const helpArgs = command && command.trim()
|
|
2339
|
+
? [command.trim(), "--help"]
|
|
2340
|
+
: ["--help"]
|
|
2341
|
+
let helpText = ""
|
|
2342
|
+
try {
|
|
2343
|
+
helpText = execFileSync(cliCommand, helpArgs, {
|
|
2344
|
+
encoding: "utf8",
|
|
2345
|
+
timeout: 30_000,
|
|
2346
|
+
}).toString().trim()
|
|
2347
|
+
} catch (error: unknown) {
|
|
2348
|
+
return JSON.stringify({ ok: false, error: formatCliError(error) })
|
|
2349
|
+
}
|
|
2350
|
+
return JSON.stringify({
|
|
2351
|
+
ok: true,
|
|
2352
|
+
command: command ?? null,
|
|
2353
|
+
topic: topic ?? null,
|
|
2354
|
+
hints: topic ? lookupAkmHelpHint(topic) : [],
|
|
2355
|
+
quickReference: AKM_HELP_QUICK_REFERENCE,
|
|
2356
|
+
help: helpText,
|
|
2357
|
+
})
|
|
1581
2358
|
},
|
|
1582
2359
|
}),
|
|
1583
2360
|
},
|