dsh-switchman 1.3.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/LICENSE +21 -0
- package/README.md +79 -0
- package/README.zh.md +79 -0
- package/client.js +2441 -0
- package/cordis.patch.yml +599 -0
- package/host/config.js +116 -0
- package/host/context-watch.js +620 -0
- package/host/data/capability-default.json +1097 -0
- package/host/dispatch.js +205 -0
- package/host/lang.js +182 -0
- package/host/lib/rank.js +187 -0
- package/host/routes.js +398 -0
- package/icon.svg +7 -0
- package/index.js +154 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -0
- package/skills/db-query/SKILL.md +322 -0
- package/skills/db-query/package-lock.json +251 -0
- package/skills/db-query/package.json +20 -0
- package/skills/db-query/references/mysql.md +137 -0
- package/skills/db-query/references/redis.md +213 -0
- package/skills/db-query/references/security.md +176 -0
- package/skills/db-query/scripts/lib/cli.js +118 -0
- package/skills/db-query/scripts/lib/config.js +215 -0
- package/skills/db-query/scripts/lib/mysql-readonly.js +655 -0
- package/skills/db-query/scripts/lib/output.js +322 -0
- package/skills/db-query/scripts/lib/redis-readonly.js +362 -0
- package/skills/db-query/scripts/mysql-query.js +198 -0
- package/skills/db-query/scripts/redis-query.js +181 -0
- package/skills/db-query/scripts/setup.sh +51 -0
- package/skills/git-commit-message/SKILL.md +49 -0
- package/skills/requirement-docs/SKILL.md +38 -0
- package/skills/requirement-docs/references/templates.md +187 -0
package/host/routes.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/** Host-side HTTP routes for the dsh-switchman client half.
|
|
2
|
+
*
|
|
3
|
+
* The browser half of this bundle runs inside the restricted dynamic-module
|
|
4
|
+
* runtime: it has no `configForms`/`remote` services and no direct network —
|
|
5
|
+
* the sanctioned bridge is same-origin `fetch` against routes the Host half
|
|
6
|
+
* registers on `ctx.webServer` (the pattern the shipped skill-explorer
|
|
7
|
+
* plugin established). This module owns that route family:
|
|
8
|
+
*
|
|
9
|
+
* GET /api/dsh-switchman/health — liveness probe for the client page.
|
|
10
|
+
* GET /api/dsh-switchman/config — current settings values snapshot.
|
|
11
|
+
* POST /api/dsh-switchman/config — { expected, values } fenced write
|
|
12
|
+
* through ctx.settings.update; a stale
|
|
13
|
+
* `expected` answers 409 + fresh values.
|
|
14
|
+
* GET /api/dsh-switchman/models — model-catalog pass-through from the
|
|
15
|
+
* host sessionController (degrades to
|
|
16
|
+
* llm.listProviders, then []).
|
|
17
|
+
*
|
|
18
|
+
* Every route carries the shared trust fence (loopback + same-origin browser
|
|
19
|
+
* markers; a live remoteWebUiPairing cookie is an extra allow path), mirroring
|
|
20
|
+
* skill-explorer's access model: the write route changes real persisted
|
|
21
|
+
* settings, so unpaired LAN clients must not reach it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Route paths (client.js mirrors these literals). */
|
|
25
|
+
const ROUTES = {
|
|
26
|
+
health: "/api/dsh-switchman/health",
|
|
27
|
+
config: "/api/dsh-switchman/config",
|
|
28
|
+
models: "/api/dsh-switchman/models",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Settings namespace this plugin's writes target: the LOADER ENTRY id from
|
|
32
|
+
* cordis.patch.yml (`- id: dsh-switchman-host`), not the plugin's `name`
|
|
33
|
+
* export — dsh-settings' write() looks entries up by entry.options.id. */
|
|
34
|
+
const NS = "dsh-switchman-host";
|
|
35
|
+
|
|
36
|
+
/** All settings field names, grouped for coercion (mirrors host/config.js). */
|
|
37
|
+
const STRING_FIELDS = ["langConversation", "langComments", "langDocs"];
|
|
38
|
+
const POOL_FIELDS = [
|
|
39
|
+
"poolEconomy",
|
|
40
|
+
"poolMechanical",
|
|
41
|
+
"poolMain",
|
|
42
|
+
"poolHard",
|
|
43
|
+
"poolVision",
|
|
44
|
+
"poolReview",
|
|
45
|
+
];
|
|
46
|
+
const NUMBER_FIELDS = [
|
|
47
|
+
"wmSoftTokens",
|
|
48
|
+
"wmHardTokens",
|
|
49
|
+
"wmForceTokens",
|
|
50
|
+
"wmReadBudgetTokens",
|
|
51
|
+
"wmSubagentForceTokens",
|
|
52
|
+
];
|
|
53
|
+
const BOOLEAN_FIELDS = [
|
|
54
|
+
"wmAutoHandover",
|
|
55
|
+
"wmSubagentCap",
|
|
56
|
+
// 以下六个 mirrors host/config.js 的 pool*Manual 开关
|
|
57
|
+
"poolEconomyManual",
|
|
58
|
+
"poolMechanicalManual",
|
|
59
|
+
"poolMainManual",
|
|
60
|
+
"poolHardManual",
|
|
61
|
+
"poolVisionManual",
|
|
62
|
+
"poolReviewManual",
|
|
63
|
+
];
|
|
64
|
+
/** Per-pool effort-route lists (mirrors host/config.js pool*Efforts). */
|
|
65
|
+
const EFFORTS_FIELDS = [
|
|
66
|
+
"poolEconomyEfforts",
|
|
67
|
+
"poolMechanicalEfforts",
|
|
68
|
+
"poolMainEfforts",
|
|
69
|
+
"poolHardEfforts",
|
|
70
|
+
"poolVisionEfforts",
|
|
71
|
+
"poolReviewEfforts",
|
|
72
|
+
];
|
|
73
|
+
const ENUM_FIELDS = {
|
|
74
|
+
dispatchEnforce: ["off", "advice", "enforce"],
|
|
75
|
+
wmDenyMode: ["cap", "deny"],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** JSON response helper. */
|
|
79
|
+
function writeJson(res, status, payload) {
|
|
80
|
+
const body = JSON.stringify(payload);
|
|
81
|
+
res.writeHead(status, {
|
|
82
|
+
"content-type": "application/json; charset=utf-8",
|
|
83
|
+
"cache-control": "no-store",
|
|
84
|
+
});
|
|
85
|
+
res.end(body);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read and parse one JSON request body (size-capped at 1 MiB). */
|
|
89
|
+
function readJsonBody(req) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let size = 0;
|
|
93
|
+
req.on("data", (chunk) => {
|
|
94
|
+
size += chunk.length;
|
|
95
|
+
if (size > 1024 * 1024) {
|
|
96
|
+
reject(new Error("body too large"));
|
|
97
|
+
req.destroy();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
chunks.push(chunk);
|
|
101
|
+
});
|
|
102
|
+
req.on("end", () => {
|
|
103
|
+
try {
|
|
104
|
+
resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
reject(new Error(`invalid JSON body: ${error?.message ?? error}`));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
req.on("error", reject);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether a Host-header hostname names the local loopback authority. */
|
|
114
|
+
function isLoopbackHostname(hostname) {
|
|
115
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
116
|
+
if (!hostname || hostname.startsWith("[")) return false;
|
|
117
|
+
const parts = hostname.split(".");
|
|
118
|
+
return parts.length === 4 && parts.every((p) => /^\d+$/u.test(p)) && parts[0] === "127";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Whether this request comes from this machine's own browser page. */
|
|
122
|
+
function isLoopbackRequest(request) {
|
|
123
|
+
const host = request.headers?.host;
|
|
124
|
+
if (typeof host !== "string" || host === "") return false;
|
|
125
|
+
let hostUrl;
|
|
126
|
+
try {
|
|
127
|
+
hostUrl = new URL(`http://${host}`);
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
132
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
133
|
+
const origin = request.headers.origin;
|
|
134
|
+
if (origin === undefined) return true;
|
|
135
|
+
try {
|
|
136
|
+
return new URL(origin).host === hostUrl.host;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether this request may enter the plugin's host routes: loopback by
|
|
144
|
+
* default, or a live paired-device cookie when remote-web-ui is loaded.
|
|
145
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx - host context.
|
|
146
|
+
* @param {import("node:http").IncomingMessage} request - incoming request.
|
|
147
|
+
*/
|
|
148
|
+
function isAllowed(ctx, request) {
|
|
149
|
+
if (isLoopbackRequest(request)) return true;
|
|
150
|
+
const pairing = typeof ctx.get === "function" ? ctx.get("remoteWebUiPairing", false) : undefined;
|
|
151
|
+
return (
|
|
152
|
+
pairing !== undefined &&
|
|
153
|
+
pairing !== null &&
|
|
154
|
+
typeof pairing.isPairedDevice === "function" &&
|
|
155
|
+
pairing.isPairedDevice(request) === true
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Read one volatile settings field's raw value (live `.get()` reference). */
|
|
160
|
+
function rawOf(field) {
|
|
161
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") return field.get();
|
|
162
|
+
return field;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Coerce one route candidate `{provider, model}` entry; null when invalid. */
|
|
166
|
+
function cleanRoute(entry) {
|
|
167
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
168
|
+
const provider = typeof entry.provider === "string" ? entry.provider.trim() : "";
|
|
169
|
+
const model = typeof entry.model === "string" ? entry.model.trim() : "";
|
|
170
|
+
return provider !== "" && model !== "" ? { provider, model } : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Coerce one route candidate rank entry (tier optional). */
|
|
174
|
+
function cleanRank(entry) {
|
|
175
|
+
const route = cleanRoute(entry);
|
|
176
|
+
if (route === null) return null;
|
|
177
|
+
const tier =
|
|
178
|
+
entry.tier === undefined || entry.tier === null || entry.tier === ""
|
|
179
|
+
? undefined
|
|
180
|
+
: ["S", "A", "B", "C"].includes(entry.tier)
|
|
181
|
+
? entry.tier
|
|
182
|
+
: undefined;
|
|
183
|
+
return tier === undefined ? route : { ...route, tier };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Coerce one effort candidate `{provider, model, effort}` entry; null when invalid. */
|
|
187
|
+
function cleanEffortEntry(entry) {
|
|
188
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
189
|
+
const provider = typeof entry.provider === "string" ? entry.provider.trim() : "";
|
|
190
|
+
const model = typeof entry.model === "string" ? entry.model.trim() : "";
|
|
191
|
+
const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
|
|
192
|
+
return provider !== "" && model !== "" && effort !== "" ? { provider, model, effort } : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Snapshot the plugin's live volatile config into plain JSON values. */
|
|
196
|
+
export function snapshotOf(config) {
|
|
197
|
+
const values = {};
|
|
198
|
+
for (const field of STRING_FIELDS) values[field] = String(rawOf(config?.[field]) ?? "");
|
|
199
|
+
for (const field of POOL_FIELDS)
|
|
200
|
+
values[field] = Array.isArray(rawOf(config?.[field]))
|
|
201
|
+
? rawOf(config[field]).map(cleanRoute).filter(Boolean)
|
|
202
|
+
: [];
|
|
203
|
+
const rank = rawOf(config?.modelRank);
|
|
204
|
+
values.modelRank = Array.isArray(rank) ? rank.map(cleanRank).filter(Boolean) : [];
|
|
205
|
+
for (const field of EFFORTS_FIELDS)
|
|
206
|
+
values[field] = Array.isArray(rawOf(config?.[field]))
|
|
207
|
+
? rawOf(config[field]).map(cleanEffortEntry).filter(Boolean)
|
|
208
|
+
: [];
|
|
209
|
+
values.dispatchEnforce = ENUM_FIELDS.dispatchEnforce.includes(rawOf(config?.dispatchEnforce))
|
|
210
|
+
? rawOf(config.dispatchEnforce)
|
|
211
|
+
: "advice";
|
|
212
|
+
for (const field of NUMBER_FIELDS) {
|
|
213
|
+
const raw = Number(rawOf(config?.[field]));
|
|
214
|
+
values[field] = Number.isFinite(raw) ? raw : 0;
|
|
215
|
+
}
|
|
216
|
+
values.wmDenyMode = ENUM_FIELDS.wmDenyMode.includes(rawOf(config?.wmDenyMode))
|
|
217
|
+
? rawOf(config.wmDenyMode)
|
|
218
|
+
: "cap";
|
|
219
|
+
for (const field of BOOLEAN_FIELDS) values[field] = Boolean(rawOf(config?.[field]));
|
|
220
|
+
return values;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Coerce and validate a client-submitted values object; null when invalid. */
|
|
224
|
+
function coerceValues(input) {
|
|
225
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return null;
|
|
226
|
+
const values = {};
|
|
227
|
+
for (const field of STRING_FIELDS) {
|
|
228
|
+
if (typeof input[field] !== "string") return null;
|
|
229
|
+
values[field] = input[field];
|
|
230
|
+
}
|
|
231
|
+
for (const field of POOL_FIELDS) {
|
|
232
|
+
if (!Array.isArray(input[field])) return null;
|
|
233
|
+
const routes = input[field].map(cleanRoute);
|
|
234
|
+
if (routes.some((route) => route === null)) return null;
|
|
235
|
+
values[field] = routes;
|
|
236
|
+
}
|
|
237
|
+
if (!Array.isArray(input.modelRank)) return null;
|
|
238
|
+
const rank = input.modelRank.map(cleanRank);
|
|
239
|
+
if (rank.some((entry) => entry === null)) return null;
|
|
240
|
+
values.modelRank = rank;
|
|
241
|
+
for (const field of EFFORTS_FIELDS) {
|
|
242
|
+
if (!Array.isArray(input[field])) return null;
|
|
243
|
+
const efforts = input[field].map(cleanEffortEntry);
|
|
244
|
+
if (efforts.some((entry) => entry === null)) return null;
|
|
245
|
+
values[field] = efforts;
|
|
246
|
+
}
|
|
247
|
+
if (!ENUM_FIELDS.dispatchEnforce.includes(input.dispatchEnforce)) return null;
|
|
248
|
+
values.dispatchEnforce = input.dispatchEnforce;
|
|
249
|
+
for (const field of NUMBER_FIELDS) {
|
|
250
|
+
const raw = Number(input[field]);
|
|
251
|
+
if (!Number.isFinite(raw) || raw < 0) return null;
|
|
252
|
+
values[field] = raw;
|
|
253
|
+
}
|
|
254
|
+
if (!ENUM_FIELDS.wmDenyMode.includes(input.wmDenyMode)) return null;
|
|
255
|
+
values.wmDenyMode = input.wmDenyMode;
|
|
256
|
+
for (const field of BOOLEAN_FIELDS) {
|
|
257
|
+
if (typeof input[field] !== "boolean") return null;
|
|
258
|
+
values[field] = input[field];
|
|
259
|
+
}
|
|
260
|
+
return values;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Deep-equal on the plain-JSON value shapes this module exchanges. */
|
|
264
|
+
function sameValues(a, b) {
|
|
265
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Enumerate the model catalog from the host session controller. */
|
|
269
|
+
async function modelCatalogOf(ctx) {
|
|
270
|
+
const controller = typeof ctx.get === "function" ? ctx.get("sessionController", false) : undefined;
|
|
271
|
+
if (controller !== undefined && typeof controller.modelCatalog === "function") {
|
|
272
|
+
const catalog = await controller.modelCatalog();
|
|
273
|
+
if (catalog !== null && typeof catalog === "object") return catalog;
|
|
274
|
+
}
|
|
275
|
+
const llm = typeof ctx.get === "function" ? ctx.get("llm", false) : undefined;
|
|
276
|
+
if (llm !== undefined && typeof llm.listProviders === "function") {
|
|
277
|
+
const providers = await llm.listProviders();
|
|
278
|
+
return {
|
|
279
|
+
groups: Array.isArray(providers)
|
|
280
|
+
? providers.map((info) => ({
|
|
281
|
+
provider: info?.id ?? info?.name ?? "unknown",
|
|
282
|
+
models: Array.isArray(info?.models) ? info.models : [],
|
|
283
|
+
}))
|
|
284
|
+
: [],
|
|
285
|
+
failures: [],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return { groups: [], failures: [] };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Build the /api/dsh-switchman route family (exact paths).
|
|
293
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
|
|
294
|
+
* @param {object} config - this plugin's live volatile config.
|
|
295
|
+
* @returns {Array<{kind: "exact", path: string, handler: Function}>} routes
|
|
296
|
+
* for ctx.webServer.register.
|
|
297
|
+
*/
|
|
298
|
+
export function makeRoutes(ctx, config) {
|
|
299
|
+
/** Guard helper: fence + method check. */
|
|
300
|
+
const guard = (req, res, method) => {
|
|
301
|
+
if (!isAllowed(ctx, req)) {
|
|
302
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
if (req.method !== method) {
|
|
306
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
return [
|
|
313
|
+
{
|
|
314
|
+
kind: "exact",
|
|
315
|
+
path: ROUTES.health,
|
|
316
|
+
handler: async (req, res) => {
|
|
317
|
+
if (!guard(req, res, "GET")) return;
|
|
318
|
+
writeJson(res, 200, { ok: true, ns: NS });
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
kind: "exact",
|
|
323
|
+
path: ROUTES.config,
|
|
324
|
+
handler: async (req, res) => {
|
|
325
|
+
if (!isAllowed(ctx, req)) {
|
|
326
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (req.method === "GET") {
|
|
330
|
+
writeJson(res, 200, { values: snapshotOf(config) });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (req.method !== "POST") {
|
|
334
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
let body;
|
|
338
|
+
try {
|
|
339
|
+
body = await readJsonBody(req);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
writeJson(res, 400, { error: error?.message ?? String(error) });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const values = coerceValues(body?.values);
|
|
345
|
+
if (values === null) {
|
|
346
|
+
writeJson(res, 400, { error: "invalid values payload" });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
// Optimistic-concurrency fence: when the client submits the
|
|
350
|
+
// snapshot it loaded (`expected`), require it to still match.
|
|
351
|
+
const current = snapshotOf(config);
|
|
352
|
+
if (body.expected !== undefined) {
|
|
353
|
+
const expected = coerceValues(body.expected);
|
|
354
|
+
if (expected === null || !sameValues(expected, current)) {
|
|
355
|
+
writeJson(res, 409, { error: "conflict: settings changed since load", values: current });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
await ctx.settings.update(NS, values);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
ctx.logger.warn(`dsh-switchman: settings write failed: ${error?.message ?? error}`);
|
|
363
|
+
writeJson(res, 500, { error: error?.message ?? String(error) });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
writeJson(res, 200, { values: snapshotOf(config) });
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
kind: "exact",
|
|
371
|
+
path: ROUTES.models,
|
|
372
|
+
handler: async (req, res) => {
|
|
373
|
+
if (!guard(req, res, "GET")) return;
|
|
374
|
+
try {
|
|
375
|
+
writeJson(res, 200, await modelCatalogOf(ctx));
|
|
376
|
+
} catch (error) {
|
|
377
|
+
ctx.logger.warn(`dsh-switchman: model catalog failed: ${error?.message ?? error}`);
|
|
378
|
+
writeJson(res, 500, { error: error?.message ?? String(error) });
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Mount the route family on the webServer (disposable through ctx.effect).
|
|
387
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
|
|
388
|
+
* @param {object} config - this plugin's live volatile config.
|
|
389
|
+
*/
|
|
390
|
+
export function applyRoutes(ctx, config) {
|
|
391
|
+
const routes = makeRoutes(ctx, config);
|
|
392
|
+
ctx.effect(() => {
|
|
393
|
+
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
394
|
+
return () => {
|
|
395
|
+
for (const dispose of disposers) dispose();
|
|
396
|
+
};
|
|
397
|
+
}, "dsh-switchman: client routes");
|
|
398
|
+
}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<!-- dsh-switchman: switch + bolt mark. Flat, theme-safe, no external refs. -->
|
|
3
|
+
<rect x="4" y="4" width="56" height="56" rx="14" fill="#2B5FFF"/>
|
|
4
|
+
<path d="M14 40h14a10 10 0 0 0 0-20H14" fill="none" stroke="#FFFFFF" stroke-width="5" stroke-linecap="round"/>
|
|
5
|
+
<circle cx="44" cy="40" r="6" fill="#FFFFFF"/>
|
|
6
|
+
<path d="M47 8 34 30h8l-5 18 16-24h-9z" fill="#FFD34D" stroke="#2B5FFF" stroke-width="1.5" stroke-linejoin="round"/>
|
|
7
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Host half of the dsh-switchman bundle.
|
|
2
|
+
*
|
|
3
|
+
* Mounts the bundle's bundled skills: every `skills/<name>/SKILL.md` under
|
|
4
|
+
* this package is registered on `ctx.skills` through one static provider,
|
|
5
|
+
* following the shipped `@deepseek-ai/dsh-skill-badge` / `dsh-skill-office`
|
|
6
|
+
* pattern (source "bundled", rank BUNDLED_SKILL_RANK, directory resource
|
|
7
|
+
* base so the skill tool tells the Agent where `scripts/` and `references/`
|
|
8
|
+
* live). The preset overrides live in cordis.patch.yml; the Client half in
|
|
9
|
+
* client.js renders the "team autonomy" badge beside the preset chip.
|
|
10
|
+
*
|
|
11
|
+
* Phase 1 additionally mounts the language-preference layer (host/lang.js):
|
|
12
|
+
* a dynamic systemPrompt section carrying the `[SWITCHMAN:LANG]` protocol
|
|
13
|
+
* block plus a `tools/post-execute` listener that captures ask_user_question
|
|
14
|
+
* answers and persists them into this plugin's settings namespace.
|
|
15
|
+
*
|
|
16
|
+
* Phase 2 mounts the dispatch-pool layer (host/dispatch.js + host/lib/rank.js):
|
|
17
|
+
* a `[SWITCHMAN:POOLS]` recommendation section ordered by modelRank anchors
|
|
18
|
+
* and the bundled capability snapshot, plus an optional (enforce mode only)
|
|
19
|
+
* `tools/pre-execute` gate that denies subagent routes outside the pools.
|
|
20
|
+
*
|
|
21
|
+
* Phase 3 mounts the context-watermark layer (host/context-watch.js): the
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
// Plugin settings schema: flat `.volatile()` fields exposed as the
|
|
32
|
+
// "dsh-switchman" settings namespace (forms + profile-patch persistence).
|
|
33
|
+
export { Config, LANES, laneField } from "./host/config.js";
|
|
34
|
+
|
|
35
|
+
// Language-preference layer (dynamic prompt section + answer capture).
|
|
36
|
+
import { applyLanguage } from "./host/lang.js";
|
|
37
|
+
|
|
38
|
+
// Dispatch-pool layer (pools recommendation section + enforce gate).
|
|
39
|
+
import { applyDispatch } from "./host/dispatch.js";
|
|
40
|
+
|
|
41
|
+
// Context-watermark layer (banner + read budget + handover + /ctx-* commands).
|
|
42
|
+
import { applyContextWatch } from "./host/context-watch.js";
|
|
43
|
+
|
|
44
|
+
// Client-bridge routes (config snapshot/fenced write + model catalog for the
|
|
45
|
+
// restricted browser half, which has no configForms/remote services).
|
|
46
|
+
import { applyRoutes } from "./host/routes.js";
|
|
47
|
+
|
|
48
|
+
/** Cordis plugin name. */
|
|
49
|
+
const name = "dsh-switchman";
|
|
50
|
+
|
|
51
|
+
/** Services this plugin contributes to: the bundled-skill registry, the
|
|
52
|
+
* system-prompt section registry, the settings store it writes back to, the
|
|
53
|
+
* 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.
|
|
56
|
+
* Compaction is deliberately NOT injected: the desktop profile provides it
|
|
57
|
+
* only inside each preset's per-agent isolated group (isolate: { compaction:
|
|
58
|
+
* true }), never at this root — host/context-watch.js reaches it by borrowing
|
|
59
|
+
* the session's own per-agent /compact handler through
|
|
60
|
+
* ctx.commands.find(agent, "compact"). */
|
|
61
|
+
const inject = ["skills", "systemPrompt", "settings", "tokenMeter", "commands", "agents", "webServer", "subagents", "sessions"];
|
|
62
|
+
|
|
63
|
+
/** Must equal `BUNDLED_SKILL_RANK` of @deepseek-ai/dsh-skill (packaged skill
|
|
64
|
+
* providers and local bundled roots; below user entries, above none). Not
|
|
65
|
+
* imported because that package is not a dependency of this bundle. */
|
|
66
|
+
const BUNDLED_SKILL_RANK = 600;
|
|
67
|
+
|
|
68
|
+
/** Provider id reported by every candidate (must match `provider`). */
|
|
69
|
+
const PROVIDER_NAME = "dsh-switchman";
|
|
70
|
+
|
|
71
|
+
/** Parse the `---` frontmatter subset used by the bundled SKILL.md files:
|
|
72
|
+
* single-line `key: value` scalars. Unknown keys are ignored, matching the
|
|
73
|
+
* shipped filesystem provider's tolerance. */
|
|
74
|
+
function parseSkillFile(raw, path) {
|
|
75
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw);
|
|
76
|
+
if (frontmatter?.[1] === undefined)
|
|
77
|
+
throw new Error(`${path}: missing YAML frontmatter`);
|
|
78
|
+
const fields = {};
|
|
79
|
+
for (const line of frontmatter[1].split(/\r?\n/u)) {
|
|
80
|
+
const match = /^([A-Za-z][\w-]*):[ \t]*(.*)$/u.exec(line);
|
|
81
|
+
if (match === null) continue;
|
|
82
|
+
let value = match[2].trim();
|
|
83
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"'))
|
|
84
|
+
value = value.slice(1, -1);
|
|
85
|
+
fields[match[1]] = value;
|
|
86
|
+
}
|
|
87
|
+
if (typeof fields.name !== "string" || fields.name.length === 0)
|
|
88
|
+
throw new Error(`${path}: frontmatter requires name`);
|
|
89
|
+
if (typeof fields.description !== "string" || fields.description.length === 0)
|
|
90
|
+
throw new Error(`${path}: frontmatter requires description`);
|
|
91
|
+
return { name: fields.name, description: fields.description, content: raw.slice(frontmatter[0].length).trim() };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Build one bundled-skill candidate from a skill directory. */
|
|
95
|
+
function loadSkillCandidate(directory) {
|
|
96
|
+
const path = join(directory, "SKILL.md");
|
|
97
|
+
const parsed = parseSkillFile(readFileSync(path, "utf8"), path);
|
|
98
|
+
return {
|
|
99
|
+
name: parsed.name,
|
|
100
|
+
description: parsed.description,
|
|
101
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
102
|
+
provider: PROVIDER_NAME,
|
|
103
|
+
source: "bundled",
|
|
104
|
+
rank: BUNDLED_SKILL_RANK,
|
|
105
|
+
resourceBase: { kind: "directory", path: `${directory}/` },
|
|
106
|
+
locator: path,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Scan ./skills at apply time; broken entries warn and drop, never throw. */
|
|
111
|
+
function loadSkillCandidates(root, warn) {
|
|
112
|
+
const candidates = [];
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
116
|
+
} catch {
|
|
117
|
+
warn(`skills directory missing or unreadable: ${root}`);
|
|
118
|
+
return candidates;
|
|
119
|
+
}
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
122
|
+
const directory = join(root, entry.name);
|
|
123
|
+
try {
|
|
124
|
+
candidates.push(loadSkillCandidate(directory));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
warn(`skill directory ${directory} ignored: ${error?.message ?? error}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return candidates;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Register the bundled dsh-switchman skill provider on `ctx.skills` and
|
|
133
|
+
* mount the language (lang), dispatch (dispatch), and watermark
|
|
134
|
+
* (context-watch) layers. */
|
|
135
|
+
function apply(ctx, config) {
|
|
136
|
+
const root = fileURLToPath(new URL("./skills/", import.meta.url));
|
|
137
|
+
const warn = (message) => ctx.logger.warn(`dsh-switchman: ${message}`);
|
|
138
|
+
const candidates = loadSkillCandidates(root, warn);
|
|
139
|
+
const provider = {
|
|
140
|
+
name: PROVIDER_NAME,
|
|
141
|
+
list: () => Promise.resolve(candidates),
|
|
142
|
+
async get(candidate) {
|
|
143
|
+
const { rank: _rank, locator, ...summary } = candidate;
|
|
144
|
+
return { ...summary, content: parseSkillFile(readFileSync(locator, "utf8"), locator).content };
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
ctx.skills.registerProvider(() => provider);
|
|
148
|
+
applyLanguage(ctx, config);
|
|
149
|
+
applyDispatch(ctx, config);
|
|
150
|
+
applyContextWatch(ctx, config);
|
|
151
|
+
applyRoutes(ctx, config);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { apply, inject, name };
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-switchman",
|
|
3
|
+
"version": "1.3.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"deepseek-harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"cordis",
|
|
10
|
+
"agent-teams",
|
|
11
|
+
"preset",
|
|
12
|
+
"orchestration",
|
|
13
|
+
"context-watermark"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/mrzturn/dsh-switchman.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/mrzturn/dsh-switchman#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/mrzturn/dsh-switchman/issues"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./index.js",
|
|
25
|
+
"icon": "./icon.svg",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=24",
|
|
28
|
+
"dsh": ">=0.2.0-rc.0"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./index.js",
|
|
32
|
+
"./client": "./client.js",
|
|
33
|
+
"./package.json": "./package.json",
|
|
34
|
+
"./locale/*.json": "./locale/*.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"index.js",
|
|
38
|
+
"client.js",
|
|
39
|
+
"host/",
|
|
40
|
+
"skills/db-query/SKILL.md",
|
|
41
|
+
"skills/db-query/package.json",
|
|
42
|
+
"skills/db-query/package-lock.json",
|
|
43
|
+
"skills/db-query/scripts",
|
|
44
|
+
"skills/db-query/references",
|
|
45
|
+
"skills/git-commit-message/SKILL.md",
|
|
46
|
+
"skills/requirement-docs/SKILL.md",
|
|
47
|
+
"skills/requirement-docs/references",
|
|
48
|
+
"locale/",
|
|
49
|
+
"icon.svg",
|
|
50
|
+
"cordis.patch.yml"
|
|
51
|
+
],
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@deepseek-ai/schemastery": "3.18.4"
|
|
54
|
+
},
|
|
55
|
+
"dsh": {
|
|
56
|
+
"manifestVersion": 1,
|
|
57
|
+
"bundle": {
|
|
58
|
+
"patch": "./cordis.patch.yml"
|
|
59
|
+
},
|
|
60
|
+
"client": {
|
|
61
|
+
"platform": "web",
|
|
62
|
+
"immediately": true,
|
|
63
|
+
"inject": [
|
|
64
|
+
"@deepseek-ai/dsh-client-locale",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
66
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|