akm-opencode 0.8.2 → 0.9.0
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 +39 -239
- package/index.ts +699 -2469
- package/package.json +10 -8
- package/shared/akm-version.ts +45 -0
- package/shared/memory-candidates.ts +19 -15
- package/shared/memory-events.ts +7 -11
- package/shared/ref-extraction.ts +75 -216
- package/shared/state-files.ts +99 -0
- package/shared/vendor-semver.ts +112 -0
- package/agent/akm-curator.md +0 -66
- package/commands/akm-evolve-session.md +0 -15
- package/commands/akm-improve-asset.md +0 -9
- package/commands/akm-propose-asset.md +0 -8
- package/commands/akm-review-proposals.md +0 -8
- package/commands/akm-workflow-status.md +0 -7
package/index.ts
CHANGED
|
@@ -1,31 +1,22 @@
|
|
|
1
1
|
import { type Plugin, tool } from "@opencode-ai/plugin"
|
|
2
|
-
|
|
3
|
-
import {
|
|
2
|
+
// @ts-expect-error akm-cli does not publish declarations for this in-process entrypoint.
|
|
3
|
+
import { akmCurate } from "akm-cli/dist/commands/read/curate.js"
|
|
4
|
+
// @ts-expect-error akm-cli does not publish declarations for this in-process entrypoint.
|
|
5
|
+
import { akmSearch } from "akm-cli/dist/commands/read/search.js"
|
|
6
|
+
// @ts-expect-error akm-cli does not publish declarations for this in-process entrypoint.
|
|
7
|
+
import { akmShowUnified } from "akm-cli/dist/commands/read/show.js"
|
|
8
|
+
import { execFileSync, spawn } from "node:child_process"
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
4
10
|
import os from "node:os"
|
|
5
11
|
import path from "node:path"
|
|
6
12
|
import { fileURLToPath } from "node:url"
|
|
7
13
|
import { classifyFeedbackSignal, shouldSubmitAutomaticFeedback } from "./shared/feedback-signals"
|
|
8
|
-
import { appendCandidates, extractCandidatesFromText, getCandidateLogPath
|
|
9
|
-
import { appendMemoryEvent, getEventLogPath,
|
|
14
|
+
import { appendCandidates, extractCandidatesFromText, getCandidateLogPath } from "./shared/memory-candidates"
|
|
15
|
+
import { appendMemoryEvent, getEventLogPath, type AkmMemoryEvent } from "./shared/memory-events"
|
|
16
|
+
import { AKM_VERSION_RANGE, satisfiesAkmVersionRange } from "./shared/akm-version"
|
|
10
17
|
import { shouldRecall } from "./shared/recall-policy"
|
|
11
|
-
import { redactObject
|
|
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
|
-
}
|
|
18
|
+
import { redactObject } from "./shared/redaction"
|
|
19
|
+
import { extractAkmRefsFromString, validateRefCandidates } from "./shared/ref-extraction"
|
|
29
20
|
|
|
30
21
|
let resolvedAkmCommand = "akm"
|
|
31
22
|
|
|
@@ -40,11 +31,16 @@ export function __resetResolvedAkmForTests(): void {
|
|
|
40
31
|
|
|
41
32
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url))
|
|
42
33
|
const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
const AKM_REQUIRED_VERSION_RANGE =
|
|
34
|
+
// The version contract lives in the shared module (also used by the Claude
|
|
35
|
+
// hook). satisfiesAkmVersionRange() routes through the same vendored semver
|
|
36
|
+
// matcher; AKM_REQUIRED_VERSION_RANGE is just the display alias used in the
|
|
37
|
+
// diagnostics below.
|
|
38
|
+
const AKM_REQUIRED_VERSION_RANGE = AKM_VERSION_RANGE
|
|
39
|
+
// The consent banner's "install this" recommendation is deliberately a single
|
|
40
|
+
// version floor rather than the full AKM_VERSION_RANGE (which is an
|
|
41
|
+
// OR-list of accepted ranges, not a valid single npm install specifier).
|
|
42
|
+
// Keep it in sync with the lowest currently-recommended stable 0.9.x release.
|
|
43
|
+
const AKM_RECOMMENDED_INSTALL_REF = "akm-cli@^0.9.0"
|
|
48
44
|
|
|
49
45
|
const AKM_AUTO_FEEDBACK = (process.env.AKM_AUTO_FEEDBACK ?? "1") !== "0"
|
|
50
46
|
const AKM_AUTO_MEMORY = (process.env.AKM_AUTO_MEMORY ?? "1") !== "0"
|
|
@@ -54,15 +50,19 @@ const AKM_PENDING_PROPOSAL_TIMEOUT_MS = Math.max(500, (Number(process.env.AKM_PE
|
|
|
54
50
|
const AKM_CURATE_LIMIT = Math.max(1, Number(process.env.AKM_CURATE_LIMIT ?? "5") || 5)
|
|
55
51
|
const AKM_CURATE_MIN_CHARS = Math.max(1, Number(process.env.AKM_CURATE_MIN_CHARS ?? "16") || 16)
|
|
56
52
|
const AKM_CURATE_TIMEOUT_MS = Math.max(1_000, (Number(process.env.AKM_CURATE_TIMEOUT ?? "8") || 8) * 1_000)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
// 13: "Memory leaks" — sessionBuffer previously grew without bound for the
|
|
54
|
+
// life of a session (a long-running session accumulates one entry per
|
|
55
|
+
// observed tool ref / memory intent). Cap it drop-oldest, matching the
|
|
56
|
+
// `.slice(-8)` cap style already used by retrospectiveState.recentRefs.
|
|
57
|
+
const AKM_SESSION_BUFFER_MAX_ENTRIES = Math.max(1, Number(process.env.AKM_SESSION_BUFFER_MAX_ENTRIES ?? "200") || 200)
|
|
58
|
+
// Best-effort sweep age for orphaned curated tmp files (os.tmpdir()/akm-opencode/curated).
|
|
59
|
+
// A session that ends without ever firing session.deleted (host crash, forced
|
|
60
|
+
// kill) would otherwise leak its curated file on disk forever.
|
|
61
|
+
const CURATED_FILE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
61
62
|
const AKM_RETROSPECTIVE_FEEDBACK_RE = createRetrospectiveFeedbackRegex()
|
|
62
63
|
const AKM_RETROSPECTIVE_NEGATIVE_RE = createRetrospectiveNegativeRegex()
|
|
63
64
|
const AKM_EXPLICIT_CORRECTION_RE = createExplicitCorrectionRegex()
|
|
64
65
|
const PLUGIN_VERSION = readPackageVersion()
|
|
65
|
-
const PLUGIN_INSTALL_LOCATION = moduleDir
|
|
66
66
|
const OPENCODE_EVENT_LOG = getEventLogPath("opencode")
|
|
67
67
|
const OPENCODE_CANDIDATE_LOG = getCandidateLogPath("opencode")
|
|
68
68
|
|
|
@@ -88,20 +88,32 @@ type SessionBufferEntry = {
|
|
|
88
88
|
checkpointed?: boolean
|
|
89
89
|
}
|
|
90
90
|
const sessionBuffer = new Map<string, SessionBufferEntry[]>()
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// Event-driven extraction (opencode): opencode has no true "session end" event
|
|
92
|
+
// and `session.idle` fires after EVERY turn. To avoid flooding extract while a
|
|
93
|
+
// session is actively worked, we min-interval-gate per session — at most one
|
|
94
|
+
// extract per AKM_EXTRACT_MIN_INTERVAL_MS. The akm content-hash ledger
|
|
95
|
+
// (akm-cli #602 / ≥0.9.0-beta.33) further no-ops unchanged content for free.
|
|
96
|
+
// The hourly `akm improve` extract pass (the periodic backstop) catches the
|
|
97
|
+
// final delta after the last turn.
|
|
98
|
+
const sessionLastExtractAt = new Map<string, number>()
|
|
99
|
+
const AKM_EXTRACT_MIN_INTERVAL_MS = (() => {
|
|
100
|
+
const raw = Number(process.env.AKM_EXTRACT_MIN_INTERVAL_MS)
|
|
101
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 10 * 60 * 1000 // default 10 min
|
|
102
|
+
})()
|
|
93
103
|
const pendingProposalSummaryCache = new Map<string, { count: number; expiresAt: number; unsupported?: boolean }>()
|
|
94
104
|
const retrospectiveState = new Map<string, { recentRefs: string[]; lastNegativeSignalAt?: number }>()
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
105
|
+
let cachedAkmBundleDir: string | undefined
|
|
106
|
+
|
|
107
|
+
// Passive ref observation (narrower than explicit show/search input, so
|
|
108
|
+
// ordinary repository paths cannot become automatic feedback targets) lives in
|
|
109
|
+
// claude/shared/ref-extraction.ts. This module deliberately keeps no local copy
|
|
110
|
+
// of the concept-root regex: a second copy silently drifted from the canonical
|
|
111
|
+
// AKM 0.9 root list (it still matched `wikis/` and never matched `facts/`,
|
|
112
|
+
// `instructions/`, or `sessions/`). extractAkmRefsFromString() is the shared
|
|
113
|
+
// whitespace-token extractor and is the single source of truth here.
|
|
102
114
|
const PROPOSED_QUALITY_WARNING = "Do not treat proposed assets as curated until accepted."
|
|
103
115
|
const AKM_WORKFLOW_INSTRUCTION = [
|
|
104
|
-
"# AKM workflow (v0.
|
|
116
|
+
"# AKM workflow (v0.9)",
|
|
105
117
|
"",
|
|
106
118
|
"Use AKM as a reusable knowledge and workflow stash.",
|
|
107
119
|
"",
|
|
@@ -109,11 +121,8 @@ const AKM_WORKFLOW_INSTRUCTION = [
|
|
|
109
121
|
"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.",
|
|
110
122
|
"2. Use `akm_show <ref>` before relying on an asset.",
|
|
111
123
|
"3. Record `akm_feedback` after the result is known.",
|
|
112
|
-
"4. Use
|
|
113
|
-
|
|
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.",
|
|
115
|
-
`7. ${PROPOSED_QUALITY_WARNING}`,
|
|
116
|
-
"8. Never accept or reject proposals, push saves, remove sources, or access env values / secret material without explicit user approval.",
|
|
124
|
+
"4. Use `akm_remember` to preserve durable project knowledge.",
|
|
125
|
+
`5. ${PROPOSED_QUALITY_WARNING}`,
|
|
117
126
|
].join("\n")
|
|
118
127
|
|
|
119
128
|
function readPackageVersion(): string {
|
|
@@ -148,80 +157,6 @@ function createExplicitCorrectionRegex(): RegExp {
|
|
|
148
157
|
return /\b(this was wrong|that was wrong|you were wrong|incorrect|not correct)\b/i
|
|
149
158
|
}
|
|
150
159
|
|
|
151
|
-
const CURATOR_AGENT_PROMPT_FALLBACK = `You are the AKM curator — a compound-engineering agent that keeps the user's AKM stash improving every time the main agent finishes a task.
|
|
152
|
-
|
|
153
|
-
Inputs you should inspect:
|
|
154
|
-
1. OpenCode app logs that include the "akm-opencode" service (feedback, memory, tool invocations).
|
|
155
|
-
2. Session-summary memories named memory:opencode-session-*.
|
|
156
|
-
3. The live stash: call akm_search "" --limit 50 (and akm_show <ref>) to enumerate assets; reach for akm_help topic="list sources" if you need the configured-sources view.
|
|
157
|
-
4. Parent-session context via akm_parent_messages when this session was dispatched as a child.
|
|
158
|
-
|
|
159
|
-
Signals to act on:
|
|
160
|
-
- Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
|
|
161
|
-
- Cold refs: assets tied to failures or user complaints. Record akm_feedback <ref> negative --note "<excerpt>" and open the asset for review.
|
|
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.
|
|
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").
|
|
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.
|
|
165
|
-
- Duplicates / drift: near-identical descriptions or overlapping responsibilities. Propose a consolidation.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
170
|
-
|
|
171
|
-
Rules of engagement:
|
|
172
|
-
- Never apply destructive changes without explicit user approval.
|
|
173
|
-
- Report findings as a prioritized action list of concrete akm_* tool calls the user can run.
|
|
174
|
-
- Prefer small, reversible edits: promote via positive feedback, draft a candidate skill, or clone and tweak.
|
|
175
|
-
- When drafting new assets, write them into the working stash directory under skills/, commands/, agents/, knowledge/, or scripts/. Use akm_help (topic="config" / topic="reindex") to look up the right CLI invocation when you need the stash path or want to force a reindex.
|
|
176
|
-
- When finished, persist your own summary with akm_remember (name: curator-run-<timestamp>) so the next curator run can build on yours.
|
|
177
|
-
|
|
178
|
-
Output shape: end every run with a markdown report that has these sections:
|
|
179
|
-
|
|
180
|
-
## Hot assets (promote)
|
|
181
|
-
- <ref> — why it helped — command to run
|
|
182
|
-
|
|
183
|
-
## Cold assets (investigate)
|
|
184
|
-
- <ref> — failure signal — proposed fix
|
|
185
|
-
|
|
186
|
-
## Lesson candidates
|
|
187
|
-
- <theme> — evidence refs — improve or propose command to run
|
|
188
|
-
|
|
189
|
-
## Coverage gaps
|
|
190
|
-
- <theme> — proposed asset (type, name, one-line description)
|
|
191
|
-
|
|
192
|
-
## Pending proposals
|
|
193
|
-
- <proposal id> — summary — accept/reject/revise recommendation
|
|
194
|
-
|
|
195
|
-
## Duplicates / drift
|
|
196
|
-
- <ref a> vs <ref b> — consolidation proposal
|
|
197
|
-
|
|
198
|
-
## Wiki health
|
|
199
|
-
- <wiki> — lint findings (orphan, broken-xref, uncited-raw, stale-index) with suggested fix
|
|
200
|
-
|
|
201
|
-
## Workflow health
|
|
202
|
-
- <workflow|runId> — blocked/failed state — resume or escalate
|
|
203
|
-
|
|
204
|
-
## Housekeeping
|
|
205
|
-
- stale memories, reindex needs, config tweaks
|
|
206
|
-
`
|
|
207
|
-
|
|
208
|
-
function loadCuratorAgentPrompt(): string {
|
|
209
|
-
try {
|
|
210
|
-
const raw = readFileSync(path.join(moduleDir, "agent", "akm-curator.md"), "utf8").trim()
|
|
211
|
-
let body = raw
|
|
212
|
-
const lines = raw.split(/\r?\n/)
|
|
213
|
-
if (lines[0] === "---") {
|
|
214
|
-
const closingIndex = lines.indexOf("---", 1)
|
|
215
|
-
if (closingIndex > 0) body = lines.slice(closingIndex + 1).join("\n").trim()
|
|
216
|
-
}
|
|
217
|
-
return body || CURATOR_AGENT_PROMPT_FALLBACK
|
|
218
|
-
} catch {
|
|
219
|
-
return CURATOR_AGENT_PROMPT_FALLBACK
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const CURATOR_AGENT_PROMPT = loadCuratorAgentPrompt()
|
|
224
|
-
|
|
225
160
|
type LogLevel = "debug" | "info" | "warn" | "error"
|
|
226
161
|
|
|
227
162
|
type LogCapableClient = {
|
|
@@ -247,20 +182,6 @@ type CliLogMeta = {
|
|
|
247
182
|
channel?: string
|
|
248
183
|
}
|
|
249
184
|
|
|
250
|
-
type SessionPromptBody = {
|
|
251
|
-
agent: string
|
|
252
|
-
parts: Array<{ type: "text"; text: string }>
|
|
253
|
-
system?: string
|
|
254
|
-
model?: { providerID: string; modelID: string }
|
|
255
|
-
tools?: Record<string, boolean>
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
type DispatchTarget = {
|
|
259
|
-
agent: string
|
|
260
|
-
model?: { providerID: string; modelID: string }
|
|
261
|
-
requested: string
|
|
262
|
-
}
|
|
263
|
-
|
|
264
185
|
function formatCliError(error: unknown): string {
|
|
265
186
|
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") {
|
|
266
187
|
return "The 'akm' CLI was not found on PATH. Install it first from https://github.com/itlackey/akm."
|
|
@@ -274,138 +195,8 @@ function needsAgentSetup(message: string): boolean {
|
|
|
274
195
|
|
|
275
196
|
function addAgentSetupGuidance(message: string): string {
|
|
276
197
|
if (!needsAgentSetup(message)) return message
|
|
277
|
-
if (/\bakm
|
|
278
|
-
return `${message}.
|
|
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
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async function ensurePlatformAgentDefault(
|
|
353
|
-
client: LogCapableClient,
|
|
354
|
-
meta: { directory?: string; sessionID?: string; trigger: string; platform: string },
|
|
355
|
-
): Promise<boolean> {
|
|
356
|
-
const command = resolveAkmCommand()
|
|
357
|
-
if (typeof command === "object" && "ok" in command) {
|
|
358
|
-
await writePluginLog(client, "warn", "AKM agent default check skipped", {
|
|
359
|
-
subsystem: "akm",
|
|
360
|
-
trigger: meta.trigger,
|
|
361
|
-
sessionID: meta.sessionID,
|
|
362
|
-
directory: meta.directory,
|
|
363
|
-
error: command.error,
|
|
364
|
-
})
|
|
365
|
-
return false
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
try {
|
|
369
|
-
const current = readConfiguredAgentDefault()
|
|
370
|
-
if (current) return true
|
|
371
|
-
|
|
372
|
-
if (!writeConfiguredAgentDefault(meta.platform)) {
|
|
373
|
-
throw new Error(`Failed to write ${getAkmConfigPath()}`)
|
|
374
|
-
}
|
|
375
|
-
await writePluginLog(client, "info", "AKM agent default initialized", {
|
|
376
|
-
subsystem: "akm",
|
|
377
|
-
trigger: meta.trigger,
|
|
378
|
-
sessionID: meta.sessionID,
|
|
379
|
-
directory: meta.directory,
|
|
380
|
-
agentDefault: meta.platform,
|
|
381
|
-
})
|
|
382
|
-
return true
|
|
383
|
-
} catch (error: unknown) {
|
|
384
|
-
await writePluginLog(client, "warn", "AKM agent default initialization failed", {
|
|
385
|
-
subsystem: "akm",
|
|
386
|
-
trigger: meta.trigger,
|
|
387
|
-
sessionID: meta.sessionID,
|
|
388
|
-
directory: meta.directory,
|
|
389
|
-
platform: meta.platform,
|
|
390
|
-
error: addAgentSetupGuidance(formatCliError(error)),
|
|
391
|
-
stdout: toLogString((error as { stdout?: unknown }).stdout) ?? "",
|
|
392
|
-
stderr: toLogString((error as { stderr?: unknown }).stderr) ?? "",
|
|
393
|
-
})
|
|
394
|
-
return false
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
async function ensureAgentSetup(
|
|
399
|
-
client: LogCapableClient,
|
|
400
|
-
meta: { directory?: string; sessionID?: string; trigger: string; platform: string },
|
|
401
|
-
): Promise<boolean> {
|
|
402
|
-
if (agentSetupPromise) return agentSetupPromise
|
|
403
|
-
const promise = ensurePlatformAgentDefault(client, meta)
|
|
404
|
-
const trackedPromise = promise.finally(() => {
|
|
405
|
-
if (agentSetupPromise === trackedPromise) agentSetupPromise = null
|
|
406
|
-
})
|
|
407
|
-
agentSetupPromise = trackedPromise
|
|
408
|
-
return trackedPromise
|
|
198
|
+
if (/\bakm setup\b/i.test(message)) return message
|
|
199
|
+
return `${message}. Ask the user to run akm setup manually when interactive configuration is needed.`
|
|
409
200
|
}
|
|
410
201
|
|
|
411
202
|
function isNotIndexedFeedbackError(message: string): boolean {
|
|
@@ -524,15 +315,12 @@ function shouldIndexOnSessionEnd(): boolean {
|
|
|
524
315
|
return (process.env.AKM_INDEX_ON_SESSION_END ?? "0") === "1"
|
|
525
316
|
}
|
|
526
317
|
|
|
527
|
-
function buildDateTag(options?: { includeTime?: boolean }): string {
|
|
528
|
-
const compactIso = new Date().toISOString().replace(/[-:]/g, "")
|
|
529
|
-
return compactIso.slice(0, options?.includeTime ? CHECKPOINT_DATE_TAG_LENGTH : SESSION_DATE_TAG_LENGTH)
|
|
530
|
-
}
|
|
531
|
-
|
|
532
318
|
function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBufferEntry, "timestamp">) {
|
|
533
319
|
if (!sessionID) return
|
|
534
320
|
const buf = sessionBuffer.get(sessionID) ?? []
|
|
535
321
|
buf.push({ timestamp: nowIso(), ...entry })
|
|
322
|
+
// Drop-oldest cap (13: "Memory leaks" — sessionBuffer was uncapped).
|
|
323
|
+
if (buf.length > AKM_SESSION_BUFFER_MAX_ENTRIES) buf.splice(0, buf.length - AKM_SESSION_BUFFER_MAX_ENTRIES)
|
|
536
324
|
sessionBuffer.set(sessionID, buf)
|
|
537
325
|
}
|
|
538
326
|
|
|
@@ -554,9 +342,110 @@ function writeCuratedFile(sessionID: string, content: string): string {
|
|
|
554
342
|
return filePath
|
|
555
343
|
}
|
|
556
344
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
345
|
+
// 13: "Memory leaks" — session.deleted cleanup only cleared sessionHints,
|
|
346
|
+
// sessionCurated, sessionWorkflow, sessionCuratorReport, the epoch/version
|
|
347
|
+
// tracking pairs, and sessionBuffer. It missed retrospectiveState,
|
|
348
|
+
// sessionRecallAudit, and the per-session pendingProposalSummaryCache entry
|
|
349
|
+
// (cacheKey is the sessionID — see getPendingProposalCount), and never
|
|
350
|
+
// deleted the session's curated tmp file. clearSessionState() is the single
|
|
351
|
+
// place every session-keyed Map/tmp-file is torn down, so a future new map
|
|
352
|
+
// would only need one line added here instead of another hand-maintained list
|
|
353
|
+
// at the session.deleted call site.
|
|
354
|
+
function clearSessionState(sessionID: string): void {
|
|
355
|
+
sessionHints.delete(sessionID)
|
|
356
|
+
sessionCurated.delete(sessionID)
|
|
357
|
+
const curatedFile = sessionCuratedFile.get(sessionID)
|
|
358
|
+
if (curatedFile) {
|
|
359
|
+
try {
|
|
360
|
+
rmSync(curatedFile, { force: true })
|
|
361
|
+
} catch {
|
|
362
|
+
// Best-effort: a failed tmp-file cleanup must not block session teardown.
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
sessionCuratedFile.delete(sessionID)
|
|
366
|
+
sessionWorkflow.delete(sessionID)
|
|
367
|
+
sessionCuratorReport.delete(sessionID)
|
|
368
|
+
sessionContextEpoch.delete(sessionID)
|
|
369
|
+
sessionContextInjectedEpoch.delete(sessionID)
|
|
370
|
+
sessionCuratedVersion.delete(sessionID)
|
|
371
|
+
sessionCuratedInjectedVersion.delete(sessionID)
|
|
372
|
+
sessionBuffer.delete(sessionID)
|
|
373
|
+
sessionLastExtractAt.delete(sessionID)
|
|
374
|
+
sessionRecallAudit.delete(sessionID)
|
|
375
|
+
pendingProposalSummaryCache.delete(sessionID)
|
|
376
|
+
retrospectiveState.delete(sessionID)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Test-only: snapshot which session-keyed Maps still hold an entry for `sid`,
|
|
380
|
+
// plus the current sessionBuffer contents. Lets tests assert clearSessionState()
|
|
381
|
+
// actually emptied every map (13: "Memory leaks") without exporting the maps
|
|
382
|
+
// themselves. Mirrors the __resetResolvedAkmForTests test-only export above.
|
|
383
|
+
export function __sessionStateSnapshotForTests(sessionID: string): {
|
|
384
|
+
sessionHints: boolean
|
|
385
|
+
sessionCurated: boolean
|
|
386
|
+
sessionCuratedFile: boolean
|
|
387
|
+
sessionWorkflow: boolean
|
|
388
|
+
sessionCuratorReport: boolean
|
|
389
|
+
sessionContextEpoch: boolean
|
|
390
|
+
sessionContextInjectedEpoch: boolean
|
|
391
|
+
sessionCuratedVersion: boolean
|
|
392
|
+
sessionCuratedInjectedVersion: boolean
|
|
393
|
+
sessionBuffer: boolean
|
|
394
|
+
sessionBufferLength: number
|
|
395
|
+
sessionBufferRefs: string[]
|
|
396
|
+
sessionLastExtractAt: boolean
|
|
397
|
+
sessionRecallAudit: boolean
|
|
398
|
+
pendingProposalSummaryCache: boolean
|
|
399
|
+
retrospectiveState: boolean
|
|
400
|
+
} {
|
|
401
|
+
return {
|
|
402
|
+
sessionHints: sessionHints.has(sessionID),
|
|
403
|
+
sessionCurated: sessionCurated.has(sessionID),
|
|
404
|
+
sessionCuratedFile: sessionCuratedFile.has(sessionID),
|
|
405
|
+
sessionWorkflow: sessionWorkflow.has(sessionID),
|
|
406
|
+
sessionCuratorReport: sessionCuratorReport.has(sessionID),
|
|
407
|
+
sessionContextEpoch: sessionContextEpoch.has(sessionID),
|
|
408
|
+
sessionContextInjectedEpoch: sessionContextInjectedEpoch.has(sessionID),
|
|
409
|
+
sessionCuratedVersion: sessionCuratedVersion.has(sessionID),
|
|
410
|
+
sessionCuratedInjectedVersion: sessionCuratedInjectedVersion.has(sessionID),
|
|
411
|
+
sessionBuffer: sessionBuffer.has(sessionID),
|
|
412
|
+
sessionBufferLength: sessionBuffer.get(sessionID)?.length ?? 0,
|
|
413
|
+
sessionBufferRefs: (sessionBuffer.get(sessionID) ?? []).flatMap((entry) => (entry.ref ? [entry.ref] : [])),
|
|
414
|
+
sessionLastExtractAt: sessionLastExtractAt.has(sessionID),
|
|
415
|
+
sessionRecallAudit: sessionRecallAudit.has(sessionID),
|
|
416
|
+
pendingProposalSummaryCache: pendingProposalSummaryCache.has(sessionID),
|
|
417
|
+
retrospectiveState: retrospectiveState.has(sessionID),
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Test-only: expose the curated tmp-file directory so tests can assert file
|
|
422
|
+
// existence/absence without hardcoding os.tmpdir() path construction twice.
|
|
423
|
+
export function __curatedDirForTests(): string {
|
|
424
|
+
return CURATED_DIR
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Best-effort sweep of orphaned curated tmp files (13: "tmp-file cleanup").
|
|
428
|
+
// clearSessionState() handles the normal session.deleted path; this covers
|
|
429
|
+
// sessions that never fire it (host crash, forced kill). Async and fully
|
|
430
|
+
// error-trapped internally so a failed sweep never surfaces as an unhandled
|
|
431
|
+
// rejection or blocks the session.created path that triggers it.
|
|
432
|
+
async function pruneStaleCuratedFiles(): Promise<void> {
|
|
433
|
+
let entries: string[]
|
|
434
|
+
try {
|
|
435
|
+
entries = readdirSync(CURATED_DIR)
|
|
436
|
+
} catch {
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
const now = Date.now()
|
|
440
|
+
for (const name of entries) {
|
|
441
|
+
try {
|
|
442
|
+
const filePath = path.join(CURATED_DIR, name)
|
|
443
|
+
const info = statSync(filePath)
|
|
444
|
+
if (now - info.mtimeMs > CURATED_FILE_MAX_AGE_MS) rmSync(filePath, { force: true })
|
|
445
|
+
} catch {
|
|
446
|
+
// Best-effort per-file: a single stat/rm failure must not abort the sweep.
|
|
447
|
+
}
|
|
448
|
+
}
|
|
560
449
|
}
|
|
561
450
|
|
|
562
451
|
function parseMaybeJson(value: string): unknown {
|
|
@@ -585,10 +474,6 @@ function runCliSyncRaw(args: string[], timeoutMs: number): { ok: true; stdout: s
|
|
|
585
474
|
}
|
|
586
475
|
}
|
|
587
476
|
|
|
588
|
-
function appendRunScopeArgs(args: string[], sessionID: string | undefined): string[] {
|
|
589
|
-
return sessionID ? [...args, "--run", sessionID] : args
|
|
590
|
-
}
|
|
591
|
-
|
|
592
477
|
function getScopeFields(): Array<"user" | "agent" | "run" | "channel"> {
|
|
593
478
|
const configured = process.env.AKM_SCOPE_KEYS?.split(",").map((part) => part.trim()).filter(Boolean)
|
|
594
479
|
const values = configured && configured.length > 0 ? configured : ["user", "agent", "run", "channel"]
|
|
@@ -621,125 +506,10 @@ function buildScopedArgs(context: Record<string, unknown> | undefined): string[]
|
|
|
621
506
|
return args
|
|
622
507
|
}
|
|
623
508
|
|
|
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
509
|
function truncateLine(value: string, maxChars = 220): string {
|
|
696
510
|
return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1)}...`
|
|
697
511
|
}
|
|
698
512
|
|
|
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
|
-
|
|
743
513
|
function runCurate(args: string[]): string | null {
|
|
744
514
|
const result = runCliSyncRaw(args, AKM_CURATE_TIMEOUT_MS)
|
|
745
515
|
if (!result.ok) return null
|
|
@@ -761,20 +531,17 @@ async function runCurateLogged(
|
|
|
761
531
|
async function runCurateForPrompt(client: LogCapableClient, text: string, sessionID?: string): Promise<string | null> {
|
|
762
532
|
if (!text || text.length < AKM_CURATE_MIN_CHARS) return null
|
|
763
533
|
return runCurateLogged(client,
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
],
|
|
776
|
-
sessionID,
|
|
777
|
-
),
|
|
534
|
+
[
|
|
535
|
+
"--shape",
|
|
536
|
+
"agent",
|
|
537
|
+
"--format",
|
|
538
|
+
"text",
|
|
539
|
+
"-q",
|
|
540
|
+
"curate",
|
|
541
|
+
text,
|
|
542
|
+
"--limit",
|
|
543
|
+
String(AKM_CURATE_LIMIT),
|
|
544
|
+
],
|
|
778
545
|
{ toolName: "chat.message", sessionID, operation: "prompt-curate" },
|
|
779
546
|
)
|
|
780
547
|
}
|
|
@@ -791,7 +558,7 @@ async function runCurateForSession(client: LogCapableClient, sessionID: string,
|
|
|
791
558
|
if (query) args.push(query)
|
|
792
559
|
args.push("--limit", String(AKM_CURATE_LIMIT))
|
|
793
560
|
return runCurateLogged(client,
|
|
794
|
-
|
|
561
|
+
args,
|
|
795
562
|
{ toolName: "session.start", sessionID, operation: "session-curate" },
|
|
796
563
|
)
|
|
797
564
|
}
|
|
@@ -822,11 +589,15 @@ function summarizeWorkflowList(value: unknown): string | null {
|
|
|
822
589
|
? record.workflowRef
|
|
823
590
|
: null
|
|
824
591
|
const state = typeof record.state === "string" ? record.state : typeof record.status === "string" ? record.status : null
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
592
|
+
// akm 0.9.0 run summaries carry `currentStepId`; `step`/`currentStep`
|
|
593
|
+
// are retained as fallbacks for older envelope shapes.
|
|
594
|
+
const step = typeof record.currentStepId === "string"
|
|
595
|
+
? record.currentStepId
|
|
596
|
+
: typeof record.step === "string"
|
|
597
|
+
? record.step
|
|
598
|
+
: typeof record.currentStep === "string"
|
|
599
|
+
? record.currentStep
|
|
600
|
+
: null
|
|
830
601
|
if (!id && !ref && !state && !step) return null
|
|
831
602
|
return `- ${ref ?? "workflow"} (${id ?? "run"})${state ? ` — ${state}` : ""}${step ? ` — next: ${step}` : ""}`
|
|
832
603
|
})
|
|
@@ -871,55 +642,59 @@ function formatPendingProposalContext(count: number): string {
|
|
|
871
642
|
"# AKM pending proposals",
|
|
872
643
|
"",
|
|
873
644
|
summaryLine,
|
|
874
|
-
"Use
|
|
645
|
+
"Use the AKM CLI to review them; mutating proposal actions require explicit user approval.",
|
|
875
646
|
PROPOSED_QUALITY_WARNING,
|
|
876
647
|
].join("\n")
|
|
877
648
|
}
|
|
878
649
|
|
|
879
|
-
function
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
async function getAkmStashDir(client?: LogCapableClient): Promise<string | undefined> {
|
|
885
|
-
if (cachedAkmStashDir !== undefined) return cachedAkmStashDir || undefined
|
|
650
|
+
async function getAkmBundleDir(client?: LogCapableClient): Promise<string | undefined> {
|
|
651
|
+
const override = process.env.AKM_BUNDLE_DIR?.trim()
|
|
652
|
+
if (override) return override
|
|
653
|
+
if (cachedAkmBundleDir !== undefined) return cachedAkmBundleDir || undefined
|
|
886
654
|
const raw = client
|
|
887
|
-
? await runCliSyncBestEffort(client, ["--format", "json", "-q"
|
|
655
|
+
? await runCliSyncBestEffort(client, ["info", "--format", "json", "-q"], AKM_CURATE_TIMEOUT_MS, {
|
|
888
656
|
toolName: "shell.env",
|
|
889
|
-
subsystem: "
|
|
890
|
-
operation: "get-
|
|
657
|
+
subsystem: "info",
|
|
658
|
+
operation: "get-bundle-dir",
|
|
891
659
|
})
|
|
892
|
-
: runCurate(["--format", "json", "-q"
|
|
660
|
+
: runCurate(["info", "--format", "json", "-q"])
|
|
893
661
|
if (!raw) {
|
|
894
|
-
|
|
662
|
+
cachedAkmBundleDir = ""
|
|
895
663
|
return undefined
|
|
896
664
|
}
|
|
897
665
|
const parsed = parseMaybeJson(raw)
|
|
898
|
-
if (typeof parsed === "string" && parsed.trim()) {
|
|
899
|
-
cachedAkmStashDir = parsed.trim()
|
|
900
|
-
return cachedAkmStashDir
|
|
901
|
-
}
|
|
902
666
|
if (parsed && typeof parsed === "object") {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
return cachedAkmStashDir
|
|
908
|
-
}
|
|
667
|
+
const value = (parsed as Record<string, unknown>).bundleDir
|
|
668
|
+
if (typeof value === "string" && value.trim()) {
|
|
669
|
+
cachedAkmBundleDir = value.trim()
|
|
670
|
+
return cachedAkmBundleDir
|
|
909
671
|
}
|
|
910
672
|
}
|
|
911
|
-
|
|
912
|
-
return
|
|
673
|
+
cachedAkmBundleDir = ""
|
|
674
|
+
return undefined
|
|
913
675
|
}
|
|
914
676
|
|
|
915
677
|
function warmIndexInBackground(): void {
|
|
916
678
|
const command = resolveAkmCommand()
|
|
917
679
|
if (typeof command === "object" && "ok" in command) return
|
|
918
680
|
try {
|
|
919
|
-
// Fire and forget
|
|
920
|
-
//
|
|
921
|
-
|
|
922
|
-
|
|
681
|
+
// Fire and forget, no shell: detached + stdio "ignore" + unref() gives the
|
|
682
|
+
// same "start it and walk away" semantics the old `… &` shell string had,
|
|
683
|
+
// without any quoting concerns. (The previous version quoted argv with
|
|
684
|
+
// JSON.stringify, which is not POSIX shell quoting — a resolved bunx/binary
|
|
685
|
+
// path containing a backslash, newline, or embedded quote would have been
|
|
686
|
+
// mis-parsed by the shell.) Matches maybeExtractSessionOnIdle/queueFeedback.
|
|
687
|
+
// Errors here are never surfaced to the session.
|
|
688
|
+
const child = spawn(command.command, [...command.argsPrefix, "index"], {
|
|
689
|
+
detached: true,
|
|
690
|
+
stdio: "ignore",
|
|
691
|
+
})
|
|
692
|
+
// Required: an unhandled 'error' event (e.g. ENOENT) would otherwise throw
|
|
693
|
+
// asynchronously, outside the try/catch below.
|
|
694
|
+
child.on("error", () => {
|
|
695
|
+
// Intentionally ignore — warming is best-effort.
|
|
696
|
+
})
|
|
697
|
+
child.unref()
|
|
923
698
|
} catch {
|
|
924
699
|
// Intentionally ignore — warming is best-effort.
|
|
925
700
|
}
|
|
@@ -1031,7 +806,7 @@ async function recordRetrospectiveFeedback(client: LogCapableClient, sessionID:
|
|
|
1031
806
|
}
|
|
1032
807
|
|
|
1033
808
|
const targetRef = recentRefs[recentRefs.length - 1]
|
|
1034
|
-
const raw = await runCli(client, ["feedback", targetRef, "--negative", "--
|
|
809
|
+
const raw = await runCli(client, ["feedback", targetRef, "--negative", "--reason", text.slice(0, 280)], {
|
|
1035
810
|
toolName: "akm_feedback",
|
|
1036
811
|
sessionID,
|
|
1037
812
|
})
|
|
@@ -1079,21 +854,14 @@ function queueFeedback(
|
|
|
1079
854
|
command.command,
|
|
1080
855
|
[
|
|
1081
856
|
...command.argsPrefix,
|
|
1082
|
-
"--format",
|
|
1083
|
-
"json",
|
|
1084
|
-
"-q",
|
|
1085
857
|
"feedback",
|
|
1086
858
|
ref,
|
|
1087
859
|
sentiment === "positive" ? "--positive" : "--negative",
|
|
1088
|
-
"--
|
|
860
|
+
"--reason",
|
|
1089
861
|
note,
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
agent: meta.agent,
|
|
1094
|
-
userID: meta.userID,
|
|
1095
|
-
channel: meta.channel,
|
|
1096
|
-
}),
|
|
862
|
+
"--format",
|
|
863
|
+
"json",
|
|
864
|
+
"-q",
|
|
1097
865
|
],
|
|
1098
866
|
{
|
|
1099
867
|
detached: true,
|
|
@@ -1142,203 +910,6 @@ function queueFeedback(
|
|
|
1142
910
|
}
|
|
1143
911
|
}
|
|
1144
912
|
|
|
1145
|
-
function rememberTextAsMemory(name: string, body: string, context?: Record<string, unknown>): string | null {
|
|
1146
|
-
const command = resolveAkmCommand()
|
|
1147
|
-
if (typeof command === "object" && "ok" in command) return null
|
|
1148
|
-
try {
|
|
1149
|
-
execResolvedAkm(command, ["--format", "json", "-q", "remember", "--name", name, "--force", ...buildScopedArgs(context)], {
|
|
1150
|
-
encoding: "utf8",
|
|
1151
|
-
timeout: AKM_CURATE_TIMEOUT_MS * 2,
|
|
1152
|
-
input: redactSecrets(body).text,
|
|
1153
|
-
})
|
|
1154
|
-
return `memory:${name}`
|
|
1155
|
-
} catch {
|
|
1156
|
-
return null
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
function captureSessionMemory(
|
|
1161
|
-
sessionID: string,
|
|
1162
|
-
reason: string,
|
|
1163
|
-
options?: { checkpoint?: boolean; directory?: string; agent?: string; channel?: string; userID?: string; user?: string },
|
|
1164
|
-
): string | null {
|
|
1165
|
-
if (!AKM_AUTO_MEMORY) return null
|
|
1166
|
-
if (!sessionID) return null
|
|
1167
|
-
const isCheckpoint = options?.checkpoint === true
|
|
1168
|
-
if (!isCheckpoint && sessionFinalMemoryCaptured.has(sessionID)) return null
|
|
1169
|
-
const entries = sessionBuffer.get(sessionID) ?? []
|
|
1170
|
-
const pendingEntries = isCheckpoint ? entries.filter((entry) => !entry.checkpointed) : entries
|
|
1171
|
-
// Require at least two observations before persisting — single events are noise.
|
|
1172
|
-
if (pendingEntries.length < 2) {
|
|
1173
|
-
if (!isCheckpoint) sessionBuffer.delete(sessionID)
|
|
1174
|
-
return null
|
|
1175
|
-
}
|
|
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
|
-
|
|
1185
|
-
const lines: string[] = []
|
|
1186
|
-
lines.push("---")
|
|
1187
|
-
lines.push("akm_memory_kind: session_checkpoint")
|
|
1188
|
-
lines.push("harness: opencode")
|
|
1189
|
-
lines.push(`session_id: ${sessionID}`)
|
|
1190
|
-
lines.push(`reason: ${reason}`)
|
|
1191
|
-
lines.push("---")
|
|
1192
|
-
lines.push("")
|
|
1193
|
-
lines.push(`# Session summary (${nowIso()})`)
|
|
1194
|
-
lines.push(`Reason: ${reason}`)
|
|
1195
|
-
lines.push(`Session: ${sessionID}`)
|
|
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))
|
|
1206
|
-
for (const entry of pendingEntries) {
|
|
1207
|
-
if (entry.kind === "memory-intent") {
|
|
1208
|
-
lines.push(`## ${entry.timestamp} — user memory intent`)
|
|
1209
|
-
if (entry.note) lines.push(entry.note)
|
|
1210
|
-
lines.push("")
|
|
1211
|
-
} else {
|
|
1212
|
-
lines.push(`## ${entry.timestamp} — ${entry.toolName ?? "tool"} ${entry.status ?? "unknown"}`)
|
|
1213
|
-
if (entry.ref) lines.push(`- ref: ${entry.ref}`)
|
|
1214
|
-
if (entry.note) lines.push(`- note: ${entry.note}`)
|
|
1215
|
-
lines.push("")
|
|
1216
|
-
}
|
|
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
|
-
}
|
|
1234
|
-
const body = lines.join("\n")
|
|
1235
|
-
|
|
1236
|
-
const dateTag = buildDateTag({ includeTime: isCheckpoint })
|
|
1237
|
-
const shortSid = sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
|
|
1238
|
-
const name = isCheckpoint
|
|
1239
|
-
? `opencode-checkpoint-${dateTag}-${shortSid}`
|
|
1240
|
-
: `opencode-session-${dateTag}-${shortSid}`
|
|
1241
|
-
|
|
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
|
-
})
|
|
1251
|
-
if (!ref) {
|
|
1252
|
-
if (!isCheckpoint) {
|
|
1253
|
-
sessionFinalMemoryCaptured.add(sessionID)
|
|
1254
|
-
sessionBuffer.delete(sessionID)
|
|
1255
|
-
}
|
|
1256
|
-
return null
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
if (isCheckpoint) {
|
|
1260
|
-
void writeStructuredEvent({
|
|
1261
|
-
event: "pre_compact_checkpoint",
|
|
1262
|
-
sessionId: sessionID,
|
|
1263
|
-
scope: buildEventScope(sessionID),
|
|
1264
|
-
memory: { ref, reason, kind: "session_checkpoint" },
|
|
1265
|
-
refs: [ref],
|
|
1266
|
-
outcome: { status: "ok" },
|
|
1267
|
-
})
|
|
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
|
-
})
|
|
1277
|
-
if (candidates.length > 0) {
|
|
1278
|
-
appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
|
|
1279
|
-
void writeStructuredEvent({
|
|
1280
|
-
event: "candidate_extracted",
|
|
1281
|
-
sessionId: sessionID,
|
|
1282
|
-
scope: buildEventScope(sessionID),
|
|
1283
|
-
memory: { sourceRef: ref, count: candidates.length },
|
|
1284
|
-
refs: [ref],
|
|
1285
|
-
outcome: { status: "ok" },
|
|
1286
|
-
})
|
|
1287
|
-
}
|
|
1288
|
-
for (const entry of entries) {
|
|
1289
|
-
if (!entry.checkpointed) entry.checkpointed = true
|
|
1290
|
-
}
|
|
1291
|
-
sessionSuccessfulAssetTouchCount.set(sessionID, 0)
|
|
1292
|
-
sessionBuffer.set(sessionID, entries)
|
|
1293
|
-
return ref
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
sessionFinalMemoryCaptured.add(sessionID)
|
|
1297
|
-
sessionBuffer.delete(sessionID)
|
|
1298
|
-
void writeStructuredEvent({
|
|
1299
|
-
event: "session_ended",
|
|
1300
|
-
sessionId: sessionID,
|
|
1301
|
-
scope: buildEventScope(sessionID),
|
|
1302
|
-
memory: { ref, reason, kind: "session_checkpoint" },
|
|
1303
|
-
refs: [ref],
|
|
1304
|
-
outcome: { status: "ok" },
|
|
1305
|
-
})
|
|
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
|
-
})
|
|
1315
|
-
if (candidates.length > 0) {
|
|
1316
|
-
appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
|
|
1317
|
-
void writeStructuredEvent({
|
|
1318
|
-
event: "candidate_extracted",
|
|
1319
|
-
sessionId: sessionID,
|
|
1320
|
-
scope: buildEventScope(sessionID),
|
|
1321
|
-
memory: { sourceRef: ref, count: candidates.length },
|
|
1322
|
-
refs: [ref],
|
|
1323
|
-
outcome: { status: "ok" },
|
|
1324
|
-
})
|
|
1325
|
-
}
|
|
1326
|
-
return ref
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
function maybeCheckpointSessionMemory(
|
|
1330
|
-
sessionID: string,
|
|
1331
|
-
options?: { directory?: string; agent?: string; channel?: string; userID?: string; user?: string },
|
|
1332
|
-
): string | null {
|
|
1333
|
-
const count = sessionSuccessfulAssetTouchCount.get(sessionID) ?? 0
|
|
1334
|
-
if (count < AKM_MEMORY_CHECKPOINT_EVERY) return null
|
|
1335
|
-
const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true, ...options })
|
|
1336
|
-
if (!captured) {
|
|
1337
|
-
sessionSuccessfulAssetTouchCount.set(sessionID, 0)
|
|
1338
|
-
}
|
|
1339
|
-
return captured
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
913
|
async function maybeIndexSessionMemory(
|
|
1343
914
|
client: LogCapableClient,
|
|
1344
915
|
sessionID: string,
|
|
@@ -1358,23 +929,57 @@ async function maybeIndexSessionMemory(
|
|
|
1358
929
|
})
|
|
1359
930
|
}
|
|
1360
931
|
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
932
|
+
// 03-R1/06-M1 kept half: the session_checkpoint `remember --force` write is
|
|
933
|
+
// gone, but the memory-candidate pipeline still harvests explicit
|
|
934
|
+
// "remember ..." intents from the session buffer at session end. There is no
|
|
935
|
+
// stash write and, as of 0.9, no in-product consumer either: the
|
|
936
|
+
// /akm-memory-promote slash command that used to review these was deleted in
|
|
937
|
+
// this release. Candidates are appended to the harness candidate log
|
|
938
|
+
// (getCandidateLogPath("opencode") -> …/akm-opencode/memory-candidates.jsonl)
|
|
939
|
+
// alongside a `candidate_extracted` structured event, and are read
|
|
940
|
+
// out-of-band. Whether to retire the pipeline or re-home the review step is a
|
|
941
|
+
// deliberately open maintainer decision, so the harvest stays wired up.
|
|
942
|
+
// Deleting the buffer after extraction is the de-dup: a re-fired lifecycle
|
|
943
|
+
// event finds an empty buffer and no-ops.
|
|
944
|
+
function maybeExtractSessionCandidates(sessionID: string, reason: string): void {
|
|
945
|
+
if (!AKM_AUTO_MEMORY) return
|
|
946
|
+
if (!sessionID) return
|
|
947
|
+
const entries = sessionBuffer.get(sessionID) ?? []
|
|
948
|
+
// Require at least two observations before persisting — single events are noise.
|
|
949
|
+
if (entries.length < 2) {
|
|
950
|
+
// Below the noise floor, only the terminal session.deleted event may
|
|
951
|
+
// discard the buffer (the old discard-noise-at-session-end behavior).
|
|
952
|
+
// Non-terminal events (session.idle fires at every turn's quiescence,
|
|
953
|
+
// session.compacted mid-session) must KEEP a below-floor buffer so a
|
|
954
|
+
// lone "remember ..." intent from one turn survives to pair with an
|
|
955
|
+
// observation from a later turn instead of being wiped at each idle.
|
|
956
|
+
if (reason === "session.deleted") sessionBuffer.delete(sessionID)
|
|
957
|
+
return
|
|
1376
958
|
}
|
|
1377
|
-
|
|
959
|
+
const targetRefHints = entries.flatMap((entry) => (entry.ref ? [entry.ref] : []))
|
|
960
|
+
const text = entries
|
|
961
|
+
.filter((entry) => entry.kind === "memory-intent" && entry.note)
|
|
962
|
+
.map((entry) => entry.note as string)
|
|
963
|
+
.join("\n")
|
|
964
|
+
const candidates = extractCandidatesFromText({
|
|
965
|
+
harness: "opencode",
|
|
966
|
+
sessionId: sessionID,
|
|
967
|
+
text,
|
|
968
|
+
evidence: [reason, ...targetRefHints],
|
|
969
|
+
sourcePaths: [OPENCODE_EVENT_LOG, OPENCODE_CANDIDATE_LOG],
|
|
970
|
+
targetRefHints,
|
|
971
|
+
})
|
|
972
|
+
if (candidates.length > 0) {
|
|
973
|
+
appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
|
|
974
|
+
void writeStructuredEvent({
|
|
975
|
+
event: "candidate_extracted",
|
|
976
|
+
sessionId: sessionID,
|
|
977
|
+
scope: buildEventScope(sessionID),
|
|
978
|
+
memory: { count: candidates.length },
|
|
979
|
+
outcome: { status: "ok" },
|
|
980
|
+
})
|
|
981
|
+
}
|
|
982
|
+
sessionBuffer.delete(sessionID)
|
|
1378
983
|
}
|
|
1379
984
|
|
|
1380
985
|
function extractToolRefs(
|
|
@@ -1386,7 +991,7 @@ function extractToolRefs(
|
|
|
1386
991
|
const positiveOnlyRefs = new Set<string>()
|
|
1387
992
|
const addMatches = (value: unknown) => {
|
|
1388
993
|
if (typeof value !== "string") return
|
|
1389
|
-
for (const ref of
|
|
994
|
+
for (const ref of extractAkmRefsFromString(value)) refs.add(ref)
|
|
1390
995
|
}
|
|
1391
996
|
|
|
1392
997
|
for (const key of ["ref", "package_ref"]) {
|
|
@@ -1407,15 +1012,6 @@ function extractToolRefs(
|
|
|
1407
1012
|
}
|
|
1408
1013
|
}
|
|
1409
1014
|
if (toolName === "akm_remember" && typeof o.ref === "string") addMatches(o.ref)
|
|
1410
|
-
if (
|
|
1411
|
-
(toolName === "akm_agent" || toolName === "akm_cmd" || toolName === "akm_evolve")
|
|
1412
|
-
&& typeof o.text === "string"
|
|
1413
|
-
) {
|
|
1414
|
-
for (const ref of extractRefsFromText(o.text)) {
|
|
1415
|
-
refs.add(ref)
|
|
1416
|
-
positiveOnlyRefs.add(ref)
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
1015
|
}
|
|
1420
1016
|
|
|
1421
1017
|
return { refs: [...refs], positiveOnlyRefs: [...positiveOnlyRefs] }
|
|
@@ -1442,7 +1038,7 @@ const AKM_HINTS_PREFIX = [
|
|
|
1442
1038
|
"",
|
|
1443
1039
|
"**Choosing the right lookup command:**",
|
|
1444
1040
|
"",
|
|
1445
|
-
"- **`akm_curate`** — use this when starting any new task, looking for patterns, docs, skills, or workflows. This is the PRIMARY lookup command.
|
|
1041
|
+
"- **`akm_curate`** — use this when starting any new task, looking for patterns, docs, skills, or workflows. This is the PRIMARY lookup command. akm automatically boosts assets that match the current project (cwd-anchored project-context ranking), so an explicit project name in the query is not required for ranking — but it still helps the reranker frame intent.",
|
|
1446
1042
|
' - Good: `akm_curate("akm CLI improve command performance analysis")` (explicit framing, still ideal)',
|
|
1447
1043
|
' - Bad: `akm_curate("improve performance analysis")` (too generic — the reranker has less to work with even with auto-boost)',
|
|
1448
1044
|
"- **`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.",
|
|
@@ -1491,140 +1087,6 @@ function applyContextBudget(blocks: string[]): string[] {
|
|
|
1491
1087
|
return injected
|
|
1492
1088
|
}
|
|
1493
1089
|
|
|
1494
|
-
// Curated quick-reference for the long-tail of `akm` CLI verbs that no longer
|
|
1495
|
-
// have a dedicated tool wrapper. Surfaced through akm_help so agents can
|
|
1496
|
-
// always find the right invocation without polluting default context.
|
|
1497
|
-
type AkmHelpEntry = {
|
|
1498
|
-
task: string
|
|
1499
|
-
command: string
|
|
1500
|
-
notes?: string
|
|
1501
|
-
keywords: string[]
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
|
|
1505
|
-
{
|
|
1506
|
-
task: "Review pending proposals and decide whether to accept, reject, or revise them",
|
|
1507
|
-
command: "akm proposal list --status pending --format json; akm proposal show <id>; akm proposal diff <id>",
|
|
1508
|
-
notes: "Accept/reject requires explicit user approval.",
|
|
1509
|
-
keywords: ["proposal", "review proposals", "pending proposals", "accept proposal", "reject proposal"],
|
|
1510
|
-
},
|
|
1511
|
-
{
|
|
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"],
|
|
1516
|
-
},
|
|
1517
|
-
{
|
|
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"],
|
|
1528
|
-
},
|
|
1529
|
-
{
|
|
1530
|
-
task: "Create a proposed asset for a coverage gap",
|
|
1531
|
-
command: "akm propose <type> <name> --task \"...\"",
|
|
1532
|
-
notes: PROPOSED_QUALITY_WARNING,
|
|
1533
|
-
keywords: ["propose", "coverage gap", "proposed asset"],
|
|
1534
|
-
},
|
|
1535
|
-
{
|
|
1536
|
-
task: "Search including proposed-quality assets",
|
|
1537
|
-
command: "akm search <query> --include-proposed",
|
|
1538
|
-
notes: PROPOSED_QUALITY_WARNING,
|
|
1539
|
-
keywords: ["include-proposed", "proposed quality", "lesson"],
|
|
1540
|
-
},
|
|
1541
|
-
{
|
|
1542
|
-
task: "Install a kit or register an external source (npm, GitHub, git, URL, local dir)",
|
|
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.",
|
|
1545
|
-
keywords: ["add", "install", "register", "kit", "source", "github", "npm"],
|
|
1546
|
-
},
|
|
1547
|
-
{
|
|
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"],
|
|
1552
|
-
},
|
|
1553
|
-
{
|
|
1554
|
-
task: "Import a file (or stdin) into the stash as a typed asset",
|
|
1555
|
-
command: "akm import <path|-> [--name <name>] [--force]",
|
|
1556
|
-
notes: "Use `-` and pipe content via stdin to import a string.",
|
|
1557
|
-
keywords: ["import", "ingest", "upload", "stdin"],
|
|
1558
|
-
},
|
|
1559
|
-
{
|
|
1560
|
-
task: "Clone an asset from any source for editing",
|
|
1561
|
-
command: "akm clone <ref> [--name <new>] [--dest <dir>] [--force]",
|
|
1562
|
-
notes: "Type subdirectory is appended automatically; ref may include origin (e.g. npm:@scope/pkg//script:foo).",
|
|
1563
|
-
keywords: ["clone", "copy", "fork", "edit"],
|
|
1564
|
-
},
|
|
1565
|
-
{
|
|
1566
|
-
task: "Update a managed source (or all of them)",
|
|
1567
|
-
command: "akm update [<package_ref>|--all] [--force]",
|
|
1568
|
-
keywords: ["update", "upgrade kit", "refresh", "pull"],
|
|
1569
|
-
},
|
|
1570
|
-
{
|
|
1571
|
-
task: "Remove a configured source and reindex",
|
|
1572
|
-
command: "akm remove <id|ref|path|url|name>",
|
|
1573
|
-
notes: "Destructive — confirm intent before running.",
|
|
1574
|
-
keywords: ["remove", "uninstall", "delete source"],
|
|
1575
|
-
},
|
|
1576
|
-
{
|
|
1577
|
-
task: "List configured sources (local dirs, kits, remotes)",
|
|
1578
|
-
command: "akm list",
|
|
1579
|
-
keywords: ["list", "sources", "kits", "show sources"],
|
|
1580
|
-
},
|
|
1581
|
-
{
|
|
1582
|
-
task: "Search the registry only (skip local stash)",
|
|
1583
|
-
command: "akm registry search <query> [--limit N] [--assets]",
|
|
1584
|
-
notes: "akm_search with source='registry' covers most cases; this is the explicit form.",
|
|
1585
|
-
keywords: ["registry", "search registry", "installable", "discover kit"],
|
|
1586
|
-
},
|
|
1587
|
-
{
|
|
1588
|
-
task: "Build or rebuild the stash search index",
|
|
1589
|
-
command: "akm index",
|
|
1590
|
-
notes: "Rarely needed — the index refreshes implicitly after writes.",
|
|
1591
|
-
keywords: ["index", "reindex", "rebuild"],
|
|
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
|
-
},
|
|
1599
|
-
{
|
|
1600
|
-
task: "View or update akm config (get/set/list/unset/path)",
|
|
1601
|
-
command: "akm config <action> [<key>] [<value>] [--all]",
|
|
1602
|
-
notes: "`akm config path --all` prints config, stash, cache, and index paths.",
|
|
1603
|
-
keywords: ["config", "settings", "configure", "path"],
|
|
1604
|
-
},
|
|
1605
|
-
{
|
|
1606
|
-
task: "Check for or install an akm CLI update",
|
|
1607
|
-
command: "akm upgrade [--check] [--force]",
|
|
1608
|
-
keywords: ["upgrade cli", "update cli", "self-upgrade"],
|
|
1609
|
-
},
|
|
1610
|
-
{
|
|
1611
|
-
task: "Run a stash script end-to-end (resolve → show → run)",
|
|
1612
|
-
command: "akm show <script-ref> # then exec the printed `run` command",
|
|
1613
|
-
notes: "Or `akm --format json -q show <ref>` and pipe `.run` into your shell.",
|
|
1614
|
-
keywords: ["run", "execute", "script", "exec"],
|
|
1615
|
-
},
|
|
1616
|
-
]
|
|
1617
|
-
|
|
1618
|
-
function lookupAkmHelpHint(topic: string): AkmHelpEntry[] {
|
|
1619
|
-
const needle = topic.toLowerCase().trim()
|
|
1620
|
-
if (!needle) return []
|
|
1621
|
-
return AKM_HELP_QUICK_REFERENCE.filter((entry) =>
|
|
1622
|
-
entry.keywords.some((kw) => needle.includes(kw))
|
|
1623
|
-
|| entry.task.toLowerCase().includes(needle)
|
|
1624
|
-
|| entry.command.toLowerCase().includes(needle),
|
|
1625
|
-
)
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
1090
|
function extractSessionIdFromEvent(payload: unknown): string | undefined {
|
|
1629
1091
|
if (!payload || typeof payload !== "object") return undefined
|
|
1630
1092
|
const p = payload as Record<string, unknown>
|
|
@@ -1645,6 +1107,123 @@ function extractSessionIdFromEvent(payload: unknown): string | undefined {
|
|
|
1645
1107
|
return undefined
|
|
1646
1108
|
}
|
|
1647
1109
|
|
|
1110
|
+
// Cap on how much of the extract child's stdout/stderr we retain for logging.
|
|
1111
|
+
// The envelope we care about is a few hundred bytes; anything past this is
|
|
1112
|
+
// dropped so a chatty/looping child can never grow the buffer unbounded.
|
|
1113
|
+
const AKM_EXTRACT_OUTPUT_MAX_CHARS = 2_000
|
|
1114
|
+
|
|
1115
|
+
// `unref()` exists on the net.Socket that node hands back for a piped child
|
|
1116
|
+
// stream, but not on the `Readable` the @types/node signature advertises.
|
|
1117
|
+
// Unref'ing keeps the piped fds from holding the host's event loop open, which
|
|
1118
|
+
// is what preserves the fire-and-forget contract now that stdio is captured.
|
|
1119
|
+
function unrefChildStream(stream: unknown): void {
|
|
1120
|
+
const handle = stream as { unref?: () => void } | null | undefined
|
|
1121
|
+
if (handle && typeof handle.unref === "function") handle.unref()
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Event-driven extraction trigger for opencode (Option #3: min-interval gate).
|
|
1126
|
+
* Called on `session.idle` (which fires after every turn). Extracts the session
|
|
1127
|
+
* into the proposal queue at most once per AKM_EXTRACT_MIN_INTERVAL_MS, so a
|
|
1128
|
+
* burst of turns collapses to a single periodic checkpoint instead of flooding.
|
|
1129
|
+
* `extract --session-id` respects the content-hash ledger, so an extract landing
|
|
1130
|
+
* on unchanged content is a free no-op. Fire-and-forget (detached + unref'd) so
|
|
1131
|
+
* it never stalls the turn; the hourly `akm improve` extract pass remains the backstop for the final delta.
|
|
1132
|
+
*
|
|
1133
|
+
* The outcome is reported through the normal plugin log + telemetry channels.
|
|
1134
|
+
* This is the only remaining memory-harvest path in 0.9, and on a default
|
|
1135
|
+
* install it does not work: `akm proposal extract` needs an LLM engine, and
|
|
1136
|
+
* without one it answers `{ ok: false, code: "LLM_NOT_CONFIGURED", … }`. Real
|
|
1137
|
+
* akm prints that envelope on stderr and exits non-zero, but the shape is not
|
|
1138
|
+
* guaranteed across builds (the fake in evals/lib/fake-akm.ts models a build
|
|
1139
|
+
* that returns the same `ok: false` body while exiting 0). Discarding the
|
|
1140
|
+
* child's output — the previous `stdio: "ignore"` — therefore turned the most
|
|
1141
|
+
* likely failure in the whole feature into a silent no-op with nothing to
|
|
1142
|
+
* grep for. We now capture the envelope and treat `ok: false` as a failure
|
|
1143
|
+
* regardless of exit status, so the actionable code/hint reaches the log.
|
|
1144
|
+
*/
|
|
1145
|
+
function maybeExtractSessionOnIdle(client: LogCapableClient, sid: string, directory: string | undefined): void {
|
|
1146
|
+
const now = Date.now()
|
|
1147
|
+
const last = sessionLastExtractAt.get(sid) ?? 0
|
|
1148
|
+
if (now - last < AKM_EXTRACT_MIN_INTERVAL_MS) return
|
|
1149
|
+
const command = resolveAkmCommand()
|
|
1150
|
+
if (typeof command === "object" && "ok" in command) return // akm unavailable — cron backstop covers it
|
|
1151
|
+
sessionLastExtractAt.set(sid, now)
|
|
1152
|
+
|
|
1153
|
+
const reportExtractFailure = (error: string, extra?: Record<string, unknown>): void => {
|
|
1154
|
+
void writePluginLog(client, "warn", "AKM extract failed", {
|
|
1155
|
+
subsystem: "extract",
|
|
1156
|
+
sessionID: sid,
|
|
1157
|
+
directory,
|
|
1158
|
+
error,
|
|
1159
|
+
...extra,
|
|
1160
|
+
})
|
|
1161
|
+
void emitWorkflowTelemetry(client, "warn", "akm.extract.failed", {
|
|
1162
|
+
sessionID: sid,
|
|
1163
|
+
directory,
|
|
1164
|
+
toolName: "session.idle",
|
|
1165
|
+
outcome: "error",
|
|
1166
|
+
reason: error,
|
|
1167
|
+
...extra,
|
|
1168
|
+
})
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
try {
|
|
1172
|
+
const child = spawn(
|
|
1173
|
+
command.command,
|
|
1174
|
+
[...command.argsPrefix, "proposal", "extract", "--type", "opencode", "--session-id", sid, "--format", "json", "-q"],
|
|
1175
|
+
{
|
|
1176
|
+
detached: true,
|
|
1177
|
+
// Piped rather than ignored so the `ok:false` envelope is observable;
|
|
1178
|
+
// both pipes are unref'd below so this stays fire-and-forget.
|
|
1179
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1180
|
+
},
|
|
1181
|
+
)
|
|
1182
|
+
let output = ""
|
|
1183
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
1184
|
+
if (!stream) continue
|
|
1185
|
+
unrefChildStream(stream)
|
|
1186
|
+
stream.setEncoding("utf8")
|
|
1187
|
+
stream.on("data", (chunk: string) => {
|
|
1188
|
+
if (output.length < AKM_EXTRACT_OUTPUT_MAX_CHARS) output += chunk
|
|
1189
|
+
})
|
|
1190
|
+
// A pipe torn down with the detached child must not raise here.
|
|
1191
|
+
stream.on("error", () => {})
|
|
1192
|
+
}
|
|
1193
|
+
// "close" rather than "exit": it fires once the piped stdio has also been
|
|
1194
|
+
// drained, so `output` is complete when we inspect the envelope.
|
|
1195
|
+
child.on("close", (code, signal) => {
|
|
1196
|
+
const body = output.trim().slice(0, AKM_EXTRACT_OUTPUT_MAX_CHARS)
|
|
1197
|
+
const envelope = safeJsonParse<{ ok?: boolean; error?: string; code?: string; hint?: string }>(body)
|
|
1198
|
+
const exitFailed = (typeof code === "number" && code !== 0) || !!signal
|
|
1199
|
+
if (envelope?.ok === false || exitFailed) {
|
|
1200
|
+
const reason = envelope?.error
|
|
1201
|
+
?? (signal ? `akm extract exited via signal ${signal}` : `akm extract exited with code ${code}`)
|
|
1202
|
+
reportExtractFailure(reason, {
|
|
1203
|
+
akmCode: envelope?.code,
|
|
1204
|
+
hint: envelope?.hint,
|
|
1205
|
+
exitCode: code,
|
|
1206
|
+
// Only fall back to the raw body when it was not parseable JSON —
|
|
1207
|
+
// otherwise the structured fields above already carry everything.
|
|
1208
|
+
output: envelope ? undefined : truncateLogText(body, 400) || undefined,
|
|
1209
|
+
})
|
|
1210
|
+
return
|
|
1211
|
+
}
|
|
1212
|
+
void writePluginLog(client, "info", "AKM extract completed", {
|
|
1213
|
+
subsystem: "extract",
|
|
1214
|
+
sessionID: sid,
|
|
1215
|
+
directory,
|
|
1216
|
+
})
|
|
1217
|
+
})
|
|
1218
|
+
child.on("error", (error) => {
|
|
1219
|
+
reportExtractFailure(formatCliError(error))
|
|
1220
|
+
})
|
|
1221
|
+
child.unref()
|
|
1222
|
+
} catch (error: unknown) {
|
|
1223
|
+
reportExtractFailure(formatCliError(error))
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1648
1227
|
function extractFirstSemverMatch(value: string): string | null {
|
|
1649
1228
|
return value.match(SEMVER_PATTERN)?.[0] ?? null
|
|
1650
1229
|
}
|
|
@@ -1691,8 +1270,19 @@ function getLocalBuildAkmCommand(): ResolvedAkmCommand | null {
|
|
|
1691
1270
|
}
|
|
1692
1271
|
}
|
|
1693
1272
|
|
|
1694
|
-
|
|
1695
|
-
|
|
1273
|
+
// Every call site passes `encoding: "utf8"`, so the real runtime return value
|
|
1274
|
+
// is always a string — but `execFileSync`'s overloads resolve on the exact
|
|
1275
|
+
// shape of the options argument, and forwarding a loosely-typed `options`
|
|
1276
|
+
// parameter defeats that resolution, leaving the inferred return type
|
|
1277
|
+
// `string | Buffer`. Pin the options type to require `encoding: "utf8"` and
|
|
1278
|
+
// assert the (already-guaranteed) string return so callers get real string
|
|
1279
|
+
// typing without changing behavior.
|
|
1280
|
+
type ExecResolvedAkmOptions = Omit<NonNullable<Parameters<typeof execFileSync>[2]>, "encoding"> & {
|
|
1281
|
+
encoding: "utf8"
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function execResolvedAkm(command: ResolvedAkmCommand, args: string[], options: ExecResolvedAkmOptions): string {
|
|
1285
|
+
return execFileSync(command.command, [...command.argsPrefix, ...args], options) as string
|
|
1696
1286
|
}
|
|
1697
1287
|
|
|
1698
1288
|
function probeCommand(command: ResolvedAkmCommand): CommandProbe {
|
|
@@ -1707,9 +1297,19 @@ function probeCommand(command: ResolvedAkmCommand): CommandProbe {
|
|
|
1707
1297
|
return { ...command, exists: false, version: null, failureReason: "command_not_on_disk" }
|
|
1708
1298
|
}
|
|
1709
1299
|
try {
|
|
1300
|
+
// Pipe (don't inherit) the child's stderr. Some akm builds validate the
|
|
1301
|
+
// user's config on EVERY invocation — including `--version` — and a version
|
|
1302
|
+
// skew (e.g. a bundled akm-cli@0.8.x against a config written by 0.9.x, whose
|
|
1303
|
+
// improve process keys 0.8 rejects) makes `--version` exit non-zero and print
|
|
1304
|
+
// an INVALID_CONFIG_FILE blob to stderr. With the default inherited stderr
|
|
1305
|
+
// that blob leaks to OpenCode's console on every plugin load, reading as a
|
|
1306
|
+
// plugin failure even though resolution correctly falls through to a
|
|
1307
|
+
// compatible akm. Capturing it keeps the probe silent; the structured
|
|
1308
|
+
// resolution trail / consent banner still surfaces a genuine no-akm case.
|
|
1710
1309
|
const version = execResolvedAkm(command, ["--version"], {
|
|
1711
1310
|
encoding: "utf8",
|
|
1712
1311
|
timeout: 10_000,
|
|
1312
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1713
1313
|
})
|
|
1714
1314
|
const parsed = extractFirstSemverMatch(version)
|
|
1715
1315
|
if (!parsed) {
|
|
@@ -1725,13 +1325,6 @@ function probeCommand(command: ResolvedAkmCommand): CommandProbe {
|
|
|
1725
1325
|
}
|
|
1726
1326
|
}
|
|
1727
1327
|
|
|
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
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
1328
|
function getBundledAkmCommand(): string | null {
|
|
1736
1329
|
const packagePath = path.join(moduleDir, "node_modules", "akm-cli", "package.json")
|
|
1737
1330
|
const fallback = path.join(moduleDir, "node_modules", ".bin", process.platform === "win32" ? "akm.cmd" : "akm")
|
|
@@ -1786,18 +1379,28 @@ let lastAkmResolutionTrail: AkmResolutionTrail = []
|
|
|
1786
1379
|
|
|
1787
1380
|
function getResolvedAkmDetails(): { command: string; argsPrefix: string[]; displayCommand: string; version: string; source: "bundled" | "path" | "local_build" } | null {
|
|
1788
1381
|
const candidates: Array<{ command: string; argsPrefix: string[]; displayCommand: string; source: "bundled" | "path" | "local_build" }> = []
|
|
1789
|
-
//
|
|
1790
|
-
//
|
|
1791
|
-
//
|
|
1792
|
-
//
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1382
|
+
// Resolution precedence — first compatible candidate wins:
|
|
1383
|
+
// 1. AKM_LOCAL_BUILD_CLI — explicit dev override
|
|
1384
|
+
// 2. PATH / user installs — the akm the user actually installed, which wrote
|
|
1385
|
+
// their config and has its native deps (e.g. embeddings) built
|
|
1386
|
+
// 3. bundled akm-cli — last-resort fallback for users with no akm
|
|
1387
|
+
// The bundled CLI is deliberately LAST: preferring it over the user's own
|
|
1388
|
+
// install would ignore a newer user akm that understands a newer config and
|
|
1389
|
+
// route through a bundled copy whose native postinstalls may be unbuilt. A
|
|
1390
|
+
// config-INCOMPATIBLE candidate fails its `--version` probe — older akm builds
|
|
1391
|
+
// validate config on every invocation and exit non-zero — so it is skipped
|
|
1392
|
+
// silently and the version probe doubles as a config-compatibility gate.
|
|
1796
1393
|
const localBuild = getLocalBuildAkmCommand()
|
|
1797
1394
|
if (localBuild) candidates.push({ ...localBuild, source: "local_build" })
|
|
1798
1395
|
for (const command of getPathAkmCandidates()) {
|
|
1799
1396
|
candidates.push({ command, argsPrefix: [], displayCommand: command, source: "path" })
|
|
1800
1397
|
}
|
|
1398
|
+
// Opt-out (default off): drop the bundled fallback entirely. Used by the eval
|
|
1399
|
+
// harness so a deterministic fake `akm` on PATH is the only candidate; also a
|
|
1400
|
+
// useful escape hatch when the bundled dep is broken.
|
|
1401
|
+
const ignoreBundled = process.env.AKM_OPENCODE_IGNORE_BUNDLED_CLI === "1"
|
|
1402
|
+
const bundled = ignoreBundled ? null : getBundledAkmCommand()
|
|
1403
|
+
if (bundled) candidates.push({ command: bundled, argsPrefix: [], displayCommand: bundled, source: "bundled" })
|
|
1801
1404
|
|
|
1802
1405
|
const trail: AkmResolutionTrail = []
|
|
1803
1406
|
const seen = new Set<string>()
|
|
@@ -1829,10 +1432,11 @@ function getResolvedAkmDetails(): { command: string; argsPrefix: string[]; displ
|
|
|
1829
1432
|
// The OpenCode plugin has never silently auto-installed akm-cli — it relies on
|
|
1830
1433
|
// the bundled binary that ships with the plugin, falling back to PATH. When
|
|
1831
1434
|
// neither path produces a compatible akm we log a warn-level event to the host
|
|
1832
|
-
// AND
|
|
1833
|
-
//
|
|
1834
|
-
// user-driven, never automatic.
|
|
1835
|
-
// (or the host-specific akm
|
|
1435
|
+
// AND emit a consent banner through the host's structured logging channel so
|
|
1436
|
+
// the human running OpenCode actually sees the problem. The banner mirrors
|
|
1437
|
+
// the Claude plugin's wording: install must be user-driven, never automatic.
|
|
1438
|
+
// The recommended consent point is `akm setup` (or the host-specific akm
|
|
1439
|
+
// setup slash command if one exists).
|
|
1836
1440
|
async function ensureSupportedAkmResolved(client: LogCapableClient): Promise<void> {
|
|
1837
1441
|
const installedAkm = getResolvedAkmDetails()
|
|
1838
1442
|
if (!installedAkm) {
|
|
@@ -1844,7 +1448,7 @@ async function ensureSupportedAkmResolved(client: LogCapableClient): Promise<voi
|
|
|
1844
1448
|
reason: "no_supported_command",
|
|
1845
1449
|
trail: lastAkmResolutionTrail,
|
|
1846
1450
|
})
|
|
1847
|
-
writeAkmConsentBanner({
|
|
1451
|
+
await writeAkmConsentBanner(client, {
|
|
1848
1452
|
detected: getCommandVersion("akm") ?? undefined,
|
|
1849
1453
|
bundled: getBundledAkmCommand(),
|
|
1850
1454
|
trail: lastAkmResolutionTrail,
|
|
@@ -1862,7 +1466,7 @@ async function ensureSupportedAkmResolved(client: LogCapableClient): Promise<voi
|
|
|
1862
1466
|
})
|
|
1863
1467
|
}
|
|
1864
1468
|
|
|
1865
|
-
function writeAkmConsentBanner(info: { detected?: string; bundled?: string | null; trail?: AkmResolutionTrail }) {
|
|
1469
|
+
async function writeAkmConsentBanner(client: LogCapableClient, info: { detected?: string; bundled?: string | null; trail?: AkmResolutionTrail }) {
|
|
1866
1470
|
const detectedLabel = info.detected ?? "(not found on PATH)"
|
|
1867
1471
|
const bundledLabel = info.bundled ?? "(none)"
|
|
1868
1472
|
const trailLines: string[] = []
|
|
@@ -1884,16 +1488,23 @@ function writeAkmConsentBanner(info: { detected?: string; bundled?: string | nul
|
|
|
1884
1488
|
"",
|
|
1885
1489
|
"Reinstall or update the akm-opencode plugin so OpenCode/Bun",
|
|
1886
1490
|
"installs the dependency, or install akm-cli manually:",
|
|
1887
|
-
|
|
1888
|
-
|
|
1491
|
+
` bun install -g ${AKM_RECOMMENDED_INSTALL_REF}`,
|
|
1492
|
+
` npm install -g ${AKM_RECOMMENDED_INSTALL_REF}`,
|
|
1889
1493
|
"Then run `akm setup` interactively to configure the stash.",
|
|
1890
1494
|
"─".repeat(60),
|
|
1891
1495
|
].join("\n")
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1496
|
+
// AGENTS.md forbids plugin runtime code from writing to
|
|
1497
|
+
// console.*/stdout/stderr; route the banner through the host's structured
|
|
1498
|
+
// logging channel (client.app.log) instead of process.stderr.write so it
|
|
1499
|
+
// still reaches the user without violating that rule.
|
|
1500
|
+
await writePluginLog(client, "warn", "akm CLI not installed or wrong version", {
|
|
1501
|
+
subsystem: "akm",
|
|
1502
|
+
detected: detectedLabel,
|
|
1503
|
+
bundled: bundledLabel,
|
|
1504
|
+
required: AKM_REQUIRED_VERSION_RANGE,
|
|
1505
|
+
installRef: AKM_RECOMMENDED_INSTALL_REF,
|
|
1506
|
+
banner,
|
|
1507
|
+
})
|
|
1897
1508
|
}
|
|
1898
1509
|
|
|
1899
1510
|
function resolveAkmCommand(): ResolvedAkmCommand | CliError {
|
|
@@ -1937,9 +1548,9 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
|
|
|
1937
1548
|
return JSON.stringify(command)
|
|
1938
1549
|
}
|
|
1939
1550
|
|
|
1940
|
-
//
|
|
1941
|
-
|
|
1942
|
-
const fullArgs =
|
|
1551
|
+
// --format is a global flag on every 0.9.0 verb (the 0.8.0-era `akm improve`
|
|
1552
|
+
// hard-reject is gone), so it is safe to auto-inject unconditionally.
|
|
1553
|
+
const fullArgs = args.includes("--format") ? [...args] : [...args, "--format", "json"]
|
|
1943
1554
|
const proposalId = args[0] === "proposal" && typeof args[2] === "string" ? args[2] : null
|
|
1944
1555
|
|
|
1945
1556
|
const recordSuccess = async (stdout: string): Promise<string> => {
|
|
@@ -1956,8 +1567,8 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
|
|
|
1956
1567
|
})
|
|
1957
1568
|
const parsed = safeJsonParse<SearchResponse>(stdout)
|
|
1958
1569
|
const refs = args[0] === "search" || args[0] === "curate"
|
|
1959
|
-
? [...new Set([...(parsed?.hits?.flatMap((hit) => hit.ref ? [hit.ref] : []) ?? []), ...
|
|
1960
|
-
:
|
|
1570
|
+
? [...new Set([...(parsed?.hits?.flatMap((hit) => hit.ref ? [hit.ref] : []) ?? []), ...extractAkmRefsFromString(stdout)])]
|
|
1571
|
+
: extractAkmRefsFromString(stdout)
|
|
1961
1572
|
noteRecentRefs(meta.sessionID, refs)
|
|
1962
1573
|
if (meta.toolName === "akm_search") {
|
|
1963
1574
|
await emitWorkflowTelemetry(client, "info", "akm.search.invoked", {
|
|
@@ -2018,9 +1629,6 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
|
|
|
2018
1629
|
return recordSuccess(stdout)
|
|
2019
1630
|
} catch (error: unknown) {
|
|
2020
1631
|
let message = formatCliError(error)
|
|
2021
|
-
if (["akm_improve", "akm_propose"].includes(meta.toolName) && needsAgentSetup(message)) {
|
|
2022
|
-
message = addAgentSetupGuidance(message)
|
|
2023
|
-
}
|
|
2024
1632
|
message = addAgentSetupGuidance(message)
|
|
2025
1633
|
await writePluginLog(client, "error", "AKM command failed", {
|
|
2026
1634
|
subsystem: "akm",
|
|
@@ -2047,64 +1655,89 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
|
|
|
2047
1655
|
}
|
|
2048
1656
|
}
|
|
2049
1657
|
|
|
1658
|
+
async function runInProcess(
|
|
1659
|
+
client: LogCapableClient,
|
|
1660
|
+
operation: "search" | "show" | "curate",
|
|
1661
|
+
input: Record<string, unknown>,
|
|
1662
|
+
meta: CliLogMeta,
|
|
1663
|
+
): Promise<string> {
|
|
1664
|
+
try {
|
|
1665
|
+
const result = operation === "search"
|
|
1666
|
+
? await akmSearch(input as Parameters<typeof akmSearch>[0])
|
|
1667
|
+
: operation === "show"
|
|
1668
|
+
? await akmShowUnified(input as Parameters<typeof akmShowUnified>[0])
|
|
1669
|
+
: await akmCurate(input as Parameters<typeof akmCurate>[0])
|
|
1670
|
+
const output = JSON.stringify(result)
|
|
1671
|
+
const refs = extractAkmRefsFromString(output)
|
|
1672
|
+
noteRecentRefs(meta.sessionID, refs)
|
|
1673
|
+
await writePluginLog(client, "info", "AKM in-process call completed", {
|
|
1674
|
+
subsystem: "akm",
|
|
1675
|
+
toolName: meta.toolName,
|
|
1676
|
+
sessionID: meta.sessionID,
|
|
1677
|
+
directory: meta.directory,
|
|
1678
|
+
operation,
|
|
1679
|
+
refs,
|
|
1680
|
+
})
|
|
1681
|
+
await emitWorkflowTelemetry(client, "info", `akm.${operation}.invoked`, {
|
|
1682
|
+
sessionID: meta.sessionID,
|
|
1683
|
+
toolName: meta.toolName,
|
|
1684
|
+
assetRef: operation === "show" ? String(input.ref ?? refs[0] ?? "") || null : refs[0] ?? null,
|
|
1685
|
+
outcome: "success",
|
|
1686
|
+
directory: meta.directory,
|
|
1687
|
+
})
|
|
1688
|
+
return output
|
|
1689
|
+
} catch (error: unknown) {
|
|
1690
|
+
const message = formatCliError(error)
|
|
1691
|
+
await writePluginLog(client, "error", "AKM in-process call failed", {
|
|
1692
|
+
subsystem: "akm",
|
|
1693
|
+
toolName: meta.toolName,
|
|
1694
|
+
sessionID: meta.sessionID,
|
|
1695
|
+
directory: meta.directory,
|
|
1696
|
+
operation,
|
|
1697
|
+
error: message,
|
|
1698
|
+
})
|
|
1699
|
+
await emitWorkflowTelemetry(client, "warn", `${meta.toolName}.failed`, {
|
|
1700
|
+
sessionID: meta.sessionID,
|
|
1701
|
+
toolName: meta.toolName,
|
|
1702
|
+
outcome: "error",
|
|
1703
|
+
reason: message,
|
|
1704
|
+
directory: meta.directory,
|
|
1705
|
+
})
|
|
1706
|
+
return JSON.stringify({ ok: false, error: message })
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
2050
1710
|
type CliError = { ok: false; error: string }
|
|
2051
|
-
type AssetType =
|
|
2052
|
-
| "agent"
|
|
2053
|
-
| "command"
|
|
2054
|
-
| "knowledge"
|
|
2055
|
-
| "lesson"
|
|
2056
|
-
| "memory"
|
|
2057
|
-
| "script"
|
|
2058
|
-
| "skill"
|
|
2059
|
-
| "task"
|
|
2060
|
-
| "workflow"
|
|
2061
|
-
| "env"
|
|
2062
|
-
| "secret"
|
|
2063
|
-
| "wiki"
|
|
2064
1711
|
|
|
1712
|
+
// The AKM 0.9 asset-type vocabulary, in the singular form `--type` accepts.
|
|
1713
|
+
// This is exactly `akm info --format json` -> .assetTypes, sorted; keep the two
|
|
1714
|
+
// in step when akm adds a type. Note there is no `wiki` type in 0.9 — the entry
|
|
1715
|
+
// that used to be here made `type: "wiki"` a selectable enum value that akm
|
|
1716
|
+
// answers with an empty hit list rather than an error, i.e. a silent dead end,
|
|
1717
|
+
// while `instruction`, `session`, and `fact` could not be filtered for at all.
|
|
1718
|
+
// `any` is a tool-surface sentinel, not an akm type: it means "no filter" and is
|
|
1719
|
+
// stripped before the value reaches akm, so it sorts last.
|
|
2065
1720
|
const ASSET_TYPES = [
|
|
2066
1721
|
"agent",
|
|
2067
1722
|
"command",
|
|
1723
|
+
"env",
|
|
1724
|
+
"fact",
|
|
1725
|
+
"instruction",
|
|
2068
1726
|
"knowledge",
|
|
2069
1727
|
"lesson",
|
|
2070
1728
|
"memory",
|
|
2071
1729
|
"script",
|
|
1730
|
+
"secret",
|
|
1731
|
+
"session",
|
|
2072
1732
|
"skill",
|
|
2073
1733
|
"task",
|
|
2074
1734
|
"workflow",
|
|
2075
|
-
"env",
|
|
2076
|
-
"secret",
|
|
2077
|
-
"wiki",
|
|
2078
1735
|
"any",
|
|
2079
1736
|
] as const
|
|
2080
1737
|
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
path: string
|
|
2085
|
-
description?: string
|
|
2086
|
-
prompt?: string
|
|
2087
|
-
toolPolicy?: unknown
|
|
2088
|
-
modelHint?: unknown
|
|
2089
|
-
editable?: boolean
|
|
2090
|
-
origin?: string | null
|
|
2091
|
-
action?: string
|
|
2092
|
-
editHint?: string
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
type ShowCommandResponse = {
|
|
2096
|
-
type: "command"
|
|
2097
|
-
name: string
|
|
2098
|
-
path: string
|
|
2099
|
-
description?: string
|
|
2100
|
-
template?: string
|
|
2101
|
-
editable?: boolean
|
|
2102
|
-
agent?: string
|
|
2103
|
-
origin?: string | null
|
|
2104
|
-
action?: string
|
|
2105
|
-
parameters?: string[]
|
|
2106
|
-
editHint?: string
|
|
2107
|
-
}
|
|
1738
|
+
// Derived from ASSET_TYPES so the enum published on the akm_search/akm_curate
|
|
1739
|
+
// tool surface and the type carried by search hits cannot drift apart again.
|
|
1740
|
+
type AssetType = Exclude<(typeof ASSET_TYPES)[number], "any">
|
|
2108
1741
|
|
|
2109
1742
|
type ShowToolResponse = {
|
|
2110
1743
|
type: "tool" | "script"
|
|
@@ -2154,46 +1787,6 @@ function isShowToolResponse(value: unknown): value is ShowToolResponse {
|
|
|
2154
1787
|
&& ((value as { type?: unknown }).type === "tool" || (value as { type?: unknown }).type === "script")
|
|
2155
1788
|
}
|
|
2156
1789
|
|
|
2157
|
-
function isShowAgentResponse(value: unknown): value is ShowAgentResponse {
|
|
2158
|
-
return !!value
|
|
2159
|
-
&& typeof value === "object"
|
|
2160
|
-
&& (value as { type?: unknown }).type === "agent"
|
|
2161
|
-
}
|
|
2162
|
-
|
|
2163
|
-
function isShowCommandResponse(value: unknown): value is ShowCommandResponse {
|
|
2164
|
-
return !!value
|
|
2165
|
-
&& typeof value === "object"
|
|
2166
|
-
&& (value as { type?: unknown }).type === "command"
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
function parseCliJson<T>(raw: string): T | CliError {
|
|
2170
|
-
try {
|
|
2171
|
-
return JSON.parse(raw) as T
|
|
2172
|
-
} catch {
|
|
2173
|
-
return {
|
|
2174
|
-
ok: false,
|
|
2175
|
-
error: "akm CLI returned non-JSON output",
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
function formatAkmInfoResponse(raw: string): string {
|
|
2181
|
-
const parsed = parseCliJson<Record<string, unknown>>(raw)
|
|
2182
|
-
if (isCliError(parsed)) return JSON.stringify(parsed)
|
|
2183
|
-
return JSON.stringify({
|
|
2184
|
-
ok: true,
|
|
2185
|
-
pluginVersion: PLUGIN_VERSION,
|
|
2186
|
-
pluginInstallLocation: PLUGIN_INSTALL_LOCATION,
|
|
2187
|
-
akmInfo: parsed,
|
|
2188
|
-
})
|
|
2189
|
-
}
|
|
2190
|
-
|
|
2191
|
-
function blockedToolResponse(args: Record<string, unknown>): string | null {
|
|
2192
|
-
return typeof args.__akmBlocked === "string"
|
|
2193
|
-
? JSON.stringify({ ok: false, error: args.__akmBlocked })
|
|
2194
|
-
: null
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
1790
|
function isCliError(value: unknown): value is CliError {
|
|
2198
1791
|
return !!value
|
|
2199
1792
|
&& typeof value === "object"
|
|
@@ -2202,62 +1795,6 @@ function isCliError(value: unknown): value is CliError {
|
|
|
2202
1795
|
&& "error" in value
|
|
2203
1796
|
}
|
|
2204
1797
|
|
|
2205
|
-
function parseModelHint(modelHint: unknown): { providerID: string; modelID: string } | undefined {
|
|
2206
|
-
if (typeof modelHint !== "string") return undefined
|
|
2207
|
-
const [providerID, ...modelParts] = modelHint.split("/")
|
|
2208
|
-
const modelID = modelParts.join("/")
|
|
2209
|
-
if (!providerID || !modelID) return undefined
|
|
2210
|
-
return { providerID, modelID }
|
|
2211
|
-
}
|
|
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
|
-
|
|
2225
|
-
function parseToolPolicy(toolPolicy: unknown): Record<string, boolean> | undefined {
|
|
2226
|
-
const result: Record<string, boolean> = {}
|
|
2227
|
-
|
|
2228
|
-
const assign = (key: string, value: unknown) => {
|
|
2229
|
-
const normalizedKey = key.trim().toLowerCase()
|
|
2230
|
-
if (!normalizedKey) return
|
|
2231
|
-
if (typeof value === "boolean") {
|
|
2232
|
-
result[normalizedKey] = value
|
|
2233
|
-
return
|
|
2234
|
-
}
|
|
2235
|
-
if (typeof value === "string") {
|
|
2236
|
-
if (value === "allow") result[normalizedKey] = true
|
|
2237
|
-
if (value === "deny") result[normalizedKey] = false
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
|
|
2241
|
-
if (typeof toolPolicy === "string") {
|
|
2242
|
-
assign(toolPolicy, true)
|
|
2243
|
-
return Object.keys(result).length > 0 ? result : undefined
|
|
2244
|
-
}
|
|
2245
|
-
|
|
2246
|
-
if (Array.isArray(toolPolicy)) {
|
|
2247
|
-
for (const item of toolPolicy) {
|
|
2248
|
-
if (typeof item === "string") assign(item, true)
|
|
2249
|
-
}
|
|
2250
|
-
return Object.keys(result).length > 0 ? result : undefined
|
|
2251
|
-
}
|
|
2252
|
-
|
|
2253
|
-
if (!toolPolicy || typeof toolPolicy !== "object") return undefined
|
|
2254
|
-
|
|
2255
|
-
for (const [key, value] of Object.entries(toolPolicy as Record<string, unknown>)) {
|
|
2256
|
-
assign(key, value)
|
|
2257
|
-
}
|
|
2258
|
-
return Object.keys(result).length > 0 ? result : undefined
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
1798
|
function extractText(parts: unknown): string {
|
|
2262
1799
|
if (!Array.isArray(parts)) return ""
|
|
2263
1800
|
const segments: string[] = []
|
|
@@ -2294,7 +1831,7 @@ function extractMemoryRefs(toolName: string, args: Record<string, unknown>, valu
|
|
|
2294
1831
|
if (parsed?.type === "memory") {
|
|
2295
1832
|
if (typeof parsed.ref === "string" && parsed.ref) refs.add(parsed.ref)
|
|
2296
1833
|
if (typeof args.ref === "string" && args.ref) refs.add(args.ref)
|
|
2297
|
-
if (refs.size === 0 && typeof parsed.name === "string" && parsed.name) refs.add(`
|
|
1834
|
+
if (refs.size === 0 && typeof parsed.name === "string" && parsed.name) refs.add(`memories/${parsed.name}`)
|
|
2298
1835
|
}
|
|
2299
1836
|
|
|
2300
1837
|
if (Array.isArray(parsed?.hits)) {
|
|
@@ -2335,282 +1872,10 @@ function truncateLogText(value: string, limit = 1_000): string {
|
|
|
2335
1872
|
return value.length > limit ? `${value.slice(0, limit)}…` : value
|
|
2336
1873
|
}
|
|
2337
1874
|
|
|
2338
|
-
async function searchRef(
|
|
2339
|
-
client: LogCapableClient,
|
|
2340
|
-
query: string,
|
|
2341
|
-
type: AssetType | "any",
|
|
2342
|
-
meta: CliLogMeta,
|
|
2343
|
-
): Promise<{ ok: true; ref: string } | CliError> {
|
|
2344
|
-
const raw = await runCli(
|
|
2345
|
-
client,
|
|
2346
|
-
["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "stash"],
|
|
2347
|
-
meta,
|
|
2348
|
-
)
|
|
2349
|
-
const parsed = parseCliJson<SearchResponse>(raw)
|
|
2350
|
-
if (isCliError(parsed)) return parsed
|
|
2351
|
-
const ref = parsed.hits?.[0]?.ref
|
|
2352
|
-
if (!ref) {
|
|
2353
|
-
return {
|
|
2354
|
-
ok: false,
|
|
2355
|
-
error: `No stash ref matched '${query}'. Use akm_search to disambiguate, then retry with an exact ref.`,
|
|
2356
|
-
}
|
|
2357
|
-
}
|
|
2358
|
-
return { ok: true, ref }
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
async function resolveRefOrQueryInput(
|
|
2362
|
-
client: LogCapableClient,
|
|
2363
|
-
input: { ref?: string; query?: string },
|
|
2364
|
-
type: AssetType | "any",
|
|
2365
|
-
meta: CliLogMeta,
|
|
2366
|
-
): Promise<{ ok: true; ref: string } | CliError> {
|
|
2367
|
-
const explicitRef = input.ref?.trim()
|
|
2368
|
-
if (explicitRef) return { ok: true, ref: explicitRef }
|
|
2369
|
-
|
|
2370
|
-
const query = input.query?.trim()
|
|
2371
|
-
if (!query) {
|
|
2372
|
-
return { ok: false, error: "Provide either 'ref' or 'query'." }
|
|
2373
|
-
}
|
|
2374
|
-
return searchRef(client, query, type, meta)
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
async function ensureTargetSessionID(input: {
|
|
2378
|
-
useSubtask: boolean
|
|
2379
|
-
context: { sessionID: string; directory: string }
|
|
2380
|
-
title: string
|
|
2381
|
-
client: PluginClient
|
|
2382
|
-
logClient: LogCapableClient
|
|
2383
|
-
toolName: string
|
|
2384
|
-
}): Promise<{ ok: true; sessionID: string } | CliError> {
|
|
2385
|
-
if (!input.useSubtask) return { ok: true, sessionID: input.context.sessionID }
|
|
2386
|
-
|
|
2387
|
-
try {
|
|
2388
|
-
const created = await input.client.session.create({
|
|
2389
|
-
body: { parentID: input.context.sessionID, title: input.title },
|
|
2390
|
-
})
|
|
2391
|
-
if (created.error || !created.data?.id) {
|
|
2392
|
-
const reason = created.error ? JSON.stringify(created.error) : "missing child session id"
|
|
2393
|
-
await writePluginLog(input.logClient, "error", "AKM dispatch child session failed", {
|
|
2394
|
-
subsystem: "dispatch",
|
|
2395
|
-
toolName: input.toolName,
|
|
2396
|
-
sessionID: input.context.sessionID,
|
|
2397
|
-
directory: input.context.directory,
|
|
2398
|
-
title: input.title,
|
|
2399
|
-
error: reason,
|
|
2400
|
-
})
|
|
2401
|
-
return { ok: false, error: `Failed to create child session: ${reason}` }
|
|
2402
|
-
}
|
|
2403
|
-
await writePluginLog(input.logClient, "info", "AKM dispatch child session created", {
|
|
2404
|
-
subsystem: "dispatch",
|
|
2405
|
-
toolName: input.toolName,
|
|
2406
|
-
sessionID: input.context.sessionID,
|
|
2407
|
-
directory: input.context.directory,
|
|
2408
|
-
childSessionID: created.data.id,
|
|
2409
|
-
title: input.title,
|
|
2410
|
-
})
|
|
2411
|
-
return { ok: true, sessionID: created.data.id }
|
|
2412
|
-
} catch (error: unknown) {
|
|
2413
|
-
const reason = error instanceof Error ? error.message : String(error)
|
|
2414
|
-
await writePluginLog(input.logClient, "error", "AKM dispatch child session threw", {
|
|
2415
|
-
subsystem: "dispatch",
|
|
2416
|
-
toolName: input.toolName,
|
|
2417
|
-
sessionID: input.context.sessionID,
|
|
2418
|
-
directory: input.context.directory,
|
|
2419
|
-
title: input.title,
|
|
2420
|
-
error: reason,
|
|
2421
|
-
})
|
|
2422
|
-
return { ok: false, error: `Failed to create child session: ${reason}` }
|
|
2423
|
-
}
|
|
2424
|
-
}
|
|
2425
|
-
|
|
2426
|
-
async function promptTargetSession(input: {
|
|
2427
|
-
client: PluginClient
|
|
2428
|
-
logClient: LogCapableClient
|
|
2429
|
-
toolName: string
|
|
2430
|
-
context: { sessionID: string; directory: string }
|
|
2431
|
-
targetSessionID: string
|
|
2432
|
-
promptBody: SessionPromptBody
|
|
2433
|
-
failureMessage: string
|
|
2434
|
-
ref?: string
|
|
2435
|
-
}): Promise<{ ok: true; data: { parts?: unknown } } | CliError> {
|
|
2436
|
-
try {
|
|
2437
|
-
const promptResponse = await input.client.session.prompt({
|
|
2438
|
-
path: { id: input.targetSessionID },
|
|
2439
|
-
body: input.promptBody,
|
|
2440
|
-
})
|
|
2441
|
-
|
|
2442
|
-
if (promptResponse.error || !promptResponse.data) {
|
|
2443
|
-
const reason = promptResponse.error ? JSON.stringify(promptResponse.error) : "empty response"
|
|
2444
|
-
await writePluginLog(input.logClient, "error", "AKM dispatch prompt failed", {
|
|
2445
|
-
subsystem: "dispatch",
|
|
2446
|
-
toolName: input.toolName,
|
|
2447
|
-
sessionID: input.context.sessionID,
|
|
2448
|
-
directory: input.context.directory,
|
|
2449
|
-
targetSessionID: input.targetSessionID,
|
|
2450
|
-
dispatchAgent: input.promptBody.agent,
|
|
2451
|
-
dispatchModel: input.promptBody.model ?? null,
|
|
2452
|
-
ref: input.ref,
|
|
2453
|
-
error: reason,
|
|
2454
|
-
})
|
|
2455
|
-
return {
|
|
2456
|
-
ok: false,
|
|
2457
|
-
error: `${input.failureMessage}: ${reason}`,
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
|
|
2461
|
-
await writePluginLog(input.logClient, "info", "AKM dispatch prompt completed", {
|
|
2462
|
-
subsystem: "dispatch",
|
|
2463
|
-
toolName: input.toolName,
|
|
2464
|
-
sessionID: input.context.sessionID,
|
|
2465
|
-
directory: input.context.directory,
|
|
2466
|
-
targetSessionID: input.targetSessionID,
|
|
2467
|
-
dispatchAgent: input.promptBody.agent,
|
|
2468
|
-
dispatchModel: input.promptBody.model ?? null,
|
|
2469
|
-
ref: input.ref,
|
|
2470
|
-
})
|
|
2471
|
-
return { ok: true, data: promptResponse.data }
|
|
2472
|
-
} catch (error: unknown) {
|
|
2473
|
-
const reason = error instanceof Error ? error.message : String(error)
|
|
2474
|
-
await writePluginLog(input.logClient, "error", "AKM dispatch prompt threw", {
|
|
2475
|
-
subsystem: "dispatch",
|
|
2476
|
-
toolName: input.toolName,
|
|
2477
|
-
sessionID: input.context.sessionID,
|
|
2478
|
-
directory: input.context.directory,
|
|
2479
|
-
targetSessionID: input.targetSessionID,
|
|
2480
|
-
dispatchAgent: input.promptBody.agent,
|
|
2481
|
-
dispatchModel: input.promptBody.model ?? null,
|
|
2482
|
-
ref: input.ref,
|
|
2483
|
-
error: reason,
|
|
2484
|
-
})
|
|
2485
|
-
return {
|
|
2486
|
-
ok: false,
|
|
2487
|
-
error: `${input.failureMessage}: ${reason}`,
|
|
2488
|
-
}
|
|
2489
|
-
}
|
|
2490
|
-
}
|
|
2491
|
-
|
|
2492
|
-
async function resolveDispatchAgent(
|
|
2493
|
-
client: PluginClient,
|
|
2494
|
-
requestedAgent: string,
|
|
2495
|
-
directory: string,
|
|
2496
|
-
): Promise<string> {
|
|
2497
|
-
if (requestedAgent !== "akm-curator") return requestedAgent
|
|
2498
|
-
try {
|
|
2499
|
-
const agents = await client.app.agents({ query: { directory } })
|
|
2500
|
-
if (agents.error) return "general"
|
|
2501
|
-
const hasCurator = (agents.data ?? []).some((agent) => agent?.name === "akm-curator")
|
|
2502
|
-
return hasCurator ? "akm-curator" : "general"
|
|
2503
|
-
} catch {
|
|
2504
|
-
return "general"
|
|
2505
|
-
}
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
|
-
function summarizeSessionMessages(
|
|
2509
|
-
sessionID: string,
|
|
2510
|
-
messages: Array<{ info?: Record<string, unknown>; parts?: unknown }>,
|
|
2511
|
-
) {
|
|
2512
|
-
return {
|
|
2513
|
-
ok: true,
|
|
2514
|
-
sessionID,
|
|
2515
|
-
messages: messages.map((message) => {
|
|
2516
|
-
const info = message.info ?? {}
|
|
2517
|
-
const role = typeof info.role === "string" ? info.role : "unknown"
|
|
2518
|
-
const agent = typeof info.agent === "string"
|
|
2519
|
-
? info.agent
|
|
2520
|
-
: typeof info.mode === "string"
|
|
2521
|
-
? info.mode
|
|
2522
|
-
: null
|
|
2523
|
-
return {
|
|
2524
|
-
role,
|
|
2525
|
-
agent,
|
|
2526
|
-
text: extractText(message.parts),
|
|
2527
|
-
}
|
|
2528
|
-
}),
|
|
2529
|
-
}
|
|
2530
|
-
}
|
|
2531
|
-
|
|
2532
|
-
async function getParentSessionID(
|
|
2533
|
-
client: PluginClient,
|
|
2534
|
-
sessionID: string,
|
|
2535
|
-
directory: string,
|
|
2536
|
-
): Promise<{ ok: true; parentID: string } | CliError> {
|
|
2537
|
-
try {
|
|
2538
|
-
const result = await client.session.get({
|
|
2539
|
-
path: { id: sessionID },
|
|
2540
|
-
query: { directory },
|
|
2541
|
-
})
|
|
2542
|
-
if (result.error || !result.data?.parentID) {
|
|
2543
|
-
return { ok: false, error: "This session does not have a parent session." }
|
|
2544
|
-
}
|
|
2545
|
-
return { ok: true, parentID: result.data.parentID }
|
|
2546
|
-
} catch (error: unknown) {
|
|
2547
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
2548
|
-
}
|
|
2549
|
-
}
|
|
2550
|
-
|
|
2551
|
-
function renderCommandTemplate(template: string, rawArguments: string): string {
|
|
2552
|
-
const args = splitArguments(rawArguments)
|
|
2553
|
-
return template
|
|
2554
|
-
.replace(/\$ARGUMENTS/g, rawArguments)
|
|
2555
|
-
.replace(/\$(\d+)/g, (_m, index: string) => args[Number(index) - 1] ?? "")
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
function normalizeSearchSource(source: "local" | "stash" | "registry" | "both"): "stash" | "registry" | "both" {
|
|
2559
|
-
return source === "local" ? "stash" : source
|
|
2560
|
-
}
|
|
2561
|
-
|
|
2562
|
-
function createSearchArgs(input: {
|
|
2563
|
-
query: string
|
|
2564
|
-
type?: AssetType | "any" | string
|
|
2565
|
-
limit?: number
|
|
2566
|
-
source?: "local" | "stash" | "registry" | "both"
|
|
2567
|
-
defaultSource?: "local" | "stash" | "registry" | "both"
|
|
2568
|
-
includeProposed?: boolean
|
|
2569
|
-
}): string[] {
|
|
2570
|
-
const args = ["search", input.query]
|
|
2571
|
-
if (input.type) args.push("--type", input.type)
|
|
2572
|
-
if (input.limit) args.push("--limit", String(input.limit))
|
|
2573
|
-
if (input.source) {
|
|
2574
|
-
args.push("--source", normalizeSearchSource(input.source))
|
|
2575
|
-
} else if (input.defaultSource) {
|
|
2576
|
-
args.push("--source", normalizeSearchSource(input.defaultSource))
|
|
2577
|
-
}
|
|
2578
|
-
if (input.includeProposed) args.push("--include-proposed")
|
|
2579
|
-
args.push("--detail", "normal")
|
|
2580
|
-
return args
|
|
2581
|
-
}
|
|
2582
|
-
|
|
2583
|
-
type PluginClient = {
|
|
2584
|
-
session: {
|
|
2585
|
-
create: (input: {
|
|
2586
|
-
body: { parentID: string; title: string }
|
|
2587
|
-
}) => Promise<{ data?: { id?: string }; error?: unknown }>
|
|
2588
|
-
get: (input: {
|
|
2589
|
-
path: { id: string }
|
|
2590
|
-
query?: { directory?: string }
|
|
2591
|
-
}) => Promise<{ data?: { id?: string; parentID?: string }; error?: unknown }>
|
|
2592
|
-
messages: (input: {
|
|
2593
|
-
path: { id: string }
|
|
2594
|
-
query?: { directory?: string }
|
|
2595
|
-
}) => Promise<{ data?: Array<{ info?: Record<string, unknown>; parts?: unknown }>; error?: unknown }>
|
|
2596
|
-
prompt: (input: {
|
|
2597
|
-
path: { id: string }
|
|
2598
|
-
body: SessionPromptBody
|
|
2599
|
-
}) => Promise<{ data?: { parts?: unknown }; error?: unknown }>
|
|
2600
|
-
}
|
|
2601
|
-
app: {
|
|
2602
|
-
agents: (input?: {
|
|
2603
|
-
query?: { directory?: string }
|
|
2604
|
-
}) => Promise<{ data?: Array<{ name?: string }>; error?: unknown }>
|
|
2605
|
-
}
|
|
2606
|
-
}
|
|
2607
|
-
|
|
2608
1875
|
export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
2609
1876
|
await ensureSupportedAkmResolved(client as unknown as LogCapableClient)
|
|
2610
1877
|
|
|
2611
1878
|
const logClient = client as unknown as LogCapableClient
|
|
2612
|
-
const sdkClient = client as unknown as PluginClient
|
|
2613
|
-
|
|
2614
1879
|
return {
|
|
2615
1880
|
// Events cover the lifecycle boundaries that Claude Code exposes as
|
|
2616
1881
|
// SessionStart / Stop / PreCompact. We use them to warm the stash, capture
|
|
@@ -2631,12 +1896,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2631
1896
|
})
|
|
2632
1897
|
if (!sessionContextEpoch.has(sid)) sessionContextEpoch.set(sid, 0)
|
|
2633
1898
|
if (type === "session.created") {
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
trigger: "session.created",
|
|
2638
|
-
platform: "opencode",
|
|
2639
|
-
})
|
|
1899
|
+
// Best-effort, fire-and-forget, fully error-trapped internally —
|
|
1900
|
+
// must never block or fail session.created (13: "tmp-file cleanup").
|
|
1901
|
+
void pruneStaleCuratedFiles()
|
|
2640
1902
|
warmIndexInBackground()
|
|
2641
1903
|
if (AKM_AUTO_CURATE && !sessionCurated.has(sid)) {
|
|
2642
1904
|
const cwdContext = gatherCwdContext(directory)
|
|
@@ -2661,73 +1923,49 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2661
1923
|
}
|
|
2662
1924
|
} else if (type === "session.compacted" || type === "session.idle" || type === "session.deleted") {
|
|
2663
1925
|
if (!sid) return
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
1926
|
+
// 03-R1/06-M1: the session_checkpoint `remember --force` write is
|
|
1927
|
+
// removed. Keep the freshness reindex on session-end events so
|
|
1928
|
+
// upstream inference/graph passes still run.
|
|
1929
|
+
await maybeIndexSessionMemory(logClient, sid, type, "")
|
|
1930
|
+
// `stop` is not a real OpenCode hook (not in the Hooks contract), so
|
|
1931
|
+
// the memory-candidate harvest has to ride real lifecycle events
|
|
1932
|
+
// instead: idle (per-turn quiescence), compacted, and deleted. This
|
|
1933
|
+
// is safe to fire on all three — a harvest always clears the buffer,
|
|
1934
|
+
// so a later event finds it empty and no-ops rather than
|
|
1935
|
+
// double-harvesting. A below-noise-floor buffer is kept across
|
|
1936
|
+
// non-terminal events (so intents accumulate across turns) and only
|
|
1937
|
+
// discarded on the terminal session.deleted.
|
|
1938
|
+
maybeExtractSessionCandidates(sid, type)
|
|
1939
|
+
// Event-driven extraction: only on session.idle (per-turn quiescence),
|
|
1940
|
+
// min-interval-gated so it doesn't flood. Not on compacted/deleted.
|
|
1941
|
+
if (type === "session.idle") {
|
|
1942
|
+
maybeExtractSessionOnIdle(logClient, sid, directory)
|
|
2676
1943
|
}
|
|
2677
1944
|
if (type === "session.compacted") {
|
|
1945
|
+
// 03-R1/06-M1: the session_checkpoint capture that used to feed
|
|
1946
|
+
// `memory.ref` here was removed along with the `remember --force`
|
|
1947
|
+
// write. Record the event as an explicit no-capture so
|
|
1948
|
+
// post_compact_summary consumers see a skipped outcome instead of
|
|
1949
|
+
// a dangling/undefined ref.
|
|
2678
1950
|
writeStructuredEvent({
|
|
2679
1951
|
event: "post_compact_summary",
|
|
2680
1952
|
sessionId: sid,
|
|
2681
1953
|
scope: buildEventScope(sid, directory),
|
|
2682
|
-
memory: { ref:
|
|
2683
|
-
outcome: { status:
|
|
1954
|
+
memory: { ref: null, reason: type },
|
|
1955
|
+
outcome: { status: "skipped" },
|
|
2684
1956
|
})
|
|
2685
1957
|
}
|
|
2686
|
-
// Drop per-session state
|
|
2687
|
-
// stale
|
|
1958
|
+
// Drop per-session state (every session-keyed Map, plus the curated
|
|
1959
|
+
// tmp file) so a re-created session does not inherit stale
|
|
1960
|
+
// hints/curation and the tmp file does not leak (13: "Memory leaks").
|
|
2688
1961
|
if (type === "session.deleted") {
|
|
2689
|
-
|
|
2690
|
-
sessionCurated.delete(sid)
|
|
2691
|
-
sessionCuratedFile.delete(sid)
|
|
2692
|
-
sessionWorkflow.delete(sid)
|
|
2693
|
-
sessionCuratorReport.delete(sid)
|
|
2694
|
-
sessionContextEpoch.delete(sid)
|
|
2695
|
-
sessionContextInjectedEpoch.delete(sid)
|
|
2696
|
-
sessionCuratedVersion.delete(sid)
|
|
2697
|
-
sessionCuratedInjectedVersion.delete(sid)
|
|
2698
|
-
sessionFinalMemoryCaptured.delete(sid)
|
|
2699
|
-
sessionSuccessfulAssetTouchCount.delete(sid)
|
|
2700
|
-
sessionBuffer.delete(sid)
|
|
1962
|
+
clearSessionState(sid)
|
|
2701
1963
|
}
|
|
2702
1964
|
}
|
|
2703
1965
|
} catch (error: unknown) {
|
|
2704
1966
|
await logHookFailure(logClient, "event", error)
|
|
2705
1967
|
}
|
|
2706
1968
|
},
|
|
2707
|
-
// Stop is the closest analogue to Claude's Stop/SubagentStop — the user or
|
|
2708
|
-
// agent halted the active run. Flush the session buffer so learnings are
|
|
2709
|
-
// preserved even if the session.idle event does not fire.
|
|
2710
|
-
stop: async (input: unknown) => {
|
|
2711
|
-
try {
|
|
2712
|
-
const sid = extractSessionIdFromEvent(input)
|
|
2713
|
-
if (!sid) return
|
|
2714
|
-
const captured = captureSessionMemory(sid, "stop", { directory })
|
|
2715
|
-
if (captured) {
|
|
2716
|
-
await writePluginLog(logClient, "info", "AKM session memory captured", {
|
|
2717
|
-
subsystem: "memory",
|
|
2718
|
-
actor: "system",
|
|
2719
|
-
sessionID: sid,
|
|
2720
|
-
reason: "stop",
|
|
2721
|
-
ref: captured,
|
|
2722
|
-
})
|
|
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)
|
|
2726
|
-
}
|
|
2727
|
-
} catch (error: unknown) {
|
|
2728
|
-
await logHookFailure(logClient, "stop", error)
|
|
2729
|
-
}
|
|
2730
|
-
},
|
|
2731
1969
|
// experimental.chat.system.transform is how OpenCode exposes the
|
|
2732
1970
|
// additionalContext channel. We append the cached hints (once per session)
|
|
2733
1971
|
// and the curated file reference (once per turn) so the next LLM call
|
|
@@ -2772,53 +2010,12 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2772
2010
|
await logHookFailure(logClient, "experimental.chat.system.transform", error)
|
|
2773
2011
|
}
|
|
2774
2012
|
},
|
|
2775
|
-
"tool.execute.before": async (input, output) => {
|
|
2776
|
-
try {
|
|
2777
|
-
if (!input.tool.startsWith("akm_")) return
|
|
2778
|
-
const args = output.args && typeof output.args === "object" ? output.args as Record<string, unknown> : {}
|
|
2779
|
-
const confirm = args.confirm === true
|
|
2780
|
-
if (input.tool === "akm_env" && (args.action === "path" || args.action === "run") && !confirm) {
|
|
2781
|
-
output.args = {
|
|
2782
|
-
...args,
|
|
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.",
|
|
2791
|
-
}
|
|
2792
|
-
return
|
|
2793
|
-
}
|
|
2794
|
-
if (input.tool === "akm_memory" && ["promote", "reject"].includes(String(args.action ?? "")) && !confirm) {
|
|
2795
|
-
output.args = {
|
|
2796
|
-
...args,
|
|
2797
|
-
__akmBlocked: `akm_memory action='${String(args.action)}' requires confirm:true because it mutates candidate or memory state.`,
|
|
2798
|
-
}
|
|
2799
|
-
return
|
|
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
|
-
}
|
|
2808
|
-
output.args = args
|
|
2809
|
-
} catch (error: unknown) {
|
|
2810
|
-
await logHookFailure(logClient, "tool.execute.before", error, {
|
|
2811
|
-
toolName: input?.tool,
|
|
2812
|
-
sessionID: input?.sessionID,
|
|
2813
|
-
})
|
|
2814
|
-
}
|
|
2815
|
-
},
|
|
2816
2013
|
"shell.env": async (_input, output) => {
|
|
2817
2014
|
try {
|
|
2818
2015
|
output.env.AKM_PROJECT = worktree
|
|
2819
2016
|
output.env.AKM_PLUGIN_VERSION = PLUGIN_VERSION
|
|
2820
|
-
const
|
|
2821
|
-
if (
|
|
2017
|
+
const bundleDir = await getAkmBundleDir(logClient)
|
|
2018
|
+
if (bundleDir) output.env.AKM_BUNDLE_DIR = bundleDir
|
|
2822
2019
|
} catch (error: unknown) {
|
|
2823
2020
|
await logHookFailure(logClient, "shell.env", error)
|
|
2824
2021
|
}
|
|
@@ -2850,8 +2047,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2850
2047
|
const directorySnapshot = directory
|
|
2851
2048
|
const agentSnapshot = input.agent
|
|
2852
2049
|
const previewText = text
|
|
2853
|
-
// Record the audit row immediately
|
|
2854
|
-
// see the decision; the `injectedRefs` field is populated lazily
|
|
2050
|
+
// Record the audit row immediately; the `injectedRefs` field is populated lazily
|
|
2855
2051
|
// when the background curate resolves.
|
|
2856
2052
|
sessionRecallAudit.set(sessionID, {
|
|
2857
2053
|
shouldRecall: true,
|
|
@@ -2864,7 +2060,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2864
2060
|
void (async () => {
|
|
2865
2061
|
try {
|
|
2866
2062
|
const curated = await runCurateForPrompt(logClient, decision.query, sessionID)
|
|
2867
|
-
|
|
2063
|
+
// Shared 0.9 concept-ID extractor. The inline regex this
|
|
2064
|
+
// replaced still matched the pre-0.9 `type:slug` ref form
|
|
2065
|
+
// (`skill:code-review`, plus a `wiki:` type that no longer
|
|
2066
|
+
// exists), so against real 0.9 curate output it matched nothing
|
|
2067
|
+
// and both the recall audit and the prompt_recall event
|
|
2068
|
+
// recorded an empty ref list on every turn.
|
|
2069
|
+
const refs = extractAkmRefsFromString(curated ?? "")
|
|
2868
2070
|
const prior = sessionRecallAudit.get(sessionID)
|
|
2869
2071
|
if (prior) {
|
|
2870
2072
|
sessionRecallAudit.set(sessionID, {
|
|
@@ -2951,7 +2153,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2951
2153
|
const recentRefs = (sessionBuffer.get(input.sessionID) ?? [])
|
|
2952
2154
|
.filter((entry) => entry.kind === "tool-ref" && !!entry.ref)
|
|
2953
2155
|
.map((entry) => entry.ref!)
|
|
2954
|
-
.filter((ref, index, refs) =>
|
|
2156
|
+
.filter((ref, index, refs) => !/^(?:.*\/\/)?(?:memories|env|secrets)\//.test(ref) && refs.indexOf(ref) === index)
|
|
2955
2157
|
.slice(-3)
|
|
2956
2158
|
const dedupe = new Set<string>()
|
|
2957
2159
|
for (const ref of recentRefs) {
|
|
@@ -2995,7 +2197,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
2995
2197
|
|
|
2996
2198
|
const allArgRefs = extractAkmRefsFromAllArgs(input.args as Record<string, unknown>)
|
|
2997
2199
|
const allOutputRefs = extractAkmRefsFromString(output.output)
|
|
2998
|
-
const
|
|
2200
|
+
const candidateRefs = [...new Set([...allArgRefs, ...allOutputRefs])]
|
|
2201
|
+
const parsedForRefs = isAkmTool ? parseToolOutput(output.output) : null
|
|
2202
|
+
const allRefs = isAkmTool && parsedForRefs
|
|
2203
|
+
? extractToolRefs(input.tool, input.args as Record<string, unknown>, parsedForRefs).refs
|
|
2204
|
+
: validateRefCandidates(candidateRefs, [await getAkmBundleDir(logClient) ?? ""])
|
|
2999
2205
|
|
|
3000
2206
|
if (allRefs.length > 0) {
|
|
3001
2207
|
writeStructuredEvent({
|
|
@@ -3065,25 +2271,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3065
2271
|
status: feedback ?? "unknown",
|
|
3066
2272
|
})
|
|
3067
2273
|
}
|
|
3068
|
-
if (feedback === "positive") {
|
|
3069
|
-
sessionSuccessfulAssetTouchCount.set(
|
|
3070
|
-
input.sessionID,
|
|
3071
|
-
(sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1,
|
|
3072
|
-
)
|
|
3073
|
-
const checkpointRef = maybeCheckpointSessionMemory(input.sessionID, {
|
|
3074
|
-
directory,
|
|
3075
|
-
agent: input.tool,
|
|
3076
|
-
})
|
|
3077
|
-
if (checkpointRef) {
|
|
3078
|
-
await writePluginLog(logClient, "info", "AKM checkpoint memory captured", {
|
|
3079
|
-
subsystem: "memory",
|
|
3080
|
-
actor: "system",
|
|
3081
|
-
sessionID: input.sessionID,
|
|
3082
|
-
reason: "checkpoint",
|
|
3083
|
-
ref: checkpointRef,
|
|
3084
|
-
})
|
|
3085
|
-
}
|
|
3086
|
-
}
|
|
3087
2274
|
}
|
|
3088
2275
|
|
|
3089
2276
|
if (
|
|
@@ -3101,10 +2288,10 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3101
2288
|
: `opencode auto: ${input.tool} failed`
|
|
3102
2289
|
for (const ref of feedbackRefs) {
|
|
3103
2290
|
// Skip refs that should never receive auto-feedback. Matches the
|
|
3104
|
-
// claude-side hook:
|
|
3105
|
-
//
|
|
2291
|
+
// claude-side hook: memories/env/secrets/lessons are excluded, including
|
|
2292
|
+
// bundle-qualified forms like `local//lessons/foo`. Lessons take
|
|
3106
2293
|
// feedback through the proposal queue, not via direct akm feedback.
|
|
3107
|
-
if (/^(?:.*\/\/)?(?:
|
|
2294
|
+
if (/^(?:.*\/\/)?(?:memories|env|secrets|lessons)\//.test(ref)) continue
|
|
3108
2295
|
const directInput = Object.values(input.args as Record<string, unknown>).some((value) => typeof value === "string" && value.includes(ref))
|
|
3109
2296
|
const signal = classifyFeedbackSignal({
|
|
3110
2297
|
ref,
|
|
@@ -3156,1094 +2343,137 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
|
|
|
3156
2343
|
}
|
|
3157
2344
|
},
|
|
3158
2345
|
tool: {
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
type
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
if (
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
}
|
|
3215
|
-
}
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
ref: tool.schema.string().describe("Asset ref to record feedback for."),
|
|
3238
|
-
sentiment: tool.schema.enum(["positive", "negative"]).describe("Whether the feedback is positive or negative."),
|
|
3239
|
-
note: tool.schema.string().optional().describe("Optional note to attach to the feedback."),
|
|
3240
|
-
},
|
|
3241
|
-
async execute({ ref, sentiment, note }, context) {
|
|
3242
|
-
const args = ["feedback", ref, sentiment === "positive" ? "--positive" : "--negative"]
|
|
3243
|
-
if (note) args.push("--note", note)
|
|
3244
|
-
args.push(...buildScopedArgs(context as unknown as Record<string, unknown>))
|
|
3245
|
-
const raw = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_feedback", sessionID: context.sessionID, directory: context.directory })
|
|
3246
|
-
const parsed = safeJsonParse<{ ok?: boolean; error?: string }>(raw)
|
|
3247
|
-
if (parsed?.ok === false) {
|
|
3248
|
-
const error = parsed.error ?? "Unknown akm feedback error"
|
|
3249
|
-
if (isNotIndexedFeedbackError(error)) {
|
|
3250
|
-
await writePluginLog(logClient, "warn", "AKM feedback skipped", {
|
|
3251
|
-
subsystem: "feedback",
|
|
3252
|
-
toolName: "akm_feedback",
|
|
3253
|
-
sessionID: context.sessionID,
|
|
3254
|
-
directory: context.directory,
|
|
3255
|
-
ref,
|
|
3256
|
-
sentiment,
|
|
3257
|
-
reason: "ref_not_indexed",
|
|
3258
|
-
error,
|
|
3259
|
-
})
|
|
3260
|
-
await emitWorkflowTelemetry(logClient, "warn", "akm.feedback.skipped", {
|
|
3261
|
-
sessionID: context.sessionID,
|
|
3262
|
-
toolName: "akm_feedback",
|
|
3263
|
-
assetRef: ref,
|
|
3264
|
-
outcome: "skipped",
|
|
3265
|
-
reason: "ref not indexed",
|
|
3266
|
-
directory: context.directory,
|
|
3267
|
-
})
|
|
3268
|
-
return JSON.stringify({ ok: true, skipped: true, reason: "ref_not_indexed", ref, sentiment })
|
|
3269
|
-
}
|
|
3270
|
-
return raw
|
|
3271
|
-
}
|
|
3272
|
-
await emitWorkflowTelemetry(logClient, "info", "akm.feedback.recorded", {
|
|
3273
|
-
sessionID: context.sessionID,
|
|
3274
|
-
toolName: "akm_feedback",
|
|
3275
|
-
assetRef: ref,
|
|
3276
|
-
outcome: "success",
|
|
3277
|
-
reason: sentiment,
|
|
3278
|
-
directory: context.directory,
|
|
3279
|
-
})
|
|
3280
|
-
return raw
|
|
3281
|
-
},
|
|
3282
|
-
}),
|
|
3283
|
-
akm_memory: tool({
|
|
3284
|
-
description: "Audit AKM memory behavior, inspect pending candidates, and promote or reject candidates. Mutating actions require confirm:true.",
|
|
3285
|
-
args: {
|
|
3286
|
-
action: tool.schema.enum(["audit", "candidates", "promote", "reject", "checkpoint", "sync"]).describe("Memory action."),
|
|
3287
|
-
candidate_id: tool.schema.string().optional().describe("Candidate id for promote/reject."),
|
|
3288
|
-
reason: tool.schema.string().optional().describe("Reason for rejection."),
|
|
3289
|
-
scope: tool.schema.enum(["last-prompt", "session", "refs", "safety"]).optional().describe("Audit scope."),
|
|
3290
|
-
status: tool.schema.enum(["pending", "promoted", "rejected"]).optional().describe("Candidate status filter."),
|
|
3291
|
-
confirm: tool.schema.boolean().optional().describe("Must be true for mutate actions."),
|
|
3292
|
-
},
|
|
3293
|
-
async execute(input, context) {
|
|
3294
|
-
const blocked = blockedToolResponse(input as Record<string, unknown>)
|
|
3295
|
-
if (blocked) return blocked
|
|
3296
|
-
if (input.action === "audit") {
|
|
3297
|
-
const events = readJsonl<AkmMemoryEvent>(OPENCODE_EVENT_LOG)
|
|
3298
|
-
const sessionEvents = context.sessionID ? events.filter((event) => event.sessionId === context.sessionID) : events
|
|
3299
|
-
const recall = sessionRecallAudit.get(context.sessionID)
|
|
3300
|
-
const recentWrites = sessionEvents.filter((event) => ["durable_memory_written", "pre_compact_checkpoint", "session_ended"].includes(event.event)).slice(-5)
|
|
3301
|
-
const recentSafetyBlocks = sessionEvents.filter((event) => event.event === "safety_blocked").slice(-5)
|
|
3302
|
-
const refs = [...new Set(sessionEvents.flatMap((event) => event.refs ?? []))]
|
|
3303
|
-
const audit = input.scope === "last-prompt"
|
|
3304
|
-
? { lastRecall: recall ?? null }
|
|
3305
|
-
: input.scope === "refs"
|
|
3306
|
-
? { refs }
|
|
3307
|
-
: input.scope === "safety"
|
|
3308
|
-
? { recentSafetyBlocks }
|
|
3309
|
-
: input.scope === "session"
|
|
3310
|
-
? { lastRecall: recall ?? null, recentWrites, recentSafetyBlocks, refs }
|
|
3311
|
-
: { lastRecall: recall ?? null, recentWrites, recentSafetyBlocks, refs }
|
|
3312
|
-
return JSON.stringify({
|
|
3313
|
-
ok: true,
|
|
3314
|
-
audit,
|
|
3315
|
-
})
|
|
3316
|
-
}
|
|
3317
|
-
if (input.action === "candidates") {
|
|
3318
|
-
const candidates = readCandidates(OPENCODE_CANDIDATE_LOG)
|
|
3319
|
-
.filter((candidate) => !input.status || candidate.status === input.status)
|
|
3320
|
-
.filter((candidate) => !context.sessionID || candidate.sessionId === context.sessionID)
|
|
3321
|
-
return JSON.stringify({ ok: true, candidates })
|
|
3322
|
-
}
|
|
3323
|
-
if (input.action === "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
|
-
})
|
|
3332
|
-
return JSON.stringify({ ok: true, ref })
|
|
3333
|
-
}
|
|
3334
|
-
if (input.action === "sync") {
|
|
3335
|
-
return JSON.stringify({ ok: true, synced: true, events: readJsonl<AkmMemoryEvent>(OPENCODE_EVENT_LOG).length, candidates: readCandidates(OPENCODE_CANDIDATE_LOG).length })
|
|
3336
|
-
}
|
|
3337
|
-
if (!input.candidate_id) return JSON.stringify({ ok: false, error: "'candidate_id' is required for promote/reject." })
|
|
3338
|
-
const candidates = readCandidates(OPENCODE_CANDIDATE_LOG)
|
|
3339
|
-
const candidate = candidates.find((entry) => entry.id === input.candidate_id)
|
|
3340
|
-
if (!candidate) return JSON.stringify({ ok: false, error: `Unknown candidate '${input.candidate_id}'.` })
|
|
3341
|
-
if (input.action === "reject") {
|
|
3342
|
-
const updated = updateCandidateStatus(OPENCODE_CANDIDATE_LOG, candidate.id, "rejected", input.reason)
|
|
3343
|
-
writeStructuredEvent({
|
|
3344
|
-
event: "candidate_rejected",
|
|
3345
|
-
sessionId: context.sessionID,
|
|
3346
|
-
scope: buildEventScope(context.sessionID, context.directory, context.agent),
|
|
3347
|
-
memory: { candidateId: candidate.id, reason: input.reason ?? null },
|
|
3348
|
-
outcome: { status: updated ? "ok" : "failed" },
|
|
3349
|
-
})
|
|
3350
|
-
return JSON.stringify({ ok: !!updated, candidate: updated ?? null })
|
|
3351
|
-
}
|
|
3352
|
-
if (input.action === "promote") {
|
|
3353
|
-
let rawResult: string | null = null
|
|
3354
|
-
if (candidate.recommendedAction === "remember") {
|
|
3355
|
-
rawResult = await runCli(client as unknown as LogCapableClient, ["remember", candidate.content, "--name", `candidate-${candidate.id}`, "--force", ...buildScopedArgs(context as unknown as Record<string, unknown>)], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
|
|
3356
|
-
} else if (candidate.recommendedAction === "feedback" && candidate.targetRef) {
|
|
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 })
|
|
3358
|
-
} else if (candidate.recommendedAction === "distill" && candidate.targetRef) {
|
|
3359
|
-
rawResult = await runCli(client as unknown as LogCapableClient, ["improve", candidate.targetRef], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
|
|
3360
|
-
} else if (candidate.recommendedAction === "propose") {
|
|
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 })
|
|
3362
|
-
} else {
|
|
3363
|
-
return JSON.stringify({ ok: false, error: "Candidate recommendedAction is 'ignore'; reject it instead." })
|
|
3364
|
-
}
|
|
3365
|
-
const parsedResult = rawResult ? parseMaybeJson(rawResult) : null
|
|
3366
|
-
const resultOk = typeof parsedResult === "object" && parsedResult !== null && "ok" in parsedResult
|
|
3367
|
-
? parsedResult.ok !== false
|
|
3368
|
-
: !!rawResult
|
|
3369
|
-
if (!resultOk) {
|
|
3370
|
-
writeStructuredEvent({
|
|
3371
|
-
event: "candidate_promoted",
|
|
3372
|
-
sessionId: context.sessionID,
|
|
3373
|
-
scope: buildEventScope(context.sessionID, context.directory, context.agent),
|
|
3374
|
-
memory: { candidateId: candidate.id, recommendedAction: candidate.recommendedAction },
|
|
3375
|
-
outcome: { status: "failed", warnings: ["candidate promotion command did not succeed"] },
|
|
3376
|
-
})
|
|
3377
|
-
return JSON.stringify({ ok: false, candidate, result: parsedResult ?? rawResult })
|
|
3378
|
-
}
|
|
3379
|
-
const updated = updateCandidateStatus(OPENCODE_CANDIDATE_LOG, candidate.id, "promoted")
|
|
3380
|
-
writeStructuredEvent({
|
|
3381
|
-
event: "candidate_promoted",
|
|
3382
|
-
sessionId: context.sessionID,
|
|
3383
|
-
scope: buildEventScope(context.sessionID, context.directory, context.agent),
|
|
3384
|
-
memory: { candidateId: candidate.id, recommendedAction: candidate.recommendedAction },
|
|
3385
|
-
outcome: { status: updated ? "ok" : "failed" },
|
|
3386
|
-
})
|
|
3387
|
-
return JSON.stringify({ ok: !!updated, candidate: updated ?? null, result: parsedResult ?? rawResult })
|
|
3388
|
-
}
|
|
3389
|
-
return JSON.stringify({ ok: false, error: `Unsupported action '${String(input.action)}'.` })
|
|
3390
|
-
},
|
|
3391
|
-
}),
|
|
3392
|
-
akm_curate: tool({
|
|
3393
|
-
description: "Curate stash assets for a task or topic. Returns the top matches as a ranked list so the agent can inspect and use them.",
|
|
3394
|
-
args: {
|
|
3395
|
-
query: tool.schema.string().describe("Task, topic, or natural-language description of what you want to do."),
|
|
3396
|
-
limit: tool.schema.number().optional().describe("Maximum number of curated matches to return. Defaults to 6."),
|
|
3397
|
-
detail: tool.schema.enum(["summary", "normal", "full"]).optional().describe("Detail level for each match. Defaults to 'summary'."),
|
|
3398
|
-
},
|
|
3399
|
-
async execute({ query, limit, detail }, context) {
|
|
3400
|
-
const args = ["curate", query, "--limit", String(limit ?? 6)]
|
|
3401
|
-
if (detail) args.push("--detail", detail)
|
|
3402
|
-
args.push(...buildScopedArgs(context as unknown as Record<string, unknown>))
|
|
3403
|
-
return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_curate", sessionID: context.sessionID, directory: context.directory })
|
|
3404
|
-
},
|
|
3405
|
-
}),
|
|
3406
|
-
akm_evolve: tool({
|
|
3407
|
-
description: "Dispatch the AKM curator agent to review recent session activity and propose stash improvements (promote hot assets, flag cold ones, draft missing coverage). Persists the report as a memory and seeds the curator-context cache so it survives compaction.",
|
|
3408
|
-
args: {
|
|
3409
|
-
focus: tool.schema.string().optional().describe("Optional focus area or theme to weight the review toward."),
|
|
3410
|
-
dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the curator with. Defaults to 'akm-curator', or falls back to 'general' when that agent is unavailable."),
|
|
3411
|
-
as_subtask: tool.schema.boolean().optional().describe("Run in a child session with parent context. Defaults to true."),
|
|
3412
|
-
},
|
|
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")
|
|
3419
|
-
const useSubtask = as_subtask ?? true
|
|
3420
|
-
const requestedAgent = dispatch_agent ?? "akm-curator"
|
|
3421
|
-
const targetAgent = await resolveDispatchAgent(sdkClient, requestedAgent, context.directory)
|
|
3422
|
-
const targetSession = await ensureTargetSessionID({
|
|
3423
|
-
useSubtask,
|
|
3424
|
-
context: { sessionID: context.sessionID, directory: context.directory },
|
|
3425
|
-
title: "akm:curator",
|
|
3426
|
-
client: sdkClient,
|
|
3427
|
-
logClient,
|
|
3428
|
-
toolName: "akm_evolve",
|
|
3429
|
-
})
|
|
3430
|
-
if (!targetSession.ok) return JSON.stringify(targetSession)
|
|
3431
|
-
|
|
3432
|
-
const task = focus && focus.trim()
|
|
3433
|
-
? `Review recent AKM activity with an emphasis on: ${focus.trim()}. Produce the prioritized action list described in the system prompt.`
|
|
3434
|
-
: "Review recent AKM activity and produce the prioritized action list described in the system prompt."
|
|
3435
|
-
|
|
3436
|
-
const promptResponse = await promptTargetSession({
|
|
3437
|
-
client: sdkClient,
|
|
3438
|
-
logClient,
|
|
3439
|
-
toolName: "akm_evolve",
|
|
3440
|
-
context: { sessionID: context.sessionID, directory: context.directory },
|
|
3441
|
-
targetSessionID: targetSession.sessionID,
|
|
3442
|
-
failureMessage: "Failed to dispatch curator",
|
|
3443
|
-
promptBody: {
|
|
3444
|
-
agent: targetAgent,
|
|
3445
|
-
system: targetAgent === "akm-curator" ? undefined : CURATOR_AGENT_PROMPT,
|
|
3446
|
-
parts: [{ type: "text", text: task }],
|
|
3447
|
-
},
|
|
3448
|
-
})
|
|
3449
|
-
if (!promptResponse.ok) return JSON.stringify(promptResponse)
|
|
3450
|
-
|
|
3451
|
-
const fullText = extractText(promptResponse.data.parts)
|
|
3452
|
-
sessionCuratorReport.set(context.sessionID, summarizeCuratorReportForContext(fullText))
|
|
3453
|
-
markContextEpochDirty(context.sessionID)
|
|
3454
|
-
const dateTag = buildDateTag()
|
|
3455
|
-
const shortSid = context.sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
|
|
3456
|
-
const curatorMemoryRef = fullText
|
|
3457
|
-
? rememberTextAsMemory(`akm-curator-${dateTag}-${shortSid}`, fullText, context as unknown as Record<string, unknown>)
|
|
3458
|
-
: null
|
|
3459
|
-
|
|
3460
|
-
return JSON.stringify({
|
|
3461
|
-
ok: true,
|
|
3462
|
-
dispatchAgent: targetAgent,
|
|
3463
|
-
usedSubtask: useSubtask,
|
|
3464
|
-
sessionID: targetSession.sessionID,
|
|
3465
|
-
focus: focus ?? null,
|
|
3466
|
-
curatorMemoryRef,
|
|
3467
|
-
text: fullText,
|
|
3468
|
-
})
|
|
3469
|
-
},
|
|
3470
|
-
}),
|
|
3471
|
-
akm_parent_messages: tool({
|
|
3472
|
-
description: "Read compact text summaries of the parent session's messages so a dispatched AKM subagent can inherit upstream context.",
|
|
3473
|
-
args: {},
|
|
3474
|
-
async execute(_input, context) {
|
|
3475
|
-
try {
|
|
3476
|
-
const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
|
|
3477
|
-
if (!parent.ok) return JSON.stringify(parent)
|
|
3478
|
-
const messages = await sdkClient.session.messages({
|
|
3479
|
-
path: { id: parent.parentID },
|
|
3480
|
-
query: { directory: context.directory },
|
|
3481
|
-
})
|
|
3482
|
-
if (messages.error || !messages.data) {
|
|
3483
|
-
return JSON.stringify({ ok: false, error: "Failed to read parent session messages." })
|
|
3484
|
-
}
|
|
3485
|
-
return JSON.stringify(summarizeSessionMessages(parent.parentID, messages.data))
|
|
3486
|
-
} catch (error: unknown) {
|
|
3487
|
-
await writePluginLog(logClient, "error", "AKM parent session read failed", {
|
|
3488
|
-
subsystem: "session",
|
|
3489
|
-
toolName: "akm_parent_messages",
|
|
3490
|
-
sessionID: context.sessionID,
|
|
3491
|
-
directory: context.directory,
|
|
3492
|
-
error: formatCliError(error),
|
|
3493
|
-
})
|
|
3494
|
-
return JSON.stringify({ ok: false, error: "Failed to read parent session messages." })
|
|
3495
|
-
}
|
|
3496
|
-
},
|
|
3497
|
-
}),
|
|
3498
|
-
akm_session_messages: tool({
|
|
3499
|
-
description: "Read compact text summaries for a specific OpenCode session. Arbitrary session IDs are restricted to the akm-curator agent; other agents may read only their current or parent session.",
|
|
3500
|
-
args: {
|
|
3501
|
-
session_id: tool.schema.string().describe("OpenCode session ID to inspect."),
|
|
3502
|
-
},
|
|
3503
|
-
async execute({ session_id }, context) {
|
|
3504
|
-
try {
|
|
3505
|
-
const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
|
|
3506
|
-
const allowedSessionIDs = new Set<string>([context.sessionID])
|
|
3507
|
-
if (parent.ok) allowedSessionIDs.add(parent.parentID)
|
|
3508
|
-
if (context.agent !== "akm-curator" && !allowedSessionIDs.has(session_id)) {
|
|
3509
|
-
return JSON.stringify({
|
|
3510
|
-
ok: false,
|
|
3511
|
-
error: "akm_session_messages only allows arbitrary session IDs for the akm-curator agent. Use akm_parent_messages for parent context.",
|
|
3512
|
-
})
|
|
3513
|
-
}
|
|
3514
|
-
const messages = await sdkClient.session.messages({
|
|
3515
|
-
path: { id: session_id },
|
|
3516
|
-
query: { directory: context.directory },
|
|
3517
|
-
})
|
|
3518
|
-
if (messages.error || !messages.data) {
|
|
3519
|
-
return JSON.stringify({ ok: false, error: `Failed to read messages for session '${session_id}'.` })
|
|
3520
|
-
}
|
|
3521
|
-
return JSON.stringify(summarizeSessionMessages(session_id, messages.data))
|
|
3522
|
-
} catch (error: unknown) {
|
|
3523
|
-
await writePluginLog(logClient, "error", "AKM session messages read failed", {
|
|
3524
|
-
subsystem: "session",
|
|
3525
|
-
toolName: "akm_session_messages",
|
|
3526
|
-
sessionID: context.sessionID,
|
|
3527
|
-
directory: context.directory,
|
|
3528
|
-
targetSessionID: session_id,
|
|
3529
|
-
error: formatCliError(error),
|
|
3530
|
-
})
|
|
3531
|
-
return JSON.stringify({ ok: false, error: `Failed to read messages for session '${session_id}'.` })
|
|
3532
|
-
}
|
|
3533
|
-
},
|
|
3534
|
-
}),
|
|
3535
|
-
akm_agent: tool({
|
|
3536
|
-
description: "Dispatch a stash agent by ref into a child OpenCode session, applying the agent prompt and metadata from akm_show.",
|
|
3537
|
-
args: {
|
|
3538
|
-
ref: tool.schema.string().optional().describe("Agent ref from akm_search (e.g. agent:my-agent.md)."),
|
|
3539
|
-
query: tool.schema.string().optional().describe("If ref is omitted, resolve best matching stash agent for this query."),
|
|
3540
|
-
task_prompt: tool.schema.string().describe("Task prompt sent to the dispatched OpenCode agent."),
|
|
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'."),
|
|
3542
|
-
as_subtask: tool.schema.boolean().optional().describe("Run in child session with parent context. Defaults to true."),
|
|
3543
|
-
},
|
|
3544
|
-
async execute({ ref, query, task_prompt, dispatch_agent, as_subtask }, context) {
|
|
3545
|
-
const logMeta = {
|
|
3546
|
-
toolName: "akm_agent",
|
|
3547
|
-
directory: context.directory,
|
|
3548
|
-
sessionID: context.sessionID,
|
|
3549
|
-
}
|
|
3550
|
-
const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "agent", logMeta)
|
|
3551
|
-
if (!resolved.ok) return JSON.stringify(resolved)
|
|
3552
|
-
|
|
3553
|
-
const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
|
|
3554
|
-
const shown = parseCliJson<ShowAgentResponse | { type: string }>(shownRaw)
|
|
3555
|
-
if (isCliError(shown)) {
|
|
3556
|
-
return JSON.stringify(shown)
|
|
3557
|
-
}
|
|
3558
|
-
|
|
3559
|
-
if (!isShowAgentResponse(shown)) {
|
|
3560
|
-
return JSON.stringify({
|
|
3561
|
-
ok: false,
|
|
3562
|
-
error: `Ref ${ref} is not an agent payload from akm_show.`,
|
|
3563
|
-
})
|
|
3564
|
-
}
|
|
3565
|
-
|
|
3566
|
-
if (!shown.prompt || !shown.prompt.trim()) {
|
|
3567
|
-
return JSON.stringify({
|
|
3568
|
-
ok: false,
|
|
3569
|
-
error: `Agent ${shown.name} is missing prompt content.`,
|
|
3570
|
-
})
|
|
3571
|
-
}
|
|
3572
|
-
|
|
3573
|
-
const useSubtask = as_subtask ?? true
|
|
3574
|
-
const dispatchTarget = resolveDispatchTarget(dispatch_agent, "general")
|
|
3575
|
-
const hintedModel = parseModelHint(shown.modelHint)
|
|
3576
|
-
const model = dispatchTarget.model ?? hintedModel
|
|
3577
|
-
const tools = parseToolPolicy(shown.toolPolicy)
|
|
3578
|
-
writeStructuredEvent({
|
|
3579
|
-
event: "subagent_started",
|
|
3580
|
-
sessionId: context.sessionID,
|
|
3581
|
-
scope: buildEventScope(context.sessionID, context.directory, context.agent),
|
|
3582
|
-
input: {
|
|
3583
|
-
ref: resolved.ref,
|
|
3584
|
-
stashAgent: shown.name,
|
|
3585
|
-
dispatchAgent: dispatchTarget.agent,
|
|
3586
|
-
requestedDispatchTarget: dispatchTarget.requested,
|
|
3587
|
-
taskPrompt: task_prompt,
|
|
3588
|
-
advisoryToolPolicy: shown.toolPolicy ?? null,
|
|
3589
|
-
appliedToolPolicy: tools ?? null,
|
|
3590
|
-
modelHint: shown.modelHint ?? null,
|
|
3591
|
-
appliedModel: model ?? null,
|
|
3592
|
-
},
|
|
3593
|
-
refs: [resolved.ref],
|
|
3594
|
-
outcome: { status: "ok" },
|
|
3595
|
-
})
|
|
3596
|
-
|
|
3597
|
-
const targetSession = await ensureTargetSessionID({
|
|
3598
|
-
useSubtask,
|
|
3599
|
-
context: { sessionID: context.sessionID, directory: context.directory },
|
|
3600
|
-
title: `akm:${shown.name}`,
|
|
3601
|
-
client: client as unknown as PluginClient,
|
|
3602
|
-
logClient,
|
|
3603
|
-
toolName: "akm_agent",
|
|
3604
|
-
})
|
|
3605
|
-
if (!targetSession.ok) return JSON.stringify(targetSession)
|
|
3606
|
-
|
|
3607
|
-
const promptBody: SessionPromptBody = {
|
|
3608
|
-
agent: dispatchTarget.agent,
|
|
3609
|
-
system: shown.prompt,
|
|
3610
|
-
parts: [{ type: "text", text: task_prompt }],
|
|
3611
|
-
}
|
|
3612
|
-
if (model) promptBody.model = model
|
|
3613
|
-
if (tools) promptBody.tools = tools
|
|
3614
|
-
|
|
3615
|
-
const promptResponse = await promptTargetSession({
|
|
3616
|
-
client: client as unknown as PluginClient,
|
|
3617
|
-
logClient,
|
|
3618
|
-
toolName: "akm_agent",
|
|
3619
|
-
context: { sessionID: context.sessionID, directory: context.directory },
|
|
3620
|
-
targetSessionID: targetSession.sessionID,
|
|
3621
|
-
promptBody,
|
|
3622
|
-
failureMessage: `Failed to dispatch prompt for ${resolved.ref}`,
|
|
3623
|
-
ref: resolved.ref,
|
|
3624
|
-
})
|
|
3625
|
-
if (!promptResponse.ok) return JSON.stringify(promptResponse)
|
|
3626
|
-
|
|
3627
|
-
const childText = extractText(promptResponse.data.parts)
|
|
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
|
-
})
|
|
3636
|
-
if (candidates.length > 0) appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
|
|
3637
|
-
writeStructuredEvent({
|
|
3638
|
-
event: "subagent_completed",
|
|
3639
|
-
sessionId: context.sessionID,
|
|
3640
|
-
scope: buildEventScope(context.sessionID, context.directory, context.agent),
|
|
3641
|
-
input: {
|
|
3642
|
-
childSessionID: targetSession.sessionID,
|
|
3643
|
-
stashAgent: shown.name,
|
|
3644
|
-
dispatchAgent: dispatchTarget.agent,
|
|
3645
|
-
requestedDispatchTarget: dispatchTarget.requested,
|
|
3646
|
-
dispatchModel: model ?? null,
|
|
3647
|
-
},
|
|
3648
|
-
memory: { parentSessionID: context.sessionID, childSessionID: targetSession.sessionID, candidatesCreated: candidates.length },
|
|
3649
|
-
refs: [resolved.ref],
|
|
3650
|
-
outcome: { status: "ok" },
|
|
3651
|
-
})
|
|
3652
|
-
|
|
3653
|
-
return JSON.stringify({
|
|
3654
|
-
ok: true,
|
|
3655
|
-
ref: resolved.ref,
|
|
3656
|
-
stashAgent: shown.name,
|
|
3657
|
-
dispatchAgent: dispatchTarget.agent,
|
|
3658
|
-
requestedDispatchTarget: dispatchTarget.requested,
|
|
3659
|
-
usedSubtask: useSubtask,
|
|
3660
|
-
sessionID: targetSession.sessionID,
|
|
3661
|
-
model,
|
|
3662
|
-
tools,
|
|
3663
|
-
text: childText,
|
|
3664
|
-
})
|
|
3665
|
-
},
|
|
3666
|
-
}),
|
|
3667
|
-
akm_cmd: tool({
|
|
3668
|
-
description: "Execute a stash command template through the OpenCode SDK in the current or child session.",
|
|
3669
|
-
args: {
|
|
3670
|
-
ref: tool.schema.string().optional().describe("Command ref from akm_search (e.g. command:review.md)."),
|
|
3671
|
-
query: tool.schema.string().optional().describe("If ref is omitted, resolve best matching stash command for this query."),
|
|
3672
|
-
arguments: tool.schema.string().optional().describe("Command arguments used for $ARGUMENTS and positional placeholders ($1, $2, ...)."),
|
|
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."),
|
|
3674
|
-
as_subtask: tool.schema.boolean().optional().describe("Run in child session with parent context. Defaults to false."),
|
|
3675
|
-
},
|
|
3676
|
-
async execute({ ref, query, arguments: commandArguments, dispatch_agent, as_subtask }, context) {
|
|
3677
|
-
const logMeta = {
|
|
3678
|
-
toolName: "akm_cmd",
|
|
3679
|
-
directory: context.directory,
|
|
3680
|
-
sessionID: context.sessionID,
|
|
3681
|
-
}
|
|
3682
|
-
const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "command", logMeta)
|
|
3683
|
-
if (!resolved.ok) return JSON.stringify(resolved)
|
|
3684
|
-
|
|
3685
|
-
const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
|
|
3686
|
-
const shown = parseCliJson<ShowCommandResponse | { type: string }>(shownRaw)
|
|
3687
|
-
if (isCliError(shown)) return JSON.stringify(shown)
|
|
3688
|
-
if (!isShowCommandResponse(shown)) {
|
|
3689
|
-
return JSON.stringify({ ok: false, error: `Ref ${resolved.ref} is not a command payload from akm_show.` })
|
|
3690
|
-
}
|
|
3691
|
-
|
|
3692
|
-
const template = shown.template?.trim()
|
|
3693
|
-
if (!template) {
|
|
3694
|
-
return JSON.stringify({ ok: false, error: `Command ${shown.name} is missing template content.` })
|
|
3695
|
-
}
|
|
3696
|
-
|
|
3697
|
-
const argsText = commandArguments ?? ""
|
|
3698
|
-
const rendered = renderCommandTemplate(template, argsText)
|
|
3699
|
-
const useSubtask = as_subtask ?? false
|
|
3700
|
-
const fallbackAgent = typeof context.agent === "string" && context.agent.trim() ? context.agent : "general"
|
|
3701
|
-
const dispatchTarget = resolveDispatchTarget(dispatch_agent, fallbackAgent)
|
|
3702
|
-
|
|
3703
|
-
const targetSession = await ensureTargetSessionID({
|
|
3704
|
-
useSubtask,
|
|
3705
|
-
context: { sessionID: context.sessionID, directory: context.directory },
|
|
3706
|
-
title: `akm:cmd:${shown.name}`,
|
|
3707
|
-
client: client as unknown as PluginClient,
|
|
3708
|
-
logClient,
|
|
3709
|
-
toolName: "akm_cmd",
|
|
3710
|
-
})
|
|
3711
|
-
if (!targetSession.ok) return JSON.stringify(targetSession)
|
|
3712
|
-
|
|
3713
|
-
const promptResponse = await promptTargetSession({
|
|
3714
|
-
client: client as unknown as PluginClient,
|
|
3715
|
-
logClient,
|
|
3716
|
-
toolName: "akm_cmd",
|
|
3717
|
-
context: { sessionID: context.sessionID, directory: context.directory },
|
|
3718
|
-
targetSessionID: targetSession.sessionID,
|
|
3719
|
-
failureMessage: `Failed to execute command ${resolved.ref}`,
|
|
3720
|
-
ref: resolved.ref,
|
|
3721
|
-
promptBody: {
|
|
3722
|
-
agent: dispatchTarget.agent,
|
|
3723
|
-
model: dispatchTarget.model,
|
|
3724
|
-
parts: [{ type: "text", text: rendered }],
|
|
3725
|
-
},
|
|
3726
|
-
})
|
|
3727
|
-
if (!promptResponse.ok) return JSON.stringify(promptResponse)
|
|
3728
|
-
|
|
3729
|
-
return JSON.stringify({
|
|
3730
|
-
ok: true,
|
|
3731
|
-
ref: resolved.ref,
|
|
3732
|
-
stashCommand: shown.name,
|
|
3733
|
-
dispatchAgent: dispatchTarget.agent,
|
|
3734
|
-
requestedDispatchTarget: dispatchTarget.requested,
|
|
3735
|
-
usedSubtask: useSubtask,
|
|
3736
|
-
sessionID: targetSession.sessionID,
|
|
3737
|
-
model: dispatchTarget.model,
|
|
3738
|
-
arguments: argsText,
|
|
3739
|
-
renderedTemplate: rendered,
|
|
3740
|
-
text: extractText(promptResponse.data.parts),
|
|
3741
|
-
})
|
|
3742
|
-
},
|
|
3743
|
-
}),
|
|
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.",
|
|
3746
|
-
args: {
|
|
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."),
|
|
3751
|
-
},
|
|
3752
|
-
async execute(input) {
|
|
3753
|
-
const blocked = blockedToolResponse(input as Record<string, unknown>)
|
|
3754
|
-
if (blocked) return blocked
|
|
3755
|
-
const { action, ref, cmd, confirm } = input
|
|
3756
|
-
const logMeta = { toolName: "akm_env" }
|
|
3757
|
-
switch (action) {
|
|
3758
|
-
case "list": {
|
|
3759
|
-
return runCli(client as unknown as LogCapableClient, ["env", "list"], logMeta)
|
|
3760
|
-
}
|
|
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)
|
|
3765
|
-
}
|
|
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)
|
|
3771
|
-
}
|
|
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'." })
|
|
3793
|
-
const command = resolveAkmCommand()
|
|
3794
|
-
if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
|
|
3795
|
-
try {
|
|
3796
|
-
const filePath = execResolvedAkm(command, ["secret", "path", ref], {
|
|
3797
|
-
encoding: "utf8",
|
|
3798
|
-
timeout: 30_000,
|
|
3799
|
-
}).toString().trim()
|
|
3800
|
-
return JSON.stringify({
|
|
3801
|
-
ok: true,
|
|
2346
|
+
akm_search: tool({
|
|
2347
|
+
description: "Search configured AKM bundles or registries in process. Use source='registry' for installable community assets.",
|
|
2348
|
+
args: {
|
|
2349
|
+
query: tool.schema.string().optional().describe("Search query. Omit to browse all assets."),
|
|
2350
|
+
type: tool.schema
|
|
2351
|
+
.enum(ASSET_TYPES as unknown as [string, ...string[]])
|
|
2352
|
+
.optional()
|
|
2353
|
+
.describe("Optional type filter. Defaults to 'any'."),
|
|
2354
|
+
limit: tool.schema.number().optional().describe("Maximum number of hits to return. Defaults to 20."),
|
|
2355
|
+
source: tool.schema.string().optional().describe("Search source: 'local', 'registry', 'all', or a configured bundle name."),
|
|
2356
|
+
include_proposed: tool.schema.boolean().optional().describe("Include proposed-quality results. Proposed assets are not curated until accepted."),
|
|
2357
|
+
},
|
|
2358
|
+
async execute({ query, type, limit, source, include_proposed }, context) {
|
|
2359
|
+
const raw = await runInProcess(
|
|
2360
|
+
client as unknown as LogCapableClient,
|
|
2361
|
+
"search",
|
|
2362
|
+
{
|
|
2363
|
+
query: query ?? "",
|
|
2364
|
+
type: type === "any" ? undefined : type,
|
|
2365
|
+
limit,
|
|
2366
|
+
source,
|
|
2367
|
+
includeProposed: include_proposed,
|
|
2368
|
+
},
|
|
2369
|
+
{ toolName: "akm_search", sessionID: context.sessionID, directory: context.directory },
|
|
2370
|
+
)
|
|
2371
|
+
return withProposedWarnings(raw)
|
|
2372
|
+
},
|
|
2373
|
+
}),
|
|
2374
|
+
akm_show: tool({
|
|
2375
|
+
description: "Show an AKM asset by [bundle//]conceptId[#fragment]. Markdown heading fragments select a section.",
|
|
2376
|
+
args: {
|
|
2377
|
+
ref: tool.schema.string().describe("Asset reference returned by akm_search, optionally with a #fragment."),
|
|
2378
|
+
detail: tool.schema.enum(["brief", "summary", "normal", "full"]).optional().describe("Response detail level. Defaults to 'normal'."),
|
|
2379
|
+
},
|
|
2380
|
+
async execute({ ref, detail }, context) {
|
|
2381
|
+
return runInProcess(
|
|
2382
|
+
client as unknown as LogCapableClient,
|
|
2383
|
+
"show",
|
|
2384
|
+
{ ref, detail },
|
|
2385
|
+
{ toolName: "akm_show", sessionID: context.sessionID, directory: context.directory },
|
|
2386
|
+
)
|
|
2387
|
+
},
|
|
2388
|
+
}),
|
|
2389
|
+
akm_remember: tool({
|
|
2390
|
+
description: "Record a memory in the default AKM stash so it can be searched and shown later.",
|
|
2391
|
+
args: {
|
|
2392
|
+
content: tool.schema.string().describe("Memory content to store."),
|
|
2393
|
+
name: tool.schema.string().optional().describe("Optional memory name."),
|
|
2394
|
+
force: tool.schema.boolean().optional().describe("Overwrite an existing memory with the same name."),
|
|
2395
|
+
},
|
|
2396
|
+
async execute({ content, name, force }, context) {
|
|
2397
|
+
const args = ["remember", content]
|
|
2398
|
+
if (name) args.push("--name", name)
|
|
2399
|
+
if (force) args.push("--force")
|
|
2400
|
+
args.push(...buildScopedArgs(context as unknown as Record<string, unknown>))
|
|
2401
|
+
return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_remember", sessionID: context.sessionID, directory: context.directory })
|
|
2402
|
+
},
|
|
2403
|
+
}),
|
|
2404
|
+
akm_feedback: tool({
|
|
2405
|
+
description: "Record positive or negative feedback for a stash asset so AKM can improve future ranking.",
|
|
2406
|
+
args: {
|
|
2407
|
+
ref: tool.schema.string().describe("Asset ref to record feedback for."),
|
|
2408
|
+
sentiment: tool.schema.enum(["positive", "negative"]).describe("Whether the feedback is positive or negative."),
|
|
2409
|
+
note: tool.schema.string().optional().describe("Optional note to attach to the feedback."),
|
|
2410
|
+
},
|
|
2411
|
+
async execute({ ref, sentiment, note }, context) {
|
|
2412
|
+
const args = ["feedback", ref, sentiment === "positive" ? "--positive" : "--negative"]
|
|
2413
|
+
if (note) args.push("--reason", note)
|
|
2414
|
+
const raw = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_feedback", sessionID: context.sessionID, directory: context.directory })
|
|
2415
|
+
const parsed = safeJsonParse<{ ok?: boolean; error?: string }>(raw)
|
|
2416
|
+
if (parsed?.ok === false) {
|
|
2417
|
+
const error = parsed.error ?? "Unknown akm feedback error"
|
|
2418
|
+
if (isNotIndexedFeedbackError(error)) {
|
|
2419
|
+
await writePluginLog(logClient, "warn", "AKM feedback skipped", {
|
|
2420
|
+
subsystem: "feedback",
|
|
2421
|
+
toolName: "akm_feedback",
|
|
2422
|
+
sessionID: context.sessionID,
|
|
2423
|
+
directory: context.directory,
|
|
3802
2424
|
ref,
|
|
3803
|
-
|
|
3804
|
-
|
|
2425
|
+
sentiment,
|
|
2426
|
+
reason: "ref_not_indexed",
|
|
2427
|
+
error,
|
|
3805
2428
|
})
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
ref,
|
|
3812
|
-
|
|
2429
|
+
await emitWorkflowTelemetry(logClient, "warn", "akm.feedback.skipped", {
|
|
2430
|
+
sessionID: context.sessionID,
|
|
2431
|
+
toolName: "akm_feedback",
|
|
2432
|
+
assetRef: ref,
|
|
2433
|
+
outcome: "skipped",
|
|
2434
|
+
reason: "ref not indexed",
|
|
2435
|
+
directory: context.directory,
|
|
3813
2436
|
})
|
|
3814
|
-
return JSON.stringify({ ok:
|
|
2437
|
+
return JSON.stringify({ ok: true, skipped: true, reason: "ref_not_indexed", ref, sentiment })
|
|
3815
2438
|
}
|
|
3816
|
-
}
|
|
3817
|
-
}
|
|
3818
|
-
},
|
|
3819
|
-
}),
|
|
3820
|
-
akm_wiki: tool({
|
|
3821
|
-
description: "Manage AKM wikis — multi-wiki knowledge bases under <stashDir>/wikis/<name>/. Supports scaffolding, registering external sources, listing pages, scoped search, stashing raw sources, lint, and ingest workflow.",
|
|
3822
|
-
args: {
|
|
3823
|
-
action: tool.schema.enum([
|
|
3824
|
-
"create",
|
|
3825
|
-
"register",
|
|
3826
|
-
"list",
|
|
3827
|
-
"show",
|
|
3828
|
-
"remove",
|
|
3829
|
-
"pages",
|
|
3830
|
-
"search",
|
|
3831
|
-
"stash",
|
|
3832
|
-
"lint",
|
|
3833
|
-
"ingest",
|
|
3834
|
-
]).describe("Wiki subcommand."),
|
|
3835
|
-
name: tool.schema.string().optional().describe("Wiki name (required for every action except 'list')."),
|
|
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."),
|
|
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')."),
|
|
3838
|
-
max_pages: tool.schema.number().optional().describe("Crawler page cap when registering a website (default 50)."),
|
|
3839
|
-
max_depth: tool.schema.number().optional().describe("Crawler depth cap when registering a website (default 3)."),
|
|
3840
|
-
query: tool.schema.string().optional().describe("Query string for action='search'."),
|
|
3841
|
-
limit: tool.schema.number().optional().describe("Result cap for action='search'."),
|
|
3842
|
-
source: tool.schema.string().optional().describe("Source path (or '-' for stdin) for action='stash'."),
|
|
3843
|
-
as_slug: tool.schema.string().optional().describe("Explicit slug for action='stash' (defaults to derived from source)."),
|
|
3844
|
-
content: tool.schema.string().optional().describe("Raw content to feed stdin when stashing with source='-'."),
|
|
3845
|
-
force: tool.schema.boolean().optional().describe("Required for action='remove'."),
|
|
3846
|
-
with_sources: tool.schema.boolean().optional().describe("When removing, also delete the raw/ sources (default false)."),
|
|
3847
|
-
},
|
|
3848
|
-
async execute({ action, name, source_ref, writable, max_pages, max_depth, query, limit, source, as_slug, content, force, with_sources }) {
|
|
3849
|
-
const logMeta = { toolName: "akm_wiki" }
|
|
3850
|
-
const requireName = () => {
|
|
3851
|
-
if (!name) return JSON.stringify({ ok: false, error: `'name' is required for action='${action}'.` })
|
|
3852
|
-
return null
|
|
3853
|
-
}
|
|
3854
|
-
switch (action) {
|
|
3855
|
-
case "list":
|
|
3856
|
-
return runCli(client as unknown as LogCapableClient, ["wiki", "list"], logMeta)
|
|
3857
|
-
case "create": {
|
|
3858
|
-
const err = requireName(); if (err) return err
|
|
3859
|
-
return runCli(client as unknown as LogCapableClient, ["wiki", "create", name!], logMeta)
|
|
3860
|
-
}
|
|
3861
|
-
case "show": {
|
|
3862
|
-
const err = requireName(); if (err) return err
|
|
3863
|
-
return runCli(client as unknown as LogCapableClient, ["wiki", "show", name!], logMeta)
|
|
3864
|
-
}
|
|
3865
|
-
case "pages": {
|
|
3866
|
-
const err = requireName(); if (err) return err
|
|
3867
|
-
return runCli(client as unknown as LogCapableClient, ["wiki", "pages", name!], logMeta)
|
|
3868
|
-
}
|
|
3869
|
-
case "ingest": {
|
|
3870
|
-
const err = requireName(); if (err) return err
|
|
3871
|
-
return runCli(client as unknown as LogCapableClient, ["wiki", "ingest", name!], logMeta)
|
|
3872
|
-
}
|
|
3873
|
-
case "lint": {
|
|
3874
|
-
const err = requireName(); if (err) return err
|
|
3875
|
-
// `wiki lint` exits 1 when findings exist, which runCli surfaces as
|
|
3876
|
-
// an error envelope. That is still useful output — the JSON body is
|
|
3877
|
-
// the lint report. Pass through either way.
|
|
3878
|
-
return runCli(client as unknown as LogCapableClient, ["wiki", "lint", name!], logMeta)
|
|
3879
|
-
}
|
|
3880
|
-
case "register": {
|
|
3881
|
-
const err = requireName(); if (err) return err
|
|
3882
|
-
if (!source_ref) return JSON.stringify({ ok: false, error: "'source_ref' is required for action='register'." })
|
|
3883
|
-
const args = ["wiki", "register", name!, source_ref]
|
|
3884
|
-
if (writable) args.push("--writable")
|
|
3885
|
-
if (max_pages != null) args.push("--max-pages", String(max_pages))
|
|
3886
|
-
if (max_depth != null) args.push("--max-depth", String(max_depth))
|
|
3887
|
-
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
3888
|
-
}
|
|
3889
|
-
case "remove": {
|
|
3890
|
-
const err = requireName(); if (err) return err
|
|
3891
|
-
if (!force) return JSON.stringify({ ok: false, error: "'force' must be true to remove a wiki." })
|
|
3892
|
-
const args = ["wiki", "remove", name!, "--force"]
|
|
3893
|
-
if (with_sources) args.push("--with-sources")
|
|
3894
|
-
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
3895
|
-
}
|
|
3896
|
-
case "search": {
|
|
3897
|
-
const err = requireName(); if (err) return err
|
|
3898
|
-
if (!query) return JSON.stringify({ ok: false, error: "'query' is required for action='search'." })
|
|
3899
|
-
const args = ["wiki", "search", name!, query]
|
|
3900
|
-
if (limit != null) args.push("--limit", String(limit))
|
|
3901
|
-
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
3902
|
-
}
|
|
3903
|
-
case "stash": {
|
|
3904
|
-
const err = requireName(); if (err) return err
|
|
3905
|
-
if (!source) return JSON.stringify({ ok: false, error: "'source' is required for action='stash'." })
|
|
3906
|
-
const args = ["wiki", "stash", name!, source]
|
|
3907
|
-
if (as_slug) args.push("--as", as_slug)
|
|
3908
|
-
if (source === "-" && content) {
|
|
3909
|
-
const command = resolveAkmCommand()
|
|
3910
|
-
if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
|
|
3911
|
-
try {
|
|
3912
|
-
const stdout = execResolvedAkm(command, [...args, "--format", "json"], {
|
|
3913
|
-
encoding: "utf8",
|
|
3914
|
-
timeout: 60_000,
|
|
3915
|
-
input: content,
|
|
3916
|
-
})
|
|
3917
|
-
return stdout
|
|
3918
|
-
} catch (error: unknown) {
|
|
3919
|
-
await writePluginLog(logClient, "error", "AKM wiki stash failed", {
|
|
3920
|
-
subsystem: "wiki",
|
|
3921
|
-
toolName: "akm_wiki",
|
|
3922
|
-
action: "stash",
|
|
3923
|
-
name,
|
|
3924
|
-
source,
|
|
3925
|
-
as_slug,
|
|
3926
|
-
error: formatCliError(error),
|
|
3927
|
-
})
|
|
3928
|
-
return JSON.stringify({ ok: false, error: formatCliError(error) })
|
|
3929
|
-
}
|
|
3930
|
-
}
|
|
3931
|
-
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
3932
|
-
}
|
|
3933
|
-
}
|
|
3934
|
-
},
|
|
3935
|
-
}),
|
|
3936
|
-
akm_workflow: tool({
|
|
3937
|
-
description: "Manage AKM workflow runs — stateful multi-step procedures defined as workflow:<name> assets. Use start/next/complete/resume to drive a run, status/list to inspect, create/template to author.",
|
|
3938
|
-
args: {
|
|
3939
|
-
action: tool.schema.enum([
|
|
3940
|
-
"start",
|
|
3941
|
-
"next",
|
|
3942
|
-
"complete",
|
|
3943
|
-
"status",
|
|
3944
|
-
"list",
|
|
3945
|
-
"create",
|
|
3946
|
-
"template",
|
|
3947
|
-
"resume",
|
|
3948
|
-
]).describe("Workflow subcommand."),
|
|
3949
|
-
ref: tool.schema.string().optional().describe("Workflow ref (e.g. workflow:release). Required for start; accepted by next/status as a target."),
|
|
3950
|
-
target: tool.schema.string().optional().describe("Run id or workflow ref for next/status. When a workflow ref is passed to 'next', a new run is auto-started."),
|
|
3951
|
-
run_id: tool.schema.string().optional().describe("Workflow run id. Required for complete and resume."),
|
|
3952
|
-
params: tool.schema.string().optional().describe("JSON object string of parameters for start/next."),
|
|
3953
|
-
step: tool.schema.string().optional().describe("Step id to transition (required for action='complete')."),
|
|
3954
|
-
state: tool.schema.enum(["completed", "blocked", "failed", "skipped"]).optional().describe("Step state for 'complete'. Defaults to 'completed'."),
|
|
3955
|
-
notes: tool.schema.string().optional().describe("Freeform notes attached to the step transition."),
|
|
3956
|
-
evidence: tool.schema.string().optional().describe("JSON object string of evidence attached to the step transition."),
|
|
3957
|
-
name: tool.schema.string().optional().describe("Workflow name for action='create'."),
|
|
3958
|
-
from: tool.schema.string().optional().describe("Path to a markdown template for action='create'."),
|
|
3959
|
-
force: tool.schema.boolean().optional().describe("Overwrite an existing workflow on create (requires --from or --reset)."),
|
|
3960
|
-
reset: tool.schema.boolean().optional().describe("Reset to the built-in template for action='create'."),
|
|
3961
|
-
filter_ref: tool.schema.string().optional().describe("Restrict action='list' to runs of this workflow ref."),
|
|
3962
|
-
active_only: tool.schema.boolean().optional().describe("Restrict action='list' to active (non-terminal) runs."),
|
|
3963
|
-
},
|
|
3964
|
-
async execute({
|
|
3965
|
-
action,
|
|
3966
|
-
ref,
|
|
3967
|
-
target,
|
|
3968
|
-
run_id,
|
|
3969
|
-
params,
|
|
3970
|
-
step,
|
|
3971
|
-
state,
|
|
3972
|
-
notes,
|
|
3973
|
-
evidence,
|
|
3974
|
-
name,
|
|
3975
|
-
from,
|
|
3976
|
-
force,
|
|
3977
|
-
reset,
|
|
3978
|
-
filter_ref,
|
|
3979
|
-
active_only,
|
|
3980
|
-
}) {
|
|
3981
|
-
const logMeta = { toolName: "akm_workflow" }
|
|
3982
|
-
switch (action) {
|
|
3983
|
-
case "start": {
|
|
3984
|
-
if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='start'." })
|
|
3985
|
-
const args = ["workflow", "start", ref]
|
|
3986
|
-
if (params) args.push("--params", params)
|
|
3987
|
-
const raw = await runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
3988
|
-
void emitWorkflowTelemetry(logClient, "info", "workflow_started", { toolName: "akm_workflow", ref, params: params ?? null })
|
|
3989
|
-
return raw
|
|
3990
|
-
}
|
|
3991
|
-
case "next": {
|
|
3992
|
-
const picked = target ?? run_id ?? ref
|
|
3993
|
-
if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='next'." })
|
|
3994
|
-
const args = ["workflow", "next", picked]
|
|
3995
|
-
if (params) args.push("--params", params)
|
|
3996
|
-
const raw = await runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
3997
|
-
void emitWorkflowTelemetry(logClient, "info", "workflow_next_loaded", { toolName: "akm_workflow", target: picked, params: params ?? null })
|
|
3998
|
-
return raw
|
|
3999
|
-
}
|
|
4000
|
-
case "complete": {
|
|
4001
|
-
if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='complete'." })
|
|
4002
|
-
if (!step) return JSON.stringify({ ok: false, error: "'step' is required for action='complete'." })
|
|
4003
|
-
const args = ["workflow", "complete", run_id, "--step", step]
|
|
4004
|
-
if (state) args.push("--state", state)
|
|
4005
|
-
if (notes) args.push("--notes", notes)
|
|
4006
|
-
if (evidence) args.push("--evidence", evidence)
|
|
4007
|
-
const raw = await runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
4008
|
-
const eventType = state === "blocked"
|
|
4009
|
-
? "workflow_step_blocked"
|
|
4010
|
-
: state === "failed"
|
|
4011
|
-
? "workflow_step_failed"
|
|
4012
|
-
: state === "skipped"
|
|
4013
|
-
? "workflow_step_skipped"
|
|
4014
|
-
: "workflow_step_completed"
|
|
4015
|
-
void emitWorkflowTelemetry(logClient, "info", eventType, { toolName: "akm_workflow", runId: run_id, step, notes: notes ?? null, evidence: evidence ?? null })
|
|
4016
|
-
return raw
|
|
4017
|
-
}
|
|
4018
|
-
case "status": {
|
|
4019
|
-
const picked = target ?? run_id ?? ref
|
|
4020
|
-
if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='status'." })
|
|
4021
|
-
return runCli(client as unknown as LogCapableClient, ["workflow", "status", picked], logMeta)
|
|
4022
|
-
}
|
|
4023
|
-
case "list": {
|
|
4024
|
-
const args = ["workflow", "list"]
|
|
4025
|
-
if (filter_ref) args.push("--ref", filter_ref)
|
|
4026
|
-
if (active_only) args.push("--active")
|
|
4027
|
-
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
4028
|
-
}
|
|
4029
|
-
case "create": {
|
|
4030
|
-
if (!name) return JSON.stringify({ ok: false, error: "'name' is required for action='create'." })
|
|
4031
|
-
const args = ["workflow", "create", name]
|
|
4032
|
-
if (from) args.push("--from", from)
|
|
4033
|
-
if (force) args.push("--force")
|
|
4034
|
-
if (reset) args.push("--reset")
|
|
4035
|
-
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
4036
|
-
}
|
|
4037
|
-
case "template": {
|
|
4038
|
-
// The workflow template is emitted as raw markdown, not JSON.
|
|
4039
|
-
const command = resolveAkmCommand()
|
|
4040
|
-
if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
|
|
4041
|
-
try {
|
|
4042
|
-
const stdout = execResolvedAkm(command, ["workflow", "template"], {
|
|
4043
|
-
encoding: "utf8",
|
|
4044
|
-
timeout: 30_000,
|
|
4045
|
-
})
|
|
4046
|
-
return JSON.stringify({ ok: true, template: stdout })
|
|
4047
|
-
} catch (error: unknown) {
|
|
4048
|
-
await writePluginLog(logClient, "error", "AKM workflow template failed", {
|
|
4049
|
-
subsystem: "workflow",
|
|
4050
|
-
toolName: "akm_workflow",
|
|
4051
|
-
action: "template",
|
|
4052
|
-
error: formatCliError(error),
|
|
4053
|
-
})
|
|
4054
|
-
return JSON.stringify({ ok: false, error: formatCliError(error) })
|
|
4055
|
-
}
|
|
4056
|
-
}
|
|
4057
|
-
case "resume": {
|
|
4058
|
-
if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='resume'." })
|
|
4059
|
-
const raw = await runCli(client as unknown as LogCapableClient, ["workflow", "resume", run_id], logMeta)
|
|
4060
|
-
void emitWorkflowTelemetry(logClient, "info", "workflow_resumed", { toolName: "akm_workflow", runId: run_id })
|
|
4061
2439
|
return raw
|
|
4062
2440
|
}
|
|
4063
|
-
|
|
4064
|
-
},
|
|
4065
|
-
}),
|
|
4066
|
-
akm_proposal: tool({
|
|
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.",
|
|
4068
|
-
args: {
|
|
4069
|
-
action: tool.schema.enum(["list", "show", "diff", "accept", "reject", "drain"]).describe("Proposal subcommand."),
|
|
4070
|
-
id: tool.schema.string().optional().describe("Proposal id. Required for show/diff/accept/reject."),
|
|
4071
|
-
status: tool.schema.enum(["pending", "accepted", "rejected"]).optional().describe("Filter for action='list'."),
|
|
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."),
|
|
4082
|
-
},
|
|
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
|
|
4087
|
-
const logMeta = { toolName: "akm_proposal" }
|
|
4088
|
-
switch (action) {
|
|
4089
|
-
case "list": {
|
|
4090
|
-
const args = ["proposal", "list"]
|
|
4091
|
-
if (status) args.push("--status", status)
|
|
4092
|
-
return runCli(client as unknown as LogCapableClient, args, logMeta)
|
|
4093
|
-
}
|
|
4094
|
-
case "show": {
|
|
4095
|
-
if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='show'." })
|
|
4096
|
-
return runCli(client as unknown as LogCapableClient, ["proposal", "show", id], logMeta)
|
|
4097
|
-
}
|
|
4098
|
-
case "diff": {
|
|
4099
|
-
if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='diff'." })
|
|
4100
|
-
return runCli(client as unknown as LogCapableClient, ["proposal", "diff", id], logMeta)
|
|
4101
|
-
}
|
|
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." })
|
|
4104
|
-
if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='accept'. Confirm with the user before accepting." })
|
|
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
|
|
4110
|
-
}
|
|
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." })
|
|
4113
|
-
if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='reject'. Confirm with the user before rejecting." })
|
|
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." })
|
|
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
|
|
4136
|
-
}
|
|
4137
|
-
}
|
|
4138
|
-
},
|
|
4139
|
-
}),
|
|
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.",
|
|
4142
|
-
args: {
|
|
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."),
|
|
4146
|
-
},
|
|
4147
|
-
async execute({ scope, task, dry_run }, context) {
|
|
4148
|
-
if (!dry_run) {
|
|
4149
|
-
await ensureFreshProposalCheckpoint(logClient, {
|
|
2441
|
+
await emitWorkflowTelemetry(logClient, "info", "akm.feedback.recorded", {
|
|
4150
2442
|
sessionID: context.sessionID,
|
|
2443
|
+
toolName: "akm_feedback",
|
|
2444
|
+
assetRef: ref,
|
|
2445
|
+
outcome: "success",
|
|
2446
|
+
reason: sentiment,
|
|
4151
2447
|
directory: context.directory,
|
|
4152
|
-
agent: context.agent,
|
|
4153
|
-
}, "pre-improve")
|
|
4154
|
-
}
|
|
4155
|
-
const args = ["improve"]
|
|
4156
|
-
if (scope) args.push(scope)
|
|
4157
|
-
if (task) args.push("--task", task)
|
|
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
|
|
4163
|
-
},
|
|
4164
|
-
}),
|
|
4165
|
-
akm_propose: tool({
|
|
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.",
|
|
4167
|
-
args: {
|
|
4168
|
-
type: tool.schema.enum(["skill", "command", "agent", "knowledge", "lesson", "script", "workflow", "wiki"]).describe("Asset type for the new proposal."),
|
|
4169
|
-
name: tool.schema.string().describe("Slug for the new asset (matches the standard ref grammar)."),
|
|
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."),
|
|
4172
|
-
},
|
|
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 })
|
|
4186
|
-
},
|
|
4187
|
-
}),
|
|
4188
|
-
akm_init: tool({
|
|
4189
|
-
description: "Initialize AKM's working stash directory and persist `stashDir` in config. This is the agent-safe initialization path; do not use interactive `akm setup` from tools.",
|
|
4190
|
-
args: {},
|
|
4191
|
-
async execute() {
|
|
4192
|
-
return runCli(client as unknown as LogCapableClient, ["init"], { toolName: "akm_init" })
|
|
4193
|
-
},
|
|
4194
|
-
}),
|
|
4195
|
-
akm_help: tool({
|
|
4196
|
-
description: "Discover the right `akm` CLI command and args for tasks not covered by a first-class tool — e.g. save/push, import, clone, update, remove, list sources, registry search, reindex, config, CLI upgrade, run script. Returns a curated quick-reference plus live `akm --help` output. Pass `command` to drill into a specific subcommand.",
|
|
4197
|
-
args: {
|
|
4198
|
-
topic: tool.schema.string().optional().describe("Natural-language description of the task (e.g. 'commit and push my stash', 'install a kit from github'). Returns curated hints if any keywords match."),
|
|
4199
|
-
command: tool.schema.string().optional().describe("Specific akm subcommand to inspect (e.g. 'save', 'clone', 'config'). Runs `akm <command> --help` and returns the output verbatim."),
|
|
4200
|
-
},
|
|
4201
|
-
async execute({ topic, command }) {
|
|
4202
|
-
const cliCommand = resolveAkmCommand()
|
|
4203
|
-
if (typeof cliCommand === "object" && "ok" in cliCommand) return JSON.stringify(cliCommand)
|
|
4204
|
-
const helpArgs = command && command.trim()
|
|
4205
|
-
? [command.trim(), "--help"]
|
|
4206
|
-
: ["--help"]
|
|
4207
|
-
let helpText = ""
|
|
4208
|
-
try {
|
|
4209
|
-
helpText = execResolvedAkm(cliCommand, helpArgs, {
|
|
4210
|
-
encoding: "utf8",
|
|
4211
|
-
timeout: 30_000,
|
|
4212
|
-
}).toString().trim()
|
|
4213
|
-
} catch (error: unknown) {
|
|
4214
|
-
await writePluginLog(logClient, "error", "AKM help command failed", {
|
|
4215
|
-
subsystem: "help",
|
|
4216
|
-
toolName: "akm_help",
|
|
4217
|
-
command: cliCommand,
|
|
4218
|
-
args: helpArgs,
|
|
4219
|
-
error: formatCliError(error),
|
|
4220
2448
|
})
|
|
4221
|
-
return
|
|
4222
|
-
}
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
"
|
|
4236
|
-
"
|
|
4237
|
-
"
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
})
|
|
4242
|
-
},
|
|
4243
|
-
}),
|
|
2449
|
+
return raw
|
|
2450
|
+
},
|
|
2451
|
+
}),
|
|
2452
|
+
akm_curate: tool({
|
|
2453
|
+
description: "Curate stash assets for a task or topic. Returns the top matches as a ranked list so the agent can inspect and use them.",
|
|
2454
|
+
args: {
|
|
2455
|
+
query: tool.schema.string().describe("Task, topic, or natural-language description of what you want to do."),
|
|
2456
|
+
type: tool.schema.enum(ASSET_TYPES as unknown as [string, ...string[]]).optional().describe("Optional asset type filter."),
|
|
2457
|
+
limit: tool.schema.number().optional().describe("Maximum number of curated matches to return. Defaults to 4."),
|
|
2458
|
+
source: tool.schema.string().optional().describe("Search source: 'local', 'registry', 'all', or a configured bundle name."),
|
|
2459
|
+
},
|
|
2460
|
+
async execute({ query, type, limit, source }, context) {
|
|
2461
|
+
return runInProcess(
|
|
2462
|
+
client as unknown as LogCapableClient,
|
|
2463
|
+
"curate",
|
|
2464
|
+
{ query, type: type === "any" ? undefined : type, limit, source },
|
|
2465
|
+
{ toolName: "akm_curate", sessionID: context.sessionID, directory: context.directory },
|
|
2466
|
+
)
|
|
2467
|
+
},
|
|
2468
|
+
}),
|
|
4244
2469
|
},
|
|
4245
2470
|
}
|
|
4246
2471
|
}
|
|
4247
|
-
|
|
4248
|
-
export
|
|
4249
|
-
|
|
2472
|
+
|
|
2473
|
+
// A single named export only. The @opencode-ai/plugin loader initializes
|
|
2474
|
+
// every exported plugin function it finds in this module, so exporting the
|
|
2475
|
+
// same function again under a second name (`server`) or bundled into a
|
|
2476
|
+
// default export risks the host registering — and running — the plugin's
|
|
2477
|
+
// hooks twice (double auto-feedback, double session-start curates, etc.).
|
|
2478
|
+
// The SDK's own example plugin (dist/example.js) exports exactly one named
|
|
2479
|
+
// const with no default export; that is the blessed shape.
|