@agjs/tsforge 0.2.7 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.2.7",
4
+ "version": "0.2.8",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
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,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
+ }
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 { IRunResult, IRunOptions, Reporter } from "./loop.types";
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, parseProjectRules, type ITtsrRule } from "./ttsr";
13
- import { DEFAULT_TTSR_RULES } from "./ttsr-defaults";
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
- /** Read and parse `<cwd>/.tsforge/rules.json` if present. Missing or invalid
70
- * files yield no rules (parseProjectRules tolerates malformed JSON). */
71
- export async function loadProjectTtsrRules(cwd: string): Promise<ITtsrRule[]> {
72
- const file = Bun.file(join(cwd, ".tsforge", "rules.json"));
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
- return parseProjectRules(await file.text());
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.ttsrInterrupts += 1;
132
-
133
- report({
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
- ttsrManager?.disable();
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
- // Append corrective message and retry without counting as a normal cycle
151
- messages.push({
152
- role: "user",
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
- emitTiming(report, taskId, turn, turnStart, taskStart);
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
- const report: Reporter = opts.onEvent ?? (() => undefined);
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",
@@ -403,7 +396,7 @@ export async function runTask(
403
396
  });
404
397
 
405
398
  if (looped !== null) {
406
- return looped;
399
+ return finish(looped);
407
400
  }
408
401
 
409
402
  const touchedEditable =
@@ -419,11 +412,11 @@ export async function runTask(
419
412
  emitTiming(report, task.id, turn, turnStart, taskStart);
420
413
 
421
414
  if (settled !== null) {
422
- return {
415
+ return finish({
423
416
  ...settled,
424
417
  edits: state.edits,
425
418
  regressions: state.regressions,
426
- };
419
+ });
427
420
  }
428
421
 
429
422
  // Stopped with no tool call while still red → nudge it to act, not narrate.
@@ -444,7 +437,7 @@ export async function runTask(
444
437
  message: `task ${task.id}: stuck (hit ${maxTurns}-turn cap)`,
445
438
  });
446
439
 
447
- return {
440
+ return finish({
448
441
  task: task.id,
449
442
  redConfirmed: true,
450
443
  status: RUN_STATUS.stuck,
@@ -452,5 +445,5 @@ export async function runTask(
452
445
  reason: STUCK_REASON.cap,
453
446
  edits: state.edits,
454
447
  regressions: state.regressions,
455
- };
448
+ });
456
449
  }
@@ -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
- return new Session(cfg, ctx);
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
- // The stream caught a degenerate repetition loop. Try a BOUNDED recovery
1443
- // (force a concrete tool call next turn can't loop in prose) before
1444
- // giving up; see degenerationRecovery.
1445
- if (res.degenerated === true) {
1446
- const stop = this.degenerationRecovery(degenerations, turn);
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
- if (stop !== null) {
1451
- return stop;
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
+ }