@seanhogg/builderforce-memory 2026.10.26 → 2026.10.27

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.
@@ -73,47 +73,78 @@ export interface WorkingTranscriptState<M extends TranscriptMessage = Transcript
73
73
  }
74
74
 
75
75
  /**
76
- * Trim the in-memory transcript to the history window before sending it to the
77
- * model. Slicing the last N can leave the window starting on an `assistant`
78
- * tool-call turn or an orphaned `tool` result (whose owning call fell off the
79
- * front). That payload is invalid for strict vendors: Gemini rejects a request
80
- * whose conversation does not begin with a user turn — surfaced as the cascade
81
- * `[googleai] 400 INVALID_ARGUMENT` after a long tool-loop crossed the window
82
- * boundary. So anchor the window at a user turn.
83
- *
84
- * If the last-N slice contains no user turn (a tool loop longer than the
85
- * window), fall back to the most recent user turn in the FULL transcript and
86
- * keep everything after it — correctness over the size cap.
76
+ * How far the message window's front moves at a time — a quarter of
77
+ * {@link HISTORY_WINDOW}. See {@link windowed}.
87
78
  */
88
- export function windowed<M extends TranscriptMessage>(convo: M[]): M[] {
89
- let w = convo.slice(-HISTORY_WINDOW);
90
- while (w.length > 0 && w[0]!.role !== 'user') w = w.slice(1);
91
- if (w.length === 0) {
92
- const lastUser = convo.map((m) => m.role).lastIndexOf('user');
93
- w = lastUser >= 0 ? convo.slice(lastUser) : convo.slice();
94
- }
95
- return tokenBounded(w);
79
+ export const HISTORY_WINDOW_DROP_CHUNK = HISTORY_WINDOW / 4;
80
+
81
+ /**
82
+ * How far (in tokens) the token window's front moves at a time — a quarter of
83
+ * {@link HISTORY_TOKEN_BUDGET}, so a drop leaves the window at ~75% of the budget and
84
+ * the next one waits for ~16k tokens of new work. See {@link windowed}.
85
+ */
86
+ export const HISTORY_TOKEN_DROP_CHUNK = HISTORY_TOKEN_BUDGET / 4;
87
+
88
+ /**
89
+ * Where the message-count window begins: 0 while the transcript fits
90
+ * {@link HISTORY_WINDOW}, else the first multiple of {@link HISTORY_WINDOW_DROP_CHUNK}
91
+ * that brings it back under. The cut points are fixed positions in the transcript, so
92
+ * the front stays put while new messages arrive and jumps a whole chunk when it moves.
93
+ */
94
+ function messageWindowStart(length: number): number {
95
+ if (length <= HISTORY_WINDOW) return 0;
96
+ return Math.ceil((length - HISTORY_WINDOW) / HISTORY_WINDOW_DROP_CHUNK) * HISTORY_WINDOW_DROP_CHUNK;
96
97
  }
97
98
 
98
99
  /**
99
- * Enforce the token budget on an already message-count-windowed slice. Drops the
100
- * OLDEST turns first, then re-anchors on a user turn (a strict vendor rejects a window
101
- * that doesn't start on `user`, and dropping a turn can orphan a `tool` result whose
102
- * `assistant` tool-call fell off the front). The most recent user turn is never
103
- * dropped: correctness over the budget when a single turn is itself oversized.
100
+ * Where the token window begins: 0 while the transcript fits
101
+ * {@link HISTORY_TOKEN_BUDGET}, else the first cut point that brings it back under. A
102
+ * cut point is the message after which the transcript's running total crosses a multiple
103
+ * of {@link HISTORY_TOKEN_DROP_CHUNK} — counted from the transcript's own start, so a
104
+ * cut point never moves as the transcript grows at the end.
104
105
  */
105
- function tokenBounded<M extends TranscriptMessage>(w: M[]): M[] {
106
- let total = transcriptTokens(w);
107
- if (total <= HISTORY_TOKEN_BUDGET) return w;
108
- const lastUser = w.map((m) => m.role).lastIndexOf('user');
109
- let start = 0;
110
- while (total > HISTORY_TOKEN_BUDGET && start < lastUser) {
111
- total -= messageTokens(w[start]!);
112
- start += 1;
106
+ function tokenWindowStart(convo: readonly TranscriptMessage[]): number {
107
+ const sizes = convo.map(messageTokens);
108
+ let remaining = sizes.reduce((sum, n) => sum + n, 0);
109
+ if (remaining <= HISTORY_TOKEN_BUDGET) return 0;
110
+ let dropped = 0;
111
+ let nextCut = HISTORY_TOKEN_DROP_CHUNK;
112
+ for (let i = 0; i < convo.length; i++) {
113
+ dropped += sizes[i]!;
114
+ remaining -= sizes[i]!;
115
+ if (dropped < nextCut) continue;
116
+ while (dropped >= nextCut) nextCut += HISTORY_TOKEN_DROP_CHUNK;
117
+ if (remaining <= HISTORY_TOKEN_BUDGET) return i + 1;
113
118
  }
114
- let trimmed = w.slice(start);
115
- while (trimmed.length > 1 && trimmed[0]!.role !== 'user') trimmed = trimmed.slice(1);
116
- return trimmed;
119
+ return convo.length;
120
+ }
121
+
122
+ /**
123
+ * Trim the in-memory transcript to the history window — the message-count window
124
+ * ({@link HISTORY_WINDOW}) and the token budget ({@link HISTORY_TOKEN_BUDGET}) — before
125
+ * sending it to the model.
126
+ *
127
+ * The OLDEST turns go first, in CHUNKS with hysteresis: once over, the front jumps to
128
+ * the next fixed cut point ({@link HISTORY_WINDOW_DROP_CHUNK} messages /
129
+ * {@link HISTORY_TOKEN_DROP_CHUNK} tokens), leaving room the following turns fill before
130
+ * it moves again. Dropping exactly to the limit slid the front by a message or two on
131
+ * every turn, so the request's prefix changed every turn and no provider prefix cache
132
+ * could hit past the system prompt.
133
+ *
134
+ * The window is then anchored at a user turn: a window starting on an `assistant`
135
+ * tool-call turn or an orphaned `tool` result (whose owning call fell off the front) is
136
+ * invalid for strict vendors — Gemini rejects a request whose conversation does not
137
+ * begin with a user turn (the cascade `[googleai] 400 INVALID_ARGUMENT` after a long
138
+ * tool loop crossed the window boundary). The most recent user turn is never dropped:
139
+ * correctness over the size cap, when a tool loop outgrows the window or a single turn
140
+ * is itself oversized.
141
+ */
142
+ export function windowed<M extends TranscriptMessage>(convo: M[]): M[] {
143
+ const lastUser = convo.map((m) => m.role).lastIndexOf('user');
144
+ if (lastUser < 0) return transcriptTokens(convo) <= HISTORY_TOKEN_BUDGET ? convo.slice() : convo.slice(-1);
145
+ let start = Math.min(Math.max(messageWindowStart(convo.length), tokenWindowStart(convo)), lastUser);
146
+ while (convo[start]!.role !== 'user') start += 1;
147
+ return convo.slice(start);
117
148
  }
118
149
 
119
150
  /**
@@ -134,11 +165,11 @@ export function stillInWorkingContext<M extends TranscriptMessage>(state: Workin
134
165
  // ---------------------------------------------------------------------------
135
166
  // Auto-compaction — summarize the bulky MIDDLE instead of dropping it.
136
167
  //
137
- // `tokenBounded` keeps the request inside the window by DROPPING the oldest turns. That
168
+ // `windowed` keeps the request inside the window by DROPPING the oldest turns. That
138
169
  // never 413s, but it silently LOSES context — which made a weak model re-read files and
139
170
  // thrash ("LOOP EXHAUSTED", chat #50). When a summarizer is available the older turns are
140
171
  // compressed into ONE memory note instead, so the model keeps working from a distilled
141
- // memory. Falls back to `tokenBounded` when no summarizer is reachable.
172
+ // memory. Falls back to `windowed` when no summarizer is reachable.
142
173
  //
143
174
  // Why this fold is NOT `packHandoff` (`../handoff`): the packer is stateless and gives the
144
175
  // verbatim tail priority, handing the note whatever room the tail leaves. The working
@@ -0,0 +1,84 @@
1
+ /**
2
+ * src/working/transcriptWindow.test.ts
3
+ * Drop-oldest windowing drops in CHUNKS with hysteresis, so the front of the request —
4
+ * the prefix a provider cache hashes — moves rarely instead of on every turn.
5
+ */
6
+ import {
7
+ windowed,
8
+ HISTORY_WINDOW,
9
+ HISTORY_WINDOW_DROP_CHUNK,
10
+ HISTORY_TOKEN_BUDGET,
11
+ HISTORY_TOKEN_DROP_CHUNK,
12
+ transcriptTokens,
13
+ } from './transcript.js';
14
+ import type { TranscriptMessage } from './transcript.js';
15
+
16
+ const msg = (role: TranscriptMessage['role'], content = 'x'): TranscriptMessage => ({ role, content });
17
+
18
+ /** user/assistant pairs, `n` messages long, each message distinct. */
19
+ function convoOf(n: number, content = (i: number) => `m${i}`): TranscriptMessage[] {
20
+ return Array.from({ length: n }, (_, i) => msg(i % 2 === 0 ? 'user' : 'assistant', content(i)));
21
+ }
22
+
23
+ describe('windowed — chunked message-count drops', () => {
24
+ it('keeps everything up to the window', () => {
25
+ const convo = convoOf(HISTORY_WINDOW);
26
+ expect(windowed(convo)).toEqual(convo);
27
+ });
28
+
29
+ it('drops a whole chunk once over, then holds the front while messages arrive', () => {
30
+ const fronts: unknown[] = [];
31
+ for (let n = HISTORY_WINDOW + 1; n <= HISTORY_WINDOW + HISTORY_WINDOW_DROP_CHUNK; n++) {
32
+ const w = windowed(convoOf(n));
33
+ expect(w.length).toBeLessThanOrEqual(HISTORY_WINDOW);
34
+ fronts.push(w[0]!.content);
35
+ }
36
+ // One front for the whole chunk: the prefix did not move turn to turn.
37
+ expect(new Set(fronts).size).toBe(1);
38
+ expect(fronts[0]).toBe(`m${HISTORY_WINDOW_DROP_CHUNK}`);
39
+ // The first drop leaves room: ~75% of the window, not exactly the window.
40
+ expect(windowed(convoOf(HISTORY_WINDOW + 1)).length).toBe(HISTORY_WINDOW + 1 - HISTORY_WINDOW_DROP_CHUNK);
41
+ });
42
+
43
+ it('moves the front by the next chunk only when the window fills again', () => {
44
+ const w = windowed(convoOf(HISTORY_WINDOW + HISTORY_WINDOW_DROP_CHUNK + 1));
45
+ expect(w[0]!.content).toBe(`m${2 * HISTORY_WINDOW_DROP_CHUNK}`);
46
+ });
47
+ });
48
+
49
+ describe('windowed — chunked token drops', () => {
50
+ // ~2k tokens per message (chars/4), so the budget holds ~32 of them.
51
+ const big = (i: number) => `${i}:${'y'.repeat(8_000)}`;
52
+
53
+ it('drops down to ~75% of the budget, not just under it', () => {
54
+ let n = 2;
55
+ while (transcriptTokens(convoOf(n, big)) <= HISTORY_TOKEN_BUDGET) n += 2;
56
+ const w = windowed(convoOf(n, big));
57
+ const kept = transcriptTokens(w);
58
+ expect(kept).toBeLessThanOrEqual(HISTORY_TOKEN_BUDGET);
59
+ expect(kept).toBeLessThanOrEqual(HISTORY_TOKEN_BUDGET - HISTORY_TOKEN_DROP_CHUNK + 4_100);
60
+ expect(w[0]!.role).toBe('user');
61
+ });
62
+
63
+ it('holds the front across turns until a whole chunk of new work arrives', () => {
64
+ let n = 2;
65
+ while (transcriptTokens(convoOf(n, big)) <= HISTORY_TOKEN_BUDGET) n += 2;
66
+ const first = windowed(convoOf(n, big))[0];
67
+ // A few more turns (< one chunk of tokens) — same front.
68
+ for (let extra = 2; extra <= 4; extra += 2) {
69
+ expect(windowed(convoOf(n + extra, big))[0]!.content).toBe(first!.content);
70
+ }
71
+ });
72
+
73
+ it('never drops the most recent user turn, however large', () => {
74
+ const convo = [msg('user', 'old'), msg('assistant', 'a'), msg('user', 'z'.repeat(HISTORY_TOKEN_BUDGET * 5))];
75
+ const w = windowed(convo);
76
+ expect(w[0]!.content).toBe(convo[2]!.content);
77
+ });
78
+
79
+ it('still starts on a user turn after a drop (strict vendors)', () => {
80
+ const convo: TranscriptMessage[] = [];
81
+ for (let i = 0; i < 40; i++) convo.push(msg('user', `u${i}`), msg('assistant', big(i)), msg('tool', big(i)));
82
+ expect(windowed(convo)[0]!.role).toBe('user');
83
+ });
84
+ });
@@ -29,4 +29,10 @@ export interface ConsolidateOptions {
29
29
  at?: Date;
30
30
  /** The workspace root — absolute paths under it are kept, made relative. */
31
31
  root?: string;
32
+ /**
33
+ * Distinct files (changed, deleted and read together) a run must touch for its map to
34
+ * be kept. Defaults to `MIN_CONSOLIDATED_FILES`; the same floor applies to a run that
35
+ * changed files as to one that only read.
36
+ */
37
+ minFiles?: number;
32
38
  }
@@ -83,7 +83,10 @@ describe('consolidate', () => {
83
83
 
84
84
  it('skips a short lookup but keeps any change', () => {
85
85
  expect(consolidate(snap({ read: ['a/b/c.ts', 'a/b/d.ts'] }))).toBeNull();
86
- expect(consolidate(snap({ edited: ['api/src/r/x.ts'] }))?.key).toBe('codemap:api/src/r');
86
+ // 2026.10.27: the 3-file floor now holds for a run that changed files too (it used to
87
+ // keep a one-file fix as a map). The kept-change half moved to `minFiles` below.
88
+ expect(consolidate(snap({ edited: ['api/src/r/x.ts'] }))).toBeNull();
89
+ expect(consolidate(snap({ edited: ['api/src/r/x.ts'] }), { minFiles: 1 })?.key).toBe('codemap:api/src/r');
87
90
  });
88
91
  });
89
92
 
@@ -91,7 +94,7 @@ describe('mergeConsolidated', () => {
91
94
  const prior = consolidate(snap({ read: ['w/s/a.ts', 'w/s/b.ts'], edited: ['w/s/c.ts'] }), { at: AT })!;
92
95
 
93
96
  it('adds a small run to the area map, restating the earlier run\'s edits', () => {
94
- const next = consolidate(snap({ task: 'fix d', edited: ['w/s/d.ts'] }), { at: AT })!;
97
+ const next = consolidate(snap({ task: 'fix d', edited: ['w/s/d.ts'] }), { at: AT, minFiles: 1 })!;
95
98
  const merged = mergeConsolidated(next.key, prior.content, next.content);
96
99
  expect(merged).toContain('asked: "fix d"');
97
100
  const entries = merged.split('\n').filter((l) => l.startsWith('- '));
@@ -99,7 +102,7 @@ describe('mergeConsolidated', () => {
99
102
  });
100
103
 
101
104
  it('removes a file this run deleted and never stores the deletion line', () => {
102
- const next = consolidate(snap({ deleted: ['w/s/a.ts'], edited: ['w/s/e.ts'] }), { at: AT })!;
105
+ const next = consolidate(snap({ deleted: ['w/s/a.ts'], edited: ['w/s/e.ts'] }), { at: AT, minFiles: 1 })!;
103
106
  const merged = mergeConsolidated(next.key, prior.content, next.content);
104
107
  expect(merged).not.toContain('w/s/a.ts');
105
108
  expect(merged).not.toContain('(deleted)');
@@ -108,7 +111,7 @@ describe('mergeConsolidated', () => {
108
111
 
109
112
  it('caps the map and says how many more', () => {
110
113
  const big = consolidate(snap({ read: Array.from({ length: 12 }, (_, i) => `w/s/f${i}.ts`) }), { at: AT })!;
111
- const next = consolidate(snap({ edited: ['w/s/new.ts'] }), { at: AT })!;
114
+ const next = consolidate(snap({ edited: ['w/s/new.ts'] }), { at: AT, minFiles: 1 })!;
112
115
  const merged = mergeConsolidated(next.key, big.content, next.content);
113
116
  expect(merged.split('\n').filter((l) => l.startsWith('- '))).toHaveLength(12);
114
117
  expect(merged).toContain('(+1 more file(s) touched)');