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/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(50000).volatile(),
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(90000).volatile(),
133
+ wmHardTokens: z.number().min(2000).default(120000).volatile(),
105
134
  /** Force watermark: backup + compaction handover (tokens). */
106
- wmForceTokens: z.number().min(3000).default(130000).volatile(),
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(1500).volatile(),
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. */
@@ -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
- * - One dynamic systemPrompt section 'switchman:watermark' (order 10500)
8
- * renders the `[SWITCHMAN:WATERMARK]` banner from the live token meter
9
- * (ctx.tokenMeter.measure(agent.session).totalTokens). Root sessions get
10
- * the tiered banner (ok/soft/hard/force/paused); subagent sessions get a
11
- * compact cap-aware banner. The text function is side-effect free.
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, 50000),
137
- hard: readNumber(config.wmHardTokens, 90000),
138
- force: readNumber(config.wmForceTokens, 130000),
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
- // ---- banner rendering (pure given state + measurement) ---------------------
159
+ // ---- watermark rendering (pure given state + measurement) ------------------
154
160
 
155
- /** Root-session banner: byte-stable anchor line + tier advice lines plus
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 renderRootBanner(level, used, th, status) {
194
+ function renderRootWatermark(level, used, th, status) {
195
+ if (level === "ok") return "";
158
196
  const head =
159
- `[SWITCHMAN:WATERMARK] level=${level} used=${kilo(used)}` +
160
- ` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
161
- if (level === "ok") return head;
162
- if (level === "paused")
163
- return `${head}\nPAUSED by /ctx-pause — measurement continues, but every watermark enforcement stays off until /ctx-resume.`;
164
- const lines = [head];
165
- if (level === "soft") lines.push(`soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.`);
166
- if (level === "hard") lines.push(`hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`);
167
- if (level === "force") {
168
- lines.push(`force tier: MANDATORY wrap-up now — produce the final answer from what you already hold.`);
169
- const phase = status?.phase;
170
- if (phase === "backup") {
171
- lines.push(`context handover running: forking the backup session and writing the handover document…`);
172
- } else if (phase === "compacting") {
173
- lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
174
- } else if (phase === "continuation") {
175
- lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
176
- } else if (status?.autoHandover) {
177
- lines.push(
178
- status?.cooldownMin > 0
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 banner: the same soft/hard/force absolute-token tiers
190
- * as the root banner ("" while ok — token economy), with subagent-flavored
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 renderSubBanner(used, th) {
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
- if (level === "soft")
200
- return `${head}\nsoft tier: keep reads narrow and drop bulky raw output.`;
201
- if (level === "hard")
202
- return `${head}\nhard tier: stop broad reading; start wrapping toward the final summary.`;
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 banner for one assembly; "" whenever anything needed is missing. */
210
- function renderBanner(ctx, config, state, agent) {
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 renderSubBanner(used, subTh);
254
+ return renderSubWatermark(used, subTh);
224
255
  }
225
- const level = state.paused.has(sessionId) ? "paused" : levelOf(used, th);
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
- return renderRootBanner(level, used, th, {
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) / 60_000)),
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 banner -------------------------------------------------
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) => renderBanner(ctx, config, state, context?.agent),
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, 1500);
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, 1500);
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 { applyContextWatch };
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
+ }