opencode-self-improvement 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -2
- package/command/repo-init.md +16 -0
- package/index.ts +2 -0
- package/package.json +53 -4
- package/src/blocklist.ts +56 -0
- package/src/command.ts +25 -0
- package/src/config.ts +60 -0
- package/src/evidence.ts +50 -0
- package/src/gates.ts +511 -0
- package/src/index.ts +509 -0
- package/src/init.ts +300 -0
- package/src/inject.ts +150 -0
- package/src/logfmt.ts +67 -0
- package/src/memory.ts +326 -0
- package/src/prune.ts +23 -0
- package/src/resolver.ts +298 -0
- package/src/state.ts +88 -0
- package/src/triggers.ts +292 -0
- package/src/undo.ts +44 -0
- package/src/viewer/mutate.ts +152 -0
- package/src/viewer/page.html +362 -0
- package/src/viewer/server.ts +191 -0
- package/vendor/opencode-memory/LICENSE +21 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Per-session in-memory state shared by the hooks and the gate pipeline.
|
|
2
|
+
// Deterministic: only tool results and tool invocations mutate it, never the
|
|
3
|
+
// model. Reset between scenario runs with resetState().
|
|
4
|
+
|
|
5
|
+
export interface ToolResult {
|
|
6
|
+
call: string;
|
|
7
|
+
tool: string;
|
|
8
|
+
summary: string;
|
|
9
|
+
ts: string;
|
|
10
|
+
status: "FAIL" | "PASS" | null;
|
|
11
|
+
cmd?: string;
|
|
12
|
+
gateCheck?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FirstTap {
|
|
16
|
+
call: string;
|
|
17
|
+
turn: number;
|
|
18
|
+
ts: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SessionState {
|
|
22
|
+
sid: string;
|
|
23
|
+
calls: ToolResult[];
|
|
24
|
+
// T1: last observed status per tool, so a FAIL->PASS transition is visible.
|
|
25
|
+
lastByTool: Map<string, { status: "FAIL" | "PASS"; call: string; emitted: boolean }>;
|
|
26
|
+
// T4: a pending completion-gate failure awaiting a later PASS.
|
|
27
|
+
pendingGate: {
|
|
28
|
+
call: string;
|
|
29
|
+
cmd: string;
|
|
30
|
+
chain: ToolResult[];
|
|
31
|
+
} | null;
|
|
32
|
+
// Session-convergence tracking (time-to-frontier): completed-message count,
|
|
33
|
+
// first activity time, and the first memory_recall that surfaced an open
|
|
34
|
+
// thread. Deterministic: derived only from queued messages and tool results.
|
|
35
|
+
turns: number;
|
|
36
|
+
firstActivityTs?: string;
|
|
37
|
+
firstTap?: FirstTap;
|
|
38
|
+
finalized: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Newest message-bearing session seen this process. Opening a session finalizes
|
|
42
|
+
// the previous one (there is no session-end hook on the host surface).
|
|
43
|
+
let lastSeenSidVar: string | null = null;
|
|
44
|
+
|
|
45
|
+
export function lastSeenSid(): string | null {
|
|
46
|
+
return lastSeenSidVar;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function setLastSeenSid(sid: string): void {
|
|
50
|
+
lastSeenSidVar = sid;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sessions = new Map<string, SessionState>();
|
|
54
|
+
|
|
55
|
+
export function session(sid: string): SessionState {
|
|
56
|
+
let s = sessions.get(sid);
|
|
57
|
+
if (!s) {
|
|
58
|
+
s = { sid, calls: [], lastByTool: new Map(), pendingGate: null, turns: 0, finalized: false };
|
|
59
|
+
sessions.set(sid, s);
|
|
60
|
+
}
|
|
61
|
+
return s;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let current: { sid: string; call: string; tool: string } = {
|
|
65
|
+
sid: "?",
|
|
66
|
+
call: "?",
|
|
67
|
+
tool: "?",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function setCurrent(input: {
|
|
71
|
+
sessionID?: unknown;
|
|
72
|
+
callID?: unknown;
|
|
73
|
+
tool?: unknown;
|
|
74
|
+
}): void {
|
|
75
|
+
if (typeof input.sessionID === "string") current.sid = input.sessionID;
|
|
76
|
+
if (typeof input.callID === "string") current.call = input.callID;
|
|
77
|
+
if (typeof input.tool === "string") current.tool = input.tool;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getCurrent(): { sid: string; call: string; tool: string } {
|
|
81
|
+
return current;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resetState(): void {
|
|
85
|
+
sessions.clear();
|
|
86
|
+
current = { sid: "?", call: "?", tool: "?" };
|
|
87
|
+
lastSeenSidVar = null;
|
|
88
|
+
}
|
package/src/triggers.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Deterministic reflection triggers T1/T2/T4. Evidence capture is driven by
|
|
2
|
+
// tool results, never by a model decision. Nothing here injects into chat.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { record } from "./evidence.ts";
|
|
6
|
+
import { evidenceDir } from "./config.ts";
|
|
7
|
+
import { parseLine, readLines } from "./logfmt.ts";
|
|
8
|
+
import {
|
|
9
|
+
session,
|
|
10
|
+
setLastSeenSid,
|
|
11
|
+
lastSeenSid,
|
|
12
|
+
type ToolResult,
|
|
13
|
+
} from "./state.ts";
|
|
14
|
+
|
|
15
|
+
export interface AfterInput {
|
|
16
|
+
tool?: unknown;
|
|
17
|
+
sessionID?: unknown;
|
|
18
|
+
callID?: unknown;
|
|
19
|
+
args?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AfterOutput {
|
|
23
|
+
output?: unknown;
|
|
24
|
+
metadata?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Code-touching tools. Seeing one of these in the session is evidence the model
|
|
28
|
+
// actually engaged with the code it later writes about (GATE-4's behavior-
|
|
29
|
+
// grounded injection): reading/grepping a shot of live code, editing it, or
|
|
30
|
+
// running commands. Empirical behavior substitutes for hand-copied refs.
|
|
31
|
+
const ENGAGEMENT_TOOLS = new Set(["grep", "glob", "read", "edit", "write", "bash", "shell"]);
|
|
32
|
+
|
|
33
|
+
// True once the session has exercised the codebase (T1/T4 capture uses the same
|
|
34
|
+
// whitelist, so engagement == "a tool_result was recorded for a code tool").
|
|
35
|
+
export function hasEngagement(sid: string): boolean {
|
|
36
|
+
return session(sid).calls.some((c) => ENGAGEMENT_TOOLS.has(c.tool));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const GATE_CMDS: [RegExp, string][] = [
|
|
40
|
+
[/vitest/, "vitest"],
|
|
41
|
+
[/playwright/, "playwright"],
|
|
42
|
+
[/lint/, "lint"],
|
|
43
|
+
[/build:source|tsc\b/, "tsc"],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function gateCmd(cmd: string): string | null {
|
|
47
|
+
for (const [re, name] of GATE_CMDS) if (re.test(cmd)) return name;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function argString(args: unknown): string {
|
|
52
|
+
if (!args || typeof args !== "object") return "";
|
|
53
|
+
const a = args as Record<string, unknown>;
|
|
54
|
+
const v = a.command ?? a.cmd ?? a.pattern ?? a.query ?? a.path ?? a.filePath;
|
|
55
|
+
return typeof v === "string" ? v : "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function exitCode(out: AfterOutput): number | undefined {
|
|
59
|
+
const m = out.metadata;
|
|
60
|
+
if (!m) return undefined;
|
|
61
|
+
const v = m.exitCode ?? m.exit ?? m.code;
|
|
62
|
+
return typeof v === "number" ? v : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function classify(tool: string, out: AfterOutput): "FAIL" | "PASS" {
|
|
66
|
+
const text = typeof out.output === "string" ? out.output : "";
|
|
67
|
+
if (tool === "bash" || tool === "shell") {
|
|
68
|
+
const code = exitCode(out);
|
|
69
|
+
if (typeof code === "number") return code === 0 ? "PASS" : "FAIL";
|
|
70
|
+
if (/\b(failed|failure|error|not found|command failed)\b/i.test(text)) return "FAIL";
|
|
71
|
+
return "PASS";
|
|
72
|
+
}
|
|
73
|
+
if (tool === "grep" || tool === "glob") {
|
|
74
|
+
if (/no (matches|files|results)|no output found|did not match/i.test(text)) return "FAIL";
|
|
75
|
+
if (/\d+\s+match/i.test(text)) return "PASS";
|
|
76
|
+
return text.trim() ? "PASS" : "FAIL";
|
|
77
|
+
}
|
|
78
|
+
return "PASS";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function summarize(text: string, n = 80): string {
|
|
82
|
+
const one = text.replace(/\s+/g, " ").trim();
|
|
83
|
+
return one.length > n ? one.slice(0, n) + "…" : one;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function afterTool(input: AfterInput, out: AfterOutput): Promise<void> {
|
|
87
|
+
const tool = typeof input.tool === "string" ? input.tool : "?";
|
|
88
|
+
const sid = typeof input.sessionID === "string" ? input.sessionID : "?";
|
|
89
|
+
const call = typeof input.callID === "string" ? input.callID : "?";
|
|
90
|
+
const cmd = argString(input.args);
|
|
91
|
+
const text = typeof out.output === "string" ? out.output : "";
|
|
92
|
+
const status = classify(tool, out);
|
|
93
|
+
const ts = new Date().toISOString();
|
|
94
|
+
|
|
95
|
+
const rec: ToolResult = { call, tool, summary: summarize(text), ts, status: null };
|
|
96
|
+
const s = session(sid);
|
|
97
|
+
const family = tool === "bash" || tool === "shell" ? gateCmd(cmd) : null;
|
|
98
|
+
|
|
99
|
+
// Generic capture (data, not chat).
|
|
100
|
+
if (["grep", "glob", "read", "edit", "write", "bash", "shell"].includes(tool)) {
|
|
101
|
+
await record("tool_result", {
|
|
102
|
+
tool,
|
|
103
|
+
title: tool,
|
|
104
|
+
output_summary: summarize(text),
|
|
105
|
+
});
|
|
106
|
+
rec.status = status;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- T4: completion gate FAIL -> PASS -----------------------------------
|
|
110
|
+
if (family) {
|
|
111
|
+
rec.gateCheck = true;
|
|
112
|
+
rec.cmd = family;
|
|
113
|
+
await record("gate_check", { cmd: family, status });
|
|
114
|
+
if (status === "FAIL") {
|
|
115
|
+
s.pendingGate = { call, cmd: family, chain: [] };
|
|
116
|
+
} else if (s.pendingGate && s.pendingGate.cmd === family) {
|
|
117
|
+
const pend = s.pendingGate;
|
|
118
|
+
const chainCalls = [pend.call, ...pend.chain.map((c) => c.call), call];
|
|
119
|
+
const edits = pend.chain.filter((c) => c.tool === "edit" || c.tool === "write");
|
|
120
|
+
const fix = edits.length ? edits[edits.length - 1].call : call;
|
|
121
|
+
await record(
|
|
122
|
+
"t4_learning",
|
|
123
|
+
{
|
|
124
|
+
type: "learning",
|
|
125
|
+
gate: `bash:${family}`,
|
|
126
|
+
from: "FAIL",
|
|
127
|
+
to: "PASS",
|
|
128
|
+
gate_couple: true,
|
|
129
|
+
evidence_chain: `[${chainCalls.join(",")}]`,
|
|
130
|
+
fix_ref: fix,
|
|
131
|
+
},
|
|
132
|
+
false,
|
|
133
|
+
);
|
|
134
|
+
s.pendingGate = null;
|
|
135
|
+
} else if (s.pendingGate) {
|
|
136
|
+
s.pendingGate.chain.push(rec);
|
|
137
|
+
}
|
|
138
|
+
s.calls.push(rec);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Keep the pending gate chain (grep/read/edit between FAIL and PASS).
|
|
143
|
+
if (s.pendingGate) s.pendingGate.chain.push(rec);
|
|
144
|
+
s.calls.push(rec);
|
|
145
|
+
|
|
146
|
+
// ---- T1: failed -> fixed retry (non-gate tools) -------------------------
|
|
147
|
+
if (["grep", "glob", "read"].includes(tool)) {
|
|
148
|
+
const prev = s.lastByTool.get(tool);
|
|
149
|
+
if (prev && prev.status === "FAIL" && status === "PASS" && !prev.emitted) {
|
|
150
|
+
prev.emitted = true;
|
|
151
|
+
await record(
|
|
152
|
+
"t1_evidence",
|
|
153
|
+
{
|
|
154
|
+
from: "FAIL",
|
|
155
|
+
to: "PASS",
|
|
156
|
+
evidence_refs: `[${prev.call},${call}]`,
|
|
157
|
+
kind: "learning",
|
|
158
|
+
},
|
|
159
|
+
false,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
s.lastByTool.set(tool, { status, call, emitted: prev?.emitted ?? false });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function onCompacting(input: AfterInput): Promise<void> {
|
|
167
|
+
const sid = typeof input.sessionID === "string" ? input.sessionID : "?";
|
|
168
|
+
const s = session(sid);
|
|
169
|
+
await record(
|
|
170
|
+
"t2_compaction",
|
|
171
|
+
{
|
|
172
|
+
session: sid,
|
|
173
|
+
captured_calls: s.calls.length,
|
|
174
|
+
pending_gate: s.pendingGate ? s.pendingGate.call : "-",
|
|
175
|
+
},
|
|
176
|
+
false,
|
|
177
|
+
);
|
|
178
|
+
await finalizeSession(sid);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function evidenceCalls(sid: string): string[] {
|
|
182
|
+
return session(sid).calls.map((c) => c.call);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function hasGateCheck(sid: string): boolean {
|
|
186
|
+
return session(sid).calls.some((c) => c.gateCheck === true);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- Session-convergence tracking (time-to-frontier) -----------------------
|
|
190
|
+
// The measurable win: sessions should reach the previous frontier quickly and
|
|
191
|
+
// stop re-deriving/ re-litigating. Everything here is deterministic — turn
|
|
192
|
+
// counts from completed messages, "frontier tap" from the FIRST memory_recall
|
|
193
|
+
// that surfaced an open thread, re-derivation from grep/glob/read results. The
|
|
194
|
+
// per-session rollup lands in EVIDENCE (data, never chat — I3), and only a
|
|
195
|
+
// tiny aggregate fact may ride the frontier block in the digest.
|
|
196
|
+
|
|
197
|
+
export function markTurn(sid: string): void {
|
|
198
|
+
const s = session(sid);
|
|
199
|
+
s.turns++;
|
|
200
|
+
if (!s.firstActivityTs) s.firstActivityTs = new Date().toISOString();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function markFrontierTap(sid: string, call: string): void {
|
|
204
|
+
const s = session(sid);
|
|
205
|
+
if (s.firstTap) return;
|
|
206
|
+
s.firstTap = { call, turn: s.turns, ts: new Date().toISOString() };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function firstFrontierTap(sid: string): { call: string; turn: number; ts: string } | undefined {
|
|
210
|
+
return session(sid).firstTap;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Idempotent per-session rollup. Emitted only when the session had any
|
|
214
|
+
// activity (a message or a tool result), so noise-free sessions stay silent.
|
|
215
|
+
export async function finalizeSession(sid: string): Promise<void> {
|
|
216
|
+
const s = session(sid);
|
|
217
|
+
if (s.finalized) return;
|
|
218
|
+
s.finalized = true;
|
|
219
|
+
if (s.calls.length === 0 && s.turns === 0) return;
|
|
220
|
+
const tap = s.firstTap;
|
|
221
|
+
const start = s.firstActivityTs ?? s.calls[0]?.ts;
|
|
222
|
+
const ms =
|
|
223
|
+
tap && start ? Math.max(0, Date.parse(tap.ts) - Date.parse(start)) : undefined;
|
|
224
|
+
await record(
|
|
225
|
+
"session_convergence",
|
|
226
|
+
{
|
|
227
|
+
session: sid,
|
|
228
|
+
turns: s.turns,
|
|
229
|
+
tap_at: tap ? tap.call : "-",
|
|
230
|
+
turns_to_tap: tap ? tap.turn : undefined,
|
|
231
|
+
re_derives: s.calls.filter((c) => ["grep", "glob", "read"].includes(c.tool)).length,
|
|
232
|
+
ms_to_tap: ms,
|
|
233
|
+
calls: s.calls.length,
|
|
234
|
+
},
|
|
235
|
+
false,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Called from chat.message: opening a new session closes the previous one
|
|
240
|
+
// (the host has no session-end hook, so a push handoff — new session in the
|
|
241
|
+
// same process — is the closest deterministic close).
|
|
242
|
+
export async function checkNewSession(sid: string): Promise<void> {
|
|
243
|
+
if (sid === lastSeenSid()) return;
|
|
244
|
+
const prev = lastSeenSid();
|
|
245
|
+
if (prev) await finalizeSession(prev);
|
|
246
|
+
setLastSeenSid(sid);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Tiny aggregate for the frontier digest block: how many recent sessions
|
|
250
|
+
// reached the frontier, and how fast on average. Data-only; never the evidence
|
|
251
|
+
// logfmt itself. Empty string when there is no measurement yet (fresh install).
|
|
252
|
+
// Merges two evidence streams: the per-session rollup (has turns_to_tap) and
|
|
253
|
+
// the frontier_tapped rows (a tap that landed AFTER the session was finalized
|
|
254
|
+
// at compaction — the late-tap race — so the rollup's tap_at was already "-").
|
|
255
|
+
export async function recentConvergenceSummary(limit = 6): Promise<string> {
|
|
256
|
+
const dir = evidenceDir();
|
|
257
|
+
if (!existsSync(dir)) return "";
|
|
258
|
+
const files: string[] = [];
|
|
259
|
+
for await (const f of new Bun.Glob("*.logfmt").scan(dir)) files.push(join(dir, f));
|
|
260
|
+
files.sort();
|
|
261
|
+
// sid -> tap turn from frontier_tapped rows (late taps lost from rollups).
|
|
262
|
+
const lateTaps = new Map<string, number>();
|
|
263
|
+
const rows: { session: string; turn: number | null }[] = [];
|
|
264
|
+
for (const file of files) {
|
|
265
|
+
for (const line of await readLines(file)) {
|
|
266
|
+
const p = parseLine(line);
|
|
267
|
+
if (!p) continue;
|
|
268
|
+
if (p.ev === "frontier_tapped" && typeof p.sid === "string") {
|
|
269
|
+
const t = Number(p.turn);
|
|
270
|
+
if (Number.isFinite(t) && !lateTaps.has(p.sid)) lateTaps.set(p.sid, t);
|
|
271
|
+
} else if (p.ev === "session_convergence") {
|
|
272
|
+
const tap =
|
|
273
|
+
typeof p.turns_to_tap === "string" && p.turns_to_tap !== ""
|
|
274
|
+
? Number(p.turns_to_tap)
|
|
275
|
+
: NaN;
|
|
276
|
+
rows.push({
|
|
277
|
+
session: String(p.session ?? ""),
|
|
278
|
+
turn: Number.isFinite(tap) ? tap : null,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const recent = rows.slice(-limit);
|
|
284
|
+
const tapped = recent.filter(
|
|
285
|
+
(r) => r.turn !== null || lateTaps.has(r.session),
|
|
286
|
+
);
|
|
287
|
+
if (!recent.length || !tapped.length) return "";
|
|
288
|
+
const sum = tapped.reduce((a, r) => a + (r.turn ?? lateTaps.get(r.session) ?? 0), 0);
|
|
289
|
+
const avg = sum / tapped.length;
|
|
290
|
+
const label = avg % 1 === 0 ? String(avg) : avg.toFixed(1);
|
|
291
|
+
return `${recent.length} sessions tracked, ${tapped.length} reached the frontier (first tap ~${label} turns)`;
|
|
292
|
+
}
|
package/src/undo.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Snapshot/rollback (/undo analog). Snapshots are written by memory.ts before
|
|
2
|
+
// any mutation; restore copies one back over its original file.
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
import { snapshotDir } from "./config.ts";
|
|
5
|
+
import { ensureDir } from "./logfmt.ts";
|
|
6
|
+
|
|
7
|
+
export interface SnapshotInfo {
|
|
8
|
+
file: string;
|
|
9
|
+
name: string;
|
|
10
|
+
bytes: number;
|
|
11
|
+
mtime: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function listSnapshots(): Promise<SnapshotInfo[]> {
|
|
15
|
+
const glob = new Bun.Glob("*.logfmt");
|
|
16
|
+
const out: SnapshotInfo[] = [];
|
|
17
|
+
try {
|
|
18
|
+
for await (const f of glob.scan(snapshotDir())) {
|
|
19
|
+
const info = Bun.file(join(snapshotDir(), f));
|
|
20
|
+
out.push({
|
|
21
|
+
file: join(snapshotDir(), f),
|
|
22
|
+
name: f,
|
|
23
|
+
bytes: info.size,
|
|
24
|
+
mtime: info.lastModified ?? 0,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
return out.sort((a, b) => b.mtime - a.mtime);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 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}`);
|
|
38
|
+
const name = basename(snapshot);
|
|
39
|
+
const original = name.replace(/^\d+-/, "");
|
|
40
|
+
const target = join(memoryRootDir, original);
|
|
41
|
+
await ensureDir(memoryRootDir);
|
|
42
|
+
await Bun.write(target, await f.text());
|
|
43
|
+
return target;
|
|
44
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Non-destructive viewer mutations (the human-approved branch of the write gate).
|
|
2
|
+
// An EDIT snapshots the pre-mutation file, marks the old entry superseded, then
|
|
3
|
+
// appends the edited entry exactly once. A DELETE snapshots the pre-mutation
|
|
4
|
+
// file and removes the entry plus an audited reason. Nothing is ever destroyed:
|
|
5
|
+
// every pre-mutation state is retained (git-style, one snapshot per mutation)
|
|
6
|
+
// and the old entry stays visible as superseded. Block is the poison
|
|
7
|
+
// intervention: keep the entry on disk but hide it from every read path.
|
|
8
|
+
// All mutations require a reason and write audit + evidence lines.
|
|
9
|
+
import { deletionsFile } from "../config.ts";
|
|
10
|
+
import { addBlock, removeBlock } from "../blocklist.ts";
|
|
11
|
+
import { record, chainId } from "../evidence.ts";
|
|
12
|
+
import { appendLog, formatLine, parseLine, readLines } from "../logfmt.ts";
|
|
13
|
+
import {
|
|
14
|
+
appendMemory,
|
|
15
|
+
loadAll,
|
|
16
|
+
markSuperseded,
|
|
17
|
+
snapshotFiles,
|
|
18
|
+
type Memory,
|
|
19
|
+
} from "../memory.ts";
|
|
20
|
+
|
|
21
|
+
export interface EditInput {
|
|
22
|
+
ts: string;
|
|
23
|
+
type: string;
|
|
24
|
+
scope: string;
|
|
25
|
+
content: string;
|
|
26
|
+
issue?: string;
|
|
27
|
+
tags?: string[];
|
|
28
|
+
reason: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DeleteInput {
|
|
32
|
+
ts: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MutationResult {
|
|
37
|
+
action: "edited" | "deleted";
|
|
38
|
+
ts: string;
|
|
39
|
+
superseded_by?: string;
|
|
40
|
+
snapshot: string;
|
|
41
|
+
audit: string;
|
|
42
|
+
chain: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function requireReason(reason: string): void {
|
|
46
|
+
if (!reason.trim()) throw new Error("reason is required");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function findMemory(ts: string): Promise<Memory> {
|
|
50
|
+
const m = (await loadAll()).find((x) => x.ts === ts);
|
|
51
|
+
if (!m) throw new Error(`memory not found: ${ts}`);
|
|
52
|
+
return m;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Non-destructive edit: snapshot the pre-state, supersede the old entry, then
|
|
56
|
+
// append the edited replacement (added ONLY because the edit requires it).
|
|
57
|
+
export async function editMemory(input: EditInput): Promise<MutationResult> {
|
|
58
|
+
requireReason(input.reason);
|
|
59
|
+
const chain = chainId();
|
|
60
|
+
const target = await findMemory(input.ts);
|
|
61
|
+
|
|
62
|
+
const snapshot = await snapshotFiles([target.file]);
|
|
63
|
+
const newTs = await appendMemory({
|
|
64
|
+
type: input.type || target.type,
|
|
65
|
+
scope: input.scope || target.scope,
|
|
66
|
+
content: input.content,
|
|
67
|
+
issue: input.issue,
|
|
68
|
+
tags: input.tags,
|
|
69
|
+
status: target.status,
|
|
70
|
+
});
|
|
71
|
+
await markSuperseded(input.ts, {
|
|
72
|
+
superseded_by: newTs,
|
|
73
|
+
reason: "human_edit",
|
|
74
|
+
});
|
|
75
|
+
await audit("edited", target, input.ts, newTs, input.reason);
|
|
76
|
+
await record("viewer_edit", {
|
|
77
|
+
chain,
|
|
78
|
+
ts: newTs,
|
|
79
|
+
original: input.ts,
|
|
80
|
+
supersedes: input.ts,
|
|
81
|
+
reason: input.reason,
|
|
82
|
+
snapshot: snapshot[0] ?? "-",
|
|
83
|
+
op: "write",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
action: "edited",
|
|
88
|
+
ts: input.ts,
|
|
89
|
+
superseded_by: newTs,
|
|
90
|
+
snapshot: snapshot[0] ?? "-",
|
|
91
|
+
audit: deletionsFile(),
|
|
92
|
+
chain,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Non-destructive delete: snapshot the pre-state, remove the entry, audit.
|
|
97
|
+
export async function deleteMemory(input: DeleteInput): Promise<MutationResult> {
|
|
98
|
+
requireReason(input.reason);
|
|
99
|
+
const chain = chainId();
|
|
100
|
+
const target = await findMemory(input.ts);
|
|
101
|
+
|
|
102
|
+
const snapshot = await snapshotFiles([target.file]);
|
|
103
|
+
const lines = await readLines(target.file);
|
|
104
|
+
const kept = lines.filter((l) => {
|
|
105
|
+
const f = parseLine(l);
|
|
106
|
+
return !(f && f.ts === input.ts);
|
|
107
|
+
});
|
|
108
|
+
if (kept.length === lines.length) throw new Error(`memory line not found: ${input.ts}`);
|
|
109
|
+
await Bun.write(target.file, kept.join("\n") + (kept.length ? "\n" : ""));
|
|
110
|
+
|
|
111
|
+
await audit("deleted", target, input.ts, undefined, input.reason);
|
|
112
|
+
await record("prune", {
|
|
113
|
+
chain,
|
|
114
|
+
mem: input.ts,
|
|
115
|
+
reason: input.reason,
|
|
116
|
+
snapshot: snapshot[0] ?? "-",
|
|
117
|
+
audit: deletionsFile(),
|
|
118
|
+
tool: "memory_viewer",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
action: "deleted",
|
|
123
|
+
ts: input.ts,
|
|
124
|
+
snapshot: snapshot[0] ?? "-",
|
|
125
|
+
audit: deletionsFile(),
|
|
126
|
+
chain,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function audit(
|
|
131
|
+
action: "edited" | "deleted",
|
|
132
|
+
mem: Memory,
|
|
133
|
+
originalTs: string,
|
|
134
|
+
newTs: string | undefined,
|
|
135
|
+
reason: string,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
await appendLog(
|
|
138
|
+
deletionsFile(),
|
|
139
|
+
formatLine({
|
|
140
|
+
ts: new Date().toISOString(),
|
|
141
|
+
action,
|
|
142
|
+
original_ts: originalTs,
|
|
143
|
+
...(newTs ? { superseded_by: newTs } : {}),
|
|
144
|
+
type: mem.type,
|
|
145
|
+
scope: mem.scope,
|
|
146
|
+
content: mem.content,
|
|
147
|
+
reason,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export { addBlock, removeBlock };
|