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