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 +89 -7
- package/package.json +2 -1
- package/src/gates.ts +8 -0
- package/src/index.ts +23 -5
- package/src/inject.ts +31 -12
- package/src/memory.ts +10 -2
- package/src/resolver.ts +79 -1
- package/src/undo.ts +78 -6
- package/src/viewer/server.ts +12 -3
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 `
|
|
77
|
-
`~/.config/opencode/opencode.
|
|
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
|
-
"
|
|
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.
|
|
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 `
|
|
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
|
-
"
|
|
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 `
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
387
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
|
151
|
-
// mid-line. Returns "" when even the header line exceeds the budget
|
|
152
|
-
//
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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,
|
|
43
|
-
return target;
|
|
114
|
+
await Bun.write(target, out.length ? out.join("\n") + "\n" : "");
|
|
115
|
+
return { target, restored: true, mode, concurrentAdded, concurrentChanged };
|
|
44
116
|
}
|
package/src/viewer/server.ts
CHANGED
|
@@ -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
|
|
184
|
-
|
|
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) {
|