@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.2.6",
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
@@ -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 the big turn budget the default cap was
1001
- // measured to cut a todo app off mid-write, before its gate ever ran.
1002
- session.setMaxTurns(LOOP_LIMITS.webMaxTurns);
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 set this many edits in a
35
- * row (genuine spinning). Generous; the turn cap is the real backstop.
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: 10,
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
@@ -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
+ }