dsh-switchman 1.8.1 → 1.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +50 -7
- package/README.es.md +50 -7
- package/README.fr.md +50 -7
- package/README.it.md +50 -7
- package/README.ja.md +50 -7
- package/README.ko.md +50 -7
- package/README.md +49 -6
- package/README.pt.md +50 -7
- package/README.ru.md +50 -7
- package/README.zh-TW.md +50 -7
- package/README.zh.md +50 -7
- package/client.js +455 -58
- package/cordis.patch.yml +32 -0
- package/host/config.js +48 -2
- package/host/context-trim/fork.js +178 -0
- package/host/context-trim/index.js +1054 -0
- package/host/context-trim/metrics.js +128 -0
- package/host/context-trim/state.js +241 -0
- package/host/context-trim/summarizer.js +179 -0
- package/host/context-trim/surface.js +736 -0
- package/host/context-trim/trigger.js +129 -0
- package/host/context-trim/view.js +362 -0
- package/host/context-watch.js +32 -62
- package/host/handover-state.js +2 -2
- package/host/routes.js +1 -1
- package/host/subagent-model.js +1 -1
- package/index.js +18 -4
- package/package.json +4 -1
|
@@ -0,0 +1,128 @@
|
|
|
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, cacheWriteTokens, outputTokens } or null when
|
|
14
|
+
* the chunk carries none. Defensive field walk — providers shape usage
|
|
15
|
+
* differently (usage.input_tokens, usage.prompt_tokens, cached_tokens,
|
|
16
|
+
* cache_read_input_tokens, ...).
|
|
17
|
+
*
|
|
18
|
+
* Two shapes matter here. The host's own stream chunks carry a normalized
|
|
19
|
+
* TokenUsage ({ inputTokens, cacheReadTokens, cacheWriteTokens, ... }) where
|
|
20
|
+
* inputTokens counts UNCACHED prompt tokens only — the camelCase buckets do
|
|
21
|
+
* not overlap (llm-deepseek/src/translate.ts). Raw provider payloads use the
|
|
22
|
+
* snake_case twins, which on the Anthropic side have the same meaning. Only
|
|
23
|
+
* the OpenAI-style prompt_tokens shape would make inputTokens the inclusive
|
|
24
|
+
* total; summing there would over-count the prompt, which merely folds
|
|
25
|
+
* earlier — the safe direction for this plugin. */
|
|
26
|
+
export function extractUsage(chunk) {
|
|
27
|
+
if (chunk === null || typeof chunk !== "object") return null;
|
|
28
|
+
const candidates = [chunk.usage, chunk.metadata?.usage, chunk.response?.usage];
|
|
29
|
+
for (const usage of candidates) {
|
|
30
|
+
if (usage === null || typeof usage !== "object") continue;
|
|
31
|
+
const input = usage.input_tokens ?? usage.prompt_tokens ?? usage.inputTokens;
|
|
32
|
+
const cached = usage.cache_read_input_tokens ?? usage.cached_tokens ?? usage.cachedTokens ?? usage.cacheReadTokens ?? usage.prompt_tokens_details?.cached_tokens;
|
|
33
|
+
const cacheWrite = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? usage.cacheWriteTokens;
|
|
34
|
+
const output = usage.output_tokens ?? usage.completion_tokens ?? usage.outputTokens;
|
|
35
|
+
if (!Number.isFinite(input) && !Number.isFinite(cached) && !Number.isFinite(cacheWrite) && !Number.isFinite(output)) continue;
|
|
36
|
+
return {
|
|
37
|
+
inputTokens: Number.isFinite(input) ? input : null,
|
|
38
|
+
cachedTokens: Number.isFinite(cached) ? cached : null,
|
|
39
|
+
cacheWriteTokens: Number.isFinite(cacheWrite) ? cacheWrite : null,
|
|
40
|
+
outputTokens: Number.isFinite(output) ? output : null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Total prompt tokens for one metrics record: uncached input + cache read +
|
|
47
|
+
* cache write (host TokenUsage buckets do not overlap). Null when no input
|
|
48
|
+
* sample has ever been recorded — callers must treat unknown as "no wire
|
|
49
|
+
* signal" and stay on the view estimate. Missing cache buckets count as 0. */
|
|
50
|
+
export function promptTokensOf(record) {
|
|
51
|
+
if (record === null || record === undefined) return null;
|
|
52
|
+
if (!Number.isFinite(record.lastInputTokens)) return null;
|
|
53
|
+
const total = record.lastInputTokens + (Number.isFinite(record.cachedTokens) ? record.cachedTokens : 0) + (Number.isFinite(record.cacheWriteTokens) ? record.cacheWriteTokens : 0);
|
|
54
|
+
return total > 0 ? Math.round(total) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Per-session metrics store. */
|
|
58
|
+
export function createMetrics() {
|
|
59
|
+
const sessions = new Map(); // sessionId -> record
|
|
60
|
+
|
|
61
|
+
function recordOf(sessionId) {
|
|
62
|
+
let record = sessions.get(sessionId);
|
|
63
|
+
if (record === undefined) {
|
|
64
|
+
record = {
|
|
65
|
+
lastInputTokens: null, // UNCACHED prompt tokens of the last sample
|
|
66
|
+
cachedTokens: null, // cache-read bucket of the last sample
|
|
67
|
+
cacheWriteTokens: null, // cache-write bucket of the last sample
|
|
68
|
+
outputTokens: null,
|
|
69
|
+
viewTokens: null, // view estimate noted by the listener pre-request
|
|
70
|
+
lastModel: null, // { provider, model } of the last stream
|
|
71
|
+
lastStreamAt: 0,
|
|
72
|
+
totalStreams: 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
sessions.delete(sessionId); // drop first so re-insert refreshes recency order
|
|
76
|
+
sessions.set(sessionId, record);
|
|
77
|
+
// Prune: keep only the 50 most recently active sessions (bounded memory).
|
|
78
|
+
if (sessions.size > 50) {
|
|
79
|
+
const oldest = sessions.keys().next().value;
|
|
80
|
+
if (oldest !== undefined && oldest !== sessionId) sessions.delete(oldest);
|
|
81
|
+
}
|
|
82
|
+
return record;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
/** Note the outbound-view estimate + model before/around a request. */
|
|
87
|
+
noteView(sessionId, viewTokens, model) {
|
|
88
|
+
const record = recordOf(sessionId);
|
|
89
|
+
if (Number.isFinite(viewTokens)) record.viewTokens = Math.round(viewTokens);
|
|
90
|
+
if (model !== null && typeof model === "object") record.lastModel = { provider: model.provider ?? null, model: model.model ?? null };
|
|
91
|
+
record.lastStreamAt = Date.now();
|
|
92
|
+
record.totalStreams += 1;
|
|
93
|
+
return record;
|
|
94
|
+
},
|
|
95
|
+
/** Merge a usage sample (from extractUsage) into the record. */
|
|
96
|
+
recordUsage(sessionId, usage) {
|
|
97
|
+
if (usage === null) return null;
|
|
98
|
+
const record = recordOf(sessionId);
|
|
99
|
+
if (usage.inputTokens !== null) record.lastInputTokens = usage.inputTokens;
|
|
100
|
+
if (usage.cachedTokens !== null) record.cachedTokens = usage.cachedTokens;
|
|
101
|
+
if (usage.cacheWriteTokens !== null) record.cacheWriteTokens = usage.cacheWriteTokens;
|
|
102
|
+
if (usage.outputTokens !== null) record.outputTokens = usage.outputTokens;
|
|
103
|
+
return record;
|
|
104
|
+
},
|
|
105
|
+
/** Record of one session (or null when never streamed). */
|
|
106
|
+
of(sessionId) {
|
|
107
|
+
return sessions.get(sessionId) ?? null;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Wrap a stream result so each chunk flows through a (failing-open)
|
|
113
|
+
* sampler before being yielded. Returns the original value untouched when
|
|
114
|
+
* it is not an async iterable (non-stream fallbacks must pass through). */
|
|
115
|
+
export function wrapStreamForMetrics(result, onChunk) {
|
|
116
|
+
if (result === null || typeof result !== "object" || typeof result[Symbol.asyncIterator] !== "function") return result;
|
|
117
|
+
async function* sampled() {
|
|
118
|
+
for await (const chunk of result) {
|
|
119
|
+
try {
|
|
120
|
+
onChunk(chunk);
|
|
121
|
+
} catch {
|
|
122
|
+
/* sampling must never break the stream */
|
|
123
|
+
}
|
|
124
|
+
yield chunk;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return sampled();
|
|
128
|
+
}
|
|
@@ -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} 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
|
+
}
|