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,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
|
+
}
|