@animalabs/context-manager 0.5.2 → 0.5.4
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/src/adaptive/index.d.ts +6 -0
- package/dist/src/adaptive/index.d.ts.map +1 -1
- package/dist/src/adaptive/index.js +8 -0
- package/dist/src/adaptive/index.js.map +1 -1
- package/dist/src/adaptive/kv-cache-sim.d.ts +128 -0
- package/dist/src/adaptive/kv-cache-sim.d.ts.map +1 -0
- package/dist/src/adaptive/kv-cache-sim.js +0 -0
- package/dist/src/adaptive/kv-cache-sim.js.map +1 -0
- package/dist/src/adaptive/kv-control.d.ts +152 -0
- package/dist/src/adaptive/kv-control.d.ts.map +1 -0
- package/dist/src/adaptive/kv-control.js +368 -0
- package/dist/src/adaptive/kv-control.js.map +1 -0
- package/dist/src/adaptive/kv-replay.d.ts +76 -0
- package/dist/src/adaptive/kv-replay.d.ts.map +1 -0
- package/dist/src/adaptive/kv-replay.js +175 -0
- package/dist/src/adaptive/kv-replay.js.map +1 -0
- package/dist/src/adaptive/render-offsets.d.ts +61 -0
- package/dist/src/adaptive/render-offsets.d.ts.map +1 -0
- package/dist/src/adaptive/render-offsets.js +88 -0
- package/dist/src/adaptive/render-offsets.js.map +1 -0
- package/dist/src/adaptive/strategies/kv-stable.d.ts +48 -0
- package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -0
- package/dist/src/adaptive/strategies/kv-stable.js +102 -0
- package/dist/src/adaptive/strategies/kv-stable.js.map +1 -0
- package/dist/src/adaptive/summary-tree.d.ts +98 -0
- package/dist/src/adaptive/summary-tree.d.ts.map +1 -0
- package/dist/src/adaptive/summary-tree.js +214 -0
- package/dist/src/adaptive/summary-tree.js.map +1 -0
- package/dist/src/context-log.d.ts.map +1 -1
- package/dist/src/context-log.js +7 -2
- package/dist/src/context-log.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/message-store.js +1 -1
- package/dist/src/message-store.js.map +1 -1
- package/dist/src/normalize-tool-messages.d.ts +33 -0
- package/dist/src/normalize-tool-messages.d.ts.map +1 -1
- package/dist/src/normalize-tool-messages.js +59 -0
- package/dist/src/normalize-tool-messages.js.map +1 -1
- package/dist/src/strategies/autobiographical.d.ts +101 -39
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +398 -222
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/types/strategy.d.ts +40 -1
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js +2 -0
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/adaptive/kv-cache-sim.test.d.ts +16 -0
- package/dist/test/adaptive/kv-cache-sim.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-cache-sim.test.js +173 -0
- package/dist/test/adaptive/kv-cache-sim.test.js.map +1 -0
- package/dist/test/adaptive/kv-control.test.d.ts +14 -0
- package/dist/test/adaptive/kv-control.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-control.test.js +130 -0
- package/dist/test/adaptive/kv-control.test.js.map +1 -0
- package/dist/test/adaptive/kv-replay.test.d.ts +15 -0
- package/dist/test/adaptive/kv-replay.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-replay.test.js +112 -0
- package/dist/test/adaptive/kv-replay.test.js.map +1 -0
- package/dist/test/adaptive/kv-stable-strategy.test.d.ts +10 -0
- package/dist/test/adaptive/kv-stable-strategy.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-stable-strategy.test.js +89 -0
- package/dist/test/adaptive/kv-stable-strategy.test.js.map +1 -0
- package/dist/test/adaptive/render-offsets.test.d.ts +11 -0
- package/dist/test/adaptive/render-offsets.test.d.ts.map +1 -0
- package/dist/test/adaptive/render-offsets.test.js +85 -0
- package/dist/test/adaptive/render-offsets.test.js.map +1 -0
- package/dist/test/adaptive/summary-tree.test.d.ts +5 -0
- package/dist/test/adaptive/summary-tree.test.d.ts.map +1 -0
- package/dist/test/adaptive/summary-tree.test.js +72 -0
- package/dist/test/adaptive/summary-tree.test.js.map +1 -0
- package/dist/test/chunker-tool-boundary.test.d.ts +12 -0
- package/dist/test/chunker-tool-boundary.test.d.ts.map +1 -0
- package/dist/test/chunker-tool-boundary.test.js +89 -0
- package/dist/test/chunker-tool-boundary.test.js.map +1 -0
- package/dist/test/compression-shape-invariants.test.d.ts +22 -0
- package/dist/test/compression-shape-invariants.test.d.ts.map +1 -0
- package/dist/test/compression-shape-invariants.test.js +312 -0
- package/dist/test/compression-shape-invariants.test.js.map +1 -0
- package/dist/test/image-strip-render-stats.test.d.ts +2 -0
- package/dist/test/image-strip-render-stats.test.d.ts.map +1 -0
- package/dist/test/image-strip-render-stats.test.js +130 -0
- package/dist/test/image-strip-render-stats.test.js.map +1 -0
- package/dist/test/image-stripping.test.d.ts +13 -0
- package/dist/test/image-stripping.test.d.ts.map +1 -0
- package/dist/test/image-stripping.test.js +104 -0
- package/dist/test/image-stripping.test.js.map +1 -0
- package/dist/test/normalize-tool-messages.test.js +61 -1
- package/dist/test/normalize-tool-messages.test.js.map +1 -1
- package/dist/test/render-stats.test.d.ts +2 -0
- package/dist/test/render-stats.test.d.ts.map +1 -0
- package/dist/test/render-stats.test.js +91 -0
- package/dist/test/render-stats.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/adaptive/index.ts +48 -0
- package/src/adaptive/kv-cache-sim.ts +0 -0
- package/src/adaptive/kv-control.ts +497 -0
- package/src/adaptive/kv-replay.ts +238 -0
- package/src/adaptive/render-offsets.ts +116 -0
- package/src/adaptive/strategies/kv-stable.ts +123 -0
- package/src/adaptive/summary-tree.ts +252 -0
- package/src/context-log.ts +7 -2
- package/src/index.ts +1 -1
- package/src/message-store.ts +1 -1
- package/src/normalize-tool-messages.ts +61 -0
- package/src/strategies/autobiographical.ts +414 -271
- package/src/types/strategy.ts +40 -1
|
@@ -17,14 +17,16 @@ import type {
|
|
|
17
17
|
ProtectedRange,
|
|
18
18
|
SearchQuery,
|
|
19
19
|
SearchResult,
|
|
20
|
+
RenderStats,
|
|
20
21
|
} from '../types/index.js';
|
|
21
22
|
import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
|
|
22
23
|
import { getSummaryParentId } from '../types/strategy.js';
|
|
23
|
-
import { splitMixedToolMessages } from '../normalize-tool-messages.js';
|
|
24
|
+
import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js';
|
|
24
25
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
25
26
|
import { dirname } from 'node:path';
|
|
26
|
-
import { Picker, OverBudgetError, type PickerChunk } from '../adaptive/picker.js';
|
|
27
|
+
import { Picker, OverBudgetError, type PickerChunk, type PickerInputs } from '../adaptive/picker.js';
|
|
27
28
|
import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
|
|
29
|
+
import { KvStableStrategy } from '../adaptive/strategies/kv-stable.js';
|
|
28
30
|
import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
|
|
29
31
|
import type {
|
|
30
32
|
FoldingStrategy,
|
|
@@ -223,8 +225,6 @@ export interface Chunk {
|
|
|
223
225
|
tokens: number;
|
|
224
226
|
/** Whether this chunk has been compressed */
|
|
225
227
|
compressed: boolean;
|
|
226
|
-
/** The diary entry if compressed (legacy mode) */
|
|
227
|
-
diary?: string;
|
|
228
228
|
/** ID of the L1 SummaryEntry (hierarchical mode) */
|
|
229
229
|
summaryId?: string;
|
|
230
230
|
/** Phase type tag (set by KnowledgeStrategy for semantic chunking) */
|
|
@@ -1064,9 +1064,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1064
1064
|
|
|
1065
1065
|
if (!chunk || chunk.compressed) return;
|
|
1066
1066
|
|
|
1067
|
-
this.pendingCompression = this.
|
|
1068
|
-
? this.compressChunkHierarchical(chunk, ctx)
|
|
1069
|
-
: this.compressChunkLegacy(chunk, ctx);
|
|
1067
|
+
this.pendingCompression = this.compressChunkHierarchical(chunk, ctx);
|
|
1070
1068
|
|
|
1071
1069
|
try {
|
|
1072
1070
|
await this.pendingCompression;
|
|
@@ -1136,12 +1134,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1136
1134
|
): ContextEntry[] {
|
|
1137
1135
|
this.rebuildChunks(store);
|
|
1138
1136
|
|
|
1137
|
+
// Image stripping runs inside each select path (before stats commit / cache
|
|
1138
|
+
// markers), so the returned entries are already bounded — see
|
|
1139
|
+
// applyImageStripping.
|
|
1139
1140
|
if (this.config.adaptiveResolution) {
|
|
1140
1141
|
return this.selectAdaptive(store, budget);
|
|
1141
1142
|
}
|
|
1142
|
-
return this.
|
|
1143
|
-
? this.selectHierarchical(store, budget)
|
|
1144
|
-
: this.selectLegacy(store, log, budget);
|
|
1143
|
+
return this.selectHierarchical(store, budget);
|
|
1145
1144
|
}
|
|
1146
1145
|
|
|
1147
1146
|
/**
|
|
@@ -1171,16 +1170,89 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1171
1170
|
* Useful for TUI / dashboards. The token sums use the strategy's own
|
|
1172
1171
|
* token estimates (which match what `select()` uses for budget math).
|
|
1173
1172
|
*/
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1173
|
+
// ===========================================================================
|
|
1174
|
+
// Render-stats instrumentation — inspect, don't reconstruct.
|
|
1175
|
+
//
|
|
1176
|
+
// selectAdaptive/selectHierarchical tally each entry into `_rs` AS THEY EMIT
|
|
1177
|
+
// it (raw head/tail, raw middle the picker kept verbatim, and recall pairs
|
|
1178
|
+
// bucketed by the ancestor summary's level), using the same token numbers the
|
|
1179
|
+
// renderer uses for budget math. `getRenderStats()` returns that committed
|
|
1180
|
+
// snapshot, so it reflects what the last compile actually rendered rather than
|
|
1181
|
+
// re-deriving the full pyramid (which, under adaptive resolution, bears little
|
|
1182
|
+
// resemblance to the folded output).
|
|
1183
|
+
// ===========================================================================
|
|
1184
|
+
private _rs: RenderStats | null = null;
|
|
1185
|
+
private _lastRenderStats: RenderStats | null = null;
|
|
1186
|
+
|
|
1187
|
+
/** Begin a render-stats accumulation for one select() pass. */
|
|
1188
|
+
protected rsBegin(): void {
|
|
1189
|
+
this._rs = {
|
|
1190
|
+
head: { messages: 0, tokens: 0 },
|
|
1191
|
+
tail: { messages: 0, tokens: 0 },
|
|
1192
|
+
middleRaw: { messages: 0, tokens: 0 },
|
|
1193
|
+
summaries: {
|
|
1194
|
+
l1: { count: 0, tokens: 0 },
|
|
1195
|
+
l2: { count: 0, tokens: 0 },
|
|
1196
|
+
l3: { count: 0, tokens: 0 },
|
|
1197
|
+
},
|
|
1198
|
+
pending: { chunks: 0, merges: 0 },
|
|
1199
|
+
total: { messages: 0, tokens: 0 },
|
|
1181
1200
|
};
|
|
1182
|
-
|
|
1183
|
-
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** Tally one (or `count`) raw message(s) into a raw bucket. */
|
|
1204
|
+
protected rsRaw(bucket: 'head' | 'tail' | 'middleRaw', tokens: number, count = 1): void {
|
|
1205
|
+
const r = this._rs;
|
|
1206
|
+
if (!r) return;
|
|
1207
|
+
r[bucket].messages += count;
|
|
1208
|
+
r[bucket].tokens += tokens;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** Tally one emitted recall pair under its ancestor's level (>=3 folds into l3). */
|
|
1212
|
+
protected rsSummary(level: number, tokens: number): void {
|
|
1213
|
+
const r = this._rs;
|
|
1214
|
+
if (!r) return;
|
|
1215
|
+
const k: 'l1' | 'l2' | 'l3' = level <= 1 ? 'l1' : level === 2 ? 'l2' : 'l3';
|
|
1216
|
+
r.summaries[k].count += 1;
|
|
1217
|
+
r.summaries[k].tokens += tokens;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** Commit the accumulated stats as the last-render snapshot. */
|
|
1221
|
+
protected rsEnd(): void {
|
|
1222
|
+
const r = this._rs;
|
|
1223
|
+
if (!r) return;
|
|
1224
|
+
r.pending = {
|
|
1225
|
+
chunks: this.chunks.filter(c => !c.compressed).length,
|
|
1226
|
+
merges: this.mergeQueue.length,
|
|
1227
|
+
};
|
|
1228
|
+
const s = r.summaries;
|
|
1229
|
+
const summaryMsgs = (s.l1.count + s.l2.count + s.l3.count) * 2; // Q/A pair each
|
|
1230
|
+
r.total = {
|
|
1231
|
+
messages: r.head.messages + r.tail.messages + r.middleRaw.messages + summaryMsgs,
|
|
1232
|
+
tokens:
|
|
1233
|
+
r.head.tokens + r.tail.tokens + r.middleRaw.tokens +
|
|
1234
|
+
s.l1.tokens + s.l2.tokens + s.l3.tokens,
|
|
1235
|
+
};
|
|
1236
|
+
this._lastRenderStats = r;
|
|
1237
|
+
this._rs = null;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Stats describing the LAST rendered context. Returns the inspected snapshot
|
|
1242
|
+
* captured during the most recent `select()`. Before any compile has run (no
|
|
1243
|
+
* snapshot yet), falls back to a reconstructed pyramid view so callers still
|
|
1244
|
+
* get a non-null shape.
|
|
1245
|
+
*/
|
|
1246
|
+
getRenderStats(store: MessageStoreView): RenderStats {
|
|
1247
|
+
return this._lastRenderStats ?? this.reconstructRenderStats(store);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Pre-render fallback: re-derive head/tail windows + the full live pyramid.
|
|
1252
|
+
* NOTE: this is the old "reconstruct" behavior and does NOT reflect adaptive
|
|
1253
|
+
* folding — used only until the first compile populates the inspected stats.
|
|
1254
|
+
*/
|
|
1255
|
+
protected reconstructRenderStats(store: MessageStoreView): RenderStats {
|
|
1184
1256
|
const messages = store.getAll();
|
|
1185
1257
|
const headStart = this.getHeadWindowStartIndex(store);
|
|
1186
1258
|
const headEnd = this.getHeadWindowEnd(store);
|
|
@@ -1197,145 +1269,31 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1197
1269
|
const sumLevelTokens = (level: SummaryLevel): number =>
|
|
1198
1270
|
live(level).reduce((acc, s) => acc + s.tokens, 0);
|
|
1199
1271
|
|
|
1272
|
+
const head = { messages: headMsgs.length, tokens: sumTokens(headMsgs) };
|
|
1273
|
+
const tail = { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) };
|
|
1274
|
+
const summaries = {
|
|
1275
|
+
l1: { count: live(1).length, tokens: sumLevelTokens(1) },
|
|
1276
|
+
l2: { count: live(2).length, tokens: sumLevelTokens(2) },
|
|
1277
|
+
l3: { count: live(3).length, tokens: sumLevelTokens(3) },
|
|
1278
|
+
};
|
|
1200
1279
|
return {
|
|
1201
|
-
head
|
|
1202
|
-
tail
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
l2: { count: live(2).length, tokens: sumLevelTokens(2) },
|
|
1206
|
-
l3: { count: live(3).length, tokens: sumLevelTokens(3) },
|
|
1207
|
-
},
|
|
1280
|
+
head,
|
|
1281
|
+
tail,
|
|
1282
|
+
middleRaw: { messages: 0, tokens: 0 },
|
|
1283
|
+
summaries,
|
|
1208
1284
|
pending: {
|
|
1209
1285
|
chunks: this.chunks.filter(c => !c.compressed).length,
|
|
1210
1286
|
merges: this.mergeQueue.length,
|
|
1211
1287
|
},
|
|
1288
|
+
total: {
|
|
1289
|
+
messages: head.messages + tail.messages
|
|
1290
|
+
+ (summaries.l1.count + summaries.l2.count + summaries.l3.count) * 2,
|
|
1291
|
+
tokens: head.tokens + tail.tokens
|
|
1292
|
+
+ summaries.l1.tokens + summaries.l2.tokens + summaries.l3.tokens,
|
|
1293
|
+
},
|
|
1212
1294
|
};
|
|
1213
1295
|
}
|
|
1214
1296
|
|
|
1215
|
-
// ============================================================================
|
|
1216
|
-
// Legacy (single-level) path
|
|
1217
|
-
// ============================================================================
|
|
1218
|
-
|
|
1219
|
-
protected selectLegacy(
|
|
1220
|
-
store: MessageStoreView,
|
|
1221
|
-
_log: ContextLogView,
|
|
1222
|
-
budget: TokenBudget
|
|
1223
|
-
): ContextEntry[] {
|
|
1224
|
-
const entries: ContextEntry[] = [];
|
|
1225
|
-
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
1226
|
-
let totalTokens = 0;
|
|
1227
|
-
const messages = store.getAll();
|
|
1228
|
-
const msgCap = this.config.maxMessageTokens;
|
|
1229
|
-
|
|
1230
|
-
// 1. Head window: preserved verbatim as raw copies
|
|
1231
|
-
const headStart = this.getHeadWindowStartIndex(store);
|
|
1232
|
-
const headEnd = this.getHeadWindowEnd(store);
|
|
1233
|
-
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
1234
|
-
const msg = messages[i];
|
|
1235
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1236
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1237
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
1238
|
-
|
|
1239
|
-
entries.push({
|
|
1240
|
-
index: entries.length,
|
|
1241
|
-
sourceMessageId: msg.id,
|
|
1242
|
-
sourceRelation: 'copy',
|
|
1243
|
-
participant: msg.participant,
|
|
1244
|
-
content,
|
|
1245
|
-
});
|
|
1246
|
-
totalTokens += tokens;
|
|
1247
|
-
}
|
|
1248
|
-
// Mark the last head entry as a cache boundary (even if budget truncated the window)
|
|
1249
|
-
if (entries.length > 0) {
|
|
1250
|
-
entries[entries.length - 1].cacheMarker = true;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// 2. Middle zone: compressed chunks as diary pairs, uncompressed as raw messages.
|
|
1254
|
-
const rawRecentStart = this.getRecentWindowStart(store);
|
|
1255
|
-
// Track which message IDs are covered by chunks
|
|
1256
|
-
const coveredByChunks = new Set<string>();
|
|
1257
|
-
|
|
1258
|
-
for (const chunk of this.chunks) {
|
|
1259
|
-
for (const m of chunk.messages) coveredByChunks.add(m.id);
|
|
1260
|
-
|
|
1261
|
-
if (chunk.compressed && chunk.diary) {
|
|
1262
|
-
const contextLabel = this.config.summaryContextLabel ?? 'Here is a summary of earlier conversation context:';
|
|
1263
|
-
const summaryParticipant = this.config.summaryParticipant ?? 'Summary';
|
|
1264
|
-
|
|
1265
|
-
const questionEntry: ContextEntry = {
|
|
1266
|
-
index: entries.length,
|
|
1267
|
-
participant: 'Context Manager',
|
|
1268
|
-
content: [{ type: 'text', text: contextLabel }],
|
|
1269
|
-
sourceRelation: 'derived',
|
|
1270
|
-
};
|
|
1271
|
-
|
|
1272
|
-
// Synthesised summary turns must respect maxMessageTokens just like raw
|
|
1273
|
-
// copies do — otherwise a runaway diary can starve recent messages.
|
|
1274
|
-
const answerContent: ContentBlock[] = [{ type: 'text', text: chunk.diary }];
|
|
1275
|
-
const answerEntry: ContextEntry = {
|
|
1276
|
-
index: entries.length + 1,
|
|
1277
|
-
participant: summaryParticipant,
|
|
1278
|
-
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
1279
|
-
sourceRelation: 'derived',
|
|
1280
|
-
};
|
|
1281
|
-
|
|
1282
|
-
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
1283
|
-
this.estimateTokens(answerEntry.content);
|
|
1284
|
-
|
|
1285
|
-
if (this.isOverBudget(totalTokens + pairTokens, maxTokens)) break;
|
|
1286
|
-
|
|
1287
|
-
entries.push(questionEntry);
|
|
1288
|
-
entries.push(answerEntry);
|
|
1289
|
-
totalTokens += pairTokens;
|
|
1290
|
-
} else {
|
|
1291
|
-
// Uncompressed: emit raw messages so they aren't lost
|
|
1292
|
-
for (const msg of chunk.messages) {
|
|
1293
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1294
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1295
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
1296
|
-
|
|
1297
|
-
entries.push({
|
|
1298
|
-
index: entries.length,
|
|
1299
|
-
sourceMessageId: msg.id,
|
|
1300
|
-
sourceRelation: 'copy',
|
|
1301
|
-
participant: msg.participant,
|
|
1302
|
-
content,
|
|
1303
|
-
});
|
|
1304
|
-
totalTokens += tokens;
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
// Emit gap messages in the compressible zone not covered by any chunk.
|
|
1310
|
-
// Compressible zone: [0, headStart) ∪ [headEnd, rawRecentStart)
|
|
1311
|
-
for (let i = 0; i < rawRecentStart && i < messages.length; i++) {
|
|
1312
|
-
// Skip head window messages (already emitted verbatim above)
|
|
1313
|
-
if (i >= headStart && i < headEnd) continue;
|
|
1314
|
-
if (coveredByChunks.has(messages[i].id)) continue;
|
|
1315
|
-
const msg = messages[i];
|
|
1316
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1317
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1318
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
1319
|
-
|
|
1320
|
-
entries.push({
|
|
1321
|
-
index: entries.length,
|
|
1322
|
-
sourceMessageId: msg.id,
|
|
1323
|
-
sourceRelation: 'copy',
|
|
1324
|
-
participant: msg.participant,
|
|
1325
|
-
content,
|
|
1326
|
-
});
|
|
1327
|
-
totalTokens += tokens;
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
// 3. Recent uncompressed messages (skip those already in head window)
|
|
1331
|
-
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
1332
|
-
this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
|
|
1333
|
-
|
|
1334
|
-
this.trimOrphanedToolUse(entries);
|
|
1335
|
-
this.pruneToolEntries(entries);
|
|
1336
|
-
return entries;
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
1297
|
/**
|
|
1340
1298
|
* Emit recent-window messages, evicting OLDEST-first when the budget is tight.
|
|
1341
1299
|
*
|
|
@@ -1355,8 +1313,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1355
1313
|
msgCap: number,
|
|
1356
1314
|
maxTokens: number,
|
|
1357
1315
|
totalTokensBefore: number,
|
|
1358
|
-
):
|
|
1359
|
-
if (recentStart >= messages.length) return;
|
|
1316
|
+
): { messages: number; tokens: number } {
|
|
1317
|
+
if (recentStart >= messages.length) return { messages: 0, tokens: 0 };
|
|
1360
1318
|
|
|
1361
1319
|
const accepted: number[] = [];
|
|
1362
1320
|
let acceptedTokens = 0;
|
|
@@ -1380,9 +1338,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1380
1338
|
accepted.shift();
|
|
1381
1339
|
}
|
|
1382
1340
|
|
|
1341
|
+
let emittedTokens = 0;
|
|
1383
1342
|
for (const i of accepted) {
|
|
1384
1343
|
const msg = messages[i];
|
|
1385
1344
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1345
|
+
const tokens = msgCap > 0
|
|
1346
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
1347
|
+
: store.estimateTokens(msg);
|
|
1386
1348
|
entries.push({
|
|
1387
1349
|
index: entries.length,
|
|
1388
1350
|
sourceMessageId: msg.id,
|
|
@@ -1390,100 +1352,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1390
1352
|
participant: msg.participant,
|
|
1391
1353
|
content,
|
|
1392
1354
|
});
|
|
1355
|
+
emittedTokens += tokens;
|
|
1393
1356
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
protected async compressChunkLegacy(chunk: Chunk, ctx: StrategyContext): Promise<void> {
|
|
1397
|
-
if (!ctx.membrane) {
|
|
1398
|
-
throw new Error('No membrane instance for compression');
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
const priorContext = this.buildPriorContextLegacy(chunk, ctx);
|
|
1402
|
-
const chunkContent = this.formatChunkForCompression(chunk);
|
|
1403
|
-
|
|
1404
|
-
const prompt = this.config.diaryUserPrompt ?? this.config.summaryUserPrompt!;
|
|
1405
|
-
const systemPrompt = this.config.diarySystemPrompt ?? this.config.summarySystemPrompt!;
|
|
1406
|
-
|
|
1407
|
-
const messages = [
|
|
1408
|
-
...priorContext,
|
|
1409
|
-
{
|
|
1410
|
-
participant: 'Context Manager',
|
|
1411
|
-
content: [{ type: 'text' as const, text: prompt.replace('{content}', chunkContent) }],
|
|
1412
|
-
},
|
|
1413
|
-
];
|
|
1414
|
-
|
|
1415
|
-
const request: NormalizedRequest = {
|
|
1416
|
-
messages: messages.map((m) => ({
|
|
1417
|
-
participant: m.participant,
|
|
1418
|
-
content: m.content,
|
|
1419
|
-
})),
|
|
1420
|
-
system: systemPrompt,
|
|
1421
|
-
config: {
|
|
1422
|
-
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1423
|
-
maxTokens: 2000,
|
|
1424
|
-
},
|
|
1425
|
-
};
|
|
1426
|
-
|
|
1427
|
-
try {
|
|
1428
|
-
const response = await ctx.membrane.complete(request);
|
|
1429
|
-
const diaryText = response.content
|
|
1430
|
-
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
1431
|
-
.map((b) => b.text)
|
|
1432
|
-
.join('\n');
|
|
1433
|
-
|
|
1434
|
-
chunk.compressed = true;
|
|
1435
|
-
chunk.diary = diaryText;
|
|
1436
|
-
this._compressionCount++;
|
|
1437
|
-
} catch (error) {
|
|
1438
|
-
console.error('Failed to compress chunk:', error);
|
|
1439
|
-
throw error;
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
protected buildPriorContextLegacy(chunk: Chunk, ctx: StrategyContext): Array<{
|
|
1444
|
-
participant: string;
|
|
1445
|
-
content: ContentBlock[];
|
|
1446
|
-
}> {
|
|
1447
|
-
const context: Array<{ participant: string; content: ContentBlock[] }> = [];
|
|
1448
|
-
|
|
1449
|
-
for (const prevChunk of this.chunks) {
|
|
1450
|
-
if (prevChunk.index >= chunk.index) break;
|
|
1451
|
-
if (!prevChunk.compressed || !prevChunk.diary) continue;
|
|
1452
|
-
|
|
1453
|
-
context.push({
|
|
1454
|
-
participant: 'Context Manager',
|
|
1455
|
-
content: [{ type: 'text', text: this.config.summaryContextLabel ?? 'Summary of earlier context:' }],
|
|
1456
|
-
});
|
|
1457
|
-
context.push({
|
|
1458
|
-
participant: this.config.summaryParticipant ?? 'Summary',
|
|
1459
|
-
content: [{ type: 'text', text: prevChunk.diary }],
|
|
1460
|
-
});
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
// Find the actual position of this chunk's first message in the full array
|
|
1464
|
-
const messages = ctx.messageStore.getAll();
|
|
1465
|
-
const firstMsgId = chunk.messages[0]?.id;
|
|
1466
|
-
const chunkAbsStart = firstMsgId
|
|
1467
|
-
? messages.findIndex(m => m.id === firstMsgId)
|
|
1468
|
-
: -1;
|
|
1469
|
-
|
|
1470
|
-
if (chunkAbsStart > 0) {
|
|
1471
|
-
const precedingStart = Math.max(0, chunkAbsStart - 50);
|
|
1472
|
-
let tokens = 0;
|
|
1473
|
-
|
|
1474
|
-
for (let i = chunkAbsStart - 1; i >= precedingStart && tokens < 15000; i--) {
|
|
1475
|
-
const msg = messages[i];
|
|
1476
|
-
if (!msg) break;
|
|
1477
|
-
|
|
1478
|
-
tokens += ctx.messageStore.estimateTokens(msg);
|
|
1479
|
-
context.unshift({
|
|
1480
|
-
participant: msg.participant,
|
|
1481
|
-
content: msg.content,
|
|
1482
|
-
});
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
return context;
|
|
1357
|
+
return { messages: accepted.length, tokens: emittedTokens };
|
|
1487
1358
|
}
|
|
1488
1359
|
|
|
1489
1360
|
// ============================================================================
|
|
@@ -1642,9 +1513,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1642
1513
|
// raw messages reappear.
|
|
1643
1514
|
const chunkFirstId = chunk.messages[0]?.id;
|
|
1644
1515
|
if (chunkFirstId) {
|
|
1645
|
-
|
|
1516
|
+
// Expand summary sourceIds down to leaf message IDs. An L2 in
|
|
1517
|
+
// `priorSummaries` has L1 IDs in its sourceIds, not message IDs;
|
|
1518
|
+
// a flat walk would miss every message it transitively covers.
|
|
1519
|
+
// (Bug 10 — same shape as executeMerge.)
|
|
1520
|
+
const summariesById = new Map<string, SummaryEntry>();
|
|
1521
|
+
for (const s of this.summaries) summariesById.set(s.id, s);
|
|
1522
|
+
const priorSummaryMessageIds = new Set<MessageId>();
|
|
1523
|
+
for (const s of this.summaries) {
|
|
1524
|
+
if (s.level === 1) this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
1525
|
+
}
|
|
1646
1526
|
for (const s of priorSummaries) {
|
|
1647
|
-
|
|
1527
|
+
this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
1648
1528
|
}
|
|
1649
1529
|
const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
|
|
1650
1530
|
for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
|
|
@@ -1693,6 +1573,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1693
1573
|
// Collapse consecutive same-participant messages for API compliance
|
|
1694
1574
|
const collapsed = this.collapseConsecutiveMessages(split);
|
|
1695
1575
|
|
|
1576
|
+
// Defense in depth against chunk boundaries that cut a tool cycle
|
|
1577
|
+
// (rebuildChunks tries to avoid this, but covers only the most common
|
|
1578
|
+
// case). The API rejects any tool_use that isn't immediately followed
|
|
1579
|
+
// by its tool_result, and any tool_result that doesn't follow a use.
|
|
1580
|
+
const cleaned = stripUnpairedToolBlocks(collapsed);
|
|
1581
|
+
|
|
1696
1582
|
// NO system prompt. The agent's identity is established by the head
|
|
1697
1583
|
// (the actual conversation opening — user message + agent reply that
|
|
1698
1584
|
// grounded the original instance). A system prompt would (a) add a
|
|
@@ -1702,7 +1588,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1702
1588
|
// structural one carried by the conversation itself. Anchoring
|
|
1703
1589
|
// identity by the chronicle's actual head is more honest.
|
|
1704
1590
|
const request: NormalizedRequest = {
|
|
1705
|
-
messages:
|
|
1591
|
+
messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
|
|
1706
1592
|
config: {
|
|
1707
1593
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1708
1594
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
@@ -1716,6 +1602,10 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1716
1602
|
|
|
1717
1603
|
try {
|
|
1718
1604
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
1605
|
+
// Text-only on purpose: this is the SUMMARIZER's one-shot response —
|
|
1606
|
+
// its thinking/redacted_thinking blocks are scratch work, not part of
|
|
1607
|
+
// the agent's history, and signed thinking is never valid inside a
|
|
1608
|
+
// rewritten summary anyway.
|
|
1719
1609
|
const summaryText = response.content
|
|
1720
1610
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
1721
1611
|
.map(b => b.text)
|
|
@@ -1753,7 +1643,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1753
1643
|
logCompressionCall({
|
|
1754
1644
|
operation: 'compress_l1',
|
|
1755
1645
|
system: null,
|
|
1756
|
-
messages:
|
|
1646
|
+
messages: cleaned.map((m) => ({
|
|
1757
1647
|
participant: m.participant,
|
|
1758
1648
|
// Flatten content for logging — store text only; binary content
|
|
1759
1649
|
// would bloat the log and isn't typical in compression input.
|
|
@@ -2032,9 +1922,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2032
1922
|
// The full unmerged-frontier set covers what's "compressed somewhere"
|
|
2033
1923
|
// for the raw-middle dedup below — a budget-dropped recall pair
|
|
2034
1924
|
// doesn't make its underlying raw messages reappear.
|
|
1925
|
+
//
|
|
1926
|
+
// Critical: `sourceIds` on an L2+ summary points at L1 IDs, not raw
|
|
1927
|
+
// message IDs. The dedup happens against raw message IDs, so we must
|
|
1928
|
+
// recursively expand each summary down to its leaf message IDs.
|
|
1929
|
+
// Without this, every message under any L2 leaks back in as raw text
|
|
1930
|
+
// (Bug 10: 525-message merge requests on a 4234-msg conversation).
|
|
1931
|
+
// Also expand merged L1s as defense in depth.
|
|
2035
1932
|
const priorSummaryMessageIds = new Set<MessageId>();
|
|
1933
|
+
for (const s of this.summaries) {
|
|
1934
|
+
if (s.level === 1) this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
1935
|
+
}
|
|
2036
1936
|
for (const s of priorSummariesAll) {
|
|
2037
|
-
|
|
1937
|
+
this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
2038
1938
|
}
|
|
2039
1939
|
|
|
2040
1940
|
for (const s of keptPriorSummaries) {
|
|
@@ -2143,12 +2043,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2143
2043
|
// Same bundled-tool-cycle defense as compressChunkHierarchical.
|
|
2144
2044
|
const split = splitMixedToolMessages(llmMessages);
|
|
2145
2045
|
const collapsed = this.collapseConsecutiveMessages(split);
|
|
2046
|
+
const cleaned = stripUnpairedToolBlocks(collapsed);
|
|
2146
2047
|
|
|
2147
2048
|
// NO system prompt — identity is established by the head window
|
|
2148
2049
|
// (present at the start of llmMessages above) and by the prior
|
|
2149
2050
|
// recall pairs. Same rationale as compressChunkHierarchical.
|
|
2150
2051
|
const request: NormalizedRequest = {
|
|
2151
|
-
messages:
|
|
2052
|
+
messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
|
|
2152
2053
|
config: {
|
|
2153
2054
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
2154
2055
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
@@ -2162,6 +2063,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2162
2063
|
|
|
2163
2064
|
try {
|
|
2164
2065
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
2066
|
+
// Text-only on purpose: summarizer scratch thinking is not agent history
|
|
2165
2067
|
const mergedText = response.content
|
|
2166
2068
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
2167
2069
|
.map(b => b.text)
|
|
@@ -2208,7 +2110,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2208
2110
|
logCompressionCall({
|
|
2209
2111
|
operation: `merge_l${targetLevel}`,
|
|
2210
2112
|
system: null,
|
|
2211
|
-
messages:
|
|
2113
|
+
messages: cleaned.map((m) => ({
|
|
2212
2114
|
participant: m.participant,
|
|
2213
2115
|
text: m.content
|
|
2214
2116
|
.filter((b: ContentBlock) => b.type === 'text')
|
|
@@ -2246,6 +2148,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2246
2148
|
* See `docs/adaptive-resolution-design.md` §3, §5.
|
|
2247
2149
|
*/
|
|
2248
2150
|
protected selectAdaptive(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
|
|
2151
|
+
this.rsBegin();
|
|
2249
2152
|
const entries: ContextEntry[] = [];
|
|
2250
2153
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
2251
2154
|
const messages = store.getAll();
|
|
@@ -2279,10 +2182,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2279
2182
|
totalTokens += tokens;
|
|
2280
2183
|
headMessageIds.add(msg.id);
|
|
2281
2184
|
headTokens += tokens;
|
|
2185
|
+
this.rsRaw('head', tokens);
|
|
2282
2186
|
}
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2187
|
+
// (Cache breakpoints are placed in one pass over the FINAL ordered entries
|
|
2188
|
+
// below — see placeCacheMarkers — capturing the stable folded prefix, not
|
|
2189
|
+
// just the head boundary.)
|
|
2286
2190
|
|
|
2287
2191
|
// Compute tail message IDs (will be emitted at end)
|
|
2288
2192
|
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
@@ -2386,19 +2290,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2386
2290
|
const headSetForPicker = new Set<ChunkId>(headMessageIds);
|
|
2387
2291
|
const tailSetForPicker = new Set<ChunkId>(tailMessageIds);
|
|
2388
2292
|
|
|
2389
|
-
const
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
foldingBudget
|
|
2401
|
-
);
|
|
2293
|
+
const pickerInputs: PickerInputs = {
|
|
2294
|
+
chunks: pickerChunks,
|
|
2295
|
+
summaries: summariesMap,
|
|
2296
|
+
recallPairTokens,
|
|
2297
|
+
headChunkIds: headSetForPicker,
|
|
2298
|
+
tailChunkIds: tailSetForPicker,
|
|
2299
|
+
headTokens,
|
|
2300
|
+
tailTokens,
|
|
2301
|
+
};
|
|
2302
|
+
const picker = this.buildPicker(pickerInputs);
|
|
2303
|
+
const result = picker.run(pickerInputs, foldingBudget);
|
|
2402
2304
|
|
|
2403
2305
|
// Commit the new resolutions back to strategy state for next compile.
|
|
2404
2306
|
// Persist to chronicle only if anything actually changed — avoids
|
|
@@ -2526,6 +2428,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2526
2428
|
content,
|
|
2527
2429
|
});
|
|
2528
2430
|
totalTokens += tokens;
|
|
2431
|
+
this.rsRaw('middleRaw', tokens);
|
|
2529
2432
|
} else {
|
|
2530
2433
|
// summary run — emit Q+A pair, dedup at the strategy level
|
|
2531
2434
|
const ancestor = currentRun.ancestor;
|
|
@@ -2552,6 +2455,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2552
2455
|
entries.push(questionEntry);
|
|
2553
2456
|
entries.push(answerEntry);
|
|
2554
2457
|
totalTokens += pairTokens;
|
|
2458
|
+
this.rsSummary(ancestor.level, pairTokens);
|
|
2555
2459
|
}
|
|
2556
2460
|
}
|
|
2557
2461
|
currentRun = null;
|
|
@@ -2612,6 +2516,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2612
2516
|
content,
|
|
2613
2517
|
});
|
|
2614
2518
|
totalTokens += tokens;
|
|
2519
|
+
this.rsRaw('middleRaw', tokens);
|
|
2615
2520
|
i++;
|
|
2616
2521
|
} else {
|
|
2617
2522
|
const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
|
|
@@ -2627,6 +2532,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2627
2532
|
content,
|
|
2628
2533
|
});
|
|
2629
2534
|
totalTokens += tokens;
|
|
2535
|
+
this.rsRaw('middleRaw', tokens);
|
|
2630
2536
|
i++;
|
|
2631
2537
|
continue;
|
|
2632
2538
|
}
|
|
@@ -2653,12 +2559,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2653
2559
|
entries.push(questionEntry);
|
|
2654
2560
|
entries.push(answerEntry);
|
|
2655
2561
|
totalTokens += pairTokens;
|
|
2562
|
+
this.rsSummary(ancestor.level, pairTokens);
|
|
2656
2563
|
i++;
|
|
2657
2564
|
}
|
|
2658
2565
|
}
|
|
2659
2566
|
|
|
2660
2567
|
// ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
|
|
2661
|
-
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2568
|
+
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2569
|
+
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
2662
2570
|
|
|
2663
2571
|
// ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
|
|
2664
2572
|
// Both head and tail emission paths emit shards as separate ContextEntries.
|
|
@@ -2668,10 +2576,59 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2668
2576
|
// it falls into (preserves KV cache through region transitions).
|
|
2669
2577
|
const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
|
|
2670
2578
|
|
|
2579
|
+
this.pruneToolEntries(merged);
|
|
2671
2580
|
this.trimOrphanedToolUse(merged);
|
|
2581
|
+
// Strip stale images BEFORE placing markers and committing stats, so both
|
|
2582
|
+
// describe the post-strip context the agent actually receives.
|
|
2583
|
+
this.applyImageStripping(merged, store);
|
|
2584
|
+
// Place ≤4 cache breakpoints across the FINAL ordered entries so the
|
|
2585
|
+
// provider can reuse the stable folded prefix — not just the head. With a
|
|
2586
|
+
// single head marker the cache hit is ~2%; well-placed breakpoints take the
|
|
2587
|
+
// real strategy to ~50% (docs/kv-stable-context-control.md — marker
|
|
2588
|
+
// placement is the dominant KV lever).
|
|
2589
|
+
this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
|
|
2590
|
+
this.rsEnd();
|
|
2672
2591
|
return merged;
|
|
2673
2592
|
}
|
|
2674
2593
|
|
|
2594
|
+
/**
|
|
2595
|
+
* Place up to four `cache_control` breakpoints across the final ordered
|
|
2596
|
+
* entries: the head/system boundary, the end of the folded history (the
|
|
2597
|
+
* stable prefix that persists turn-to-turn — the most valuable), a mid-history
|
|
2598
|
+
* seam, and the very end (for pure-append reuse). Mirrors `placeMarkers` in
|
|
2599
|
+
* the adaptive layer but operates on emitted entries. Idempotent; clears any
|
|
2600
|
+
* pre-existing markers first.
|
|
2601
|
+
*/
|
|
2602
|
+
protected placeCacheMarkers(
|
|
2603
|
+
entries: ContextEntry[],
|
|
2604
|
+
headMessageIds: ReadonlySet<MessageId>,
|
|
2605
|
+
tailMessageIds: ReadonlySet<MessageId>,
|
|
2606
|
+
): void {
|
|
2607
|
+
for (const e of entries) if (e.cacheMarker) e.cacheMarker = false;
|
|
2608
|
+
const n = entries.length;
|
|
2609
|
+
if (n === 0) return;
|
|
2610
|
+
|
|
2611
|
+
let lastHead = -1;
|
|
2612
|
+
let firstTail = n;
|
|
2613
|
+
for (let i = 0; i < n; i++) {
|
|
2614
|
+
const sid = entries[i].sourceMessageId;
|
|
2615
|
+
if (sid && headMessageIds.has(sid)) lastHead = i;
|
|
2616
|
+
}
|
|
2617
|
+
for (let i = 0; i < n; i++) {
|
|
2618
|
+
const sid = entries[i].sourceMessageId;
|
|
2619
|
+
if (sid && tailMessageIds.has(sid)) { firstTail = i; break; }
|
|
2620
|
+
}
|
|
2621
|
+
const historyEnd = firstTail - 1; // last middle (folded-history) entry
|
|
2622
|
+
|
|
2623
|
+
const marks = new Set<number>();
|
|
2624
|
+
if (lastHead >= 0) marks.add(lastHead); // system / head block
|
|
2625
|
+
if (historyEnd > lastHead) marks.add(historyEnd); // stable folded prefix (the big one)
|
|
2626
|
+
if (historyEnd - lastHead > 2) marks.add(lastHead + Math.floor((historyEnd - lastHead) / 2)); // mid-history
|
|
2627
|
+
marks.add(n - 1); // end → pure-append reuse
|
|
2628
|
+
|
|
2629
|
+
for (const idx of marks) if (idx >= 0 && idx < n) entries[idx].cacheMarker = true;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2675
2632
|
/**
|
|
2676
2633
|
* Walk an entries array; for every run of consecutive entries that
|
|
2677
2634
|
* (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
|
|
@@ -2765,6 +2722,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2765
2722
|
return this._adaptivePicker;
|
|
2766
2723
|
}
|
|
2767
2724
|
|
|
2725
|
+
/**
|
|
2726
|
+
* Build the picker for this compile. Instance folding strategies (kv-stable)
|
|
2727
|
+
* need the per-compile `PickerInputs` at construction, so they're built fresh
|
|
2728
|
+
* here; stateless ones (flat-profile / oldest-first) reuse the memoized picker.
|
|
2729
|
+
*/
|
|
2730
|
+
protected buildPicker(inputs: PickerInputs): Picker {
|
|
2731
|
+
if (this.config.foldingStrategy === 'kv-stable') {
|
|
2732
|
+
return new Picker(
|
|
2733
|
+
new KvStableStrategy(inputs, {
|
|
2734
|
+
reachTokens: this.config.kvStableReachTokens,
|
|
2735
|
+
mergeThreshold: this.config.mergeThreshold,
|
|
2736
|
+
}),
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
return this.getAdaptivePicker();
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2768
2742
|
/**
|
|
2769
2743
|
* Walk the summary tree to find the L_k ancestor of a message.
|
|
2770
2744
|
* Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
|
|
@@ -2794,6 +2768,48 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2794
2768
|
return current;
|
|
2795
2769
|
}
|
|
2796
2770
|
|
|
2771
|
+
/**
|
|
2772
|
+
* Recursively expand a summary's `sourceIds` down to the leaf message IDs
|
|
2773
|
+
* it covers, adding each leaf into `out`.
|
|
2774
|
+
*
|
|
2775
|
+
* Required because `SummaryEntry.sourceIds` are level-relative: an L1's
|
|
2776
|
+
* sourceIds are raw message IDs (sourceLevel=0), but an L2's sourceIds
|
|
2777
|
+
* are L1 IDs (sourceLevel=1), and so on. Any dedup that walks `sourceIds`
|
|
2778
|
+
* directly only works for L1s — once L2+ summaries enter the picture
|
|
2779
|
+
* (which happens as soon as `mergeThreshold` L1s accumulate during
|
|
2780
|
+
* interleaved compression+merge ticks), the dedup silently fails and
|
|
2781
|
+
* already-summarized messages leak back into the request as raw text.
|
|
2782
|
+
* That's how Bug 10 produced 200k+ token merge prompts on a 4234-msg
|
|
2783
|
+
* import.
|
|
2784
|
+
*
|
|
2785
|
+
* Callers should also expand merged L1s (not just the unmerged frontier)
|
|
2786
|
+
* as defense in depth — a stale `mergedInto` pointer or a partially
|
|
2787
|
+
* applied merge shouldn't surface raw messages.
|
|
2788
|
+
*
|
|
2789
|
+
* `visited` guards against pathological cycles in the summary graph (a
|
|
2790
|
+
* corrupted store or a future merge regression that lets a summary
|
|
2791
|
+
* reference itself). The hierarchy is a DAG by construction, but a
|
|
2792
|
+
* stack overflow during compression — exactly when the safety net is
|
|
2793
|
+
* supposed to save the session — is too steep a price for trusting that.
|
|
2794
|
+
*/
|
|
2795
|
+
protected expandSummaryToLeafMessageIds(
|
|
2796
|
+
summary: SummaryEntry,
|
|
2797
|
+
summariesById: ReadonlyMap<string, SummaryEntry>,
|
|
2798
|
+
out: Set<MessageId>,
|
|
2799
|
+
visited: Set<string> = new Set(),
|
|
2800
|
+
): void {
|
|
2801
|
+
if (visited.has(summary.id)) return;
|
|
2802
|
+
visited.add(summary.id);
|
|
2803
|
+
if (summary.sourceLevel === 0) {
|
|
2804
|
+
for (const id of summary.sourceIds) out.add(id);
|
|
2805
|
+
return;
|
|
2806
|
+
}
|
|
2807
|
+
for (const childId of summary.sourceIds) {
|
|
2808
|
+
const child = summariesById.get(childId);
|
|
2809
|
+
if (child) this.expandSummaryToLeafMessageIds(child, summariesById, out, visited);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2797
2813
|
// ============================================================================
|
|
2798
2814
|
// Hierarchical (threshold-driven) path
|
|
2799
2815
|
// ============================================================================
|
|
@@ -2803,6 +2819,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2803
2819
|
* Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
|
|
2804
2820
|
*/
|
|
2805
2821
|
protected selectHierarchical(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
|
|
2822
|
+
this.rsBegin();
|
|
2806
2823
|
const entries: ContextEntry[] = [];
|
|
2807
2824
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
2808
2825
|
const messages = store.getAll();
|
|
@@ -2827,6 +2844,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2827
2844
|
content,
|
|
2828
2845
|
});
|
|
2829
2846
|
totalTokens += tokens;
|
|
2847
|
+
this.rsRaw('head', tokens);
|
|
2830
2848
|
}
|
|
2831
2849
|
// Mark the last head entry as a cache boundary (even if budget truncated the window)
|
|
2832
2850
|
if (entries.length > 0) {
|
|
@@ -2986,6 +3004,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2986
3004
|
entries.push(questionEntry);
|
|
2987
3005
|
entries.push(answerEntry);
|
|
2988
3006
|
totalTokens += pairTokens;
|
|
3007
|
+
this.rsSummary(summary.level, pairTokens);
|
|
2989
3008
|
} else {
|
|
2990
3009
|
const msg = item.msg;
|
|
2991
3010
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
@@ -3001,6 +3020,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3001
3020
|
content,
|
|
3002
3021
|
});
|
|
3003
3022
|
totalTokens += tokens;
|
|
3023
|
+
this.rsRaw('middleRaw', tokens);
|
|
3004
3024
|
}
|
|
3005
3025
|
}
|
|
3006
3026
|
} else {
|
|
@@ -3034,6 +3054,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3034
3054
|
entries.push(questionEntry);
|
|
3035
3055
|
entries.push(answerEntry);
|
|
3036
3056
|
totalTokens += pairTokens;
|
|
3057
|
+
for (const s of selectedSummaries) this.rsSummary(s.level, s.tokens);
|
|
3037
3058
|
}
|
|
3038
3059
|
|
|
3039
3060
|
// Sort by position so uncompressed-middle messages and pins both
|
|
@@ -3053,6 +3074,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3053
3074
|
content,
|
|
3054
3075
|
});
|
|
3055
3076
|
totalTokens += tokens;
|
|
3077
|
+
this.rsRaw('middleRaw', tokens);
|
|
3056
3078
|
}
|
|
3057
3079
|
}
|
|
3058
3080
|
}
|
|
@@ -3062,10 +3084,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3062
3084
|
// budget, the latest messages (the ones the agent actually needs to act
|
|
3063
3085
|
// on) are preserved and the oldest recent-window messages are dropped.
|
|
3064
3086
|
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
3065
|
-
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
3087
|
+
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
3088
|
+
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
3066
3089
|
|
|
3067
3090
|
this.trimOrphanedToolUse(entries);
|
|
3068
3091
|
this.pruneToolEntries(entries);
|
|
3092
|
+
// Strip stale images before committing stats so RenderStats.total reflects
|
|
3093
|
+
// the post-strip context (this path places no cache markers).
|
|
3094
|
+
this.applyImageStripping(entries, store);
|
|
3095
|
+
this.rsEnd();
|
|
3069
3096
|
return entries;
|
|
3070
3097
|
}
|
|
3071
3098
|
|
|
@@ -3320,6 +3347,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3320
3347
|
};
|
|
3321
3348
|
|
|
3322
3349
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
3350
|
+
// Text-only on purpose: summarizer scratch thinking is not agent history
|
|
3323
3351
|
return response.content
|
|
3324
3352
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
3325
3353
|
.map(b => b.text)
|
|
@@ -3410,6 +3438,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3410
3438
|
currentTokens >= this.config.targetChunkTokens &&
|
|
3411
3439
|
currentChunk.length >= 4;
|
|
3412
3440
|
|
|
3441
|
+
// Don't close a chunk on a message containing a tool_use block —
|
|
3442
|
+
// the matching tool_result lives in the immediately-following user
|
|
3443
|
+
// message, and the Anthropic API rejects a request where a tool_use
|
|
3444
|
+
// isn't immediately followed by its tool_result. Defer the close
|
|
3445
|
+
// by one iteration so the result rides along in the same chunk.
|
|
3446
|
+
// The stripUnpairedToolBlocks runtime pass is a safety net for the
|
|
3447
|
+
// rare case where a tool_use is the very last message in the store.
|
|
3448
|
+
if (shouldClose && this.lastMessageContainsToolUse(currentChunk)) {
|
|
3449
|
+
continue;
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3413
3452
|
if (shouldClose) {
|
|
3414
3453
|
const chunk = this.createChunk(
|
|
3415
3454
|
this.chunks.length,
|
|
@@ -3448,6 +3487,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3448
3487
|
}
|
|
3449
3488
|
}
|
|
3450
3489
|
|
|
3490
|
+
/**
|
|
3491
|
+
* Returns true if the last message in the chunk-in-progress contains a
|
|
3492
|
+
* `tool_use` block. Used by `rebuildChunks` to defer chunk closure until
|
|
3493
|
+
* the matching `tool_result` (in the immediately-following user message)
|
|
3494
|
+
* is pulled into the same chunk. See `stripUnpairedToolBlocks` for the
|
|
3495
|
+
* runtime safety net.
|
|
3496
|
+
*/
|
|
3497
|
+
protected lastMessageContainsToolUse(chunk: StoredMessage[]): boolean {
|
|
3498
|
+
const last = chunk[chunk.length - 1];
|
|
3499
|
+
if (!last) return false;
|
|
3500
|
+
return last.content.some((b) => b.type === 'tool_use');
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3451
3503
|
protected createChunk(
|
|
3452
3504
|
index: number,
|
|
3453
3505
|
startIndex: number,
|
|
@@ -3469,7 +3521,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3469
3521
|
const existing = existingCompressed.get(key);
|
|
3470
3522
|
if (existing) {
|
|
3471
3523
|
chunk.compressed = true;
|
|
3472
|
-
chunk.diary = existing.diary;
|
|
3473
3524
|
chunk.summaryId = existing.summaryId;
|
|
3474
3525
|
}
|
|
3475
3526
|
|
|
@@ -3491,6 +3542,98 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3491
3542
|
return chunk.messages.map((m) => m.id).join(':');
|
|
3492
3543
|
}
|
|
3493
3544
|
|
|
3545
|
+
/** True if any content block is a live image. */
|
|
3546
|
+
protected hasImageBlock(content: ContentBlock[]): boolean {
|
|
3547
|
+
return content.some((b) => b.type === 'image');
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
/** Message index marking the image-strip depth boundary: walks newest→oldest
|
|
3551
|
+
* summing the same per-message estimate as getRecentWindowStart, and returns
|
|
3552
|
+
* the index of the first message still within `depthTokens`. Messages before
|
|
3553
|
+
* this index have their images stripped to placeholders. */
|
|
3554
|
+
protected getImageStripStart(store: MessageStoreView, depthTokens: number): number {
|
|
3555
|
+
const messages = store.getAll();
|
|
3556
|
+
let tokens = 0;
|
|
3557
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
3558
|
+
tokens += store.estimateTokens(messages[i]);
|
|
3559
|
+
if (tokens > depthTokens) return i + 1;
|
|
3560
|
+
}
|
|
3561
|
+
return 0;
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
/** Text substituted for an image block once it leaves the live-image window. */
|
|
3565
|
+
private static readonly IMAGE_PLACEHOLDER = '[image dropped from live context]';
|
|
3566
|
+
|
|
3567
|
+
/** Post-pass over compiled entries: replace image blocks with a text
|
|
3568
|
+
* placeholder once they fall outside the live-image window — either deeper
|
|
3569
|
+
* than `imageStripDepthTokens` from the newest message, or beyond the
|
|
3570
|
+
* `maxLiveImages` most-recent images (counted newest-first). Summaries are
|
|
3571
|
+
* already text, so they're naturally unaffected. The adjacent
|
|
3572
|
+
* "[image attachment: <name>]" text added at ingest preserves the filename,
|
|
3573
|
+
* so the placeholder itself stays terse. Reduces tokens, so it never pushes
|
|
3574
|
+
* a compiled context back over budget.
|
|
3575
|
+
*
|
|
3576
|
+
* Runs INSIDE each select path, *before* `rsEnd()` and `placeCacheMarkers`,
|
|
3577
|
+
* so the committed render stats (and the cache breakpoints) describe the
|
|
3578
|
+
* post-strip context. As it strips, it decrements the matching raw bucket of
|
|
3579
|
+
* the in-progress render stats by the reclaimed tokens, keeping
|
|
3580
|
+
* `RenderStats.total` equal to the real rendered size. */
|
|
3581
|
+
protected applyImageStripping(entries: ContextEntry[], store: MessageStoreView): void {
|
|
3582
|
+
const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
|
|
3583
|
+
const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
|
|
3584
|
+
if (maxLive === 0 && depthTokens === 0) return; // policy disabled
|
|
3585
|
+
|
|
3586
|
+
const messages = store.getAll();
|
|
3587
|
+
const posById = new Map<string, number>();
|
|
3588
|
+
for (let i = 0; i < messages.length; i++) posById.set(messages[i].id, i);
|
|
3589
|
+
const stripStart = depthTokens > 0 ? this.getImageStripStart(store, depthTokens) : 0;
|
|
3590
|
+
|
|
3591
|
+
// Same region windows select() bucketed by, so a stripped image's reclaimed
|
|
3592
|
+
// tokens come back out of the bucket it was originally tallied into.
|
|
3593
|
+
const headStart = this.getHeadWindowStartIndex(store);
|
|
3594
|
+
const headEnd = this.getHeadWindowEnd(store);
|
|
3595
|
+
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
3596
|
+
const bucketAt = (pos: number): 'head' | 'tail' | 'middleRaw' => {
|
|
3597
|
+
if (pos < 0) return 'middleRaw'; // no resolvable region — keep total == Σbuckets
|
|
3598
|
+
if (pos >= headStart && pos < headEnd) return 'head';
|
|
3599
|
+
if (pos >= recentStart) return 'tail';
|
|
3600
|
+
return 'middleRaw';
|
|
3601
|
+
};
|
|
3602
|
+
const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
|
|
3603
|
+
|
|
3604
|
+
// Image-bearing entries, newest-first by source position. Entries with no
|
|
3605
|
+
// resolvable source position sort last (pos -1) and never count as "live".
|
|
3606
|
+
const ordered = entries
|
|
3607
|
+
.map((entry, idx) => ({
|
|
3608
|
+
idx,
|
|
3609
|
+
pos: entry.sourceMessageId !== undefined ? posById.get(entry.sourceMessageId) ?? -1 : -1,
|
|
3610
|
+
}))
|
|
3611
|
+
.filter(({ idx }) => this.hasImageBlock(entries[idx].content))
|
|
3612
|
+
.sort((a, b) => b.pos - a.pos);
|
|
3613
|
+
|
|
3614
|
+
let keptImages = 0;
|
|
3615
|
+
for (const { idx, pos } of ordered) {
|
|
3616
|
+
const entry = entries[idx];
|
|
3617
|
+
const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
|
|
3618
|
+
const bucket = bucketAt(pos);
|
|
3619
|
+
entry.content = entry.content.map((block) => {
|
|
3620
|
+
if (block.type !== 'image') return block;
|
|
3621
|
+
const overCount = maxLive > 0 && keptImages >= maxLive;
|
|
3622
|
+
if (tooDeep || overCount) {
|
|
3623
|
+
// The renderer estimates an image at `tokenEstimate ?? 1600` (see
|
|
3624
|
+
// message-store/context-log). Hand that back to the bucket, less the
|
|
3625
|
+
// placeholder text that replaces it, so the stats match the output.
|
|
3626
|
+
const reclaimed =
|
|
3627
|
+
((block as { tokenEstimate?: number }).tokenEstimate ?? 1600) - placeholderTokens;
|
|
3628
|
+
if (this._rs && reclaimed > 0) this._rs[bucket].tokens -= reclaimed;
|
|
3629
|
+
return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER } as ContentBlock;
|
|
3630
|
+
}
|
|
3631
|
+
keptImages++;
|
|
3632
|
+
return block;
|
|
3633
|
+
});
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3494
3637
|
protected getRecentWindowStart(store: MessageStoreView): number {
|
|
3495
3638
|
const messages = store.getAll();
|
|
3496
3639
|
let tokens = 0;
|