@agjs/tsforge 0.2.6 → 0.2.8
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/package.json +1 -1
- package/src/cli.ts +41 -10
- package/src/loop/loop.constants.ts +18 -4
- package/src/loop/loop.types.ts +5 -0
- package/src/loop/memory/consolidate.ts +254 -0
- package/src/loop/memory/index.ts +18 -0
- package/src/loop/memory/memory.types.ts +65 -0
- package/src/loop/memory/mine.ts +76 -0
- package/src/loop/rule-docs.generated.json +136 -1
- package/src/loop/run.ts +76 -82
- package/src/loop/session.ts +156 -14
- package/src/loop/ttsr-init.ts +111 -0
- package/src/loop/turn.ts +76 -1
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -2,13 +2,7 @@
|
|
|
2
2
|
import { join, isAbsolute } from "node:path";
|
|
3
3
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
|
-
import {
|
|
6
|
-
runTask,
|
|
7
|
-
RUN_STATUS,
|
|
8
|
-
Session,
|
|
9
|
-
PLAN_APPROVED_NOTE,
|
|
10
|
-
LOOP_LIMITS,
|
|
11
|
-
} from "./loop";
|
|
5
|
+
import { runTask, RUN_STATUS, Session, PLAN_APPROVED_NOTE } from "./loop";
|
|
12
6
|
import {
|
|
13
7
|
PROVIDER_LIMITS,
|
|
14
8
|
PROVIDER_DEFAULTS,
|
|
@@ -34,6 +28,7 @@ import {
|
|
|
34
28
|
} from "./render";
|
|
35
29
|
import type { ITask } from "./spec";
|
|
36
30
|
import type { Reporter, ILoopEvent } from "./loop";
|
|
31
|
+
import { loadLedger, activeRules, forgetMemory } from "./loop/memory";
|
|
37
32
|
import {
|
|
38
33
|
buildGate,
|
|
39
34
|
buildWebGate,
|
|
@@ -681,6 +676,7 @@ const HELP = [
|
|
|
681
676
|
" /sessions list saved sessions (resume one with: tsforge --resume <id>)",
|
|
682
677
|
" /cost rough conversation size (messages + ~tokens)",
|
|
683
678
|
" /metrics token totals + generation rate (tok/s) this session",
|
|
679
|
+
" /memory show learned failure→fix lessons (/memory forget to clear)",
|
|
684
680
|
" /exit, /quit leave the session",
|
|
685
681
|
"",
|
|
686
682
|
"Anything else is sent to the agent. It works with its tools; when it stops,",
|
|
@@ -997,9 +993,10 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
997
993
|
session.setFix(buildWebFix(framework));
|
|
998
994
|
session.setIncrementalCheck(buildWebTscCheck());
|
|
999
995
|
session.guide(webGuidance(framework));
|
|
1000
|
-
// A from-scratch web build needs
|
|
1001
|
-
//
|
|
1002
|
-
|
|
996
|
+
// A from-scratch web build legitimately needs many turns. Don't pin a low
|
|
997
|
+
// ceiling here — the interactive session already rides the high runaway
|
|
998
|
+
// backstop (interactiveBackstopTurns) and stops on the progress guards, so a
|
|
999
|
+
// long, converging build is never cut off mid-write.
|
|
1003
1000
|
};
|
|
1004
1001
|
|
|
1005
1002
|
// The `scaffold_web` tool invokes this when the AGENT decides to build a web app
|
|
@@ -1252,6 +1249,40 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1252
1249
|
await printSessions(args.dir);
|
|
1253
1250
|
break;
|
|
1254
1251
|
|
|
1252
|
+
case "memory": {
|
|
1253
|
+
if (arg.trim() === "forget") {
|
|
1254
|
+
await forgetMemory(args.dir);
|
|
1255
|
+
process.stdout.write(" memory cleared for this repo\n");
|
|
1256
|
+
break;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const ledger = await loadLedger(args.dir);
|
|
1260
|
+
|
|
1261
|
+
if (ledger.entries.length === 0) {
|
|
1262
|
+
process.stdout.write(" no learned lessons yet\n");
|
|
1263
|
+
break;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const activeNames = new Set(
|
|
1267
|
+
activeRules(ledger, Date.now()).map((r) => r.name)
|
|
1268
|
+
);
|
|
1269
|
+
|
|
1270
|
+
process.stdout.write(
|
|
1271
|
+
` ${String(ledger.entries.length)} lesson(s), ${String(activeNames.size)} active (● fires · ○ still accruing):\n`
|
|
1272
|
+
);
|
|
1273
|
+
|
|
1274
|
+
for (const entry of ledger.entries.slice(0, 20)) {
|
|
1275
|
+
const mark = activeNames.has(entry.name) ? "●" : "○";
|
|
1276
|
+
|
|
1277
|
+
process.stdout.write(
|
|
1278
|
+
` ${mark} ${entry.rule} · ${String(entry.hits)} hit(s)\n`
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
process.stdout.write(" /memory forget to clear\n");
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1255
1286
|
case "cost": {
|
|
1256
1287
|
const chars = session.messages.reduce(
|
|
1257
1288
|
(sum, m) => sum + m.content.length,
|
|
@@ -31,17 +31,31 @@ export const LOOP_LIMITS = {
|
|
|
31
31
|
*/
|
|
32
32
|
maxEditLines: 50,
|
|
33
33
|
/**
|
|
34
|
-
* Give up after the gate shows the EXACT same error
|
|
35
|
-
* row (genuine spinning)
|
|
34
|
+
* Give up after the gate shows the EXACT same error SET this many edits in a
|
|
35
|
+
* row (genuine spinning) — the coarse net. The finer `samePersist` guard
|
|
36
|
+
* (below) usually trips first; this catches a stable-but-shuffling set.
|
|
36
37
|
*/
|
|
37
|
-
gateStuckRepeats:
|
|
38
|
+
gateStuckRepeats: 6,
|
|
39
|
+
/**
|
|
40
|
+
* The PRIMARY no-progress guard: give up when a SINGLE error — same (file,rule)
|
|
41
|
+
* key — survives this many consecutive gate cycles, i.e. the model keeps failing
|
|
42
|
+
* at the same thing N attempts running, even while OTHER errors churn around it.
|
|
43
|
+
* This (not a raw turn count) is how the loop decides it's genuinely stuck.
|
|
44
|
+
*/
|
|
45
|
+
samePersist: 5,
|
|
38
46
|
/**
|
|
39
47
|
* Above this many chars of combined file content, the seed prompt sends a
|
|
40
48
|
* navigable project MAP instead of full dumps. Below it, full dumps.
|
|
41
49
|
*/
|
|
42
50
|
mapThresholdChars: 12000,
|
|
43
|
-
/** Hard backstop on model turns per task
|
|
51
|
+
/** Hard backstop on model turns per HEADLESS task (eval/cron — no human to
|
|
52
|
+
* intervene). Interactive sessions use `interactiveBackstopTurns` instead. */
|
|
44
53
|
maxTurns: 40,
|
|
54
|
+
/** Interactive runaway safety only — NOT the primary stop. A human is present
|
|
55
|
+
* and can interrupt, and the progress guards (`samePersist` / `gateStuckRepeats`)
|
|
56
|
+
* pull the agent out the moment it stops converging, so this is set high enough
|
|
57
|
+
* that normal long, productive back-and-forth never trips it. */
|
|
58
|
+
interactiveBackstopTurns: 250,
|
|
45
59
|
/** Turn budget for a from-scratch WEB build (heavy gate, many files): used by
|
|
46
60
|
* headless web builds AND applied when an interactive session scaffolds via
|
|
47
61
|
* `scaffold_web` — measured: a todo app was still WRITING components when it
|
package/src/loop/loop.types.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ILoopEvent {
|
|
|
37
37
|
* reads to tell a type error from a lint rule, not just a count. */
|
|
38
38
|
rules?: readonly string[];
|
|
39
39
|
passed?: boolean;
|
|
40
|
+
/** For `stuck` events: a human-readable blocker diagnosis. */
|
|
41
|
+
detail?: string;
|
|
40
42
|
file?: string;
|
|
41
43
|
/** For `create` events: the new file's content (rendered as a code block). */
|
|
42
44
|
content?: string;
|
|
@@ -82,6 +84,9 @@ export interface IRunResult {
|
|
|
82
84
|
/** Model turns used. */
|
|
83
85
|
cycles: number;
|
|
84
86
|
reason?: StuckReason;
|
|
87
|
+
/** When stuck: a human-readable blocker diagnosis (the persistent rule/file +
|
|
88
|
+
* last error) so an interactive session can hand back something actionable. */
|
|
89
|
+
detail?: string;
|
|
85
90
|
/** Edits/creates applied to editable files (measure edit churn). */
|
|
86
91
|
edits?: number;
|
|
87
92
|
/** Times an edit RAISED the gate error count (regressions). */
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { isRecord, isArray } from "../../lib/guards";
|
|
5
|
+
import type { ITtsrRule } from "../ttsr";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
EMPTY_LEDGER,
|
|
9
|
+
MIN_HITS_TO_ACTIVATE,
|
|
10
|
+
DECAY_MS,
|
|
11
|
+
type ICandidateLesson,
|
|
12
|
+
type ILedgerEntry,
|
|
13
|
+
type IMemoryLedger,
|
|
14
|
+
} from "./memory.types";
|
|
15
|
+
|
|
16
|
+
const MEMORY_DIR = ".tsforge";
|
|
17
|
+
const LEDGER_FILE = "memory.json";
|
|
18
|
+
const LEARNED_RULES_FILE = "learned-rules.json";
|
|
19
|
+
const MAX_GUIDANCE = 300;
|
|
20
|
+
|
|
21
|
+
/** Escape a string for use as a literal regex source. */
|
|
22
|
+
function escapeRegex(text: string): string {
|
|
23
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** djb2 hash → base36, for a short stable id from a snippet. */
|
|
27
|
+
function shortHash(text: string): string {
|
|
28
|
+
let hash = 5381;
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
31
|
+
hash = (hash * 33) ^ text.charCodeAt(i);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (hash >>> 0).toString(36);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The single most informative line of a snippet — the condition matches THIS,
|
|
38
|
+
* not the whole (possibly multi-line) replaced block, so it stays a tight,
|
|
39
|
+
* meaningful trigger. */
|
|
40
|
+
function salientLine(before: string): string {
|
|
41
|
+
const lines = before
|
|
42
|
+
.split("\n")
|
|
43
|
+
.map((l) => l.trim())
|
|
44
|
+
.filter((l) => l.length > 0);
|
|
45
|
+
|
|
46
|
+
return lines.reduce(
|
|
47
|
+
(best, line) => (line.length > best.length ? line : best),
|
|
48
|
+
""
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A conservative literal-regex condition source matching the mistake's salient line. */
|
|
53
|
+
export function conditionFor(before: string): string {
|
|
54
|
+
return escapeRegex(salientLine(before));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A deterministic, collision-resistant rule name from the gate rule + the snippet. */
|
|
58
|
+
export function ruleName(rule: string, before: string): string {
|
|
59
|
+
const slug = rule.replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase();
|
|
60
|
+
|
|
61
|
+
return `learned-${slug}-${shortHash(salientLine(before))}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** TTSR file globs for the fixed file's family (by extension). */
|
|
65
|
+
function fileGlobsFor(file: string): string[] {
|
|
66
|
+
if (file.endsWith(".tsx")) {
|
|
67
|
+
return ["**/*.tsx"];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (file.endsWith(".ts")) {
|
|
71
|
+
return ["**/*.ts"];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return ["**/*"];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The corrective nudge shown when the learned pattern recurs (capped). */
|
|
78
|
+
function guidanceFor(rule: string, after: string): string {
|
|
79
|
+
const fix = salientLine(after);
|
|
80
|
+
const base = `This pattern previously failed the gate (${rule}) in this repo. Use the known-good fix instead`;
|
|
81
|
+
const withFix = fix.length > 0 ? `${base}, e.g.: ${fix}` : `${base}.`;
|
|
82
|
+
|
|
83
|
+
return withFix.length > MAX_GUIDANCE
|
|
84
|
+
? `${withFix.slice(0, MAX_GUIDANCE - 1)}…`
|
|
85
|
+
: withFix;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function candidateToEntry(
|
|
89
|
+
candidate: ICandidateLesson,
|
|
90
|
+
sessionId: string,
|
|
91
|
+
now: number
|
|
92
|
+
): ILedgerEntry {
|
|
93
|
+
return {
|
|
94
|
+
name: ruleName(candidate.rule, candidate.before),
|
|
95
|
+
rule: candidate.rule,
|
|
96
|
+
condition: conditionFor(candidate.before),
|
|
97
|
+
guidance: guidanceFor(candidate.rule, candidate.after),
|
|
98
|
+
fileGlobs: fileGlobsFor(candidate.file),
|
|
99
|
+
hits: 1,
|
|
100
|
+
source: sessionId,
|
|
101
|
+
lastSeen: now,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Merge a run's candidates into the ledger. `hits` counts DISTINCT SESSIONS, so
|
|
107
|
+
* multiple candidates with the same name from one run bump it by exactly one;
|
|
108
|
+
* a lesson from a NEW session bumps an existing entry. Returns a new ledger.
|
|
109
|
+
*/
|
|
110
|
+
export function mergeCandidates(
|
|
111
|
+
ledger: IMemoryLedger,
|
|
112
|
+
candidates: readonly ICandidateLesson[],
|
|
113
|
+
sessionId: string,
|
|
114
|
+
now: number
|
|
115
|
+
): IMemoryLedger {
|
|
116
|
+
const byName = new Map<string, ILedgerEntry>(
|
|
117
|
+
ledger.entries.map((e) => [e.name, e])
|
|
118
|
+
);
|
|
119
|
+
// Dedupe this session's candidates by name first (one fix seen twice in a run
|
|
120
|
+
// is still one occurrence).
|
|
121
|
+
const seenThisSession = new Set<string>();
|
|
122
|
+
|
|
123
|
+
for (const candidate of candidates) {
|
|
124
|
+
const name = ruleName(candidate.rule, candidate.before);
|
|
125
|
+
|
|
126
|
+
if (seenThisSession.has(name)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
seenThisSession.add(name);
|
|
131
|
+
|
|
132
|
+
const existing = byName.get(name);
|
|
133
|
+
|
|
134
|
+
if (existing === undefined) {
|
|
135
|
+
byName.set(name, candidateToEntry(candidate, sessionId, now));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A new session re-producing the lesson bumps hits; the same session never does.
|
|
140
|
+
const bump = existing.source === sessionId ? 0 : 1;
|
|
141
|
+
|
|
142
|
+
byName.set(name, {
|
|
143
|
+
...existing,
|
|
144
|
+
hits: existing.hits + bump,
|
|
145
|
+
source: sessionId,
|
|
146
|
+
lastSeen: now,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { version: 1, entries: [...byName.values()] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Project the ledger to ACTIVE learned TTSR rules: recurring (hits ≥ threshold)
|
|
154
|
+
* and not decayed (seen within DECAY_MS). Accumulation ≠ injection. */
|
|
155
|
+
export function activeRules(ledger: IMemoryLedger, now: number): ITtsrRule[] {
|
|
156
|
+
return ledger.entries
|
|
157
|
+
.filter(
|
|
158
|
+
(e) => e.hits >= MIN_HITS_TO_ACTIVATE && now - e.lastSeen < DECAY_MS
|
|
159
|
+
)
|
|
160
|
+
.map((e) => ({
|
|
161
|
+
name: e.name,
|
|
162
|
+
condition: [e.condition],
|
|
163
|
+
scope: "tool-args" as const,
|
|
164
|
+
fileGlobs: e.fileGlobs,
|
|
165
|
+
guidance: e.guidance,
|
|
166
|
+
repeatMode: "cooldown" as const,
|
|
167
|
+
repeatGap: 3,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Parse a stored ledger, tolerating any malformation (→ empty), like the rest
|
|
172
|
+
* of tsforge's disk readers. */
|
|
173
|
+
function parseLedger(content: string): IMemoryLedger {
|
|
174
|
+
let parsed: unknown;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(content);
|
|
178
|
+
} catch {
|
|
179
|
+
return EMPTY_LEDGER;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!isRecord(parsed) || !isArray(parsed.entries)) {
|
|
183
|
+
return EMPTY_LEDGER;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const entries = parsed.entries.filter(
|
|
187
|
+
(e): e is ILedgerEntry =>
|
|
188
|
+
isRecord(e) &&
|
|
189
|
+
typeof e.name === "string" &&
|
|
190
|
+
typeof e.rule === "string" &&
|
|
191
|
+
typeof e.condition === "string" &&
|
|
192
|
+
typeof e.guidance === "string" &&
|
|
193
|
+
isArray(e.fileGlobs) &&
|
|
194
|
+
typeof e.hits === "number" &&
|
|
195
|
+
typeof e.source === "string" &&
|
|
196
|
+
typeof e.lastSeen === "number"
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return { version: 1, entries };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Forget all learned memory for a project: delete the ledger and the active
|
|
203
|
+
* learned-rules file (`tsforge memory forget`). Best-effort. */
|
|
204
|
+
export async function forgetMemory(cwd: string): Promise<void> {
|
|
205
|
+
for (const name of [LEDGER_FILE, LEARNED_RULES_FILE]) {
|
|
206
|
+
await rm(join(cwd, MEMORY_DIR, name), { force: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Load the project ledger from `<cwd>/.tsforge/memory.json`. */
|
|
211
|
+
export async function loadLedger(cwd: string): Promise<IMemoryLedger> {
|
|
212
|
+
const file = Bun.file(join(cwd, MEMORY_DIR, LEDGER_FILE));
|
|
213
|
+
|
|
214
|
+
if (!(await file.exists())) {
|
|
215
|
+
return EMPTY_LEDGER;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return parseLedger(await file.text());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The full Phase-1 consolidation step: load the ledger, merge this run's
|
|
223
|
+
* candidates, persist the ledger, and (re)write the active learned-rules file
|
|
224
|
+
* the TTSR loader reads. Returns the count of active rules written.
|
|
225
|
+
*/
|
|
226
|
+
export async function consolidate(
|
|
227
|
+
cwd: string,
|
|
228
|
+
candidates: readonly ICandidateLesson[],
|
|
229
|
+
sessionId: string,
|
|
230
|
+
now: number = Date.now()
|
|
231
|
+
): Promise<number> {
|
|
232
|
+
if (candidates.length === 0) {
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const merged = mergeCandidates(
|
|
237
|
+
await loadLedger(cwd),
|
|
238
|
+
candidates,
|
|
239
|
+
sessionId,
|
|
240
|
+
now
|
|
241
|
+
);
|
|
242
|
+
const active = activeRules(merged, now);
|
|
243
|
+
|
|
244
|
+
await Bun.write(
|
|
245
|
+
join(cwd, MEMORY_DIR, LEDGER_FILE),
|
|
246
|
+
`${JSON.stringify(merged, null, 2)}\n`
|
|
247
|
+
);
|
|
248
|
+
await Bun.write(
|
|
249
|
+
join(cwd, MEMORY_DIR, LEARNED_RULES_FILE),
|
|
250
|
+
`${JSON.stringify(active, null, 2)}\n`
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return active.length;
|
|
254
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { mineLessons } from "./mine";
|
|
2
|
+
export {
|
|
3
|
+
consolidate,
|
|
4
|
+
loadLedger,
|
|
5
|
+
forgetMemory,
|
|
6
|
+
mergeCandidates,
|
|
7
|
+
activeRules,
|
|
8
|
+
conditionFor,
|
|
9
|
+
ruleName,
|
|
10
|
+
} from "./consolidate";
|
|
11
|
+
export {
|
|
12
|
+
EMPTY_LEDGER,
|
|
13
|
+
MIN_HITS_TO_ACTIVATE,
|
|
14
|
+
DECAY_MS,
|
|
15
|
+
type ICandidateLesson,
|
|
16
|
+
type ILedgerEntry,
|
|
17
|
+
type IMemoryLedger,
|
|
18
|
+
} from "./memory.types";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tsforge MEMORY — the reflect → distill → consolidate → recall loop.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 (this module set) is the failure→fix channel: mine a run's event
|
|
5
|
+
* stream for mistakes the model MADE then FIXED, accumulate them in a local
|
|
6
|
+
* ledger with recurrence counts, and project the recurring ones into the
|
|
7
|
+
* `.tsforge/learned-rules.json` TTSR file the harness already loads. Memory
|
|
8
|
+
* accumulates aggressively; recall stays cheap (a learned rule is a dormant
|
|
9
|
+
* trigger — zero prompt cost until the exact mistake recurs).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A mistake-then-fix pair mined from one run: the gate rule that was failing,
|
|
13
|
+
* the file it was fixed in, and the edit that cleared it. */
|
|
14
|
+
export interface ICandidateLesson {
|
|
15
|
+
/** The gate rule/code that disappeared after the edit (e.g. "no-explicit-any",
|
|
16
|
+
* "TS18048"). */
|
|
17
|
+
readonly rule: string;
|
|
18
|
+
/** The file whose edit cleared the failure. */
|
|
19
|
+
readonly file: string;
|
|
20
|
+
/** The offending snippet (the edit's replaced text). */
|
|
21
|
+
readonly before: string;
|
|
22
|
+
/** The fix (the edit's replacement text). */
|
|
23
|
+
readonly after: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One accumulated lesson in the project ledger. Carries provenance so it can
|
|
27
|
+
* decay: a lesson the model stops re-committing eventually retires. */
|
|
28
|
+
export interface ILedgerEntry {
|
|
29
|
+
/** Stable, deterministic rule name (derived from rule + a hash of `before`). */
|
|
30
|
+
readonly name: string;
|
|
31
|
+
/** The gate rule/code this lesson guards against. */
|
|
32
|
+
readonly rule: string;
|
|
33
|
+
/** The TTSR condition (a conservative literal regex source matching `before`). */
|
|
34
|
+
readonly condition: string;
|
|
35
|
+
/** The corrective guidance injected when the pattern recurs (≤300 chars). */
|
|
36
|
+
readonly guidance: string;
|
|
37
|
+
/** Glob(s) the rule applies to (the fixed file's directory family). */
|
|
38
|
+
readonly fileGlobs: readonly string[];
|
|
39
|
+
/** How many distinct sessions have produced this lesson. */
|
|
40
|
+
readonly hits: number;
|
|
41
|
+
/** The most recent session id that produced it. */
|
|
42
|
+
readonly source: string;
|
|
43
|
+
/** ms timestamp of the most recent occurrence (for decay). */
|
|
44
|
+
readonly lastSeen: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The on-disk project ledger (`.tsforge/memory.json`): every candidate ever
|
|
48
|
+
* seen, with counts. The subset with `hits >= MIN_HITS_TO_ACTIVATE` is
|
|
49
|
+
* projected into the active `.tsforge/learned-rules.json`. */
|
|
50
|
+
export interface IMemoryLedger {
|
|
51
|
+
readonly version: 1;
|
|
52
|
+
readonly entries: readonly ILedgerEntry[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Empty ledger for a repo with no memory yet. */
|
|
56
|
+
export const EMPTY_LEDGER: IMemoryLedger = { version: 1, entries: [] };
|
|
57
|
+
|
|
58
|
+
/** A lesson must be seen in at least this many sessions before it becomes an
|
|
59
|
+
* active learned rule — so a single fluke fix never starts steering future
|
|
60
|
+
* runs (accumulation ≠ injection). */
|
|
61
|
+
export const MIN_HITS_TO_ACTIVATE = 2;
|
|
62
|
+
|
|
63
|
+
/** A learned rule unseen for longer than this is retired from the active set
|
|
64
|
+
* (kept in the ledger but no longer projected), so stale lessons don't silt up. */
|
|
65
|
+
export const DECAY_MS = 1000 * 60 * 60 * 24 * 45; // 45 days
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ILoopEvent } from "../loop.types";
|
|
2
|
+
|
|
3
|
+
import type { ICandidateLesson } from "./memory.types";
|
|
4
|
+
|
|
5
|
+
/** Cap edits attributed to a single fix window — keeps a noisy burst of edits
|
|
6
|
+
* from cross-producing a flood of weak candidates (the hits gate filters the
|
|
7
|
+
* rest anyway). */
|
|
8
|
+
const MAX_EDITS_PER_WINDOW = 3;
|
|
9
|
+
|
|
10
|
+
interface IEditWindow {
|
|
11
|
+
file: string;
|
|
12
|
+
before: string;
|
|
13
|
+
after: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mine a run's event stream for failure→fix lessons: a gate rule/code that was
|
|
18
|
+
* FAILING and then DISAPPEARED after one or more edits. Each such (rule, edit)
|
|
19
|
+
* pair is a candidate — the edit's replaced text (`before`) is the mistake, its
|
|
20
|
+
* replacement (`after`) is the fix.
|
|
21
|
+
*
|
|
22
|
+
* Deterministic, no model call. Only `edit` events teach (they carry the
|
|
23
|
+
* before→after diff); `create` is net-new so there is no mistake-pattern to
|
|
24
|
+
* learn. Attribution is coarse (the validated event lists rule codes, not their
|
|
25
|
+
* files), so one fix window can yield a few candidates; the cross-session hits
|
|
26
|
+
* gate in consolidation is what promotes only the recurring, real ones.
|
|
27
|
+
*/
|
|
28
|
+
export function mineLessons(events: readonly ILoopEvent[]): ICandidateLesson[] {
|
|
29
|
+
const candidates: ICandidateLesson[] = [];
|
|
30
|
+
let prevFailing: Set<string> | null = null;
|
|
31
|
+
let edits: IEditWindow[] = [];
|
|
32
|
+
|
|
33
|
+
for (const event of events) {
|
|
34
|
+
if (
|
|
35
|
+
event.kind === "edit" &&
|
|
36
|
+
event.file !== undefined &&
|
|
37
|
+
event.oldString !== undefined &&
|
|
38
|
+
event.newString !== undefined &&
|
|
39
|
+
event.oldString.trim().length > 0
|
|
40
|
+
) {
|
|
41
|
+
edits.push({
|
|
42
|
+
file: event.file,
|
|
43
|
+
before: event.oldString,
|
|
44
|
+
after: event.newString,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (event.kind !== "validated") {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const failing = new Set(event.rules ?? []);
|
|
55
|
+
|
|
56
|
+
if (prevFailing !== null && edits.length > 0) {
|
|
57
|
+
const fixed = [...prevFailing].filter((rule) => !failing.has(rule));
|
|
58
|
+
|
|
59
|
+
for (const rule of fixed) {
|
|
60
|
+
for (const edit of edits.slice(-MAX_EDITS_PER_WINDOW)) {
|
|
61
|
+
candidates.push({
|
|
62
|
+
rule,
|
|
63
|
+
file: edit.file,
|
|
64
|
+
before: edit.before,
|
|
65
|
+
after: edit.after,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
prevFailing = failing;
|
|
72
|
+
edits = [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return candidates;
|
|
76
|
+
}
|