opencode-self-improvement 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 };