dsh-switchman 1.7.1 → 1.8.1

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/routes.js CHANGED
@@ -26,6 +26,12 @@
26
26
  * `session` query parameter judges image
27
27
  * capability against THAT session's
28
28
  * model (unknown/missing → root model).
29
+ * GET /api/dsh-switchman/intent-state — live intent-routing state for the
30
+ * dock chip (optional `session` query
31
+ * parameter judges THAT session's
32
+ * auto-selection and pinned decision).
33
+ * POST /api/dsh-switchman/intent-reset — { session } clears one session's
34
+ * intent decision state (debug/reset).
29
35
  * POST /api/dsh-switchman/ui-locale — the client half reports its active
30
36
  * UI locale (feeds the ask guidance's
31
37
  * question language; no settings write).
@@ -38,6 +44,8 @@
38
44
 
39
45
  import { authorizedChildRoutes } from "./dispatch.js";
40
46
  import { handoverSnapshot } from "./handover-state.js";
47
+ import { clearSessionState, intentStateOf } from "./intent.js";
48
+ import { INTENT_PRESETS, mergeIntentCandidates } from "./intent-presets.js";
41
49
  import { pumpTeamsOrchestration, scheduleTeamsOrchestration, SWITCHMAN_SETTINGS_NS, teamsBundleState, teamsSyncSnapshot } from "./teams.js";
42
50
  import { reportedUiLocale, setReportedUiLocale } from "./ui-locale.js";
43
51
  import { visionStateOf } from "./vision.js";
@@ -50,6 +58,8 @@ const ROUTES = {
50
58
  authorized: "/api/dsh-switchman/authorized",
51
59
  handoverState: "/api/dsh-switchman/handover-state",
52
60
  visionState: "/api/dsh-switchman/vision-state",
61
+ intentState: "/api/dsh-switchman/intent-state",
62
+ intentReset: "/api/dsh-switchman/intent-reset",
53
63
  uiLocale: "/api/dsh-switchman/ui-locale",
54
64
  };
55
65
 
@@ -62,7 +72,14 @@ const ROUTES = {
62
72
  const NS = SWITCHMAN_SETTINGS_NS;
63
73
 
64
74
  /** All settings field names, grouped for coercion (mirrors host/config.js). */
65
- const STRING_FIELDS = ["langConversation", "langComments", "langDocs"];
75
+ const STRING_FIELDS = [
76
+ "langConversation",
77
+ "langComments",
78
+ "langDocs",
79
+ "intentClassifierEffort",
80
+ "intentClassifierEndpoint",
81
+ "intentClassifierSystemoneModel",
82
+ ];
66
83
  const POOL_FIELDS = [
67
84
  "poolEconomy",
68
85
  "poolMechanical",
@@ -91,7 +108,27 @@ const BOOLEAN_FIELDS = [
91
108
  // Teams switches (mirrors the teams* group in host/config.js)
92
109
  "teamsMode",
93
110
  "syncWhitelist",
111
+ // Intent recognition (mirrors the intent* group in host/config.js).
112
+ "intentEnabled",
113
+ ];
114
+ /** Intent numbers carry their own schema bounds (config.js), so the shared
115
+ * NUMBER_FIELDS coercion (which only rejects negatives) is too loose — these
116
+ * validate against each field's declared min/max. */
117
+ const INTENT_NUMBER_FIELDS = [
118
+ { field: "intentCacheTtlSeconds", min: 0, max: Number.POSITIVE_INFINITY, def: 300 },
119
+ { field: "intentSwitchConfidence", min: 0, max: 100, def: 80 },
120
+ { field: "intentTimeoutMs", min: 200, max: Number.POSITIVE_INFINITY, def: 60000 },
121
+ { field: "intentHistoryMessages", min: 1, max: 20, def: 6 },
122
+ { field: "intentHistoryMaxChars", min: 200, max: 20000, def: 2400 },
123
+ { field: "intentContextWindow", min: 1024, max: Number.POSITIVE_INFINITY, def: 200000 },
94
124
  ];
125
+ /** Intent route references: unlike pool routes they round-trip the "unset"
126
+ * {provider:"", model:""} pair, so they validate against the loosened
127
+ * cleanIntentRoute (both keys must be strings; either may be empty). */
128
+ const INTENT_ROUTE_FIELDS = ["intentClassifierRoute", "intentFallbackRoute"];
129
+ /** Intent candidate list (route + optional effort/note per entry). */
130
+ const INTENT_CANDIDATE_FIELDS = ["intentCandidates"];
131
+
95
132
  /** Per-pool effort-route lists (mirrors host/config.js pool*Efforts). */
96
133
  const EFFORTS_FIELDS = [
97
134
  "poolEconomyEfforts",
@@ -106,6 +143,7 @@ const ENUM_FIELDS = {
106
143
  langScope: ["global", "project"],
107
144
  dispatchEnforce: ["off", "advice", "enforce"],
108
145
  wmDenyMode: ["cap", "deny"],
146
+ intentClassifierKind: ["chat", "systemone"],
109
147
  };
110
148
 
111
149
  /** JSON response helper. */
@@ -233,6 +271,43 @@ function cleanEffortEntry(entry) {
233
271
  return provider !== "" && model !== "" && effort !== "" ? { provider, model, effort } : null;
234
272
  }
235
273
 
274
+ /** Coerce one intent route reference: both keys must be strings and either
275
+ * may be empty (the round-tripped "unset" pair) — non-empty values trim.
276
+ * null for any other shape. */
277
+ function cleanIntentRoute(entry) {
278
+ if (entry === null || typeof entry !== "object") return null;
279
+ const provider = typeof entry.provider === "string" ? entry.provider.trim() : null;
280
+ const model = typeof entry.model === "string" ? entry.model.trim() : null;
281
+ return provider === null || model === null ? null : { provider, model };
282
+ }
283
+
284
+ /** Coerce one intent candidate: provider/model must be non-empty; effort/note
285
+ * ride along as trimmed strings when non-empty, otherwise are omitted
286
+ * (mirrors client.js's sanitizeIntentCandidate). */
287
+ function cleanIntentCandidate(entry) {
288
+ if (entry === null || typeof entry !== "object") return null;
289
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
290
+ const provider = typeof entry.provider === "string" ? entry.provider.trim() : "";
291
+ const model = typeof entry.model === "string" ? entry.model.trim() : "";
292
+ const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
293
+ const note = typeof entry.note === "string" ? entry.note.trim() : "";
294
+ const preset = INTENT_PRESETS.find((row) => row.id === id) ?? null;
295
+ // Empty routes are legal ONLY for identified rows (a preset waiting for
296
+ // its model, or a half-edited custom row); anonymous empty junk is data
297
+ // noise and dropped.
298
+ if (preset === null && id === "" && provider === "" && model === "" && effort === "" && note === "") return null;
299
+ if (preset !== null) {
300
+ // Defense in depth: whatever the client echoes back, a preset row's
301
+ // note stays the code value and the row keeps its identity.
302
+ const candidate = { id: preset.id, preset: true, enabled: entry.enabled !== false, provider, model, note: preset.note };
303
+ if (effort !== "") candidate.effort = effort;
304
+ return candidate;
305
+ }
306
+ const candidate = { id, preset: false, enabled: entry.enabled !== false, provider, model, note };
307
+ if (effort !== "") candidate.effort = effort;
308
+ return candidate;
309
+ }
310
+
236
311
  /** Snapshot the plugin's live volatile config into plain JSON values. */
237
312
  export function snapshotOf(config) {
238
313
  const values = {};
@@ -247,6 +322,16 @@ export function snapshotOf(config) {
247
322
  values[field] = Array.isArray(rawOf(config?.[field]))
248
323
  ? rawOf(config[field]).map(cleanEffortEntry).filter(Boolean)
249
324
  : [];
325
+ for (const field of INTENT_ROUTE_FIELDS) values[field] = cleanIntentRoute(rawOf(config?.[field])) ?? { provider: "", model: "" };
326
+ for (const field of INTENT_CANDIDATE_FIELDS)
327
+ values[field] = mergeIntentCandidates(rawOf(config?.[field]));
328
+ values.intentClassifierKind = ENUM_FIELDS.intentClassifierKind.includes(rawOf(config?.intentClassifierKind))
329
+ ? rawOf(config.intentClassifierKind)
330
+ : "chat";
331
+ for (const { field, def } of INTENT_NUMBER_FIELDS) {
332
+ const raw = Number(rawOf(config?.[field]));
333
+ values[field] = Number.isFinite(raw) ? raw : def;
334
+ }
250
335
  values.uiLocale = ENUM_FIELDS.uiLocale.includes(rawOf(config?.uiLocale)) ? rawOf(config.uiLocale) : "auto";
251
336
  values.langScope = ENUM_FIELDS.langScope.includes(rawOf(config?.langScope)) ? rawOf(config.langScope) : "global";
252
337
  values.dispatchEnforce = ENUM_FIELDS.dispatchEnforce.includes(rawOf(config?.dispatchEnforce))
@@ -273,7 +358,9 @@ function whitelistSyncOf(config) {
273
358
  }
274
359
 
275
360
  /** Coerce and validate a client-submitted values object; null when invalid. */
276
- function coerceValues(input) {
361
+ /** Validate+normalize an incoming settings payload; exported so the
362
+ * smoke suite can exercise the PUT-fence round-trip invariant. */
363
+ export function coerceValues(input) {
277
364
  if (input === null || typeof input !== "object" || Array.isArray(input)) return null;
278
365
  const values = {};
279
366
  for (const field of STRING_FIELDS) {
@@ -296,6 +383,24 @@ function coerceValues(input) {
296
383
  if (efforts.some((entry) => entry === null)) return null;
297
384
  values[field] = efforts;
298
385
  }
386
+ for (const field of INTENT_ROUTE_FIELDS) {
387
+ const route = cleanIntentRoute(input[field]);
388
+ if (route === null) return null;
389
+ values[field] = route;
390
+ }
391
+ for (const field of INTENT_CANDIDATE_FIELDS) {
392
+ if (!Array.isArray(input[field])) return null;
393
+ const candidates = input[field].map(cleanIntentCandidate);
394
+ if (candidates.some((entry) => entry === null)) return null;
395
+ values[field] = candidates;
396
+ }
397
+ if (!ENUM_FIELDS.intentClassifierKind.includes(input.intentClassifierKind)) return null;
398
+ values.intentClassifierKind = input.intentClassifierKind;
399
+ for (const { field, min, max } of INTENT_NUMBER_FIELDS) {
400
+ const raw = Number(input[field]);
401
+ if (!Number.isFinite(raw) || raw < min || raw > max) return null;
402
+ values[field] = raw;
403
+ }
299
404
  if (!ENUM_FIELDS.uiLocale.includes(input.uiLocale)) return null;
300
405
  values.uiLocale = input.uiLocale;
301
406
  if (!ENUM_FIELDS.langScope.includes(input.langScope)) return null;
@@ -411,7 +516,17 @@ export function makeRoutes(ctx, config) {
411
516
  const current = snapshotOf(config);
412
517
  if (body.expected !== undefined) {
413
518
  const expected = coerceValues(body.expected);
414
- if (expected === null || !sameValues(expected, current)) {
519
+ // Compare candidate rows through the idempotent merge on
520
+ // BOTH sides: the snapshot carries derived labels and the
521
+ // coerce path strips them, so a raw stringify would never
522
+ // match once presets exist (PHANTOM 409 on every save).
523
+ const fenceSame =
524
+ expected !== null &&
525
+ sameValues(
526
+ { ...expected, intentCandidates: mergeIntentCandidates(expected.intentCandidates) },
527
+ { ...current, intentCandidates: mergeIntentCandidates(current.intentCandidates) },
528
+ );
529
+ if (!fenceSame) {
415
530
  writeJson(res, 409, { error: "conflict: settings changed since load", values: current });
416
531
  return;
417
532
  }
@@ -498,6 +613,46 @@ export function makeRoutes(ctx, config) {
498
613
  }
499
614
  },
500
615
  },
616
+ {
617
+ kind: "exact",
618
+ path: ROUTES.intentState,
619
+ handler: async (req, res) => {
620
+ if (!guard(req, res, "GET")) return;
621
+ pumpTeamsOrchestration(ctx, config); // clean-context retry pump (see GET config)
622
+ try {
623
+ // Optional `session` query parameter (a child session id):
624
+ // report THAT session's auto-selection and pinned decision;
625
+ // a missing session reports enabled/configured/candidates
626
+ // with autoSelected=false.
627
+ const session = new URL(req.url ?? "/", "http://localhost").searchParams.get("session");
628
+ writeJson(res, 200, await intentStateOf(ctx, config, typeof session === "string" && session !== "" ? session : null));
629
+ } catch (error) {
630
+ ctx.logger.warn(`dsh-switchman: intent-state read failed: ${error?.message ?? error}`);
631
+ writeJson(res, 500, { error: error?.message ?? String(error) });
632
+ }
633
+ },
634
+ },
635
+ {
636
+ kind: "exact",
637
+ path: ROUTES.intentReset,
638
+ handler: async (req, res) => {
639
+ if (!guard(req, res, "POST")) return;
640
+ let body;
641
+ try {
642
+ body = await readJsonBody(req);
643
+ } catch (error) {
644
+ writeJson(res, 400, { error: error?.message ?? String(error) });
645
+ return;
646
+ }
647
+ const session = typeof body?.session === "string" ? body.session : "";
648
+ if (session === "") {
649
+ writeJson(res, 400, { error: "missing session" });
650
+ return;
651
+ }
652
+ clearSessionState(session);
653
+ writeJson(res, 200, { ok: true });
654
+ },
655
+ },
501
656
  {
502
657
  kind: "exact",
503
658
  path: ROUTES.uiLocale,
package/host/vision.js CHANGED
@@ -21,12 +21,15 @@
21
21
  * placeholder, so the followup forbids that. With an empty vision
22
22
  * pool the command refuses with setup guidance instead, and the client
23
23
  * composer-dock hint (fed by GET /api/dsh-switchman/vision-state)
24
- * points at the same setting. The hint's imageCapable verdict normally
25
- * reads the first root session's model; the route's optional `session`
26
- * query parameter judges the named session's own model instead (via the
27
- * session-query observeSession channel shared with host/subagent-model.js),
28
- * so a child session on a different route no longer misreports — unknown
29
- * sessions fall back to the root verdict.
24
+ * points at the same setting. The route's optional `session` query
25
+ * parameter judges the named session's own model (via the session-query
26
+ * observeSession channel shared with host/subagent-model.js), so a child
27
+ * session on a different route no longer misreports — unknown sessions
28
+ * report null (unknown), never another conversation's verdict; only
29
+ * session-less probes fall back to a best-effort root verdict. Auto
30
+ * pseudo-model selections are judged through the intent layer's current
31
+ * decided route; the hint itself renders only on a positively confirmed
32
+ * text-only verdict (client.js), so "unknown" stays silent.
30
33
  */
31
34
 
32
35
  import { execFile as execFileCb } from "node:child_process";
@@ -36,6 +39,7 @@ import { access, copyFile, mkdir, open, rm } from "node:fs/promises";
36
39
  import { join } from "node:path";
37
40
  import { promisify } from "node:util";
38
41
  import { dshHome } from "./lib/dsh-home.js";
42
+ import { AUTO_PROVIDER, intentStateOf } from "./intent.js";
39
43
  import { sessionModelSelectionOf } from "./subagent-model.js";
40
44
  import { reportedUiLocale } from "./ui-locale.js";
41
45
 
@@ -200,13 +204,17 @@ async function materializeImage(store, ref, warn) {
200
204
  * exact semantics: undefined inputModalities pass (no restriction
201
205
  * declared); anything else must include "image". Returns null when
202
206
  * unknowable (bad route, no llm service, resolution failure) — callers
203
- * treat null as "don't know", never as capable/incapable. */
207
+ * treat null as "don't know", never as capable/incapable.
208
+ *
209
+ * The resolveModelInfo call MUST stay receiver-bound: LlmRuntime methods
210
+ * read `this` (this.resolveModelInfoFor), so detaching the method turned
211
+ * every probe into a TypeError → null (the "hint always shows" bug). */
204
212
  async function modelImageCapableOf(ctx, provider, model) {
205
213
  try {
206
214
  if (typeof provider !== "string" || provider === "" || typeof model !== "string" || model === "") return null;
207
- const resolveModelInfo = ctx.get?.("llm", false)?.resolveModelInfo;
208
- if (typeof resolveModelInfo !== "function") return null;
209
- const info = await resolveModelInfo(provider, model);
215
+ const llm = ctx.get?.("llm", false);
216
+ if (llm === null || typeof llm !== "object" || typeof llm.resolveModelInfo !== "function") return null;
217
+ const info = await llm.resolveModelInfo(provider, model);
210
218
  if (info?.inputModalities === undefined) return true;
211
219
  return Array.isArray(info.inputModalities) && info.inputModalities.includes("image");
212
220
  } catch {
@@ -214,15 +222,43 @@ async function modelImageCapableOf(ctx, provider, model) {
214
222
  }
215
223
  }
216
224
 
225
+ /** Image verdict for one session's model SELECTION. A concrete route is
226
+ * judged directly; the dsh-switchman-auto pseudo route is judged through
227
+ * the intent layer's current decided route (the adapter's routing point is
228
+ * invisible to every DSH projection, so switchman's own routedCurrent is
229
+ * the only source). No decision yet (fresh auto session) or an unreadable
230
+ * intent state yields null — "don't know", not incapable. */
231
+ async function selectionImageCapableOf(ctx, config, selection, sessionId) {
232
+ if (selection === null) return null;
233
+ if (selection.provider === AUTO_PROVIDER) {
234
+ try {
235
+ const state = await intentStateOf(ctx, config, sessionId);
236
+ const decided = state?.current;
237
+ if (decided === null || typeof decided !== "object") return null;
238
+ return modelImageCapableOf(ctx, decided.provider, decided.model);
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+ return modelImageCapableOf(ctx, selection.provider, selection.model);
244
+ }
245
+
217
246
  /** Whether the first root session's selected model accepts image input.
218
- * Null when unknowable (no root, no usable selection). */
219
- async function rootImageCapableOf(ctx) {
247
+ * Null when unknowable. Best-effort for probes WITHOUT a session id only
248
+ * (the client dock always passes one): `selectionFor` lives on the
249
+ * session-controller's internal controller, not on the cordis ctx.agents
250
+ * registry, so the first read yields undefined on current DSH and the
251
+ * dsh ≥0.2.2 `agent.model.current()` shape is tried as the second
252
+ * attempt. The root's session id rides along so an auto-selection
253
+ * resolves its decided route through the intent layer (agent ids are
254
+ * session ids in the agents registry). */
255
+ async function rootImageCapableOf(ctx, config) {
220
256
  try {
221
257
  const root = ctx.agents?.roots?.()?.[0];
222
258
  if (root === undefined) return null;
223
- const current = ctx.agents?.selectionFor?.(root)?.current;
259
+ const current = ctx.agents?.selectionFor?.(root)?.current ?? root?.model?.current?.();
224
260
  if (!current?.provider || !current?.model) return null;
225
- return modelImageCapableOf(ctx, current.provider, current.model);
261
+ return selectionImageCapableOf(ctx, config, current, typeof root?.id === "string" ? root.id : null);
226
262
  } catch {
227
263
  return null;
228
264
  }
@@ -231,27 +267,27 @@ async function rootImageCapableOf(ctx) {
231
267
  /** Whether ONE named session's effective model accepts image input, via the
232
268
  * session-query observeSession channel (shared with host/subagent-model.js;
233
269
  * cold sessions resolve without activating the child). Null when the
234
- * session is unknown, unreadable, or carries no selection yet — callers
235
- * fall back to the root verdict. */
236
- async function sessionImageCapableOf(ctx, sessionId) {
237
- const selection = await sessionModelSelectionOf(ctx, sessionId);
238
- if (selection === null) return null;
239
- return modelImageCapableOf(ctx, selection.provider, selection.model);
270
+ * session is unknown, unreadable, or carries no selection yet. */
271
+ async function sessionImageCapableOf(ctx, config, sessionId) {
272
+ return selectionImageCapableOf(ctx, config, await sessionModelSelectionOf(ctx, sessionId), sessionId);
240
273
  }
241
274
 
242
275
  /**
243
276
  * Live vision-gate state for the client hint route. `sessionId` (an optional
244
277
  * child session id, from the vision-state route's `session` query parameter)
245
- * judges image capability against THAT session's model; a missing/unknown
246
- * session falls back to the first root session's verdict.
278
+ * judges image capability against THAT session's model — a known-but-
279
+ * unreadable session stays null instead of judging some other conversation's
280
+ * root model. Only probes WITHOUT a session id fall back to the first root
281
+ * session's verdict.
247
282
  * @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
248
283
  * @param {object} config - this plugin's live volatile config.
249
284
  * @param {string | null} [sessionId] - session to judge instead of the root.
250
285
  * @returns {Promise<{poolConfigured: boolean, imageCapable: boolean | null}>}
251
286
  */
252
287
  export async function visionStateOf(ctx, config, sessionId = null) {
253
- let imageCapable = typeof sessionId === "string" && sessionId !== "" ? await sessionImageCapableOf(ctx, sessionId) : null;
254
- if (imageCapable === null) imageCapable = await rootImageCapableOf(ctx);
288
+ const hasSession = typeof sessionId === "string" && sessionId !== "";
289
+ let imageCapable = hasSession ? await sessionImageCapableOf(ctx, config, sessionId) : null;
290
+ if (imageCapable === null && !hasSession) imageCapable = await rootImageCapableOf(ctx, config);
255
291
  return {
256
292
  poolConfigured: readPool(config.poolVision).length > 0,
257
293
  imageCapable,
@@ -306,8 +342,8 @@ export function applyVision(ctx, config) {
306
342
  return {
307
343
  kind: "error",
308
344
  text: isZh()
309
- ? "/vision:视觉池未配置模型。当前会话模型不支持读图——请先在 设置 → dsh-switchman → 派发池与模型排序 的「视觉池 · vision」中添加模型,再重新发送。"
310
- : "/vision: the vision pool has no models. This session model cannot read images — add vision-pool models under Settings → dsh-switchman → dispatch pools, then resend.",
345
+ ? "/vision:视觉池未配置模型——请先在 设置 → dsh-switchman → 派发池与模型排序 的「视觉池 · vision」中添加模型,再重新发送。"
346
+ : "/vision: the vision pool has no models — add vision-pool models under Settings → dsh-switchman → dispatch pools, then resend.",
311
347
  };
312
348
  }
313
349
  const store = ctx.get?.("attachments", false);
package/index.js CHANGED
@@ -33,6 +33,18 @@
33
33
  * subagent / subagent_fork results with one `model: provider/model@effort`
34
34
  * line from the child session's modelSelection projection (the same channel
35
35
  * host/vision.js reuses for the per-session vision-state verdict).
36
+ *
37
+ * The intent layer (host/intent.js) adds the "auto" pseudo-model: a
38
+ * `dsh-switchman-auto` adapter registered on `ctx.llm` (native catalog entry,
39
+ * composer dropdown) plus an `agent/request` waterfall listener that rewrites
40
+ * the pseudo route onto a real model — a small classifier model scores the
41
+ * recent conversation against the configured candidate list once per turn,
42
+ * the decision pins for the turn's retries and later steps, and a
43
+ * provider-cache window keeps the current model while confidence stays below
44
+ * the switch threshold. The llm/sessions services are reached through
45
+ * defensive ctx.get reads (NOT declared in inject): a deployment without the
46
+ * llm service leaves this layer a warn-once no-op — a documented runtime
47
+ * precondition, like sessionQuery/pluginManager above.
36
48
  */
37
49
 
38
50
  import { readdirSync, readFileSync } from "node:fs";
@@ -60,6 +72,9 @@ import { applyContextWatch } from "./host/context-watch.js";
60
72
  // Vision dispatch gate (/vision command + vision-state route for the
61
73
  // composer-dock hint): unlocks image sending on text-only models.
62
74
  import { applyVision } from "./host/vision.js";
75
+ // Intent-routing layer ("auto" pseudo-model: classifier-routed real model,
76
+ // per-turn pin + prompt-cache window; intent-state/intent-reset routes).
77
+ import { applyIntent } from "./host/intent.js";
63
78
  // Client-bridge routes (config snapshot/fenced write + model catalog for the
64
79
  // restricted browser half, which has no configForms/remote services).
65
80
  import { applyRoutes } from "./host/routes.js";
@@ -159,8 +174,8 @@ function loadSkillCandidates(root, warn) {
159
174
 
160
175
  /** Register the bundled dsh-switchman skill provider on `ctx.skills` and
161
176
  * mount the language (lang), dispatch (dispatch), teams (teams), watermark
162
- * (context-watch), vision (vision), subagent model visibility
163
- * (subagent-model), and client-bridge (routes) layers. */
177
+ * (context-watch), vision (vision), intent routing (intent), subagent model
178
+ * visibility (subagent-model), and client-bridge (routes) layers. */
164
179
  function apply(ctx, config) {
165
180
  const root = fileURLToPath(new URL("./skills/", import.meta.url));
166
181
  const warn = (message) => ctx.logger.warn(`dsh-switchman: ${message}`);
@@ -179,6 +194,7 @@ function apply(ctx, config) {
179
194
  applyTeams(ctx, config);
180
195
  applyContextWatch(ctx, config);
181
196
  applyVision(ctx, config);
197
+ applyIntent(ctx, config);
182
198
  applySubagentModel(ctx);
183
199
  applyRoutes(ctx, config);
184
200
  }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "dsh-switchman",
4
- "description": "Autonomous Agent Teams for the four shipped presets, plus language preferences, dispatch pools with model ranking, and context watermark control."
4
+ "description": "Autonomous Agent Teams for the four shipped presets, plus language preferences, dispatch pools with model ranking, context watermark control, and intent routing that auto-selects a model per turn."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "dsh-switchman 调度管家",
4
- "description": "为四个出厂预设注入「默认委派 + 分级验证」的自主智能体团队规程(总纲:返工是最贵的 token),并提供多语言偏好、派发池与模型能力排序、上下文水位控制的设置页。"
4
+ "description": "为四个出厂预设注入「默认委派 + 分级验证」的自主智能体团队规程(总纲:返工是最贵的 token),并提供多语言偏好、派发池与模型能力排序、上下文水位控制、意图识别自动选模型的设置页。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-switchman",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
4
4
  "description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
5
5
  "license": "MIT",
6
6
  "keywords": [