opencode-self-improvement 0.8.3 → 0.8.5
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 +2 -0
- package/package.json +1 -1
- package/src/index.ts +5 -2
- package/src/schema.ts +1 -1
- package/src/undo.ts +140 -25
package/README.md
CHANGED
|
@@ -302,6 +302,8 @@ Run `/repo-init` once in every repo you work in. It:
|
|
|
302
302
|
Ongoing use is just the memory tools. Ask the agent to remember something and it
|
|
303
303
|
sticks across sessions; check what's stored with `memory_list`, refine with
|
|
304
304
|
`memory_update`, retract with `memory_forget`, roll back with `memory_undo`.
|
|
305
|
+
`memory_undo` only lists and restores its own `<stamp>-YYYY-MM-DD.logfmt`
|
|
306
|
+
snapshots; any other file in `snapshots/` is ignored.
|
|
305
307
|
|
|
306
308
|
`memory_update` takes a `query` that picks the row: its id, a clock fragment such
|
|
307
309
|
as `00:11:25.867`, or a unique substring of its text. Omit it to update the newest
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
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": {
|
package/src/index.ts
CHANGED
|
@@ -399,7 +399,7 @@ const impl = {
|
|
|
399
399
|
editor.add(validated(UndoArgs, {
|
|
400
400
|
name: "memory_undo",
|
|
401
401
|
description:
|
|
402
|
-
"List memory snapshots, or restore one (rollback).
|
|
402
|
+
"List memory snapshots, or restore one (rollback). Only snapshots inside this store's snapshots directory are accepted: pass a bare name from the list or its full path. Non-standard files in snapshots/ are not listed or restorable. SAFE BY DEFAULT: restore refuses if the current file has rows added or edited since the snapshot, and audited human/tool deletions stay deleted. Re-run with merge=true to restore AND keep divergent rows, or force=true for an explicit exact rollback. Blocklisted rows remain inactive in every mode.",
|
|
403
403
|
options: direct,
|
|
404
404
|
async execute(args): Promise<{ content: string }> {
|
|
405
405
|
if (args.snapshot) {
|
|
@@ -420,7 +420,10 @@ const impl = {
|
|
|
420
420
|
? ` Kept ${res.concurrentAdded.length} added / ${res.concurrentChanged.length} changed row(s) written after the snapshot.`
|
|
421
421
|
: "";
|
|
422
422
|
const safety = res.safety ? ` Pre-restore file snapshotted -> ${res.safety}.` : "";
|
|
423
|
-
|
|
423
|
+
const omitted = res.deletedOmitted.length
|
|
424
|
+
? ` Omitted ${res.deletedOmitted.length} deliberately deleted row(s); use force=true to recover them.`
|
|
425
|
+
: "";
|
|
426
|
+
return { content: `Restored ${basename(res.target)} from ${res.mode} snapshot -> ${res.target}.${kept}${safety}${omitted}` };
|
|
424
427
|
}
|
|
425
428
|
const snaps = await listSnapshots();
|
|
426
429
|
if (!snaps.length) return { content: "No snapshots" };
|
package/src/schema.ts
CHANGED
|
@@ -94,7 +94,7 @@ export const UndoArgs = z.object({
|
|
|
94
94
|
list: z.boolean().describe("List snapshots").optional(),
|
|
95
95
|
snapshot: z
|
|
96
96
|
.string()
|
|
97
|
-
.describe("
|
|
97
|
+
.describe("Store-owned snapshot to restore (bare name from list, or its full path)")
|
|
98
98
|
.optional(),
|
|
99
99
|
merge: z
|
|
100
100
|
.boolean()
|
package/src/undo.ts
CHANGED
|
@@ -10,12 +10,32 @@
|
|
|
10
10
|
// MERGE replays them on top of the snapshot; FORCE is the old exact rollback,
|
|
11
11
|
// and additionally snapshots the file it replaces, so the state it drops stays
|
|
12
12
|
// undoable (result.safety).
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
13
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
14
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { deletionsFile, snapshotDir } from "./config.ts";
|
|
16
|
+
import { ensureDir, parseLine, readLines, writeAtomic } from "./logfmt.ts";
|
|
16
17
|
import { withStoreLock, LOCK_RESTORE } from "./lock.ts";
|
|
17
18
|
import { snapshotFiles } from "./memory.ts";
|
|
18
19
|
|
|
20
|
+
const SNAPSHOT_NAME = /^(\d+)-(\d{4}-\d{2}-\d{2}\.logfmt)$/;
|
|
21
|
+
|
|
22
|
+
function matchSnapshotName(name: string): RegExpExecArray | null {
|
|
23
|
+
const match = SNAPSHOT_NAME.exec(name);
|
|
24
|
+
if (!match) return null;
|
|
25
|
+
const original = match[2];
|
|
26
|
+
if (!original) return null;
|
|
27
|
+
const year = Number(original.slice(0, 4));
|
|
28
|
+
const month = Number(original.slice(5, 7));
|
|
29
|
+
const day = Number(original.slice(8, 10));
|
|
30
|
+
const date = new Date(0);
|
|
31
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
32
|
+
return date.getUTCFullYear() === year &&
|
|
33
|
+
date.getUTCMonth() === month - 1 &&
|
|
34
|
+
date.getUTCDate() === day
|
|
35
|
+
? match
|
|
36
|
+
: null;
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
export interface SnapshotInfo {
|
|
20
40
|
file: string;
|
|
21
41
|
name: string;
|
|
@@ -28,6 +48,7 @@ export async function listSnapshots(): Promise<SnapshotInfo[]> {
|
|
|
28
48
|
const out: SnapshotInfo[] = [];
|
|
29
49
|
try {
|
|
30
50
|
for await (const f of glob.scan(snapshotDir())) {
|
|
51
|
+
if (!matchSnapshotName(f)) continue;
|
|
31
52
|
const info = Bun.file(join(snapshotDir(), f));
|
|
32
53
|
out.push({
|
|
33
54
|
file: join(snapshotDir(), f),
|
|
@@ -55,6 +76,9 @@ export interface RestoreResult {
|
|
|
55
76
|
// (added after it — or in-place edits, listed under their original ts).
|
|
56
77
|
concurrentAdded: string[];
|
|
57
78
|
concurrentChanged: string[];
|
|
79
|
+
// ts of rows intentionally deleted after this snapshot. CHECK and MERGE
|
|
80
|
+
// leave them deleted; FORCE is the explicit recovery path.
|
|
81
|
+
deletedOmitted: string[];
|
|
58
82
|
}
|
|
59
83
|
|
|
60
84
|
// Rows are keyed by their ts= field; unparseable lines key on the whole line.
|
|
@@ -66,33 +90,113 @@ function rows(text: string): string[] {
|
|
|
66
90
|
return text.split("\n").filter((l) => l.trim());
|
|
67
91
|
}
|
|
68
92
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
93
|
+
function isWithin(parent: string, child: string): boolean {
|
|
94
|
+
const rel = relative(parent, child);
|
|
95
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function resolveSnapshot(
|
|
99
|
+
snapshot: string,
|
|
100
|
+
memoryRootDir: string,
|
|
101
|
+
): Promise<{ path: string; original: string }> {
|
|
102
|
+
const name = basename(snapshot);
|
|
103
|
+
const match = matchSnapshotName(name);
|
|
104
|
+
const original = match?.[2];
|
|
105
|
+
const stamp = match ? Number(match[1]) : Number.NaN;
|
|
106
|
+
if (!original || !Number.isSafeInteger(stamp)) {
|
|
107
|
+
throw new Error(`invalid snapshot name: ${name}`);
|
|
108
|
+
}
|
|
109
|
+
if (!isAbsolute(snapshot) && snapshot !== name) {
|
|
110
|
+
throw new Error("snapshot must be a bare name from memory_undo list or a full path");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rootResolved = resolve(memoryRootDir);
|
|
114
|
+
const snapshotsResolved = resolve(rootResolved, "snapshots");
|
|
115
|
+
const requestedResolved = isAbsolute(snapshot)
|
|
116
|
+
? resolve(snapshot)
|
|
117
|
+
: resolve(snapshotsResolved, name);
|
|
118
|
+
if (!isWithin(snapshotsResolved, requestedResolved)) {
|
|
119
|
+
throw new Error(`snapshot is outside the snapshots directory: ${snapshot}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let rootReal: string;
|
|
123
|
+
let snapshotsReal: string;
|
|
124
|
+
let snapshotReal: string;
|
|
125
|
+
try {
|
|
126
|
+
[rootReal, snapshotsReal, snapshotReal] = await Promise.all([
|
|
127
|
+
realpath(rootResolved),
|
|
128
|
+
realpath(snapshotsResolved),
|
|
129
|
+
realpath(requestedResolved),
|
|
130
|
+
]);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error(`snapshot not found: ${snapshot}`);
|
|
133
|
+
}
|
|
134
|
+
if (!isWithin(rootReal, snapshotsReal)) {
|
|
135
|
+
throw new Error("snapshots directory escapes the memory root");
|
|
136
|
+
}
|
|
137
|
+
if (!isWithin(snapshotsReal, snapshotReal)) {
|
|
138
|
+
throw new Error(`snapshot is outside the snapshots directory: ${snapshot}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const [linkInfo, fileInfo] = await Promise.all([
|
|
142
|
+
lstat(requestedResolved),
|
|
143
|
+
stat(snapshotReal),
|
|
144
|
+
]);
|
|
145
|
+
if (linkInfo.isSymbolicLink() || !fileInfo.isFile()) {
|
|
146
|
+
throw new Error(`snapshot is not a regular file: ${snapshot}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { path: snapshotReal, original };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ts of every row memory_forget or a viewer delete has removed. Ordering
|
|
153
|
+
// against the snapshot is not needed: a snapshot row with a deletion audit
|
|
154
|
+
// that is absent from the live file was removed on purpose.
|
|
155
|
+
async function auditedDeletions(): Promise<Set<string>> {
|
|
156
|
+
const deleted = new Set<string>();
|
|
157
|
+
for (const line of await readLines(deletionsFile())) {
|
|
158
|
+
const fields = parseLine(line);
|
|
159
|
+
if (fields?.action !== "deleted" || typeof fields.original_ts !== "string") continue;
|
|
160
|
+
deleted.add(fields.original_ts);
|
|
161
|
+
}
|
|
162
|
+
return deleted;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Restore `snapshot` over its original file. Only `<stamp>-<YYYY-MM-DD>.logfmt`
|
|
166
|
+
// files inside <memory-root>/snapshots/ are accepted (the viewer restore's
|
|
167
|
+
// grammar); the stamp is stripped to recover the target day file. A bare name
|
|
168
|
+
// (as printed by the list) resolves against <memory-root>/snapshots/.
|
|
72
169
|
export async function restoreSnapshot(
|
|
73
170
|
snapshot: string,
|
|
74
171
|
memoryRootDir: string,
|
|
75
172
|
mode: RestoreMode = "check",
|
|
76
173
|
): Promise<RestoreResult> {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
174
|
+
const snap = await resolveSnapshot(snapshot, memoryRootDir);
|
|
175
|
+
const rootReal = await realpath(resolve(memoryRootDir));
|
|
176
|
+
const realTarget = resolve(rootReal, snap.original);
|
|
177
|
+
if (!isWithin(rootReal, realTarget)) {
|
|
178
|
+
throw new Error(`snapshot target escapes the memory root: ${snap.original}`);
|
|
179
|
+
}
|
|
180
|
+
let targetInfo: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
181
|
+
try {
|
|
182
|
+
targetInfo = await lstat(realTarget);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (!(err instanceof Error && "code" in err && err.code === "ENOENT")) throw err;
|
|
82
185
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (!target.startsWith(rootAbs + sep)) {
|
|
93
|
-
throw new Error(`snapshot target escapes the memory root: ${original}`);
|
|
186
|
+
if (targetInfo) {
|
|
187
|
+
if (targetInfo.isSymbolicLink()) {
|
|
188
|
+
throw new Error(`snapshot target must not be a symbolic link: ${snap.original}`);
|
|
189
|
+
}
|
|
190
|
+
const targetReal = await realpath(realTarget);
|
|
191
|
+
if (relative(realTarget, targetReal) !== "") {
|
|
192
|
+
throw new Error(`snapshot target must not be a symbolic link: ${snap.original}`);
|
|
193
|
+
}
|
|
194
|
+
if (!targetInfo.isFile()) throw new Error(`snapshot target is not a file: ${snap.original}`);
|
|
94
195
|
}
|
|
95
|
-
|
|
196
|
+
// Checks above ran on real paths; lock and write through the same lexical
|
|
197
|
+
// path every other writer uses, so the in-process lock queue is shared.
|
|
198
|
+
const target = resolve(memoryRootDir, snap.original);
|
|
199
|
+
const rawSnapLines = rows(await Bun.file(snap.path).text());
|
|
96
200
|
|
|
97
201
|
// Comparing and publishing happen under the same lock: a divergence check on
|
|
98
202
|
// a stale read would wave through a row that landed after it. A restore waits
|
|
@@ -104,6 +208,17 @@ export async function restoreSnapshot(
|
|
|
104
208
|
async () => {
|
|
105
209
|
const cur = Bun.file(target);
|
|
106
210
|
const curLines = (await cur.exists()) ? rows(await cur.text()) : [];
|
|
211
|
+
// Deliberate deletions are read inside the lock too, so a forget that
|
|
212
|
+
// finished just before this restore is honoured.
|
|
213
|
+
const audited = mode !== "force" ? await auditedDeletions() : new Set<string>();
|
|
214
|
+
const live = new Set(curLines.map(rowKey));
|
|
215
|
+
const deletedOmitted: string[] = [];
|
|
216
|
+
const snapLines = rawSnapLines.filter((line) => {
|
|
217
|
+
const key = rowKey(line);
|
|
218
|
+
if (!audited.has(key) || live.has(key)) return true;
|
|
219
|
+
deletedOmitted.push(key);
|
|
220
|
+
return false;
|
|
221
|
+
});
|
|
107
222
|
|
|
108
223
|
const snapByKey = new Map(snapLines.map((l) => [rowKey(l), l]));
|
|
109
224
|
const concurrentAdded: string[] = [];
|
|
@@ -118,7 +233,7 @@ export async function restoreSnapshot(
|
|
|
118
233
|
}
|
|
119
234
|
|
|
120
235
|
if (mode === "check" && (concurrentAdded.length || concurrentChanged.length)) {
|
|
121
|
-
return { target, restored: false, mode, concurrentAdded, concurrentChanged };
|
|
236
|
+
return { target, restored: false, mode, concurrentAdded, concurrentChanged, deletedOmitted };
|
|
122
237
|
}
|
|
123
238
|
|
|
124
239
|
// FORCE is the mode that drops rows, so the file it replaces is
|
|
@@ -140,7 +255,7 @@ export async function restoreSnapshot(
|
|
|
140
255
|
}
|
|
141
256
|
await ensureDir(memoryRootDir);
|
|
142
257
|
await writeAtomic(target, out.length ? out.join("\n") + "\n" : "");
|
|
143
|
-
return { target, restored: true, mode, safety, concurrentAdded, concurrentChanged };
|
|
258
|
+
return { target, restored: true, mode, safety, concurrentAdded, concurrentChanged, deletedOmitted };
|
|
144
259
|
},
|
|
145
260
|
LOCK_RESTORE,
|
|
146
261
|
);
|