dsh-switchman 1.3.0 → 1.5.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/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 fire-and-forget handover into
24
- * compaction, and the /ctx-pause, /ctx-resume, /ctx-handover commands.
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 subagents service the handover's backup fork goes through,
55
- * and the sessions persistence the handover's continuation flush uses.
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), and watermark
134
- * (context-watch) layers. */
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.0",
3
+ "version": "1.5.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": [
@@ -66,5 +66,8 @@
66
66
  "@deepseek-ai/dsh-client-ui-settings"
67
67
  ]
68
68
  }
69
+ },
70
+ "devDependencies": {
71
+ "yaml": "^2.9.1"
69
72
  }
70
73
  }