@animalabs/context-manager 0.3.0 → 0.5.0
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/chunker.d.ts +70 -0
- package/dist/src/adaptive/chunker.d.ts.map +1 -0
- package/dist/src/adaptive/chunker.js +233 -0
- package/dist/src/adaptive/chunker.js.map +1 -0
- package/dist/src/adaptive/folding-strategy.d.ts +118 -0
- package/dist/src/adaptive/folding-strategy.d.ts.map +1 -0
- package/dist/src/adaptive/folding-strategy.js +12 -0
- package/dist/src/adaptive/folding-strategy.js.map +1 -0
- package/dist/src/adaptive/index.d.ts +17 -0
- package/dist/src/adaptive/index.d.ts.map +1 -0
- package/dist/src/adaptive/index.js +23 -0
- package/dist/src/adaptive/index.js.map +1 -0
- package/dist/src/adaptive/picker.d.ts +103 -0
- package/dist/src/adaptive/picker.d.ts.map +1 -0
- package/dist/src/adaptive/picker.js +357 -0
- package/dist/src/adaptive/picker.js.map +1 -0
- package/dist/src/adaptive/render.d.ts +39 -0
- package/dist/src/adaptive/render.d.ts.map +1 -0
- package/dist/src/adaptive/render.js +104 -0
- package/dist/src/adaptive/render.js.map +1 -0
- package/dist/src/adaptive/strategies/flat-profile.d.ts +19 -0
- package/dist/src/adaptive/strategies/flat-profile.d.ts.map +1 -0
- package/dist/src/adaptive/strategies/flat-profile.js +86 -0
- package/dist/src/adaptive/strategies/flat-profile.js.map +1 -0
- package/dist/src/adaptive/strategies/oldest-first.d.ts +19 -0
- package/dist/src/adaptive/strategies/oldest-first.d.ts.map +1 -0
- package/dist/src/adaptive/strategies/oldest-first.js +39 -0
- package/dist/src/adaptive/strategies/oldest-first.js.map +1 -0
- package/dist/src/context-manager.d.ts +74 -2
- package/dist/src/context-manager.d.ts.map +1 -1
- package/dist/src/context-manager.js +150 -10
- package/dist/src/context-manager.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.map +1 -1
- package/dist/src/message-store.d.ts +36 -1
- package/dist/src/message-store.d.ts.map +1 -1
- package/dist/src/message-store.js +134 -8
- package/dist/src/message-store.js.map +1 -1
- package/dist/src/strategies/autobiographical.d.ts +422 -5
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +2248 -98
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/strategies/index.d.ts +1 -1
- package/dist/src/strategies/index.d.ts.map +1 -1
- package/dist/src/strategies/index.js.map +1 -1
- package/dist/src/types/index.d.ts +2 -2
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/types/index.js +1 -1
- package/dist/src/types/index.js.map +1 -1
- package/dist/src/types/message.d.ts +38 -0
- package/dist/src/types/message.d.ts.map +1 -1
- package/dist/src/types/strategy.d.ts +302 -10
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js +28 -0
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/adaptive/branching.test.d.ts +20 -0
- package/dist/test/adaptive/branching.test.d.ts.map +1 -0
- package/dist/test/adaptive/branching.test.js +176 -0
- package/dist/test/adaptive/branching.test.js.map +1 -0
- package/dist/test/adaptive/chunker.test.d.ts +2 -0
- package/dist/test/adaptive/chunker.test.d.ts.map +1 -0
- package/dist/test/adaptive/chunker.test.js +120 -0
- package/dist/test/adaptive/chunker.test.js.map +1 -0
- package/dist/test/adaptive/deep-levels.test.d.ts +10 -0
- package/dist/test/adaptive/deep-levels.test.d.ts.map +1 -0
- package/dist/test/adaptive/deep-levels.test.js +167 -0
- package/dist/test/adaptive/deep-levels.test.js.map +1 -0
- package/dist/test/adaptive/doc-plus-chat.test.d.ts +10 -0
- package/dist/test/adaptive/doc-plus-chat.test.d.ts.map +1 -0
- package/dist/test/adaptive/doc-plus-chat.test.js +194 -0
- package/dist/test/adaptive/doc-plus-chat.test.js.map +1 -0
- package/dist/test/adaptive/hard-fail.test.d.ts +9 -0
- package/dist/test/adaptive/hard-fail.test.d.ts.map +1 -0
- package/dist/test/adaptive/hard-fail.test.js +154 -0
- package/dist/test/adaptive/hard-fail.test.js.map +1 -0
- package/dist/test/adaptive/harness.d.ts +109 -0
- package/dist/test/adaptive/harness.d.ts.map +1 -0
- package/dist/test/adaptive/harness.js +263 -0
- package/dist/test/adaptive/harness.js.map +1 -0
- package/dist/test/adaptive/ingestion.test.d.ts +13 -0
- package/dist/test/adaptive/ingestion.test.d.ts.map +1 -0
- package/dist/test/adaptive/ingestion.test.js +306 -0
- package/dist/test/adaptive/ingestion.test.js.map +1 -0
- package/dist/test/adaptive/integration.test.d.ts +12 -0
- package/dist/test/adaptive/integration.test.d.ts.map +1 -0
- package/dist/test/adaptive/integration.test.js +247 -0
- package/dist/test/adaptive/integration.test.js.map +1 -0
- package/dist/test/adaptive/long-chronicle.test.d.ts +13 -0
- package/dist/test/adaptive/long-chronicle.test.d.ts.map +1 -0
- package/dist/test/adaptive/long-chronicle.test.js +186 -0
- package/dist/test/adaptive/long-chronicle.test.js.map +1 -0
- package/dist/test/adaptive/persistence.test.d.ts +11 -0
- package/dist/test/adaptive/persistence.test.d.ts.map +1 -0
- package/dist/test/adaptive/persistence.test.js +200 -0
- package/dist/test/adaptive/persistence.test.js.map +1 -0
- package/dist/test/adaptive/picker.test.d.ts +2 -0
- package/dist/test/adaptive/picker.test.d.ts.map +1 -0
- package/dist/test/adaptive/picker.test.js +333 -0
- package/dist/test/adaptive/picker.test.js.map +1 -0
- package/dist/test/adaptive/render.test.d.ts +2 -0
- package/dist/test/adaptive/render.test.d.ts.map +1 -0
- package/dist/test/adaptive/render.test.js +109 -0
- package/dist/test/adaptive/render.test.js.map +1 -0
- package/dist/test/adaptive/shard-immutability.test.d.ts +14 -0
- package/dist/test/adaptive/shard-immutability.test.d.ts.map +1 -0
- package/dist/test/adaptive/shard-immutability.test.js +184 -0
- package/dist/test/adaptive/shard-immutability.test.js.map +1 -0
- package/dist/test/adaptive/strategy-integration.test.d.ts +9 -0
- package/dist/test/adaptive/strategy-integration.test.d.ts.map +1 -0
- package/dist/test/adaptive/strategy-integration.test.js +293 -0
- package/dist/test/adaptive/strategy-integration.test.js.map +1 -0
- package/dist/test/message-store-sequence.test.d.ts +25 -0
- package/dist/test/message-store-sequence.test.d.ts.map +1 -0
- package/dist/test/message-store-sequence.test.js +157 -0
- package/dist/test/message-store-sequence.test.js.map +1 -0
- package/dist/test/non-blocking-compile.test.d.ts +22 -0
- package/dist/test/non-blocking-compile.test.d.ts.map +1 -0
- package/dist/test/non-blocking-compile.test.js +114 -0
- package/dist/test/non-blocking-compile.test.js.map +1 -0
- package/dist/test/pins.test.d.ts +10 -0
- package/dist/test/pins.test.d.ts.map +1 -0
- package/dist/test/pins.test.js +164 -0
- package/dist/test/pins.test.js.map +1 -0
- package/dist/test/recall-positioning.test.d.ts +16 -0
- package/dist/test/recall-positioning.test.d.ts.map +1 -0
- package/dist/test/recall-positioning.test.js +181 -0
- package/dist/test/recall-positioning.test.js.map +1 -0
- package/dist/test/search.test.d.ts +10 -0
- package/dist/test/search.test.d.ts.map +1 -0
- package/dist/test/search.test.js +141 -0
- package/dist/test/search.test.js.map +1 -0
- package/dist/test/speculation-cap.test.d.ts +17 -0
- package/dist/test/speculation-cap.test.d.ts.map +1 -0
- package/dist/test/speculation-cap.test.js +157 -0
- package/dist/test/speculation-cap.test.js.map +1 -0
- package/dist/test/strategy-persistence.test.d.ts +15 -0
- package/dist/test/strategy-persistence.test.d.ts.map +1 -0
- package/dist/test/strategy-persistence.test.js +244 -0
- package/dist/test/strategy-persistence.test.js.map +1 -0
- package/dist/test/tool-pruning.test.d.ts +10 -0
- package/dist/test/tool-pruning.test.d.ts.map +1 -0
- package/dist/test/tool-pruning.test.js +140 -0
- package/dist/test/tool-pruning.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/adaptive/chunker.ts +323 -0
- package/src/adaptive/folding-strategy.ts +129 -0
- package/src/adaptive/index.ts +51 -0
- package/src/adaptive/picker.ts +442 -0
- package/src/adaptive/render.ts +115 -0
- package/src/adaptive/strategies/flat-profile.ts +96 -0
- package/src/adaptive/strategies/oldest-first.ts +48 -0
- package/src/context-manager.ts +174 -10
- package/src/index.ts +1 -1
- package/src/message-store.ts +147 -8
- package/src/strategies/autobiographical.ts +2468 -166
- package/src/strategies/index.ts +1 -1
- package/src/types/index.ts +14 -1
- package/src/types/message.ts +43 -0
- package/src/types/strategy.ts +332 -10
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic chunker for messages that exceed `chunkThreshold` tokens.
|
|
3
|
+
*
|
|
4
|
+
* Splits a message body into shards such that concatenating the shards in
|
|
5
|
+
* order reproduces the original body byte-for-byte. Each shard is identified
|
|
6
|
+
* by a stable `sourceHash` so re-ingesting the same content produces
|
|
7
|
+
* identical shard boundaries (and reuses any existing L_k summaries via the
|
|
8
|
+
* archive's hash-keyed lookup).
|
|
9
|
+
*
|
|
10
|
+
* Strategy:
|
|
11
|
+
* 1. Structural pass — split at markdown headings, code-fence boundaries,
|
|
12
|
+
* blank-line paragraph breaks. Prefer larger structural units when they
|
|
13
|
+
* fit under `chunkSize`.
|
|
14
|
+
* 2. Token-bucket fallback — when a structural unit alone exceeds
|
|
15
|
+
* `chunkSize`, split it at paragraph or line boundaries until each
|
|
16
|
+
* resulting piece fits.
|
|
17
|
+
* 3. Last-resort hard split — only when no structural seam exists in the
|
|
18
|
+
* piece (e.g., one very long line). Splits at the largest UTF-8-safe
|
|
19
|
+
* position under the threshold.
|
|
20
|
+
*
|
|
21
|
+
* See `docs/adaptive-resolution-design.md` §3.6.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
export interface ChunkerOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Messages with token count strictly greater than this are chunked.
|
|
29
|
+
* Messages at or below are passed through unchanged.
|
|
30
|
+
* Default: 8192.
|
|
31
|
+
*/
|
|
32
|
+
chunkThreshold: number;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Target token count per shard. The chunker aims for shards close to
|
|
36
|
+
* but not exceeding this size. Default: 4096.
|
|
37
|
+
*/
|
|
38
|
+
chunkSize: number;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Approximate characters per token. Used for size estimation since we
|
|
42
|
+
* can't easily run a tokenizer here. Default: 4.0. The picker uses
|
|
43
|
+
* cached real token counts; the chunker only needs approximate sizing.
|
|
44
|
+
*/
|
|
45
|
+
charsPerToken: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const DEFAULT_CHUNKER_OPTIONS: ChunkerOptions = {
|
|
49
|
+
chunkThreshold: 8192,
|
|
50
|
+
chunkSize: 4096,
|
|
51
|
+
charsPerToken: 4.0,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface Shard {
|
|
55
|
+
/** Order of this shard within its bodyGroup, starting at 0. */
|
|
56
|
+
index: number;
|
|
57
|
+
/** Byte offsets into the original body. [start, end) — UTF-8 byte positions. */
|
|
58
|
+
range: { startByte: number; endByte: number };
|
|
59
|
+
/** Shard content (a substring of the original body). */
|
|
60
|
+
content: string;
|
|
61
|
+
/** SHA-256 of the shard content. */
|
|
62
|
+
sourceHash: string;
|
|
63
|
+
/** Approximate token count (chars / charsPerToken, rounded). */
|
|
64
|
+
approxTokens: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ShardingResult {
|
|
68
|
+
/** True if the message was split into multiple shards. */
|
|
69
|
+
wasSharded: boolean;
|
|
70
|
+
/** The shards. If wasSharded is false, exactly one shard containing the entire body. */
|
|
71
|
+
shards: Shard[];
|
|
72
|
+
/** The stable group id for these shards (sha256 of the full original body, prefixed). */
|
|
73
|
+
bodyGroupId: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Chunk a message body. Pure function — no side effects.
|
|
78
|
+
*/
|
|
79
|
+
export function chunkMessage(body: string, options: Partial<ChunkerOptions> = {}): ShardingResult {
|
|
80
|
+
const opts: ChunkerOptions = { ...DEFAULT_CHUNKER_OPTIONS, ...options };
|
|
81
|
+
const totalApproxTokens = Math.ceil(body.length / opts.charsPerToken);
|
|
82
|
+
const bodyHash = hashOf(body);
|
|
83
|
+
const bodyGroupId = `bg-${bodyHash.slice(0, 16)}`;
|
|
84
|
+
|
|
85
|
+
if (totalApproxTokens <= opts.chunkThreshold) {
|
|
86
|
+
return {
|
|
87
|
+
wasSharded: false,
|
|
88
|
+
bodyGroupId,
|
|
89
|
+
shards: [
|
|
90
|
+
{
|
|
91
|
+
index: 0,
|
|
92
|
+
range: { startByte: 0, endByte: byteLength(body) },
|
|
93
|
+
content: body,
|
|
94
|
+
sourceHash: bodyHash,
|
|
95
|
+
approxTokens: totalApproxTokens,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Build a list of split points — preferred seams in priority order.
|
|
102
|
+
// Each seam is a byte offset into the body. The chunker walks the body
|
|
103
|
+
// and emits shards by greedily accumulating content up to chunkSize at
|
|
104
|
+
// the highest-quality seam available.
|
|
105
|
+
const seams = findSeams(body);
|
|
106
|
+
const targetChars = opts.chunkSize * opts.charsPerToken;
|
|
107
|
+
const maxChars = opts.chunkSize * opts.charsPerToken * 1.25; // 25% overflow tolerance to land on a clean seam
|
|
108
|
+
|
|
109
|
+
const shards: Shard[] = [];
|
|
110
|
+
let cursor = 0;
|
|
111
|
+
let index = 0;
|
|
112
|
+
const bodyByteLen = byteLength(body);
|
|
113
|
+
|
|
114
|
+
while (cursor < body.length) {
|
|
115
|
+
// Find the best seam to split at, given the [cursor, cursor + maxChars] window.
|
|
116
|
+
const windowEnd = Math.min(cursor + maxChars, body.length);
|
|
117
|
+
const targetEnd = Math.min(cursor + targetChars, body.length);
|
|
118
|
+
|
|
119
|
+
let splitAt: number;
|
|
120
|
+
if (windowEnd >= body.length) {
|
|
121
|
+
splitAt = body.length;
|
|
122
|
+
} else {
|
|
123
|
+
splitAt = bestSeamIn(seams, cursor, targetEnd, windowEnd, body);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Ensure forward progress: if no seam found and we'd produce an empty
|
|
127
|
+
// shard, take a hard split at the targetEnd.
|
|
128
|
+
if (splitAt <= cursor) {
|
|
129
|
+
splitAt = safeCharBoundary(body, targetEnd);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const content = body.slice(cursor, splitAt);
|
|
133
|
+
const startByte = byteLengthOf(body, 0, cursor);
|
|
134
|
+
const endByte = startByte + byteLength(content);
|
|
135
|
+
shards.push({
|
|
136
|
+
index,
|
|
137
|
+
range: { startByte, endByte },
|
|
138
|
+
content,
|
|
139
|
+
sourceHash: hashOf(content),
|
|
140
|
+
approxTokens: Math.ceil(content.length / opts.charsPerToken),
|
|
141
|
+
});
|
|
142
|
+
cursor = splitAt;
|
|
143
|
+
index += 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Sanity: byte-faithful reassembly
|
|
147
|
+
const reassembled = shards.map((s) => s.content).join('');
|
|
148
|
+
if (reassembled !== body) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`chunker: shard reassembly mismatch (original=${body.length} chars, reassembled=${reassembled.length} chars)`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (shards[shards.length - 1].range.endByte !== bodyByteLen) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`chunker: byte-range mismatch (expected end=${bodyByteLen}, got=${shards[shards.length - 1].range.endByte})`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
wasSharded: true,
|
|
161
|
+
bodyGroupId,
|
|
162
|
+
shards,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Identify candidate split seams in the body, returned as character offsets.
|
|
168
|
+
* Each seam has a priority — higher = more preferred (split here if possible).
|
|
169
|
+
*
|
|
170
|
+
* Priorities (higher is better):
|
|
171
|
+
* 100 = markdown H1/H2 heading (after the heading line)
|
|
172
|
+
* 90 = markdown H3+ heading
|
|
173
|
+
* 80 = code-fence end (closing ```)
|
|
174
|
+
* 70 = blank line (paragraph break)
|
|
175
|
+
* 50 = single newline
|
|
176
|
+
* 30 = sentence end (period/question mark/exclamation + space)
|
|
177
|
+
* 10 = word boundary (whitespace)
|
|
178
|
+
*
|
|
179
|
+
* Returns a sorted array by offset ascending.
|
|
180
|
+
*/
|
|
181
|
+
interface Seam {
|
|
182
|
+
offset: number; // character offset (split happens BEFORE this character — actually at this offset)
|
|
183
|
+
priority: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function findSeams(body: string): Seam[] {
|
|
187
|
+
const seams: Seam[] = [];
|
|
188
|
+
|
|
189
|
+
// Markdown headings: ^(#+)\s
|
|
190
|
+
// Match at line starts.
|
|
191
|
+
const headingRe = /(?:^|\n)(#{1,6})\s[^\n]*\n/g;
|
|
192
|
+
let m: RegExpExecArray | null;
|
|
193
|
+
while ((m = headingRe.exec(body)) !== null) {
|
|
194
|
+
const hashes = m[1];
|
|
195
|
+
// Position is just before the heading's newline so the heading itself
|
|
196
|
+
// starts the next shard (cleaner visual boundary).
|
|
197
|
+
const lineStart = m.index === 0 ? 0 : m.index + 1;
|
|
198
|
+
if (lineStart === 0) continue; // can't split at the start
|
|
199
|
+
seams.push({
|
|
200
|
+
offset: lineStart,
|
|
201
|
+
priority: hashes.length <= 2 ? 100 : 90,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Code-fence boundaries.
|
|
206
|
+
const fenceRe = /\n```[^\n]*\n/g;
|
|
207
|
+
while ((m = fenceRe.exec(body)) !== null) {
|
|
208
|
+
seams.push({ offset: m.index + 1, priority: 80 });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Blank lines (paragraph breaks): \n\n
|
|
212
|
+
const blankRe = /\n\n+/g;
|
|
213
|
+
while ((m = blankRe.exec(body)) !== null) {
|
|
214
|
+
// Position is just after the last \n in the sequence, so the next
|
|
215
|
+
// shard begins at the non-blank content.
|
|
216
|
+
seams.push({ offset: m.index + m[0].length, priority: 70 });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Single newlines.
|
|
220
|
+
const nlRe = /\n/g;
|
|
221
|
+
while ((m = nlRe.exec(body)) !== null) {
|
|
222
|
+
seams.push({ offset: m.index + 1, priority: 50 });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Sentence ends.
|
|
226
|
+
const sentRe = /[.!?]\s+/g;
|
|
227
|
+
while ((m = sentRe.exec(body)) !== null) {
|
|
228
|
+
seams.push({ offset: m.index + m[0].length, priority: 30 });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Word boundaries (any whitespace).
|
|
232
|
+
const wsRe = /\s+/g;
|
|
233
|
+
while ((m = wsRe.exec(body)) !== null) {
|
|
234
|
+
seams.push({ offset: m.index + m[0].length, priority: 10 });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Deduplicate by offset, keeping highest priority.
|
|
238
|
+
const byOffset = new Map<number, number>();
|
|
239
|
+
for (const s of seams) {
|
|
240
|
+
const prev = byOffset.get(s.offset);
|
|
241
|
+
if (prev === undefined || prev < s.priority) byOffset.set(s.offset, s.priority);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return Array.from(byOffset.entries())
|
|
245
|
+
.map(([offset, priority]) => ({ offset, priority }))
|
|
246
|
+
.sort((a, b) => a.offset - b.offset);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Find the highest-priority seam in (cursor, windowEnd] that falls at or
|
|
251
|
+
* before targetEnd if possible, otherwise the closest after targetEnd up to
|
|
252
|
+
* windowEnd. Returns the chosen split offset.
|
|
253
|
+
*/
|
|
254
|
+
function bestSeamIn(
|
|
255
|
+
seams: Seam[],
|
|
256
|
+
cursor: number,
|
|
257
|
+
targetEnd: number,
|
|
258
|
+
windowEnd: number,
|
|
259
|
+
body: string
|
|
260
|
+
): number {
|
|
261
|
+
// Binary search for the first seam strictly > cursor.
|
|
262
|
+
let lo = 0;
|
|
263
|
+
let hi = seams.length;
|
|
264
|
+
while (lo < hi) {
|
|
265
|
+
const mid = (lo + hi) >>> 1;
|
|
266
|
+
if (seams[mid].offset <= cursor) lo = mid + 1;
|
|
267
|
+
else hi = mid;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let bestPreTarget: Seam | null = null;
|
|
271
|
+
let bestPostTarget: Seam | null = null;
|
|
272
|
+
|
|
273
|
+
for (let i = lo; i < seams.length; i++) {
|
|
274
|
+
const s = seams[i];
|
|
275
|
+
if (s.offset > windowEnd) break;
|
|
276
|
+
if (s.offset <= targetEnd) {
|
|
277
|
+
if (!bestPreTarget || s.priority > bestPreTarget.priority || (s.priority === bestPreTarget.priority && s.offset > bestPreTarget.offset)) {
|
|
278
|
+
bestPreTarget = s;
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
if (!bestPostTarget || s.priority > bestPostTarget.priority || (s.priority === bestPostTarget.priority && s.offset < bestPostTarget.offset)) {
|
|
282
|
+
bestPostTarget = s;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Prefer the best seam at or before target.
|
|
288
|
+
if (bestPreTarget) return bestPreTarget.offset;
|
|
289
|
+
if (bestPostTarget) return bestPostTarget.offset;
|
|
290
|
+
// No seams in window — hard split at a UTF-8-safe boundary near target.
|
|
291
|
+
return safeCharBoundary(body, targetEnd);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Return an offset ≤ target that doesn't fall inside a UTF-16 surrogate pair.
|
|
296
|
+
* The chunker operates on JS strings (UTF-16); splitting inside a surrogate
|
|
297
|
+
* pair would corrupt the output.
|
|
298
|
+
*/
|
|
299
|
+
function safeCharBoundary(body: string, target: number): number {
|
|
300
|
+
if (target >= body.length) return body.length;
|
|
301
|
+
if (target <= 0) return 0;
|
|
302
|
+
const code = body.charCodeAt(target);
|
|
303
|
+
// If we'd split before a low surrogate, back up one.
|
|
304
|
+
if (code >= 0xdc00 && code <= 0xdfff) {
|
|
305
|
+
return target - 1;
|
|
306
|
+
}
|
|
307
|
+
return target;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** UTF-8 byte length of a JS string. */
|
|
311
|
+
function byteLength(s: string): number {
|
|
312
|
+
return Buffer.byteLength(s, 'utf8');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** UTF-8 byte length of body[start..end] without allocating the slice when possible. */
|
|
316
|
+
function byteLengthOf(body: string, start: number, end: number): number {
|
|
317
|
+
if (start === 0 && end === 0) return 0;
|
|
318
|
+
return Buffer.byteLength(body.slice(start, end), 'utf8');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function hashOf(s: string): string {
|
|
322
|
+
return createHash('sha256').update(s, 'utf8').digest('hex');
|
|
323
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable folding strategy interface for the adaptive-resolution picker.
|
|
3
|
+
*
|
|
4
|
+
* The picker is a generic orchestrator; the policy (which group to raise next,
|
|
5
|
+
* whether to lower, whether to request a missing summary) lives in a strategy
|
|
6
|
+
* object. V1 ships `FlatProfileStrategy` (default, level-equalizing) and
|
|
7
|
+
* `OldestFirstStrategy` (chronological, matches the rev-1 design).
|
|
8
|
+
*
|
|
9
|
+
* See `docs/adaptive-resolution-design.md` §3.5.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { MessageId } from '../types/message.js';
|
|
13
|
+
import type { SummaryEntry } from '../types/strategy.js';
|
|
14
|
+
|
|
15
|
+
/** Identifier of a summary in the archive (e.g., "L2-15"). */
|
|
16
|
+
export type SummaryId = string;
|
|
17
|
+
|
|
18
|
+
/** Identifier of a chunk — for now equivalent to a MessageId. */
|
|
19
|
+
export type ChunkId = MessageId;
|
|
20
|
+
|
|
21
|
+
/** Half-open range of chunks, identified by message IDs. */
|
|
22
|
+
export interface ChunkRange {
|
|
23
|
+
firstChunkId: ChunkId;
|
|
24
|
+
lastChunkId: ChunkId;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A view of a single chunk for strategy decisions.
|
|
29
|
+
*
|
|
30
|
+
* Strategies should treat ChunkView as read-only. Use the picker's
|
|
31
|
+
* apply() to commit fold operations rather than mutating fields directly.
|
|
32
|
+
*/
|
|
33
|
+
export interface ChunkView {
|
|
34
|
+
id: ChunkId;
|
|
35
|
+
/** Position in source order (lower = older). */
|
|
36
|
+
sequence: number;
|
|
37
|
+
/** Approximate tokens of raw content. */
|
|
38
|
+
rawTokens: number;
|
|
39
|
+
/** Current display resolution. 0 = raw, k>0 = L_k summary. */
|
|
40
|
+
currentResolution: number;
|
|
41
|
+
/** Whether the picker is forbidden from changing currentResolution. */
|
|
42
|
+
lockedByAgent: boolean;
|
|
43
|
+
/** If this chunk is a shard of a larger logical message, the group id. */
|
|
44
|
+
bodyGroupId?: string;
|
|
45
|
+
/** True if this chunk is in the head window (kept raw). */
|
|
46
|
+
inHead: boolean;
|
|
47
|
+
/** True if this chunk is in the tail window (kept raw). */
|
|
48
|
+
inTail: boolean;
|
|
49
|
+
/** True if this chunk is pinned. */
|
|
50
|
+
pinned: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The chunk's ancestor at level k in the summary tree, if one exists.
|
|
53
|
+
* Returns null if the level has not been produced for this chunk's range.
|
|
54
|
+
*/
|
|
55
|
+
ancestorAt(level: number): SummaryEntry | null;
|
|
56
|
+
/** The highest level k for which a summary exists covering this chunk. */
|
|
57
|
+
maxAvailableLevel(): number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read-only view passed to a FoldingStrategy.
|
|
62
|
+
*/
|
|
63
|
+
export interface FoldingState {
|
|
64
|
+
/** All chunks in source order. */
|
|
65
|
+
chunks(): readonly ChunkView[];
|
|
66
|
+
/** Chunks that are foldable — not in head/tail, not pinned, not locked. */
|
|
67
|
+
foldableMiddle(): readonly ChunkView[];
|
|
68
|
+
/** Look up a summary by id. */
|
|
69
|
+
getSummary(id: SummaryId): SummaryEntry | null;
|
|
70
|
+
/** All leaf chunks under a given summary (recursively walks the tree). */
|
|
71
|
+
leavesUnder(groupRoot: SummaryId): readonly ChunkView[];
|
|
72
|
+
/**
|
|
73
|
+
* Estimate of total tokens that would be rendered with the current
|
|
74
|
+
* per-chunk resolutions. Cached; recompute via recomputeTokens() after
|
|
75
|
+
* applying a fold op.
|
|
76
|
+
*/
|
|
77
|
+
tokenCount(): number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A fold operation returned by FoldingStrategy.selectNextFold. */
|
|
81
|
+
export type FoldOp =
|
|
82
|
+
| {
|
|
83
|
+
kind: 'raise';
|
|
84
|
+
/** L_{k+1} summary id that becomes the new visible level for the group. */
|
|
85
|
+
groupRoot: SummaryId;
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
kind: 'lower';
|
|
89
|
+
/** L_k summary id whose group is being lowered to (k-1). V2 only. */
|
|
90
|
+
groupRoot: SummaryId;
|
|
91
|
+
}
|
|
92
|
+
| {
|
|
93
|
+
kind: 'produce';
|
|
94
|
+
/** Level to produce (e.g., 2 for L2). */
|
|
95
|
+
level: number;
|
|
96
|
+
/** Range of chunks the produced summary should cover. */
|
|
97
|
+
range: ChunkRange;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Budget context for the strategy.
|
|
102
|
+
*/
|
|
103
|
+
export interface FoldingBudget {
|
|
104
|
+
/** Hard maximum tokens (model context - response reserve). */
|
|
105
|
+
totalBudget: number;
|
|
106
|
+
/** Soft target tokens (totalBudget * (1 - slack)). Strategies fold until at or below. */
|
|
107
|
+
targetBudget: number;
|
|
108
|
+
/** Slack ratio (e.g., 0.1 for 10%). */
|
|
109
|
+
slack: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Pluggable strategy interface.
|
|
114
|
+
*
|
|
115
|
+
* The picker calls selectNextFold() repeatedly until it returns null. On
|
|
116
|
+
* each call, the strategy inspects current state and returns one operation:
|
|
117
|
+
* - 'raise' — fold a group up one level (the picker applies it and re-asks)
|
|
118
|
+
* - 'lower' — refold down one level (V2; default V1 strategies don't emit this)
|
|
119
|
+
* - 'produce' — request lazy summary production (the picker enqueues it
|
|
120
|
+
* and stops; the next compile will retry)
|
|
121
|
+
*
|
|
122
|
+
* A strategy MUST converge: it must either eventually return null, or emit
|
|
123
|
+
* a 'produce' op that doesn't loop indefinitely. The picker has its own
|
|
124
|
+
* loop-bound safeguard, but well-behaved strategies make it unnecessary.
|
|
125
|
+
*/
|
|
126
|
+
export interface FoldingStrategy {
|
|
127
|
+
readonly name: string;
|
|
128
|
+
selectNextFold(state: FoldingState, budget: FoldingBudget): FoldOp | null;
|
|
129
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive-resolution context management subsystem.
|
|
3
|
+
*
|
|
4
|
+
* See `docs/adaptive-resolution-design.md` for the architectural overview.
|
|
5
|
+
*
|
|
6
|
+
* Status: V1 implementation, not yet wired into `AutobiographicalStrategy`.
|
|
7
|
+
* Modules in this directory are exercised via the test harness in
|
|
8
|
+
* `test/adaptive/` and will be integrated into the strategy in a follow-up
|
|
9
|
+
* once the design is validated against real workloads.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Chunker
|
|
13
|
+
export {
|
|
14
|
+
chunkMessage,
|
|
15
|
+
DEFAULT_CHUNKER_OPTIONS,
|
|
16
|
+
type ChunkerOptions,
|
|
17
|
+
type Shard,
|
|
18
|
+
type ShardingResult,
|
|
19
|
+
} from './chunker.js';
|
|
20
|
+
|
|
21
|
+
// Folding strategy interface
|
|
22
|
+
export type {
|
|
23
|
+
FoldingStrategy,
|
|
24
|
+
FoldingState,
|
|
25
|
+
FoldingBudget,
|
|
26
|
+
FoldOp,
|
|
27
|
+
ChunkView,
|
|
28
|
+
ChunkId,
|
|
29
|
+
ChunkRange,
|
|
30
|
+
SummaryId,
|
|
31
|
+
} from './folding-strategy.js';
|
|
32
|
+
|
|
33
|
+
// Concrete strategies
|
|
34
|
+
export { FlatProfileStrategy } from './strategies/flat-profile.js';
|
|
35
|
+
export { OldestFirstStrategy } from './strategies/oldest-first.js';
|
|
36
|
+
|
|
37
|
+
// Picker
|
|
38
|
+
export {
|
|
39
|
+
Picker,
|
|
40
|
+
OverBudgetError,
|
|
41
|
+
type PickerInputs,
|
|
42
|
+
type PickerResult,
|
|
43
|
+
type PickerChunk,
|
|
44
|
+
} from './picker.js';
|
|
45
|
+
|
|
46
|
+
// Render helpers
|
|
47
|
+
export { concatBodyGroups, placeholderRecallText } from './render.js';
|
|
48
|
+
|
|
49
|
+
// Chunk locking is exposed as a method on `AutobiographicalStrategy`
|
|
50
|
+
// (`lockChunk(id)` / `unlockChunk(id)`); there is no standalone lock API at
|
|
51
|
+
// the adaptive layer. The picker honors `PickerChunk.lockedByAgent`.
|