dsh-switchman 1.10.3 → 1.11.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/README.de.md +5 -4
- package/README.es.md +5 -4
- package/README.fr.md +5 -4
- package/README.it.md +5 -4
- package/README.ja.md +5 -4
- package/README.ko.md +5 -4
- package/README.md +14 -5
- package/README.pt.md +5 -4
- package/README.ru.md +5 -4
- package/README.zh-TW.md +5 -4
- package/README.zh.md +5 -4
- package/client.js +323 -40
- package/cordis.patch.yml +23 -20
- package/host/config.js +51 -7
- package/host/context-trim/fork.js +5 -1
- package/host/context-trim/index.js +329 -178
- package/host/context-trim/metrics.js +7 -7
- package/host/context-trim/state.js +351 -50
- package/host/context-trim/summarizer.js +147 -33
- package/host/context-trim/surface.js +222 -2
- package/host/context-trim/trigger.js +170 -51
- package/host/context-trim/view.js +132 -16
- package/host/context-watch.js +80 -73
- package/host/routes.js +64 -4
- package/package.json +2 -2
|
@@ -12,23 +12,29 @@
|
|
|
12
12
|
* - agent/status idle hook: the only place fold decisions run (design §4:
|
|
13
13
|
* fold at the idle boundary, never mid-stream).
|
|
14
14
|
* - ctx_trim_fold / ctx_trim_status tools: the L1 advisory surface — the
|
|
15
|
-
* session model may fold ranges itself and inspect
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* session model may fold ranges itself and inspect compressible ranges
|
|
16
|
+
* (true sizes only — NEVER a fill percentage or water level; bili lesson
|
|
17
|
+
* #2, docs/ladder-fold.md).
|
|
18
|
+
*
|
|
19
|
+
* MODEL-VISIBILITY INVARIANT: the trim layer registers NO systemPrompt
|
|
20
|
+
* context line. Water levels, view-token estimates, growth numbers, and
|
|
21
|
+
* fold counts are engine internals; the model sees only the byte-stable
|
|
22
|
+
* protocol head and (on demand) the true-size recommendation list. Human
|
|
23
|
+
* diagnostics live in decisions.log.
|
|
19
24
|
*
|
|
20
25
|
* Config fields (host/config.js, all volatile): trimEnabled, trimMode
|
|
21
26
|
* ("off"|"advisory"|"auto"), trimGrowthTokens, trimIncrementTokens,
|
|
22
27
|
* trimPressurePercent, trimEmergencyPercent, trimWindowTokens,
|
|
23
|
-
* trimProtectedTail,
|
|
24
|
-
*
|
|
28
|
+
* trimProtectedTail, trimMaxFoldsPerHour, trimSideModel, trimFoldFallbackModel, trimStateDir,
|
|
29
|
+
* trimLadderEnabled, trimLadderT2Tokens, trimLadderT2MinBlocks,
|
|
30
|
+
* trimLadderT3Tokens, trimLadderT3MinBlocks.
|
|
25
31
|
*/
|
|
26
32
|
|
|
27
33
|
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
28
34
|
import { join } from "node:path";
|
|
29
|
-
import { buildView, estimateTokensOf, injectView, ledgerIsStale, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
|
|
35
|
+
import { buildView, estimateTokensOf, injectView, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
|
|
30
36
|
import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
|
|
31
|
-
import { mechanicalSummary, renderMaterial, summarizeWithModel } from "./summarizer.js";
|
|
37
|
+
import { LADDER_SYSTEM_PROMPT, mechanicalSummary, renderLadderMaterial, renderMaterial, summarizeWithModel } from "./summarizer.js";
|
|
32
38
|
import { createStore } from "./state.js";
|
|
33
39
|
import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } from "./metrics.js";
|
|
34
40
|
import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
|
|
@@ -42,13 +48,16 @@ import { childSessionIdOf } from "../subagent-model.js";
|
|
|
42
48
|
* agent/status and assembleContextFor's context.agent are both the object
|
|
43
49
|
* form). Mirrors the host/context-watch.js:122 precedent. Returns null when
|
|
44
50
|
* nothing usable is present. */
|
|
45
|
-
// ---- cross-layer
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
+
// ---- cross-layer meter registries (meter duality, design §8) ---------------
|
|
52
|
+
// Per session: the latest outbound-view token estimate plus the last sampled
|
|
53
|
+
// provider prompt, refreshed on every ACCEPTED main llm/stream call (the
|
|
54
|
+
// side-dispatch guard keeps auxiliary calls out of both). host/context-watch.js
|
|
55
|
+
// reads the wire-domain bridge over these (recentTrimPromptTokensOf:
|
|
56
|
+
// max(view, wire)) so the watermark meters on the same boundary as the trim
|
|
57
|
+
// pressure tiers; the wire-truth gate decides when neither registry may drive
|
|
58
|
+
// it and the stored tally takes over.
|
|
51
59
|
const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
|
|
60
|
+
const trimWireBySession = new Map(); // sessionId -> { promptTokens, at } — last sampled provider prompt of an accepted MAIN dispatch (side calls never write; a tiny inherited-id usage sample must not lower the watermark's wire floor)
|
|
52
61
|
/** Freshness TTL for the in-memory view/injection registry (watermark and
|
|
53
62
|
* banner display only). NEVER a fold gate: gating folds on "did the surface
|
|
54
63
|
* channel ship recently" deadlocks resumed sessions (2026-11-05). The fold
|
|
@@ -72,33 +81,87 @@ const SIDE_DISPATCH_SKIP_BUDGET = 2;
|
|
|
72
81
|
* accepted main dispatch). */
|
|
73
82
|
const sideDispatchSkipsBySession = new Map();
|
|
74
83
|
|
|
84
|
+
/** Shared wire-truth gate (docs/context-trim.md): the view-registry entry for
|
|
85
|
+
* one session when the recent dispatch stream provably carried the view —
|
|
86
|
+
* null when missing, stale, injected "none", or inside the sticky lastNoneAt
|
|
87
|
+
* window (mixed sessions exist — one real session logged 46 none + 14
|
|
88
|
+
* swapped). Observed 2026-11-04: a view that never reaches the wire must not
|
|
89
|
+
* drive the watermark (view ~80k vs a 176k wire while the force tier stayed
|
|
90
|
+
* silent and the auto-handover never fired). Both watermark accessors read
|
|
91
|
+
* through this gate; itself fail-open. */
|
|
92
|
+
function gatedViewEntry(sessionId, maxAgeMs) {
|
|
93
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
94
|
+
const entry = trimViewBySession.get(sessionId);
|
|
95
|
+
if (entry === undefined) return null;
|
|
96
|
+
if (Date.now() - entry.at > maxAgeMs) return null;
|
|
97
|
+
if (entry.inject === "none") return null;
|
|
98
|
+
if (Number.isFinite(entry.lastNoneAt) && entry.lastNoneAt > 0 && Date.now() - entry.lastNoneAt < maxAgeMs) return null;
|
|
99
|
+
return entry;
|
|
100
|
+
}
|
|
101
|
+
|
|
75
102
|
/** Most recent outbound-view token estimate for a session, or null when
|
|
76
|
-
* missing, non-positive,
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* tally is the only truthful meter). The gate is deliberately sticky: a
|
|
80
|
-
* single mutable dispatch right after frozen ones re-arms maybeFold, but
|
|
81
|
-
* the WATERMARK keeps falling back to the tally until no "none" occurred
|
|
82
|
-
* within the TTL window (mixed sessions exist — one real session logged 46
|
|
83
|
-
* none + 14 swapped). Read by the watermark layer (meter duality); itself
|
|
84
|
-
* fail-open. */
|
|
103
|
+
* missing, non-positive, or gate-rejected. The bare-view half of the meter
|
|
104
|
+
* duality bridge — kept for diagnostics and as the gate primitive; the
|
|
105
|
+
* watermark layer reads the wire-domain accessor below. */
|
|
85
106
|
export function recentTrimViewTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
|
|
86
107
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (entry === undefined) return null;
|
|
90
|
-
if (Date.now() - entry.at > maxAgeMs) return null;
|
|
91
|
-
// Wire-truth gate: a view that never reaches the wire must not drive
|
|
92
|
-
// the watermark (observed 2026-11-04: view ~80k vs a 176k wire while
|
|
93
|
-
// the force tier stayed silent and the auto-handover never fired).
|
|
94
|
-
if (entry.inject === "none") return null;
|
|
95
|
-
if (Number.isFinite(entry.lastNoneAt) && entry.lastNoneAt > 0 && Date.now() - entry.lastNoneAt < maxAgeMs) return null;
|
|
96
|
-
return Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
|
|
108
|
+
const entry = gatedViewEntry(sessionId, maxAgeMs);
|
|
109
|
+
return entry !== null && Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
|
|
97
110
|
} catch {
|
|
98
111
|
return null;
|
|
99
112
|
}
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
/** Meter-duality bridge, wire domain (design §8): the same number the trim
|
|
116
|
+
* pressure tiers evaluate in maybeFold — Math.max(outbound view estimate,
|
|
117
|
+
* the last sampled provider prompt) — or null when the gate rejects or
|
|
118
|
+
* neither component is fresh. The watermark layer reads THIS (not the bare
|
|
119
|
+
* view) so its soft/hard/force tiers and the read budget sit on the same
|
|
120
|
+
* boundary as the fold engine: the view estimate never counts the
|
|
121
|
+
* system/tool sections (a session-shape-dependent mass — live decision-log
|
|
122
|
+
* pairs drift −5k…+6k between view and wire in steady state), so a
|
|
123
|
+
* view-only watermark tightens every protective tier late by exactly that
|
|
124
|
+
* mass, and the wmHardTokens-95k ≙ trim-pressure-90k alignment (c453b91)
|
|
125
|
+
* is silently defeated. The wire half comes from accepted MAIN dispatches
|
|
126
|
+
* only (noteTrimWireTokens below). Same sticky gate as the view bridge;
|
|
127
|
+
* fail-open — null means the caller falls back to the stored tally. */
|
|
128
|
+
export function recentTrimPromptTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
|
|
129
|
+
try {
|
|
130
|
+
const entry = gatedViewEntry(sessionId, maxAgeMs);
|
|
131
|
+
if (entry === null) return null;
|
|
132
|
+
let best = 0;
|
|
133
|
+
if (Number.isFinite(entry.viewTokens) && entry.viewTokens > 0) best = entry.viewTokens;
|
|
134
|
+
const wire = trimWireBySession.get(sessionId);
|
|
135
|
+
if (wire !== undefined && Date.now() - wire.at <= maxAgeMs && Number.isFinite(wire.promptTokens) && wire.promptTokens > best) best = wire.promptTokens;
|
|
136
|
+
return best > 0 ? best : null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Record the provider prompt size (uncached + cacheRead + cacheWrite) of one
|
|
143
|
+
* usage sample from an ACCEPTED main dispatch. Side dispatches (fold-span
|
|
144
|
+
* re-sends, title generation, web-search summaries) sample usage on the same
|
|
145
|
+
* session id but must never reach this registry: their tiny prompts would
|
|
146
|
+
* LOWER the watermark's wire floor below the main history's real prompt —
|
|
147
|
+
* exactly the pollution the side-dispatch guard keeps out of the view
|
|
148
|
+
* registry. Fail-open, bounded like the view registry. */
|
|
149
|
+
function noteTrimWireTokens(sessionId, usage) {
|
|
150
|
+
try {
|
|
151
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
152
|
+
const promptTokens = promptTokensOf({
|
|
153
|
+
lastInputTokens: usage.inputTokens,
|
|
154
|
+
cachedTokens: usage.cachedTokens,
|
|
155
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
156
|
+
});
|
|
157
|
+
if (promptTokens === null) return;
|
|
158
|
+
trimWireBySession.set(sessionId, { promptTokens, at: Date.now() });
|
|
159
|
+
if (trimWireBySession.size > 200) trimWireBySession.delete(trimWireBySession.keys().next().value);
|
|
160
|
+
} catch {
|
|
161
|
+
/* observability must never throw */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
102
165
|
/** Injection outcome of the last dispatch for one session ("in-place" |
|
|
103
166
|
* "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
|
|
104
167
|
* when the wire cannot carry the view, folding cannot relieve anything and
|
|
@@ -124,14 +187,6 @@ export function agentSessionIdOf(agent) {
|
|
|
124
187
|
return null;
|
|
125
188
|
}
|
|
126
189
|
|
|
127
|
-
/** Display label for the banner: object → name, string → itself, else the
|
|
128
|
-
* session id (never prints [object Object]). */
|
|
129
|
-
export function agentLabelOf(agent) {
|
|
130
|
-
if (typeof agent === "string") return agent;
|
|
131
|
-
if (agent !== null && typeof agent === "object" && typeof agent.name === "string" && agent.name !== "") return agent.name;
|
|
132
|
-
return agentSessionIdOf(agent);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
190
|
// ---- config field readers (fail-open defaults) -------------------------------
|
|
136
191
|
|
|
137
192
|
function readBool(config, key, fallback) {
|
|
@@ -189,12 +244,14 @@ function readRoute(config, key) {
|
|
|
189
244
|
// ---- prompt texts -------------------------------------------------------------
|
|
190
245
|
|
|
191
246
|
/** Byte-stable head section: the trim protocol (digit-free, like the
|
|
192
|
-
* watermark protocol, so it stays out of KV invariants' way).
|
|
247
|
+
* watermark protocol, so it stays out of KV invariants' way). States the
|
|
248
|
+
* number-free disclosure policy explicitly — the model never receives
|
|
249
|
+
* water levels or progress numbers from this engine. */
|
|
193
250
|
export const TRIM_PROTOCOL_TEXT = [
|
|
194
|
-
"[SWITCHMAN:TRIM] This session has automatic context trimming.",
|
|
251
|
+
"[SWITCHMAN:TRIM] This session has automatic context trimming. The engine folds consumed ranges itself and never reports sizes, percentages, or progress to you — no action or pacing is expected of you on that front.",
|
|
195
252
|
"- Old message ranges may be replaced by ONE synthetic [SWITCHMAN:TRIM] summary message; treat those summaries as faithful compressions, and message refs inside them as historical anchors.",
|
|
196
253
|
"- NEVER contradict or re-derive content a summary already settled; build on it.",
|
|
197
|
-
"-
|
|
254
|
+
"- You may fold a contiguous range yourself with the ctx_trim_fold tool (give firstId/lastId message refs and a summary following the compression rules); ctx_trim_status lists the compressible ranges with their true sizes.",
|
|
198
255
|
"- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
|
|
199
256
|
].join("\n");
|
|
200
257
|
|
|
@@ -209,37 +266,6 @@ export function effectiveWindowTokens(resolved, cap) {
|
|
|
209
266
|
return Math.min(a, b);
|
|
210
267
|
}
|
|
211
268
|
|
|
212
|
-
/** Live context line renderer (pure; exported for tests). Silent in "off"
|
|
213
|
-
* mode and whenever nothing has been measured yet. wireRaw marks a session
|
|
214
|
-
* whose last dispatch could not carry the fold view (frozen request) — the
|
|
215
|
-
* displayed view is then meter-only and the watermark governs off the
|
|
216
|
-
* provider tally. */
|
|
217
|
-
export function renderTrimContextLine(agent, { enabled, mode, viewTokens, windowTokens, folds, growthTokens, lastModel, wireRaw = false }) {
|
|
218
|
-
if (!enabled) return "";
|
|
219
|
-
if (mode === "off") return "";
|
|
220
|
-
if (agent === null || agent === undefined || agent === "") return "";
|
|
221
|
-
const hasView = Number.isFinite(viewTokens) && viewTokens > 0;
|
|
222
|
-
const hasFolds = Number.isFinite(folds) && folds > 0;
|
|
223
|
-
if (!hasView && !hasFolds) return "";
|
|
224
|
-
const kilo = (n) => `${Math.round(n / 1000)}k`;
|
|
225
|
-
const parts = [];
|
|
226
|
-
if (hasView) {
|
|
227
|
-
let usage = "";
|
|
228
|
-
if (Number.isFinite(windowTokens) && windowTokens > 0) usage = ` (${Math.round((viewTokens / windowTokens) * 100)}% of window ${kilo(windowTokens)})`;
|
|
229
|
-
else usage = " (window unknown — pressure tiers off)";
|
|
230
|
-
parts.push(`view ~${kilo(viewTokens)} tokens${usage}`);
|
|
231
|
-
if (wireRaw) parts.push("wire=raw (folds not shipping; watermark reads the tally)");
|
|
232
|
-
}
|
|
233
|
-
if (hasFolds) {
|
|
234
|
-
parts.push(`folds ${folds}`);
|
|
235
|
-
if (Number.isFinite(growthTokens)) parts.push(`growth ~${kilo(growthTokens)} since last fold`);
|
|
236
|
-
}
|
|
237
|
-
if (lastModel !== null && typeof lastModel === "object" && typeof lastModel.model === "string" && lastModel.model !== "") parts.push(`model ${lastModel.model}`);
|
|
238
|
-
if (parts.length === 0) return "";
|
|
239
|
-
const modeNote = mode === "advisory" ? "advisory: fold via ctx_trim_fold when context feels heavy" : "auto folding active";
|
|
240
|
-
return `[SWITCHMAN:TRIM] agent ${agent}: ${parts.join(", ")}; ${modeNote}.\n`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
269
|
// ---- layer ---------------------------------------------------------------------
|
|
244
270
|
|
|
245
271
|
/** Register the context-trim layer. Wired from the plugin root after
|
|
@@ -254,6 +280,28 @@ export function applyContextTrim(ctx, config) {
|
|
|
254
280
|
}
|
|
255
281
|
};
|
|
256
282
|
|
|
283
|
+
// Reload/teardown marker: HMR disposes and remounts this section on every
|
|
284
|
+
// plugin edit (the profile's node_modules entry is a dev symlink into this
|
|
285
|
+
// checkout). An in-flight fold whose llm stream is cut by the dispose ends
|
|
286
|
+
// as a clean zero-chunk stream — inside the summarizer that is
|
|
287
|
+
// indistinguishable from a genuine empty model reply. Cycles record their
|
|
288
|
+
// start time; a dispose at/after that time reclassifies the outcome as
|
|
289
|
+
// reload-aborted so it neither climbs the failure ladder nor reads as a
|
|
290
|
+
// model failure in decisions.log.
|
|
291
|
+
let disposedAt = 0;
|
|
292
|
+
try {
|
|
293
|
+
// cordis emits no "dispose" event: self-teardown arrives as
|
|
294
|
+
// "internal/plugin" carrying OUR fiber with its uid CLEARED — uid is a
|
|
295
|
+
// positive counter at construction (vendor/cordis fiber.ts:239) and is
|
|
296
|
+
// set to null right before the disposal emit (:274-275; the DISPOSED
|
|
297
|
+
// state is defined as uid === null at :581). Same guard DSH ships in
|
|
298
|
+
// mcp-manager:249 and client-ui-cot-translation mount.ts:123.
|
|
299
|
+
ctx.on("internal/plugin", (fiber) => { if (fiber === ctx.fiber && fiber.uid === null) disposedAt = Date.now(); });
|
|
300
|
+
} catch {
|
|
301
|
+
/* lifecycle events unavailable on this ctx — flag stays 0, behavior unchanged */
|
|
302
|
+
}
|
|
303
|
+
const disposedSince = (since) => disposedAt !== 0 && disposedAt >= since;
|
|
304
|
+
|
|
257
305
|
// Per-process pipeline state (process-wide by design: ledgers persist on
|
|
258
306
|
// disk per session, metrics/captures live per session in memory).
|
|
259
307
|
const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
|
|
@@ -276,21 +324,6 @@ export function applyContextTrim(ctx, config) {
|
|
|
276
324
|
return false;
|
|
277
325
|
}
|
|
278
326
|
};
|
|
279
|
-
// Durable surface-channel EVIDENCE for the banner: a block that already
|
|
280
|
-
// SHIPPED a replacement (surfaceReplaced) proves the channel reaches the
|
|
281
|
-
// wire, even across a process restart and after the in-memory recency
|
|
282
|
-
// window expired. A commit-time capture (surfaceSeqs) does NOT — the block
|
|
283
|
-
// may never ship, and a capture must not silence the honest wire=raw
|
|
284
|
-
// warning. Display only — the fold gate uses the live capability probe.
|
|
285
|
-
const ledgerShowsShippedReplacement = (sessionId) => {
|
|
286
|
-
try {
|
|
287
|
-
const blocks = store.ledgerOf(sessionId)?.blocks;
|
|
288
|
-
if (!Array.isArray(blocks)) return false;
|
|
289
|
-
return blocks.some((block) => block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined);
|
|
290
|
-
} catch {
|
|
291
|
-
return false;
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
327
|
const metrics = createMetrics();
|
|
295
328
|
|
|
296
329
|
/** Most recent provider prompt size for one session, or null when unknown.
|
|
@@ -448,8 +481,47 @@ export function applyContextTrim(ctx, config) {
|
|
|
448
481
|
pressurePercent: readNumber(config, "trimPressurePercent", 60),
|
|
449
482
|
emergencyPercent: readNumber(config, "trimEmergencyPercent", 95),
|
|
450
483
|
protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 6))),
|
|
451
|
-
maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour",
|
|
484
|
+
maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour", 16),
|
|
485
|
+
ladderT2Tokens: readNumber(config, "trimLadderT2Tokens", 8_000),
|
|
486
|
+
ladderT2MinBlocks: readNumber(config, "trimLadderT2MinBlocks", 3),
|
|
487
|
+
ladderT3Tokens: readNumber(config, "trimLadderT3Tokens", 6_000),
|
|
488
|
+
ladderT3MinBlocks: readNumber(config, "trimLadderT3MinBlocks", 2),
|
|
452
489
|
});
|
|
490
|
+
const ladderEnabledNow = () => readBool(config, "trimLadderEnabled", true);
|
|
491
|
+
/** CJK-aware summary-mass estimator for the ladder census (same units
|
|
492
|
+
* the view estimate speaks, applied to summary text). */
|
|
493
|
+
const summaryTokensOf = (text) => messageTokens({ role: "user", content: [{ type: "text", text: typeof text === "string" ? text : "" }] });
|
|
494
|
+
/** Maximal runs of consecutive ACTIVE tier-1 / tier-2 blocks in ledger
|
|
495
|
+
* order — the ladder's candidate consumes sets (docs/ladder-fold.md).
|
|
496
|
+
* A run breaks on an inactive member, a tier change, or a tier-3 block
|
|
497
|
+
* interleaving. Runs carry { consumes, mass, count }; mass is the
|
|
498
|
+
* CJK-aware summary-mass estimate. Pure over the ledger. */
|
|
499
|
+
function ladderRunsOf(ledger) {
|
|
500
|
+
const runs = { t2: [], t3: [] };
|
|
501
|
+
if (ledger === null || !Array.isArray(ledger.blocks)) return runs;
|
|
502
|
+
let cur = null;
|
|
503
|
+
const flush = () => {
|
|
504
|
+
if (cur !== null && cur.ids.length > 0) {
|
|
505
|
+
const run = { consumes: cur.ids, mass: cur.mass, count: cur.ids.length };
|
|
506
|
+
if (cur.tier === 1) runs.t2.push(run);
|
|
507
|
+
else if (cur.tier === 2) runs.t3.push(run);
|
|
508
|
+
}
|
|
509
|
+
cur = null;
|
|
510
|
+
};
|
|
511
|
+
for (const block of ledger.blocks) {
|
|
512
|
+
const tier = block?.active === true && (block.tier === 1 || block.tier === 2) ? block.tier : null;
|
|
513
|
+
if (tier === null || (cur !== null && cur.tier !== tier)) {
|
|
514
|
+
flush();
|
|
515
|
+
}
|
|
516
|
+
if (tier !== null && cur === null) cur = { tier, ids: [], mass: 0 };
|
|
517
|
+
if (cur !== null) {
|
|
518
|
+
cur.ids.push(block.id);
|
|
519
|
+
cur.mass += summaryTokensOf(block.summary);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
flush();
|
|
523
|
+
return runs;
|
|
524
|
+
}
|
|
453
525
|
/** Effective-window cap: explicit trimWindowTokens, else auto-follow
|
|
454
526
|
* wmForceTokens (keeps fold tiers ahead of the handover tier). */
|
|
455
527
|
const windowCapNow = () => {
|
|
@@ -530,7 +602,11 @@ export function applyContextTrim(ctx, config) {
|
|
|
530
602
|
// some dispatch options are frozen on top of that. injectView picks the
|
|
531
603
|
// best strategy per dispatch and fails open; the view-applied line
|
|
532
604
|
// records which strategy was used.
|
|
533
|
-
|
|
605
|
+
// [2026-11-07]-[trimMode "off" means measure only]-[the view build runs
|
|
606
|
+
// for the meter, but the fold must not reach the wire — inspection
|
|
607
|
+
// report P0-2: off previously still spliced the folded view into the
|
|
608
|
+
// dispatch.]
|
|
609
|
+
const injected = modeNow() !== "off" ? injectView(options, messages, view) : "nothing-to-apply";
|
|
534
610
|
const viewTokens = estimateTokensOf(view.messages);
|
|
535
611
|
const blocksCount = Array.isArray(ledger.blocks) ? ledger.blocks.length : 0;
|
|
536
612
|
// Wire truth in the row: the most recent provider prompt size (from the
|
|
@@ -558,8 +634,17 @@ export function applyContextTrim(ctx, config) {
|
|
|
558
634
|
// history below the floor, so the guard must not stick: after
|
|
559
635
|
// SIDE_DISPATCH_SKIP_BUDGET consecutive skips the dispatch is accepted
|
|
560
636
|
// as main, which re-arms capture/baseline on the compacted history.
|
|
561
|
-
|
|
562
|
-
|
|
637
|
+
// [2026-11-07]-[fold-span dispatches are self-noise, not side
|
|
638
|
+
// candidates]-[an in-flight fold cycle's own summarizer re-sends used
|
|
639
|
+
// to consume the side-dispatch skip budget: three consecutive span
|
|
640
|
+
// dispatches got "budget exhausted — accepting as main" and polluted
|
|
641
|
+
// the capture/baseline (the 2026-11-04 regression path). They now
|
|
642
|
+
// bypass unconditionally — never counted, never main.]
|
|
643
|
+
if (inFlight.has(sessionId)) {
|
|
644
|
+
decisionLog(`session=${sessionId} fold-span dispatch skipped view=${Math.round(viewTokens)} msgs=${messages.length}`);
|
|
645
|
+
return { sessionId, viewTokens, sideDispatch: true };
|
|
646
|
+
}
|
|
647
|
+
const sideCandidate = view.applied === 0 && messages.length < SIDE_DISPATCH_MIN_MESSAGES && (blocksCount > 0 || captures.has(sessionId) || trimViewBySession.has(sessionId));
|
|
563
648
|
const skipCount = sideDispatchSkipsBySession.get(sessionId) ?? 0;
|
|
564
649
|
if (sideCandidate && skipCount < SIDE_DISPATCH_SKIP_BUDGET) {
|
|
565
650
|
sideDispatchSkipsBySession.set(sessionId, skipCount + 1);
|
|
@@ -613,11 +698,60 @@ export function applyContextTrim(ctx, config) {
|
|
|
613
698
|
return null;
|
|
614
699
|
}
|
|
615
700
|
|
|
701
|
+
/** Resolve the fold-fallback route (trimFoldFallbackModel): the second
|
|
702
|
+
* chance when the side/session route fails, and the last model stop
|
|
703
|
+
* before a raw pressure fold degrades to mechanical. Null = no fallback. */
|
|
704
|
+
function foldFallbackRouteFor() {
|
|
705
|
+
return readRoute(config, "trimFoldFallbackModel");
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** True when two resolved routes name the same provider+model (a fallback
|
|
709
|
+
* identical to the primary route would only double the failure latency). */
|
|
710
|
+
function sameRoute(a, b) {
|
|
711
|
+
return a !== null && b !== null && a.provider === b.provider && a.model === b.model;
|
|
712
|
+
}
|
|
713
|
+
|
|
616
714
|
/** Run one fold cycle for a session (called at the idle boundary ONLY).
|
|
617
715
|
* Returns a short outcome string for logging. */
|
|
618
|
-
async function runFoldCycle(sessionId, decision) {
|
|
716
|
+
async function runFoldCycle(sessionId, decision, cycleStartedAt) {
|
|
619
717
|
const capture = captures.get(sessionId);
|
|
620
718
|
if (capture === undefined) return "no-capture";
|
|
719
|
+
const { protectedTail } = thresholdsNow();
|
|
720
|
+
// Ladder cycle (docs/ladder-fold.md): distill the decision's consumed
|
|
721
|
+
// run of block summaries into ONE replacement. Never mechanical — a
|
|
722
|
+
// failed distillation skips the cycle (fail-open + failure ladder).
|
|
723
|
+
if (decision.kind === "ladder") {
|
|
724
|
+
const ledgerBefore = store.ledgerOf(sessionId);
|
|
725
|
+
const byId = new Map(Array.isArray(ledgerBefore.blocks) ? ledgerBefore.blocks.map((block) => [block.id, block]) : []);
|
|
726
|
+
const consumed = decision.consumes.map((id) => byId.get(id)).filter((block) => block !== undefined);
|
|
727
|
+
if (consumed.length !== decision.consumes.length) return "ladder-consumed-missing";
|
|
728
|
+
const llm = ctx.get("llm", false);
|
|
729
|
+
const route = sideRouteFor(sessionId);
|
|
730
|
+
const fallback = foldFallbackRouteFor();
|
|
731
|
+
const approxTokens = consumed.reduce((sum, block) => sum + (Number.isFinite(block.approxTokens) && block.approxTokens > 0 ? block.approxTokens : 0), 0);
|
|
732
|
+
const summarize = (r, tag) => summarizeWithModel(llm, r, renderLadderMaterial(consumed), {
|
|
733
|
+
systemPrompt: LADDER_SYSTEM_PROMPT,
|
|
734
|
+
onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} blocks=${consumed.length} ~${approxTokens}t ladder=${decision.tier}`),
|
|
735
|
+
});
|
|
736
|
+
let summary = route !== null ? await summarize(route, "side") : null;
|
|
737
|
+
if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
|
|
738
|
+
// Ladder folds never degrade to mechanical (docs/ladder-fold.md):
|
|
739
|
+
// after both routes fail the cycle stays fail-open with backoff.
|
|
740
|
+
if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
|
|
741
|
+
const result = store.commitFold(
|
|
742
|
+
sessionId,
|
|
743
|
+
{ consumes: decision.consumes.slice(), summary, kind: "summary", tier: decision.tier === "ladder-3" ? 3 : 2, approxTokens },
|
|
744
|
+
null, // ladder commits validate against the ledger, never the capture
|
|
745
|
+
);
|
|
746
|
+
if (!result.ok) return `commit-rejected:${result.reason}`;
|
|
747
|
+
// Post-fold low baseline: the ladder shrinks the view; re-anchor
|
|
748
|
+
// the growth gate at the new low so it does not re-fire on stale
|
|
749
|
+
// anchor math (same pair semantics as raw folds).
|
|
750
|
+
const lowLedger = store.ledgerOf(sessionId);
|
|
751
|
+
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail }).messages);
|
|
752
|
+
store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
|
|
753
|
+
return `folded:${decision.tier}:${decision.consumes.length} blocks (~${approxTokens} tokens)`;
|
|
754
|
+
}
|
|
621
755
|
const { startIdx, endIdx } = decision;
|
|
622
756
|
const slice = capture.messages.slice(startIdx, endIdx + 1);
|
|
623
757
|
if (slice.length === 0) return "empty-slice";
|
|
@@ -626,6 +760,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
626
760
|
const approxTokens = estimateTokensOf(slice);
|
|
627
761
|
let summary = null;
|
|
628
762
|
let kind = "summary";
|
|
763
|
+
let degraded = false; // set when a PRESSURE fold falls back to mechanical
|
|
629
764
|
if (decision.action === "emergency") {
|
|
630
765
|
// Emergency cooldown timestamp set BEFORE the cycle: a commitFold
|
|
631
766
|
// rejection (e.g. duplicate-range) must cool the emergency tier
|
|
@@ -639,10 +774,25 @@ export function applyContextTrim(ctx, config) {
|
|
|
639
774
|
} else {
|
|
640
775
|
const llm = ctx.get("llm", false);
|
|
641
776
|
const route = sideRouteFor(sessionId);
|
|
642
|
-
|
|
643
|
-
|
|
777
|
+
const fallback = foldFallbackRouteFor();
|
|
778
|
+
const material = renderMaterial(capture.messages, startIdx, endIdx, refOf);
|
|
779
|
+
const summarize = (r, tag) => summarizeWithModel(llm, r, material, {
|
|
780
|
+
onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
|
|
644
781
|
});
|
|
645
|
-
|
|
782
|
+
summary = route !== null ? await summarize(route, "side") : null;
|
|
783
|
+
if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
|
|
784
|
+
// Last resort for PRESSURE folds only: relief beats fidelity when
|
|
785
|
+
// the window is nearly gone; growth folds stay fail-open (a
|
|
786
|
+
// mechanical growth fold would burn fold budget on junk). The
|
|
787
|
+
// outcome row marks the degrade so the log shows why no model
|
|
788
|
+
// summary landed.
|
|
789
|
+
if (summary === null && decision.tier === "pressure") {
|
|
790
|
+
if (disposedSince(cycleStartedAt)) return "reload-aborted";
|
|
791
|
+
summary = mechanicalSummary(capture.messages, startIdx, endIdx, refOf);
|
|
792
|
+
kind = "mechanical";
|
|
793
|
+
degraded = true;
|
|
794
|
+
}
|
|
795
|
+
if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
|
|
646
796
|
}
|
|
647
797
|
const positions = refPositions(capture.messages);
|
|
648
798
|
const result = store.commitFold(
|
|
@@ -659,9 +809,9 @@ export function applyContextTrim(ctx, config) {
|
|
|
659
809
|
// post-fold low (tier-a anchor — keeps the gate armed after folds
|
|
660
810
|
// that commit at high view points).
|
|
661
811
|
const lowLedger = store.ledgerOf(sessionId);
|
|
662
|
-
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail
|
|
812
|
+
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail }).messages);
|
|
663
813
|
store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
|
|
664
|
-
return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)`;
|
|
814
|
+
return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)${degraded ? " fallback=mechanical" : ""}`;
|
|
665
815
|
}
|
|
666
816
|
|
|
667
817
|
/** Idle-boundary hook (also re-used by the stream-path fallback): decide
|
|
@@ -692,18 +842,22 @@ export function applyContextTrim(ctx, config) {
|
|
|
692
842
|
return;
|
|
693
843
|
}
|
|
694
844
|
inFlight.add(sessionId);
|
|
845
|
+
const cycleStartedAt = Date.now();
|
|
846
|
+
// Hoisted for the catch below: a dispose-cut throw may land before the
|
|
847
|
+
// trigger ever ran, and the catch must not touch a dead binding.
|
|
848
|
+
let decision = null;
|
|
695
849
|
try {
|
|
696
850
|
const { protectedTail, ...thresholds } = thresholdsNow();
|
|
697
|
-
const foldable = computeFoldableRange(capture.messages, store.ledgerOf(sessionId), protectedTail);
|
|
698
|
-
const windowTokens = await windowTokensFor(capture.model);
|
|
699
851
|
const ledger = store.ledgerOf(sessionId);
|
|
852
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
|
|
853
|
+
const windowTokens = await windowTokensFor(capture.model);
|
|
700
854
|
// Pressure tiers run on the REAL prompt size when the tally is known:
|
|
701
855
|
// max(view, wire). The view estimate misses the system/context
|
|
702
856
|
// sections, so a 136k wire once read as a 50k view and the tiers
|
|
703
857
|
// stayed silent. Unknown tally → exactly today's behavior.
|
|
704
858
|
const wireTokens = wireTokensOf(sessionId);
|
|
705
859
|
const pressureTokens = wireTokens !== null ? Math.max(capture.viewTokens, wireTokens) : capture.viewTokens;
|
|
706
|
-
|
|
860
|
+
decision = evaluateTrigger({
|
|
707
861
|
foldable,
|
|
708
862
|
messages: capture.messages,
|
|
709
863
|
viewTokens: pressureTokens,
|
|
@@ -714,23 +868,31 @@ export function applyContextTrim(ctx, config) {
|
|
|
714
868
|
foldsThisHour: ledger.foldsThisHour,
|
|
715
869
|
lastEmergencyAt: lastEmergencyAtBySession.get(sessionId) ?? 0,
|
|
716
870
|
ledger,
|
|
871
|
+
ladderRuns: ladderEnabledNow() ? ladderRunsOf(ledger) : { t2: [], t3: [] },
|
|
717
872
|
});
|
|
718
873
|
decisionLog(`session=${sessionId} via=${via} action=${decision.action} tier=${decision.tier ?? "-"} reason=${decision.reason || "-"} view=${Math.round(capture.viewTokens)}${wireTokens === null ? "" : ` wire=${wireTokens}`} window=${windowTokens ?? "null"}`);
|
|
719
874
|
if (decision.action === "none") return;
|
|
720
|
-
const outcome = await runFoldCycle(sessionId, decision);
|
|
875
|
+
const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt);
|
|
721
876
|
// Failure ladder: commit resets it; a summarize/commit failure
|
|
722
877
|
// climbs it (the no-backoff loop this closes — those outcomes
|
|
723
|
-
// advance no cadence gate on their own).
|
|
878
|
+
// advance no cadence gate on their own). A ladder run whose
|
|
879
|
+
// consumed blocks vanished between trigger and commit is the same
|
|
880
|
+
// family: without backoff it would re-fire at every boundary.
|
|
724
881
|
if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
|
|
725
|
-
else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:")) recordFoldFailure(sessionId);
|
|
882
|
+
else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:") || outcome === "ladder-consumed-missing") recordFoldFailure(sessionId);
|
|
726
883
|
// Outcome lands in the decision log too: warn() has no reachable
|
|
727
884
|
// sink on some hosts, and the commit/fail boundary is exactly what
|
|
728
885
|
// live diagnosis needs.
|
|
729
|
-
decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier}`);
|
|
886
|
+
decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier ?? "-"}`);
|
|
730
887
|
warn(`[context-trim] ${sessionId}: ${outcome} (${decision.reason})`);
|
|
731
888
|
} catch (error) {
|
|
732
|
-
|
|
733
|
-
|
|
889
|
+
// A dispose mid-cycle throws through here too (ctx/service teardown) —
|
|
890
|
+
// that is a reload cut, not a model failure: log it and skip the ladder.
|
|
891
|
+
if (disposedSince(cycleStartedAt)) decisionLog(`session=${sessionId} outcome=reload-aborted tier=${decision?.tier ?? "-"}`);
|
|
892
|
+
else {
|
|
893
|
+
recordFoldFailure(sessionId); // a thrown cycle counts as failed too
|
|
894
|
+
warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
|
|
895
|
+
}
|
|
734
896
|
} finally {
|
|
735
897
|
inFlight.delete(sessionId);
|
|
736
898
|
}
|
|
@@ -738,63 +900,17 @@ export function applyContextTrim(ctx, config) {
|
|
|
738
900
|
|
|
739
901
|
// ---- host registrations (each individually fail-open) --------------------
|
|
740
902
|
|
|
903
|
+
// MODEL-VISIBILITY INVARIANT: only the byte-stable protocol head is
|
|
904
|
+
// registered. The previous live context line (view tokens, % of window,
|
|
905
|
+
// folds, growth) is gone by design — water levels and progress numbers
|
|
906
|
+
// steer model judgment (bili lesson #2, docs/ladder-fold.md). Human
|
|
907
|
+
// diagnostics live in decisions.log (view-applied / decision / outcome
|
|
908
|
+
// rows) and the client badge.
|
|
741
909
|
try {
|
|
742
|
-
ctx.systemPrompt?.section?.({ name: "switchman:trim", order: 10450, interpolate: false, text: () => TRIM_PROTOCOL_TEXT });
|
|
910
|
+
ctx.systemPrompt?.section?.({ name: "switchman:trim", order: 10450, interpolate: false, text: () => (enabledNow() && modeNow() !== "off" ? TRIM_PROTOCOL_TEXT : "") });
|
|
743
911
|
} catch (error) {
|
|
744
912
|
warn(`[context-trim] systemPrompt.section failed: ${error?.message ?? error}`);
|
|
745
913
|
}
|
|
746
|
-
try {
|
|
747
|
-
ctx.systemPrompt?.context?.({
|
|
748
|
-
name: "switchman:trim",
|
|
749
|
-
order: 299,
|
|
750
|
-
required: true,
|
|
751
|
-
interpolate: false,
|
|
752
|
-
text: (context) => {
|
|
753
|
-
try {
|
|
754
|
-
const agent = context?.agent ?? null;
|
|
755
|
-
// context.agent is also the agent OBJECT — derive the id plus a
|
|
756
|
-
// display label so the banner never prints [object Object].
|
|
757
|
-
const sessionId = agentSessionIdOf(agent);
|
|
758
|
-
const label = agentLabelOf(agent);
|
|
759
|
-
const record = sessionId !== null ? metrics.of(sessionId) : null;
|
|
760
|
-
const ledger = sessionId !== null ? store.ledgerOf(sessionId) : null;
|
|
761
|
-
const model = captureModelOf(sessionId);
|
|
762
|
-
const windowTokens = model === null ? null : cachedWindowOf(model);
|
|
763
|
-
const growthBaseline = ledger !== null && Number.isFinite(ledger.lastFoldLowTokens) && ledger.lastFoldLowTokens > 0 ? ledger.lastFoldLowTokens : ledger !== null && Number.isFinite(ledger.lastFoldViewTokens) && ledger.lastFoldViewTokens > 0 ? ledger.lastFoldViewTokens : null;
|
|
764
|
-
// Honest wire status: the wire is only "raw" when NEITHER channel
|
|
765
|
-
// can reach it — a capable surface pass, or a ledger that already
|
|
766
|
-
// proves a SHIPPED replacement (a capture alone does not: it may
|
|
767
|
-
// never ship), keeps the marker off even after the in-memory
|
|
768
|
-
// recency window expired.
|
|
769
|
-
const wireRaw = recentTrimInjectOf(sessionId) === "none" && !surfaceChannelCapable(sessionId) && !ledgerShowsShippedReplacement(sessionId);
|
|
770
|
-
return renderTrimContextLine(label, {
|
|
771
|
-
enabled: enabledNow(),
|
|
772
|
-
mode: modeNow(),
|
|
773
|
-
viewTokens: record?.viewTokens ?? null,
|
|
774
|
-
windowTokens,
|
|
775
|
-
folds: Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0,
|
|
776
|
-
growthTokens: record?.viewTokens !== null && record !== undefined && growthBaseline !== null ? record.viewTokens - growthBaseline : null,
|
|
777
|
-
lastModel: record?.lastModel ?? null,
|
|
778
|
-
wireRaw,
|
|
779
|
-
});
|
|
780
|
-
} catch {
|
|
781
|
-
return "";
|
|
782
|
-
}
|
|
783
|
-
},
|
|
784
|
-
});
|
|
785
|
-
} catch (error) {
|
|
786
|
-
warn(`[context-trim] systemPrompt.context failed: ${error?.message ?? error}`);
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
/** Synchronous window lookup for the banner: raw cache composed with
|
|
790
|
-
* the cap (async resolution happens in the idle path / stream path). */
|
|
791
|
-
function captureModelOf(sessionId) {
|
|
792
|
-
return captures.get(sessionId)?.model ?? null;
|
|
793
|
-
}
|
|
794
|
-
function cachedWindowOf(model) {
|
|
795
|
-
const resolved = model !== null && typeof model.provider === "string" && typeof model.model === "string" ? windowCache.get(`${model.provider}/${model.model}`)?.windowTokens ?? null : null;
|
|
796
|
-
return effectiveWindowTokens(resolved, windowCapNow());
|
|
797
|
-
}
|
|
798
914
|
|
|
799
915
|
try {
|
|
800
916
|
ctx.on("agent/status", (payload) => {
|
|
@@ -860,29 +976,56 @@ export function applyContextTrim(ctx, config) {
|
|
|
860
976
|
|
|
861
977
|
// ---- tools (L1 advisory surface) ------------------------------------------
|
|
862
978
|
|
|
979
|
+
/** Render the fold ledger for the status tool: ACTIVE blocks with true
|
|
980
|
+
* sizes, absorbed history as one line. NO water level, NO fill
|
|
981
|
+
* percentage, NO window math, NO threshold percentages — sizes of
|
|
982
|
+
* concrete ranges are the model-facing interface (bili mechanism #5);
|
|
983
|
+
* global progress numbers are not (lesson #2). */
|
|
984
|
+
function renderStatusLedger(ledger) {
|
|
985
|
+
const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
|
|
986
|
+
const active = blocks.filter((block) => block?.active === true);
|
|
987
|
+
const absorbed = blocks.length - active.length;
|
|
988
|
+
const lines = [];
|
|
989
|
+
if (blocks.length === 0) {
|
|
990
|
+
lines.push("fold ledger: empty (nothing folded yet)");
|
|
991
|
+
return lines;
|
|
992
|
+
}
|
|
993
|
+
lines.push(`fold ledger: ${active.length} active block(s)${absorbed > 0 ? `, ${absorbed} absorbed by higher tiers` : ""} (oldest first)`);
|
|
994
|
+
for (const block of active) {
|
|
995
|
+
const tier = block.tier === 3 ? "T3" : block.tier === 2 ? "T2" : "T1";
|
|
996
|
+
const range = block.refRange !== null && block.refRange !== undefined ? ` refs [m${block.refRange.firstId}]..[m${block.refRange.lastId}]` : ` distills ${block.count} lower-tier block(s)`;
|
|
997
|
+
lines.push(`- ${block.id} ${tier}: ~${Math.round(Number.isFinite(block.approxTokens) ? block.approxTokens : 0)} tokens of material${range}${block.tier >= 2 ? ` — foldable into a higher tier together with its sibling blocks` : ""}`);
|
|
998
|
+
}
|
|
999
|
+
return lines;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
863
1002
|
try {
|
|
864
1003
|
ctx.tools?.register?.({
|
|
865
1004
|
name: "ctx_trim_status",
|
|
866
|
-
description: "
|
|
1005
|
+
description: "List this session's compressible context ranges with their true sizes: the fold ledger, the raw range a fold would cover, and distillation targets. Read-only. Contains sizes only — never progress percentages.",
|
|
867
1006
|
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
868
1007
|
required: [],
|
|
869
1008
|
async execute() {
|
|
870
1009
|
const sessionId = sessionIdOf({});
|
|
871
1010
|
if (sessionId === null) return "No session context available.";
|
|
1011
|
+
if (!enabledNow()) return "Context trimming is disabled for this deployment.";
|
|
872
1012
|
const ledger = store.ledgerOf(sessionId);
|
|
873
|
-
const record = metrics.of(sessionId);
|
|
874
1013
|
const capture = captures.get(sessionId);
|
|
875
|
-
const { protectedTail
|
|
876
|
-
const lines = [
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
`view tokens (estimate): ${record?.viewTokens ?? "n/a"}`,
|
|
880
|
-
`window tokens: ${cachedWindowOf(capture?.model ?? null) ?? "unknown"}`,
|
|
881
|
-
`folds: ${Array.isArray(ledger.blocks) ? ledger.blocks.length : 0}, frontier: ${ledger.frontier ?? "none"}, last fold view tokens: ${ledger.lastFoldViewTokens || "n/a"}`,
|
|
882
|
-
`thresholds: growth>${thresholds.growthTokens} increment>=${thresholds.incrementTokens} pressure=${thresholds.pressurePercent}% emergency=${thresholds.emergencyPercent}% maxPerHour=${thresholds.maxFoldsPerHour} protectedTail=${protectedTail}`,
|
|
883
|
-
];
|
|
1014
|
+
const { protectedTail } = thresholdsNow();
|
|
1015
|
+
const lines = [`ctx_trim_status — mode: ${modeNow()} (enabled: ${enabledNow()})`];
|
|
1016
|
+
lines.push(...renderStatusLedger(ledger));
|
|
1017
|
+
// The raw range a fold would cover next, with its true size.
|
|
884
1018
|
if (capture !== undefined) {
|
|
885
|
-
|
|
1019
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
|
|
1020
|
+
if (foldable !== null) {
|
|
1021
|
+
const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
|
|
1022
|
+
const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
|
|
1023
|
+
lines.push("", `compressible raw range: [m${firstRef}]..[m${lastRef}] (${foldable.endIdx - foldable.startIdx + 1} messages, ~${estimateTokensOf(capture.messages, foldable.startIdx, foldable.endIdx)} tokens) — a ctx_trim_fold over this range replaces it with your summary.`);
|
|
1024
|
+
} else {
|
|
1025
|
+
lines.push("", "compressible raw range: none right now (everything settled is already folded).");
|
|
1026
|
+
}
|
|
1027
|
+
// Message refs stay available for fold targeting (sizes only).
|
|
1028
|
+
lines.push("", `captured history (${capture.messages.length} messages; refs [m...] are fold anchors, per-message sizes are estimates):`);
|
|
886
1029
|
capture.messages.forEach((message, i) => {
|
|
887
1030
|
lines.push(`- [m${refOf(message, i)}] (${message?.role ?? "?"}) ~${estimateTokensOf(capture.messages, i, i)} tokens`);
|
|
888
1031
|
});
|
|
@@ -914,6 +1057,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
914
1057
|
async execute(args) {
|
|
915
1058
|
const sessionId = sessionIdOf({});
|
|
916
1059
|
if (sessionId === null) return "No session context available.";
|
|
1060
|
+
if (!enabledNow() || modeNow() === "off") return "Context trimming is disabled for this deployment.";
|
|
917
1061
|
const capture = captures.get(sessionId);
|
|
918
1062
|
if (capture === undefined) return "No captured history yet — fold is unavailable until the next model call.";
|
|
919
1063
|
const positions = refPositions(capture.messages);
|
|
@@ -945,7 +1089,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
945
1089
|
approxTokens: estimateTokensOf(capture.messages, firstIdx, lastIdx),
|
|
946
1090
|
summary: String(args.summary ?? ""),
|
|
947
1091
|
kind: "summary",
|
|
948
|
-
tier:
|
|
1092
|
+
tier: 1,
|
|
949
1093
|
},
|
|
950
1094
|
positions,
|
|
951
1095
|
);
|
|
@@ -968,10 +1112,14 @@ export function applyContextTrim(ctx, config) {
|
|
|
968
1112
|
|
|
969
1113
|
function streamListener(options, next) {
|
|
970
1114
|
let viewTokens = null;
|
|
1115
|
+
let mainDispatch = false;
|
|
971
1116
|
try {
|
|
972
1117
|
if (enabledNow()) {
|
|
973
1118
|
const applied = applyViewToOptions(options);
|
|
974
|
-
if (applied !== null)
|
|
1119
|
+
if (applied !== null) {
|
|
1120
|
+
viewTokens = applied.viewTokens;
|
|
1121
|
+
mainDispatch = applied.sideDispatch !== true;
|
|
1122
|
+
}
|
|
975
1123
|
}
|
|
976
1124
|
} catch (error) {
|
|
977
1125
|
decisionLog(`view-build-failed why=${error?.message ?? error}`);
|
|
@@ -985,7 +1133,10 @@ export function applyContextTrim(ctx, config) {
|
|
|
985
1133
|
if (wrappedSession === null) return result;
|
|
986
1134
|
return wrapStreamForMetrics(result, (chunk) => {
|
|
987
1135
|
const usage = extractUsage(chunk);
|
|
988
|
-
if (usage !== null)
|
|
1136
|
+
if (usage !== null) {
|
|
1137
|
+
metrics.recordUsage(wrappedSession, usage);
|
|
1138
|
+
if (mainDispatch) noteTrimWireTokens(wrappedSession, usage);
|
|
1139
|
+
}
|
|
989
1140
|
});
|
|
990
1141
|
} catch (error) {
|
|
991
1142
|
warn(`[context-trim] stream wrap failed: ${error?.message ?? error}`);
|
|
@@ -1050,5 +1201,5 @@ export function applyContextTrim(ctx, config) {
|
|
|
1050
1201
|
warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
|
|
1051
1202
|
}
|
|
1052
1203
|
|
|
1053
|
-
return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass
|
|
1204
|
+
return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
|
|
1054
1205
|
}
|