@animalabs/context-manager 0.5.4 → 0.5.5
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 +15 -1
- package/dist/src/adaptive/kv-control.d.ts.map +1 -1
- package/dist/src/adaptive/kv-control.js +91 -25
- package/dist/src/adaptive/kv-control.js.map +1 -1
- package/dist/src/adaptive/picker.d.ts +13 -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.map +1 -1
- package/dist/src/adaptive/strategies/kv-stable.js +53 -2
- package/dist/src/adaptive/strategies/kv-stable.js.map +1 -1
- package/dist/src/context-manager.d.ts +10 -5
- package/dist/src/context-manager.d.ts.map +1 -1
- package/dist/src/context-manager.js +9 -0
- package/dist/src/context-manager.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.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 +40 -6
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +136 -17
- package/dist/src/strategies/autobiographical.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 +30 -6
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.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/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 +175 -3
- package/dist/test/speculation-cap.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/adaptive/kv-control.ts +102 -25
- package/src/adaptive/picker.ts +13 -0
- package/src/adaptive/strategies/kv-stable.ts +40 -2
- package/src/context-manager.ts +13 -2
- package/src/index.ts +3 -0
- package/src/phase-channel.ts +57 -0
- package/src/strategies/autobiographical.ts +139 -19
- package/src/types/index.ts +1 -0
- package/src/types/strategy.ts +31 -2
package/src/adaptive/picker.ts
CHANGED
|
@@ -81,6 +81,19 @@ 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.
|
|
@@ -69,20 +69,56 @@ export class KvStableStrategy implements FoldingStrategy {
|
|
|
69
69
|
private solve(budget: FoldingBudget): Map<ChunkId, number> {
|
|
70
70
|
const tree = new SummaryTree(this.inputs);
|
|
71
71
|
|
|
72
|
-
// Flat zone (forced raw, never folded): head, tail,
|
|
73
|
-
// their carried resolution, never folded): locked.
|
|
72
|
+
// Flat zone (forced raw, never folded): head, tail, classic pins. Frozen
|
|
73
|
+
// (kept at their carried resolution, never folded): locked. V2 dynamic pins
|
|
74
|
+
// (`pinLevel` / `pinMaxLevel`) are honored as a fixed level or a hard fold-
|
|
75
|
+
// depth cap — see `planControlledFrontier`.
|
|
74
76
|
const rawZone = new Set<ChunkId>();
|
|
75
77
|
const frozen = new Set<ChunkId>();
|
|
78
|
+
const fixedLevels = new Map<ChunkId, number>();
|
|
79
|
+
const pinCaps = new Map<ChunkId, number>();
|
|
80
|
+
const fixedPins: Array<{ id: ChunkId; level: number }> = [];
|
|
76
81
|
let now = 0;
|
|
77
82
|
for (const c of this.inputs.chunks) {
|
|
78
83
|
if (c.sequence > now) now = c.sequence;
|
|
79
84
|
if (this.inputs.headChunkIds.has(c.id) || this.inputs.tailChunkIds.has(c.id) || c.pinned) {
|
|
80
85
|
rawZone.add(c.id);
|
|
86
|
+
} else if (c.pinLevel !== undefined) {
|
|
87
|
+
// Pin-at-level-k: fix exactly at k (0 = raw). k=0 is equivalent to a
|
|
88
|
+
// classic raw pin, so route it through the raw zone; k>0 is resolved to
|
|
89
|
+
// its whole L_k node below (group-consistent — see the loop after).
|
|
90
|
+
if (c.pinLevel <= 0) rawZone.add(c.id);
|
|
91
|
+
else fixedPins.push({ id: c.id, level: c.pinLevel });
|
|
92
|
+
} else if (c.pinMaxLevel !== undefined) {
|
|
93
|
+
// Pin-max-level: a hard fold-depth cap. maxLevel 0 ≡ classic raw pin.
|
|
94
|
+
if (c.pinMaxLevel <= 0) rawZone.add(c.id);
|
|
95
|
+
else pinCaps.set(c.id, c.pinMaxLevel);
|
|
96
|
+
if (c.lockedByAgent) frozen.add(c.id);
|
|
81
97
|
} else if (c.lockedByAgent) {
|
|
82
98
|
frozen.add(c.id);
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
101
|
|
|
102
|
+
// Group-consistency for pin-at-level-k: an L_k recall pair is atomic over
|
|
103
|
+
// its whole covered range, so "cut through the L_k node" (design §7) fixes
|
|
104
|
+
// EVERY leaf under that node at k — not just the addressed chunk. Fixing a
|
|
105
|
+
// single sub-chunk while its siblings render raw is an unrenderable (and
|
|
106
|
+
// non-converging) frontier. Clamp k to the deepest produced level for the
|
|
107
|
+
// chunk; if none exists, fall back to fixing just the chunk (the controller
|
|
108
|
+
// clamps it further). Skip leaves already forced raw (head/tail/classic pin).
|
|
109
|
+
for (const { id, level } of fixedPins) {
|
|
110
|
+
const eff = Math.min(level, tree.maxLevel(id));
|
|
111
|
+
if (eff <= 0) { rawZone.add(id); continue; }
|
|
112
|
+
const node = tree.ancestorAt(id, eff);
|
|
113
|
+
if (!node) { fixedLevels.set(id, eff); continue; }
|
|
114
|
+
for (const leaf of node.leafChunkIds) {
|
|
115
|
+
if (rawZone.has(leaf)) continue;
|
|
116
|
+
// Finest requirement wins if two pins overlap a leaf.
|
|
117
|
+
const prev = fixedLevels.get(leaf);
|
|
118
|
+
fixedLevels.set(leaf, prev === undefined ? eff : Math.min(prev, eff));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
86
122
|
return planControlledFrontier(this.inputs, tree, {
|
|
87
123
|
previous: this.fPrev,
|
|
88
124
|
// Bidirectional within the slack band: fold when over the hard budget,
|
|
@@ -95,6 +131,8 @@ export class KvStableStrategy implements FoldingStrategy {
|
|
|
95
131
|
reachTokens: this.opts.reachTokens,
|
|
96
132
|
rawZone,
|
|
97
133
|
frozen,
|
|
134
|
+
fixedLevels,
|
|
135
|
+
pinCaps,
|
|
98
136
|
now,
|
|
99
137
|
mergeThreshold: this.opts.mergeThreshold,
|
|
100
138
|
}).resolutions;
|
package/src/context-manager.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
ContextInjection,
|
|
17
17
|
CompileResult,
|
|
18
18
|
ProtectedRange,
|
|
19
|
+
PinLevelOptions,
|
|
19
20
|
SearchQuery,
|
|
20
21
|
SearchResult,
|
|
21
22
|
SummaryEntry,
|
|
@@ -640,19 +641,29 @@ export class ContextManager {
|
|
|
640
641
|
*
|
|
641
642
|
* Throws if the active strategy doesn't support pins.
|
|
642
643
|
*/
|
|
643
|
-
pinRange(firstMessageId: MessageId, lastMessageId: MessageId, opts?:
|
|
644
|
+
pinRange(firstMessageId: MessageId, lastMessageId: MessageId, opts?: PinLevelOptions): string {
|
|
644
645
|
if (!isPinnableStrategy(this.strategy)) {
|
|
645
646
|
throw new Error('Active strategy does not support pins');
|
|
646
647
|
}
|
|
647
648
|
return this.strategy.pinRange(firstMessageId, lastMessageId, opts);
|
|
648
649
|
}
|
|
649
650
|
|
|
651
|
+
/**
|
|
652
|
+
* V2 dynamic pin-at-level-k: fix a range to render at EXACTLY fold level
|
|
653
|
+
* `level` (0 = raw) — the frontier cut passes through that L_k node. Honored
|
|
654
|
+
* only by `foldingStrategy: 'kv-stable'`; other strategies fall back to
|
|
655
|
+
* treating the range as raw. Returns the new pin id.
|
|
656
|
+
*/
|
|
657
|
+
pinAtLevel(firstMessageId: MessageId, lastMessageId: MessageId, level: number, opts?: { name?: string }): string {
|
|
658
|
+
return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
|
|
659
|
+
}
|
|
660
|
+
|
|
650
661
|
/**
|
|
651
662
|
* Mark a single message as a "document" (semantically a body of
|
|
652
663
|
* information to retain in full). Same effect as a single-message pin
|
|
653
664
|
* with `kind: 'document'`. Returns the new pin id.
|
|
654
665
|
*/
|
|
655
|
-
markDocument(messageId: MessageId, opts?:
|
|
666
|
+
markDocument(messageId: MessageId, opts?: PinLevelOptions): string {
|
|
656
667
|
if (!isPinnableStrategy(this.strategy)) {
|
|
657
668
|
throw new Error('Active strategy does not support documents');
|
|
658
669
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
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
10
|
export type { MessageStoreEvent, MessageStoreListener } from './message-store.js';
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { JsStore } from '@animalabs/chronicle';
|
|
2
2
|
import type { Membrane, NormalizedRequest, ContentBlock, CompleteOptions } from '@animalabs/membrane';
|
|
3
3
|
import { NativeFormatter } from '@animalabs/membrane';
|
|
4
|
+
import { phaseChannel } from '../phase-channel.js';
|
|
4
5
|
import type {
|
|
5
6
|
ContextStrategy,
|
|
6
7
|
ResettableStrategy,
|
|
@@ -15,6 +16,7 @@ import type {
|
|
|
15
16
|
SummaryLevel,
|
|
16
17
|
SummaryEntry,
|
|
17
18
|
ProtectedRange,
|
|
19
|
+
PinLevelOptions,
|
|
18
20
|
SearchQuery,
|
|
19
21
|
SearchResult,
|
|
20
22
|
RenderStats,
|
|
@@ -252,6 +254,21 @@ export interface AutobiographicalProgressSnapshot {
|
|
|
252
254
|
pending: boolean;
|
|
253
255
|
}
|
|
254
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Validate + normalize the optional V2 pin fold-depth bounds. Returns only the
|
|
259
|
+
* fields that are present and valid (non-negative integers), so a classic pin
|
|
260
|
+
* with no bounds persists exactly as before. `level` takes precedence over
|
|
261
|
+
* `maxLevel` (pin-at-k is stronger than a cap), so they're never both emitted.
|
|
262
|
+
*/
|
|
263
|
+
function normalizePinLevels(opts?: PinLevelOptions): { level?: number; maxLevel?: number } {
|
|
264
|
+
const clean = (v: number | undefined): number | undefined =>
|
|
265
|
+
typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined;
|
|
266
|
+
const level = clean(opts?.level);
|
|
267
|
+
if (level !== undefined) return { level };
|
|
268
|
+
const maxLevel = clean(opts?.maxLevel);
|
|
269
|
+
return maxLevel !== undefined ? { maxLevel } : {};
|
|
270
|
+
}
|
|
271
|
+
|
|
255
272
|
/**
|
|
256
273
|
* Autobiographical chunking strategy.
|
|
257
274
|
* Compresses old conversation chunks into summaries in the model's own words.
|
|
@@ -271,6 +288,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
271
288
|
protected pendingCompression: Promise<void> | null = null;
|
|
272
289
|
protected compressionQueue: number[] = [];
|
|
273
290
|
protected _compressionCount = 0;
|
|
291
|
+
/**
|
|
292
|
+
* Monotonic counter of tick() operations that actually processed a queue item
|
|
293
|
+
* (compressed a chunk or executed a merge). `driveSpeculativeDrain` recurses
|
|
294
|
+
* while this advances — a length-delta check would falsely read "no progress"
|
|
295
|
+
* when a productive tick also enqueues a follow-on item (net queue length
|
|
296
|
+
* unchanged), halting the drain with work still queued.
|
|
297
|
+
*/
|
|
298
|
+
protected _drainProgress = 0;
|
|
274
299
|
|
|
275
300
|
// Hierarchical state
|
|
276
301
|
protected summaries: SummaryEntry[] = [];
|
|
@@ -583,7 +608,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
583
608
|
* Pin a range of messages so they aren't compressed and render raw at
|
|
584
609
|
* their original position. Returns the pin id.
|
|
585
610
|
*/
|
|
586
|
-
pinRange(firstMessageId: string, lastMessageId: string, opts?:
|
|
611
|
+
pinRange(firstMessageId: string, lastMessageId: string, opts?: PinLevelOptions): string {
|
|
587
612
|
const id = `pin-${this.pinIdCounter++}`;
|
|
588
613
|
this.pins.push({
|
|
589
614
|
id,
|
|
@@ -592,6 +617,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
592
617
|
kind: 'pin',
|
|
593
618
|
name: opts?.name,
|
|
594
619
|
created: Date.now(),
|
|
620
|
+
...normalizePinLevels(opts),
|
|
595
621
|
});
|
|
596
622
|
this.persistPins();
|
|
597
623
|
return id;
|
|
@@ -602,7 +628,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
602
628
|
* information the agent wants to retain in full. Functionally a
|
|
603
629
|
* single-message pin with `kind: 'document'`.
|
|
604
630
|
*/
|
|
605
|
-
markDocument(messageId: string, opts?:
|
|
631
|
+
markDocument(messageId: string, opts?: PinLevelOptions): string {
|
|
606
632
|
const id = `pin-${this.pinIdCounter++}`;
|
|
607
633
|
this.pins.push({
|
|
608
634
|
id,
|
|
@@ -611,11 +637,22 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
611
637
|
kind: 'document',
|
|
612
638
|
name: opts?.name,
|
|
613
639
|
created: Date.now(),
|
|
640
|
+
...normalizePinLevels(opts),
|
|
614
641
|
});
|
|
615
642
|
this.persistPins();
|
|
616
643
|
return id;
|
|
617
644
|
}
|
|
618
645
|
|
|
646
|
+
/**
|
|
647
|
+
* V2 dynamic pin-at-level-k convenience: fix a range to render at EXACTLY
|
|
648
|
+
* fold level `level` (0 = raw). Honored only by `foldingStrategy: 'kv-stable'`;
|
|
649
|
+
* other strategies fall back to treating the range as raw. Equivalent to
|
|
650
|
+
* `pinRange(first, last, { level })`.
|
|
651
|
+
*/
|
|
652
|
+
pinAtLevel(firstMessageId: string, lastMessageId: string, level: number, opts?: { name?: string }): string {
|
|
653
|
+
return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
|
|
654
|
+
}
|
|
655
|
+
|
|
619
656
|
/** Remove a pin or document mark by id. Returns true if removed. */
|
|
620
657
|
unpin(pinId: string): boolean {
|
|
621
658
|
const before = this.pins.length;
|
|
@@ -735,6 +772,42 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
735
772
|
return out;
|
|
736
773
|
}
|
|
737
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Resolve the V2 dynamic-pin fold-depth bounds (`ProtectedRange.level` /
|
|
777
|
+
* `maxLevel`) to message positions. Only pins that carry a bound appear; a
|
|
778
|
+
* classic raw pin (no bound) is absent here and handled by `pinnedPositions`.
|
|
779
|
+
* When ranges overlap, the FINEST requirement wins (lowest effective level):
|
|
780
|
+
* a fixed `level` clamps both ends; a `maxLevel` only caps depth. Honored
|
|
781
|
+
* solely by the KV-stable controller — see `ProtectedRange`.
|
|
782
|
+
*/
|
|
783
|
+
protected pinLevelBounds(messages: StoredMessage[]): Map<number, { level?: number; maxLevel?: number }> {
|
|
784
|
+
const out = new Map<number, { level?: number; maxLevel?: number }>();
|
|
785
|
+
if (this.pins.length === 0) return out;
|
|
786
|
+
const positionOf = new Map<string, number>();
|
|
787
|
+
for (let i = 0; i < messages.length; i++) positionOf.set(messages[i].id, i);
|
|
788
|
+
|
|
789
|
+
for (const pin of this.pins) {
|
|
790
|
+
if (pin.level === undefined && pin.maxLevel === undefined) continue;
|
|
791
|
+
const first = positionOf.get(pin.firstMessageId);
|
|
792
|
+
const last = positionOf.get(pin.lastMessageId);
|
|
793
|
+
if (first === undefined || last === undefined) continue;
|
|
794
|
+
const lo = Math.min(first, last);
|
|
795
|
+
const hi = Math.max(first, last);
|
|
796
|
+
for (let i = lo; i <= hi; i++) {
|
|
797
|
+
const prev = out.get(i) ?? {};
|
|
798
|
+
// A fixed level is the strongest constraint; when two pins fix the same
|
|
799
|
+
// position, the shallower (lower) level wins (finest requirement).
|
|
800
|
+
if (pin.level !== undefined) {
|
|
801
|
+
prev.level = prev.level === undefined ? pin.level : Math.min(prev.level, pin.level);
|
|
802
|
+
} else if (pin.maxLevel !== undefined) {
|
|
803
|
+
prev.maxLevel = prev.maxLevel === undefined ? pin.maxLevel : Math.min(prev.maxLevel, pin.maxLevel);
|
|
804
|
+
}
|
|
805
|
+
out.set(i, prev);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return out;
|
|
809
|
+
}
|
|
810
|
+
|
|
738
811
|
/**
|
|
739
812
|
* Append a summary to the in-memory list and to the chronicle AppendLog.
|
|
740
813
|
* Single point so subclasses inherit persistence.
|
|
@@ -1004,20 +1077,31 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1004
1077
|
*/
|
|
1005
1078
|
protected driveSpeculativeDrain(ctx: StrategyContext): void {
|
|
1006
1079
|
if (this.pendingCompression) return;
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1080
|
+
// Merges consolidate existing L_k summaries into L_{k+1} and REDUCE the
|
|
1081
|
+
// unmerged-L1 count; L1 compression PRODUCES new unmerged L1s. The
|
|
1082
|
+
// speculation cap / preflight throttle *production* only — they must never
|
|
1083
|
+
// gate merges, otherwise exceeding the cap (e.g. after a manual backfill)
|
|
1084
|
+
// permanently deadlocks the drain: too many unmerged L1s trips the cap,
|
|
1085
|
+
// which blocks the very merges that would bring the count back down.
|
|
1086
|
+
const hasMerges = this.config.hierarchical === true && this.mergeQueue.length > 0;
|
|
1087
|
+
const hasCompression = this.compressionQueue.length > 0;
|
|
1088
|
+
if (!hasCompression && !hasMerges) return;
|
|
1089
|
+
// Only bail when the *sole* available work is L1 compression that the cap
|
|
1090
|
+
// or preflight currently forbids. Merge work always proceeds.
|
|
1091
|
+
if (!hasMerges && (this.isAtSpeculativeCap() || !this.shouldCompressPreflight())) return;
|
|
1092
|
+
|
|
1093
|
+
const progressBefore = this._drainProgress;
|
|
1013
1094
|
|
|
1014
1095
|
this.tick(ctx)
|
|
1015
1096
|
.then(() => {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
//
|
|
1019
|
-
|
|
1020
|
-
|
|
1097
|
+
// Progress = the tick actually processed a queue item (compress or
|
|
1098
|
+
// merge), tracked by `_drainProgress`. A queue-length delta is the
|
|
1099
|
+
// wrong signal: a productive merge tick can also enqueue a follow-on
|
|
1100
|
+
// merge, leaving the length unchanged — which the old check misread as
|
|
1101
|
+
// "no progress" and halted the drain mid-backlog. A genuine no-op tick
|
|
1102
|
+
// (empty queues, at-cap with no merges, no membrane) doesn't advance
|
|
1103
|
+
// the counter, so this still stops cleanly (no runaway recursion).
|
|
1104
|
+
if (this._drainProgress === progressBefore) return;
|
|
1021
1105
|
// Recurse to drain more. queueMicrotask defers until the current
|
|
1022
1106
|
// task is done, letting other code (the agent's stream consumer)
|
|
1023
1107
|
// interleave.
|
|
@@ -1029,14 +1113,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1029
1113
|
}
|
|
1030
1114
|
|
|
1031
1115
|
/**
|
|
1032
|
-
* Whether the
|
|
1116
|
+
* Whether the count of *produced, unmerged* L1 summaries has reached the cap
|
|
1033
1117
|
* configured by `maxSpeculativeL1s`. If no cap is set, always false.
|
|
1118
|
+
*
|
|
1119
|
+
* The cap bounds how many L1 summaries may sit un-consolidated before the
|
|
1120
|
+
* strategy must merge them into L_{k+1} (bounding prefix churn / merge debt).
|
|
1121
|
+
* It deliberately does NOT count the pending `compressionQueue`: that queue is
|
|
1122
|
+
* the backlog of work to be *drained*, not produced summaries. Counting it
|
|
1123
|
+
* here would let a large backlog permanently trip the cap and block the very
|
|
1124
|
+
* compression that would clear it — a deadlock (merges relieve the cap, but
|
|
1125
|
+
* compression of the backlog never resumes). The throttle is on produced L1s;
|
|
1126
|
+
* the queue drains freely, with merges keeping the unmerged count under the cap.
|
|
1034
1127
|
*/
|
|
1035
1128
|
protected isAtSpeculativeCap(): boolean {
|
|
1036
1129
|
const cap = this.config.maxSpeculativeL1s;
|
|
1037
1130
|
if (cap === undefined || cap < 0) return false;
|
|
1038
1131
|
const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
|
|
1039
|
-
return unmergedL1s
|
|
1132
|
+
return unmergedL1s > cap;
|
|
1040
1133
|
}
|
|
1041
1134
|
|
|
1042
1135
|
/**
|
|
@@ -1050,6 +1143,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1050
1143
|
}
|
|
1051
1144
|
|
|
1052
1145
|
async tick(ctx: StrategyContext): Promise<void> {
|
|
1146
|
+
phaseChannel.report('compress-tick'); // liveness-watchdog phase
|
|
1053
1147
|
if (this.pendingCompression) return;
|
|
1054
1148
|
|
|
1055
1149
|
if (!ctx.membrane) {
|
|
@@ -1057,9 +1151,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1057
1151
|
return;
|
|
1058
1152
|
}
|
|
1059
1153
|
|
|
1060
|
-
// Priority 1: Compress raw chunks → L1
|
|
1061
|
-
|
|
1154
|
+
// Priority 1: Compress raw chunks → L1. Skipped while at the speculative
|
|
1155
|
+
// cap (maxSpeculativeL1s) so we don't pile up more unmerged L1s; the merge
|
|
1156
|
+
// priority below still runs to consolidate existing L1s and relieve the cap.
|
|
1157
|
+
// No cap configured → isAtSpeculativeCap() is always false → unchanged.
|
|
1158
|
+
if (this.compressionQueue.length > 0 && !this.isAtSpeculativeCap()) {
|
|
1062
1159
|
const chunkIndex = this.compressionQueue.shift()!;
|
|
1160
|
+
this._drainProgress++; // consumed a queue item (real work or stale-cleanup)
|
|
1063
1161
|
const chunk = this.chunks[chunkIndex];
|
|
1064
1162
|
|
|
1065
1163
|
if (!chunk || chunk.compressed) return;
|
|
@@ -1085,6 +1183,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1085
1183
|
// and the next tick() retries it.
|
|
1086
1184
|
if (this.config.hierarchical && this.mergeQueue.length > 0) {
|
|
1087
1185
|
const merge = this.mergeQueue[0]!;
|
|
1186
|
+
this._drainProgress++; // executing a merge is real work, even if a
|
|
1187
|
+
// follow-on merge gets enqueued and the queue length nets out unchanged
|
|
1088
1188
|
this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
|
|
1089
1189
|
|
|
1090
1190
|
try {
|
|
@@ -1403,6 +1503,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1403
1503
|
* No system prompt — framing via message structure only.
|
|
1404
1504
|
*/
|
|
1405
1505
|
protected async compressChunkHierarchical(chunk: Chunk, ctx: StrategyContext): Promise<void> {
|
|
1506
|
+
phaseChannel.report('compress-chunk'); // liveness-watchdog phase
|
|
1406
1507
|
if (!ctx.membrane) {
|
|
1407
1508
|
throw new Error('No membrane instance for compression');
|
|
1408
1509
|
}
|
|
@@ -1681,6 +1782,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1681
1782
|
* summaries when the queue eventually drains.
|
|
1682
1783
|
*/
|
|
1683
1784
|
protected checkMergeThreshold(): void {
|
|
1785
|
+
phaseChannel.report('merge-threshold'); // liveness-watchdog phase
|
|
1684
1786
|
if (this.config.speculativeProduction) {
|
|
1685
1787
|
this.checkMergeThresholdRecursive();
|
|
1686
1788
|
return;
|
|
@@ -2148,6 +2250,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2148
2250
|
* See `docs/adaptive-resolution-design.md` §3, §5.
|
|
2149
2251
|
*/
|
|
2150
2252
|
protected selectAdaptive(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
|
|
2253
|
+
phaseChannel.report('context-build'); // liveness-watchdog phase
|
|
2151
2254
|
this.rsBegin();
|
|
2152
2255
|
const entries: ContextEntry[] = [];
|
|
2153
2256
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
@@ -2211,6 +2314,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2211
2314
|
// Pinned-position set so the picker doesn't fold messages the user
|
|
2212
2315
|
// explicitly marked as keep-raw. Built once and reused.
|
|
2213
2316
|
const pinnedSet = this.pinnedPositions(messages);
|
|
2317
|
+
// V2 dynamic-pin fold-depth bounds (level / maxLevel). A position with a
|
|
2318
|
+
// bound is NOT a classic force-raw pin — the KV-stable controller must be
|
|
2319
|
+
// able to move it to/within its bound — so it renders as `pinned: false`
|
|
2320
|
+
// carrying `pinLevel` / `pinMaxLevel` instead.
|
|
2321
|
+
const pinBounds = this.pinLevelBounds(messages);
|
|
2214
2322
|
|
|
2215
2323
|
// O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
|
|
2216
2324
|
// calls during emission.
|
|
@@ -2224,13 +2332,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2224
2332
|
const tokens = msgCap > 0
|
|
2225
2333
|
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
2226
2334
|
: store.estimateTokens(msg);
|
|
2335
|
+
const bound = pinBounds.get(i);
|
|
2227
2336
|
pickerChunks.push({
|
|
2228
2337
|
id: msg.id,
|
|
2229
2338
|
sequence: i,
|
|
2230
2339
|
rawTokens: tokens,
|
|
2231
2340
|
currentResolution: this.resolutions.get(msg.id) ?? 0,
|
|
2232
2341
|
lockedByAgent: this.locked.has(msg.id),
|
|
2233
|
-
|
|
2342
|
+
// A classic pin (in pinnedSet with no level bound) stays force-raw. A
|
|
2343
|
+
// leveled pin is not force-raw; it carries its bound instead.
|
|
2344
|
+
pinned: pinnedSet.has(i) && bound === undefined,
|
|
2345
|
+
pinLevel: bound?.level,
|
|
2346
|
+
pinMaxLevel: bound?.maxLevel,
|
|
2234
2347
|
l1Id: ch?.summaryId,
|
|
2235
2348
|
});
|
|
2236
2349
|
}
|
|
@@ -2279,7 +2392,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2279
2392
|
}
|
|
2280
2393
|
|
|
2281
2394
|
// ----- 4. Run the picker -----
|
|
2282
|
-
|
|
2395
|
+
// The picker's token count ALREADY includes the pinned head+tail (it gets
|
|
2396
|
+
// headTokens/tailTokens in pickerInputs and result.finalTokens covers them).
|
|
2397
|
+
// So the budget it folds against is the full maxTokens — NOT maxTokens-head,
|
|
2398
|
+
// which double-counts the head (reserves it twice: once here, once because
|
|
2399
|
+
// finalTokens already includes it). The old form threw ~head-tokens early at
|
|
2400
|
+
// tight budgets and quietly under-used the budget by ~head everywhere.
|
|
2401
|
+
const totalBudget = maxTokens;
|
|
2283
2402
|
const slack = this.config.compressionSlackRatio ?? 0.1;
|
|
2284
2403
|
const foldingBudget: FoldingBudget = {
|
|
2285
2404
|
totalBudget,
|
|
@@ -2819,6 +2938,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2819
2938
|
* Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
|
|
2820
2939
|
*/
|
|
2821
2940
|
protected selectHierarchical(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
|
|
2941
|
+
phaseChannel.report('context-build'); // liveness-watchdog phase
|
|
2822
2942
|
this.rsBegin();
|
|
2823
2943
|
const entries: ContextEntry[] = [];
|
|
2824
2944
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
package/src/types/index.ts
CHANGED
package/src/types/strategy.ts
CHANGED
|
@@ -186,8 +186,8 @@ export function isResettableStrategy(s: ContextStrategy): s is ResettableStrateg
|
|
|
186
186
|
* original chronological position. Implemented by AutobiographicalStrategy.
|
|
187
187
|
*/
|
|
188
188
|
export interface PinnableStrategy extends ContextStrategy {
|
|
189
|
-
pinRange(firstMessageId: string, lastMessageId: string, opts?:
|
|
190
|
-
markDocument(messageId: string, opts?:
|
|
189
|
+
pinRange(firstMessageId: string, lastMessageId: string, opts?: PinLevelOptions): string;
|
|
190
|
+
markDocument(messageId: string, opts?: PinLevelOptions): string;
|
|
191
191
|
unpin(pinId: string): boolean;
|
|
192
192
|
listPins(): ReadonlyArray<ProtectedRange>;
|
|
193
193
|
}
|
|
@@ -596,6 +596,35 @@ export interface ProtectedRange {
|
|
|
596
596
|
name?: string;
|
|
597
597
|
/** Creation timestamp (ms since epoch). */
|
|
598
598
|
created: number;
|
|
599
|
+
/**
|
|
600
|
+
* Pin-AT-level-k (V2 dynamic pins, `docs/best-fit-frontier-resolution.md` §7).
|
|
601
|
+
* Fix the covered chunks to render at EXACTLY this fold level (0 = raw): the
|
|
602
|
+
* frontier cut passes through the L_k node — neither folded deeper nor
|
|
603
|
+
* un-folded shallower. When set, this range is NOT a classic force-raw pin.
|
|
604
|
+
* Takes precedence over `maxLevel`. Omitted → not a fixed-level pin.
|
|
605
|
+
*
|
|
606
|
+
* Honored only by the KV-stable controller (`foldingStrategy: 'kv-stable'`);
|
|
607
|
+
* other strategies fall back to treating the range as raw (a safe superset).
|
|
608
|
+
*/
|
|
609
|
+
level?: number;
|
|
610
|
+
/**
|
|
611
|
+
* Pin-max-level (V2 dynamic pins): the covered chunks may fold no DEEPER than
|
|
612
|
+
* this level — raw..L_k allowed, deeper forbidden — a hard cap honored even
|
|
613
|
+
* under the window-pressure emergency. `maxLevel: 0` ≡ classic pin-raw.
|
|
614
|
+
* Ignored when `level` is set. Omitted → this pin imposes no depth cap.
|
|
615
|
+
*
|
|
616
|
+
* Honored only by the KV-stable controller; see `level`.
|
|
617
|
+
*/
|
|
618
|
+
maxLevel?: number;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Optional fold-depth bounds for a V2 dynamic pin (see `ProtectedRange`). */
|
|
622
|
+
export interface PinLevelOptions {
|
|
623
|
+
name?: string;
|
|
624
|
+
/** Pin AT exactly this level (0 = raw). Fixes the cut through the L_k node. */
|
|
625
|
+
level?: number;
|
|
626
|
+
/** Fold no deeper than this level (hard cap; `0` ≡ classic pin-raw). */
|
|
627
|
+
maxLevel?: number;
|
|
599
628
|
}
|
|
600
629
|
|
|
601
630
|
/**
|