dsh-switchman 1.8.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +41 -1
- package/README.es.md +41 -1
- package/README.fr.md +41 -1
- package/README.it.md +41 -1
- package/README.ja.md +41 -1
- package/README.ko.md +41 -1
- package/README.md +41 -1
- package/README.pt.md +41 -1
- package/README.ru.md +41 -1
- package/README.zh-TW.md +41 -1
- package/README.zh.md +41 -1
- package/client.js +399 -2
- package/cordis.patch.yml +32 -0
- package/host/config.js +40 -0
- package/host/context-trim/fork.js +168 -0
- package/host/context-trim/index.js +830 -0
- package/host/context-trim/metrics.js +104 -0
- package/host/context-trim/state.js +241 -0
- package/host/context-trim/summarizer.js +179 -0
- package/host/context-trim/trigger.js +122 -0
- package/host/context-trim/view.js +316 -0
- package/host/context-watch.js +22 -2
- package/host/subagent-model.js +1 -1
- package/index.js +15 -1
- package/package.json +4 -1
|
@@ -0,0 +1,830 @@
|
|
|
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, wrapStreamForMetrics } from "./metrics.js";
|
|
34
|
+
import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
|
|
35
|
+
import { childSessionIdOf } from "../subagent-model.js";
|
|
36
|
+
|
|
37
|
+
// ---- agent reference helpers --------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** Derive a session id from an agent reference: either a plain string id or
|
|
40
|
+
* the agent OBJECT (dsh-agent fuses { ...payload, agent } — payload.agent on
|
|
41
|
+
* agent/status and assembleContextFor's context.agent are both the object
|
|
42
|
+
* form). Mirrors the host/context-watch.js:122 precedent. Returns null when
|
|
43
|
+
* nothing usable is present. */
|
|
44
|
+
// ---- cross-layer view registry (meter duality, design §8) ------------------
|
|
45
|
+
// Latest outbound-view token estimate per session, refreshed on EVERY
|
|
46
|
+
// llm/stream call. host/context-watch.js prefers this over the token meter's
|
|
47
|
+
// stored tally while it is fresh, so the watermark tracks what the model
|
|
48
|
+
// actually sees after billion-context folding instead of over-triggering
|
|
49
|
+
// handovers off a stale stored measure.
|
|
50
|
+
const trimViewBySession = new Map(); // sessionId -> { viewTokens, at }
|
|
51
|
+
const TRIM_VIEW_MAX_AGE_MS = 600_000; // 10 minutes
|
|
52
|
+
|
|
53
|
+
/** Most recent outbound-view token estimate for a session, or null when
|
|
54
|
+
* missing, non-positive, or older than maxAgeMs. Read by the watermark
|
|
55
|
+
* layer (meter duality); itself fail-open. */
|
|
56
|
+
export function recentTrimViewTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
|
|
57
|
+
try {
|
|
58
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
59
|
+
const entry = trimViewBySession.get(sessionId);
|
|
60
|
+
if (entry === undefined) return null;
|
|
61
|
+
if (Date.now() - entry.at > maxAgeMs) return null;
|
|
62
|
+
return Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function agentSessionIdOf(agent) {
|
|
69
|
+
if (typeof agent === "string") return agent !== "" ? agent : null;
|
|
70
|
+
if (agent !== null && typeof agent === "object") {
|
|
71
|
+
const id = agent.session?.id ?? agent.id;
|
|
72
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Display label for the banner: object → name, string → itself, else the
|
|
78
|
+
* session id (never prints [object Object]). */
|
|
79
|
+
export function agentLabelOf(agent) {
|
|
80
|
+
if (typeof agent === "string") return agent;
|
|
81
|
+
if (agent !== null && typeof agent === "object" && typeof agent.name === "string" && agent.name !== "") return agent.name;
|
|
82
|
+
return agentSessionIdOf(agent);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- config field readers (fail-open defaults) -------------------------------
|
|
86
|
+
|
|
87
|
+
function readBool(config, key, fallback) {
|
|
88
|
+
try {
|
|
89
|
+
const value = config?.[key];
|
|
90
|
+
if (typeof value === "boolean") return value;
|
|
91
|
+
if (value !== null && typeof value === "object" && typeof value.get === "function") {
|
|
92
|
+
const got = value.get();
|
|
93
|
+
if (typeof got === "boolean") return got;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
/* fall through */
|
|
97
|
+
}
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readNumber(config, key, fallback) {
|
|
102
|
+
try {
|
|
103
|
+
const value = config?.[key];
|
|
104
|
+
let got = value;
|
|
105
|
+
if (value !== null && typeof value === "object" && typeof value.get === "function") got = value.get();
|
|
106
|
+
if (Number.isFinite(got)) return got;
|
|
107
|
+
} catch {
|
|
108
|
+
/* fall through */
|
|
109
|
+
}
|
|
110
|
+
return fallback;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readString(config, key, fallback) {
|
|
114
|
+
try {
|
|
115
|
+
const value = config?.[key];
|
|
116
|
+
let got = value;
|
|
117
|
+
if (value !== null && typeof value === "object" && typeof value.get === "function") got = value.get();
|
|
118
|
+
if (typeof got === "string" && got !== "") return got;
|
|
119
|
+
} catch {
|
|
120
|
+
/* fall through */
|
|
121
|
+
}
|
|
122
|
+
return fallback;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readRoute(config, key) {
|
|
126
|
+
try {
|
|
127
|
+
const value = config?.[key];
|
|
128
|
+
let got = value;
|
|
129
|
+
if (value !== null && typeof value === "object" && typeof value.get === "function") got = value.get();
|
|
130
|
+
if (got !== null && typeof got === "object" && typeof got.provider === "string" && got.provider !== "" && typeof got.model === "string" && got.model !== "") {
|
|
131
|
+
return { provider: got.provider, model: got.model };
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
/* fall through */
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- prompt texts -------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/** Byte-stable head section: the trim protocol (digit-free, like the
|
|
142
|
+
* watermark protocol, so it stays out of KV invariants' way). */
|
|
143
|
+
export const TRIM_PROTOCOL_TEXT = [
|
|
144
|
+
"[SWITCHMAN:TRIM] This session has automatic context trimming.",
|
|
145
|
+
"- 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.",
|
|
146
|
+
"- NEVER contradict or re-derive content a summary already settled; build on it.",
|
|
147
|
+
"- 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.",
|
|
148
|
+
"- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
|
|
149
|
+
].join("\n");
|
|
150
|
+
|
|
151
|
+
/** Effective window for the pressure/emergency tier math:
|
|
152
|
+
* min(model-resolved window, configured cap). Null only when BOTH inputs
|
|
153
|
+
* are unusable — the trigger then skips those tiers (fail-open). */
|
|
154
|
+
export function effectiveWindowTokens(resolved, cap) {
|
|
155
|
+
const a = Number.isFinite(resolved) && resolved > 0 ? resolved : null;
|
|
156
|
+
const b = Number.isFinite(cap) && cap > 0 ? cap : null;
|
|
157
|
+
if (a === null) return b;
|
|
158
|
+
if (b === null) return a;
|
|
159
|
+
return Math.min(a, b);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Live context line renderer (pure; exported for tests). Silent in "off"
|
|
163
|
+
* mode and whenever nothing has been measured yet. */
|
|
164
|
+
export function renderTrimContextLine(agent, { enabled, mode, viewTokens, windowTokens, folds, growthTokens, lastModel }) {
|
|
165
|
+
if (!enabled) return "";
|
|
166
|
+
if (mode === "off") return "";
|
|
167
|
+
if (agent === null || agent === undefined || agent === "") return "";
|
|
168
|
+
const hasView = Number.isFinite(viewTokens) && viewTokens > 0;
|
|
169
|
+
const hasFolds = Number.isFinite(folds) && folds > 0;
|
|
170
|
+
if (!hasView && !hasFolds) return "";
|
|
171
|
+
const kilo = (n) => `${Math.round(n / 1000)}k`;
|
|
172
|
+
const parts = [];
|
|
173
|
+
if (hasView) {
|
|
174
|
+
let usage = "";
|
|
175
|
+
if (Number.isFinite(windowTokens) && windowTokens > 0) usage = ` (${Math.round((viewTokens / windowTokens) * 100)}% of window ${kilo(windowTokens)})`;
|
|
176
|
+
else usage = " (window unknown — pressure tiers off)";
|
|
177
|
+
parts.push(`view ~${kilo(viewTokens)} tokens${usage}`);
|
|
178
|
+
}
|
|
179
|
+
if (hasFolds) {
|
|
180
|
+
parts.push(`folds ${folds}`);
|
|
181
|
+
if (Number.isFinite(growthTokens)) parts.push(`growth ~${kilo(growthTokens)} since last fold`);
|
|
182
|
+
}
|
|
183
|
+
if (lastModel !== null && typeof lastModel === "object" && typeof lastModel.model === "string" && lastModel.model !== "") parts.push(`model ${lastModel.model}`);
|
|
184
|
+
if (parts.length === 0) return "";
|
|
185
|
+
const modeNote = mode === "advisory" ? "advisory: fold via ctx_trim_fold when context feels heavy" : "auto folding active";
|
|
186
|
+
return `[SWITCHMAN:TRIM] agent ${agent}: ${parts.join(", ")}; ${modeNote}.\n`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- layer ---------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Register the context-trim layer. Wired from the plugin root after
|
|
192
|
+
* applyContextWatch. Every host touch is fail-open: registration failures
|
|
193
|
+
* warn and the layer degrades to whatever part succeeded. */
|
|
194
|
+
export function applyContextTrim(ctx, config) {
|
|
195
|
+
const warn = (message) => {
|
|
196
|
+
try {
|
|
197
|
+
ctx.log?.warn?.(message);
|
|
198
|
+
} catch {
|
|
199
|
+
/* logging must never throw */
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Per-process pipeline state (process-wide by design: ledgers persist on
|
|
204
|
+
// disk per session, metrics/captures live per session in memory).
|
|
205
|
+
const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
|
|
206
|
+
const metrics = createMetrics();
|
|
207
|
+
const captures = new Map(); // sessionId -> { messages, refs, viewTokens, model, at }
|
|
208
|
+
const inFlight = new Set(); // sessionIds with a fold running
|
|
209
|
+
const windowCache = new Map(); // "provider/model" -> { windowTokens, at }
|
|
210
|
+
const lastEmergencyAtBySession = new Map(); // sessionId -> epoch ms of last emergency fold (per-session cooldown)
|
|
211
|
+
const lastStreamCheckAt = new Map(); // sessionId -> epoch ms of last stream-path fold check (throttle)
|
|
212
|
+
const epochResetDoneBySession = new Map(); // sessionId -> ledger-stale baseline reset already done for this host epoch (cleared when a fold applies again)
|
|
213
|
+
|
|
214
|
+
/** Append one fold-decision line to <stateDir>/decisions.log (fail-open,
|
|
215
|
+
* best-effort observability — console warn stays reserved for outcomes). */
|
|
216
|
+
function decisionLog(entry) {
|
|
217
|
+
try {
|
|
218
|
+
const dir = store.stateDir;
|
|
219
|
+
if (typeof dir !== "string" || dir === "") return;
|
|
220
|
+
mkdirSync(dir, { recursive: true });
|
|
221
|
+
const logPath = join(dir, "decisions.log");
|
|
222
|
+
if ((statSync(logPath, { throwIfNoEntry: false })?.size ?? 0) > 2_000_000) renameSync(logPath, `${logPath}.1`); // bound the log, keep one previous generation
|
|
223
|
+
appendFileSync(logPath, `${new Date().toISOString()} ${entry}\n`);
|
|
224
|
+
} catch {
|
|
225
|
+
/* observability must never throw */
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- fork-state inheritance (copy-on-fork; design in fork.js header) ------
|
|
230
|
+
const linksCache = { mtime: -1, map: null }; // mtime-gated fork-links map
|
|
231
|
+
const inheritFirstSeen = new Map(); // sessionId -> epoch ms of first eligibility check
|
|
232
|
+
const inheritDecided = new Set(); // sessions whose inheritance attempt settled (this process)
|
|
233
|
+
const inheritWarned = new Set(); // warn-once per session
|
|
234
|
+
|
|
235
|
+
/** Fresh fork-links map, reloaded when the file changed on disk (the
|
|
236
|
+
* handover fork writes it from a different call site/process). Fail-open:
|
|
237
|
+
* a stat failure keeps the cached map. */
|
|
238
|
+
function freshLinks() {
|
|
239
|
+
try {
|
|
240
|
+
const mtime = statSync(join(store.stateDir, "fork-links.json"), { throwIfNoEntry: false })?.mtimeMs ?? -1;
|
|
241
|
+
if (linksCache.map === null || mtime !== linksCache.mtime) {
|
|
242
|
+
linksCache.map = loadForkLinks(store.stateDir, warn);
|
|
243
|
+
linksCache.mtime = mtime;
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
/* stat is best-effort; keep the cached map */
|
|
247
|
+
}
|
|
248
|
+
return linksCache.map;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Copy-on-fork: clone the parent fold ledger into this session at its
|
|
252
|
+
* first model call (called from the stream view path ONLY — the guard
|
|
253
|
+
* needs the child history). Guarded, one-shot per session, fail-open:
|
|
254
|
+
* any failure degrades to a clean start with one warning. */
|
|
255
|
+
function maybeInherit(sessionId, messages) {
|
|
256
|
+
if (inheritDecided.has(sessionId)) return;
|
|
257
|
+
const finish = () => {
|
|
258
|
+
inheritDecided.add(sessionId);
|
|
259
|
+
// keep the decided set bounded (2000 sessions)
|
|
260
|
+
if (inheritDecided.size > 2000) {
|
|
261
|
+
const oldest = inheritDecided.keys().next().value;
|
|
262
|
+
if (oldest !== undefined) inheritDecided.delete(oldest);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
try {
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
const firstSeen = inheritFirstSeen.get(sessionId) ?? now;
|
|
268
|
+
inheritFirstSeen.set(sessionId, firstSeen);
|
|
269
|
+
if (inheritFirstSeen.size > 2000) inheritFirstSeen.delete(inheritFirstSeen.keys().next().value);
|
|
270
|
+
if (inheritWarned.size > 2000) inheritWarned.delete(inheritWarned.values().next().value); // same bound family as inheritDecided
|
|
271
|
+
const ledger = store.ledgerOf(sessionId);
|
|
272
|
+
if ((Array.isArray(ledger.blocks) && ledger.blocks.length > 0) || ledger.inheritedFrom !== null) return finish(); // already folded / already inherited
|
|
273
|
+
const link = forkLinkOf(freshLinks(), sessionId);
|
|
274
|
+
const parentLedger = link !== null ? store.ledgerOf(link.parent) : null;
|
|
275
|
+
const parentHasBlocks = parentLedger !== null && Array.isArray(parentLedger.blocks) && parentLedger.blocks.length > 0;
|
|
276
|
+
if (link === null || !parentHasBlocks) {
|
|
277
|
+
// Nothing (yet) to inherit: stay pending inside the bounded window
|
|
278
|
+
// to cover the fork race (child's first call may beat the parent's
|
|
279
|
+
// post-execute bookkeeping or first idle-boundary fold), then stop
|
|
280
|
+
// checking — a plain session never inherits, and a fold-less parent
|
|
281
|
+
// has nothing to give.
|
|
282
|
+
if (now - firstSeen > INHERIT_WINDOW_MS) finish();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const outcome = resolveInheritance(parentLedger, link.parent, sessionId, messages, now);
|
|
286
|
+
if (!outcome.ok) {
|
|
287
|
+
if (!inheritWarned.has(sessionId)) {
|
|
288
|
+
inheritWarned.add(sessionId);
|
|
289
|
+
warn(`[context-trim] ${sessionId}: fork inheritance rejected (${outcome.reason}) — starting clean`);
|
|
290
|
+
}
|
|
291
|
+
return finish();
|
|
292
|
+
}
|
|
293
|
+
if (!store.adoptLedger(sessionId, outcome.ledger)) return finish();
|
|
294
|
+
inheritWarned.add(sessionId); // any later rejection for this child stays quiet
|
|
295
|
+
warn(`[context-trim] ${sessionId}: inherited fold state from ${link.parent} (${outcome.ledger.blocks.length} blocks, frontier preserved)`);
|
|
296
|
+
finish();
|
|
297
|
+
} catch (error) {
|
|
298
|
+
finish();
|
|
299
|
+
if (!inheritWarned.has(sessionId)) {
|
|
300
|
+
inheritWarned.add(sessionId);
|
|
301
|
+
warn(`[context-trim] fork inheritance failed for ${sessionId} (${error?.message ?? error}) — starting clean`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const enabledNow = () => readBool(config, "trimEnabled", true);
|
|
307
|
+
const modeNow = () => {
|
|
308
|
+
const mode = readString(config, "trimMode", "auto");
|
|
309
|
+
return mode === "off" || mode === "advisory" || mode === "auto" ? mode : "auto";
|
|
310
|
+
};
|
|
311
|
+
const thresholdsNow = () => ({
|
|
312
|
+
growthTokens: readNumber(config, "trimGrowthTokens", 22_500),
|
|
313
|
+
incrementTokens: readNumber(config, "trimIncrementTokens", 50_000),
|
|
314
|
+
pressurePercent: readNumber(config, "trimPressurePercent", 60),
|
|
315
|
+
emergencyPercent: readNumber(config, "trimEmergencyPercent", 95),
|
|
316
|
+
protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 6))),
|
|
317
|
+
maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour", 6),
|
|
318
|
+
});
|
|
319
|
+
/** Effective-window cap: explicit trimWindowTokens, else auto-follow
|
|
320
|
+
* wmForceTokens (keeps fold tiers ahead of the handover tier). */
|
|
321
|
+
const windowCapNow = () => {
|
|
322
|
+
const explicit = readNumber(config, "trimWindowTokens", 0);
|
|
323
|
+
if (explicit > 0) return explicit;
|
|
324
|
+
const force = readNumber(config, "wmForceTokens", 150_000);
|
|
325
|
+
return force > 0 ? force : null;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** Session id for a stream call: options.sessionId, else the current
|
|
329
|
+
* initiator's session (try/catch — host internals shift). */
|
|
330
|
+
function sessionIdOf(options) {
|
|
331
|
+
if (typeof options?.sessionId === "string" && options.sessionId !== "") return options.sessionId;
|
|
332
|
+
try {
|
|
333
|
+
return ctx.agents?.currentInitiator?.()?.session?.id ?? null;
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Effective window for the tier math: min(model-resolved window, cap).
|
|
340
|
+
* The cache stores the RAW resolved value so runtime cap changes apply
|
|
341
|
+
* immediately without re-resolution. */
|
|
342
|
+
async function windowTokensFor(model) {
|
|
343
|
+
if (model === null || typeof model.provider !== "string" || typeof model.model !== "string") return windowCapNow();
|
|
344
|
+
const key = `${model.provider}/${model.model}`;
|
|
345
|
+
const cached = windowCache.get(key);
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
const resolved = cached !== undefined && now - cached.at < 600_000 ? cached.windowTokens : await resolveRawWindow(model, key, now);
|
|
348
|
+
return effectiveWindowTokens(resolved, windowCapNow());
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Async raw-window resolution (fills the cache; null when unknown). */
|
|
352
|
+
async function resolveRawWindow(model, key, now) {
|
|
353
|
+
try {
|
|
354
|
+
const llm = ctx.get("llm", false);
|
|
355
|
+
const info = await llm?.resolveModelInfo?.(model);
|
|
356
|
+
const windowTokens = info?.context?.contextWindow;
|
|
357
|
+
const value = Number.isFinite(windowTokens) && windowTokens > 0 ? windowTokens : null;
|
|
358
|
+
windowCache.set(key, { windowTokens: value, at: now });
|
|
359
|
+
if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
|
|
360
|
+
return value;
|
|
361
|
+
} catch {
|
|
362
|
+
windowCache.set(key, { windowTokens: null, at: now });
|
|
363
|
+
if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Capture the pre-mutation messages of one stream call (shallow array
|
|
369
|
+
* copy — message objects are treated as immutable records). */
|
|
370
|
+
function captureOf(sessionId, messages, model, viewTokens) {
|
|
371
|
+
captures.set(sessionId, { messages: messages.slice(), model, viewTokens, at: Date.now() });
|
|
372
|
+
// keep the capture map bounded (200 sessions)
|
|
373
|
+
if (captures.size > 200) {
|
|
374
|
+
const oldest = captures.keys().next().value;
|
|
375
|
+
if (oldest !== undefined) captures.delete(oldest);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The outbound view for one request: fold blocks spliced in place. */
|
|
380
|
+
function applyViewToOptions(options) {
|
|
381
|
+
const messages = options?.messages;
|
|
382
|
+
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
383
|
+
const sessionId = sessionIdOf(options);
|
|
384
|
+
if (sessionId === null) return null;
|
|
385
|
+
maybeInherit(sessionId, messages); // copy-on-fork: inherit BEFORE the first view build
|
|
386
|
+
const { protectedTail } = thresholdsNow();
|
|
387
|
+
const ledger = store.ledgerOf(sessionId);
|
|
388
|
+
const view = buildView(messages, ledger, { protectedTail });
|
|
389
|
+
// Capture the RAW history BEFORE splicing the view in: the host rebuilds
|
|
390
|
+
// a fresh RAW array per llm/stream call, so refs/frontier must always be
|
|
391
|
+
// resolved against raw history (with a folded capture, fold #2+ refs
|
|
392
|
+
// would never resolve and the frontier would silently reset).
|
|
393
|
+
const rawSnapshot = messages.slice();
|
|
394
|
+
// Dispatch immutability varies: probe/fresh re-sends hand out mutable
|
|
395
|
+
// arrays, resumed sessions re-send frozen host-assembled history, and
|
|
396
|
+
// some dispatch options are frozen on top of that. injectView picks the
|
|
397
|
+
// best strategy per dispatch and fails open; the view-applied line
|
|
398
|
+
// records which strategy was used.
|
|
399
|
+
const injected = injectView(options, messages, view);
|
|
400
|
+
const viewTokens = estimateTokensOf(view.messages);
|
|
401
|
+
const blocksCount = Array.isArray(ledger.blocks) ? ledger.blocks.length : 0;
|
|
402
|
+
// Per-request application evidence: whether the ledger's blocks actually
|
|
403
|
+
// locate inside THIS re-send (the host may compact/replace history, in
|
|
404
|
+
// which case applied=0 explains a stuck-high view without any failure).
|
|
405
|
+
decisionLog(`session=${sessionId} view-applied applied=${view.applied} foldedMsgs=${view.foldedMessages} view=${Math.round(viewTokens)} blocks=${blocksCount} injected=${injected}`);
|
|
406
|
+
// Host-compaction epoch boundary: every block failed to LOCATE inside
|
|
407
|
+
// this re-send, so the ledger aged out as a whole. Re-anchor the growth
|
|
408
|
+
// baseline at the true post-epoch view once per epoch — carrying the
|
|
409
|
+
// pre-epoch baseline across the boundary produced misleading negative
|
|
410
|
+
// growth and a growth gate armed against vanished messages. The flag
|
|
411
|
+
// clears when a fold applies again (fresh epoch folds locate).
|
|
412
|
+
// Skipped while a fold cycle is in flight: the cycle's own span dispatch presents a
|
|
413
|
+
// subset array where refs cannot locate — not epoch evidence, just self-noise.
|
|
414
|
+
if (!inFlight.has(sessionId) && ledgerIsStale(view, ledger)) {
|
|
415
|
+
if (!epochResetDoneBySession.has(sessionId)) {
|
|
416
|
+
epochResetDoneBySession.set(sessionId, true);
|
|
417
|
+
if (epochResetDoneBySession.size > 200) epochResetDoneBySession.delete(epochResetDoneBySession.keys().next().value); // bounded like captures/trimViewBySession
|
|
418
|
+
store.recordFoldBaseline(sessionId, viewTokens, viewTokens);
|
|
419
|
+
decisionLog(`session=${sessionId} ledger-stale blocks=${blocksCount} view=${Math.round(viewTokens)} growth-baseline-reset`);
|
|
420
|
+
}
|
|
421
|
+
} else if (view.applied > 0) {
|
|
422
|
+
epochResetDoneBySession.delete(sessionId);
|
|
423
|
+
}
|
|
424
|
+
const model = { provider: options.provider ?? null, model: options.model ?? null };
|
|
425
|
+
captureOf(sessionId, rawSnapshot, model, viewTokens);
|
|
426
|
+
metrics.noteView(sessionId, viewTokens, model);
|
|
427
|
+
trimViewBySession.set(sessionId, { viewTokens, at: Date.now() });
|
|
428
|
+
// keep the view registry bounded (200 sessions, same as captures)
|
|
429
|
+
if (trimViewBySession.size > 200) {
|
|
430
|
+
const oldest = trimViewBySession.keys().next().value;
|
|
431
|
+
if (oldest !== undefined) trimViewBySession.delete(oldest);
|
|
432
|
+
}
|
|
433
|
+
return { sessionId, viewTokens };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Resolve the side-model route: explicit trimSideModel route, else the
|
|
437
|
+
* session's own model (inherit — design §5). */
|
|
438
|
+
function sideRouteFor(sessionId) {
|
|
439
|
+
const explicit = readRoute(config, "trimSideModel");
|
|
440
|
+
if (explicit !== null) return explicit;
|
|
441
|
+
const model = metrics.of(sessionId)?.lastModel ?? null;
|
|
442
|
+
if (model !== null && typeof model.provider === "string" && model.provider !== "" && typeof model.model === "string" && model.model !== "") {
|
|
443
|
+
return { provider: model.provider, model: model.model };
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Run one fold cycle for a session (called at the idle boundary ONLY).
|
|
449
|
+
* Returns a short outcome string for logging. */
|
|
450
|
+
async function runFoldCycle(sessionId, decision) {
|
|
451
|
+
const capture = captures.get(sessionId);
|
|
452
|
+
if (capture === undefined) return "no-capture";
|
|
453
|
+
const { startIdx, endIdx } = decision;
|
|
454
|
+
const slice = capture.messages.slice(startIdx, endIdx + 1);
|
|
455
|
+
if (slice.length === 0) return "empty-slice";
|
|
456
|
+
const refs = slice.map((message, i) => refOf(message, startIdx + i));
|
|
457
|
+
const count = slice.length;
|
|
458
|
+
const approxTokens = estimateTokensOf(slice);
|
|
459
|
+
let summary = null;
|
|
460
|
+
let kind = "summary";
|
|
461
|
+
if (decision.action === "emergency") {
|
|
462
|
+
summary = mechanicalSummary(capture.messages, startIdx, endIdx, refOf);
|
|
463
|
+
kind = "mechanical";
|
|
464
|
+
} else {
|
|
465
|
+
const llm = ctx.get("llm", false);
|
|
466
|
+
const route = sideRouteFor(sessionId);
|
|
467
|
+
summary = await summarizeWithModel(llm, route, renderMaterial(capture.messages, startIdx, endIdx, refOf), {
|
|
468
|
+
onFail: (why) => decisionLog(`session=${sessionId} summary-fail why=${why}`),
|
|
469
|
+
});
|
|
470
|
+
if (summary === null) return "summary-unavailable"; // skip cycle, fail-open
|
|
471
|
+
}
|
|
472
|
+
const positions = refPositions(capture.messages);
|
|
473
|
+
const result = store.commitFold(
|
|
474
|
+
sessionId,
|
|
475
|
+
{ refRange: { firstId: refs[0], lastId: refs[refs.length - 1] }, count, approxTokens, summary, kind, tier: decision.tier },
|
|
476
|
+
positions,
|
|
477
|
+
);
|
|
478
|
+
if (!result.ok) return `commit-rejected:${result.reason}`;
|
|
479
|
+
// Growth-gate baseline pair: pre-fold view (banner display) plus the
|
|
480
|
+
// post-fold low (tier-a anchor — keeps the gate armed after folds
|
|
481
|
+
// that commit at high view points).
|
|
482
|
+
const lowLedger = store.ledgerOf(sessionId);
|
|
483
|
+
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
|
|
484
|
+
store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
|
|
485
|
+
if (decision.action === "emergency") lastEmergencyAtBySession.set(sessionId, Date.now());
|
|
486
|
+
if (lastEmergencyAtBySession.size > 200) lastEmergencyAtBySession.delete(lastEmergencyAtBySession.keys().next().value);
|
|
487
|
+
return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Idle-boundary hook (also re-used by the stream-path fallback): decide
|
|
491
|
+
* and run the fold. All failures contained. */
|
|
492
|
+
async function maybeFold(agent, via = "idle") {
|
|
493
|
+
const sessionId = agent;
|
|
494
|
+
if (sessionId === null || sessionId === undefined || sessionId === "") return;
|
|
495
|
+
if (!enabledNow() || modeNow() !== "auto") return;
|
|
496
|
+
if (inFlight.has(sessionId)) return;
|
|
497
|
+
const capture = captures.get(sessionId);
|
|
498
|
+
if (capture === undefined) return;
|
|
499
|
+
inFlight.add(sessionId);
|
|
500
|
+
try {
|
|
501
|
+
const { protectedTail, ...thresholds } = thresholdsNow();
|
|
502
|
+
const foldable = computeFoldableRange(capture.messages, store.ledgerOf(sessionId), protectedTail);
|
|
503
|
+
const windowTokens = await windowTokensFor(capture.model);
|
|
504
|
+
const ledger = store.ledgerOf(sessionId);
|
|
505
|
+
const decision = evaluateTrigger({
|
|
506
|
+
foldable,
|
|
507
|
+
messages: capture.messages,
|
|
508
|
+
viewTokens: capture.viewTokens,
|
|
509
|
+
windowTokens,
|
|
510
|
+
thresholds,
|
|
511
|
+
now: Date.now(),
|
|
512
|
+
lastFoldAt: ledger.lastFoldAt,
|
|
513
|
+
foldsThisHour: ledger.foldsThisHour,
|
|
514
|
+
lastEmergencyAt: lastEmergencyAtBySession.get(sessionId) ?? 0,
|
|
515
|
+
ledger,
|
|
516
|
+
});
|
|
517
|
+
decisionLog(`session=${sessionId} via=${via} action=${decision.action} tier=${decision.tier ?? "-"} reason=${decision.reason || "-"} view=${Math.round(capture.viewTokens)} window=${windowTokens ?? "null"}`);
|
|
518
|
+
if (decision.action === "none") return;
|
|
519
|
+
const outcome = await runFoldCycle(sessionId, decision);
|
|
520
|
+
// Outcome lands in the decision log too: warn() has no reachable
|
|
521
|
+
// sink on some hosts, and the commit/fail boundary is exactly what
|
|
522
|
+
// live diagnosis needs.
|
|
523
|
+
decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier}`);
|
|
524
|
+
warn(`[context-trim] ${sessionId}: ${outcome} (${decision.reason})`);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
|
|
527
|
+
} finally {
|
|
528
|
+
inFlight.delete(sessionId);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ---- host registrations (each individually fail-open) --------------------
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
ctx.systemPrompt?.section?.({ name: "switchman:trim", order: 10450, interpolate: false, text: () => TRIM_PROTOCOL_TEXT });
|
|
536
|
+
} catch (error) {
|
|
537
|
+
warn(`[context-trim] systemPrompt.section failed: ${error?.message ?? error}`);
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
ctx.systemPrompt?.context?.({
|
|
541
|
+
name: "switchman:trim",
|
|
542
|
+
order: 299,
|
|
543
|
+
required: true,
|
|
544
|
+
interpolate: false,
|
|
545
|
+
text: (context) => {
|
|
546
|
+
try {
|
|
547
|
+
const agent = context?.agent ?? null;
|
|
548
|
+
// context.agent is also the agent OBJECT — derive the id plus a
|
|
549
|
+
// display label so the banner never prints [object Object].
|
|
550
|
+
const sessionId = agentSessionIdOf(agent);
|
|
551
|
+
const label = agentLabelOf(agent);
|
|
552
|
+
const record = sessionId !== null ? metrics.of(sessionId) : null;
|
|
553
|
+
const ledger = sessionId !== null ? store.ledgerOf(sessionId) : null;
|
|
554
|
+
const model = captureModelOf(sessionId);
|
|
555
|
+
const windowTokens = model === null ? null : cachedWindowOf(model);
|
|
556
|
+
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;
|
|
557
|
+
return renderTrimContextLine(label, {
|
|
558
|
+
enabled: enabledNow(),
|
|
559
|
+
mode: modeNow(),
|
|
560
|
+
viewTokens: record?.viewTokens ?? null,
|
|
561
|
+
windowTokens,
|
|
562
|
+
folds: Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0,
|
|
563
|
+
growthTokens: record?.viewTokens !== null && record !== undefined && growthBaseline !== null ? record.viewTokens - growthBaseline : null,
|
|
564
|
+
lastModel: record?.lastModel ?? null,
|
|
565
|
+
});
|
|
566
|
+
} catch {
|
|
567
|
+
return "";
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
} catch (error) {
|
|
572
|
+
warn(`[context-trim] systemPrompt.context failed: ${error?.message ?? error}`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Synchronous window lookup for the banner: raw cache composed with
|
|
576
|
+
* the cap (async resolution happens in the idle path / stream path). */
|
|
577
|
+
function captureModelOf(sessionId) {
|
|
578
|
+
return captures.get(sessionId)?.model ?? null;
|
|
579
|
+
}
|
|
580
|
+
function cachedWindowOf(model) {
|
|
581
|
+
const resolved = model !== null && typeof model.provider === "string" && typeof model.model === "string" ? windowCache.get(`${model.provider}/${model.model}`)?.windowTokens ?? null : null;
|
|
582
|
+
return effectiveWindowTokens(resolved, windowCapNow());
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
ctx.on("agent/status", (payload) => {
|
|
587
|
+
try {
|
|
588
|
+
if (payload?.status !== "idle") return;
|
|
589
|
+
// payload.agent is the agent OBJECT (dsh-agent fuses { ...payload,
|
|
590
|
+
// agent }) — derive the session id per host/context-watch.js:122.
|
|
591
|
+
const sessionId = agentSessionIdOf(payload?.agent);
|
|
592
|
+
if (sessionId === null) return;
|
|
593
|
+
void maybeFold(sessionId);
|
|
594
|
+
} catch {
|
|
595
|
+
/* idle hook must never throw */
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
} catch (error) {
|
|
599
|
+
warn(`[context-trim] agent/status hook failed: ${error?.message ?? error}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ---- fork linkage, channel A: the subagent_fork tool -----------------------
|
|
603
|
+
// The child id is read from the tool result (subagent-model.js
|
|
604
|
+
// childSessionIdOf: kind activation/continuable -> subagentId, kind
|
|
605
|
+
// foreground -> runId); the parent id is THIS session (currentInitiator).
|
|
606
|
+
// Recording is bookkeeping on an already-successful fork: best-effort,
|
|
607
|
+
// and the downstream decision is always returned untouched.
|
|
608
|
+
try {
|
|
609
|
+
ctx.on("tools/post-execute", (exec, result, next) => {
|
|
610
|
+
// Fast path: only successful subagent_fork calls fork a conversation
|
|
611
|
+
// (a plain subagent child starts with fresh history — nothing to
|
|
612
|
+
// inherit). Everything else stays exactly on the pipeline.
|
|
613
|
+
if (exec?.name !== "subagent_fork" || result?.isError === true) return next();
|
|
614
|
+
return (async () => {
|
|
615
|
+
const downstream = await next();
|
|
616
|
+
try {
|
|
617
|
+
if (downstream === null || typeof downstream !== "object" || downstream.kind !== "accept") return downstream;
|
|
618
|
+
const childId = childSessionIdOf(result?.value);
|
|
619
|
+
if (childId === null) return downstream;
|
|
620
|
+
const parentId = sessionIdOf({});
|
|
621
|
+
if (parentId === null || parentId === childId) return downstream;
|
|
622
|
+
if (recordForkLink(parentId, childId, { stateDir: store.stateDir, warn })) {
|
|
623
|
+
// The child's eligibility window re-opens: its first model
|
|
624
|
+
// call may already have raced past the fork bookkeeping.
|
|
625
|
+
inheritDecided.delete(childId);
|
|
626
|
+
inheritFirstSeen.delete(childId);
|
|
627
|
+
}
|
|
628
|
+
} catch (error) {
|
|
629
|
+
warn(`[context-trim] fork-link recording failed: ${error?.message ?? error}`);
|
|
630
|
+
}
|
|
631
|
+
return downstream;
|
|
632
|
+
})();
|
|
633
|
+
});
|
|
634
|
+
} catch (error) {
|
|
635
|
+
warn(`[context-trim] tools/post-execute hook failed: ${error?.message ?? error}`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ---- tools (L1 advisory surface) ------------------------------------------
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
ctx.tools?.register?.({
|
|
642
|
+
name: "ctx_trim_status",
|
|
643
|
+
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.",
|
|
644
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
645
|
+
required: [],
|
|
646
|
+
async execute() {
|
|
647
|
+
const sessionId = sessionIdOf({});
|
|
648
|
+
if (sessionId === null) return "No session context available.";
|
|
649
|
+
const ledger = store.ledgerOf(sessionId);
|
|
650
|
+
const record = metrics.of(sessionId);
|
|
651
|
+
const capture = captures.get(sessionId);
|
|
652
|
+
const { protectedTail, ...thresholds } = thresholdsNow();
|
|
653
|
+
const lines = [
|
|
654
|
+
"ctx_trim_status",
|
|
655
|
+
`mode: ${modeNow()} (enabled: ${enabledNow()})`,
|
|
656
|
+
`view tokens (estimate): ${record?.viewTokens ?? "n/a"}`,
|
|
657
|
+
`window tokens: ${cachedWindowOf(capture?.model ?? null) ?? "unknown"}`,
|
|
658
|
+
`folds: ${Array.isArray(ledger.blocks) ? ledger.blocks.length : 0}, frontier: ${ledger.frontier ?? "none"}, last fold view tokens: ${ledger.lastFoldViewTokens || "n/a"}`,
|
|
659
|
+
`thresholds: growth>${thresholds.growthTokens} increment>=${thresholds.incrementTokens} pressure=${thresholds.pressurePercent}% emergency=${thresholds.emergencyPercent}% maxPerHour=${thresholds.maxFoldsPerHour} protectedTail=${protectedTail}`,
|
|
660
|
+
];
|
|
661
|
+
if (capture !== undefined) {
|
|
662
|
+
lines.push("", `captured history (${capture.messages.length} messages; refs [m...] are fold anchors):`);
|
|
663
|
+
capture.messages.forEach((message, i) => {
|
|
664
|
+
lines.push(`- [m${refOf(message, i)}] (${message?.role ?? "?"}) ~${estimateTokensOf(capture.messages, i, i)} tokens`);
|
|
665
|
+
});
|
|
666
|
+
} else {
|
|
667
|
+
lines.push("", "captured history: none yet (appears after the next model call)");
|
|
668
|
+
}
|
|
669
|
+
return lines.join("\n");
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
} catch (error) {
|
|
673
|
+
warn(`[context-trim] ctx_trim_status tool failed: ${error?.message ?? error}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
ctx.tools?.register?.({
|
|
678
|
+
name: "ctx_trim_fold",
|
|
679
|
+
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.",
|
|
680
|
+
parameters: {
|
|
681
|
+
type: "object",
|
|
682
|
+
properties: {
|
|
683
|
+
firstId: { type: "string", description: "Ref of the first message to fold ([m...] form from ctx_trim_status, without the brackets)." },
|
|
684
|
+
lastId: { type: "string", description: "Ref of the last message to fold." },
|
|
685
|
+
count: { type: "number", description: "Number of messages in the inclusive range." },
|
|
686
|
+
summary: { type: "string", description: "Self-contained summary text replacing the range." },
|
|
687
|
+
},
|
|
688
|
+
required: ["firstId", "lastId", "count", "summary"],
|
|
689
|
+
additionalProperties: false,
|
|
690
|
+
},
|
|
691
|
+
async execute(args) {
|
|
692
|
+
const sessionId = sessionIdOf({});
|
|
693
|
+
if (sessionId === null) return "No session context available.";
|
|
694
|
+
const capture = captures.get(sessionId);
|
|
695
|
+
if (capture === undefined) return "No captured history yet — fold is unavailable until the next model call.";
|
|
696
|
+
const positions = refPositions(capture.messages);
|
|
697
|
+
const firstList = positions.get(String(args?.firstId ?? ""));
|
|
698
|
+
const lastList = positions.get(String(args?.lastId ?? ""));
|
|
699
|
+
if (firstList === undefined || lastList === undefined) return "Refs not found in the current history — re-check with ctx_trim_status.";
|
|
700
|
+
// Count-anchored resolution: duplicate refs (identical content)
|
|
701
|
+
// make the first/last occurrence lists ambiguous — anchor on the
|
|
702
|
+
// stated count and take the first consistent pair (commitFold does
|
|
703
|
+
// the same).
|
|
704
|
+
const wanted = Number.isInteger(args?.count) && args.count > 0 ? args.count : null;
|
|
705
|
+
let firstIdx = -1;
|
|
706
|
+
let lastIdx = -1;
|
|
707
|
+
for (const start of firstList) {
|
|
708
|
+
const end = wanted !== null ? start + wanted - 1 : lastList[lastList.length - 1];
|
|
709
|
+
if (end >= start && lastList.includes(end)) { firstIdx = start; lastIdx = end; break; }
|
|
710
|
+
}
|
|
711
|
+
if (firstIdx < 0) { firstIdx = firstList[0]; lastIdx = lastList[lastList.length - 1]; }
|
|
712
|
+
if (lastIdx < firstIdx) return "lastId sorts before firstId in the current history.";
|
|
713
|
+
const { protectedTail } = thresholdsNow();
|
|
714
|
+
if (lastIdx >= capture.messages.length - Math.max(0, protectedTail)) return `Range reaches into the protected tail (last ${protectedTail} messages are never folded).`;
|
|
715
|
+
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).";
|
|
716
|
+
const count = wanted !== null ? wanted : lastIdx - firstIdx + 1;
|
|
717
|
+
const result = store.commitFold(
|
|
718
|
+
sessionId,
|
|
719
|
+
{
|
|
720
|
+
refRange: { firstId: String(args.firstId), lastId: String(args.lastId) },
|
|
721
|
+
count,
|
|
722
|
+
approxTokens: estimateTokensOf(capture.messages, firstIdx, lastIdx),
|
|
723
|
+
summary: String(args.summary ?? ""),
|
|
724
|
+
kind: "summary",
|
|
725
|
+
tier: "model",
|
|
726
|
+
},
|
|
727
|
+
positions,
|
|
728
|
+
);
|
|
729
|
+
if (!result.ok) return `Fold rejected: ${result.reason}.`;
|
|
730
|
+
// Growth-gate baseline pair: pre-fold view (banner display) plus the
|
|
731
|
+
// post-fold low (tier-a anchor — keeps the gate armed after folds
|
|
732
|
+
// that commit at high view points).
|
|
733
|
+
const lowLedger = store.ledgerOf(sessionId);
|
|
734
|
+
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
|
|
735
|
+
store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
|
|
736
|
+
return `Folded ${count} messages into block ${result.block.id}. The view applies on the next model call.`;
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
} catch (error) {
|
|
740
|
+
warn(`[context-trim] ctx_trim_fold tool failed: ${error?.message ?? error}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ---- llm/stream listener (mounted when the llm fiber exists) ---------------
|
|
744
|
+
|
|
745
|
+
function streamListener(options, next) {
|
|
746
|
+
let viewTokens = null;
|
|
747
|
+
try {
|
|
748
|
+
if (enabledNow()) {
|
|
749
|
+
const applied = applyViewToOptions(options);
|
|
750
|
+
if (applied !== null) viewTokens = applied.viewTokens;
|
|
751
|
+
}
|
|
752
|
+
} catch (error) {
|
|
753
|
+
decisionLog(`view-build-failed why=${error?.message ?? error}`);
|
|
754
|
+
warn(`[context-trim] view build failed: ${error?.message ?? error}`);
|
|
755
|
+
}
|
|
756
|
+
const result = next();
|
|
757
|
+
let wrappedSession = null;
|
|
758
|
+
try {
|
|
759
|
+
if (!enabledNow()) return result;
|
|
760
|
+
wrappedSession = sessionIdOf(options);
|
|
761
|
+
if (wrappedSession === null) return result;
|
|
762
|
+
return wrapStreamForMetrics(result, (chunk) => {
|
|
763
|
+
const usage = extractUsage(chunk);
|
|
764
|
+
if (usage !== null) metrics.recordUsage(wrappedSession, usage);
|
|
765
|
+
});
|
|
766
|
+
} catch (error) {
|
|
767
|
+
warn(`[context-trim] stream wrap failed: ${error?.message ?? error}`);
|
|
768
|
+
return result;
|
|
769
|
+
} finally {
|
|
770
|
+
// Fallback fold check on the stream path: the agent/status idle event
|
|
771
|
+
// may not reach a globally-registered listener on every host topology,
|
|
772
|
+
// so each stream end also runs a throttled (60 s/session) check. The
|
|
773
|
+
// trigger's own cadence gates still guard over-folding.
|
|
774
|
+
try {
|
|
775
|
+
if (enabledNow() && modeNow() === "auto" && wrappedSession !== null) {
|
|
776
|
+
const nowMs = Date.now();
|
|
777
|
+
if (nowMs - (lastStreamCheckAt.get(wrappedSession) ?? 0) >= 60_000) {
|
|
778
|
+
lastStreamCheckAt.set(wrappedSession, nowMs);
|
|
779
|
+
if (lastStreamCheckAt.size > 200) lastStreamCheckAt.delete(lastStreamCheckAt.keys().next().value);
|
|
780
|
+
void maybeFold(wrappedSession, "stream");
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
} catch {
|
|
784
|
+
/* fallback must never break the stream */
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
ctx.inject(["llm"], (llmCtx) => {
|
|
791
|
+
const attach = () => {
|
|
792
|
+
try {
|
|
793
|
+
// Global + prepend: the cordis dispatch filter silently drops NON-global
|
|
794
|
+
// llm/stream listeners when the dispatching llm runtime lives outside
|
|
795
|
+
// this fiber's isolate (resumed sessions dispatch through their own
|
|
796
|
+
// subtree) — that silenced the whole stream path for a resumed session
|
|
797
|
+
// while fresh sessions kept logging. Stock consumers (dsh-session-title)
|
|
798
|
+
// register exactly this way.
|
|
799
|
+
llmCtx.on?.("llm/stream", streamListener, { global: true, prepend: true });
|
|
800
|
+
decisionLog(`llm-attached hasOn=${typeof llmCtx.on} hasOff=${typeof llmCtx.off} hasEffect=${typeof llmCtx.effect} pid=${process.pid}`);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
warn(`[context-trim] llm/stream attach failed: ${error?.message ?? error}`);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
// Reload-safe release (applyIntent precedent): undo the listener
|
|
806
|
+
// registration when the llm fiber disposes (plugin reload).
|
|
807
|
+
try {
|
|
808
|
+
const effect = llmCtx.effect;
|
|
809
|
+
if (typeof effect === "function") {
|
|
810
|
+
effect(function* () {
|
|
811
|
+
yield () => {
|
|
812
|
+
try {
|
|
813
|
+
llmCtx.off?.("llm/stream", streamListener);
|
|
814
|
+
} catch {
|
|
815
|
+
/* best effort */
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
}, "context-trim-stream-listener");
|
|
819
|
+
}
|
|
820
|
+
} catch {
|
|
821
|
+
/* effect registration is best-effort */
|
|
822
|
+
}
|
|
823
|
+
attach();
|
|
824
|
+
});
|
|
825
|
+
} catch (error) {
|
|
826
|
+
warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, renderTrimContextLine: (agent) => renderTrimContextLine(agent, { enabled: enabledNow(), mode: modeNow(), viewTokens: null, windowTokens: null, folds: 0, growthTokens: null, lastModel: null }) };
|
|
830
|
+
}
|