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.
- package/LICENSE +21 -0
- package/README.md +122 -2
- package/command/repo-init.md +16 -0
- package/index.ts +2 -0
- package/package.json +51 -3
- 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 +461 -0
- package/src/init.ts +300 -0
- package/src/inject.ts +150 -0
- package/src/logfmt.ts +67 -0
- package/src/memory.ts +242 -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/memory.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Durable memory store engine. Reproduces the opencode-memory logfmt format
|
|
2
|
+
// (MIT, github.com/shuans/opencode-memory; license retained under
|
|
3
|
+
// vendor/opencode-memory/LICENSE) and extends it with the fields the write-gate
|
|
4
|
+
// needs: supersession markers, snapshots and audited deletion.
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join, basename } from "node:path";
|
|
7
|
+
import { memoryRoot, deletionsFile, snapshotDir } from "./config.ts";
|
|
8
|
+
import { appendLog, ensureDir, formatLine, parseLine, readLines } from "./logfmt.ts";
|
|
9
|
+
import { blockedTs } from "./blocklist.ts";
|
|
10
|
+
|
|
11
|
+
export type MemoryStatus = "open" | "settled";
|
|
12
|
+
|
|
13
|
+
export interface Memory {
|
|
14
|
+
ts: string;
|
|
15
|
+
type: string;
|
|
16
|
+
scope: string;
|
|
17
|
+
content: string;
|
|
18
|
+
issue?: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
superseded_by?: string;
|
|
21
|
+
reason?: string;
|
|
22
|
+
status?: MemoryStatus;
|
|
23
|
+
file: string;
|
|
24
|
+
index: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const RESERVED = new Set([
|
|
28
|
+
"deletions.logfmt",
|
|
29
|
+
"deleted.logfmt",
|
|
30
|
+
"canonical.logfmt",
|
|
31
|
+
"blocklist.logfmt",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function toMemory(fields: Record<string, unknown>, file: string, index: number): Memory | null {
|
|
35
|
+
const ts = fields.ts;
|
|
36
|
+
const type = fields.type;
|
|
37
|
+
const scope = fields.scope;
|
|
38
|
+
if (typeof ts !== "string" || typeof type !== "string") return null;
|
|
39
|
+
if (typeof scope !== "string") return null;
|
|
40
|
+
return {
|
|
41
|
+
ts,
|
|
42
|
+
type,
|
|
43
|
+
scope,
|
|
44
|
+
content: typeof fields.content === "string" ? fields.content : "",
|
|
45
|
+
issue: typeof fields.issue === "string" ? fields.issue : undefined,
|
|
46
|
+
tags: typeof fields.tags === "string" ? fields.tags.split(",") : undefined,
|
|
47
|
+
superseded_by: typeof fields.superseded_by === "string" ? fields.superseded_by : undefined,
|
|
48
|
+
reason: typeof fields.reason === "string" ? fields.reason : undefined,
|
|
49
|
+
status:
|
|
50
|
+
fields.status === "open" || fields.status === "settled" ? fields.status : undefined,
|
|
51
|
+
file,
|
|
52
|
+
index,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function memoryFiles(): Promise<string[]> {
|
|
57
|
+
const root = memoryRoot();
|
|
58
|
+
// Bun.Glob.scan throws ENOENT on a missing root; a fresh store must read as
|
|
59
|
+
// empty so the system-digest hook never throws (it runs on every request,
|
|
60
|
+
// including the compaction model call).
|
|
61
|
+
if (!existsSync(root)) return [];
|
|
62
|
+
const glob = new Bun.Glob("*.logfmt");
|
|
63
|
+
const files: string[] = [];
|
|
64
|
+
for await (const f of glob.scan(root)) {
|
|
65
|
+
if (!RESERVED.has(f)) files.push(join(root, f));
|
|
66
|
+
}
|
|
67
|
+
return files.sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function loadAll(): Promise<Memory[]> {
|
|
71
|
+
const blocked = await blockedTs();
|
|
72
|
+
const out: Memory[] = [];
|
|
73
|
+
for (const file of await memoryFiles()) {
|
|
74
|
+
const lines = await readLines(file);
|
|
75
|
+
lines.forEach((line, index) => {
|
|
76
|
+
const m = toMemory(parseLine(line) ?? {}, file, index);
|
|
77
|
+
if (m && !blocked.has(m.ts)) out.push(m);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function loadScope(scope: string): Promise<Memory[]> {
|
|
84
|
+
return (await loadAll()).filter((m) => m.scope === scope);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AppendMemoryInput {
|
|
88
|
+
type: string;
|
|
89
|
+
scope: string;
|
|
90
|
+
content: string;
|
|
91
|
+
issue?: string;
|
|
92
|
+
tags?: string[];
|
|
93
|
+
status?: MemoryStatus;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Existing ts values across all memory files, used to guarantee uniqueness of
|
|
97
|
+
// newly appended entries (a fast seed->append within one millisecond would
|
|
98
|
+
// otherwise collide on the ISO timestamp).
|
|
99
|
+
async function existingTs(): Promise<Set<string>> {
|
|
100
|
+
const set = new Set<string>();
|
|
101
|
+
for (const file of await memoryFiles()) {
|
|
102
|
+
for (const line of await readLines(file)) {
|
|
103
|
+
const f = parseLine(line);
|
|
104
|
+
if (f && typeof f.ts === "string") set.add(f.ts);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return set;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Append a durable memory. Only the gate's GATE-4 promotion step calls this.
|
|
111
|
+
export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
112
|
+
const taken = await existingTs();
|
|
113
|
+
let ts = new Date().toISOString();
|
|
114
|
+
let guard = 0;
|
|
115
|
+
while (taken.has(ts) && guard++ < 1000) {
|
|
116
|
+
ts = new Date(new Date(ts).getTime() + 1).toISOString();
|
|
117
|
+
}
|
|
118
|
+
const file = join(memoryRoot(), `${ts.split("T")[0]}.logfmt`);
|
|
119
|
+
await appendLog(
|
|
120
|
+
file,
|
|
121
|
+
formatLine({
|
|
122
|
+
ts,
|
|
123
|
+
type: input.type,
|
|
124
|
+
scope: input.scope,
|
|
125
|
+
content: input.content,
|
|
126
|
+
issue: input.issue,
|
|
127
|
+
tags: input.tags?.length ? input.tags.join(",") : undefined,
|
|
128
|
+
status: input.status,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
return ts;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface SupersedeFields {
|
|
135
|
+
superseded_by: string;
|
|
136
|
+
reason: string;
|
|
137
|
+
symbol?: string;
|
|
138
|
+
from?: string;
|
|
139
|
+
to?: string;
|
|
140
|
+
entity?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Mark an existing memory as superseded IN PLACE (entry retained, recoverable).
|
|
144
|
+
// Idempotent: a second call is a no-op, so no double `superseded_by`.
|
|
145
|
+
export async function markSuperseded(ts: string, fields: SupersedeFields): Promise<boolean> {
|
|
146
|
+
for (const file of await memoryFiles()) {
|
|
147
|
+
const lines = await readLines(file);
|
|
148
|
+
const idx = lines.findIndex((l) => {
|
|
149
|
+
const f = parseLine(l);
|
|
150
|
+
return f && f.ts === ts;
|
|
151
|
+
});
|
|
152
|
+
if (idx === -1) continue;
|
|
153
|
+
const parsed = parseLine(lines[idx]);
|
|
154
|
+
if (parsed && typeof parsed.superseded_by === "string") return false;
|
|
155
|
+
const extra = formatLine({
|
|
156
|
+
superseded_by: fields.superseded_by,
|
|
157
|
+
reason: fields.reason,
|
|
158
|
+
symbol: fields.symbol,
|
|
159
|
+
from: fields.from,
|
|
160
|
+
to: fields.to,
|
|
161
|
+
entity: fields.entity,
|
|
162
|
+
});
|
|
163
|
+
lines[idx] = `${lines[idx]} ${extra}`;
|
|
164
|
+
await ensureDir(snapshotDir());
|
|
165
|
+
const snap = join(snapshotDir(), `${Date.now()}-${basename(file)}`);
|
|
166
|
+
await Bun.write(snap, (await Bun.file(file).text()) || "");
|
|
167
|
+
await Bun.write(file, lines.join("\n") + "\n");
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface DeletedMemory {
|
|
174
|
+
memory: Memory;
|
|
175
|
+
reason: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Snapshot-before-mutate: copy each affected file into snapshots/ so /undo can
|
|
179
|
+
// restore it. Returns the snapshot paths keyed by original file.
|
|
180
|
+
export async function snapshotFiles(files: string[]): Promise<string[]> {
|
|
181
|
+
await ensureDir(snapshotDir());
|
|
182
|
+
const stamp = Date.now();
|
|
183
|
+
const snaps: string[] = [];
|
|
184
|
+
for (const file of files) {
|
|
185
|
+
const f = Bun.file(file);
|
|
186
|
+
if (!(await f.exists())) continue;
|
|
187
|
+
const snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
|
|
188
|
+
await Bun.write(snap, await f.text());
|
|
189
|
+
snaps.push(snap);
|
|
190
|
+
}
|
|
191
|
+
return snaps;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function logDeletion(memory: Memory, reason: string): Promise<void> {
|
|
195
|
+
await appendLog(
|
|
196
|
+
deletionsFile(),
|
|
197
|
+
formatLine({
|
|
198
|
+
ts: new Date().toISOString(),
|
|
199
|
+
action: "deleted",
|
|
200
|
+
original_ts: memory.ts,
|
|
201
|
+
type: memory.type,
|
|
202
|
+
scope: memory.scope,
|
|
203
|
+
content: memory.content,
|
|
204
|
+
reason,
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface PruneResult {
|
|
210
|
+
deleted: DeletedMemory[];
|
|
211
|
+
snapshot: string[];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Remove matching memories (used by memory_forget), snapshotting first and
|
|
215
|
+
// logging every deletion with its reason.
|
|
216
|
+
export async function prune(scope: string, type: string, reason: string): Promise<PruneResult> {
|
|
217
|
+
const files = await memoryFiles();
|
|
218
|
+
const snapshot = await snapshotFiles(files);
|
|
219
|
+
const deleted: DeletedMemory[] = [];
|
|
220
|
+
for (const file of files) {
|
|
221
|
+
const lines = await readLines(file);
|
|
222
|
+
const kept: string[] = [];
|
|
223
|
+
let changed = false;
|
|
224
|
+
for (const line of lines) {
|
|
225
|
+
const parsed = parseLine(line);
|
|
226
|
+
const m = parsed ? toMemory(parsed, file, 0) : null;
|
|
227
|
+
if (m && m.scope === scope && m.type === type) {
|
|
228
|
+
deleted.push({ memory: m, reason });
|
|
229
|
+
await logDeletion(m, reason);
|
|
230
|
+
changed = true;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
kept.push(line);
|
|
234
|
+
}
|
|
235
|
+
if (changed) await Bun.write(file, kept.join("\n") + (kept.length ? "\n" : ""));
|
|
236
|
+
}
|
|
237
|
+
return { deleted, snapshot };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function isActive(m: Memory): boolean {
|
|
241
|
+
return !m.superseded_by;
|
|
242
|
+
}
|
package/src/prune.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Principle 3: a human can prune a bad memory. Deletion is auditable (reason),
|
|
2
|
+
// reversible (snapshot) and never touches a repo. The `prune` evidence line is
|
|
3
|
+
// emitted before/around the mutation so S5 can assert the reason and snapshot.
|
|
4
|
+
import { deletionsFile } from "./config.ts";
|
|
5
|
+
import { prune, type PruneResult } from "./memory.ts";
|
|
6
|
+
import { record } from "./evidence.ts";
|
|
7
|
+
|
|
8
|
+
export async function pruneMemory(
|
|
9
|
+
scope: string,
|
|
10
|
+
type: string,
|
|
11
|
+
reason: string,
|
|
12
|
+
): Promise<PruneResult & { audit: string }> {
|
|
13
|
+
const res = await prune(scope, type, reason);
|
|
14
|
+
const memTs = res.deleted.map((d) => d.memory.ts).join(",") || "-";
|
|
15
|
+
await record("prune", {
|
|
16
|
+
mem: memTs,
|
|
17
|
+
reason,
|
|
18
|
+
snapshot: res.snapshot[0] ?? "-",
|
|
19
|
+
audit: deletionsFile(),
|
|
20
|
+
tool: "memory_forget",
|
|
21
|
+
});
|
|
22
|
+
return { ...res, audit: deletionsFile() };
|
|
23
|
+
}
|
package/src/resolver.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// GATE-1 resolver. Extracts embedded path/symbol refs from a memory/evidence
|
|
2
|
+
// candidate and re-resolves them against the live source tree (and, when
|
|
3
|
+
// OPENCODE_CBM_BIN is set, the codebase-memory-mcp file graph). Deterministic:
|
|
4
|
+
// regex + balanced-paren scanning, no model involvement.
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { type Memory } from "./memory.ts";
|
|
7
|
+
|
|
8
|
+
export interface CallForm {
|
|
9
|
+
symbol: string;
|
|
10
|
+
name: string;
|
|
11
|
+
ownerPath: string;
|
|
12
|
+
fileHint?: string;
|
|
13
|
+
argsText: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DriftInfo {
|
|
17
|
+
symbol: string;
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
source: "candidate" | "indexed";
|
|
21
|
+
file?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Gate1Result {
|
|
25
|
+
refs: number;
|
|
26
|
+
resolved: string[];
|
|
27
|
+
drift: DriftInfo | null;
|
|
28
|
+
candidateStale: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function balancedParen(text: string, openIdx: number): { inner: string; end: number } | null {
|
|
32
|
+
if (text[openIdx] !== "(") return null;
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let quote: string | null = null;
|
|
35
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
36
|
+
const c = text[i];
|
|
37
|
+
if (quote) {
|
|
38
|
+
if (c === "\\") i++;
|
|
39
|
+
else if (c === quote) quote = null;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
43
|
+
quote = c;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (c === "(") depth++;
|
|
47
|
+
else if (c === ")") {
|
|
48
|
+
depth--;
|
|
49
|
+
if (depth === 0) return { inner: text.slice(openIdx + 1, i), end: i };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CALL_RE = /([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+)\s*\(/g;
|
|
56
|
+
|
|
57
|
+
export function extractCallForms(content: string): CallForm[] {
|
|
58
|
+
const forms: CallForm[] = [];
|
|
59
|
+
CALL_RE.lastIndex = 0;
|
|
60
|
+
let m: RegExpExecArray | null;
|
|
61
|
+
while ((m = CALL_RE.exec(content)) !== null) {
|
|
62
|
+
const symbol = m[1];
|
|
63
|
+
const openIdx = content.indexOf("(", m.index + symbol.length);
|
|
64
|
+
const paren = openIdx >= 0 ? balancedParen(content, openIdx) : null;
|
|
65
|
+
if (!paren) continue;
|
|
66
|
+
const parts = symbol.split(".");
|
|
67
|
+
const name = parts[parts.length - 1];
|
|
68
|
+
const ownerPath = parts.slice(0, -1).join(".");
|
|
69
|
+
forms.push({
|
|
70
|
+
symbol,
|
|
71
|
+
name,
|
|
72
|
+
ownerPath,
|
|
73
|
+
fileHint: parts.length >= 2 ? parts[parts.length - 2] : undefined,
|
|
74
|
+
argsText: paren.inner,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return forms;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const PATH_RE = /[A-Za-z0-9_@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs)(?::\d+)?/g;
|
|
81
|
+
|
|
82
|
+
export function extractPathRefs(content: string): string[] {
|
|
83
|
+
return [...content.matchAll(PATH_RE)].map((m) => m[0]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function splitTopLevel(argsText: string): string[] {
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let quote: string | null = null;
|
|
90
|
+
let current = "";
|
|
91
|
+
for (let i = 0; i < argsText.length; i++) {
|
|
92
|
+
const c = argsText[i];
|
|
93
|
+
if (quote) {
|
|
94
|
+
current += c;
|
|
95
|
+
if (c === "\\") {
|
|
96
|
+
current += argsText[++i] ?? "";
|
|
97
|
+
} else if (c === quote) quote = null;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
101
|
+
quote = c;
|
|
102
|
+
current += c;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if ("([{<".includes(c)) depth++;
|
|
106
|
+
if (")]}>".includes(c)) depth--;
|
|
107
|
+
if (c === "," && depth === 0) {
|
|
108
|
+
parts.push(current);
|
|
109
|
+
current = "";
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
current += c;
|
|
113
|
+
}
|
|
114
|
+
if (current.trim()) parts.push(current);
|
|
115
|
+
return parts;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Canonicalize an argument list to its parameter names so that type
|
|
119
|
+
// annotations and defaults do not read as signature changes:
|
|
120
|
+
// `config: any, opts = {}` -> `config, opts`.
|
|
121
|
+
// A TS `_` prefix on an unused parameter (`_name`, `_size`) is stripped so the
|
|
122
|
+
// same identifier with and without the underscore reads as the same name.
|
|
123
|
+
export function normalizeArgs(argsText: string): string {
|
|
124
|
+
return splitTopLevel(argsText)
|
|
125
|
+
.map((a) => {
|
|
126
|
+
let t = a.split("=")[0] ?? a;
|
|
127
|
+
t = t.split(":")[0] ?? t;
|
|
128
|
+
t = t.trim().replace(/^_/, "").replace(/\?$/, "").trim();
|
|
129
|
+
return t;
|
|
130
|
+
})
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.join(", ");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Prefix-aware signature drift. A memory states only the args it actually
|
|
136
|
+
// names; trailing live params it omits (scene?, updatable?) are not drift.
|
|
137
|
+
// Drift is a positional mismatch at any stated index, or a claim that the live
|
|
138
|
+
// signature dropped (candidate has more args than live).
|
|
139
|
+
export function sameArgs(candidate: string, live: string): boolean {
|
|
140
|
+
const c = splitTopLevel(normalizeArgs(candidate));
|
|
141
|
+
const l = splitTopLevel(normalizeArgs(live));
|
|
142
|
+
if (c.length === 0) return true;
|
|
143
|
+
if (c.length > l.length) return false;
|
|
144
|
+
return c.every((a, i) => a === l[i]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function displayCall(name: string, argsText: string): string {
|
|
148
|
+
return `${name}(${normalizeArgs(argsText)})`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Deterministic source-tree scan used by GATE-1 live resolution. Excludes junk
|
|
152
|
+
// that poisons signature comparison: node_modules (type-decl .d.ts files often
|
|
153
|
+
// carry empty or spread args), build output (dist/lib), and VCS dirs. Prefers
|
|
154
|
+
// files under src/ over build output, .ts/.tsx sources over compiled .js, and
|
|
155
|
+
// case-insensitively ranks basenames matching the call's fileHint first so
|
|
156
|
+
// `MeshBuilder.CreateBox` resolves to `meshbuilder.pure.ts` instead of a
|
|
157
|
+
// different file that merely also calls CreateBox.
|
|
158
|
+
const SKIP_DIRS = ["node_modules", ".git", ".hg", ".svn"];
|
|
159
|
+
const SOURCE_EXTS = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
|
|
160
|
+
|
|
161
|
+
function relRank(rel: string): number {
|
|
162
|
+
const seg = rel.split("/");
|
|
163
|
+
if (seg.some((s) => SKIP_DIRS.includes(s))) return Infinity;
|
|
164
|
+
if (rel.endsWith(".d.ts")) return Infinity;
|
|
165
|
+
let rank = 0;
|
|
166
|
+
if (seg.includes("src")) rank -= 100;
|
|
167
|
+
if (rel.endsWith(".d.ts")) rank += 1000;
|
|
168
|
+
if (seg.some((s) => ["dist", "build", "out", "lib"].includes(s))) rank += 50;
|
|
169
|
+
if (/\.(js|mjs|cjs)$/.test(rel)) rank += 10;
|
|
170
|
+
return rank;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function scanSourceFiles(liveRoot: string): Promise<string[]> {
|
|
174
|
+
const out = new Set<string>();
|
|
175
|
+
for (const ext of SOURCE_EXTS) {
|
|
176
|
+
try {
|
|
177
|
+
for await (const f of new Bun.Glob(`**/*.${ext}`).scan(liveRoot)) out.add(f);
|
|
178
|
+
} catch {
|
|
179
|
+
// Non-existent root -> no candidates.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return [...out].filter((f) => relRank(f) < Infinity);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function hintBoost(rel: string, hint: string): number {
|
|
186
|
+
const base = rel.split("/").pop()!.toLowerCase();
|
|
187
|
+
if (base.startsWith(hint)) return 4;
|
|
188
|
+
if (base.includes(hint)) return 3;
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// First live definition of `name` in the source tree, falling back to the
|
|
193
|
+
// first call site. A definition found anywhere beats a call site that sorts
|
|
194
|
+
// earlier: `MeshBuilder.CreateBox`'s real signature lives in
|
|
195
|
+
// boxBuilder.pure.ts (a facade property in meshBuilder.pure.ts re-imports it),
|
|
196
|
+
// but a call site in an alphabetically-earlier file must not shadow it.
|
|
197
|
+
export async function findLiveCall(
|
|
198
|
+
name: string,
|
|
199
|
+
fileHint: string | undefined,
|
|
200
|
+
liveRoot: string,
|
|
201
|
+
): Promise<{ file: string; argsText: string } | null> {
|
|
202
|
+
let files = await scanSourceFiles(liveRoot);
|
|
203
|
+
const tiebreak = (a: string, b: string) => a.localeCompare(b);
|
|
204
|
+
if (fileHint) {
|
|
205
|
+
const h = fileHint.toLowerCase();
|
|
206
|
+
files.sort((a, b) => relRank(a) - relRank(b) || hintBoost(b, h) - hintBoost(a, h) || tiebreak(a, b));
|
|
207
|
+
} else {
|
|
208
|
+
files.sort((a, b) => relRank(a) - relRank(b) || tiebreak(a, b));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let fallback: { file: string; argsText: string } | null = null;
|
|
212
|
+
for (const rel of files) {
|
|
213
|
+
const file = join(liveRoot, rel);
|
|
214
|
+
let text: string;
|
|
215
|
+
try {
|
|
216
|
+
text = await Bun.file(file).text();
|
|
217
|
+
} catch {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (!text.includes(name)) continue;
|
|
221
|
+
const defRe = new RegExp(`(?:function|const|class|var|static)\\s+${name}\\b`);
|
|
222
|
+
const defMatch = defRe.exec(text);
|
|
223
|
+
if (defMatch) {
|
|
224
|
+
const idx = text.indexOf("(", defMatch.index + defMatch[0].length);
|
|
225
|
+
const paren = idx >= 0 ? balancedParen(text, idx) : null;
|
|
226
|
+
if (paren) return { file, argsText: paren.inner };
|
|
227
|
+
}
|
|
228
|
+
if (!fallback) {
|
|
229
|
+
const callMatch = new RegExp(`\\b${name}\\s*\\(`).exec(text);
|
|
230
|
+
if (callMatch) {
|
|
231
|
+
const idx = text.indexOf("(", callMatch.index);
|
|
232
|
+
const paren = idx >= 0 ? balancedParen(text, idx) : null;
|
|
233
|
+
if (paren) fallback = { file, argsText: paren.inner };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return fallback;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The signature a memory/evidence already embeds for `symbol` in `scope`.
|
|
241
|
+
export function indexedSignature(symbol: string, scope: Memory[]): CallForm | null {
|
|
242
|
+
for (const m of scope) {
|
|
243
|
+
for (const form of extractCallForms(m.content)) {
|
|
244
|
+
if (form.symbol === symbol) return form;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface ResolveInput {
|
|
251
|
+
content: string;
|
|
252
|
+
scope: string;
|
|
253
|
+
liveRoot: string;
|
|
254
|
+
scopeMemories: Memory[];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function gate1(input: ResolveInput): Promise<Gate1Result> {
|
|
258
|
+
const forms = extractCallForms(input.content);
|
|
259
|
+
const pathRefs = extractPathRefs(input.content);
|
|
260
|
+
const refs = forms.length + pathRefs.length;
|
|
261
|
+
if (refs === 0) return { refs: 0, resolved: [], drift: null, candidateStale: false };
|
|
262
|
+
|
|
263
|
+
let drift: DriftInfo | null = null;
|
|
264
|
+
const resolved: string[] = [];
|
|
265
|
+
|
|
266
|
+
for (const form of forms) {
|
|
267
|
+
resolved.push(form.symbol);
|
|
268
|
+
const live = await findLiveCall(form.name, form.fileHint, input.liveRoot);
|
|
269
|
+
if (!live) continue;
|
|
270
|
+
const indexed = indexedSignature(form.symbol, input.scopeMemories);
|
|
271
|
+
const candidateDiffers = !sameArgs(form.argsText, live.argsText);
|
|
272
|
+
const indexedDiffers = indexed ? !sameArgs(indexed.argsText, live.argsText) : false;
|
|
273
|
+
if (candidateDiffers) {
|
|
274
|
+
drift = {
|
|
275
|
+
symbol: form.symbol,
|
|
276
|
+
from: displayCall(form.name, form.argsText),
|
|
277
|
+
to: displayCall(form.name, live.argsText),
|
|
278
|
+
source: "candidate",
|
|
279
|
+
file: live.file,
|
|
280
|
+
};
|
|
281
|
+
} else if (indexedDiffers && indexed) {
|
|
282
|
+
drift = {
|
|
283
|
+
symbol: form.symbol,
|
|
284
|
+
from: displayCall(form.name, indexed.argsText),
|
|
285
|
+
to: displayCall(form.name, live.argsText),
|
|
286
|
+
source: "indexed",
|
|
287
|
+
file: live.file,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
refs,
|
|
294
|
+
resolved,
|
|
295
|
+
drift,
|
|
296
|
+
candidateStale: drift?.source === "candidate",
|
|
297
|
+
};
|
|
298
|
+
}
|
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
|
+
}
|