akm-opencode 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +41 -6
  2. package/index.ts +483 -6
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # akm-opencode
2
2
 
3
- OpenCode plugin for the [Agentikit](https://github.com/itlackey/agentikit) CLI. Registers tools that let your AI agent **search**, **show**, and **manage** extension assets from stash directories and registries.
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
- The plugin also uses the OpenCode `chat.message` and `tool.execute.after` hooks to record user/system feedback activity and memory usage in OpenCode app logs when relevant.
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/agentikit/main/install.sh | bash
121
+ curl -fsSL https://raw.githubusercontent.com/itlackey/akm/main/install.sh | bash
87
122
  # PowerShell (Windows)
88
- irm https://raw.githubusercontent.com/itlackey/agentikit/main/install.ps1 -OutFile install.ps1; ./install.ps1
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
- - [Agentikit CLI](https://github.com/itlackey/agentikit)
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/agentikit."
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/agentikit.`,
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 AgentikitPlugin: Plugin = async ({ client }) => {
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(client as unknown as LogCapableClient, "info", "AKM user feedback recorded", {
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(client as unknown as LogCapableClient, feedback === "negative" ? "warn" : "info", "AKM system feedback recorded", {
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(client as unknown as LogCapableClient, "info", "AKM memory usage recorded", {
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: {
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "akm-opencode",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
- "description": "OpenCode plugin for Agentikit - search and show extension assets via the akm CLI.",
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
- "agentikit",
11
+ "akm",
12
12
  "ai-agent",
13
13
  "developer-tools",
14
14
  "plugin"