dsh-switchman 1.3.1 → 1.6.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 +56 -34
- package/README.es.md +53 -31
- package/README.fr.md +52 -30
- package/README.it.md +47 -25
- package/README.ja.md +54 -32
- package/README.ko.md +55 -33
- package/README.md +53 -31
- package/README.pt.md +47 -25
- package/README.ru.md +52 -30
- package/README.zh-TW.md +52 -30
- package/README.zh.md +46 -24
- package/client.js +2344 -40
- package/cordis.patch.yml +24 -8
- package/host/config.js +31 -0
- package/host/context-watch.js +424 -84
- package/host/dispatch.js +107 -6
- package/host/handover-state.js +38 -0
- package/host/lang.js +211 -36
- package/host/lib/dsh-home.js +18 -0
- package/host/routes.js +143 -8
- package/host/subagent-model.js +147 -0
- package/host/subagents.js +160 -0
- package/host/teams.js +583 -0
- package/host/ui-locale.js +35 -0
- package/host/vision.js +367 -0
- package/index.js +40 -8
- package/package.json +1 -1
- package/skills/db-query/package.json +1 -1
- package/skills/db-query/scripts/lib/cli.js +23 -23
- package/skills/db-query/scripts/lib/config.js +78 -61
- package/skills/db-query/scripts/lib/mysql-readonly.js +167 -158
- package/skills/db-query/scripts/lib/output.js +56 -55
- package/skills/db-query/scripts/lib/redis-readonly.js +116 -111
- package/skills/db-query/scripts/mysql-query.js +46 -42
- package/skills/db-query/scripts/redis-query.js +32 -29
- package/skills/db-query/scripts/setup.sh +16 -16
package/host/vision.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vision dispatch gate: the /vision command + vision-state helper.
|
|
3
|
+
*
|
|
4
|
+
* DSH refuses plain prompts containing images while the session model
|
|
5
|
+
* reports no image input (MODEL_DOES_NOT_SUPPORT_IMAGES, thrown at
|
|
6
|
+
* prompt admission — the composer never blocks attaching). The command
|
|
7
|
+
* channel is the sanctioned unlock: a command declaring
|
|
8
|
+
* input.attachments: true passes NO model-capability check (the same
|
|
9
|
+
* channel /goal and /plan use), so /vision admits pasted images on a
|
|
10
|
+
* text-only session, resolves each admitted object's host path through
|
|
11
|
+
* the attachment store, and re-emits them as a TEXT followup — paths
|
|
12
|
+
* plus dispatch instructions — that any model can act on by delegating
|
|
13
|
+
* to the vision pool. The store's content-addressed objects carry NO
|
|
14
|
+
* file extension, which MCP image-analysis tools reject on sight
|
|
15
|
+
* ("Unsupported image format"), and normalization may hand us formats
|
|
16
|
+
* some tools refuse outright (e.g. image/webp) — so each image is
|
|
17
|
+
* first materialized into <DSH_HOME>/dsh-switchman/vision/ as a
|
|
18
|
+
* durable, extension-named copy (native copy for PNG/JPEG, transcoded
|
|
19
|
+
* to PNG via the first available converter for everything else).
|
|
20
|
+
* Calling read_image in a text-only context would only yield a
|
|
21
|
+
* placeholder, so the followup forbids that. With an empty vision
|
|
22
|
+
* pool the command refuses with setup guidance instead, and the client
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
33
|
+
import { randomUUID } from "node:crypto";
|
|
34
|
+
import { constants } from "node:fs";
|
|
35
|
+
import { access, copyFile, mkdir, open, rm } from "node:fs/promises";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { promisify } from "node:util";
|
|
38
|
+
import { dshHome } from "./lib/dsh-home.js";
|
|
39
|
+
import { sessionModelSelectionOf } from "./subagent-model.js";
|
|
40
|
+
import { reportedUiLocale } from "./ui-locale.js";
|
|
41
|
+
|
|
42
|
+
const execFile = promisify(execFileCb);
|
|
43
|
+
|
|
44
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
45
|
+
|
|
46
|
+
/** Read one volatile settings field's plain value (live `.get()` ref or raw). */
|
|
47
|
+
function readPool(field) {
|
|
48
|
+
const value =
|
|
49
|
+
field !== null && typeof field === "object" && typeof field.get === "function" ? field.get() : field;
|
|
50
|
+
return Array.isArray(value) ? value : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Host path of one admitted normalized image object: prefer the store's
|
|
54
|
+
* authoritative imageHostPath, else derive the content-addressed path
|
|
55
|
+
* from the attachmentId (sha256 of the NORMALIZED bytes — mediaType and
|
|
56
|
+
* dimensions on the ref also describe the normalized object, not the
|
|
57
|
+
* user's original file). Returns undefined when neither works. */
|
|
58
|
+
function imageHostPathOf(store, ref) {
|
|
59
|
+
const viaStore = store?.imageHostPath?.(ref);
|
|
60
|
+
if (typeof viaStore === "string") return viaStore;
|
|
61
|
+
const match = /^sha256:([a-f0-9]{64})$/.exec(String(ref?.attachmentId ?? ""));
|
|
62
|
+
if (match === null) return undefined;
|
|
63
|
+
return join(dshHome(), "attachments", "v1", "objects", match[1].slice(0, 2), match[1]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Formats MCP image tools accept verbatim — copied as-is with their
|
|
67
|
+
* native extension. Everything else gets transcoded to PNG. */
|
|
68
|
+
const NATIVE_EXT = new Map([
|
|
69
|
+
["image/png", "png"],
|
|
70
|
+
["image/jpeg", "jpg"],
|
|
71
|
+
["image/pjpeg", "jpg"],
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** Extension for the best-effort raw fallback when no converter exists. */
|
|
75
|
+
const FALLBACK_EXT = new Map([
|
|
76
|
+
["image/webp", "webp"],
|
|
77
|
+
["image/gif", "gif"],
|
|
78
|
+
["image/bmp", "bmp"],
|
|
79
|
+
["image/tiff", "tiff"],
|
|
80
|
+
["image/avif", "avif"],
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/** Transcode-to-PNG chain, first executable wins (sips: macOS built-in;
|
|
84
|
+
* magick/ffmpeg: common cross-platform installs). */
|
|
85
|
+
const CONVERTERS = [
|
|
86
|
+
{ bin: "sips", args: (src, dst) => ["-s", "format", "png", src, "--out", dst] },
|
|
87
|
+
{ bin: "magick", args: (src, dst) => [src, dst] },
|
|
88
|
+
{ bin: "ffmpeg", args: (src, dst) => ["-y", "-i", src, dst] },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const exists = (path) =>
|
|
92
|
+
access(path, constants.F_OK)
|
|
93
|
+
.then(() => true)
|
|
94
|
+
.catch(() => false);
|
|
95
|
+
|
|
96
|
+
/** First-8-bytes PNG signature check — a converter killed mid-write
|
|
97
|
+
* (timeout, full disk) or exiting 0 with garbage output must not leave
|
|
98
|
+
* a corrupt file behind that the exists() cache would serve forever. */
|
|
99
|
+
async function looksLikePng(path) {
|
|
100
|
+
let handle;
|
|
101
|
+
try {
|
|
102
|
+
handle = await open(path, "r");
|
|
103
|
+
const buf = Buffer.alloc(8);
|
|
104
|
+
const { bytesRead } = await handle.read(buf, 0, 8, 0);
|
|
105
|
+
return bytesRead === 8 && buf.equals(PNG_MAGIC);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
} finally {
|
|
109
|
+
await handle?.close?.().catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const drop = (path) => rm(path, { force: true }).catch(() => {});
|
|
114
|
+
|
|
115
|
+
/** Materialize one admitted image into <DSH_HOME>/dsh-switchman/vision/
|
|
116
|
+
* as an extension-named, tool-readable copy keyed by its content hash
|
|
117
|
+
* (re-admitting the same object reuses the same file). PNG/JPEG copy
|
|
118
|
+
* natively; other formats transcode to PNG when a converter is
|
|
119
|
+
* available, else fall back to a native-extension copy the followup
|
|
120
|
+
* flags for manual conversion. Absolute last resort: the extension-less
|
|
121
|
+
* store path itself, clearly marked.
|
|
122
|
+
* @returns {Promise<{path: string, note?: string} | undefined>}
|
|
123
|
+
* undefined only when the store path itself is unresolvable. */
|
|
124
|
+
async function materializeImage(store, ref, warn) {
|
|
125
|
+
const src = imageHostPathOf(store, ref);
|
|
126
|
+
if (src === undefined) return undefined;
|
|
127
|
+
const idMatch = /^sha256:([a-f0-9]{64})$/.exec(String(ref?.attachmentId ?? ""));
|
|
128
|
+
const hash =
|
|
129
|
+
idMatch?.[1] ??
|
|
130
|
+
(String(ref?.attachmentId ?? "").replace(/[^A-Za-z0-9_-]/g, "") || randomUUID());
|
|
131
|
+
const dir = join(dshHome(), "dsh-switchman", "vision");
|
|
132
|
+
const native = NATIVE_EXT.get(String(ref?.mediaType ?? "").toLowerCase());
|
|
133
|
+
|
|
134
|
+
// Native PNG/JPEG: a plain copy is exactly what image tools want.
|
|
135
|
+
if (native !== undefined) {
|
|
136
|
+
const dst = join(dir, `${hash}.${native}`);
|
|
137
|
+
if (await exists(dst)) return { path: dst };
|
|
138
|
+
try {
|
|
139
|
+
await mkdir(dir, { recursive: true });
|
|
140
|
+
await copyFile(src, dst);
|
|
141
|
+
return { path: dst };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
warn(`vision: native copy failed (${error?.message ?? error})`);
|
|
144
|
+
return {
|
|
145
|
+
path: src,
|
|
146
|
+
note: "extension-less store path — copy it to a .png/.jpg file before image tools can read it",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Everything else: transcode to PNG via the first usable converter.
|
|
152
|
+
const dst = join(dir, `${hash}.png`);
|
|
153
|
+
if (await exists(dst)) {
|
|
154
|
+
if (await looksLikePng(dst)) return { path: dst, note: "transcoded to PNG" };
|
|
155
|
+
await drop(dst); // cached file is corrupt — rebuild it
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await mkdir(dir, { recursive: true });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
warn(`vision: vision dir unavailable (${error?.message ?? error})`);
|
|
161
|
+
return {
|
|
162
|
+
path: src,
|
|
163
|
+
note: "extension-less store path — copy it to a .png file before image tools can read it",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
for (const { bin, args } of CONVERTERS) {
|
|
167
|
+
try {
|
|
168
|
+
await execFile(bin, args(src, dst), { timeout: 20_000, windowsHide: true });
|
|
169
|
+
} catch {
|
|
170
|
+
// Converter missing or failed on this input — drop any partial
|
|
171
|
+
// output, then try the next one.
|
|
172
|
+
await drop(dst);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (await looksLikePng(dst)) return { path: dst, note: `transcoded to PNG via ${bin}` };
|
|
176
|
+
await drop(dst); // exited 0 but output is not a PNG — keep trying
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// No converter: best-effort native-extension copy + explicit flag.
|
|
180
|
+
const ext = FALLBACK_EXT.get(String(ref?.mediaType ?? "").toLowerCase()) ?? "img";
|
|
181
|
+
const raw = join(dir, `${hash}.${ext}`);
|
|
182
|
+
try {
|
|
183
|
+
await copyFile(src, raw);
|
|
184
|
+
warn(`vision: no PNG converter available; kept ${ref?.mediaType} copy at ${raw}`);
|
|
185
|
+
return {
|
|
186
|
+
path: raw,
|
|
187
|
+
note: "not transcoded — some image tools reject this format; convert it to PNG yourself first (e.g. sips -s format png <path> --out <out>.png)",
|
|
188
|
+
};
|
|
189
|
+
} catch (error) {
|
|
190
|
+
warn(`vision: fallback copy failed (${error?.message ?? error})`);
|
|
191
|
+
return {
|
|
192
|
+
path: src,
|
|
193
|
+
note: "extension-less store path — copy it to a .png file before image tools can read it",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Whether ONE exact model route accepts image input — the shared core of
|
|
199
|
+
* the root and per-session verdicts. Mirrors the prompt-admission gate's
|
|
200
|
+
* exact semantics: undefined inputModalities pass (no restriction
|
|
201
|
+
* declared); anything else must include "image". Returns null when
|
|
202
|
+
* unknowable (bad route, no llm service, resolution failure) — callers
|
|
203
|
+
* treat null as "don't know", never as capable/incapable. */
|
|
204
|
+
async function modelImageCapableOf(ctx, provider, model) {
|
|
205
|
+
try {
|
|
206
|
+
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);
|
|
210
|
+
if (info?.inputModalities === undefined) return true;
|
|
211
|
+
return Array.isArray(info.inputModalities) && info.inputModalities.includes("image");
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 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) {
|
|
220
|
+
try {
|
|
221
|
+
const root = ctx.agents?.roots?.()?.[0];
|
|
222
|
+
if (root === undefined) return null;
|
|
223
|
+
const current = ctx.agents?.selectionFor?.(root)?.current;
|
|
224
|
+
if (!current?.provider || !current?.model) return null;
|
|
225
|
+
return modelImageCapableOf(ctx, current.provider, current.model);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Whether ONE named session's effective model accepts image input, via the
|
|
232
|
+
* session-query observeSession channel (shared with host/subagent-model.js;
|
|
233
|
+
* 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);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Live vision-gate state for the client hint route. `sessionId` (an optional
|
|
244
|
+
* 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.
|
|
247
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
|
|
248
|
+
* @param {object} config - this plugin's live volatile config.
|
|
249
|
+
* @param {string | null} [sessionId] - session to judge instead of the root.
|
|
250
|
+
* @returns {Promise<{poolConfigured: boolean, imageCapable: boolean | null}>}
|
|
251
|
+
*/
|
|
252
|
+
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);
|
|
255
|
+
return {
|
|
256
|
+
poolConfigured: readPool(config.poolVision).length > 0,
|
|
257
|
+
imageCapable,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Localized (zh/en) helper for user-facing command result text. */
|
|
262
|
+
const isZh = () => (reportedUiLocale() ?? "").toLowerCase().startsWith("zh");
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Mount the /vision command (global, plain-context registration).
|
|
266
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
|
|
267
|
+
* @param {object} config - this plugin's live volatile config.
|
|
268
|
+
*/
|
|
269
|
+
export function applyVision(ctx, config) {
|
|
270
|
+
const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
|
|
271
|
+
ctx.commands?.register?.({
|
|
272
|
+
name: "vision",
|
|
273
|
+
description:
|
|
274
|
+
"Send pasted images with a text-only session model: admits the attachments, hands their host paths to the agent, and instructs vision-pool dispatch.",
|
|
275
|
+
input: { hint: "[message to accompany the images]", attachments: true },
|
|
276
|
+
handler: async (invocation) => {
|
|
277
|
+
const agent = invocation?.agent;
|
|
278
|
+
const attachments = Array.isArray(invocation?.attachments) ? invocation.attachments : [];
|
|
279
|
+
const message = (invocation?.rawInput ?? "").trim();
|
|
280
|
+
// Defensive (the frozen CommandInvocation contract always carries
|
|
281
|
+
// agent): a missing followup must NOT yield a false success below.
|
|
282
|
+
if (typeof agent?.followup !== "function") {
|
|
283
|
+
return {
|
|
284
|
+
kind: "error",
|
|
285
|
+
text: isZh() ? "/vision:当前会话不可用,未发送。" : "/vision: no usable session agent; nothing was sent.",
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const images = attachments.filter((block) => block?.type === "image" && block.attachment);
|
|
289
|
+
if (attachments.some((block) => block?.type === "file")) {
|
|
290
|
+
return {
|
|
291
|
+
kind: "error",
|
|
292
|
+
text: isZh()
|
|
293
|
+
? "/vision:仅接受图片附件。普通文件不受模型读图限制,直接随普通消息发送即可。"
|
|
294
|
+
: "/vision: image attachments only. Generic files are not gated by image support — send them with a normal message.",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (images.length === 0) {
|
|
298
|
+
return {
|
|
299
|
+
kind: "error",
|
|
300
|
+
text: isZh()
|
|
301
|
+
? "/vision:没有图片附件。请先粘贴或拖入图片,再发送本命令。"
|
|
302
|
+
: "/vision: no image attachments. Paste or drop images before sending this command.",
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (readPool(config.poolVision).length === 0) {
|
|
306
|
+
return {
|
|
307
|
+
kind: "error",
|
|
308
|
+
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.",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const store = ctx.get?.("attachments", false);
|
|
314
|
+
const lines = [];
|
|
315
|
+
for (const block of images) {
|
|
316
|
+
const material = await materializeImage(store, block.attachment, warn);
|
|
317
|
+
if (material === undefined) {
|
|
318
|
+
warn("vision: could not resolve host path for admitted image");
|
|
319
|
+
return {
|
|
320
|
+
kind: "error",
|
|
321
|
+
text: isZh()
|
|
322
|
+
? "/vision:无法解析某张已收图片的宿主路径(附件存储不可达且推导失败),本次未发送。"
|
|
323
|
+
: "/vision: could not resolve the host path of an admitted image (attachment store unreachable and derivation failed); nothing was sent.",
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const ref = block.attachment;
|
|
327
|
+
const name = typeof ref.name === "string" && ref.name !== "" ? `, ${ref.name}` : "";
|
|
328
|
+
const note = material.note === undefined ? "" : ` — ${material.note}`;
|
|
329
|
+
lines.push(`- ${material.path} (${ref.mediaType}${name}, ${ref.width}x${ref.height}${note})`);
|
|
330
|
+
}
|
|
331
|
+
const followup = [
|
|
332
|
+
"[SWITCHMAN:VISION] The user sent image attachment(s) that this session model cannot read directly.",
|
|
333
|
+
`User message: ${message === "" ? "(none)" : message}`,
|
|
334
|
+
"Admitted normalized objects, already materialized as extension-named readable copies (absolute host paths):",
|
|
335
|
+
...lines,
|
|
336
|
+
"Read them by delegating to the vision pool (vision lane): pass a local file path above to an MCP image-analysis tool that accepts paths (e.g. analyze_image with image_source=<path>). Each path already carries a proper file extension (non-PNG/JPEG formats were transcoded to PNG where a converter was available), so image tools accept the paths directly — do not re-copy files yourself unless a per-file note asks you to. Do NOT call read_image on them yourself — a text-only context would only receive a placeholder. Then answer the user in their language.",
|
|
337
|
+
].join("\n");
|
|
338
|
+
try {
|
|
339
|
+
agent?.followup?.({
|
|
340
|
+
// The gateway's stored-session validation (assertMessageEventShape)
|
|
341
|
+
// rejects a user/message whose record lacks a non-empty id/role —
|
|
342
|
+
// an id-less synthetic message bricks the session on reload
|
|
343
|
+
// ("session event at seq N lacks an identified message").
|
|
344
|
+
id: randomUUID(),
|
|
345
|
+
role: "user",
|
|
346
|
+
content: [{ type: "text", text: followup }],
|
|
347
|
+
source: { kind: "user" },
|
|
348
|
+
});
|
|
349
|
+
await ctx.sessions?.flush?.(agent?.session);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
warn(`vision: followup failed: ${error?.message ?? error}`);
|
|
352
|
+
return {
|
|
353
|
+
kind: "error",
|
|
354
|
+
text: isZh()
|
|
355
|
+
? `/vision:消息回注失败(${error?.message ?? error}),图片未送达会话。`
|
|
356
|
+
: `/vision: followup failed (${error?.message ?? error}); the images were not delivered.`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
kind: "success",
|
|
361
|
+
text: isZh()
|
|
362
|
+
? `[SWITCHMAN:VISION] 已放行 ${images.length} 张图片,并把带扩展名的可读副本+视觉派发指令注入会话。`
|
|
363
|
+
: `[SWITCHMAN:VISION] ${images.length} image(s) admitted; extension-named readable copies + vision-dispatch instructions injected into the session.`,
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
package/index.js
CHANGED
|
@@ -20,8 +20,19 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Phase 3 mounts the context-watermark layer (host/context-watch.js): the
|
|
22
22
|
* `[SWITCHMAN:WATERMARK]` tiered banner from the live token meter, a per-turn
|
|
23
|
-
* read budget with deny/cap gates, force-tier
|
|
24
|
-
*
|
|
23
|
+
* read budget with deny/cap gates, the four-step force-tier handover
|
|
24
|
+
* (backup fork + document + compaction + continuation) plus the
|
|
25
|
+
* ctx_handover agent tool, and the /ctx-pause, /ctx-resume,
|
|
26
|
+
* /ctx-handover commands.
|
|
27
|
+
*
|
|
28
|
+
* Teams mode mounts two more layers: host/teams.js carries the team clauses
|
|
29
|
+
* extracted from the cordis.patch.yml suffix in a `[SWITCHMAN:TEAMS]` section
|
|
30
|
+
* (order 10250, injected only while teamsMode is ON) plus the deferred ON
|
|
31
|
+
* orchestration (agent-team bundle enable, then the optional six-pool
|
|
32
|
+
* → DSH whitelist replace-sync); host/subagent-model.js annotates successful
|
|
33
|
+
* subagent / subagent_fork results with one `model: provider/model@effort`
|
|
34
|
+
* line from the child session's modelSelection projection (the same channel
|
|
35
|
+
* host/vision.js reuses for the per-session vision-state verdict).
|
|
25
36
|
*/
|
|
26
37
|
|
|
27
38
|
import { readdirSync, readFileSync } from "node:fs";
|
|
@@ -38,9 +49,17 @@ import { applyLanguage } from "./host/lang.js";
|
|
|
38
49
|
// Dispatch-pool layer (pools recommendation section + enforce gate).
|
|
39
50
|
import { applyDispatch } from "./host/dispatch.js";
|
|
40
51
|
|
|
52
|
+
// Agent Teams layer (teams-mode doctrine section + bundle/whitelist
|
|
53
|
+
// orchestration; the clause text extracted from the cordis.patch.yml suffix).
|
|
54
|
+
import { applyTeams } from "./host/teams.js";
|
|
55
|
+
// Subagent model-visibility layer (post-execute `model:` annotation).
|
|
56
|
+
import { applySubagentModel } from "./host/subagent-model.js";
|
|
57
|
+
|
|
41
58
|
// Context-watermark layer (banner + read budget + handover + /ctx-* commands).
|
|
42
59
|
import { applyContextWatch } from "./host/context-watch.js";
|
|
43
|
-
|
|
60
|
+
// Vision dispatch gate (/vision command + vision-state route for the
|
|
61
|
+
// composer-dock hint): unlocks image sending on text-only models.
|
|
62
|
+
import { applyVision } from "./host/vision.js";
|
|
44
63
|
// Client-bridge routes (config snapshot/fenced write + model catalog for the
|
|
45
64
|
// restricted browser half, which has no configForms/remote services).
|
|
46
65
|
import { applyRoutes } from "./host/routes.js";
|
|
@@ -51,14 +70,23 @@ const name = "dsh-switchman";
|
|
|
51
70
|
/** Services this plugin contributes to: the bundled-skill registry, the
|
|
52
71
|
* system-prompt section registry, the settings store it writes back to, the
|
|
53
72
|
* token meter and agent registry the watermark reads, the command registry
|
|
54
|
-
* for /ctx-*, the
|
|
55
|
-
*
|
|
73
|
+
* for /ctx-*, the tools registry for the ctx_handover agent tool, the
|
|
74
|
+
* subagents service the handover's backup fork goes through, the sessions
|
|
75
|
+
* persistence the handover's continuation flush uses, the plugin-manager
|
|
76
|
+
* service the teams layer's bundle enable goes through, and the session
|
|
77
|
+
* query engine the subagent model annotation + per-session vision verdicts
|
|
78
|
+
* read child modelSelection projections from. pluginManager and
|
|
79
|
+
* sessionQuery are profile-composition services (the plugin-manager bundle
|
|
80
|
+
* is disabled without profileContext; session-query ships in the base
|
|
81
|
+
* patch): deployments lacking them leave this fiber INACTIVE — a documented
|
|
82
|
+
* runtime precondition, not a degraded mode. The defensive ctx.get calls
|
|
83
|
+
* inside teams.js / subagent-model.js guard API drift, not absence.
|
|
56
84
|
* Compaction is deliberately NOT injected: the desktop profile provides it
|
|
57
85
|
* only inside each preset's per-agent isolated group (isolate: { compaction:
|
|
58
86
|
* true }), never at this root — host/context-watch.js reaches it by borrowing
|
|
59
87
|
* the session's own per-agent /compact handler through
|
|
60
88
|
* ctx.commands.find(agent, "compact"). */
|
|
61
|
-
const inject = ["skills", "systemPrompt", "settings", "tokenMeter", "commands", "agents", "webServer", "subagents", "sessions"];
|
|
89
|
+
const inject = ["skills", "systemPrompt", "settings", "tokenMeter", "commands", "tools", "agents", "webServer", "subagents", "sessions", "pluginManager", "sessionQuery"];
|
|
62
90
|
|
|
63
91
|
/** Must equal `BUNDLED_SKILL_RANK` of @deepseek-ai/dsh-skill (packaged skill
|
|
64
92
|
* providers and local bundled roots; below user entries, above none). Not
|
|
@@ -130,8 +158,9 @@ function loadSkillCandidates(root, warn) {
|
|
|
130
158
|
}
|
|
131
159
|
|
|
132
160
|
/** Register the bundled dsh-switchman skill provider on `ctx.skills` and
|
|
133
|
-
* mount the language (lang), dispatch (dispatch),
|
|
134
|
-
* (context-watch)
|
|
161
|
+
* 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. */
|
|
135
164
|
function apply(ctx, config) {
|
|
136
165
|
const root = fileURLToPath(new URL("./skills/", import.meta.url));
|
|
137
166
|
const warn = (message) => ctx.logger.warn(`dsh-switchman: ${message}`);
|
|
@@ -147,7 +176,10 @@ function apply(ctx, config) {
|
|
|
147
176
|
ctx.skills.registerProvider(() => provider);
|
|
148
177
|
applyLanguage(ctx, config);
|
|
149
178
|
applyDispatch(ctx, config);
|
|
179
|
+
applyTeams(ctx, config);
|
|
150
180
|
applyContextWatch(ctx, config);
|
|
181
|
+
applyVision(ctx, config);
|
|
182
|
+
applySubagentModel(ctx);
|
|
151
183
|
applyRoutes(ctx, config);
|
|
152
184
|
}
|
|
153
185
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-switchman",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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": [
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Strict CLI argument parser
|
|
5
|
+
* Supports --key=value, --key value, boolean flags, and -- to separate SQL/Redis commands
|
|
6
|
+
* Unknown options, duplicate conflicts, out-of-range numbers → fail immediately (fail-closed)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
class CliError extends Error {
|
|
@@ -11,16 +11,16 @@ class CliError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Parse CLI arguments and return { options: {}, rest: string[] }
|
|
15
15
|
* @param {string[]} argv - process.argv.slice(2)
|
|
16
|
-
* @param {object} spec -
|
|
16
|
+
* @param {object} spec - option spec { name: { type, default, min, max, alias } }
|
|
17
17
|
*/
|
|
18
18
|
function parseCli(argv, spec) {
|
|
19
19
|
const options = {};
|
|
20
20
|
const rest = [];
|
|
21
21
|
const seen = new Set();
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// Fill in defaults
|
|
24
24
|
for (const [key, def] of Object.entries(spec)) {
|
|
25
25
|
if (def.default !== undefined) options[key] = def.default;
|
|
26
26
|
}
|
|
@@ -29,13 +29,13 @@ function parseCli(argv, spec) {
|
|
|
29
29
|
while (i < argv.length) {
|
|
30
30
|
const arg = argv[i];
|
|
31
31
|
|
|
32
|
-
// --
|
|
32
|
+
// -- separator: everything after it is positional
|
|
33
33
|
if (arg === '--') {
|
|
34
34
|
rest.push(...argv.slice(i + 1));
|
|
35
35
|
break;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// --option=value
|
|
38
|
+
// --option=value or --option value form
|
|
39
39
|
if (arg.startsWith('--')) {
|
|
40
40
|
let key, value;
|
|
41
41
|
const eqIdx = arg.indexOf('=');
|
|
@@ -44,29 +44,29 @@ function parseCli(argv, spec) {
|
|
|
44
44
|
value = arg.slice(eqIdx + 1);
|
|
45
45
|
} else {
|
|
46
46
|
key = arg.slice(2);
|
|
47
|
-
//
|
|
47
|
+
// Look up the option's type in the spec
|
|
48
48
|
const s = findSpec(spec, key);
|
|
49
|
-
if (!s) throw new CliError(
|
|
49
|
+
if (!s) throw new CliError(`Unknown option: --${key}`);
|
|
50
50
|
if (s.type === 'boolean') {
|
|
51
51
|
value = true;
|
|
52
52
|
} else {
|
|
53
53
|
i++;
|
|
54
|
-
if (i >= argv.length) throw new CliError(`--${key}
|
|
54
|
+
if (i >= argv.length) throw new CliError(`--${key} requires a value`);
|
|
55
55
|
value = argv[i];
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
const resolved = resolveAlias(spec, key);
|
|
60
|
-
if (!resolved) throw new CliError(
|
|
60
|
+
if (!resolved) throw new CliError(`Unknown option: --${key}`);
|
|
61
61
|
|
|
62
|
-
//
|
|
63
|
-
if (seen.has(resolved)) throw new CliError(
|
|
62
|
+
// Duplicate option check
|
|
63
|
+
if (seen.has(resolved)) throw new CliError(`Duplicate option: --${resolved}`);
|
|
64
64
|
seen.add(resolved);
|
|
65
65
|
|
|
66
66
|
const s = spec[resolved];
|
|
67
67
|
options[resolved] = coerceValue(resolved, value, s);
|
|
68
68
|
} else {
|
|
69
|
-
//
|
|
69
|
+
// Non-option argument → positional
|
|
70
70
|
rest.push(arg);
|
|
71
71
|
}
|
|
72
72
|
i++;
|
|
@@ -75,7 +75,7 @@ function parseCli(argv, spec) {
|
|
|
75
75
|
return { options, rest };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/**
|
|
78
|
+
/** Find an option name in the spec or its aliases */
|
|
79
79
|
function findSpec(spec, key) {
|
|
80
80
|
if (spec[key]) return spec[key];
|
|
81
81
|
for (const s of Object.values(spec)) {
|
|
@@ -84,7 +84,7 @@ function findSpec(spec, key) {
|
|
|
84
84
|
return null;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/**
|
|
87
|
+
/** Resolve an alias to its canonical name */
|
|
88
88
|
function resolveAlias(spec, key) {
|
|
89
89
|
if (spec[key]) return key;
|
|
90
90
|
for (const [name, s] of Object.entries(spec)) {
|
|
@@ -93,23 +93,23 @@ function resolveAlias(spec, key) {
|
|
|
93
93
|
return null;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/**
|
|
96
|
+
/** Type coercion + bounds checking */
|
|
97
97
|
function coerceValue(key, raw, spec) {
|
|
98
98
|
if (spec.type === 'boolean') {
|
|
99
99
|
if (raw === 'true' || raw === true) return true;
|
|
100
100
|
if (raw === 'false' || raw === false) return false;
|
|
101
|
-
throw new CliError(`--${key}
|
|
101
|
+
throw new CliError(`--${key} expects a boolean, got: ${raw}`);
|
|
102
102
|
}
|
|
103
103
|
if (spec.type === 'integer') {
|
|
104
104
|
const n = Number(raw);
|
|
105
|
-
if (!Number.isInteger(n)) throw new CliError(`--${key}
|
|
106
|
-
if (spec.min !== undefined && n < spec.min) throw new CliError(`--${key}
|
|
107
|
-
if (spec.max !== undefined && n > spec.max) throw new CliError(`--${key}
|
|
105
|
+
if (!Number.isInteger(n)) throw new CliError(`--${key} expects an integer, got: ${raw}`);
|
|
106
|
+
if (spec.min !== undefined && n < spec.min) throw new CliError(`--${key} minimum is ${spec.min}, got: ${n}`);
|
|
107
|
+
if (spec.max !== undefined && n > spec.max) throw new CliError(`--${key} maximum is ${spec.max}, got: ${n}`);
|
|
108
108
|
return n;
|
|
109
109
|
}
|
|
110
110
|
if (spec.type === 'string' && spec.choices) {
|
|
111
111
|
const lower = String(raw).toLowerCase();
|
|
112
|
-
if (!spec.choices.includes(lower)) throw new CliError(`--${key}
|
|
112
|
+
if (!spec.choices.includes(lower)) throw new CliError(`--${key} must be one of ${spec.choices.join('|')}, got: ${raw}`);
|
|
113
113
|
return lower;
|
|
114
114
|
}
|
|
115
115
|
return String(raw);
|