opencode-self-improvement 0.1.0 → 0.2.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.
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
@@ -105,6 +165,11 @@ Ongoing use is just the memory tools. Ask the agent to remember something and it
105
165
  sticks across sessions; check what's stored with `memory_list`, refine with
106
166
  `memory_update`, retract with `memory_forget`, roll back with `memory_undo`.
107
167
 
168
+ `memory_update` takes a `query` that picks the row: its id, a clock fragment such
169
+ as `00:11:25.867`, or a unique substring of its text. Omit it to update the newest
170
+ entry of that scope and type. A `query` matching nothing, or matching several rows,
171
+ writes nothing and says so rather than updating the wrong memory.
172
+
108
173
  ## Admin Tool
109
174
 
110
175
  Editing and deleting memories is easy!
@@ -119,5 +184,6 @@ Run `bun memory-view` in your terminal and visit http://127.0.0.1:8787 in your
119
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. |
120
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. |
121
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`. |
122
188
  | Reset all memories | Delete `~/.config/opencode/memory/`. |
123
189
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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,14 +33,14 @@
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",
43
+ "semantic-release": "^25.0.9",
44
44
  "typescript": "^7.0.2"
45
45
  },
46
46
  "peerDependencies": {
@@ -49,6 +49,7 @@
49
49
  "scripts": {
50
50
  "test": "bun test",
51
51
  "typecheck": "bunx tsc --noEmit -p tsconfig.json",
52
+ "bench": "bun bench/inject-cost.ts",
52
53
  "memory-view": "bun run src/viewer/server.ts"
53
54
  }
54
- }
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,19 +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";
13
- import { loadAll, isActive, markSuperseded, type Memory } from "./memory.ts";
15
+ import { evidenceDir, injectBudget, memoryRoot, dateStr } from "./config.ts";
16
+ import { STATUSES, TYPES } from "./types.ts";
17
+ import {
18
+ clearSuperseded,
19
+ isActive,
20
+ loadAll,
21
+ markSuperseded,
22
+ resolveMemoryTarget,
23
+ snapshotFiles,
24
+ type Memory,
25
+ } from "./memory.ts";
14
26
  import { runWriteGate, type GateContext, type WriteCandidate } from "./gates.ts";
15
27
  import { pruneMemory } from "./prune.ts";
28
+ import { exportMemories, findStaleOpen, importMemories } from "./portable.ts";
16
29
  import { listSnapshots, restoreSnapshot } from "./undo.ts";
17
30
  import { record } from "./evidence.ts";
18
31
  import {
32
+ capToBudget,
33
+ compactionAnchor,
19
34
  contextualPull,
20
35
  formatMemory,
21
36
  relevantKnowledge,
37
+ storeVersion,
22
38
  systemDigest,
23
39
  } from "./inject.ts";
24
40
  import { formatInit, runInit } from "./init.ts";
@@ -36,9 +52,7 @@ import {
36
52
  } from "./triggers.ts";
37
53
  import { getCurrent, setCurrent } from "./state.ts";
38
54
  import { readLines } from "./logfmt.ts";
39
-
40
- const TYPES = ["decision", "learning", "preference", "blocker", "context", "pattern"] as const;
41
- const STATUSES = ["open", "settled"] as const;
55
+ import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
42
56
 
43
57
  // --- minimal JSON Schema helpers (OpenCode V2 tools take JSON Schema) --------
44
58
  type JSONSchema = Record<string, unknown>;
@@ -81,6 +95,7 @@ interface HookReg {
81
95
  }
82
96
  interface PluginCtx {
83
97
  location?: { directory?: string };
98
+ options?: Record<string, unknown>;
84
99
  tool?: {
85
100
  transform: (cb: (editor: ToolEditorLike) => void) => Promise<unknown>;
86
101
  hook: (name: string, cb: (event: any) => Promise<void> | void) => Promise<HookReg | void>;
@@ -95,6 +110,13 @@ interface PluginCtx {
95
110
  text: string;
96
111
  delivery?: "steer" | "queue";
97
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>;
98
120
  };
99
121
  }
100
122
 
@@ -126,11 +148,17 @@ function resultMeta(result: unknown): Record<string, unknown> {
126
148
 
127
149
  const PLUGIN_ID = "opencode-self-improvement";
128
150
 
129
- const plugin = {
130
- id: PLUGIN_ID,
131
- 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> {
132
155
  const liveRoot = ctx?.location?.directory ?? process.cwd();
133
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
+
134
162
  // Mirror the /repo-init slash command into opencode's global commands dir
135
163
  // (idempotent, write-if-missing). OpenCode 2.x loads `*.md` from
136
164
  // ~/.config/opencode/commands, so this is the canonical V2 install path.
@@ -249,24 +277,58 @@ const plugin = {
249
277
 
250
278
  editor.add({
251
279
  name: "memory_update",
252
- description: "Update an existing memory (gated like remember; the prior entry is superseded, not erased).",
280
+ description:
281
+ "Update an existing memory (gated like remember; the prior entry is superseded, not erased). `query` selects which row to update: its exact id (ts), a clock fragment (e.g. HH:MM:SS.mmm), or a unique substring of its content. Omitting `query` targets the most recent active row of scope+type. An unmatched or ambiguous `query` fails closed and writes nothing.",
253
282
  options: direct,
254
283
  input: obj(
255
- { scope: str(), type: en(TYPES), content: str(), query: str(), status: en(STATUSES) },
284
+ {
285
+ scope: str(),
286
+ type: en(TYPES),
287
+ content: str(),
288
+ query: withDesc(
289
+ str(),
290
+ "Row selector: id (ts), clock fragment, or unique content substring. Omit to target the newest active row of scope+type.",
291
+ ),
292
+ status: en(STATUSES),
293
+ },
256
294
  ["scope", "type", "content"],
257
295
  ),
258
296
  async execute(args: any): Promise<{ content: string }> {
259
297
  const prior = (await loadAll()).filter(
260
298
  (m) => m.scope === args.scope && m.type === args.type && isActive(m),
261
299
  );
262
- const target = prior[prior.length - 1];
300
+ // Resolve the row BEFORE the gate: an update that cannot identify its
301
+ // target must write nothing, rather than silently superseding whichever
302
+ // row happened to be newest in this scope+type.
303
+ const resolved = resolveMemoryTarget(prior, args.query);
304
+ if (resolved.status === "not_found") {
305
+ const where = args.query ? ` matches ${JSON.stringify(args.query)}` : "";
306
+ return {
307
+ content: `memory_update blocked: no active ${args.type} row in ${args.scope}${where}. Nothing was written.`,
308
+ };
309
+ }
310
+ if (resolved.status === "ambiguous") {
311
+ return {
312
+ content:
313
+ `memory_update blocked: ${JSON.stringify(args.query)} matches ${resolved.candidates.length} active ` +
314
+ `${args.type} rows in ${args.scope} (${resolved.candidates.map((m) => m.ts).join(", ")}). ` +
315
+ "Pass a row id or a unique content substring. Nothing was written.",
316
+ };
317
+ }
318
+ const target = resolved.target;
263
319
  const outcome = await runWriteGate(
264
320
  { type: args.type, scope: args.scope, content: args.content, status: args.status },
265
321
  gateContext(),
266
322
  );
267
323
  if (outcome.action === "promoted" && outcome.ts && target) {
268
- await markSuperseded(target.ts, { superseded_by: outcome.ts, reason: "update" });
269
- return { content: `Updated ${args.type} in ${args.scope}` };
324
+ await markSuperseded(target.ts, {
325
+ superseded_by: outcome.ts,
326
+ reason: "update",
327
+ symbol: target.ts,
328
+ from: target.ts,
329
+ to: outcome.ts,
330
+ });
331
+ return { content: `Updated ${args.type} in ${args.scope} (superseded ${target.ts})` };
270
332
  }
271
333
  if (outcome.action === "promoted") return { content: `Remembered: ${args.type} in ${args.scope}` };
272
334
  if (outcome.action === "collapsed") return { content: `Duplicate ignored: ${args.type}/${args.scope}` };
@@ -348,7 +410,7 @@ const plugin = {
348
410
  editor.add({
349
411
  name: "memory_merge_review",
350
412
  description:
351
- "Resolve an open merge/review gate non-destructively: mark the loser as superseded_by the survivor. Both rows stay active data (the loser is recoverable via memory_undo); nothing is deleted. Fails closed (no partial apply) when the reason or the survivor id is missing, or the loser is a canonical/settled/thin row that must not be superseded.",
413
+ "Resolve an open merge/review gate non-destructively: mark the loser as superseded_by the survivor, and clear any supersession the survivor itself carries so the canonical row stays recallable. Both rows stay active data (the loser is recoverable via memory_undo); nothing is deleted. Fails closed (no partial apply) when the reason or the survivor id is missing, or the loser is already superseded.",
352
414
  options: direct,
353
415
  input: obj(
354
416
  {
@@ -372,16 +434,136 @@ const plugin = {
372
434
  if (!ok) {
373
435
  return { content: "merge_review: loser not found or already superseded — nothing to resolve. No evidence written." };
374
436
  }
437
+ // The survivor may itself have been superseded earlier (e.g. by a row
438
+ // this very call just made the loser). Declaring it canonical has to
439
+ // make it recallable, so clear its own marker too.
440
+ const revived = await clearSuperseded(args.survivor);
375
441
  return {
376
- content: `Resolved merge: ${args.survivor} is canonical; ${args.loser} superseded (recoverable via memory_undo). Evidence recorded for reason='${args.reason}'.`,
442
+ content:
443
+ `Resolved merge: ${args.survivor} is canonical; ${args.loser} superseded (recoverable via memory_undo). ` +
444
+ (revived ? `Survivor's own supersession cleared, it is active again. ` : "") +
445
+ `Evidence recorded for reason='${args.reason}'.`,
446
+ };
447
+ },
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.",
377
556
  };
378
557
  },
379
558
  });
380
559
  });
381
560
 
382
- // Per-session durable-memory blocks stashed by the prompt hook and pushed
383
- // onto the system prompt by the context hook (invisible to the user).
384
- 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 }>();
385
567
 
386
568
  // ---- hooks (all fail-closed) -------------------------------------------
387
569
  // Bookkeeping: track the active session/tool/call before a tool runs.
@@ -421,41 +603,158 @@ const plugin = {
421
603
  const parts: string[] = [];
422
604
  await systemDigest({ sessionID: event?.sessionID }, { system: parts });
423
605
  for (const text of parts) event?.system?.push?.({ type: "text", text });
424
- // Deliver this turn's relevance-ranked memory via the system channel.
606
+ // Deliver this epoch's relevance-ranked memory via the system channel.
425
607
  // The UI never renders system parts, unlike appended prompt text.
426
608
  const sid = event?.sessionID;
427
- const knowledge = typeof sid === "string" ? pendingKnowledge.get(sid) : undefined;
609
+ const knowledge = typeof sid === "string" ? epoch.get(sid)?.block : undefined;
428
610
  if (knowledge) event?.system?.push?.({ type: "text", text: knowledge });
429
611
  }),
430
612
  );
431
613
 
432
- // Memory-first injection: compute relevance against each user prompt and
433
- // stash the block for invisible delivery via the context hook. Never mutate
434
- // 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.
435
620
  await ctx.session?.hook("prompt", (event: any) =>
436
621
  guarded("session.prompt (inject)", async () => {
437
622
  const sid = event?.sessionID;
438
623
  if (typeof sid === "string") {
439
624
  markTurn(sid);
440
625
  await checkNewSession(sid);
441
- }
442
- const text = event?.prompt?.text ?? "";
443
- const block = await relevantKnowledge(text);
444
- if (typeof sid === "string") {
445
- if (block) pendingKnowledge.set(sid, block);
446
- 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
+ }
447
643
  }
448
644
  }),
449
645
  );
450
646
 
451
- // 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.
452
653
  await ctx.session?.hook("compaction", (event: any) =>
453
654
  guarded("session.compaction (onCompacting)", async () => {
454
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 });
455
659
  }),
456
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;
457
742
  },
458
743
  };
459
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
+
460
759
  export const SelfImprovement = plugin;
461
760
  export default plugin;
package/src/inject.ts CHANGED
@@ -135,6 +135,65 @@ 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 lines while the joined length stays <= budget, drop the rest, never cut
151
+ // mid-line. Returns "" when even the header line exceeds the budget (the block
152
+ // is not worth injecting).
153
+ export function capToBudget(block: string, budget: number): string {
154
+ const kept: string[] = [];
155
+ let len = 0;
156
+ for (const line of block.split("\n")) {
157
+ const add = kept.length ? line.length + 1 : line.length;
158
+ if (len + add > budget) break;
159
+ kept.push(line);
160
+ len += add;
161
+ }
162
+ return kept.join("\n");
163
+ }
164
+
165
+ // Memory-aware compaction (Phase 3): a durable-memory block pushed onto the
166
+ // V2 compaction hook's `system` parts, where the summarizer model can see it
167
+ // (OpenCode appends its own summary prompt AFTER hooks). Deterministic, zero
168
+ // extra model calls; fixes "compaction amnesia". Composition: up to 10 open
169
+ // threads newest-first, then up to 5 newest settled memories, total rows
170
+ // capped by `limit`; the whole block capped at 2500 chars at whole-line
171
+ // boundaries. "" when the store has no active memories.
172
+ const ANCHOR_OPEN_LIMIT = 10;
173
+ const ANCHOR_SETTLED_LIMIT = 5;
174
+ const ANCHOR_BUDGET = 2500;
175
+
176
+ export async function compactionAnchor(limit = 12): Promise<string> {
177
+ const mems = (await loadAll()).filter(isActive);
178
+ if (!mems.length) return "";
179
+ const byNewest = (a: Memory, b: Memory) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
180
+ const open = mems
181
+ .filter((m) => m.status === "open")
182
+ .sort(byNewest)
183
+ .slice(0, Math.min(ANCHOR_OPEN_LIMIT, Math.max(0, limit)));
184
+ const settled = mems
185
+ .filter((m) => m.status !== "open")
186
+ .sort(byNewest)
187
+ .slice(0, Math.min(ANCHOR_SETTLED_LIMIT, Math.max(0, limit - open.length)));
188
+ const rows = [...open, ...settled];
189
+ if (!rows.length) return "";
190
+ const block =
191
+ "## Durable memory to preserve in this summary\n" +
192
+ "Preserve these facts verbatim (dates, commands, ids) in the compaction summary:\n" +
193
+ rows.map(formatMemory).join("\n");
194
+ return capToBudget(block, ANCHOR_BUDGET);
195
+ }
196
+
138
197
  export async function contextualPull(input: PullInput, output: PullOutput): Promise<void> {
139
198
  const tool = typeof input.tool === "string" ? input.tool : "";
140
199
  if (!["grep", "glob", "read"].includes(tool)) return;
package/src/memory.ts CHANGED
@@ -140,6 +140,18 @@ export interface SupersedeFields {
140
140
  entity?: string;
141
141
  }
142
142
 
143
+ // Copy `file` into snapshots/ before a mutation. Names follow
144
+ // `<stamp>-<original>` because /undo strips the stamp to find its target, so a
145
+ // same-millisecond collision would clobber an earlier, still-undoable snapshot
146
+ // (supersede + clear in one merge_review can land in the same millisecond).
147
+ async function snapshotBefore(file: string): Promise<void> {
148
+ await ensureDir(snapshotDir());
149
+ let stamp = Date.now();
150
+ let snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
151
+ while (await Bun.file(snap).exists()) snap = join(snapshotDir(), `${++stamp}-${basename(file)}`);
152
+ await Bun.write(snap, (await Bun.file(file).text()) || "");
153
+ }
154
+
143
155
  // Mark an existing memory as superseded IN PLACE (entry retained, recoverable).
144
156
  // Idempotent: a second call is a no-op, so no double `superseded_by`.
145
157
  export async function markSuperseded(ts: string, fields: SupersedeFields): Promise<boolean> {
@@ -161,15 +173,87 @@ export async function markSuperseded(ts: string, fields: SupersedeFields): Promi
161
173
  entity: fields.entity,
162
174
  });
163
175
  lines[idx] = `${lines[idx]} ${extra}`;
164
- await ensureDir(snapshotDir());
165
- const snap = join(snapshotDir(), `${Date.now()}-${basename(file)}`);
166
- await Bun.write(snap, (await Bun.file(file).text()) || "");
176
+ await snapshotBefore(file);
177
+ await Bun.write(file, lines.join("\n") + "\n");
178
+ return true;
179
+ }
180
+ return false;
181
+ }
182
+
183
+ // Drop a row's supersession markers IN PLACE (snapshot-first), making it active
184
+ // again. Used by memory_merge_review: a declared survivor must be recallable
185
+ // even when an earlier row — now the loser — had superseded it first.
186
+ export async function clearSuperseded(ts: string): Promise<boolean> {
187
+ for (const file of await memoryFiles()) {
188
+ const lines = await readLines(file);
189
+ const idx = lines.findIndex((l) => {
190
+ const f = parseLine(l);
191
+ return f && f.ts === ts;
192
+ });
193
+ if (idx === -1) continue;
194
+ const parsed = parseLine(lines[idx]);
195
+ if (!parsed || typeof parsed.superseded_by !== "string") return false;
196
+ // These five keys are written only by markSuperseded (drift, canonical,
197
+ // update and merge_review), never by appendMemory, so they are all part of
198
+ // the marker and go with it. Field order round-trips because parseLine
199
+ // inserts keys in source order.
200
+ for (const key of ["superseded_by", "reason", "symbol", "from", "to", "entity"]) {
201
+ delete parsed[key];
202
+ }
203
+ lines[idx] = formatLine(parsed);
204
+ await snapshotBefore(file);
167
205
  await Bun.write(file, lines.join("\n") + "\n");
168
206
  return true;
169
207
  }
170
208
  return false;
171
209
  }
172
210
 
211
+ export type TargetResolution =
212
+ | { status: "ok"; target: Memory }
213
+ | { status: "not_found" }
214
+ | { status: "ambiguous"; candidates: Memory[] };
215
+
216
+ // Score one row against a query: exact row id first, then content.
217
+ function targetScore(m: Memory, query: string): number {
218
+ const ts = m.ts.toLowerCase();
219
+ if (ts === query) return 100;
220
+ // A clock fragment ("00:11:25.867") is unique enough to identify a row.
221
+ if (query.length >= 8 && ts.includes(query)) return 95;
222
+ const content = m.content.toLowerCase();
223
+ if (content === query) return 90;
224
+ if (content.includes(query)) return 80;
225
+ if (query.includes(content)) return 70;
226
+ return 0;
227
+ }
228
+
229
+ // Pick the row an update/merge call means. Without a query the caller keeps the
230
+ // legacy "most recent active row of this scope+type" behaviour; with one, a
231
+ // miss or a tie fails CLOSED rather than silently rewriting a different row.
232
+ export function resolveMemoryTarget(rows: Memory[], query?: string): TargetResolution {
233
+ if (!query || !query.trim()) {
234
+ const last = rows[rows.length - 1];
235
+ return last ? { status: "ok", target: last } : { status: "not_found" };
236
+ }
237
+ const q = query.trim().toLowerCase();
238
+ let best: Memory | undefined;
239
+ let bestScore = 0;
240
+ const tied: Memory[] = [];
241
+ for (const m of rows) {
242
+ const score = targetScore(m, q);
243
+ if (score > bestScore) {
244
+ best = m;
245
+ bestScore = score;
246
+ tied.length = 0;
247
+ tied.push(m);
248
+ } else if (score === bestScore && score > 0) {
249
+ tied.push(m);
250
+ }
251
+ }
252
+ if (!best) return { status: "not_found" };
253
+ if (tied.length > 1) return { status: "ambiguous", candidates: tied };
254
+ return { status: "ok", target: best };
255
+ }
256
+
173
257
  export interface DeletedMemory {
174
258
  memory: Memory;
175
259
  reason: string;
@@ -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/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;