@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.
- 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/consolidationMarker.d.ts +8 -2
- package/dist/working/consolidationMarker.d.ts.map +1 -1
- package/dist/working/consolidationMarker.js +13 -5
- package/dist/working/consolidationMarker.js.map +1 -1
- package/dist/working/index.d.ts +4 -3
- package/dist/working/index.d.ts.map +1 -1
- package/dist/working/index.js +4 -3
- package/dist/working/index.js.map +1 -1
- package/dist/working/summary.d.ts +18 -4
- package/dist/working/summary.d.ts.map +1 -1
- package/dist/working/summary.js +58 -14
- package/dist/working/summary.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 +72 -40
- 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/consolidationMarker.test.ts +12 -2
- package/src/working/consolidationMarker.ts +12 -5
- package/src/working/index.ts +19 -8
- package/src/working/summary.test.ts +35 -1
- package/src/working/summary.ts +58 -13
- package/src/working/transcript.ts +69 -38
- package/src/working/transcriptWindow.test.ts +84 -0
- package/src/working/types.ts +6 -0
- 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
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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('
|
|
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(['
|
|
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
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
package/src/working/index.ts
CHANGED
|
@@ -24,26 +24,37 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
export { WorkingMemory } from './workingMemory.js';
|
|
27
|
-
export {
|
|
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
|
+
});
|
package/src/working/summary.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* the same everywhere.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type
|
|
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
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
*
|
|
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
|
|
@@ -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
|
|
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);
|