@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
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { NativeFormatter } from '@animalabs/membrane';
|
|
2
2
|
import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
|
|
3
3
|
import { getSummaryParentId } from '../types/strategy.js';
|
|
4
|
-
import { splitMixedToolMessages } from '../normalize-tool-messages.js';
|
|
4
|
+
import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js';
|
|
5
5
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
6
6
|
import { dirname } from 'node:path';
|
|
7
7
|
import { Picker, OverBudgetError } from '../adaptive/picker.js';
|
|
8
8
|
import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
|
|
9
|
+
import { KvStableStrategy } from '../adaptive/strategies/kv-stable.js';
|
|
9
10
|
import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
|
|
10
11
|
import { chunkMessage, DEFAULT_CHUNKER_OPTIONS } from '../adaptive/chunker.js';
|
|
11
12
|
/**
|
|
@@ -923,9 +924,7 @@ export class AutobiographicalStrategy {
|
|
|
923
924
|
const chunk = this.chunks[chunkIndex];
|
|
924
925
|
if (!chunk || chunk.compressed)
|
|
925
926
|
return;
|
|
926
|
-
this.pendingCompression = this.
|
|
927
|
-
? this.compressChunkHierarchical(chunk, ctx)
|
|
928
|
-
: this.compressChunkLegacy(chunk, ctx);
|
|
927
|
+
this.pendingCompression = this.compressChunkHierarchical(chunk, ctx);
|
|
929
928
|
try {
|
|
930
929
|
await this.pendingCompression;
|
|
931
930
|
}
|
|
@@ -992,12 +991,13 @@ export class AutobiographicalStrategy {
|
|
|
992
991
|
}
|
|
993
992
|
select(store, log, budget) {
|
|
994
993
|
this.rebuildChunks(store);
|
|
994
|
+
// Image stripping runs inside each select path (before stats commit / cache
|
|
995
|
+
// markers), so the returned entries are already bounded — see
|
|
996
|
+
// applyImageStripping.
|
|
995
997
|
if (this.config.adaptiveResolution) {
|
|
996
998
|
return this.selectAdaptive(store, budget);
|
|
997
999
|
}
|
|
998
|
-
return this.
|
|
999
|
-
? this.selectHierarchical(store, budget)
|
|
1000
|
-
: this.selectLegacy(store, log, budget);
|
|
1000
|
+
return this.selectHierarchical(store, budget);
|
|
1001
1001
|
}
|
|
1002
1002
|
/**
|
|
1003
1003
|
* Get summary statistics for observability.
|
|
@@ -1022,7 +1022,85 @@ export class AutobiographicalStrategy {
|
|
|
1022
1022
|
* Useful for TUI / dashboards. The token sums use the strategy's own
|
|
1023
1023
|
* token estimates (which match what `select()` uses for budget math).
|
|
1024
1024
|
*/
|
|
1025
|
+
// ===========================================================================
|
|
1026
|
+
// Render-stats instrumentation — inspect, don't reconstruct.
|
|
1027
|
+
//
|
|
1028
|
+
// selectAdaptive/selectHierarchical tally each entry into `_rs` AS THEY EMIT
|
|
1029
|
+
// it (raw head/tail, raw middle the picker kept verbatim, and recall pairs
|
|
1030
|
+
// bucketed by the ancestor summary's level), using the same token numbers the
|
|
1031
|
+
// renderer uses for budget math. `getRenderStats()` returns that committed
|
|
1032
|
+
// snapshot, so it reflects what the last compile actually rendered rather than
|
|
1033
|
+
// re-deriving the full pyramid (which, under adaptive resolution, bears little
|
|
1034
|
+
// resemblance to the folded output).
|
|
1035
|
+
// ===========================================================================
|
|
1036
|
+
_rs = null;
|
|
1037
|
+
_lastRenderStats = null;
|
|
1038
|
+
/** Begin a render-stats accumulation for one select() pass. */
|
|
1039
|
+
rsBegin() {
|
|
1040
|
+
this._rs = {
|
|
1041
|
+
head: { messages: 0, tokens: 0 },
|
|
1042
|
+
tail: { messages: 0, tokens: 0 },
|
|
1043
|
+
middleRaw: { messages: 0, tokens: 0 },
|
|
1044
|
+
summaries: {
|
|
1045
|
+
l1: { count: 0, tokens: 0 },
|
|
1046
|
+
l2: { count: 0, tokens: 0 },
|
|
1047
|
+
l3: { count: 0, tokens: 0 },
|
|
1048
|
+
},
|
|
1049
|
+
pending: { chunks: 0, merges: 0 },
|
|
1050
|
+
total: { messages: 0, tokens: 0 },
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/** Tally one (or `count`) raw message(s) into a raw bucket. */
|
|
1054
|
+
rsRaw(bucket, tokens, count = 1) {
|
|
1055
|
+
const r = this._rs;
|
|
1056
|
+
if (!r)
|
|
1057
|
+
return;
|
|
1058
|
+
r[bucket].messages += count;
|
|
1059
|
+
r[bucket].tokens += tokens;
|
|
1060
|
+
}
|
|
1061
|
+
/** Tally one emitted recall pair under its ancestor's level (>=3 folds into l3). */
|
|
1062
|
+
rsSummary(level, tokens) {
|
|
1063
|
+
const r = this._rs;
|
|
1064
|
+
if (!r)
|
|
1065
|
+
return;
|
|
1066
|
+
const k = level <= 1 ? 'l1' : level === 2 ? 'l2' : 'l3';
|
|
1067
|
+
r.summaries[k].count += 1;
|
|
1068
|
+
r.summaries[k].tokens += tokens;
|
|
1069
|
+
}
|
|
1070
|
+
/** Commit the accumulated stats as the last-render snapshot. */
|
|
1071
|
+
rsEnd() {
|
|
1072
|
+
const r = this._rs;
|
|
1073
|
+
if (!r)
|
|
1074
|
+
return;
|
|
1075
|
+
r.pending = {
|
|
1076
|
+
chunks: this.chunks.filter(c => !c.compressed).length,
|
|
1077
|
+
merges: this.mergeQueue.length,
|
|
1078
|
+
};
|
|
1079
|
+
const s = r.summaries;
|
|
1080
|
+
const summaryMsgs = (s.l1.count + s.l2.count + s.l3.count) * 2; // Q/A pair each
|
|
1081
|
+
r.total = {
|
|
1082
|
+
messages: r.head.messages + r.tail.messages + r.middleRaw.messages + summaryMsgs,
|
|
1083
|
+
tokens: r.head.tokens + r.tail.tokens + r.middleRaw.tokens +
|
|
1084
|
+
s.l1.tokens + s.l2.tokens + s.l3.tokens,
|
|
1085
|
+
};
|
|
1086
|
+
this._lastRenderStats = r;
|
|
1087
|
+
this._rs = null;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Stats describing the LAST rendered context. Returns the inspected snapshot
|
|
1091
|
+
* captured during the most recent `select()`. Before any compile has run (no
|
|
1092
|
+
* snapshot yet), falls back to a reconstructed pyramid view so callers still
|
|
1093
|
+
* get a non-null shape.
|
|
1094
|
+
*/
|
|
1025
1095
|
getRenderStats(store) {
|
|
1096
|
+
return this._lastRenderStats ?? this.reconstructRenderStats(store);
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Pre-render fallback: re-derive head/tail windows + the full live pyramid.
|
|
1100
|
+
* NOTE: this is the old "reconstruct" behavior and does NOT reflect adaptive
|
|
1101
|
+
* folding — used only until the first compile populates the inspected stats.
|
|
1102
|
+
*/
|
|
1103
|
+
reconstructRenderStats(store) {
|
|
1026
1104
|
const messages = store.getAll();
|
|
1027
1105
|
const headStart = this.getHeadWindowStartIndex(store);
|
|
1028
1106
|
const headEnd = this.getHeadWindowEnd(store);
|
|
@@ -1032,131 +1110,30 @@ export class AutobiographicalStrategy {
|
|
|
1032
1110
|
const tailMsgs = messages.slice(recentStart);
|
|
1033
1111
|
const live = (level) => this.summaries.filter(s => s.level === level && !s.mergedInto);
|
|
1034
1112
|
const sumLevelTokens = (level) => live(level).reduce((acc, s) => acc + s.tokens, 0);
|
|
1113
|
+
const head = { messages: headMsgs.length, tokens: sumTokens(headMsgs) };
|
|
1114
|
+
const tail = { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) };
|
|
1115
|
+
const summaries = {
|
|
1116
|
+
l1: { count: live(1).length, tokens: sumLevelTokens(1) },
|
|
1117
|
+
l2: { count: live(2).length, tokens: sumLevelTokens(2) },
|
|
1118
|
+
l3: { count: live(3).length, tokens: sumLevelTokens(3) },
|
|
1119
|
+
};
|
|
1035
1120
|
return {
|
|
1036
|
-
head
|
|
1037
|
-
tail
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
l2: { count: live(2).length, tokens: sumLevelTokens(2) },
|
|
1041
|
-
l3: { count: live(3).length, tokens: sumLevelTokens(3) },
|
|
1042
|
-
},
|
|
1121
|
+
head,
|
|
1122
|
+
tail,
|
|
1123
|
+
middleRaw: { messages: 0, tokens: 0 },
|
|
1124
|
+
summaries,
|
|
1043
1125
|
pending: {
|
|
1044
1126
|
chunks: this.chunks.filter(c => !c.compressed).length,
|
|
1045
1127
|
merges: this.mergeQueue.length,
|
|
1046
1128
|
},
|
|
1129
|
+
total: {
|
|
1130
|
+
messages: head.messages + tail.messages
|
|
1131
|
+
+ (summaries.l1.count + summaries.l2.count + summaries.l3.count) * 2,
|
|
1132
|
+
tokens: head.tokens + tail.tokens
|
|
1133
|
+
+ summaries.l1.tokens + summaries.l2.tokens + summaries.l3.tokens,
|
|
1134
|
+
},
|
|
1047
1135
|
};
|
|
1048
1136
|
}
|
|
1049
|
-
// ============================================================================
|
|
1050
|
-
// Legacy (single-level) path
|
|
1051
|
-
// ============================================================================
|
|
1052
|
-
selectLegacy(store, _log, budget) {
|
|
1053
|
-
const entries = [];
|
|
1054
|
-
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
1055
|
-
let totalTokens = 0;
|
|
1056
|
-
const messages = store.getAll();
|
|
1057
|
-
const msgCap = this.config.maxMessageTokens;
|
|
1058
|
-
// 1. Head window: preserved verbatim as raw copies
|
|
1059
|
-
const headStart = this.getHeadWindowStartIndex(store);
|
|
1060
|
-
const headEnd = this.getHeadWindowEnd(store);
|
|
1061
|
-
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
1062
|
-
const msg = messages[i];
|
|
1063
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1064
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1065
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
1066
|
-
break;
|
|
1067
|
-
entries.push({
|
|
1068
|
-
index: entries.length,
|
|
1069
|
-
sourceMessageId: msg.id,
|
|
1070
|
-
sourceRelation: 'copy',
|
|
1071
|
-
participant: msg.participant,
|
|
1072
|
-
content,
|
|
1073
|
-
});
|
|
1074
|
-
totalTokens += tokens;
|
|
1075
|
-
}
|
|
1076
|
-
// Mark the last head entry as a cache boundary (even if budget truncated the window)
|
|
1077
|
-
if (entries.length > 0) {
|
|
1078
|
-
entries[entries.length - 1].cacheMarker = true;
|
|
1079
|
-
}
|
|
1080
|
-
// 2. Middle zone: compressed chunks as diary pairs, uncompressed as raw messages.
|
|
1081
|
-
const rawRecentStart = this.getRecentWindowStart(store);
|
|
1082
|
-
// Track which message IDs are covered by chunks
|
|
1083
|
-
const coveredByChunks = new Set();
|
|
1084
|
-
for (const chunk of this.chunks) {
|
|
1085
|
-
for (const m of chunk.messages)
|
|
1086
|
-
coveredByChunks.add(m.id);
|
|
1087
|
-
if (chunk.compressed && chunk.diary) {
|
|
1088
|
-
const contextLabel = this.config.summaryContextLabel ?? 'Here is a summary of earlier conversation context:';
|
|
1089
|
-
const summaryParticipant = this.config.summaryParticipant ?? 'Summary';
|
|
1090
|
-
const questionEntry = {
|
|
1091
|
-
index: entries.length,
|
|
1092
|
-
participant: 'Context Manager',
|
|
1093
|
-
content: [{ type: 'text', text: contextLabel }],
|
|
1094
|
-
sourceRelation: 'derived',
|
|
1095
|
-
};
|
|
1096
|
-
// Synthesised summary turns must respect maxMessageTokens just like raw
|
|
1097
|
-
// copies do — otherwise a runaway diary can starve recent messages.
|
|
1098
|
-
const answerContent = [{ type: 'text', text: chunk.diary }];
|
|
1099
|
-
const answerEntry = {
|
|
1100
|
-
index: entries.length + 1,
|
|
1101
|
-
participant: summaryParticipant,
|
|
1102
|
-
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
1103
|
-
sourceRelation: 'derived',
|
|
1104
|
-
};
|
|
1105
|
-
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
1106
|
-
this.estimateTokens(answerEntry.content);
|
|
1107
|
-
if (this.isOverBudget(totalTokens + pairTokens, maxTokens))
|
|
1108
|
-
break;
|
|
1109
|
-
entries.push(questionEntry);
|
|
1110
|
-
entries.push(answerEntry);
|
|
1111
|
-
totalTokens += pairTokens;
|
|
1112
|
-
}
|
|
1113
|
-
else {
|
|
1114
|
-
// Uncompressed: emit raw messages so they aren't lost
|
|
1115
|
-
for (const msg of chunk.messages) {
|
|
1116
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1117
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1118
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
1119
|
-
break;
|
|
1120
|
-
entries.push({
|
|
1121
|
-
index: entries.length,
|
|
1122
|
-
sourceMessageId: msg.id,
|
|
1123
|
-
sourceRelation: 'copy',
|
|
1124
|
-
participant: msg.participant,
|
|
1125
|
-
content,
|
|
1126
|
-
});
|
|
1127
|
-
totalTokens += tokens;
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
// Emit gap messages in the compressible zone not covered by any chunk.
|
|
1132
|
-
// Compressible zone: [0, headStart) ∪ [headEnd, rawRecentStart)
|
|
1133
|
-
for (let i = 0; i < rawRecentStart && i < messages.length; i++) {
|
|
1134
|
-
// Skip head window messages (already emitted verbatim above)
|
|
1135
|
-
if (i >= headStart && i < headEnd)
|
|
1136
|
-
continue;
|
|
1137
|
-
if (coveredByChunks.has(messages[i].id))
|
|
1138
|
-
continue;
|
|
1139
|
-
const msg = messages[i];
|
|
1140
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1141
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1142
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
1143
|
-
break;
|
|
1144
|
-
entries.push({
|
|
1145
|
-
index: entries.length,
|
|
1146
|
-
sourceMessageId: msg.id,
|
|
1147
|
-
sourceRelation: 'copy',
|
|
1148
|
-
participant: msg.participant,
|
|
1149
|
-
content,
|
|
1150
|
-
});
|
|
1151
|
-
totalTokens += tokens;
|
|
1152
|
-
}
|
|
1153
|
-
// 3. Recent uncompressed messages (skip those already in head window)
|
|
1154
|
-
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
1155
|
-
this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
|
|
1156
|
-
this.trimOrphanedToolUse(entries);
|
|
1157
|
-
this.pruneToolEntries(entries);
|
|
1158
|
-
return entries;
|
|
1159
|
-
}
|
|
1160
1137
|
/**
|
|
1161
1138
|
* Emit recent-window messages, evicting OLDEST-first when the budget is tight.
|
|
1162
1139
|
*
|
|
@@ -1170,7 +1147,7 @@ export class AutobiographicalStrategy {
|
|
|
1170
1147
|
*/
|
|
1171
1148
|
emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokensBefore) {
|
|
1172
1149
|
if (recentStart >= messages.length)
|
|
1173
|
-
return;
|
|
1150
|
+
return { messages: 0, tokens: 0 };
|
|
1174
1151
|
const accepted = [];
|
|
1175
1152
|
let acceptedTokens = 0;
|
|
1176
1153
|
for (let i = messages.length - 1; i >= recentStart; i--) {
|
|
@@ -1190,9 +1167,13 @@ export class AutobiographicalStrategy {
|
|
|
1190
1167
|
!this.hasToolUse(messages[accepted[0]])) {
|
|
1191
1168
|
accepted.shift();
|
|
1192
1169
|
}
|
|
1170
|
+
let emittedTokens = 0;
|
|
1193
1171
|
for (const i of accepted) {
|
|
1194
1172
|
const msg = messages[i];
|
|
1195
1173
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1174
|
+
const tokens = msgCap > 0
|
|
1175
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
1176
|
+
: store.estimateTokens(msg);
|
|
1196
1177
|
entries.push({
|
|
1197
1178
|
index: entries.length,
|
|
1198
1179
|
sourceMessageId: msg.id,
|
|
@@ -1200,86 +1181,9 @@ export class AutobiographicalStrategy {
|
|
|
1200
1181
|
participant: msg.participant,
|
|
1201
1182
|
content,
|
|
1202
1183
|
});
|
|
1184
|
+
emittedTokens += tokens;
|
|
1203
1185
|
}
|
|
1204
|
-
|
|
1205
|
-
async compressChunkLegacy(chunk, ctx) {
|
|
1206
|
-
if (!ctx.membrane) {
|
|
1207
|
-
throw new Error('No membrane instance for compression');
|
|
1208
|
-
}
|
|
1209
|
-
const priorContext = this.buildPriorContextLegacy(chunk, ctx);
|
|
1210
|
-
const chunkContent = this.formatChunkForCompression(chunk);
|
|
1211
|
-
const prompt = this.config.diaryUserPrompt ?? this.config.summaryUserPrompt;
|
|
1212
|
-
const systemPrompt = this.config.diarySystemPrompt ?? this.config.summarySystemPrompt;
|
|
1213
|
-
const messages = [
|
|
1214
|
-
...priorContext,
|
|
1215
|
-
{
|
|
1216
|
-
participant: 'Context Manager',
|
|
1217
|
-
content: [{ type: 'text', text: prompt.replace('{content}', chunkContent) }],
|
|
1218
|
-
},
|
|
1219
|
-
];
|
|
1220
|
-
const request = {
|
|
1221
|
-
messages: messages.map((m) => ({
|
|
1222
|
-
participant: m.participant,
|
|
1223
|
-
content: m.content,
|
|
1224
|
-
})),
|
|
1225
|
-
system: systemPrompt,
|
|
1226
|
-
config: {
|
|
1227
|
-
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1228
|
-
maxTokens: 2000,
|
|
1229
|
-
},
|
|
1230
|
-
};
|
|
1231
|
-
try {
|
|
1232
|
-
const response = await ctx.membrane.complete(request);
|
|
1233
|
-
const diaryText = response.content
|
|
1234
|
-
.filter((b) => b.type === 'text')
|
|
1235
|
-
.map((b) => b.text)
|
|
1236
|
-
.join('\n');
|
|
1237
|
-
chunk.compressed = true;
|
|
1238
|
-
chunk.diary = diaryText;
|
|
1239
|
-
this._compressionCount++;
|
|
1240
|
-
}
|
|
1241
|
-
catch (error) {
|
|
1242
|
-
console.error('Failed to compress chunk:', error);
|
|
1243
|
-
throw error;
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
buildPriorContextLegacy(chunk, ctx) {
|
|
1247
|
-
const context = [];
|
|
1248
|
-
for (const prevChunk of this.chunks) {
|
|
1249
|
-
if (prevChunk.index >= chunk.index)
|
|
1250
|
-
break;
|
|
1251
|
-
if (!prevChunk.compressed || !prevChunk.diary)
|
|
1252
|
-
continue;
|
|
1253
|
-
context.push({
|
|
1254
|
-
participant: 'Context Manager',
|
|
1255
|
-
content: [{ type: 'text', text: this.config.summaryContextLabel ?? 'Summary of earlier context:' }],
|
|
1256
|
-
});
|
|
1257
|
-
context.push({
|
|
1258
|
-
participant: this.config.summaryParticipant ?? 'Summary',
|
|
1259
|
-
content: [{ type: 'text', text: prevChunk.diary }],
|
|
1260
|
-
});
|
|
1261
|
-
}
|
|
1262
|
-
// Find the actual position of this chunk's first message in the full array
|
|
1263
|
-
const messages = ctx.messageStore.getAll();
|
|
1264
|
-
const firstMsgId = chunk.messages[0]?.id;
|
|
1265
|
-
const chunkAbsStart = firstMsgId
|
|
1266
|
-
? messages.findIndex(m => m.id === firstMsgId)
|
|
1267
|
-
: -1;
|
|
1268
|
-
if (chunkAbsStart > 0) {
|
|
1269
|
-
const precedingStart = Math.max(0, chunkAbsStart - 50);
|
|
1270
|
-
let tokens = 0;
|
|
1271
|
-
for (let i = chunkAbsStart - 1; i >= precedingStart && tokens < 15000; i--) {
|
|
1272
|
-
const msg = messages[i];
|
|
1273
|
-
if (!msg)
|
|
1274
|
-
break;
|
|
1275
|
-
tokens += ctx.messageStore.estimateTokens(msg);
|
|
1276
|
-
context.unshift({
|
|
1277
|
-
participant: msg.participant,
|
|
1278
|
-
content: msg.content,
|
|
1279
|
-
});
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
return context;
|
|
1186
|
+
return { messages: accepted.length, tokens: emittedTokens };
|
|
1283
1187
|
}
|
|
1284
1188
|
// ============================================================================
|
|
1285
1189
|
// Hierarchical (L1/L2/L3) path
|
|
@@ -1410,10 +1314,20 @@ export class AutobiographicalStrategy {
|
|
|
1410
1314
|
// raw messages reappear.
|
|
1411
1315
|
const chunkFirstId = chunk.messages[0]?.id;
|
|
1412
1316
|
if (chunkFirstId) {
|
|
1317
|
+
// Expand summary sourceIds down to leaf message IDs. An L2 in
|
|
1318
|
+
// `priorSummaries` has L1 IDs in its sourceIds, not message IDs;
|
|
1319
|
+
// a flat walk would miss every message it transitively covers.
|
|
1320
|
+
// (Bug 10 — same shape as executeMerge.)
|
|
1321
|
+
const summariesById = new Map();
|
|
1322
|
+
for (const s of this.summaries)
|
|
1323
|
+
summariesById.set(s.id, s);
|
|
1413
1324
|
const priorSummaryMessageIds = new Set();
|
|
1325
|
+
for (const s of this.summaries) {
|
|
1326
|
+
if (s.level === 1)
|
|
1327
|
+
this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
1328
|
+
}
|
|
1414
1329
|
for (const s of priorSummaries) {
|
|
1415
|
-
|
|
1416
|
-
priorSummaryMessageIds.add(id);
|
|
1330
|
+
this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
1417
1331
|
}
|
|
1418
1332
|
const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
|
|
1419
1333
|
for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
|
|
@@ -1457,6 +1371,11 @@ export class AutobiographicalStrategy {
|
|
|
1457
1371
|
const split = splitMixedToolMessages(llmMessages);
|
|
1458
1372
|
// Collapse consecutive same-participant messages for API compliance
|
|
1459
1373
|
const collapsed = this.collapseConsecutiveMessages(split);
|
|
1374
|
+
// Defense in depth against chunk boundaries that cut a tool cycle
|
|
1375
|
+
// (rebuildChunks tries to avoid this, but covers only the most common
|
|
1376
|
+
// case). The API rejects any tool_use that isn't immediately followed
|
|
1377
|
+
// by its tool_result, and any tool_result that doesn't follow a use.
|
|
1378
|
+
const cleaned = stripUnpairedToolBlocks(collapsed);
|
|
1460
1379
|
// NO system prompt. The agent's identity is established by the head
|
|
1461
1380
|
// (the actual conversation opening — user message + agent reply that
|
|
1462
1381
|
// grounded the original instance). A system prompt would (a) add a
|
|
@@ -1466,7 +1385,7 @@ export class AutobiographicalStrategy {
|
|
|
1466
1385
|
// structural one carried by the conversation itself. Anchoring
|
|
1467
1386
|
// identity by the chronicle's actual head is more honest.
|
|
1468
1387
|
const request = {
|
|
1469
|
-
messages:
|
|
1388
|
+
messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
|
|
1470
1389
|
config: {
|
|
1471
1390
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1472
1391
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
@@ -1478,6 +1397,10 @@ export class AutobiographicalStrategy {
|
|
|
1478
1397
|
let logSummaryId;
|
|
1479
1398
|
try {
|
|
1480
1399
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
1400
|
+
// Text-only on purpose: this is the SUMMARIZER's one-shot response —
|
|
1401
|
+
// its thinking/redacted_thinking blocks are scratch work, not part of
|
|
1402
|
+
// the agent's history, and signed thinking is never valid inside a
|
|
1403
|
+
// rewritten summary anyway.
|
|
1481
1404
|
const summaryText = response.content
|
|
1482
1405
|
.filter((b) => b.type === 'text')
|
|
1483
1406
|
.map(b => b.text)
|
|
@@ -1514,7 +1437,7 @@ export class AutobiographicalStrategy {
|
|
|
1514
1437
|
logCompressionCall({
|
|
1515
1438
|
operation: 'compress_l1',
|
|
1516
1439
|
system: null,
|
|
1517
|
-
messages:
|
|
1440
|
+
messages: cleaned.map((m) => ({
|
|
1518
1441
|
participant: m.participant,
|
|
1519
1442
|
// Flatten content for logging — store text only; binary content
|
|
1520
1443
|
// would bloat the log and isn't typical in compression input.
|
|
@@ -1767,10 +1690,20 @@ export class AutobiographicalStrategy {
|
|
|
1767
1690
|
// The full unmerged-frontier set covers what's "compressed somewhere"
|
|
1768
1691
|
// for the raw-middle dedup below — a budget-dropped recall pair
|
|
1769
1692
|
// doesn't make its underlying raw messages reappear.
|
|
1693
|
+
//
|
|
1694
|
+
// Critical: `sourceIds` on an L2+ summary points at L1 IDs, not raw
|
|
1695
|
+
// message IDs. The dedup happens against raw message IDs, so we must
|
|
1696
|
+
// recursively expand each summary down to its leaf message IDs.
|
|
1697
|
+
// Without this, every message under any L2 leaks back in as raw text
|
|
1698
|
+
// (Bug 10: 525-message merge requests on a 4234-msg conversation).
|
|
1699
|
+
// Also expand merged L1s as defense in depth.
|
|
1770
1700
|
const priorSummaryMessageIds = new Set();
|
|
1701
|
+
for (const s of this.summaries) {
|
|
1702
|
+
if (s.level === 1)
|
|
1703
|
+
this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
1704
|
+
}
|
|
1771
1705
|
for (const s of priorSummariesAll) {
|
|
1772
|
-
|
|
1773
|
-
priorSummaryMessageIds.add(id);
|
|
1706
|
+
this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
1774
1707
|
}
|
|
1775
1708
|
for (const s of keptPriorSummaries) {
|
|
1776
1709
|
llmMessages.push({
|
|
@@ -1871,11 +1804,12 @@ export class AutobiographicalStrategy {
|
|
|
1871
1804
|
// Same bundled-tool-cycle defense as compressChunkHierarchical.
|
|
1872
1805
|
const split = splitMixedToolMessages(llmMessages);
|
|
1873
1806
|
const collapsed = this.collapseConsecutiveMessages(split);
|
|
1807
|
+
const cleaned = stripUnpairedToolBlocks(collapsed);
|
|
1874
1808
|
// NO system prompt — identity is established by the head window
|
|
1875
1809
|
// (present at the start of llmMessages above) and by the prior
|
|
1876
1810
|
// recall pairs. Same rationale as compressChunkHierarchical.
|
|
1877
1811
|
const request = {
|
|
1878
|
-
messages:
|
|
1812
|
+
messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
|
|
1879
1813
|
config: {
|
|
1880
1814
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1881
1815
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
@@ -1887,6 +1821,7 @@ export class AutobiographicalStrategy {
|
|
|
1887
1821
|
let logNewSummaryId;
|
|
1888
1822
|
try {
|
|
1889
1823
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
1824
|
+
// Text-only on purpose: summarizer scratch thinking is not agent history
|
|
1890
1825
|
const mergedText = response.content
|
|
1891
1826
|
.filter((b) => b.type === 'text')
|
|
1892
1827
|
.map(b => b.text)
|
|
@@ -1930,7 +1865,7 @@ export class AutobiographicalStrategy {
|
|
|
1930
1865
|
logCompressionCall({
|
|
1931
1866
|
operation: `merge_l${targetLevel}`,
|
|
1932
1867
|
system: null,
|
|
1933
|
-
messages:
|
|
1868
|
+
messages: cleaned.map((m) => ({
|
|
1934
1869
|
participant: m.participant,
|
|
1935
1870
|
text: m.content
|
|
1936
1871
|
.filter((b) => b.type === 'text')
|
|
@@ -1966,6 +1901,7 @@ export class AutobiographicalStrategy {
|
|
|
1966
1901
|
* See `docs/adaptive-resolution-design.md` §3, §5.
|
|
1967
1902
|
*/
|
|
1968
1903
|
selectAdaptive(store, budget) {
|
|
1904
|
+
this.rsBegin();
|
|
1969
1905
|
const entries = [];
|
|
1970
1906
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
1971
1907
|
const messages = store.getAll();
|
|
@@ -1996,10 +1932,11 @@ export class AutobiographicalStrategy {
|
|
|
1996
1932
|
totalTokens += tokens;
|
|
1997
1933
|
headMessageIds.add(msg.id);
|
|
1998
1934
|
headTokens += tokens;
|
|
1935
|
+
this.rsRaw('head', tokens);
|
|
1999
1936
|
}
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
1937
|
+
// (Cache breakpoints are placed in one pass over the FINAL ordered entries
|
|
1938
|
+
// below — see placeCacheMarkers — capturing the stable folded prefix, not
|
|
1939
|
+
// just the head boundary.)
|
|
2003
1940
|
// Compute tail message IDs (will be emitted at end)
|
|
2004
1941
|
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
2005
1942
|
for (let i = effectiveRecentStart; i < messages.length; i++) {
|
|
@@ -2094,8 +2031,7 @@ export class AutobiographicalStrategy {
|
|
|
2094
2031
|
};
|
|
2095
2032
|
const headSetForPicker = new Set(headMessageIds);
|
|
2096
2033
|
const tailSetForPicker = new Set(tailMessageIds);
|
|
2097
|
-
const
|
|
2098
|
-
const result = picker.run({
|
|
2034
|
+
const pickerInputs = {
|
|
2099
2035
|
chunks: pickerChunks,
|
|
2100
2036
|
summaries: summariesMap,
|
|
2101
2037
|
recallPairTokens,
|
|
@@ -2103,7 +2039,9 @@ export class AutobiographicalStrategy {
|
|
|
2103
2039
|
tailChunkIds: tailSetForPicker,
|
|
2104
2040
|
headTokens,
|
|
2105
2041
|
tailTokens,
|
|
2106
|
-
}
|
|
2042
|
+
};
|
|
2043
|
+
const picker = this.buildPicker(pickerInputs);
|
|
2044
|
+
const result = picker.run(pickerInputs, foldingBudget);
|
|
2107
2045
|
// Commit the new resolutions back to strategy state for next compile.
|
|
2108
2046
|
// Persist to chronicle only if anything actually changed — avoids
|
|
2109
2047
|
// unnecessary state-slot writes on no-op compiles (which is the common
|
|
@@ -2210,6 +2148,7 @@ export class AutobiographicalStrategy {
|
|
|
2210
2148
|
content,
|
|
2211
2149
|
});
|
|
2212
2150
|
totalTokens += tokens;
|
|
2151
|
+
this.rsRaw('middleRaw', tokens);
|
|
2213
2152
|
}
|
|
2214
2153
|
else {
|
|
2215
2154
|
// summary run — emit Q+A pair, dedup at the strategy level
|
|
@@ -2237,6 +2176,7 @@ export class AutobiographicalStrategy {
|
|
|
2237
2176
|
entries.push(questionEntry);
|
|
2238
2177
|
entries.push(answerEntry);
|
|
2239
2178
|
totalTokens += pairTokens;
|
|
2179
|
+
this.rsSummary(ancestor.level, pairTokens);
|
|
2240
2180
|
}
|
|
2241
2181
|
}
|
|
2242
2182
|
currentRun = null;
|
|
@@ -2310,6 +2250,7 @@ export class AutobiographicalStrategy {
|
|
|
2310
2250
|
content,
|
|
2311
2251
|
});
|
|
2312
2252
|
totalTokens += tokens;
|
|
2253
|
+
this.rsRaw('middleRaw', tokens);
|
|
2313
2254
|
i++;
|
|
2314
2255
|
}
|
|
2315
2256
|
else {
|
|
@@ -2327,6 +2268,7 @@ export class AutobiographicalStrategy {
|
|
|
2327
2268
|
content,
|
|
2328
2269
|
});
|
|
2329
2270
|
totalTokens += tokens;
|
|
2271
|
+
this.rsRaw('middleRaw', tokens);
|
|
2330
2272
|
i++;
|
|
2331
2273
|
continue;
|
|
2332
2274
|
}
|
|
@@ -2354,11 +2296,13 @@ export class AutobiographicalStrategy {
|
|
|
2354
2296
|
entries.push(questionEntry);
|
|
2355
2297
|
entries.push(answerEntry);
|
|
2356
2298
|
totalTokens += pairTokens;
|
|
2299
|
+
this.rsSummary(ancestor.level, pairTokens);
|
|
2357
2300
|
i++;
|
|
2358
2301
|
}
|
|
2359
2302
|
}
|
|
2360
2303
|
// ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
|
|
2361
|
-
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2304
|
+
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2305
|
+
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
2362
2306
|
// ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
|
|
2363
2307
|
// Both head and tail emission paths emit shards as separate ContextEntries.
|
|
2364
2308
|
// The middle path already merges consecutive same-bodyGroup raw shards into
|
|
@@ -2366,9 +2310,62 @@ export class AutobiographicalStrategy {
|
|
|
2366
2310
|
// a sharded message renders as ONE API message regardless of which region
|
|
2367
2311
|
// it falls into (preserves KV cache through region transitions).
|
|
2368
2312
|
const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
|
|
2313
|
+
this.pruneToolEntries(merged);
|
|
2369
2314
|
this.trimOrphanedToolUse(merged);
|
|
2315
|
+
// Strip stale images BEFORE placing markers and committing stats, so both
|
|
2316
|
+
// describe the post-strip context the agent actually receives.
|
|
2317
|
+
this.applyImageStripping(merged, store);
|
|
2318
|
+
// Place ≤4 cache breakpoints across the FINAL ordered entries so the
|
|
2319
|
+
// provider can reuse the stable folded prefix — not just the head. With a
|
|
2320
|
+
// single head marker the cache hit is ~2%; well-placed breakpoints take the
|
|
2321
|
+
// real strategy to ~50% (docs/kv-stable-context-control.md — marker
|
|
2322
|
+
// placement is the dominant KV lever).
|
|
2323
|
+
this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
|
|
2324
|
+
this.rsEnd();
|
|
2370
2325
|
return merged;
|
|
2371
2326
|
}
|
|
2327
|
+
/**
|
|
2328
|
+
* Place up to four `cache_control` breakpoints across the final ordered
|
|
2329
|
+
* entries: the head/system boundary, the end of the folded history (the
|
|
2330
|
+
* stable prefix that persists turn-to-turn — the most valuable), a mid-history
|
|
2331
|
+
* seam, and the very end (for pure-append reuse). Mirrors `placeMarkers` in
|
|
2332
|
+
* the adaptive layer but operates on emitted entries. Idempotent; clears any
|
|
2333
|
+
* pre-existing markers first.
|
|
2334
|
+
*/
|
|
2335
|
+
placeCacheMarkers(entries, headMessageIds, tailMessageIds) {
|
|
2336
|
+
for (const e of entries)
|
|
2337
|
+
if (e.cacheMarker)
|
|
2338
|
+
e.cacheMarker = false;
|
|
2339
|
+
const n = entries.length;
|
|
2340
|
+
if (n === 0)
|
|
2341
|
+
return;
|
|
2342
|
+
let lastHead = -1;
|
|
2343
|
+
let firstTail = n;
|
|
2344
|
+
for (let i = 0; i < n; i++) {
|
|
2345
|
+
const sid = entries[i].sourceMessageId;
|
|
2346
|
+
if (sid && headMessageIds.has(sid))
|
|
2347
|
+
lastHead = i;
|
|
2348
|
+
}
|
|
2349
|
+
for (let i = 0; i < n; i++) {
|
|
2350
|
+
const sid = entries[i].sourceMessageId;
|
|
2351
|
+
if (sid && tailMessageIds.has(sid)) {
|
|
2352
|
+
firstTail = i;
|
|
2353
|
+
break;
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
const historyEnd = firstTail - 1; // last middle (folded-history) entry
|
|
2357
|
+
const marks = new Set();
|
|
2358
|
+
if (lastHead >= 0)
|
|
2359
|
+
marks.add(lastHead); // system / head block
|
|
2360
|
+
if (historyEnd > lastHead)
|
|
2361
|
+
marks.add(historyEnd); // stable folded prefix (the big one)
|
|
2362
|
+
if (historyEnd - lastHead > 2)
|
|
2363
|
+
marks.add(lastHead + Math.floor((historyEnd - lastHead) / 2)); // mid-history
|
|
2364
|
+
marks.add(n - 1); // end → pure-append reuse
|
|
2365
|
+
for (const idx of marks)
|
|
2366
|
+
if (idx >= 0 && idx < n)
|
|
2367
|
+
entries[idx].cacheMarker = true;
|
|
2368
|
+
}
|
|
2372
2369
|
/**
|
|
2373
2370
|
* Walk an entries array; for every run of consecutive entries that
|
|
2374
2371
|
* (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
|
|
@@ -2457,6 +2454,20 @@ export class AutobiographicalStrategy {
|
|
|
2457
2454
|
this._adaptivePicker = new Picker(strategy);
|
|
2458
2455
|
return this._adaptivePicker;
|
|
2459
2456
|
}
|
|
2457
|
+
/**
|
|
2458
|
+
* Build the picker for this compile. Instance folding strategies (kv-stable)
|
|
2459
|
+
* need the per-compile `PickerInputs` at construction, so they're built fresh
|
|
2460
|
+
* here; stateless ones (flat-profile / oldest-first) reuse the memoized picker.
|
|
2461
|
+
*/
|
|
2462
|
+
buildPicker(inputs) {
|
|
2463
|
+
if (this.config.foldingStrategy === 'kv-stable') {
|
|
2464
|
+
return new Picker(new KvStableStrategy(inputs, {
|
|
2465
|
+
reachTokens: this.config.kvStableReachTokens,
|
|
2466
|
+
mergeThreshold: this.config.mergeThreshold,
|
|
2467
|
+
}));
|
|
2468
|
+
}
|
|
2469
|
+
return this.getAdaptivePicker();
|
|
2470
|
+
}
|
|
2460
2471
|
/**
|
|
2461
2472
|
* Walk the summary tree to find the L_k ancestor of a message.
|
|
2462
2473
|
* Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
|
|
@@ -2483,6 +2494,45 @@ export class AutobiographicalStrategy {
|
|
|
2483
2494
|
return null;
|
|
2484
2495
|
return current;
|
|
2485
2496
|
}
|
|
2497
|
+
/**
|
|
2498
|
+
* Recursively expand a summary's `sourceIds` down to the leaf message IDs
|
|
2499
|
+
* it covers, adding each leaf into `out`.
|
|
2500
|
+
*
|
|
2501
|
+
* Required because `SummaryEntry.sourceIds` are level-relative: an L1's
|
|
2502
|
+
* sourceIds are raw message IDs (sourceLevel=0), but an L2's sourceIds
|
|
2503
|
+
* are L1 IDs (sourceLevel=1), and so on. Any dedup that walks `sourceIds`
|
|
2504
|
+
* directly only works for L1s — once L2+ summaries enter the picture
|
|
2505
|
+
* (which happens as soon as `mergeThreshold` L1s accumulate during
|
|
2506
|
+
* interleaved compression+merge ticks), the dedup silently fails and
|
|
2507
|
+
* already-summarized messages leak back into the request as raw text.
|
|
2508
|
+
* That's how Bug 10 produced 200k+ token merge prompts on a 4234-msg
|
|
2509
|
+
* import.
|
|
2510
|
+
*
|
|
2511
|
+
* Callers should also expand merged L1s (not just the unmerged frontier)
|
|
2512
|
+
* as defense in depth — a stale `mergedInto` pointer or a partially
|
|
2513
|
+
* applied merge shouldn't surface raw messages.
|
|
2514
|
+
*
|
|
2515
|
+
* `visited` guards against pathological cycles in the summary graph (a
|
|
2516
|
+
* corrupted store or a future merge regression that lets a summary
|
|
2517
|
+
* reference itself). The hierarchy is a DAG by construction, but a
|
|
2518
|
+
* stack overflow during compression — exactly when the safety net is
|
|
2519
|
+
* supposed to save the session — is too steep a price for trusting that.
|
|
2520
|
+
*/
|
|
2521
|
+
expandSummaryToLeafMessageIds(summary, summariesById, out, visited = new Set()) {
|
|
2522
|
+
if (visited.has(summary.id))
|
|
2523
|
+
return;
|
|
2524
|
+
visited.add(summary.id);
|
|
2525
|
+
if (summary.sourceLevel === 0) {
|
|
2526
|
+
for (const id of summary.sourceIds)
|
|
2527
|
+
out.add(id);
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
for (const childId of summary.sourceIds) {
|
|
2531
|
+
const child = summariesById.get(childId);
|
|
2532
|
+
if (child)
|
|
2533
|
+
this.expandSummaryToLeafMessageIds(child, summariesById, out, visited);
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2486
2536
|
// ============================================================================
|
|
2487
2537
|
// Hierarchical (threshold-driven) path
|
|
2488
2538
|
// ============================================================================
|
|
@@ -2491,6 +2541,7 @@ export class AutobiographicalStrategy {
|
|
|
2491
2541
|
* Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
|
|
2492
2542
|
*/
|
|
2493
2543
|
selectHierarchical(store, budget) {
|
|
2544
|
+
this.rsBegin();
|
|
2494
2545
|
const entries = [];
|
|
2495
2546
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
2496
2547
|
const messages = store.getAll();
|
|
@@ -2513,6 +2564,7 @@ export class AutobiographicalStrategy {
|
|
|
2513
2564
|
content,
|
|
2514
2565
|
});
|
|
2515
2566
|
totalTokens += tokens;
|
|
2567
|
+
this.rsRaw('head', tokens);
|
|
2516
2568
|
}
|
|
2517
2569
|
// Mark the last head entry as a cache boundary (even if budget truncated the window)
|
|
2518
2570
|
if (entries.length > 0) {
|
|
@@ -2662,6 +2714,7 @@ export class AutobiographicalStrategy {
|
|
|
2662
2714
|
entries.push(questionEntry);
|
|
2663
2715
|
entries.push(answerEntry);
|
|
2664
2716
|
totalTokens += pairTokens;
|
|
2717
|
+
this.rsSummary(summary.level, pairTokens);
|
|
2665
2718
|
}
|
|
2666
2719
|
else {
|
|
2667
2720
|
const msg = item.msg;
|
|
@@ -2679,6 +2732,7 @@ export class AutobiographicalStrategy {
|
|
|
2679
2732
|
content,
|
|
2680
2733
|
});
|
|
2681
2734
|
totalTokens += tokens;
|
|
2735
|
+
this.rsRaw('middleRaw', tokens);
|
|
2682
2736
|
}
|
|
2683
2737
|
}
|
|
2684
2738
|
}
|
|
@@ -2710,6 +2764,8 @@ export class AutobiographicalStrategy {
|
|
|
2710
2764
|
entries.push(questionEntry);
|
|
2711
2765
|
entries.push(answerEntry);
|
|
2712
2766
|
totalTokens += pairTokens;
|
|
2767
|
+
for (const s of selectedSummaries)
|
|
2768
|
+
this.rsSummary(s.level, s.tokens);
|
|
2713
2769
|
}
|
|
2714
2770
|
// Sort by position so uncompressed-middle messages and pins both
|
|
2715
2771
|
// appear in their chronological place after the combined recall pair.
|
|
@@ -2729,6 +2785,7 @@ export class AutobiographicalStrategy {
|
|
|
2729
2785
|
content,
|
|
2730
2786
|
});
|
|
2731
2787
|
totalTokens += tokens;
|
|
2788
|
+
this.rsRaw('middleRaw', tokens);
|
|
2732
2789
|
}
|
|
2733
2790
|
}
|
|
2734
2791
|
}
|
|
@@ -2737,9 +2794,14 @@ export class AutobiographicalStrategy {
|
|
|
2737
2794
|
// budget, the latest messages (the ones the agent actually needs to act
|
|
2738
2795
|
// on) are preserved and the oldest recent-window messages are dropped.
|
|
2739
2796
|
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
2740
|
-
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2797
|
+
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2798
|
+
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
2741
2799
|
this.trimOrphanedToolUse(entries);
|
|
2742
2800
|
this.pruneToolEntries(entries);
|
|
2801
|
+
// Strip stale images before committing stats so RenderStats.total reflects
|
|
2802
|
+
// the post-strip context (this path places no cache markers).
|
|
2803
|
+
this.applyImageStripping(entries, store);
|
|
2804
|
+
this.rsEnd();
|
|
2743
2805
|
return entries;
|
|
2744
2806
|
}
|
|
2745
2807
|
// ============================================================================
|
|
@@ -2960,6 +3022,7 @@ export class AutobiographicalStrategy {
|
|
|
2960
3022
|
},
|
|
2961
3023
|
};
|
|
2962
3024
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
3025
|
+
// Text-only on purpose: summarizer scratch thinking is not agent history
|
|
2963
3026
|
return response.content
|
|
2964
3027
|
.filter((b) => b.type === 'text')
|
|
2965
3028
|
.map(b => b.text)
|
|
@@ -3036,6 +3099,16 @@ export class AutobiographicalStrategy {
|
|
|
3036
3099
|
currentTokens += msgTokens;
|
|
3037
3100
|
const shouldClose = currentTokens >= this.config.targetChunkTokens &&
|
|
3038
3101
|
currentChunk.length >= 4;
|
|
3102
|
+
// Don't close a chunk on a message containing a tool_use block —
|
|
3103
|
+
// the matching tool_result lives in the immediately-following user
|
|
3104
|
+
// message, and the Anthropic API rejects a request where a tool_use
|
|
3105
|
+
// isn't immediately followed by its tool_result. Defer the close
|
|
3106
|
+
// by one iteration so the result rides along in the same chunk.
|
|
3107
|
+
// The stripUnpairedToolBlocks runtime pass is a safety net for the
|
|
3108
|
+
// rare case where a tool_use is the very last message in the store.
|
|
3109
|
+
if (shouldClose && this.lastMessageContainsToolUse(currentChunk)) {
|
|
3110
|
+
continue;
|
|
3111
|
+
}
|
|
3039
3112
|
if (shouldClose) {
|
|
3040
3113
|
const chunk = this.createChunk(this.chunks.length, chunkFilteredStart, i + 1, currentChunk, currentTokens, existingCompressed);
|
|
3041
3114
|
this.chunks.push(chunk);
|
|
@@ -3055,6 +3128,19 @@ export class AutobiographicalStrategy {
|
|
|
3055
3128
|
}
|
|
3056
3129
|
}
|
|
3057
3130
|
}
|
|
3131
|
+
/**
|
|
3132
|
+
* Returns true if the last message in the chunk-in-progress contains a
|
|
3133
|
+
* `tool_use` block. Used by `rebuildChunks` to defer chunk closure until
|
|
3134
|
+
* the matching `tool_result` (in the immediately-following user message)
|
|
3135
|
+
* is pulled into the same chunk. See `stripUnpairedToolBlocks` for the
|
|
3136
|
+
* runtime safety net.
|
|
3137
|
+
*/
|
|
3138
|
+
lastMessageContainsToolUse(chunk) {
|
|
3139
|
+
const last = chunk[chunk.length - 1];
|
|
3140
|
+
if (!last)
|
|
3141
|
+
return false;
|
|
3142
|
+
return last.content.some((b) => b.type === 'tool_use');
|
|
3143
|
+
}
|
|
3058
3144
|
createChunk(index, startIndex, endIndex, messages, tokens, existingCompressed) {
|
|
3059
3145
|
const chunk = {
|
|
3060
3146
|
index,
|
|
@@ -3068,7 +3154,6 @@ export class AutobiographicalStrategy {
|
|
|
3068
3154
|
const existing = existingCompressed.get(key);
|
|
3069
3155
|
if (existing) {
|
|
3070
3156
|
chunk.compressed = true;
|
|
3071
|
-
chunk.diary = existing.diary;
|
|
3072
3157
|
chunk.summaryId = existing.summaryId;
|
|
3073
3158
|
}
|
|
3074
3159
|
// In hierarchical mode, also check if a summary exists for this chunk
|
|
@@ -3084,6 +3169,97 @@ export class AutobiographicalStrategy {
|
|
|
3084
3169
|
chunkKey(chunk) {
|
|
3085
3170
|
return chunk.messages.map((m) => m.id).join(':');
|
|
3086
3171
|
}
|
|
3172
|
+
/** True if any content block is a live image. */
|
|
3173
|
+
hasImageBlock(content) {
|
|
3174
|
+
return content.some((b) => b.type === 'image');
|
|
3175
|
+
}
|
|
3176
|
+
/** Message index marking the image-strip depth boundary: walks newest→oldest
|
|
3177
|
+
* summing the same per-message estimate as getRecentWindowStart, and returns
|
|
3178
|
+
* the index of the first message still within `depthTokens`. Messages before
|
|
3179
|
+
* this index have their images stripped to placeholders. */
|
|
3180
|
+
getImageStripStart(store, depthTokens) {
|
|
3181
|
+
const messages = store.getAll();
|
|
3182
|
+
let tokens = 0;
|
|
3183
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
3184
|
+
tokens += store.estimateTokens(messages[i]);
|
|
3185
|
+
if (tokens > depthTokens)
|
|
3186
|
+
return i + 1;
|
|
3187
|
+
}
|
|
3188
|
+
return 0;
|
|
3189
|
+
}
|
|
3190
|
+
/** Text substituted for an image block once it leaves the live-image window. */
|
|
3191
|
+
static IMAGE_PLACEHOLDER = '[image dropped from live context]';
|
|
3192
|
+
/** Post-pass over compiled entries: replace image blocks with a text
|
|
3193
|
+
* placeholder once they fall outside the live-image window — either deeper
|
|
3194
|
+
* than `imageStripDepthTokens` from the newest message, or beyond the
|
|
3195
|
+
* `maxLiveImages` most-recent images (counted newest-first). Summaries are
|
|
3196
|
+
* already text, so they're naturally unaffected. The adjacent
|
|
3197
|
+
* "[image attachment: <name>]" text added at ingest preserves the filename,
|
|
3198
|
+
* so the placeholder itself stays terse. Reduces tokens, so it never pushes
|
|
3199
|
+
* a compiled context back over budget.
|
|
3200
|
+
*
|
|
3201
|
+
* Runs INSIDE each select path, *before* `rsEnd()` and `placeCacheMarkers`,
|
|
3202
|
+
* so the committed render stats (and the cache breakpoints) describe the
|
|
3203
|
+
* post-strip context. As it strips, it decrements the matching raw bucket of
|
|
3204
|
+
* the in-progress render stats by the reclaimed tokens, keeping
|
|
3205
|
+
* `RenderStats.total` equal to the real rendered size. */
|
|
3206
|
+
applyImageStripping(entries, store) {
|
|
3207
|
+
const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
|
|
3208
|
+
const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
|
|
3209
|
+
if (maxLive === 0 && depthTokens === 0)
|
|
3210
|
+
return; // policy disabled
|
|
3211
|
+
const messages = store.getAll();
|
|
3212
|
+
const posById = new Map();
|
|
3213
|
+
for (let i = 0; i < messages.length; i++)
|
|
3214
|
+
posById.set(messages[i].id, i);
|
|
3215
|
+
const stripStart = depthTokens > 0 ? this.getImageStripStart(store, depthTokens) : 0;
|
|
3216
|
+
// Same region windows select() bucketed by, so a stripped image's reclaimed
|
|
3217
|
+
// tokens come back out of the bucket it was originally tallied into.
|
|
3218
|
+
const headStart = this.getHeadWindowStartIndex(store);
|
|
3219
|
+
const headEnd = this.getHeadWindowEnd(store);
|
|
3220
|
+
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
3221
|
+
const bucketAt = (pos) => {
|
|
3222
|
+
if (pos < 0)
|
|
3223
|
+
return 'middleRaw'; // no resolvable region — keep total == Σbuckets
|
|
3224
|
+
if (pos >= headStart && pos < headEnd)
|
|
3225
|
+
return 'head';
|
|
3226
|
+
if (pos >= recentStart)
|
|
3227
|
+
return 'tail';
|
|
3228
|
+
return 'middleRaw';
|
|
3229
|
+
};
|
|
3230
|
+
const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
|
|
3231
|
+
// Image-bearing entries, newest-first by source position. Entries with no
|
|
3232
|
+
// resolvable source position sort last (pos -1) and never count as "live".
|
|
3233
|
+
const ordered = entries
|
|
3234
|
+
.map((entry, idx) => ({
|
|
3235
|
+
idx,
|
|
3236
|
+
pos: entry.sourceMessageId !== undefined ? posById.get(entry.sourceMessageId) ?? -1 : -1,
|
|
3237
|
+
}))
|
|
3238
|
+
.filter(({ idx }) => this.hasImageBlock(entries[idx].content))
|
|
3239
|
+
.sort((a, b) => b.pos - a.pos);
|
|
3240
|
+
let keptImages = 0;
|
|
3241
|
+
for (const { idx, pos } of ordered) {
|
|
3242
|
+
const entry = entries[idx];
|
|
3243
|
+
const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
|
|
3244
|
+
const bucket = bucketAt(pos);
|
|
3245
|
+
entry.content = entry.content.map((block) => {
|
|
3246
|
+
if (block.type !== 'image')
|
|
3247
|
+
return block;
|
|
3248
|
+
const overCount = maxLive > 0 && keptImages >= maxLive;
|
|
3249
|
+
if (tooDeep || overCount) {
|
|
3250
|
+
// The renderer estimates an image at `tokenEstimate ?? 1600` (see
|
|
3251
|
+
// message-store/context-log). Hand that back to the bucket, less the
|
|
3252
|
+
// placeholder text that replaces it, so the stats match the output.
|
|
3253
|
+
const reclaimed = (block.tokenEstimate ?? 1600) - placeholderTokens;
|
|
3254
|
+
if (this._rs && reclaimed > 0)
|
|
3255
|
+
this._rs[bucket].tokens -= reclaimed;
|
|
3256
|
+
return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER };
|
|
3257
|
+
}
|
|
3258
|
+
keptImages++;
|
|
3259
|
+
return block;
|
|
3260
|
+
});
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3087
3263
|
getRecentWindowStart(store) {
|
|
3088
3264
|
const messages = store.getAll();
|
|
3089
3265
|
let tokens = 0;
|