@agjs/tsforge 0.2.7 → 0.2.9
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 +36 -0
- package/src/loop/memory/consolidate.ts +256 -0
- package/src/loop/memory/index.ts +18 -0
- package/src/loop/memory/memory.types.ts +65 -0
- package/src/loop/memory/mine.ts +85 -0
- package/src/loop/run.ts +78 -82
- package/src/loop/session.ts +148 -12
- package/src/loop/ttsr-init.ts +111 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "./render";
|
|
29
29
|
import type { ITask } from "./spec";
|
|
30
30
|
import type { Reporter, ILoopEvent } from "./loop";
|
|
31
|
+
import { loadLedger, activeRules, forgetMemory } from "./loop/memory";
|
|
31
32
|
import {
|
|
32
33
|
buildGate,
|
|
33
34
|
buildWebGate,
|
|
@@ -675,6 +676,7 @@ const HELP = [
|
|
|
675
676
|
" /sessions list saved sessions (resume one with: tsforge --resume <id>)",
|
|
676
677
|
" /cost rough conversation size (messages + ~tokens)",
|
|
677
678
|
" /metrics token totals + generation rate (tok/s) this session",
|
|
679
|
+
" /memory show learned failure→fix lessons (/memory forget to clear)",
|
|
678
680
|
" /exit, /quit leave the session",
|
|
679
681
|
"",
|
|
680
682
|
"Anything else is sent to the agent. It works with its tools; when it stops,",
|
|
@@ -1247,6 +1249,40 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1247
1249
|
await printSessions(args.dir);
|
|
1248
1250
|
break;
|
|
1249
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
|
+
|
|
1250
1286
|
case "cost": {
|
|
1251
1287
|
const chars = session.messages.reduce(
|
|
1252
1288
|
(sum, m) => sum + m.content.length,
|
|
@@ -0,0 +1,256 @@
|
|
|
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
|
+
// No fileGlobs: the condition is already a specific code snippet, and the
|
|
165
|
+
// TTSR file-glob matcher does not match `**/*.ts`-style patterns — setting
|
|
166
|
+
// one would silently prevent the rule from ever firing.
|
|
167
|
+
guidance: e.guidance,
|
|
168
|
+
repeatMode: "cooldown" as const,
|
|
169
|
+
repeatGap: 3,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Parse a stored ledger, tolerating any malformation (→ empty), like the rest
|
|
174
|
+
* of tsforge's disk readers. */
|
|
175
|
+
function parseLedger(content: string): IMemoryLedger {
|
|
176
|
+
let parsed: unknown;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
parsed = JSON.parse(content);
|
|
180
|
+
} catch {
|
|
181
|
+
return EMPTY_LEDGER;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!isRecord(parsed) || !isArray(parsed.entries)) {
|
|
185
|
+
return EMPTY_LEDGER;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const entries = parsed.entries.filter(
|
|
189
|
+
(e): e is ILedgerEntry =>
|
|
190
|
+
isRecord(e) &&
|
|
191
|
+
typeof e.name === "string" &&
|
|
192
|
+
typeof e.rule === "string" &&
|
|
193
|
+
typeof e.condition === "string" &&
|
|
194
|
+
typeof e.guidance === "string" &&
|
|
195
|
+
isArray(e.fileGlobs) &&
|
|
196
|
+
typeof e.hits === "number" &&
|
|
197
|
+
typeof e.source === "string" &&
|
|
198
|
+
typeof e.lastSeen === "number"
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return { version: 1, entries };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Forget all learned memory for a project: delete the ledger and the active
|
|
205
|
+
* learned-rules file (`tsforge memory forget`). Best-effort. */
|
|
206
|
+
export async function forgetMemory(cwd: string): Promise<void> {
|
|
207
|
+
for (const name of [LEDGER_FILE, LEARNED_RULES_FILE]) {
|
|
208
|
+
await rm(join(cwd, MEMORY_DIR, name), { force: true });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Load the project ledger from `<cwd>/.tsforge/memory.json`. */
|
|
213
|
+
export async function loadLedger(cwd: string): Promise<IMemoryLedger> {
|
|
214
|
+
const file = Bun.file(join(cwd, MEMORY_DIR, LEDGER_FILE));
|
|
215
|
+
|
|
216
|
+
if (!(await file.exists())) {
|
|
217
|
+
return EMPTY_LEDGER;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return parseLedger(await file.text());
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The full Phase-1 consolidation step: load the ledger, merge this run's
|
|
225
|
+
* candidates, persist the ledger, and (re)write the active learned-rules file
|
|
226
|
+
* the TTSR loader reads. Returns the count of active rules written.
|
|
227
|
+
*/
|
|
228
|
+
export async function consolidate(
|
|
229
|
+
cwd: string,
|
|
230
|
+
candidates: readonly ICandidateLesson[],
|
|
231
|
+
sessionId: string,
|
|
232
|
+
now: number = Date.now()
|
|
233
|
+
): Promise<number> {
|
|
234
|
+
if (candidates.length === 0) {
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const merged = mergeCandidates(
|
|
239
|
+
await loadLedger(cwd),
|
|
240
|
+
candidates,
|
|
241
|
+
sessionId,
|
|
242
|
+
now
|
|
243
|
+
);
|
|
244
|
+
const active = activeRules(merged, now);
|
|
245
|
+
|
|
246
|
+
await Bun.write(
|
|
247
|
+
join(cwd, MEMORY_DIR, LEDGER_FILE),
|
|
248
|
+
`${JSON.stringify(merged, null, 2)}\n`
|
|
249
|
+
);
|
|
250
|
+
await Bun.write(
|
|
251
|
+
join(cwd, MEMORY_DIR, LEARNED_RULES_FILE),
|
|
252
|
+
`${JSON.stringify(active, null, 2)}\n`
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
return active.length;
|
|
256
|
+
}
|
|
@@ -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,85 @@
|
|
|
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
|
+
// The pre-run RED gate is a `red` event (not `validated`), so seed the
|
|
35
|
+
// baseline failing set from it — otherwise a one-turn red→green fix, whose
|
|
36
|
+
// only `validated` event is the GREEN one, would mine nothing.
|
|
37
|
+
if (event.kind === "red") {
|
|
38
|
+
prevFailing = new Set(event.rules ?? []);
|
|
39
|
+
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (
|
|
44
|
+
event.kind === "edit" &&
|
|
45
|
+
event.file !== undefined &&
|
|
46
|
+
event.oldString !== undefined &&
|
|
47
|
+
event.newString !== undefined &&
|
|
48
|
+
event.oldString.trim().length > 0
|
|
49
|
+
) {
|
|
50
|
+
edits.push({
|
|
51
|
+
file: event.file,
|
|
52
|
+
before: event.oldString,
|
|
53
|
+
after: event.newString,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (event.kind !== "validated") {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const failing = new Set(event.rules ?? []);
|
|
64
|
+
|
|
65
|
+
if (prevFailing !== null && edits.length > 0) {
|
|
66
|
+
const fixed = [...prevFailing].filter((rule) => !failing.has(rule));
|
|
67
|
+
|
|
68
|
+
for (const rule of fixed) {
|
|
69
|
+
for (const edit of edits.slice(-MAX_EDITS_PER_WINDOW)) {
|
|
70
|
+
candidates.push({
|
|
71
|
+
rule,
|
|
72
|
+
file: edit.file,
|
|
73
|
+
before: edit.before,
|
|
74
|
+
after: edit.after,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
prevFailing = failing;
|
|
81
|
+
edits = [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return candidates;
|
|
85
|
+
}
|
package/src/loop/run.ts
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
1
|
import type { ITask } from "../spec";
|
|
3
2
|
import type { IChatMessage, IModelResponse, IProvider } from "../inference";
|
|
4
3
|
import { validate, type ErrorParser } from "../validate";
|
|
5
4
|
import { parseEslintJson } from "../validate";
|
|
6
5
|
import { readFiles } from "../lib/fs";
|
|
7
6
|
import { RUN_STATUS, STUCK_REASON, LOOP_LIMITS } from "./loop.constants";
|
|
8
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
IRunResult,
|
|
9
|
+
IRunOptions,
|
|
10
|
+
Reporter,
|
|
11
|
+
ILoopEvent,
|
|
12
|
+
} from "./loop.types";
|
|
13
|
+
import { mineLessons, consolidate as consolidateMemory } from "./memory";
|
|
9
14
|
import { flags } from "../config";
|
|
10
15
|
import { SYSTEM, seedPrompt } from "./prompt";
|
|
11
16
|
import { detectStack } from "../stack-detection";
|
|
12
|
-
import { TtsrManager
|
|
13
|
-
import {
|
|
17
|
+
import type { TtsrManager } from "./ttsr";
|
|
18
|
+
import {
|
|
19
|
+
initTtsrManager,
|
|
20
|
+
loadProjectTtsrRules,
|
|
21
|
+
applyTtsrInterrupt,
|
|
22
|
+
} from "./ttsr-init";
|
|
14
23
|
import {
|
|
15
24
|
type ILoopCtx,
|
|
16
25
|
type ILoopState,
|
|
@@ -66,57 +75,14 @@ function handleDegeneration(
|
|
|
66
75
|
};
|
|
67
76
|
}
|
|
68
77
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (!(await file.exists())) {
|
|
75
|
-
return [];
|
|
76
|
-
}
|
|
78
|
+
// TTSR init + project/learned-rule loading live in the shared ttsr-init module
|
|
79
|
+
// (the interactive session uses the same loaders). Re-exported here so existing
|
|
80
|
+
// importers (and tests) keep their path.
|
|
81
|
+
export { initTtsrManager, loadProjectTtsrRules };
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
/** Build and configure a TTSR manager if enabled. Returns null if disabled.
|
|
82
|
-
* Built-in defaults register first, then optional project rules from
|
|
83
|
-
* `<cwd>/.tsforge/rules.json`; `addRule` ignores duplicate names, so a
|
|
84
|
-
* built-in safety rule always wins over a same-named project rule. */
|
|
85
|
-
export async function initTtsrManager(
|
|
86
|
-
cwd: string,
|
|
87
|
-
report: Reporter,
|
|
88
|
-
taskId: string
|
|
89
|
-
): Promise<TtsrManager | null> {
|
|
90
|
-
if (!flags.ttsr()) {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const manager = new TtsrManager();
|
|
95
|
-
|
|
96
|
-
for (const rule of DEFAULT_TTSR_RULES) {
|
|
97
|
-
manager.addRule(rule);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
let added = 0;
|
|
101
|
-
|
|
102
|
-
for (const rule of await loadProjectTtsrRules(cwd)) {
|
|
103
|
-
if (manager.addRule(rule)) {
|
|
104
|
-
added += 1;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (added > 0) {
|
|
109
|
-
report({
|
|
110
|
-
kind: "ttsr",
|
|
111
|
-
task: taskId,
|
|
112
|
-
message: `loaded ${added} custom TTSR rule(s) from .tsforge/rules.json`,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return manager;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Handle a TTSR interrupt: report, inject corrective message, and optionally disable. */
|
|
83
|
+
/** Handle a TTSR interrupt in the headless loop: apply the shared interrupt
|
|
84
|
+
* (count, report, inject corrective guidance, disable at the cap) then emit
|
|
85
|
+
* timing for the interrupted turn. */
|
|
120
86
|
function handleTtsrInterrupt(
|
|
121
87
|
ttsrFired: { ruleName: string; guidance: string },
|
|
122
88
|
state: ILoopState,
|
|
@@ -128,32 +94,41 @@ function handleTtsrInterrupt(
|
|
|
128
94
|
taskStart: number,
|
|
129
95
|
ttsrManager: TtsrManager | null
|
|
130
96
|
): void {
|
|
131
|
-
state
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
kind: "ttsr",
|
|
135
|
-
task: taskId,
|
|
136
|
-
message: `⚠ TTSR interrupted: ${ttsrFired.ruleName}`,
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
// Hard cap: after 3 interrupts, disable TTSR to prevent loops
|
|
140
|
-
if (state.ttsrInterrupts >= 3) {
|
|
141
|
-
report({
|
|
142
|
-
kind: "tool",
|
|
143
|
-
task: taskId,
|
|
144
|
-
message: `TTSR disabled after ${state.ttsrInterrupts} interrupts (hit cap)`,
|
|
145
|
-
});
|
|
97
|
+
applyTtsrInterrupt(ttsrFired, state, messages, report, taskId, ttsrManager);
|
|
98
|
+
emitTiming(report, taskId, turn, turnStart, taskStart);
|
|
99
|
+
}
|
|
146
100
|
|
|
147
|
-
|
|
101
|
+
/**
|
|
102
|
+
* MEMORY post-run hook: mine this run's events for failure→fix lessons and
|
|
103
|
+
* consolidate them into `.tsforge/`. Gated on the TTSR flag (learned rules are
|
|
104
|
+
* recalled via TTSR, so there's nothing to learn for if it's off). Best-effort:
|
|
105
|
+
* a memory failure never affects the run's result. `runId` is unique per run so
|
|
106
|
+
* the same task re-run counts as a distinct session for the recurrence gate.
|
|
107
|
+
*/
|
|
108
|
+
async function consolidateLessons(
|
|
109
|
+
cwd: string,
|
|
110
|
+
events: readonly ILoopEvent[],
|
|
111
|
+
runId: string,
|
|
112
|
+
report: Reporter
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
if (!flags.ttsr()) {
|
|
115
|
+
return;
|
|
148
116
|
}
|
|
149
117
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
content: `⚠ generation interrupted: ${ttsrFired.guidance} Rewrite the affected part without that pattern.`,
|
|
154
|
-
});
|
|
118
|
+
try {
|
|
119
|
+
const candidates = mineLessons(events);
|
|
120
|
+
const active = await consolidateMemory(cwd, candidates, runId);
|
|
155
121
|
|
|
156
|
-
|
|
122
|
+
if (active > 0) {
|
|
123
|
+
report({
|
|
124
|
+
kind: "ttsr",
|
|
125
|
+
task: runId,
|
|
126
|
+
message: `memory: ${String(active)} learned rule(s) active in .tsforge/learned-rules.json`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// Memory is supplementary — never let it break a run.
|
|
131
|
+
}
|
|
157
132
|
}
|
|
158
133
|
|
|
159
134
|
/** Assemble per-call completion options, leaving optional knobs unset when absent. */
|
|
@@ -242,7 +217,25 @@ export async function runTask(
|
|
|
242
217
|
const effectiveParse = effectiveParserFor(parse);
|
|
243
218
|
const temperature = opts.temperature ?? 0;
|
|
244
219
|
const maxTurns = opts.maxTurns ?? LOOP_LIMITS.maxTurns;
|
|
245
|
-
|
|
220
|
+
// Buffer every event so the post-run memory hook can mine the run for
|
|
221
|
+
// failure→fix lessons, while still forwarding live to the real reporter.
|
|
222
|
+
const base: Reporter = opts.onEvent ?? (() => undefined);
|
|
223
|
+
const events: ILoopEvent[] = [];
|
|
224
|
+
|
|
225
|
+
const report: Reporter = (event) => {
|
|
226
|
+
events.push(event);
|
|
227
|
+
base(event);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Unique per run, so re-running the same task counts as a distinct session for
|
|
231
|
+
// the lesson-recurrence gate.
|
|
232
|
+
const runId = `${task.id}-${Date.now().toString(36)}`;
|
|
233
|
+
|
|
234
|
+
const finish = async (result: IRunResult): Promise<IRunResult> => {
|
|
235
|
+
await consolidateLessons(cwd, events, runId, report);
|
|
236
|
+
|
|
237
|
+
return result;
|
|
238
|
+
};
|
|
246
239
|
|
|
247
240
|
report({
|
|
248
241
|
kind: "start",
|
|
@@ -275,6 +268,9 @@ export async function runTask(
|
|
|
275
268
|
kind: "red",
|
|
276
269
|
task: task.id,
|
|
277
270
|
errors: red.errors.length,
|
|
271
|
+
// Carry the failing rule codes so the memory miner can seed the baseline
|
|
272
|
+
// failure set — a one-turn red→green fix has no prior `validated` event.
|
|
273
|
+
rules: red.errors.flatMap((e) => (e.rule === undefined ? [] : [e.rule])),
|
|
278
274
|
message: `task ${task.id}: RED (${red.errors.length} error(s))`,
|
|
279
275
|
});
|
|
280
276
|
|
|
@@ -403,7 +399,7 @@ export async function runTask(
|
|
|
403
399
|
});
|
|
404
400
|
|
|
405
401
|
if (looped !== null) {
|
|
406
|
-
return looped;
|
|
402
|
+
return finish(looped);
|
|
407
403
|
}
|
|
408
404
|
|
|
409
405
|
const touchedEditable =
|
|
@@ -419,11 +415,11 @@ export async function runTask(
|
|
|
419
415
|
emitTiming(report, task.id, turn, turnStart, taskStart);
|
|
420
416
|
|
|
421
417
|
if (settled !== null) {
|
|
422
|
-
return {
|
|
418
|
+
return finish({
|
|
423
419
|
...settled,
|
|
424
420
|
edits: state.edits,
|
|
425
421
|
regressions: state.regressions,
|
|
426
|
-
};
|
|
422
|
+
});
|
|
427
423
|
}
|
|
428
424
|
|
|
429
425
|
// Stopped with no tool call while still red → nudge it to act, not narrate.
|
|
@@ -444,7 +440,7 @@ export async function runTask(
|
|
|
444
440
|
message: `task ${task.id}: stuck (hit ${maxTurns}-turn cap)`,
|
|
445
441
|
});
|
|
446
442
|
|
|
447
|
-
return {
|
|
443
|
+
return finish({
|
|
448
444
|
task: task.id,
|
|
449
445
|
redConfirmed: true,
|
|
450
446
|
status: RUN_STATUS.stuck,
|
|
@@ -452,5 +448,5 @@ export async function runTask(
|
|
|
452
448
|
reason: STUCK_REASON.cap,
|
|
453
449
|
edits: state.edits,
|
|
454
450
|
regressions: state.regressions,
|
|
455
|
-
};
|
|
451
|
+
});
|
|
456
452
|
}
|
package/src/loop/session.ts
CHANGED
|
@@ -28,7 +28,10 @@ import {
|
|
|
28
28
|
import { connectMcpServers } from "../mcp";
|
|
29
29
|
import { loadAndRegisterPlugins } from "../config/external-plugins";
|
|
30
30
|
import { LOOP_LIMITS, RUN_STATUS } from "./loop.constants";
|
|
31
|
-
import type { Reporter } from "./loop.types";
|
|
31
|
+
import type { Reporter, ILoopEvent } from "./loop.types";
|
|
32
|
+
import type { TtsrManager } from "./ttsr";
|
|
33
|
+
import { initTtsrManager, applyTtsrInterrupt } from "./ttsr-init";
|
|
34
|
+
import { mineLessons, consolidate as consolidateMemory } from "./memory";
|
|
32
35
|
import { CHAT_SYSTEM, COMPACT_SYSTEM } from "./prompt";
|
|
33
36
|
import {
|
|
34
37
|
buildTsService,
|
|
@@ -396,6 +399,12 @@ export class Session {
|
|
|
396
399
|
private readonly forceTools: boolean;
|
|
397
400
|
/** Mid-session turn-cap override (setMaxTurns) — a web scaffold raises it. */
|
|
398
401
|
private maxTurnsOverride?: number;
|
|
402
|
+
/** TTSR manager (built-in + project + memory-learned rules). Null when TTSR is
|
|
403
|
+
* disabled. Built in `create` (needs async rule loading). */
|
|
404
|
+
private ttsrManager: TtsrManager | null = null;
|
|
405
|
+
/** Events of the CURRENT send (reset each drive), buffered off ctx.report so the
|
|
406
|
+
* post-send memory hook can mine the run for failure→fix lessons. */
|
|
407
|
+
private readonly sendEvents: ILoopEvent[] = [];
|
|
399
408
|
|
|
400
409
|
private constructor(cfg: ISessionConfig, ctx: ILoopCtx) {
|
|
401
410
|
this.provider = cfg.provider;
|
|
@@ -443,6 +452,15 @@ export class Session {
|
|
|
443
452
|
}
|
|
444
453
|
|
|
445
454
|
this.ctx = ctx;
|
|
455
|
+
// Buffer events off ctx.report (where edit/create/validated flow) so the
|
|
456
|
+
// post-send memory hook can mine them; still forward to the original reporter.
|
|
457
|
+
const rawCtxReport = ctx.report;
|
|
458
|
+
|
|
459
|
+
this.ctx.report = (event) => {
|
|
460
|
+
this.sendEvents.push(event);
|
|
461
|
+
rawCtxReport(event);
|
|
462
|
+
};
|
|
463
|
+
|
|
446
464
|
this.state = {
|
|
447
465
|
prevGateErrors: [],
|
|
448
466
|
gateNoProgress: 0,
|
|
@@ -523,7 +541,14 @@ export class Session {
|
|
|
523
541
|
},
|
|
524
542
|
};
|
|
525
543
|
|
|
526
|
-
|
|
544
|
+
const session = new Session(cfg, ctx);
|
|
545
|
+
|
|
546
|
+
// Build the TTSR manager (built-in + project + memory-learned rules) so the
|
|
547
|
+
// interactive loop gets the SAME mid-stream guidance the headless loop does —
|
|
548
|
+
// including the failure→fix lessons learned in this repo.
|
|
549
|
+
session.ttsrManager = await initTtsrManager(cfg.cwd, report, SESSION_ID);
|
|
550
|
+
|
|
551
|
+
return session;
|
|
527
552
|
}
|
|
528
553
|
|
|
529
554
|
/** The current gate command (empty when none). */
|
|
@@ -1024,6 +1049,9 @@ export class Session {
|
|
|
1024
1049
|
mcpSchemas.length > 0 ? [...baseTools, ...mcpSchemas] : baseTools;
|
|
1025
1050
|
const callStart = performance.now();
|
|
1026
1051
|
let firstTokenAt = 0;
|
|
1052
|
+
|
|
1053
|
+
this.ttsrManager?.resetBuffer();
|
|
1054
|
+
|
|
1027
1055
|
const res = await this.provider.complete(ctx.messages, {
|
|
1028
1056
|
tools: offeredTools,
|
|
1029
1057
|
temperature: this.cfg.temperature ?? 0,
|
|
@@ -1032,6 +1060,7 @@ export class Session {
|
|
|
1032
1060
|
...(this.cfg.thinkingTokenBudget === undefined
|
|
1033
1061
|
? {}
|
|
1034
1062
|
: { thinkingTokenBudget: this.cfg.thinkingTokenBudget }),
|
|
1063
|
+
...this.ttsrCallOption(),
|
|
1035
1064
|
...(signal === undefined ? {} : { signal }),
|
|
1036
1065
|
onToken: (token, channel) => {
|
|
1037
1066
|
// Stamp the first token so tokens/sec measures generation rate (excluding
|
|
@@ -1073,6 +1102,10 @@ export class Session {
|
|
|
1073
1102
|
|
|
1074
1103
|
ctx.messages.push(assistantMessage(res));
|
|
1075
1104
|
|
|
1105
|
+
// Every model call advances TTSR cooldown accounting (including interrupted
|
|
1106
|
+
// ones, so repeatGap rules count correctly after a retry).
|
|
1107
|
+
this.ttsrManager?.incrementTurnCount();
|
|
1108
|
+
|
|
1076
1109
|
if (res.salvaged !== undefined && res.salvaged > 0) {
|
|
1077
1110
|
report({
|
|
1078
1111
|
kind: "tool",
|
|
@@ -1370,10 +1403,103 @@ export class Session {
|
|
|
1370
1403
|
}
|
|
1371
1404
|
}
|
|
1372
1405
|
|
|
1406
|
+
/** Drive one send to a terminal result, then mine the send's events for
|
|
1407
|
+
* failure→fix lessons (best-effort, never affects the result). The buffer is
|
|
1408
|
+
* reset per send so each maps to one "run". */
|
|
1373
1409
|
private async drive(
|
|
1374
1410
|
maxTurns: number,
|
|
1375
1411
|
sendStart: number,
|
|
1376
1412
|
opts: ISendOptions
|
|
1413
|
+
): Promise<ISendResult> {
|
|
1414
|
+
this.sendEvents.length = 0;
|
|
1415
|
+
|
|
1416
|
+
try {
|
|
1417
|
+
return await this.driveInner(maxTurns, sendStart, opts);
|
|
1418
|
+
} finally {
|
|
1419
|
+
await this.consolidateLessons();
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/** Mine the current send's events into the project's learned-rules memory.
|
|
1424
|
+
* Gated on the TTSR flag (learned rules are recalled via TTSR). */
|
|
1425
|
+
private async consolidateLessons(): Promise<void> {
|
|
1426
|
+
if (!flags.ttsr()) {
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
try {
|
|
1431
|
+
const candidates = mineLessons(this.sendEvents);
|
|
1432
|
+
const runId = `${SESSION_ID}-${Date.now().toString(36)}`;
|
|
1433
|
+
const active = await consolidateMemory(this.ctx.cwd, candidates, runId);
|
|
1434
|
+
|
|
1435
|
+
if (active > 0) {
|
|
1436
|
+
this.report({
|
|
1437
|
+
kind: "ttsr",
|
|
1438
|
+
task: SESSION_ID,
|
|
1439
|
+
message: `memory: ${String(active)} learned rule(s) active in .tsforge/learned-rules.json`,
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
} catch {
|
|
1443
|
+
// Memory is supplementary — never let it break a send.
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/** The `ttsrManager` completion option, or nothing when TTSR is off. */
|
|
1448
|
+
private ttsrCallOption():
|
|
1449
|
+
| { ttsrManager: TtsrManager }
|
|
1450
|
+
| Record<string, never> {
|
|
1451
|
+
return this.ttsrManager === null ? {} : { ttsrManager: this.ttsrManager };
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** Apply a mid-stream TTSR fire (inject guidance, retry). Returns true when it
|
|
1455
|
+
* fired (the caller should `continue`). */
|
|
1456
|
+
private handleTtsrFired(
|
|
1457
|
+
res: IModelResponse,
|
|
1458
|
+
turn: number,
|
|
1459
|
+
turnStart: number,
|
|
1460
|
+
sendStart: number
|
|
1461
|
+
): boolean {
|
|
1462
|
+
if (res.ttsrFired === undefined) {
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
applyTtsrInterrupt(
|
|
1467
|
+
res.ttsrFired,
|
|
1468
|
+
this.state,
|
|
1469
|
+
this.ctx.messages,
|
|
1470
|
+
this.report,
|
|
1471
|
+
SESSION_ID,
|
|
1472
|
+
this.ttsrManager
|
|
1473
|
+
);
|
|
1474
|
+
emitTiming(this.report, SESSION_ID, turn, turnStart, sendStart);
|
|
1475
|
+
|
|
1476
|
+
return true;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/** Handle a degenerate stream: a bounded recovery or a terminal stop. Returns a
|
|
1480
|
+
* stop result, "retry" to continue with a forced tool, or null if not degenerate. */
|
|
1481
|
+
private degenerationStop(
|
|
1482
|
+
res: IModelResponse,
|
|
1483
|
+
degenerations: number,
|
|
1484
|
+
turn: number,
|
|
1485
|
+
turnStart: number,
|
|
1486
|
+
sendStart: number
|
|
1487
|
+
): ISendResult | "retry" | null {
|
|
1488
|
+
if (res.degenerated !== true) {
|
|
1489
|
+
return null;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const stop = this.degenerationRecovery(degenerations, turn);
|
|
1493
|
+
|
|
1494
|
+
emitTiming(this.report, SESSION_ID, turn, turnStart, sendStart);
|
|
1495
|
+
|
|
1496
|
+
return stop ?? "retry";
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
private async driveInner(
|
|
1500
|
+
maxTurns: number,
|
|
1501
|
+
sendStart: number,
|
|
1502
|
+
opts: ISendOptions
|
|
1377
1503
|
): Promise<ISendResult> {
|
|
1378
1504
|
const { ctx, report } = this;
|
|
1379
1505
|
// The gate confirms CHANGES, not answers: it fires only once the model has
|
|
@@ -1439,24 +1565,34 @@ export class Session {
|
|
|
1439
1565
|
|
|
1440
1566
|
forceTool = false;
|
|
1441
1567
|
|
|
1442
|
-
//
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
if (res
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
emitTiming(report, SESSION_ID, turn, turnStart, sendStart);
|
|
1568
|
+
// A learned/built-in TTSR rule fired mid-stream — inject its corrective
|
|
1569
|
+
// guidance and retry (checked before degeneration so the fix lands first).
|
|
1570
|
+
// This is how memory's failure→fix lessons reach an interactive session.
|
|
1571
|
+
if (this.handleTtsrFired(res, turn, turnStart, sendStart)) {
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1449
1574
|
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1575
|
+
// The stream caught a degenerate repetition loop. Bounded recovery (force a
|
|
1576
|
+
// concrete tool call next turn) before giving up; see degenerationRecovery.
|
|
1577
|
+
const deg = this.degenerationStop(
|
|
1578
|
+
res,
|
|
1579
|
+
degenerations,
|
|
1580
|
+
turn,
|
|
1581
|
+
turnStart,
|
|
1582
|
+
sendStart
|
|
1583
|
+
);
|
|
1453
1584
|
|
|
1585
|
+
if (deg === "retry") {
|
|
1454
1586
|
degenerations += 1;
|
|
1455
1587
|
forceTool = true;
|
|
1456
1588
|
|
|
1457
1589
|
continue;
|
|
1458
1590
|
}
|
|
1459
1591
|
|
|
1592
|
+
if (deg !== null) {
|
|
1593
|
+
return deg;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1460
1596
|
// FORCED-TOOLS: a lone yield_status call becomes a normal stop.
|
|
1461
1597
|
this.resolveYieldCalls(res);
|
|
1462
1598
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { Reporter } from "./loop.types";
|
|
4
|
+
import type { ILoopState } from "./turn";
|
|
5
|
+
import type { IChatMessage } from "../inference";
|
|
6
|
+
import { flags } from "../config";
|
|
7
|
+
import { TtsrManager, parseProjectRules, type ITtsrRule } from "./ttsr";
|
|
8
|
+
import { DEFAULT_TTSR_RULES } from "./ttsr-defaults";
|
|
9
|
+
|
|
10
|
+
const TTSR_INTERRUPT_CAP = 3;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load a project's TTSR rules: hand-authored `.tsforge/rules.json` AND the
|
|
14
|
+
* memory-learned `.tsforge/learned-rules.json` (the failure→fix lessons the
|
|
15
|
+
* harness wrote itself). Both are tolerated-if-missing. Learned rules are named
|
|
16
|
+
* `learned-*`, so they never collide with hand or built-in rules on dedup.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadProjectTtsrRules(cwd: string): Promise<ITtsrRule[]> {
|
|
19
|
+
const files = [
|
|
20
|
+
join(cwd, ".tsforge", "rules.json"),
|
|
21
|
+
join(cwd, ".tsforge", "learned-rules.json"),
|
|
22
|
+
];
|
|
23
|
+
const rules: ITtsrRule[] = [];
|
|
24
|
+
|
|
25
|
+
for (const path of files) {
|
|
26
|
+
const file = Bun.file(path);
|
|
27
|
+
|
|
28
|
+
if (await file.exists()) {
|
|
29
|
+
rules.push(...parseProjectRules(await file.text()));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return rules;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the TTSR manager for a run: built-in defaults + project + learned rules.
|
|
38
|
+
* Shared by the headless loop (run.ts) and the interactive session (session.ts).
|
|
39
|
+
* Returns null when TTSR is disabled by flag.
|
|
40
|
+
*/
|
|
41
|
+
export async function initTtsrManager(
|
|
42
|
+
cwd: string,
|
|
43
|
+
report: Reporter,
|
|
44
|
+
taskId: string
|
|
45
|
+
): Promise<TtsrManager | null> {
|
|
46
|
+
if (!flags.ttsr()) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const manager = new TtsrManager();
|
|
51
|
+
|
|
52
|
+
for (const rule of DEFAULT_TTSR_RULES) {
|
|
53
|
+
manager.addRule(rule);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let added = 0;
|
|
57
|
+
|
|
58
|
+
for (const rule of await loadProjectTtsrRules(cwd)) {
|
|
59
|
+
if (manager.addRule(rule)) {
|
|
60
|
+
added += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (added > 0) {
|
|
65
|
+
report({
|
|
66
|
+
kind: "ttsr",
|
|
67
|
+
task: taskId,
|
|
68
|
+
message: `loaded ${added} project/learned TTSR rule(s) from .tsforge/`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return manager;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Apply a TTSR interrupt: count it, report it, inject the corrective guidance as
|
|
77
|
+
* a user message, and disable the manager once the per-run cap is hit (so a
|
|
78
|
+
* stubborn pattern can't loop forever). Shared by both loops; the caller decides
|
|
79
|
+
* what to do next (retry the turn). Timing emission stays with the caller.
|
|
80
|
+
*/
|
|
81
|
+
export function applyTtsrInterrupt(
|
|
82
|
+
ttsrFired: { ruleName: string; guidance: string },
|
|
83
|
+
state: ILoopState,
|
|
84
|
+
messages: IChatMessage[],
|
|
85
|
+
report: Reporter,
|
|
86
|
+
taskId: string,
|
|
87
|
+
ttsrManager: TtsrManager | null
|
|
88
|
+
): void {
|
|
89
|
+
state.ttsrInterrupts += 1;
|
|
90
|
+
|
|
91
|
+
report({
|
|
92
|
+
kind: "ttsr",
|
|
93
|
+
task: taskId,
|
|
94
|
+
message: `⚠ TTSR interrupted: ${ttsrFired.ruleName}`,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (state.ttsrInterrupts >= TTSR_INTERRUPT_CAP) {
|
|
98
|
+
report({
|
|
99
|
+
kind: "tool",
|
|
100
|
+
task: taskId,
|
|
101
|
+
message: `TTSR disabled after ${state.ttsrInterrupts} interrupts (hit cap)`,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
ttsrManager?.disable();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
messages.push({
|
|
108
|
+
role: "user",
|
|
109
|
+
content: `⚠ generation interrupted: ${ttsrFired.guidance} Rewrite the affected part without that pattern.`,
|
|
110
|
+
});
|
|
111
|
+
}
|