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/cordis.patch.yml CHANGED
@@ -168,6 +168,14 @@
168
168
  config:
169
169
  - id: compaction-basic
170
170
  name: '@deepseek-ai/dsh-compaction-basic'
171
+ # Compaction arbitration (option A): auto=false disables ONLY the
172
+ # engine's automatic triggers (pre-step pressure + overflow
173
+ # retry). The compaction service stays mounted, so manual /compact
174
+ # and the context-watch handover borrow keep working (compactNow
175
+ # never consults this flag). scripts/validate.mjs carries a drift
176
+ # guard for the shipped auto gate.
177
+ config:
178
+ auto: false
171
179
  - id: command-compact
172
180
  name: '@deepseek-ai/dsh-command-compact'
173
181
  - id: tool-result-pruner
@@ -298,6 +306,14 @@
298
306
  config:
299
307
  - id: compaction-basic
300
308
  name: '@deepseek-ai/dsh-compaction-basic'
309
+ # Compaction arbitration (option A): auto=false disables ONLY the
310
+ # engine's automatic triggers (pre-step pressure + overflow
311
+ # retry). The compaction service stays mounted, so manual /compact
312
+ # and the context-watch handover borrow keep working (compactNow
313
+ # never consults this flag). scripts/validate.mjs carries a drift
314
+ # guard for the shipped auto gate.
315
+ config:
316
+ auto: false
301
317
  - id: command-compact
302
318
  name: '@deepseek-ai/dsh-command-compact'
303
319
  - id: tool-result-pruner
@@ -399,6 +415,14 @@
399
415
  config:
400
416
  - id: compaction-basic
401
417
  name: '@deepseek-ai/dsh-compaction-basic'
418
+ # Compaction arbitration (option A): auto=false disables ONLY the
419
+ # engine's automatic triggers (pre-step pressure + overflow
420
+ # retry). The compaction service stays mounted, so manual /compact
421
+ # and the context-watch handover borrow keep working (compactNow
422
+ # never consults this flag). scripts/validate.mjs carries a drift
423
+ # guard for the shipped auto gate.
424
+ config:
425
+ auto: false
402
426
  - id: command-compact
403
427
  name: '@deepseek-ai/dsh-command-compact'
404
428
  - id: persistent-shell
@@ -520,6 +544,14 @@
520
544
  config:
521
545
  - id: compaction-basic
522
546
  name: '@deepseek-ai/dsh-compaction-basic'
547
+ # Compaction arbitration (option A): auto=false disables ONLY the
548
+ # engine's automatic triggers (pre-step pressure + overflow
549
+ # retry). The compaction service stays mounted, so manual /compact
550
+ # and the context-watch handover borrow keep working (compactNow
551
+ # never consults this flag). scripts/validate.mjs carries a drift
552
+ # guard for the shipped auto gate.
553
+ config:
554
+ auto: false
523
555
  - id: command-compact
524
556
  name: '@deepseek-ai/dsh-command-compact'
525
557
  - id: tool-result-pruner
package/host/config.js CHANGED
@@ -131,13 +131,13 @@ export const Config = z.object({
131
131
  wmSoftTokens: z.number().min(1000).default(80000).volatile(),
132
132
  /** Hard watermark: read budget tightens, wrap-up advised (tokens). */
133
133
  wmHardTokens: z.number().min(2000).default(120000).volatile(),
134
- /** Force watermark: backup + compaction handover (tokens). */
134
+ /** Force watermark: handover document + compaction (tokens). */
135
135
  wmForceTokens: z.number().min(3000).default(150000).volatile(),
136
136
  /** Per-call read budget for read/glob/grep (tokens). */
137
137
  wmReadBudgetTokens: z.number().min(200).default(3000).volatile(),
138
138
  /** Hard-tier behavior: cap reads and advise, or deny outright. */
139
139
  wmDenyMode: z.union(["cap", "deny"]).default("cap").volatile(),
140
- /** Force tier triggers backup + compaction automatically. */
140
+ /** Force tier triggers the handover (document + compaction) automatically. */
141
141
  wmAutoHandover: z.boolean().default(true).volatile(),
142
142
  /** Cap subagent contexts at wmSubagentForceTokens. */
143
143
  wmSubagentCap: z.boolean().default(true).volatile(),
@@ -221,6 +221,52 @@ export const Config = z.object({
221
221
  /** Optional last-resort route when the classifier fails and no previous
222
222
  * routed model exists; empty = the first candidate. */
223
223
  intentFallbackRoute: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
224
+
225
+ // --- Context trim: fully-automatic conversation compression ------------
226
+ /** Master switch for the context-trim layer (design:
227
+ * .dsh-switchman/analysis/context-trim-port-design.md). OFF disables
228
+ * view folding AND metrics sampling entirely; the llm/stream listener
229
+ * degrades to a pure pass-through. */
230
+ trimEnabled: z.boolean().default(true).volatile(),
231
+ /** Automation level: "off" = measure only (no banner, no folds),
232
+ * "advisory" = expose ctx_trim_fold/ctx_trim_status + the protocol head
233
+ * so the session model can fold itself, "auto" = everything plus
234
+ * idle-boundary automatic folds (growth gate / pressure / emergency). */
235
+ trimMode: z.union(["off", "advisory", "auto"]).default("auto").volatile(),
236
+ /** Growth gate (tier a): fold when the outbound-view estimate grew this
237
+ * much since the last fold. */
238
+ trimGrowthTokens: z.number().min(1000).default(22500).volatile(),
239
+ /** Growth gate companion: the contiguous compressible increment must
240
+ * also reach this many estimated tokens (chars/4 heuristic). */
241
+ trimIncrementTokens: z.number().min(1000).default(50000).volatile(),
242
+ /** Pressure tier (tier b): fold when view usage reaches this percent of
243
+ * the context window (requires a resolvable window; otherwise skipped). */
244
+ trimPressurePercent: z.number().min(1).max(100).default(60).volatile(),
245
+ /** Emergency tier (tier c): forced MECHANICAL fold (no side-model call)
246
+ * at this percent of the window; cooldown-gated. */
247
+ trimEmergencyPercent: z.number().min(1).max(100).default(95).volatile(),
248
+ /** Effective-window cap for the tier math: tier points are computed
249
+ * against min(model-resolved window, cap). 0 = auto (cap follows
250
+ * wmForceTokens, so folds always fire ahead of the handover tier). */
251
+ trimWindowTokens: z.number().min(0).default(0).volatile(),
252
+ /** Last N messages of a history are never folded (the live tail the
253
+ * next model call reasons over). */
254
+ trimProtectedTail: z.number().min(0).max(100).default(6).volatile(),
255
+ /** Side-model fold cadence cap per rolling hour (emergency mechanical
256
+ * folds are exempt; they have their own cooldown). */
257
+ trimMaxFoldsPerHour: z.number().min(1).max(60).default(6).volatile(),
258
+ /** Route for the side-model summarizer (L3). Empty = inherit the
259
+ * session's current model. */
260
+ trimSideModel: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
261
+ /** Explicit ledger state directory. Empty = <dshHome>/.dsh-switchman/
262
+ * state/trim/. */
263
+ trimStateDir: z.string().default("").volatile(),
264
+ /** Ship committed folds onto the session surface: append the host-style
265
+ * shadow-price + surfaceOp replace event pair so folds reach the wire
266
+ * even while the host freezes assembled requests. OFF keeps the legacy
267
+ * splice-only view (wire=raw banner on frozen dispatches). Auto-noop
268
+ * on hosts without the surface mechanism. */
269
+ surfaceFold: z.boolean().default(true).volatile(),
224
270
  });
225
271
 
226
272
  /** The six dispatch lanes, in display order. */
@@ -0,0 +1,178 @@
1
+ /** dsh-switchman context-trim fork-state inheritance.
2
+ *
3
+ * When a session is forked (subagent_fork tool), the child conversation STARTS as a
4
+ * content copy of the parent's history — including every range the parent
5
+ * already folded. Without inheritance the child re-derives its ledger from
6
+ * scratch and re-folds (or never folds) ranges the parent already settled,
7
+ * and its first outbound view shows raw history.
8
+ *
9
+ * Copy-on-fork, in two halves:
10
+ *
11
+ * - Linkage (fork time): the parent→child edge is recorded the moment the
12
+ * fork is observed, into <stateDir>/fork-links.json (one bounded JSON
13
+ * map, atomic tmp+rename writes). Two call sites observe forks: the
14
+ * tools/post-execute listener on the subagent_fork tool (index.js). Refs are
15
+ * CONTENT-anchored (view.js refOf), so a link only stores session ids.
16
+ * - Inheritance (child first sight): when the child's history first flows
17
+ * through the llm/stream view builder, the parent ledger is cloned into
18
+ * the child's ledger — but ONLY when the parent ledger exists AND the
19
+ * child's history actually resolves the parent's frontier ref. A stale
20
+ * inherited frontier that resolves nothing is worse than no inheritance
21
+ * (the frontier anchors fold monotonicity), so a failed guard starts the
22
+ * child clean. One inheritance per child: the adopted ledger carries an
23
+ * inheritedFrom/inheritedAt marker and adoptLedger (state.js) refuses to
24
+ * overwrite a ledger that already has blocks or a marker — idempotent
25
+ * across restarts because the marker persists with the ledger.
26
+ *
27
+ * Fail-open everywhere: a lost link, an unreadable map, or a rejected
28
+ * guard only downgrades to a clean start (or a later attempt inside the
29
+ * bounded window) — never a broken session start.
30
+ */
31
+
32
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { dshHome } from "../lib/dsh-home.js";
35
+ import { LEDGER_VERSION } from "./state.js";
36
+ import { refPositions } from "./view.js";
37
+
38
+ /** Current fork-links file format version. */
39
+ export const FORK_LINKS_VERSION = 1;
40
+
41
+ /** The link map stays bounded: at most this many child entries. */
42
+ export const FORK_LINKS_MAX = 500;
43
+
44
+ /** Link entries older than this are pruned on write. */
45
+ export const FORK_LINKS_MAX_AGE_MS = 30 * 24 * 3_600_000;
46
+
47
+ /** How long a child stays eligible for inheritance while no link (or no
48
+ * parent folds) has appeared yet — covers the fork race where the child's
49
+ * first model call beats the parent's post-execute bookkeeping or the
50
+ * parent's first idle-boundary fold. */
51
+ export const INHERIT_WINDOW_MS = 60_000;
52
+
53
+ /** Default state dir (same resolution as createStore in state.js). */
54
+ function defaultStateDir() {
55
+ return join(dshHome(), ".dsh-switchman", "state", "trim");
56
+ }
57
+
58
+ /** Config field reader for trimStateDir (plain string or {get()} accessor,
59
+ * matching the volatile-settings pattern). Null when unset. */
60
+ export function trimStateDirOf(config) {
61
+ try {
62
+ const value = config?.trimStateDir;
63
+ const got = value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
64
+ if (typeof got === "string" && got !== "") return got;
65
+ } catch {
66
+ /* fall through */
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function linksFileOf(stateDir) {
72
+ return join(stateDir ?? defaultStateDir(), "fork-links.json");
73
+ }
74
+
75
+ /** Load the fork-links map (tolerant: any structural surprise yields an
76
+ * empty map — a lost link only downgrades to a clean start). */
77
+ export function loadForkLinks(stateDir, warn = () => {}) {
78
+ const file = linksFileOf(stateDir);
79
+ try {
80
+ if (!existsSync(file)) return { version: FORK_LINKS_VERSION, links: {} };
81
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
82
+ if (parsed === null || typeof parsed !== "object" || parsed.version !== FORK_LINKS_VERSION || parsed.links === null || typeof parsed.links !== "object") {
83
+ warn("[context-trim] fork-links map has an unknown format — ignoring");
84
+ return { version: FORK_LINKS_VERSION, links: {} };
85
+ }
86
+ const links = {};
87
+ for (const [childId, entry] of Object.entries(parsed.links)) {
88
+ if (typeof childId !== "string" || childId === "") continue;
89
+ if (entry === null || typeof entry !== "object" || typeof entry.parent !== "string" || entry.parent === "") continue;
90
+ links[childId] = { parent: entry.parent, at: Number.isFinite(entry.at) ? entry.at : 0 };
91
+ }
92
+ return { version: FORK_LINKS_VERSION, links };
93
+ } catch (error) {
94
+ warn(`[context-trim] fork-links load failed (${error?.message ?? error}) — ignoring`);
95
+ return { version: FORK_LINKS_VERSION, links: {} };
96
+ }
97
+ }
98
+
99
+ /** Record one parent→child fork edge (merge + prune + cap + atomic write).
100
+ * Returns true when the edge is durably recorded; false on any failure.
101
+ * Never throws — the fork itself already succeeded and must not be
102
+ * invalidated by bookkeeping. */
103
+ export function recordForkLink(parentId, childId, options = {}) {
104
+ const warn = typeof options.warn === "function" ? options.warn : () => {};
105
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
106
+ if (typeof parentId !== "string" || parentId === "" || typeof childId !== "string" || childId === "" || parentId === childId) return false;
107
+ try {
108
+ const map = loadForkLinks(options.stateDir, warn);
109
+ map.links[childId] = { parent: parentId, at: now };
110
+ // Prune by age, then cap by count (oldest dropped first).
111
+ const entries = Object.entries(map.links).filter(([, entry]) => now - entry.at < FORK_LINKS_MAX_AGE_MS);
112
+ entries.sort((a, b) => a[1].at - b[1].at);
113
+ while (entries.length > FORK_LINKS_MAX) entries.shift();
114
+ const links = {};
115
+ for (const [keptId, entry] of entries) links[keptId] = entry;
116
+ const dir = options.stateDir ?? defaultStateDir();
117
+ mkdirSync(dir, { recursive: true });
118
+ const file = linksFileOf(dir);
119
+ const tmp = `${file}.tmp`;
120
+ writeFileSync(tmp, JSON.stringify({ version: FORK_LINKS_VERSION, links }));
121
+ renameSync(tmp, file);
122
+ return true;
123
+ } catch (error) {
124
+ warn(`[context-trim] fork-link recording failed (${error?.message ?? error})`);
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** The fork edge recorded for one child, or null. */
130
+ export function forkLinkOf(map, childId) {
131
+ try {
132
+ const entry = map?.links?.[childId];
133
+ if (entry === null || typeof entry !== "object" || typeof entry.parent !== "string" || entry.parent === "") return null;
134
+ return entry;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /** Validate + build the child ledger that inherits the parent's fold state.
141
+ * Pure. The guard requires the child's history to resolve the parent's
142
+ * FRONTIER ref (content-anchored): a frontier that resolves nothing would
143
+ * silently pin every future fold decision to a wrong boundary, so a
144
+ * mismatching child starts clean. Returns { ok: true, ledger } or
145
+ * { ok: false, reason }. */
146
+ export function resolveInheritance(parentLedger, parentId, childId, childMessages, now = Date.now()) {
147
+ const blocks = Array.isArray(parentLedger?.blocks) ? parentLedger.blocks : [];
148
+ if (blocks.length === 0) return { ok: false, reason: "parent-ledger-empty" };
149
+ if (!Array.isArray(childMessages) || childMessages.length === 0) return { ok: false, reason: "no-child-history" };
150
+ const frontier = typeof parentLedger.frontier === "string" && parentLedger.frontier !== "" ? parentLedger.frontier : null;
151
+ if (frontier === null || !refPositions(childMessages).has(frontier)) return { ok: false, reason: "frontier-not-found" };
152
+ // Detached copy: blocks are shared CONTENT but the ledgers must never
153
+ // alias each other (two levels deep covers block + refRange). The
154
+ // parent's SURFACE bookkeeping (surfaceSeqs / surfaceReplaced /
155
+ // surfaceSkip) is stripped: those fields index the PARENT's event log —
156
+ // meaningless seqs in the child, and a parent-shipped range must re-ship
157
+ // (or re-locate) against the child's own surface (see surface.js).
158
+ const ledger = {
159
+ version: LEDGER_VERSION,
160
+ sessionId: childId,
161
+ blocks: blocks.map((block) => {
162
+ const clone = { ...block, refRange: { ...block.refRange } };
163
+ delete clone.surfaceSeqs;
164
+ delete clone.surfaceReplaced;
165
+ delete clone.surfaceSkip;
166
+ delete clone.surfaceHeadClamped;
167
+ return clone;
168
+ }),
169
+ frontier,
170
+ lastFoldViewTokens: Number.isFinite(parentLedger.lastFoldViewTokens) && parentLedger.lastFoldViewTokens > 0 ? Math.round(parentLedger.lastFoldViewTokens) : 0,
171
+ lastFoldLowTokens: Number.isFinite(parentLedger.lastFoldLowTokens) && parentLedger.lastFoldLowTokens > 0 ? Math.round(parentLedger.lastFoldLowTokens) : 0,
172
+ lastFoldAt: Number.isFinite(parentLedger.lastFoldAt) ? parentLedger.lastFoldAt : 0,
173
+ foldsThisHour: Array.isArray(parentLedger.foldsThisHour) ? parentLedger.foldsThisHour.filter((ts) => Number.isFinite(ts)) : [],
174
+ inheritedFrom: typeof parentId === "string" && parentId !== "" ? parentId : "unknown",
175
+ inheritedAt: now,
176
+ };
177
+ return { ok: true, ledger };
178
+ }