opencode-self-improvement 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -10,14 +10,56 @@ LLMs waste a lot of tokens reading and re-reading your code and asking you for c
10
10
  - **`memory_*` tools** — remember facts, decisions, and commands the agent should
11
11
  keep across sessions (`memory_init`, `memory_remember`, `memory_recall`,
12
12
  `memory_update`, `memory_forget`, `memory_list`, `memory_undo`,
13
- `memory_evidence`).
13
+ `memory_evidence`, `memory_export`, `memory_import`, `memory_decay`).
14
14
  - **`/repo-init`** — for each repo: indexes the code graph and remembers the
15
15
  test/build commands, keyed to the git head. The command installs itself; no
16
16
  setup beyond the plugin.
17
+ - **Memory-aware compaction** — durable memories are handed to the compaction
18
+ summarizer, so a long session doesn't get an amnesia wipe exactly when it
19
+ needs its memory most.
20
+ - **Export / import (JSONL)** — `memory_export` / `memory_import` back the store
21
+ up or move it between machines. Every imported row re-enters through the same
22
+ write gates, so an export can't smuggle junk in.
23
+ - **Decay** — `memory_decay` retires stale open threads. Dry run by default;
24
+ supersede-not-delete, snapshot-first, undoable.
25
+ - **Auto-capture (opt-in)** — with `autoCapture` on, session-idle events get one
26
+ cheap model pass that proposes memory candidates; every candidate still faces
27
+ the same write gates. Off by default, so the install stays zero-token.
17
28
  - Automatically de-dupes similar memories and avoids contradictions by searching for existing memories before saving new ones.
18
29
  - Automatically looks for insights when you complete the verification gates (tests, linting, etc)
19
30
  - Always defaults to memories first before sending a prompt to the LLM, which saves tokens!
20
31
 
32
+ ## Token cost, on purpose
33
+
34
+ Memory plugins have a reputation for burning tokens and busting the provider's
35
+ prompt cache on every message. This one is built the other way around:
36
+
37
+ - **Epoch-frozen injection.** The "Relevant durable memory" block is computed
38
+ once per epoch — first prompt of a session, a change to the memory store, or
39
+ a compaction — and stays byte-identical in between. A stable system prefix
40
+ means the provider's prompt cache survives ordinary turns instead of being
41
+ invalidated on every message.
42
+ - **Hard character budget.** The block is capped at 1200 characters by default
43
+ (see Options) and trimmed at whole-memory boundaries — never cut mid-sentence.
44
+ - **Evidence is never injected**, and the standing system digest is two lines.
45
+
46
+ Measured on a deterministic workload (50 memories, 20 prompts, 3 mid-session
47
+ store writes) using the plugin's real injection code:
48
+
49
+ | Policy | Recomputes | Injected chars (20 turns) | Avg/turn | Worst block |
50
+ | --- | ---: | ---: | ---: | ---: |
51
+ | A — recompute per prompt, uncapped (pre-0.2) | 20 | 27748 | 1387.4 | 1575 |
52
+ | B — epoch freeze + 1200-char cap (0.2) | 4 | 20654 | 1032.7 | 1157 |
53
+
54
+ Policy B injects 25.6% fewer characters — and the bigger saving isn't even in
55
+ the character count: B's block is byte-identical between store changes, so the
56
+ prompt cache stays warm across ordinary turns. Reproduce (byte-identical
57
+ output):
58
+
59
+ ```sh
60
+ bun bench/inject-cost.ts
61
+ ```
62
+
21
63
  ## Quick start
22
64
 
23
65
  1. Add the plugin to your Opencode config (see install below) and restart Opencode.
@@ -37,7 +79,7 @@ Add a `plugin` entry to your opencode config —
37
79
  ```json
38
80
  {
39
81
  "$schema": "https://opencode.ai/config.json",
40
- "plugin": ["opencode-self-improvement@0.1.0"]
82
+ "plugin": ["opencode-self-improvement@0.1.1"]
41
83
  }
42
84
  ```
43
85
 
@@ -51,7 +93,7 @@ bump the version in the config and restart. On Opencode v2 you can also manage
51
93
  it with the CLI:
52
94
 
53
95
  ```sh
54
- opencode plugin add opencode-self-improvement@0.1.0
96
+ opencode plugin add opencode-self-improvement@0.1.1
55
97
  opencode plugin update opencode-self-improvement
56
98
  ```
57
99
 
@@ -77,6 +119,24 @@ After changes, `git pull` and restart opencode.
77
119
 
78
120
  </details>
79
121
 
122
+ ## Options
123
+
124
+ Zero configuration is the default. To tune the plugin, use the object form of
125
+ the plugin entry in `opencode.json(c)` instead of the plain string:
126
+
127
+ ```json
128
+ {
129
+ "plugins": [{ "package": "opencode-self-improvement", "options": { "injectBudget": 1200 } }]
130
+ }
131
+ ```
132
+
133
+ | Option | Default | What it does |
134
+ | --- | --- | --- |
135
+ | `injectBudget` | `1200` | Character cap on the injected relevance block, trimmed at whole-memory boundaries. Env fallback: `OPENCODE_MEMORY_INJECT_BUDGET`. |
136
+ | `autoCapture` | `false` (off) | When on, each session-idle event makes one cheap model pass that proposes memory candidates. Every candidate still goes through the 4-gate write pipeline — capture can never bypass the gates. Off by default: the zero-token install stays zero-token. |
137
+ | `captureModel` | — | `"provider/id"` model used for auto-capture. Required when `autoCapture` is on; a missing or invalid value disables capture with a startup warning. |
138
+ | `captureMax` | `3` | Maximum candidates proposed per idle event. |
139
+
80
140
  ## Code graph (optional)
81
141
 
82
142
  `/repo-init` also indexes the repo into a queryable knowledge graph via
@@ -124,5 +184,6 @@ Run `bun memory-view` in your terminal and visit http://127.0.0.1:8787 in your
124
184
  | `/repo-init` missing from the command list | The self-install couldn't write to `~/.config/opencode/commands/` (e.g. read-only config dir). Create it, restart, or just ask the model to "init memory for this repo" — it calls the same tool. |
125
185
  | Init says `Graph: unavailable` | `codebase-memory-mcp` couldn't start (not installed, or its once-only download is blocked). Install pinned `0.11.0` via the one-liner above, or point `OPENCODE_CBM_BIN` at the binary. Scripts are still remembered without it. |
126
186
  | Stale plugin after an update | Bump the pinned version in the `plugin` entry (or `opencode plugin update opencode-self-improvement` on v2) and restart opencode. |
187
+ | Auto-capture does nothing | `autoCapture` needs a valid `captureModel` in `provider/id` form — a bad one disables capture with a startup warning. Candidates rejected by the write gates are logged as evidence, not errors; check `memory_evidence`. |
127
188
  | Reset all memories | Delete `~/.config/opencode/memory/`. |
128
189
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Durable memory for opencode: remember facts, decisions, and commands across sessions, plus a one-command per-repo setup (/repo-init).",
5
5
  "main": "index.ts",
6
6
  "exports": {
@@ -33,13 +33,12 @@
33
33
  "README.md",
34
34
  "LICENSE"
35
35
  ],
36
- "dependencies": {
37
- "@opencode-ai/plugin": "1.18.31"
38
- },
39
36
  "optionalDependencies": {
40
37
  "codebase-memory-mcp": "0.11.0"
41
38
  },
42
39
  "devDependencies": {
40
+ "@opencode/plugin": "^2.0.32",
41
+ "@semantic-release/git": "^11.0.1",
43
42
  "@types/bun": "latest",
44
43
  "semantic-release": "^25.0.9",
45
44
  "typescript": "^7.0.2"
@@ -50,6 +49,7 @@
50
49
  "scripts": {
51
50
  "test": "bun test",
52
51
  "typecheck": "bunx tsc --noEmit -p tsconfig.json",
52
+ "bench": "bun bench/inject-cost.ts",
53
53
  "memory-view": "bun run src/viewer/server.ts"
54
54
  }
55
55
  }
package/src/capture.ts ADDED
@@ -0,0 +1,175 @@
1
+ // Opt-in auto-capture core (Phase 4). Pure and dependency-free: no opencode
2
+ // imports, no filesystem, no ambient clock — the transcript, the model call
3
+ // and time are all injected by the caller (src/index.ts), so everything here
4
+ // is unit-testable. Every candidate that leaves this module still flows
5
+ // through runWriteGate in index.ts: capture NEVER writes memories directly,
6
+ // so dupes/stale/canonical rules keep the moat (same as model-authored writes).
7
+ // All parsing functions are TOTAL: any malformed input yields [] / "" / null,
8
+ // never a throw.
9
+ import { STATUSES, TYPES } from "./types.ts";
10
+
11
+ export interface CaptureOptions {
12
+ model: string; // "provider/id"
13
+ max: number;
14
+ }
15
+
16
+ export interface CaptureDeps {
17
+ generate: (prompt: string) => Promise<string>;
18
+ sessionText: string;
19
+ now: () => Date;
20
+ }
21
+
22
+ export interface CaptureCandidate {
23
+ type: string;
24
+ scope: string;
25
+ content: string;
26
+ status?: "open" | "settled";
27
+ }
28
+
29
+ // Prompt for the cheap capture model. The output contract is a bare JSON
30
+ // array so parseCandidates can trust it; anything else parses to [].
31
+ export function buildCapturePrompt(transcript: string, max = 3): string {
32
+ return [
33
+ "You mine one coding-agent session transcript for durable memories worth keeping across future sessions.",
34
+ "",
35
+ `Output ONLY a JSON array of at most ${max} objects (a fenced json block is fine), each:`,
36
+ '{"type": "...", "scope": "...", "content": "...", "status": "..."}',
37
+ "",
38
+ "Rules:",
39
+ `- type: one of ${TYPES.join(", ")}.`,
40
+ "- scope: short area name (e.g. auth, api, build).",
41
+ '- status: "open" for a live planning thread; otherwise "settled".',
42
+ "- content: one self-contained fact, 20-600 characters, useful in a NEW session.",
43
+ "- Keep: decisions made, working commands, user preferences, blockers hit, durable context, conventions.",
44
+ "- Skip: anything derivable from the code or git history, small talk, and details that only mattered inside this conversation.",
45
+ "- If nothing is worth remembering, output [ ].",
46
+ "",
47
+ "Transcript:",
48
+ transcript,
49
+ ].join("\n");
50
+ }
51
+
52
+ const VALID_TYPES = new Set<string>(TYPES);
53
+
54
+ // Remove markdown code-fence marker lines, keeping the fenced payload.
55
+ function stripFences(text: string): string {
56
+ return text.replace(/^[ \t]*```[^\n]*$/gm, "");
57
+ }
58
+
59
+ // First balanced [...] span, string-literal aware. null when absent or
60
+ // unbalanced (a model that truncates mid-array yields nothing, not garbage).
61
+ function firstJsonArray(text: string): string | null {
62
+ const start = text.indexOf("[");
63
+ if (start === -1) return null;
64
+ let depth = 0;
65
+ let inString = false;
66
+ let escaped = false;
67
+ for (let i = start; i < text.length; i++) {
68
+ const ch = text[i];
69
+ if (inString) {
70
+ if (escaped) escaped = false;
71
+ else if (ch === "\\") escaped = true;
72
+ else if (ch === '"') inString = false;
73
+ continue;
74
+ }
75
+ if (ch === '"') inString = true;
76
+ else if (ch === "[") depth++;
77
+ else if (ch === "]") {
78
+ depth--;
79
+ if (depth === 0) return text.slice(start, i + 1);
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ // Extract memory candidates from a model reply. Never throws: any malformed
86
+ // input yields []. Items missing fields, with unknown types, or with content
87
+ // outside 20-600 chars are dropped; the result is clamped to `max`.
88
+ export function parseCandidates(raw: string, max: number): CaptureCandidate[] {
89
+ try {
90
+ if (typeof raw !== "string" || !raw.trim() || !(max > 0)) return [];
91
+ const span = firstJsonArray(stripFences(raw));
92
+ if (!span) return [];
93
+ const parsed = JSON.parse(span);
94
+ if (!Array.isArray(parsed)) return [];
95
+ const out: CaptureCandidate[] = [];
96
+ for (const item of parsed) {
97
+ if (out.length >= max) break;
98
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
99
+ const o = item as Record<string, unknown>;
100
+ const type = typeof o.type === "string" ? o.type.trim() : "";
101
+ const scope = typeof o.scope === "string" ? o.scope.trim() : "";
102
+ const content = typeof o.content === "string" ? o.content.trim() : "";
103
+ if (!type || !scope || !content) continue;
104
+ if (!VALID_TYPES.has(type)) continue;
105
+ if (content.length < 20 || content.length > 600) continue;
106
+ const status = o.status === "open" || o.status === "settled" ? o.status : undefined;
107
+ out.push(status ? { type, scope, content, status } : { type, scope, content });
108
+ }
109
+ return out;
110
+ } catch {
111
+ return [];
112
+ }
113
+ }
114
+
115
+ // "provider/id" -> { providerID, id }, splitting on the FIRST "/". Returns
116
+ // null when the string has no slash or either half is empty — the caller then
117
+ // skips capture entirely rather than guessing a model.
118
+ export function parseModelRef(ref: unknown): { providerID: string; id: string } | null {
119
+ if (typeof ref !== "string") return null;
120
+ const i = ref.indexOf("/");
121
+ if (i <= 0) return null;
122
+ const providerID = ref.slice(0, i).trim();
123
+ const id = ref.slice(i + 1).trim();
124
+ if (!providerID || !id) return null;
125
+ return { providerID, id };
126
+ }
127
+
128
+ // Render an array of session-context messages of unknown shape into
129
+ // "role: text" lines. Defensive by construction (the API shape varies and is
130
+ // not ours to trust): non-arrays and junk entries are skipped; role comes
131
+ // from info.role | role, text from parts[].text | text | content (strings
132
+ // only); capped at 40 user turns. Never throws.
133
+ const TRANSCRIPT_USER_TURN_CAP = 40;
134
+
135
+ export function flattenTranscript(msgs: unknown): string {
136
+ try {
137
+ if (!Array.isArray(msgs)) return "";
138
+ const lines: string[] = [];
139
+ let userTurns = 0;
140
+ for (const msg of msgs) {
141
+ if (!msg || typeof msg !== "object") continue;
142
+ const m = msg as Record<string, unknown>;
143
+ const info =
144
+ m.info && typeof m.info === "object" ? (m.info as Record<string, unknown>) : undefined;
145
+ const role =
146
+ typeof info?.role === "string" ? info.role : typeof m.role === "string" ? m.role : "";
147
+ let text = "";
148
+ if (Array.isArray(m.parts)) {
149
+ text = m.parts
150
+ .map((p) =>
151
+ p && typeof p === "object" ? (p as Record<string, unknown>).text : undefined,
152
+ )
153
+ .filter((t): t is string => typeof t === "string" && t.length > 0)
154
+ .join(" ");
155
+ }
156
+ if (!text) {
157
+ for (const k of ["text", "content"]) {
158
+ if (typeof m[k] === "string") {
159
+ text = m[k] as string;
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ if (!role || !text.trim()) continue;
165
+ if (role === "user") {
166
+ userTurns++;
167
+ if (userTurns > TRANSCRIPT_USER_TURN_CAP) break;
168
+ }
169
+ lines.push(`${role}: ${text.trim()}`);
170
+ }
171
+ return lines.join("\n");
172
+ } catch {
173
+ return "";
174
+ }
175
+ }
package/src/config.ts CHANGED
@@ -41,6 +41,19 @@ export function dateStr(d: Date = new Date()): string {
41
41
  return d.toISOString().split("T")[0];
42
42
  }
43
43
 
44
+ // Character budget for the injected "Relevant durable memory" block (Phase 2:
45
+ // cache-safe epoch injection). Overridable via OPENCODE_MEMORY_INJECT_BUDGET
46
+ // (plugin options take precedence — see src/index.ts); falls back to 1200 on
47
+ // missing or invalid values.
48
+ export function injectBudget(): number {
49
+ const raw = process.env.OPENCODE_MEMORY_INJECT_BUDGET;
50
+ if (raw) {
51
+ const n = Number.parseInt(raw, 10);
52
+ if (Number.isFinite(n) && n > 0) return n;
53
+ }
54
+ return 1200;
55
+ }
56
+
44
57
  // Optional live-graph binary. When present the resolver cross-checks the local
45
58
  // source scan against the codebase-memory-mcp file graph; when absent the local
46
59
  // scan alone is authoritative (install-from-scratch requirement).
package/src/index.ts CHANGED
@@ -6,26 +6,35 @@
6
6
  // V2 port: registers tools via `ctx.tool.transform` (JSON Schema), mirrors the
7
7
  // V1 hooks onto the OpenCode V2 session/tool hook API, and registers the
8
8
  // /repo-init command via `ctx.command.transform`. The plugin contract is a
9
- // default export object `{ id, setup(ctx) }` (OpenCode >= 2.0). All business
10
- // logic still lives in the decoupled helper modules.
9
+ // default export object `{ id, setup(ctx) }` (OpenCode >= 2.0), pinned at
10
+ // compile time against the real SDK type (`@opencode/plugin`, type-only — see
11
+ // the note on `plugin` below). All business logic still lives in the decoupled
12
+ // helper modules.
13
+ import type { Cleanup, Plugin } from "@opencode/plugin/promise/plugin";
11
14
  import { join } from "node:path";
12
- import { evidenceDir, memoryRoot, dateStr } from "./config.ts";
15
+ import { evidenceDir, injectBudget, memoryRoot, dateStr } from "./config.ts";
16
+ import { STATUSES, TYPES } from "./types.ts";
13
17
  import {
14
18
  clearSuperseded,
15
19
  isActive,
16
20
  loadAll,
17
21
  markSuperseded,
18
22
  resolveMemoryTarget,
23
+ snapshotFiles,
19
24
  type Memory,
20
25
  } from "./memory.ts";
21
26
  import { runWriteGate, type GateContext, type WriteCandidate } from "./gates.ts";
22
27
  import { pruneMemory } from "./prune.ts";
28
+ import { exportMemories, findStaleOpen, importMemories } from "./portable.ts";
23
29
  import { listSnapshots, restoreSnapshot } from "./undo.ts";
24
30
  import { record } from "./evidence.ts";
25
31
  import {
32
+ capToBudget,
33
+ compactionAnchor,
26
34
  contextualPull,
27
35
  formatMemory,
28
36
  relevantKnowledge,
37
+ storeVersion,
29
38
  systemDigest,
30
39
  } from "./inject.ts";
31
40
  import { formatInit, runInit } from "./init.ts";
@@ -43,9 +52,7 @@ import {
43
52
  } from "./triggers.ts";
44
53
  import { getCurrent, setCurrent } from "./state.ts";
45
54
  import { readLines } from "./logfmt.ts";
46
-
47
- const TYPES = ["decision", "learning", "preference", "blocker", "context", "pattern"] as const;
48
- const STATUSES = ["open", "settled"] as const;
55
+ import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
49
56
 
50
57
  // --- minimal JSON Schema helpers (OpenCode V2 tools take JSON Schema) --------
51
58
  type JSONSchema = Record<string, unknown>;
@@ -88,6 +95,7 @@ interface HookReg {
88
95
  }
89
96
  interface PluginCtx {
90
97
  location?: { directory?: string };
98
+ options?: Record<string, unknown>;
91
99
  tool?: {
92
100
  transform: (cb: (editor: ToolEditorLike) => void) => Promise<unknown>;
93
101
  hook: (name: string, cb: (event: any) => Promise<void> | void) => Promise<HookReg | void>;
@@ -102,6 +110,13 @@ interface PluginCtx {
102
110
  text: string;
103
111
  delivery?: "steer" | "queue";
104
112
  }) => Promise<unknown>;
113
+ context?: (input: { sessionID: string }) => Promise<unknown>;
114
+ };
115
+ event?: {
116
+ subscribe: (o?: { signal?: AbortSignal }) => AsyncIterable<unknown>;
117
+ };
118
+ generate?: {
119
+ text: (input: unknown) => Promise<{ text?: string } | string>;
105
120
  };
106
121
  }
107
122
 
@@ -133,11 +148,17 @@ function resultMeta(result: unknown): Record<string, unknown> {
133
148
 
134
149
  const PLUGIN_ID = "opencode-self-improvement";
135
150
 
136
- const plugin = {
137
- id: PLUGIN_ID,
138
- async setup(ctx: PluginCtx): Promise<void> {
151
+ // The real setup implementation, written against the loose local PluginCtx
152
+ // (the only members of the V2 context this plugin actually touches).
153
+ const impl = {
154
+ async setup(ctx: PluginCtx): Promise<Cleanup | void> {
139
155
  const liveRoot = ctx?.location?.directory ?? process.cwd();
140
156
 
157
+ // Character budget for the epoch-frozen injection block: plugin options
158
+ // (object form in opencode.json(c)) win, then OPENCODE_MEMORY_INJECT_BUDGET,
159
+ // then the 1200 default.
160
+ const budget = Number(ctx.options?.injectBudget) || injectBudget();
161
+
141
162
  // Mirror the /repo-init slash command into opencode's global commands dir
142
163
  // (idempotent, write-if-missing). OpenCode 2.x loads `*.md` from
143
164
  // ~/.config/opencode/commands, so this is the canonical V2 install path.
@@ -425,11 +446,124 @@ const plugin = {
425
446
  };
426
447
  },
427
448
  });
449
+
450
+ editor.add({
451
+ name: "memory_export",
452
+ description:
453
+ "Export active memories as a JSONL file (one JSON object per line: {ts,type,scope,content,status,tags,issue}) for backup or transfer to another machine. Defaults to <memory-root>/exports/<timestamp>.jsonl; pass `file` to choose the path, `scope` to export one scope. Superseded rows are not exported (history stays in the store).",
454
+ options: direct,
455
+ input: obj({
456
+ scope: withDesc(str(), "Only export memories in this exact scope"),
457
+ file: withDesc(str(), "Destination path (default: <memory-root>/exports/)"),
458
+ }),
459
+ async execute(args: any): Promise<{ content: string }> {
460
+ const res = await exportMemories({ scope: args.scope, file: args.file });
461
+ return { content: `Exported ${res.count} memories -> ${res.path}` };
462
+ },
463
+ });
464
+
465
+ editor.add({
466
+ name: "memory_import",
467
+ description:
468
+ "Import memories from a JSONL export (see memory_export). Every row goes through the same 4-gate write pipeline as memory_remember (tagged `import`, original tags preserved), so duplicates collapse and ungrounded rows are blocked exactly as usual — those are normal tallied outcomes, not errors. Malformed lines are skipped and counted.",
469
+ options: direct,
470
+ input: obj({ file: withDesc(str(), "Path to a .jsonl export file") }, ["file"]),
471
+ async execute(args: any): Promise<{ content: string }> {
472
+ try {
473
+ const t = await importMemories(args.file, (c) =>
474
+ runWriteGate(
475
+ { ...c, tags: [...new Set([...(c.tags ?? []), "import"])] },
476
+ gateContext(),
477
+ ),
478
+ );
479
+ return {
480
+ content:
481
+ `Imported ${args.file}: ${t.promoted} promoted, ${t.collapsed} duplicates collapsed, ` +
482
+ `${t.blocked} blocked by the gate, ${t.skipped} malformed lines skipped.`,
483
+ };
484
+ } catch (err) {
485
+ return {
486
+ content: `memory_import failed: ${err instanceof Error ? err.message : String(err)}`,
487
+ };
488
+ }
489
+ },
490
+ });
491
+
492
+ editor.add({
493
+ name: "memory_decay",
494
+ description:
495
+ "Retire stale open threads (status=open older than olderThanDays) by superseding them — never deleting; rows stay recoverable via memory_undo. DRY RUN BY DEFAULT: without an explicit dryRun=false this only LISTS what would retire and mutates nothing (fail-closed). A real run snapshots every affected file before the first mutation.",
496
+ options: direct,
497
+ input: obj({
498
+ olderThanDays: withDesc(
499
+ { type: "number", default: 14 },
500
+ "Age threshold in days (default 14)",
501
+ ),
502
+ dryRun: withDesc(
503
+ { type: "boolean", default: true },
504
+ "Preview only (default true). Set false to actually supersede.",
505
+ ),
506
+ }),
507
+ async execute(args: any): Promise<{ content: string }> {
508
+ const days = Number(args.olderThanDays) > 0 ? Number(args.olderThanDays) : 14;
509
+ const dryRun = args.dryRun !== false; // only an explicit false mutates
510
+ const stale = await findStaleOpen(days);
511
+ if (!stale.length) {
512
+ return { content: `No active open threads older than ${days} days.` };
513
+ }
514
+ const listing = stale
515
+ .map((m) => ` ${m.ts} ${m.type}/${m.scope}: ${m.content.slice(0, 100)}`)
516
+ .join("\n");
517
+ if (dryRun) {
518
+ await record(
519
+ "decay_dry_run",
520
+ { days, stale: stale.length, mem: stale.map((m) => m.ts).join(",") },
521
+ false,
522
+ );
523
+ return {
524
+ content:
525
+ `Dry run: ${stale.length} open thread(s) older than ${days} days:\n${listing}\n` +
526
+ "Re-run with dryRun=false to supersede them (snapshot first; recoverable via memory_undo).",
527
+ };
528
+ }
529
+ // Snapshot-before-mutate: the SAME helper prune() uses in memory.ts,
530
+ // one whole-file copy per affected file, taken before the first
531
+ // mutation. markSuperseded also snapshots internally per row; this
532
+ // pass guarantees a pre-decay copy exists even if a later row fails.
533
+ const snapshot = await snapshotFiles([...new Set(stale.map((m) => m.file))]);
534
+ const retiredTs: string[] = [];
535
+ for (const m of stale) {
536
+ // Idempotent (a no-op on already-superseded rows), so a re-run
537
+ // cannot double-mark. No successor row exists — decay retires.
538
+ const ok = await markSuperseded(m.ts, { superseded_by: "decay", reason: "decay" });
539
+ if (ok) retiredTs.push(m.ts);
540
+ }
541
+ await record(
542
+ "decay_applied",
543
+ {
544
+ days,
545
+ retired: retiredTs.length,
546
+ mem: retiredTs.join(",") || "-",
547
+ snapshot: snapshot.join(",") || "-",
548
+ },
549
+ false,
550
+ );
551
+ return {
552
+ content:
553
+ `Decayed ${retiredTs.length} open thread(s) older than ${days} days ` +
554
+ `(superseded, not deleted; snapshot: ${snapshot[0] ?? "-"}). ` +
555
+ "Recover with memory_undo.",
556
+ };
557
+ },
558
+ });
428
559
  });
429
560
 
430
- // Per-session durable-memory blocks stashed by the prompt hook and pushed
431
- // onto the system prompt by the context hook (invisible to the user).
432
- const pendingKnowledge = new Map<string, string>();
561
+ // Per-session frozen injection epochs: the relevance block plus the store
562
+ // fingerprint it was computed against. Recomputed ONLY on the first prompt
563
+ // of a session, when the store changes, or after a compaction (see the
564
+ // prompt/compaction hooks) — so the system prefix, and with it the
565
+ // provider prompt cache, survives ordinary turns.
566
+ const epoch = new Map<string, { version: string; block: string }>();
433
567
 
434
568
  // ---- hooks (all fail-closed) -------------------------------------------
435
569
  // Bookkeeping: track the active session/tool/call before a tool runs.
@@ -469,41 +603,158 @@ const plugin = {
469
603
  const parts: string[] = [];
470
604
  await systemDigest({ sessionID: event?.sessionID }, { system: parts });
471
605
  for (const text of parts) event?.system?.push?.({ type: "text", text });
472
- // Deliver this turn's relevance-ranked memory via the system channel.
606
+ // Deliver this epoch's relevance-ranked memory via the system channel.
473
607
  // The UI never renders system parts, unlike appended prompt text.
474
608
  const sid = event?.sessionID;
475
- const knowledge = typeof sid === "string" ? pendingKnowledge.get(sid) : undefined;
609
+ const knowledge = typeof sid === "string" ? epoch.get(sid)?.block : undefined;
476
610
  if (knowledge) event?.system?.push?.({ type: "text", text: knowledge });
477
611
  }),
478
612
  );
479
613
 
480
- // Memory-first injection: compute relevance against each user prompt and
481
- // stash the block for invisible delivery via the context hook. Never mutate
482
- // event.prompt.text — it is stored verbatim and rendered in the UI.
614
+ // Memory-first injection, frozen per epoch (cache-safe). The block is
615
+ // relevance-scored against the prompt that STARTED the epoch — accepted
616
+ // staleness, traded for a stable system prefix across ordinary turns. An
617
+ // epoch is invalidated only on first prompt, store change, or compaction.
618
+ // Never mutate event.prompt.text — it is stored verbatim and rendered in
619
+ // the UI.
483
620
  await ctx.session?.hook("prompt", (event: any) =>
484
621
  guarded("session.prompt (inject)", async () => {
485
622
  const sid = event?.sessionID;
486
623
  if (typeof sid === "string") {
487
624
  markTurn(sid);
488
625
  await checkNewSession(sid);
489
- }
490
- const text = event?.prompt?.text ?? "";
491
- const block = await relevantKnowledge(text);
492
- if (typeof sid === "string") {
493
- if (block) pendingKnowledge.set(sid, block);
494
- else pendingKnowledge.delete(sid);
626
+ const version = await storeVersion();
627
+ const cached = epoch.get(sid);
628
+ if (!cached || cached.version !== version) {
629
+ const text = event?.prompt?.text ?? "";
630
+ const raw = await relevantKnowledge(text, 5); // widen candidate pool
631
+ const block = raw ? capToBudget(raw, budget) : "";
632
+ epoch.set(sid, { version, block });
633
+ await record(
634
+ "epoch_recompute",
635
+ {
636
+ session: sid,
637
+ reason: cached ? "store_changed" : "first",
638
+ chars: block.length,
639
+ },
640
+ false,
641
+ );
642
+ }
495
643
  }
496
644
  }),
497
645
  );
498
646
 
499
- // Session-convergence rollup at compaction.
647
+ // Session-convergence rollup at compaction. The epoch is dropped so the
648
+ // next prompt re-scores against the post-compaction store. Memory-aware
649
+ // compaction: the durable-memory anchor is pushed onto the hook's system
650
+ // parts (visible to the summarizer model, which runs AFTER hooks) so the
651
+ // summary preserves the facts. Never sets event.result — that would skip
652
+ // the summarizer call entirely.
500
653
  await ctx.session?.hook("compaction", (event: any) =>
501
654
  guarded("session.compaction (onCompacting)", async () => {
502
655
  await onCompacting({ sessionID: event?.sessionID });
656
+ if (typeof event?.sessionID === "string") epoch.delete(event.sessionID);
657
+ const anchor = await compactionAnchor();
658
+ if (anchor) event?.system?.push?.({ type: "text", text: anchor });
503
659
  }),
504
660
  );
661
+
662
+ // ---- opt-in auto-capture on session idle --------------------------------
663
+ // Default OFF: zero-token installs never subscribe to anything. When
664
+ // enabled (options.autoCapture === true AND a valid captureModel), each
665
+ // session.idle event runs ONE cheap ctx.generate.text call whose reply is
666
+ // parsed into candidates that go through the SAME runWriteGate as
667
+ // model-authored writes — capture can never bypass the moat, and it only
668
+ // ever writes through the gate. A per-session cooldown bounds the cost.
669
+ // Every failure inside the loop is swallowed: capture must never be able
670
+ // to break a session.
671
+ let captureCleanup: Cleanup | undefined;
672
+ if (ctx.options?.autoCapture === true) {
673
+ const captureModel =
674
+ typeof ctx.options.captureModel === "string" ? ctx.options.captureModel : "";
675
+ const captureMax = Number(ctx.options.captureMax) || 3;
676
+ const modelRef = parseModelRef(captureModel);
677
+ if (!modelRef) {
678
+ // Invalid/missing model: skip registering the whole subscription
679
+ // rather than guess (and warn once, at setup).
680
+ console.warn(
681
+ `[opencode-self-improvement] autoCapture is on but captureModel ${JSON.stringify(captureModel)} is invalid (expected "provider/id"); auto-capture disabled.`,
682
+ );
683
+ } else if (!ctx.event?.subscribe || !ctx.generate?.text || !ctx.session?.context) {
684
+ console.warn(
685
+ "[opencode-self-improvement] autoCapture is on but this OpenCode context lacks event.subscribe/generate.text/session.context; auto-capture disabled.",
686
+ );
687
+ } else {
688
+ const controller = new AbortController();
689
+ const lastRun = new Map<string, number>();
690
+ const eventApi = ctx.event;
691
+ const generateApi = ctx.generate;
692
+ // Arrow wrapper: keep the receiver bound. The harness fakes are closures,
693
+ // but a real client method that reads `this` would break if detached —
694
+ // and the fail-closed catch below would hide it. Optional call: the
695
+ // setup guard proves it exists, but property narrowing does not survive
696
+ // into the closure, and undefined degrades safely (empty transcript).
697
+ const contextFn = (input: { sessionID: string }) => ctx.session?.context?.(input);
698
+ void (async () => {
699
+ try {
700
+ for await (const raw of eventApi.subscribe({ signal: controller.signal })) {
701
+ const event = raw as Record<string, any>;
702
+ if (event?.type !== "session.idle") continue;
703
+ const sid = event.sessionID ?? event.properties?.sessionID;
704
+ if (typeof sid !== "string") continue;
705
+ const now = Date.now();
706
+ if (now - (lastRun.get(sid) ?? 0) < 5 * 60_000) continue; // 5-min cooldown
707
+ lastRun.set(sid, now);
708
+ try {
709
+ const msgs = await contextFn({ sessionID: sid });
710
+ const transcript = flattenTranscript(msgs);
711
+ if (transcript.length < 200) continue; // too thin to mine reliably
712
+ const out = await generateApi.text({
713
+ model: modelRef,
714
+ prompt: buildCapturePrompt(transcript.slice(-8000), captureMax),
715
+ });
716
+ const text = typeof out === "string" ? out : out?.text ?? "";
717
+ for (const c of parseCandidates(text, captureMax)) {
718
+ const outcome = await runWriteGate(
719
+ { ...c, tags: ["auto-capture"] },
720
+ gateContext(),
721
+ );
722
+ await record(
723
+ `auto_capture_${outcome.action}`,
724
+ { session: sid, type: c.type, scope: c.scope, reason: outcome.reason ?? "" },
725
+ false,
726
+ );
727
+ }
728
+ } catch {
729
+ // fail-closed: a broken capture attempt is invisible to the session
730
+ }
731
+ }
732
+ } catch {
733
+ // the subscription itself ended (abort or host error); nothing to do
734
+ }
735
+ })();
736
+ captureCleanup = () => controller.abort();
737
+ }
738
+ }
739
+ // Single cleanup returned by setup: tears down the capture subscription.
740
+ // Future teardowns merge into this same function.
741
+ return captureCleanup;
505
742
  },
506
743
  };
507
744
 
745
+ // The plain object IS the V2 contract ({ id, setup }); the SDK's `Plugin` type
746
+ // pins its shape at compile time. The import is deliberately type-only:
747
+ // opencode installs npm plugins with runtime dependencies only (verified in
748
+ // ~/.cache/opencode/npm/<pkg>@<ver>/), so a value import of @opencode/plugin —
749
+ // a devDependency here — would fail to resolve at load time for published
750
+ // consumers, and making it a runtime dependency would pull effect +
751
+ // @opencode/client into every install, the exact dead-weight problem dropping
752
+ // the V1 SDK fixes. `Plugin.define` is an identity function at runtime, so this
753
+ // annotation buys the same compile-time contract for zero runtime cost.
754
+ const plugin: Plugin = {
755
+ id: PLUGIN_ID,
756
+ setup: async (ctx) => impl.setup(ctx as unknown as PluginCtx),
757
+ };
758
+
508
759
  export const SelfImprovement = plugin;
509
760
  export default plugin;
package/src/inject.ts CHANGED
@@ -135,6 +135,70 @@ export async function relevantKnowledge(message: string, limit = 3): Promise<str
135
135
  return `## Relevant durable memory\n${top.map(formatMemory).join("\n")}`;
136
136
  }
137
137
 
138
+ // Deterministic fingerprint of the active store: `${activeCount}:${maxActiveTs}`.
139
+ // Cheap change detector for the per-session epoch cache in index.ts: the frozen
140
+ // injection block is recomputed only when this string changes (or after a
141
+ // compaction). Evidence and superseded rows never affect it (I3, memory.ts).
142
+ export async function storeVersion(): Promise<string> {
143
+ const mems = (await loadAll()).filter(isActive);
144
+ let max = "";
145
+ for (const m of mems) if (m.ts > max) max = m.ts;
146
+ return `${mems.length}:${max}`;
147
+ }
148
+
149
+ // Trim an injection block to `budget` characters at WHOLE-line boundaries:
150
+ // keep every line that still fits (skipping over-long ones), never cut
151
+ // mid-line. Returns "" when even the header line exceeds the budget, and ""
152
+ // when NOTHING but header lines survived — a dangling "## Relevant durable
153
+ // memory" over zero memories is noise, not memory (bug seen live: a store
154
+ // whose top matches all exceed the budget injected exactly the 26-char header).
155
+ export function capToBudget(block: string, budget: number): string {
156
+ const lines = block.split("\n");
157
+ // The header gives the block its meaning; if it does not fit, nothing does.
158
+ if (lines[0].length > budget) return "";
159
+ const kept: string[] = [lines[0]];
160
+ let len = lines[0].length;
161
+ for (const line of lines.slice(1)) {
162
+ if (len + line.length + 1 > budget) continue; // skip over-long lines, keep scanning
163
+ kept.push(line);
164
+ len += line.length + 1;
165
+ }
166
+ if (kept.every((l) => l.startsWith("## "))) return "";
167
+ return kept.join("\n");
168
+ }
169
+
170
+ // Memory-aware compaction (Phase 3): a durable-memory block pushed onto the
171
+ // V2 compaction hook's `system` parts, where the summarizer model can see it
172
+ // (OpenCode appends its own summary prompt AFTER hooks). Deterministic, zero
173
+ // extra model calls; fixes "compaction amnesia". Composition: up to 10 open
174
+ // threads newest-first, then up to 5 newest settled memories, total rows
175
+ // capped by `limit`; the whole block capped at 2500 chars at whole-line
176
+ // boundaries. "" when the store has no active memories.
177
+ const ANCHOR_OPEN_LIMIT = 10;
178
+ const ANCHOR_SETTLED_LIMIT = 5;
179
+ const ANCHOR_BUDGET = 2500;
180
+
181
+ export async function compactionAnchor(limit = 12): Promise<string> {
182
+ const mems = (await loadAll()).filter(isActive);
183
+ if (!mems.length) return "";
184
+ const byNewest = (a: Memory, b: Memory) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
185
+ const open = mems
186
+ .filter((m) => m.status === "open")
187
+ .sort(byNewest)
188
+ .slice(0, Math.min(ANCHOR_OPEN_LIMIT, Math.max(0, limit)));
189
+ const settled = mems
190
+ .filter((m) => m.status !== "open")
191
+ .sort(byNewest)
192
+ .slice(0, Math.min(ANCHOR_SETTLED_LIMIT, Math.max(0, limit - open.length)));
193
+ const rows = [...open, ...settled];
194
+ if (!rows.length) return "";
195
+ const block =
196
+ "## Durable memory to preserve in this summary\n" +
197
+ "Preserve these facts verbatim (dates, commands, ids) in the compaction summary:\n" +
198
+ rows.map(formatMemory).join("\n");
199
+ return capToBudget(block, ANCHOR_BUDGET);
200
+ }
201
+
138
202
  export async function contextualPull(input: PullInput, output: PullOutput): Promise<void> {
139
203
  const tool = typeof input.tool === "string" ? input.tool : "";
140
204
  if (!["grep", "glob", "read"].includes(tool)) return;
@@ -0,0 +1,121 @@
1
+ // Portability (Phase 5): JSONL export/import of the durable store, plus the
2
+ // stale-open-thread query that backs memory_decay. Import NEVER writes: every
3
+ // row flows through the caller-provided gate callback (index.ts wires it to
4
+ // runWriteGate), so moving memories between machines cannot bypass the 4-gate
5
+ // moat. Decay is supersede-not-delete and snapshot-first, mirroring prune.
6
+ import { join } from "node:path";
7
+ import { memoryRoot } from "./config.ts";
8
+ import { isActive, loadAll, type Memory } from "./memory.ts";
9
+ import { TYPES } from "./types.ts";
10
+ import type { WriteCandidate } from "./gates.ts";
11
+
12
+ // --- export -------------------------------------------------------------------
13
+
14
+ export interface ExportOptions {
15
+ /** Only export rows from this exact scope. */
16
+ scope?: string;
17
+ /** Destination path; defaults to <memory-root>/exports/<stamp>.jsonl. */
18
+ file?: string;
19
+ }
20
+
21
+ export interface ExportResult {
22
+ path: string;
23
+ count: number;
24
+ }
25
+
26
+ // Filesystem-safe YYYY-MM-DDTHHMMSS (no colons).
27
+ function stamp(d: Date = new Date()): string {
28
+ return d.toISOString().slice(0, 19).replace(/:/g, "");
29
+ }
30
+
31
+ // Write the ACTIVE store as JSONL — one JSON object per line carrying
32
+ // {ts,type,scope,content,status,tags,issue}. Superseded rows are not exported:
33
+ // the export is the live store, and history stays auditable where it lives
34
+ // (the original logfmt files + snapshots).
35
+ export async function exportMemories(opts: ExportOptions = {}): Promise<ExportResult> {
36
+ let mems = (await loadAll()).filter(isActive);
37
+ if (opts.scope) mems = mems.filter((m) => m.scope === opts.scope);
38
+ const path = opts.file ?? join(memoryRoot(), "exports", `${stamp()}.jsonl`);
39
+ const lines = mems.map((m) =>
40
+ JSON.stringify({
41
+ ts: m.ts,
42
+ type: m.type,
43
+ scope: m.scope,
44
+ content: m.content,
45
+ status: m.status,
46
+ tags: m.tags,
47
+ issue: m.issue,
48
+ }),
49
+ );
50
+ await Bun.write(path, lines.length ? lines.join("\n") + "\n" : "");
51
+ return { path, count: mems.length };
52
+ }
53
+
54
+ // --- import -------------------------------------------------------------------
55
+
56
+ export interface ImportTally {
57
+ promoted: number;
58
+ collapsed: number;
59
+ blocked: number;
60
+ skipped: number;
61
+ }
62
+
63
+ // A line is malformed (skipped, never gated) unless it is a JSON object with a
64
+ // known type, a non-empty scope, and non-empty content. Optional fields are
65
+ // sanitized rather than fatal: an unusable status/tags/issue is dropped.
66
+ function parseRow(line: string): WriteCandidate | null {
67
+ let row: unknown;
68
+ try {
69
+ row = JSON.parse(line);
70
+ } catch {
71
+ return null;
72
+ }
73
+ if (!row || typeof row !== "object" || Array.isArray(row)) return null;
74
+ const r = row as Record<string, unknown>;
75
+ if (typeof r.type !== "string" || !(TYPES as readonly string[]).includes(r.type)) return null;
76
+ if (typeof r.scope !== "string" || !r.scope.trim()) return null;
77
+ if (typeof r.content !== "string" || !r.content.trim()) return null;
78
+ const c: WriteCandidate = { type: r.type, scope: r.scope, content: r.content };
79
+ if (r.status === "open" || r.status === "settled") c.status = r.status;
80
+ if (Array.isArray(r.tags) && r.tags.every((t) => typeof t === "string")) c.tags = r.tags;
81
+ if (typeof r.issue === "string" && r.issue) c.issue = r.issue;
82
+ return c;
83
+ }
84
+
85
+ // Read a JSONL export and run every valid row through `gate`. The CALLER owns
86
+ // promotion (index.ts passes runWriteGate with the "import" tag), so imported
87
+ // rows face the same canonical/stale/dedup/ambiguity gates as any other write.
88
+ // Blocked and collapsed rows are normal outcomes, tallied rather than raised.
89
+ // Only a missing file throws.
90
+ export async function importMemories(
91
+ path: string,
92
+ gate: (c: WriteCandidate) => Promise<{ action: string }>,
93
+ ): Promise<ImportTally> {
94
+ const file = Bun.file(path);
95
+ if (!(await file.exists())) throw new Error(`import file not found: ${path}`);
96
+ const tally: ImportTally = { promoted: 0, collapsed: 0, blocked: 0, skipped: 0 };
97
+ for (const line of (await file.text()).split("\n")) {
98
+ if (!line.trim()) continue;
99
+ const c = parseRow(line);
100
+ if (!c) {
101
+ tally.skipped++;
102
+ continue;
103
+ }
104
+ const outcome = await gate(c);
105
+ if (outcome.action === "promoted") tally.promoted++;
106
+ else if (outcome.action === "collapsed") tally.collapsed++;
107
+ else tally.blocked++;
108
+ }
109
+ return tally;
110
+ }
111
+
112
+ // --- decay support --------------------------------------------------------------
113
+
114
+ // Active open threads with ts older than `days` days, oldest first (both sides
115
+ // are UTC-Z ISO strings, so plain lexicographic compare is correct).
116
+ export async function findStaleOpen(days: number): Promise<Memory[]> {
117
+ const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
118
+ return (await loadAll())
119
+ .filter((m) => isActive(m) && m.status === "open" && m.ts < cutoff)
120
+ .sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
121
+ }
package/src/resolver.ts CHANGED
@@ -54,6 +54,30 @@ export function balancedParen(text: string, openIdx: number): { inner: string; e
54
54
 
55
55
  const CALL_RE = /([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+)\s*\(/g;
56
56
 
57
+ // Last dotted segment is a known file extension: `package.json(committed by…)`
58
+ // is a file reference followed by parenthetical prose, not a call to a method
59
+ // named `json`. This class produced 9 false drift_ref_stale_incoming blocks in
60
+ // live use before the guard existed.
61
+ const FILE_EXT = new Set([
62
+ "json", "jsonc", "jsonl", "logfmt", "md", "mdx", "ts", "tsx", "js", "jsx",
63
+ "mjs", "cjs", "py", "go", "rs", "rb", "java", "kt", "sh", "bash", "zsh",
64
+ "yaml", "yml", "toml", "ini", "conf", "cfg", "txt", "csv", "html", "htm",
65
+ "css", "scss", "xml", "sql", "tf", "lock", "log", "env",
66
+ ]);
67
+
68
+ // Parenthetical PROSE is not a code call signature. A code-like arg list is
69
+ // empty or contains code punctuation ({ } ( ) : = , . < > quotes); the markers
70
+ // [ ] @ / never appear in the arg snippets drift detection cares about, and
71
+ // bare multi-word English with no code punctuation ("main-agent review fix")
72
+ // is prose.
73
+ function looksProseArgs(inner: string): boolean {
74
+ const s = inner.trim();
75
+ if (!s) return false;
76
+ if (/[[\]@/]/.test(s)) return true;
77
+ if (/[{}():=",.<>'"`]/.test(s)) return false;
78
+ return s.split(/\s+/).length >= 2;
79
+ }
80
+
57
81
  export function extractCallForms(content: string): CallForm[] {
58
82
  const forms: CallForm[] = [];
59
83
  CALL_RE.lastIndex = 0;
@@ -65,6 +89,8 @@ export function extractCallForms(content: string): CallForm[] {
65
89
  if (!paren) continue;
66
90
  const parts = symbol.split(".");
67
91
  const name = parts[parts.length - 1];
92
+ if (FILE_EXT.has(name.toLowerCase())) continue;
93
+ if (looksProseArgs(paren.inner)) continue;
68
94
  const ownerPath = parts.slice(0, -1).join(".");
69
95
  forms.push({
70
96
  symbol,
package/src/types.ts ADDED
@@ -0,0 +1,6 @@
1
+ // Shared memory-row enums. Lives outside index.ts so the pure capture core
2
+ // (src/capture.ts) can reference the admissible values without importing the
3
+ // plugin entry. index.ts feeds these into the tool JSON Schemas; capture.ts
4
+ // uses them to prompt and validate.
5
+ export const TYPES = ["decision", "learning", "preference", "blocker", "context", "pattern"] as const;
6
+ export const STATUSES = ["open", "settled"] as const;