@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.
- package/README.md +25 -0
- package/dist/working/codemapAreas.d.ts +49 -0
- package/dist/working/codemapAreas.d.ts.map +1 -0
- package/dist/working/codemapAreas.js +139 -0
- package/dist/working/codemapAreas.js.map +1 -0
- package/dist/working/consolidate.d.ts +17 -4
- package/dist/working/consolidate.d.ts.map +1 -1
- package/dist/working/consolidate.js +40 -7
- package/dist/working/consolidate.js.map +1 -1
- package/dist/working/index.d.ts +2 -1
- package/dist/working/index.d.ts.map +1 -1
- package/dist/working/index.js +2 -1
- package/dist/working/index.js.map +1 -1
- package/dist/working/transcript.d.ts +28 -10
- package/dist/working/transcript.d.ts.map +1 -1
- package/dist/working/transcript.js +71 -39
- package/dist/working/transcript.js.map +1 -1
- package/dist/working/types.d.ts +6 -0
- package/dist/working/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/working/codemap.test.ts +144 -0
- package/src/working/codemapAreas.ts +142 -0
- package/src/working/consolidate.ts +40 -6
- package/src/working/index.ts +18 -1
- package/src/working/transcript.ts +68 -37
- package/src/working/transcriptWindow.test.ts +84 -0
- package/src/working/types.ts +6 -0
- package/src/working/working.test.ts +7 -4
|
@@ -73,47 +73,78 @@ export interface WorkingTranscriptState<M extends TranscriptMessage = Transcript
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
let
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
// `
|
|
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 `
|
|
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
|
+
});
|
package/src/working/types.ts
CHANGED
|
@@ -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
|
-
|
|
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)');
|