dsh-switchman 1.7.0 → 1.8.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 +25 -4
- package/README.es.md +25 -4
- package/README.fr.md +25 -4
- package/README.it.md +25 -4
- package/README.ja.md +25 -4
- package/README.ko.md +25 -4
- package/README.md +26 -4
- package/README.pt.md +25 -4
- package/README.ru.md +25 -4
- package/README.zh-TW.md +25 -4
- package/README.zh.md +26 -4
- package/client.js +1916 -35
- package/host/config.js +93 -4
- package/host/context-watch.js +126 -58
- package/host/intent-presets.js +163 -0
- package/host/intent.js +952 -0
- package/host/routes.js +158 -3
- package/host/vision.js +62 -26
- package/index.js +18 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +3 -3
package/host/config.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - pool/rank dispatch pools and capability ranking (Phase 2)
|
|
13
13
|
* - wm* context watermark control (Phase 3)
|
|
14
14
|
* - teams* Agent Teams doctrine mode + whitelist sync (teams mode)
|
|
15
|
+
* - intent* intent-routing "auto" pseudo-model (classifier + candidates)
|
|
15
16
|
*
|
|
16
17
|
* Ported from opencode-switchman's opencode-switchman.jsonc surface
|
|
17
18
|
* (oc/src/config.ts:23-51, oc/src/types.ts:281-344) onto DSH settings.
|
|
@@ -44,6 +45,34 @@ export const EffortEntrySchema = z.object({
|
|
|
44
45
|
effort: z.string().min(1).required(),
|
|
45
46
|
});
|
|
46
47
|
|
|
48
|
+
/** One intent-routing candidate: a real model route the classifier may pick,
|
|
49
|
+
* plus an optional reasoning-effort pin and a capability note the classifier
|
|
50
|
+
* reads. Every row carries a stable id; preset rows (id owned by
|
|
51
|
+
* host/intent-presets.js) keep a code-owned immutable note, while enabled /
|
|
52
|
+
* provider / model / effort are user-configured — provider/model may be
|
|
53
|
+
* empty while a preset waits for its route. effort/note follow
|
|
54
|
+
* RankEntrySchema.tier's optional style (no `.required()`: schemastery
|
|
55
|
+
* passes undefined through, so entries may omit either key). */
|
|
56
|
+
export const IntentCandidateSchema = z.object({
|
|
57
|
+
id: z.string(),
|
|
58
|
+
preset: z.boolean(),
|
|
59
|
+
enabled: z.boolean(),
|
|
60
|
+
provider: z.string(),
|
|
61
|
+
model: z.string(),
|
|
62
|
+
effort: z.string(),
|
|
63
|
+
note: z.string(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** One intent-layer route reference (classifier or fallback). Unlike
|
|
67
|
+
* ModelRouteSchema this accepts the "unset" `{provider:"", model:""}`
|
|
68
|
+
* pair the settings page round-trips — ModelRouteSchema's `.min(1)`
|
|
69
|
+
* would reject that exact declared default on every settings write.
|
|
70
|
+
* Consumers validate non-empty before trusting the route. */
|
|
71
|
+
const IntentRouteSchema = z.object({
|
|
72
|
+
provider: z.string(),
|
|
73
|
+
model: z.string(),
|
|
74
|
+
});
|
|
75
|
+
|
|
47
76
|
/** All dsh-switchman settings. Field names are the settings mutation paths. */
|
|
48
77
|
export const Config = z.object({
|
|
49
78
|
// --- Phase 0: UI locale ------------------------------------------------
|
|
@@ -99,13 +128,13 @@ export const Config = z.object({
|
|
|
99
128
|
|
|
100
129
|
// --- Phase 3: context watermark control --------------------------------
|
|
101
130
|
/** Soft watermark: advisory banner + delegation nudge (tokens). */
|
|
102
|
-
wmSoftTokens: z.number().min(1000).default(
|
|
131
|
+
wmSoftTokens: z.number().min(1000).default(80000).volatile(),
|
|
103
132
|
/** Hard watermark: read budget tightens, wrap-up advised (tokens). */
|
|
104
|
-
wmHardTokens: z.number().min(2000).default(
|
|
133
|
+
wmHardTokens: z.number().min(2000).default(120000).volatile(),
|
|
105
134
|
/** Force watermark: backup + compaction handover (tokens). */
|
|
106
|
-
wmForceTokens: z.number().min(3000).default(
|
|
135
|
+
wmForceTokens: z.number().min(3000).default(150000).volatile(),
|
|
107
136
|
/** Per-call read budget for read/glob/grep (tokens). */
|
|
108
|
-
wmReadBudgetTokens: z.number().min(200).default(
|
|
137
|
+
wmReadBudgetTokens: z.number().min(200).default(3000).volatile(),
|
|
109
138
|
/** Hard-tier behavior: cap reads and advise, or deny outright. */
|
|
110
139
|
wmDenyMode: z.union(["cap", "deny"]).default("cap").volatile(),
|
|
111
140
|
/** Force tier triggers backup + compaction automatically. */
|
|
@@ -132,6 +161,66 @@ export const Config = z.object({
|
|
|
132
161
|
* wholesale. The whitelist only applies to top-level sessions composed
|
|
133
162
|
* afterwards. */
|
|
134
163
|
syncWhitelist: z.boolean().default(false).volatile(),
|
|
164
|
+
|
|
165
|
+
// --- Intent recognition: classifier-routed "auto" model ----------------
|
|
166
|
+
/** Master switch for intent routing (design: .dsh-switchman/plans/
|
|
167
|
+
* intent-routing.md §2). The adapter stays mounted either way; OFF
|
|
168
|
+
* only hides the catalog entry (listModels returns [], the selector
|
|
169
|
+
* drops the empty group) — a session already on Auto routes through
|
|
170
|
+
* the documented fallback chain (routedCurrent → intentFallbackRoute
|
|
171
|
+
* → first candidate) with NO classifier call. */
|
|
172
|
+
intentEnabled: z.boolean().default(false).volatile(),
|
|
173
|
+
/** Small-model route that classifies the conversation against the
|
|
174
|
+
* candidates. Empty = unset; classification is skipped (fail-open). */
|
|
175
|
+
intentClassifierRoute: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
|
|
176
|
+
/** Optional reasoning-effort pin for the classifier call ("" = adapter
|
|
177
|
+
* default). */
|
|
178
|
+
intentClassifierEffort: z.string().default("").volatile(),
|
|
179
|
+
/** Classifier channel; must be an explicit choice. "chat" calls the
|
|
180
|
+
* intentClassifierRoute above through its provider's standard chat
|
|
181
|
+
* API with a strict-JSON instruction. "systemone" = direct HTTP to a
|
|
182
|
+
* hand-configured ollama /v1/systemone decision endpoint — no
|
|
183
|
+
* provider, uses intentClassifierEndpoint +
|
|
184
|
+
* intentClassifierSystemoneModel. */
|
|
185
|
+
intentClassifierKind: z.union(["chat", "systemone"]).default("chat").volatile(),
|
|
186
|
+
/** Full URL of the ollama /v1/systemone endpoint for kind "systemone"
|
|
187
|
+
* (e.g. http://127.0.0.1:11434/v1/systemone). Empty = unset; that
|
|
188
|
+
* kind fails open to the fallback chain. */
|
|
189
|
+
intentClassifierEndpoint: z.string().default("").volatile(),
|
|
190
|
+
/** Model name sent to the decision endpoint for kind "systemone"
|
|
191
|
+
* (free text, e.g. tev1:4b-mxfp8). Independent of the llm provider
|
|
192
|
+
* catalog — no provider registration needed. Empty = unset; that
|
|
193
|
+
* kind fails open to the fallback chain. */
|
|
194
|
+
intentClassifierSystemoneModel: z.string().default("").volatile(),
|
|
195
|
+
/** Independent candidate list the "auto" pseudo-model may route to (NOT
|
|
196
|
+
* poolMain). Each entry: {id, preset?, enabled?, provider, model,
|
|
197
|
+
* effort?, note?}; readers merge the built-in presets from
|
|
198
|
+
* host/intent-presets.js (see mergeIntentCandidates). */
|
|
199
|
+
intentCandidates: z.array(IntentCandidateSchema).default([]).volatile(),
|
|
200
|
+
/** Provider prompt-cache reuse window: while the current route stays
|
|
201
|
+
* below the switch-confidence threshold, keep it for this many
|
|
202
|
+
* seconds since the last applied routing. */
|
|
203
|
+
intentCacheTtlSeconds: z.number().min(0).default(300).volatile(),
|
|
204
|
+
/** Classifier confidence (0-100) required to leave the current model. */
|
|
205
|
+
intentSwitchConfidence: z.number().min(0).max(100).default(80).volatile(),
|
|
206
|
+
/** Classification-call budget in ms; timeout/abort fails open to the
|
|
207
|
+
* fallback chain. */
|
|
208
|
+
intentTimeoutMs: z.number().min(200).default(60000).volatile(),
|
|
209
|
+
/** Recent user/assistant messages fed to the classifier. */
|
|
210
|
+
intentHistoryMessages: z.number().min(1).max(20).default(6).volatile(),
|
|
211
|
+
/** Total character budget for that conversation excerpt. */
|
|
212
|
+
intentHistoryMaxChars: z.number().min(200).max(20000).default(2400).volatile(),
|
|
213
|
+
/** Context window (tokens) declared by the "auto" pseudo-model. Purely
|
|
214
|
+
* advisory metadata: drives the usage popover and the auto-compaction
|
|
215
|
+
* pressure math for sessions routed through Auto. While wmAutoHandover
|
|
216
|
+
* is on, the effective declared value is floored at
|
|
217
|
+
* ceil(wmForceTokens / 0.8) so the smooth handover always fires before
|
|
218
|
+
* the 0.8×-window auto-compaction (see effectiveContextWindow in
|
|
219
|
+
* host/intent.js). */
|
|
220
|
+
intentContextWindow: z.number().min(1024).default(200000).volatile(),
|
|
221
|
+
/** Optional last-resort route when the classifier fails and no previous
|
|
222
|
+
* routed model exists; empty = the first candidate. */
|
|
223
|
+
intentFallbackRoute: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
|
|
135
224
|
});
|
|
136
225
|
|
|
137
226
|
/** The six dispatch lanes, in display order. */
|
package/host/context-watch.js
CHANGED
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
* src/index.ts:386-459 (banner rendering) and 2066-2110 (auto-handover with
|
|
5
5
|
* its anti-deadlock lesson) onto DSH services:
|
|
6
6
|
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
11
|
-
*
|
|
7
|
+
* - KV-cache invariant: the systemPrompt section 'switchman:watermark'
|
|
8
|
+
* (order 10500) carries only byte-stable protocol text (root/subagent
|
|
9
|
+
* variants, no live numbers), so the request head never changes
|
|
10
|
+
* mid-session; the live tier + bucketed used reading travel as the
|
|
11
|
+
* 'switchman:watermark' runtime context (ctx.systemPrompt.context,
|
|
12
|
+
* order 300, required) — DSH renders contexts into the tail-appended
|
|
13
|
+
* "Current runtime context" snapshot, which is emitted as a new trailing
|
|
14
|
+
* user message only when its text changes, preserving the cached prefix
|
|
15
|
+
* by construction. Root sessions get tiers ok (silent)/soft/hard/force
|
|
16
|
+
* plus a static paused line; subagent sessions get soft/hard/force with
|
|
17
|
+
* their own force cap. Both text functions are side-effect free.
|
|
12
18
|
* - Read budget double gate: `tools/post-execute` accounts read/glob/grep/
|
|
13
19
|
* bash result text (~len/3.5 tokens) per session per turn; `tools/
|
|
14
20
|
* pre-execute` denies or caps read/glob/grep once the budget is exhausted
|
|
@@ -133,9 +139,9 @@ function isRoot(ctx, agent) {
|
|
|
133
139
|
/** Live threshold snapshot from the volatile settings. */
|
|
134
140
|
function readThresholds(config) {
|
|
135
141
|
return {
|
|
136
|
-
soft: readNumber(config.wmSoftTokens,
|
|
137
|
-
hard: readNumber(config.wmHardTokens,
|
|
138
|
-
force: readNumber(config.wmForceTokens,
|
|
142
|
+
soft: readNumber(config.wmSoftTokens, 80000),
|
|
143
|
+
hard: readNumber(config.wmHardTokens, 120000),
|
|
144
|
+
force: readNumber(config.wmForceTokens, 150000),
|
|
139
145
|
};
|
|
140
146
|
}
|
|
141
147
|
|
|
@@ -150,64 +156,89 @@ function measureNow(ctx, agent) {
|
|
|
150
156
|
}
|
|
151
157
|
}
|
|
152
158
|
|
|
153
|
-
// ----
|
|
159
|
+
// ---- watermark rendering (pure given state + measurement) ------------------
|
|
154
160
|
|
|
155
|
-
/**
|
|
161
|
+
/** Bucket width for the live used reading. 5k steps keep the tail snapshot
|
|
162
|
+
* emission rate low (the snapshot re-emits only when its joined text
|
|
163
|
+
* changes); tier crossings change the line immediately regardless. */
|
|
164
|
+
const USED_BUCKET_TOKENS = 5000;
|
|
165
|
+
|
|
166
|
+
/** `~50k`-style bucketed reading for the live watermark line. */
|
|
167
|
+
function bucketKilo(tokens) {
|
|
168
|
+
return `${Math.round(tokens / USED_BUCKET_TOKENS) * (USED_BUCKET_TOKENS / 1000)}k`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Byte-stable protocol text for root sessions: no live numbers, so the
|
|
172
|
+
* request head stays byte-identical for the whole session (KV-cache
|
|
173
|
+
* invariant); the numbers live in the tail context line only. */
|
|
174
|
+
const PROTOCOL_ROOT_TEXT = [
|
|
175
|
+
"[SWITCHMAN:WATERMARK] protocol: this session is guarded by a context watermark measured every step.",
|
|
176
|
+
"ok tier is silent. soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself. hard tier: stop broad reading (read/glob/grep are budget-limited and may be denied) and start wrapping up. force tier: MANDATORY wrap-up now — produce the final answer from what you already hold; an automatic context handover (backup + document + compaction + continuation) may run and will tell you to end the turn.",
|
|
177
|
+
"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.",
|
|
178
|
+
].join("\n");
|
|
179
|
+
|
|
180
|
+
/** Byte-stable protocol text for subagent sessions (same invariant). */
|
|
181
|
+
const PROTOCOL_SUB_TEXT = [
|
|
182
|
+
"[SWITCHMAN:WATERMARK] protocol: this subagent session is guarded by a context watermark measured every step.",
|
|
183
|
+
"ok tier is silent. soft tier: keep reads narrow and drop bulky raw output. hard tier: stop broad reading; wrap toward the final summary. force tier: stop reading and return the final summary now — end the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.",
|
|
184
|
+
"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.",
|
|
185
|
+
].join("\n");
|
|
186
|
+
|
|
187
|
+
/** Static paused line (numbers omitted on purpose: enforcement is off, and a
|
|
188
|
+
* frozen string emits once and never churns the snapshot again). */
|
|
189
|
+
const PAUSED_LINE =
|
|
190
|
+
"[SWITCHMAN:WATERMARK] paused — measurement continues, but every watermark enforcement stays off until /ctx-resume.";
|
|
191
|
+
|
|
192
|
+
/** Root-session live line: "" at ok (token economy), tier advice lines plus
|
|
156
193
|
* the live handover progress (phase/attempt) and auto-cooldown state. */
|
|
157
|
-
function
|
|
194
|
+
function renderRootWatermark(level, used, th, status) {
|
|
195
|
+
if (level === "ok") return "";
|
|
158
196
|
const head =
|
|
159
|
-
`[SWITCHMAN:WATERMARK]
|
|
160
|
-
` budget
|
|
161
|
-
if (level === "
|
|
162
|
-
if (level === "
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
lines.push(`
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
|
|
180
|
-
: `an automatic context handover (backup + document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
|
|
181
|
-
);
|
|
182
|
-
} else {
|
|
183
|
-
lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
|
|
184
|
-
}
|
|
197
|
+
`[SWITCHMAN:WATERMARK] ${level} tier — used ~${bucketKilo(used)}` +
|
|
198
|
+
` of budget ${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
|
|
199
|
+
if (level === "soft") return `${head}\nsoft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.`;
|
|
200
|
+
if (level === "hard") return `${head}\nhard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`;
|
|
201
|
+
const lines = [head, "force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
|
|
202
|
+
const phase = status?.phase;
|
|
203
|
+
if (phase === "backup") {
|
|
204
|
+
lines.push(`context handover running: forking the backup session and writing the handover document…`);
|
|
205
|
+
} else if (phase === "compacting") {
|
|
206
|
+
lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
|
|
207
|
+
} else if (phase === "continuation") {
|
|
208
|
+
lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
|
|
209
|
+
} else if (status?.autoHandover) {
|
|
210
|
+
lines.push(
|
|
211
|
+
status?.cooldownMin > 0
|
|
212
|
+
? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
|
|
213
|
+
: `an automatic context handover (backup + document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
|
|
185
217
|
}
|
|
186
218
|
return lines.join("\n");
|
|
187
219
|
}
|
|
188
220
|
|
|
189
|
-
/** Subagent-session
|
|
190
|
-
* as the root
|
|
221
|
+
/** Subagent-session live line: the same soft/hard/force absolute-token tiers
|
|
222
|
+
* as the root line ("" while ok — token economy), with subagent-flavored
|
|
191
223
|
* advice and the HANDOFF block due at the force tier. Pausing never applies
|
|
192
224
|
* to subagents (oc index.ts:187-190). */
|
|
193
|
-
function
|
|
225
|
+
function renderSubWatermark(used, th) {
|
|
194
226
|
const level = levelOf(used, th);
|
|
195
|
-
const head =
|
|
196
|
-
`[SWITCHMAN:WATERMARK] subagent level=${level} used=${kilo(used)}` +
|
|
197
|
-
` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
|
|
198
227
|
if (level === "ok") return "";
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
228
|
+
const head =
|
|
229
|
+
`[SWITCHMAN:WATERMARK] subagent ${level} tier — used ~${bucketKilo(used)}` +
|
|
230
|
+
` of budget ${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
|
|
231
|
+
if (level === "soft") return `${head}\nsoft tier: keep reads narrow and drop bulky raw output.`;
|
|
232
|
+
if (level === "hard") return `${head}\nhard tier: stop broad reading; start wrapping toward the final summary.`;
|
|
203
233
|
return (
|
|
204
234
|
`${head}\nforce tier: stop reading and return the final summary now.\n` +
|
|
205
235
|
`End the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.`
|
|
206
236
|
);
|
|
207
237
|
}
|
|
208
238
|
|
|
209
|
-
/** Whole
|
|
210
|
-
|
|
239
|
+
/** Whole live line for one assembly (the tail context); "" whenever anything
|
|
240
|
+
* needed is missing or the session sits at the silent ok tier. */
|
|
241
|
+
function renderWatermarkContext(ctx, config, state, agent) {
|
|
211
242
|
try {
|
|
212
243
|
const sessionId = sessionIdOf(agent);
|
|
213
244
|
if (sessionId === null || agent.session === undefined) return "";
|
|
@@ -220,16 +251,18 @@ function renderBanner(ctx, config, state, agent) {
|
|
|
220
251
|
// (enabled by the wmSubagentCap toggle, 0/0 = follow the root set).
|
|
221
252
|
const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
|
|
222
253
|
const subTh = subForce > 0 ? { ...th, force: subForce } : th;
|
|
223
|
-
return
|
|
254
|
+
return renderSubWatermark(used, subTh);
|
|
224
255
|
}
|
|
225
|
-
|
|
256
|
+
if (state.paused.has(sessionId)) return PAUSED_LINE;
|
|
226
257
|
const live = handoverOf(sessionId);
|
|
227
258
|
const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
|
|
228
|
-
|
|
259
|
+
// Cooldown quantized to 5-minute steps so the emitted line stays
|
|
260
|
+
// byte-stable between steps (snapshot re-emits only on change).
|
|
261
|
+
return renderRootWatermark(levelOf(used, th), used, th, {
|
|
229
262
|
autoHandover: readBool(config.wmAutoHandover, true),
|
|
230
263
|
phase: live?.phase,
|
|
231
264
|
attempt: live?.attempt,
|
|
232
|
-
cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) /
|
|
265
|
+
cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) / 300_000) * 5),
|
|
233
266
|
tool: state.handoverTool ?? undefined,
|
|
234
267
|
});
|
|
235
268
|
} catch {
|
|
@@ -298,13 +331,40 @@ function applyContextWatch(ctx, config) {
|
|
|
298
331
|
};
|
|
299
332
|
const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
|
|
300
333
|
|
|
301
|
-
// --- A. watermark
|
|
334
|
+
// --- A. watermark: byte-stable head protocol + live tail context --------
|
|
335
|
+
// KV-cache invariant (see the file header): everything before the
|
|
336
|
+
// conversation history must stay byte-identical for the whole session,
|
|
337
|
+
// so the head section carries only static protocol text while ALL live
|
|
338
|
+
// numbers travel as runtime context. DSH joins contexts into the
|
|
339
|
+
// "Current runtime context" snapshot and appends it at the tail as a new
|
|
340
|
+
// user message only when its text changes — append-only by construction,
|
|
341
|
+
// the cached prefix is never touched.
|
|
302
342
|
ctx.systemPrompt.section({
|
|
303
343
|
name: "switchman:watermark",
|
|
304
344
|
order: 10500,
|
|
305
345
|
interpolate: false,
|
|
306
|
-
text: (context) =>
|
|
346
|
+
text: (context) => (isRoot(ctx, context?.agent) ? PROTOCOL_ROOT_TEXT : PROTOCOL_SUB_TEXT),
|
|
307
347
|
});
|
|
348
|
+
// Capability check first: on a host without the runtime-context API an
|
|
349
|
+
// optional chain would short-circuit silently — the live watermark line
|
|
350
|
+
// would vanish with no log at all. Warn once so it stays diagnosable.
|
|
351
|
+
if (typeof ctx.systemPrompt?.context !== "function") {
|
|
352
|
+
warn("systemPrompt.context API unavailable on this DSH host — live watermark line disabled (static head protocol remains)");
|
|
353
|
+
} else {
|
|
354
|
+
try {
|
|
355
|
+
ctx.systemPrompt.context({
|
|
356
|
+
name: "switchman:watermark",
|
|
357
|
+
// After the core placements (WORKING_DIRECTORY 100 … WEB_SURFACE
|
|
358
|
+
// 210), so the watermark line closes the snapshot.
|
|
359
|
+
order: 300,
|
|
360
|
+
required: true,
|
|
361
|
+
interpolate: false,
|
|
362
|
+
text: (context) => renderWatermarkContext(ctx, config, state, context?.agent),
|
|
363
|
+
});
|
|
364
|
+
} catch (error) {
|
|
365
|
+
warn(`watermark context registration failed: ${error?.message ?? error}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
308
368
|
|
|
309
369
|
// --- C. handover execution: borrow the session's own /compact -----------
|
|
310
370
|
// The desktop profile provides compaction ONLY inside each preset's
|
|
@@ -805,7 +865,7 @@ function applyContextWatch(ctx, config) {
|
|
|
805
865
|
if (sessionId !== null && ACCOUNT_TOOLS.has(exec?.name ?? "")) {
|
|
806
866
|
const entry = accountRead(state, sessionId, resultTokens(result));
|
|
807
867
|
if (GATED_TOOLS.has(exec.name) && !state.paused.has(sessionId)) {
|
|
808
|
-
const budget = readNumber(config.wmReadBudgetTokens,
|
|
868
|
+
const budget = readNumber(config.wmReadBudgetTokens, 3000);
|
|
809
869
|
const ceiling = budget * CEILING_MULTIPLE;
|
|
810
870
|
const used = measureNow(ctx, exec.agent);
|
|
811
871
|
const th = readThresholds(config);
|
|
@@ -841,7 +901,7 @@ function applyContextWatch(ctx, config) {
|
|
|
841
901
|
if (!GATED_TOOLS.has(exec?.name ?? "")) return next();
|
|
842
902
|
const sessionId = sessionIdOf(exec?.agent);
|
|
843
903
|
if (sessionId === null || state.paused.has(sessionId)) return next();
|
|
844
|
-
const budget = readNumber(config.wmReadBudgetTokens,
|
|
904
|
+
const budget = readNumber(config.wmReadBudgetTokens, 3000);
|
|
845
905
|
if (turnUsed(state, sessionId) < budget * CEILING_MULTIPLE) return next();
|
|
846
906
|
const used = measureNow(ctx, exec.agent);
|
|
847
907
|
if (used === null) return next();
|
|
@@ -957,4 +1017,12 @@ function applyContextWatch(ctx, config) {
|
|
|
957
1017
|
}
|
|
958
1018
|
}
|
|
959
1019
|
|
|
960
|
-
export {
|
|
1020
|
+
export {
|
|
1021
|
+
applyContextWatch,
|
|
1022
|
+
bucketKilo,
|
|
1023
|
+
PAUSED_LINE,
|
|
1024
|
+
PROTOCOL_ROOT_TEXT,
|
|
1025
|
+
PROTOCOL_SUB_TEXT,
|
|
1026
|
+
renderRootWatermark,
|
|
1027
|
+
renderSubWatermark,
|
|
1028
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** Preset intent categories for the "auto" pseudo-model's candidate list.
|
|
2
|
+
*
|
|
3
|
+
* A preset row ALWAYS exists in the merged candidate list: its note (the
|
|
4
|
+
* text the classifier reads) is code-owned and immutable by mandate —
|
|
5
|
+
* upgrades ship new wording without any settings migration — while the
|
|
6
|
+
* route fields (provider/model/effort) and the enabled toggle stay
|
|
7
|
+
* user-configured. Presets ship enabled with an empty model, so filling
|
|
8
|
+
* the model is the single step that activates one.
|
|
9
|
+
*
|
|
10
|
+
* Descriptions are deliberately written FOR the classifier, not for
|
|
11
|
+
* humans: each one states which user messages belong to the lane and
|
|
12
|
+
* carries a contrastive anchor against its neighbours, because a small
|
|
13
|
+
* local model needs explicit discrimination cues far more than prose
|
|
14
|
+
* polish does. English by mandate. */
|
|
15
|
+
|
|
16
|
+
export const INTENT_PRESETS = [
|
|
17
|
+
{
|
|
18
|
+
id: "quick-chat",
|
|
19
|
+
label: "Casual chat & quick Q&A",
|
|
20
|
+
note: "Casual conversation, greetings, quick factual or meta questions about the session itself, and follow-up chatter. No code changes and no deep reasoning required; speed and cost matter most.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "mechanical-edit",
|
|
24
|
+
label: "Mechanical edits & chores",
|
|
25
|
+
note: "Small, fully specified changes: renames, typo and formatting fixes, config tweaks, license headers, running a known command, repetitive boilerplate edits. The plan is already given; execution is trivial.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "codebase-exploration",
|
|
29
|
+
label: "Read-only codebase exploration",
|
|
30
|
+
note: "Read-only investigation of the repository: locating code, tracing call paths, grepping symbols, summarizing how a subsystem works, or answering where/how something is implemented. Output is an explanation, not a diff.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "general-coding",
|
|
34
|
+
label: "Everyday implementation",
|
|
35
|
+
note: "Regular feature work and bug fixing: implementing a clear requirement across a few files, ordinary debugging, writing or updating tests and small scripts. The default lane for hands-on code changes.",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "deep-reasoning",
|
|
39
|
+
label: "Architecture & hard problems",
|
|
40
|
+
note: "Decisions and problems that need careful, long thinking: architecture and design choices, multi-step orchestration, tricky cross-component bugs, algorithmic or concurrency issues, security-sensitive changes, anything with high rework cost if answered shallowly.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "code-review",
|
|
44
|
+
label: "Review & verification",
|
|
45
|
+
note: "Reading and judging existing changes or output: diff and PR review, adversarial verification of another agent's work, spotting regressions or spec violations, explaining whether and why a change is correct.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "docs-writing",
|
|
49
|
+
label: "Docs, translation & wording",
|
|
50
|
+
note: "Producing or polishing prose: documentation, READMEs, changelogs, commit messages, translation between languages, tone and clarity improvements. Language quality matters more than reasoning depth.",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "vision-input",
|
|
54
|
+
label: "Image understanding",
|
|
55
|
+
note: "Turns whose input includes screenshots, photos, diagrams, charts, or UI mockups that must be visually interpreted to proceed.",
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const PRESET_BY_ID = new Map(INTENT_PRESETS.map((preset) => [preset.id, preset]));
|
|
60
|
+
|
|
61
|
+
function stringOf(value) {
|
|
62
|
+
return typeof value === "string" ? value.trim() : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Deterministic id for a stored entry that predates ids (legacy rows keep
|
|
66
|
+
* a stable identity across restarts even before the next settings save
|
|
67
|
+
* persists one). Route-key based, with a numeric suffix on collisions. */
|
|
68
|
+
function derivedId(provider, model, used) {
|
|
69
|
+
const base = `user:${provider}/${model}`;
|
|
70
|
+
if (!used.has(base)) return base;
|
|
71
|
+
let n = 2;
|
|
72
|
+
while (used.has(`${base}#${n}`)) n += 1;
|
|
73
|
+
return `${base}#${n}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Merge the stored candidate entries with the built-in presets into the
|
|
77
|
+
* unified list the settings UI renders and the router filters.
|
|
78
|
+
*
|
|
79
|
+
* Rules:
|
|
80
|
+
* - presets come first, in INTENT_PRESETS order, always all present;
|
|
81
|
+
* - a stored entry whose id names a preset overrides that preset's
|
|
82
|
+
* route/effort/enabled, but its note is ALWAYS the code value;
|
|
83
|
+
* - stored entries with unknown ids survive as user rows (graceful
|
|
84
|
+
* downgrade when a future preset id is removed again);
|
|
85
|
+
* - legacy entries without ids get deterministic derived ids;
|
|
86
|
+
* - fully-empty legacy junk (no id, no route, no note) is dropped, while
|
|
87
|
+
* any entry carrying an id survives as-is — a half-edited new row must
|
|
88
|
+
* not vanish from under the user between save and snapshot. */
|
|
89
|
+
export function mergeIntentCandidates(stored) {
|
|
90
|
+
const list = Array.isArray(stored) ? stored : [];
|
|
91
|
+
const presetOverrides = new Map();
|
|
92
|
+
const userRows = [];
|
|
93
|
+
// Pass 1 claims every explicit id BEFORE any derived id is minted, so a
|
|
94
|
+
// later explicit row can never collide with an earlier legacy row's
|
|
95
|
+
// derived `user:<provider>/<model>` name.
|
|
96
|
+
const usedIds = new Set();
|
|
97
|
+
for (const entry of list) {
|
|
98
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
99
|
+
const id = stringOf(entry.id);
|
|
100
|
+
if (id !== "") usedIds.add(id);
|
|
101
|
+
}
|
|
102
|
+
for (const entry of list) {
|
|
103
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
104
|
+
const id = stringOf(entry.id);
|
|
105
|
+
const provider = stringOf(entry.provider);
|
|
106
|
+
const model = stringOf(entry.model);
|
|
107
|
+
const effort = stringOf(entry.effort);
|
|
108
|
+
const note = stringOf(entry.note);
|
|
109
|
+
const enabled = entry.enabled !== false;
|
|
110
|
+
const preset = id === "" ? null : PRESET_BY_ID.get(id);
|
|
111
|
+
if (preset !== null && preset !== undefined) {
|
|
112
|
+
presetOverrides.set(preset.id, { provider, model, effort, enabled });
|
|
113
|
+
usedIds.add(preset.id);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (id === "" && provider === "" && model === "" && note === "" && effort === "") continue;
|
|
117
|
+
const row = {
|
|
118
|
+
id: id !== "" ? id : derivedId(provider, model, usedIds),
|
|
119
|
+
preset: false,
|
|
120
|
+
enabled,
|
|
121
|
+
provider,
|
|
122
|
+
model,
|
|
123
|
+
};
|
|
124
|
+
if (effort !== "") row.effort = effort;
|
|
125
|
+
if (note !== "") row.note = note;
|
|
126
|
+
usedIds.add(row.id);
|
|
127
|
+
userRows.push(row);
|
|
128
|
+
}
|
|
129
|
+
const rows = [];
|
|
130
|
+
for (const preset of INTENT_PRESETS) {
|
|
131
|
+
const override = presetOverrides.get(preset.id);
|
|
132
|
+
const row = {
|
|
133
|
+
id: preset.id,
|
|
134
|
+
preset: true,
|
|
135
|
+
enabled: override ? override.enabled : true,
|
|
136
|
+
provider: override ? override.provider : "",
|
|
137
|
+
model: override ? override.model : "",
|
|
138
|
+
note: preset.note,
|
|
139
|
+
label: preset.label,
|
|
140
|
+
};
|
|
141
|
+
if (override && override.effort !== "") row.effort = override.effort;
|
|
142
|
+
rows.push(row);
|
|
143
|
+
}
|
|
144
|
+
for (const row of userRows) rows.push(row);
|
|
145
|
+
return rows;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Only rows that actually participate in routing: enabled AND a complete
|
|
149
|
+
* route. A preset still waiting for its model (or a freshly added custom
|
|
150
|
+
* row) stays visible in the UI but outside the classifier's list. */
|
|
151
|
+
export function effectiveIntentCandidates(rows) {
|
|
152
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
153
|
+
return list.filter(
|
|
154
|
+
(entry) =>
|
|
155
|
+
entry !== null &&
|
|
156
|
+
typeof entry === "object" &&
|
|
157
|
+
entry.enabled !== false &&
|
|
158
|
+
typeof entry.provider === "string" &&
|
|
159
|
+
entry.provider.trim() !== "" &&
|
|
160
|
+
typeof entry.model === "string" &&
|
|
161
|
+
entry.model.trim() !== "",
|
|
162
|
+
);
|
|
163
|
+
}
|