akm-opencode 0.2.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 +47 -8
  2. package/index.ts +626 -22
  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
 
@@ -23,14 +23,53 @@ Add to your OpenCode config (`opencode.json`):
23
23
  | `akm_agent` | Dispatch a stash `agent:*` into OpenCode using the stash prompt and metadata |
24
24
  | `akm_cmd` | Execute a stash `command:*` template in OpenCode via SDK session prompting |
25
25
  | `akm_add` | Install kits from npm, GitHub, git URLs, or local directories |
26
- | `akm_list` | List installed registry kits |
27
- | `akm_remove` | Remove an installed registry kit and reindex |
28
- | `akm_update` | Update one installed kit or all installed kits |
26
+ | `akm_list` | List configured AKM sources |
27
+ | `akm_remove` | Remove a configured AKM source and reindex |
28
+ | `akm_update` | Update one managed source or all managed sources |
29
29
  | `akm_clone` | Clone an asset into the working stash or a custom destination for editing |
30
+ | `akm_remember` | Record a memory in the default stash |
31
+ | `akm_feedback` | Record positive or negative feedback for a stash asset |
30
32
  | `akm_config` | Get, set, unset, list, or inspect akm configuration (including `config path --all`) |
31
33
  | `akm_run` | Execute a stash script using its `run` field |
32
- | `akm_sources` | List all resolved stash search paths |
34
+ | `akm_sources` | Backward-compatible alias that lists configured AKM sources |
33
35
  | `akm_upgrade` | Check for or install akm CLI updates |
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.
34
73
 
35
74
  ### Registry discovery
36
75
 
@@ -79,9 +118,9 @@ When the plugin loads, it runs `bun install -g akm-cli@latest` so it always pick
79
118
 
80
119
  ```sh
81
120
  # macOS / Linux
82
- 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
83
122
  # PowerShell (Windows)
84
- 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
85
124
 
86
125
  # Or via Bun
87
126
  bun install -g akm-cli@latest
@@ -110,6 +149,6 @@ Assets are resolved from three source types: **working** (local stash), **search
110
149
 
111
150
  ## Docs
112
151
 
113
- - [Agentikit CLI](https://github.com/itlackey/agentikit)
152
+ - [AKM CLI](https://github.com/itlackey/akm)
114
153
  - [OpenCode Plugins](https://opencode.ai/docs/plugins/)
115
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
 
@@ -212,7 +480,7 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
212
480
  }
213
481
 
214
482
  type CliError = { ok: false; error: string }
215
- type AssetType = "skill" | "command" | "agent" | "knowledge" | "script"
483
+ type AssetType = "agent" | "command" | "knowledge" | "memory" | "script" | "skill"
216
484
 
217
485
  type ShowAgentResponse = {
218
486
  type: "agent"
@@ -276,7 +544,7 @@ type SearchHit = {
276
544
 
277
545
  type SearchResponse = {
278
546
  hits?: SearchHit[]
279
- source?: "local" | "registry" | "both"
547
+ source?: "local" | "stash" | "registry" | "both"
280
548
  stashDir?: string
281
549
  timing?: { totalMs?: number; rankMs?: number; embedMs?: number }
282
550
  warnings?: string[]
@@ -376,6 +644,59 @@ function extractText(parts: unknown): string {
376
644
  return segments.join("\n\n")
377
645
  }
378
646
 
647
+ function parseToolOutput(raw: string): unknown {
648
+ try {
649
+ return JSON.parse(raw)
650
+ } catch {
651
+ return undefined
652
+ }
653
+ }
654
+
655
+ function extractMemoryRefs(toolName: string, args: Record<string, unknown>, value: unknown): string[] {
656
+ const refs = new Set<string>()
657
+ const parsed = value && typeof value === "object" ? value as {
658
+ type?: unknown
659
+ ref?: unknown
660
+ name?: unknown
661
+ hits?: unknown
662
+ } : undefined
663
+
664
+ if (toolName === "akm_remember" && typeof parsed?.ref === "string" && parsed.ref) {
665
+ refs.add(parsed.ref)
666
+ }
667
+
668
+ if (parsed?.type === "memory") {
669
+ if (typeof parsed.ref === "string" && parsed.ref) refs.add(parsed.ref)
670
+ if (typeof args.ref === "string" && args.ref) refs.add(args.ref)
671
+ if (refs.size === 0 && typeof parsed.name === "string" && parsed.name) refs.add(`memory:${parsed.name}`)
672
+ }
673
+
674
+ if (Array.isArray(parsed?.hits)) {
675
+ for (const hit of parsed.hits) {
676
+ if (!hit || typeof hit !== "object") continue
677
+ if ((hit as { type?: unknown }).type !== "memory") continue
678
+ const ref = (hit as { ref?: unknown }).ref
679
+ if (typeof ref === "string" && ref) refs.add(ref)
680
+ }
681
+ }
682
+
683
+ return [...refs]
684
+ }
685
+
686
+ function classifyToolFeedback(value: unknown): "positive" | "negative" | undefined {
687
+ if (!value || typeof value !== "object") return undefined
688
+ if (isCliError(value)) return "negative"
689
+ if ("ok" in value && (value as { ok?: unknown }).ok === false) return "negative"
690
+ if ("error" in value && typeof (value as { error?: unknown }).error === "string") return "negative"
691
+ if ("ok" in value && (value as { ok?: unknown }).ok === true) return "positive"
692
+ if ("type" in value || "hits" in value || "assetHits" in value || "sources" in value) return "positive"
693
+ return undefined
694
+ }
695
+
696
+ function truncateLogText(value: string, limit = 1_000): string {
697
+ return value.length > limit ? `${value.slice(0, limit)}…` : value
698
+ }
699
+
379
700
  async function resolveRefInput(
380
701
  client: LogCapableClient,
381
702
  input: { ref?: string; query?: string },
@@ -391,7 +712,7 @@ async function resolveRefInput(
391
712
  return { ok: false, error: "Provide either 'ref' or 'query'." }
392
713
  }
393
714
 
394
- const raw = await runCli(client, ["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "local"], meta)
715
+ const raw = await runCli(client, ["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "stash"], meta)
395
716
  const parsed = parseCliJson<SearchResponse>(raw)
396
717
  if (isCliError(parsed)) return parsed
397
718
 
@@ -440,20 +761,24 @@ function renderCommandTemplate(template: string, rawArguments: string): string {
440
761
  .replace(/\$(\d+)/g, (_m, index: string) => args[Number(index) - 1] ?? "")
441
762
  }
442
763
 
764
+ function normalizeSearchSource(source: "local" | "stash" | "registry" | "both"): "stash" | "registry" | "both" {
765
+ return source === "local" ? "stash" : source
766
+ }
767
+
443
768
  function createSearchArgs(input: {
444
769
  query: string
445
770
  type?: AssetType | "any"
446
771
  limit?: number
447
- source?: "local" | "registry" | "both"
448
- defaultSource?: "local" | "registry" | "both"
772
+ source?: "local" | "stash" | "registry" | "both"
773
+ defaultSource?: "local" | "stash" | "registry" | "both"
449
774
  }): string[] {
450
775
  const args = ["search", input.query]
451
776
  if (input.type) args.push("--type", input.type)
452
777
  if (input.limit) args.push("--limit", String(input.limit))
453
778
  if (input.source) {
454
- args.push("--source", input.source)
779
+ args.push("--source", normalizeSearchSource(input.source))
455
780
  } else if (input.defaultSource) {
456
- args.push("--source", input.defaultSource)
781
+ args.push("--source", normalizeSearchSource(input.defaultSource))
457
782
  }
458
783
  args.push("--detail", "normal")
459
784
  return args
@@ -479,24 +804,203 @@ type PluginClient = {
479
804
  }
480
805
  }
481
806
 
482
- export const AgentikitPlugin: Plugin = async ({ client }) => {
807
+ export const AkmPlugin: Plugin = async ({ client }) => {
483
808
  await ensureLatestAkmInstalled(client as unknown as LogCapableClient)
484
809
 
810
+ const logClient = client as unknown as LogCapableClient
811
+
485
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
+ },
899
+ "chat.message": async (input, output) => {
900
+ const text = extractText(output.parts).trim()
901
+ if (!text) return
902
+ await writePluginLog(logClient, "info", "AKM user feedback recorded", {
903
+ subsystem: "feedback",
904
+ actor: "user",
905
+ sessionID: input.sessionID,
906
+ messageID: input.messageID,
907
+ agent: input.agent,
908
+ text: truncateLogText(text),
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
+ }
926
+ },
927
+ "tool.execute.after": async (input, output) => {
928
+ if (!input.tool.startsWith("akm_")) return
929
+
930
+ const parsed = parseToolOutput(output.output)
931
+ if (!parsed) return
932
+
933
+ const feedback = classifyToolFeedback(parsed)
934
+ if (feedback) {
935
+ await writePluginLog(logClient, feedback === "negative" ? "warn" : "info", "AKM system feedback recorded", {
936
+ subsystem: "feedback",
937
+ actor: "system",
938
+ feedback,
939
+ toolName: input.tool,
940
+ sessionID: input.sessionID,
941
+ callID: input.callID,
942
+ title: output.title,
943
+ error: typeof (parsed as { error?: unknown }).error === "string" ? (parsed as { error?: string }).error : undefined,
944
+ })
945
+ }
946
+
947
+ const memoryRefs = extractMemoryRefs(input.tool, input.args as Record<string, unknown>, parsed)
948
+ if (memoryRefs.length > 0) {
949
+ await writePluginLog(logClient, "info", "AKM memory usage recorded", {
950
+ subsystem: "memory",
951
+ toolName: input.tool,
952
+ sessionID: input.sessionID,
953
+ callID: input.callID,
954
+ refs: memoryRefs,
955
+ })
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
+ }
989
+ },
486
990
  tool: {
487
991
  akm_search: tool({
488
- description: "Search your local stash or the akm registry for scripts, skills, commands, agents, and knowledge. Use source='registry' or akm_registry_search for installable community kits.",
992
+ description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, and memories. Use source='registry' or akm_registry_search for installable community kits.",
489
993
  args: {
490
994
  query: tool.schema.string().describe("Case-insensitive substring search."),
491
995
  type: tool.schema
492
- .enum(["skill", "command", "agent", "knowledge", "script", "any"])
996
+ .enum(["agent", "command", "knowledge", "memory", "script", "skill", "any"])
493
997
  .optional()
494
998
  .describe("Optional type filter. Defaults to 'any'."),
495
999
  limit: tool.schema.number().optional().describe("Maximum number of hits to return. Defaults to 20."),
496
1000
  source: tool.schema
497
- .enum(["local", "registry", "both"])
1001
+ .enum(["local", "stash", "registry", "both"])
498
1002
  .optional()
499
- .describe("Search source. 'local' searches stash dirs, 'registry' searches npm/GitHub, 'both' searches all. Defaults to 'local'."),
1003
+ .describe("Search source. 'stash' searches local stash directories, 'registry' searches registries, and 'both' searches all sources. 'local' remains a backward-compatible alias for 'stash'."),
500
1004
  },
501
1005
  async execute({ query, type, limit, source }) {
502
1006
  return runCli(client as unknown as LogCapableClient, createSearchArgs({ query, type, limit, source }), { toolName: "akm_search" })
@@ -507,7 +1011,7 @@ export const AgentikitPlugin: Plugin = async ({ client }) => {
507
1011
  args: {
508
1012
  query: tool.schema.string().describe("Search query for installable registry kits."),
509
1013
  type: tool.schema
510
- .enum(["skill", "command", "agent", "knowledge", "script", "any"])
1014
+ .enum(["agent", "command", "knowledge", "memory", "script", "skill", "any"])
511
1015
  .optional()
512
1016
  .describe("Optional asset type filter. Defaults to 'any'."),
513
1017
  limit: tool.schema.number().optional().describe("Maximum number of registry hits to return. Defaults to 20."),
@@ -582,25 +1086,25 @@ export const AgentikitPlugin: Plugin = async ({ client }) => {
582
1086
  },
583
1087
  }),
584
1088
  akm_list: tool({
585
- description: "List all kits installed from the registry.",
1089
+ description: "List all configured AKM sources, including local directories, managed kits, and remote providers.",
586
1090
  args: {},
587
1091
  async execute() {
588
1092
  return runCli(client as unknown as LogCapableClient, ["list"], { toolName: "akm_list" })
589
1093
  },
590
1094
  }),
591
1095
  akm_remove: tool({
592
- description: "Remove an installed registry kit by id or ref and reindex the stash.",
1096
+ description: "Remove a configured AKM source by id, ref, path, URL, or name and reindex the stash.",
593
1097
  args: {
594
- package_ref: tool.schema.string().describe("Installed kit id or ref, such as npm:@scope/kit or owner/repo."),
1098
+ package_ref: tool.schema.string().describe("Source id, ref, path, URL, or name, such as npm:@scope/kit, owner/repo, or ~/.claude/skills."),
595
1099
  },
596
1100
  async execute({ package_ref }) {
597
1101
  return runCli(client as unknown as LogCapableClient, ["remove", package_ref], { toolName: "akm_remove" })
598
1102
  },
599
1103
  }),
600
1104
  akm_update: tool({
601
- description: "Update one installed kit or all installed kits to the latest available version.",
1105
+ description: "Update one managed AKM source or all managed sources to the latest available version.",
602
1106
  args: {
603
- package_ref: tool.schema.string().optional().describe("Installed kit id or ref to update."),
1107
+ package_ref: tool.schema.string().optional().describe("Managed source id or ref to update."),
604
1108
  all: tool.schema.boolean().optional().describe("Update all installed kits."),
605
1109
  force: tool.schema.boolean().optional().describe("Force a fresh download even if the version is unchanged."),
606
1110
  },
@@ -634,6 +1138,106 @@ export const AgentikitPlugin: Plugin = async ({ client }) => {
634
1138
  return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_clone" })
635
1139
  },
636
1140
  }),
1141
+ akm_remember: tool({
1142
+ description: "Record a memory in the default AKM stash so it can be searched and shown later.",
1143
+ args: {
1144
+ content: tool.schema.string().describe("Memory content to store."),
1145
+ name: tool.schema.string().optional().describe("Optional memory name."),
1146
+ force: tool.schema.boolean().optional().describe("Overwrite an existing memory with the same name."),
1147
+ },
1148
+ async execute({ content, name, force }) {
1149
+ const args = ["remember", content]
1150
+ if (name) args.push("--name", name)
1151
+ if (force) args.push("--force")
1152
+ return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_remember" })
1153
+ },
1154
+ }),
1155
+ akm_feedback: tool({
1156
+ description: "Record positive or negative feedback for a stash asset so AKM can improve future ranking.",
1157
+ args: {
1158
+ ref: tool.schema.string().describe("Asset ref to record feedback for."),
1159
+ sentiment: tool.schema.enum(["positive", "negative"]).describe("Whether the feedback is positive or negative."),
1160
+ note: tool.schema.string().optional().describe("Optional note to attach to the feedback."),
1161
+ },
1162
+ async execute({ ref, sentiment, note }) {
1163
+ const args = ["feedback", ref, sentiment === "positive" ? "--positive" : "--negative"]
1164
+ if (note) args.push("--note", note)
1165
+ return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_feedback" })
1166
+ },
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
+ }),
637
1241
  akm_agent: tool({
638
1242
  description: "Dispatch a stash agent by ref into a child OpenCode session, applying the agent prompt and metadata from akm_show.",
639
1243
  args: {
@@ -871,10 +1475,10 @@ export const AgentikitPlugin: Plugin = async ({ client }) => {
871
1475
  },
872
1476
  }),
873
1477
  akm_sources: tool({
874
- description: "List all resolved stash search paths and their status.",
1478
+ description: "List all configured AKM sources. Kept as a backward-compatible alias for the older sources command.",
875
1479
  args: {},
876
1480
  async execute() {
877
- return runCli(client as unknown as LogCapableClient, ["sources"], { toolName: "akm_sources" })
1481
+ return runCli(client as unknown as LogCapableClient, ["list"], { toolName: "akm_sources" })
878
1482
  },
879
1483
  }),
880
1484
  akm_upgrade: tool({
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "akm-opencode",
3
- "version": "0.2.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"