dsh-switchman 1.8.1 → 1.10.2

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.
@@ -0,0 +1,1054 @@
1
+ /** dsh-switchman context-trim assembly.
2
+ *
3
+ * applyContextTrim(ctx, config) wires the trimming pipeline:
4
+ *
5
+ * - llm/stream listener (mounted via ctx.inject(["llm"], ...) so it
6
+ * attaches only once the llm fiber exists; released via llmCtx.effect
7
+ * for reload safety): builds the deterministic outbound view from the
8
+ * session ledger, splices it into options.messages IN PLACE before
9
+ * calling next(), then wraps the returned stream to sample usage. The
10
+ * listener ALWAYS calls next() — any internal failure degrades to a
11
+ * no-op pass-through (fail-open). No fold decisions happen here.
12
+ * - agent/status idle hook: the only place fold decisions run (design §4:
13
+ * fold at the idle boundary, never mid-stream).
14
+ * - ctx_trim_fold / ctx_trim_status tools: the L1 advisory surface — the
15
+ * session model may fold ranges itself and inspect trim state.
16
+ * - systemPrompt head section (byte-stable protocol text) + live context
17
+ * line (bucketed view tokens / fold count / growth — byte-stable between
18
+ * 5k-token steps so the serialized tail stops churning).
19
+ *
20
+ * Config fields (host/config.js, all volatile): trimEnabled, trimMode
21
+ * ("off"|"advisory"|"auto"), trimGrowthTokens, trimIncrementTokens,
22
+ * trimPressurePercent, trimEmergencyPercent, trimWindowTokens,
23
+ * trimProtectedTail,
24
+ * trimMaxFoldsPerHour, trimSideModel, trimStateDir.
25
+ */
26
+
27
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { buildView, estimateTokensOf, injectView, ledgerIsStale, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
30
+ import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
31
+ import { mechanicalSummary, renderMaterial, summarizeWithModel } from "./summarizer.js";
32
+ import { createStore } from "./state.js";
33
+ import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } from "./metrics.js";
34
+ import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
35
+ import { createSurfaceFoldPass } from "./surface.js";
36
+ import { childSessionIdOf } from "../subagent-model.js";
37
+
38
+ // ---- agent reference helpers --------------------------------------------------
39
+
40
+ /** Derive a session id from an agent reference: either a plain string id or
41
+ * the agent OBJECT (dsh-agent fuses { ...payload, agent } — payload.agent on
42
+ * agent/status and assembleContextFor's context.agent are both the object
43
+ * form). Mirrors the host/context-watch.js:122 precedent. Returns null when
44
+ * nothing usable is present. */
45
+ // ---- cross-layer view registry (meter duality, design §8) ------------------
46
+ // Latest outbound-view token estimate per session, refreshed on EVERY
47
+ // llm/stream call. host/context-watch.js prefers this over the token meter's
48
+ // stored tally while it is fresh, so the watermark tracks what the model
49
+ // actually sees after billion-context folding instead of over-triggering
50
+ // handovers off a stale stored measure.
51
+ const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
52
+ /** Freshness TTL for the in-memory view/injection registry (watermark and
53
+ * banner display only). NEVER a fold gate: gating folds on "did the surface
54
+ * channel ship recently" deadlocks resumed sessions (2026-11-05). The fold
55
+ * gate asks surfacePass.supported() — a durable capability question. */
56
+ const TRIM_VIEW_MAX_AGE_MS = 600_000; // 10 minutes
57
+
58
+ /** Dispatches below this message count are never treated as the session's
59
+ * main history (the side-dispatch guard in applyViewToOptions): auxiliary
60
+ * llm calls (web-search summaries, title generation) inherit the session id
61
+ * with 1-6 message arrays and would clobber every per-session meter. */
62
+ const SIDE_DISPATCH_MIN_MESSAGES = 12;
63
+
64
+ /** Consecutive side-dispatch skips tolerated before one is accepted as the
65
+ * main history anyway — the release valve for a genuine host-compaction
66
+ * epoch that shrank the history below SIDE_DISPATCH_MIN_MESSAGES (without
67
+ * it the guard would stick: no capture refresh, no epoch reset, folds run
68
+ * against a stale capture forever). */
69
+ const SIDE_DISPATCH_SKIP_BUDGET = 2;
70
+
71
+ /** Consecutive side-dispatch skips per session (bounded; reset on every
72
+ * accepted main dispatch). */
73
+ const sideDispatchSkipsBySession = new Map();
74
+
75
+ /** Most recent outbound-view token estimate for a session, or null when
76
+ * missing, non-positive, older than maxAgeMs — or when the recent dispatch
77
+ * stream could not carry the view (injected "none": frozen options AND
78
+ * messages, so the model still receives the raw history and the provider
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. */
85
+ export function recentTrimViewTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
86
+ try {
87
+ if (typeof sessionId !== "string" || sessionId === "") return null;
88
+ const entry = trimViewBySession.get(sessionId);
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;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /** Injection outcome of the last dispatch for one session ("in-place" |
103
+ * "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
104
+ * when the wire cannot carry the view, folding cannot relieve anything and
105
+ * only burns a summarizer call — the watermark's tally-driven auto-handover
106
+ * is the correct relief path instead. */
107
+ export function recentTrimInjectOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
108
+ try {
109
+ if (typeof sessionId !== "string" || sessionId === "") return null;
110
+ const entry = trimViewBySession.get(sessionId);
111
+ if (entry === undefined || Date.now() - entry.at > maxAgeMs) return null;
112
+ return typeof entry.inject === "string" ? entry.inject : null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ export function agentSessionIdOf(agent) {
119
+ if (typeof agent === "string") return agent !== "" ? agent : null;
120
+ if (agent !== null && typeof agent === "object") {
121
+ const id = agent.session?.id ?? agent.id;
122
+ return typeof id === "string" && id !== "" ? id : null;
123
+ }
124
+ return null;
125
+ }
126
+
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
+ // ---- config field readers (fail-open defaults) -------------------------------
136
+
137
+ function readBool(config, key, fallback) {
138
+ try {
139
+ const value = config?.[key];
140
+ if (typeof value === "boolean") return value;
141
+ if (value !== null && typeof value === "object" && typeof value.get === "function") {
142
+ const got = value.get();
143
+ if (typeof got === "boolean") return got;
144
+ }
145
+ } catch {
146
+ /* fall through */
147
+ }
148
+ return fallback;
149
+ }
150
+
151
+ function readNumber(config, key, fallback) {
152
+ try {
153
+ const value = config?.[key];
154
+ let got = value;
155
+ if (value !== null && typeof value === "object" && typeof value.get === "function") got = value.get();
156
+ if (Number.isFinite(got)) return got;
157
+ } catch {
158
+ /* fall through */
159
+ }
160
+ return fallback;
161
+ }
162
+
163
+ function readString(config, key, fallback) {
164
+ try {
165
+ const value = config?.[key];
166
+ let got = value;
167
+ if (value !== null && typeof value === "object" && typeof value.get === "function") got = value.get();
168
+ if (typeof got === "string" && got !== "") return got;
169
+ } catch {
170
+ /* fall through */
171
+ }
172
+ return fallback;
173
+ }
174
+
175
+ function readRoute(config, key) {
176
+ try {
177
+ const value = config?.[key];
178
+ let got = value;
179
+ if (value !== null && typeof value === "object" && typeof value.get === "function") got = value.get();
180
+ if (got !== null && typeof got === "object" && typeof got.provider === "string" && got.provider !== "" && typeof got.model === "string" && got.model !== "") {
181
+ return { provider: got.provider, model: got.model };
182
+ }
183
+ } catch {
184
+ /* fall through */
185
+ }
186
+ return null;
187
+ }
188
+
189
+ // ---- prompt texts -------------------------------------------------------------
190
+
191
+ /** Byte-stable head section: the trim protocol (digit-free, like the
192
+ * watermark protocol, so it stays out of KV invariants' way). */
193
+ export const TRIM_PROTOCOL_TEXT = [
194
+ "[SWITCHMAN:TRIM] This session has automatic context trimming.",
195
+ "- 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
+ "- NEVER contradict or re-derive content a summary already settled; build on it.",
197
+ "- When context grows large, 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); inspect state with ctx_trim_status.",
198
+ "- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
199
+ ].join("\n");
200
+
201
+ /** Effective window for the pressure/emergency tier math:
202
+ * min(model-resolved window, configured cap). Null only when BOTH inputs
203
+ * are unusable — the trigger then skips those tiers (fail-open). */
204
+ export function effectiveWindowTokens(resolved, cap) {
205
+ const a = Number.isFinite(resolved) && resolved > 0 ? resolved : null;
206
+ const b = Number.isFinite(cap) && cap > 0 ? cap : null;
207
+ if (a === null) return b;
208
+ if (b === null) return a;
209
+ return Math.min(a, b);
210
+ }
211
+
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
+ // ---- layer ---------------------------------------------------------------------
244
+
245
+ /** Register the context-trim layer. Wired from the plugin root after
246
+ * applyContextWatch. Every host touch is fail-open: registration failures
247
+ * warn and the layer degrades to whatever part succeeded. */
248
+ export function applyContextTrim(ctx, config) {
249
+ const warn = (message) => {
250
+ try {
251
+ ctx.log?.warn?.(message);
252
+ } catch {
253
+ /* logging must never throw */
254
+ }
255
+ };
256
+
257
+ // Per-process pipeline state (process-wide by design: ledgers persist on
258
+ // disk per session, metrics/captures live per session in memory).
259
+ const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
260
+
261
+ // Surface-replace pass (docs/surface-fold.md): ships committed fold blocks
262
+ // onto the session surface so folds reach the wire even for frozen
263
+ // dispatches. Registers its own agent/turn-stopping trigger; the idle
264
+ // sweep below runs after each fold cycle.
265
+ const surfacePass = createSurfaceFoldPass({ ctx, config, store, decisionLog, warn });
266
+
267
+ // Durable capability probe (surface.js supported): the fold gate asks THIS,
268
+ // never a recency window. An in-memory "did we ship in the last 10 minutes"
269
+ // test deadlocked resumed sessions: dispatches report injected=none, so
270
+ // after the window expired no fold ran, no fold meant no new ship, and the
271
+ // gate stayed shut forever while the provider prompt grew to 135k.
272
+ const surfaceChannelCapable = (sessionId) => {
273
+ try {
274
+ return surfacePass.supported(sessionId);
275
+ } catch {
276
+ return false;
277
+ }
278
+ };
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
+ const metrics = createMetrics();
295
+
296
+ /** Most recent provider prompt size for one session, or null when unknown.
297
+ * The tally is the FULL prompt: the host reports TokenUsage as
298
+ * { inputTokens (uncached), cacheReadTokens, cacheWriteTokens }, so a
299
+ * heavily cached session (~130k of 136k cached) would otherwise read as
300
+ * the uncached delta and keep max(view, wire) silent. The view estimate
301
+ * only counts the messages array, so it under-reports by the system and
302
+ * context sections; pressure tiers must not run blind on it. */
303
+ const wireTokensOf = (sessionId) => {
304
+ try {
305
+ return promptTokensOf(metrics.of(sessionId));
306
+ } catch {
307
+ return null;
308
+ }
309
+ };
310
+ const captures = new Map(); // sessionId -> { messages, refs, viewTokens, model, at }
311
+ const inFlight = new Set(); // sessionIds with a fold running
312
+ const windowCache = new Map(); // "provider/model" -> { windowTokens, at }
313
+ const lastEmergencyAtBySession = new Map(); // sessionId -> epoch ms of last emergency fold (per-session cooldown)
314
+ const lastStreamCheckAt = new Map(); // sessionId -> epoch ms of last stream-path fold check (throttle)
315
+ const epochResetDoneBySession = new Map(); // sessionId -> ledger-stale baseline reset already done for this host epoch (cleared when a fold applies again)
316
+
317
+ // Per-session fold-failure cooldown: a failing summarize/commit cycle
318
+ // advances NONE of the cadence gates (lastFoldAt / foldsThisHour only
319
+ // move on a successful commitFold), so without this the same failure
320
+ // re-fires at every idle boundary plus every >=60s stream check forever.
321
+ // In-memory only by design — a process restart re-arms the cycle open.
322
+ const foldFailureBySession = new Map(); // sessionId -> { count, lastAt }
323
+ const FOLD_FAILURE_BASE_MS = 60_000; // backoff ladder start (2^(n-1) growth)
324
+ const FOLD_FAILURE_MAX_MS = 900_000; // 15 min ceiling
325
+
326
+ /** Record one failed fold cycle (summary unavailable / commit rejected /
327
+ * thrown): consecutive-attempt count + timestamp drive the backoff. */
328
+ function recordFoldFailure(sessionId) {
329
+ const prior = foldFailureBySession.get(sessionId);
330
+ foldFailureBySession.set(sessionId, { count: Math.min((prior?.count ?? 0) + 1, 16), lastAt: Date.now() });
331
+ if (foldFailureBySession.size > 200) foldFailureBySession.delete(foldFailureBySession.keys().next().value);
332
+ }
333
+
334
+ /** Remaining cooldown ms for one session (0 when not cooling): the delay
335
+ * doubles per consecutive failure from 60s, capped at 15 min. */
336
+ function foldFailureCooldownOf(sessionId, now = Date.now()) {
337
+ const entry = foldFailureBySession.get(sessionId);
338
+ if (entry === undefined || entry.count <= 0) return 0;
339
+ const backoff = Math.min(FOLD_FAILURE_BASE_MS * 2 ** (entry.count - 1), FOLD_FAILURE_MAX_MS);
340
+ return Math.max(0, entry.lastAt + backoff - now);
341
+ }
342
+
343
+ /** A committed fold resets the failure ladder. */
344
+ function clearFoldFailure(sessionId) {
345
+ foldFailureBySession.delete(sessionId);
346
+ }
347
+
348
+ /** Append one fold-decision line to <stateDir>/decisions.log (fail-open,
349
+ * best-effort observability — console warn stays reserved for outcomes). */
350
+ function decisionLog(entry) {
351
+ try {
352
+ const dir = store.stateDir;
353
+ if (typeof dir !== "string" || dir === "") return;
354
+ mkdirSync(dir, { recursive: true });
355
+ const logPath = join(dir, "decisions.log");
356
+ if ((statSync(logPath, { throwIfNoEntry: false })?.size ?? 0) > 2_000_000) renameSync(logPath, `${logPath}.1`); // bound the log, keep one previous generation
357
+ appendFileSync(logPath, `${new Date().toISOString()} ${entry}\n`);
358
+ } catch {
359
+ /* observability must never throw */
360
+ }
361
+ }
362
+
363
+ // ---- fork-state inheritance (copy-on-fork; design in fork.js header) ------
364
+ const linksCache = { mtime: -1, map: null }; // mtime-gated fork-links map
365
+ const inheritFirstSeen = new Map(); // sessionId -> epoch ms of first eligibility check
366
+ const inheritDecided = new Set(); // sessions whose inheritance attempt settled (this process)
367
+ const inheritWarned = new Set(); // warn-once per session
368
+
369
+ /** Fresh fork-links map, reloaded when the file changed on disk (the
370
+ * handover fork writes it from a different call site/process). Fail-open:
371
+ * a stat failure keeps the cached map. */
372
+ function freshLinks() {
373
+ try {
374
+ const mtime = statSync(join(store.stateDir, "fork-links.json"), { throwIfNoEntry: false })?.mtimeMs ?? -1;
375
+ if (linksCache.map === null || mtime !== linksCache.mtime) {
376
+ linksCache.map = loadForkLinks(store.stateDir, warn);
377
+ linksCache.mtime = mtime;
378
+ }
379
+ } catch {
380
+ /* stat is best-effort; keep the cached map */
381
+ }
382
+ return linksCache.map;
383
+ }
384
+
385
+ /** Copy-on-fork: clone the parent fold ledger into this session at its
386
+ * first model call (called from the stream view path ONLY — the guard
387
+ * needs the child history). Guarded, one-shot per session, fail-open:
388
+ * any failure degrades to a clean start with one warning. */
389
+ function maybeInherit(sessionId, messages) {
390
+ if (inheritDecided.has(sessionId)) return;
391
+ const finish = () => {
392
+ inheritDecided.add(sessionId);
393
+ // keep the decided set bounded (2000 sessions)
394
+ if (inheritDecided.size > 2000) {
395
+ const oldest = inheritDecided.keys().next().value;
396
+ if (oldest !== undefined) inheritDecided.delete(oldest);
397
+ }
398
+ };
399
+ try {
400
+ const now = Date.now();
401
+ const firstSeen = inheritFirstSeen.get(sessionId) ?? now;
402
+ inheritFirstSeen.set(sessionId, firstSeen);
403
+ if (inheritFirstSeen.size > 2000) inheritFirstSeen.delete(inheritFirstSeen.keys().next().value);
404
+ if (inheritWarned.size > 2000) inheritWarned.delete(inheritWarned.values().next().value); // same bound family as inheritDecided
405
+ const ledger = store.ledgerOf(sessionId);
406
+ if ((Array.isArray(ledger.blocks) && ledger.blocks.length > 0) || ledger.inheritedFrom !== null) return finish(); // already folded / already inherited
407
+ const link = forkLinkOf(freshLinks(), sessionId);
408
+ const parentLedger = link !== null ? store.ledgerOf(link.parent) : null;
409
+ const parentHasBlocks = parentLedger !== null && Array.isArray(parentLedger.blocks) && parentLedger.blocks.length > 0;
410
+ if (link === null || !parentHasBlocks) {
411
+ // Nothing (yet) to inherit: stay pending inside the bounded window
412
+ // to cover the fork race (child's first call may beat the parent's
413
+ // post-execute bookkeeping or first idle-boundary fold), then stop
414
+ // checking — a plain session never inherits, and a fold-less parent
415
+ // has nothing to give.
416
+ if (now - firstSeen > INHERIT_WINDOW_MS) finish();
417
+ return;
418
+ }
419
+ const outcome = resolveInheritance(parentLedger, link.parent, sessionId, messages, now);
420
+ if (!outcome.ok) {
421
+ if (!inheritWarned.has(sessionId)) {
422
+ inheritWarned.add(sessionId);
423
+ warn(`[context-trim] ${sessionId}: fork inheritance rejected (${outcome.reason}) — starting clean`);
424
+ }
425
+ return finish();
426
+ }
427
+ if (!store.adoptLedger(sessionId, outcome.ledger)) return finish();
428
+ inheritWarned.add(sessionId); // any later rejection for this child stays quiet
429
+ warn(`[context-trim] ${sessionId}: inherited fold state from ${link.parent} (${outcome.ledger.blocks.length} blocks, frontier preserved)`);
430
+ finish();
431
+ } catch (error) {
432
+ finish();
433
+ if (!inheritWarned.has(sessionId)) {
434
+ inheritWarned.add(sessionId);
435
+ warn(`[context-trim] fork inheritance failed for ${sessionId} (${error?.message ?? error}) — starting clean`);
436
+ }
437
+ }
438
+ }
439
+
440
+ const enabledNow = () => readBool(config, "trimEnabled", true);
441
+ const modeNow = () => {
442
+ const mode = readString(config, "trimMode", "auto");
443
+ return mode === "off" || mode === "advisory" || mode === "auto" ? mode : "auto";
444
+ };
445
+ const thresholdsNow = () => ({
446
+ growthTokens: readNumber(config, "trimGrowthTokens", 22_500),
447
+ incrementTokens: readNumber(config, "trimIncrementTokens", 50_000),
448
+ pressurePercent: readNumber(config, "trimPressurePercent", 60),
449
+ emergencyPercent: readNumber(config, "trimEmergencyPercent", 95),
450
+ protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 6))),
451
+ maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour", 6),
452
+ });
453
+ /** Effective-window cap: explicit trimWindowTokens, else auto-follow
454
+ * wmForceTokens (keeps fold tiers ahead of the handover tier). */
455
+ const windowCapNow = () => {
456
+ const explicit = readNumber(config, "trimWindowTokens", 0);
457
+ if (explicit > 0) return explicit;
458
+ const force = readNumber(config, "wmForceTokens", 150_000);
459
+ return force > 0 ? force : null;
460
+ };
461
+
462
+ /** Session id for a stream call: options.sessionId, else the current
463
+ * initiator's session (try/catch — host internals shift). */
464
+ function sessionIdOf(options) {
465
+ if (typeof options?.sessionId === "string" && options.sessionId !== "") return options.sessionId;
466
+ try {
467
+ return ctx.agents?.currentInitiator?.()?.session?.id ?? null;
468
+ } catch {
469
+ return null;
470
+ }
471
+ }
472
+
473
+ /** Effective window for the tier math: min(model-resolved window, cap).
474
+ * The cache stores the RAW resolved value so runtime cap changes apply
475
+ * immediately without re-resolution. */
476
+ async function windowTokensFor(model) {
477
+ if (model === null || typeof model.provider !== "string" || typeof model.model !== "string") return windowCapNow();
478
+ const key = `${model.provider}/${model.model}`;
479
+ const cached = windowCache.get(key);
480
+ const now = Date.now();
481
+ const resolved = cached !== undefined && now - cached.at < 600_000 ? cached.windowTokens : await resolveRawWindow(model, key, now);
482
+ return effectiveWindowTokens(resolved, windowCapNow());
483
+ }
484
+
485
+ /** Async raw-window resolution (fills the cache; null when unknown). */
486
+ async function resolveRawWindow(model, key, now) {
487
+ try {
488
+ const llm = ctx.get("llm", false);
489
+ const info = await llm?.resolveModelInfo?.(model);
490
+ const windowTokens = info?.context?.contextWindow;
491
+ const value = Number.isFinite(windowTokens) && windowTokens > 0 ? windowTokens : null;
492
+ windowCache.set(key, { windowTokens: value, at: now });
493
+ if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
494
+ return value;
495
+ } catch {
496
+ windowCache.set(key, { windowTokens: null, at: now });
497
+ if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
498
+ return null;
499
+ }
500
+ }
501
+
502
+ /** Capture the pre-mutation messages of one stream call (shallow array
503
+ * copy — message objects are treated as immutable records). */
504
+ function captureOf(sessionId, messages, model, viewTokens) {
505
+ captures.set(sessionId, { messages: messages.slice(), model, viewTokens, at: Date.now() });
506
+ // keep the capture map bounded (200 sessions)
507
+ if (captures.size > 200) {
508
+ const oldest = captures.keys().next().value;
509
+ if (oldest !== undefined) captures.delete(oldest);
510
+ }
511
+ }
512
+
513
+ /** The outbound view for one request: fold blocks spliced in place. */
514
+ function applyViewToOptions(options) {
515
+ const messages = options?.messages;
516
+ if (!Array.isArray(messages) || messages.length === 0) return null;
517
+ const sessionId = sessionIdOf(options);
518
+ if (sessionId === null) return null;
519
+ maybeInherit(sessionId, messages); // copy-on-fork: inherit BEFORE the first view build
520
+ const { protectedTail } = thresholdsNow();
521
+ const ledger = store.ledgerOf(sessionId);
522
+ const view = buildView(messages, ledger, { protectedTail });
523
+ // Capture the RAW history BEFORE splicing the view in: the host rebuilds
524
+ // a fresh RAW array per llm/stream call, so refs/frontier must always be
525
+ // resolved against raw history (with a folded capture, fold #2+ refs
526
+ // would never resolve and the frontier would silently reset).
527
+ const rawSnapshot = messages.slice();
528
+ // Dispatch immutability varies: probe/fresh re-sends hand out mutable
529
+ // arrays, resumed sessions re-send frozen host-assembled history, and
530
+ // some dispatch options are frozen on top of that. injectView picks the
531
+ // best strategy per dispatch and fails open; the view-applied line
532
+ // records which strategy was used.
533
+ const injected = injectView(options, messages, view);
534
+ const viewTokens = estimateTokensOf(view.messages);
535
+ const blocksCount = Array.isArray(ledger.blocks) ? ledger.blocks.length : 0;
536
+ // Wire truth in the row: the most recent provider prompt size (from the
537
+ // previous response's usage) makes the view-vs-wire divergence visible
538
+ // instead of leaving the view estimate as the only lullaby in the log.
539
+ const wireTokens = wireTokensOf(sessionId);
540
+ // Per-request application evidence: whether the ledger's blocks actually
541
+ // locate inside THIS re-send (the host may compact/replace history, in
542
+ // which case applied=0 explains a stuck-high view without any failure).
543
+ decisionLog(`session=${sessionId} view-applied applied=${view.applied} foldedMsgs=${view.foldedMessages} view=${Math.round(viewTokens)} blocks=${blocksCount} injected=${injected}${wireTokens === null ? "" : ` wire=${wireTokens}`}`);
544
+ // Side-dispatch guard: auxiliary llm calls that inherit the session id
545
+ // (web-search summaries, title generation, fold-span material) present
546
+ // tiny arrays in which the ledger cannot locate. Accepting them as the
547
+ // session view clobbers the capture, the cross-layer registry that
548
+ // measureNow reads, and the growth baseline (observed 2026-11-04: a
549
+ // baseline reset to ~150 tokens mid-session). Fold-span dispatches are
550
+ // already covered by inFlight. A tiny dispatch is a side candidate
551
+ // whenever the session ALREADY carries trim state — fold blocks, a
552
+ // capture, or a view registry entry — regardless of blocksCount,
553
+ // which is exactly 0 in the young zero-fold sessions an auxiliary
554
+ // dispatch most often hits. A session's very FIRST dispatch is
555
+ // indistinguishable from a real one and stays accepted: it cannot
556
+ // clobber state that does not exist yet, and the next real dispatch
557
+ // overwrites the capture. A genuine host-compaction epoch ALSO shrinks
558
+ // history below the floor, so the guard must not stick: after
559
+ // SIDE_DISPATCH_SKIP_BUDGET consecutive skips the dispatch is accepted
560
+ // as main, which re-arms capture/baseline on the compacted history.
561
+ const sideCandidate = inFlight.has(sessionId)
562
+ || (view.applied === 0 && messages.length < SIDE_DISPATCH_MIN_MESSAGES && (blocksCount > 0 || captures.has(sessionId) || trimViewBySession.has(sessionId)));
563
+ const skipCount = sideDispatchSkipsBySession.get(sessionId) ?? 0;
564
+ if (sideCandidate && skipCount < SIDE_DISPATCH_SKIP_BUDGET) {
565
+ sideDispatchSkipsBySession.set(sessionId, skipCount + 1);
566
+ if (sideDispatchSkipsBySession.size > 200) sideDispatchSkipsBySession.delete(sideDispatchSkipsBySession.keys().next().value);
567
+ decisionLog(`session=${sessionId} side-dispatch skipped n=${skipCount + 1} msgs=${messages.length} view=${Math.round(viewTokens)} blocks=${blocksCount}`);
568
+ return { sessionId, viewTokens, sideDispatch: true };
569
+ }
570
+ if (sideCandidate) decisionLog(`session=${sessionId} side-dispatch budget exhausted — accepting as main msgs=${messages.length}`);
571
+ sideDispatchSkipsBySession.delete(sessionId);
572
+ // Host-compaction epoch boundary: every block failed to LOCATE inside
573
+ // this re-send, so the ledger aged out as a whole. Re-anchor the growth
574
+ // baseline at the true post-epoch view once per epoch — carrying the
575
+ // pre-epoch baseline across the boundary produced misleading negative
576
+ // growth and a growth gate armed against vanished messages. The flag
577
+ // clears when a fold applies again (fresh epoch folds locate).
578
+ // Skipped while a fold cycle is in flight: the cycle's own span dispatch presents a
579
+ // subset array where refs cannot locate — not epoch evidence, just self-noise.
580
+ if (!inFlight.has(sessionId) && ledgerIsStale(view, ledger)) {
581
+ if (!epochResetDoneBySession.has(sessionId)) {
582
+ epochResetDoneBySession.set(sessionId, true);
583
+ if (epochResetDoneBySession.size > 200) epochResetDoneBySession.delete(epochResetDoneBySession.keys().next().value); // bounded like captures/trimViewBySession
584
+ store.recordFoldBaseline(sessionId, viewTokens, viewTokens);
585
+ decisionLog(`session=${sessionId} ledger-stale blocks=${blocksCount} view=${Math.round(viewTokens)} growth-baseline-reset`);
586
+ }
587
+ } else if (view.applied > 0) {
588
+ epochResetDoneBySession.delete(sessionId);
589
+ }
590
+ const model = { provider: options.provider ?? null, model: options.model ?? null };
591
+ captureOf(sessionId, rawSnapshot, model, viewTokens);
592
+ metrics.noteView(sessionId, viewTokens, model);
593
+ const priorEntry = trimViewBySession.get(sessionId);
594
+ const lastNoneAt = injected === "none" ? Date.now() : Number.isFinite(priorEntry?.lastNoneAt) ? priorEntry.lastNoneAt : 0;
595
+ trimViewBySession.set(sessionId, { viewTokens, at: Date.now(), inject: injected, lastNoneAt });
596
+ // keep the view registry bounded (200 sessions, same as captures)
597
+ if (trimViewBySession.size > 200) {
598
+ const oldest = trimViewBySession.keys().next().value;
599
+ if (oldest !== undefined) trimViewBySession.delete(oldest);
600
+ }
601
+ return { sessionId, viewTokens };
602
+ }
603
+
604
+ /** Resolve the side-model route: explicit trimSideModel route, else the
605
+ * session's own model (inherit — design §5). */
606
+ function sideRouteFor(sessionId) {
607
+ const explicit = readRoute(config, "trimSideModel");
608
+ if (explicit !== null) return explicit;
609
+ const model = metrics.of(sessionId)?.lastModel ?? null;
610
+ if (model !== null && typeof model.provider === "string" && model.provider !== "" && typeof model.model === "string" && model.model !== "") {
611
+ return { provider: model.provider, model: model.model };
612
+ }
613
+ return null;
614
+ }
615
+
616
+ /** Run one fold cycle for a session (called at the idle boundary ONLY).
617
+ * Returns a short outcome string for logging. */
618
+ async function runFoldCycle(sessionId, decision) {
619
+ const capture = captures.get(sessionId);
620
+ if (capture === undefined) return "no-capture";
621
+ const { startIdx, endIdx } = decision;
622
+ const slice = capture.messages.slice(startIdx, endIdx + 1);
623
+ if (slice.length === 0) return "empty-slice";
624
+ const refs = slice.map((message, i) => refOf(message, startIdx + i));
625
+ const count = slice.length;
626
+ const approxTokens = estimateTokensOf(slice);
627
+ let summary = null;
628
+ let kind = "summary";
629
+ if (decision.action === "emergency") {
630
+ // Emergency cooldown timestamp set BEFORE the cycle: a commitFold
631
+ // rejection (e.g. duplicate-range) must cool the emergency tier
632
+ // down like a success, or a rejected emergency fold retries at
633
+ // every idle boundary forever. The 90s trigger gate semantics
634
+ // (EMERGENCY_COOLDOWN_MS) stay untouched.
635
+ lastEmergencyAtBySession.set(sessionId, Date.now());
636
+ if (lastEmergencyAtBySession.size > 200) lastEmergencyAtBySession.delete(lastEmergencyAtBySession.keys().next().value);
637
+ summary = mechanicalSummary(capture.messages, startIdx, endIdx, refOf);
638
+ kind = "mechanical";
639
+ } else {
640
+ const llm = ctx.get("llm", false);
641
+ const route = sideRouteFor(sessionId);
642
+ summary = await summarizeWithModel(llm, route, renderMaterial(capture.messages, startIdx, endIdx, refOf), {
643
+ onFail: (why) => decisionLog(`session=${sessionId} summary-fail why=${why}`),
644
+ });
645
+ if (summary === null) return "summary-unavailable"; // skip cycle, fail-open
646
+ }
647
+ const positions = refPositions(capture.messages);
648
+ const result = store.commitFold(
649
+ sessionId,
650
+ { refRange: { firstId: refs[0], lastId: refs[refs.length - 1] }, count, approxTokens, summary, kind, tier: decision.tier },
651
+ positions,
652
+ );
653
+ if (!result.ok) return `commit-rejected:${result.reason}`;
654
+ // Commit-time surface capture (primary range-mapping strategy, see
655
+ // surface.js header): store the covered event seqs while the live
656
+ // surface still resolves the folded range.
657
+ surfacePass.captureFoldSeqs(sessionId, result.block);
658
+ // Growth-gate baseline pair: pre-fold view (banner display) plus the
659
+ // post-fold low (tier-a anchor — keeps the gate armed after folds
660
+ // that commit at high view points).
661
+ const lowLedger = store.ledgerOf(sessionId);
662
+ const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
663
+ store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
664
+ return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)`;
665
+ }
666
+
667
+ /** Idle-boundary hook (also re-used by the stream-path fallback): decide
668
+ * and run the fold. All failures contained. */
669
+ async function maybeFold(agent, via = "idle") {
670
+ const sessionId = agent;
671
+ if (sessionId === null || sessionId === undefined || sessionId === "") return;
672
+ if (!enabledNow() || modeNow() !== "auto") return;
673
+ if (inFlight.has(sessionId)) return;
674
+ // Failure backoff: a session whose recent cycles failed stays quiet
675
+ // for 60s*2^(n-1) (cap 15 min) instead of re-firing at every idle
676
+ // boundary and stream check. Silent gate — the outcome row that armed
677
+ // the cooldown is already in decisions.log.
678
+ if (foldFailureCooldownOf(sessionId) > 0) return;
679
+ const capture = captures.get(sessionId);
680
+ if (capture === undefined) return;
681
+ // Injection-unavailable: the last dispatch could not carry the view
682
+ // (frozen options AND messages — the wire always ships raw history).
683
+ // Folding is pointless ONLY when NEITHER channel can reach the wire:
684
+ // the spliced view (inject !== "none") OR the surface-replace pass
685
+ // (durable capability probe, not a recency window — a proven channel
686
+ // that last shipped before the process restarted is still capable and
687
+ // the next fold ships a fresh surface event). When neither can, the
688
+ // watermark's tally-driven force tier + auto-handover is the relief
689
+ // path.
690
+ if (recentTrimInjectOf(sessionId) === "none" && !surfaceChannelCapable(sessionId)) {
691
+ decisionLog(`session=${sessionId} via=${via} action=none reason=injection-unavailable view=${Math.round(capture.viewTokens)}`);
692
+ return;
693
+ }
694
+ inFlight.add(sessionId);
695
+ try {
696
+ const { protectedTail, ...thresholds } = thresholdsNow();
697
+ const foldable = computeFoldableRange(capture.messages, store.ledgerOf(sessionId), protectedTail);
698
+ const windowTokens = await windowTokensFor(capture.model);
699
+ const ledger = store.ledgerOf(sessionId);
700
+ // Pressure tiers run on the REAL prompt size when the tally is known:
701
+ // max(view, wire). The view estimate misses the system/context
702
+ // sections, so a 136k wire once read as a 50k view and the tiers
703
+ // stayed silent. Unknown tally → exactly today's behavior.
704
+ const wireTokens = wireTokensOf(sessionId);
705
+ const pressureTokens = wireTokens !== null ? Math.max(capture.viewTokens, wireTokens) : capture.viewTokens;
706
+ const decision = evaluateTrigger({
707
+ foldable,
708
+ messages: capture.messages,
709
+ viewTokens: pressureTokens,
710
+ windowTokens,
711
+ thresholds,
712
+ now: Date.now(),
713
+ lastFoldAt: ledger.lastFoldAt,
714
+ foldsThisHour: ledger.foldsThisHour,
715
+ lastEmergencyAt: lastEmergencyAtBySession.get(sessionId) ?? 0,
716
+ ledger,
717
+ });
718
+ 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
+ if (decision.action === "none") return;
720
+ const outcome = await runFoldCycle(sessionId, decision);
721
+ // Failure ladder: commit resets it; a summarize/commit failure
722
+ // climbs it (the no-backoff loop this closes — those outcomes
723
+ // advance no cadence gate on their own).
724
+ if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
725
+ else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:")) recordFoldFailure(sessionId);
726
+ // Outcome lands in the decision log too: warn() has no reachable
727
+ // sink on some hosts, and the commit/fail boundary is exactly what
728
+ // live diagnosis needs.
729
+ decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier}`);
730
+ warn(`[context-trim] ${sessionId}: ${outcome} (${decision.reason})`);
731
+ } catch (error) {
732
+ recordFoldFailure(sessionId); // a thrown cycle counts as failed too
733
+ warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
734
+ } finally {
735
+ inFlight.delete(sessionId);
736
+ }
737
+ }
738
+
739
+ // ---- host registrations (each individually fail-open) --------------------
740
+
741
+ try {
742
+ ctx.systemPrompt?.section?.({ name: "switchman:trim", order: 10450, interpolate: false, text: () => TRIM_PROTOCOL_TEXT });
743
+ } catch (error) {
744
+ warn(`[context-trim] systemPrompt.section failed: ${error?.message ?? error}`);
745
+ }
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
+
799
+ try {
800
+ ctx.on("agent/status", (payload) => {
801
+ try {
802
+ if (payload?.status !== "idle") return;
803
+ // payload.agent is the agent OBJECT (dsh-agent fuses { ...payload,
804
+ // agent }) — derive the session id per host/context-watch.js:122.
805
+ const sessionId = agentSessionIdOf(payload?.agent);
806
+ if (sessionId === null) return;
807
+ // Fold first, then the surface-replace idle sweep (design
808
+ // docs/surface-fold.md): a block committed by this idle pass
809
+ // ships on the same boundary.
810
+ void maybeFold(sessionId).finally(() => {
811
+ try {
812
+ void surfacePass.sweep(sessionId, "idle");
813
+ } catch {
814
+ /* sweep must never break the idle hook */
815
+ }
816
+ });
817
+ } catch {
818
+ /* idle hook must never throw */
819
+ }
820
+ });
821
+ } catch (error) {
822
+ warn(`[context-trim] agent/status hook failed: ${error?.message ?? error}`);
823
+ }
824
+
825
+ // ---- fork linkage, channel A: the subagent_fork tool -----------------------
826
+ // The child id is read from the tool result (subagent-model.js
827
+ // childSessionIdOf: kind activation/continuable -> subagentId, kind
828
+ // foreground -> runId); the parent id is THIS session (currentInitiator).
829
+ // Recording is bookkeeping on an already-successful fork: best-effort,
830
+ // and the downstream decision is always returned untouched.
831
+ try {
832
+ ctx.on("tools/post-execute", (exec, result, next) => {
833
+ // Fast path: only successful subagent_fork calls fork a conversation
834
+ // (a plain subagent child starts with fresh history — nothing to
835
+ // inherit). Everything else stays exactly on the pipeline.
836
+ if (exec?.name !== "subagent_fork" || result?.isError === true) return next();
837
+ return (async () => {
838
+ const downstream = await next();
839
+ try {
840
+ if (downstream === null || typeof downstream !== "object" || downstream.kind !== "accept") return downstream;
841
+ const childId = childSessionIdOf(result?.value);
842
+ if (childId === null) return downstream;
843
+ const parentId = sessionIdOf({});
844
+ if (parentId === null || parentId === childId) return downstream;
845
+ if (recordForkLink(parentId, childId, { stateDir: store.stateDir, warn })) {
846
+ // The child's eligibility window re-opens: its first model
847
+ // call may already have raced past the fork bookkeeping.
848
+ inheritDecided.delete(childId);
849
+ inheritFirstSeen.delete(childId);
850
+ }
851
+ } catch (error) {
852
+ warn(`[context-trim] fork-link recording failed: ${error?.message ?? error}`);
853
+ }
854
+ return downstream;
855
+ })();
856
+ });
857
+ } catch (error) {
858
+ warn(`[context-trim] tools/post-execute hook failed: ${error?.message ?? error}`);
859
+ }
860
+
861
+ // ---- tools (L1 advisory surface) ------------------------------------------
862
+
863
+ try {
864
+ ctx.tools?.register?.({
865
+ name: "ctx_trim_status",
866
+ description: "Inspect the context-trim state of the current session: mode, thresholds, view token estimate, fold ledger, per-message refs of the captured history. Read-only.",
867
+ parameters: { type: "object", properties: {}, additionalProperties: false },
868
+ required: [],
869
+ async execute() {
870
+ const sessionId = sessionIdOf({});
871
+ if (sessionId === null) return "No session context available.";
872
+ const ledger = store.ledgerOf(sessionId);
873
+ const record = metrics.of(sessionId);
874
+ const capture = captures.get(sessionId);
875
+ const { protectedTail, ...thresholds } = thresholdsNow();
876
+ const lines = [
877
+ "ctx_trim_status",
878
+ `mode: ${modeNow()} (enabled: ${enabledNow()})`,
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
+ ];
884
+ if (capture !== undefined) {
885
+ lines.push("", `captured history (${capture.messages.length} messages; refs [m...] are fold anchors):`);
886
+ capture.messages.forEach((message, i) => {
887
+ lines.push(`- [m${refOf(message, i)}] (${message?.role ?? "?"}) ~${estimateTokensOf(capture.messages, i, i)} tokens`);
888
+ });
889
+ } else {
890
+ lines.push("", "captured history: none yet (appears after the next model call)");
891
+ }
892
+ return lines.join("\n");
893
+ },
894
+ });
895
+ } catch (error) {
896
+ warn(`[context-trim] ctx_trim_status tool failed: ${error?.message ?? error}`);
897
+ }
898
+
899
+ try {
900
+ ctx.tools?.register?.({
901
+ name: "ctx_trim_fold",
902
+ description: "Fold a contiguous range of this session's history into one summary message (model-driven advisory fold). Provide firstId/lastId message refs (from ctx_trim_status), a summary text (compression rules: keep paths/signatures/errors verbatim, decisions with rationale, open objectives), and the message count in the range. Tool-call pairs must be folded whole.",
903
+ parameters: {
904
+ type: "object",
905
+ properties: {
906
+ firstId: { type: "string", description: "Ref of the first message to fold ([m...] form from ctx_trim_status, without the brackets)." },
907
+ lastId: { type: "string", description: "Ref of the last message to fold." },
908
+ count: { type: "number", description: "Number of messages in the inclusive range." },
909
+ summary: { type: "string", description: "Self-contained summary text replacing the range." },
910
+ },
911
+ required: ["firstId", "lastId", "count", "summary"],
912
+ additionalProperties: false,
913
+ },
914
+ async execute(args) {
915
+ const sessionId = sessionIdOf({});
916
+ if (sessionId === null) return "No session context available.";
917
+ const capture = captures.get(sessionId);
918
+ if (capture === undefined) return "No captured history yet — fold is unavailable until the next model call.";
919
+ const positions = refPositions(capture.messages);
920
+ const firstList = positions.get(String(args?.firstId ?? ""));
921
+ const lastList = positions.get(String(args?.lastId ?? ""));
922
+ if (firstList === undefined || lastList === undefined) return "Refs not found in the current history — re-check with ctx_trim_status.";
923
+ // Count-anchored resolution: duplicate refs (identical content)
924
+ // make the first/last occurrence lists ambiguous — anchor on the
925
+ // stated count and take the first consistent pair (commitFold does
926
+ // the same).
927
+ const wanted = Number.isInteger(args?.count) && args.count > 0 ? args.count : null;
928
+ let firstIdx = -1;
929
+ let lastIdx = -1;
930
+ for (const start of firstList) {
931
+ const end = wanted !== null ? start + wanted - 1 : lastList[lastList.length - 1];
932
+ if (end >= start && lastList.includes(end)) { firstIdx = start; lastIdx = end; break; }
933
+ }
934
+ if (firstIdx < 0) { firstIdx = firstList[0]; lastIdx = lastList[lastList.length - 1]; }
935
+ if (lastIdx < firstIdx) return "lastId sorts before firstId in the current history.";
936
+ const { protectedTail } = thresholdsNow();
937
+ if (lastIdx >= capture.messages.length - Math.max(0, protectedTail)) return `Range reaches into the protected tail (last ${protectedTail} messages are never folded).`;
938
+ if (!rangeIsSegmentAligned(capture.messages, firstIdx, lastIdx)) return "Range splits a tool-call pair — fold whole assistant/tool segments (extend firstId/lastId to the segment boundaries).";
939
+ const count = wanted !== null ? wanted : lastIdx - firstIdx + 1;
940
+ const result = store.commitFold(
941
+ sessionId,
942
+ {
943
+ refRange: { firstId: String(args.firstId), lastId: String(args.lastId) },
944
+ count,
945
+ approxTokens: estimateTokensOf(capture.messages, firstIdx, lastIdx),
946
+ summary: String(args.summary ?? ""),
947
+ kind: "summary",
948
+ tier: "model",
949
+ },
950
+ positions,
951
+ );
952
+ if (!result.ok) return `Fold rejected: ${result.reason}.`;
953
+ surfacePass.captureFoldSeqs(sessionId, result.block);
954
+ // Growth-gate baseline pair: pre-fold view (banner display) plus the
955
+ // post-fold low (tier-a anchor — keeps the gate armed after folds
956
+ // that commit at high view points).
957
+ const lowLedger = store.ledgerOf(sessionId);
958
+ const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
959
+ store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
960
+ return `Folded ${count} messages into block ${result.block.id}. The view applies on the next model call.`;
961
+ },
962
+ });
963
+ } catch (error) {
964
+ warn(`[context-trim] ctx_trim_fold tool failed: ${error?.message ?? error}`);
965
+ }
966
+
967
+ // ---- llm/stream listener (mounted when the llm fiber exists) ---------------
968
+
969
+ function streamListener(options, next) {
970
+ let viewTokens = null;
971
+ try {
972
+ if (enabledNow()) {
973
+ const applied = applyViewToOptions(options);
974
+ if (applied !== null) viewTokens = applied.viewTokens;
975
+ }
976
+ } catch (error) {
977
+ decisionLog(`view-build-failed why=${error?.message ?? error}`);
978
+ warn(`[context-trim] view build failed: ${error?.message ?? error}`);
979
+ }
980
+ const result = next();
981
+ let wrappedSession = null;
982
+ try {
983
+ if (!enabledNow()) return result;
984
+ wrappedSession = sessionIdOf(options);
985
+ if (wrappedSession === null) return result;
986
+ return wrapStreamForMetrics(result, (chunk) => {
987
+ const usage = extractUsage(chunk);
988
+ if (usage !== null) metrics.recordUsage(wrappedSession, usage);
989
+ });
990
+ } catch (error) {
991
+ warn(`[context-trim] stream wrap failed: ${error?.message ?? error}`);
992
+ return result;
993
+ } finally {
994
+ // Fallback fold check on the stream path: the agent/status idle event
995
+ // may not reach a globally-registered listener on every host topology,
996
+ // so each stream end also runs a throttled (60 s/session) check. The
997
+ // trigger's own cadence gates still guard over-folding.
998
+ try {
999
+ if (enabledNow() && modeNow() === "auto" && wrappedSession !== null) {
1000
+ const nowMs = Date.now();
1001
+ if (nowMs - (lastStreamCheckAt.get(wrappedSession) ?? 0) >= 60_000) {
1002
+ lastStreamCheckAt.set(wrappedSession, nowMs);
1003
+ if (lastStreamCheckAt.size > 200) lastStreamCheckAt.delete(lastStreamCheckAt.keys().next().value);
1004
+ void maybeFold(wrappedSession, "stream");
1005
+ }
1006
+ }
1007
+ } catch {
1008
+ /* fallback must never break the stream */
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ try {
1014
+ ctx.inject(["llm"], (llmCtx) => {
1015
+ const attach = () => {
1016
+ try {
1017
+ // Global + prepend: the cordis dispatch filter silently drops NON-global
1018
+ // llm/stream listeners when the dispatching llm runtime lives outside
1019
+ // this fiber's isolate (resumed sessions dispatch through their own
1020
+ // subtree) — that silenced the whole stream path for a resumed session
1021
+ // while fresh sessions kept logging. Stock consumers (dsh-session-title)
1022
+ // register exactly this way.
1023
+ llmCtx.on?.("llm/stream", streamListener, { global: true, prepend: true });
1024
+ decisionLog(`llm-attached hasOn=${typeof llmCtx.on} hasOff=${typeof llmCtx.off} hasEffect=${typeof llmCtx.effect} pid=${process.pid}`);
1025
+ } catch (error) {
1026
+ warn(`[context-trim] llm/stream attach failed: ${error?.message ?? error}`);
1027
+ }
1028
+ };
1029
+ // Reload-safe release (applyIntent precedent): undo the listener
1030
+ // registration when the llm fiber disposes (plugin reload).
1031
+ try {
1032
+ const effect = llmCtx.effect;
1033
+ if (typeof effect === "function") {
1034
+ effect(function* () {
1035
+ yield () => {
1036
+ try {
1037
+ llmCtx.off?.("llm/stream", streamListener);
1038
+ } catch {
1039
+ /* best effort */
1040
+ }
1041
+ };
1042
+ }, "context-trim-stream-listener");
1043
+ }
1044
+ } catch {
1045
+ /* effect registration is best-effort */
1046
+ }
1047
+ attach();
1048
+ });
1049
+ } catch (error) {
1050
+ warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
1051
+ }
1052
+
1053
+ return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass, renderTrimContextLine: (agent) => renderTrimContextLine(agent, { enabled: enabledNow(), mode: modeNow(), viewTokens: null, windowTokens: null, folds: 0, growthTokens: null, lastModel: null, wireRaw: false }) };
1054
+ }