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.
- package/README.de.md +50 -7
- package/README.es.md +50 -7
- package/README.fr.md +50 -7
- package/README.it.md +50 -7
- package/README.ja.md +50 -7
- package/README.ko.md +50 -7
- package/README.md +49 -6
- package/README.pt.md +50 -7
- package/README.ru.md +50 -7
- package/README.zh-TW.md +50 -7
- package/README.zh.md +50 -7
- package/client.js +455 -58
- package/cordis.patch.yml +32 -0
- package/host/config.js +48 -2
- package/host/context-trim/fork.js +178 -0
- package/host/context-trim/index.js +1054 -0
- package/host/context-trim/metrics.js +128 -0
- package/host/context-trim/state.js +241 -0
- package/host/context-trim/summarizer.js +179 -0
- package/host/context-trim/surface.js +736 -0
- package/host/context-trim/trigger.js +129 -0
- package/host/context-trim/view.js +362 -0
- package/host/context-watch.js +32 -62
- package/host/handover-state.js +2 -2
- package/host/routes.js +1 -1
- package/host/subagent-model.js +1 -1
- package/index.js +18 -4
- package/package.json +4 -1
|
@@ -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
|
+
}
|