dsh-switchman 1.10.2 → 1.11.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 +5 -4
- package/README.es.md +5 -4
- package/README.fr.md +5 -4
- package/README.it.md +5 -4
- package/README.ja.md +5 -4
- package/README.ko.md +5 -4
- package/README.md +14 -5
- package/README.pt.md +5 -4
- package/README.ru.md +5 -4
- package/README.zh-TW.md +5 -4
- package/README.zh.md +5 -4
- package/client.js +323 -40
- package/cordis.patch.yml +23 -20
- package/host/config.js +51 -7
- package/host/context-trim/fork.js +5 -1
- package/host/context-trim/index.js +329 -178
- package/host/context-trim/metrics.js +7 -7
- package/host/context-trim/state.js +351 -50
- package/host/context-trim/summarizer.js +147 -33
- package/host/context-trim/surface.js +415 -11
- package/host/context-trim/trigger.js +170 -51
- package/host/context-trim/view.js +144 -20
- package/host/context-watch.js +80 -73
- package/host/routes.js +64 -4
- package/package.json +2 -2
package/host/context-watch.js
CHANGED
|
@@ -53,7 +53,7 @@ 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 {
|
|
56
|
+
import { recentTrimPromptTokensOf } from "./context-trim/index.js";
|
|
57
57
|
|
|
58
58
|
/** Tools whose result text is accounted against the per-turn read budget. */
|
|
59
59
|
const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
|
|
@@ -115,11 +115,6 @@ function levelOf(used, th) {
|
|
|
115
115
|
return "ok";
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/** `42k`-style rounding for the anchor line. */
|
|
119
|
-
function kilo(tokens) {
|
|
120
|
-
return `${Math.round(tokens / 1000)}k`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
118
|
/** Session id off an exec/invocation agent (shared agent/session id). */
|
|
124
119
|
function sessionIdOf(agent) {
|
|
125
120
|
const id = agent?.session?.id ?? agent?.id;
|
|
@@ -141,7 +136,7 @@ function isRoot(ctx, agent) {
|
|
|
141
136
|
function readThresholds(config) {
|
|
142
137
|
return {
|
|
143
138
|
soft: readNumber(config.wmSoftTokens, 80000),
|
|
144
|
-
hard: readNumber(config.wmHardTokens,
|
|
139
|
+
hard: readNumber(config.wmHardTokens, 95000),
|
|
145
140
|
force: readNumber(config.wmForceTokens, 150000),
|
|
146
141
|
};
|
|
147
142
|
}
|
|
@@ -151,15 +146,21 @@ export function measureNow(ctx, agent) {
|
|
|
151
146
|
try {
|
|
152
147
|
const measurement = agent?.session ? ctx.tokenMeter?.measure?.(agent.session) : undefined;
|
|
153
148
|
const used = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
|
|
154
|
-
// Meter duality (design §8): prefer the trim layer's
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
149
|
+
// Meter duality (design §8): prefer the trim layer's wire-domain meter —
|
|
150
|
+
// max(fresh outbound view, last sampled MAIN-dispatch prompt), the same
|
|
151
|
+
// number the trim pressure tiers evaluate — when present. Each pure
|
|
152
|
+
// source has one bias: the stored tally is wire-domain but never
|
|
153
|
+
// re-prices splice-level folds (it keeps counting material the model no
|
|
154
|
+
// longer receives — high bias), while the bare view never counts the
|
|
155
|
+
// system/tool sections (low bias). max(view, wire) fixes both and puts
|
|
156
|
+
// the watermark tiers (soft/hard/force + read budget) on the SAME
|
|
157
|
+
// boundary as the fold engine — wmHardTokens 95k ≙ trim pressure 90k
|
|
158
|
+
// (c453b91) holds in units, not just in numbers. Strictly additive +
|
|
159
|
+
// fail-open: any trim failure falls back to the stored measure.
|
|
159
160
|
try {
|
|
160
161
|
const sessionId = agent?.session?.id ?? (typeof agent?.id === "string" ? agent.id : null);
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
162
|
+
const promptTokens = sessionId === null ? null : recentTrimPromptTokensOf(sessionId);
|
|
163
|
+
if (promptTokens !== null) return promptTokens;
|
|
163
164
|
} catch {
|
|
164
165
|
// fall through to the stored measure
|
|
165
166
|
}
|
|
@@ -171,58 +172,53 @@ export function measureNow(ctx, agent) {
|
|
|
171
172
|
|
|
172
173
|
// ---- watermark rendering (pure given state + measurement) ------------------
|
|
173
174
|
|
|
174
|
-
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
175
|
+
/** MODEL-VISIBILITY POLICY (docs/ladder-fold.md, bili lesson #2): the
|
|
176
|
+
* watermark NEVER shows the model a water level — no used-token counts, no
|
|
177
|
+
* budget values, no percentages, no cooldown minutes. A model told "you're
|
|
178
|
+
* at 20%" anchors on "looks healthy" and refuses to fold or wrap; with the
|
|
179
|
+
* numbers hidden, judgment roots in the task. Only BEHAVIORAL directives
|
|
180
|
+
* travel, and only when enforcement changes (a tier crossing or a handover
|
|
181
|
+
* phase change), which also keeps the runtime-context snapshot byte-stable
|
|
182
|
+
* between those events. The full numeric picture stays available to the
|
|
183
|
+
* human through the client badge. */
|
|
183
184
|
|
|
184
185
|
/** Byte-stable protocol text for root sessions: no live numbers, so the
|
|
185
186
|
* request head stays byte-identical for the whole session (KV-cache
|
|
186
|
-
* invariant)
|
|
187
|
+
* invariant). */
|
|
187
188
|
const PROTOCOL_ROOT_TEXT = [
|
|
188
|
-
"[SWITCHMAN:WATERMARK] protocol: this session is
|
|
189
|
-
"
|
|
190
|
-
"The live tier and the bucketed used-token reading (against the soft/hard/force budget) arrive ONLY as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot at the conversation tail; a tier change is announced on the step it happens. /ctx-pause pauses every watermark enforcement (measurement continues), /ctx-resume restores it, /ctx-handover starts a handover immediately.",
|
|
189
|
+
"[SWITCHMAN:WATERMARK] protocol: this session's context is managed automatically (folding runs before you notice; a handover may run when needed). You will never receive token counts, percentages, or progress numbers — none exist in this protocol, and no pacing decision is yours to make from them.",
|
|
190
|
+
"Only BEHAVIORAL directives arrive, as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot, when enforcement changes: a soft line asks you to prefer delegation over reading more context yourself; a hard line says broad reading is budget-limited this turn and it is time to start wrapping up; a force line is a MANDATORY wrap-up — produce the final answer from what you already hold. /ctx-pause pauses every watermark enforcement (measurement continues), /ctx-resume restores it, /ctx-handover starts a handover immediately.",
|
|
191
191
|
].join("\n");
|
|
192
192
|
|
|
193
193
|
/** Byte-stable protocol text for subagent sessions (same invariant). */
|
|
194
194
|
const PROTOCOL_SUB_TEXT = [
|
|
195
|
-
"[SWITCHMAN:WATERMARK] protocol: this subagent session is
|
|
196
|
-
"
|
|
197
|
-
"The live tier and the bucketed used-token reading arrive ONLY as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot; a tier change is announced on the step it happens.",
|
|
195
|
+
"[SWITCHMAN:WATERMARK] protocol: this subagent session's context is managed automatically. You will never receive token counts, percentages, or progress numbers — judgment stays rooted in the task.",
|
|
196
|
+
"Only BEHAVIORAL directives arrive, as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot, when enforcement changes: a soft line asks for narrow reads; a hard line says stop broad reading and wrap toward the final summary; a force line says stop reading and return the final summary now, ending the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.",
|
|
198
197
|
].join("\n");
|
|
199
198
|
|
|
200
|
-
/** Static paused line (
|
|
199
|
+
/** Static paused line (never changes while paused: enforcement is off, and a
|
|
201
200
|
* frozen string emits once and never churns the snapshot again). */
|
|
202
201
|
const PAUSED_LINE =
|
|
203
202
|
"[SWITCHMAN:WATERMARK] paused — measurement continues, but every watermark enforcement stays off until /ctx-resume.";
|
|
204
203
|
|
|
205
|
-
/** Root-session live line:
|
|
206
|
-
* the
|
|
207
|
-
function renderRootWatermark(level,
|
|
204
|
+
/** Root-session live line: directive text only — no numbers (see the
|
|
205
|
+
* policy note above). "" while the session sits at the silent ok tier. */
|
|
206
|
+
function renderRootWatermark(level, status) {
|
|
208
207
|
if (level === "ok") return "";
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (level === "soft") return `${head}\nsoft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.`;
|
|
213
|
-
if (level === "hard") return `${head}\nhard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`;
|
|
214
|
-
const lines = [head, "force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
|
|
208
|
+
if (level === "soft") return "[SWITCHMAN:WATERMARK] soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.";
|
|
209
|
+
if (level === "hard") return "[SWITCHMAN:WATERMARK] hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.";
|
|
210
|
+
const lines = ["[SWITCHMAN:WATERMARK] force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
|
|
215
211
|
const phase = status?.phase;
|
|
216
212
|
if (phase === "backup") {
|
|
217
213
|
lines.push(`context handover running: writing the handover document…`);
|
|
218
214
|
} else if (phase === "compacting") {
|
|
219
|
-
lines.push(`context handover running: compaction
|
|
215
|
+
lines.push(`context handover running: compaction in progress (retries while the turn is still running)…`);
|
|
220
216
|
} else if (phase === "continuation") {
|
|
221
217
|
lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
|
|
222
218
|
} else if (status?.autoHandover) {
|
|
223
219
|
lines.push(
|
|
224
|
-
status?.
|
|
225
|
-
? `automatic handover
|
|
220
|
+
status?.cooldown
|
|
221
|
+
? `automatic handover is cooling down; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
|
|
226
222
|
: `an automatic context handover (document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
|
|
227
223
|
);
|
|
228
224
|
} else {
|
|
@@ -231,21 +227,16 @@ function renderRootWatermark(level, used, th, status) {
|
|
|
231
227
|
return lines.join("\n");
|
|
232
228
|
}
|
|
233
229
|
|
|
234
|
-
/** Subagent-session live line: the same
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
|
|
238
|
-
function renderSubWatermark(used, th) {
|
|
239
|
-
const level = levelOf(used, th);
|
|
230
|
+
/** Subagent-session live line: the same tiers as the root line, directive
|
|
231
|
+
* text only — no numbers (see the policy note above). Pausing never
|
|
232
|
+
* applies to subagents (oc index.ts:187-190). */
|
|
233
|
+
function renderSubWatermark(level) {
|
|
240
234
|
if (level === "ok") return "";
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
` of budget ${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
|
|
244
|
-
if (level === "soft") return `${head}\nsoft tier: keep reads narrow and drop bulky raw output.`;
|
|
245
|
-
if (level === "hard") return `${head}\nhard tier: stop broad reading; start wrapping toward the final summary.`;
|
|
235
|
+
if (level === "soft") return "[SWITCHMAN:WATERMARK] soft tier: keep reads narrow and drop bulky raw output.";
|
|
236
|
+
if (level === "hard") return "[SWITCHMAN:WATERMARK] hard tier: stop broad reading; start wrapping toward the final summary.";
|
|
246
237
|
return (
|
|
247
|
-
|
|
248
|
-
|
|
238
|
+
"[SWITCHMAN:WATERMARK] force tier: stop reading and return the final summary now.\n" +
|
|
239
|
+
"End the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps."
|
|
249
240
|
);
|
|
250
241
|
}
|
|
251
242
|
|
|
@@ -258,24 +249,24 @@ function renderWatermarkContext(ctx, config, state, agent) {
|
|
|
258
249
|
const th = readThresholds(config);
|
|
259
250
|
const used = measureNow(ctx, agent);
|
|
260
251
|
if (used === null) return "";
|
|
252
|
+
const level = levelOf(used, th);
|
|
253
|
+
if (level === "ok") return "";
|
|
261
254
|
if (!isRoot(ctx, agent)) {
|
|
262
255
|
// Subagents share the root soft/hard thresholds; the optional
|
|
263
256
|
// wmSubagentForceTokens override replaces only the force tier
|
|
264
257
|
// (enabled by the wmSubagentCap toggle, 0/0 = follow the root set).
|
|
265
258
|
const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
|
|
266
259
|
const subTh = subForce > 0 ? { ...th, force: subForce } : th;
|
|
267
|
-
return renderSubWatermark(used, subTh);
|
|
260
|
+
return renderSubWatermark(levelOf(used, subTh));
|
|
268
261
|
}
|
|
269
262
|
if (state.paused.has(sessionId)) return PAUSED_LINE;
|
|
270
263
|
const live = handoverOf(sessionId);
|
|
271
264
|
const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
|
|
272
|
-
|
|
273
|
-
// byte-stable between steps (snapshot re-emits only on change).
|
|
274
|
-
return renderRootWatermark(levelOf(used, th), used, th, {
|
|
265
|
+
return renderRootWatermark(level, {
|
|
275
266
|
autoHandover: readBool(config.wmAutoHandover, true),
|
|
276
267
|
phase: live?.phase,
|
|
277
268
|
attempt: live?.attempt,
|
|
278
|
-
|
|
269
|
+
cooldown: elapsed < HANDOVER_COOLDOWN_MS,
|
|
279
270
|
tool: state.handoverTool ?? undefined,
|
|
280
271
|
});
|
|
281
272
|
} catch {
|
|
@@ -846,16 +837,26 @@ function applyContextWatch(ctx, config) {
|
|
|
846
837
|
readString(config.wmDenyMode) !== "deny" &&
|
|
847
838
|
Array.isArray(result?.content)
|
|
848
839
|
) {
|
|
849
|
-
return
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
840
|
+
// [2026-11-07]-[the warning branch used to return without
|
|
841
|
+
// calling next()]-[that short-circuited every post-execute
|
|
842
|
+
// handler downstream of the watermark (trim fork-link
|
|
843
|
+
// accounting, subagent model annotation) on exactly the
|
|
844
|
+
// turns that read the most. Run the pipeline first, then
|
|
845
|
+
// append the warning to whatever the pipeline accepted.]
|
|
846
|
+
return (async () => {
|
|
847
|
+
const downstream = await next();
|
|
848
|
+
const base = downstream !== null && typeof downstream === "object" && downstream.kind === "accept" && Array.isArray(downstream.content) ? downstream : result;
|
|
849
|
+
return {
|
|
850
|
+
kind: "accept",
|
|
851
|
+
content: [
|
|
852
|
+
...base.content,
|
|
853
|
+
{
|
|
854
|
+
type: "text",
|
|
855
|
+
text: `[SWITCHMAN:WATERMARK] read budget exceeded this turn (used ${Math.round(entry.used)}/${Math.round(ceiling)} tokens) — further reads may be denied; wrap up or delegate the remaining reading.`,
|
|
856
|
+
},
|
|
857
|
+
],
|
|
858
|
+
};
|
|
859
|
+
})();
|
|
859
860
|
}
|
|
860
861
|
}
|
|
861
862
|
}
|
|
@@ -929,8 +930,15 @@ function applyContextWatch(ctx, config) {
|
|
|
929
930
|
ctx.commands.register({
|
|
930
931
|
name: "ctx-handover",
|
|
931
932
|
description: "Trigger a full context handover for this session now (document + compaction + continuation).",
|
|
933
|
+
// Signal deliberately dropped (inspection W20): a turn-scoped
|
|
934
|
+
// AbortSignal shared by every /compact retry turns a user cancel into
|
|
935
|
+
// six straight failures walking the full ~530 s backoff ladder with a
|
|
936
|
+
// misleading error text — the same reason the ctx_handover TOOL path
|
|
937
|
+
// drops its turn signal. sessionCompact fabricates a fresh un-aborted
|
|
938
|
+
// signal when none arrives; user cancel lets compaction finish, like
|
|
939
|
+
// the tool path.
|
|
932
940
|
handler: (invocation) =>
|
|
933
|
-
runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId,
|
|
941
|
+
runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, undefined, "manual"),
|
|
934
942
|
});
|
|
935
943
|
|
|
936
944
|
// --- E. ctx_handover: the agent-callable front door to the same
|
|
@@ -989,7 +997,6 @@ function applyContextWatch(ctx, config) {
|
|
|
989
997
|
|
|
990
998
|
export {
|
|
991
999
|
applyContextWatch,
|
|
992
|
-
bucketKilo,
|
|
993
1000
|
PAUSED_LINE,
|
|
994
1001
|
PROTOCOL_ROOT_TEXT,
|
|
995
1002
|
PROTOCOL_SUB_TEXT,
|
package/host/routes.js
CHANGED
|
@@ -95,6 +95,24 @@ const NUMBER_FIELDS = [
|
|
|
95
95
|
"wmReadBudgetTokens",
|
|
96
96
|
"wmSubagentForceTokens",
|
|
97
97
|
];
|
|
98
|
+
/** Trim numbers carry their own schema bounds (host/config.js), so the
|
|
99
|
+
* shared NUMBER_FIELDS coercion (which only rejects negatives) is too
|
|
100
|
+
* loose — these validate against each field's declared min/max. This group
|
|
101
|
+
* MUST mirror the client page's TRIM_NUMBER_FIELDS: a field coerced here
|
|
102
|
+
* but never sent by the client fails every fenced write (and vice versa). */
|
|
103
|
+
const TRIM_NUMBER_FIELDS = [
|
|
104
|
+
{ field: "trimGrowthTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 22500 },
|
|
105
|
+
{ field: "trimIncrementTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 50000 },
|
|
106
|
+
{ field: "trimPressurePercent", min: 5, max: 100, def: 60 },
|
|
107
|
+
{ field: "trimEmergencyPercent", min: 5, max: 100, def: 95 },
|
|
108
|
+
{ field: "trimWindowTokens", min: 0, max: Number.POSITIVE_INFINITY, def: 0 },
|
|
109
|
+
{ field: "trimProtectedTail", min: 0, max: 100, def: 6 },
|
|
110
|
+
{ field: "trimMaxFoldsPerHour", min: 1, max: 60, def: 6 },
|
|
111
|
+
{ field: "trimLadderT2Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 8000 },
|
|
112
|
+
{ field: "trimLadderT2MinBlocks", min: 2, max: 200, def: 3 },
|
|
113
|
+
{ field: "trimLadderT3Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 6000 },
|
|
114
|
+
{ field: "trimLadderT3MinBlocks", min: 2, max: 200, def: 2 },
|
|
115
|
+
];
|
|
98
116
|
const BOOLEAN_FIELDS = [
|
|
99
117
|
"wmAutoHandover",
|
|
100
118
|
"wmSubagentCap",
|
|
@@ -110,6 +128,10 @@ const BOOLEAN_FIELDS = [
|
|
|
110
128
|
"syncWhitelist",
|
|
111
129
|
// Intent recognition (mirrors the intent* group in host/config.js).
|
|
112
130
|
"intentEnabled",
|
|
131
|
+
// Context trim switches (mirrors the trim group in host/config.js).
|
|
132
|
+
"trimEnabled",
|
|
133
|
+
"trimLadderEnabled",
|
|
134
|
+
"surfaceFold",
|
|
113
135
|
];
|
|
114
136
|
/** Intent numbers carry their own schema bounds (config.js), so the shared
|
|
115
137
|
* NUMBER_FIELDS coercion (which only rejects negatives) is too loose — these
|
|
@@ -125,7 +147,7 @@ const INTENT_NUMBER_FIELDS = [
|
|
|
125
147
|
/** Intent route references: unlike pool routes they round-trip the "unset"
|
|
126
148
|
* {provider:"", model:""} pair, so they validate against the loosened
|
|
127
149
|
* cleanIntentRoute (both keys must be strings; either may be empty). */
|
|
128
|
-
const INTENT_ROUTE_FIELDS = ["intentClassifierRoute", "intentFallbackRoute"];
|
|
150
|
+
const INTENT_ROUTE_FIELDS = ["intentClassifierRoute", "intentFallbackRoute", "trimSideModel", "trimFoldFallbackModel"];
|
|
129
151
|
/** Intent candidate list (route + optional effort/note per entry). */
|
|
130
152
|
const INTENT_CANDIDATE_FIELDS = ["intentCandidates"];
|
|
131
153
|
|
|
@@ -144,7 +166,10 @@ const ENUM_FIELDS = {
|
|
|
144
166
|
dispatchEnforce: ["off", "advice", "enforce"],
|
|
145
167
|
wmDenyMode: ["cap", "deny"],
|
|
146
168
|
intentClassifierKind: ["chat", "systemone"],
|
|
169
|
+
trimMode: ["off", "advisory", "auto"],
|
|
147
170
|
};
|
|
171
|
+
/** Trim string fields (mirrors host/config.js strings). */
|
|
172
|
+
const TRIM_STRING_FIELDS = ["trimStateDir"];
|
|
148
173
|
|
|
149
174
|
/** JSON response helper. */
|
|
150
175
|
function writeJson(res, status, payload) {
|
|
@@ -312,6 +337,7 @@ function cleanIntentCandidate(entry) {
|
|
|
312
337
|
export function snapshotOf(config) {
|
|
313
338
|
const values = {};
|
|
314
339
|
for (const field of STRING_FIELDS) values[field] = String(rawOf(config?.[field]) ?? "");
|
|
340
|
+
for (const field of TRIM_STRING_FIELDS) values[field] = String(rawOf(config?.[field]) ?? "");
|
|
315
341
|
for (const field of POOL_FIELDS)
|
|
316
342
|
values[field] = Array.isArray(rawOf(config?.[field]))
|
|
317
343
|
? rawOf(config[field]).map(cleanRoute).filter(Boolean)
|
|
@@ -337,6 +363,11 @@ export function snapshotOf(config) {
|
|
|
337
363
|
values.dispatchEnforce = ENUM_FIELDS.dispatchEnforce.includes(rawOf(config?.dispatchEnforce))
|
|
338
364
|
? rawOf(config.dispatchEnforce)
|
|
339
365
|
: "advice";
|
|
366
|
+
values.trimMode = ENUM_FIELDS.trimMode.includes(rawOf(config?.trimMode)) ? rawOf(config.trimMode) : "auto";
|
|
367
|
+
for (const { field, def } of TRIM_NUMBER_FIELDS) {
|
|
368
|
+
const raw = Number(rawOf(config?.[field]));
|
|
369
|
+
values[field] = Number.isFinite(raw) ? raw : def;
|
|
370
|
+
}
|
|
340
371
|
for (const field of NUMBER_FIELDS) {
|
|
341
372
|
const raw = Number(rawOf(config?.[field]));
|
|
342
373
|
values[field] = Number.isFinite(raw) ? raw : 0;
|
|
@@ -407,6 +438,17 @@ export function coerceValues(input) {
|
|
|
407
438
|
values.langScope = input.langScope;
|
|
408
439
|
if (!ENUM_FIELDS.dispatchEnforce.includes(input.dispatchEnforce)) return null;
|
|
409
440
|
values.dispatchEnforce = input.dispatchEnforce;
|
|
441
|
+
if (!ENUM_FIELDS.trimMode.includes(input.trimMode)) return null;
|
|
442
|
+
values.trimMode = input.trimMode;
|
|
443
|
+
for (const field of TRIM_STRING_FIELDS) {
|
|
444
|
+
if (typeof input[field] !== "string") return null;
|
|
445
|
+
values[field] = input[field];
|
|
446
|
+
}
|
|
447
|
+
for (const { field, min, max } of TRIM_NUMBER_FIELDS) {
|
|
448
|
+
const raw = Number(input[field]);
|
|
449
|
+
if (!Number.isFinite(raw) || raw < min || raw > max) return null;
|
|
450
|
+
values[field] = raw;
|
|
451
|
+
}
|
|
410
452
|
for (const field of NUMBER_FIELDS) {
|
|
411
453
|
const raw = Number(input[field]);
|
|
412
454
|
if (!Number.isFinite(raw) || raw < 0) return null;
|
|
@@ -421,9 +463,27 @@ export function coerceValues(input) {
|
|
|
421
463
|
return values;
|
|
422
464
|
}
|
|
423
465
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
426
|
-
|
|
466
|
+
/** Stable JSON text: object keys sorted at every depth. snapshotOf and
|
|
467
|
+
* coerceValues build the same fields in DIFFERENT insertion orders
|
|
468
|
+
* (e.g. trimStateDir sits 7th in a snapshot but near the tail after
|
|
469
|
+
* coercion), so a raw stringify compared key order, not content —
|
|
470
|
+
* every fenced save 409'd ("settings changed elsewhere") even with no
|
|
471
|
+
* intervening writer (2026-11-07 settings-save bug). */
|
|
472
|
+
function canonicalJson(value) {
|
|
473
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
474
|
+
if (value !== null && typeof value === "object")
|
|
475
|
+
return `{${Object.keys(value)
|
|
476
|
+
.sort()
|
|
477
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
478
|
+
.join(",")}}`;
|
|
479
|
+
return JSON.stringify(value) ?? "undefined";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Deep-equal on the plain-JSON value shapes this module exchanges;
|
|
483
|
+
* key-order insensitive (see canonicalJson). Exported for the smoke
|
|
484
|
+
* suite alongside coerceValues — the fence predicate IS the contract. */
|
|
485
|
+
export function sameValues(a, b) {
|
|
486
|
+
return canonicalJson(a) === canonicalJson(b);
|
|
427
487
|
}
|
|
428
488
|
|
|
429
489
|
/** Enumerate the model catalog from the host session controller. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-switchman",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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": [
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"icon": "./icon.svg",
|
|
29
29
|
"engines": {
|
|
30
30
|
"node": ">=24",
|
|
31
|
-
"dsh": ">=0.2.
|
|
31
|
+
"dsh": ">=0.2.2-rc.1"
|
|
32
32
|
},
|
|
33
33
|
"exports": {
|
|
34
34
|
".": "./index.js",
|