dsh-switchman 1.7.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +25 -4
- package/README.es.md +25 -4
- package/README.fr.md +25 -4
- package/README.it.md +25 -4
- package/README.ja.md +25 -4
- package/README.ko.md +25 -4
- package/README.md +25 -4
- package/README.pt.md +25 -4
- package/README.ru.md +25 -4
- package/README.zh-TW.md +25 -4
- package/README.zh.md +25 -4
- package/client.js +1916 -35
- package/host/config.js +93 -4
- package/host/context-watch.js +126 -58
- package/host/intent-presets.js +163 -0
- package/host/intent.js +952 -0
- package/host/routes.js +158 -3
- package/host/vision.js +62 -26
- package/index.js +18 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* session
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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
|
-
*
|
|
219
|
-
|
|
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
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
246
|
-
* session
|
|
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
|
-
|
|
254
|
-
|
|
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
|
|
310
|
-
: "/vision: the vision pool has no models
|
|
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
|
|
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,
|
|
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.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|