@animalabs/context-manager 0.5.4 → 0.5.9
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/kv-control.d.ts +84 -33
- package/dist/src/adaptive/kv-control.d.ts.map +1 -1
- package/dist/src/adaptive/kv-control.js +510 -145
- package/dist/src/adaptive/kv-control.js.map +1 -1
- package/dist/src/adaptive/picker.d.ts +21 -0
- package/dist/src/adaptive/picker.d.ts.map +1 -1
- package/dist/src/adaptive/picker.js.map +1 -1
- package/dist/src/adaptive/strategies/kv-stable.d.ts +12 -4
- package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -1
- package/dist/src/adaptive/strategies/kv-stable.js +67 -7
- package/dist/src/adaptive/strategies/kv-stable.js.map +1 -1
- package/dist/src/context-log.d.ts.map +1 -1
- package/dist/src/context-log.js +11 -0
- package/dist/src/context-log.js.map +1 -1
- package/dist/src/context-manager.d.ts +38 -6
- package/dist/src/context-manager.d.ts.map +1 -1
- package/dist/src/context-manager.js +51 -0
- package/dist/src/context-manager.js.map +1 -1
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/message-store.d.ts +60 -0
- package/dist/src/message-store.d.ts.map +1 -1
- package/dist/src/message-store.js +165 -10
- package/dist/src/message-store.js.map +1 -1
- package/dist/src/phase-channel.d.ts +13 -0
- package/dist/src/phase-channel.d.ts.map +1 -0
- package/dist/src/phase-channel.js +55 -0
- package/dist/src/phase-channel.js.map +1 -0
- package/dist/src/strategies/autobiographical.d.ts +247 -8
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +1419 -141
- 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/strategies/knowledge.d.ts +8 -0
- package/dist/src/strategies/knowledge.d.ts.map +1 -1
- package/dist/src/strategies/knowledge.js +23 -1
- package/dist/src/strategies/knowledge.js.map +1 -1
- package/dist/src/types/index.d.ts +1 -1
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/types/index.js.map +1 -1
- package/dist/src/types/strategy.d.ts +112 -11
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/adaptive/doc-plus-chat.test.js +7 -3
- package/dist/test/adaptive/doc-plus-chat.test.js.map +1 -1
- package/dist/test/adaptive/hard-fail.test.js +22 -0
- package/dist/test/adaptive/hard-fail.test.js.map +1 -1
- package/dist/test/adaptive/ingestion.test.js +3 -1
- package/dist/test/adaptive/ingestion.test.js.map +1 -1
- package/dist/test/adaptive/kv-control.test.js +17 -11
- package/dist/test/adaptive/kv-control.test.js.map +1 -1
- package/dist/test/adaptive/pin-at-level.test.d.ts +16 -0
- package/dist/test/adaptive/pin-at-level.test.d.ts.map +1 -0
- package/dist/test/adaptive/pin-at-level.test.js +133 -0
- package/dist/test/adaptive/pin-at-level.test.js.map +1 -0
- package/dist/test/adaptive/single-path-solve.test.d.ts +16 -0
- package/dist/test/adaptive/single-path-solve.test.d.ts.map +1 -0
- package/dist/test/adaptive/single-path-solve.test.js +204 -0
- package/dist/test/adaptive/single-path-solve.test.js.map +1 -0
- package/dist/test/anti-redundancy-budget.test.d.ts +17 -0
- package/dist/test/anti-redundancy-budget.test.d.ts.map +1 -0
- package/dist/test/anti-redundancy-budget.test.js +197 -0
- package/dist/test/anti-redundancy-budget.test.js.map +1 -0
- package/dist/test/chunk-persistence.test.d.ts +29 -0
- package/dist/test/chunk-persistence.test.d.ts.map +1 -0
- package/dist/test/chunk-persistence.test.js +290 -0
- package/dist/test/chunk-persistence.test.js.map +1 -0
- package/dist/test/compression-dedup.test.d.ts +20 -0
- package/dist/test/compression-dedup.test.d.ts.map +1 -0
- package/dist/test/compression-dedup.test.js +92 -0
- package/dist/test/compression-dedup.test.js.map +1 -0
- package/dist/test/compression-shape-invariants.test.js +40 -3
- package/dist/test/compression-shape-invariants.test.js.map +1 -1
- package/dist/test/head-window-order.test.d.ts +19 -0
- package/dist/test/head-window-order.test.d.ts.map +1 -0
- package/dist/test/head-window-order.test.js +159 -0
- package/dist/test/head-window-order.test.js.map +1 -0
- package/dist/test/image-strip-render-stats.test.js +25 -3
- package/dist/test/image-strip-render-stats.test.js.map +1 -1
- package/dist/test/knowledge-tool-boundary.test.d.ts +14 -0
- package/dist/test/knowledge-tool-boundary.test.d.ts.map +1 -0
- package/dist/test/knowledge-tool-boundary.test.js +93 -0
- package/dist/test/knowledge-tool-boundary.test.js.map +1 -0
- package/dist/test/l1-holdback.test.d.ts +15 -0
- package/dist/test/l1-holdback.test.d.ts.map +1 -0
- package/dist/test/l1-holdback.test.js +119 -0
- package/dist/test/l1-holdback.test.js.map +1 -0
- package/dist/test/merge-contiguity.test.d.ts +11 -0
- package/dist/test/merge-contiguity.test.d.ts.map +1 -0
- package/dist/test/merge-contiguity.test.js +74 -0
- package/dist/test/merge-contiguity.test.js.map +1 -0
- package/dist/test/message-store-window.test.d.ts +13 -0
- package/dist/test/message-store-window.test.d.ts.map +1 -0
- package/dist/test/message-store-window.test.js +177 -0
- package/dist/test/message-store-window.test.js.map +1 -0
- package/dist/test/pins.test.js +40 -0
- package/dist/test/pins.test.js.map +1 -1
- package/dist/test/speculation-cap.test.d.ts +9 -3
- package/dist/test/speculation-cap.test.d.ts.map +1 -1
- package/dist/test/speculation-cap.test.js +179 -3
- package/dist/test/speculation-cap.test.js.map +1 -1
- package/dist/test/summary-log-consistency.test.d.ts +18 -0
- package/dist/test/summary-log-consistency.test.d.ts.map +1 -0
- package/dist/test/summary-log-consistency.test.js +97 -0
- package/dist/test/summary-log-consistency.test.js.map +1 -0
- package/dist/test/tool-pairing-invariant.test.d.ts +15 -0
- package/dist/test/tool-pairing-invariant.test.d.ts.map +1 -0
- package/dist/test/tool-pairing-invariant.test.js +225 -0
- package/dist/test/tool-pairing-invariant.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +11 -3
- package/src/adaptive/kv-control.ts +554 -152
- package/src/adaptive/picker.ts +21 -0
- package/src/adaptive/strategies/kv-stable.ts +62 -12
- package/src/context-log.ts +11 -0
- package/src/context-manager.ts +61 -4
- package/src/index.ts +6 -2
- package/src/message-store.ts +202 -9
- package/src/phase-channel.ts +57 -0
- package/src/strategies/autobiographical.ts +1482 -167
- package/src/strategies/index.ts +1 -1
- package/src/strategies/knowledge.ts +25 -1
- package/src/types/index.ts +1 -0
- package/src/types/strategy.ts +120 -7
package/src/adaptive/picker.ts
CHANGED
|
@@ -81,11 +81,32 @@ export interface PickerChunk {
|
|
|
81
81
|
lockedByAgent: boolean;
|
|
82
82
|
bodyGroupId?: string;
|
|
83
83
|
pinned: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* V2 dynamic pin — fix this chunk at EXACTLY this fold level (0 = raw). Set
|
|
86
|
+
* from a `ProtectedRange.level`. Such a chunk is NOT `pinned` (the controller
|
|
87
|
+
* must be able to move it to its level), but the KV-stable controller holds it
|
|
88
|
+
* there and never folds/un-folds it. Ignored by non-kv-stable strategies.
|
|
89
|
+
*/
|
|
90
|
+
pinLevel?: number;
|
|
91
|
+
/**
|
|
92
|
+
* V2 dynamic pin — this chunk may fold no deeper than this level (hard cap).
|
|
93
|
+
* Set from a `ProtectedRange.maxLevel`. Honored only by the KV-stable
|
|
94
|
+
* controller (in normal and emergency shedding). Ignored elsewhere.
|
|
95
|
+
*/
|
|
96
|
+
pinMaxLevel?: number;
|
|
84
97
|
/**
|
|
85
98
|
* The L1 summary covering this chunk, if any. Higher levels are derived
|
|
86
99
|
* by walking parentId pointers in the summary tree.
|
|
87
100
|
*/
|
|
88
101
|
l1Id?: SummaryId;
|
|
102
|
+
/**
|
|
103
|
+
* Salience ∈ [0,1] — the coefficient on this chunk's information loss
|
|
104
|
+
* (design §13.3). Lower = folds earlier and costs less when folded:
|
|
105
|
+
* content whose payload is externalized (code on disk, tool output,
|
|
106
|
+
* images, link drops) vs conversation that exists nowhere else. Default 1.
|
|
107
|
+
* Honored by the kv-stable controller; never overrides hard protections.
|
|
108
|
+
*/
|
|
109
|
+
salience?: number;
|
|
89
110
|
}
|
|
90
111
|
|
|
91
112
|
export interface PickerInputs {
|
|
@@ -30,14 +30,16 @@ import type {
|
|
|
30
30
|
} from '../folding-strategy.js';
|
|
31
31
|
import type { PickerInputs } from '../picker.js';
|
|
32
32
|
import { SummaryTree } from '../summary-tree.js';
|
|
33
|
-
import { planControlledFrontier } from '../kv-control.js';
|
|
33
|
+
import { planControlledFrontier, type ControlPlan } from '../kv-control.js';
|
|
34
34
|
|
|
35
35
|
export interface KvStableOptions {
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
/** Trust region P on per-turn perturbation, in tokens the provider would
|
|
37
|
+
* re-read (exact kvCost — see design §13.4). Within it the solver adopts
|
|
38
|
+
* the relevance-ideal cut; beyond it it amortizes via suffix adoption or
|
|
39
|
+
* overrides with cause. Default: the hard budget (never binds). */
|
|
40
40
|
reachTokens?: number;
|
|
41
|
+
/** Quality-gap override threshold (design §13.4). Default 0.35. */
|
|
42
|
+
qualityGapRatio?: number;
|
|
41
43
|
/** Base-k summary grouping (matches the strategy's mergeThreshold). Default 6. */
|
|
42
44
|
mergeThreshold?: number;
|
|
43
45
|
}
|
|
@@ -49,6 +51,7 @@ export class KvStableStrategy implements FoldingStrategy {
|
|
|
49
51
|
private readonly opts: KvStableOptions;
|
|
50
52
|
private readonly fPrev: Map<ChunkId, number>;
|
|
51
53
|
private target: Map<ChunkId, number> | null = null;
|
|
54
|
+
private _lastPlan: ControlPlan | null = null;
|
|
52
55
|
|
|
53
56
|
constructor(inputs: PickerInputs, opts: KvStableOptions = {}) {
|
|
54
57
|
this.inputs = inputs;
|
|
@@ -66,38 +69,85 @@ export class KvStableStrategy implements FoldingStrategy {
|
|
|
66
69
|
return this.target;
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/** The full control plan behind the target (perturbation, override) — for
|
|
73
|
+
* observability at the call site (`[kv-escalation]` logging). Null before
|
|
74
|
+
* the first `selectNextFold`. */
|
|
75
|
+
lastPlan(): ControlPlan | null {
|
|
76
|
+
return this._lastPlan;
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
private solve(budget: FoldingBudget): Map<ChunkId, number> {
|
|
70
80
|
const tree = new SummaryTree(this.inputs);
|
|
71
81
|
|
|
72
|
-
// Flat zone (forced raw, never folded): head, tail,
|
|
73
|
-
// their carried resolution, never folded): locked.
|
|
82
|
+
// Flat zone (forced raw, never folded): head, tail, classic pins. Frozen
|
|
83
|
+
// (kept at their carried resolution, never folded): locked. V2 dynamic pins
|
|
84
|
+
// (`pinLevel` / `pinMaxLevel`) are honored as a fixed level or a hard fold-
|
|
85
|
+
// depth cap — see `planControlledFrontier`.
|
|
74
86
|
const rawZone = new Set<ChunkId>();
|
|
75
87
|
const frozen = new Set<ChunkId>();
|
|
88
|
+
const fixedLevels = new Map<ChunkId, number>();
|
|
89
|
+
const pinCaps = new Map<ChunkId, number>();
|
|
90
|
+
const fixedPins: Array<{ id: ChunkId; level: number }> = [];
|
|
76
91
|
let now = 0;
|
|
77
92
|
for (const c of this.inputs.chunks) {
|
|
78
93
|
if (c.sequence > now) now = c.sequence;
|
|
79
94
|
if (this.inputs.headChunkIds.has(c.id) || this.inputs.tailChunkIds.has(c.id) || c.pinned) {
|
|
80
95
|
rawZone.add(c.id);
|
|
96
|
+
} else if (c.pinLevel !== undefined) {
|
|
97
|
+
// Pin-at-level-k: fix exactly at k (0 = raw). k=0 is equivalent to a
|
|
98
|
+
// classic raw pin, so route it through the raw zone; k>0 is resolved to
|
|
99
|
+
// its whole L_k node below (group-consistent — see the loop after).
|
|
100
|
+
if (c.pinLevel <= 0) rawZone.add(c.id);
|
|
101
|
+
else fixedPins.push({ id: c.id, level: c.pinLevel });
|
|
102
|
+
} else if (c.pinMaxLevel !== undefined) {
|
|
103
|
+
// Pin-max-level: a hard fold-depth cap. maxLevel 0 ≡ classic raw pin.
|
|
104
|
+
if (c.pinMaxLevel <= 0) rawZone.add(c.id);
|
|
105
|
+
else pinCaps.set(c.id, c.pinMaxLevel);
|
|
106
|
+
if (c.lockedByAgent) frozen.add(c.id);
|
|
81
107
|
} else if (c.lockedByAgent) {
|
|
82
108
|
frozen.add(c.id);
|
|
83
109
|
}
|
|
84
110
|
}
|
|
85
111
|
|
|
86
|
-
|
|
112
|
+
// Group-consistency for pin-at-level-k: an L_k recall pair is atomic over
|
|
113
|
+
// its whole covered range, so "cut through the L_k node" (design §7) fixes
|
|
114
|
+
// EVERY leaf under that node at k — not just the addressed chunk. Fixing a
|
|
115
|
+
// single sub-chunk while its siblings render raw is an unrenderable (and
|
|
116
|
+
// non-converging) frontier. Clamp k to the deepest produced level for the
|
|
117
|
+
// chunk; if none exists, fall back to fixing just the chunk (the controller
|
|
118
|
+
// clamps it further). Skip leaves already forced raw (head/tail/classic pin).
|
|
119
|
+
for (const { id, level } of fixedPins) {
|
|
120
|
+
const eff = Math.min(level, tree.maxLevel(id));
|
|
121
|
+
if (eff <= 0) { rawZone.add(id); continue; }
|
|
122
|
+
const node = tree.ancestorAt(id, eff);
|
|
123
|
+
if (!node) { fixedLevels.set(id, eff); continue; }
|
|
124
|
+
for (const leaf of node.leafChunkIds) {
|
|
125
|
+
if (rawZone.has(leaf)) continue;
|
|
126
|
+
// Finest requirement wins if two pins overlap a leaf.
|
|
127
|
+
const prev = fixedLevels.get(leaf);
|
|
128
|
+
fixedLevels.set(leaf, prev === undefined ? eff : Math.min(prev, eff));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const plan = planControlledFrontier(this.inputs, tree, {
|
|
87
133
|
previous: this.fPrev,
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// [targetBudget, totalBudget] is the quiet dead band.
|
|
134
|
+
// Single-path solve (design §13.4): the [targetBudget, totalBudget] band
|
|
135
|
+
// is the quiet dead band; the trust region and overrides do the rest.
|
|
91
136
|
foldAtTokens: budget.totalBudget,
|
|
92
137
|
expandAtTokens: budget.targetBudget,
|
|
93
138
|
targetTokens: budget.targetBudget,
|
|
94
139
|
windowTokens: budget.totalBudget,
|
|
95
140
|
reachTokens: this.opts.reachTokens,
|
|
141
|
+
qualityGapRatio: this.opts.qualityGapRatio,
|
|
96
142
|
rawZone,
|
|
97
143
|
frozen,
|
|
144
|
+
fixedLevels,
|
|
145
|
+
pinCaps,
|
|
98
146
|
now,
|
|
99
147
|
mergeThreshold: this.opts.mergeThreshold,
|
|
100
|
-
})
|
|
148
|
+
});
|
|
149
|
+
this._lastPlan = plan;
|
|
150
|
+
return plan.resolutions;
|
|
101
151
|
}
|
|
102
152
|
|
|
103
153
|
/** Emit one op moving the live state toward the target frontier. */
|
package/src/context-log.ts
CHANGED
|
@@ -323,6 +323,17 @@ export class ContextLog {
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
private getInternal(index: number): ContextEntryInternal | null {
|
|
326
|
+
// Point lookup through chronicle's per-item cache — O(item size).
|
|
327
|
+
// See MessageStore.getInternal for why this must never fetch the
|
|
328
|
+
// full state per index.
|
|
329
|
+
//
|
|
330
|
+
// Feature-detect: getStateItemJson landed in chronicle 0.2.2; boxes
|
|
331
|
+
// still on <= 0.2.1 (npm copies) fall back to the full-materialization
|
|
332
|
+
// path so a routine `git pull` of this package can never crash them.
|
|
333
|
+
if (typeof (this.store as { getStateItemJson?: unknown }).getStateItemJson === 'function') {
|
|
334
|
+
const item = this.store.getStateItemJson(this.stateId, index);
|
|
335
|
+
return (item as ContextEntryInternal | null) ?? null;
|
|
336
|
+
}
|
|
326
337
|
const all = this.getAllInternal();
|
|
327
338
|
return all[index] ?? null;
|
|
328
339
|
}
|
package/src/context-manager.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { JsStore } from '@animalabs/chronicle';
|
|
2
|
-
import type { Membrane, NormalizedMessage, ContentBlock } from '@animalabs/membrane';
|
|
2
|
+
import type { Membrane, NormalizedMessage, ContentBlock, ToolDefinition } from '@animalabs/membrane';
|
|
3
3
|
import type {
|
|
4
4
|
MessageId,
|
|
5
5
|
Sequence,
|
|
@@ -16,13 +16,14 @@ import type {
|
|
|
16
16
|
ContextInjection,
|
|
17
17
|
CompileResult,
|
|
18
18
|
ProtectedRange,
|
|
19
|
+
PinLevelOptions,
|
|
19
20
|
SearchQuery,
|
|
20
21
|
SearchResult,
|
|
21
22
|
SummaryEntry,
|
|
22
23
|
} from './types/index.js';
|
|
23
24
|
import { isResettableStrategy, isPinnableStrategy, isSearchableStrategy, isRenderStatsCapable } from './types/index.js';
|
|
24
25
|
import type { RenderStats } from './types/index.js';
|
|
25
|
-
import { MessageStore, MessageStoreEvent } from './message-store.js';
|
|
26
|
+
import { MessageStore, MessageStoreEvent, MessageStoreListener, MessageWindow, MessageWindowOptions } from './message-store.js';
|
|
26
27
|
import { ContextLog } from './context-log.js';
|
|
27
28
|
import { PassthroughStrategy } from './strategies/passthrough.js';
|
|
28
29
|
import { splitMixedToolMessages } from './normalize-tool-messages.js';
|
|
@@ -300,6 +301,31 @@ export class ContextManager {
|
|
|
300
301
|
return this.messageStore.getAll();
|
|
301
302
|
}
|
|
302
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Get the total number of messages — O(1).
|
|
306
|
+
*/
|
|
307
|
+
getMessageCount(): number {
|
|
308
|
+
return this.messageStore.length();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get a window of messages by slot index — O(window), not O(all).
|
|
313
|
+
* See MessageStore.getWindow for options (blob resolution, bodyGroup
|
|
314
|
+
* alignment). Intended for viewers/paginated UIs.
|
|
315
|
+
*/
|
|
316
|
+
getMessageWindow(offset: number, limit: number, opts?: MessageWindowOptions): MessageWindow {
|
|
317
|
+
return this.messageStore.getWindow(offset, limit, opts);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Subscribe to message-store mutations (add/edit/remove/removeRange).
|
|
322
|
+
* Returns a detacher. Unlike trace events, this fires for ALL stored
|
|
323
|
+
* messages including assistant turns and tool results.
|
|
324
|
+
*/
|
|
325
|
+
onMessage(listener: MessageStoreListener): () => void {
|
|
326
|
+
return this.messageStore.addListener(listener);
|
|
327
|
+
}
|
|
328
|
+
|
|
303
329
|
/**
|
|
304
330
|
* Query messages by filter criteria.
|
|
305
331
|
* Useful for finding messages from external sources, by participant, etc.
|
|
@@ -640,19 +666,29 @@ export class ContextManager {
|
|
|
640
666
|
*
|
|
641
667
|
* Throws if the active strategy doesn't support pins.
|
|
642
668
|
*/
|
|
643
|
-
pinRange(firstMessageId: MessageId, lastMessageId: MessageId, opts?:
|
|
669
|
+
pinRange(firstMessageId: MessageId, lastMessageId: MessageId, opts?: PinLevelOptions): string {
|
|
644
670
|
if (!isPinnableStrategy(this.strategy)) {
|
|
645
671
|
throw new Error('Active strategy does not support pins');
|
|
646
672
|
}
|
|
647
673
|
return this.strategy.pinRange(firstMessageId, lastMessageId, opts);
|
|
648
674
|
}
|
|
649
675
|
|
|
676
|
+
/**
|
|
677
|
+
* V2 dynamic pin-at-level-k: fix a range to render at EXACTLY fold level
|
|
678
|
+
* `level` (0 = raw) — the frontier cut passes through that L_k node. Honored
|
|
679
|
+
* only by `foldingStrategy: 'kv-stable'`; other strategies fall back to
|
|
680
|
+
* treating the range as raw. Returns the new pin id.
|
|
681
|
+
*/
|
|
682
|
+
pinAtLevel(firstMessageId: MessageId, lastMessageId: MessageId, level: number, opts?: { name?: string }): string {
|
|
683
|
+
return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
|
|
684
|
+
}
|
|
685
|
+
|
|
650
686
|
/**
|
|
651
687
|
* Mark a single message as a "document" (semantically a body of
|
|
652
688
|
* information to retain in full). Same effect as a single-message pin
|
|
653
689
|
* with `kind: 'document'`. Returns the new pin id.
|
|
654
690
|
*/
|
|
655
|
-
markDocument(messageId: MessageId, opts?:
|
|
691
|
+
markDocument(messageId: MessageId, opts?: PinLevelOptions): string {
|
|
656
692
|
if (!isPinnableStrategy(this.strategy)) {
|
|
657
693
|
throw new Error('Active strategy does not support documents');
|
|
658
694
|
}
|
|
@@ -758,7 +794,22 @@ export class ContextManager {
|
|
|
758
794
|
}
|
|
759
795
|
}
|
|
760
796
|
|
|
797
|
+
/**
|
|
798
|
+
* Live tool definitions for the owning agent, refreshed by the host on
|
|
799
|
+
* every activation (Agent.buildActivationRequest in agent-framework).
|
|
800
|
+
* Threaded into StrategyContext so compression/summarizer LLM calls can
|
|
801
|
+
* declare the same tools as the live instance — required to avoid
|
|
802
|
+
* reasoning_extraction refusals on transcripts containing tool blocks.
|
|
803
|
+
*/
|
|
804
|
+
private toolDefinitions?: ToolDefinition[];
|
|
805
|
+
|
|
806
|
+
/** Host hook: record the agent's current tool definitions (see above). */
|
|
807
|
+
setToolDefinitions(tools: ToolDefinition[] | undefined): void {
|
|
808
|
+
if (tools && tools.length > 0) this.toolDefinitions = tools;
|
|
809
|
+
}
|
|
810
|
+
|
|
761
811
|
private createStrategyContext(): StrategyContext {
|
|
812
|
+
const self = this;
|
|
762
813
|
return {
|
|
763
814
|
messageStore: this.messageStore.createView(),
|
|
764
815
|
contextLog: this.contextLog.createView(),
|
|
@@ -766,6 +817,12 @@ export class ContextManager {
|
|
|
766
817
|
currentSequence: this.store.currentSequence(),
|
|
767
818
|
store: this.store,
|
|
768
819
|
namespace: this.strategyNamespace,
|
|
820
|
+
// Live getter, not a snapshot: strategies capture a ctx object once and
|
|
821
|
+
// reuse it across a long-running drain (driveSpeculativeDrain recurses
|
|
822
|
+
// with the same ctx). A snapshot taken before the session's first
|
|
823
|
+
// activation would freeze `tools` as undefined for the drain's entire
|
|
824
|
+
// lifetime — the getter always reflects the latest activation.
|
|
825
|
+
get tools() { return self.toolDefinitions; },
|
|
769
826
|
};
|
|
770
827
|
}
|
|
771
828
|
|
package/src/index.ts
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
export { ContextManager } from './context-manager.js';
|
|
3
3
|
export type { ContextManagerConfig } from './context-manager.js';
|
|
4
4
|
|
|
5
|
+
// Phase channel (liveness-watchdog observability hook)
|
|
6
|
+
export { phaseChannel, enterPhase, withPhase, withPhaseAsync } from './phase-channel.js';
|
|
7
|
+
|
|
5
8
|
// Storage
|
|
6
9
|
export { MessageStore } from './message-store.js';
|
|
7
|
-
export type { MessageStoreEvent, MessageStoreListener } from './message-store.js';
|
|
10
|
+
export type { MessageStoreEvent, MessageStoreListener, MessageWindow, MessageWindowOptions } from './message-store.js';
|
|
11
|
+
export { concatBodyGroups } from './adaptive/render.js';
|
|
8
12
|
export { ContextLog } from './context-log.js';
|
|
9
13
|
export { BlobManager } from './blob-manager.js';
|
|
10
14
|
|
|
11
15
|
// Strategies
|
|
12
16
|
export { PassthroughStrategy } from './strategies/passthrough.js';
|
|
13
|
-
export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot } from './strategies/autobiographical.js';
|
|
17
|
+
export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot, type Chunk } from './strategies/autobiographical.js';
|
|
14
18
|
export { KnowledgeStrategy } from './strategies/knowledge.js';
|
|
15
19
|
|
|
16
20
|
// Utilities
|
package/src/message-store.ts
CHANGED
|
@@ -28,6 +28,39 @@ export type MessageStoreEvent =
|
|
|
28
28
|
*/
|
|
29
29
|
export type MessageStoreListener = (event: MessageStoreEvent) => void;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Options for windowed message reads.
|
|
33
|
+
*/
|
|
34
|
+
export interface MessageWindowOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Re-inline blob media (images/documents) into content blocks, matching
|
|
37
|
+
* the behavior of get()/getAll(). Default true. Viewers that only need
|
|
38
|
+
* text/thinking/tool blocks should pass false to avoid inflating large
|
|
39
|
+
* base64 payloads.
|
|
40
|
+
*/
|
|
41
|
+
resolveBlobs?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Extend the window edges outward so that no bodyGroup (shard run of a
|
|
44
|
+
* single large message) is split across the window boundary. Default
|
|
45
|
+
* false (exact offset/limit semantics).
|
|
46
|
+
*/
|
|
47
|
+
alignToBodyGroups?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A window of messages plus enough metadata to page through the store.
|
|
52
|
+
*/
|
|
53
|
+
export interface MessageWindow {
|
|
54
|
+
messages: StoredMessage[];
|
|
55
|
+
/**
|
|
56
|
+
* Actual first slot index of the returned window. May be lower than the
|
|
57
|
+
* requested offset when alignToBodyGroups extended the window backward.
|
|
58
|
+
*/
|
|
59
|
+
startIndex: number;
|
|
60
|
+
/** Total number of messages in the store at read time. */
|
|
61
|
+
totalCount: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
31
64
|
/**
|
|
32
65
|
* Options for token estimation.
|
|
33
66
|
*/
|
|
@@ -358,19 +391,72 @@ export class MessageStore {
|
|
|
358
391
|
);
|
|
359
392
|
}
|
|
360
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Get a window of messages by slot index — O(window), not O(all).
|
|
396
|
+
*
|
|
397
|
+
* Backed by chronicle's `getStateSlice` (0.2.2+) with a
|
|
398
|
+
* full-materialization fallback for older chronicle copies, mirroring
|
|
399
|
+
* the feature-detect in getInternal().
|
|
400
|
+
*/
|
|
401
|
+
getWindow(offset: number, limit: number, opts: MessageWindowOptions = {}): MessageWindow {
|
|
402
|
+
const totalCount = this.length();
|
|
403
|
+
let start = Math.max(0, Math.min(offset, totalCount));
|
|
404
|
+
let end = Math.min(start + Math.max(0, limit), totalCount);
|
|
405
|
+
|
|
406
|
+
if (start >= end) {
|
|
407
|
+
return { messages: [], startIndex: Math.min(start, totalCount), totalCount };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (opts.alignToBodyGroups) {
|
|
411
|
+
// Shards of a bodyGroup are contiguous by construction (removeRange
|
|
412
|
+
// refuses to bisect a group). Walk edges outward with O(item) point
|
|
413
|
+
// lookups until the group boundary.
|
|
414
|
+
const first = this.getInternal(start);
|
|
415
|
+
if (first?.bodyGroupId !== undefined) {
|
|
416
|
+
while (start > 0) {
|
|
417
|
+
const prev = this.getInternal(start - 1);
|
|
418
|
+
if (prev?.bodyGroupId !== first.bodyGroupId) break;
|
|
419
|
+
start--;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const last = this.getInternal(end - 1);
|
|
423
|
+
if (last?.bodyGroupId !== undefined) {
|
|
424
|
+
while (end < totalCount) {
|
|
425
|
+
const next = this.getInternal(end);
|
|
426
|
+
if (next?.bodyGroupId !== last.bodyGroupId) break;
|
|
427
|
+
end++;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const internals = this.getSliceInternal(start, end - start);
|
|
433
|
+
const resolveBlobs = opts.resolveBlobs !== false;
|
|
434
|
+
return {
|
|
435
|
+
messages: internals.map((internal, i) =>
|
|
436
|
+
this.internalToStored(internal, internal.id, start + i, resolveBlobs)
|
|
437
|
+
),
|
|
438
|
+
startIndex: start,
|
|
439
|
+
totalCount,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
361
443
|
/**
|
|
362
444
|
* Get messages from a specific index.
|
|
445
|
+
* Negative indices count from the end, matching Array.prototype.slice.
|
|
363
446
|
*/
|
|
364
447
|
getFrom(index: number): StoredMessage[] {
|
|
365
|
-
|
|
448
|
+
const len = this.length();
|
|
449
|
+
const start = index < 0 ? Math.max(0, len + index) : Math.min(index, len);
|
|
450
|
+
return this.getWindow(start, len - start).messages;
|
|
366
451
|
}
|
|
367
452
|
|
|
368
453
|
/**
|
|
369
454
|
* Get the last N messages.
|
|
370
455
|
*/
|
|
371
456
|
getTail(count: number): StoredMessage[] {
|
|
372
|
-
const
|
|
373
|
-
|
|
457
|
+
const len = this.length();
|
|
458
|
+
const n = Math.max(0, Math.min(count, len));
|
|
459
|
+
return this.getWindow(len - n, n).messages;
|
|
374
460
|
}
|
|
375
461
|
|
|
376
462
|
/**
|
|
@@ -391,21 +477,62 @@ export class MessageStore {
|
|
|
391
477
|
return tokens;
|
|
392
478
|
}
|
|
393
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Closed-loop calibration multiplier applied to every estimate (default 1).
|
|
482
|
+
* Owned by the strategy: it compares real `usage` totals against the
|
|
483
|
+
* compile-time estimate and feeds the EMA back here, so the store's
|
|
484
|
+
* numbers track the live model/content mix instead of a fixed heuristic.
|
|
485
|
+
*/
|
|
486
|
+
private tokenCalibration = 1;
|
|
487
|
+
|
|
488
|
+
setTokenCalibration(factor: number): void {
|
|
489
|
+
if (Number.isFinite(factor) && factor > 0.25 && factor < 4) this.tokenCalibration = factor;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
getTokenCalibration(): number {
|
|
493
|
+
return this.tokenCalibration;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Hidden-CoT price for a signed thinking block whose text was summarized/
|
|
498
|
+
* redacted away (2026-07-12): the block estimates as EMPTY client-side, but
|
|
499
|
+
* signed replay bills the FULL original chain of thought. Measured on
|
|
500
|
+
* mythos production turns: median ~608 tokens/block, mean ~590, p90 ~1210.
|
|
501
|
+
* A per-block `tokenEstimate` stamped at creation (from usage residuals)
|
|
502
|
+
* takes precedence; this constant is the fallback for unstamped history.
|
|
503
|
+
*/
|
|
504
|
+
static readonly HIDDEN_THINKING_TOKENS_DEFAULT = 600;
|
|
505
|
+
|
|
394
506
|
private estimateBlockTokens(block: ContentBlock): number {
|
|
507
|
+
return Math.round(this.estimateBlockTokensRaw(block) * this.tokenCalibration);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private estimateBlockTokensRaw(block: ContentBlock): number {
|
|
395
511
|
switch (block.type) {
|
|
396
512
|
case 'text':
|
|
397
513
|
return this.tokenEstimator(block.text);
|
|
398
|
-
case 'thinking':
|
|
514
|
+
case 'thinking': {
|
|
515
|
+
// Stamped price wins; a signed-but-empty block is a HIDDEN full CoT
|
|
516
|
+
// (never "no thinking") — price it at the measured default.
|
|
517
|
+
const stamped = (block as { tokenEstimate?: number }).tokenEstimate;
|
|
518
|
+
if (typeof stamped === 'number') return stamped;
|
|
519
|
+
const hasSignature =
|
|
520
|
+
typeof (block as { signature?: string }).signature === 'string' &&
|
|
521
|
+
((block as { signature?: string }).signature as string).length > 0;
|
|
522
|
+
if (hasSignature && (!block.thinking || block.thinking.length === 0)) {
|
|
523
|
+
return MessageStore.HIDDEN_THINKING_TOKENS_DEFAULT;
|
|
524
|
+
}
|
|
399
525
|
return this.tokenEstimator(block.thinking);
|
|
526
|
+
}
|
|
400
527
|
case 'tool_use':
|
|
401
|
-
return
|
|
528
|
+
return jsonTokenEstimator(JSON.stringify(block.input)) + 20; // overhead for name, id
|
|
402
529
|
case 'tool_result':
|
|
403
530
|
if (!block.content) return 0;
|
|
404
531
|
if (typeof block.content === 'string') {
|
|
405
|
-
return
|
|
532
|
+
return jsonTokenEstimator(block.content);
|
|
406
533
|
}
|
|
407
534
|
if (Array.isArray(block.content)) {
|
|
408
|
-
return block.content.reduce((sum, b) => sum + this.
|
|
535
|
+
return block.content.reduce((sum, b) => sum + this.estimateBlockTokensRaw(b), 0);
|
|
409
536
|
}
|
|
410
537
|
return 0;
|
|
411
538
|
case 'image':
|
|
@@ -429,6 +556,8 @@ export class MessageStore {
|
|
|
429
556
|
getFrom: (index) => this.getFrom(index),
|
|
430
557
|
getTail: (count) => this.getTail(count),
|
|
431
558
|
length: () => this.length(),
|
|
559
|
+
setTokenCalibration: (f: number) => this.setTokenCalibration(f),
|
|
560
|
+
getTokenCalibration: () => this.getTokenCalibration(),
|
|
432
561
|
estimateTokens: (msg) => this.estimateTokens(msg),
|
|
433
562
|
};
|
|
434
563
|
}
|
|
@@ -545,7 +674,35 @@ export class MessageStore {
|
|
|
545
674
|
return state as StoredMessageInternal[];
|
|
546
675
|
}
|
|
547
676
|
|
|
677
|
+
private getSliceInternal(offset: number, limit: number): StoredMessageInternal[] {
|
|
678
|
+
// Windowed read — O(window) JSON conversion instead of materializing
|
|
679
|
+
// the entire state slot. getStateSlice landed in chronicle 0.2.2 and
|
|
680
|
+
// returns the window as a JSON-array Buffer; feature-detect so boxes
|
|
681
|
+
// on <= 0.2.1 fall back to full materialization (same pattern as
|
|
682
|
+
// getInternal below).
|
|
683
|
+
const s = this.store as { getStateSlice?: (id: string, offset: number, limit: number) => Buffer | null };
|
|
684
|
+
if (typeof s.getStateSlice === 'function') {
|
|
685
|
+
const buf = s.getStateSlice(this.stateId, offset, limit);
|
|
686
|
+
if (!buf) return [];
|
|
687
|
+
return JSON.parse(buf.toString('utf-8')) as StoredMessageInternal[];
|
|
688
|
+
}
|
|
689
|
+
return this.getAllInternal().slice(offset, offset + limit);
|
|
690
|
+
}
|
|
691
|
+
|
|
548
692
|
private getInternal(index: number): StoredMessageInternal | null {
|
|
693
|
+
// Point lookup through chronicle's per-item cache — O(item size).
|
|
694
|
+
// Never fetch the full state for a single index: with a 4.6k-message
|
|
695
|
+
// session, each full `getStateJson` materialization cost ~15ms, and
|
|
696
|
+
// per-entry get() loops turned renders into minutes (observed on
|
|
697
|
+
// Lena, 2026-07-02, /debug/context at 51–108s).
|
|
698
|
+
//
|
|
699
|
+
// Feature-detect: getStateItemJson landed in chronicle 0.2.2; boxes
|
|
700
|
+
// still on <= 0.2.1 (npm copies) fall back to the full-materialization
|
|
701
|
+
// path so a routine `git pull` of this package can never crash them.
|
|
702
|
+
if (typeof (this.store as { getStateItemJson?: unknown }).getStateItemJson === 'function') {
|
|
703
|
+
const item = this.store.getStateItemJson(this.stateId, index);
|
|
704
|
+
return (item as StoredMessageInternal | null) ?? null;
|
|
705
|
+
}
|
|
549
706
|
const all = this.getAllInternal();
|
|
550
707
|
return all[index] ?? null;
|
|
551
708
|
}
|
|
@@ -554,6 +711,7 @@ export class MessageStore {
|
|
|
554
711
|
internal: StoredMessageInternal,
|
|
555
712
|
id: MessageId,
|
|
556
713
|
_index: number,
|
|
714
|
+
resolveBlobs: boolean = true,
|
|
557
715
|
): StoredMessage {
|
|
558
716
|
const stored: StoredMessage = {
|
|
559
717
|
id,
|
|
@@ -570,7 +728,12 @@ export class MessageStore {
|
|
|
570
728
|
// so /undo of a 800-message conversation forked ~400 messages back.
|
|
571
729
|
sequence: internal.sequence,
|
|
572
730
|
participant: internal.participant,
|
|
573
|
-
|
|
731
|
+
// When resolveBlobs is false, blob_ref placeholder blocks are passed
|
|
732
|
+
// through un-inflated (StoredContentBlock ⊂ wire-safe superset of
|
|
733
|
+
// ContentBlock for viewer purposes).
|
|
734
|
+
content: resolveBlobs
|
|
735
|
+
? this.blobManager.resolveBlobs(internal.content)
|
|
736
|
+
: (internal.content as unknown as ContentBlock[]),
|
|
574
737
|
metadata: internal.metadata,
|
|
575
738
|
timestamp: new Date(internal.timestamp),
|
|
576
739
|
causedBy: internal.causedBy,
|
|
@@ -587,6 +750,36 @@ export class MessageStore {
|
|
|
587
750
|
/**
|
|
588
751
|
* Default token estimator: chars / 4
|
|
589
752
|
*/
|
|
753
|
+
/**
|
|
754
|
+
* Content-class token rates (2026-07-12, measured on mythos production
|
|
755
|
+
* requests by reconciling real `usage` against per-class char counts):
|
|
756
|
+
* - prose (Discord multiparty, markdown, emoji) ≈ 2.9 chars/token
|
|
757
|
+
* - JSON / tool i/o / code ≈ 2.3 chars/token
|
|
758
|
+
* The old flat chars/4 under-priced real windows by ~1.7-1.9x (a 183.6k
|
|
759
|
+
* "hard budget" compiled to a 344k request — straight into the refusal
|
|
760
|
+
* band). Rates are deliberately slightly conservative; the closed-loop
|
|
761
|
+
* calibration multiplier trims the residual per agent.
|
|
762
|
+
*/
|
|
763
|
+
const PROSE_CHARS_PER_TOKEN = 2.9;
|
|
764
|
+
const DENSE_CHARS_PER_TOKEN = 2.3;
|
|
765
|
+
|
|
590
766
|
function defaultTokenEstimator(text: string): number {
|
|
591
|
-
|
|
767
|
+
if (!text) return 0;
|
|
768
|
+
// Cheap density probe: JSON/code punctuation and non-ASCII share.
|
|
769
|
+
let dense = 0;
|
|
770
|
+
const n = Math.min(text.length, 2000);
|
|
771
|
+
for (let i = 0; i < n; i++) {
|
|
772
|
+
const c = text.charCodeAt(i);
|
|
773
|
+
if (c > 126) { dense++; continue; } // non-ASCII (emoji, accents, CJK)
|
|
774
|
+
const ch = text[i];
|
|
775
|
+
if (ch === '{' || ch === '}' || ch === '[' || ch === ']' || ch === '"' || ch === ':' || ch === '_' || ch === '/' || ch === '=' || ch === '`') dense++;
|
|
776
|
+
}
|
|
777
|
+
const rate = dense / n > 0.12 ? DENSE_CHARS_PER_TOKEN : PROSE_CHARS_PER_TOKEN;
|
|
778
|
+
return Math.ceil(text.length / rate);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** JSON-ish payloads (tool inputs/results) always use the dense rate. */
|
|
782
|
+
function jsonTokenEstimator(text: string): number {
|
|
783
|
+
if (!text) return 0;
|
|
784
|
+
return Math.ceil(text.length / DENSE_CHARS_PER_TOKEN);
|
|
592
785
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase channel — a process-wide observability hook for labelling the
|
|
3
|
+
* synchronous operation the main thread is currently running.
|
|
4
|
+
*
|
|
5
|
+
* Long synchronous work (context assembly, compression, merge-graph walks) can
|
|
6
|
+
* wedge the single-threaded event loop. An external liveness watchdog (in the
|
|
7
|
+
* host/agent-framework) can detect the wedge but not name what was running —
|
|
8
|
+
* unless the running code labels itself here. This module is a no-op sink by
|
|
9
|
+
* default; the watchdog installs `report` on start so labels reach its wedge
|
|
10
|
+
* report. context-manager owns the channel (no upward dependency); the watchdog
|
|
11
|
+
* (which depends on context-manager) wires it.
|
|
12
|
+
*/
|
|
13
|
+
const stack: string[] = [];
|
|
14
|
+
|
|
15
|
+
export const phaseChannel: {
|
|
16
|
+
/** Installed by the watchdog; receives the current (innermost) phase label. */
|
|
17
|
+
report: (label: string) => void;
|
|
18
|
+
} = {
|
|
19
|
+
report: () => {},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Enter a named phase. Returns a disposer that restores the previous phase. */
|
|
23
|
+
export function enterPhase(label: string): () => void {
|
|
24
|
+
stack.push(label);
|
|
25
|
+
phaseChannel.report(label);
|
|
26
|
+
let done = false;
|
|
27
|
+
return () => {
|
|
28
|
+
if (done) return;
|
|
29
|
+
done = true;
|
|
30
|
+
// Pop this label (tolerate out-of-order disposal defensively).
|
|
31
|
+
const idx = stack.lastIndexOf(label);
|
|
32
|
+
if (idx !== -1) stack.splice(idx, 1);
|
|
33
|
+
phaseChannel.report(stack[stack.length - 1] ?? 'idle');
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Run a synchronous section under a phase label (nesting-safe). */
|
|
38
|
+
export function withPhase<T>(label: string, fn: () => T): T {
|
|
39
|
+
const leave = enterPhase(label);
|
|
40
|
+
try {
|
|
41
|
+
return fn();
|
|
42
|
+
} finally {
|
|
43
|
+
leave();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Run an async section under a phase label. Note: this labels across awaits,
|
|
48
|
+
* which is what we want — the synchronous spans inside (where a wedge would
|
|
49
|
+
* occur) stay attributed to this phase. */
|
|
50
|
+
export async function withPhaseAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
|
51
|
+
const leave = enterPhase(label);
|
|
52
|
+
try {
|
|
53
|
+
return await fn();
|
|
54
|
+
} finally {
|
|
55
|
+
leave();
|
|
56
|
+
}
|
|
57
|
+
}
|