akm-opencode 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -6
- package/index.ts +486 -6
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# akm-opencode
|
|
2
2
|
|
|
3
|
-
OpenCode plugin for the [
|
|
3
|
+
OpenCode plugin for the [AKM](https://github.com/itlackey/akm) CLI. Registers tools that let your AI agent **search**, **show**, and **manage** extension assets from stash directories and registries — plus **agentic hooks** that auto-load relevant assets into each turn, record feedback when assets are used, and harvest session memories so the stash improves with every session.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -33,8 +33,43 @@ Add to your OpenCode config (`opencode.json`):
|
|
|
33
33
|
| `akm_run` | Execute a stash script using its `run` field |
|
|
34
34
|
| `akm_sources` | Backward-compatible alias that lists configured AKM sources |
|
|
35
35
|
| `akm_upgrade` | Check for or install akm CLI updates |
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
| `akm_curate` | Curate the stash for a task or topic and return ranked matches the agent can use |
|
|
37
|
+
| `akm_evolve` | Dispatch the AKM curator agent to review recent session activity and propose stash improvements |
|
|
38
|
+
|
|
39
|
+
## Compound-engineering hooks
|
|
40
|
+
|
|
41
|
+
The plugin subscribes to OpenCode lifecycle events so AKM participates in the
|
|
42
|
+
session loop instead of waiting to be called. Every hook is non-blocking and
|
|
43
|
+
fails silently when `akm` is not on PATH — the TUI is never affected.
|
|
44
|
+
|
|
45
|
+
| Event | What happens |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| **`session.created`** (event hook) | Warms the stash index in the background and caches `akm hints` for the next system transform so the agent knows the CLI surface area at turn 0. |
|
|
48
|
+
| **`chat.message`** | Runs `akm curate "<prompt>"` on each user message (prompts shorter than `AKM_CURATE_MIN_CHARS` are skipped). The top matches are stored for injection. Memory intents (prompts mentioning "remember" / "memory") are tracked in the session buffer. |
|
|
49
|
+
| **`experimental.chat.system.transform`** | Appends the cached hints (once per session) and the curated context (once per turn) to the model's system prompt so the agent sees relevant stash assets before answering. |
|
|
50
|
+
| **`tool.execute.after`** (`akm_*` tools) | Logs asset usage, accumulates refs into the session buffer, and records `akm feedback <ref> --positive` / `--negative` automatically based on whether the tool succeeded or failed. Never recurses into `akm_feedback` and skips `memory:` refs. |
|
|
51
|
+
| **`stop`** / **`session.idle`** / **`session.compacted`** / **`session.deleted`** | Flushes the per-session buffer into a `memory:opencode-session-YYYYMMDD-<sid>` memory so every meaningful session contributes durable context for future searches. Requires at least two observations before persisting. |
|
|
52
|
+
|
|
53
|
+
### Environment overrides
|
|
54
|
+
|
|
55
|
+
| Variable | Default | Purpose |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `AKM_AUTO_CURATE` | `1` | Set to `0` to disable automatic `akm curate` on user messages. |
|
|
58
|
+
| `AKM_AUTO_FEEDBACK` | `1` | Set to `0` to disable automatic `akm feedback` on tool success/failure. |
|
|
59
|
+
| `AKM_AUTO_HINTS` | `1` | Set to `0` to skip injecting `akm hints` at session start. |
|
|
60
|
+
| `AKM_AUTO_MEMORY` | `1` | Set to `0` to disable automatic session-summary memories. |
|
|
61
|
+
| `AKM_CURATE_LIMIT` | `5` | Max curated results injected into context per prompt. |
|
|
62
|
+
| `AKM_CURATE_MIN_CHARS` | `16` | Minimum prompt length before curation runs. |
|
|
63
|
+
| `AKM_CURATE_TIMEOUT` | `8` | Wall-clock seconds for `akm` invocations inside hooks. |
|
|
64
|
+
|
|
65
|
+
### Curator agent
|
|
66
|
+
|
|
67
|
+
`akm_evolve` dispatches a child OpenCode session running a built-in curator
|
|
68
|
+
prompt that reviews recent AKM activity (OpenCode app logs, session-summary
|
|
69
|
+
memories, live stash) and produces a prioritized action list: hot assets to
|
|
70
|
+
promote, cold ones to investigate, coverage gaps to draft, duplicates to
|
|
71
|
+
consolidate. The curator never applies destructive changes without explicit
|
|
72
|
+
user approval.
|
|
38
73
|
|
|
39
74
|
### Registry discovery
|
|
40
75
|
|
|
@@ -83,9 +118,9 @@ When the plugin loads, it runs `bun install -g akm-cli@latest` so it always pick
|
|
|
83
118
|
|
|
84
119
|
```sh
|
|
85
120
|
# macOS / Linux
|
|
86
|
-
curl -fsSL https://raw.githubusercontent.com/itlackey/
|
|
121
|
+
curl -fsSL https://raw.githubusercontent.com/itlackey/akm/main/install.sh | bash
|
|
87
122
|
# PowerShell (Windows)
|
|
88
|
-
irm https://raw.githubusercontent.com/itlackey/
|
|
123
|
+
irm https://raw.githubusercontent.com/itlackey/akm/main/install.ps1 -OutFile install.ps1; ./install.ps1
|
|
89
124
|
|
|
90
125
|
# Or via Bun
|
|
91
126
|
bun install -g akm-cli@latest
|
|
@@ -114,6 +149,6 @@ Assets are resolved from three source types: **working** (local stash), **search
|
|
|
114
149
|
|
|
115
150
|
## Docs
|
|
116
151
|
|
|
117
|
-
- [
|
|
152
|
+
- [AKM CLI](https://github.com/itlackey/akm)
|
|
118
153
|
- [OpenCode Plugins](https://opencode.ai/docs/plugins/)
|
|
119
154
|
- [OpenCode Custom Tools](https://opencode.ai/docs/custom-tools/)
|
package/index.ts
CHANGED
|
@@ -5,6 +5,71 @@ import path from "node:path"
|
|
|
5
5
|
let resolvedAkmCommand = "akm"
|
|
6
6
|
const autoInstallPackageRef = "akm-cli@latest"
|
|
7
7
|
|
|
8
|
+
const AKM_AUTO_FEEDBACK = (process.env.AKM_AUTO_FEEDBACK ?? "1") !== "0"
|
|
9
|
+
const AKM_AUTO_MEMORY = (process.env.AKM_AUTO_MEMORY ?? "1") !== "0"
|
|
10
|
+
const AKM_AUTO_CURATE = (process.env.AKM_AUTO_CURATE ?? "1") !== "0"
|
|
11
|
+
const AKM_AUTO_HINTS = (process.env.AKM_AUTO_HINTS ?? "1") !== "0"
|
|
12
|
+
const AKM_CURATE_LIMIT = Math.max(1, Number(process.env.AKM_CURATE_LIMIT ?? "5") || 5)
|
|
13
|
+
const AKM_CURATE_MIN_CHARS = Math.max(1, Number(process.env.AKM_CURATE_MIN_CHARS ?? "16") || 16)
|
|
14
|
+
const AKM_CURATE_TIMEOUT_MS = Math.max(1_000, (Number(process.env.AKM_CURATE_TIMEOUT ?? "8") || 8) * 1_000)
|
|
15
|
+
|
|
16
|
+
// Per-session state that drives the compound-engineering loop.
|
|
17
|
+
// These maps are keyed by OpenCode sessionID.
|
|
18
|
+
const sessionHints = new Map<string, string>()
|
|
19
|
+
const sessionCurated = new Map<string, string>()
|
|
20
|
+
type SessionBufferEntry = {
|
|
21
|
+
timestamp: string
|
|
22
|
+
kind: "memory-intent" | "tool-ref"
|
|
23
|
+
toolName?: string
|
|
24
|
+
ref?: string
|
|
25
|
+
status?: "positive" | "negative" | "unknown"
|
|
26
|
+
note?: string
|
|
27
|
+
}
|
|
28
|
+
const sessionBuffer = new Map<string, SessionBufferEntry[]>()
|
|
29
|
+
const sessionMemoryCaptured = new Set<string>()
|
|
30
|
+
|
|
31
|
+
// Asset-ref grammar matching the stash skill: [origin//]type:name
|
|
32
|
+
const AKM_REF_PATTERN = /(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script):[A-Za-z0-9._/\-]+/g
|
|
33
|
+
|
|
34
|
+
const CURATOR_AGENT_PROMPT = `You are the AKM curator — a compound-engineering agent that keeps the user's AKM stash improving every time the main agent finishes a task.
|
|
35
|
+
|
|
36
|
+
Inputs you should inspect:
|
|
37
|
+
1. OpenCode app logs that include the "akm-opencode" service (feedback, memory, tool invocations).
|
|
38
|
+
2. Session-summary memories named memory:opencode-session-*.
|
|
39
|
+
3. The live stash: call akm_list, akm_search "" --limit 50, and akm_show <ref>.
|
|
40
|
+
|
|
41
|
+
Signals to act on:
|
|
42
|
+
- Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
|
|
43
|
+
- Cold refs: assets tied to failures or user complaints. Record akm_feedback <ref> negative --note "<excerpt>" and open the asset for review.
|
|
44
|
+
- Missing coverage: recurring user prompts with no matching asset. Draft a new skill, command, or knowledge doc in the working stash and reindex with akm_index.
|
|
45
|
+
- Duplicates / drift: near-identical descriptions or overlapping responsibilities. Propose a consolidation.
|
|
46
|
+
- Stale memories: session summaries that never get recalled. Propose akm_remove memory:<name> once distilled into a durable knowledge doc.
|
|
47
|
+
|
|
48
|
+
Rules of engagement:
|
|
49
|
+
- Never apply destructive changes without explicit user approval.
|
|
50
|
+
- Report findings as a prioritized action list of concrete akm_* tool calls the user can run.
|
|
51
|
+
- Prefer small, reversible edits: promote via positive feedback, draft a candidate skill, or clone and tweak.
|
|
52
|
+
- When drafting new assets, write them into the working stash directory (akm_config get stashDir) under skills/, commands/, agents/, knowledge/, or scripts/. Call akm_index when finished.
|
|
53
|
+
- When finished, persist your own summary with akm_remember (name: curator-run-<timestamp>) so the next curator run can build on yours.
|
|
54
|
+
|
|
55
|
+
Output shape: end every run with a markdown report that has these sections:
|
|
56
|
+
|
|
57
|
+
## Hot assets (promote)
|
|
58
|
+
- <ref> — why it helped — command to run
|
|
59
|
+
|
|
60
|
+
## Cold assets (investigate)
|
|
61
|
+
- <ref> — failure signal — proposed fix
|
|
62
|
+
|
|
63
|
+
## Coverage gaps
|
|
64
|
+
- <theme> — proposed asset (type, name, one-line description)
|
|
65
|
+
|
|
66
|
+
## Duplicates / drift
|
|
67
|
+
- <ref a> vs <ref b> — consolidation proposal
|
|
68
|
+
|
|
69
|
+
## Housekeeping
|
|
70
|
+
- stale memories, reindex needs, config tweaks
|
|
71
|
+
`
|
|
72
|
+
|
|
8
73
|
type LogLevel = "debug" | "info" | "warn" | "error"
|
|
9
74
|
|
|
10
75
|
type LogCapableClient = {
|
|
@@ -29,7 +94,7 @@ type CliLogMeta = {
|
|
|
29
94
|
|
|
30
95
|
function formatCliError(error: unknown): string {
|
|
31
96
|
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") {
|
|
32
|
-
return "The 'akm' CLI was not found on PATH. Install it first from https://github.com/itlackey/
|
|
97
|
+
return "The 'akm' CLI was not found on PATH. Install it first from https://github.com/itlackey/akm."
|
|
33
98
|
}
|
|
34
99
|
return error instanceof Error ? error.message : String(error)
|
|
35
100
|
}
|
|
@@ -62,6 +127,209 @@ async function writePluginLog(client: LogCapableClient, level: LogLevel, message
|
|
|
62
127
|
}
|
|
63
128
|
}
|
|
64
129
|
|
|
130
|
+
function nowIso(): string {
|
|
131
|
+
return new Date().toISOString()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBufferEntry, "timestamp">) {
|
|
135
|
+
if (!sessionID) return
|
|
136
|
+
const buf = sessionBuffer.get(sessionID) ?? []
|
|
137
|
+
buf.push({ timestamp: nowIso(), ...entry })
|
|
138
|
+
sessionBuffer.set(sessionID, buf)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Synchronous CLI invocation used by the lifecycle hooks — the plugin host does
|
|
142
|
+
// not await these in a hot path, but we still cap execution time so a slow
|
|
143
|
+
// stash never wedges the session loop.
|
|
144
|
+
function runCliSyncRaw(args: string[], timeoutMs: number): { ok: true; stdout: string } | { ok: false; error: string } {
|
|
145
|
+
const command = resolveAkmCommand()
|
|
146
|
+
if (typeof command !== "string") return { ok: false, error: command.error }
|
|
147
|
+
try {
|
|
148
|
+
const stdout = execFileSync(command, args, {
|
|
149
|
+
encoding: "utf8",
|
|
150
|
+
timeout: timeoutMs,
|
|
151
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
152
|
+
})
|
|
153
|
+
return { ok: true, stdout }
|
|
154
|
+
} catch (error: unknown) {
|
|
155
|
+
return { ok: false, error: formatCliError(error) }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function runCurateForPrompt(text: string): string | null {
|
|
160
|
+
if (!text || text.length < AKM_CURATE_MIN_CHARS) return null
|
|
161
|
+
const result = runCliSyncRaw(
|
|
162
|
+
[
|
|
163
|
+
"--for-agent",
|
|
164
|
+
"--format",
|
|
165
|
+
"text",
|
|
166
|
+
"--detail",
|
|
167
|
+
"summary",
|
|
168
|
+
"-q",
|
|
169
|
+
"curate",
|
|
170
|
+
text,
|
|
171
|
+
"--limit",
|
|
172
|
+
String(AKM_CURATE_LIMIT),
|
|
173
|
+
],
|
|
174
|
+
AKM_CURATE_TIMEOUT_MS,
|
|
175
|
+
)
|
|
176
|
+
if (!result.ok) return null
|
|
177
|
+
const body = result.stdout.trim()
|
|
178
|
+
return body || null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function runHintsForSession(): string | null {
|
|
182
|
+
const result = runCliSyncRaw(["--format", "text", "-q", "hints"], AKM_CURATE_TIMEOUT_MS)
|
|
183
|
+
if (!result.ok) return null
|
|
184
|
+
const body = result.stdout.trim()
|
|
185
|
+
return body || null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function warmIndexInBackground(): void {
|
|
189
|
+
const command = resolveAkmCommand()
|
|
190
|
+
if (typeof command !== "string") return
|
|
191
|
+
try {
|
|
192
|
+
// Fire and forget — execSync with a timeout would block, so spawn via the
|
|
193
|
+
// shell and detach. Errors here are never surfaced to the session.
|
|
194
|
+
execSync(`${JSON.stringify(command)} index >/dev/null 2>&1 &`, { timeout: 2_000 })
|
|
195
|
+
} catch {
|
|
196
|
+
// Intentionally ignore — warming is best-effort.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function recordFeedbackSync(ref: string, sentiment: "positive" | "negative", note: string): boolean {
|
|
201
|
+
const result = runCliSyncRaw(
|
|
202
|
+
[
|
|
203
|
+
"--format",
|
|
204
|
+
"json",
|
|
205
|
+
"-q",
|
|
206
|
+
"feedback",
|
|
207
|
+
ref,
|
|
208
|
+
sentiment === "positive" ? "--positive" : "--negative",
|
|
209
|
+
"--note",
|
|
210
|
+
note,
|
|
211
|
+
],
|
|
212
|
+
AKM_CURATE_TIMEOUT_MS,
|
|
213
|
+
)
|
|
214
|
+
return result.ok
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function captureSessionMemory(sessionID: string, reason: string): string | null {
|
|
218
|
+
if (!AKM_AUTO_MEMORY) return null
|
|
219
|
+
if (!sessionID) return null
|
|
220
|
+
if (sessionMemoryCaptured.has(sessionID)) return null
|
|
221
|
+
const entries = sessionBuffer.get(sessionID) ?? []
|
|
222
|
+
// Require at least two observations before persisting — single events are noise.
|
|
223
|
+
if (entries.length < 2) {
|
|
224
|
+
sessionBuffer.delete(sessionID)
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const lines: string[] = []
|
|
229
|
+
lines.push(`# Session summary (${nowIso()})`)
|
|
230
|
+
lines.push(`Reason: ${reason}`)
|
|
231
|
+
lines.push(`Session: ${sessionID}`)
|
|
232
|
+
lines.push("")
|
|
233
|
+
for (const entry of entries) {
|
|
234
|
+
if (entry.kind === "memory-intent") {
|
|
235
|
+
lines.push(`## ${entry.timestamp} — user memory intent`)
|
|
236
|
+
if (entry.note) lines.push(entry.note)
|
|
237
|
+
lines.push("")
|
|
238
|
+
} else {
|
|
239
|
+
lines.push(`## ${entry.timestamp} — ${entry.toolName ?? "tool"} ${entry.status ?? "unknown"}`)
|
|
240
|
+
if (entry.ref) lines.push(`- ref: ${entry.ref}`)
|
|
241
|
+
if (entry.note) lines.push(`- note: ${entry.note}`)
|
|
242
|
+
lines.push("")
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const body = lines.join("\n")
|
|
246
|
+
|
|
247
|
+
const dateTag = new Date().toISOString().replace(/[-:]/g, "").slice(0, 8)
|
|
248
|
+
const shortSid = sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
|
|
249
|
+
const name = `opencode-session-${dateTag}-${shortSid}`
|
|
250
|
+
|
|
251
|
+
const command = resolveAkmCommand()
|
|
252
|
+
if (typeof command !== "string") {
|
|
253
|
+
sessionMemoryCaptured.add(sessionID)
|
|
254
|
+
sessionBuffer.delete(sessionID)
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
execFileSync(command, ["--format", "json", "-q", "remember", "--name", name, "--force"], {
|
|
259
|
+
encoding: "utf8",
|
|
260
|
+
timeout: AKM_CURATE_TIMEOUT_MS * 2,
|
|
261
|
+
input: body,
|
|
262
|
+
})
|
|
263
|
+
sessionMemoryCaptured.add(sessionID)
|
|
264
|
+
sessionBuffer.delete(sessionID)
|
|
265
|
+
return `memory:${name}`
|
|
266
|
+
} catch {
|
|
267
|
+
sessionMemoryCaptured.add(sessionID)
|
|
268
|
+
sessionBuffer.delete(sessionID)
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function extractToolRefs(toolName: string, args: Record<string, unknown>, output: unknown): string[] {
|
|
274
|
+
const refs = new Set<string>()
|
|
275
|
+
const addMatches = (value: unknown) => {
|
|
276
|
+
if (typeof value !== "string") return
|
|
277
|
+
const matches = value.match(AKM_REF_PATTERN)
|
|
278
|
+
if (matches) for (const ref of matches) refs.add(ref)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const key of ["ref", "package_ref"]) {
|
|
282
|
+
addMatches((args as Record<string, unknown>)[key])
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (output && typeof output === "object") {
|
|
286
|
+
const o = output as Record<string, unknown>
|
|
287
|
+
addMatches(o.ref)
|
|
288
|
+
if (Array.isArray(o.hits)) {
|
|
289
|
+
for (const hit of o.hits) {
|
|
290
|
+
if (hit && typeof hit === "object") addMatches((hit as Record<string, unknown>).ref)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (Array.isArray(o.assetHits)) {
|
|
294
|
+
for (const hit of o.assetHits) {
|
|
295
|
+
if (hit && typeof hit === "object") addMatches((hit as Record<string, unknown>).ref)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (toolName === "akm_remember" && typeof o.ref === "string") addMatches(o.ref)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return [...refs]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const AKM_HINTS_PREFIX = [
|
|
305
|
+
"# AKM is available in this session",
|
|
306
|
+
"",
|
|
307
|
+
"You have an AKM stash on this machine. Before writing anything from scratch, call `akm_search` or `akm_curate` to see if the stash already covers it. Record `akm_feedback <ref> positive|negative` whenever an asset materially helps or misses, and use `akm_remember` to persist durable learnings so future sessions inherit them.",
|
|
308
|
+
].join("\n")
|
|
309
|
+
|
|
310
|
+
const AKM_CURATED_HEADER = "# AKM stash — assets relevant to this prompt"
|
|
311
|
+
const AKM_CURATED_TAIL = "\n\nTip: call `akm_show <ref>` to fetch full content, and record `akm_feedback <ref> positive|negative` once you know whether the asset helped."
|
|
312
|
+
|
|
313
|
+
function extractSessionIdFromEvent(payload: unknown): string | undefined {
|
|
314
|
+
if (!payload || typeof payload !== "object") return undefined
|
|
315
|
+
const p = payload as Record<string, unknown>
|
|
316
|
+
const candidates = [
|
|
317
|
+
p.sessionID,
|
|
318
|
+
p.session_id,
|
|
319
|
+
p.session,
|
|
320
|
+
(p.session as Record<string, unknown> | undefined)?.id,
|
|
321
|
+
(p.properties as Record<string, unknown> | undefined)?.sessionID,
|
|
322
|
+
(p.properties as Record<string, unknown> | undefined)?.session_id,
|
|
323
|
+
(p.properties as Record<string, unknown> | undefined)?.id,
|
|
324
|
+
(p.info as Record<string, unknown> | undefined)?.id,
|
|
325
|
+
(p.info as Record<string, unknown> | undefined)?.sessionID,
|
|
326
|
+
]
|
|
327
|
+
for (const value of candidates) {
|
|
328
|
+
if (typeof value === "string" && value) return value
|
|
329
|
+
}
|
|
330
|
+
return undefined
|
|
331
|
+
}
|
|
332
|
+
|
|
65
333
|
function getCommandStatus(command: string): "ok" | "missing" | "error" {
|
|
66
334
|
try {
|
|
67
335
|
execFileSync(command, ["--version"], {
|
|
@@ -154,7 +422,7 @@ function resolveAkmCommand(): string | CliError {
|
|
|
154
422
|
|
|
155
423
|
return {
|
|
156
424
|
ok: false,
|
|
157
|
-
error: `The 'akm' CLI could not be resolved after attempting to install '${autoInstallPackageRef}' with Bun. Install akm from https://github.com/itlackey/
|
|
425
|
+
error: `The 'akm' CLI could not be resolved after attempting to install '${autoInstallPackageRef}' with Bun. Install akm from https://github.com/itlackey/akm.`,
|
|
158
426
|
}
|
|
159
427
|
}
|
|
160
428
|
|
|
@@ -536,14 +804,102 @@ type PluginClient = {
|
|
|
536
804
|
}
|
|
537
805
|
}
|
|
538
806
|
|
|
539
|
-
export const
|
|
807
|
+
export const AkmPlugin: Plugin = async ({ client }) => {
|
|
540
808
|
await ensureLatestAkmInstalled(client as unknown as LogCapableClient)
|
|
541
809
|
|
|
810
|
+
const logClient = client as unknown as LogCapableClient
|
|
811
|
+
|
|
542
812
|
return {
|
|
813
|
+
// Events cover the lifecycle boundaries that Claude Code exposes as
|
|
814
|
+
// SessionStart / Stop / PreCompact. We use them to warm the stash, capture
|
|
815
|
+
// hints for the next system transform, and flush per-session memories.
|
|
816
|
+
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
817
|
+
try {
|
|
818
|
+
const type = event?.type
|
|
819
|
+
if (!type) return
|
|
820
|
+
const sid = extractSessionIdFromEvent(event) ?? extractSessionIdFromEvent((event as { properties?: unknown }).properties)
|
|
821
|
+
if (type === "session.created" || type === "session.updated") {
|
|
822
|
+
if (!sid) return
|
|
823
|
+
if (!AKM_AUTO_HINTS) return
|
|
824
|
+
if (sessionHints.has(sid)) return
|
|
825
|
+
warmIndexInBackground()
|
|
826
|
+
const hints = runHintsForSession()
|
|
827
|
+
if (hints) sessionHints.set(sid, hints)
|
|
828
|
+
} else if (type === "session.compacted" || type === "session.idle" || type === "session.deleted") {
|
|
829
|
+
if (!sid) return
|
|
830
|
+
const captured = captureSessionMemory(sid, type)
|
|
831
|
+
if (captured) {
|
|
832
|
+
await writePluginLog(logClient, "info", "AKM session memory captured", {
|
|
833
|
+
subsystem: "memory",
|
|
834
|
+
actor: "system",
|
|
835
|
+
sessionID: sid,
|
|
836
|
+
reason: type,
|
|
837
|
+
ref: captured,
|
|
838
|
+
})
|
|
839
|
+
}
|
|
840
|
+
// Drop per-session state so a re-created session does not inherit
|
|
841
|
+
// stale hints/curation.
|
|
842
|
+
if (type === "session.deleted") {
|
|
843
|
+
sessionHints.delete(sid)
|
|
844
|
+
sessionCurated.delete(sid)
|
|
845
|
+
sessionMemoryCaptured.delete(sid)
|
|
846
|
+
sessionBuffer.delete(sid)
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
} catch {
|
|
850
|
+
// Lifecycle hooks must never throw into the TUI.
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
// Stop is the closest analogue to Claude's Stop/SubagentStop — the user or
|
|
854
|
+
// agent halted the active run. Flush the session buffer so learnings are
|
|
855
|
+
// preserved even if the session.idle event does not fire.
|
|
856
|
+
stop: async (input: unknown) => {
|
|
857
|
+
try {
|
|
858
|
+
const sid = extractSessionIdFromEvent(input)
|
|
859
|
+
if (!sid) return
|
|
860
|
+
const captured = captureSessionMemory(sid, "stop")
|
|
861
|
+
if (captured) {
|
|
862
|
+
await writePluginLog(logClient, "info", "AKM session memory captured", {
|
|
863
|
+
subsystem: "memory",
|
|
864
|
+
actor: "system",
|
|
865
|
+
sessionID: sid,
|
|
866
|
+
reason: "stop",
|
|
867
|
+
ref: captured,
|
|
868
|
+
})
|
|
869
|
+
}
|
|
870
|
+
} catch {
|
|
871
|
+
// Best-effort only.
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
// experimental.chat.system.transform is how OpenCode exposes the
|
|
875
|
+
// additionalContext channel. We append the cached hints (once per session)
|
|
876
|
+
// and the curated assets (once per turn) so the next LLM call sees them.
|
|
877
|
+
"experimental.chat.system.transform": async (
|
|
878
|
+
input: { sessionID?: string; session_id?: string } | undefined,
|
|
879
|
+
output: { system?: string[] } | undefined,
|
|
880
|
+
) => {
|
|
881
|
+
try {
|
|
882
|
+
if (!output || !Array.isArray(output.system)) return
|
|
883
|
+
const sid = extractSessionIdFromEvent(input) ?? ""
|
|
884
|
+
const hints = sid ? sessionHints.get(sid) : undefined
|
|
885
|
+
if (hints) {
|
|
886
|
+
output.system.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
|
|
887
|
+
// Only inject hints on the first transform of the session.
|
|
888
|
+
sessionHints.delete(sid)
|
|
889
|
+
}
|
|
890
|
+
const curated = sid ? sessionCurated.get(sid) : undefined
|
|
891
|
+
if (curated) {
|
|
892
|
+
output.system.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
|
|
893
|
+
sessionCurated.delete(sid)
|
|
894
|
+
}
|
|
895
|
+
} catch {
|
|
896
|
+
// Never break the turn because of a transform failure.
|
|
897
|
+
}
|
|
898
|
+
},
|
|
543
899
|
"chat.message": async (input, output) => {
|
|
544
900
|
const text = extractText(output.parts).trim()
|
|
545
901
|
if (!text) return
|
|
546
|
-
await writePluginLog(
|
|
902
|
+
await writePluginLog(logClient, "info", "AKM user feedback recorded", {
|
|
547
903
|
subsystem: "feedback",
|
|
548
904
|
actor: "user",
|
|
549
905
|
sessionID: input.sessionID,
|
|
@@ -551,6 +907,22 @@ export const AgentikitPlugin: Plugin = async ({ client }) => {
|
|
|
551
907
|
agent: input.agent,
|
|
552
908
|
text: truncateLogText(text),
|
|
553
909
|
})
|
|
910
|
+
|
|
911
|
+
// Compound-engineering loop: on every user message, curate the stash and
|
|
912
|
+
// stash the result so experimental.chat.system.transform can inject it.
|
|
913
|
+
if (AKM_AUTO_CURATE && input.sessionID) {
|
|
914
|
+
const curated = runCurateForPrompt(text)
|
|
915
|
+
if (curated) sessionCurated.set(input.sessionID, curated)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Track explicit memory intents so capture-memory has something durable
|
|
919
|
+
// to flush when the session ends.
|
|
920
|
+
if (/\b(remember|memory|memories)\b/i.test(text)) {
|
|
921
|
+
addBufferEntry(input.sessionID, {
|
|
922
|
+
kind: "memory-intent",
|
|
923
|
+
note: truncateLogText(text, 500),
|
|
924
|
+
})
|
|
925
|
+
}
|
|
554
926
|
},
|
|
555
927
|
"tool.execute.after": async (input, output) => {
|
|
556
928
|
if (!input.tool.startsWith("akm_")) return
|
|
@@ -560,7 +932,7 @@ export const AgentikitPlugin: Plugin = async ({ client }) => {
|
|
|
560
932
|
|
|
561
933
|
const feedback = classifyToolFeedback(parsed)
|
|
562
934
|
if (feedback) {
|
|
563
|
-
await writePluginLog(
|
|
935
|
+
await writePluginLog(logClient, feedback === "negative" ? "warn" : "info", "AKM system feedback recorded", {
|
|
564
936
|
subsystem: "feedback",
|
|
565
937
|
actor: "system",
|
|
566
938
|
feedback,
|
|
@@ -574,7 +946,7 @@ export const AgentikitPlugin: Plugin = async ({ client }) => {
|
|
|
574
946
|
|
|
575
947
|
const memoryRefs = extractMemoryRefs(input.tool, input.args as Record<string, unknown>, parsed)
|
|
576
948
|
if (memoryRefs.length > 0) {
|
|
577
|
-
await writePluginLog(
|
|
949
|
+
await writePluginLog(logClient, "info", "AKM memory usage recorded", {
|
|
578
950
|
subsystem: "memory",
|
|
579
951
|
toolName: input.tool,
|
|
580
952
|
sessionID: input.sessionID,
|
|
@@ -582,6 +954,38 @@ export const AgentikitPlugin: Plugin = async ({ client }) => {
|
|
|
582
954
|
refs: memoryRefs,
|
|
583
955
|
})
|
|
584
956
|
}
|
|
957
|
+
|
|
958
|
+
// Auto-feedback + session buffering: record every asset ref the tool
|
|
959
|
+
// touched so the stash ranking improves over time and so Stop/Compact
|
|
960
|
+
// has material to flush into a session summary memory.
|
|
961
|
+
const allRefs = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
|
|
962
|
+
if (allRefs.length > 0 && input.sessionID) {
|
|
963
|
+
for (const ref of allRefs) {
|
|
964
|
+
addBufferEntry(input.sessionID, {
|
|
965
|
+
kind: "tool-ref",
|
|
966
|
+
toolName: input.tool,
|
|
967
|
+
ref,
|
|
968
|
+
status: feedback ?? "unknown",
|
|
969
|
+
})
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (
|
|
974
|
+
AKM_AUTO_FEEDBACK
|
|
975
|
+
&& feedback
|
|
976
|
+
&& input.tool !== "akm_feedback"
|
|
977
|
+
&& allRefs.length > 0
|
|
978
|
+
) {
|
|
979
|
+
const note = feedback === "positive"
|
|
980
|
+
? `opencode auto: ${input.tool} succeeded`
|
|
981
|
+
: `opencode auto: ${input.tool} failed`
|
|
982
|
+
for (const ref of allRefs) {
|
|
983
|
+
// Memories do not accept feedback in the current CLI.
|
|
984
|
+
if (ref.startsWith("memory:")) continue
|
|
985
|
+
const ok = recordFeedbackSync(ref, feedback, note)
|
|
986
|
+
if (!ok) break
|
|
987
|
+
}
|
|
988
|
+
}
|
|
585
989
|
},
|
|
586
990
|
tool: {
|
|
587
991
|
akm_search: tool({
|
|
@@ -761,6 +1165,79 @@ export const AgentikitPlugin: Plugin = async ({ client }) => {
|
|
|
761
1165
|
return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_feedback" })
|
|
762
1166
|
},
|
|
763
1167
|
}),
|
|
1168
|
+
akm_curate: tool({
|
|
1169
|
+
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.",
|
|
1170
|
+
args: {
|
|
1171
|
+
query: tool.schema.string().describe("Task, topic, or natural-language description of what you want to do."),
|
|
1172
|
+
limit: tool.schema.number().optional().describe("Maximum number of curated matches to return. Defaults to 6."),
|
|
1173
|
+
detail: tool.schema.enum(["summary", "normal", "full"]).optional().describe("Detail level for each match. Defaults to 'summary'."),
|
|
1174
|
+
},
|
|
1175
|
+
async execute({ query, limit, detail }) {
|
|
1176
|
+
const args = [
|
|
1177
|
+
"--for-agent",
|
|
1178
|
+
"--format",
|
|
1179
|
+
"text",
|
|
1180
|
+
"--detail",
|
|
1181
|
+
detail ?? "summary",
|
|
1182
|
+
"-q",
|
|
1183
|
+
"curate",
|
|
1184
|
+
query,
|
|
1185
|
+
"--limit",
|
|
1186
|
+
String(limit ?? 6),
|
|
1187
|
+
]
|
|
1188
|
+
return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_curate" })
|
|
1189
|
+
},
|
|
1190
|
+
}),
|
|
1191
|
+
akm_evolve: tool({
|
|
1192
|
+
description: "Dispatch the AKM curator agent to review recent session activity and propose stash improvements (promote hot assets, flag cold ones, draft missing coverage).",
|
|
1193
|
+
args: {
|
|
1194
|
+
focus: tool.schema.string().optional().describe("Optional focus area or theme to weight the review toward."),
|
|
1195
|
+
dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the curator with. Defaults to 'general'."),
|
|
1196
|
+
as_subtask: tool.schema.boolean().optional().describe("Run in a child session with parent context. Defaults to true."),
|
|
1197
|
+
},
|
|
1198
|
+
async execute({ focus, dispatch_agent, as_subtask }, context) {
|
|
1199
|
+
const useSubtask = as_subtask ?? true
|
|
1200
|
+
const targetAgent = dispatch_agent ?? "general"
|
|
1201
|
+
const targetSession = await ensureTargetSessionID({
|
|
1202
|
+
useSubtask,
|
|
1203
|
+
context: { sessionID: context.sessionID, directory: context.directory },
|
|
1204
|
+
title: "akm:curator",
|
|
1205
|
+
client: client as unknown as PluginClient,
|
|
1206
|
+
})
|
|
1207
|
+
if (!targetSession.ok) return JSON.stringify(targetSession)
|
|
1208
|
+
|
|
1209
|
+
const task = focus && focus.trim()
|
|
1210
|
+
? `Review recent AKM activity with an emphasis on: ${focus.trim()}. Produce the prioritized action list described in the system prompt.`
|
|
1211
|
+
: "Review recent AKM activity and produce the prioritized action list described in the system prompt."
|
|
1212
|
+
|
|
1213
|
+
const promptResponse = await client.session.prompt({
|
|
1214
|
+
query: { directory: context.directory },
|
|
1215
|
+
path: { id: targetSession.sessionID },
|
|
1216
|
+
body: {
|
|
1217
|
+
agent: targetAgent,
|
|
1218
|
+
system: CURATOR_AGENT_PROMPT,
|
|
1219
|
+
parts: [{ type: "text", text: task }],
|
|
1220
|
+
},
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
if (promptResponse.error || !promptResponse.data) {
|
|
1224
|
+
const reason = promptResponse.error ? JSON.stringify(promptResponse.error) : "empty response"
|
|
1225
|
+
return JSON.stringify({
|
|
1226
|
+
ok: false,
|
|
1227
|
+
error: `Failed to dispatch curator: ${reason}`,
|
|
1228
|
+
})
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
return JSON.stringify({
|
|
1232
|
+
ok: true,
|
|
1233
|
+
dispatchAgent: targetAgent,
|
|
1234
|
+
usedSubtask: useSubtask,
|
|
1235
|
+
sessionID: targetSession.sessionID,
|
|
1236
|
+
focus: focus ?? null,
|
|
1237
|
+
text: extractText(promptResponse.data.parts),
|
|
1238
|
+
})
|
|
1239
|
+
},
|
|
1240
|
+
}),
|
|
764
1241
|
akm_agent: tool({
|
|
765
1242
|
description: "Dispatch a stash agent by ref into a child OpenCode session, applying the agent prompt and metadata from akm_show.",
|
|
766
1243
|
args: {
|
|
@@ -1020,3 +1497,6 @@ export const AgentikitPlugin: Plugin = async ({ client }) => {
|
|
|
1020
1497
|
},
|
|
1021
1498
|
}
|
|
1022
1499
|
}
|
|
1500
|
+
|
|
1501
|
+
export const server = AkmPlugin
|
|
1502
|
+
export default { server }
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akm-opencode",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "OpenCode plugin for
|
|
5
|
+
"description": "OpenCode plugin for AKM - search, show, and manage extension assets via the akm CLI, with agentic hooks that auto-load relevant stash assets, record feedback, and harvest session memories so the stash improves every session.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"opencode",
|
|
8
8
|
"opencode-ai",
|
|
9
9
|
"opencode-plugin",
|
|
10
10
|
"opencode-extensions",
|
|
11
|
-
"
|
|
11
|
+
"akm",
|
|
12
12
|
"ai-agent",
|
|
13
13
|
"developer-tools",
|
|
14
14
|
"plugin"
|