akm-opencode 0.8.0-rc1 → 0.8.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 +90 -30
- package/agent/akm-curator.md +4 -4
- package/commands/akm-improve-asset.md +9 -0
- package/commands/akm-propose-asset.md +1 -1
- package/commands/akm-review-proposals.md +2 -1
- package/index.ts +1068 -532
- package/package.json +10 -3
- package/shared/feedback-signals.ts +74 -0
- package/shared/memory-candidates.ts +196 -0
- package/shared/memory-events.ts +122 -0
- package/shared/recall-policy.ts +70 -0
- package/shared/redaction.ts +292 -0
- package/shared/ref-extraction.ts +261 -0
- package/commands/akm-distill-lesson.md +0 -7
- package/commands/akm-reflect-on-failure.md +0 -7
package/index.ts
CHANGED
|
@@ -1,18 +1,50 @@
|
|
|
1
1
|
import { type Plugin, tool } from "@opencode-ai/plugin"
|
|
2
2
|
import { execFileSync, execSync, spawn } from "node:child_process"
|
|
3
|
-
import { readFileSync } from "node:fs"
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
4
|
+
import os from "node:os"
|
|
4
5
|
import path from "node:path"
|
|
5
6
|
import { fileURLToPath } from "node:url"
|
|
6
|
-
import { classifyFeedbackSignal, shouldSubmitAutomaticFeedback } from "
|
|
7
|
-
import { appendCandidates, extractCandidatesFromText, getCandidateLogPath, readCandidates, updateCandidateStatus } from "
|
|
8
|
-
import { appendMemoryEvent, getEventLogPath, readJsonl, type AkmMemoryEvent } from "
|
|
9
|
-
import { shouldRecall } from "
|
|
10
|
-
import { redactObject, redactSecrets } from "
|
|
7
|
+
import { classifyFeedbackSignal, shouldSubmitAutomaticFeedback } from "./shared/feedback-signals"
|
|
8
|
+
import { appendCandidates, extractCandidatesFromText, getCandidateLogPath, readCandidates, updateCandidateStatus } from "./shared/memory-candidates"
|
|
9
|
+
import { appendMemoryEvent, getEventLogPath, readJsonl, type AkmMemoryEvent } from "./shared/memory-events"
|
|
10
|
+
import { shouldRecall } from "./shared/recall-policy"
|
|
11
|
+
import { redactObject, redactSecrets } from "./shared/redaction"
|
|
12
|
+
import { extractAkmRefsFromString } from "./shared/ref-extraction"
|
|
13
|
+
|
|
14
|
+
// Quote-aware shell tokenizer. Splits on whitespace but respects single,
|
|
15
|
+
// double, and backtick quotes. Used by renderCommandTemplate() to fill
|
|
16
|
+
// `$1`/`$2` positional placeholders from raw `$ARGUMENTS` strings. Was
|
|
17
|
+
// previously co-located with the risky-command assessor in
|
|
18
|
+
// shared/risky-command.ts; inlined here when that gate was removed.
|
|
19
|
+
function splitArguments(raw: string): string[] {
|
|
20
|
+
if (!raw.trim()) return []
|
|
21
|
+
const args: string[] = []
|
|
22
|
+
const re = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g
|
|
23
|
+
let match: RegExpExecArray | null
|
|
24
|
+
while ((match = re.exec(raw)) !== null) {
|
|
25
|
+
args.push(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "")
|
|
26
|
+
}
|
|
27
|
+
return args
|
|
28
|
+
}
|
|
11
29
|
|
|
12
30
|
let resolvedAkmCommand = "akm"
|
|
13
|
-
|
|
31
|
+
|
|
32
|
+
// Test-only: reset the module-level resolved-CLI cache so each test resolves
|
|
33
|
+
// the akm command fresh under its own sandboxed env (HOME / AKM_OPENCODE_*).
|
|
34
|
+
// Without this, the first test to resolve pins `resolvedAkmCommand` for the
|
|
35
|
+
// rest of the process (resolveAkmCommand short-circuits on a still-valid
|
|
36
|
+
// cached command), making later resolution tests order-dependent.
|
|
37
|
+
export function __resetResolvedAkmForTests(): void {
|
|
38
|
+
resolvedAkmCommand = "akm"
|
|
39
|
+
}
|
|
40
|
+
|
|
14
41
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url))
|
|
15
42
|
const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/
|
|
43
|
+
// Note: satisfiesAkmVersionRange() implements a custom matcher that accepts
|
|
44
|
+
// any 0.8.x release including prereleases (0.8.0-rc.5, 0.8.0-beta.2, etc.).
|
|
45
|
+
// Strict semver `^0.8.0` would NOT match prereleases, so the constant string
|
|
46
|
+
// is widened to honestly reflect what the matcher accepts.
|
|
47
|
+
const AKM_REQUIRED_VERSION_RANGE = "^0.8.0 || ^0.8.0-rc0"
|
|
16
48
|
|
|
17
49
|
const AKM_AUTO_FEEDBACK = (process.env.AKM_AUTO_FEEDBACK ?? "1") !== "0"
|
|
18
50
|
const AKM_AUTO_MEMORY = (process.env.AKM_AUTO_MEMORY ?? "1") !== "0"
|
|
@@ -42,10 +74,10 @@ const sessionWorkflow = new Map<string, string>()
|
|
|
42
74
|
const sessionCuratorReport = new Map<string, string>()
|
|
43
75
|
const sessionContextEpoch = new Map<string, number>()
|
|
44
76
|
const sessionContextInjectedEpoch = new Map<string, number>()
|
|
77
|
+
const sessionCuratedFile = new Map<string, string>()
|
|
45
78
|
const sessionCuratedVersion = new Map<string, number>()
|
|
46
79
|
const sessionCuratedInjectedVersion = new Map<string, number>()
|
|
47
80
|
const sessionRecallAudit = new Map<string, { shouldRecall: boolean; reason: string; query: string; injectedRefs: string[]; injectedChars: number; warnings: string[] }>()
|
|
48
|
-
type ParsedSemver = { core: [number, number, number]; prerelease: Array<string | number> | null }
|
|
49
81
|
type SessionBufferEntry = {
|
|
50
82
|
timestamp: string
|
|
51
83
|
kind: "memory-intent" | "tool-ref"
|
|
@@ -66,22 +98,22 @@ let agentSetupPromise: Promise<boolean> | null = null
|
|
|
66
98
|
// Asset-ref grammar matching the stash skill: [origin//]type:name.
|
|
67
99
|
// We validate normalized tokens individually instead of running a global regex
|
|
68
100
|
// over arbitrary tool output to keep extraction predictable and ReDoS-safe.
|
|
69
|
-
const AKM_REF_PATTERN = /^(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|
|
|
101
|
+
const AKM_REF_PATTERN = /^(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|task|env|secret|wiki|lesson):[A-Za-z0-9._/\-]+$/
|
|
70
102
|
const PROPOSED_QUALITY_WARNING = "Do not treat proposed assets as curated until accepted."
|
|
71
103
|
const AKM_WORKFLOW_INSTRUCTION = [
|
|
72
|
-
"# AKM workflow (v0.
|
|
104
|
+
"# AKM workflow (v0.8.0)",
|
|
73
105
|
"",
|
|
74
106
|
"Use AKM as a reusable knowledge and workflow stash.",
|
|
75
107
|
"",
|
|
76
108
|
"Before writing from scratch:",
|
|
77
|
-
"1. Use `akm_search`
|
|
109
|
+
"1. Use `akm_curate` with a query that includes the current project name/domain (primary discovery). Fall back to `akm_search` only when you already know an asset exists and need its exact ref.",
|
|
78
110
|
"2. Use `akm_show <ref>` before relying on an asset.",
|
|
79
111
|
"3. Record `akm_feedback` after the result is known.",
|
|
80
|
-
"4. Use the dedicated v0.
|
|
81
|
-
"5. Treat `lesson:*` as first-class durable learning assets — they are produced
|
|
112
|
+
"4. Use the dedicated v0.8.0 tools for the proposal flow: `akm_proposal` (list/show/diff/accept/reject/drain), `akm_improve`, and `akm_propose`. Fall back to `akm_help` for any verb without a dedicated tool.",
|
|
113
|
+
"5. Treat `lesson:*` as first-class durable learning assets — they are produced through `akm_improve` and accepted via `akm_proposal action=accept`.",
|
|
82
114
|
"6. Use `akm_init` when you need to create the working stash or persist `stashDir`. Do not use `akm setup` from an agent; it is interactive and human-facing.",
|
|
83
115
|
`7. ${PROPOSED_QUALITY_WARNING}`,
|
|
84
|
-
"8. Never accept or reject proposals, push saves, remove sources, or access
|
|
116
|
+
"8. Never accept or reject proposals, push saves, remove sources, or access env values / secret material without explicit user approval.",
|
|
85
117
|
].join("\n")
|
|
86
118
|
|
|
87
119
|
function readPackageVersion(): string {
|
|
@@ -127,14 +159,14 @@ Inputs you should inspect:
|
|
|
127
159
|
Signals to act on:
|
|
128
160
|
- Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
|
|
129
161
|
- Cold refs: assets tied to failures or user complaints. Record akm_feedback <ref> negative --note "<excerpt>" and open the asset for review.
|
|
130
|
-
- Lesson candidates: repeated memories or failures that should become a proposed lesson. Use akm_help topic="
|
|
162
|
+
- Lesson candidates: repeated memories or failures that should become a proposed lesson. Use akm_improve or akm_help topic="improve" before raw CLI improvement commands.
|
|
131
163
|
- 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").
|
|
132
164
|
- Pending proposals: list or diff them via akm_help topic="proposal" and recommend accept, reject, or revise. Never accept or reject without explicit user approval.
|
|
133
165
|
- Duplicates / drift: near-identical descriptions or overlapping responsibilities. Propose a consolidation.
|
|
134
166
|
- 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.
|
|
135
167
|
- 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.
|
|
136
168
|
- 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.
|
|
137
|
-
- Never touch
|
|
169
|
+
- Never touch env or secret values: do not call akm_env run or akm_secret path unless the user explicitly asks. Env values and secret material must never appear in reports.
|
|
138
170
|
|
|
139
171
|
Rules of engagement:
|
|
140
172
|
- Never apply destructive changes without explicit user approval.
|
|
@@ -152,7 +184,7 @@ Output shape: end every run with a markdown report that has these sections:
|
|
|
152
184
|
- <ref> — failure signal — proposed fix
|
|
153
185
|
|
|
154
186
|
## Lesson candidates
|
|
155
|
-
- <theme> — evidence refs —
|
|
187
|
+
- <theme> — evidence refs — improve or propose command to run
|
|
156
188
|
|
|
157
189
|
## Coverage gaps
|
|
158
190
|
- <theme> — proposed asset (type, name, one-line description)
|
|
@@ -210,6 +242,9 @@ type CliLogMeta = {
|
|
|
210
242
|
toolName: string
|
|
211
243
|
directory?: string
|
|
212
244
|
sessionID?: string
|
|
245
|
+
agent?: string
|
|
246
|
+
userID?: string
|
|
247
|
+
channel?: string
|
|
213
248
|
}
|
|
214
249
|
|
|
215
250
|
type SessionPromptBody = {
|
|
@@ -220,6 +255,12 @@ type SessionPromptBody = {
|
|
|
220
255
|
tools?: Record<string, boolean>
|
|
221
256
|
}
|
|
222
257
|
|
|
258
|
+
type DispatchTarget = {
|
|
259
|
+
agent: string
|
|
260
|
+
model?: { providerID: string; modelID: string }
|
|
261
|
+
requested: string
|
|
262
|
+
}
|
|
263
|
+
|
|
223
264
|
function formatCliError(error: unknown): string {
|
|
224
265
|
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") {
|
|
225
266
|
return "The 'akm' CLI was not found on PATH. Install it first from https://github.com/itlackey/akm."
|
|
@@ -234,7 +275,78 @@ function needsAgentSetup(message: string): boolean {
|
|
|
234
275
|
function addAgentSetupGuidance(message: string): string {
|
|
235
276
|
if (!needsAgentSetup(message)) return message
|
|
236
277
|
if (/\bakm init\b|\bakm_init\b|\bakm setup\b/i.test(message)) return message
|
|
237
|
-
return `${message}. Agents should not run akm setup because it is interactive. The plugin should set
|
|
278
|
+
return `${message}. Agents should not run akm setup because it is interactive. The plugin should set the AKM default agent to the current platform when it is missing; if interactive configuration is still needed, ask the user to run akm setup manually. Use akm_init for agent-safe stash initialization.`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getAkmConfigPath(): string {
|
|
282
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? ".", ".config")
|
|
283
|
+
return path.join(configHome, "akm", "config.json")
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function readAkmConfig(): Record<string, unknown> {
|
|
287
|
+
try {
|
|
288
|
+
const raw = readFileSync(getAkmConfigPath(), "utf8")
|
|
289
|
+
const parsed = JSON.parse(raw)
|
|
290
|
+
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {}
|
|
291
|
+
} catch {
|
|
292
|
+
return {}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function readConfiguredAgentDefault(): string {
|
|
297
|
+
const config = readAkmConfig()
|
|
298
|
+
// 0.8.0 canonical shape: defaults.agent. Fall back to the legacy agent.default
|
|
299
|
+
// slot when running against a pre-0.8 config that has not been migrated yet.
|
|
300
|
+
const defaults = config.defaults
|
|
301
|
+
if (defaults && typeof defaults === "object") {
|
|
302
|
+
const value = (defaults as Record<string, unknown>).agent
|
|
303
|
+
if (typeof value === "string" && value.trim()) return value.trim()
|
|
304
|
+
}
|
|
305
|
+
const agent = config.agent
|
|
306
|
+
if (agent && typeof agent === "object") {
|
|
307
|
+
const value = (agent as Record<string, unknown>).default
|
|
308
|
+
if (typeof value === "string" && value.trim()) return value.trim()
|
|
309
|
+
}
|
|
310
|
+
return ""
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function writeConfiguredAgentDefault(platform: string): boolean {
|
|
314
|
+
if (!platform.trim()) return false
|
|
315
|
+
// #463: route through `akm config set` so akm's schema-walker / validator
|
|
316
|
+
// is the single source of truth for the on-disk shape. Direct JSON writes
|
|
317
|
+
// here would bypass strict-mode validation and the 5-backup ring buffer,
|
|
318
|
+
// and historically clobbered nearby keys when the legacy `agent.default`
|
|
319
|
+
// slot triggered an auto-migration.
|
|
320
|
+
const command = resolveAkmCommand()
|
|
321
|
+
if (typeof command === "object" && "ok" in command) return false
|
|
322
|
+
try {
|
|
323
|
+
// #463: --silent --layer user (akm-cli 0.8.0+) pins writes to the user
|
|
324
|
+
// config layer regardless of merged-read scope and silences hook-driven
|
|
325
|
+
// CLI output. Without them, hook writes could race with a project-layer
|
|
326
|
+
// override or pollute the parent process stdout.
|
|
327
|
+
execFileSync(
|
|
328
|
+
command.command,
|
|
329
|
+
[
|
|
330
|
+
...command.argsPrefix,
|
|
331
|
+
"config",
|
|
332
|
+
"set",
|
|
333
|
+
"--silent",
|
|
334
|
+
"--layer",
|
|
335
|
+
"user",
|
|
336
|
+
`profiles.agent.${platform}`,
|
|
337
|
+
JSON.stringify({ platform }),
|
|
338
|
+
],
|
|
339
|
+
{ stdio: "ignore" },
|
|
340
|
+
)
|
|
341
|
+
execFileSync(
|
|
342
|
+
command.command,
|
|
343
|
+
[...command.argsPrefix, "config", "set", "--silent", "--layer", "user", "defaults.agent", platform],
|
|
344
|
+
{ stdio: "ignore" },
|
|
345
|
+
)
|
|
346
|
+
return true
|
|
347
|
+
} catch {
|
|
348
|
+
return false
|
|
349
|
+
}
|
|
238
350
|
}
|
|
239
351
|
|
|
240
352
|
async function ensurePlatformAgentDefault(
|
|
@@ -242,7 +354,7 @@ async function ensurePlatformAgentDefault(
|
|
|
242
354
|
meta: { directory?: string; sessionID?: string; trigger: string; platform: string },
|
|
243
355
|
): Promise<boolean> {
|
|
244
356
|
const command = resolveAkmCommand()
|
|
245
|
-
if (typeof command
|
|
357
|
+
if (typeof command === "object" && "ok" in command) {
|
|
246
358
|
await writePluginLog(client, "warn", "AKM agent default check skipped", {
|
|
247
359
|
subsystem: "akm",
|
|
248
360
|
trigger: meta.trigger,
|
|
@@ -254,26 +366,12 @@ async function ensurePlatformAgentDefault(
|
|
|
254
366
|
}
|
|
255
367
|
|
|
256
368
|
try {
|
|
257
|
-
const
|
|
258
|
-
encoding: "utf8",
|
|
259
|
-
timeout: 30_000,
|
|
260
|
-
})
|
|
261
|
-
const parsed = safeJsonParse<unknown>(currentRaw)
|
|
262
|
-
const current = typeof parsed === "string"
|
|
263
|
-
? parsed.trim()
|
|
264
|
-
: parsed && typeof parsed === "object"
|
|
265
|
-
? typeof (parsed as Record<string, unknown>).value === "string"
|
|
266
|
-
? ((parsed as Record<string, unknown>).value as string).trim()
|
|
267
|
-
: typeof (parsed as Record<string, unknown>)["agent.default"] === "string"
|
|
268
|
-
? ((parsed as Record<string, unknown>)["agent.default"] as string).trim()
|
|
269
|
-
: ""
|
|
270
|
-
: ""
|
|
369
|
+
const current = readConfiguredAgentDefault()
|
|
271
370
|
if (current) return true
|
|
272
371
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
})
|
|
372
|
+
if (!writeConfiguredAgentDefault(meta.platform)) {
|
|
373
|
+
throw new Error(`Failed to write ${getAkmConfigPath()}`)
|
|
374
|
+
}
|
|
277
375
|
await writePluginLog(client, "info", "AKM agent default initialized", {
|
|
278
376
|
subsystem: "akm",
|
|
279
377
|
trigger: meta.trigger,
|
|
@@ -364,6 +462,46 @@ function writeStructuredEvent(event: Omit<AkmMemoryEvent, "version" | "timestamp
|
|
|
364
462
|
})
|
|
365
463
|
}
|
|
366
464
|
|
|
465
|
+
const CURATED_DIR = path.join(os.tmpdir(), "akm-opencode", "curated")
|
|
466
|
+
mkdirSync(CURATED_DIR, { recursive: true })
|
|
467
|
+
|
|
468
|
+
function gatherCwdContext(directory: string): string {
|
|
469
|
+
const parts: string[] = []
|
|
470
|
+
const indicators: Array<{ file: string; label: string }> = [
|
|
471
|
+
{ file: "package.json", label: "Node" },
|
|
472
|
+
{ file: "Cargo.toml", label: "Rust" },
|
|
473
|
+
{ file: "pyproject.toml", label: "Python" },
|
|
474
|
+
{ file: "go.mod", label: "Go" },
|
|
475
|
+
{ file: "Gemfile", label: "Ruby" },
|
|
476
|
+
{ file: "Makefile", label: "Make" },
|
|
477
|
+
{ file: "Dockerfile", label: "Docker" },
|
|
478
|
+
{ file: "docker-compose.yml", label: "Docker Compose" },
|
|
479
|
+
{ file: ".github/workflows", label: "GitHub Actions" },
|
|
480
|
+
{ file: "composer.json", label: "PHP" },
|
|
481
|
+
]
|
|
482
|
+
for (const indicator of indicators) {
|
|
483
|
+
try {
|
|
484
|
+
if (existsSync(path.join(directory, indicator.file))) parts.push(indicator.label)
|
|
485
|
+
} catch {}
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
const pkgPath = path.join(directory, "package.json")
|
|
489
|
+
if (existsSync(pkgPath)) {
|
|
490
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
|
|
491
|
+
if (pkg.name) parts.push(pkg.name)
|
|
492
|
+
if (pkg.description) parts.push(String(pkg.description).slice(0, 120))
|
|
493
|
+
}
|
|
494
|
+
} catch {}
|
|
495
|
+
try {
|
|
496
|
+
const readme = path.join(directory, "README.md")
|
|
497
|
+
if (existsSync(readme)) {
|
|
498
|
+
const firstContent = readFileSync(readme, "utf8").split("\n").find((l) => l.trim() && !l.startsWith("#"))
|
|
499
|
+
if (firstContent) parts.push(firstContent.trim().slice(0, 100))
|
|
500
|
+
}
|
|
501
|
+
} catch {}
|
|
502
|
+
return parts.join(", ")
|
|
503
|
+
}
|
|
504
|
+
|
|
367
505
|
async function logHookFailure(
|
|
368
506
|
client: LogCapableClient,
|
|
369
507
|
hook: string,
|
|
@@ -406,6 +544,17 @@ function bumpCuratedVersion(sessionID: string) {
|
|
|
406
544
|
sessionCuratedVersion.set(sessionID, (sessionCuratedVersion.get(sessionID) ?? 0) + 1)
|
|
407
545
|
}
|
|
408
546
|
|
|
547
|
+
function writeCuratedFile(sessionID: string, content: string): string {
|
|
548
|
+
const sanitized = sessionID.replace(/[^A-Za-z0-9._-]/g, "_")
|
|
549
|
+
const filePath = path.join(CURATED_DIR, `${sanitized}.md`)
|
|
550
|
+
try {
|
|
551
|
+
writeFileSync(filePath, content)
|
|
552
|
+
sessionCuratedFile.set(sessionID, filePath)
|
|
553
|
+
} catch {}
|
|
554
|
+
return filePath
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
409
558
|
function isAkmRef(value: string): boolean {
|
|
410
559
|
return AKM_REF_PATTERN.test(value)
|
|
411
560
|
}
|
|
@@ -423,9 +572,9 @@ function parseMaybeJson(value: string): unknown {
|
|
|
423
572
|
// stash never wedges the session loop.
|
|
424
573
|
function runCliSyncRaw(args: string[], timeoutMs: number): { ok: true; stdout: string } | { ok: false; error: string } {
|
|
425
574
|
const command = resolveAkmCommand()
|
|
426
|
-
if (typeof command
|
|
575
|
+
if (typeof command === "object" && "ok" in command) return { ok: false, error: command.error }
|
|
427
576
|
try {
|
|
428
|
-
const stdout =
|
|
577
|
+
const stdout = execResolvedAkm(command, args, {
|
|
429
578
|
encoding: "utf8",
|
|
430
579
|
timeout: timeoutMs,
|
|
431
580
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -472,6 +621,125 @@ function buildScopedArgs(context: Record<string, unknown> | undefined): string[]
|
|
|
472
621
|
return args
|
|
473
622
|
}
|
|
474
623
|
|
|
624
|
+
function getHarnessStatePaths(): { stateDir: string; eventLog: string; candidateLog: string; opencodeLogDir: string } {
|
|
625
|
+
const stateDir = path.dirname(OPENCODE_EVENT_LOG)
|
|
626
|
+
return {
|
|
627
|
+
stateDir,
|
|
628
|
+
eventLog: OPENCODE_EVENT_LOG,
|
|
629
|
+
candidateLog: OPENCODE_CANDIDATE_LOG,
|
|
630
|
+
opencodeLogDir: path.join(process.env.XDG_DATA_HOME ?? path.join(process.env.HOME ?? ".", ".local", "share"), "opencode", "log"),
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function formatPathBullet(label: string, filePath: string): string {
|
|
635
|
+
return `- ${label}: ${filePath}`
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function countByValue(values: string[]): Array<[string, number]> {
|
|
639
|
+
const counts = new Map<string, number>()
|
|
640
|
+
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1)
|
|
641
|
+
return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function uniqueRecent<T>(values: T[], key: (value: T) => string, limit: number): T[] {
|
|
645
|
+
const selected: T[] = []
|
|
646
|
+
const seen = new Set<string>()
|
|
647
|
+
for (let index = values.length - 1; index >= 0 && selected.length < limit; index -= 1) {
|
|
648
|
+
const value = values[index]
|
|
649
|
+
const id = key(value)
|
|
650
|
+
if (!id || seen.has(id)) continue
|
|
651
|
+
seen.add(id)
|
|
652
|
+
selected.push(value)
|
|
653
|
+
}
|
|
654
|
+
return selected.reverse()
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function sessionHasPendingCheckpointEvidence(sessionID: string | undefined): boolean {
|
|
658
|
+
if (!sessionID) return false
|
|
659
|
+
return (sessionBuffer.get(sessionID) ?? []).some((entry) => !entry.checkpointed)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function ensureFreshProposalCheckpoint(
|
|
663
|
+
client: LogCapableClient,
|
|
664
|
+
context: { sessionID?: string; directory?: string; agent?: string },
|
|
665
|
+
reason: string,
|
|
666
|
+
): Promise<string | null> {
|
|
667
|
+
if (!context.sessionID || !sessionHasPendingCheckpointEvidence(context.sessionID)) return null
|
|
668
|
+
const ref = captureSessionMemory(context.sessionID, reason, { checkpoint: true })
|
|
669
|
+
if (!ref) return null
|
|
670
|
+
await writePluginLog(client, "info", "AKM proposal checkpoint captured", {
|
|
671
|
+
subsystem: "memory",
|
|
672
|
+
actor: "system",
|
|
673
|
+
sessionID: context.sessionID,
|
|
674
|
+
directory: context.directory,
|
|
675
|
+
agent: context.agent,
|
|
676
|
+
reason,
|
|
677
|
+
ref,
|
|
678
|
+
})
|
|
679
|
+
const indexResult = runCliSyncRaw(["index"], AKM_CURATE_TIMEOUT_MS)
|
|
680
|
+
if (!indexResult.ok) {
|
|
681
|
+
await writePluginLog(client, "warn", "AKM proposal checkpoint indexing failed", {
|
|
682
|
+
subsystem: "memory",
|
|
683
|
+
actor: "system",
|
|
684
|
+
sessionID: context.sessionID,
|
|
685
|
+
directory: context.directory,
|
|
686
|
+
agent: context.agent,
|
|
687
|
+
reason,
|
|
688
|
+
ref,
|
|
689
|
+
error: indexResult.error,
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
return ref
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function truncateLine(value: string, maxChars = 220): string {
|
|
696
|
+
return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1)}...`
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function formatEvidenceSummary(events: AkmMemoryEvent[], candidates: ReturnType<typeof readCandidates>, entries: SessionBufferEntry[]): string[] {
|
|
700
|
+
const lines: string[] = []
|
|
701
|
+
const toolRefs = entries.filter((entry) => entry.kind === "tool-ref" && entry.ref)
|
|
702
|
+
const touchedRefs = countByValue(toolRefs.map((entry) => entry.ref!))
|
|
703
|
+
const toolNames = countByValue(toolRefs.map((entry) => entry.toolName ?? "tool"))
|
|
704
|
+
const statuses = countByValue(toolRefs.map((entry) => entry.status ?? "unknown"))
|
|
705
|
+
const eventTypes = countByValue(events.map((event) => event.event))
|
|
706
|
+
const candidateTypes = countByValue(candidates.map((candidate) => candidate.type))
|
|
707
|
+
|
|
708
|
+
lines.push("## Evidence aggregates")
|
|
709
|
+
lines.push(`- buffered observations: ${entries.length}`)
|
|
710
|
+
if (toolRefs.length > 0) lines.push(`- asset-touch observations: ${toolRefs.length}`)
|
|
711
|
+
if (touchedRefs.length > 0) lines.push(`- top refs: ${touchedRefs.slice(0, 5).map(([ref, count]) => `${ref} (${count})`).join(", ")}`)
|
|
712
|
+
if (toolNames.length > 0) lines.push(`- tools involved: ${toolNames.slice(0, 5).map(([name, count]) => `${name} (${count})`).join(", ")}`)
|
|
713
|
+
if (statuses.length > 0) lines.push(`- statuses: ${statuses.map(([status, count]) => `${status} (${count})`).join(", ")}`)
|
|
714
|
+
if (eventTypes.length > 0) lines.push(`- event types: ${eventTypes.slice(0, 6).map(([event, count]) => `${event} (${count})`).join(", ")}`)
|
|
715
|
+
if (candidateTypes.length > 0) lines.push(`- candidate types: ${candidateTypes.map(([type, count]) => `${type} (${count})`).join(", ")}`)
|
|
716
|
+
lines.push("")
|
|
717
|
+
|
|
718
|
+
const notableEvents = uniqueRecent(events, (event) => `${event.timestamp}:${event.event}:${(event.refs ?? []).join(",")}`, 5)
|
|
719
|
+
if (notableEvents.length > 0) {
|
|
720
|
+
lines.push("## Notable recent events")
|
|
721
|
+
for (const event of notableEvents) {
|
|
722
|
+
const status = event.outcome?.status ?? "unknown"
|
|
723
|
+
const refs = Array.isArray(event.refs) && event.refs.length > 0 ? ` refs=${event.refs.join(", ")}` : ""
|
|
724
|
+
const warning = event.outcome?.warnings?.[0] ? ` warning=${truncateLine(event.outcome.warnings[0], 120)}` : ""
|
|
725
|
+
lines.push(`- ${event.timestamp} ${event.event} (${status})${refs}${warning}`)
|
|
726
|
+
}
|
|
727
|
+
lines.push("")
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const notableCandidates = uniqueRecent(candidates, (candidate) => candidate.id, 5)
|
|
731
|
+
if (notableCandidates.length > 0) {
|
|
732
|
+
lines.push("## Candidate highlights")
|
|
733
|
+
for (const candidate of notableCandidates) {
|
|
734
|
+
const target = candidate.targetRef ? ` target=${candidate.targetRef}` : ""
|
|
735
|
+
lines.push(`- [${candidate.status}] ${candidate.type}/${candidate.scope}${target} :: ${truncateLine(candidate.content, 180)}`)
|
|
736
|
+
}
|
|
737
|
+
lines.push("")
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return lines
|
|
741
|
+
}
|
|
742
|
+
|
|
475
743
|
function runCurate(args: string[]): string | null {
|
|
476
744
|
const result = runCliSyncRaw(args, AKM_CURATE_TIMEOUT_MS)
|
|
477
745
|
if (!result.ok) return null
|
|
@@ -495,7 +763,7 @@ async function runCurateForPrompt(client: LogCapableClient, text: string, sessio
|
|
|
495
763
|
return runCurateLogged(client,
|
|
496
764
|
appendRunScopeArgs(
|
|
497
765
|
[
|
|
498
|
-
"--
|
|
766
|
+
"--shape",
|
|
499
767
|
"agent",
|
|
500
768
|
"--format",
|
|
501
769
|
"text",
|
|
@@ -511,21 +779,19 @@ async function runCurateForPrompt(client: LogCapableClient, text: string, sessio
|
|
|
511
779
|
)
|
|
512
780
|
}
|
|
513
781
|
|
|
514
|
-
async function runCurateForSession(client: LogCapableClient, sessionID: string): Promise<string | null> {
|
|
782
|
+
async function runCurateForSession(client: LogCapableClient, sessionID: string, query?: string): Promise<string | null> {
|
|
783
|
+
const args = [
|
|
784
|
+
"--shape",
|
|
785
|
+
"agent",
|
|
786
|
+
"--format",
|
|
787
|
+
"text",
|
|
788
|
+
"-q",
|
|
789
|
+
"curate",
|
|
790
|
+
]
|
|
791
|
+
if (query) args.push(query)
|
|
792
|
+
args.push("--limit", String(AKM_CURATE_LIMIT))
|
|
515
793
|
return runCurateLogged(client,
|
|
516
|
-
appendRunScopeArgs(
|
|
517
|
-
[
|
|
518
|
-
"--detail",
|
|
519
|
-
"agent",
|
|
520
|
-
"--format",
|
|
521
|
-
"text",
|
|
522
|
-
"-q",
|
|
523
|
-
"curate",
|
|
524
|
-
"--limit",
|
|
525
|
-
String(AKM_CURATE_LIMIT),
|
|
526
|
-
],
|
|
527
|
-
sessionID,
|
|
528
|
-
),
|
|
794
|
+
appendRunScopeArgs(args, sessionID),
|
|
529
795
|
{ toolName: "session.start", sessionID, operation: "session-curate" },
|
|
530
796
|
)
|
|
531
797
|
}
|
|
@@ -648,11 +914,12 @@ async function getAkmStashDir(client?: LogCapableClient): Promise<string | undef
|
|
|
648
914
|
|
|
649
915
|
function warmIndexInBackground(): void {
|
|
650
916
|
const command = resolveAkmCommand()
|
|
651
|
-
if (typeof command
|
|
917
|
+
if (typeof command === "object" && "ok" in command) return
|
|
652
918
|
try {
|
|
653
919
|
// Fire and forget — execSync with a timeout would block, so spawn via the
|
|
654
920
|
// shell and detach. Errors here are never surfaced to the session.
|
|
655
|
-
|
|
921
|
+
const shellArgs = [command.command, ...command.argsPrefix, "index"].map((part) => JSON.stringify(part)).join(" ")
|
|
922
|
+
execSync(`${shellArgs} >/dev/null 2>&1 &`, { timeout: 2_000 })
|
|
656
923
|
} catch {
|
|
657
924
|
// Intentionally ignore — warming is best-effort.
|
|
658
925
|
}
|
|
@@ -724,9 +991,10 @@ async function getPendingProposalCount(client: LogCapableClient, sessionID?: str
|
|
|
724
991
|
if (cached && cached.expiresAt > Date.now()) return cached
|
|
725
992
|
|
|
726
993
|
const command = resolveAkmCommand()
|
|
727
|
-
if (typeof command
|
|
994
|
+
if (typeof command === "object" && "ok" in command) return { count: 0, unsupported: true }
|
|
728
995
|
try {
|
|
729
|
-
|
|
996
|
+
// 0.8.0 canonical proposal-queue listing path: `akm proposal list`.
|
|
997
|
+
const stdout = execResolvedAkm(command, ["proposal", "list", "--status", "pending", "--format", "json"], {
|
|
730
998
|
encoding: "utf8",
|
|
731
999
|
timeout: AKM_PENDING_PROPOSAL_TIMEOUT_MS,
|
|
732
1000
|
})
|
|
@@ -763,7 +1031,7 @@ async function recordRetrospectiveFeedback(client: LogCapableClient, sessionID:
|
|
|
763
1031
|
}
|
|
764
1032
|
|
|
765
1033
|
const targetRef = recentRefs[recentRefs.length - 1]
|
|
766
|
-
const raw = await runCli(client, ["feedback", targetRef, "--negative", "--note", text.slice(0, 280)], {
|
|
1034
|
+
const raw = await runCli(client, ["feedback", targetRef, "--negative", "--note", text.slice(0, 280), ...buildScopedArgs({ sessionID })], {
|
|
767
1035
|
toolName: "akm_feedback",
|
|
768
1036
|
sessionID,
|
|
769
1037
|
})
|
|
@@ -780,53 +1048,6 @@ async function recordRetrospectiveFeedback(client: LogCapableClient, sessionID:
|
|
|
780
1048
|
retrospectiveState.set(sessionID, { recentRefs })
|
|
781
1049
|
}
|
|
782
1050
|
|
|
783
|
-
type RiskyCommandAssessment = {
|
|
784
|
-
category: string
|
|
785
|
-
reason: string
|
|
786
|
-
approval: string
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
function assessRiskyAkmCommand(command: string): RiskyCommandAssessment | undefined {
|
|
790
|
-
const args = splitArguments(command)
|
|
791
|
-
const akmIndex = args.findIndex((arg) => arg === "akm" || arg.endsWith("/akm") || arg.endsWith("\\akm.exe"))
|
|
792
|
-
if (akmIndex === -1) return undefined
|
|
793
|
-
const tokens = args.slice(akmIndex + 1)
|
|
794
|
-
if (tokens[0] === "proposal" && tokens[1] === "accept") {
|
|
795
|
-
return { category: "proposal-accept", reason: "Proposal acceptance changes curated AKM content.", approval: "Ask the user to approve `akm proposal accept <id>`." }
|
|
796
|
-
}
|
|
797
|
-
if (tokens[0] === "proposal" && tokens[1] === "reject") {
|
|
798
|
-
return { category: "proposal-reject", reason: "Proposal rejection is a durable curation decision.", approval: "Ask the user to approve `akm proposal reject <id> --reason \"...\"`." }
|
|
799
|
-
}
|
|
800
|
-
if (tokens[0] === "save" && tokens.includes("--push")) {
|
|
801
|
-
return { category: "save-push", reason: "Pushing stash changes must be explicitly approved.", approval: "Ask the user to approve `akm save --push`." }
|
|
802
|
-
}
|
|
803
|
-
if (tokens[0] === "remove") {
|
|
804
|
-
return { category: "remove", reason: "Removing AKM sources is destructive.", approval: "Ask the user to approve the exact `akm remove ...` command." }
|
|
805
|
-
}
|
|
806
|
-
if (tokens[0] === "vault" && ["show", "load", "set", "unset"].includes(tokens[1] ?? "")) {
|
|
807
|
-
return { category: `vault-${tokens[1]}`, reason: "Vault access or mutation is sensitive.", approval: `Ask the user to approve the exact \`akm vault ${tokens[1]} ...\` command.` }
|
|
808
|
-
}
|
|
809
|
-
if (tokens[0] === "config" && tokens[1] === "set" && (tokens[2]?.startsWith("llm.features.") ?? false)) {
|
|
810
|
-
return { category: "config-llm-features", reason: "Changing AKM LLM feature flags alters autonomous behavior.", approval: "Ask the user to approve the exact `akm config set llm.features.* ...` command." }
|
|
811
|
-
}
|
|
812
|
-
if (tokens[0] === "update" && tokens.includes("--all")) {
|
|
813
|
-
return { category: "update-all", reason: "Updating all AKM kits changes many assets at once.", approval: "Ask the user to approve `akm update --all`." }
|
|
814
|
-
}
|
|
815
|
-
if (tokens[0] === "upgrade") {
|
|
816
|
-
return { category: "upgrade", reason: "Upgrading the AKM CLI changes the toolchain.", approval: "Ask the user to approve the exact `akm upgrade` command." }
|
|
817
|
-
}
|
|
818
|
-
return undefined
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
function blockedCommandMessage(command: string, assessment: RiskyCommandAssessment): string {
|
|
822
|
-
return [
|
|
823
|
-
`Blocked risky AKM command: ${command}`,
|
|
824
|
-
assessment.reason,
|
|
825
|
-
assessment.approval,
|
|
826
|
-
"Retry only after explicit user approval in this conversation.",
|
|
827
|
-
].join("\n")
|
|
828
|
-
}
|
|
829
|
-
|
|
830
1051
|
function queueFeedback(
|
|
831
1052
|
client: LogCapableClient,
|
|
832
1053
|
ref: string,
|
|
@@ -840,7 +1061,7 @@ function queueFeedback(
|
|
|
840
1061
|
dedupe?.add(dedupeKey)
|
|
841
1062
|
|
|
842
1063
|
const command = resolveAkmCommand()
|
|
843
|
-
if (typeof command
|
|
1064
|
+
if (typeof command === "object" && "ok" in command) {
|
|
844
1065
|
void writePluginLog(client, "warn", "AKM auto-feedback skipped", {
|
|
845
1066
|
subsystem: "feedback",
|
|
846
1067
|
toolName: meta.toolName,
|
|
@@ -855,8 +1076,9 @@ function queueFeedback(
|
|
|
855
1076
|
|
|
856
1077
|
try {
|
|
857
1078
|
const child = spawn(
|
|
858
|
-
command,
|
|
1079
|
+
command.command,
|
|
859
1080
|
[
|
|
1081
|
+
...command.argsPrefix,
|
|
860
1082
|
"--format",
|
|
861
1083
|
"json",
|
|
862
1084
|
"-q",
|
|
@@ -865,6 +1087,13 @@ function queueFeedback(
|
|
|
865
1087
|
sentiment === "positive" ? "--positive" : "--negative",
|
|
866
1088
|
"--note",
|
|
867
1089
|
note,
|
|
1090
|
+
...buildScopedArgs({
|
|
1091
|
+
sessionID: meta.sessionID,
|
|
1092
|
+
directory: meta.directory,
|
|
1093
|
+
agent: meta.agent,
|
|
1094
|
+
userID: meta.userID,
|
|
1095
|
+
channel: meta.channel,
|
|
1096
|
+
}),
|
|
868
1097
|
],
|
|
869
1098
|
{
|
|
870
1099
|
detached: true,
|
|
@@ -913,11 +1142,11 @@ function queueFeedback(
|
|
|
913
1142
|
}
|
|
914
1143
|
}
|
|
915
1144
|
|
|
916
|
-
function rememberTextAsMemory(name: string, body: string): string | null {
|
|
1145
|
+
function rememberTextAsMemory(name: string, body: string, context?: Record<string, unknown>): string | null {
|
|
917
1146
|
const command = resolveAkmCommand()
|
|
918
|
-
if (typeof command
|
|
1147
|
+
if (typeof command === "object" && "ok" in command) return null
|
|
919
1148
|
try {
|
|
920
|
-
|
|
1149
|
+
execResolvedAkm(command, ["--format", "json", "-q", "remember", "--name", name, "--force", ...buildScopedArgs(context)], {
|
|
921
1150
|
encoding: "utf8",
|
|
922
1151
|
timeout: AKM_CURATE_TIMEOUT_MS * 2,
|
|
923
1152
|
input: redactSecrets(body).text,
|
|
@@ -931,7 +1160,7 @@ function rememberTextAsMemory(name: string, body: string): string | null {
|
|
|
931
1160
|
function captureSessionMemory(
|
|
932
1161
|
sessionID: string,
|
|
933
1162
|
reason: string,
|
|
934
|
-
options?: { checkpoint?: boolean },
|
|
1163
|
+
options?: { checkpoint?: boolean; directory?: string; agent?: string; channel?: string; userID?: string; user?: string },
|
|
935
1164
|
): string | null {
|
|
936
1165
|
if (!AKM_AUTO_MEMORY) return null
|
|
937
1166
|
if (!sessionID) return null
|
|
@@ -945,6 +1174,14 @@ function captureSessionMemory(
|
|
|
945
1174
|
return null
|
|
946
1175
|
}
|
|
947
1176
|
|
|
1177
|
+
const relatedEvents = readJsonl<AkmMemoryEvent>(OPENCODE_EVENT_LOG)
|
|
1178
|
+
.filter((event) => event.sessionId === sessionID)
|
|
1179
|
+
.slice(-12)
|
|
1180
|
+
const relatedCandidates = readCandidates(OPENCODE_CANDIDATE_LOG)
|
|
1181
|
+
.filter((candidate) => candidate.sessionId === sessionID)
|
|
1182
|
+
.slice(-8)
|
|
1183
|
+
const harnessPaths = getHarnessStatePaths()
|
|
1184
|
+
|
|
948
1185
|
const lines: string[] = []
|
|
949
1186
|
lines.push("---")
|
|
950
1187
|
lines.push("akm_memory_kind: session_checkpoint")
|
|
@@ -957,6 +1194,15 @@ function captureSessionMemory(
|
|
|
957
1194
|
lines.push(`Reason: ${reason}`)
|
|
958
1195
|
lines.push(`Session: ${sessionID}`)
|
|
959
1196
|
lines.push("")
|
|
1197
|
+
lines.push("## Full-detail evidence files")
|
|
1198
|
+
lines.push(formatPathBullet("OpenCode state dir", harnessPaths.stateDir))
|
|
1199
|
+
lines.push(formatPathBullet("Structured event log", harnessPaths.eventLog))
|
|
1200
|
+
lines.push(formatPathBullet("Memory candidate log", harnessPaths.candidateLog))
|
|
1201
|
+
lines.push(formatPathBullet("OpenCode host log dir", harnessPaths.opencodeLogDir))
|
|
1202
|
+
const harnessLog = process.env.AKM_EVAL_HARNESS_LOG?.trim()
|
|
1203
|
+
if (harnessLog) lines.push(formatPathBullet("Harness log", harnessLog))
|
|
1204
|
+
lines.push("")
|
|
1205
|
+
lines.push(...formatEvidenceSummary(relatedEvents, relatedCandidates, pendingEntries))
|
|
960
1206
|
for (const entry of pendingEntries) {
|
|
961
1207
|
if (entry.kind === "memory-intent") {
|
|
962
1208
|
lines.push(`## ${entry.timestamp} — user memory intent`)
|
|
@@ -969,6 +1215,22 @@ function captureSessionMemory(
|
|
|
969
1215
|
lines.push("")
|
|
970
1216
|
}
|
|
971
1217
|
}
|
|
1218
|
+
if (relatedEvents.length > 0) {
|
|
1219
|
+
lines.push("## Plugin event summary")
|
|
1220
|
+
for (const event of relatedEvents) {
|
|
1221
|
+
const status = event.outcome?.status ?? "unknown"
|
|
1222
|
+
const refs = Array.isArray(event.refs) && event.refs.length > 0 ? ` — refs: ${event.refs.join(", ")}` : ""
|
|
1223
|
+
lines.push(`- ${event.timestamp} — ${event.event} (${status})${refs}`)
|
|
1224
|
+
}
|
|
1225
|
+
lines.push("")
|
|
1226
|
+
}
|
|
1227
|
+
if (relatedCandidates.length > 0) {
|
|
1228
|
+
lines.push("## Memory candidates observed")
|
|
1229
|
+
for (const candidate of relatedCandidates) {
|
|
1230
|
+
lines.push(`- [${candidate.status}] ${candidate.type}/${candidate.scope}: ${candidate.content}`)
|
|
1231
|
+
}
|
|
1232
|
+
lines.push("")
|
|
1233
|
+
}
|
|
972
1234
|
const body = lines.join("\n")
|
|
973
1235
|
|
|
974
1236
|
const dateTag = buildDateTag({ includeTime: isCheckpoint })
|
|
@@ -977,7 +1239,15 @@ function captureSessionMemory(
|
|
|
977
1239
|
? `opencode-checkpoint-${dateTag}-${shortSid}`
|
|
978
1240
|
: `opencode-session-${dateTag}-${shortSid}`
|
|
979
1241
|
|
|
980
|
-
const ref = rememberTextAsMemory(name, body
|
|
1242
|
+
const ref = rememberTextAsMemory(name, body, {
|
|
1243
|
+
sessionID,
|
|
1244
|
+
run: sessionID,
|
|
1245
|
+
directory: options?.directory,
|
|
1246
|
+
agent: options?.agent,
|
|
1247
|
+
channel: options?.channel,
|
|
1248
|
+
userID: options?.userID,
|
|
1249
|
+
user: options?.user,
|
|
1250
|
+
})
|
|
981
1251
|
if (!ref) {
|
|
982
1252
|
if (!isCheckpoint) {
|
|
983
1253
|
sessionFinalMemoryCaptured.add(sessionID)
|
|
@@ -995,7 +1265,15 @@ function captureSessionMemory(
|
|
|
995
1265
|
refs: [ref],
|
|
996
1266
|
outcome: { status: "ok" },
|
|
997
1267
|
})
|
|
998
|
-
const
|
|
1268
|
+
const targetRefHints = pendingEntries.flatMap((entry) => entry.ref ? [entry.ref] : [])
|
|
1269
|
+
const candidates = extractCandidatesFromText({
|
|
1270
|
+
harness: "opencode",
|
|
1271
|
+
sessionId: sessionID,
|
|
1272
|
+
text: body,
|
|
1273
|
+
evidence: [ref, reason, ...targetRefHints],
|
|
1274
|
+
sourcePaths: [harnessPaths.eventLog, harnessPaths.candidateLog],
|
|
1275
|
+
targetRefHints,
|
|
1276
|
+
})
|
|
999
1277
|
if (candidates.length > 0) {
|
|
1000
1278
|
appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
|
|
1001
1279
|
void writeStructuredEvent({
|
|
@@ -1025,7 +1303,15 @@ function captureSessionMemory(
|
|
|
1025
1303
|
refs: [ref],
|
|
1026
1304
|
outcome: { status: "ok" },
|
|
1027
1305
|
})
|
|
1028
|
-
const
|
|
1306
|
+
const targetRefHints = entries.flatMap((entry) => entry.ref ? [entry.ref] : [])
|
|
1307
|
+
const candidates = extractCandidatesFromText({
|
|
1308
|
+
harness: "opencode",
|
|
1309
|
+
sessionId: sessionID,
|
|
1310
|
+
text: body,
|
|
1311
|
+
evidence: [ref, reason, ...targetRefHints],
|
|
1312
|
+
sourcePaths: [harnessPaths.eventLog, harnessPaths.candidateLog],
|
|
1313
|
+
targetRefHints,
|
|
1314
|
+
})
|
|
1029
1315
|
if (candidates.length > 0) {
|
|
1030
1316
|
appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
|
|
1031
1317
|
void writeStructuredEvent({
|
|
@@ -1040,10 +1326,13 @@ function captureSessionMemory(
|
|
|
1040
1326
|
return ref
|
|
1041
1327
|
}
|
|
1042
1328
|
|
|
1043
|
-
function maybeCheckpointSessionMemory(
|
|
1329
|
+
function maybeCheckpointSessionMemory(
|
|
1330
|
+
sessionID: string,
|
|
1331
|
+
options?: { directory?: string; agent?: string; channel?: string; userID?: string; user?: string },
|
|
1332
|
+
): string | null {
|
|
1044
1333
|
const count = sessionSuccessfulAssetTouchCount.get(sessionID) ?? 0
|
|
1045
1334
|
if (count < AKM_MEMORY_CHECKPOINT_EVERY) return null
|
|
1046
|
-
const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true })
|
|
1335
|
+
const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true, ...options })
|
|
1047
1336
|
if (!captured) {
|
|
1048
1337
|
sessionSuccessfulAssetTouchCount.set(sessionID, 0)
|
|
1049
1338
|
}
|
|
@@ -1132,10 +1421,34 @@ function extractToolRefs(
|
|
|
1132
1421
|
return { refs: [...refs], positiveOnlyRefs: [...positiveOnlyRefs] }
|
|
1133
1422
|
}
|
|
1134
1423
|
|
|
1424
|
+
function extractAkmRefsFromAllArgs(args: Record<string, unknown>): string[] {
|
|
1425
|
+
if (!args || typeof args !== "object") return []
|
|
1426
|
+
const refs = new Set<string>()
|
|
1427
|
+
for (const value of Object.values(args)) {
|
|
1428
|
+
if (typeof value === "string") {
|
|
1429
|
+
for (const ref of extractAkmRefsFromString(value)) refs.add(ref)
|
|
1430
|
+
} else if (typeof value === "object" && value !== null) {
|
|
1431
|
+
const serialized = JSON.stringify(value)
|
|
1432
|
+
for (const ref of extractAkmRefsFromString(serialized)) refs.add(ref)
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
return [...refs]
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1135
1438
|
const AKM_HINTS_PREFIX = [
|
|
1136
1439
|
"# AKM is available in this session",
|
|
1137
1440
|
"",
|
|
1138
|
-
"You have an AKM stash on this machine. Before writing anything from scratch, call `
|
|
1441
|
+
"You have an AKM stash on this machine. Before writing anything from scratch, call `akm_curate` with a task description to find relevant assets with LLM-reranked relevance scores.",
|
|
1442
|
+
"",
|
|
1443
|
+
"**Choosing the right lookup command:**",
|
|
1444
|
+
"",
|
|
1445
|
+
"- **`akm_curate`** — use this when starting any new task, looking for patterns, docs, skills, or workflows. This is the PRIMARY lookup command. v0.8.0 automatically boosts assets that match the current project (cwd-anchored project-context ranking), so an explicit project name in the query is no longer required for ranking — but it still helps the reranker frame intent.",
|
|
1446
|
+
' - Good: `akm_curate("akm CLI improve command performance analysis")` (explicit framing, still ideal)',
|
|
1447
|
+
' - Bad: `akm_curate("improve performance analysis")` (too generic — the reranker has less to work with even with auto-boost)',
|
|
1448
|
+
"- **`akm_search` (known name)** — use ONLY when you already know an asset exists (e.g. after `akm_show` returned \"not found\") and need to locate its exact ref. Do not use as a discovery tool.",
|
|
1449
|
+
"- **`akm_show <stash>//meta`** — when working in or with an unfamiliar stash, read its optional `.meta/` orientation (purpose, key assets, conventions, maintainer) before diving in. `akm_show meta` reads your working stash's `.meta/index.md`; `akm_show meta:<name>` reads other `.meta/` docs (e.g. `meta:about`). These docs are direct-read and never appear in `akm_search`.",
|
|
1450
|
+
"",
|
|
1451
|
+
"Record `akm_feedback <ref> positive|negative` whenever an asset materially helps or misses, and use `akm_remember` to persist durable learnings so future sessions inherit them.",
|
|
1139
1452
|
"",
|
|
1140
1453
|
AKM_WORKFLOW_INSTRUCTION,
|
|
1141
1454
|
].join("\n")
|
|
@@ -1196,15 +1509,22 @@ const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
|
|
|
1196
1509
|
keywords: ["proposal", "review proposals", "pending proposals", "accept proposal", "reject proposal"],
|
|
1197
1510
|
},
|
|
1198
1511
|
{
|
|
1199
|
-
task: "
|
|
1200
|
-
command: "akm
|
|
1201
|
-
notes: "
|
|
1202
|
-
keywords: ["
|
|
1512
|
+
task: "Bulk-triage the standing pending proposal backlog by policy",
|
|
1513
|
+
command: "akm proposal drain --policy <personal-stash|conservative|manual> --dry-run",
|
|
1514
|
+
notes: "Mutating: promotes/rejects in bulk and commits to git (no batch revert). Preview with --dry-run, then --promote --yes after explicit approval. Supersedes the old manual proposal-management agent session; also runs as the processes.triage improve pre-pass.",
|
|
1515
|
+
keywords: ["proposal", "drain", "triage", "backlog", "bulk accept", "bulk reject"],
|
|
1203
1516
|
},
|
|
1204
1517
|
{
|
|
1205
|
-
task: "
|
|
1206
|
-
command: "akm
|
|
1207
|
-
|
|
1518
|
+
task: "Improve existing assets or distill repeated evidence into proposals",
|
|
1519
|
+
command: "akm improve [<type>|<ref>] [--task \"...\"]",
|
|
1520
|
+
notes: "Improve owns the former reflect/distill flow; proposed assets are not curated until accepted. Profiles add a processes.triage pre-pass and end-of-run sync.",
|
|
1521
|
+
keywords: ["improve", "lesson", "reflect", "distill", "drift", "failure"],
|
|
1522
|
+
},
|
|
1523
|
+
{
|
|
1524
|
+
task: "Manage scheduled task assets via the OS scheduler",
|
|
1525
|
+
command: "akm tasks <add|list|show|remove|enable|disable|run|history|sync|doctor> ...",
|
|
1526
|
+
notes: "Tasks are first-class in v0.8.0 but remain a long-tail CLI surface in this plugin.",
|
|
1527
|
+
keywords: ["tasks", "scheduled task", "cron", "launchd", "schtasks"],
|
|
1208
1528
|
},
|
|
1209
1529
|
{
|
|
1210
1530
|
task: "Create a proposed asset for a coverage gap",
|
|
@@ -1220,15 +1540,15 @@ const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
|
|
|
1220
1540
|
},
|
|
1221
1541
|
{
|
|
1222
1542
|
task: "Install a kit or register an external source (npm, GitHub, git, URL, local dir)",
|
|
1223
|
-
command: "akm add <package-ref> [--name <n>] [--type wiki] [--writable] [--
|
|
1224
|
-
notes: "Confirm with the user before
|
|
1543
|
+
command: "akm add <package-ref> [--name <n>] [--type wiki] [--writable] [--provider <p>] [--max-pages N] [--max-depth N] [--allow-insecure]",
|
|
1544
|
+
notes: "Confirm with the user before registering a website crawler or passing --allow-insecure.",
|
|
1225
1545
|
keywords: ["add", "install", "register", "kit", "source", "github", "npm"],
|
|
1226
1546
|
},
|
|
1227
1547
|
{
|
|
1228
|
-
task: "Commit
|
|
1229
|
-
command: "akm
|
|
1230
|
-
notes: "
|
|
1231
|
-
keywords: ["save", "commit", "push", "publish", "git"],
|
|
1548
|
+
task: "Commit and push pending stash changes",
|
|
1549
|
+
command: "akm sync [<source-name>] [-m <msg>] [--no-push]",
|
|
1550
|
+
notes: "For writable git-backed sources, sync commits and pushes by default (pass --no-push to skip); review the diff first.",
|
|
1551
|
+
keywords: ["sync", "save", "commit", "push", "publish", "git"],
|
|
1232
1552
|
},
|
|
1233
1553
|
{
|
|
1234
1554
|
task: "Import a file (or stdin) into the stash as a typed asset",
|
|
@@ -1270,6 +1590,12 @@ const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
|
|
|
1270
1590
|
notes: "Rarely needed — the index refreshes implicitly after writes.",
|
|
1271
1591
|
keywords: ["index", "reindex", "rebuild"],
|
|
1272
1592
|
},
|
|
1593
|
+
{
|
|
1594
|
+
task: "Manage whole-file secrets outside the chat-safe read surface",
|
|
1595
|
+
command: "akm secret <set|run|remove> ...",
|
|
1596
|
+
notes: "Use the first-class akm_secret tool for list/path. Secret values must never be pasted back into chat; `set` reads from stdin/--from-file/--from-env and `run` injects into a child process only.",
|
|
1597
|
+
keywords: ["secret", "docker secret", "pem", "token", "_FILE"],
|
|
1598
|
+
},
|
|
1273
1599
|
{
|
|
1274
1600
|
task: "View or update akm config (get/set/list/unset/path)",
|
|
1275
1601
|
command: "akm config <action> [<key>] [<value>] [--all]",
|
|
@@ -1319,21 +1645,6 @@ function extractSessionIdFromEvent(payload: unknown): string | undefined {
|
|
|
1319
1645
|
return undefined
|
|
1320
1646
|
}
|
|
1321
1647
|
|
|
1322
|
-
function getCommandStatus(command: string): "ok" | "missing" | "error" {
|
|
1323
|
-
try {
|
|
1324
|
-
execFileSync(command, ["--version"], {
|
|
1325
|
-
encoding: "utf8",
|
|
1326
|
-
timeout: 10_000,
|
|
1327
|
-
})
|
|
1328
|
-
return "ok"
|
|
1329
|
-
} catch (error: unknown) {
|
|
1330
|
-
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") {
|
|
1331
|
-
return "missing"
|
|
1332
|
-
}
|
|
1333
|
-
return "error"
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
1648
|
function extractFirstSemverMatch(value: string): string | null {
|
|
1338
1649
|
return value.match(SEMVER_PATTERN)?.[0] ?? null
|
|
1339
1650
|
}
|
|
@@ -1350,208 +1661,268 @@ function getCommandVersion(command: string): string | null {
|
|
|
1350
1661
|
}
|
|
1351
1662
|
}
|
|
1352
1663
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1664
|
+
type ResolvedAkmCommand = {
|
|
1665
|
+
command: string
|
|
1666
|
+
argsPrefix: string[]
|
|
1667
|
+
displayCommand: string
|
|
1668
|
+
}
|
|
1356
1669
|
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1670
|
+
type CommandProbe = {
|
|
1671
|
+
command: string
|
|
1672
|
+
argsPrefix: string[]
|
|
1673
|
+
displayCommand: string
|
|
1674
|
+
exists: boolean
|
|
1675
|
+
version: string | null
|
|
1676
|
+
failureReason: string | null
|
|
1677
|
+
}
|
|
1364
1678
|
|
|
1679
|
+
// Like getCommandVersion, but distinguishes "binary not found on disk" from
|
|
1680
|
+
// "binary found but failed to produce a version" — the latter typically means
|
|
1681
|
+
// the binary is corrupt, wrong architecture, or has a runtime dependency missing.
|
|
1682
|
+
// Used by the diagnostic resolution trail so the consent banner can tell users
|
|
1683
|
+
// which failure mode they're hitting.
|
|
1684
|
+
function getLocalBuildAkmCommand(): ResolvedAkmCommand | null {
|
|
1685
|
+
const cliPath = process.env.AKM_LOCAL_BUILD_CLI?.trim()
|
|
1686
|
+
if (!cliPath) return null
|
|
1365
1687
|
return {
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
: null,
|
|
1688
|
+
command: process.env.BUN || "bun",
|
|
1689
|
+
argsPrefix: [cliPath],
|
|
1690
|
+
displayCommand: cliPath,
|
|
1370
1691
|
}
|
|
1371
1692
|
}
|
|
1372
1693
|
|
|
1373
|
-
function
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
if (!leftParsed || !rightParsed) return left.localeCompare(right)
|
|
1694
|
+
function execResolvedAkm(command: ResolvedAkmCommand, args: string[], options?: Parameters<typeof execFileSync>[2]) {
|
|
1695
|
+
return execFileSync(command.command, [...command.argsPrefix, ...args], options)
|
|
1696
|
+
}
|
|
1377
1697
|
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1698
|
+
function probeCommand(command: ResolvedAkmCommand): CommandProbe {
|
|
1699
|
+
const displayIsAbsolute = path.isAbsolute(command.displayCommand)
|
|
1700
|
+
const displayExists = displayIsAbsolute ? existsSync(command.displayCommand) : true
|
|
1701
|
+
if (displayIsAbsolute && !displayExists) {
|
|
1702
|
+
return { ...command, exists: false, version: null, failureReason: "not_on_disk" }
|
|
1381
1703
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
if (!
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
if (
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1704
|
+
const commandIsAbsolute = path.isAbsolute(command.command)
|
|
1705
|
+
const commandExists = commandIsAbsolute ? existsSync(command.command) : true
|
|
1706
|
+
if (commandIsAbsolute && !commandExists) {
|
|
1707
|
+
return { ...command, exists: false, version: null, failureReason: "command_not_on_disk" }
|
|
1708
|
+
}
|
|
1709
|
+
try {
|
|
1710
|
+
const version = execResolvedAkm(command, ["--version"], {
|
|
1711
|
+
encoding: "utf8",
|
|
1712
|
+
timeout: 10_000,
|
|
1713
|
+
})
|
|
1714
|
+
const parsed = extractFirstSemverMatch(version)
|
|
1715
|
+
if (!parsed) {
|
|
1716
|
+
return { ...command, exists: true, version: null, failureReason: "no_semver_in_output" }
|
|
1717
|
+
}
|
|
1718
|
+
return { ...command, exists: true, version: parsed, failureReason: null }
|
|
1719
|
+
} catch (err) {
|
|
1720
|
+
const code = (err as NodeJS.ErrnoException)?.code
|
|
1721
|
+
if (code === "ENOENT") {
|
|
1722
|
+
return { ...command, exists: false, version: null, failureReason: "not_on_path" }
|
|
1723
|
+
}
|
|
1724
|
+
return { ...command, exists: true, version: null, failureReason: code ?? "version_check_failed" }
|
|
1398
1725
|
}
|
|
1726
|
+
}
|
|
1399
1727
|
|
|
1400
|
-
|
|
1728
|
+
function satisfiesAkmVersionRange(version: string | null): boolean {
|
|
1729
|
+
if (typeof version !== "string") return false
|
|
1730
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/)
|
|
1731
|
+
if (!match) return false
|
|
1732
|
+
return Number(match[1]) === 0 && Number(match[2]) === 8
|
|
1401
1733
|
}
|
|
1402
1734
|
|
|
1403
|
-
function
|
|
1735
|
+
function getBundledAkmCommand(): string | null {
|
|
1736
|
+
const packagePath = path.join(moduleDir, "node_modules", "akm-cli", "package.json")
|
|
1737
|
+
const fallback = path.join(moduleDir, "node_modules", ".bin", process.platform === "win32" ? "akm.cmd" : "akm")
|
|
1404
1738
|
try {
|
|
1405
|
-
const
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1739
|
+
const raw = readFileSync(packagePath, "utf8")
|
|
1740
|
+
const pkg = JSON.parse(raw) as { bin?: unknown }
|
|
1741
|
+
const bin = pkg.bin
|
|
1742
|
+
const relativeBin = typeof bin === "string"
|
|
1743
|
+
? bin
|
|
1744
|
+
: bin && typeof bin === "object" && typeof (bin as Record<string, unknown>).akm === "string"
|
|
1745
|
+
? (bin as Record<string, string>).akm
|
|
1746
|
+
: null
|
|
1747
|
+
if (!relativeBin) return existsSync(fallback) ? fallback : null
|
|
1748
|
+
const bundledCommand = path.join(moduleDir, "node_modules", "akm-cli", relativeBin)
|
|
1749
|
+
return existsSync(bundledCommand) ? bundledCommand : existsSync(fallback) ? fallback : null
|
|
1413
1750
|
} catch {
|
|
1414
|
-
return null
|
|
1751
|
+
return existsSync(fallback) ? fallback : null
|
|
1415
1752
|
}
|
|
1416
1753
|
}
|
|
1417
1754
|
|
|
1418
|
-
function
|
|
1419
|
-
const
|
|
1420
|
-
const
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1755
|
+
function getConfigNodeModulesAkmCommand(): string | null {
|
|
1756
|
+
const homeDir = process.env.HOME || os.homedir()
|
|
1757
|
+
const configDir = process.env.XDG_CONFIG_HOME || path.join(homeDir, ".config")
|
|
1758
|
+
const configBin = path.join(configDir, "opencode", "node_modules", ".bin", process.platform === "win32" ? "akm.cmd" : "akm")
|
|
1759
|
+
return existsSync(configBin) ? configBin : null
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
function getPathAkmCandidates(): string[] {
|
|
1763
|
+
const candidates: string[] = []
|
|
1764
|
+
const configNodeModules = getConfigNodeModulesAkmCommand()
|
|
1765
|
+
if (configNodeModules) candidates.push(configNodeModules)
|
|
1766
|
+
candidates.push("akm")
|
|
1767
|
+
// Honor an explicit $HOME override (standard POSIX, and matches
|
|
1768
|
+
// getConfigNodeModulesAkmCommand). os.homedir() snapshots HOME at process
|
|
1769
|
+
// start and ignores later changes, which made this path non-sandboxable.
|
|
1770
|
+
const home = process.env.HOME || os.homedir()
|
|
1771
|
+
if (home) {
|
|
1772
|
+
candidates.push(path.join(home, ".local", "bin", process.platform === "win32" ? "akm.cmd" : "akm"))
|
|
1426
1773
|
}
|
|
1427
|
-
return
|
|
1774
|
+
return candidates
|
|
1428
1775
|
}
|
|
1429
1776
|
|
|
1430
|
-
|
|
1431
|
-
|
|
1777
|
+
type AkmResolutionTrail = Array<{
|
|
1778
|
+
command: string
|
|
1779
|
+
source: "bundled" | "path" | "local_build"
|
|
1780
|
+
version: string | null
|
|
1781
|
+
outcome: "selected" | "version_out_of_range" | "missing" | "probe_failed"
|
|
1782
|
+
failureReason: string | null
|
|
1783
|
+
}>
|
|
1784
|
+
|
|
1785
|
+
let lastAkmResolutionTrail: AkmResolutionTrail = []
|
|
1786
|
+
|
|
1787
|
+
function getResolvedAkmDetails(): { command: string; argsPrefix: string[]; displayCommand: string; version: string; source: "bundled" | "path" | "local_build" } | null {
|
|
1788
|
+
const candidates: Array<{ command: string; argsPrefix: string[]; displayCommand: string; source: "bundled" | "path" | "local_build" }> = []
|
|
1789
|
+
// Opt-out (default off): force the plugin to ignore its bundled akm-cli and
|
|
1790
|
+
// fall through to AKM_LOCAL_BUILD_CLI / PATH. Used by the eval harness so a
|
|
1791
|
+
// deterministic fake `akm` on PATH wins over the real bundled dependency;
|
|
1792
|
+
// also a useful escape hatch when a bundled dep is broken.
|
|
1793
|
+
const ignoreBundled = process.env.AKM_OPENCODE_IGNORE_BUNDLED_CLI === "1"
|
|
1794
|
+
const bundled = ignoreBundled ? null : getBundledAkmCommand()
|
|
1795
|
+
if (bundled) candidates.push({ command: bundled, argsPrefix: [], displayCommand: bundled, source: "bundled" })
|
|
1796
|
+
const localBuild = getLocalBuildAkmCommand()
|
|
1797
|
+
if (localBuild) candidates.push({ ...localBuild, source: "local_build" })
|
|
1798
|
+
for (const command of getPathAkmCandidates()) {
|
|
1799
|
+
candidates.push({ command, argsPrefix: [], displayCommand: command, source: "path" })
|
|
1800
|
+
}
|
|
1432
1801
|
|
|
1433
|
-
const
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
const
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
if (!
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1802
|
+
const trail: AkmResolutionTrail = []
|
|
1803
|
+
const seen = new Set<string>()
|
|
1804
|
+
for (const candidate of candidates) {
|
|
1805
|
+
const cacheKey = `${candidate.command}::${candidate.argsPrefix.join(" ")}`
|
|
1806
|
+
if (!candidate.command || seen.has(cacheKey)) continue
|
|
1807
|
+
seen.add(cacheKey)
|
|
1808
|
+
const probe = probeCommand(candidate)
|
|
1809
|
+
if (!probe.exists) {
|
|
1810
|
+
trail.push({ command: candidate.displayCommand, source: candidate.source, version: null, outcome: "missing", failureReason: probe.failureReason })
|
|
1811
|
+
continue
|
|
1812
|
+
}
|
|
1813
|
+
if (probe.failureReason || !probe.version) {
|
|
1814
|
+
trail.push({ command: candidate.displayCommand, source: candidate.source, version: probe.version, outcome: "probe_failed", failureReason: probe.failureReason })
|
|
1815
|
+
continue
|
|
1816
|
+
}
|
|
1817
|
+
if (!satisfiesAkmVersionRange(probe.version)) {
|
|
1818
|
+
trail.push({ command: candidate.displayCommand, source: candidate.source, version: probe.version, outcome: "version_out_of_range", failureReason: null })
|
|
1819
|
+
continue
|
|
1820
|
+
}
|
|
1821
|
+
trail.push({ command: candidate.displayCommand, source: candidate.source, version: probe.version, outcome: "selected", failureReason: null })
|
|
1822
|
+
lastAkmResolutionTrail = trail
|
|
1823
|
+
return { ...candidate, version: probe.version }
|
|
1448
1824
|
}
|
|
1825
|
+
lastAkmResolutionTrail = trail
|
|
1826
|
+
return null
|
|
1449
1827
|
}
|
|
1450
1828
|
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1829
|
+
// The OpenCode plugin has never silently auto-installed akm-cli — it relies on
|
|
1830
|
+
// the bundled binary that ships with the plugin, falling back to PATH. When
|
|
1831
|
+
// neither path produces a compatible akm we log a warn-level event to the host
|
|
1832
|
+
// AND write a stderr banner so the human running OpenCode actually sees the
|
|
1833
|
+
// problem. The banner mirrors the Claude plugin's wording: install must be
|
|
1834
|
+
// user-driven, never automatic. The recommended consent point is `akm setup`
|
|
1835
|
+
// (or the host-specific akm setup slash command if one exists).
|
|
1836
|
+
async function ensureSupportedAkmResolved(client: LogCapableClient): Promise<void> {
|
|
1837
|
+
const installedAkm = getResolvedAkmDetails()
|
|
1838
|
+
if (!installedAkm) {
|
|
1839
|
+
await writePluginLog(client, "warn", "AKM CLI resolution failed", {
|
|
1459
1840
|
subsystem: "akm",
|
|
1460
|
-
|
|
1461
|
-
|
|
1841
|
+
requiredRange: AKM_REQUIRED_VERSION_RANGE,
|
|
1842
|
+
bundledCommand: getBundledAkmCommand(),
|
|
1843
|
+
pathCommand: "akm",
|
|
1844
|
+
reason: "no_supported_command",
|
|
1845
|
+
trail: lastAkmResolutionTrail,
|
|
1846
|
+
})
|
|
1847
|
+
writeAkmConsentBanner({
|
|
1848
|
+
detected: getCommandVersion("akm") ?? undefined,
|
|
1849
|
+
bundled: getBundledAkmCommand(),
|
|
1850
|
+
trail: lastAkmResolutionTrail,
|
|
1462
1851
|
})
|
|
1463
1852
|
return
|
|
1464
1853
|
}
|
|
1465
1854
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
package: autoInstallPackageRef,
|
|
1476
|
-
command: resolvedAkmCommand,
|
|
1477
|
-
installedVersion: installedAkm.version,
|
|
1478
|
-
latestStable,
|
|
1479
|
-
reason: "latest_version_unavailable",
|
|
1480
|
-
})
|
|
1481
|
-
return
|
|
1482
|
-
}
|
|
1855
|
+
resolvedAkmCommand = installedAkm.command
|
|
1856
|
+
await writePluginLog(client, "info", "AKM CLI resolved", {
|
|
1857
|
+
subsystem: "akm",
|
|
1858
|
+
command: installedAkm.command,
|
|
1859
|
+
source: installedAkm.source,
|
|
1860
|
+
version: installedAkm.version,
|
|
1861
|
+
requiredRange: AKM_REQUIRED_VERSION_RANGE,
|
|
1862
|
+
})
|
|
1863
|
+
}
|
|
1483
1864
|
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
})
|
|
1495
|
-
return
|
|
1865
|
+
function writeAkmConsentBanner(info: { detected?: string; bundled?: string | null; trail?: AkmResolutionTrail }) {
|
|
1866
|
+
const detectedLabel = info.detected ?? "(not found on PATH)"
|
|
1867
|
+
const bundledLabel = info.bundled ?? "(none)"
|
|
1868
|
+
const trailLines: string[] = []
|
|
1869
|
+
if (info.trail && info.trail.length > 0) {
|
|
1870
|
+
trailLines.push("", " resolution trail (in order tried):")
|
|
1871
|
+
for (const entry of info.trail) {
|
|
1872
|
+
const versionLabel = entry.version ?? "no version"
|
|
1873
|
+
const reason = entry.failureReason ? ` reason=${entry.failureReason}` : ""
|
|
1874
|
+
trailLines.push(` [${entry.source}] ${entry.command} → ${entry.outcome} (${versionLabel})${reason}`)
|
|
1496
1875
|
}
|
|
1497
1876
|
}
|
|
1498
|
-
|
|
1877
|
+
const banner = [
|
|
1878
|
+
"─".repeat(60),
|
|
1879
|
+
"akm-opencode plugin: akm CLI not installed or wrong version",
|
|
1880
|
+
` detected on PATH: ${detectedLabel}`,
|
|
1881
|
+
` bundled fallback: ${bundledLabel}`,
|
|
1882
|
+
` required: ${AKM_REQUIRED_VERSION_RANGE}`,
|
|
1883
|
+
...trailLines,
|
|
1884
|
+
"",
|
|
1885
|
+
"Reinstall or update the akm-opencode plugin so OpenCode/Bun",
|
|
1886
|
+
"installs the dependency, or install akm-cli manually:",
|
|
1887
|
+
" bun install -g akm-cli@^0.8.0",
|
|
1888
|
+
" npm install -g akm-cli@^0.8.0",
|
|
1889
|
+
"Then run `akm setup` interactively to configure the stash.",
|
|
1890
|
+
"─".repeat(60),
|
|
1891
|
+
].join("\n")
|
|
1499
1892
|
try {
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
stdio: "pipe",
|
|
1504
|
-
})
|
|
1505
|
-
|
|
1506
|
-
const bunGlobalAkm = getBunGlobalAkmCommand()
|
|
1507
|
-
if (bunGlobalAkm && getCommandStatus(bunGlobalAkm) === "ok") {
|
|
1508
|
-
resolvedAkmCommand = bunGlobalAkm
|
|
1509
|
-
} else if (getCommandStatus("akm") === "ok") {
|
|
1510
|
-
resolvedAkmCommand = "akm"
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
await writePluginLog(client, "info", "AKM CLI install check completed", {
|
|
1514
|
-
subsystem: "akm",
|
|
1515
|
-
installer: "bun",
|
|
1516
|
-
package: autoInstallPackageRef,
|
|
1517
|
-
command: resolvedAkmCommand,
|
|
1518
|
-
installedVersion: installedAkm?.version ?? null,
|
|
1519
|
-
latestStable,
|
|
1520
|
-
})
|
|
1521
|
-
} catch (error: unknown) {
|
|
1522
|
-
await writePluginLog(client, "warn", "AKM auto-install failed", {
|
|
1523
|
-
subsystem: "akm",
|
|
1524
|
-
installer: "bun",
|
|
1525
|
-
package: autoInstallPackageRef,
|
|
1526
|
-
error: formatCliError(error),
|
|
1527
|
-
})
|
|
1893
|
+
process.stderr.write(banner + "\n")
|
|
1894
|
+
} catch {
|
|
1895
|
+
// best-effort; never crash the plugin over a banner
|
|
1528
1896
|
}
|
|
1529
1897
|
}
|
|
1530
1898
|
|
|
1531
|
-
function resolveAkmCommand():
|
|
1532
|
-
const
|
|
1533
|
-
if (
|
|
1899
|
+
function resolveAkmCommand(): ResolvedAkmCommand | CliError {
|
|
1900
|
+
const localBuild = getLocalBuildAkmCommand()
|
|
1901
|
+
if (localBuild) {
|
|
1902
|
+
const probe = probeCommand(localBuild)
|
|
1903
|
+
if (probe.exists && satisfiesAkmVersionRange(probe.version)) return localBuild
|
|
1904
|
+
}
|
|
1534
1905
|
|
|
1535
|
-
const
|
|
1536
|
-
if (
|
|
1537
|
-
resolvedAkmCommand
|
|
1538
|
-
return resolvedAkmCommand
|
|
1906
|
+
const currentVersion = getCommandVersion(resolvedAkmCommand)
|
|
1907
|
+
if (satisfiesAkmVersionRange(currentVersion)) {
|
|
1908
|
+
return { command: resolvedAkmCommand, argsPrefix: [], displayCommand: resolvedAkmCommand }
|
|
1539
1909
|
}
|
|
1540
1910
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1911
|
+
const installedAkm = getResolvedAkmDetails()
|
|
1912
|
+
if (installedAkm) {
|
|
1913
|
+
resolvedAkmCommand = installedAkm.command
|
|
1914
|
+
return { command: installedAkm.command, argsPrefix: installedAkm.argsPrefix, displayCommand: installedAkm.displayCommand }
|
|
1544
1915
|
}
|
|
1545
1916
|
|
|
1546
1917
|
return {
|
|
1547
1918
|
ok: false,
|
|
1548
|
-
error: `
|
|
1919
|
+
error: `AKM CLI ${AKM_REQUIRED_VERSION_RANGE} is required, but no compatible bundled or PATH 'akm' executable was found. Reinstall or update the akm-opencode plugin so OpenCode/Bun installs the dependency.`,
|
|
1549
1920
|
}
|
|
1550
1921
|
}
|
|
1551
1922
|
|
|
1552
1923
|
async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta): Promise<string> {
|
|
1553
1924
|
const command = resolveAkmCommand()
|
|
1554
|
-
if (typeof command
|
|
1925
|
+
if (typeof command === "object" && "ok" in command) {
|
|
1555
1926
|
await writePluginLog(client, "error", "AKM command resolution failed", {
|
|
1556
1927
|
subsystem: "akm",
|
|
1557
1928
|
toolName: meta.toolName,
|
|
@@ -1566,7 +1937,9 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
|
|
|
1566
1937
|
return JSON.stringify(command)
|
|
1567
1938
|
}
|
|
1568
1939
|
|
|
1569
|
-
|
|
1940
|
+
// `akm improve` hard-rejects --format in 0.8.0 (cli.ts:4131-4137); never auto-inject it there.
|
|
1941
|
+
const skipFormatInjection = args[0] === "improve"
|
|
1942
|
+
const fullArgs = skipFormatInjection || args.includes("--format") ? [...args] : [...args, "--format", "json"]
|
|
1570
1943
|
const proposalId = args[0] === "proposal" && typeof args[2] === "string" ? args[2] : null
|
|
1571
1944
|
|
|
1572
1945
|
const recordSuccess = async (stdout: string): Promise<string> => {
|
|
@@ -1625,8 +1998,8 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
|
|
|
1625
1998
|
directory: meta.directory,
|
|
1626
1999
|
})
|
|
1627
2000
|
}
|
|
1628
|
-
if (args[0] === "proposal" && ["accept", "reject"].includes(args[1] ?? "")) {
|
|
1629
|
-
await emitWorkflowTelemetry(client, "info", args[1] === "accept" ? "akm.proposal.accept.requested" : "akm.proposal.reject.requested", {
|
|
2001
|
+
if (args[0] === "proposal" && ["accept", "reject", "drain"].includes(args[1] ?? "")) {
|
|
2002
|
+
await emitWorkflowTelemetry(client, "info", args[1] === "accept" ? "akm.proposal.accept.requested" : args[1] === "drain" ? "akm.proposal.drain.requested" : "akm.proposal.reject.requested", {
|
|
1630
2003
|
sessionID: meta.sessionID,
|
|
1631
2004
|
toolName: meta.toolName,
|
|
1632
2005
|
proposalId,
|
|
@@ -1638,14 +2011,14 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
|
|
|
1638
2011
|
}
|
|
1639
2012
|
|
|
1640
2013
|
try {
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
2014
|
+
const stdout = execResolvedAkm(command, fullArgs, {
|
|
2015
|
+
encoding: "utf8",
|
|
2016
|
+
timeout: 60_000,
|
|
2017
|
+
})
|
|
1645
2018
|
return recordSuccess(stdout)
|
|
1646
2019
|
} catch (error: unknown) {
|
|
1647
2020
|
let message = formatCliError(error)
|
|
1648
|
-
if (["
|
|
2021
|
+
if (["akm_improve", "akm_propose"].includes(meta.toolName) && needsAgentSetup(message)) {
|
|
1649
2022
|
message = addAgentSetupGuidance(message)
|
|
1650
2023
|
}
|
|
1651
2024
|
message = addAgentSetupGuidance(message)
|
|
@@ -1654,8 +2027,8 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
|
|
|
1654
2027
|
toolName: meta.toolName,
|
|
1655
2028
|
sessionID: meta.sessionID,
|
|
1656
2029
|
directory: meta.directory,
|
|
1657
|
-
|
|
1658
|
-
|
|
2030
|
+
command: command.displayCommand,
|
|
2031
|
+
args: fullArgs,
|
|
1659
2032
|
exitCode: getExecStatus(error),
|
|
1660
2033
|
stdout: toLogString((error as { stdout?: unknown }).stdout) ?? "",
|
|
1661
2034
|
stderr: toLogString((error as { stderr?: unknown }).stderr) ?? message,
|
|
@@ -1683,8 +2056,10 @@ type AssetType =
|
|
|
1683
2056
|
| "memory"
|
|
1684
2057
|
| "script"
|
|
1685
2058
|
| "skill"
|
|
2059
|
+
| "task"
|
|
1686
2060
|
| "workflow"
|
|
1687
|
-
| "
|
|
2061
|
+
| "env"
|
|
2062
|
+
| "secret"
|
|
1688
2063
|
| "wiki"
|
|
1689
2064
|
|
|
1690
2065
|
const ASSET_TYPES = [
|
|
@@ -1695,8 +2070,10 @@ const ASSET_TYPES = [
|
|
|
1695
2070
|
"memory",
|
|
1696
2071
|
"script",
|
|
1697
2072
|
"skill",
|
|
2073
|
+
"task",
|
|
1698
2074
|
"workflow",
|
|
1699
|
-
"
|
|
2075
|
+
"env",
|
|
2076
|
+
"secret",
|
|
1700
2077
|
"wiki",
|
|
1701
2078
|
"any",
|
|
1702
2079
|
] as const
|
|
@@ -1833,6 +2210,18 @@ function parseModelHint(modelHint: unknown): { providerID: string; modelID: stri
|
|
|
1833
2210
|
return { providerID, modelID }
|
|
1834
2211
|
}
|
|
1835
2212
|
|
|
2213
|
+
function resolveDispatchTarget(requested: string | undefined, fallbackAgent: string): DispatchTarget {
|
|
2214
|
+
const trimmed = requested?.trim()
|
|
2215
|
+
if (!trimmed) {
|
|
2216
|
+
return { agent: fallbackAgent, requested: fallbackAgent }
|
|
2217
|
+
}
|
|
2218
|
+
const model = parseModelHint(trimmed)
|
|
2219
|
+
if (model) {
|
|
2220
|
+
return { agent: fallbackAgent, model, requested: trimmed }
|
|
2221
|
+
}
|
|
2222
|
+
return { agent: trimmed, requested: trimmed }
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1836
2225
|
function parseToolPolicy(toolPolicy: unknown): Record<string, boolean> | undefined {
|
|
1837
2226
|
const result: Record<string, boolean> = {}
|
|
1838
2227
|
|
|
@@ -2047,8 +2436,8 @@ async function promptTargetSession(input: {
|
|
|
2047
2436
|
try {
|
|
2048
2437
|
const promptResponse = await input.client.session.prompt({
|
|
2049
2438
|
path: { id: input.targetSessionID },
|
|
2050
|
-
|
|
2051
|
-
|
|
2439
|
+
body: input.promptBody,
|
|
2440
|
+
})
|
|
2052
2441
|
|
|
2053
2442
|
if (promptResponse.error || !promptResponse.data) {
|
|
2054
2443
|
const reason = promptResponse.error ? JSON.stringify(promptResponse.error) : "empty response"
|
|
@@ -2059,6 +2448,7 @@ async function promptTargetSession(input: {
|
|
|
2059
2448
|
directory: input.context.directory,
|
|
2060
2449
|
targetSessionID: input.targetSessionID,
|
|
2061
2450
|
dispatchAgent: input.promptBody.agent,
|
|
2451
|
+
dispatchModel: input.promptBody.model ?? null,
|
|
2062
2452
|
ref: input.ref,
|
|
2063
2453
|
error: reason,
|
|
2064
2454
|
})
|
|
@@ -2073,10 +2463,11 @@ async function promptTargetSession(input: {
|
|
|
2073
2463
|
toolName: input.toolName,
|
|
2074
2464
|
sessionID: input.context.sessionID,
|
|
2075
2465
|
directory: input.context.directory,
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2466
|
+
targetSessionID: input.targetSessionID,
|
|
2467
|
+
dispatchAgent: input.promptBody.agent,
|
|
2468
|
+
dispatchModel: input.promptBody.model ?? null,
|
|
2469
|
+
ref: input.ref,
|
|
2470
|
+
})
|
|
2080
2471
|
return { ok: true, data: promptResponse.data }
|
|
2081
2472
|
} catch (error: unknown) {
|
|
2082
2473
|
const reason = error instanceof Error ? error.message : String(error)
|
|
@@ -2087,6 +2478,7 @@ async function promptTargetSession(input: {
|
|
|
2087
2478
|
directory: input.context.directory,
|
|
2088
2479
|
targetSessionID: input.targetSessionID,
|
|
2089
2480
|
dispatchAgent: input.promptBody.agent,
|
|
2481
|
+
dispatchModel: input.promptBody.model ?? null,
|
|
2090
2482
|
ref: input.ref,
|
|
2091
2483
|
error: reason,
|
|
2092
2484
|
})
|
|
@@ -2156,17 +2548,6 @@ async function getParentSessionID(
|
|
|
2156
2548
|
}
|
|
2157
2549
|
}
|
|
2158
2550
|
|
|
2159
|
-
function splitArguments(raw: string): string[] {
|
|
2160
|
-
if (!raw.trim()) return []
|
|
2161
|
-
const args: string[] = []
|
|
2162
|
-
const re = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g
|
|
2163
|
-
let match: RegExpExecArray | null
|
|
2164
|
-
while ((match = re.exec(raw)) !== null) {
|
|
2165
|
-
args.push(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "")
|
|
2166
|
-
}
|
|
2167
|
-
return args
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
2551
|
function renderCommandTemplate(template: string, rawArguments: string): string {
|
|
2171
2552
|
const args = splitArguments(rawArguments)
|
|
2172
2553
|
return template
|
|
@@ -2225,7 +2606,7 @@ type PluginClient = {
|
|
|
2225
2606
|
}
|
|
2226
2607
|
|
|
2227
2608
|
export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
2228
|
-
await
|
|
2609
|
+
await ensureSupportedAkmResolved(client as unknown as LogCapableClient)
|
|
2229
2610
|
|
|
2230
2611
|
const logClient = client as unknown as LogCapableClient
|
|
2231
2612
|
const sdkClient = client as unknown as PluginClient
|
|
@@ -2258,10 +2639,12 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2258
2639
|
})
|
|
2259
2640
|
warmIndexInBackground()
|
|
2260
2641
|
if (AKM_AUTO_CURATE && !sessionCurated.has(sid)) {
|
|
2261
|
-
const
|
|
2642
|
+
const cwdContext = gatherCwdContext(directory)
|
|
2643
|
+
const curated = await runCurateForSession(logClient, sid, cwdContext || undefined)
|
|
2262
2644
|
if (curated) {
|
|
2263
2645
|
bumpCuratedVersion(sid)
|
|
2264
2646
|
sessionCurated.set(sid, curated)
|
|
2647
|
+
writeCuratedFile(sid, curated)
|
|
2265
2648
|
}
|
|
2266
2649
|
}
|
|
2267
2650
|
}
|
|
@@ -2278,7 +2661,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2278
2661
|
}
|
|
2279
2662
|
} else if (type === "session.compacted" || type === "session.idle" || type === "session.deleted") {
|
|
2280
2663
|
if (!sid) return
|
|
2281
|
-
const captured = captureSessionMemory(sid, type)
|
|
2664
|
+
const captured = captureSessionMemory(sid, type, { directory })
|
|
2282
2665
|
if (captured) {
|
|
2283
2666
|
await writePluginLog(logClient, "info", "AKM session memory captured", {
|
|
2284
2667
|
subsystem: "memory",
|
|
@@ -2288,6 +2671,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2288
2671
|
ref: captured,
|
|
2289
2672
|
})
|
|
2290
2673
|
await maybeIndexSessionMemory(logClient, sid, type, captured)
|
|
2674
|
+
// Auto-signal so improve picks up this session memory on the next run.
|
|
2675
|
+
runCliSyncRaw(["feedback", captured, "--positive", "--note", "session checkpoint: auto-signal for improve eligibility"], AKM_CURATE_TIMEOUT_MS)
|
|
2291
2676
|
}
|
|
2292
2677
|
if (type === "session.compacted") {
|
|
2293
2678
|
writeStructuredEvent({
|
|
@@ -2303,6 +2688,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2303
2688
|
if (type === "session.deleted") {
|
|
2304
2689
|
sessionHints.delete(sid)
|
|
2305
2690
|
sessionCurated.delete(sid)
|
|
2691
|
+
sessionCuratedFile.delete(sid)
|
|
2306
2692
|
sessionWorkflow.delete(sid)
|
|
2307
2693
|
sessionCuratorReport.delete(sid)
|
|
2308
2694
|
sessionContextEpoch.delete(sid)
|
|
@@ -2325,7 +2711,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2325
2711
|
try {
|
|
2326
2712
|
const sid = extractSessionIdFromEvent(input)
|
|
2327
2713
|
if (!sid) return
|
|
2328
|
-
const captured = captureSessionMemory(sid, "stop")
|
|
2714
|
+
const captured = captureSessionMemory(sid, "stop", { directory })
|
|
2329
2715
|
if (captured) {
|
|
2330
2716
|
await writePluginLog(logClient, "info", "AKM session memory captured", {
|
|
2331
2717
|
subsystem: "memory",
|
|
@@ -2335,6 +2721,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2335
2721
|
ref: captured,
|
|
2336
2722
|
})
|
|
2337
2723
|
await maybeIndexSessionMemory(logClient, sid, "stop", captured)
|
|
2724
|
+
// Auto-signal so improve picks up this session memory on the next run.
|
|
2725
|
+
runCliSyncRaw(["feedback", captured, "--positive", "--note", "session checkpoint: auto-signal for improve eligibility"], AKM_CURATE_TIMEOUT_MS)
|
|
2338
2726
|
}
|
|
2339
2727
|
} catch (error: unknown) {
|
|
2340
2728
|
await logHookFailure(logClient, "stop", error)
|
|
@@ -2342,7 +2730,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2342
2730
|
},
|
|
2343
2731
|
// experimental.chat.system.transform is how OpenCode exposes the
|
|
2344
2732
|
// additionalContext channel. We append the cached hints (once per session)
|
|
2345
|
-
// and the curated
|
|
2733
|
+
// and the curated file reference (once per turn) so the next LLM call
|
|
2734
|
+
// sees instructions to read the curated file instead of raw content.
|
|
2346
2735
|
"experimental.chat.system.transform": async (
|
|
2347
2736
|
input: { sessionID?: string; session_id?: string } | undefined,
|
|
2348
2737
|
output: { system?: string[] } | undefined,
|
|
@@ -2353,9 +2742,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2353
2742
|
const epoch = sessionContextEpoch.get(sid) ?? 0
|
|
2354
2743
|
const injectedEpoch = sessionContextInjectedEpoch.get(sid)
|
|
2355
2744
|
if (sid && injectedEpoch !== epoch) {
|
|
2745
|
+
const curatedFile = sessionCuratedFile.get(sid)
|
|
2746
|
+
const curatedBlock = curatedFile
|
|
2747
|
+
? `AKM stash curation available at \`${curatedFile}\`. Read that file to discover assets relevant to this session. ${AKM_CURATED_TAIL}`
|
|
2748
|
+
: ""
|
|
2356
2749
|
const blocks = [
|
|
2357
2750
|
sessionHints.get(sid) ? `${AKM_HINTS_PREFIX}\n\n${sessionHints.get(sid)}` : "",
|
|
2358
|
-
|
|
2751
|
+
curatedBlock,
|
|
2359
2752
|
sessionWorkflow.get(sid) ? formatWorkflowContext(sessionWorkflow.get(sid)!) : "",
|
|
2360
2753
|
(await getPendingProposalCount(logClient, sid)).count > 0 && !(await getPendingProposalCount(logClient, sid)).unsupported ? formatPendingProposalContext((await getPendingProposalCount(logClient, sid)).count) : "",
|
|
2361
2754
|
sessionCuratorReport.get(sid) ? formatCuratorReportContext(sessionCuratorReport.get(sid)!) : "",
|
|
@@ -2363,8 +2756,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2363
2756
|
output.system.push(...applyContextBudget(blocks))
|
|
2364
2757
|
sessionContextInjectedEpoch.set(sid, epoch)
|
|
2365
2758
|
if (sessionCurated.has(sid)) {
|
|
2366
|
-
// Startup curation is already included in the epoch-scoped block,
|
|
2367
|
-
// so mark that version as consumed to avoid a duplicate inject below.
|
|
2368
2759
|
sessionCuratedInjectedVersion.set(sid, sessionCuratedVersion.get(sid) ?? 0)
|
|
2369
2760
|
}
|
|
2370
2761
|
}
|
|
@@ -2372,7 +2763,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2372
2763
|
const curatedVersion = sessionCuratedVersion.get(sid) ?? 0
|
|
2373
2764
|
if (curated) {
|
|
2374
2765
|
if (sessionCuratedInjectedVersion.get(sid) !== curatedVersion) {
|
|
2375
|
-
|
|
2766
|
+
const curatedFile = writeCuratedFile(sid, curated)
|
|
2767
|
+
output.system.push(...applyContextBudget([`AKM stash curation written to \`${curatedFile}\`. Read that file to discover assets relevant to the current task. ${AKM_CURATED_TAIL}`]))
|
|
2376
2768
|
sessionCuratedInjectedVersion.set(sid, curatedVersion)
|
|
2377
2769
|
}
|
|
2378
2770
|
}
|
|
@@ -2385,10 +2777,17 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2385
2777
|
if (!input.tool.startsWith("akm_")) return
|
|
2386
2778
|
const args = output.args && typeof output.args === "object" ? output.args as Record<string, unknown> : {}
|
|
2387
2779
|
const confirm = args.confirm === true
|
|
2388
|
-
if (input.tool === "
|
|
2780
|
+
if (input.tool === "akm_env" && (args.action === "path" || args.action === "run") && !confirm) {
|
|
2389
2781
|
output.args = {
|
|
2390
2782
|
...args,
|
|
2391
|
-
__akmBlocked: `
|
|
2783
|
+
__akmBlocked: `akm_env action='${String(args.action)}' requires confirm:true because env reads are sensitive.`,
|
|
2784
|
+
}
|
|
2785
|
+
return
|
|
2786
|
+
}
|
|
2787
|
+
if (input.tool === "akm_secret" && args.action === "path" && !confirm) {
|
|
2788
|
+
output.args = {
|
|
2789
|
+
...args,
|
|
2790
|
+
__akmBlocked: "akm_secret action='path' requires confirm:true because secret paths are sensitive.",
|
|
2392
2791
|
}
|
|
2393
2792
|
return
|
|
2394
2793
|
}
|
|
@@ -2399,6 +2798,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2399
2798
|
}
|
|
2400
2799
|
return
|
|
2401
2800
|
}
|
|
2801
|
+
if (input.tool === "akm_proposal" && ["accept", "reject", "drain"].includes(String(args.action ?? "")) && !confirm) {
|
|
2802
|
+
output.args = {
|
|
2803
|
+
...args,
|
|
2804
|
+
__akmBlocked: `akm_proposal action='${String(args.action)}' requires confirm:true because it mutates the proposal queue.`,
|
|
2805
|
+
}
|
|
2806
|
+
return
|
|
2807
|
+
}
|
|
2402
2808
|
output.args = args
|
|
2403
2809
|
} catch (error: unknown) {
|
|
2404
2810
|
await logHookFailure(logClient, "tool.execute.before", error, {
|
|
@@ -2407,59 +2813,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2407
2813
|
})
|
|
2408
2814
|
}
|
|
2409
2815
|
},
|
|
2410
|
-
"permission.ask": async (input, output) => {
|
|
2411
|
-
try {
|
|
2412
|
-
const command = typeof input?.metadata?.command === "string"
|
|
2413
|
-
? input.metadata.command
|
|
2414
|
-
: Array.isArray(input?.patterns)
|
|
2415
|
-
? input.patterns.join(" && ")
|
|
2416
|
-
: ""
|
|
2417
|
-
if (!command.includes("akm")) return
|
|
2418
|
-
await emitWorkflowTelemetry(logClient, "info", "akm.raw_cli.invoked", {
|
|
2419
|
-
sessionID: input.sessionID,
|
|
2420
|
-
toolName: "bash",
|
|
2421
|
-
outcome: "requested",
|
|
2422
|
-
command,
|
|
2423
|
-
})
|
|
2424
|
-
const assessment = assessRiskyAkmCommand(command)
|
|
2425
|
-
if (!assessment) return
|
|
2426
|
-
output.status = "deny"
|
|
2427
|
-
await emitWorkflowTelemetry(logClient, "warn", "akm.raw_cli.blocked", {
|
|
2428
|
-
sessionID: input.sessionID,
|
|
2429
|
-
toolName: "bash",
|
|
2430
|
-
outcome: "blocked",
|
|
2431
|
-
reason: assessment.reason,
|
|
2432
|
-
command,
|
|
2433
|
-
category: assessment.category,
|
|
2434
|
-
})
|
|
2435
|
-
} catch (error: unknown) {
|
|
2436
|
-
await logHookFailure(logClient, "permission.ask", error, {
|
|
2437
|
-
sessionID: input?.sessionID,
|
|
2438
|
-
command: typeof input?.metadata?.command === "string" ? input.metadata.command : undefined,
|
|
2439
|
-
})
|
|
2440
|
-
}
|
|
2441
|
-
},
|
|
2442
|
-
"command.execute.before": async (input, output) => {
|
|
2443
|
-
try {
|
|
2444
|
-
const command = `${input.command ?? ""} ${input.arguments ?? ""}`.trim()
|
|
2445
|
-
const assessment = assessRiskyAkmCommand(command)
|
|
2446
|
-
if (!assessment) return
|
|
2447
|
-
output.parts = [{ type: "text", text: blockedCommandMessage(command, assessment) }]
|
|
2448
|
-
await emitWorkflowTelemetry(logClient, "warn", "akm.raw_cli.blocked", {
|
|
2449
|
-
sessionID: input.sessionID,
|
|
2450
|
-
toolName: String(input.command ?? "bash"),
|
|
2451
|
-
outcome: "blocked",
|
|
2452
|
-
reason: assessment.reason,
|
|
2453
|
-
command,
|
|
2454
|
-
category: assessment.category,
|
|
2455
|
-
})
|
|
2456
|
-
} catch (error: unknown) {
|
|
2457
|
-
await logHookFailure(logClient, "command.execute.before", error, {
|
|
2458
|
-
sessionID: input?.sessionID,
|
|
2459
|
-
command: input?.command,
|
|
2460
|
-
})
|
|
2461
|
-
}
|
|
2462
|
-
},
|
|
2463
2816
|
"shell.env": async (_input, output) => {
|
|
2464
2817
|
try {
|
|
2465
2818
|
output.env.AKM_PROJECT = worktree
|
|
@@ -2486,28 +2839,63 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2486
2839
|
if (AKM_AUTO_CURATE && input.sessionID) {
|
|
2487
2840
|
const decision = shouldRecall(text, { activeWorkflow: !!sessionWorkflow.get(input.sessionID), recentAssetFailure: retrospectiveState.get(input.sessionID)?.lastNegativeSignalAt != null })
|
|
2488
2841
|
if (decision.shouldRecall) {
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2842
|
+
// Do NOT block the model on `akm curate` — previously this awaited
|
|
2843
|
+
// an 8s-timeout sync curate on every user message, adding up to 8s
|
|
2844
|
+
// to the time-to-first-token of every turn. Fire-and-forget the
|
|
2845
|
+
// curate; when it completes, store the result in `sessionCurated`
|
|
2846
|
+
// which gets picked up by `experimental.chat.system.transform` on
|
|
2847
|
+
// the NEXT message. The current message proceeds with whatever
|
|
2848
|
+
// curated context (if any) was cached from previous turns.
|
|
2849
|
+
const sessionID = input.sessionID
|
|
2850
|
+
const directorySnapshot = directory
|
|
2851
|
+
const agentSnapshot = input.agent
|
|
2852
|
+
const previewText = text
|
|
2853
|
+
// Record the audit row immediately so callers (akm_memory audit)
|
|
2854
|
+
// see the decision; the `injectedRefs` field is populated lazily
|
|
2855
|
+
// when the background curate resolves.
|
|
2856
|
+
sessionRecallAudit.set(sessionID, {
|
|
2492
2857
|
shouldRecall: true,
|
|
2493
2858
|
reason: decision.reason,
|
|
2494
2859
|
query: decision.query,
|
|
2495
|
-
injectedRefs:
|
|
2496
|
-
injectedChars:
|
|
2497
|
-
warnings: [],
|
|
2498
|
-
})
|
|
2499
|
-
writeStructuredEvent({
|
|
2500
|
-
event: "prompt_recall",
|
|
2501
|
-
sessionId: input.sessionID,
|
|
2502
|
-
scope: buildEventScope(input.sessionID, directory, input.agent),
|
|
2503
|
-
input: { promptPreview: text.slice(0, 280), query: decision.query, reason: decision.reason },
|
|
2504
|
-
refs,
|
|
2505
|
-
outcome: { status: curated ? "ok" : "skipped" },
|
|
2860
|
+
injectedRefs: [],
|
|
2861
|
+
injectedChars: 0,
|
|
2862
|
+
warnings: ["curate dispatched asynchronously; result injected on next message"],
|
|
2506
2863
|
})
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2864
|
+
void (async () => {
|
|
2865
|
+
try {
|
|
2866
|
+
const curated = await runCurateForPrompt(logClient, decision.query, sessionID)
|
|
2867
|
+
const refs = [...new Set((curated ?? "").match(/(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|lesson|script|workflow|task|env|secret|wiki):[A-Za-z0-9._/-]+/g) ?? [])]
|
|
2868
|
+
const prior = sessionRecallAudit.get(sessionID)
|
|
2869
|
+
if (prior) {
|
|
2870
|
+
sessionRecallAudit.set(sessionID, {
|
|
2871
|
+
...prior,
|
|
2872
|
+
injectedRefs: refs,
|
|
2873
|
+
injectedChars: curated?.length ?? 0,
|
|
2874
|
+
warnings: prior.warnings.filter((warning) => !warning.startsWith("curate dispatched asynchronously")),
|
|
2875
|
+
})
|
|
2876
|
+
}
|
|
2877
|
+
writeStructuredEvent({
|
|
2878
|
+
event: "prompt_recall",
|
|
2879
|
+
sessionId: sessionID,
|
|
2880
|
+
scope: buildEventScope(sessionID, directorySnapshot, agentSnapshot),
|
|
2881
|
+
input: { promptPreview: previewText.slice(0, 280), query: decision.query, reason: decision.reason },
|
|
2882
|
+
refs,
|
|
2883
|
+
outcome: { status: curated ? "ok" : "skipped" },
|
|
2884
|
+
})
|
|
2885
|
+
if (curated) {
|
|
2886
|
+
sessionCurated.set(sessionID, curated)
|
|
2887
|
+
writeCuratedFile(sessionID, curated)
|
|
2888
|
+
bumpCuratedVersion(sessionID)
|
|
2889
|
+
}
|
|
2890
|
+
} catch (error: unknown) {
|
|
2891
|
+
await writePluginLog(logClient, "warn", "AKM background curate failed", {
|
|
2892
|
+
subsystem: "curation",
|
|
2893
|
+
sessionID,
|
|
2894
|
+
directory: directorySnapshot,
|
|
2895
|
+
error: formatCliError(error),
|
|
2896
|
+
})
|
|
2897
|
+
}
|
|
2898
|
+
})()
|
|
2511
2899
|
} else {
|
|
2512
2900
|
const hint = "Need more AKM context? Use `akm_search` or `akm_curate` before writing from scratch."
|
|
2513
2901
|
sessionRecallAudit.set(input.sessionID, {
|
|
@@ -2527,7 +2915,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2527
2915
|
})
|
|
2528
2916
|
const current = sessionCurated.get(input.sessionID) ?? ""
|
|
2529
2917
|
if (!current.includes(hint)) {
|
|
2530
|
-
|
|
2918
|
+
const updated = current ? `${current}\n\n${hint}` : hint
|
|
2919
|
+
sessionCurated.set(input.sessionID, updated)
|
|
2920
|
+
writeCuratedFile(input.sessionID, updated)
|
|
2531
2921
|
bumpCuratedVersion(input.sessionID)
|
|
2532
2922
|
}
|
|
2533
2923
|
}
|
|
@@ -2542,17 +2932,42 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2542
2932
|
})
|
|
2543
2933
|
}
|
|
2544
2934
|
|
|
2545
|
-
|
|
2935
|
+
// Retrospective positive feedback: the loose "thanks|perfect|worked"
|
|
2936
|
+
// matcher previously fired on `thanks, but it didn't work` because it
|
|
2937
|
+
// only checked for a positive token without considering negation in
|
|
2938
|
+
// the same message. Gate the path with the same negative/correction
|
|
2939
|
+
// matchers used by `recordRetrospectiveFeedback` so a mixed-signal
|
|
2940
|
+
// message is treated as ambiguous (skip rather than misattribute).
|
|
2941
|
+
// Refs still flow through the shared confidence gate so Claude and
|
|
2942
|
+
// OpenCode produce parallel auto-feedback verdicts for the same
|
|
2943
|
+
// signal (README "Confidence-scored auto-feedback" parity claim).
|
|
2944
|
+
if (
|
|
2945
|
+
input.sessionID
|
|
2946
|
+
&& AKM_AUTO_FEEDBACK
|
|
2947
|
+
&& AKM_RETROSPECTIVE_FEEDBACK_RE.test(text)
|
|
2948
|
+
&& !AKM_RETROSPECTIVE_NEGATIVE_RE.test(text)
|
|
2949
|
+
&& !AKM_EXPLICIT_CORRECTION_RE.test(text)
|
|
2950
|
+
) {
|
|
2546
2951
|
const recentRefs = (sessionBuffer.get(input.sessionID) ?? [])
|
|
2547
2952
|
.filter((entry) => entry.kind === "tool-ref" && !!entry.ref)
|
|
2548
2953
|
.map((entry) => entry.ref!)
|
|
2549
|
-
.filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("
|
|
2954
|
+
.filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("env:") && !ref.startsWith("secret:") && refs.indexOf(ref) === index)
|
|
2550
2955
|
.slice(-3)
|
|
2551
2956
|
const dedupe = new Set<string>()
|
|
2552
2957
|
for (const ref of recentRefs) {
|
|
2553
|
-
|
|
2958
|
+
const signal = classifyFeedbackSignal({
|
|
2959
|
+
ref,
|
|
2960
|
+
polarity: "positive",
|
|
2961
|
+
harness: "opencode",
|
|
2962
|
+
sessionId: input.sessionID,
|
|
2963
|
+
retrospective: true,
|
|
2964
|
+
note: "opencode retrospective: user confirmed it worked",
|
|
2965
|
+
})
|
|
2966
|
+
if (!shouldSubmitAutomaticFeedback(signal)) continue
|
|
2967
|
+
queueFeedback(logClient, ref, "positive", signal.note, {
|
|
2554
2968
|
toolName: "chat.message",
|
|
2555
2969
|
sessionID: input.sessionID,
|
|
2970
|
+
agent: input.agent,
|
|
2556
2971
|
}, dedupe)
|
|
2557
2972
|
}
|
|
2558
2973
|
}
|
|
@@ -2570,7 +2985,38 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2570
2985
|
},
|
|
2571
2986
|
"tool.execute.after": async (input, output) => {
|
|
2572
2987
|
try {
|
|
2573
|
-
|
|
2988
|
+
const isAkmTool = input.tool.startsWith("akm_")
|
|
2989
|
+
// The SDK type for `tool.execute.after` input does not expose `directory`,
|
|
2990
|
+
// but the OpenCode runtime does provide it for tool-scoped hooks. Read
|
|
2991
|
+
// it via a structural cast so we get the value when present without
|
|
2992
|
+
// accepting `any` everywhere it's used.
|
|
2993
|
+
const inputDirectory = (input as { directory?: unknown }).directory
|
|
2994
|
+
const directory = typeof inputDirectory === "string" ? inputDirectory : undefined
|
|
2995
|
+
|
|
2996
|
+
const allArgRefs = extractAkmRefsFromAllArgs(input.args as Record<string, unknown>)
|
|
2997
|
+
const allOutputRefs = extractAkmRefsFromString(output.output)
|
|
2998
|
+
const allRefs = [...new Set([...allArgRefs, ...allOutputRefs])]
|
|
2999
|
+
|
|
3000
|
+
if (allRefs.length > 0) {
|
|
3001
|
+
writeStructuredEvent({
|
|
3002
|
+
event: "tool_ref_observed",
|
|
3003
|
+
sessionId: input.sessionID,
|
|
3004
|
+
scope: buildEventScope(input.sessionID, directory, input.tool),
|
|
3005
|
+
input: { tool: input.tool, callID: input.callID },
|
|
3006
|
+
refs: allRefs,
|
|
3007
|
+
outcome: { status: "ok" },
|
|
3008
|
+
})
|
|
3009
|
+
for (const ref of allRefs) {
|
|
3010
|
+
addBufferEntry(input.sessionID, {
|
|
3011
|
+
kind: "tool-ref",
|
|
3012
|
+
toolName: input.tool,
|
|
3013
|
+
ref,
|
|
3014
|
+
status: "unknown",
|
|
3015
|
+
})
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
if (!isAkmTool) return
|
|
2574
3020
|
|
|
2575
3021
|
const parsed = parseToolOutput(output.output)
|
|
2576
3022
|
if (!parsed) return
|
|
@@ -2600,15 +3046,12 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2600
3046
|
})
|
|
2601
3047
|
}
|
|
2602
3048
|
|
|
2603
|
-
// Auto-feedback + session buffering: record every asset ref the tool
|
|
2604
|
-
// touched so the stash ranking improves over time and so Stop/Compact
|
|
2605
|
-
// has material to flush into a session summary memory.
|
|
2606
3049
|
const refResult = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
|
|
2607
3050
|
noteRecentRefs(input.sessionID, refResult.refs)
|
|
2608
3051
|
writeStructuredEvent({
|
|
2609
3052
|
event: "tool_observation",
|
|
2610
3053
|
sessionId: input.sessionID,
|
|
2611
|
-
scope: buildEventScope(input.sessionID,
|
|
3054
|
+
scope: buildEventScope(input.sessionID, directory, input.tool),
|
|
2612
3055
|
input: { tool: input.tool, callID: input.callID, args: input.args as Record<string, unknown>, output: parsed as Record<string, unknown> },
|
|
2613
3056
|
refs: refResult.refs,
|
|
2614
3057
|
outcome: { status: feedback === "negative" ? "failed" : "ok" },
|
|
@@ -2627,7 +3070,10 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2627
3070
|
input.sessionID,
|
|
2628
3071
|
(sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1,
|
|
2629
3072
|
)
|
|
2630
|
-
const checkpointRef = maybeCheckpointSessionMemory(input.sessionID
|
|
3073
|
+
const checkpointRef = maybeCheckpointSessionMemory(input.sessionID, {
|
|
3074
|
+
directory,
|
|
3075
|
+
agent: input.tool,
|
|
3076
|
+
})
|
|
2631
3077
|
if (checkpointRef) {
|
|
2632
3078
|
await writePluginLog(logClient, "info", "AKM checkpoint memory captured", {
|
|
2633
3079
|
subsystem: "memory",
|
|
@@ -2654,10 +3100,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2654
3100
|
? `opencode auto: ${input.tool} succeeded`
|
|
2655
3101
|
: `opencode auto: ${input.tool} failed`
|
|
2656
3102
|
for (const ref of feedbackRefs) {
|
|
2657
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
2660
|
-
|
|
3103
|
+
// Skip refs that should never receive auto-feedback. Matches the
|
|
3104
|
+
// claude-side hook: memory/env/secret/lesson are excluded, including
|
|
3105
|
+
// origin-qualified forms like `local//lesson:foo`. Lessons take
|
|
3106
|
+
// feedback through the proposal queue, not via direct akm feedback.
|
|
3107
|
+
if (/^(?:.*\/\/)?(?:memory|env|secret|lesson):/.test(ref)) continue
|
|
2661
3108
|
const directInput = Object.values(input.args as Record<string, unknown>).some((value) => typeof value === "string" && value.includes(ref))
|
|
2662
3109
|
const signal = classifyFeedbackSignal({
|
|
2663
3110
|
ref,
|
|
@@ -2671,7 +3118,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2671
3118
|
writeStructuredEvent({
|
|
2672
3119
|
event: "feedback_recorded",
|
|
2673
3120
|
sessionId: input.sessionID,
|
|
2674
|
-
scope: buildEventScope(input.sessionID,
|
|
3121
|
+
scope: buildEventScope(input.sessionID, directory, input.tool),
|
|
2675
3122
|
refs: [ref],
|
|
2676
3123
|
input: { source: signal.source, confidence: signal.confidence, note: signal.note },
|
|
2677
3124
|
outcome: { status: "skipped", warnings: ["confidence below automatic submission threshold"] },
|
|
@@ -2681,12 +3128,14 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2681
3128
|
const ok = queueFeedback(logClient, ref, feedback, signal.note, {
|
|
2682
3129
|
toolName: input.tool,
|
|
2683
3130
|
sessionID: input.sessionID,
|
|
3131
|
+
directory,
|
|
3132
|
+
agent: input.tool,
|
|
2684
3133
|
}, dedupe)
|
|
2685
3134
|
if (ok) {
|
|
2686
3135
|
writeStructuredEvent({
|
|
2687
3136
|
event: "feedback_recorded",
|
|
2688
3137
|
sessionId: input.sessionID,
|
|
2689
|
-
scope: buildEventScope(input.sessionID,
|
|
3138
|
+
scope: buildEventScope(input.sessionID, directory, input.tool),
|
|
2690
3139
|
refs: [ref],
|
|
2691
3140
|
input: { source: signal.source, confidence: signal.confidence, note: signal.note },
|
|
2692
3141
|
outcome: { status: "ok" },
|
|
@@ -2716,7 +3165,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2716
3165
|
},
|
|
2717
3166
|
}),
|
|
2718
3167
|
akm_search: tool({
|
|
2719
|
-
description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, memories, workflows,
|
|
3168
|
+
description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, memories, lessons, tasks, workflows, env configs, secrets, and wikis. Use source='registry' for installable community kits.",
|
|
2720
3169
|
args: {
|
|
2721
3170
|
query: tool.schema.string().describe("Case-insensitive substring search."),
|
|
2722
3171
|
type: tool.schema
|
|
@@ -2872,7 +3321,14 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2872
3321
|
return JSON.stringify({ ok: true, candidates })
|
|
2873
3322
|
}
|
|
2874
3323
|
if (input.action === "checkpoint") {
|
|
2875
|
-
const ref = captureSessionMemory(context.sessionID, "manual-checkpoint", {
|
|
3324
|
+
const ref = captureSessionMemory(context.sessionID, "manual-checkpoint", {
|
|
3325
|
+
checkpoint: true,
|
|
3326
|
+
directory: context.directory,
|
|
3327
|
+
agent: context.agent,
|
|
3328
|
+
channel: (context as Record<string, unknown>).channel as string | undefined,
|
|
3329
|
+
userID: (context as Record<string, unknown>).userID as string | undefined,
|
|
3330
|
+
user: (context as Record<string, unknown>).user as string | undefined,
|
|
3331
|
+
})
|
|
2876
3332
|
return JSON.stringify({ ok: true, ref })
|
|
2877
3333
|
}
|
|
2878
3334
|
if (input.action === "sync") {
|
|
@@ -2900,7 +3356,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2900
3356
|
} else if (candidate.recommendedAction === "feedback" && candidate.targetRef) {
|
|
2901
3357
|
rawResult = await runCli(client as unknown as LogCapableClient, ["feedback", candidate.targetRef, "--positive", "--note", candidate.content, ...buildScopedArgs(context as unknown as Record<string, unknown>)], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
|
|
2902
3358
|
} else if (candidate.recommendedAction === "distill" && candidate.targetRef) {
|
|
2903
|
-
rawResult = await runCli(client as unknown as LogCapableClient, ["
|
|
3359
|
+
rawResult = await runCli(client as unknown as LogCapableClient, ["improve", candidate.targetRef], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
|
|
2904
3360
|
} else if (candidate.recommendedAction === "propose") {
|
|
2905
3361
|
rawResult = await runCli(client as unknown as LogCapableClient, ["propose", "knowledge", `candidate-${candidate.id}`, "--task", candidate.content], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
|
|
2906
3362
|
} else {
|
|
@@ -2955,6 +3411,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2955
3411
|
as_subtask: tool.schema.boolean().optional().describe("Run in a child session with parent context. Defaults to true."),
|
|
2956
3412
|
},
|
|
2957
3413
|
async execute({ focus, dispatch_agent, as_subtask }, context) {
|
|
3414
|
+
await ensureFreshProposalCheckpoint(logClient, {
|
|
3415
|
+
sessionID: context.sessionID,
|
|
3416
|
+
directory: context.directory,
|
|
3417
|
+
agent: context.agent,
|
|
3418
|
+
}, "pre-evolve")
|
|
2958
3419
|
const useSubtask = as_subtask ?? true
|
|
2959
3420
|
const requestedAgent = dispatch_agent ?? "akm-curator"
|
|
2960
3421
|
const targetAgent = await resolveDispatchAgent(sdkClient, requestedAgent, context.directory)
|
|
@@ -2993,7 +3454,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2993
3454
|
const dateTag = buildDateTag()
|
|
2994
3455
|
const shortSid = context.sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
|
|
2995
3456
|
const curatorMemoryRef = fullText
|
|
2996
|
-
? rememberTextAsMemory(`akm-curator-${dateTag}-${shortSid}`, fullText)
|
|
3457
|
+
? rememberTextAsMemory(`akm-curator-${dateTag}-${shortSid}`, fullText, context as unknown as Record<string, unknown>)
|
|
2997
3458
|
: null
|
|
2998
3459
|
|
|
2999
3460
|
return JSON.stringify({
|
|
@@ -3077,7 +3538,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3077
3538
|
ref: tool.schema.string().optional().describe("Agent ref from akm_search (e.g. agent:my-agent.md)."),
|
|
3078
3539
|
query: tool.schema.string().optional().describe("If ref is omitted, resolve best matching stash agent for this query."),
|
|
3079
3540
|
task_prompt: tool.schema.string().describe("Task prompt sent to the dispatched OpenCode agent."),
|
|
3080
|
-
dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the task with. Defaults to 'general'."),
|
|
3541
|
+
dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the task with, or a provider/model override like 'openai/gpt-5.3-codex'. Defaults to 'general'."),
|
|
3081
3542
|
as_subtask: tool.schema.boolean().optional().describe("Run in child session with parent context. Defaults to true."),
|
|
3082
3543
|
},
|
|
3083
3544
|
async execute({ ref, query, task_prompt, dispatch_agent, as_subtask }, context) {
|
|
@@ -3110,10 +3571,10 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3110
3571
|
}
|
|
3111
3572
|
|
|
3112
3573
|
const useSubtask = as_subtask ?? true
|
|
3113
|
-
const
|
|
3114
|
-
const
|
|
3574
|
+
const dispatchTarget = resolveDispatchTarget(dispatch_agent, "general")
|
|
3575
|
+
const hintedModel = parseModelHint(shown.modelHint)
|
|
3576
|
+
const model = dispatchTarget.model ?? hintedModel
|
|
3115
3577
|
const tools = parseToolPolicy(shown.toolPolicy)
|
|
3116
|
-
|
|
3117
3578
|
writeStructuredEvent({
|
|
3118
3579
|
event: "subagent_started",
|
|
3119
3580
|
sessionId: context.sessionID,
|
|
@@ -3121,7 +3582,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3121
3582
|
input: {
|
|
3122
3583
|
ref: resolved.ref,
|
|
3123
3584
|
stashAgent: shown.name,
|
|
3124
|
-
dispatchAgent:
|
|
3585
|
+
dispatchAgent: dispatchTarget.agent,
|
|
3586
|
+
requestedDispatchTarget: dispatchTarget.requested,
|
|
3125
3587
|
taskPrompt: task_prompt,
|
|
3126
3588
|
advisoryToolPolicy: shown.toolPolicy ?? null,
|
|
3127
3589
|
appliedToolPolicy: tools ?? null,
|
|
@@ -3143,7 +3605,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3143
3605
|
if (!targetSession.ok) return JSON.stringify(targetSession)
|
|
3144
3606
|
|
|
3145
3607
|
const promptBody: SessionPromptBody = {
|
|
3146
|
-
agent:
|
|
3608
|
+
agent: dispatchTarget.agent,
|
|
3147
3609
|
system: shown.prompt,
|
|
3148
3610
|
parts: [{ type: "text", text: task_prompt }],
|
|
3149
3611
|
}
|
|
@@ -3163,13 +3625,26 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3163
3625
|
if (!promptResponse.ok) return JSON.stringify(promptResponse)
|
|
3164
3626
|
|
|
3165
3627
|
const childText = extractText(promptResponse.data.parts)
|
|
3166
|
-
const candidates = extractCandidatesFromText({
|
|
3628
|
+
const candidates = extractCandidatesFromText({
|
|
3629
|
+
harness: "opencode",
|
|
3630
|
+
sessionId: targetSession.sessionID,
|
|
3631
|
+
text: childText,
|
|
3632
|
+
evidence: [resolved.ref, task_prompt],
|
|
3633
|
+
sourcePaths: [OPENCODE_EVENT_LOG, OPENCODE_CANDIDATE_LOG],
|
|
3634
|
+
targetRefHints: [resolved.ref],
|
|
3635
|
+
})
|
|
3167
3636
|
if (candidates.length > 0) appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
|
|
3168
3637
|
writeStructuredEvent({
|
|
3169
3638
|
event: "subagent_completed",
|
|
3170
3639
|
sessionId: context.sessionID,
|
|
3171
3640
|
scope: buildEventScope(context.sessionID, context.directory, context.agent),
|
|
3172
|
-
input: {
|
|
3641
|
+
input: {
|
|
3642
|
+
childSessionID: targetSession.sessionID,
|
|
3643
|
+
stashAgent: shown.name,
|
|
3644
|
+
dispatchAgent: dispatchTarget.agent,
|
|
3645
|
+
requestedDispatchTarget: dispatchTarget.requested,
|
|
3646
|
+
dispatchModel: model ?? null,
|
|
3647
|
+
},
|
|
3173
3648
|
memory: { parentSessionID: context.sessionID, childSessionID: targetSession.sessionID, candidatesCreated: candidates.length },
|
|
3174
3649
|
refs: [resolved.ref],
|
|
3175
3650
|
outcome: { status: "ok" },
|
|
@@ -3179,7 +3654,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3179
3654
|
ok: true,
|
|
3180
3655
|
ref: resolved.ref,
|
|
3181
3656
|
stashAgent: shown.name,
|
|
3182
|
-
dispatchAgent:
|
|
3657
|
+
dispatchAgent: dispatchTarget.agent,
|
|
3658
|
+
requestedDispatchTarget: dispatchTarget.requested,
|
|
3183
3659
|
usedSubtask: useSubtask,
|
|
3184
3660
|
sessionID: targetSession.sessionID,
|
|
3185
3661
|
model,
|
|
@@ -3194,7 +3670,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3194
3670
|
ref: tool.schema.string().optional().describe("Command ref from akm_search (e.g. command:review.md)."),
|
|
3195
3671
|
query: tool.schema.string().optional().describe("If ref is omitted, resolve best matching stash command for this query."),
|
|
3196
3672
|
arguments: tool.schema.string().optional().describe("Command arguments used for $ARGUMENTS and positional placeholders ($1, $2, ...)."),
|
|
3197
|
-
dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the rendered command. Defaults to current agent."),
|
|
3673
|
+
dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the rendered command, or a provider/model override like 'openai/gpt-5.3-codex'. Defaults to current agent."),
|
|
3198
3674
|
as_subtask: tool.schema.boolean().optional().describe("Run in child session with parent context. Defaults to false."),
|
|
3199
3675
|
},
|
|
3200
3676
|
async execute({ ref, query, arguments: commandArguments, dispatch_agent, as_subtask }, context) {
|
|
@@ -3221,7 +3697,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3221
3697
|
const argsText = commandArguments ?? ""
|
|
3222
3698
|
const rendered = renderCommandTemplate(template, argsText)
|
|
3223
3699
|
const useSubtask = as_subtask ?? false
|
|
3224
|
-
const
|
|
3700
|
+
const fallbackAgent = typeof context.agent === "string" && context.agent.trim() ? context.agent : "general"
|
|
3701
|
+
const dispatchTarget = resolveDispatchTarget(dispatch_agent, fallbackAgent)
|
|
3225
3702
|
|
|
3226
3703
|
const targetSession = await ensureTargetSessionID({
|
|
3227
3704
|
useSubtask,
|
|
@@ -3242,7 +3719,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3242
3719
|
failureMessage: `Failed to execute command ${resolved.ref}`,
|
|
3243
3720
|
ref: resolved.ref,
|
|
3244
3721
|
promptBody: {
|
|
3245
|
-
agent:
|
|
3722
|
+
agent: dispatchTarget.agent,
|
|
3723
|
+
model: dispatchTarget.model,
|
|
3246
3724
|
parts: [{ type: "text", text: rendered }],
|
|
3247
3725
|
},
|
|
3248
3726
|
})
|
|
@@ -3252,75 +3730,84 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3252
3730
|
ok: true,
|
|
3253
3731
|
ref: resolved.ref,
|
|
3254
3732
|
stashCommand: shown.name,
|
|
3255
|
-
dispatchAgent:
|
|
3733
|
+
dispatchAgent: dispatchTarget.agent,
|
|
3734
|
+
requestedDispatchTarget: dispatchTarget.requested,
|
|
3256
3735
|
usedSubtask: useSubtask,
|
|
3257
3736
|
sessionID: targetSession.sessionID,
|
|
3737
|
+
model: dispatchTarget.model,
|
|
3258
3738
|
arguments: argsText,
|
|
3259
3739
|
renderedTemplate: rendered,
|
|
3260
3740
|
text: extractText(promptResponse.data.parts),
|
|
3261
3741
|
})
|
|
3262
3742
|
},
|
|
3263
3743
|
}),
|
|
3264
|
-
|
|
3265
|
-
description: "
|
|
3744
|
+
akm_env: tool({
|
|
3745
|
+
description: "Access AKM env assets (.env files) without surfacing values. 'list' returns env refs. 'path' returns the on-disk file path. 'run' injects the env into a child command without values reaching stdout — the preferred agent-safe path. action='path' and action='run' require confirm:true.",
|
|
3266
3746
|
args: {
|
|
3267
|
-
action: tool.schema.enum(["list", "
|
|
3268
|
-
ref: tool.schema.string().optional().describe("
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
value: tool.schema.string().optional().describe("Value to store. Never echoed back."),
|
|
3272
|
-
comment: tool.schema.string().optional().describe("Optional inline '# comment' written above the key for 'set'."),
|
|
3273
|
-
confirm: tool.schema.boolean().optional().describe("Must be true for sensitive actions like show and unset."),
|
|
3747
|
+
action: tool.schema.enum(["list", "path", "run"]).describe("Env subcommand. 'list' returns refs; 'path' returns the file path; 'run' injects env into a child command."),
|
|
3748
|
+
ref: tool.schema.string().optional().describe("Env ref such as env:prod or env:myapp. Required for path/run."),
|
|
3749
|
+
cmd: tool.schema.string().optional().describe("Command to run with env injected. Required for action='run'. Example: 'npm start'."),
|
|
3750
|
+
confirm: tool.schema.boolean().optional().describe("Must be true for path and run actions."),
|
|
3274
3751
|
},
|
|
3275
3752
|
async execute(input) {
|
|
3276
3753
|
const blocked = blockedToolResponse(input as Record<string, unknown>)
|
|
3277
3754
|
if (blocked) return blocked
|
|
3278
|
-
const { action, ref,
|
|
3279
|
-
const logMeta = { toolName: "
|
|
3755
|
+
const { action, ref, cmd, confirm } = input
|
|
3756
|
+
const logMeta = { toolName: "akm_env" }
|
|
3280
3757
|
switch (action) {
|
|
3281
3758
|
case "list": {
|
|
3282
|
-
|
|
3283
|
-
if (ref) args.push(ref)
|
|
3284
|
-
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
3285
|
-
}
|
|
3286
|
-
case "show": {
|
|
3287
|
-
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='show'." })
|
|
3288
|
-
return runCli(client as unknown as LogCapableClient, ["vault", "show", ref], logMeta)
|
|
3289
|
-
}
|
|
3290
|
-
case "create": {
|
|
3291
|
-
if (!name) return JSON.stringify({ ok: false, error: "'name' is required for action='create'." })
|
|
3292
|
-
return runCli(client as unknown as LogCapableClient, ["vault", "create", name], logMeta)
|
|
3759
|
+
return runCli(client as unknown as LogCapableClient, ["env", "list"], logMeta)
|
|
3293
3760
|
}
|
|
3294
|
-
case "
|
|
3295
|
-
if (
|
|
3296
|
-
if (!
|
|
3297
|
-
|
|
3298
|
-
if (value != null) args.push(value)
|
|
3299
|
-
if (comment) args.push("--comment", comment)
|
|
3300
|
-
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
3761
|
+
case "path": {
|
|
3762
|
+
if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_env action='path' requires confirm:true." })
|
|
3763
|
+
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='path'." })
|
|
3764
|
+
return runCli(client as unknown as LogCapableClient, ["env", "path", ref], logMeta)
|
|
3301
3765
|
}
|
|
3302
|
-
case "
|
|
3303
|
-
if (
|
|
3304
|
-
if (!
|
|
3305
|
-
|
|
3766
|
+
case "run": {
|
|
3767
|
+
if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_env action='run' requires confirm:true." })
|
|
3768
|
+
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='run'." })
|
|
3769
|
+
if (!cmd) return JSON.stringify({ ok: false, error: "'cmd' is required for action='run'." })
|
|
3770
|
+
return runCli(client as unknown as LogCapableClient, ["env", "run", ref, "--", ...cmd.split(/\s+/)], logMeta)
|
|
3306
3771
|
}
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3772
|
+
}
|
|
3773
|
+
},
|
|
3774
|
+
}),
|
|
3775
|
+
akm_secret: tool({
|
|
3776
|
+
description: "Inspect whole-file AKM secrets without surfacing contents. 'list' returns secret refs only. 'path' returns the on-disk file path for `_FILE`-style consumers. action='path' requires confirm:true.",
|
|
3777
|
+
args: {
|
|
3778
|
+
action: tool.schema.enum(["list", "path"]).describe("Secret subcommand. 'path' returns the absolute file path without reading or echoing the secret bytes."),
|
|
3779
|
+
ref: tool.schema.string().optional().describe("Secret ref such as secret:deploy-key. Required for action='path'."),
|
|
3780
|
+
confirm: tool.schema.boolean().optional().describe("Must be true for sensitive actions like path."),
|
|
3781
|
+
},
|
|
3782
|
+
async execute(input) {
|
|
3783
|
+
const blocked = blockedToolResponse(input as Record<string, unknown>)
|
|
3784
|
+
if (blocked) return blocked
|
|
3785
|
+
const { action, ref, confirm } = input
|
|
3786
|
+
const logMeta = { toolName: "akm_secret" }
|
|
3787
|
+
switch (action) {
|
|
3788
|
+
case "list":
|
|
3789
|
+
return runCli(client as unknown as LogCapableClient, ["secret", "list"], logMeta)
|
|
3790
|
+
case "path": {
|
|
3791
|
+
if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_secret action='path' requires confirm:true because secret paths are sensitive." })
|
|
3792
|
+
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='path'." })
|
|
3311
3793
|
const command = resolveAkmCommand()
|
|
3312
|
-
if (typeof command
|
|
3794
|
+
if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
|
|
3313
3795
|
try {
|
|
3314
|
-
const
|
|
3796
|
+
const filePath = execResolvedAkm(command, ["secret", "path", ref], {
|
|
3315
3797
|
encoding: "utf8",
|
|
3316
3798
|
timeout: 30_000,
|
|
3799
|
+
}).toString().trim()
|
|
3800
|
+
return JSON.stringify({
|
|
3801
|
+
ok: true,
|
|
3802
|
+
ref,
|
|
3803
|
+
filePath,
|
|
3804
|
+
usage: "Pass this file path to trusted `_FILE`-style consumers or shells. The plugin did not read or surface the secret contents.",
|
|
3317
3805
|
})
|
|
3318
|
-
return JSON.stringify({ ok: true, ref, shell: stdout.trim() })
|
|
3319
3806
|
} catch (error: unknown) {
|
|
3320
|
-
await writePluginLog(logClient, "error", "AKM
|
|
3321
|
-
subsystem: "
|
|
3322
|
-
toolName: "
|
|
3323
|
-
action: "
|
|
3807
|
+
await writePluginLog(logClient, "error", "AKM secret path failed", {
|
|
3808
|
+
subsystem: "secret",
|
|
3809
|
+
toolName: "akm_secret",
|
|
3810
|
+
action: "path",
|
|
3324
3811
|
ref,
|
|
3325
3812
|
error: formatCliError(error),
|
|
3326
3813
|
})
|
|
@@ -3347,8 +3834,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3347
3834
|
]).describe("Wiki subcommand."),
|
|
3348
3835
|
name: tool.schema.string().optional().describe("Wiki name (required for every action except 'list')."),
|
|
3349
3836
|
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."),
|
|
3350
|
-
writable: tool.schema.boolean().optional().describe("When registering a git-backed source, mark it as push-writable (used by `akm
|
|
3351
|
-
trust: tool.schema.boolean().optional().describe("Bypass install-audit blocking for this registration only."),
|
|
3837
|
+
writable: tool.schema.boolean().optional().describe("When registering a git-backed source, mark it as push-writable (used by `akm sync`; see akm_help topic='sync')."),
|
|
3352
3838
|
max_pages: tool.schema.number().optional().describe("Crawler page cap when registering a website (default 50)."),
|
|
3353
3839
|
max_depth: tool.schema.number().optional().describe("Crawler depth cap when registering a website (default 3)."),
|
|
3354
3840
|
query: tool.schema.string().optional().describe("Query string for action='search'."),
|
|
@@ -3359,7 +3845,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3359
3845
|
force: tool.schema.boolean().optional().describe("Required for action='remove'."),
|
|
3360
3846
|
with_sources: tool.schema.boolean().optional().describe("When removing, also delete the raw/ sources (default false)."),
|
|
3361
3847
|
},
|
|
3362
|
-
async execute({ action, name, source_ref, writable,
|
|
3848
|
+
async execute({ action, name, source_ref, writable, max_pages, max_depth, query, limit, source, as_slug, content, force, with_sources }) {
|
|
3363
3849
|
const logMeta = { toolName: "akm_wiki" }
|
|
3364
3850
|
const requireName = () => {
|
|
3365
3851
|
if (!name) return JSON.stringify({ ok: false, error: `'name' is required for action='${action}'.` })
|
|
@@ -3396,7 +3882,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3396
3882
|
if (!source_ref) return JSON.stringify({ ok: false, error: "'source_ref' is required for action='register'." })
|
|
3397
3883
|
const args = ["wiki", "register", name!, source_ref]
|
|
3398
3884
|
if (writable) args.push("--writable")
|
|
3399
|
-
if (trust) args.push("--trust")
|
|
3400
3885
|
if (max_pages != null) args.push("--max-pages", String(max_pages))
|
|
3401
3886
|
if (max_depth != null) args.push("--max-depth", String(max_depth))
|
|
3402
3887
|
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
@@ -3422,9 +3907,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3422
3907
|
if (as_slug) args.push("--as", as_slug)
|
|
3423
3908
|
if (source === "-" && content) {
|
|
3424
3909
|
const command = resolveAkmCommand()
|
|
3425
|
-
if (typeof command
|
|
3910
|
+
if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
|
|
3426
3911
|
try {
|
|
3427
|
-
const stdout =
|
|
3912
|
+
const stdout = execResolvedAkm(command, [...args, "--format", "json"], {
|
|
3428
3913
|
encoding: "utf8",
|
|
3429
3914
|
timeout: 60_000,
|
|
3430
3915
|
input: content,
|
|
@@ -3552,9 +4037,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3552
4037
|
case "template": {
|
|
3553
4038
|
// The workflow template is emitted as raw markdown, not JSON.
|
|
3554
4039
|
const command = resolveAkmCommand()
|
|
3555
|
-
if (typeof command
|
|
4040
|
+
if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
|
|
3556
4041
|
try {
|
|
3557
|
-
const stdout =
|
|
4042
|
+
const stdout = execResolvedAkm(command, ["workflow", "template"], {
|
|
3558
4043
|
encoding: "utf8",
|
|
3559
4044
|
timeout: 30_000,
|
|
3560
4045
|
})
|
|
@@ -3579,14 +4064,26 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3579
4064
|
},
|
|
3580
4065
|
}),
|
|
3581
4066
|
akm_proposal: tool({
|
|
3582
|
-
description: "Operate the AKM v0.
|
|
4067
|
+
description: "Operate the AKM v0.8.0 proposal queue — list/show/diff/accept/reject pending drafts, or action='drain' for deterministic BULK triage of the whole backlog. All proposal-producing commands (improve, propose, plus plugin-emitted proposals) write through this queue. Acceptance runs full validation before promoting; rejection archives the draft. Always confirm with the user before action='accept', 'reject', or 'drain'. action='drain' clears the standing pending backlog by a deterministic policy (default queue/stage mode; pass promote:true to actually accept) — mutating, commits to git, no batch revert; always preview with dry_run:true first. This and the automatic improve `processes.triage` pre-pass supersede manual one-by-one queue management.",
|
|
3583
4068
|
args: {
|
|
3584
|
-
action: tool.schema.enum(["list", "show", "diff", "accept", "reject"]).describe("Proposal subcommand."),
|
|
4069
|
+
action: tool.schema.enum(["list", "show", "diff", "accept", "reject", "drain"]).describe("Proposal subcommand."),
|
|
3585
4070
|
id: tool.schema.string().optional().describe("Proposal id. Required for show/diff/accept/reject."),
|
|
3586
4071
|
status: tool.schema.enum(["pending", "accepted", "rejected"]).optional().describe("Filter for action='list'."),
|
|
3587
4072
|
reason: tool.schema.string().optional().describe("Required for action='reject'. Recorded with the archived proposal."),
|
|
4073
|
+
confirm: tool.schema.boolean().optional().describe("Must be true for action='accept', 'reject', and 'drain'."),
|
|
4074
|
+
policy: tool.schema.string().optional().describe("action='drain' only: built-in preset (personal-stash|conservative|manual) or path to a policy file."),
|
|
4075
|
+
promote: tool.schema.boolean().optional().describe("action='drain' only: promote (accept) matching proposals. Default is queue/stage mode (no writes to assets)."),
|
|
4076
|
+
dry_run: tool.schema.boolean().optional().describe("action='drain' only: list the planned accept/reject/defer set without writing. Always preview first."),
|
|
4077
|
+
max_accepts: tool.schema.number().optional().describe("action='drain' only: hard per-run accept ceiling."),
|
|
4078
|
+
max_diff_lines: tool.schema.number().optional().describe("action='drain' only: defer accepts whose proposed content exceeds this many lines."),
|
|
4079
|
+
older_than: tool.schema.number().optional().describe("action='drain' only: only consider proposals created more than this many days ago."),
|
|
4080
|
+
judgment: tool.schema.boolean().optional().describe("action='drain' only: opt into the judgment tier for deferred items."),
|
|
4081
|
+
profile: tool.schema.string().optional().describe("action='drain' only: read the triage block (policy, applyMode, ceilings, judgment) from this improve profile."),
|
|
3588
4082
|
},
|
|
3589
|
-
async execute(
|
|
4083
|
+
async execute(input) {
|
|
4084
|
+
const blocked = blockedToolResponse(input as Record<string, unknown>)
|
|
4085
|
+
if (blocked) return blocked
|
|
4086
|
+
const { action, id, status, reason, confirm, policy, promote, dry_run, max_accepts, max_diff_lines, older_than, judgment, profile } = input
|
|
3590
4087
|
const logMeta = { toolName: "akm_proposal" }
|
|
3591
4088
|
switch (action) {
|
|
3592
4089
|
case "list": {
|
|
@@ -3603,51 +4100,89 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3603
4100
|
return runCli(client as unknown as LogCapableClient, ["proposal", "diff", id], logMeta)
|
|
3604
4101
|
}
|
|
3605
4102
|
case "accept": {
|
|
4103
|
+
if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_proposal action='accept' requires confirm:true because it mutates the proposal queue." })
|
|
3606
4104
|
if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='accept'. Confirm with the user before accepting." })
|
|
3607
|
-
|
|
4105
|
+
const acceptResult = await runCli(client as unknown as LogCapableClient, ["proposal", "accept", id, "--yes"], logMeta)
|
|
4106
|
+
// Invalidate the proposal-count cache so the next getPendingProposalCount() call
|
|
4107
|
+
// reflects the updated queue immediately (WS-7a: no stale 60s TTL after mutations).
|
|
4108
|
+
pendingProposalSummaryCache.clear()
|
|
4109
|
+
return acceptResult
|
|
3608
4110
|
}
|
|
3609
4111
|
case "reject": {
|
|
4112
|
+
if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_proposal action='reject' requires confirm:true because it mutates the proposal queue." })
|
|
3610
4113
|
if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='reject'. Confirm with the user before rejecting." })
|
|
3611
4114
|
if (!reason || !reason.trim()) return JSON.stringify({ ok: false, error: "'reason' is required for action='reject'. Ask the user why the proposal is being rejected." })
|
|
3612
|
-
|
|
4115
|
+
const rejectResult = await runCli(client as unknown as LogCapableClient, ["proposal", "reject", id, "--reason", reason, "--yes"], logMeta)
|
|
4116
|
+
// Invalidate the proposal-count cache (WS-7a).
|
|
4117
|
+
pendingProposalSummaryCache.clear()
|
|
4118
|
+
return rejectResult
|
|
4119
|
+
}
|
|
4120
|
+
case "drain": {
|
|
4121
|
+
if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_proposal action='drain' requires confirm:true because it bulk-mutates the proposal queue and commits to git." })
|
|
4122
|
+
const args = ["proposal", "drain"]
|
|
4123
|
+
if (policy) args.push("--policy", policy)
|
|
4124
|
+
if (promote) args.push("--promote")
|
|
4125
|
+
if (dry_run) args.push("--dry-run")
|
|
4126
|
+
else args.push("--yes")
|
|
4127
|
+
if (typeof max_accepts === "number") args.push("--max-accepts", String(max_accepts))
|
|
4128
|
+
if (typeof max_diff_lines === "number") args.push("--max-diff-lines", String(max_diff_lines))
|
|
4129
|
+
if (typeof older_than === "number") args.push("--older-than", String(older_than))
|
|
4130
|
+
if (judgment) args.push("--judgment")
|
|
4131
|
+
if (profile) args.push("--profile", profile)
|
|
4132
|
+
const drainResult = await runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
4133
|
+
// A real drain (not a dry run) mutates the queue; invalidate the count cache (WS-7a).
|
|
4134
|
+
if (!dry_run) pendingProposalSummaryCache.clear()
|
|
4135
|
+
return drainResult
|
|
3613
4136
|
}
|
|
3614
4137
|
}
|
|
3615
4138
|
},
|
|
3616
4139
|
}),
|
|
3617
|
-
|
|
3618
|
-
description: "Generate
|
|
4140
|
+
akm_improve: tool({
|
|
4141
|
+
description: "Generate AKM improvement proposals for an existing ref, an asset type, or the broader stash. This is the v0.8.0 replacement for the old reflect/distill flow. Output lands in the proposal queue only — never mutates live stash content. Requires `defaults.agent` (with a matching `profiles.agent.<name>` entry) to be set for agent-backed proposal generation; the legacy `agent.default` shape is auto-migrated on load.",
|
|
3619
4142
|
args: {
|
|
3620
|
-
|
|
3621
|
-
task: tool.schema.string().optional().describe("
|
|
4143
|
+
scope: tool.schema.string().optional().describe("Optional asset type or [origin//]type:name ref to improve. When omitted, improves the current stash scope."),
|
|
4144
|
+
task: tool.schema.string().optional().describe("Optional extra guidance for this improvement pass."),
|
|
4145
|
+
dry_run: tool.schema.boolean().optional().describe("Show planned actions without generating proposals."),
|
|
3622
4146
|
},
|
|
3623
|
-
async execute({
|
|
3624
|
-
|
|
3625
|
-
|
|
4147
|
+
async execute({ scope, task, dry_run }, context) {
|
|
4148
|
+
if (!dry_run) {
|
|
4149
|
+
await ensureFreshProposalCheckpoint(logClient, {
|
|
4150
|
+
sessionID: context.sessionID,
|
|
4151
|
+
directory: context.directory,
|
|
4152
|
+
agent: context.agent,
|
|
4153
|
+
}, "pre-improve")
|
|
4154
|
+
}
|
|
4155
|
+
const args = ["improve"]
|
|
4156
|
+
if (scope) args.push(scope)
|
|
3626
4157
|
if (task) args.push("--task", task)
|
|
3627
|
-
|
|
4158
|
+
if (dry_run) args.push("--dry-run")
|
|
4159
|
+
const improveResult = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_improve", sessionID: context.sessionID, directory: context.directory, agent: context.agent })
|
|
4160
|
+
// Invalidate the proposal-count cache after improve (may have added proposals) (WS-7a).
|
|
4161
|
+
if (!dry_run) pendingProposalSummaryCache.clear()
|
|
4162
|
+
return improveResult
|
|
3628
4163
|
},
|
|
3629
4164
|
}),
|
|
3630
4165
|
akm_propose: tool({
|
|
3631
|
-
description: "Generate a new-asset proposal via the configured agent CLI. The asset is drafted as `quality:\"proposed\"` and lands in the proposal queue — never directly into curated content. Requires `agent
|
|
4166
|
+
description: "Generate a new-asset proposal via the configured agent CLI. The asset is drafted as `quality:\"proposed\"` and lands in the proposal queue — never directly into curated content. Requires `defaults.agent` (with a matching `profiles.agent.<name>` entry; run akm_setup first if missing). The legacy `agent.default` shape is auto-migrated on load.",
|
|
3632
4167
|
args: {
|
|
3633
4168
|
type: tool.schema.enum(["skill", "command", "agent", "knowledge", "lesson", "script", "workflow", "wiki"]).describe("Asset type for the new proposal."),
|
|
3634
4169
|
name: tool.schema.string().describe("Slug for the new asset (matches the standard ref grammar)."),
|
|
3635
|
-
task: tool.schema.string().describe("
|
|
3636
|
-
|
|
3637
|
-
async execute({ type, name, task }) {
|
|
3638
|
-
if (!task || !task.trim()) return JSON.stringify({ ok: false, error: "'task' is required for akm_propose." })
|
|
3639
|
-
const args = ["propose", type, name, "--task", task]
|
|
3640
|
-
return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_propose" })
|
|
4170
|
+
task: tool.schema.string().optional().describe("Optional inline task text describing what the asset should do."),
|
|
4171
|
+
file: tool.schema.string().optional().describe("Optional UTF-8 file path to read the task text from. Exactly one of task/file is required."),
|
|
3641
4172
|
},
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
4173
|
+
async execute({ type, name, task, file }, context) {
|
|
4174
|
+
const hasTask = !!task?.trim()
|
|
4175
|
+
const hasFile = !!file?.trim()
|
|
4176
|
+
if (hasTask === hasFile) return JSON.stringify({ ok: false, error: "Exactly one of 'task' or 'file' is required for akm_propose." })
|
|
4177
|
+
await ensureFreshProposalCheckpoint(logClient, {
|
|
4178
|
+
sessionID: context.sessionID,
|
|
4179
|
+
directory: context.directory,
|
|
4180
|
+
agent: context.agent,
|
|
4181
|
+
}, "pre-propose")
|
|
4182
|
+
const args = ["propose", type, name]
|
|
4183
|
+
if (hasTask) args.push("--task", task!.trim())
|
|
4184
|
+
if (hasFile) args.push("--file", file!.trim())
|
|
4185
|
+
return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_propose", sessionID: context.sessionID, directory: context.directory, agent: context.agent })
|
|
3651
4186
|
},
|
|
3652
4187
|
}),
|
|
3653
4188
|
akm_init: tool({
|
|
@@ -3665,13 +4200,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3665
4200
|
},
|
|
3666
4201
|
async execute({ topic, command }) {
|
|
3667
4202
|
const cliCommand = resolveAkmCommand()
|
|
3668
|
-
if (typeof cliCommand
|
|
4203
|
+
if (typeof cliCommand === "object" && "ok" in cliCommand) return JSON.stringify(cliCommand)
|
|
3669
4204
|
const helpArgs = command && command.trim()
|
|
3670
4205
|
? [command.trim(), "--help"]
|
|
3671
4206
|
: ["--help"]
|
|
3672
4207
|
let helpText = ""
|
|
3673
4208
|
try {
|
|
3674
|
-
helpText =
|
|
4209
|
+
helpText = execResolvedAkm(cliCommand, helpArgs, {
|
|
3675
4210
|
encoding: "utf8",
|
|
3676
4211
|
timeout: 30_000,
|
|
3677
4212
|
}).toString().trim()
|
|
@@ -3694,13 +4229,14 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3694
4229
|
help: helpText,
|
|
3695
4230
|
workflowTopics: [
|
|
3696
4231
|
"proposal",
|
|
3697
|
-
"
|
|
3698
|
-
"reflect",
|
|
4232
|
+
"improve",
|
|
3699
4233
|
"propose",
|
|
3700
4234
|
"lesson",
|
|
4235
|
+
"tasks",
|
|
4236
|
+
"secret-safety",
|
|
3701
4237
|
"include-proposed",
|
|
3702
4238
|
"llm-features",
|
|
3703
|
-
"
|
|
4239
|
+
"env-safety",
|
|
3704
4240
|
],
|
|
3705
4241
|
})
|
|
3706
4242
|
},
|
|
@@ -3708,6 +4244,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3708
4244
|
},
|
|
3709
4245
|
}
|
|
3710
4246
|
}
|
|
3711
|
-
|
|
4247
|
+
|
|
3712
4248
|
export const server = AkmPlugin
|
|
3713
|
-
export default { server }
|
|
4249
|
+
export default { server, id: "akm-opencode" }
|