akm-opencode 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +30 -5
  2. package/index.ts +527 -73
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # akm-opencode
2
2
 
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.
3
+ OpenCode plugin for the [AKM](https://github.com/itlackey/akm) CLI (v0.5.0+). Registers tools that let your AI agent **search**, **show**, and **manage** stash assets — skills, commands, agents, knowledge, memories, scripts, workflows, vaults, and wikis — 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
 
@@ -16,25 +16,30 @@ Add to your OpenCode config (`opencode.json`):
16
16
 
17
17
  | Tool | Description |
18
18
  |------|-------------|
19
- | `akm_search` | Search the local stash, the registry, or both for scripts, skills, commands, agents, and knowledge |
19
+ | `akm_search` | Search the local stash, the registry, or both. Type filter accepts `skill`, `command`, `agent`, `knowledge`, `memory`, `script`, `workflow`, `vault`, `wiki`, `any` |
20
20
  | `akm_registry_search` | Search configured registries for installable kits and optional asset-level hits |
21
21
  | `akm_show` | Show a stash asset by its ref |
22
22
  | `akm_index` | Build or rebuild the search index |
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
- | `akm_add` | Install kits from npm, GitHub, git URLs, or local directories |
25
+ | `akm_add` | Install kits or register external sources from npm, GitHub, git URLs, URLs, or local dirs (use `type: "wiki"` to register a wiki; `writable`, `trust`, `max_pages`, `max_depth`, `provider`, `options` also supported) |
26
26
  | `akm_list` | List configured AKM sources |
27
27
  | `akm_remove` | Remove a configured AKM source and reindex |
28
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
30
  | `akm_remember` | Record a memory in the default stash |
31
- | `akm_feedback` | Record positive or negative feedback for a stash asset |
31
+ | `akm_feedback` | Record positive or negative feedback for a stash asset (skipped automatically for `memory:` and `vault:` refs) |
32
32
  | `akm_config` | Get, set, unset, list, or inspect akm configuration (including `config path --all`) |
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
36
  | `akm_curate` | Curate the stash for a task or topic and return ranked matches the agent can use |
37
37
  | `akm_evolve` | Dispatch the AKM curator agent to review recent session activity and propose stash improvements |
38
+ | `akm_save` | Commit (and push, when writable) pending changes in a git-backed stash |
39
+ | `akm_import` | Import a file (or stdin content) into the stash as a typed asset |
40
+ | `akm_vault` | Manage vaults (`list`, `show`, `create`, `set`, `unset`, `shell_snippet`). **Values never surface** — `show`/`list` return key names only. `shell_snippet` returns opaque `eval` text |
41
+ | `akm_wiki` | Manage wikis (`create`, `register`, `list`, `show`, `pages`, `search`, `stash`, `lint`, `ingest`, `remove`) |
42
+ | `akm_workflow` | Drive workflow runs (`start`, `next`, `complete`, `status`, `list`, `create`, `template`, `resume`) |
38
43
 
39
44
  ## Compound-engineering hooks
40
45
 
@@ -142,9 +147,29 @@ stash/
142
147
  ├── skills/ # skill directories containing SKILL.md
143
148
  ├── commands/ # markdown files
144
149
  ├── agents/ # markdown files
145
- └── knowledge/ # markdown files
150
+ ├── knowledge/ # markdown files
151
+ ├── memories/ # markdown memory files (akm remember)
152
+ ├── workflows/ # multi-step procedures (workflow:<name>)
153
+ ├── vaults/ # .env secret stores (vault:<name>) — values never surface through structured output
154
+ └── wikis/ # per-wiki directories <name>/{schema,index,log}.md + raw/ + pages
146
155
  ```
147
156
 
157
+ ## Vaults
158
+
159
+ `akm_vault` is the one tool in this plugin with a hard contract on output. The
160
+ AKM CLI itself guarantees vault values never appear in JSON, the search index,
161
+ `.stash.json`, or any structured output channel. This plugin mirrors that:
162
+
163
+ - `action: "list"` / `"show"` return key names and comments only.
164
+ - `action: "set"` / `"unset"` never echo the value.
165
+ - `action: "shell_snippet"` wraps `akm vault load` and returns the raw shell
166
+ text as-is. Treat it as opaque and hand it straight to a shell via
167
+ `eval "$(…)"` — do not log it, do not pass it through another tool, and do
168
+ not let the agent inspect it.
169
+
170
+ Automatic feedback recording (`tool.execute.after`) skips `vault:*` refs so
171
+ that usage signals can't leak which vault was touched.
172
+
148
173
  Assets are resolved from three source types: **working** (local stash), **search paths** (additional dirs via `searchPaths` config), and **installed** (registry kits via `akm add`).
149
174
 
150
175
  ## Docs
package/index.ts CHANGED
@@ -29,7 +29,7 @@ const sessionBuffer = new Map<string, SessionBufferEntry[]>()
29
29
  const sessionMemoryCaptured = new Set<string>()
30
30
 
31
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
32
+ const AKM_REF_PATTERN = /(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|vault|wiki):[A-Za-z0-9._/\-]+/g
33
33
 
34
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
35
 
@@ -41,9 +41,12 @@ Inputs you should inspect:
41
41
  Signals to act on:
42
42
  - Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
43
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.
44
+ - Missing coverage: recurring user prompts with no matching asset. Draft a new skill, command, knowledge doc, wiki page, or workflow in the working stash and reindex with akm_index.
45
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.
46
+ - Stale memories: session summaries that never get recalled. Propose akm_remove memory:<name> once distilled into a durable knowledge doc or wiki page.
47
+ - Wiki hygiene: for each wiki returned by akm_wiki list, run akm_wiki lint <name> and report orphans, broken xrefs, uncited raws, and stale indexes as fix candidates.
48
+ - Stuck workflows: run akm_workflow list --active and surface any runs in blocked or failed state with their step ids. Propose whether to resume or escalate.
49
+ - Never touch vaults: do not call akm_vault show or shell_snippet unless the user explicitly asks. Vault values must never appear in reports.
47
50
 
48
51
  Rules of engagement:
49
52
  - Never apply destructive changes without explicit user approval.
@@ -66,6 +69,12 @@ Output shape: end every run with a markdown report that has these sections:
66
69
  ## Duplicates / drift
67
70
  - <ref a> vs <ref b> — consolidation proposal
68
71
 
72
+ ## Wiki health
73
+ - <wiki> — lint findings (orphan, broken-xref, uncited-raw, stale-index) with suggested fix
74
+
75
+ ## Workflow health
76
+ - <workflow|runId> — blocked/failed state — resume or escalate
77
+
69
78
  ## Housekeeping
70
79
  - stale memories, reindex needs, config tweaks
71
80
  `
@@ -92,6 +101,14 @@ type CliLogMeta = {
92
101
  sessionID?: string
93
102
  }
94
103
 
104
+ type SessionPromptBody = {
105
+ agent: string
106
+ parts: Array<{ type: "text"; text: string }>
107
+ system?: string
108
+ model?: { providerID: string; modelID: string }
109
+ tools?: Record<string, boolean>
110
+ }
111
+
95
112
  function formatCliError(error: unknown): string {
96
113
  if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") {
97
114
  return "The 'akm' CLI was not found on PATH. Install it first from https://github.com/itlackey/akm."
@@ -480,7 +497,29 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
480
497
  }
481
498
 
482
499
  type CliError = { ok: false; error: string }
483
- type AssetType = "agent" | "command" | "knowledge" | "memory" | "script" | "skill"
500
+ type AssetType =
501
+ | "agent"
502
+ | "command"
503
+ | "knowledge"
504
+ | "memory"
505
+ | "script"
506
+ | "skill"
507
+ | "workflow"
508
+ | "vault"
509
+ | "wiki"
510
+
511
+ const ASSET_TYPES = [
512
+ "agent",
513
+ "command",
514
+ "knowledge",
515
+ "memory",
516
+ "script",
517
+ "skill",
518
+ "workflow",
519
+ "vault",
520
+ "wiki",
521
+ "any",
522
+ ] as const
484
523
 
485
524
  type ShowAgentResponse = {
486
525
  type: "agent"
@@ -729,18 +768,111 @@ async function ensureTargetSessionID(input: {
729
768
  context: { sessionID: string; directory: string }
730
769
  title: string
731
770
  client: PluginClient
771
+ logClient: LogCapableClient
772
+ toolName: string
732
773
  }): Promise<{ ok: true; sessionID: string } | CliError> {
733
774
  if (!input.useSubtask) return { ok: true, sessionID: input.context.sessionID }
734
775
 
735
- const created = await input.client.session.create({
736
- query: { directory: input.context.directory },
737
- body: { parentID: input.context.sessionID, title: input.title },
738
- })
739
- if (created.error || !created.data?.id) {
740
- const reason = created.error ? JSON.stringify(created.error) : "missing child session id"
776
+ try {
777
+ const created = await input.client.session.create({
778
+ body: { parentID: input.context.sessionID, title: input.title },
779
+ })
780
+ if (created.error || !created.data?.id) {
781
+ const reason = created.error ? JSON.stringify(created.error) : "missing child session id"
782
+ await writePluginLog(input.logClient, "error", "AKM dispatch child session failed", {
783
+ subsystem: "dispatch",
784
+ toolName: input.toolName,
785
+ sessionID: input.context.sessionID,
786
+ directory: input.context.directory,
787
+ title: input.title,
788
+ error: reason,
789
+ })
790
+ return { ok: false, error: `Failed to create child session: ${reason}` }
791
+ }
792
+ await writePluginLog(input.logClient, "info", "AKM dispatch child session created", {
793
+ subsystem: "dispatch",
794
+ toolName: input.toolName,
795
+ sessionID: input.context.sessionID,
796
+ directory: input.context.directory,
797
+ childSessionID: created.data.id,
798
+ title: input.title,
799
+ })
800
+ return { ok: true, sessionID: created.data.id }
801
+ } catch (error: unknown) {
802
+ const reason = error instanceof Error ? error.message : String(error)
803
+ await writePluginLog(input.logClient, "error", "AKM dispatch child session threw", {
804
+ subsystem: "dispatch",
805
+ toolName: input.toolName,
806
+ sessionID: input.context.sessionID,
807
+ directory: input.context.directory,
808
+ title: input.title,
809
+ error: reason,
810
+ })
741
811
  return { ok: false, error: `Failed to create child session: ${reason}` }
742
812
  }
743
- return { ok: true, sessionID: created.data.id }
813
+ }
814
+
815
+ async function promptTargetSession(input: {
816
+ client: PluginClient
817
+ logClient: LogCapableClient
818
+ toolName: string
819
+ context: { sessionID: string; directory: string }
820
+ targetSessionID: string
821
+ promptBody: SessionPromptBody
822
+ failureMessage: string
823
+ ref?: string
824
+ }): Promise<{ ok: true; data: { parts?: unknown } } | CliError> {
825
+ try {
826
+ const promptResponse = await input.client.session.prompt({
827
+ path: { id: input.targetSessionID },
828
+ body: input.promptBody,
829
+ })
830
+
831
+ if (promptResponse.error || !promptResponse.data) {
832
+ const reason = promptResponse.error ? JSON.stringify(promptResponse.error) : "empty response"
833
+ await writePluginLog(input.logClient, "error", "AKM dispatch prompt failed", {
834
+ subsystem: "dispatch",
835
+ toolName: input.toolName,
836
+ sessionID: input.context.sessionID,
837
+ directory: input.context.directory,
838
+ targetSessionID: input.targetSessionID,
839
+ dispatchAgent: input.promptBody.agent,
840
+ ref: input.ref,
841
+ error: reason,
842
+ })
843
+ return {
844
+ ok: false,
845
+ error: `${input.failureMessage}: ${reason}`,
846
+ }
847
+ }
848
+
849
+ await writePluginLog(input.logClient, "info", "AKM dispatch prompt completed", {
850
+ subsystem: "dispatch",
851
+ toolName: input.toolName,
852
+ sessionID: input.context.sessionID,
853
+ directory: input.context.directory,
854
+ targetSessionID: input.targetSessionID,
855
+ dispatchAgent: input.promptBody.agent,
856
+ ref: input.ref,
857
+ })
858
+ return { ok: true, data: promptResponse.data }
859
+ } catch (error: unknown) {
860
+ const reason = error instanceof Error ? error.message : String(error)
861
+ await writePluginLog(input.logClient, "error", "AKM dispatch prompt threw", {
862
+ subsystem: "dispatch",
863
+ toolName: input.toolName,
864
+ sessionID: input.context.sessionID,
865
+ directory: input.context.directory,
866
+ targetSessionID: input.targetSessionID,
867
+ dispatchAgent: input.promptBody.agent,
868
+ ref: input.ref,
869
+ error: reason,
870
+ })
871
+ return {
872
+ ok: false,
873
+ error: `${input.failureMessage}: ${reason}`,
874
+ }
875
+ }
744
876
  }
745
877
 
746
878
  function splitArguments(raw: string): string[] {
@@ -767,7 +899,7 @@ function normalizeSearchSource(source: "local" | "stash" | "registry" | "both"):
767
899
 
768
900
  function createSearchArgs(input: {
769
901
  query: string
770
- type?: AssetType | "any"
902
+ type?: AssetType | "any" | string
771
903
  limit?: number
772
904
  source?: "local" | "stash" | "registry" | "both"
773
905
  defaultSource?: "local" | "stash" | "registry" | "both"
@@ -787,19 +919,11 @@ function createSearchArgs(input: {
787
919
  type PluginClient = {
788
920
  session: {
789
921
  create: (input: {
790
- query: { directory: string }
791
922
  body: { parentID: string; title: string }
792
923
  }) => Promise<{ data?: { id?: string }; error?: unknown }>
793
924
  prompt: (input: {
794
- query: { directory: string }
795
925
  path: { id: string }
796
- body: {
797
- agent: string
798
- parts: Array<{ type: "text"; text: string }>
799
- system?: string
800
- model?: { providerID: string; modelID: string }
801
- tools?: Record<string, boolean>
802
- }
926
+ body: SessionPromptBody
803
927
  }) => Promise<{ data?: { parts?: unknown }; error?: unknown }>
804
928
  }
805
929
  }
@@ -980,8 +1104,10 @@ export const AkmPlugin: Plugin = async ({ client }) => {
980
1104
  ? `opencode auto: ${input.tool} succeeded`
981
1105
  : `opencode auto: ${input.tool} failed`
982
1106
  for (const ref of allRefs) {
983
- // Memories do not accept feedback in the current CLI.
984
- if (ref.startsWith("memory:")) continue
1107
+ // Memories and vault refs are not first-class feedback targets —
1108
+ // memories do not accept feedback, and vault values never surface in
1109
+ // JSON so automatic usage signals would be misleading.
1110
+ if (ref.startsWith("memory:") || ref.startsWith("vault:")) continue
985
1111
  const ok = recordFeedbackSync(ref, feedback, note)
986
1112
  if (!ok) break
987
1113
  }
@@ -989,11 +1115,11 @@ export const AkmPlugin: Plugin = async ({ client }) => {
989
1115
  },
990
1116
  tool: {
991
1117
  akm_search: tool({
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.",
1118
+ description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, memories, workflows, vaults, and wikis. Use source='registry' or akm_registry_search for installable community kits.",
993
1119
  args: {
994
1120
  query: tool.schema.string().describe("Case-insensitive substring search."),
995
1121
  type: tool.schema
996
- .enum(["agent", "command", "knowledge", "memory", "script", "skill", "any"])
1122
+ .enum(ASSET_TYPES as unknown as [string, ...string[]])
997
1123
  .optional()
998
1124
  .describe("Optional type filter. Defaults to 'any'."),
999
1125
  limit: tool.schema.number().optional().describe("Maximum number of hits to return. Defaults to 20."),
@@ -1011,7 +1137,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1011
1137
  args: {
1012
1138
  query: tool.schema.string().describe("Search query for installable registry kits."),
1013
1139
  type: tool.schema
1014
- .enum(["agent", "command", "knowledge", "memory", "script", "skill", "any"])
1140
+ .enum(ASSET_TYPES as unknown as [string, ...string[]])
1015
1141
  .optional()
1016
1142
  .describe("Optional asset type filter. Defaults to 'any'."),
1017
1143
  limit: tool.schema.number().optional().describe("Maximum number of registry hits to return. Defaults to 20."),
@@ -1077,12 +1203,29 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1077
1203
  },
1078
1204
  }),
1079
1205
  akm_add: tool({
1080
- description: "Install a kit from npm, GitHub, another git host, or a local directory. Installed kits become searchable alongside local assets.",
1206
+ description: "Install a kit or register an external source from npm, GitHub, another git host, a URL, or a local directory. Use type='wiki' to register a wiki source instead of a stash kit.",
1081
1207
  args: {
1082
- package_ref: tool.schema.string().describe("Package reference such as npm:@scope/kit, github:<owner>/<repo>, git+https://host/repo, or ./local/kit."),
1208
+ package_ref: tool.schema.string().describe("Package reference such as npm:@scope/kit, github:<owner>/<repo>, git+https://host/repo, https://url, or ./local/kit."),
1209
+ type: tool.schema.enum(["wiki"]).optional().describe("Route the add through a typed registrar. 'wiki' registers an external wiki source."),
1210
+ name: tool.schema.string().optional().describe("Optional name to register the source under."),
1211
+ writable: tool.schema.boolean().optional().describe("Mark a git-backed source as push-writable (used by akm save)."),
1212
+ trust: tool.schema.boolean().optional().describe("Bypass install-audit blocking for this registration only."),
1213
+ provider: tool.schema.string().optional().describe("Provider hint (required for raw URL refs, e.g. 'github', 'website')."),
1214
+ options: tool.schema.string().optional().describe("JSON string of provider-specific options."),
1215
+ max_pages: tool.schema.number().optional().describe("Cap for website crawlers (default 50)."),
1216
+ max_depth: tool.schema.number().optional().describe("Depth cap for website crawlers (default 3)."),
1083
1217
  },
1084
- async execute({ package_ref }) {
1085
- return runCli(client as unknown as LogCapableClient, ["add", package_ref], { toolName: "akm_add" })
1218
+ async execute({ package_ref, type, name, writable, trust, provider, options, max_pages, max_depth }) {
1219
+ const args = ["add", package_ref]
1220
+ if (type) args.push("--type", type)
1221
+ if (name) args.push("--name", name)
1222
+ if (writable) args.push("--writable")
1223
+ if (trust) args.push("--trust")
1224
+ if (provider) args.push("--provider", provider)
1225
+ if (options) args.push("--options", options)
1226
+ if (max_pages != null) args.push("--max-pages", String(max_pages))
1227
+ if (max_depth != null) args.push("--max-depth", String(max_depth))
1228
+ return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_add" })
1086
1229
  },
1087
1230
  }),
1088
1231
  akm_list: tool({
@@ -1203,6 +1346,8 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1203
1346
  context: { sessionID: context.sessionID, directory: context.directory },
1204
1347
  title: "akm:curator",
1205
1348
  client: client as unknown as PluginClient,
1349
+ logClient,
1350
+ toolName: "akm_evolve",
1206
1351
  })
1207
1352
  if (!targetSession.ok) return JSON.stringify(targetSession)
1208
1353
 
@@ -1210,23 +1355,20 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1210
1355
  ? `Review recent AKM activity with an emphasis on: ${focus.trim()}. Produce the prioritized action list described in the system prompt.`
1211
1356
  : "Review recent AKM activity and produce the prioritized action list described in the system prompt."
1212
1357
 
1213
- const promptResponse = await client.session.prompt({
1214
- query: { directory: context.directory },
1215
- path: { id: targetSession.sessionID },
1216
- body: {
1358
+ const promptResponse = await promptTargetSession({
1359
+ client: client as unknown as PluginClient,
1360
+ logClient,
1361
+ toolName: "akm_evolve",
1362
+ context: { sessionID: context.sessionID, directory: context.directory },
1363
+ targetSessionID: targetSession.sessionID,
1364
+ failureMessage: "Failed to dispatch curator",
1365
+ promptBody: {
1217
1366
  agent: targetAgent,
1218
1367
  system: CURATOR_AGENT_PROMPT,
1219
1368
  parts: [{ type: "text", text: task }],
1220
1369
  },
1221
1370
  })
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
- }
1371
+ if (!promptResponse.ok) return JSON.stringify(promptResponse)
1230
1372
 
1231
1373
  return JSON.stringify({
1232
1374
  ok: true,
@@ -1286,16 +1428,12 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1286
1428
  context: { sessionID: context.sessionID, directory: context.directory },
1287
1429
  title: `akm:${shown.name}`,
1288
1430
  client: client as unknown as PluginClient,
1431
+ logClient,
1432
+ toolName: "akm_agent",
1289
1433
  })
1290
1434
  if (!targetSession.ok) return JSON.stringify(targetSession)
1291
1435
 
1292
- const promptBody: {
1293
- agent: string
1294
- system: string
1295
- parts: Array<{ type: "text"; text: string }>
1296
- model?: { providerID: string; modelID: string }
1297
- tools?: Record<string, boolean>
1298
- } = {
1436
+ const promptBody: SessionPromptBody = {
1299
1437
  agent: targetAgent,
1300
1438
  system: shown.prompt,
1301
1439
  parts: [{ type: "text", text: task_prompt }],
@@ -1303,19 +1441,17 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1303
1441
  if (model) promptBody.model = model
1304
1442
  if (tools) promptBody.tools = tools
1305
1443
 
1306
- const promptResponse = await client.session.prompt({
1307
- query: { directory: context.directory },
1308
- path: { id: targetSession.sessionID },
1309
- body: promptBody,
1444
+ const promptResponse = await promptTargetSession({
1445
+ client: client as unknown as PluginClient,
1446
+ logClient,
1447
+ toolName: "akm_agent",
1448
+ context: { sessionID: context.sessionID, directory: context.directory },
1449
+ targetSessionID: targetSession.sessionID,
1450
+ promptBody,
1451
+ failureMessage: `Failed to dispatch prompt for ${resolved.ref}`,
1452
+ ref: resolved.ref,
1310
1453
  })
1311
-
1312
- if (promptResponse.error || !promptResponse.data) {
1313
- const reason = promptResponse.error ? JSON.stringify(promptResponse.error) : "empty response"
1314
- return JSON.stringify({
1315
- ok: false,
1316
- error: `Failed to dispatch prompt for ${resolved.ref}: ${reason}`,
1317
- })
1318
- }
1454
+ if (!promptResponse.ok) return JSON.stringify(promptResponse)
1319
1455
 
1320
1456
  return JSON.stringify({
1321
1457
  ok: true,
@@ -1370,25 +1506,25 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1370
1506
  context: { sessionID: context.sessionID, directory: context.directory },
1371
1507
  title: `akm:cmd:${shown.name}`,
1372
1508
  client: client as unknown as PluginClient,
1509
+ logClient,
1510
+ toolName: "akm_cmd",
1373
1511
  })
1374
1512
  if (!targetSession.ok) return JSON.stringify(targetSession)
1375
1513
 
1376
- const promptResponse = await client.session.prompt({
1377
- query: { directory: context.directory },
1378
- path: { id: targetSession.sessionID },
1379
- body: {
1514
+ const promptResponse = await promptTargetSession({
1515
+ client: client as unknown as PluginClient,
1516
+ logClient,
1517
+ toolName: "akm_cmd",
1518
+ context: { sessionID: context.sessionID, directory: context.directory },
1519
+ targetSessionID: targetSession.sessionID,
1520
+ failureMessage: `Failed to execute command ${resolved.ref}`,
1521
+ ref: resolved.ref,
1522
+ promptBody: {
1380
1523
  agent: targetAgent,
1381
1524
  parts: [{ type: "text", text: rendered }],
1382
1525
  },
1383
1526
  })
1384
-
1385
- if (promptResponse.error || !promptResponse.data) {
1386
- const reason = promptResponse.error ? JSON.stringify(promptResponse.error) : "empty response"
1387
- return JSON.stringify({
1388
- ok: false,
1389
- error: `Failed to execute command ${resolved.ref}: ${reason}`,
1390
- })
1391
- }
1527
+ if (!promptResponse.ok) return JSON.stringify(promptResponse)
1392
1528
 
1393
1529
  return JSON.stringify({
1394
1530
  ok: true,
@@ -1481,6 +1617,324 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1481
1617
  return runCli(client as unknown as LogCapableClient, ["list"], { toolName: "akm_sources" })
1482
1618
  },
1483
1619
  }),
1620
+ akm_save: tool({
1621
+ description: "Commit (and push, when writable) pending changes in a git-backed stash. No-op for non-git stashes.",
1622
+ args: {
1623
+ name: tool.schema.string().optional().describe("Optional stash source name. Defaults to the primary stash."),
1624
+ message: tool.schema.string().optional().describe("Commit message. Defaults to an auto-generated summary."),
1625
+ },
1626
+ async execute({ name, message }) {
1627
+ const args = ["save"]
1628
+ if (name) args.push(name)
1629
+ if (message) args.push("-m", message)
1630
+ return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_save" })
1631
+ },
1632
+ }),
1633
+ akm_import: tool({
1634
+ description: "Import a file (or stdin) into the stash as a typed asset. Pass '-' as source to read from a string via content.",
1635
+ args: {
1636
+ source: tool.schema.string().describe("Path to the source file, or '-' to read from stdin (use 'content' to provide it)."),
1637
+ name: tool.schema.string().optional().describe("Optional asset name override."),
1638
+ force: tool.schema.boolean().optional().describe("Overwrite an existing asset with the same name."),
1639
+ content: tool.schema.string().optional().describe("Raw content to feed on stdin when source is '-'."),
1640
+ },
1641
+ async execute({ source, name, force, content }) {
1642
+ const args = ["import", source]
1643
+ if (name) args.push("--name", name)
1644
+ if (force) args.push("--force")
1645
+ const command = resolveAkmCommand()
1646
+ if (typeof command !== "string") return JSON.stringify(command)
1647
+ if (source === "-" && content) {
1648
+ try {
1649
+ const stdout = execFileSync(command, [...args, "--format", "json"], {
1650
+ encoding: "utf8",
1651
+ timeout: 60_000,
1652
+ input: content,
1653
+ })
1654
+ return stdout
1655
+ } catch (error: unknown) {
1656
+ return JSON.stringify({ ok: false, error: formatCliError(error) })
1657
+ }
1658
+ }
1659
+ return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_import" })
1660
+ },
1661
+ }),
1662
+ akm_vault: tool({
1663
+ description: "Manage encrypted-at-rest vaults of KEY=VALUE pairs. Values never surface in any output channel — 'show'/'list' return key names only, 'set'/'unset' never echo the value. Use 'shell_snippet' to get a shell-eval snippet that loads values into the process.",
1664
+ args: {
1665
+ action: tool.schema.enum(["list", "show", "create", "set", "unset", "shell_snippet"]).describe("Vault subcommand. 'shell_snippet' wraps 'vault load' — treat its output as opaque shell text meant for eval."),
1666
+ ref: tool.schema.string().optional().describe("Vault ref such as vault:prod or vault:team/prod. Required for show/set/unset/shell_snippet; optional for list."),
1667
+ name: tool.schema.string().optional().describe("Vault name when action is 'create' (e.g. 'prod' → vaults/prod.env)."),
1668
+ key: tool.schema.string().optional().describe("Variable name for set/unset. May include '=' to pass KEY=VALUE in one field when value is omitted."),
1669
+ value: tool.schema.string().optional().describe("Value to store. Never echoed back."),
1670
+ comment: tool.schema.string().optional().describe("Optional inline '# comment' written above the key for 'set'."),
1671
+ },
1672
+ async execute({ action, ref, name, key, value, comment }) {
1673
+ const logMeta = { toolName: "akm_vault" }
1674
+ switch (action) {
1675
+ case "list": {
1676
+ const args = ["vault", "list"]
1677
+ if (ref) args.push(ref)
1678
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1679
+ }
1680
+ case "show": {
1681
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='show'." })
1682
+ return runCli(client as unknown as LogCapableClient, ["vault", "show", ref], logMeta)
1683
+ }
1684
+ case "create": {
1685
+ if (!name) return JSON.stringify({ ok: false, error: "'name' is required for action='create'." })
1686
+ return runCli(client as unknown as LogCapableClient, ["vault", "create", name], logMeta)
1687
+ }
1688
+ case "set": {
1689
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='set'." })
1690
+ if (!key) return JSON.stringify({ ok: false, error: "'key' is required for action='set'." })
1691
+ const args = ["vault", "set", ref, key]
1692
+ if (value != null) args.push(value)
1693
+ if (comment) args.push("--comment", comment)
1694
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1695
+ }
1696
+ case "unset": {
1697
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='unset'." })
1698
+ if (!key) return JSON.stringify({ ok: false, error: "'key' is required for action='unset'." })
1699
+ return runCli(client as unknown as LogCapableClient, ["vault", "unset", ref, key], logMeta)
1700
+ }
1701
+ case "shell_snippet": {
1702
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='shell_snippet'." })
1703
+ // `vault load` emits raw shell — not JSON. Return the snippet verbatim
1704
+ // so the caller can hand it to a shell via eval. Never parse values.
1705
+ const command = resolveAkmCommand()
1706
+ if (typeof command !== "string") return JSON.stringify(command)
1707
+ try {
1708
+ const stdout = execFileSync(command, ["vault", "load", ref], {
1709
+ encoding: "utf8",
1710
+ timeout: 30_000,
1711
+ })
1712
+ return JSON.stringify({ ok: true, ref, shell: stdout.trim() })
1713
+ } catch (error: unknown) {
1714
+ return JSON.stringify({ ok: false, error: formatCliError(error) })
1715
+ }
1716
+ }
1717
+ }
1718
+ },
1719
+ }),
1720
+ akm_wiki: tool({
1721
+ description: "Manage AKM wikis — multi-wiki knowledge bases under <stashDir>/wikis/<name>/. Supports scaffolding, registering external sources, listing pages, scoped search, stashing raw sources, lint, and ingest workflow.",
1722
+ args: {
1723
+ action: tool.schema.enum([
1724
+ "create",
1725
+ "register",
1726
+ "list",
1727
+ "show",
1728
+ "remove",
1729
+ "pages",
1730
+ "search",
1731
+ "stash",
1732
+ "lint",
1733
+ "ingest",
1734
+ ]).describe("Wiki subcommand."),
1735
+ name: tool.schema.string().optional().describe("Wiki name (required for every action except 'list')."),
1736
+ source_ref: tool.schema.string().optional().describe("Source ref to register (required for action='register'). Accepts directory paths, git URLs, github owner/repo, or https:// website roots."),
1737
+ writable: tool.schema.boolean().optional().describe("When registering a git-backed source, mark it as push-writable (used by akm_save)."),
1738
+ trust: tool.schema.boolean().optional().describe("Bypass install-audit blocking for this registration only."),
1739
+ max_pages: tool.schema.number().optional().describe("Crawler page cap when registering a website (default 50)."),
1740
+ max_depth: tool.schema.number().optional().describe("Crawler depth cap when registering a website (default 3)."),
1741
+ query: tool.schema.string().optional().describe("Query string for action='search'."),
1742
+ limit: tool.schema.number().optional().describe("Result cap for action='search'."),
1743
+ source: tool.schema.string().optional().describe("Source path (or '-' for stdin) for action='stash'."),
1744
+ as_slug: tool.schema.string().optional().describe("Explicit slug for action='stash' (defaults to derived from source)."),
1745
+ content: tool.schema.string().optional().describe("Raw content to feed stdin when stashing with source='-'."),
1746
+ force: tool.schema.boolean().optional().describe("Required for action='remove'."),
1747
+ with_sources: tool.schema.boolean().optional().describe("When removing, also delete the raw/ sources (default false)."),
1748
+ },
1749
+ async execute({ action, name, source_ref, writable, trust, max_pages, max_depth, query, limit, source, as_slug, content, force, with_sources }) {
1750
+ const logMeta = { toolName: "akm_wiki" }
1751
+ const requireName = () => {
1752
+ if (!name) return JSON.stringify({ ok: false, error: `'name' is required for action='${action}'.` })
1753
+ return null
1754
+ }
1755
+ switch (action) {
1756
+ case "list":
1757
+ return runCli(client as unknown as LogCapableClient, ["wiki", "list"], logMeta)
1758
+ case "create": {
1759
+ const err = requireName(); if (err) return err
1760
+ return runCli(client as unknown as LogCapableClient, ["wiki", "create", name!], logMeta)
1761
+ }
1762
+ case "show": {
1763
+ const err = requireName(); if (err) return err
1764
+ return runCli(client as unknown as LogCapableClient, ["wiki", "show", name!], logMeta)
1765
+ }
1766
+ case "pages": {
1767
+ const err = requireName(); if (err) return err
1768
+ return runCli(client as unknown as LogCapableClient, ["wiki", "pages", name!], logMeta)
1769
+ }
1770
+ case "ingest": {
1771
+ const err = requireName(); if (err) return err
1772
+ return runCli(client as unknown as LogCapableClient, ["wiki", "ingest", name!], logMeta)
1773
+ }
1774
+ case "lint": {
1775
+ const err = requireName(); if (err) return err
1776
+ // `wiki lint` exits 1 when findings exist, which runCli surfaces as
1777
+ // an error envelope. That is still useful output — the JSON body is
1778
+ // the lint report. Pass through either way.
1779
+ return runCli(client as unknown as LogCapableClient, ["wiki", "lint", name!], logMeta)
1780
+ }
1781
+ case "register": {
1782
+ const err = requireName(); if (err) return err
1783
+ if (!source_ref) return JSON.stringify({ ok: false, error: "'source_ref' is required for action='register'." })
1784
+ const args = ["wiki", "register", name!, source_ref]
1785
+ if (writable) args.push("--writable")
1786
+ if (trust) args.push("--trust")
1787
+ if (max_pages != null) args.push("--max-pages", String(max_pages))
1788
+ if (max_depth != null) args.push("--max-depth", String(max_depth))
1789
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1790
+ }
1791
+ case "remove": {
1792
+ const err = requireName(); if (err) return err
1793
+ if (!force) return JSON.stringify({ ok: false, error: "'force' must be true to remove a wiki." })
1794
+ const args = ["wiki", "remove", name!, "--force"]
1795
+ if (with_sources) args.push("--with-sources")
1796
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1797
+ }
1798
+ case "search": {
1799
+ const err = requireName(); if (err) return err
1800
+ if (!query) return JSON.stringify({ ok: false, error: "'query' is required for action='search'." })
1801
+ const args = ["wiki", "search", name!, query]
1802
+ if (limit != null) args.push("--limit", String(limit))
1803
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1804
+ }
1805
+ case "stash": {
1806
+ const err = requireName(); if (err) return err
1807
+ if (!source) return JSON.stringify({ ok: false, error: "'source' is required for action='stash'." })
1808
+ const args = ["wiki", "stash", name!, source]
1809
+ if (as_slug) args.push("--as", as_slug)
1810
+ if (source === "-" && content) {
1811
+ const command = resolveAkmCommand()
1812
+ if (typeof command !== "string") return JSON.stringify(command)
1813
+ try {
1814
+ const stdout = execFileSync(command, [...args, "--format", "json"], {
1815
+ encoding: "utf8",
1816
+ timeout: 60_000,
1817
+ input: content,
1818
+ })
1819
+ return stdout
1820
+ } catch (error: unknown) {
1821
+ return JSON.stringify({ ok: false, error: formatCliError(error) })
1822
+ }
1823
+ }
1824
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1825
+ }
1826
+ }
1827
+ },
1828
+ }),
1829
+ akm_workflow: tool({
1830
+ description: "Manage AKM workflow runs — stateful multi-step procedures defined as workflow:<name> assets. Use start/next/complete/resume to drive a run, status/list to inspect, create/template to author.",
1831
+ args: {
1832
+ action: tool.schema.enum([
1833
+ "start",
1834
+ "next",
1835
+ "complete",
1836
+ "status",
1837
+ "list",
1838
+ "create",
1839
+ "template",
1840
+ "resume",
1841
+ ]).describe("Workflow subcommand."),
1842
+ ref: tool.schema.string().optional().describe("Workflow ref (e.g. workflow:release). Required for start; accepted by next/status as a target."),
1843
+ target: tool.schema.string().optional().describe("Run id or workflow ref for next/status. When a workflow ref is passed to 'next', a new run is auto-started."),
1844
+ run_id: tool.schema.string().optional().describe("Workflow run id. Required for complete and resume."),
1845
+ params: tool.schema.string().optional().describe("JSON object string of parameters for start/next."),
1846
+ step: tool.schema.string().optional().describe("Step id to transition (required for action='complete')."),
1847
+ state: tool.schema.enum(["completed", "blocked", "failed", "skipped"]).optional().describe("Step state for 'complete'. Defaults to 'completed'."),
1848
+ notes: tool.schema.string().optional().describe("Freeform notes attached to the step transition."),
1849
+ evidence: tool.schema.string().optional().describe("JSON object string of evidence attached to the step transition."),
1850
+ name: tool.schema.string().optional().describe("Workflow name for action='create'."),
1851
+ from: tool.schema.string().optional().describe("Path to a markdown template for action='create'."),
1852
+ force: tool.schema.boolean().optional().describe("Overwrite an existing workflow on create (requires --from or --reset)."),
1853
+ reset: tool.schema.boolean().optional().describe("Reset to the built-in template for action='create'."),
1854
+ filter_ref: tool.schema.string().optional().describe("Restrict action='list' to runs of this workflow ref."),
1855
+ active_only: tool.schema.boolean().optional().describe("Restrict action='list' to active (non-terminal) runs."),
1856
+ },
1857
+ async execute({
1858
+ action,
1859
+ ref,
1860
+ target,
1861
+ run_id,
1862
+ params,
1863
+ step,
1864
+ state,
1865
+ notes,
1866
+ evidence,
1867
+ name,
1868
+ from,
1869
+ force,
1870
+ reset,
1871
+ filter_ref,
1872
+ active_only,
1873
+ }) {
1874
+ const logMeta = { toolName: "akm_workflow" }
1875
+ switch (action) {
1876
+ case "start": {
1877
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='start'." })
1878
+ const args = ["workflow", "start", ref]
1879
+ if (params) args.push("--params", params)
1880
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1881
+ }
1882
+ case "next": {
1883
+ const picked = target ?? run_id ?? ref
1884
+ if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='next'." })
1885
+ const args = ["workflow", "next", picked]
1886
+ if (params) args.push("--params", params)
1887
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1888
+ }
1889
+ case "complete": {
1890
+ if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='complete'." })
1891
+ if (!step) return JSON.stringify({ ok: false, error: "'step' is required for action='complete'." })
1892
+ const args = ["workflow", "complete", run_id, "--step", step]
1893
+ if (state) args.push("--state", state)
1894
+ if (notes) args.push("--notes", notes)
1895
+ if (evidence) args.push("--evidence", evidence)
1896
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1897
+ }
1898
+ case "status": {
1899
+ const picked = target ?? run_id ?? ref
1900
+ if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='status'." })
1901
+ return runCli(client as unknown as LogCapableClient, ["workflow", "status", picked], logMeta)
1902
+ }
1903
+ case "list": {
1904
+ const args = ["workflow", "list"]
1905
+ if (filter_ref) args.push("--ref", filter_ref)
1906
+ if (active_only) args.push("--active")
1907
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1908
+ }
1909
+ case "create": {
1910
+ if (!name) return JSON.stringify({ ok: false, error: "'name' is required for action='create'." })
1911
+ const args = ["workflow", "create", name]
1912
+ if (from) args.push("--from", from)
1913
+ if (force) args.push("--force")
1914
+ if (reset) args.push("--reset")
1915
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
1916
+ }
1917
+ case "template": {
1918
+ // The workflow template is emitted as raw markdown, not JSON.
1919
+ const command = resolveAkmCommand()
1920
+ if (typeof command !== "string") return JSON.stringify(command)
1921
+ try {
1922
+ const stdout = execFileSync(command, ["workflow", "template"], {
1923
+ encoding: "utf8",
1924
+ timeout: 30_000,
1925
+ })
1926
+ return JSON.stringify({ ok: true, template: stdout })
1927
+ } catch (error: unknown) {
1928
+ return JSON.stringify({ ok: false, error: formatCliError(error) })
1929
+ }
1930
+ }
1931
+ case "resume": {
1932
+ if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='resume'." })
1933
+ return runCli(client as unknown as LogCapableClient, ["workflow", "resume", run_id], logMeta)
1934
+ }
1935
+ }
1936
+ },
1937
+ }),
1484
1938
  akm_upgrade: tool({
1485
1939
  description: "Check for or install akm CLI updates.",
1486
1940
  args: {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "akm-opencode",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "type": "module",
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.",
5
+ "description": "OpenCode plugin for AKM - search, show, and manage extension assets via the akm CLI, including v0.5.0 vaults, wikis, and workflows, 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",