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/intent.js ADDED
@@ -0,0 +1,952 @@
1
+ /** dsh-switchman intent-routing layer: the "auto" pseudo-model.
2
+ *
3
+ * Adds a `dsh-switchman-auto/auto` entry to the native model catalog (via
4
+ * `ctx.llm.registerAdapter`) so the composer dropdown offers an "Auto" route,
5
+ * and routes that pseudo route onto a real model per request: the adapter's
6
+ * own stream is THE routing point — it classifies the recent conversation
7
+ * against the configured candidate list with a small classifier model,
8
+ * advances the per-session state machine, and forwards to the decided route.
9
+ * (The host's agent/request waterfall cannot do this: it throws when a
10
+ * listener proposes provider/model different from the selection fixed at
11
+ * step preparation, and its result is structuredClone'd — spreading this
12
+ * plugin's volatile config into the return value crashed requests with
13
+ * "() => current could not be cloned".) The switch is silent (no
14
+ * `[model changed]` notice, no cache-breaking session mutation); retries
15
+ * re-dispatching the same options object pin to the decided route via a
16
+ * WeakMap; every model call within one user message reuses that message's
17
+ * decision — the turn pin keys on the newest user/message event id, because
18
+ * GenerateOptions carries no turn number (the abandoned waterfall did);
19
+ * a provider prompt-cache window keeps the current model while the
20
+ * classifier's confidence stays below the switch threshold (design:
21
+ * .dsh-switchman/plans/intent-routing.md — field names/protocol/state machine
22
+ * are frozen there).
23
+ *
24
+ * Fail-open everywhere: a missing service, an unconfigured classifier, a
25
+ * timeout/abort, or an unparseable verdict degrades to the fallback chain
26
+ * (pinned → previous routed route → intentFallbackRoute → first candidate);
27
+ * with nothing routable at all the stream throws an explicit error telling
28
+ * the user to pick a concrete model. All services are reached
29
+ * through defensive `ctx.get` reads — a deployment without the `llm` service
30
+ * leaves the layer a warn-once no-op (documented runtime precondition, same
31
+ * as index.js's inject note for sessionQuery/pluginManager). The classifier's
32
+ * own call is a session-title-llm-style auxiliary request (real provider ≠
33
+ * AUTO_PROVIDER → cannot re-enter this adapter), with
34
+ * a local AbortController+setTimeout deadline instead of the
35
+ * @deepseek-ai/dsh-timeout dependency (this bundle adds no dependencies).
36
+ */
37
+
38
+ import { randomUUID } from "node:crypto";
39
+ import { join } from "node:path";
40
+ import { effectiveIntentCandidates, mergeIntentCandidates } from "./intent-presets.js";
41
+ import { sessionModelSelectionOf } from "./subagent-model.js";
42
+
43
+ /** Pseudo provider id (must match client.js and the design contract). */
44
+ export const AUTO_PROVIDER = "dsh-switchman-auto";
45
+
46
+ /** Pseudo model id under AUTO_PROVIDER. */
47
+ export const AUTO_MODEL = "auto";
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // ---------------------------------------------------------------------------
51
+ // Volatile settings readers (mirror host/dispatch.js's guards — these files
52
+ // stay independently mountable).
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** Read one volatile settings field as a plain string. */
56
+ function readField(field) {
57
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
58
+ try {
59
+ const value = field.get();
60
+ return typeof value === "string" ? value : "";
61
+ } catch {
62
+ return "";
63
+ }
64
+ }
65
+ return typeof field === "string" ? field : "";
66
+ }
67
+
68
+ /** Boolean volatile-field read (false on any malformed value). */
69
+ function readBool(field) {
70
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
71
+ try {
72
+ return field.get() === true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+ return field === true;
78
+ }
79
+
80
+ /** Numeric volatile-field read (fallback on any malformed value). */
81
+ function readNumber(field, fallback) {
82
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
83
+ try {
84
+ const value = Number(field.get());
85
+ return Number.isFinite(value) ? value : fallback;
86
+ } catch {
87
+ return fallback;
88
+ }
89
+ }
90
+ const value = Number(field);
91
+ return Number.isFinite(value) ? value : fallback;
92
+ }
93
+
94
+ /** Array volatile-field read (non-arrays degrade to []). */
95
+ function readList(field) {
96
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
97
+ try {
98
+ const value = field.get();
99
+ return Array.isArray(value) ? value : [];
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+ return Array.isArray(field) ? field : [];
105
+ }
106
+
107
+ /** One validated intent route reference ({provider, model} both non-empty
108
+ * after trim, plus optional non-empty effort), or null when unset. */
109
+ function readRoute(field) {
110
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
111
+ try {
112
+ return routeOf(field.get());
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+ return routeOf(field);
118
+ }
119
+
120
+ /** Validate one raw route-like value; null unless provider+model are
121
+ * non-empty strings. A non-empty effort rides along as a pin. */
122
+ function routeOf(value) {
123
+ if (value === null || typeof value !== "object") return null;
124
+ const provider = typeof value.provider === "string" ? value.provider.trim() : "";
125
+ const model = typeof value.model === "string" ? value.model.trim() : "";
126
+ if (provider === "" || model === "") return null;
127
+ // Never route TO the pseudo provider itself: the rewrite would hand the
128
+ // pseudo model back to the loop and the adapter fallback would re-enter
129
+ // itself forever (review finding, 2026-10-24).
130
+ if (provider === AUTO_PROVIDER) return null;
131
+ const route = { provider, model };
132
+ if (typeof value.effort === "string" && value.effort.trim() !== "") route.effort = value.effort.trim();
133
+ return route;
134
+ }
135
+
136
+ /** Effective context window (tokens) the Auto pseudo-model should declare.
137
+ * The point of a custom Auto window is to position DSH core's
138
+ * 0.8×-window auto-compaction line AFTER switchman's absolute force-tier
139
+ * handover, so the smooth four-step handover always fires before the
140
+ * cruder single-shot compaction. The 0.8 ratio is compaction-basic's
141
+ * default thresholdRatio (documented assumption, docs/porting-plan.md).
142
+ * While wmAutoHandover is on, the declared value is floored at
143
+ * ceil(wmForceTokens / 0.8) to preserve that invariant; with automatic
144
+ * handover off the user's raw window is respected as-is — they opted out
145
+ * of the handover pipeline, so the 0.8 line may sit wherever they put it. */
146
+ export function effectiveContextWindow(config) {
147
+ const user = Math.max(1024, Math.round(readNumber(config.intentContextWindow, 200000)));
148
+ if (!readBool(config.wmAutoHandover)) return user;
149
+ const floor = Math.ceil(readNumber(config.wmForceTokens, 150000) / 0.8);
150
+ return Math.max(user, floor);
151
+ }
152
+
153
+ /** Validated intent-candidate list: routes plus their optional effort/note. */
154
+ export function readCandidates(field) {
155
+ // Unified list = stored rows merged with the built-in presets; only
156
+ // enabled rows with a complete route reach the classifier, so a preset
157
+ // still waiting for its model (or a freshly added custom row) never
158
+ // dilutes the prompt.
159
+ const candidates = [];
160
+ const byRoute = new Map();
161
+ for (const entry of effectiveIntentCandidates(mergeIntentCandidates(readList(field)))) {
162
+ if (entry.provider === AUTO_PROVIDER) continue; // self-reference guard, mirrors routeOf
163
+ // Collapse same-route rows (several presets mapped to one model) into
164
+ // ONE classifier candidate. The merged note carries the route's FULL
165
+ // job description — keep-first would silently ignore the user's
166
+ // mapping for every non-first category on that route.
167
+ const routeKey = `${entry.provider}/${entry.model}`;
168
+ let candidate = byRoute.get(routeKey);
169
+ if (candidate === undefined) {
170
+ candidate = { provider: entry.provider, model: entry.model, noteSet: new Set() };
171
+ byRoute.set(routeKey, candidate);
172
+ candidates.push(candidate);
173
+ }
174
+ const note = typeof entry.note === "string" ? entry.note.trim() : "";
175
+ if (note !== "") candidate.noteSet.add(note);
176
+ const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
177
+ if (effort !== "" && candidate.effort === undefined) candidate.effort = effort;
178
+ }
179
+ for (const candidate of candidates) {
180
+ const notes = [...candidate.noteSet];
181
+ if (notes.length > 0) candidate.note = notes.join(" ");
182
+ delete candidate.noteSet;
183
+ }
184
+ return candidates;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Pure helpers (exported for tests).
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /** Stable identity of one provider/model route (effort is a pin, not part
192
+ * of the model identity). */
193
+ function routeKeyOf(route) {
194
+ return `${route?.provider ?? ""}\u0000${route?.model ?? ""}`;
195
+ }
196
+
197
+ /** Copy one route with its effort pin kept only when a non-empty string. */
198
+ function withEffort(route) {
199
+ if (route === null || typeof route !== "object") return null;
200
+ const copy = { provider: route.provider, model: route.model };
201
+ if (typeof route.effort === "string" && route.effort !== "") copy.effort = route.effort;
202
+ return copy;
203
+ }
204
+
205
+ /** First JSON number found in a number-or-numeric-string value. */
206
+ function numberOr(value) {
207
+ if (typeof value === "number" && Number.isFinite(value)) return value;
208
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value.trim()))) return Number(value.trim());
209
+ return null;
210
+ }
211
+
212
+ /** Validate one parsed verdict object against the candidate count: an
213
+ * in-range integer `best` (0-based) with a clamped 0-100 confidence
214
+ * (missing confidence = decisive pick, 100); null otherwise. */
215
+ function verdictFromValue(value, candidateCount) {
216
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
217
+ const best = numberOr(value.best);
218
+ if (best === null || !Number.isInteger(best) || best < 0 || best >= candidateCount) return null;
219
+ const confidence = numberOr(value.confidence);
220
+ return {
221
+ best,
222
+ confidence: confidence === null ? 100 : Math.min(100, Math.max(0, Math.round(confidence))),
223
+ };
224
+ }
225
+
226
+ /** First balanced `{...}` segment of a text (string/escape aware), or null. */
227
+ function firstBalancedObject(text) {
228
+ const start = text.indexOf("{");
229
+ if (start < 0) return null;
230
+ let depth = 0;
231
+ let inString = false;
232
+ let escaped = false;
233
+ for (let i = start; i < text.length; i++) {
234
+ const ch = text[i];
235
+ if (inString) {
236
+ if (escaped) escaped = false;
237
+ else if (ch === "\\") escaped = true;
238
+ else if (ch === '"') inString = false;
239
+ continue;
240
+ }
241
+ if (ch === '"') inString = true;
242
+ else if (ch === "{") depth++;
243
+ else if (ch === "}") {
244
+ depth--;
245
+ if (depth === 0) return text.slice(start, i + 1);
246
+ }
247
+ }
248
+ return null;
249
+ }
250
+
251
+ /**
252
+ * Tolerant classifier-verdict parser (design §3 chain, exported for tests):
253
+ * ① direct JSON.parse → ② first balanced {...} segment → ③ JSON inside a
254
+ * markdown code fence → ④ first bare-integer line (confidence = 100).
255
+ * @param {string} text - raw classifier output.
256
+ * @param {number} candidateCount - number of candidates (best is 0-based).
257
+ * @returns {{best: number, confidence: number} | null} null when nothing
258
+ * parseable yields an in-range best (callers fail open).
259
+ */
260
+ export function parseClassifierVerdict(text, candidateCount) {
261
+ if (typeof text !== "string" || !Number.isInteger(candidateCount) || candidateCount < 1) return null;
262
+ try {
263
+ const direct = verdictFromValue(JSON.parse(text), candidateCount);
264
+ if (direct !== null) return direct;
265
+ } catch {
266
+ /* not bare JSON — keep the chain */
267
+ }
268
+ const segment = firstBalancedObject(text);
269
+ if (segment !== null) {
270
+ try {
271
+ const inner = verdictFromValue(JSON.parse(segment), candidateCount);
272
+ if (inner !== null) return inner;
273
+ } catch {
274
+ /* malformed segment — keep the chain */
275
+ }
276
+ }
277
+ const fence = /```[a-zA-Z]*\r?\n?([\s\S]*?)```/.exec(text);
278
+ if (fence !== null) {
279
+ const body = fence[1].trim();
280
+ try {
281
+ const fenced = verdictFromValue(JSON.parse(body), candidateCount);
282
+ if (fenced !== null) return fenced;
283
+ } catch {
284
+ /* fence without bare JSON — try a balanced segment inside it */
285
+ }
286
+ const innerSegment = firstBalancedObject(body);
287
+ if (innerSegment !== null) {
288
+ try {
289
+ const inner = verdictFromValue(JSON.parse(innerSegment), candidateCount);
290
+ if (inner !== null) return inner;
291
+ } catch {
292
+ /* keep the chain */
293
+ }
294
+ }
295
+ }
296
+ const line = /^[ \t]*(\d+)[ \t]*$/m.exec(text);
297
+ if (line !== null) {
298
+ const best = Number(line[1]);
299
+ if (Number.isInteger(best) && best >= 0 && best < candidateCount) return { best, confidence: 100 };
300
+ }
301
+ return null;
302
+ }
303
+
304
+ /**
305
+ * The routing decision state machine (design §4, exported for tests). Pure:
306
+ * no clocks, no settings reads, no mutation.
307
+ * @param {object} args
308
+ * @param {Array<{provider: string, model: string, effort?: string}>} args.candidates
309
+ * @param {{provider: string, model: string, effort?: string} | null} args.current
310
+ * previously routed route (cache-reuse anchor).
311
+ * @param {{best: number, confidence: number} | null} args.parsed
312
+ * classifier verdict, or null (parse failure/timeout/disabled → chain).
313
+ * @param {number} args.now - decision timestamp (ms).
314
+ * @param {number} args.routedAt - when `current` was last applied (ms), or
315
+ * null when unknown (the task brief's signature omits it; §4's cache rule
316
+ * needs the previous-route timestamp, so it rides along here).
317
+ * @param {number} args.cacheTtlSeconds - provider prompt-cache window.
318
+ * @param {number} args.switchConfidence - threshold to leave the current model.
319
+ * @param {{provider: string, model: string, effort?: string} | null} args.fallback
320
+ * @returns {{route: {provider: string, model: string, effort?: string} | null,
321
+ * switched: boolean, reason: string}} route null = nothing routable (the
322
+ * pseudo model passes through; the adapter explains how to configure).
323
+ */
324
+ export function decideRoute({ candidates, current, parsed, now, routedAt, cacheTtlSeconds, switchConfidence, fallback }) {
325
+ const list = Array.isArray(candidates) ? candidates.filter((entry) => routeOf(entry) !== null) : [];
326
+ const ttlMs = Math.max(0, Number(cacheTtlSeconds) || 0) * 1000;
327
+ const threshold = Number.isFinite(Number(switchConfidence)) ? Number(switchConfidence) : 80;
328
+ const chain = () => {
329
+ if (routeOf(current) !== null) return { route: withEffort(current), switched: false, reason: "fallback-keep-current" };
330
+ if (routeOf(fallback) !== null) return { route: withEffort(fallback), switched: false, reason: "fallback-configured" };
331
+ if (list.length > 0) return { route: withEffort(list[0]), switched: true, reason: "fallback-first-candidate" };
332
+ return { route: null, switched: false, reason: "unavailable" };
333
+ };
334
+ if (parsed === null || typeof parsed !== "object") return chain();
335
+ const bestRoute = routeOf(list[parsed.best]) === null ? null : withEffort(list[parsed.best]);
336
+ if (bestRoute === null) return chain(); // defensive: verdict outlived its candidate list
337
+ const currentRoute = routeOf(current) === null ? null : withEffort(current);
338
+ if (currentRoute !== null) {
339
+ if (routeKeyOf(currentRoute) === routeKeyOf(bestRoute)) {
340
+ return { route: currentRoute, switched: false, reason: "keep-best-is-current" };
341
+ }
342
+ const currentListed = list.some((entry) => routeKeyOf(entry) === routeKeyOf(currentRoute));
343
+ if (!currentListed) return { route: bestRoute, switched: true, reason: "switch-current-not-candidate" };
344
+ if (parsed.confidence >= threshold) return { route: bestRoute, switched: true, reason: "switch-confidence" };
345
+ const withinWindow = Number.isFinite(Number(routedAt)) && Number(now) - Number(routedAt) < ttlMs;
346
+ if (withinWindow) return { route: currentRoute, switched: false, reason: "keep-cache-window" };
347
+ return { route: bestRoute, switched: true, reason: "switch-cache-expired" };
348
+ }
349
+ // No previously routed model (first decision): the best pick applies.
350
+ return { route: bestRoute, switched: true, reason: "switch-first-decision" };
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Per-session decision state (host/handover-state.js pattern: module-level,
355
+ // process-lifetime, never persisted; cleaned on session/disposed).
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /** sessionId -> { pinnedTurnKey, decision, routedCurrent, routedAt,
359
+ * lastDecision } (design §4). */
360
+ const sessionState = new Map();
361
+
362
+ function stateOf(sessionId) {
363
+ let state = sessionState.get(sessionId);
364
+ if (state === undefined) {
365
+ state = { pinnedTurnKey: null, decision: null, routedCurrent: null, routedAt: null, lastDecision: null };
366
+ sessionState.set(sessionId, state);
367
+ }
368
+ return state;
369
+ }
370
+
371
+ /** Drop one session's decision state (session/disposed + intent-reset route). */
372
+ export function clearSessionState(sessionId) {
373
+ if (typeof sessionId === "string" && sessionId !== "") sessionState.delete(sessionId);
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Classifier prompt + conversation excerpt.
378
+ // ---------------------------------------------------------------------------
379
+
380
+ /** System prompt for the chat-channel classifier (design §3: strict JSON;
381
+ * tolerant parsing still accepts legacy bare-integer replies). */
382
+ function classifierSystemPrompt() {
383
+ return 'You route this conversation to one candidate model. Reply with STRICT JSON only: {"best": <candidate number>, "confidence": <0-100>}. No prose, no markdown fence.';
384
+ }
385
+
386
+ /** Candidate list lines: `#N provider/model — note`, the currently routed
387
+ * model annotated. */
388
+ function candidateLines(candidates, currentKey) {
389
+ return candidates.map((candidate, index) => {
390
+ const current = routeKeyOf(candidate) === currentKey ? " (current)" : "";
391
+ const note = typeof candidate.note === "string" && candidate.note !== "" ? ` — ${candidate.note}` : "";
392
+ return `#${index} ${candidate.provider}/${candidate.model}${current}${note}`;
393
+ });
394
+ }
395
+
396
+ /** The user-turn text: candidates, the neutral tie-break hint, the recent
397
+ * conversation excerpt, and the strict-JSON question. */
398
+ function classifierInput(config, candidates, current, excerpt) {
399
+ const currentKey = current === null ? "\u0000-none" : routeKeyOf(current);
400
+ const question =
401
+ 'Which candidate number is best for the next reply? Reply with strict JSON {"best": <number>, "confidence": <0-100>}.';
402
+ return [
403
+ "Candidates:",
404
+ ...candidateLines(candidates, currentKey),
405
+ "When no candidate has a clear advantage, prefer the current model.",
406
+ "",
407
+ "Recent conversation:",
408
+ excerpt === "" ? "(no messages yet)" : excerpt,
409
+ "",
410
+ question,
411
+ ].join("\n");
412
+ }
413
+
414
+ /** Plain-text blocks of one message content array, joined by newlines. */
415
+ function textBlocksOf(content) {
416
+ if (!Array.isArray(content)) return "";
417
+ return content
418
+ .filter((block) => block !== null && typeof block === "object" && block.type === "text")
419
+ .map((block) => (typeof block.text === "string" ? block.text : ""))
420
+ .join("\n");
421
+ }
422
+
423
+ /**
424
+ * Recent user/assistant conversation excerpt for the classifier (design §3;
425
+ * extraction mirrors session-title's user/message eligibility: text blocks
426
+ * joined with "\n"), newest-first with a total character budget.
427
+ */
428
+ function sessionHistoryOf(ctx, sessionId, config) {
429
+ if (sessionId === null) return "";
430
+ const maxMessages = Math.max(1, Math.round(readNumber(config.intentHistoryMessages, 6)));
431
+ const maxChars = Math.max(0, Math.round(readNumber(config.intentHistoryMaxChars, 2400)));
432
+ try {
433
+ const session = ctx.sessions?.get?.(sessionId);
434
+ const events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents() : [];
435
+ if (!Array.isArray(events)) return "";
436
+ const picked = [];
437
+ let budget = maxChars;
438
+ for (let i = events.length - 1; i >= 0 && picked.length < maxMessages && budget > 0; i--) {
439
+ const event = events[i];
440
+ const data = event?.data ?? null;
441
+ const isUser = event?.type === "user/message" && data?.source?.kind === "user";
442
+ const isAssistant = event?.type === "assistant/message";
443
+ if (!isUser && !isAssistant) continue;
444
+ const message = isUser ? data : data?.message;
445
+ const text = textBlocksOf(message?.content).trim();
446
+ if (text === "") continue;
447
+ const prefix = isUser ? "User" : "Assistant";
448
+ const room = budget - prefix.length - 2;
449
+ if (room <= 0) break;
450
+ const body = text.length > room ? `${text.slice(0, room)}…` : text;
451
+ picked.unshift(`${prefix}: ${body}`);
452
+ budget -= prefix.length + 2 + body.length;
453
+ }
454
+ return picked.join("\n");
455
+ } catch {
456
+ return ""; // unreadable history degrades to an empty excerpt, never throws
457
+ }
458
+ }
459
+
460
+ /** Session event snapshot for turn-key extraction (empty array on any
461
+ * unreadable state — the pin then degrades to the no-user key, never
462
+ * throws; same defensive ctx.sessions access as sessionHistoryOf). */
463
+ function snapshotEventsOf(ctx, sessionId) {
464
+ try {
465
+ const session = ctx.sessions?.get?.(sessionId);
466
+ const events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents() : [];
467
+ return Array.isArray(events) ? events : [];
468
+ } catch {
469
+ return [];
470
+ }
471
+ }
472
+
473
+ /** The per-turn pin key: the id of the newest HUMAN user/message event
474
+ * (source.kind === "user"; seq fallback when the id is missing). Only the
475
+ * human kind counts: goal rounds (kind "goal"), compaction checkpoints and
476
+ * session recalls (kind "plugin"), and subagent-settled notices are
477
+ * user/message events too, but they are autonomous-work artifacts — keying
478
+ * on them would re-classify mid-work exactly like the bug this fixes. The
479
+ * guard mirrors sessionHistoryOf's excerpt eligibility, so the pin boundary
480
+ * and the classifier input stay consistent. GenerateOptions carries no
481
+ * turn/step (the abandoned agent/request waterfall did), so this event is
482
+ * the only turn boundary visible at the adapter's stream. (design §4,
483
+ * 2026-10-25) */
484
+ function turnKeyOf(sessionId, events) {
485
+ for (let i = events.length - 1; i >= 0; i--) {
486
+ const event = events[i];
487
+ if (event?.type !== "user/message") continue;
488
+ const data = event?.data;
489
+ if (data?.source?.kind !== "user") continue;
490
+ const id = typeof data?.id === "string" && data.id !== "" ? data.id : `seq:${typeof event?.seq === "number" ? event.seq : i}`;
491
+ return `${sessionId}:${id}`;
492
+ }
493
+ return `${sessionId}:no-user`;
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // Classifier call (session-title-llm pattern; local deadline, no new deps).
498
+ // ---------------------------------------------------------------------------
499
+
500
+ /** One user message literal (createUserMessage shape: role/content/source/id). */
501
+ function userMessage(text) {
502
+ return {
503
+ id: randomUUID(),
504
+ role: "user",
505
+ content: [{ type: "text", text }],
506
+ source: { kind: "dsh-switchman-intent" },
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Run one classification request and return the raw output text. Throws on
512
+ * timeout, abort, operational finish (error/aborted), or a missing llm
513
+ * service — callers fail open. The classifier route is a real provider, so
514
+ * the call cannot re-enter this adapter (real provider ≠ AUTO_PROVIDER).
515
+ */
516
+ async function classifyOnce(ctx, config, route, system, input, turnSignal) {
517
+ const llm = typeof ctx.get === "function" ? ctx.get("llm", false) : undefined;
518
+ if (llm === undefined || llm === null || typeof llm.prepareCall !== "function" || typeof llm.stream !== "function") {
519
+ throw new Error("llm service unavailable");
520
+ }
521
+ const deadlineMs = Math.max(200, Math.round(readNumber(config.intentTimeoutMs, 60000)));
522
+ const controller = new AbortController();
523
+ const timer = setTimeout(() => controller.abort(), deadlineMs);
524
+ const signal = turnSignal instanceof AbortSignal ? AbortSignal.any([controller.signal, turnSignal]) : controller.signal;
525
+ try {
526
+ const effort = readField(config.intentClassifierEffort).trim();
527
+ const call = await llm.prepareCall(
528
+ { provider: route.provider, model: route.model, maxTokens: 4096 },
529
+ signal,
530
+ (controls) => (effort === "" ? controls : { ...controls, reasoningEffort: effort }),
531
+ );
532
+ const options = {
533
+ ...call.config,
534
+ messages: [userMessage(input)],
535
+ system,
536
+ signal,
537
+ };
538
+ let text = "";
539
+ let finish = null;
540
+ for await (const chunk of call.stream(options)) {
541
+ signal.throwIfAborted();
542
+ if (chunk === null || typeof chunk !== "object") continue;
543
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
544
+ else if (chunk.type === "finish") finish = chunk.reason ?? null;
545
+ }
546
+ signal.throwIfAborted();
547
+ if (finish !== null && typeof finish === "object" && (finish.kind === "error" || finish.kind === "aborted")) {
548
+ throw new Error(finish.failure?.message ?? "classifier stream failed");
549
+ }
550
+ return text;
551
+ } finally {
552
+ clearTimeout(timer);
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Classify via an ollama /v1/systemone decision endpoint (direct HTTP —
558
+ * no llm-service provider registration needed; the model name comes from
559
+ * intentClassifierSystemoneModel, the URL from intentClassifierEndpoint).
560
+ * Contract: POST {model, state, questions:{best:{type:"choice",
561
+ * instructions, criteria:{key: description}}}} → answers.best.{choice,
562
+ * probabilities, confidence}. Returns a verdict {best, confidence} with
563
+ * confidence scaled to 0-100 (top probability preferred over the model's
564
+ * calibrated `confidence`). Throws on unset config, timeout/abort/HTTP/
565
+ * parse errors — callers fail open. Exported for tests.
566
+ */
567
+ export async function classifySystemone(ctx, config, candidates, current, excerpt, turnSignal) {
568
+ const endpoint = readField(config.intentClassifierEndpoint).trim();
569
+ if (endpoint === "") throw new Error("systemone endpoint unset (intentClassifierEndpoint)");
570
+ const model = readField(config.intentClassifierSystemoneModel).trim();
571
+ if (model === "") throw new Error("systemone model unset (intentClassifierSystemoneModel)");
572
+ if (candidates.length === 0) throw new Error("no candidates");
573
+ const currentKey = current === null ? "\u0000-none" : routeKeyOf(current);
574
+ const keys = [];
575
+ const criteria = {};
576
+ for (const [index, candidate] of candidates.entries()) {
577
+ const key = `#${index} ${candidate.provider}/${candidate.model}${routeKeyOf(candidate) === currentKey ? " (current)" : ""}`;
578
+ keys.push(key);
579
+ criteria[key] = typeof candidate.note === "string" && candidate.note !== "" ? candidate.note : "no specific note";
580
+ }
581
+ const state = [
582
+ "Candidates:",
583
+ ...candidateLines(candidates, currentKey),
584
+ "When no candidate has a clear advantage, prefer the current model.",
585
+ "",
586
+ "Recent conversation:",
587
+ excerpt === "" ? "(no messages yet)" : excerpt,
588
+ ].join("\n");
589
+ const payload = {
590
+ model,
591
+ state,
592
+ questions: {
593
+ best: {
594
+ type: "choice",
595
+ instructions: "Which candidate model should answer the next turn? When no candidate has a clear advantage, prefer the current model.",
596
+ criteria,
597
+ },
598
+ },
599
+ };
600
+ const deadlineMs = Math.max(200, Math.round(readNumber(config.intentTimeoutMs, 60000)));
601
+ const controller = new AbortController();
602
+ const timer = setTimeout(() => controller.abort(), deadlineMs);
603
+ const signal = turnSignal instanceof AbortSignal ? AbortSignal.any([controller.signal, turnSignal]) : controller.signal;
604
+ try {
605
+ signal.throwIfAborted();
606
+ const response = await fetch(endpoint, {
607
+ method: "POST",
608
+ headers: { "Content-Type": "application/json" },
609
+ body: JSON.stringify(payload),
610
+ signal,
611
+ });
612
+ if (!response.ok) throw new Error(`systemone HTTP ${response.status}`);
613
+ const body = await response.json();
614
+ const answer = body?.answers?.best;
615
+ const choice = typeof answer?.choice === "string" ? answer.choice : null;
616
+ const best = choice === null ? -1 : keys.indexOf(choice);
617
+ if (best < 0) throw new Error(`systemone choice unknown: ${JSON.stringify(choice)}`);
618
+ const probabilities = answer?.probabilities !== null && typeof answer?.probabilities === "object" ? answer.probabilities : null;
619
+ const top = probabilities !== null ? numberOr(probabilities[choice]) : null;
620
+ const calibrated = numberOr(answer?.confidence);
621
+ const score = top !== null && top >= 0 && top <= 1 ? top : calibrated !== null && calibrated >= 0 && calibrated <= 1 ? calibrated : null;
622
+ // No usable confidence means the verdict carries zero evidence; treat it as a
623
+ // classification failure so the shared catch fails open (fallback chain keeps
624
+ // the current model) instead of committing a confidence-0 switch.
625
+ if (score === null) throw new Error("systemone answer carried no usable confidence");
626
+ return { best, confidence: Math.round(score * 1000) / 10 };
627
+ } finally {
628
+ clearTimeout(timer);
629
+ }
630
+ }
631
+
632
+ // ---------------------------------------------------------------------------
633
+ // Rewrite + routing entry point.
634
+ // ---------------------------------------------------------------------------
635
+
636
+ /** Decide and apply one auto request (design §4 steps 2-4). Returns the
637
+ * decided route ({provider, model, effort?}) — reasoningEffort only when
638
+ * the route pins one (any effort the user picked on the pseudo model is
639
+ * dropped: the pseudo resolveModel declares no reasoning metadata, so a
640
+ * leaked effort would hard-fail prepareCall with
641
+ * UNSUPPORTED_REASONING_EFFORT) — or null when nothing is configured. */
642
+ async function routeAutoRequest(ctx, config, payload, warn) {
643
+ const enabled = readBool(config.intentEnabled);
644
+ const candidates = readCandidates(config.intentCandidates);
645
+ const fallback = readRoute(config.intentFallbackRoute);
646
+ const rawSessionId = payload?.agent?.session?.id;
647
+ const sessionId = typeof rawSessionId === "string" && rawSessionId !== "" ? rawSessionId : null;
648
+ const state = sessionId === null ? null : stateOf(sessionId);
649
+ // Turn pin keyed by the newest human message (turnKeyOf): one
650
+ // classification per user message; every model call of the agent loop
651
+ // within that message reuses the decision. GenerateOptions has no
652
+ // turn/step, so the old numeric-turn pin could never fire — every step
653
+ // re-classified, observed as the dock chip's decision churning mid-work
654
+ // (2026-10-25 fix; design §4 note).
655
+ const turnKey = sessionId === null ? null : turnKeyOf(sessionId, snapshotEventsOf(ctx, sessionId));
656
+ if (state !== null && turnKey !== null && state.pinnedTurnKey === turnKey && routeOf(state.decision) !== null) {
657
+ // Pin hit = another call routed on the decided model: the provider
658
+ // prompt cache is being hit again, so the rolling window renews (the
659
+ // §4 keep-branch rule — without this, any turn longer than
660
+ // intentCacheTtlSeconds would freeze the window and the next message
661
+ // would switch at sub-threshold confidence via switch-cache-expired,
662
+ // discarding a cache that was hit all turn). lastDecision stays
663
+ // untouched — it timestamps the decision, not the last use.
664
+ state.routedAt = Date.now();
665
+ return state.decision;
666
+ }
667
+
668
+ const classifier = readRoute(config.intentClassifierRoute);
669
+ const kind = readField(config.intentClassifierKind);
670
+ const now = Date.now();
671
+ let parsed = null;
672
+ // systemone needs no classifier route at all (endpoint + model are
673
+ // hand-configured); the chat kind requires one. Either way a missing
674
+ // prerequisite only skips classification — the fallback chain decides.
675
+ if (enabled && candidates.length > 0 && (kind === "systemone" || classifier !== null)) {
676
+ try {
677
+ const excerpt = sessionHistoryOf(ctx, sessionId, config);
678
+ const current = routeOf(state?.routedCurrent) === null ? null : state.routedCurrent;
679
+ if (kind === "systemone") {
680
+ // Direct decision endpoint — skips the llm service entirely;
681
+ // model and URL are hand-configured, no provider involved.
682
+ parsed = await classifySystemone(ctx, config, candidates, current, excerpt, payload?.signal);
683
+ } else {
684
+ const system = classifierSystemPrompt();
685
+ const input = classifierInput(config, candidates, current, excerpt);
686
+ const text = await classifyOnce(ctx, config, classifier, system, input, payload?.signal);
687
+ parsed = parseClassifierVerdict(text, candidates.length);
688
+ if (parsed === null) warn("intent: classifier output unparseable; using the fallback chain");
689
+ }
690
+ } catch (error) {
691
+ warn(`intent: classification failed (${error?.message ?? error}); using the fallback chain`);
692
+ parsed = null;
693
+ }
694
+ }
695
+ const verdict = decideRoute({
696
+ candidates,
697
+ current: state === null ? null : state.routedCurrent,
698
+ parsed,
699
+ now,
700
+ routedAt: state === null ? null : state.routedAt,
701
+ cacheTtlSeconds: readNumber(config.intentCacheTtlSeconds, 300),
702
+ switchConfidence: readNumber(config.intentSwitchConfidence, 80),
703
+ fallback,
704
+ });
705
+ if (verdict.route === null) return null; // nothing configured — caller degrades
706
+ if (state !== null) {
707
+ state.pinnedTurnKey = turnKey;
708
+ state.decision = verdict.route;
709
+ state.routedCurrent = verdict.route;
710
+ state.routedAt = now; // provider prompt caches refresh on hit — the window restarts
711
+ state.lastDecision = {
712
+ best: `${verdict.route.provider}/${verdict.route.model}`,
713
+ confidence: parsed === null ? 0 : parsed.confidence,
714
+ at: now,
715
+ switched: verdict.switched === true,
716
+ };
717
+ }
718
+ return verdict.route;
719
+ }
720
+
721
+ /**
722
+ * Live intent-routing state for the client route (design §5 shape).
723
+ * @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
724
+ * @param {object} config - this plugin's live volatile config.
725
+ * @param {string | null} [sessionId] - session to report (null = root-only
726
+ * fields; autoSelected needs a session).
727
+ */
728
+ export async function intentStateOf(ctx, config, sessionId = null) {
729
+ const enabled = readBool(config.intentEnabled);
730
+ const kind = readField(config.intentClassifierKind);
731
+ const candidates = readCandidates(config.intentCandidates);
732
+ const classifier = readRoute(config.intentClassifierRoute);
733
+ const state = typeof sessionId === "string" && sessionId !== "" ? sessionState.get(sessionId) : undefined;
734
+ const selection = typeof sessionId === "string" && sessionId !== "" ? await sessionModelSelectionOf(ctx, sessionId) : null;
735
+ const now = Date.now();
736
+ const ttlMs = Math.max(0, readNumber(config.intentCacheTtlSeconds, 300)) * 1000;
737
+ // Smallest REAL context window across the candidates, probed through
738
+ // llm.resolveModelInfo. The call MUST stay receiver-bound (the
739
+ // host/vision.js lesson): LlmRuntime methods read `this`, so fetch the
740
+ // service via ctx.get and call ON the llm object — a detached call
741
+ // TypeErrors into null. Sequential awaits, one try/catch per candidate;
742
+ // every failure (missing service, unknown model, throw, non-finite
743
+ // value) reads as null = "don't know", never throws, fail-open.
744
+ let llm = null;
745
+ try {
746
+ const service = ctx.get?.("llm", false);
747
+ if (service !== null && typeof service === "object" && typeof service.resolveModelInfo === "function") llm = service;
748
+ } catch {
749
+ llm = null;
750
+ }
751
+ let minCandidateWindow = null;
752
+ let minCandidateModel = null;
753
+ if (llm !== null) {
754
+ for (const candidate of candidates) {
755
+ let window = null;
756
+ try {
757
+ const info = await llm.resolveModelInfo(candidate.provider, candidate.model);
758
+ const value = info?.context?.contextWindow;
759
+ window = typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
760
+ } catch {
761
+ window = null;
762
+ }
763
+ if (window !== null && (minCandidateWindow === null || window < minCandidateWindow)) {
764
+ minCandidateWindow = window;
765
+ minCandidateModel = candidate.model;
766
+ }
767
+ }
768
+ }
769
+ const forceTokens = readNumber(config.wmForceTokens, 150000);
770
+ return {
771
+ enabled,
772
+ autoSelected: selection?.provider === AUTO_PROVIDER,
773
+ // systemone runs on the hand-configured endpoint + model, so a blank
774
+ // catalog route must not mark the feature unconfigured.
775
+ configured:
776
+ (kind === "systemone"
777
+ ? readField(config.intentClassifierEndpoint).trim() !== "" &&
778
+ readField(config.intentClassifierSystemoneModel).trim() !== ""
779
+ : classifier !== null) &&
780
+ candidates.length > 0,
781
+ current: routeOf(state?.routedCurrent) === null ? null : { provider: state.routedCurrent.provider, model: state.routedCurrent.model },
782
+ lastDecision: state?.lastDecision ?? null,
783
+ cacheWindowRemainingMs:
784
+ typeof state?.routedAt === "number" ? Math.max(0, Math.round(state.routedAt + ttlMs - now)) : 0,
785
+ context: {
786
+ // Effective declared window (the handover floor applies — see
787
+ // effectiveContextWindow) plus the smallest candidate window
788
+ // probe. overflowRisk is informational ONLY (drives a dock
789
+ // tooltip line): a candidate narrower than the force watermark
790
+ // may overflow near the cap before the handover line — it never
791
+ // gates routing here.
792
+ contextWindow: effectiveContextWindow(config),
793
+ minCandidateWindow,
794
+ // The route owning that minimum rides along so the client can
795
+ // name the risky model; null exactly when minCandidateWindow is.
796
+ minCandidateModel: minCandidateWindow === null ? null : minCandidateModel,
797
+ overflowRisk: minCandidateWindow !== null && minCandidateWindow < forceTokens,
798
+ },
799
+ candidates: candidates.map((candidate) => ({
800
+ provider: candidate.provider,
801
+ model: candidate.model,
802
+ ...(typeof candidate.note === "string" && candidate.note !== "" ? { note: candidate.note } : {}),
803
+ })),
804
+ };
805
+ }
806
+
807
+ // ---------------------------------------------------------------------------
808
+ // The pseudo adapter (catalog entry + the routing stream).
809
+ // ---------------------------------------------------------------------------
810
+
811
+ /** Options object → decided route. Llm-layer retries re-dispatch the SAME
812
+ * options object; the pin makes them reuse the decision instead of
813
+ * re-classifying (and possibly flip-flopping mid-retry). */
814
+ const requestPin = new WeakMap();
815
+
816
+ /** Build the dsh-switchman-auto adapter object (LlmAdapter protocol). We ship
817
+ * a plain object, which inherits NOTHING from the harness base class — every
818
+ * member the registry or the stream path touches must be present here. */
819
+ function createAutoAdapter(ctx, config, warn) {
820
+ return {
821
+ providerInfo: (provider) => ({ id: provider, name: "Auto · Switchman" }),
822
+ /** Required by the registry's prepareRoutes (called unconditionally);
823
+ * returning undefined lets the registry apply its default retry policy. */
824
+ providerRetryPolicy: () => undefined,
825
+ /** Base-class default: declare no provider-owned image pricing so the
826
+ * token meter falls back to its neutral estimate. The registry reads
827
+ * this for registered routes when pricing durable history. */
828
+ imageRequestPricing: () => undefined,
829
+ /** Base-class default: preserve normalized images untouched. */
830
+ imageRequestTarget: (_provider, _model, image) => ({ width: image.width, height: image.height, maxBytes: image.bytes }),
831
+ /** Catalog entries, decided per call from the live config: OFF hides
832
+ * the group (the selector drops adapters whose listModels yields an
833
+ * empty array) while the adapter stays mounted, so flipping the
834
+ * toggle re-exposes Auto with no remount, and a session already on
835
+ * Auto keeps routing through the documented fallback chain (no
836
+ * classifier call). */
837
+ listModels: async (provider) =>
838
+ readBool(config.intentEnabled)
839
+ ? [{ provider, id: AUTO_MODEL, name: "Auto", description: "Intent-based model routing by switchman" }]
840
+ : [],
841
+ resolveModel: async (provider, model) => ({
842
+ provider,
843
+ id: model,
844
+ name: "Auto",
845
+ context: { contextWindow: effectiveContextWindow(config) },
846
+ }),
847
+ /** Base-class default (replicated): the registry's stream path calls
848
+ * prepareCall and dispatches through the returned stream — it never
849
+ * calls adapter.stream directly. */
850
+ async prepareCall(provider, model, signal) {
851
+ return {
852
+ model: await this.resolveModel(provider, model, signal),
853
+ stream: (options) => this.stream(options),
854
+ };
855
+ },
856
+ /** THE routing point (see the file header): classify, advance the
857
+ * state machine, forward to the decided route on a NEW options
858
+ * object — a real provider cannot re-enter this adapter. */
859
+ async *stream(options) {
860
+ const sessionId = typeof options?.sessionId === "string" ? options.sessionId : null;
861
+ let route = requestPin.get(options);
862
+ if (route === undefined) {
863
+ route = await routeAutoRequest(ctx, config, { agent: { session: { id: sessionId } }, signal: options?.signal }, warn);
864
+ if (route !== null) requestPin.set(options, route);
865
+ }
866
+ // routeAutoRequest returning null means nothing is routable
867
+ // (disabled + no fallback + no candidates) and the caller was
868
+ // supposed to degrade — but the adapter IS the last caller, so
869
+ // the contract ends here: fail loudly and legibly instead of the
870
+ // old TypeError on `route.provider`, telling the user the fix.
871
+ if (route === null)
872
+ throw new Error(
873
+ "dsh-switchman auto: no route available (intent routing disabled, no fallback model, no candidates) — pick a concrete model",
874
+ );
875
+ const { reasoningEffort: _droppedPseudoEffort, ...rest } = options ?? {};
876
+ const forwarded = {
877
+ ...rest,
878
+ provider: route.provider,
879
+ model: route.model,
880
+ ...(typeof route.effort === "string" && route.effort !== "" ? { reasoningEffort: route.effort } : {}),
881
+ };
882
+ yield* ctx.get("llm", false).stream(forwarded);
883
+ },
884
+ };
885
+ }
886
+
887
+ // ---------------------------------------------------------------------------
888
+ // Mount.
889
+ // ---------------------------------------------------------------------------
890
+
891
+ /** Warn-once latch for the missing-llm-service degrade. */
892
+ let warnedUnavailable = false;
893
+
894
+ /**
895
+ * Mount the intent-routing layer: the pseudo adapter and the session/disposed
896
+ * cleanup. (Routing itself lives in the adapter's stream — see the header.)
897
+ * The adapter mounts REGARDLESS of intentEnabled — a degraded-resident OFF
898
+ * mode: listModels returns [] so the selector hides the Auto group (see
899
+ * createAutoAdapter), and a session already on Auto keeps streaming through
900
+ * the documented fallback chain with no classifier call.
901
+ *
902
+ * The llm service is deliberately NOT in the plugin's `inject` list (see
903
+ * index.js): a deployment without it must still mount the rest of the plugin.
904
+ * `ctx.inject(["llm"])` defers this feature's mount until the service is
905
+ * stored (immediately when it already is) and re-runs it cleanly if the
906
+ * service is ever replaced. Two timing traps this closes:
907
+ * 1. A one-shot `ctx.get("llm", false)` at apply time loses the race against
908
+ * the llm fiber at boot — the layer silently no-ops forever (observed in
909
+ * the field as the composer picker missing the Auto group).
910
+ * 2. `registerAdapter`'s backing effect lives on the LLM fiber, not ours, so
911
+ * without an explicit release any plugin reload (any settings change)
912
+ * re-registers onto a still-owned provider and dies on DUPLICATE_ADAPTER.
913
+ * @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
914
+ * @param {object} config - this plugin's live volatile config.
915
+ */
916
+ export function applyIntent(ctx, config) {
917
+ const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
918
+ ctx.inject(["llm"], (llmCtx) => {
919
+ try {
920
+ const llm = llmCtx.llm;
921
+ if (llm === undefined || llm === null || typeof llm.registerAdapter !== "function" || typeof llm.prepareCall !== "function" || typeof llm.stream !== "function") {
922
+ if (!warnedUnavailable) {
923
+ warnedUnavailable = true;
924
+ warn("intent routing unavailable: llm service malformed (documented runtime precondition); layer stays a no-op");
925
+ }
926
+ return;
927
+ }
928
+ const handle = llm.registerAdapter([AUTO_PROVIDER], createAutoAdapter(llmCtx, config, warn));
929
+ // Release on OUR fiber: the disposal runs synchronously inside the
930
+ // sub-fiber's unload, which cordis fully drains before any re-apply,
931
+ // so reloads can never race into DUPLICATE_ADAPTER.
932
+ llmCtx.effect(function* () {
933
+ yield () => {
934
+ try {
935
+ handle();
936
+ } catch {
937
+ /* release is best-effort */
938
+ }
939
+ };
940
+ }, "dsh-switchman:intent-auto-adapter-release");
941
+ llmCtx.on("session/disposed", (session) => {
942
+ try {
943
+ if (session !== null && typeof session === "object" && typeof session.id === "string") clearSessionState(session.id);
944
+ } catch {
945
+ /* cleanup is best-effort */
946
+ }
947
+ });
948
+ } catch (error) {
949
+ warn(`intent routing layer failed to mount: ${error?.message ?? error}`);
950
+ }
951
+ });
952
+ }