@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,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The working transcript — what of a run the model is SHOWN each turn.
|
|
3
|
+
*
|
|
4
|
+
* A run's full transcript grows without bound; the request sent to the model must not.
|
|
5
|
+
* This module owns the two ways it is kept in bounds: the message-count + token WINDOW
|
|
6
|
+
* (drop-oldest, anchored on a user turn) and AUTO-COMPACTION (fold the older part into a
|
|
7
|
+
* memory note, keep every later message verbatim). A host supplies only what is
|
|
8
|
+
* run-specific: the summarizer it calls (see `summary.ts`) and what a fold records.
|
|
9
|
+
*
|
|
10
|
+
* Pure except for {@link buildWorkingTranscript}, which awaits the injected summarizer and
|
|
11
|
+
* updates the caller's `compactMemo`. Generic over the host's message shape: anything with
|
|
12
|
+
* the OpenAI-style `role` / `content` / `tool_calls` fields (`HandoffMessage`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { defaultEstimateTokens, messageTokens as handoffMessageTokens, tokensOf as handoffTokensOf } from '../handoff/budget.js';
|
|
16
|
+
import type { HandoffMessage } from '../handoff/types.js';
|
|
17
|
+
|
|
18
|
+
/** A transcript message — the hand-off packer's shape, so both count a message alike. */
|
|
19
|
+
export type TranscriptMessage = HandoffMessage;
|
|
20
|
+
|
|
21
|
+
/** How much history we send to the model (message-count ceiling). */
|
|
22
|
+
export const HISTORY_WINDOW = 80;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Token budget for the working transcript sent to the model each turn — how much of this
|
|
26
|
+
* run the model can SEE: the file windows it read, the searches it ran, what it decided.
|
|
27
|
+
*
|
|
28
|
+
* It bounds context, which message-count windowing (HISTORY_WINDOW) alone does not: one
|
|
29
|
+
* `tasks.list` result can be tens of thousands of tokens. It is NOT what keeps a request
|
|
30
|
+
* inside the serving model's window — the gateway fits every request to a model that can
|
|
31
|
+
* hold it.
|
|
32
|
+
*
|
|
33
|
+
* It was 24k, "sized under the smallest pool model's window". That protected nothing — the
|
|
34
|
+
* ~16k-token system prompt + tool catalog in front of it already put a full turn at ~40k,
|
|
35
|
+
* past any 32k window — and it cost every coding run its memory. Six 4k-token file windows
|
|
36
|
+
* filled it, compaction folded them into a 1.2k-token note, and the model went back for the
|
|
37
|
+
* files it had just read (chat #105: 55 turns, 63% of calls revisiting, zero edits, prompt
|
|
38
|
+
* peak 40,351). 64k holds a coding task's working set — a dozen-plus file windows —
|
|
39
|
+
* verbatim, while a full turn stays well inside the 128k window every coding-capable pool
|
|
40
|
+
* model has. See {@link windowed} and {@link buildWorkingTranscript}.
|
|
41
|
+
*/
|
|
42
|
+
export const HISTORY_TOKEN_BUDGET = 64_000;
|
|
43
|
+
/**
|
|
44
|
+
* How much of {@link HISTORY_TOKEN_BUDGET} the verbatim TAIL may keep when the older part
|
|
45
|
+
* of the transcript is compacted. The rest is headroom new work fills before the next
|
|
46
|
+
* fold, so the memo is re-folded once per ~20k tokens of progress, not on every turn.
|
|
47
|
+
*/
|
|
48
|
+
export const COMPACT_TAIL_TOKEN_BUDGET = 40_000;
|
|
49
|
+
|
|
50
|
+
/** Estimated tokens for one message (content + tool-call payload + framing) — THE
|
|
51
|
+
* hand-off packer's estimate, so the working transcript and a hand-off to a smaller
|
|
52
|
+
* model count a message the same way. */
|
|
53
|
+
function messageTokens(m: TranscriptMessage): number {
|
|
54
|
+
return handoffMessageTokens(m, defaultEstimateTokens);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The compressed memory of a transcript's older part: `note` summarizes
|
|
59
|
+
* `transcript[0, coveredEnd)`; every message from `coveredEnd` on is sent verbatim.
|
|
60
|
+
*/
|
|
61
|
+
export interface CompactMemo {
|
|
62
|
+
note: string;
|
|
63
|
+
coveredEnd: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What the working transcript is built from — the run's full transcript and its cached
|
|
68
|
+
* compressed memory. {@link buildWorkingTranscript} updates `compactMemo` in place.
|
|
69
|
+
*/
|
|
70
|
+
export interface WorkingTranscriptState<M extends TranscriptMessage = TranscriptMessage> {
|
|
71
|
+
transcript: M[];
|
|
72
|
+
compactMemo: CompactMemo | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
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.
|
|
87
|
+
*/
|
|
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);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
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.
|
|
104
|
+
*/
|
|
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;
|
|
113
|
+
}
|
|
114
|
+
let trimmed = w.slice(start);
|
|
115
|
+
while (trimmed.length > 1 && trimmed[0]!.role !== 'user') trimmed = trimmed.slice(1);
|
|
116
|
+
return trimmed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Is a transcript message still part of what the model sees? False once auto-compaction
|
|
121
|
+
* has folded it into the memory note, or the drop-oldest window has let it fall off the
|
|
122
|
+
* front. Deliberately conservative: it judges against the CURRENT transcript (which has
|
|
123
|
+
* grown since the last working set was built), so it can only ever call a message gone a
|
|
124
|
+
* turn early — a needless replay costs tokens, a stub for a vanished result costs the run.
|
|
125
|
+
*/
|
|
126
|
+
export function stillInWorkingContext<M extends TranscriptMessage>(state: WorkingTranscriptState<M>, anchor: unknown): boolean {
|
|
127
|
+
const convo = state.transcript;
|
|
128
|
+
const idx = convo.indexOf(anchor as M);
|
|
129
|
+
if (idx < 0) return false;
|
|
130
|
+
if (state.compactMemo) return idx >= verbatimStart(convo, state.compactMemo.coveredEnd);
|
|
131
|
+
return windowed(convo).includes(convo[idx]!);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Auto-compaction — summarize the bulky MIDDLE instead of dropping it.
|
|
136
|
+
//
|
|
137
|
+
// `tokenBounded` keeps the request inside the window by DROPPING the oldest turns. That
|
|
138
|
+
// never 413s, but it silently LOSES context — which made a weak model re-read files and
|
|
139
|
+
// thrash ("LOOP EXHAUSTED", chat #50). When a summarizer is available the older turns are
|
|
140
|
+
// 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.
|
|
142
|
+
//
|
|
143
|
+
// Why this fold is NOT `packHandoff` (`../handoff`): the packer is stateless and gives the
|
|
144
|
+
// verbatim tail priority, handing the note whatever room the tail leaves. The working
|
|
145
|
+
// transcript needs the opposite on both counts — an INCREMENTAL memo reused across turns
|
|
146
|
+
// (one summarizer call per ~20k tokens of progress), a tail capped at
|
|
147
|
+
// COMPACT_TAIL_TOKEN_BUDGET so the note and the next turns have headroom, and a
|
|
148
|
+
// message-count window. What the two share — the token estimate — is the packer's.
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/** The fewest recent messages the verbatim tail keeps, however large they are. */
|
|
152
|
+
export const COMPACT_TAIL_TURNS = 8;
|
|
153
|
+
|
|
154
|
+
/** The most recent messages the verbatim tail may hold — half the message window, so a
|
|
155
|
+
* compacted transcript stays inside {@link HISTORY_WINDOW} with room to grow. */
|
|
156
|
+
const COMPACT_TAIL_MAX_MESSAGES = HISTORY_WINDOW / 2;
|
|
157
|
+
|
|
158
|
+
/** The first index at or after `from` that is not a `tool` result: a tool row whose
|
|
159
|
+
* assistant call was folded into the memo would be orphaned (strict vendors 400 on it). */
|
|
160
|
+
export function verbatimStart(convo: readonly TranscriptMessage[], from: number): number {
|
|
161
|
+
let start = Math.max(0, Math.min(from, convo.length));
|
|
162
|
+
while (start < convo.length && convo[start]!.role === 'tool') start += 1;
|
|
163
|
+
return start;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Start index of the last `tailTurns` messages, walked off a leading `tool` result. */
|
|
167
|
+
export function compactTailStart(convo: readonly TranscriptMessage[], tailTurns: number): number {
|
|
168
|
+
return verbatimStart(convo, convo.length - tailTurns);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Where the verbatim tail begins when the transcript is compacted: as many of the most
|
|
173
|
+
* recent messages as fit in `budgetTokens` — never fewer than {@link COMPACT_TAIL_TURNS},
|
|
174
|
+
* never more than {@link COMPACT_TAIL_MAX_MESSAGES} — walked off a leading tool result.
|
|
175
|
+
*
|
|
176
|
+
* Sized by TOKENS, not a message count: a fixed eight messages is four tool results, so
|
|
177
|
+
* after a fold the fifth file the model needed was always the one it had to read again.
|
|
178
|
+
*/
|
|
179
|
+
export function compactTailStartForBudget(convo: readonly TranscriptMessage[], budgetTokens: number): number {
|
|
180
|
+
let start = convo.length;
|
|
181
|
+
let tokens = 0;
|
|
182
|
+
while (start > 0) {
|
|
183
|
+
const kept = convo.length - start;
|
|
184
|
+
if (kept >= COMPACT_TAIL_MAX_MESSAGES) break;
|
|
185
|
+
const next = messageTokens(convo[start - 1]!);
|
|
186
|
+
if (kept >= COMPACT_TAIL_TURNS && tokens + next > budgetTokens) break;
|
|
187
|
+
tokens += next;
|
|
188
|
+
start -= 1;
|
|
189
|
+
}
|
|
190
|
+
return verbatimStart(convo, start);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Index of the MOST RECENT user turn to re-inject verbatim ahead of the tail — the
|
|
195
|
+
* ACTIVE directive — or -1 when the latest user turn is already inside the tail. Pinning
|
|
196
|
+
* the FIRST user turn (the old behaviour) made a multi-instruction chat revert to its
|
|
197
|
+
* opening request (chat #55); the current instruction is always the LATEST user turn.
|
|
198
|
+
*/
|
|
199
|
+
export function pinnedDirectiveIndex(convo: readonly TranscriptMessage[], tailStart: number): number {
|
|
200
|
+
const lastUser = convo.map((m) => m.role).lastIndexOf('user');
|
|
201
|
+
return lastUser >= 0 && lastUser < tailStart ? lastUser : -1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Assemble the working transcript from a compressed-memory `note` that covers
|
|
206
|
+
* `convo[0, coveredEnd)`: system + the memory note + the ACTIVE user directive re-injected
|
|
207
|
+
* verbatim (when the note covers it) + EVERY message from `coveredEnd` on (tool-pairing
|
|
208
|
+
* safe via {@link verbatimStart}). Nothing is in neither the note nor the tail. The
|
|
209
|
+
* directive sits immediately before the tail so the model reads it as the CURRENT
|
|
210
|
+
* instruction.
|
|
211
|
+
*/
|
|
212
|
+
export function assembleCompacted<M extends TranscriptMessage>(
|
|
213
|
+
systemPrompt: string,
|
|
214
|
+
convo: M[],
|
|
215
|
+
note: string,
|
|
216
|
+
coveredEnd: number,
|
|
217
|
+
): M[] {
|
|
218
|
+
const tailStart = verbatimStart(convo, coveredEnd);
|
|
219
|
+
const out: M[] = [{ role: 'system', content: systemPrompt } as M];
|
|
220
|
+
out.push({ role: 'assistant', content: note } as M);
|
|
221
|
+
const directiveIdx = pinnedDirectiveIndex(convo, tailStart);
|
|
222
|
+
if (directiveIdx >= 0) out.push(convo[directiveIdx]!);
|
|
223
|
+
out.push(...convo.slice(tailStart));
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Estimated tokens of a message list — the ONE count compaction budgets by, exported so
|
|
228
|
+
* a run measures what compaction kept out of a call with the same rule. */
|
|
229
|
+
export function transcriptTokens(msgs: readonly TranscriptMessage[]): number {
|
|
230
|
+
return handoffTokensOf(msgs, defaultEstimateTokens);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Can these messages (plus `extraTokens` in front of them) go to the model verbatim —
|
|
234
|
+
* inside both the token budget and the message window? */
|
|
235
|
+
function fitsVerbatim(msgs: readonly TranscriptMessage[], extraTokens = 0): boolean {
|
|
236
|
+
return msgs.length <= HISTORY_WINDOW && extraTokens + transcriptTokens(msgs) <= HISTORY_TOKEN_BUDGET;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** What a fold reports: how many messages it newly compressed, the note it wrote, and
|
|
240
|
+
* the transcript index the note now covers up to (exclusive). */
|
|
241
|
+
export interface TranscriptFold {
|
|
242
|
+
droppedMessages: number;
|
|
243
|
+
note: string;
|
|
244
|
+
coveredEnd: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build the working transcript for a turn. When the whole transcript fits → all of it,
|
|
249
|
+
* verbatim. Otherwise → a memory note of the older part plus EVERY message after it,
|
|
250
|
+
* verbatim; `onFolded` is told about each NEW fold, so the caller can record it (a
|
|
251
|
+
* visible step, a durable consolidation marker).
|
|
252
|
+
*
|
|
253
|
+
* The note is folded INCREMENTALLY, and only when it has to be. A note whose verbatim
|
|
254
|
+
* remainder still fits is reused as-is; once it no longer fits, the previous note plus
|
|
255
|
+
* exactly the messages since it are folded into a new one, up to a token-sized tail
|
|
256
|
+
* ({@link compactTailStartForBudget}). Falls back to drop-oldest when the summarizer is
|
|
257
|
+
* unavailable (`summarize` resolves null). A message-count overflow folds too.
|
|
258
|
+
*/
|
|
259
|
+
export async function buildWorkingTranscript<M extends TranscriptMessage>(
|
|
260
|
+
state: WorkingTranscriptState<M>,
|
|
261
|
+
systemPrompt: string,
|
|
262
|
+
summarize: (msgs: M[]) => Promise<string | null>,
|
|
263
|
+
onFolded: (fold: TranscriptFold) => void,
|
|
264
|
+
): Promise<M[]> {
|
|
265
|
+
const convo = state.transcript;
|
|
266
|
+
if (fitsVerbatim(convo)) {
|
|
267
|
+
state.compactMemo = null; // back under budget — a later overflow summarizes afresh
|
|
268
|
+
return [{ role: 'system', content: systemPrompt } as M, ...windowed(convo)];
|
|
269
|
+
}
|
|
270
|
+
// A note written against a transcript that has since been replaced covers nothing here.
|
|
271
|
+
const memo = state.compactMemo && state.compactMemo.coveredEnd <= convo.length ? state.compactMemo : null;
|
|
272
|
+
if (memo && fitsVerbatim(convo.slice(verbatimStart(convo, memo.coveredEnd)), defaultEstimateTokens(memo.note))) {
|
|
273
|
+
return assembleCompacted(systemPrompt, convo, memo.note, memo.coveredEnd);
|
|
274
|
+
}
|
|
275
|
+
const from = memo?.coveredEnd ?? 0;
|
|
276
|
+
const to = Math.max(from, compactTailStartForBudget(convo, COMPACT_TAIL_TOKEN_BUDGET));
|
|
277
|
+
const fold: M[] = memo
|
|
278
|
+
? [{ role: 'assistant', content: memo.note } as M, ...convo.slice(from, to)]
|
|
279
|
+
: convo.slice(from, to);
|
|
280
|
+
const summary = to > from ? await summarize(fold) : null;
|
|
281
|
+
if (summary == null) {
|
|
282
|
+
// Nothing new to fold, or no summarizer: keep the note already held, else drop-oldest.
|
|
283
|
+
return memo
|
|
284
|
+
? assembleCompacted(systemPrompt, convo, memo.note, memo.coveredEnd)
|
|
285
|
+
: [{ role: 'system', content: systemPrompt } as M, ...windowed(convo)];
|
|
286
|
+
}
|
|
287
|
+
const note = `Compressed memory of the first ${to} message(s) of this conversation:\n${summary}`;
|
|
288
|
+
state.compactMemo = { note, coveredEnd: to };
|
|
289
|
+
onFolded({ droppedMessages: to - from, note, coveredEnd: to });
|
|
290
|
+
return assembleCompacted(systemPrompt, convo, note, to);
|
|
291
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The working-memory contract — pure data, so the subpath stays Worker/browser/Node safe.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** One thing a run did that is worth remembering, in the memory's own terms. */
|
|
6
|
+
export type Observation =
|
|
7
|
+
| { kind: 'read' | 'created' | 'edited' | 'deleted'; path: string }
|
|
8
|
+
| { kind: 'searched'; query: string };
|
|
9
|
+
|
|
10
|
+
/** What a run's working memory holds. Lists keep duplicates — how often is a signal. */
|
|
11
|
+
export interface WorkingMemorySnapshot {
|
|
12
|
+
/** What the run was asked. */
|
|
13
|
+
task?: string;
|
|
14
|
+
created: readonly string[];
|
|
15
|
+
edited: readonly string[];
|
|
16
|
+
deleted: readonly string[];
|
|
17
|
+
read: readonly string[];
|
|
18
|
+
searched: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One long-term memory, ready for a keyed store: the same key replaces (or merges). */
|
|
22
|
+
export interface ConsolidatedMemory {
|
|
23
|
+
key: string;
|
|
24
|
+
content: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ConsolidateOptions {
|
|
28
|
+
/** Dates the memory so a reader can judge its age. */
|
|
29
|
+
at?: Date;
|
|
30
|
+
/** The workspace root — absolute paths under it are kept, made relative. */
|
|
31
|
+
root?: string;
|
|
32
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/working/working.test.ts
|
|
3
|
+
* Short-term working memory (the per-turn brief) and its consolidation into a long-term
|
|
4
|
+
* code map, including the write-side merge a keyed store applies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { consolidate, mergeConsolidated, WorkingMemory, type WorkingMemorySnapshot } from './index.js';
|
|
8
|
+
|
|
9
|
+
const AT = new Date('2026-10-24T12:00:00Z');
|
|
10
|
+
|
|
11
|
+
function snap(partial: Partial<WorkingMemorySnapshot>): WorkingMemorySnapshot {
|
|
12
|
+
return { created: [], edited: [], deleted: [], read: [], searched: [], ...partial };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('WorkingMemory.brief', () => {
|
|
16
|
+
it('stays silent while the run is still short enough to see its own results', () => {
|
|
17
|
+
const m = new WorkingMemory();
|
|
18
|
+
for (let i = 0; i < 9; i++) m.observe({ kind: 'read', path: `a/${i}.ts` });
|
|
19
|
+
expect(m.brief()).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('restates changes, reads with counts and searches once the run is long', () => {
|
|
23
|
+
const m = new WorkingMemory();
|
|
24
|
+
m.observe({ kind: 'searched', query: 'search_code "canvas"' });
|
|
25
|
+
m.observe({ kind: 'searched', query: 'search_code "canvas"' });
|
|
26
|
+
for (let i = 0; i < 6; i++) m.observe({ kind: 'read', path: 'web/src/App.tsx' });
|
|
27
|
+
m.observe({ kind: 'edited', path: 'web/src/Surface.tsx' });
|
|
28
|
+
m.observe({ kind: 'deleted', path: 'web/src/Old.tsx' });
|
|
29
|
+
const brief = m.brief()!;
|
|
30
|
+
expect(brief).toContain('## What this run has already established');
|
|
31
|
+
expect(brief).toContain('Changed:\n- web/src/Surface.tsx');
|
|
32
|
+
expect(brief).toContain('Deleted:\n- web/src/Old.tsx');
|
|
33
|
+
expect(brief).toContain('- web/src/App.tsx (6×)');
|
|
34
|
+
expect(brief).toContain('- search_code "canvas" (2×)');
|
|
35
|
+
// Verification is not discouraged.
|
|
36
|
+
expect(brief).toMatch(/re-running a search to verify an edit is fine/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('lets the host count its own activity toward the threshold', () => {
|
|
40
|
+
const m = new WorkingMemory();
|
|
41
|
+
m.observe({ kind: 'read', path: 'a.ts' });
|
|
42
|
+
expect(m.brief({ observations: 12 })).toContain('- a.ts');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('consolidate', () => {
|
|
47
|
+
it('keys by where most of the work was, ranks changed then most-read', () => {
|
|
48
|
+
const c = consolidate(
|
|
49
|
+
snap({
|
|
50
|
+
task: 'Move the canvas',
|
|
51
|
+
read: ['web/src/a/x.ts', 'web/src/a/x.ts', 'web/src/a/y.ts'],
|
|
52
|
+
edited: ['web/src/a/z.ts', 'DONE.md'],
|
|
53
|
+
}),
|
|
54
|
+
{ at: AT },
|
|
55
|
+
)!;
|
|
56
|
+
expect(c.key).toBe('codemap:web/src/a');
|
|
57
|
+
expect(c.content).toContain('mapped 2026-10-24 by a run asked: "Move the canvas"');
|
|
58
|
+
const entries = c.content.split('\n').filter((l) => l.startsWith('- '));
|
|
59
|
+
expect(entries).toEqual(['- web/src/a/z.ts (changed)', '- DONE.md (changed)', '- web/src/a/x.ts (read 2×)', '- web/src/a/y.ts']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('resolves absolute, MSYS and dotted paths against the root and drops noise', () => {
|
|
63
|
+
const c = consolidate(
|
|
64
|
+
snap({
|
|
65
|
+
read: [
|
|
66
|
+
'C:\\code\\repo\\web\\src\\a.ts',
|
|
67
|
+
'/c/code/repo/web/src/b.ts',
|
|
68
|
+
'web/./src//c.ts',
|
|
69
|
+
'web/node_modules/x.js',
|
|
70
|
+
'web/dist/out.js',
|
|
71
|
+
'pnpm-lock.yaml',
|
|
72
|
+
'/elsewhere/d.ts',
|
|
73
|
+
'scripts/build/gen.ts',
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
{ at: AT, root: 'C:\\code\\repo' },
|
|
77
|
+
)!;
|
|
78
|
+
expect(c.key).toBe('codemap:web/src');
|
|
79
|
+
expect(c.content).toContain('- web/src/c.ts');
|
|
80
|
+
expect(c.content).toContain('- scripts/build/gen.ts');
|
|
81
|
+
expect(c.content).not.toMatch(/node_modules|dist|lock|elsewhere/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('skips a short lookup but keeps any change', () => {
|
|
85
|
+
expect(consolidate(snap({ read: ['a/b/c.ts', 'a/b/d.ts'] }))).toBeNull();
|
|
86
|
+
expect(consolidate(snap({ edited: ['api/src/r/x.ts'] }))?.key).toBe('codemap:api/src/r');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('mergeConsolidated', () => {
|
|
91
|
+
const prior = consolidate(snap({ read: ['w/s/a.ts', 'w/s/b.ts'], edited: ['w/s/c.ts'] }), { at: AT })!;
|
|
92
|
+
|
|
93
|
+
it('adds a small run to the area map, restating the earlier run\'s edits', () => {
|
|
94
|
+
const next = consolidate(snap({ task: 'fix d', edited: ['w/s/d.ts'] }), { at: AT })!;
|
|
95
|
+
const merged = mergeConsolidated(next.key, prior.content, next.content);
|
|
96
|
+
expect(merged).toContain('asked: "fix d"');
|
|
97
|
+
const entries = merged.split('\n').filter((l) => l.startsWith('- '));
|
|
98
|
+
expect(entries).toEqual(['- w/s/d.ts (changed)', '- w/s/c.ts (changed earlier)', '- w/s/a.ts', '- w/s/b.ts']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('removes a file this run deleted and never stores the deletion line', () => {
|
|
102
|
+
const next = consolidate(snap({ deleted: ['w/s/a.ts'], edited: ['w/s/e.ts'] }), { at: AT })!;
|
|
103
|
+
const merged = mergeConsolidated(next.key, prior.content, next.content);
|
|
104
|
+
expect(merged).not.toContain('w/s/a.ts');
|
|
105
|
+
expect(merged).not.toContain('(deleted)');
|
|
106
|
+
expect(mergeConsolidated(next.key, null, next.content)).not.toContain('(deleted)');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('caps the map and says how many more', () => {
|
|
110
|
+
const big = consolidate(snap({ read: Array.from({ length: 12 }, (_, i) => `w/s/f${i}.ts`) }), { at: AT })!;
|
|
111
|
+
const next = consolidate(snap({ edited: ['w/s/new.ts'] }), { at: AT })!;
|
|
112
|
+
const merged = mergeConsolidated(next.key, big.content, next.content);
|
|
113
|
+
expect(merged.split('\n').filter((l) => l.startsWith('- '))).toHaveLength(12);
|
|
114
|
+
expect(merged).toContain('(+1 more file(s) touched)');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('leaves every other key as sent', () => {
|
|
118
|
+
expect(mergeConsolidated('learned:x', 'old', 'new')).toBe('new');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-term memory for one run: what it has read, searched and changed so far.
|
|
3
|
+
*
|
|
4
|
+
* ## Why the brief exists
|
|
5
|
+
*
|
|
6
|
+
* A model's reasoning is stripped from the transcript it reads next (models copy
|
|
7
|
+
* reasoning markup instead of calling tools), and a bounded working window compacts old
|
|
8
|
+
* tool results away. So by turn forty a run cannot see what it concluded at turn ten,
|
|
9
|
+
* and re-derives the whole architecture in every turn's reasoning — measured: 8–10k
|
|
10
|
+
* completion tokens a turn for two tool calls each, the same files re-read a dozen
|
|
11
|
+
* times. {@link WorkingMemory.brief} is the run's own record, handed to EVERY turn on
|
|
12
|
+
* that request only (never into the transcript, so it neither grows history nor breaks
|
|
13
|
+
* prompt caching), so the model reasons from "here is what I have" instead of from
|
|
14
|
+
* scratch.
|
|
15
|
+
*
|
|
16
|
+
* Record SUCCESSFUL observations only: a memory claiming a file was written when the
|
|
17
|
+
* write failed grounds every later turn — and run — on a lie.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { rankByCount } from './rank.js';
|
|
21
|
+
import type { Observation, WorkingMemorySnapshot } from './types.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Below this many observations the run's own results are still in its window, so a
|
|
25
|
+
* restatement only costs tokens. Past it, compaction starts dropping them.
|
|
26
|
+
*/
|
|
27
|
+
const BRIEF_MIN_OBSERVATIONS = 10;
|
|
28
|
+
const BRIEF_MAX_FILES = 25;
|
|
29
|
+
const BRIEF_MAX_SEARCHES = 15;
|
|
30
|
+
|
|
31
|
+
export class WorkingMemory {
|
|
32
|
+
private task: string | undefined;
|
|
33
|
+
private readonly created: string[] = [];
|
|
34
|
+
private readonly edited: string[] = [];
|
|
35
|
+
private readonly deleted: string[] = [];
|
|
36
|
+
private readonly read: string[] = [];
|
|
37
|
+
private readonly searched: string[] = [];
|
|
38
|
+
private observations = 0;
|
|
39
|
+
|
|
40
|
+
/** What the run was asked — the consolidation names it, so a map is a lesson, not a list. */
|
|
41
|
+
setTask(task: string): void {
|
|
42
|
+
const asked = task.replace(/\s+/g, ' ').trim();
|
|
43
|
+
if (asked) this.task = asked;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
observe(o: Observation): void {
|
|
47
|
+
this.observations++;
|
|
48
|
+
if (o.kind === 'searched') {
|
|
49
|
+
if (o.query.trim()) this.searched.push(o.query.trim());
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const p = o.path.trim();
|
|
53
|
+
if (!p) return;
|
|
54
|
+
this[o.kind].push(p);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Observations recorded so far (searches and file events alike). */
|
|
58
|
+
get size(): number {
|
|
59
|
+
return this.observations;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get snapshot(): WorkingMemorySnapshot {
|
|
63
|
+
return {
|
|
64
|
+
...(this.task ? { task: this.task } : {}),
|
|
65
|
+
created: this.created,
|
|
66
|
+
edited: this.edited,
|
|
67
|
+
deleted: this.deleted,
|
|
68
|
+
read: this.read,
|
|
69
|
+
searched: this.searched,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What this run has ALREADY established, to put in front of its next turn — or `null`
|
|
75
|
+
* until it has done enough for a restatement to be worth its tokens. `observations`
|
|
76
|
+
* lets a host count its own activity (every tool call, not only memory-relevant ones).
|
|
77
|
+
* Hosts deliver it inside the per-request volatile tail (`/handoff` `volatileTail`), so
|
|
78
|
+
* it never becomes the cached prefix nor "the latest user directive".
|
|
79
|
+
*/
|
|
80
|
+
brief(opts: { observations?: number } = {}): string | null {
|
|
81
|
+
if ((opts.observations ?? this.observations) < BRIEF_MIN_OBSERVATIONS) return null;
|
|
82
|
+
const changed = [...new Set([...this.created, ...this.edited])];
|
|
83
|
+
const changedSet = new Set(changed);
|
|
84
|
+
const deleted = [...new Set(this.deleted)];
|
|
85
|
+
const reads = rankByCount(this.read).filter(([p]) => !changedSet.has(p));
|
|
86
|
+
const searches = rankByCount(this.searched);
|
|
87
|
+
if (changed.length === 0 && deleted.length === 0 && reads.length === 0 && searches.length === 0) return null;
|
|
88
|
+
const lines = [
|
|
89
|
+
'## What this run has already established',
|
|
90
|
+
'Your own record so far, kept by the runtime — not a new request. Build on it instead of re-deriving it: earlier results may have been compressed out of your context, but what you concluded from them still holds. Re-read a file only when it may have changed since (a command rewrote it) or you need a part you have not seen; re-running a search to verify an edit is fine. If you already know where the change goes, make it.',
|
|
91
|
+
];
|
|
92
|
+
if (changed.length) lines.push('', 'Changed:', ...changed.slice(0, BRIEF_MAX_FILES).map((p) => `- ${p}`));
|
|
93
|
+
if (deleted.length) lines.push('', 'Deleted:', ...deleted.slice(0, BRIEF_MAX_FILES).map((p) => `- ${p}`));
|
|
94
|
+
if (reads.length) {
|
|
95
|
+
lines.push('', 'Read:', ...reads.slice(0, BRIEF_MAX_FILES).map(([p, n]) => `- ${p}${n > 1 ? ` (${n}×)` : ''}`));
|
|
96
|
+
if (reads.length > BRIEF_MAX_FILES) lines.push(`- … ${reads.length - BRIEF_MAX_FILES} more`);
|
|
97
|
+
}
|
|
98
|
+
if (searches.length) {
|
|
99
|
+
lines.push('', 'Searched:', ...searches.slice(0, BRIEF_MAX_SEARCHES).map(([s, n]) => `- ${s}${n > 1 ? ` (${n}×)` : ''}`));
|
|
100
|
+
}
|
|
101
|
+
return lines.join('\n');
|
|
102
|
+
}
|
|
103
|
+
}
|