dsh-switchman 1.8.0 → 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.
@@ -0,0 +1,104 @@
1
+ /** dsh-switchman context-trim stream metrics.
2
+ *
3
+ * Meter duality (design §8): trim thresholds run on VIEW-estimated tokens
4
+ * (buildView output, the meter the fold logic controls), while the
5
+ * watermark keeps its STORED-token meter — the two signals are kept
6
+ * separate on purpose. This module wraps the llm/stream result generator
7
+ * so every provider response is sampled for usage (input/cached/output)
8
+ * without ever breaking the stream: sampling errors are swallowed, the
9
+ * chunk always flows through untouched.
10
+ */
11
+
12
+ /** Extract usage fields from a stream chunk. Returns
13
+ * { inputTokens, cachedTokens, outputTokens } or null when the chunk
14
+ * carries none. Defensive field walk — providers shape usage differently
15
+ * (usage.input_tokens, usage.prompt_tokens, cached_tokens,
16
+ * cache_read_input_tokens, ...). */
17
+ export function extractUsage(chunk) {
18
+ if (chunk === null || typeof chunk !== "object") return null;
19
+ const candidates = [chunk.usage, chunk.metadata?.usage, chunk.response?.usage];
20
+ for (const usage of candidates) {
21
+ if (usage === null || typeof usage !== "object") continue;
22
+ const input = usage.input_tokens ?? usage.prompt_tokens ?? usage.inputTokens;
23
+ const cached = usage.cache_read_input_tokens ?? usage.cached_tokens ?? usage.cachedTokens ?? usage.prompt_tokens_details?.cached_tokens;
24
+ const output = usage.output_tokens ?? usage.completion_tokens ?? usage.outputTokens;
25
+ if (!Number.isFinite(input) && !Number.isFinite(cached) && !Number.isFinite(output)) continue;
26
+ return {
27
+ inputTokens: Number.isFinite(input) ? input : null,
28
+ cachedTokens: Number.isFinite(cached) ? cached : null,
29
+ outputTokens: Number.isFinite(output) ? output : null,
30
+ };
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /** Per-session metrics store. */
36
+ export function createMetrics() {
37
+ const sessions = new Map(); // sessionId -> record
38
+
39
+ function recordOf(sessionId) {
40
+ let record = sessions.get(sessionId);
41
+ if (record === undefined) {
42
+ record = {
43
+ lastInputTokens: null,
44
+ cachedTokens: null,
45
+ outputTokens: null,
46
+ viewTokens: null, // view estimate noted by the listener pre-request
47
+ lastModel: null, // { provider, model } of the last stream
48
+ lastStreamAt: 0,
49
+ totalStreams: 0,
50
+ };
51
+ }
52
+ sessions.delete(sessionId); // drop first so re-insert refreshes recency order
53
+ sessions.set(sessionId, record);
54
+ // Prune: keep only the 50 most recently active sessions (bounded memory).
55
+ if (sessions.size > 50) {
56
+ const oldest = sessions.keys().next().value;
57
+ if (oldest !== undefined && oldest !== sessionId) sessions.delete(oldest);
58
+ }
59
+ return record;
60
+ }
61
+
62
+ return {
63
+ /** Note the outbound-view estimate + model before/around a request. */
64
+ noteView(sessionId, viewTokens, model) {
65
+ const record = recordOf(sessionId);
66
+ if (Number.isFinite(viewTokens)) record.viewTokens = Math.round(viewTokens);
67
+ if (model !== null && typeof model === "object") record.lastModel = { provider: model.provider ?? null, model: model.model ?? null };
68
+ record.lastStreamAt = Date.now();
69
+ record.totalStreams += 1;
70
+ return record;
71
+ },
72
+ /** Merge a usage sample (from extractUsage) into the record. */
73
+ recordUsage(sessionId, usage) {
74
+ if (usage === null) return null;
75
+ const record = recordOf(sessionId);
76
+ if (usage.inputTokens !== null) record.lastInputTokens = usage.inputTokens;
77
+ if (usage.cachedTokens !== null) record.cachedTokens = usage.cachedTokens;
78
+ if (usage.outputTokens !== null) record.outputTokens = usage.outputTokens;
79
+ return record;
80
+ },
81
+ /** Record of one session (or null when never streamed). */
82
+ of(sessionId) {
83
+ return sessions.get(sessionId) ?? null;
84
+ },
85
+ };
86
+ }
87
+
88
+ /** Wrap a stream result so each chunk flows through a (failing-open)
89
+ * sampler before being yielded. Returns the original value untouched when
90
+ * it is not an async iterable (non-stream fallbacks must pass through). */
91
+ export function wrapStreamForMetrics(result, onChunk) {
92
+ if (result === null || typeof result !== "object" || typeof result[Symbol.asyncIterator] !== "function") return result;
93
+ async function* sampled() {
94
+ for await (const chunk of result) {
95
+ try {
96
+ onChunk(chunk);
97
+ } catch {
98
+ /* sampling must never break the stream */
99
+ }
100
+ yield chunk;
101
+ }
102
+ }
103
+ return sampled();
104
+ }
@@ -0,0 +1,241 @@
1
+ /** dsh-switchman context-trim fold ledger store (host side).
2
+ *
3
+ * One ledger per session: the ordered list of fold blocks plus fold
4
+ * metadata (last fold's view-token estimate for the growth gate, fold
5
+ * timestamps for the hourly cadence cap). Ledgers persist as one JSON
6
+ * file per session under <dshHome>/.dsh-switchman/state/trim/ (or an
7
+ * explicit state dir) — atomic tmp+rename writes, corrupt files reset the
8
+ * ledger (fail-open: trimming must never brick a session over its own
9
+ * bookkeeping).
10
+ *
11
+ * commitFold validates a new block against the CURRENT messages snapshot
12
+ * (the posMap: refs -> indices) before it enters the ledger — ref range
13
+ * must be locatable, segment-aligned, disjoint from every existing block,
14
+ * and strictly monotonic (behind or inside the existing frontier is
15
+ * rejected). Without a posMap (ctx_trim_fold against a stale capture) only
16
+ * structural checks and exact-duplicate guards apply.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { dshHome } from "../lib/dsh-home.js";
22
+ import { estimateTokensOf, refPositions } from "./view.js";
23
+
24
+ /** Current ledger file format version. */
25
+ export const LEDGER_VERSION = 1;
26
+
27
+ /** Filenames are session-id-derived; keep them filesystem-safe. */
28
+ function sanitizeSessionId(sessionId) {
29
+ const raw = typeof sessionId === "string" && sessionId !== "" ? sessionId : "default";
30
+ // Percent-encode % and path separators FIRST so the mapping is injective
31
+ // ("a/b" and "a_b" must never share one ledger file).
32
+ const safe = Array.from(raw, (ch) => (ch === "%" ? "%25" : ch === "/" ? "%2F" : ch === "\\" ? "%5C" : ch)).join("").slice(0, 128);
33
+ return safe === "" ? "default" : safe;
34
+ }
35
+
36
+ /** Empty ledger for one session. frontier = ref of the newest folded
37
+ * message (the fold boundary); it only ever advances (monotonic). */
38
+ export function emptyLedger(sessionId) {
39
+ return {
40
+ version: LEDGER_VERSION,
41
+ sessionId,
42
+ blocks: [],
43
+ frontier: null,
44
+ lastFoldViewTokens: 0,
45
+ lastFoldLowTokens: 0,
46
+ lastFoldAt: 0,
47
+ foldsThisHour: [],
48
+ // Fork-state inheritance marker (fork.js): set only when this ledger
49
+ // was cloned from a parent session — one inheritance per child.
50
+ inheritedFrom: null,
51
+ inheritedAt: 0,
52
+ };
53
+ }
54
+
55
+ /** Structural validation of a candidate block (refRange/count/summary).
56
+ * Returns the normalized block or null. */
57
+ export function normalizeBlock(candidate, now) {
58
+ if (candidate === null || typeof candidate !== "object") return null;
59
+ const firstId = candidate.refRange?.firstId;
60
+ const lastId = candidate.refRange?.lastId;
61
+ if (typeof firstId !== "string" || firstId === "" || typeof lastId !== "string" || lastId === "") return null;
62
+ const summary = typeof candidate.summary === "string" ? candidate.summary.trim() : "";
63
+ if (summary === "") return null;
64
+ const count = Number.isInteger(candidate.count) && candidate.count > 0 ? candidate.count : 0;
65
+ if (count <= 0) return null;
66
+ const approxTokens = Number.isFinite(candidate.approxTokens) && candidate.approxTokens > 0 ? Math.round(candidate.approxTokens) : count;
67
+ const kind = candidate.kind === "mechanical" ? "mechanical" : "summary";
68
+ const tier = candidate.tier === "emergency" || candidate.tier === "pressure" || candidate.tier === "growth" || candidate.tier === "model" ? candidate.tier : "model";
69
+ return {
70
+ id: typeof candidate.id === "string" && candidate.id !== "" ? candidate.id : `b${now}`,
71
+ kind,
72
+ tier,
73
+ refRange: { firstId, lastId },
74
+ count,
75
+ approxTokens,
76
+ summary,
77
+ createdAt: now,
78
+ };
79
+ }
80
+
81
+ /** Create the ledger store. options: { stateDir?, warn? }. stateDir wins
82
+ * over the dshHome default; warn receives soft failures. */
83
+ export function createStore(options = {}) {
84
+ const warn = typeof options.warn === "function" ? options.warn : () => {};
85
+ const stateDir = options.stateDir ?? join(dshHome(), ".dsh-switchman", "state", "trim");
86
+ const ledgers = new Map(); // sessionId -> ledger (loaded lazily)
87
+
88
+ function fileFor(sessionId) {
89
+ return join(stateDir, `${sanitizeSessionId(sessionId)}.json`);
90
+ }
91
+
92
+ /** Load one ledger (cached). Corrupt or unreadable files reset the
93
+ * ledger and are moved aside (best effort) for forensics. */
94
+ function ledgerOf(sessionId) {
95
+ const cached = ledgers.get(sessionId);
96
+ if (cached !== undefined) return cached;
97
+ let ledger = emptyLedger(sessionId);
98
+ const file = fileFor(sessionId);
99
+ try {
100
+ if (existsSync(file)) {
101
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
102
+ if (parsed !== null && typeof parsed === "object" && Array.isArray(parsed.blocks) && parsed.version === LEDGER_VERSION) {
103
+ ledger = {
104
+ version: LEDGER_VERSION,
105
+ sessionId,
106
+ blocks: parsed.blocks,
107
+ frontier: typeof parsed.frontier === "string" ? parsed.frontier : null,
108
+ lastFoldViewTokens: Number.isFinite(parsed.lastFoldViewTokens) ? parsed.lastFoldViewTokens : 0,
109
+ lastFoldLowTokens: Number.isFinite(parsed.lastFoldLowTokens) ? parsed.lastFoldLowTokens : 0,
110
+ lastFoldAt: Number.isFinite(parsed.lastFoldAt) ? parsed.lastFoldAt : 0,
111
+ foldsThisHour: Array.isArray(parsed.foldsThisHour) ? parsed.foldsThisHour.filter((ts) => Number.isFinite(ts)) : [],
112
+ inheritedFrom: typeof parsed.inheritedFrom === "string" ? parsed.inheritedFrom : null,
113
+ inheritedAt: Number.isFinite(parsed.inheritedAt) ? parsed.inheritedAt : 0,
114
+ };
115
+ } else {
116
+ warn(`[context-trim] ledger for ${sanitizeSessionId(sessionId)} has an unknown format — resetting`);
117
+ try {
118
+ renameSync(file, `${file}.corrupt`);
119
+ } catch {
120
+ /* best effort */
121
+ }
122
+ }
123
+ }
124
+ } catch (error) {
125
+ warn(`[context-trim] ledger load failed (${error?.message ?? error}) — resetting`);
126
+ ledger = emptyLedger(sessionId);
127
+ try {
128
+ renameSync(file, `${file}.corrupt`);
129
+ } catch {
130
+ /* best effort */
131
+ }
132
+ }
133
+ ledgers.set(sessionId, ledger);
134
+ return ledger;
135
+ }
136
+
137
+ /** Persist one ledger atomically (tmp + rename). */
138
+ function persist(ledger) {
139
+ try {
140
+ mkdirSync(stateDir, { recursive: true });
141
+ const file = fileFor(ledger.sessionId);
142
+ const tmp = `${file}.tmp`;
143
+ writeFileSync(tmp, JSON.stringify(ledger));
144
+ renameSync(tmp, file);
145
+ return true;
146
+ } catch (error) {
147
+ warn(`[context-trim] ledger persist failed (${error?.message ?? error})`);
148
+ return false;
149
+ }
150
+ }
151
+
152
+ /** Commit a fold block. posMap (refs -> indices of the CURRENT capture)
153
+ * enables full validation; returns { ok, ledger, block } or
154
+ * { ok: false, reason }. Memory only — persist() separately. */
155
+ function commitFold(sessionId, candidate, posMap = null, now = Date.now()) {
156
+ const block = normalizeBlock(candidate, now);
157
+ if (block === null) return { ok: false, reason: "invalid-block" };
158
+ const ledger = ledgerOf(sessionId);
159
+ // exact-duplicate guard (same range already folded)
160
+ for (const existing of ledger.blocks) {
161
+ if (existing.refRange.firstId === block.refRange.firstId && existing.refRange.lastId === block.refRange.lastId) {
162
+ return { ok: false, reason: "duplicate-range" };
163
+ }
164
+ }
165
+ if (posMap !== null) {
166
+ const positions = posMap instanceof Map ? posMap : refPositions(posMap);
167
+ const firstList = positions.get(block.refRange.firstId);
168
+ const lastList = positions.get(block.refRange.lastId);
169
+ if (firstList === undefined || lastList === undefined) return { ok: false, reason: "refs-not-found" };
170
+ // Content-addressed refs collide for duplicate messages, so one ref
171
+ // may map to many positions. Anchor the pair on block.count and
172
+ // search occurrences instead of trusting list extremes.
173
+ let firstIdx = -1;
174
+ let lastIdx = -1;
175
+ for (const start of firstList) {
176
+ const end = start + block.count - 1;
177
+ if (lastList.includes(end)) {
178
+ firstIdx = start;
179
+ lastIdx = end;
180
+ break;
181
+ }
182
+ }
183
+ if (firstIdx < 0) return { ok: false, reason: "count-mismatch" };
184
+ // monotonicity: the new range must sit strictly behind the
185
+ // frontier range and be disjoint from every existing block.
186
+ for (const existing of ledger.blocks) {
187
+ const eFirst = positions.get(existing.refRange.firstId);
188
+ const eLast = positions.get(existing.refRange.lastId);
189
+ if (eFirst === undefined || eLast === undefined) continue; // stale block outside this capture
190
+ const eStart = eFirst[0];
191
+ const eEnd = eLast[eLast.length - 1];
192
+ if (firstIdx <= eEnd && eStart <= lastIdx) return { ok: false, reason: "overlaps-existing-fold" };
193
+ }
194
+ }
195
+ ledger.blocks.push(block);
196
+ ledger.frontier = block.refRange.lastId;
197
+ ledger.lastFoldAt = now;
198
+ ledger.foldsThisHour = ledger.foldsThisHour.filter((ts) => now - ts < 3_600_000);
199
+ ledger.foldsThisHour.push(now);
200
+ ledgers.set(sessionId, ledger);
201
+ return { ok: true, ledger, block };
202
+ }
203
+
204
+ /** Adopt a fully-built ledger as one session's ledger (fork-state
205
+ * inheritance, fork.js resolveInheritance). One-shot: refused when the
206
+ * session already carries fold blocks or an inheritance marker — real
207
+ * state is never overwritten. Caches AND persists; returns true when
208
+ * adopted. */
209
+ function adoptLedger(sessionId, ledger) {
210
+ const current = ledgerOf(sessionId);
211
+ if ((Array.isArray(current.blocks) && current.blocks.length > 0) || current.inheritedFrom !== null) return false;
212
+ if (ledger === null || typeof ledger !== "object" || ledger.sessionId !== sessionId) return false;
213
+ ledgers.set(sessionId, ledger);
214
+ return persist(ledger);
215
+ }
216
+
217
+ /** Record the view-token estimate captured at the last fold (growth-gate
218
+ * baseline) and persist. */
219
+ function recordFoldBaseline(sessionId, viewTokens, lowTokens) {
220
+ const ledger = ledgerOf(sessionId);
221
+ if (Number.isFinite(viewTokens) && viewTokens > 0) ledger.lastFoldViewTokens = Math.round(viewTokens);
222
+ if (Number.isFinite(lowTokens) && lowTokens > 0) ledger.lastFoldLowTokens = Math.round(lowTokens);
223
+ persist(ledger);
224
+ return ledger;
225
+ }
226
+
227
+ return { ledgerOf, commitFold, recordFoldBaseline, adoptLedger, persist, stateDir };
228
+ }
229
+
230
+ /** Test/inspection helper: estimated tokens of a ledger's folds. */
231
+ export function ledgerFoldedTokens(messages, ledger) {
232
+ const positions = refPositions(messages);
233
+ let total = 0;
234
+ for (const block of ledger?.blocks ?? []) {
235
+ const firstList = positions.get(block.refRange?.firstId);
236
+ const lastList = positions.get(block.refRange?.lastId);
237
+ if (firstList === undefined || lastList === undefined) continue;
238
+ total += estimateTokensOf(messages, firstList[0], lastList[lastList.length - 1]);
239
+ }
240
+ return total;
241
+ }
@@ -0,0 +1,179 @@
1
+ /** dsh-switchman context-trim summarizer.
2
+ *
3
+ * Produces the summary text for one fold block, two ways:
4
+ *
5
+ * - summarizeWithModel — the L3 path (design §5): a side-model call via
6
+ * llm.prepareCall + call.stream (receiver-bound on the llm service, the
7
+ * same waterfall pattern intent classification uses). Guarded by a hard
8
+ * deadline (AbortController); ANY failure (timeout, error chunk, empty
9
+ * output) returns null and the caller skips the fold cycle — fail-open.
10
+ * - mechanicalSummary — the deterministic fallback used by the emergency
11
+ * tier: structural heads of each message, no model round trip.
12
+ *
13
+ * The summary prompt carries the bili compression rules: message refs and
14
+ * file paths verbatim, decisions WITH rationale, exact values, open
15
+ * objectives carry forward, verbose logs drop.
16
+ */
17
+
18
+ import { randomUUID } from "node:crypto";
19
+
20
+ /** Default side-model summary deadline. Large increments legitimately
21
+ * take a while; the emergency tier does not wait on this path at all. */
22
+ export const SUMMARY_TIMEOUT_MS = 120_000;
23
+
24
+ /** Material cap: beyond this the render keeps head+tail and marks the cut
25
+ * (a 1M-token material would burn the side-model call for no fidelity). */
26
+ export const MAX_MATERIAL_CHARS = 400_000;
27
+
28
+ /** System prompt for the side-model summarizer. English per repo docs
29
+ * style; the OUTPUT language follows the summarized material. */
30
+ export const SUMMARY_SYSTEM_PROMPT = [
31
+ "You compress one contiguous range of a coding-agent conversation into ONE self-contained plain-text summary that will replace that range permanently.",
32
+ "",
33
+ "KEEP VERBATIM:",
34
+ "- Full file paths with line numbers (lib/hooks.ts:347) — never bare filenames.",
35
+ "- Function/class/type signatures and the critical code line that encodes a finding.",
36
+ "- Error messages and stack-trace key lines (exact text).",
37
+ "- Exact values: versions, config keys, thresholds, magic numbers.",
38
+ "- Decisions WITH their rationale (the because is load-bearing).",
39
+ "- Open objectives, unresolved TODOs, and open questions, each tagged with its message ref.",
40
+ "",
41
+ "DROP (keep one line of lesson for failures): verbose logs once the error line is captured, repeated file reads, dead-end exploration details, back-and-forth discussion once the outcome is recorded.",
42
+ "",
43
+ "FORMAT:",
44
+ "- Dense bullets, short thematic headers; no narrative prose.",
45
+ "- Reference messages as [m<ref>] using the refs in the material.",
46
+ "- Write the summary in the same language as the material.",
47
+ "- Output ONLY the summary text.",
48
+ ].join("\n");
49
+
50
+ /** Render the fold material for one message range: labeled, ref-stamped
51
+ * blocks. Deterministic (same slice, same bytes) — refs come from refOf so
52
+ * id-less messages stay stable across re-sends. */
53
+ export function renderMaterial(messages, startIdx, endIdx, refOf) {
54
+ const parts = [];
55
+ let chars = 0;
56
+ let truncated = false;
57
+ for (let i = startIdx; i <= endIdx; i++) {
58
+ const message = messages[i];
59
+ const ref = refOf(message, i);
60
+ const role = typeof message?.role === "string" ? message.role : "unknown";
61
+ let text;
62
+ const content = message?.content;
63
+ if (typeof content === "string") text = content;
64
+ else if (Array.isArray(content)) {
65
+ const chunks = [];
66
+ for (const block of content) {
67
+ if (block !== null && typeof block === "object" && typeof block.text === "string") chunks.push(block.text);
68
+ else {
69
+ try {
70
+ chunks.push(JSON.stringify(block ?? null));
71
+ } catch {
72
+ chunks.push("[unserializable]");
73
+ }
74
+ }
75
+ }
76
+ text = chunks.join("\n");
77
+ } else {
78
+ try {
79
+ text = JSON.stringify(message ?? null);
80
+ } catch {
81
+ text = "[unserializable]";
82
+ }
83
+ }
84
+ if (Array.isArray(message?.tool_calls) && message.tool_calls.length > 0) {
85
+ try {
86
+ text += `\n[tool_calls] ${JSON.stringify(message.tool_calls)}`;
87
+ } catch {
88
+ /* keep the base text */
89
+ }
90
+ }
91
+ const head = `--- [m${ref}] (${role}) ---\n`;
92
+ const body = `${text}\n\n`;
93
+ if (chars + head.length + body.length > MAX_MATERIAL_CHARS) {
94
+ truncated = true;
95
+ break;
96
+ }
97
+ parts.push(head + body);
98
+ chars += head.length + body.length;
99
+ }
100
+ if (truncated) parts.push(`--- [...material truncated after ${chars} chars; ${endIdx - startIdx + 1 - (parts.length - 1)} messages dropped from this render...] ---\n`);
101
+ return parts.join("");
102
+ }
103
+
104
+ /** Emergency-tier mechanical summary: deterministic structural heads (no
105
+ * model call). One line per message: ref, role, first line (capped). */
106
+ export function mechanicalSummary(messages, startIdx, endIdx, refOf) {
107
+ const lines = [`Mechanical fold of ${endIdx - startIdx + 1} messages (emergency tier; no model summary available).`, ""];
108
+ for (let i = startIdx; i <= endIdx; i++) {
109
+ const message = messages[i];
110
+ const ref = refOf(message, i);
111
+ const role = typeof message?.role === "string" ? message.role : "unknown";
112
+ const content = message?.content;
113
+ let firstLine = "";
114
+ if (typeof content === "string") firstLine = content;
115
+ else if (Array.isArray(content)) {
116
+ for (const block of content) {
117
+ if (block !== null && typeof block === "object" && typeof block.text === "string" && block.text.trim() !== "") {
118
+ firstLine = block.text;
119
+ break;
120
+ }
121
+ }
122
+ }
123
+ const line = firstLine.replace(/\s+/gu, " ").trim().slice(0, 160);
124
+ lines.push(`- [m${ref}] (${role}) ${line === "" ? "(no text content)" : line}`);
125
+ }
126
+ lines.push("", "Open objectives and exact details are NOT preserved by this mechanical fold; the next model-driven fold will supersede it.");
127
+ return lines.join("\n");
128
+ }
129
+
130
+ /** Side-model summary. llm: the dsh llm service (receiver-bound methods);
131
+ * route: { provider, model } | null (null → caller skips); materialText:
132
+ * renderMaterial output; deadlineMs: hard budget. Returns the summary
133
+ * text, or null on ANY failure (caller skips the fold cycle). */
134
+ export async function summarizeWithModel(llm, route, materialText, options = {}) {
135
+ const fail = typeof options.onFail === "function" ? options.onFail : null;
136
+ if (route === null || route === undefined) { fail?.("no-route"); return null; }
137
+ if (typeof llm?.prepareCall !== "function") { fail?.("no-prepare-call"); return null; }
138
+ const deadlineMs = Number.isFinite(options.deadlineMs) && options.deadlineMs > 0 ? options.deadlineMs : SUMMARY_TIMEOUT_MS;
139
+ const maxTokens = Number.isFinite(options.maxTokens) && options.maxTokens > 0 ? options.maxTokens : 8_192;
140
+ const controller = new AbortController();
141
+ const timer = setTimeout(() => controller.abort(new Error("trim summary deadline")), deadlineMs);
142
+ try {
143
+ const call = await llm.prepareCall(
144
+ { provider: route.provider, model: route.model, maxTokens },
145
+ controller.signal,
146
+ (controls) => controls,
147
+ );
148
+ let text = "";
149
+ let finishKind = null;
150
+ // stream options MUST carry the prepared call config — the adapter
151
+ // dispatch binds credentials/routing from it (same waterfall shape as
152
+ // the intent classifier; dropping it fails prepare-time integrity).
153
+ // system rides as a top-level option and the user message carries a
154
+ // source tag, exactly like the intent classifier's proven shape.
155
+ const streamOptions = {
156
+ ...call.config,
157
+ system: SUMMARY_SYSTEM_PROMPT,
158
+ messages: [
159
+ { id: randomUUID(), role: "user", content: [{ type: "text", text: materialText }], source: { kind: "dsh-switchman-trim-summary" } },
160
+ ],
161
+ signal: controller.signal,
162
+ };
163
+ for await (const chunk of call.stream(streamOptions)) {
164
+ if (chunk?.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
165
+ else if (chunk?.type === "finish") {
166
+ finishKind = chunk.kind ?? null;
167
+ if (chunk.kind === "error" || chunk.kind === "aborted") { fail?.(`stream-${chunk.kind}`); return null; }
168
+ }
169
+ }
170
+ const summary = text.trim();
171
+ if (summary === "") { fail?.(`empty-output finish=${finishKind ?? "none"}`); return null; }
172
+ return summary;
173
+ } catch (error) {
174
+ fail?.(`error: ${error?.message ?? error}`);
175
+ return null;
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
179
+ }
@@ -0,0 +1,122 @@
1
+ /** dsh-switchman context-trim trigger tiers (pure decision logic).
2
+ *
3
+ * The trigger half of the port (design §4/§5): decides whether a fold
4
+ * should run NOW. Called only at idle boundaries (agent/status idle) —
5
+ * never inside llm/stream, never while a fold for the same session is in
6
+ * flight. Three tiers, checked emergency-first:
7
+ *
8
+ * - emergency : view usage >= trimEmergencyPercent of the context window.
9
+ * Forced MECHANICAL fold (no side-model round trip — at 95%
10
+ * the session cannot afford the latency or the failure).
11
+ * - pressure : view usage >= trimPressurePercent. Side-model fold.
12
+ * - growth : growth since the last fold's post-fold low (the anchor
13
+ * that keeps the gate armed after folds committing at high
14
+ * view points) > trimGrowthTokens AND the contiguous
15
+ * compressible increment >= trimIncrementTokens. The
16
+ * primary, cache-friendly gate (the bili heuristic).
17
+ *
18
+ * Side-model folds are additionally throttled by trimMaxFoldsPerHour and a
19
+ * hard minimum interval between folds; emergency folds have their own
20
+ * cooldown so a stuck-at-95% session folds at most once per window.
21
+ * Everything is derived from measured inputs — no clock, no host access —
22
+ * which keeps the decision unit-testable (golden thresholds).
23
+ */
24
+
25
+ import { estimateTokensOf, refPositions, segmentBounds } from "./view.js";
26
+
27
+ /** Hard minimum wall-clock interval between side-model folds. */
28
+ export const FOLD_MIN_INTERVAL_MS = 60_000;
29
+
30
+ /** Minimum interval between emergency (mechanical) folds. */
31
+ export const EMERGENCY_COOLDOWN_MS = 90_000;
32
+
33
+ /** The foldable span of a captured messages array for one ledger: from the
34
+ * first segment boundary strictly after the frontier to the last segment
35
+ * end that still clears the protected tail. Returns { startIdx, endIdx }
36
+ * or null when nothing is foldable (empty history, everything folded, or
37
+ * protected tail covers it all). */
38
+ export function computeFoldableRange(messages, ledger, protectedTail) {
39
+ if (!Array.isArray(messages) || messages.length === 0) return null;
40
+ const tail = Number.isInteger(protectedTail) && protectedTail > 0 ? protectedTail : 0;
41
+ const tailStart = messages.length - tail;
42
+ if (tailStart <= 0) return null;
43
+ // first candidate segment start: strictly after the frontier message
44
+ let startFrom = 0;
45
+ if (typeof ledger?.frontier === "string" && ledger.frontier !== "") {
46
+ const positions = refPositions(messages);
47
+ const list = positions.get(ledger.frontier);
48
+ if (list !== undefined && list.length > 0) startFrom = list[list.length - 1] + 1;
49
+ }
50
+ let best = null;
51
+ for (const [start, end] of segmentBounds(messages)) {
52
+ if (start < startFrom) continue; // behind the frontier
53
+ if (end >= tailStart) break; // segment would cut into the protected tail
54
+ if (best === null) best = { startIdx: start, endIdx: end };
55
+ else best.endIdx = end; // extend the contiguous span
56
+ }
57
+ return best;
58
+ }
59
+
60
+ /** Evaluate the trigger. input: {
61
+ * foldable: { startIdx, endIdx } | null, // computeFoldableRange result
62
+ * messages, // captured raw messages (increment measurement)
63
+ * viewTokens, // outbound-view token estimate of the current history
64
+ * windowTokens, // context window size, or null when unknown
65
+ * thresholds: { growthTokens, incrementTokens, pressurePercent, emergencyPercent, maxFoldsPerHour },
66
+ * now, lastFoldAt, foldsThisHour, lastEmergencyAt,
67
+ * }.
68
+ * Returns { action: "none"|"fold"|"emergency", tier, reason, startIdx, endIdx, incrementTokens }. */
69
+ export function evaluateTrigger(input) {
70
+ const none = { action: "none", tier: null, reason: "", startIdx: -1, endIdx: -1, incrementTokens: 0 };
71
+ const foldable = input?.foldable ?? null;
72
+ if (foldable === null) return { ...none, reason: "nothing-foldable" };
73
+ const { startIdx, endIdx } = foldable;
74
+ const thresholds = input?.thresholds ?? {};
75
+ const now = Number.isFinite(input?.now) ? input.now : Date.now();
76
+ const incrementTokens = estimateTokensOf(input?.messages ?? [], startIdx, endIdx);
77
+ if (incrementTokens < 1) return { ...none, reason: "empty-increment" };
78
+
79
+ // Tier c — emergency: window pressure, mechanical, cooldown-gated.
80
+ const windowTokens = Number.isFinite(input?.windowTokens) && input.windowTokens > 0 ? input.windowTokens : null;
81
+ const emergencyPercent = Number.isFinite(thresholds.emergencyPercent) && thresholds.emergencyPercent > 0 ? thresholds.emergencyPercent : 95;
82
+ if (windowTokens !== null) {
83
+ const usagePct = (input.viewTokens / windowTokens) * 100;
84
+ if (usagePct >= emergencyPercent) {
85
+ const lastEmergencyAt = Number.isFinite(input?.lastEmergencyAt) ? input.lastEmergencyAt : 0;
86
+ if (now - lastEmergencyAt < EMERGENCY_COOLDOWN_MS) return { ...none, reason: "emergency-cooldown" };
87
+ return { action: "emergency", tier: "emergency", reason: `usage ${usagePct.toFixed(1)}% >= ${emergencyPercent}%`, startIdx, endIdx, incrementTokens };
88
+ }
89
+ }
90
+
91
+ // Side-model folds (pressure + growth) share the cadence gates.
92
+ const lastFoldAt = Number.isFinite(input?.lastFoldAt) ? input.lastFoldAt : 0;
93
+ const hourly = Array.isArray(input?.foldsThisHour) ? input.foldsThisHour.filter((ts) => now - ts < 3_600_000).length : 0;
94
+ const maxFoldsPerHour = Number.isInteger(thresholds.maxFoldsPerHour) && thresholds.maxFoldsPerHour > 0 ? thresholds.maxFoldsPerHour : 6;
95
+ if (hourly >= maxFoldsPerHour) return { ...none, reason: "hourly-cap" };
96
+ if (now - lastFoldAt < FOLD_MIN_INTERVAL_MS) return { ...none, reason: "min-interval" };
97
+
98
+ // Tier b — pressure: soft window threshold, side-model.
99
+ const pressurePercent = Number.isFinite(thresholds.pressurePercent) && thresholds.pressurePercent > 0 ? thresholds.pressurePercent : 60;
100
+ if (windowTokens !== null) {
101
+ const usagePct = (input.viewTokens / windowTokens) * 100;
102
+ if (usagePct >= pressurePercent) {
103
+ return { action: "fold", tier: "pressure", reason: `usage ${usagePct.toFixed(1)}% >= ${pressurePercent}%`, startIdx, endIdx, incrementTokens };
104
+ }
105
+ }
106
+
107
+ // Tier a — growth gate: the primary cache-friendly heuristic.
108
+ const growthTokens = Number.isFinite(thresholds.growthTokens) && thresholds.growthTokens > 0 ? thresholds.growthTokens : 22_500;
109
+ const incrementGate = Number.isFinite(thresholds.incrementTokens) && thresholds.incrementTokens > 0 ? thresholds.incrementTokens : 50_000;
110
+ // Growth anchor: the post-fold low when known (keeps the gate armed
111
+ // after folds that commit at high view points), else the pre-fold
112
+ // view for ledgers written before lastFoldLowTokens existed.
113
+ const anchor = Number.isFinite(input?.ledger?.lastFoldLowTokens) && input.ledger.lastFoldLowTokens > 0
114
+ ? input.ledger.lastFoldLowTokens
115
+ : Number.isFinite(input?.ledger?.lastFoldViewTokens) ? input.ledger.lastFoldViewTokens : 0;
116
+ const growth = input.viewTokens - anchor;
117
+ if (growth > growthTokens && incrementTokens >= incrementGate) {
118
+ return { action: "fold", tier: "growth", reason: `growth ~${Math.round(growth)} > ${growthTokens} and increment ~${incrementTokens} >= ${incrementGate}`, startIdx, endIdx, incrementTokens };
119
+ }
120
+
121
+ return { ...none, reason: "below-thresholds" };
122
+ }