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