@cendor/contextkit 1.0.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/index.js ADDED
@@ -0,0 +1,741 @@
1
+ /**
2
+ * `@cendor/contextkit` — assemble context within a token budget, with a receipt. The TypeScript port
3
+ * of `cendor.contextkit`.
4
+ *
5
+ * Treat the context window like a packed suitcase: declare {@link Block}s with priority, pin, and a
6
+ * per-block eviction rule; {@link Context.assemble} packs them to a token budget (deterministically)
7
+ * and {@link Context.report} returns the receipt — what was kept, shrunk, or dropped, with the token
8
+ * math. Depends only on `@cendor/core` (`tokens` + the `Compressor`/`EvictionStrategy` protocols).
9
+ * Tools never import each other; `squeeze` plugs in by shape via the optional `@cendor/squeeze` peer.
10
+ *
11
+ * The receipt is honest at the **message** level: budgeting charges the per-message framing overhead
12
+ * that providers add around every turn (self-calibrated from `core.tokens`), so `report().used`
13
+ * equals `tokens.count(await assemble(), model)` for text content — what the model actually sees.
14
+ *
15
+ * Parity note: the Python module has a sync `assemble` and an async `aassemble`; this port collapses
16
+ * them into a single `async assemble()` that awaits summarizers (sync or async) and the compressor.
17
+ * The Python "sync path falls back to truncation for an async summarizer" behavior therefore has no
18
+ * analog here — async summarizers are simply awaited.
19
+ */
20
+ import { bus, tokens } from '@cendor/core';
21
+ // -------------------------------------------------------------------------------------- exceptions
22
+ /** Raised when pinned blocks alone exceed the budget (they are never evicted). */
23
+ export class BudgetError extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'BudgetError';
27
+ }
28
+ }
29
+ /** Mirrors Python's `ValueError` — invalid construction arguments. */
30
+ export class ValueError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = 'ValueError';
34
+ }
35
+ }
36
+ /** Mirrors Python's `RuntimeError` — `report()` before `assemble()`. */
37
+ export class RuntimeError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = 'RuntimeError';
41
+ }
42
+ }
43
+ // ------------------------------------------------------------------------ compressor global default
44
+ // Optional process-wide default compressor for evict="compress" blocks. contextkit doesn't care
45
+ // *who* compresses — only that it matches core's Compressor protocol by shape. By default it
46
+ // auto-discovers `@cendor/squeeze` (the deterministic, zero-dep backend); set this to swap in any
47
+ // other backend globally.
48
+ let _defaultCompressor = null;
49
+ /**
50
+ * Set the default compressor for `evict="compress"` blocks; returns the previous one.
51
+ *
52
+ * Accepts anything matching core's `Compressor` protocol — a `compress(content, opts)` object or a
53
+ * `(text, opts) => [small, handle]` callable — so you can plug in an alternative backend without
54
+ * touching call sites. Pass `null` to clear (falls back to auto-discovering `@cendor/squeeze`). A
55
+ * per-`Context` `compressor` option still overrides this default.
56
+ */
57
+ export function useCompressor(compressor) {
58
+ const previous = _defaultCompressor;
59
+ _defaultCompressor = compressor;
60
+ return previous;
61
+ }
62
+ // --------------------------------------------------------------------------- framing calibration
63
+ // Per-model (priming, per_message) framing overhead, derived once from core.tokens' public API:
64
+ // count([one empty msg]) = priming + per_message; the delta to two empty msgs isolates per_message.
65
+ // This stays correct for any registered tokenizer without importing core internals.
66
+ const _framingCache = new Map();
67
+ /** Return `[priming, perMessage]` token overhead for `model`, per `core.tokens`. */
68
+ function framing(model) {
69
+ const cached = _framingCache.get(model);
70
+ if (cached !== undefined)
71
+ return cached;
72
+ const one = tokens.count([{ role: 'user', content: '' }], model);
73
+ const two = tokens.count([
74
+ { role: 'user', content: '' },
75
+ { role: 'user', content: '' },
76
+ ], model);
77
+ const perMessage = Math.max(0, two - one);
78
+ const priming = Math.max(0, one - perMessage);
79
+ const value = [priming, perMessage];
80
+ _framingCache.set(model, value);
81
+ return value;
82
+ }
83
+ // -------------------------------------------------------------------------------------- constants
84
+ // Default render order: system first, history/context middle, the user turn last.
85
+ const ROLE_RANK = {
86
+ system: 0,
87
+ history: 1,
88
+ tool: 1,
89
+ assistant: 2,
90
+ user: 3,
91
+ };
92
+ const ORDERS = ['default', 'attention', 'cache'];
93
+ // A short, honest marker appended (head) or prepended (tail) so a truncated block reads as cut.
94
+ // The ellipsis is the single Unicode character U+2026 (…), NOT three dots.
95
+ const TRUNC_MARK = {
96
+ head: '\n…[truncated]',
97
+ tail: '[truncated]…\n',
98
+ };
99
+ /**
100
+ * A unit of context with packing intent.
101
+ *
102
+ * Provide **exactly one** of `content` (a single message, text or multimodal parts) or `messages`
103
+ * (a conversation segment — a list of `{role, content}` turns that `evict="drop_oldest"` shrinks by
104
+ * peeling the *oldest* turns until it fits).
105
+ *
106
+ * Construct positionally with the content first (`new Block('hi', { priority: 5, role: 'system' })`)
107
+ * or via an options object for `messages` blocks (`new Block({ messages: [...], priority: 5 })`).
108
+ */
109
+ export class Block {
110
+ content;
111
+ priority;
112
+ pin;
113
+ evict;
114
+ role;
115
+ summarizer;
116
+ keep;
117
+ messages;
118
+ constructor(contentOrOpts, opts) {
119
+ const contentFirst = typeof contentOrOpts === 'string' || Array.isArray(contentOrOpts);
120
+ const options = (contentFirst ? opts : contentOrOpts) ?? {};
121
+ const contentArg = contentFirst ? contentOrOpts : options.content;
122
+ this.content = contentArg ?? null;
123
+ this.priority = options.priority ?? 0;
124
+ this.pin = options.pin ?? false;
125
+ this.evict = options.evict ?? 'drop_oldest';
126
+ this.role = options.role ?? 'user';
127
+ this.summarizer = options.summarizer ?? null;
128
+ this.keep = options.keep ?? 'head';
129
+ this.messages = options.messages ?? null;
130
+ if ((this.content === null) === (this.messages === null)) {
131
+ throw new ValueError('Block requires exactly one of content= or messages=');
132
+ }
133
+ if (this.keep !== 'head' && this.keep !== 'tail') {
134
+ throw new ValueError(`keep must be 'head' or 'tail', got ${JSON.stringify(this.keep)}`);
135
+ }
136
+ if (this.messages !== null &&
137
+ !this.messages.every((t) => t !== null && typeof t === 'object' && 'role' in t && 'content' in t)) {
138
+ throw new ValueError("each item in messages= must be a {'role', 'content'} dict");
139
+ }
140
+ }
141
+ }
142
+ /**
143
+ * What happened to one block during assembly (a line on the receipt).
144
+ *
145
+ * `tokensBefore`/`tokensAfter` are *content* tokens (framing-exclusive); the report's `used`
146
+ * additionally accounts for per-message framing.
147
+ */
148
+ export class BlockDecision {
149
+ role;
150
+ action; // "kept" | "truncated" | "summarized" | "compressed" | "dropped" | "evicted"
151
+ tokensBefore;
152
+ tokensAfter;
153
+ note;
154
+ // For a "compressed" block, the reversible squeeze Handle — call `.expand()` to restore the
155
+ // original content. `null` for every other action.
156
+ handle;
157
+ constructor(role, action, tokensBefore, tokensAfter, note = '', handle = null) {
158
+ this.role = role;
159
+ this.action = action;
160
+ this.tokensBefore = tokensBefore;
161
+ this.tokensAfter = tokensAfter;
162
+ this.note = note;
163
+ this.handle = handle;
164
+ }
165
+ }
166
+ /**
167
+ * The receipt: budget math + per-block decisions.
168
+ *
169
+ * `used` is the message-level token count of the assembled prompt (content + framing), so it equals
170
+ * `tokens.count(messages, model)` for text content; multimodal image budget is also charged into
171
+ * `used` even though `core.tokens` can't see image parts.
172
+ */
173
+ export class AssemblyReport {
174
+ budget;
175
+ used;
176
+ reservedOutput;
177
+ model;
178
+ decisions;
179
+ order;
180
+ constructor(budget, used, reservedOutput, model, decisions = [], order = 'default') {
181
+ this.budget = budget;
182
+ this.used = used;
183
+ this.reservedOutput = reservedOutput;
184
+ this.model = model;
185
+ this.decisions = decisions;
186
+ this.order = order;
187
+ }
188
+ toString() {
189
+ const lines = [
190
+ `AssemblyReport(model=${this.model}, order=${this.order}) ` +
191
+ `budget=${this.budget} reserved_output=${this.reservedOutput} ` +
192
+ `used=${this.used}/${this.budget - this.reservedOutput}`,
193
+ ];
194
+ for (const d of this.decisions) {
195
+ const arrow = `${d.tokensBefore}->${d.tokensAfter}tok`;
196
+ const note = d.note ? ` # ${d.note}` : '';
197
+ lines.push(` [${d.action.padEnd(10)}] ${d.role.padEnd(9)} ${arrow}${note}`);
198
+ }
199
+ return lines.join('\n');
200
+ }
201
+ }
202
+ export class Context {
203
+ budgetTokens;
204
+ model;
205
+ reserveOutput;
206
+ order;
207
+ imageTokens;
208
+ _compressor;
209
+ blocks;
210
+ _report;
211
+ _messages;
212
+ constructor(opts) {
213
+ const order = opts.order ?? 'default';
214
+ if (!ORDERS.includes(order)) {
215
+ throw new ValueError(`order must be one of ${ORDERS.join(', ')}, got ${JSON.stringify(opts.order)}`);
216
+ }
217
+ this.budgetTokens = opts.budgetTokens;
218
+ this.model = opts.model;
219
+ this.reserveOutput = opts.reserveOutput ?? 0;
220
+ this._compressor = opts.compressor ?? null;
221
+ this.order = order;
222
+ // Token cost per image part in multimodal blocks: a flat int, or a callable
223
+ // (part -> tokens) for resolution-aware estimates.
224
+ this.imageTokens = opts.imageTokens ?? 0;
225
+ this.blocks = [];
226
+ this._report = null;
227
+ this._messages = [];
228
+ }
229
+ /** Add a block. Returns `this` for chaining. */
230
+ add(block) {
231
+ this.blocks.push(block);
232
+ return this;
233
+ }
234
+ /**
235
+ * Pack blocks within the budget; return provider-ready messages (OpenAI/Foundry shape).
236
+ *
237
+ * Deterministic: stable sort by `(pinned, priority, insertion order)`. Awaits summarizers and the
238
+ * compressor, then emits the {@link AssemblyReport} onto core's bus.
239
+ */
240
+ async assemble() {
241
+ const [messages, report] = await this.pack(this.budgetTokens, true);
242
+ this._messages = messages;
243
+ this._report = report;
244
+ return messages;
245
+ }
246
+ /** Return the receipt for the most recent {@link assemble}. Throws before the first one. */
247
+ report() {
248
+ if (this._report === null) {
249
+ throw new RuntimeError('call assemble() before report()');
250
+ }
251
+ return this._report;
252
+ }
253
+ /** Preview the assembly at a different budget without committing (no bus emit). */
254
+ async whatif(budgetTokens) {
255
+ const [, report] = await this.pack(budgetTokens, false);
256
+ return report;
257
+ }
258
+ /**
259
+ * Anthropic adapter: split system blocks out (the Messages API takes `system` apart).
260
+ *
261
+ * Returns `[systemText, messages]`. The Messages API accepts only `user`/`assistant` roles, so any
262
+ * other role (e.g. `tool`) is coerced to `user`. Multimodal content is passed through unchanged.
263
+ */
264
+ async forAnthropic() {
265
+ if (this._messages.length === 0)
266
+ await this.assemble();
267
+ const rest = this._messages
268
+ .filter((m) => m.role !== 'system')
269
+ .map((m) => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content }));
270
+ return [this.systemText(), rest];
271
+ }
272
+ /**
273
+ * Gemini adapter: returns `[systemInstruction, contents]`.
274
+ *
275
+ * `contents` are `{role: "user"|"model", parts: [...]}` (Gemini uses `model`, not `assistant`);
276
+ * system blocks become the separate `systemInstruction`. Content is normalized to Gemini parts.
277
+ */
278
+ async forGemini() {
279
+ if (this._messages.length === 0)
280
+ await this.assemble();
281
+ const contents = this._messages
282
+ .filter((m) => m.role !== 'system')
283
+ .map((m) => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: partsOf(m.content) }));
284
+ return [this.systemText(), contents];
285
+ }
286
+ /**
287
+ * Bedrock Converse adapter: returns `[system, messages]`.
288
+ *
289
+ * `system` is `[{text: ...}]` (or empty); `messages` are `{role: "user"|"assistant", content:
290
+ * [...]}` — Bedrock allows only those two roles, so non-user blocks map to `assistant`.
291
+ */
292
+ async forBedrock() {
293
+ if (this._messages.length === 0)
294
+ await this.assemble();
295
+ const systemText = this.systemText();
296
+ const system = systemText ? [{ text: systemText }] : [];
297
+ const messages = this._messages
298
+ .filter((m) => m.role !== 'system')
299
+ .map((m) => ({
300
+ role: m.role === 'user' ? 'user' : 'assistant',
301
+ content: partsOf(m.content),
302
+ }));
303
+ return [system, messages];
304
+ }
305
+ // ---------------------------------------------------------------------------------- internals
306
+ /** Join the text of all assembled system messages (adapters split `system` out). */
307
+ systemText() {
308
+ return this._messages
309
+ .filter((m) => m.role === 'system')
310
+ .map((m) => textOf(m.content))
311
+ .join('\n\n');
312
+ }
313
+ orderedBlocks() {
314
+ // (not pin) -> pinned (false=0) sorts first; then priority desc; then insertion order.
315
+ const indexed = this.blocks.map((b, i) => [i, b]);
316
+ return indexed.sort(byKey(([i, b]) => [Number(!b.pin), -b.priority, i]));
317
+ }
318
+ imageCost(part) {
319
+ const it = this.imageTokens;
320
+ return typeof it === 'function' ? it(part) : it;
321
+ }
322
+ /** Token cost of content, charging `imageTokens` per image part in multimodal lists. */
323
+ contentTokens(content) {
324
+ if (Array.isArray(content)) {
325
+ let text = '';
326
+ for (const p of content) {
327
+ if (p !== null && typeof p === 'object' && 'text' in p) {
328
+ const t = p.text;
329
+ text += typeof t === 'string' ? t : String(t ?? '');
330
+ }
331
+ }
332
+ let n = text ? tokens.count(text, this.model) : 0;
333
+ for (const p of content) {
334
+ if (p !== null && typeof p === 'object') {
335
+ const type = p.type;
336
+ if (type === 'image' || type === 'image_url')
337
+ n += this.imageCost(p);
338
+ }
339
+ }
340
+ return n;
341
+ }
342
+ return tokens.count(String(content), this.model);
343
+ }
344
+ finish(budgetTokens, used, decisions, kept, emit) {
345
+ const ordered = orderBlocks(kept, this.order);
346
+ const messages = [];
347
+ for (const [, , blockMessages] of ordered)
348
+ messages.push(...blockMessages);
349
+ const report = new AssemblyReport(budgetTokens, used, this.reserveOutput, this.model, decisions, this.order);
350
+ if (emit)
351
+ bus.emit(report);
352
+ return [messages, report];
353
+ }
354
+ /** Pack blocks within the budget. Awaits the evictor (summarizers / compressor). */
355
+ async pack(budgetTokens, emit) {
356
+ const [priming, perMessage] = framing(this.model);
357
+ const effective = Math.max(0, budgetTokens - this.reserveOutput);
358
+ const state = { used: 0, hasMsgs: false, decisions: [], kept: [] };
359
+ for (const [idx, block] of this.orderedBlocks()) {
360
+ if (block.messages !== null) {
361
+ this.packHistoryInto(block, idx, effective, priming, perMessage, state);
362
+ continue;
363
+ }
364
+ const plan = this.planBlock(block, effective, state, priming, perMessage);
365
+ if (plan.status === 'evict') {
366
+ const [newText, action, note, handle] = await this.evict(block, plan.text ?? '', plan.contentBudget ?? 0);
367
+ this.applyEvicted(idx, block, plan, perMessage, newText, action, note, state, handle);
368
+ }
369
+ else {
370
+ this.applyPlan(idx, block, plan, state);
371
+ }
372
+ }
373
+ return this.finish(budgetTokens, state.used, state.decisions, state.kept, emit);
374
+ }
375
+ /**
376
+ * Decide a single-message block's fate up to (not performing) eviction. Throws {@link BudgetError}
377
+ * on pinned overflow.
378
+ */
379
+ planBlock(block, effective, state, priming, perMessage) {
380
+ const contentTokens = this.contentTokens(block.content);
381
+ // Priming is charged ONCE, attributed to the first admitted message.
382
+ const prim = state.hasMsgs ? 0 : priming;
383
+ if (state.used + prim + perMessage + contentTokens <= effective) {
384
+ return {
385
+ status: 'kept',
386
+ used: state.used + prim + perMessage + contentTokens,
387
+ message: { role: block.role, content: block.content },
388
+ decision: new BlockDecision(block.role, 'kept', contentTokens, contentTokens),
389
+ };
390
+ }
391
+ if (block.pin) {
392
+ throw new BudgetError(`pinned block(s) exceed budget: need ${prim + perMessage + contentTokens} ` +
393
+ `tokens (${contentTokens} content + ${prim + perMessage} framing), ` +
394
+ `${effective - state.used} of ${effective} remaining ` +
395
+ `(reserve_output=${this.reserveOutput})`);
396
+ }
397
+ if (typeof block.content !== 'string') {
398
+ // Can't shrink a multimodal/list block.
399
+ return {
400
+ status: 'dropped',
401
+ decision: new BlockDecision(block.role, 'dropped', contentTokens, 0, 'multimodal: too large'),
402
+ };
403
+ }
404
+ const contentBudget = effective - state.used - prim - perMessage;
405
+ if (contentBudget <= 0) {
406
+ return {
407
+ status: 'dropped',
408
+ decision: new BlockDecision(block.role, 'dropped', contentTokens, 0, 'no room (framing)'),
409
+ };
410
+ }
411
+ return { status: 'evict', contentBudget, prim, contentTokens, text: block.content };
412
+ }
413
+ /** Fold a non-evict plan ("kept" / "dropped") into the running state. */
414
+ applyPlan(idx, block, plan, state) {
415
+ if (plan.status === 'kept') {
416
+ state.used = plan.used ?? state.used;
417
+ state.hasMsgs = true;
418
+ state.kept.push([idx, block, [plan.message]]);
419
+ }
420
+ state.decisions.push(plan.decision);
421
+ }
422
+ /** Fold an evictor's result back into the running state. */
423
+ applyEvicted(idx, block, plan, perMessage, newText, action, note, state, handle) {
424
+ const contentTokens = plan.contentTokens ?? 0;
425
+ if (newText === null) {
426
+ state.decisions.push(new BlockDecision(block.role, 'dropped', contentTokens, 0, note));
427
+ return;
428
+ }
429
+ const after = this.contentTokens(newText);
430
+ state.used += (plan.prim ?? 0) + perMessage + after;
431
+ state.hasMsgs = true;
432
+ state.kept.push([idx, block, [{ role: block.role, content: newText }]]);
433
+ state.decisions.push(new BlockDecision(block.role, action, contentTokens, after, note, handle));
434
+ }
435
+ /** Pack a multi-turn block into `state` (the messages-block branch). */
436
+ packHistoryInto(block, idx, effective, priming, perMessage, state) {
437
+ const [turns, dec, used, hasMsgs] = this.packHistory(block, effective, state.used, state.hasMsgs, priming, perMessage);
438
+ state.used = used;
439
+ state.hasMsgs = hasMsgs;
440
+ if (turns.length)
441
+ state.kept.push([idx, block, turns]);
442
+ state.decisions.push(dec);
443
+ }
444
+ /**
445
+ * Pack a multi-turn block: keep the newest turns that fit, peeling the oldest. `evict="truncate"`
446
+ * additionally tail-trims the surviving newest turn when even it overflows.
447
+ */
448
+ packHistory(block, effective, used, hasMsgs, priming, perMessage) {
449
+ const turns = block.messages ?? [];
450
+ const turnTokens = turns.map((t) => this.contentTokens(turnContent(t)));
451
+ const totalBefore = turnTokens.reduce((a, b) => a + b, 0);
452
+ const isTruncate = block.evict === 'truncate';
453
+ if (block.pin) {
454
+ const full = (hasMsgs ? 0 : priming) + perMessage * turns.length + totalBefore;
455
+ if (used + full > effective) {
456
+ throw new BudgetError(`pinned history block exceeds budget: needs ${full} tokens, ` +
457
+ `${effective - used} of ${effective} remaining (reserve_output=${this.reserveOutput})`);
458
+ }
459
+ }
460
+ const kept = []; // built newest-first, reversed at the end
461
+ let running = used;
462
+ let localHas = hasMsgs;
463
+ for (let i = turns.length - 1; i >= 0; i--) {
464
+ const prim = localHas ? 0 : priming;
465
+ const tt = turnTokens[i] ?? 0;
466
+ if (running + prim + perMessage + tt <= effective) {
467
+ running += prim + perMessage + tt;
468
+ localHas = true;
469
+ kept.push(turns[i]);
470
+ continue;
471
+ }
472
+ if (isTruncate && kept.length === 0) {
473
+ // The newest turn alone overflows -> tail-trim it.
474
+ const budgetCt = effective - running - prim - perMessage;
475
+ if (budgetCt > 0) {
476
+ const trimmed = truncateToTokens(String(turnContent(turns[i])), budgetCt, this.model, 'tail');
477
+ running += prim + perMessage + this.contentTokens(trimmed);
478
+ localHas = true;
479
+ kept.push({ ...turns[i], content: trimmed });
480
+ }
481
+ }
482
+ break; // older turns are dropped (we keep a contiguous suffix of recent turns)
483
+ }
484
+ kept.reverse();
485
+ const n = turns.length;
486
+ const k = kept.length;
487
+ const after = kept.reduce((a, t) => a + this.contentTokens(turnContent(t)), 0);
488
+ let action;
489
+ let note;
490
+ if (k === 0) {
491
+ action = 'dropped';
492
+ note = `history: dropped all ${n} turns (no room)`;
493
+ }
494
+ else if (k < n) {
495
+ action = 'truncated';
496
+ note = `history: kept ${k} of ${n} turns`;
497
+ if (block.evict !== 'drop_oldest' && block.evict !== 'truncate') {
498
+ note += `; '${String(block.evict)}' n/a for message blocks, peeled oldest`;
499
+ }
500
+ }
501
+ else {
502
+ action = 'kept';
503
+ note = '';
504
+ }
505
+ const decision = new BlockDecision('history', action, totalBefore, after, note);
506
+ return [kept, decision, running, localHas];
507
+ }
508
+ /**
509
+ * Apply a block's eviction strategy. Returns `[contentOrNull, action, note, handle]`; a `null`
510
+ * content means DROP. `contentBudget` is always > 0. Awaits async summarizers and the compressor.
511
+ */
512
+ async evict(block, text, contentBudget) {
513
+ const strategy = block.evict;
514
+ if (typeof strategy !== 'string') {
515
+ // A core EvictionStrategy object.
516
+ let result;
517
+ try {
518
+ result = strategy.evict(text, contentBudget, this.model);
519
+ }
520
+ catch (exc) {
521
+ // A custom strategy must never break assembly.
522
+ return [null, 'dropped', `custom strategy raised: ${repr(exc)}`, null];
523
+ }
524
+ const [newVal, action] = result;
525
+ if (newVal === null)
526
+ return [null, 'dropped', action || '', null];
527
+ let out = newVal;
528
+ if (tokens.count(out, this.model) > contentBudget) {
529
+ out = truncateToTokens(out, contentBudget, this.model, block.keep);
530
+ }
531
+ return [out, action || 'evicted', '', null];
532
+ }
533
+ if (strategy === 'drop_oldest') {
534
+ return [null, 'dropped', 'block dropped whole (use messages= for turn-level eviction)', null];
535
+ }
536
+ if (strategy === 'truncate') {
537
+ return [truncateToTokens(text, contentBudget, this.model, block.keep), 'truncated', '', null];
538
+ }
539
+ if (strategy === 'summarize') {
540
+ if (block.summarizer !== null && block.summarizer !== undefined) {
541
+ let summary = await block.summarizer(text, contentBudget);
542
+ if (tokens.count(summary, this.model) > contentBudget) {
543
+ summary = truncateToTokens(summary, contentBudget, this.model, block.keep);
544
+ }
545
+ return [summary, 'summarized', '', null];
546
+ }
547
+ return [
548
+ truncateToTokens(text, contentBudget, this.model, block.keep),
549
+ 'truncated',
550
+ 'no summarizer; truncated',
551
+ null,
552
+ ];
553
+ }
554
+ if (strategy === 'compress') {
555
+ const compressor = await this.getCompressor();
556
+ if (compressor !== null && compressor !== undefined) {
557
+ // Keep the squeeze Handle (reversibility is squeeze's USP) so report() exposes it.
558
+ let [small, handle] = await callCompressor(compressor, text, contentBudget, this.model);
559
+ if (tokens.count(small, this.model) > contentBudget) {
560
+ small = truncateToTokens(small, contentBudget, this.model, block.keep);
561
+ }
562
+ return [small, 'compressed', '', handle];
563
+ }
564
+ return [
565
+ truncateToTokens(text, contentBudget, this.model, block.keep),
566
+ 'truncated',
567
+ 'squeeze not installed; fell back to truncate',
568
+ null,
569
+ ];
570
+ }
571
+ return [null, 'dropped', `unknown evict strategy ${repr(strategy)}`, null];
572
+ }
573
+ async getCompressor() {
574
+ if (this._compressor !== null && this._compressor !== undefined)
575
+ return this._compressor; // per-Context override wins
576
+ if (_defaultCompressor !== null && _defaultCompressor !== undefined)
577
+ return _defaultCompressor; // process-wide default
578
+ // Otherwise auto-discover `@cendor/squeeze` at runtime (the optional peer). The string-typed
579
+ // specifier keeps this a soft dependency: if squeeze is not installed/built the import throws
580
+ // and we fall back to truncation.
581
+ try {
582
+ const specifier = '@cendor/squeeze';
583
+ const mod = (await import(specifier));
584
+ return mod.compress ?? null;
585
+ }
586
+ catch {
587
+ return null;
588
+ }
589
+ }
590
+ }
591
+ // ------------------------------------------------------------------------------ ordering helpers
592
+ /** A stable multi-key ascending comparator built from a numeric key tuple. */
593
+ function byKey(keyFn) {
594
+ return (a, b) => {
595
+ const ka = keyFn(a);
596
+ const kb = keyFn(b);
597
+ for (let i = 0; i < ka.length; i++) {
598
+ const d = (ka[i] ?? 0) - (kb[i] ?? 0);
599
+ if (d !== 0)
600
+ return d;
601
+ }
602
+ return 0;
603
+ };
604
+ }
605
+ /** The role a block is ordered by — `"history"` for a multi-turn (`messages`) block. */
606
+ function ordRole(block) {
607
+ return block.messages !== null ? 'history' : block.role;
608
+ }
609
+ /** Arrange kept blocks for rendering per the chosen strategy. Deterministic. */
610
+ function orderBlocks(kept, mode) {
611
+ if (mode === 'cache') {
612
+ // Stable prefix: pinned, high-priority blocks lead so the prompt prefix is reused.
613
+ return [...kept].sort(byKey(([i, b]) => [Number(!b.pin), -b.priority, i]));
614
+ }
615
+ if (mode === 'attention') {
616
+ const systems = kept
617
+ .filter((k) => ordRole(k[1]) === 'system')
618
+ .sort(byKey((k) => [-k[1].priority, k[0]]));
619
+ // Ascending -> the highest-priority user turn ends up last (strongest end position).
620
+ const finals = kept
621
+ .filter((k) => ordRole(k[1]) === 'user')
622
+ .sort(byKey((k) => [k[1].priority, k[0]]));
623
+ const middles = kept
624
+ .filter((k) => {
625
+ const r = ordRole(k[1]);
626
+ return r !== 'system' && r !== 'user';
627
+ })
628
+ .sort(byKey((k) => [-k[1].priority, k[0]]));
629
+ return [...systems, ...edgeLoad(middles), ...finals];
630
+ }
631
+ // default: role-grouped, insertion order within a role.
632
+ return [...kept].sort(byKey((k) => [ROLE_RANK[ordRole(k[1])] ?? 1, k[0]]));
633
+ }
634
+ /** Edge-load a priority-descending list: highest at both edges, lowest in the center. */
635
+ function edgeLoad(items) {
636
+ const left = [];
637
+ const right = [];
638
+ items.forEach((item, i) => {
639
+ (i % 2 === 0 ? left : right).push(item);
640
+ });
641
+ return [...left, ...right.reverse()];
642
+ }
643
+ // --------------------------------------------------------------------- text / parts normalization
644
+ /** Plain text of message content — a string, or the text parts of a multimodal list. */
645
+ function textOf(content) {
646
+ if (Array.isArray(content)) {
647
+ let s = '';
648
+ for (const p of content) {
649
+ if (p !== null && typeof p === 'object' && 'text' in p) {
650
+ const t = p.text;
651
+ s += typeof t === 'string' ? t : String(t ?? '');
652
+ }
653
+ }
654
+ return s;
655
+ }
656
+ return String(content);
657
+ }
658
+ /** Normalize content to a list of parts (text parts as `{text: ...}`). */
659
+ function partsOf(content) {
660
+ if (Array.isArray(content)) {
661
+ return content.map((p) => p !== null && typeof p === 'object' && 'text' in p
662
+ ? { text: p.text }
663
+ : p);
664
+ }
665
+ return [{ text: String(content) }];
666
+ }
667
+ /** `t.get("content", "")` — a turn's content, defaulting to the empty string. */
668
+ function turnContent(t) {
669
+ const c = t.content;
670
+ return (c ?? '');
671
+ }
672
+ // ---------------------------------------------------------------------------------- compressor call
673
+ /**
674
+ * Call a Compressor-protocol object or a `compress`-style callable. Returns `[compressedText,
675
+ * handle]`. Forwards `model` so the compressor sizes against the *context's* model; a legacy callable
676
+ * that ignores `model` still works (JS ignores the extra option).
677
+ */
678
+ async function callCompressor(compressor, text, target, model) {
679
+ const opts = { targetTokens: target, model };
680
+ if (compressor !== null &&
681
+ typeof compressor === 'object' &&
682
+ typeof compressor.compress === 'function') {
683
+ const fn = compressor.compress;
684
+ return normalizeCompress(await fn.call(compressor, text, opts));
685
+ }
686
+ if (typeof compressor === 'function') {
687
+ return normalizeCompress(await compressor(text, opts));
688
+ }
689
+ return [text, null];
690
+ }
691
+ function normalizeCompress(result) {
692
+ const arr = result;
693
+ return [String(arr[0]), arr[1] ?? null];
694
+ }
695
+ // -------------------------------------------------------------------------------- truncation
696
+ /** Binary-search the longest head/tail slice of `text` that fits `target` tokens (code-point aware). */
697
+ function hardCut(text, target, model, keep) {
698
+ if (target <= 0)
699
+ return '';
700
+ if (tokens.count(text, model) <= target)
701
+ return text;
702
+ const chars = [...text]; // code-point units, matching Python's character slicing
703
+ let lo = 0;
704
+ let hi = chars.length;
705
+ let best = '';
706
+ while (lo <= hi) {
707
+ const mid = Math.floor((lo + hi) / 2);
708
+ const cand = keep === 'head' ? chars.slice(0, mid).join('') : chars.slice(chars.length - mid).join('');
709
+ if (tokens.count(cand, model) <= target) {
710
+ best = cand;
711
+ lo = mid + 1;
712
+ }
713
+ else {
714
+ hi = mid - 1;
715
+ }
716
+ }
717
+ return best;
718
+ }
719
+ /**
720
+ * Trim `text` to at most `target` tokens, keeping the `head` or `tail`, with a marker. The marker is
721
+ * counted against `target` so the result never exceeds the budget; if there's no room for both
722
+ * content and marker, the text is hard-cut without one.
723
+ */
724
+ function truncateToTokens(text, target, model, keep = 'head') {
725
+ if (target <= 0)
726
+ return '';
727
+ if (tokens.count(text, model) <= target)
728
+ return text;
729
+ const marker = TRUNC_MARK[keep];
730
+ const bodyBudget = Math.max(0, target - tokens.count(marker, model));
731
+ if (bodyBudget === 0)
732
+ return hardCut(text, target, model, keep);
733
+ const body = hardCut(text, bodyBudget, model, keep);
734
+ return keep === 'head' ? body + marker : marker + body;
735
+ }
736
+ // ------------------------------------------------------------------------------------ misc
737
+ /** A small Python-`repr`-ish rendering for note strings (strings single-quoted). */
738
+ function repr(v) {
739
+ return typeof v === 'string' ? `'${v}'` : String(v);
740
+ }
741
+ //# sourceMappingURL=index.js.map