@seanhogg/builderforce-memory 2026.10.23 → 2026.10.25
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/dist/handoff/index.d.ts +1 -0
- package/dist/handoff/index.d.ts.map +1 -1
- package/dist/handoff/index.js +1 -0
- package/dist/handoff/index.js.map +1 -1
- package/dist/handoff/pack.d.ts.map +1 -1
- package/dist/handoff/pack.js +30 -3
- package/dist/handoff/pack.js.map +1 -1
- package/dist/handoff/volatile.d.ts +32 -0
- package/dist/handoff/volatile.d.ts.map +1 -0
- package/dist/handoff/volatile.js +42 -0
- package/dist/handoff/volatile.js.map +1 -0
- package/dist/working/consolidate.d.ts +39 -0
- package/dist/working/consolidate.d.ts.map +1 -0
- package/dist/working/consolidate.js +179 -0
- package/dist/working/consolidate.js.map +1 -0
- package/dist/working/consolidationMarker.d.ts +75 -0
- package/dist/working/consolidationMarker.d.ts.map +1 -0
- package/dist/working/consolidationMarker.js +104 -0
- package/dist/working/consolidationMarker.js.map +1 -0
- package/dist/working/index.d.ts +35 -0
- package/dist/working/index.d.ts.map +1 -0
- package/dist/working/index.js +31 -0
- package/dist/working/index.js.map +1 -0
- package/dist/working/rank.d.ts +7 -0
- package/dist/working/rank.d.ts.map +1 -0
- package/dist/working/rank.js +12 -0
- package/dist/working/rank.js.map +1 -0
- package/dist/working/summary.d.ts +31 -0
- package/dist/working/summary.d.ts.map +1 -0
- package/dist/working/summary.js +48 -0
- package/dist/working/summary.js.map +1 -0
- package/dist/working/transcript.d.ts +137 -0
- package/dist/working/transcript.d.ts.map +1 -0
- package/dist/working/transcript.js +243 -0
- package/dist/working/transcript.js.map +1 -0
- package/dist/working/types.d.ts +33 -0
- package/dist/working/types.d.ts.map +1 -0
- package/dist/working/types.js +5 -0
- package/dist/working/types.js.map +1 -0
- package/dist/working/workingMemory.d.ts +45 -0
- package/dist/working/workingMemory.d.ts.map +1 -0
- package/dist/working/workingMemory.js +103 -0
- package/dist/working/workingMemory.js.map +1 -0
- package/package.json +7 -3
- package/src/handoff/handoff.test.ts +28 -1
- package/src/handoff/index.ts +1 -0
- package/src/handoff/pack.ts +30 -4
- package/src/handoff/volatile.ts +46 -0
- package/src/working/consolidate.ts +181 -0
- package/src/working/consolidationMarker.test.ts +95 -0
- package/src/working/consolidationMarker.ts +115 -0
- package/src/working/index.ts +59 -0
- package/src/working/rank.ts +10 -0
- package/src/working/summary.test.ts +51 -0
- package/src/working/summary.ts +68 -0
- package/src/working/transcript.test.ts +125 -0
- package/src/working/transcript.ts +291 -0
- package/src/working/types.ts +32 -0
- package/src/working/working.test.ts +120 -0
- package/src/working/workingMemory.ts +103 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/working/consolidationMarker.test.ts
|
|
3
|
+
* Consolidation markers (moved unchanged from brain-embedded) + the auto-fold coverage rule.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
consolidationMetadata,
|
|
7
|
+
consolidationMarkerContent,
|
|
8
|
+
isConsolidationMarker,
|
|
9
|
+
lastConsolidationIndex,
|
|
10
|
+
scopeToConsolidation,
|
|
11
|
+
CONSOLIDATION_MARKER_PREFIX,
|
|
12
|
+
consolidationMarkerOf,
|
|
13
|
+
foldKeepFromId,
|
|
14
|
+
} from './consolidationMarker.js';
|
|
15
|
+
|
|
16
|
+
const msg = (content: string, metadata: string | null = null) => ({ content, metadata });
|
|
17
|
+
|
|
18
|
+
describe('consolidation markers', () => {
|
|
19
|
+
it('flags a message tagged in metadata', () => {
|
|
20
|
+
expect(isConsolidationMarker(msg('x', consolidationMetadata()))).toBe(true);
|
|
21
|
+
expect(isConsolidationMarker(msg('x', null))).toBe(false);
|
|
22
|
+
expect(isConsolidationMarker(msg('x', '{"feedback":"up"}'))).toBe(false);
|
|
23
|
+
expect(isConsolidationMarker(msg('x', 'not json'))).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('finds the LAST marker index', () => {
|
|
27
|
+
const list = [
|
|
28
|
+
msg('a'),
|
|
29
|
+
msg('sum1', consolidationMetadata()),
|
|
30
|
+
msg('b'),
|
|
31
|
+
msg('sum2', consolidationMetadata()),
|
|
32
|
+
msg('c'),
|
|
33
|
+
];
|
|
34
|
+
expect(lastConsolidationIndex(list)).toBe(3);
|
|
35
|
+
expect(lastConsolidationIndex([msg('a'), msg('b')])).toBe(-1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('scopes the seed from the last marker (inclusive)', () => {
|
|
39
|
+
const list = [msg('a'), msg('b'), msg('sum', consolidationMetadata()), msg('c')];
|
|
40
|
+
expect(scopeToConsolidation(list).map((m) => m.content)).toEqual(['sum', 'c']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns the full list unchanged when there is no marker', () => {
|
|
44
|
+
const list = [msg('a'), msg('b')];
|
|
45
|
+
expect(scopeToConsolidation(list)).toBe(list);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('wraps a summary with the visible marker prefix', () => {
|
|
49
|
+
const content = consolidationMarkerContent(' hello ');
|
|
50
|
+
expect(content).toBe(`${CONSOLIDATION_MARKER_PREFIX}hello`);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('auto consolidation markers (a persisted fold)', () => {
|
|
55
|
+
const row = (id: number, content: string, metadata: string | null = null) => ({ id, content, metadata });
|
|
56
|
+
|
|
57
|
+
it('keeps every message from keepFromId on, after the marker, and drops older notes', () => {
|
|
58
|
+
const list = [
|
|
59
|
+
row(1, 'old user'), row(2, 'old reply'),
|
|
60
|
+
row(3, 'superseded note', consolidationMetadata({ keepFromId: 2 })),
|
|
61
|
+
row(4, 'user turn'), row(5, 'narration'),
|
|
62
|
+
row(6, 'note', consolidationMetadata({ keepFromId: 4, folded: 12 })),
|
|
63
|
+
row(7, 'final reply'),
|
|
64
|
+
];
|
|
65
|
+
expect(scopeToConsolidation(list).map((m) => m.content)).toEqual(['note', 'user turn', 'narration', 'final reply']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('reads as manual when keepFromId is gone or after the marker', () => {
|
|
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']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('round-trips the auto metadata', () => {
|
|
74
|
+
expect(consolidationMarkerOf(row(1, 'x', consolidationMetadata({ keepFromId: 4, folded: 3 })))).toEqual({ consolidation: true, auto: true, keepFromId: 4, folded: 3 });
|
|
75
|
+
expect(consolidationMarkerOf(row(1, 'x', consolidationMetadata()))).toEqual({ consolidation: true });
|
|
76
|
+
expect(consolidationMarkerOf(row(1, 'x', null))).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('foldKeepFromId', () => {
|
|
81
|
+
const anchors = [{ index: 0, id: 10 }, { index: 1, id: 11 }, { index: 2, id: 12 }, { index: 7, id: 20 }];
|
|
82
|
+
|
|
83
|
+
it('is exact when a persisted message sits at the cut', () => {
|
|
84
|
+
expect(foldKeepFromId(anchors, 2)).toBe(12);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('falls back to the nearest persisted message before the cut (covered twice, never lost)', () => {
|
|
88
|
+
expect(foldKeepFromId(anchors, 5)).toBe(12);
|
|
89
|
+
expect(foldKeepFromId(anchors, 30)).toBe(20);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('is undefined with nothing persisted before the cut', () => {
|
|
93
|
+
expect(foldKeepFromId([{ index: 4, id: 1 }], 2)).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat consolidation markers — the durable form of a compressed conversation.
|
|
3
|
+
*
|
|
4
|
+
* A long conversation is compressed into ONE summary that becomes the new base context:
|
|
5
|
+
* everything before the marker stays visible in the transcript, but the next run's model
|
|
6
|
+
* seed starts from the marker instead of the full history. The marker is a normal
|
|
7
|
+
* assistant message (so a reader SEES the summary) tagged in its metadata.
|
|
8
|
+
*
|
|
9
|
+
* Two writers:
|
|
10
|
+
* - MANUAL — "Consolidate" / "Fork": the summary covers everything before the marker.
|
|
11
|
+
* - AUTO — a run's working-transcript fold (`transcript.ts`), persisted so the next run
|
|
12
|
+
* (after a reload, on another surface) starts from the note instead of summarizing the
|
|
13
|
+
* whole history again. A fold covers only the OLDER part of the transcript; its
|
|
14
|
+
* `keepFromId` names the first persisted message it did NOT cover, and every message
|
|
15
|
+
* from there on stays verbatim in the seed. Nothing is in neither.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The marker's metadata. `auto` / `keepFromId` / `folded` are set only by a run's fold. */
|
|
19
|
+
export interface ConsolidationMarkerMeta {
|
|
20
|
+
consolidation: true;
|
|
21
|
+
/** Written by auto-compaction, not by a person. */
|
|
22
|
+
auto?: true;
|
|
23
|
+
/** The first persisted message the note does NOT cover (kept verbatim after the marker). */
|
|
24
|
+
keepFromId?: number;
|
|
25
|
+
/** How many transcript messages the fold newly compressed. */
|
|
26
|
+
folded?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The metadata of a manual marker. */
|
|
30
|
+
export const CONSOLIDATION_META: ConsolidationMarkerMeta = { consolidation: true };
|
|
31
|
+
|
|
32
|
+
/** Serialized metadata for a marker message (ready to persist). Manual when no `auto`. */
|
|
33
|
+
export function consolidationMetadata(auto?: { keepFromId?: number; folded?: number }): string {
|
|
34
|
+
if (!auto) return JSON.stringify(CONSOLIDATION_META);
|
|
35
|
+
const meta: ConsolidationMarkerMeta = {
|
|
36
|
+
consolidation: true,
|
|
37
|
+
auto: true,
|
|
38
|
+
...(auto.keepFromId !== undefined ? { keepFromId: auto.keepFromId } : {}),
|
|
39
|
+
...(auto.folded !== undefined ? { folded: auto.folded } : {}),
|
|
40
|
+
};
|
|
41
|
+
return JSON.stringify(meta);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The marker metadata a persisted message carries, or null when it is not a marker. */
|
|
45
|
+
export function consolidationMarkerOf(msg: { metadata?: string | null }): ConsolidationMarkerMeta | null {
|
|
46
|
+
if (!msg.metadata) return null;
|
|
47
|
+
try {
|
|
48
|
+
const m = JSON.parse(msg.metadata) as Partial<ConsolidationMarkerMeta> | null;
|
|
49
|
+
if (m?.consolidation !== true) return null;
|
|
50
|
+
return {
|
|
51
|
+
consolidation: true,
|
|
52
|
+
...(m.auto === true ? { auto: true as const } : {}),
|
|
53
|
+
...(typeof m.keepFromId === 'number' && Number.isFinite(m.keepFromId) ? { keepFromId: m.keepFromId } : {}),
|
|
54
|
+
...(typeof m.folded === 'number' && Number.isFinite(m.folded) && m.folded > 0 ? { folded: m.folded } : {}),
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when a persisted message is a consolidation marker (by its metadata flag). */
|
|
62
|
+
export function isConsolidationMarker(msg: { metadata?: string | null }): boolean {
|
|
63
|
+
return consolidationMarkerOf(msg) !== null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The index of the LAST consolidation marker in a message list, or -1 if none. */
|
|
67
|
+
export function lastConsolidationIndex(messages: ReadonlyArray<{ metadata?: string | null }>): number {
|
|
68
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
69
|
+
if (isConsolidationMarker(messages[i]!)) return i;
|
|
70
|
+
}
|
|
71
|
+
return -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Trim a message list to the compressed context: the last marker, then what it does not
|
|
76
|
+
* cover. A manual marker covers everything before it (the list from the marker on). An
|
|
77
|
+
* auto marker covers up to its `keepFromId`: the marker, then every message from that id
|
|
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.
|
|
80
|
+
*/
|
|
81
|
+
export function scopeToConsolidation<T extends { id?: number; metadata?: string | null }>(messages: T[]): T[] {
|
|
82
|
+
const idx = lastConsolidationIndex(messages);
|
|
83
|
+
if (idx < 0) return messages;
|
|
84
|
+
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);
|
|
88
|
+
return [marker, ...messages.slice(from).filter((m) => m !== marker && !isConsolidationMarker(m))];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where an auto marker's verbatim part begins: given the persisted messages the run's
|
|
93
|
+
* transcript holds (`anchors`: transcript index → message id) and the index the fold now
|
|
94
|
+
* covers up to (exclusive), the id of the persisted message the next seed must keep from.
|
|
95
|
+
*
|
|
96
|
+
* Exact when a persisted message sits AT `coveredEnd` (the fold ended inside seeded
|
|
97
|
+
* history). Otherwise the nearest persisted message BEFORE it — conservative: the span
|
|
98
|
+
* between it and `coveredEnd` is then both in the note and verbatim, never in neither.
|
|
99
|
+
* Undefined when no persisted message precedes the cut (nothing to keep from).
|
|
100
|
+
*/
|
|
101
|
+
export function foldKeepFromId(anchors: ReadonlyArray<{ index: number; id: number }>, coveredEnd: number): number | undefined {
|
|
102
|
+
let best: { index: number; id: number } | undefined;
|
|
103
|
+
for (const a of anchors) {
|
|
104
|
+
if (a.index <= coveredEnd && (!best || a.index > best.index)) best = a;
|
|
105
|
+
}
|
|
106
|
+
return best?.id;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The visible header prefixed onto a consolidation summary so a reader recognizes it. */
|
|
110
|
+
export const CONSOLIDATION_MARKER_PREFIX = '📌 **Consolidated summary** — context continues from here.\n\n';
|
|
111
|
+
|
|
112
|
+
/** Wrap a raw summary as the marker's visible content (prefix + summary). */
|
|
113
|
+
export function consolidationMarkerContent(summary: string): string {
|
|
114
|
+
return `${CONSOLIDATION_MARKER_PREFIX}${summary.trim()}`;
|
|
115
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working memory — the `@seanhogg/builderforce-memory/working` subpath.
|
|
3
|
+
*
|
|
4
|
+
* A run has two memories, and they do different jobs:
|
|
5
|
+
*
|
|
6
|
+
* - SHORT-TERM: {@link WorkingMemory}, what THIS run has read, searched and changed.
|
|
7
|
+
* Restated before every turn ({@link WorkingMemory.brief}), because a model's own
|
|
8
|
+
* reasoning is not carried into its next turn and old tool results are compacted
|
|
9
|
+
* out of its window — without it a long run re-derives the codebase every turn.
|
|
10
|
+
* - LONG-TERM: {@link consolidate}, what survives the run — a map of where the code
|
|
11
|
+
* for that area lives, keyed per area so the next run on it (any surface) opens the
|
|
12
|
+
* right files first. {@link mergeConsolidated} is the write-side rule that makes
|
|
13
|
+
* consolidations of one area accumulate instead of overwriting each other.
|
|
14
|
+
*
|
|
15
|
+
* The memory knows observations ("read this file", "changed that one"), never tool
|
|
16
|
+
* names — a host maps its own tool vocabulary onto {@link Observation}.
|
|
17
|
+
*
|
|
18
|
+
* And what the MODEL sees of a run each turn — the working TRANSCRIPT (`transcript.ts`):
|
|
19
|
+
* the drop-oldest window and the incremental auto-compaction fold, the ONE summarizer
|
|
20
|
+
* prompt it folds with (`summary.ts`), and the consolidation markers a fold or a
|
|
21
|
+
* "Consolidate" persists so the next run starts from the note (`consolidationMarker.ts`).
|
|
22
|
+
*
|
|
23
|
+
* Pure: no `node:*`, no I/O — runs in a Cloudflare Worker, a browser page and Node alike.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export { WorkingMemory } from './workingMemory.js';
|
|
27
|
+
export { consolidate, mergeConsolidated, consolidatedKey, CONSOLIDATED_KEY_PREFIX, MIN_CONSOLIDATED_FILES } from './consolidate.js';
|
|
28
|
+
export { rankByCount } from './rank.js';
|
|
29
|
+
export type { Observation, WorkingMemorySnapshot, ConsolidatedMemory, ConsolidateOptions } from './types.js';
|
|
30
|
+
export {
|
|
31
|
+
HISTORY_WINDOW,
|
|
32
|
+
HISTORY_TOKEN_BUDGET,
|
|
33
|
+
COMPACT_TAIL_TOKEN_BUDGET,
|
|
34
|
+
COMPACT_TAIL_TURNS,
|
|
35
|
+
windowed,
|
|
36
|
+
stillInWorkingContext,
|
|
37
|
+
verbatimStart,
|
|
38
|
+
compactTailStart,
|
|
39
|
+
compactTailStartForBudget,
|
|
40
|
+
pinnedDirectiveIndex,
|
|
41
|
+
assembleCompacted,
|
|
42
|
+
transcriptTokens,
|
|
43
|
+
buildWorkingTranscript,
|
|
44
|
+
} from './transcript.js';
|
|
45
|
+
export type { TranscriptMessage, CompactMemo, WorkingTranscriptState, TranscriptFold } from './transcript.js';
|
|
46
|
+
export { TRANSCRIPT_SUMMARY_PROMPT, TRANSCRIPT_SUMMARY_MAX_TOKENS, renderForSummary, summarizeTranscript } from './summary.js';
|
|
47
|
+
export type { SummaryCompletion } from './summary.js';
|
|
48
|
+
export {
|
|
49
|
+
CONSOLIDATION_META,
|
|
50
|
+
CONSOLIDATION_MARKER_PREFIX,
|
|
51
|
+
consolidationMetadata,
|
|
52
|
+
consolidationMarkerOf,
|
|
53
|
+
consolidationMarkerContent,
|
|
54
|
+
isConsolidationMarker,
|
|
55
|
+
lastConsolidationIndex,
|
|
56
|
+
scopeToConsolidation,
|
|
57
|
+
foldKeepFromId,
|
|
58
|
+
} from './consolidationMarker.js';
|
|
59
|
+
export type { ConsolidationMarkerMeta } from './consolidationMarker.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Each distinct item with how often it occurs, most frequent first, ties by name — the
|
|
3
|
+
* one ranking the brief, the consolidation and its area all use (and that hosts reuse
|
|
4
|
+
* for their own renderings of a run, so a run reads the same everywhere).
|
|
5
|
+
*/
|
|
6
|
+
export function rankByCount(items: readonly string[]): [string, number][] {
|
|
7
|
+
const counts = new Map<string, number>();
|
|
8
|
+
for (const i of items) counts.set(i, (counts.get(i) ?? 0) + 1);
|
|
9
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
10
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/working/summary.test.ts
|
|
3
|
+
* The ONE transcript summarizer: what it asks, how it renders tool calls, and that a
|
|
4
|
+
* failing or empty completion reads as "no summary" (the caller falls back).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { renderForSummary, summarizeTranscript, TRANSCRIPT_SUMMARY_MAX_TOKENS, TRANSCRIPT_SUMMARY_PROMPT } from './summary.js';
|
|
8
|
+
import { buildWorkingTranscript, HISTORY_TOKEN_BUDGET, type TranscriptFold, type TranscriptMessage, type WorkingTranscriptState } from './transcript.js';
|
|
9
|
+
|
|
10
|
+
describe('renderForSummary', () => {
|
|
11
|
+
it('names the tools an assistant turn called', () => {
|
|
12
|
+
const text = renderForSummary([
|
|
13
|
+
{ role: 'user', content: 'fix it' },
|
|
14
|
+
{ role: 'assistant', content: '', tool_calls: [{ function: { name: 'read_file' } }, { function: { name: 'edit' } }] },
|
|
15
|
+
{ role: 'tool', content: { ok: true } },
|
|
16
|
+
]);
|
|
17
|
+
expect(text).toBe('user: fix it\n\nassistant [called: read_file, edit]: \n\ntool: {"ok":true}');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('summarizeTranscript', () => {
|
|
22
|
+
it('asks the shared prompt with the bounded budget and trims the answer', async () => {
|
|
23
|
+
const seen: Array<{ system: string; maxTokens: number }> = [];
|
|
24
|
+
const out = await summarizeTranscript(async (req) => { seen.push(req); return ' the note '; }, [{ role: 'user', content: 'x' }]);
|
|
25
|
+
expect(out).toBe('the note');
|
|
26
|
+
expect(seen[0]).toMatchObject({ system: TRANSCRIPT_SUMMARY_PROMPT, maxTokens: TRANSCRIPT_SUMMARY_MAX_TOKENS });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('is null for nothing to summarize, an empty answer, or a failure', async () => {
|
|
30
|
+
expect(await summarizeTranscript(async () => 'x', [])).toBeNull();
|
|
31
|
+
expect(await summarizeTranscript(async () => ' ', [{ role: 'user', content: 'x' }])).toBeNull();
|
|
32
|
+
expect(await summarizeTranscript(async () => { throw new Error('down'); }, [{ role: 'user', content: 'x' }])).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('buildWorkingTranscript fold report', () => {
|
|
37
|
+
it('reports the note and the index it covers up to', async () => {
|
|
38
|
+
const big = 'x'.repeat(HISTORY_TOKEN_BUDGET); // ~16k tokens each
|
|
39
|
+
const state: WorkingTranscriptState = {
|
|
40
|
+
transcript: Array.from({ length: 12 }, (_, i): TranscriptMessage => ({ role: i % 2 ? 'assistant' : 'user', content: big })),
|
|
41
|
+
compactMemo: null,
|
|
42
|
+
};
|
|
43
|
+
const folds: TranscriptFold[] = [];
|
|
44
|
+
const out = await buildWorkingTranscript(state, 'sys', async () => 'summary', (f) => folds.push(f));
|
|
45
|
+
expect(folds).toHaveLength(1);
|
|
46
|
+
expect(folds[0]!.droppedMessages).toBe(folds[0]!.coveredEnd);
|
|
47
|
+
expect(folds[0]!.note).toContain('summary');
|
|
48
|
+
expect(state.compactMemo).toEqual({ note: folds[0]!.note, coveredEnd: folds[0]!.coveredEnd });
|
|
49
|
+
expect(out[1]).toEqual({ role: 'assistant', content: folds[0]!.note });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript summarizer — the ONE prompt every fold, hand-off note and chat
|
|
3
|
+
* consolidation compresses a conversation with. The model call is the host's
|
|
4
|
+
* ({@link SummaryCompletion}); this module owns what is asked and how the transcript is
|
|
5
|
+
* rendered for it, so a compaction note, a hand-off note and a "Consolidate" summary read
|
|
6
|
+
* the same everywhere.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TranscriptMessage } from './transcript.js';
|
|
10
|
+
|
|
11
|
+
/** The summarizer's instructions. */
|
|
12
|
+
export const TRANSCRIPT_SUMMARY_PROMPT =
|
|
13
|
+
'You compress an in-progress AI agent transcript into a concise MEMORY the agent keeps working from. Capture: the CURRENT outstanding instruction from the user (the most recent user message is authoritative — earlier requests it supersedes are history, not the active task), concrete facts/answers discovered, tool results that matter (ids, paths, values), decisions made, and what still remains to do. Keep every file path, symbol name and line number the remaining work depends on, with the specific facts learned from each file — the agent no longer sees those results, so what you leave out it must read again. Be information-dense; drop pleasantries. No preamble.';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The note's output ceiling. A compaction note is a UTILITY completion: a bounded answer
|
|
17
|
+
* with no thinking. ~2.5k tokens: the note is the agent's only record of every file it
|
|
18
|
+
* folds (paths, symbols, line numbers), and 1.2k was too little to carry them.
|
|
19
|
+
*/
|
|
20
|
+
export const TRANSCRIPT_SUMMARY_MAX_TOKENS = 2_500;
|
|
21
|
+
|
|
22
|
+
/** One no-tools, no-reasoning completion — the host's model call. Resolves the text. */
|
|
23
|
+
export type SummaryCompletion = (request: {
|
|
24
|
+
system: string;
|
|
25
|
+
user: string;
|
|
26
|
+
maxTokens: number;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
}) => Promise<string | null | undefined>;
|
|
29
|
+
|
|
30
|
+
interface NamedToolCall {
|
|
31
|
+
function?: { name?: unknown };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Render a slice of the transcript to the compact text the summarizer compresses. */
|
|
35
|
+
export function renderForSummary(msgs: readonly TranscriptMessage[]): string {
|
|
36
|
+
return msgs
|
|
37
|
+
.map((m) => {
|
|
38
|
+
const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '');
|
|
39
|
+
const calls = Array.isArray(m.tool_calls) && m.tool_calls.length > 0
|
|
40
|
+
? ` [called: ${(m.tool_calls as NamedToolCall[]).map((t) => t.function?.name).filter((n): n is string => typeof n === 'string' && n.length > 0).join(', ')}]`
|
|
41
|
+
: '';
|
|
42
|
+
return `${m.role}${calls}: ${content}`;
|
|
43
|
+
})
|
|
44
|
+
.join('\n\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
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").
|
|
50
|
+
*/
|
|
51
|
+
export async function summarizeTranscript(
|
|
52
|
+
complete: SummaryCompletion,
|
|
53
|
+
msgs: readonly TranscriptMessage[],
|
|
54
|
+
signal?: AbortSignal,
|
|
55
|
+
): Promise<string | null> {
|
|
56
|
+
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;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/working/transcript.test.ts
|
|
3
|
+
* The working-transcript window and fold partitioning (moved unchanged from brain-embedded).
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
windowed,
|
|
7
|
+
compactTailStart,
|
|
8
|
+
compactTailStartForBudget,
|
|
9
|
+
verbatimStart,
|
|
10
|
+
assembleCompacted,
|
|
11
|
+
pinnedDirectiveIndex,
|
|
12
|
+
COMPACT_TAIL_TURNS,
|
|
13
|
+
} from './transcript.js';
|
|
14
|
+
import type { TranscriptMessage as ChatCompletionMessage } from './transcript.js';
|
|
15
|
+
|
|
16
|
+
describe('windowed history (must begin with a user turn)', () => {
|
|
17
|
+
const msg = (role: ChatCompletionMessage['role'], content = 'x'): ChatCompletionMessage => ({ role, content });
|
|
18
|
+
|
|
19
|
+
it('keeps a normal short conversation intact', () => {
|
|
20
|
+
const convo = [msg('user'), msg('assistant'), msg('user'), msg('assistant')];
|
|
21
|
+
expect(windowed(convo)).toEqual(convo);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('drops a leading orphaned tool result', () => {
|
|
25
|
+
const convo = [msg('tool'), msg('user'), msg('assistant')];
|
|
26
|
+
expect(windowed(convo)[0].role).toBe('user');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('drops a leading assistant turn so the payload starts at a user turn (the googleai 400)', () => {
|
|
30
|
+
// After a long tool-loop slid the user turn out of the last-N slice, the
|
|
31
|
+
// window would otherwise start on an assistant tool-call turn — which Gemini
|
|
32
|
+
// rejects with INVALID_ARGUMENT.
|
|
33
|
+
const convo = [msg('assistant'), msg('tool'), msg('user'), msg('assistant'), msg('tool')];
|
|
34
|
+
expect(windowed(convo)[0].role).toBe('user');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('anchors to the last user turn when the window has none (tool loop > window)', () => {
|
|
38
|
+
// 90 assistant/tool messages after a single user turn: the last-80 slice has
|
|
39
|
+
// no user turn, so we fall back to the most recent user turn in the full
|
|
40
|
+
// transcript rather than emit a user-less (invalid) request.
|
|
41
|
+
const convo: ChatCompletionMessage[] = [msg('user', 'go')];
|
|
42
|
+
for (let i = 0; i < 90; i++) convo.push(msg(i % 2 === 0 ? 'assistant' : 'tool'));
|
|
43
|
+
const w = windowed(convo);
|
|
44
|
+
expect(w[0].role).toBe('user');
|
|
45
|
+
expect(w[0].content).toBe('go');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('auto-compaction partitioning (summarize the middle, never orphan a tool)', () => {
|
|
50
|
+
const msg = (role: ChatCompletionMessage['role'], content = 'x'): ChatCompletionMessage => ({ role, content });
|
|
51
|
+
|
|
52
|
+
it('walks the tail forward off a leading orphaned tool result', () => {
|
|
53
|
+
// A tail that would start on a `tool` message (its assistant call is in the
|
|
54
|
+
// summarized middle) must advance past it so nothing is orphaned.
|
|
55
|
+
const convo = [msg('user'), msg('assistant'), msg('tool'), msg('assistant'), msg('user')];
|
|
56
|
+
const start = compactTailStart(convo, 3); // last 3 = [tool, assistant, user]
|
|
57
|
+
expect(convo[start].role).not.toBe('tool');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('assembled output is [system, memo, active directive, ...tail] and never orphans a tool', () => {
|
|
61
|
+
const convo: ChatCompletionMessage[] = [msg('user', 'task')];
|
|
62
|
+
for (let i = 0; i < 30; i++) convo.push(msg(i % 2 === 0 ? 'assistant' : 'tool', `s${i}`));
|
|
63
|
+
const out = assembleCompacted('SYS', convo, 'MEMO', compactTailStart(convo, COMPACT_TAIL_TURNS));
|
|
64
|
+
expect(out[0]).toEqual({ role: 'system', content: 'SYS' });
|
|
65
|
+
// Memo first (the compressed history), THEN the active directive verbatim — not the
|
|
66
|
+
// other way round, so the model reads the directive as the current instruction.
|
|
67
|
+
expect(out[1]).toEqual({ role: 'assistant', content: 'MEMO' });
|
|
68
|
+
expect(out[2].role).toBe('user');
|
|
69
|
+
expect(out[2].content).toBe('task');
|
|
70
|
+
// The first tail message after the directive is never an orphaned tool result.
|
|
71
|
+
expect(out[3].role).not.toBe('tool');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('re-injects the MOST RECENT user directive, not the first, when several fell out of the tail', () => {
|
|
75
|
+
// The opening request, then a superseding instruction, then a long tool loop that
|
|
76
|
+
// pushes BOTH out of the verbatim tail. The active directive is the latest one.
|
|
77
|
+
const convo: ChatCompletionMessage[] = [msg('user', 'run a self-diagnostic'), msg('assistant', 'ok')];
|
|
78
|
+
convo.push(msg('user', 'now create the gap and fix the code'));
|
|
79
|
+
for (let i = 0; i < 30; i++) convo.push(msg(i % 2 === 0 ? 'assistant' : 'tool', `s${i}`));
|
|
80
|
+
const idx = pinnedDirectiveIndex(convo, compactTailStart(convo, COMPACT_TAIL_TURNS));
|
|
81
|
+
expect(convo[idx].content).toBe('now create the gap and fix the code');
|
|
82
|
+
const out = assembleCompacted('SYS', convo, 'MEMO', COMPACT_TAIL_TURNS);
|
|
83
|
+
const directive = out[2];
|
|
84
|
+
expect(directive.role).toBe('user');
|
|
85
|
+
expect(directive.content).toBe('now create the gap and fix the code');
|
|
86
|
+
// The stale opening request is NOT re-injected verbatim (it lives only in the memo).
|
|
87
|
+
expect(out.filter((m) => m.content === 'run a self-diagnostic')).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('keeps EVERY message from the covered end on — nothing falls between the memo and the tail', () => {
|
|
91
|
+
// The old fold summarised "all but the last eight" on a turn count, so messages that
|
|
92
|
+
// slid out of the tail between two folds were in neither the memo nor the tail.
|
|
93
|
+
const convo: ChatCompletionMessage[] = [msg('user', 'task')];
|
|
94
|
+
for (let i = 0; i < 30; i++) convo.push(msg('assistant', `a${i}`));
|
|
95
|
+
const out = assembleCompacted('SYS', convo, 'MEMO', 5);
|
|
96
|
+
// [system, memo, the covered directive 'task', ...convo[5..]]
|
|
97
|
+
expect(out[2]).toEqual({ role: 'user', content: 'task' });
|
|
98
|
+
expect(out.slice(3)).toEqual(convo.slice(5));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('walks the verbatim start off tool results whose call the memo folded', () => {
|
|
102
|
+
const convo = [msg('user'), msg('assistant'), msg('tool'), msg('tool'), msg('assistant')];
|
|
103
|
+
expect(verbatimStart(convo, 2)).toBe(4);
|
|
104
|
+
expect(verbatimStart(convo, 99)).toBe(convo.length);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('sizes the compacted tail by tokens, never below the minimum tail', () => {
|
|
108
|
+
// Small messages: far more than eight fit, so far more than eight are kept verbatim.
|
|
109
|
+
const small: ChatCompletionMessage[] = [msg('user', 'task')];
|
|
110
|
+
for (let i = 0; i < 30; i++) small.push(msg('assistant', 'short'));
|
|
111
|
+
expect(small.length - compactTailStartForBudget(small, 10_000)).toBeGreaterThan(COMPACT_TAIL_TURNS);
|
|
112
|
+
// Oversized messages: the minimum tail survives however large it is.
|
|
113
|
+
const big: ChatCompletionMessage[] = [msg('user', 'task')];
|
|
114
|
+
for (let i = 0; i < 30; i++) big.push(msg('assistant', 'x'.repeat(40_000)));
|
|
115
|
+
expect(big.length - compactTailStartForBudget(big, 10_000)).toBe(COMPACT_TAIL_TURNS);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does not re-inject the directive when the latest user turn already lives in the tail', () => {
|
|
119
|
+
const convo = [msg('user', 'task'), msg('assistant'), msg('user', 'later')];
|
|
120
|
+
// Latest user turn ('later') is inside the tail → nothing to re-inject.
|
|
121
|
+
expect(pinnedDirectiveIndex(convo, compactTailStart(convo, COMPACT_TAIL_TURNS))).toBe(-1);
|
|
122
|
+
const out = assembleCompacted('SYS', convo, 'MEMO', COMPACT_TAIL_TURNS);
|
|
123
|
+
expect(out.filter((m) => m.content === 'later')).toHaveLength(1);
|
|
124
|
+
});
|
|
125
|
+
});
|