opencode-self-improvement 0.2.0 → 0.2.2

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
@@ -26,6 +26,11 @@ LLMs waste a lot of tokens reading and re-reading your code and asking you for c
26
26
  cheap model pass that proposes memory candidates; every candidate still faces
27
27
  the same write gates. Off by default, so the install stays zero-token.
28
28
  - Automatically de-dupes similar memories and avoids contradictions by searching for existing memories before saving new ones.
29
+ - **Write gates** — every memory passes 4 checks before it sticks: code references are
30
+ verified against your actual repo, near-duplicates are collapsed, contradictions with
31
+ superseded memories are rejected. Blocked writes are logged with a reason
32
+ (`memory_evidence`), and nothing is ever deleted — updates supersede the old row and
33
+ `memory_undo` rolls back.
29
34
  - Automatically looks for insights when you complete the verification gates (tests, linting, etc)
30
35
  - Always defaults to memories first before sending a prompt to the LLM, which saves tokens!
31
36
 
@@ -60,6 +65,52 @@ output):
60
65
  bun bench/inject-cost.ts
61
66
  ```
62
67
 
68
+ ## Recall quality — no models required
69
+
70
+ Memory plugins get told "keyword matching isn't good enough," and the usual
71
+ prescription is a vector database or a local embedding model. This plugin ships
72
+ neither — no model, no download, no API key, ever. The question is what that
73
+ actually costs you, so we measured it:
74
+
75
+ ```sh
76
+ bun bench:recall
77
+ ```
78
+
79
+ A deterministic, auditable eval of the **shipped** matcher against classic
80
+ BM25, on query classes assigned by *measured* vocabulary overlap with the
81
+ right memory (store of 200; k=3 = the production injection limit):
82
+
83
+ | Query, vs. the stored memory | share | R@1 | R@3 |
84
+ | --- | ---: | ---: | ---: |
85
+ | lexical — reuses your project's words | 46% | 100% | 100% |
86
+ | near — reworded, one overlap | 17% | 44% | 83% |
87
+ | strict — zero shared words | 37% | 0% | 0% |
88
+
89
+ What the benchmark bought us:
90
+
91
+ - **In-project prompts are overwhelmingly lexical** — you keep saying
92
+ `bun test`, "the rate limiter", "staging deploys". The shipped matcher
93
+ nails those: 100% R@3, flat from store size 50 to 500.
94
+ - **It caught a real bug.** Function words ("the", "what", "how") counted as
95
+ matches, so nearly half the time the top memory was the wrong one. The
96
+ matcher now ignores them on the query side: wrong-first rate fell 49% → 34%,
97
+ and classic BM25 — a tuned scoring algorithm — no longer beats the shipped
98
+ matcher on any class except the hopeless one.
99
+ - **Strict paraphrase is the real lexical wall**, and we won't pretend
100
+ otherwise. Nothing keyword-side crosses it. So instead of confident noise the
101
+ matcher now goes *silent* when the vocabulary shares nothing (a third of
102
+ strict queries inject nothing at all), and the agent's `memory_recall` loop
103
+ remains as the pull path. Embeddings are the only known crossing — the bench
104
+ exposes a scorer seam if you want to measure them — but requiring an
105
+ inference engine just to remember things is a non-starter for a zero-dependency
106
+ plugin.
107
+ - Caveats, honestly: the dataset is synthetic (52 memories / 105 queries, a
108
+ fictional project), its class mix is deliberately adversarial (far richer in
109
+ zero-overlap paraphrases than real prompts), and scoring is single-gold.
110
+
111
+ Full methodology, all store sizes: `bench/recall-results.md`
112
+ (regenerate with `bun bench:recall`).
113
+
63
114
  ## Quick start
64
115
 
65
116
  1. Add the plugin to your Opencode config (see install below) and restart Opencode.
@@ -73,13 +124,13 @@ That's it. Rerun `/repo-init force` after a refactor to refresh.
73
124
 
74
125
  ## How to install
75
126
 
76
- Add a `plugin` entry to your opencode config —
77
- `~/.config/opencode/opencode.json` (global) or any project `opencode.json`:
127
+ Add a `plugins` entry to your opencode config —
128
+ `~/.config/opencode/opencode.jsonc` (global) or any project `opencode.jsonc`:
78
129
 
79
130
  ```json
80
131
  {
81
132
  "$schema": "https://opencode.ai/config.json",
82
- "plugin": ["opencode-self-improvement@0.1.1"]
133
+ "plugins": ["opencode-self-improvement@0.2.1"]
83
134
  }
84
135
  ```
85
136
 
@@ -93,7 +144,7 @@ bump the version in the config and restart. On Opencode v2 you can also manage
93
144
  it with the CLI:
94
145
 
95
146
  ```sh
96
- opencode plugin add opencode-self-improvement@0.1.1
147
+ opencode plugin add opencode-self-improvement@0.2.1
97
148
  opencode plugin update opencode-self-improvement
98
149
  ```
99
150
 
@@ -106,12 +157,12 @@ cd opencode-self-improvement
106
157
  bun install
107
158
  ```
108
159
 
109
- Then point the `plugin` entry at the local file instead:
160
+ Then point the `plugins` entry at the local file instead:
110
161
 
111
162
  ```json
112
163
  {
113
164
  "$schema": "https://opencode.ai/config.json",
114
- "plugin": ["/full/path/to/opencode-self-improvement/index.ts"]
165
+ "plugins": ["/full/path/to/opencode-self-improvement/index.ts"]
115
166
  }
116
167
  ```
117
168
 
@@ -170,6 +221,36 @@ as `00:11:25.867`, or a unique substring of its text. Omit it to update the newe
170
221
  entry of that scope and type. A `query` matching nothing, or matching several rows,
171
222
  writes nothing and says so rather than updating the wrong memory.
172
223
 
224
+ ## Backup & move between machines
225
+
226
+ `memory_export` writes the live store as JSONL — one JSON object per line
227
+ (`{ts,type,scope,content,status,tags,issue}`), active rows only (superseded
228
+ history stays in the store). `memory_import` reads such a file back. Defaults
229
+ to `<memory-root>/exports/<timestamp>.jsonl`; pass `file` to choose the path
230
+ and `scope` to export just one scope.
231
+
232
+ In practice you just ask the agent:
233
+
234
+ ```
235
+ Export my memories to ~/memories.jsonl # memory_export
236
+ # …copy the file to the other machine…
237
+ Import ~/memories.jsonl # memory_import
238
+ ```
239
+
240
+ Import is safe by construction: every row re-enters through the same 4-gate
241
+ write pipeline as `memory_remember` (tagged `import`, original tags preserved),
242
+ and the tool reports a tally of four outcomes per line —
243
+
244
+ | Outcome | Meaning |
245
+ | --- | --- |
246
+ | `promoted` | passed the gates and was stored |
247
+ | `collapsed` | near-duplicate of an existing memory; nothing written |
248
+ | `blocked` | rejected by a gate; reason is in `memory_evidence` |
249
+ | `skipped` | malformed line; never reaches the gates |
250
+
251
+ so re-running the same import is a no-op (everything collapses). Copying an
252
+ export to a fresh machine is how you seed a new laptop from an old one.
253
+
173
254
  ## Admin Tool
174
255
 
175
256
  Editing and deleting memories is easy!
@@ -180,7 +261,8 @@ Run `bun memory-view` in your terminal and visit http://127.0.0.1:8787 in your
180
261
 
181
262
  | Problem | Fix |
182
263
  | --- | --- |
183
- | `memory_*` tools not available | The plugin didn't load. Restart opencode and check the `plugin` entry in your config. |
264
+ | `memory_*` tools not available | The plugin didn't load. Restart opencode and check the `plugins` entry in your config. |
265
+ | `memory_remember` says blocked | A gate rejected the write (stale code reference, a cited `path/to/file.ts` that doesn't exist in the repo, overlap with a superseded memory, or an ambiguous claim). The reason is logged — check `memory_evidence`, then rephrase against current code and retry. |
184
266
  | `/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. |
185
267
  | 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. |
186
268
  | 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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": {
@@ -50,6 +50,7 @@
50
50
  "test": "bun test",
51
51
  "typecheck": "bunx tsc --noEmit -p tsconfig.json",
52
52
  "bench": "bun bench/inject-cost.ts",
53
+ "bench:recall": "bun bench/recall-quality.ts",
53
54
  "memory-view": "bun run src/viewer/server.ts"
54
55
  }
55
56
  }
package/src/gates.ts CHANGED
@@ -440,6 +440,14 @@ export async function runWriteGate(
440
440
  if (g1.candidateStale) {
441
441
  blockReason = "drift_ref_stale_incoming";
442
442
  blockFlags = ["incoming_embeds_pre_drift_ref"];
443
+ } else if (g1.missingPaths.length && !planningObject) {
444
+ // A settled claim about code must point at files that exist. Live demo
445
+ // 2026-10-20: a memory citing src/ghost_pipeline_removed.ts (never existed)
446
+ // was promoted on trust. Open planning threads are exempt — plans routinely
447
+ // cite files not yet created.
448
+ blockReason = "ref_path_missing";
449
+ blockFlags = [`missing=${g1.missingPaths.join(",")}`];
450
+ hint = `Path ref(s) not found in the repo: ${g1.missingPaths.join(", ")}. Rephrase against the current tree (drop the path or fix it) and retry; use status="open" if the file is planned, not existing.`;
443
451
  } else if (canon && !canon.aligns) {
444
452
  blockReason = "conflicts_with_canonical";
445
453
  blockFlags = [
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // the note on `plugin` below). All business logic still lives in the decoupled
12
12
  // helper modules.
13
13
  import type { Cleanup, Plugin } from "@opencode/plugin/promise/plugin";
14
- import { join } from "node:path";
14
+ import { basename, join } from "node:path";
15
15
  import { evidenceDir, injectBudget, memoryRoot, dateStr } from "./config.ts";
16
16
  import { STATUSES, TYPES } from "./types.ts";
17
17
  import {
@@ -375,16 +375,34 @@ const impl = {
375
375
 
376
376
  editor.add({
377
377
  name: "memory_undo",
378
- description: "List memory snapshots, or restore one by name (rollback).",
378
+ description:
379
+ "List memory snapshots, or restore one (rollback). SAFE BY DEFAULT: daily store files are shared across sessions, so if the current file has rows added or edited since the snapshot, restore REFUSES and lists them — re-run with merge=true to restore AND keep those rows, or force=true for the exact rollback. `snapshot` accepts a bare name from the list or a full path.",
379
380
  options: direct,
380
381
  input: obj({
381
382
  list: withDesc(bool(), "List snapshots"),
382
- snapshot: withDesc(str(), "Snapshot file to restore"),
383
+ snapshot: withDesc(str(), "Snapshot to restore (bare name from list, or a full path)"),
384
+ merge: withDesc(bool(), "With snapshot: keep rows written after the snapshot instead of refusing"),
385
+ force: withDesc(bool(), "With snapshot: exact rollback even if the file changed since"),
383
386
  }),
384
387
  async execute(args: any): Promise<{ content: string }> {
385
388
  if (args.snapshot) {
386
- const target = await restoreSnapshot(args.snapshot, memoryRoot());
387
- return { content: `Restored ${args.snapshot} -> ${target}` };
389
+ const mode = args.force ? "force" : args.merge ? "merge" : "check";
390
+ const res = await restoreSnapshot(args.snapshot, memoryRoot(), mode);
391
+ if (!res.restored) {
392
+ const parts = [...res.concurrentAdded, ...res.concurrentChanged];
393
+ return {
394
+ content:
395
+ `Undo refused: ${res.concurrentAdded.length} row(s) added and ` +
396
+ `${res.concurrentChanged.length} changed since this snapshot — restoring it ` +
397
+ `would clobber them. Divergent rows (ts): ${parts.join(", ")}. ` +
398
+ `If those belong to another session, re-run with merge=true (restore the file ` +
399
+ `AND keep them); if they are the writes you are undoing, use force=true.`,
400
+ };
401
+ }
402
+ const kept = res.mode === "merge" && (res.concurrentAdded.length || res.concurrentChanged.length)
403
+ ? ` Kept ${res.concurrentAdded.length} added / ${res.concurrentChanged.length} changed row(s) written after the snapshot.`
404
+ : "";
405
+ return { content: `Restored ${basename(res.target)} from ${res.mode} snapshot -> ${res.target}.${kept}` };
388
406
  }
389
407
  const snaps = await listSnapshots();
390
408
  if (!snaps.length) return { content: "No snapshots" };
package/src/inject.ts CHANGED
@@ -43,15 +43,29 @@ function argText(args: unknown): string {
43
43
  return "";
44
44
  }
45
45
 
46
- function words(text: string): string[] {
46
+ export function words(text: string): string[] {
47
47
  return text
48
48
  .toLowerCase()
49
49
  .split(/[^a-z0-9_]+/)
50
50
  .filter((t) => t.length > 2);
51
51
  }
52
52
 
53
- function scoreMemories(term: string, mems: Memory[], limit: number): Memory[] {
54
- const query = new Set(words(term));
53
+ // Query-side stopwords: bag-of-words scoring has no IDF, so "the/what/how"
54
+ // match a large share of every memory and let a query with ZERO content
55
+ // overlap rank (recall bench 2026-10-20 measured 49% wrong-top-1). Dropping
56
+ // them makes the matcher abstain — inject nothing — when the vocabulary
57
+ // genuinely has nothing in common, instead of injecting confident noise.
58
+ // Memory-side tokens are never filtered: a memory stating "the" must not
59
+ // gain or lose rank from it, only queries drive matching.
60
+ const QUERY_STOPWORDS = new Set(
61
+ "a an and are at be but by can did do does for from had has have how i in into is it its me my no not of on or our so than that the their them then there these they this to us was we were what when where which who why will with you your run runs getting want needs right back yet also just still ever".split(" "),
62
+ );
63
+
64
+ // Production relevance ranking: score = number of distinct query tokens the
65
+ // memory's (scope+type+content) token set contains. Exported for
66
+ // bench/recall-quality.ts — the benchmark must measure the shipped matcher.
67
+ export function scoreMemories(term: string, mems: Memory[], limit: number): Memory[] {
68
+ const query = new Set(words(term).filter((t) => !QUERY_STOPWORDS.has(t)));
55
69
  if (!query.size) return [];
56
70
  return mems
57
71
  .map((m) => {
@@ -147,18 +161,23 @@ export async function storeVersion(): Promise<string> {
147
161
  }
148
162
 
149
163
  // 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).
164
+ // keep every line that still fits (skipping over-long ones), never cut
165
+ // mid-line. Returns "" when even the header line exceeds the budget, and ""
166
+ // when NOTHING but header lines survived — a dangling "## Relevant durable
167
+ // memory" over zero memories is noise, not memory (bug seen live: a store
168
+ // whose top matches all exceed the budget injected exactly the 26-char header).
153
169
  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;
170
+ const lines = block.split("\n");
171
+ // The header gives the block its meaning; if it does not fit, nothing does.
172
+ if (lines[0].length > budget) return "";
173
+ const kept: string[] = [lines[0]];
174
+ let len = lines[0].length;
175
+ for (const line of lines.slice(1)) {
176
+ if (len + line.length + 1 > budget) continue; // skip over-long lines, keep scanning
159
177
  kept.push(line);
160
- len += add;
178
+ len += line.length + 1;
161
179
  }
180
+ if (kept.every((l) => l.startsWith("## "))) return "";
162
181
  return kept.join("\n");
163
182
  }
164
183
 
package/src/memory.ts CHANGED
@@ -263,12 +263,20 @@ export interface DeletedMemory {
263
263
  // restore it. Returns the snapshot paths keyed by original file.
264
264
  export async function snapshotFiles(files: string[]): Promise<string[]> {
265
265
  await ensureDir(snapshotDir());
266
- const stamp = Date.now();
266
+ let stamp = Date.now();
267
267
  const snaps: string[] = [];
268
268
  for (const file of files) {
269
269
  const f = Bun.file(file);
270
270
  if (!(await f.exists())) continue;
271
- const snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
271
+ // Names must be unique. A `${Date.now()}-` stamp collides whenever two
272
+ // mutations happen in the same millisecond — live CI proved a viewer
273
+ // restore's backup overwrote the very snapshot it was about to read,
274
+ // restoring post-edit content. Bump until free; never clobber a snapshot.
275
+ let snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
276
+ while (await Bun.file(snap).exists()) {
277
+ stamp++;
278
+ snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
279
+ }
272
280
  await Bun.write(snap, await f.text());
273
281
  snaps.push(snap);
274
282
  }
package/src/resolver.ts CHANGED
@@ -26,6 +26,10 @@ export interface Gate1Result {
26
26
  resolved: string[];
27
27
  drift: DriftInfo | null;
28
28
  candidateStale: boolean;
29
+ // Directory-qualified path refs that do NOT exist under liveRoot. A settled
30
+ // claim citing one is hallucinated or machine-specific data (live demo
31
+ // 2026-10-20: src/ghost_pipeline_removed.ts passed on trust).
32
+ missingPaths: string[];
29
33
  }
30
34
 
31
35
  export function balancedParen(text: string, openIdx: number): { inner: string; end: number } | null {
@@ -54,6 +58,30 @@ export function balancedParen(text: string, openIdx: number): { inner: string; e
54
58
 
55
59
  const CALL_RE = /([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+)\s*\(/g;
56
60
 
61
+ // Last dotted segment is a known file extension: `package.json(committed by…)`
62
+ // is a file reference followed by parenthetical prose, not a call to a method
63
+ // named `json`. This class produced 9 false drift_ref_stale_incoming blocks in
64
+ // live use before the guard existed.
65
+ const FILE_EXT = new Set([
66
+ "json", "jsonc", "jsonl", "logfmt", "md", "mdx", "ts", "tsx", "js", "jsx",
67
+ "mjs", "cjs", "py", "go", "rs", "rb", "java", "kt", "sh", "bash", "zsh",
68
+ "yaml", "yml", "toml", "ini", "conf", "cfg", "txt", "csv", "html", "htm",
69
+ "css", "scss", "xml", "sql", "tf", "lock", "log", "env",
70
+ ]);
71
+
72
+ // Parenthetical PROSE is not a code call signature. A code-like arg list is
73
+ // empty or contains code punctuation ({ } ( ) : = , . < > quotes); the markers
74
+ // [ ] @ / never appear in the arg snippets drift detection cares about, and
75
+ // bare multi-word English with no code punctuation ("main-agent review fix")
76
+ // is prose.
77
+ function looksProseArgs(inner: string): boolean {
78
+ const s = inner.trim();
79
+ if (!s) return false;
80
+ if (/[[\]@/]/.test(s)) return true;
81
+ if (/[{}():=",.<>'"`]/.test(s)) return false;
82
+ return s.split(/\s+/).length >= 2;
83
+ }
84
+
57
85
  export function extractCallForms(content: string): CallForm[] {
58
86
  const forms: CallForm[] = [];
59
87
  CALL_RE.lastIndex = 0;
@@ -65,6 +93,8 @@ export function extractCallForms(content: string): CallForm[] {
65
93
  if (!paren) continue;
66
94
  const parts = symbol.split(".");
67
95
  const name = parts[parts.length - 1];
96
+ if (FILE_EXT.has(name.toLowerCase())) continue;
97
+ if (looksProseArgs(paren.inner)) continue;
68
98
  const ownerPath = parts.slice(0, -1).join(".");
69
99
  forms.push({
70
100
  symbol,
@@ -83,6 +113,43 @@ export function extractPathRefs(content: string): string[] {
83
113
  return [...content.matchAll(PATH_RE)].map((m) => m[0]);
84
114
  }
85
115
 
116
+ // Directory-qualified, repo-relative path refs whose existence gate1 can check
117
+ // against liveRoot. Skipped: bare names (`undo.ts` may live anywhere),
118
+ // absolute paths and `..` (another checkout's truth), URLs (`//`), and refs
119
+ // whose match starts just after `$` or `~` — env vars and home paths describe
120
+ // another machine, e.g. in `$BUILD_ROOT/src/a.ts` the regex match is
121
+ // `BUILD_ROOT/src/a.ts`, which the preceding-char check drops.
122
+ export function checkablePathRefs(content: string): string[] {
123
+ const out: string[] = [];
124
+ for (const m of content.matchAll(PATH_RE)) {
125
+ const ref = m[0].replace(/:\d+$/, "");
126
+ const prev = m.index && m.index > 0 ? content[m.index - 1] : "";
127
+ if (prev === "$" || prev === "~" || prev === "/") continue;
128
+ if (!ref.includes("/")) continue;
129
+ if (ref.includes("//") || ref.startsWith("/") || ref.split("/").includes("..")) continue;
130
+ // A dotted first segment reads as a hostname (opencode.ai/x/index.ts), not
131
+ // a repo directory. `.` itself is the legitimate relative root prefix.
132
+ const first = ref.split("/")[0];
133
+ if (first.includes(".") && first !== ".") continue;
134
+ if (!out.includes(ref)) out.push(ref);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ async function isDirectory(path: string): Promise<boolean> {
140
+ try {
141
+ // A listing that yields even one entry proves liveRoot is a real tree;
142
+ // a missing path throws ENOENT/ENOTDIR and fails open (nothing verifiable).
143
+ for (const _ of new Bun.Glob("*").scanSync({ onlyFiles: false, cwd: path })) {
144
+ void _;
145
+ return true;
146
+ }
147
+ return false;
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
86
153
  function splitTopLevel(argsText: string): string[] {
87
154
  const parts: string[] = [];
88
155
  let depth = 0;
@@ -258,11 +325,21 @@ export async function gate1(input: ResolveInput): Promise<Gate1Result> {
258
325
  const forms = extractCallForms(input.content);
259
326
  const pathRefs = extractPathRefs(input.content);
260
327
  const refs = forms.length + pathRefs.length;
261
- if (refs === 0) return { refs: 0, resolved: [], drift: null, candidateStale: false };
328
+ if (refs === 0) return { refs: 0, resolved: [], drift: null, candidateStale: false, missingPaths: [] };
262
329
 
263
330
  let drift: DriftInfo | null = null;
264
331
  const resolved: string[] = [];
265
332
 
333
+ // Existence check for repo-relative path refs. When liveRoot is not a real
334
+ // tree (unit tests, deleted worktree) nothing is verifiable, so report no
335
+ // missing paths rather than flagging everything.
336
+ const missingPaths: string[] = [];
337
+ if (await isDirectory(input.liveRoot)) {
338
+ for (const ref of checkablePathRefs(input.content)) {
339
+ if (!(await Bun.file(join(input.liveRoot, ref)).exists())) missingPaths.push(ref);
340
+ }
341
+ }
342
+
266
343
  for (const form of forms) {
267
344
  resolved.push(form.symbol);
268
345
  const live = await findLiveCall(form.name, form.fileHint, input.liveRoot);
@@ -294,5 +371,6 @@ export async function gate1(input: ResolveInput): Promise<Gate1Result> {
294
371
  resolved,
295
372
  drift,
296
373
  candidateStale: drift?.source === "candidate",
374
+ missingPaths,
297
375
  };
298
376
  }
package/src/undo.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  // Snapshot/rollback (/undo analog). Snapshots are written by memory.ts before
2
2
  // any mutation; restore copies one back over its original file.
3
+ //
4
+ // Daily logfmt files are SHARED across concurrent sessions, so a naive
5
+ // whole-file write can silently clobber rows another session appended after
6
+ // the snapshot (live data loss observed 2026-10-20). Restore is therefore
7
+ // conflict-aware. The snapshot cannot tell "rows I added since the snapshot,
8
+ // which undo should remove" apart from "rows another session added, which
9
+ // must survive" — that call belongs to the caller, so the default mode CHECK
10
+ // refuses and lists the divergent rows; MERGE replays them on top of the
11
+ // snapshot; FORCE is the old exact rollback.
3
12
  import { join, basename } from "node:path";
4
13
  import { snapshotDir } from "./config.ts";
5
14
  import { ensureDir } from "./logfmt.ts";
@@ -30,15 +39,78 @@ export async function listSnapshots(): Promise<SnapshotInfo[]> {
30
39
  return out.sort((a, b) => b.mtime - a.mtime);
31
40
  }
32
41
 
42
+ export type RestoreMode = "check" | "merge" | "force";
43
+
44
+ export interface RestoreResult {
45
+ target: string;
46
+ restored: boolean;
47
+ mode: RestoreMode;
48
+ // ts of rows present in the CURRENT file but absent from the snapshot
49
+ // (added after it — or in-place edits, listed under their original ts).
50
+ concurrentAdded: string[];
51
+ concurrentChanged: string[];
52
+ }
53
+
54
+ // Rows are keyed by their ts= field; unparseable lines key on the whole line.
55
+ function rowKey(line: string): string {
56
+ return line.match(/^ts=(\S+)/)?.[1] ?? line;
57
+ }
58
+
59
+ function rows(text: string): string[] {
60
+ return text.split("\n").filter((l) => l.trim());
61
+ }
62
+
33
63
  // Restore `snapshot` over its original file. The snapshot name is
34
- // `<stamp>-<original>.logfmt`; strip the stamp to recover the target.
35
- export async function restoreSnapshot(snapshot: string, memoryRootDir: string): Promise<string> {
36
- const f = Bun.file(snapshot);
37
- if (!(await f.exists())) throw new Error(`snapshot not found: ${snapshot}`);
64
+ // `<stamp>-<original>.logfmt`; strip the stamp to recover the target. A bare
65
+ // name (as printed by the list) resolves against <memory-root>/snapshots/.
66
+ export async function restoreSnapshot(
67
+ snapshot: string,
68
+ memoryRootDir: string,
69
+ mode: RestoreMode = "check",
70
+ ): Promise<RestoreResult> {
38
71
  const name = basename(snapshot);
72
+ let snapPath = snapshot;
73
+ if (!(await Bun.file(snapPath).exists())) {
74
+ const candidate = join(memoryRootDir, "snapshots", name);
75
+ if (await Bun.file(candidate).exists()) snapPath = candidate;
76
+ }
77
+ const f = Bun.file(snapPath);
78
+ if (!(await f.exists())) throw new Error(`snapshot not found: ${snapshot}`);
79
+
39
80
  const original = name.replace(/^\d+-/, "");
40
81
  const target = join(memoryRootDir, original);
82
+ const snapLines = rows(await f.text());
83
+ const cur = Bun.file(target);
84
+ const curLines = (await cur.exists()) ? rows(await cur.text()) : [];
85
+
86
+ const snapByKey = new Map(snapLines.map((l) => [rowKey(l), l]));
87
+ const concurrentAdded: string[] = [];
88
+ const concurrentChanged: string[] = [];
89
+ const curByKey = new Map<string, string>();
90
+ for (const l of curLines) {
91
+ const k = rowKey(l);
92
+ curByKey.set(k, l);
93
+ const s = snapByKey.get(k);
94
+ if (s === undefined) concurrentAdded.push(k);
95
+ else if (s !== l) concurrentChanged.push(k);
96
+ }
97
+
98
+ if (mode === "check" && (concurrentAdded.length || concurrentChanged.length)) {
99
+ return { target, restored: false, mode, concurrentAdded, concurrentChanged };
100
+ }
101
+
102
+ let out = snapLines;
103
+ if (mode === "merge") {
104
+ // Keep the CURRENT version of rows the snapshot also has (in-place edits —
105
+ // e.g. a supersede stamp — made after the snapshot win), then append the
106
+ // rows the snapshot never saw. Merge is a deliberate superset: rows the
107
+ // snapshotter wrote after the snapshot are NOT dropped here; choose FORCE
108
+ // for that.
109
+ out = snapLines.map((l) => curByKey.get(rowKey(l)) ?? l).concat(
110
+ curLines.filter((l) => !snapByKey.has(rowKey(l))),
111
+ );
112
+ }
41
113
  await ensureDir(memoryRootDir);
42
- await Bun.write(target, await f.text());
43
- return target;
114
+ await Bun.write(target, out.length ? out.join("\n") + "\n" : "");
115
+ return { target, restored: true, mode, concurrentAdded, concurrentChanged };
44
116
  }
@@ -9,7 +9,7 @@ import { serve, type Server } from "bun";
9
9
  import { deletionsFile, memoryRoot, snapshotDir } from "../config.ts";
10
10
  import { blockedEntries } from "../blocklist.ts";
11
11
  import { listSnapshots, restoreSnapshot } from "../undo.ts";
12
- import { appendMemory, loadAll, memoryFiles } from "../memory.ts";
12
+ import { appendMemory, loadAll, memoryFiles, snapshotFiles } from "../memory.ts";
13
13
  import { parseLine, readLines } from "../logfmt.ts";
14
14
  import { deleteMemory, editMemory, addBlock, removeBlock, type MutationResult } from "./mutate.ts";
15
15
 
@@ -180,8 +180,17 @@ async function restoreEntryRoute(body: Record<string, unknown>): Promise<Respons
180
180
  }
181
181
 
182
182
  async function restoreRoute(body: Record<string, unknown>): Promise<Response> {
183
- const target = await restoreSnapshot(join(snapshotDir(), String(body.name ?? "")), memoryRoot());
184
- return json({ restored: target });
183
+ const name = String(body.name ?? "");
184
+ const original = name.replace(/^\d+-/, "");
185
+ // Explicit human admin action: exact rollback — but the rollback is itself
186
+ // rollback-able. Snapshot the current file first so concurrent-session rows
187
+ // lost here stay recoverable, and report how many rows were rolled back.
188
+ await snapshotFiles([join(memoryRoot(), original)]);
189
+ const res = await restoreSnapshot(join(snapshotDir(), name), memoryRoot(), "force");
190
+ return json({
191
+ restored: res.target,
192
+ rolledBack: res.concurrentAdded.length + res.concurrentChanged.length,
193
+ });
185
194
  }
186
195
 
187
196
  if (import.meta.main) {