@seanhogg/builderforce-memory 2026.10.25 → 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.
Files changed (40) hide show
  1. package/README.md +25 -0
  2. package/dist/working/codemapAreas.d.ts +49 -0
  3. package/dist/working/codemapAreas.d.ts.map +1 -0
  4. package/dist/working/codemapAreas.js +139 -0
  5. package/dist/working/codemapAreas.js.map +1 -0
  6. package/dist/working/consolidate.d.ts +17 -4
  7. package/dist/working/consolidate.d.ts.map +1 -1
  8. package/dist/working/consolidate.js +40 -7
  9. package/dist/working/consolidate.js.map +1 -1
  10. package/dist/working/consolidationMarker.d.ts +8 -2
  11. package/dist/working/consolidationMarker.d.ts.map +1 -1
  12. package/dist/working/consolidationMarker.js +13 -5
  13. package/dist/working/consolidationMarker.js.map +1 -1
  14. package/dist/working/index.d.ts +4 -3
  15. package/dist/working/index.d.ts.map +1 -1
  16. package/dist/working/index.js +4 -3
  17. package/dist/working/index.js.map +1 -1
  18. package/dist/working/summary.d.ts +18 -4
  19. package/dist/working/summary.d.ts.map +1 -1
  20. package/dist/working/summary.js +58 -14
  21. package/dist/working/summary.js.map +1 -1
  22. package/dist/working/transcript.d.ts +28 -10
  23. package/dist/working/transcript.d.ts.map +1 -1
  24. package/dist/working/transcript.js +72 -40
  25. package/dist/working/transcript.js.map +1 -1
  26. package/dist/working/types.d.ts +6 -0
  27. package/dist/working/types.d.ts.map +1 -1
  28. package/package.json +3 -3
  29. package/src/working/codemap.test.ts +144 -0
  30. package/src/working/codemapAreas.ts +142 -0
  31. package/src/working/consolidate.ts +40 -6
  32. package/src/working/consolidationMarker.test.ts +12 -2
  33. package/src/working/consolidationMarker.ts +12 -5
  34. package/src/working/index.ts +19 -8
  35. package/src/working/summary.test.ts +35 -1
  36. package/src/working/summary.ts +58 -13
  37. package/src/working/transcript.ts +69 -38
  38. package/src/working/transcriptWindow.test.ts +84 -0
  39. package/src/working/types.ts +6 -0
  40. package/src/working/working.test.ts +7 -4
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Code-map RECALL — which `codemap:<area>` memories a run should be handed.
3
+ *
4
+ * {@link consolidate} writes one map per code area. A map only saves a search when the
5
+ * next run on that area is GIVEN it, so the read side lives here beside the write side,
6
+ * and every host (editor, cloud, on-prem) picks maps with the same rules:
7
+ *
8
+ * - {@link areaOfPath} — the area a named file or directory belongs to. A file at the
9
+ * repository root belongs to {@link CONSOLIDATED_ROOT_AREA}, the same area
10
+ * {@link consolidate} files a root-only run under, so that map is reachable.
11
+ * - {@link areasOfPathsIn} — the areas of the repo-relative paths a text names.
12
+ * - {@link areasNamedIn} — the stored areas a request names by WORD ("fix the run
13
+ * store" names `brain-embedded/src/runStore`), for a request that names no path.
14
+ * - {@link consolidatedFactsFor} — the maps whose area overlaps the areas a run is
15
+ * about, most specific first.
16
+ *
17
+ * Pure: no I/O.
18
+ */
19
+
20
+ /** Key prefix every consolidated code map carries. */
21
+ export const CONSOLIDATED_KEY_PREFIX = 'codemap:';
22
+ /** The area of files at the repository root (`codemap:(root)`). */
23
+ export const CONSOLIDATED_ROOT_AREA = '(root)';
24
+ /** Maps a run is handed, at most. */
25
+ export const MAX_RECALLED_MAPS = 6;
26
+
27
+ /** Too generic to say which area a request is about: nearly every area has one. */
28
+ const GENERIC_SEGMENTS: ReadonlySet<string> = new Set([
29
+ 'src', 'lib', 'libs', 'app', 'apps', 'package', 'packages', 'component', 'components', 'util', 'utils',
30
+ 'test', 'tests', 'index', 'main', 'core', 'common', 'shared', 'source', 'internal', 'client', 'clients',
31
+ 'server', 'docs', 'scripts', 'public', 'assets',
32
+ ]);
33
+ /** A word shorter than this matches too much to name an area. */
34
+ const MIN_AREA_WORD = 4;
35
+ /** Areas one request may name by word, at most. */
36
+ const MAX_WORD_AREAS = 8;
37
+
38
+ /**
39
+ * The area a repo-relative path belongs to: a file's directory, a directory itself (a
40
+ * path with no extension), and {@link CONSOLIDATED_ROOT_AREA} for a file at the root.
41
+ * `''` for an empty path.
42
+ */
43
+ export function areaOfPath(p: string): string {
44
+ const norm = p.trim().replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '');
45
+ if (!norm) return '';
46
+ if (!/\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(norm)) return norm;
47
+ const i = norm.lastIndexOf('/');
48
+ return i > 0 ? norm.slice(0, i) : CONSOLIDATED_ROOT_AREA;
49
+ }
50
+
51
+ /** The area a `codemap:` key is for, or undefined for any other key. */
52
+ export function consolidatedArea(key: string): string | undefined {
53
+ return key.startsWith(CONSOLIDATED_KEY_PREFIX) ? key.slice(CONSOLIDATED_KEY_PREFIX.length) : undefined;
54
+ }
55
+
56
+ /** True when `area` and `dir` are the same directory or one contains the other. */
57
+ function overlaps(area: string, dir: string): boolean {
58
+ return area === dir || dir.startsWith(`${area}/`) || area.startsWith(`${dir}/`);
59
+ }
60
+
61
+ /** The `codemap:` facts whose area overlaps a named area, most specific first, capped. */
62
+ export function consolidatedFactsFor<F extends { key: string }>(
63
+ facts: readonly F[],
64
+ areas: readonly string[],
65
+ max: number = MAX_RECALLED_MAPS,
66
+ ): F[] {
67
+ if (areas.length === 0) return [];
68
+ const scored: Array<{ fact: F; depth: number }> = [];
69
+ const seen = new Set<string>();
70
+ for (const fact of facts) {
71
+ const area = consolidatedArea(fact.key);
72
+ if (area === undefined || seen.has(fact.key)) continue;
73
+ if (!areas.some((d) => overlaps(area, d))) continue;
74
+ seen.add(fact.key);
75
+ scored.push({ fact, depth: area.split('/').length });
76
+ }
77
+ return scored.sort((a, b) => b.depth - a.depth).slice(0, max).map((s) => s.fact);
78
+ }
79
+
80
+ /** A repo-relative path token: has a `/` or a file extension, and is not absolute. */
81
+ const PATH_TOKEN = /[A-Za-z0-9_.@()\-\\/]+/g;
82
+ /** Paths one text may name, at most. */
83
+ const MAX_PATH_AREAS = 24;
84
+
85
+ /**
86
+ * The areas of the repo-relative paths a text names (`api/src/run.ts` → `api/src`,
87
+ * `package.json` → the root area). Absolute paths are somebody else's machine and are
88
+ * skipped, as are URLs.
89
+ */
90
+ export function areasOfPathsIn(text: string): string[] {
91
+ const out: string[] = [];
92
+ for (const raw of text.replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, ' ').match(PATH_TOKEN) ?? []) {
93
+ // A drive letter (`C:`) is not part of a token, so `C:/x/y.ts` arrives as `/x/y.ts`
94
+ // and is skipped as absolute below.
95
+ const token = raw.replace(/^[(]+|[.)]+$/g, '');
96
+ if (!token || /^[\\/]/.test(token) || token.split(/[\\/]/).includes('..')) continue;
97
+ // A bare file name needs a stem of two or more characters, so `e.g` is not a file.
98
+ const isPath = /[\\/]/.test(token) || /^[^.]{2,}.*\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(token);
99
+ if (!isPath) continue;
100
+ const area = areaOfPath(token);
101
+ if (area && !out.includes(area)) out.push(area);
102
+ if (out.length >= MAX_PATH_AREAS) break;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** `runStore`, `run-store`, `run_store` and `run store` all read `runstore`. */
108
+ function squash(s: string): string {
109
+ return s.toLowerCase().replace(/[^a-z0-9]/g, '');
110
+ }
111
+
112
+ /** The request's words and adjacent pairs, squashed — `context pack` also reads `contextpack`. */
113
+ function requestTerms(text: string): Set<string> {
114
+ const words = text
115
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
116
+ .toLowerCase()
117
+ .split(/[^a-z0-9]+/)
118
+ .filter(Boolean);
119
+ const out = new Set<string>();
120
+ for (let i = 0; i < words.length; i++) {
121
+ out.add(words[i]!);
122
+ if (words[i]!.endsWith('s') && words[i]!.length > MIN_AREA_WORD) out.add(words[i]!.slice(0, -1));
123
+ if (i + 1 < words.length) out.add(words[i]! + words[i + 1]!);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * The stored areas a request names by word: an area matches when one of its directory
130
+ * names (generic ones like `src` and short ones aside) is a word, or two adjacent words,
131
+ * of the request. Deepest areas first, capped.
132
+ */
133
+ export function areasNamedIn(text: string, areas: readonly string[]): string[] {
134
+ const terms = requestTerms(text);
135
+ if (terms.size === 0) return [];
136
+ const hits = [...new Set(areas)].filter((area) =>
137
+ area.split('/').some((seg) => {
138
+ const s = squash(seg);
139
+ return s.length >= MIN_AREA_WORD && !GENERIC_SEGMENTS.has(s) && terms.has(s);
140
+ }));
141
+ return hits.sort((a, b) => b.split('/').length - a.split('/').length).slice(0, MAX_WORD_AREAS);
142
+ }
@@ -10,15 +10,21 @@
10
10
  *
11
11
  * {@link mergeConsolidated} is the WRITE-side rule a keyed store applies, so a small run
12
12
  * ADDS to an area's map instead of overwriting the twelve files an earlier one found,
13
- * and a file this run deleted leaves the map.
13
+ * and a file this run deleted leaves the map. {@link consolidatedFiles} and
14
+ * {@link withoutConsolidatedFiles} read the stored format back, so a store can anchor a
15
+ * map to its files and drop one that changed since without hiding the rest.
14
16
  */
15
17
 
18
+ import { CONSOLIDATED_KEY_PREFIX, CONSOLIDATED_ROOT_AREA } from './codemapAreas.js';
16
19
  import { rankByCount } from './rank.js';
17
20
  import type { ConsolidateOptions, ConsolidatedMemory, WorkingMemorySnapshot } from './types.js';
18
21
 
19
- /** Key prefix every consolidated code map carries. */
20
- export const CONSOLIDATED_KEY_PREFIX = 'codemap:';
21
- /** Fewer distinct files than this, with nothing changed, is a lookup — not a map worth keeping. */
22
+ /**
23
+ * Fewer distinct files than this — changed, deleted and read together — is a lookup,
24
+ * not a map worth keeping. It holds for EVERY run, a run that changed files included: a
25
+ * one-file fix keyed to its own directory would otherwise write (or, once merged, keep
26
+ * re-dating) a map that tells the next run nothing a search would not.
27
+ */
22
28
  export const MIN_CONSOLIDATED_FILES = 3;
23
29
  const MAX_FILES = 12;
24
30
  const MIN_AREA_SEGMENTS = 2;
@@ -77,7 +83,7 @@ function dirSegments(file: string): string[] {
77
83
  */
78
84
  function areaOf(files: readonly string[]): string {
79
85
  const placed = files.map(dirSegments).filter((d) => d.length > 0);
80
- if (placed.length === 0) return '(root)';
86
+ if (placed.length === 0) return CONSOLIDATED_ROOT_AREA;
81
87
  const prefixes = (depth: number): [string, number][] =>
82
88
  rankByCount(placed.filter((d) => d.length >= depth).map((d) => d.slice(0, depth).join('/')));
83
89
  for (let depth = MAX_AREA_SEGMENTS; depth >= MIN_AREA_SEGMENTS; depth--) {
@@ -107,8 +113,8 @@ export function consolidate(snapshot: WorkingMemorySnapshot, opts: ConsolidateOp
107
113
  const reads = rankByCount(kept(snapshot.read)).filter(([p]) => !changedSet.has(p) && !deletedSet.has(p));
108
114
  const readCounts = new Map(reads);
109
115
  const ranked = [...changed, ...reads.map(([p]) => p)];
116
+ if (ranked.length + deleted.length < (opts.minFiles ?? MIN_CONSOLIDATED_FILES)) return null;
110
117
  if (ranked.length === 0 && deleted.length === 0) return null;
111
- if (changed.length === 0 && deleted.length === 0 && ranked.length < MIN_CONSOLIDATED_FILES) return null;
112
118
 
113
119
  const shown = ranked.slice(0, MAX_FILES);
114
120
  const area = areaOf(ranked.length > 0 ? ranked : deleted);
@@ -179,3 +185,31 @@ export function mergeConsolidated(key: string, prior: string | null | undefined,
179
185
  const more = merged.length - shown.length;
180
186
  return [...header, ...shown, ...(more > 0 ? [`(+${more} more file(s) touched)`] : [])].join('\n');
181
187
  }
188
+
189
+ /** The files a stored map lists, in order — never a `(deleted)` line. */
190
+ export function consolidatedFiles(content: string): string[] {
191
+ return entriesOf(content).filter((e) => e.note !== 'deleted').map((e) => e.path);
192
+ }
193
+
194
+ /** Paths compare as the store and the filesystem do: slashes normalised, case ignored. */
195
+ function samePathKey(p: string): string {
196
+ return canonical(p).toLowerCase();
197
+ }
198
+
199
+ /**
200
+ * The map with `paths` taken out — for a store whose files changed since the map was
201
+ * written: the changed file's line goes, the rest of the map stays. `null` when no file
202
+ * is left to list, so the caller drops the map rather than serving a bare header.
203
+ */
204
+ export function withoutConsolidatedFiles(content: string, paths: Iterable<string>): string | null {
205
+ const drop = new Set([...paths].map(samePathKey));
206
+ let left = 0;
207
+ const lines = content.split('\n').filter((line) => {
208
+ const m = ENTRY.exec(line);
209
+ if (!m) return true;
210
+ if (drop.has(samePathKey(m[1]!))) return false;
211
+ left++;
212
+ return true;
213
+ });
214
+ return left > 0 ? lines.join('\n') : null;
215
+ }
@@ -65,9 +65,19 @@ describe('auto consolidation markers (a persisted fold)', () => {
65
65
  expect(scopeToConsolidation(list).map((m) => m.content)).toEqual(['note', 'user turn', 'narration', 'final reply']);
66
66
  });
67
67
 
68
- it('reads as manual when keepFromId is gone or after the marker', () => {
68
+ it('ignores an auto marker whose keepFromId cannot be placed (falls back, never drops)', () => {
69
69
  const gone = [row(1, 'a'), row(2, 'note', consolidationMetadata({ keepFromId: 99 })), row(3, 'b')];
70
- expect(scopeToConsolidation(gone).map((m) => m.content)).toEqual(['note', 'b']);
70
+ expect(scopeToConsolidation(gone).map((m) => m.content)).toEqual(['a', 'b']);
71
+ const after = [row(1, 'a'), row(2, 'note', consolidationMetadata({ keepFromId: 3 })), row(3, 'b')];
72
+ expect(scopeToConsolidation(after).map((m) => m.content)).toEqual(['a', 'b']);
73
+ });
74
+
75
+ it('falls back to the previous marker when the newest auto marker is unplaceable', () => {
76
+ const list = [
77
+ row(1, 'old'), row(2, 'manual', consolidationMetadata()), row(3, 'kept'),
78
+ row(4, 'auto', consolidationMetadata({ keepFromId: 99 })), row(5, 'new'),
79
+ ];
80
+ expect(scopeToConsolidation(list).map((m) => m.content)).toEqual(['manual', 'kept', 'new']);
71
81
  });
72
82
 
73
83
  it('round-trips the auto metadata', () => {
@@ -27,7 +27,7 @@ export interface ConsolidationMarkerMeta {
27
27
  }
28
28
 
29
29
  /** The metadata of a manual marker. */
30
- export const CONSOLIDATION_META: ConsolidationMarkerMeta = { consolidation: true };
30
+ export const CONSOLIDATION_META: Readonly<ConsolidationMarkerMeta> = Object.freeze({ consolidation: true });
31
31
 
32
32
  /** Serialized metadata for a marker message (ready to persist). Manual when no `auto`. */
33
33
  export function consolidationMetadata(auto?: { keepFromId?: number; folded?: number }): string {
@@ -76,15 +76,22 @@ export function lastConsolidationIndex(messages: ReadonlyArray<{ metadata?: stri
76
76
  * cover. A manual marker covers everything before it (the list from the marker on). An
77
77
  * auto marker covers up to its `keepFromId`: the marker, then every message from that id
78
78
  * on (other markers dropped — an older note is superseded by the newer one). Unchanged
79
- * when there is no marker; a `keepFromId` no longer in the list reads as manual.
79
+ * when there is no marker.
80
+ *
81
+ * An auto marker whose `keepFromId` cannot be placed (the row was deleted, or a merge or
82
+ * sync re-inserted the history under new ids) is IGNORED — the scope falls back to the
83
+ * marker before it, or the whole list. Reading it as manual would drop every message
84
+ * between `keepFromId` and the marker that the note never held; ignoring it only costs a
85
+ * re-summarize.
80
86
  */
81
87
  export function scopeToConsolidation<T extends { id?: number; metadata?: string | null }>(messages: T[]): T[] {
82
88
  const idx = lastConsolidationIndex(messages);
83
89
  if (idx < 0) return messages;
84
90
  const marker = messages[idx]!;
85
- const keepFromId = consolidationMarkerOf(marker)?.keepFromId;
86
- const from = keepFromId === undefined ? -1 : messages.findIndex((m) => m.id === keepFromId);
87
- if (from < 0 || from > idx) return messages.slice(idx);
91
+ const meta = consolidationMarkerOf(marker)!;
92
+ if (!meta.auto) return messages.slice(idx);
93
+ const from = meta.keepFromId === undefined ? -1 : messages.findIndex((m) => m.id === meta.keepFromId);
94
+ if (from < 0 || from > idx) return scopeToConsolidation(messages.filter((m) => m !== marker));
88
95
  return [marker, ...messages.slice(from).filter((m) => m !== marker && !isConsolidationMarker(m))];
89
96
  }
90
97
 
@@ -24,26 +24,37 @@
24
24
  */
25
25
 
26
26
  export { WorkingMemory } from './workingMemory.js';
27
- export { consolidate, mergeConsolidated, consolidatedKey, CONSOLIDATED_KEY_PREFIX, MIN_CONSOLIDATED_FILES } from './consolidate.js';
27
+ export {
28
+ consolidate,
29
+ mergeConsolidated,
30
+ consolidatedKey,
31
+ consolidatedFiles,
32
+ withoutConsolidatedFiles,
33
+ MIN_CONSOLIDATED_FILES,
34
+ } from './consolidate.js';
35
+ export {
36
+ CONSOLIDATED_KEY_PREFIX,
37
+ CONSOLIDATED_ROOT_AREA,
38
+ MAX_RECALLED_MAPS,
39
+ areaOfPath,
40
+ areasNamedIn,
41
+ areasOfPathsIn,
42
+ consolidatedArea,
43
+ consolidatedFactsFor,
44
+ } from './codemapAreas.js';
28
45
  export { rankByCount } from './rank.js';
29
46
  export type { Observation, WorkingMemorySnapshot, ConsolidatedMemory, ConsolidateOptions } from './types.js';
30
47
  export {
31
48
  HISTORY_WINDOW,
32
49
  HISTORY_TOKEN_BUDGET,
33
50
  COMPACT_TAIL_TOKEN_BUDGET,
34
- COMPACT_TAIL_TURNS,
35
51
  windowed,
36
52
  stillInWorkingContext,
37
- verbatimStart,
38
- compactTailStart,
39
- compactTailStartForBudget,
40
- pinnedDirectiveIndex,
41
- assembleCompacted,
42
53
  transcriptTokens,
43
54
  buildWorkingTranscript,
44
55
  } from './transcript.js';
45
56
  export type { TranscriptMessage, CompactMemo, WorkingTranscriptState, TranscriptFold } from './transcript.js';
46
- export { TRANSCRIPT_SUMMARY_PROMPT, TRANSCRIPT_SUMMARY_MAX_TOKENS, renderForSummary, summarizeTranscript } from './summary.js';
57
+ export { TRANSCRIPT_SUMMARY_PROMPT, TRANSCRIPT_SUMMARY_MAX_TOKENS, TRANSCRIPT_SUMMARY_INPUT_TOKENS, renderForSummary, summaryChunks, summarizeTranscript } from './summary.js';
47
58
  export type { SummaryCompletion } from './summary.js';
48
59
  export {
49
60
  CONSOLIDATION_META,
@@ -4,7 +4,7 @@
4
4
  * failing or empty completion reads as "no summary" (the caller falls back).
5
5
  */
6
6
 
7
- import { renderForSummary, summarizeTranscript, TRANSCRIPT_SUMMARY_MAX_TOKENS, TRANSCRIPT_SUMMARY_PROMPT } from './summary.js';
7
+ import { renderForSummary, summaryChunks, summarizeTranscript, TRANSCRIPT_SUMMARY_MAX_TOKENS, TRANSCRIPT_SUMMARY_PROMPT } from './summary.js';
8
8
  import { buildWorkingTranscript, HISTORY_TOKEN_BUDGET, type TranscriptFold, type TranscriptMessage, type WorkingTranscriptState } from './transcript.js';
9
9
 
10
10
  describe('renderForSummary', () => {
@@ -49,3 +49,37 @@ describe('buildWorkingTranscript fold report', () => {
49
49
  expect(out[1]).toEqual({ role: 'assistant', content: folds[0]!.note });
50
50
  });
51
51
  });
52
+
53
+ describe('summarizeTranscript over a transcript longer than one call reads', () => {
54
+ const long = (n: number): TranscriptMessage[] =>
55
+ Array.from({ length: n }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: `turn ${i} ${'x'.repeat(400)}` }));
56
+
57
+ it('splits into consecutive chunks within the budget, losing nothing', () => {
58
+ const msgs = long(40);
59
+ const chunks = summaryChunks(msgs, 1_000);
60
+ expect(chunks.length).toBeGreaterThan(1);
61
+ expect(chunks.flat()).toEqual(msgs);
62
+ });
63
+
64
+ it('folds each chunk onto the note so far and returns the last note', async () => {
65
+ const inputs: string[] = [];
66
+ let call = 0;
67
+ const out = await summarizeTranscript(async (req) => { inputs.push(req.user); return `note ${++call}`; }, long(40), undefined, 1_000);
68
+ expect(call).toBeGreaterThan(1);
69
+ expect(out).toBe(`note ${call}`);
70
+ expect(inputs[0]).not.toContain('note ');
71
+ expect(inputs[1]!.startsWith('assistant: note 1')).toBe(true);
72
+ });
73
+
74
+ it('is null when any chunk fails — never a note that skipped part of the history', async () => {
75
+ let call = 0;
76
+ expect(await summarizeTranscript(async () => (++call === 2 ? '' : 'n'), long(40), undefined, 1_000)).toBeNull();
77
+ });
78
+
79
+ it('clips one message larger than the whole budget', async () => {
80
+ let seen = '';
81
+ await summarizeTranscript(async (req) => { seen = req.user; return 'n'; }, [{ role: 'user', content: 'y'.repeat(50_000) }], undefined, 1_000);
82
+ expect(seen.length).toBeLessThan(5_000);
83
+ expect(seen.endsWith('[…clipped]')).toBe(true);
84
+ });
85
+ });
@@ -6,7 +6,7 @@
6
6
  * the same everywhere.
7
7
  */
8
8
 
9
- import type { TranscriptMessage } from './transcript.js';
9
+ import { transcriptTokens, type TranscriptMessage } from './transcript.js';
10
10
 
11
11
  /** The summarizer's instructions. */
12
12
  export const TRANSCRIPT_SUMMARY_PROMPT =
@@ -19,6 +19,14 @@ export const TRANSCRIPT_SUMMARY_PROMPT =
19
19
  */
20
20
  export const TRANSCRIPT_SUMMARY_MAX_TOKENS = 2_500;
21
21
 
22
+ /**
23
+ * The most transcript one summarizer call reads (~tokens). A longer input — a whole long
24
+ * chat on "Consolidate", a first fold over a big seeded history — is summarized in
25
+ * CHUNKS of this size, each folded onto the note so far, so no single call can overrun
26
+ * a model's window.
27
+ */
28
+ export const TRANSCRIPT_SUMMARY_INPUT_TOKENS = 48_000;
29
+
22
30
  /** One no-tools, no-reasoning completion — the host's model call. Resolves the text. */
23
31
  export type SummaryCompletion = (request: {
24
32
  system: string;
@@ -45,24 +53,61 @@ export function renderForSummary(msgs: readonly TranscriptMessage[]): string {
45
53
  }
46
54
 
47
55
  /**
48
- * Compress `msgs` into a memory note. Null on any failure or an empty answer, so the
49
- * caller falls back (drop-oldest, elision, "nothing to consolidate").
56
+ * Split a transcript into consecutive chunks of at most `budget` tokens. A single message
57
+ * larger than the budget is a chunk of its own (its text is clipped when rendered).
58
+ */
59
+ export function summaryChunks(msgs: readonly TranscriptMessage[], budget = TRANSCRIPT_SUMMARY_INPUT_TOKENS): TranscriptMessage[][] {
60
+ const chunks: TranscriptMessage[][] = [];
61
+ let chunk: TranscriptMessage[] = [];
62
+ let used = 0;
63
+ for (const m of msgs) {
64
+ const cost = transcriptTokens([m]);
65
+ if (chunk.length > 0 && used + cost > budget) {
66
+ chunks.push(chunk);
67
+ chunk = [];
68
+ used = 0;
69
+ }
70
+ chunk.push(m);
71
+ used += cost;
72
+ }
73
+ if (chunk.length > 0) chunks.push(chunk);
74
+ return chunks;
75
+ }
76
+
77
+ /** ~4 characters a token: the rendered text one call may carry. */
78
+ const clip = (text: string, budget: number) => {
79
+ const max = budget * 4;
80
+ return text.length > max ? `${text.slice(0, max)}\n[…clipped]` : text;
81
+ };
82
+
83
+ /**
84
+ * Compress `msgs` into a memory note — in one call, or chunk by chunk with each chunk
85
+ * folded onto the note so far when the transcript is longer than one call may read. Null
86
+ * on any failure or an empty answer, so the caller falls back (drop-oldest, elision,
87
+ * "nothing to consolidate").
50
88
  */
51
89
  export async function summarizeTranscript(
52
90
  complete: SummaryCompletion,
53
91
  msgs: readonly TranscriptMessage[],
54
92
  signal?: AbortSignal,
93
+ budget = TRANSCRIPT_SUMMARY_INPUT_TOKENS,
55
94
  ): Promise<string | null> {
56
95
  if (msgs.length === 0) return null;
57
- try {
58
- const out = ((await complete({
59
- system: TRANSCRIPT_SUMMARY_PROMPT,
60
- user: renderForSummary(msgs),
61
- maxTokens: TRANSCRIPT_SUMMARY_MAX_TOKENS,
62
- ...(signal ? { signal } : {}),
63
- })) ?? '').trim();
64
- return out.length > 0 ? out : null;
65
- } catch {
66
- return null;
96
+ let note: string | null = null;
97
+ for (const chunk of summaryChunks(msgs, budget)) {
98
+ const input: TranscriptMessage[] = note ? [{ role: 'assistant', content: note } as TranscriptMessage, ...chunk] : chunk;
99
+ try {
100
+ const out: string = ((await complete({
101
+ system: TRANSCRIPT_SUMMARY_PROMPT,
102
+ user: clip(renderForSummary(input), budget),
103
+ maxTokens: TRANSCRIPT_SUMMARY_MAX_TOKENS,
104
+ ...(signal ? { signal } : {}),
105
+ })) ?? '').trim();
106
+ if (!out) return null;
107
+ note = out;
108
+ } catch {
109
+ return null;
110
+ }
67
111
  }
112
+ return note;
68
113
  }
@@ -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
@@ -284,7 +315,7 @@ export async function buildWorkingTranscript<M extends TranscriptMessage>(
284
315
  ? assembleCompacted(systemPrompt, convo, memo.note, memo.coveredEnd)
285
316
  : [{ role: 'system', content: systemPrompt } as M, ...windowed(convo)];
286
317
  }
287
- const note = `Compressed memory of the first ${to} message(s) of this conversation:\n${summary}`;
318
+ const note = `Compressed memory of the earlier part of this conversation:\n${summary}`;
288
319
  state.compactMemo = { note, coveredEnd: to };
289
320
  onFolded({ droppedMessages: to - from, note, coveredEnd: to });
290
321
  return assembleCompacted(systemPrompt, convo, note, to);