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.
@@ -0,0 +1,316 @@
1
+ /** dsh-switchman context-trim view builder (pure).
2
+ *
3
+ * The outbound-view half of the context-trim port (design:
4
+ * .dsh-switchman/analysis/context-trim-port-design.md §4): folds stored in
5
+ * a session ledger replace contiguous message ranges with ONE synthetic
6
+ * user message each. Everything here is pure and deterministic — the same
7
+ * (messages, ledger) pair must render a byte-identical view on every call
8
+ * (the golden test pins this), because a stable serialized prefix between
9
+ * folds is exactly what keeps provider prompt-cache hits alive.
10
+ *
11
+ * Message refs: messages at the llm/stream boundary are plain JSON-ish
12
+ * objects ({id?, role, content blocks / text}). An explicit id is trusted
13
+ * only when it looks addressable (short, no whitespace/control chars — the
14
+ * same validation billion-context applies); otherwise the ref is derived as
15
+ * a content hash of the canonicalized message (stable key order, volatile
16
+ * fields dropped), which survives the full-history re-send the agent loop
17
+ * performs every turn. Fold blocks anchor on refs, so a ledger keeps
18
+ * applying across process restarts without a parallel session log.
19
+ *
20
+ * Fail-open: blocks whose refs cannot be located (history rewritten by a
21
+ * racing compaction, content mutated) or that would reach into the
22
+ * protected tail are skipped, never guessed at — a stale ledger can only
23
+ * degrade to "no fold applied", never to lost messages.
24
+ */
25
+
26
+ import { createHash } from "node:crypto";
27
+
28
+ /** Cheap char → token estimate (the brief's char/4 heuristic). */
29
+ export const TOKENS_PER_CHAR = 1 / 4;
30
+
31
+ /** Object keys dropped before hashing (volatility would churn refs). */
32
+ const VOLATILE_KEYS = new Set(["ts", "time", "timestamp", "createdAt", "updatedAt", "receivedAt", "sentAt"]);
33
+
34
+ /** Canonicalization depth cap (well beyond any real message nesting). */
35
+ const MAX_CANON_DEPTH = 8;
36
+
37
+ /** Canonical JSON of one value: object keys sorted, volatile keys dropped,
38
+ * arrays kept in order. Used ONLY for ref hashing, never for output. */
39
+ function canonicalValue(value, depth) {
40
+ if (value === null || typeof value !== "object") {
41
+ if (typeof value === "bigint") return String(value);
42
+ if (typeof value === "number" && !Number.isFinite(value)) return null;
43
+ return value;
44
+ }
45
+ if (depth >= MAX_CANON_DEPTH) return "[depth]";
46
+ if (Array.isArray(value)) return value.map((item) => canonicalValue(item, depth + 1));
47
+ const out = {};
48
+ for (const key of Object.keys(value).sort()) {
49
+ if (VOLATILE_KEYS.has(key)) continue;
50
+ out[key] = canonicalValue(value[key], depth + 1);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Canonical JSON text of one message (ref-hash input). */
56
+ function canonicalizeMessage(message) {
57
+ try {
58
+ return JSON.stringify(canonicalValue(message ?? null, 0)) ?? "null";
59
+ } catch {
60
+ return String(message);
61
+ }
62
+ }
63
+
64
+ /** True when an explicit message id is safe to address directly. */
65
+ function usableId(id) {
66
+ if (typeof id !== "string" || id === "" || id.length > 512) return false;
67
+ for (const char of id) {
68
+ const code = char.charCodeAt(0);
69
+ if (code <= 0x20 || code === 0x7f) return false;
70
+ }
71
+ return true;
72
+ }
73
+
74
+ /** Stable ref of one message: the explicit id when usable, else a short
75
+ * content hash (prefixed "h" so the two families can be told apart in
76
+ * logs). Index is unused for hashing — refs are position-independent on
77
+ * purpose (the host may prepend/insert nothing, but ids may be absent). */
78
+ export function refOf(message, _index) {
79
+ const id = message?.id;
80
+ if (usableId(id)) return id;
81
+ return `h${createHash("sha256").update(canonicalizeMessage(message)).digest("hex").slice(0, 12)}`;
82
+ }
83
+
84
+ /** ref -> ascending index list for one messages array. */
85
+ export function refPositions(messages) {
86
+ const positions = new Map();
87
+ messages.forEach((message, index) => {
88
+ const ref = refOf(message, index);
89
+ const list = positions.get(ref);
90
+ if (list === undefined) positions.set(ref, [index]);
91
+ else list.push(index);
92
+ });
93
+ return positions;
94
+ }
95
+
96
+ /** Estimated token count of one message (text content + tool-call payloads
97
+ * + small per-message framing overhead). Never throws. */
98
+ export function messageTokens(message) {
99
+ let chars = 8;
100
+ const content = message?.content;
101
+ try {
102
+ if (typeof content === "string") chars += content.length;
103
+ else if (Array.isArray(content)) {
104
+ for (const block of content) {
105
+ if (block !== null && typeof block === "object" && typeof block.text === "string") chars += block.text.length;
106
+ else chars += JSON.stringify(block ?? null).length;
107
+ }
108
+ }
109
+ if (Array.isArray(message?.tool_calls)) chars += JSON.stringify(message.tool_calls).length;
110
+ } catch {
111
+ /* unserializable block: keep the framing estimate */
112
+ }
113
+ return Math.max(1, Math.round(chars * TOKENS_PER_CHAR));
114
+ }
115
+
116
+ /** Estimated token count of a message range (inclusive indices). */
117
+ export function estimateTokensOf(messages, startIdx = 0, endIdx = messages.length - 1) {
118
+ let total = 0;
119
+ for (let i = Math.max(0, startIdx); i <= Math.min(messages.length - 1, endIdx); i++) total += messageTokens(messages[i]);
120
+ return total;
121
+ }
122
+
123
+ // ---- tool-pair segmentation -------------------------------------------------
124
+
125
+ /** True when the message OPENS tool calls (either wire shape). */
126
+ function opensToolCalls(message) {
127
+ if (message?.role !== "assistant") return false;
128
+ if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return true;
129
+ if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_use");
130
+ return false;
131
+ }
132
+
133
+ /** True when the message CARRIES tool results (either wire shape). */
134
+ function carriesToolResults(message) {
135
+ if (message?.role === "tool") return true;
136
+ if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_result");
137
+ return false;
138
+ }
139
+
140
+ /** Atomic segments of a messages array: an assistant message that opens
141
+ * tool calls plus every directly following tool-result message form ONE
142
+ * segment (they must live or fold together — a split pair is rejected by
143
+ * the upstream API, dsh-compaction/lib/index.js:557 validates the same
144
+ * rule); every other message is its own segment. Returns [start, end]
145
+ * index pairs covering the array in order. */
146
+ export function segmentBounds(messages) {
147
+ const bounds = [];
148
+ let i = 0;
149
+ while (i < messages.length) {
150
+ let end = i;
151
+ if (opensToolCalls(messages[i])) {
152
+ let j = i + 1;
153
+ while (j < messages.length && carriesToolResults(messages[j])) j++;
154
+ end = j - 1;
155
+ }
156
+ bounds.push([i, end]);
157
+ i = end + 1;
158
+ }
159
+ return bounds;
160
+ }
161
+
162
+ /** True when [startIdx, endIdx] aligns with segment boundaries (a fold
163
+ * range must never cut inside a tool pair). */
164
+ export function rangeIsSegmentAligned(messages, startIdx, endIdx) {
165
+ if (!Number.isInteger(startIdx) || !Number.isInteger(endIdx) || startIdx < 0 || endIdx < startIdx) return false;
166
+ // The range must START at a segment start and END at a segment end (it may
167
+ // span any number of whole segments — segments tile the array contiguously,
168
+ // so aligned ends guarantee no tool pair is cut).
169
+ let startHit = false;
170
+ for (const [start, end] of segmentBounds(messages)) {
171
+ if (start === startIdx) startHit = true;
172
+ if (end === endIdx) return startHit && start <= endIdx;
173
+ if (start > endIdx) break;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ // ---- fold blocks ------------------------------------------------------------
179
+
180
+ /** Deterministic wire text of one fold block's synthetic message. */
181
+ export function renderSummaryText(block) {
182
+ return `[SWITCHMAN:TRIM] ${block.summary}`;
183
+ }
184
+
185
+ /** The ONE synthetic message replacing a folded range (brief: synthetic
186
+ * user message; id carries the acp_summary_ prefix downstream tooling
187
+ * skips). Pure — same block, same bytes. */
188
+ export function summaryMessageOf(block) {
189
+ return {
190
+ id: `acp_summary_${block.id}`,
191
+ role: "user",
192
+ content: [{ type: "text", text: renderSummaryText(block) }],
193
+ };
194
+ }
195
+
196
+ /** Locate a block's range in a positions map at/after afterIdx. Strict:
197
+ * both endpoint refs must resolve and the located span must equal the
198
+ * block's stored message count (guards against re-sent histories that no
199
+ * longer match the folded range). Returns [firstIdx, lastIdx] or null. */
200
+ function locateBlock(positions, block, afterIdx) {
201
+ const range = block?.refRange;
202
+ if (range === null || typeof range !== "object") return null;
203
+ const firstList = positions.get(range.firstId);
204
+ const lastList = positions.get(range.lastId);
205
+ if (firstList === undefined || lastList === undefined) return null;
206
+ const anchored = Number.isInteger(block.count) && block.count > 0;
207
+ let firstIdx = null;
208
+ let lastIdx = null;
209
+ for (const start of firstList) {
210
+ if (start <= afterIdx) continue;
211
+ if (anchored) {
212
+ // Duplicate-content messages share one content-addressed ref, so
213
+ // resolve the pair anchored on the stored count, not on extremes.
214
+ const end = start + block.count - 1;
215
+ if (!lastList.includes(end)) continue;
216
+ firstIdx = start;
217
+ lastIdx = end;
218
+ } else {
219
+ firstIdx = start;
220
+ }
221
+ break;
222
+ }
223
+ if (firstIdx === null) return null;
224
+ if (lastIdx === null) {
225
+ for (const index of lastList) {
226
+ if (index >= firstIdx) {
227
+ lastIdx = index;
228
+ break;
229
+ }
230
+ }
231
+ if (lastIdx === null) return null;
232
+ }
233
+ const span = lastIdx - firstIdx + 1;
234
+ const expected = anchored ? block.count : span;
235
+ return span === expected ? [firstIdx, lastIdx] : null;
236
+ }
237
+
238
+ /** Build the outbound view: apply every applicable fold block to a copy of
239
+ * the messages array. Pure (never mutates the input; the assembly splices
240
+ * the result into the live request array). Blocks apply in positional
241
+ * order; a block that cannot be located, overlaps an already-applied one,
242
+ * or reaches into the last protectedTail messages is skipped. Returns
243
+ * { messages, applied, foldedMessages } (foldedMessages = live messages
244
+ * the applied blocks replaced). */
245
+ export function buildView(messages, ledger, options = {}) {
246
+ const protectedTail = Number.isInteger(options.protectedTail) && options.protectedTail > 0 ? options.protectedTail : 0;
247
+ const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
248
+ const positions = refPositions(messages);
249
+ const located = [];
250
+ let unlocated = 0;
251
+ for (const block of blocks) {
252
+ const range = locateBlock(positions, block, -1);
253
+ if (range !== null) located.push({ block, firstIdx: range[0], lastIdx: range[1] });
254
+ else unlocated += 1;
255
+ }
256
+ located.sort((a, b) => a.firstIdx - b.firstIdx || b.lastIdx - a.lastIdx);
257
+ const out = [];
258
+ let at = 0;
259
+ let applied = 0;
260
+ let foldedMessages = 0;
261
+ for (const entry of located) {
262
+ if (entry.firstIdx < at) continue; // overlaps an applied block — skip
263
+ if (protectedTail > 0 && entry.lastIdx >= messages.length - protectedTail) continue;
264
+ if (!rangeIsSegmentAligned(messages, entry.firstIdx, entry.lastIdx)) continue; // never split a tool pair (design §4)
265
+ for (let i = at; i < entry.firstIdx; i++) out.push(messages[i]);
266
+ out.push(summaryMessageOf(entry.block));
267
+ foldedMessages += entry.lastIdx - entry.firstIdx + 1;
268
+ at = entry.lastIdx + 1;
269
+ applied += 1;
270
+ }
271
+ for (let i = at; i < messages.length; i++) out.push(messages[i]);
272
+ return { messages: out, applied, foldedMessages, unlocated };
273
+ }
274
+
275
+ /** Epoch-boundary detection: every block failed to LOCATE inside the current
276
+ * re-send — the host replaced history (its own compaction) and the ledger
277
+ * aged out as a whole. The fail-open skip stays correct, but a growth
278
+ * baseline carried over from the previous epoch does not: the caller
279
+ * re-anchors it exactly once per epoch. */
280
+ export function ledgerIsStale(view, ledger) {
281
+ const blocksCount = Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0;
282
+ return blocksCount > 0 && view.applied === 0 && view.unlocated === blocksCount;
283
+ }
284
+
285
+ /** Install the built view for one dispatch. Host immutability varies: probe
286
+ * and fresh re-sends hand out mutable arrays (splice in place — cheapest,
287
+ * identity-preserving), resumed sessions re-send FROZEN host-assembled
288
+ * history (splice throws on its internal index deletes), and some dispatch
289
+ * options are frozen objects on top of that. Returns the strategy used so
290
+ * the caller can log it: "in-place" | "swapped" | "none". */
291
+ export function injectView(options, messages, view) {
292
+ if (view.applied > 0) {
293
+ try {
294
+ replaceInPlace(messages, view.messages);
295
+ return "in-place";
296
+ } catch {
297
+ /* frozen array — fall through to the reference swap */
298
+ }
299
+ try {
300
+ options.messages = view.messages;
301
+ return "swapped";
302
+ } catch {
303
+ /* frozen options — this dispatch cannot carry the view; fail open */
304
+ }
305
+ }
306
+ return "none";
307
+ }
308
+
309
+ /** Replace an array's CONTENT in place (identity preserved — the llm/stream
310
+ * contract mutates the live request array). Chunked pushes keep huge
311
+ * histories clear of the argument-count limit. */
312
+ export function replaceInPlace(target, source) {
313
+ target.splice(0, target.length);
314
+ for (let i = 0; i < source.length; i += 2048) target.push(...source.slice(i, i + 2048));
315
+ return target;
316
+ }
@@ -53,6 +53,8 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
53
53
  import { dirname, join } from "node:path";
54
54
  import { clearHandover, handoverOf, recordHandover } from "./handover-state.js";
55
55
  import { enumerateLiveSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
56
+ import { recentTrimViewTokensOf } from "./context-trim/index.js";
57
+ import { recordForkLink, trimStateDirOf } from "./context-trim/fork.js";
56
58
 
57
59
  /** Tools whose result text is accounted against the per-turn read budget. */
58
60
  const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
@@ -146,11 +148,23 @@ function readThresholds(config) {
146
148
  }
147
149
 
148
150
  /** Measure one agent's session now; null when the meter is missing/failing. */
149
- function measureNow(ctx, agent) {
151
+ export function measureNow(ctx, agent) {
150
152
  try {
151
153
  const measurement = agent?.session ? ctx.tokenMeter?.measure?.(agent.session) : undefined;
152
154
  const used = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
153
- return used === null ? null : used;
155
+ // Meter duality (design §8): prefer the trim layer's fresh outbound-view
156
+ // estimate when present — billion-context can fold far more away than the
157
+ // meter's stored tally admits, and the watermark must track what the
158
+ // model actually sees. Strictly additive + fail-open: any trim failure
159
+ // falls back to the stored measure.
160
+ try {
161
+ const sessionId = agent?.session?.id ?? (typeof agent?.id === "string" ? agent.id : null);
162
+ const viewTokens = sessionId === null ? null : recentTrimViewTokensOf(sessionId);
163
+ if (viewTokens !== null) return viewTokens;
164
+ } catch {
165
+ // fall through to the stored measure
166
+ }
167
+ return used;
154
168
  } catch {
155
169
  return null;
156
170
  }
@@ -443,6 +457,12 @@ function applyContextWatch(ctx, config) {
443
457
  );
444
458
  return { childId: null, reason };
445
459
  }
460
+ // Copy-on-fork: the backup child inherits this session's trim fold
461
+ // ledger at its first model call (host/context-trim/fork.js). The
462
+ // link is best-effort bookkeeping on an already-successful fork —
463
+ // recordForkLink never throws; a lost link only downgrades the
464
+ // child to a clean ledger.
465
+ recordForkLink(sessionId, started.childId, { stateDir: trimStateDirOf(config), warn });
446
466
  return { childId: started.childId, reason: null };
447
467
  } catch (error) {
448
468
  const reason = error?.message ?? String(error);
@@ -149,4 +149,4 @@ function applySubagentModel(ctx) {
149
149
  });
150
150
  }
151
151
 
152
- export { applySubagentModel, sessionModelSelectionOf };
152
+ export { applySubagentModel, childSessionIdOf, sessionModelSelectionOf };
package/index.js CHANGED
@@ -69,6 +69,9 @@ import { applySubagentModel } from "./host/subagent-model.js";
69
69
 
70
70
  // Context-watermark layer (banner + read budget + handover + /ctx-* commands).
71
71
  import { applyContextWatch } from "./host/context-watch.js";
72
+ // Context-trim layer (deterministic outbound-view folding + idle-boundary
73
+ // automatic folds + ctx_trim_fold/ctx_trim_status advisory tools).
74
+ import { applyContextTrim } from "./host/context-trim/index.js";
72
75
  // Vision dispatch gate (/vision command + vision-state route for the
73
76
  // composer-dock hint): unlocks image sending on text-only models.
74
77
  import { applyVision } from "./host/vision.js";
@@ -100,7 +103,17 @@ const name = "dsh-switchman";
100
103
  * only inside each preset's per-agent isolated group (isolate: { compaction:
101
104
  * true }), never at this root — host/context-watch.js reaches it by borrowing
102
105
  * the session's own per-agent /compact handler through
103
- * ctx.commands.find(agent, "compact"). */
106
+ * ctx.commands.find(agent, "compact").
107
+ *
108
+ * The context-trim layer (host/context-trim/) adds fully-automatic context
109
+ * compression: an llm/stream listener rebuilds a deterministic outbound
110
+ * view (folded ranges replaced by ONE synthetic summary message each,
111
+ * per-session ledger) in place before every agent-loop model call, an
112
+ * agent/status idle hook runs the fold decisions (growth gate / pressure /
113
+ * emergency tiers; side-model summaries with mechanical emergency
114
+ * fallback), and the ctx_trim_fold / ctx_trim_status tools expose the
115
+ * advisory surface. Fail-open throughout: the listener always calls
116
+ * next(), and trimming degrades to a no-op on any internal failure. */
104
117
  const inject = ["skills", "systemPrompt", "settings", "tokenMeter", "commands", "tools", "agents", "webServer", "subagents", "sessions", "pluginManager", "sessionQuery"];
105
118
 
106
119
  /** Must equal `BUNDLED_SKILL_RANK` of @deepseek-ai/dsh-skill (packaged skill
@@ -193,6 +206,7 @@ function apply(ctx, config) {
193
206
  applyDispatch(ctx, config);
194
207
  applyTeams(ctx, config);
195
208
  applyContextWatch(ctx, config);
209
+ applyContextTrim(ctx, config);
196
210
  applyVision(ctx, config);
197
211
  applyIntent(ctx, config);
198
212
  applySubagentModel(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-switchman",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -22,6 +22,9 @@
22
22
  },
23
23
  "type": "module",
24
24
  "main": "./index.js",
25
+ "scripts": {
26
+ "test": "node --test scripts/test/*.test.mjs"
27
+ },
25
28
  "icon": "./icon.svg",
26
29
  "engines": {
27
30
  "node": ">=24",