akm-opencode 0.5.2 → 0.6.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 +5 -3
  2. package/index.ts +139 -46
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -41,14 +41,14 @@ fails silently when `akm` is not on PATH — the TUI is never affected.
41
41
 
42
42
  | Event | What happens |
43
43
  | --- | --- |
44
- | **`session.created`** (event hook) | Warms the stash index in the background and caches `akm hints` plus active workflow status for the next system transform so the agent knows the CLI surface area at turn 0. |
45
- | **`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. |
44
+ | **`session.created`** (event hook) | Warms the stash index in the background, caches `akm hints` plus active workflow status, and runs a scoped `akm curate --run <sessionID>` so fresh sessions see relevant stash context before the first user message. |
45
+ | **`chat.message`** | Runs `akm curate "<prompt>" --run <sessionID>` 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. |
46
46
  | **`experimental.chat.system.transform`** | Appends cached hints, active workflow state, the last curator report, and the current prompt's curated context to the model's system prompt. Hints and workflow state are re-injected after transcript compaction. |
47
47
  | **`tool.execute.before`** (`akm_*` tools) | Blocks destructive or sensitive operations until `confirm:true` is provided. |
48
48
  | **`tool.execute.after`** (`akm_*` tools) | Logs asset usage, accumulates refs into the session buffer, records `akm feedback <ref> --positive` / `--negative` asynchronously with per-call dedupe, checkpoints memories every `AKM_MEMORY_CHECKPOINT_EVERY` successful asset-touching tool calls, and scans child-agent free text for additional refs. |
49
49
  | **`experimental.session.compacting`** | Pushes hints, curated context, active workflows, and the last curator report into the compaction prompt so they survive transcript shrinking. |
50
50
  | **`shell.env`** | Exposes `AKM_STASH_DIR`, `AKM_PROJECT`, and `AKM_PLUGIN_VERSION` to shell tools so plain `akm` calls inherit the right context. |
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. |
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. When `AKM_INDEX_ON_SESSION_END=1`, the hook follows a successful flush with `akm index` so upstream inference/graph passes run immediately. |
52
52
 
53
53
  ### Environment overrides
54
54
 
@@ -58,9 +58,11 @@ fails silently when `akm` is not on PATH — the TUI is never affected.
58
58
  | `AKM_AUTO_FEEDBACK` | `1` | Set to `0` to disable automatic `akm feedback` on tool success/failure. |
59
59
  | `AKM_AUTO_HINTS` | `1` | Set to `0` to skip injecting `akm hints` at session start. |
60
60
  | `AKM_AUTO_MEMORY` | `1` | Set to `0` to disable automatic session-summary memories. |
61
+ | `AKM_INDEX_ON_SESSION_END` | `0` | Set to `1` to run `akm index` after a session-end memory is captured. |
61
62
  | `AKM_CURATE_LIMIT` | `5` | Max curated results injected into context per prompt. |
62
63
  | `AKM_CURATE_MIN_CHARS` | `16` | Minimum prompt length before curation runs. |
63
64
  | `AKM_CURATE_TIMEOUT` | `8` | Wall-clock seconds for `akm` invocations inside hooks. |
65
+ | `AKM_CONTEXT_BUDGET_CHARS` | `4000` | Max total characters injected into system/compaction context for a single turn. |
64
66
  | `AKM_CURATOR_CONTEXT_MAX_CHARS` | `4000` | Max cached curator-report characters re-injected into system/compaction context; the full report is still persisted as memory. |
65
67
  | `AKM_MEMORY_CHECKPOINT_EVERY` | `8` | Number of successful asset-touching tool calls between mid-session checkpoint memories. |
66
68
  | `AKM_RETROSPECTIVE_FEEDBACK_PATTERN` | `\b(thanks|perfect|worked)\b` | Case-insensitive regex used for lightweight positive retrospective feedback on the most recent refs. |
package/index.ts CHANGED
@@ -207,6 +207,10 @@ function nowIso(): string {
207
207
  return new Date().toISOString()
208
208
  }
209
209
 
210
+ function shouldIndexOnSessionEnd(): boolean {
211
+ return (process.env.AKM_INDEX_ON_SESSION_END ?? "0") === "1"
212
+ }
213
+
210
214
  function buildDateTag(options?: { includeTime?: boolean }): string {
211
215
  const compactIso = new Date().toISOString().replace(/[-:]/g, "")
212
216
  return compactIso.slice(0, options?.includeTime ? CHECKPOINT_DATE_TAG_LENGTH : SESSION_DATE_TAG_LENGTH)
@@ -257,28 +261,57 @@ function runCliSyncRaw(args: string[], timeoutMs: number): { ok: true; stdout: s
257
261
  }
258
262
  }
259
263
 
260
- function runCurateForPrompt(text: string): string | null {
261
- if (!text || text.length < AKM_CURATE_MIN_CHARS) return null
262
- const result = runCliSyncRaw(
263
- [
264
- "--for-agent",
265
- "--format",
266
- "text",
267
- "--detail",
268
- "summary",
269
- "-q",
270
- "curate",
271
- text,
272
- "--limit",
273
- String(AKM_CURATE_LIMIT),
274
- ],
275
- AKM_CURATE_TIMEOUT_MS,
276
- )
264
+ function appendRunScopeArgs(args: string[], sessionID: string | undefined): string[] {
265
+ return sessionID ? [...args, "--run", sessionID] : args
266
+ }
267
+
268
+ function runCurate(args: string[]): string | null {
269
+ const result = runCliSyncRaw(args, AKM_CURATE_TIMEOUT_MS)
277
270
  if (!result.ok) return null
278
271
  const body = result.stdout.trim()
279
272
  return body || null
280
273
  }
281
274
 
275
+ function runCurateForPrompt(text: string, sessionID?: string): string | null {
276
+ if (!text || text.length < AKM_CURATE_MIN_CHARS) return null
277
+ return runCurate(
278
+ appendRunScopeArgs(
279
+ [
280
+ "--for-agent",
281
+ "--format",
282
+ "text",
283
+ "--detail",
284
+ "summary",
285
+ "-q",
286
+ "curate",
287
+ text,
288
+ "--limit",
289
+ String(AKM_CURATE_LIMIT),
290
+ ],
291
+ sessionID,
292
+ ),
293
+ )
294
+ }
295
+
296
+ function runCurateForSession(sessionID: string): string | null {
297
+ return runCurate(
298
+ appendRunScopeArgs(
299
+ [
300
+ "--for-agent",
301
+ "--format",
302
+ "text",
303
+ "--detail",
304
+ "summary",
305
+ "-q",
306
+ "curate",
307
+ "--limit",
308
+ String(AKM_CURATE_LIMIT),
309
+ ],
310
+ sessionID,
311
+ ),
312
+ )
313
+ }
314
+
282
315
  function runHintsForSession(): string | null {
283
316
  const result = runCliSyncRaw(["--format", "text", "-q", "hints"], AKM_CURATE_TIMEOUT_MS)
284
317
  if (!result.ok) return null
@@ -545,6 +578,25 @@ function maybeCheckpointSessionMemory(sessionID: string): string | null {
545
578
  return captured
546
579
  }
547
580
 
581
+ async function maybeIndexSessionMemory(
582
+ client: LogCapableClient,
583
+ sessionID: string,
584
+ reason: string,
585
+ ref: string,
586
+ ): Promise<void> {
587
+ if (!shouldIndexOnSessionEnd()) return
588
+ const result = runCliSyncRaw(["index"], AKM_CURATE_TIMEOUT_MS)
589
+ if (result.ok) return
590
+ await writePluginLog(client, "warn", "AKM session indexing failed", {
591
+ subsystem: "memory",
592
+ actor: "system",
593
+ sessionID,
594
+ reason,
595
+ ref,
596
+ error: result.error,
597
+ })
598
+ }
599
+
548
600
  const AKM_REF_EDGE_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "'", "\"", "`"])
549
601
 
550
602
  function normalizeExtractedRef(ref: string): string {
@@ -616,6 +668,41 @@ const AKM_HINTS_PREFIX = [
616
668
 
617
669
  const AKM_CURATED_HEADER = "# AKM stash — assets relevant to this prompt"
618
670
  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."
671
+ const AKM_CONTEXT_TRUNCATED_MARKER = "\n\n[truncated for context]"
672
+
673
+ function getContextBudgetChars(): number {
674
+ const parsed = Number(process.env.AKM_CONTEXT_BUDGET_CHARS)
675
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 4000
676
+ }
677
+
678
+ function truncateContextBlock(block: string, maxChars: number): string {
679
+ if (block.length <= maxChars) return block
680
+ if (maxChars <= AKM_CONTEXT_TRUNCATED_MARKER.length) return block.slice(0, maxChars)
681
+ return `${block.slice(0, maxChars - AKM_CONTEXT_TRUNCATED_MARKER.length)}${AKM_CONTEXT_TRUNCATED_MARKER}`
682
+ }
683
+
684
+ function applyContextBudget(blocks: string[]): string[] {
685
+ const budget = getContextBudgetChars()
686
+ const injected: string[] = []
687
+ let remaining = budget
688
+ for (const block of blocks) {
689
+ if (!block) continue
690
+ // The host effectively concatenates injected blocks into one prompt body;
691
+ // we budget for a single newline separator between adjacent blocks.
692
+ const separatorCost = injected.length > 0 ? 1 : 0
693
+ if (remaining <= separatorCost) break
694
+ const allowed = remaining - separatorCost
695
+ if (block.length <= allowed) {
696
+ injected.push(block)
697
+ remaining -= separatorCost + block.length
698
+ continue
699
+ }
700
+ const truncated = truncateContextBlock(block, allowed)
701
+ if (truncated) injected.push(truncated)
702
+ break
703
+ }
704
+ return injected
705
+ }
619
706
 
620
707
  // Curated quick-reference for the long-tail of `akm` CLI verbs that no longer
621
708
  // have a dedicated tool wrapper. Surfaced through akm_help so agents can
@@ -1552,7 +1639,16 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
1552
1639
  if (type === "session.created" || type === "session.updated") {
1553
1640
  if (!sid) return
1554
1641
  if (!sessionContextEpoch.has(sid)) sessionContextEpoch.set(sid, 0)
1555
- if (type === "session.created") warmIndexInBackground()
1642
+ if (type === "session.created") {
1643
+ warmIndexInBackground()
1644
+ if (AKM_AUTO_CURATE && !sessionCurated.has(sid)) {
1645
+ const curated = runCurateForSession(sid)
1646
+ if (curated) {
1647
+ bumpCuratedVersion(sid)
1648
+ sessionCurated.set(sid, curated)
1649
+ }
1650
+ }
1651
+ }
1556
1652
  if (AKM_AUTO_HINTS && !sessionHints.has(sid)) {
1557
1653
  const hints = runHintsForSession()
1558
1654
  if (hints) sessionHints.set(sid, hints)
@@ -1571,6 +1667,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
1571
1667
  reason: type,
1572
1668
  ref: captured,
1573
1669
  })
1670
+ await maybeIndexSessionMemory(logClient, sid, type, captured)
1574
1671
  }
1575
1672
  // Drop per-session state so a re-created session does not inherit
1576
1673
  // stale hints/curation.
@@ -1608,6 +1705,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
1608
1705
  reason: "stop",
1609
1706
  ref: captured,
1610
1707
  })
1708
+ await maybeIndexSessionMemory(logClient, sid, "stop", captured)
1611
1709
  }
1612
1710
  } catch {
1613
1711
  // Best-effort only.
@@ -1619,14 +1717,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
1619
1717
  if (!sid) return
1620
1718
  if (!Array.isArray(output.context)) return
1621
1719
  markContextEpochDirty(sid)
1622
- const hints = sessionHints.get(sid)
1623
- if (hints) output.context.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
1624
- const curated = sessionCurated.get(sid)
1625
- if (curated) output.context.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
1626
- const workflow = sessionWorkflow.get(sid)
1627
- if (workflow) output.context.push(formatWorkflowContext(workflow))
1628
- const curatorReport = sessionCuratorReport.get(sid)
1629
- if (curatorReport) output.context.push(formatCuratorReportContext(curatorReport))
1720
+ const blocks = [
1721
+ sessionHints.get(sid) ? `${AKM_HINTS_PREFIX}\n\n${sessionHints.get(sid)}` : "",
1722
+ sessionCurated.get(sid) ? `${AKM_CURATED_HEADER}\n${sessionCurated.get(sid)}${AKM_CURATED_TAIL}` : "",
1723
+ sessionWorkflow.get(sid) ? formatWorkflowContext(sessionWorkflow.get(sid)!) : "",
1724
+ sessionCuratorReport.get(sid) ? formatCuratorReportContext(sessionCuratorReport.get(sid)!) : "",
1725
+ ]
1726
+ output.context.push(...applyContextBudget(blocks))
1630
1727
  } catch {
1631
1728
  // Never break compaction because of plugin context.
1632
1729
  }
@@ -1644,19 +1741,25 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
1644
1741
  const epoch = sessionContextEpoch.get(sid) ?? 0
1645
1742
  const injectedEpoch = sessionContextInjectedEpoch.get(sid)
1646
1743
  if (sid && injectedEpoch !== epoch) {
1647
- const hints = sessionHints.get(sid)
1648
- if (hints) output.system.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
1649
- const workflow = sessionWorkflow.get(sid)
1650
- if (workflow) output.system.push(formatWorkflowContext(workflow))
1651
- const curatorReport = sessionCuratorReport.get(sid)
1652
- if (curatorReport) output.system.push(formatCuratorReportContext(curatorReport))
1744
+ const blocks = [
1745
+ sessionHints.get(sid) ? `${AKM_HINTS_PREFIX}\n\n${sessionHints.get(sid)}` : "",
1746
+ sessionCurated.get(sid) ? `${AKM_CURATED_HEADER}\n${sessionCurated.get(sid)}${AKM_CURATED_TAIL}` : "",
1747
+ sessionWorkflow.get(sid) ? formatWorkflowContext(sessionWorkflow.get(sid)!) : "",
1748
+ sessionCuratorReport.get(sid) ? formatCuratorReportContext(sessionCuratorReport.get(sid)!) : "",
1749
+ ]
1750
+ output.system.push(...applyContextBudget(blocks))
1653
1751
  sessionContextInjectedEpoch.set(sid, epoch)
1752
+ if (sessionCurated.has(sid)) {
1753
+ // Startup curation is already included in the epoch-scoped block,
1754
+ // so mark that version as consumed to avoid a duplicate inject below.
1755
+ sessionCuratedInjectedVersion.set(sid, sessionCuratedVersion.get(sid) ?? 0)
1756
+ }
1654
1757
  }
1655
1758
  const curated = sid ? sessionCurated.get(sid) : undefined
1656
1759
  const curatedVersion = sessionCuratedVersion.get(sid) ?? 0
1657
1760
  if (curated) {
1658
1761
  if (sessionCuratedInjectedVersion.get(sid) !== curatedVersion) {
1659
- output.system.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
1762
+ output.system.push(...applyContextBudget([`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`]))
1660
1763
  sessionCuratedInjectedVersion.set(sid, curatedVersion)
1661
1764
  }
1662
1765
  }
@@ -1706,7 +1809,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
1706
1809
  // Compound-engineering loop: on every user message, curate the stash and
1707
1810
  // stash the result so experimental.chat.system.transform can inject it.
1708
1811
  if (AKM_AUTO_CURATE && input.sessionID) {
1709
- const curated = runCurateForPrompt(text)
1812
+ const curated = runCurateForPrompt(text, input.sessionID)
1710
1813
  if (curated) {
1711
1814
  sessionCurated.set(input.sessionID, curated)
1712
1815
  bumpCuratedVersion(input.sessionID)
@@ -1907,18 +2010,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
1907
2010
  detail: tool.schema.enum(["summary", "normal", "full"]).optional().describe("Detail level for each match. Defaults to 'summary'."),
1908
2011
  },
1909
2012
  async execute({ query, limit, detail }) {
1910
- const args = [
1911
- "--for-agent",
1912
- "--format",
1913
- "text",
1914
- "--detail",
1915
- detail ?? "summary",
1916
- "-q",
1917
- "curate",
1918
- query,
1919
- "--limit",
1920
- String(limit ?? 6),
1921
- ]
2013
+ const args = ["curate", query, "--limit", String(limit ?? 6)]
2014
+ if (detail) args.push("--detail", detail)
1922
2015
  return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_curate" })
1923
2016
  },
1924
2017
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-opencode",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
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": [