dsh-plugin-labels 0.0.0-stage → 0.7.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/lib/index.js ADDED
@@ -0,0 +1,1075 @@
1
+ /**
2
+ * dsh-plugin-labels — host half (v0.7.0: display overrides, no plugin UI).
3
+ *
4
+ * Four loopback-only endpoints (kept for scripts/AI even though the client no
5
+ * longer renders a tab):
6
+ * GET /api/plugin-labels/list -> installed profile bundles + cached labels
7
+ * POST /api/plugin-labels/translate -> translate the bundles that have no label yet
8
+ * POST /api/plugin-labels/label -> set one manual label, or reset one back to AI
9
+ * POST /api/plugin-labels/import -> merge an exported labels JSON
10
+ *
11
+ * Translation uses the live Harness LLM service exactly like the official
12
+ * @deepseek-ai/dsh-experimental-auto-review plugin does:
13
+ * ctx.llm.stream({ provider, model, temperature: 0, system, messages })
14
+ * Labels are cached in <DSH_HOME>/plugin-labels.json (UTF-8, no BOM, atomic
15
+ * rename). Manual labels carry source:"manual" and are never overwritten by a
16
+ * later translate run; translating only fills missing entries.
17
+ *
18
+ * Display overrides (all failure-safe; marker-detected because Cordis hands out
19
+ * traceable Proxies whose method references differ on every read):
20
+ * - `installOfficialMeta` wraps `pluginPackages.metaOf()` so the official
21
+ * Plugins page rows and detail pages show the Chinese labels in place.
22
+ * - `installBundleMeta` wraps `pluginManager.listBundles()` so the plugin
23
+ * package cards (which call `readPluginMeta` directly) show them too.
24
+ * Set DSH_PLUGIN_LABELS_OFFICIAL_META=0 to disable both display overrides.
25
+ *
26
+ * Auto translation: shortly after startup one pass fills up to
27
+ * MAX_PENDING_PER_CALL missing labels through the same core, so a newly
28
+ * installed plugin gets a Chinese name without any UI. Set
29
+ * DSH_PLUGIN_LABELS_AUTO_TRANSLATE=0 to disable it. It never throws into or
30
+ * blocks host startup.
31
+ *
32
+ * Failure policy: every handler catches its own errors and answers with JSON;
33
+ * nothing is allowed to escape as an unhandled rejection (that is fatal to the
34
+ * Harness process). The plugin never blocks DSH startup.
35
+ */
36
+
37
+ import fs from "node:fs";
38
+ import os from "node:os";
39
+ import path from "node:path";
40
+
41
+ export const name = "plugin-labels";
42
+ export const inject = ["webServer"];
43
+
44
+ export const LIST_PATH = "/api/plugin-labels/list";
45
+ export const TRANSLATE_PATH = "/api/plugin-labels/translate";
46
+ export const LABEL_PATH = "/api/plugin-labels/label";
47
+ export const IMPORT_PATH = "/api/plugin-labels/import";
48
+
49
+ const CACHE_VERSION = 2;
50
+ const LEGACY_CACHE_VERSION = 1;
51
+ const MAX_PENDING_PER_CALL = 20;
52
+ const MAX_TITLE = 40;
53
+ const MAX_ONE_LINE = 120;
54
+ const MAX_BODY_BYTES = 65536;
55
+ const TRANSLATE_TIMEOUT_MS = 120000;
56
+
57
+ const SYSTEM_PROMPT = [
58
+ "You label DeepSeek Harness (DSH) plugins for a Chinese-speaking user.",
59
+ "For every plugin you receive, produce:",
60
+ "1. title: a short Chinese name, at most 8 Chinese characters, naming what it does (not a literal translation of the package name).",
61
+ "2. oneLine: one Chinese sentence, at most 40 characters, telling the user what the plugin does and whether it matters to them.",
62
+ "Be concrete. Avoid marketing words. If the description is unclear, say what the package name plus description imply.",
63
+ "Answer with strict JSON only, no prose, no markdown fences:",
64
+ '{"items":[{"name":"<exact package name>","title":"...","oneLine":"..."}]}',
65
+ ].join("\n");
66
+
67
+ /**
68
+ * Experimental display-layer override: wrap the host `pluginPackages.metaOf`
69
+ * service so the official Plugins page shows our Chinese labels in place
70
+ * (list rows and detail pages). This only changes display text; it does not
71
+ * touch package identity, installation or updates. Every failure path keeps the
72
+ * host's original behavior.
73
+ */
74
+ const OFFICIAL_META_ENV = "DSH_PLUGIN_LABELS_OFFICIAL_META";
75
+ const AUTO_TRANSLATE_ENV = "DSH_PLUGIN_LABELS_AUTO_TRANSLATE";
76
+ const AUTO_TRANSLATE_DELAY_MS = 4000;
77
+
78
+ /** Auto-translate missing labels at startup unless explicitly disabled. */
79
+ function autoTranslateEnabled() {
80
+ return process.env[AUTO_TRANSLATE_ENV] !== "0";
81
+ }
82
+
83
+ /** Display overrides can be turned off together for a plain host. */
84
+ function displayOverrideEnabled() {
85
+ return process.env[OFFICIAL_META_ENV] !== "0";
86
+ }
87
+
88
+ /**
89
+ * Marker carried by our wrapper. Cordis hands out traceable Proxy views whose
90
+ * function properties are re-wrapped on every read, so a wrapper can only be
91
+ * recognized by a marker that rides on the underlying function itself.
92
+ */
93
+ const OFFICIAL_META_MARK = Symbol.for("dsh-plugin-labels.official-meta");
94
+ const BUNDLE_META_MARK = Symbol.for("dsh-plugin-labels.bundle-meta");
95
+
96
+ const isOfficialMetaWrapper = (value) => Boolean(value && typeof value === "function" && value[OFFICIAL_META_MARK] === true);
97
+ const isBundleMetaWrapper = (value) => Boolean(value && typeof value === "function" && value[BUNDLE_META_MARK] === true);
98
+
99
+ function markWithSymbol(wrapper, symbol) {
100
+ try {
101
+ Object.defineProperty(wrapper, symbol, {
102
+ value: true,
103
+ enumerable: false,
104
+ configurable: false,
105
+ writable: false,
106
+ });
107
+ } catch {
108
+ // Exotic function object: the wrapper still works, only marking failed.
109
+ }
110
+ return wrapper;
111
+ }
112
+
113
+ function markOfficialMetaWrapper(wrapper) {
114
+ return markWithSymbol(wrapper, OFFICIAL_META_MARK);
115
+ }
116
+
117
+ function markBundleMetaWrapper(wrapper) {
118
+ return markWithSymbol(wrapper, BUNDLE_META_MARK);
119
+ }
120
+
121
+ /** Split a configured specifier into its bare package name (`@scope/name/sub` -> `@scope/name`). */
122
+ export function bareNameOf(specifier) {
123
+ if (typeof specifier !== "string") return "";
124
+ const request = specifier.trim();
125
+ if (request === "" || request.startsWith("cordis:")) return "";
126
+ // Relative/absolute/hash specifiers and other schemes are never plugin packages.
127
+ if (/^[.\/\\#]/.test(request) || request.includes(":")) return "";
128
+ const parts = request.split("/").filter((part) => part !== "");
129
+ if (parts.length === 0) return "";
130
+ if (request.startsWith("@")) return parts.length >= 2 ? parts[0] + "/" + parts[1] : "";
131
+ return parts[0];
132
+ }
133
+
134
+ /** English variant of a display field that may be a string or a language map. */
135
+ function englishDisplayText(value, fallback) {
136
+ if (typeof value === "string" && value.trim() !== "") return value;
137
+ if (value && typeof value === "object" && !Array.isArray(value)) {
138
+ // Only the English variant counts as "the English original"; a map that
139
+ // carries other languages must not impersonate English.
140
+ if (typeof value.en === "string" && value.en.trim() !== "") return value.en;
141
+ }
142
+ return fallback;
143
+ }
144
+
145
+ /**
146
+ * Overlay one cached label on readPluginMeta()'s base description.
147
+ * `base` may be undefined; title/description keep their English original so the
148
+ * official page can still resolve every language it knows.
149
+ */
150
+ export function mergeDisplayMeta(base, label, name) {
151
+ if (!label || typeof label !== "object") return base;
152
+ const title = typeof label.title === "string" ? label.title.trim() : "";
153
+ const oneLine = typeof label.oneLine === "string" ? label.oneLine.trim() : "";
154
+ if (title === "") return base;
155
+ const source = base && typeof base === "object" && !Array.isArray(base) ? base : {};
156
+ return {
157
+ ...source,
158
+ title: { en: englishDisplayText(source.title, name || ""), zh: title },
159
+ description: { en: englishDisplayText(source.description, ""), zh: oneLine },
160
+ };
161
+ }
162
+
163
+ /**
164
+ * An error whose message is meant for the browser. Everything else that escapes
165
+ * a handler is answered with a generic 500: an unexpected error may carry stack
166
+ * traces or absolute filesystem paths and must never reach the web UI.
167
+ */
168
+ class HttpError extends Error {
169
+ constructor(status, message, options) {
170
+ super(message);
171
+ this.name = "HttpError";
172
+ this.status = status;
173
+ this.respond = !(options && options.respond === false);
174
+ this.afterRespond = options && typeof options.afterRespond === "function" ? options.afterRespond : null;
175
+ }
176
+ }
177
+
178
+ function badRequest(message) {
179
+ return new HttpError(400, message);
180
+ }
181
+
182
+ /** Write one JSON answer; never throw when the socket is already gone. */
183
+ function json(res, status, body, afterRespond) {
184
+ let data;
185
+ try {
186
+ data = JSON.stringify(body);
187
+ } catch {
188
+ status = 500;
189
+ data = '{"ok":false,"error":"internal error"}';
190
+ }
191
+ const done = () => {
192
+ if (typeof afterRespond !== "function") return;
193
+ try {
194
+ afterRespond();
195
+ } catch {
196
+ // The socket is already gone; nothing useful is left to do.
197
+ }
198
+ };
199
+ try {
200
+ if (!res || res.destroyed || res.writableEnded) {
201
+ done();
202
+ return;
203
+ }
204
+ if (!res.headersSent) {
205
+ res.statusCode = status;
206
+ res.setHeader("content-type", "application/json; charset=utf-8");
207
+ res.setHeader("cache-control", "no-store");
208
+ // A rejected oversized body is not read to the end, so this
209
+ // connection must not be reused by the client.
210
+ if (status === 413) res.setHeader("connection", "close");
211
+ }
212
+ res.end(data, done);
213
+ } catch {
214
+ // Writing after the peer vanished is expected under cancellation.
215
+ done();
216
+ }
217
+ }
218
+
219
+ /** Keep an explicit 4xx status, hide everything else behind a stable 500. */
220
+ function respondError(res, error) {
221
+ const explicit = error instanceof HttpError;
222
+ if (explicit && error.respond === false) return;
223
+ const afterRespond = explicit ? error.afterRespond : null;
224
+ if (explicit && error.status >= 400 && error.status < 500) {
225
+ json(res, error.status, { ok: false, error: error.message }, afterRespond);
226
+ return;
227
+ }
228
+ json(res, 500, { ok: false, error: "internal error" }, afterRespond);
229
+ }
230
+
231
+ function isLoopback(req) {
232
+ const address = req.socket && req.socket.remoteAddress;
233
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
234
+ }
235
+
236
+ /**
237
+ * Loopback is not a security boundary: the remote-web-ui tunnel makes every
238
+ * internet client arrive from 127.0.0.1. Reject requests a browser marks as
239
+ * cross-site, and require the write verbs this plugin expects.
240
+ */
241
+ function isSameOriginRequest(req) {
242
+ const site = req.headers["sec-fetch-site"];
243
+ if (typeof site === "string" && site !== "same-origin" && site !== "none") return false;
244
+ return true;
245
+ }
246
+
247
+ /**
248
+ * Read a small JSON body; rejects instead of buffering unbounded input.
249
+ *
250
+ * On overflow the promise rejects with a 413 and buffering stops, but the
251
+ * request is only destroyed after the caller has answered (`afterRespond`):
252
+ * destroying it here would make the response write fail with
253
+ * ERR_STREAM_DESTROYED. A client that disconnects first rejects with
254
+ * respond:false so no doomed write is attempted.
255
+ */
256
+ function readBody(req) {
257
+ return new Promise((resolve, reject) => {
258
+ const chunks = [];
259
+ let size = 0;
260
+ let settled = false;
261
+ const cleanup = () => {
262
+ // The terminal listeners stay attached on purpose: a stream error
263
+ // after settling (or after req.destroy()) must not go unhandled,
264
+ // and settle() already ignores late calls.
265
+ req.removeListener("data", onData);
266
+ req.removeListener("end", onEnd);
267
+ };
268
+ const settle = (error, value) => {
269
+ if (settled) return;
270
+ settled = true;
271
+ cleanup();
272
+ if (error) reject(error);
273
+ else resolve(value);
274
+ };
275
+ const clientGone = () => settle(new HttpError(400, "client closed the request", { respond: false }));
276
+ function onData(chunk) {
277
+ if (settled) return;
278
+ size += chunk.length;
279
+ if (size > MAX_BODY_BYTES) {
280
+ settle(new HttpError(413, "request body is too large", {
281
+ afterRespond: () => {
282
+ try {
283
+ req.destroy();
284
+ } catch {
285
+ // Already destroyed.
286
+ }
287
+ },
288
+ }));
289
+ // Stop buffering but keep draining, so the response can flush.
290
+ try {
291
+ if (typeof req.resume === "function" && !req.destroyed) req.resume();
292
+ } catch {
293
+ // The stream is already unusable.
294
+ }
295
+ return;
296
+ }
297
+ chunks.push(chunk);
298
+ }
299
+ function onEnd() {
300
+ settle(null, Buffer.concat(chunks).toString("utf8"));
301
+ }
302
+ function onError() {
303
+ clientGone();
304
+ }
305
+ function onAborted() {
306
+ clientGone();
307
+ }
308
+ function onClose() {
309
+ if (!req.readableEnded) clientGone();
310
+ }
311
+ req.on("data", onData);
312
+ req.on("end", onEnd);
313
+ req.on("error", onError);
314
+ req.on("aborted", onAborted);
315
+ req.on("close", onClose);
316
+ });
317
+ }
318
+
319
+ async function readJsonBody(req) {
320
+ const raw = await readBody(req);
321
+ if (raw.trim() === "") return {};
322
+ let parsed;
323
+ try {
324
+ parsed = JSON.parse(raw);
325
+ } catch {
326
+ throw badRequest("request body is not valid JSON");
327
+ }
328
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw badRequest("request body must be a JSON object");
329
+ return parsed;
330
+ }
331
+
332
+ function resolveHome() {
333
+ const fromEnv = process.env.DSH_HOME;
334
+ if (fromEnv && fromEnv.trim() !== "") return fromEnv.trim();
335
+ return path.join(os.homedir(), ".dsh");
336
+ }
337
+
338
+ function readProfile(home) {
339
+ const profileDir = path.join(home, "profiles", "desktop");
340
+ const profile = JSON.parse(fs.readFileSync(path.join(profileDir, "package.json"), "utf8"));
341
+ const list = profile && profile.dsh && profile.dsh.profile && profile.dsh.profile.bundles;
342
+ const bundles = Array.isArray(list) ? list.filter((item) => typeof item === "string") : [];
343
+ const nodeModules = path.join(profileDir, "node_modules");
344
+ const plugins = bundles.map((bundle) => {
345
+ let version = "";
346
+ let description = "";
347
+ let kind = "profile-bundle";
348
+ try {
349
+ const pkg = JSON.parse(fs.readFileSync(path.join(nodeModules, bundle, "package.json"), "utf8"));
350
+ version = typeof pkg.version === "string" ? pkg.version : "";
351
+ description = typeof pkg.description === "string" ? pkg.description : "";
352
+ } catch {
353
+ kind = "official-or-missing";
354
+ }
355
+ return { name: bundle, version, description, kind };
356
+ });
357
+ return { profileDir, plugins };
358
+ }
359
+
360
+ function cachePath(home) {
361
+ return path.join(home, "plugin-labels.json");
362
+ }
363
+
364
+ /** Cache keys that would mutate an object's prototype instead of adding an entry. */
365
+ const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
366
+
367
+ /**
368
+ * Validate one cached label. Unknown shapes are dropped per entry, so one
369
+ * corrupt row cannot take down the whole cache (or the plugin page).
370
+ */
371
+ function normalizeLabel(value) {
372
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
373
+ const title = typeof value.title === "string" ? value.title.trim() : "";
374
+ const oneLine = typeof value.oneLine === "string" ? value.oneLine.trim() : "";
375
+ if (title === "" || oneLine === "") return null;
376
+ const source = value.source === "manual" ? "manual" : "ai";
377
+ const label = {
378
+ title: title.slice(0, MAX_TITLE),
379
+ oneLine: oneLine.slice(0, MAX_ONE_LINE),
380
+ source,
381
+ };
382
+ if (typeof value.at === "string" && value.at !== "") label.at = value.at;
383
+ // Only AI labels carry the model that produced them; manual labels never do.
384
+ if (source === "ai" && typeof value.model === "string" && value.model !== "") label.model = value.model;
385
+ return label;
386
+ }
387
+
388
+ function emptyCache() {
389
+ return { version: CACHE_VERSION, entries: {} };
390
+ }
391
+
392
+ function readCache(home) {
393
+ let parsed;
394
+ try {
395
+ parsed = JSON.parse(fs.readFileSync(cachePath(home), "utf8"));
396
+ } catch {
397
+ // Missing or corrupt cache: start over, never fail the request.
398
+ return emptyCache();
399
+ }
400
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyCache();
401
+ // v1 caches precede shape validation; read them, then persist as v2 on the
402
+ // next write. Any other version is treated as foreign data.
403
+ if (parsed.version !== CACHE_VERSION && parsed.version !== LEGACY_CACHE_VERSION) return emptyCache();
404
+ if (!parsed.entries || typeof parsed.entries !== "object" || Array.isArray(parsed.entries)) return emptyCache();
405
+ const cache = emptyCache();
406
+ for (const [pluginName, value] of Object.entries(parsed.entries)) {
407
+ if (typeof pluginName !== "string" || pluginName.trim() === "" || RESERVED_KEYS.has(pluginName)) continue;
408
+ const label = normalizeLabel(value);
409
+ if (label) cache.entries[pluginName] = label;
410
+ }
411
+ return cache;
412
+ }
413
+
414
+ function writeCache(home, cache) {
415
+ const target = cachePath(home);
416
+ const temporary = target + ".tmp-" + process.pid;
417
+ // UTF-8 without BOM: DSH and Node parse these files as raw JSON text.
418
+ // Renaming a fully written temp file keeps readers from seeing half a cache.
419
+ const body = JSON.stringify({ version: CACHE_VERSION, entries: cache.entries || {} }, null, 2);
420
+ fs.writeFileSync(temporary, body, "utf8");
421
+ try {
422
+ fs.renameSync(temporary, target);
423
+ } catch (error) {
424
+ try {
425
+ fs.unlinkSync(temporary);
426
+ } catch {
427
+ // The temp file is already gone.
428
+ }
429
+ throw error;
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Install the experimental official-page display override.
435
+ * Wraps `pluginPackages.metaOf(specifier, parentURL)` with our cached labels,
436
+ * re-applies it if the host swaps the service instance (HMR/refresh), and
437
+ * restores the original method on dispose. Nothing here may throw: a failed
438
+ * hook just keeps the host's own metadata.
439
+ *
440
+ * Detection is marker-based (v0.6.1): `ctx.get()` hands out Cordis traceable
441
+ * Proxies, and every read of `service.metaOf` returns a fresh wrapper function,
442
+ * so reference/identity comparisons are meaningless. The wrapper therefore
443
+ * carries a symbol marker; `isOfficialMetaWrapper()` is the only ownership test.
444
+ */
445
+ export function installOfficialMeta(ctx, home) {
446
+ // Display overrides are on by default; only an explicit "0" turns them off.
447
+ const enabled = displayOverrideEnabled();
448
+ const warn = (message) => {
449
+ try {
450
+ ctx.logger?.warn?.("[plugin-labels] " + message);
451
+ } catch {
452
+ // A logger failure must not break the host.
453
+ }
454
+ };
455
+ let installed = null; // { service, raw, wrapper }
456
+
457
+ const currentService = () => {
458
+ try {
459
+ return ctx.get("pluginPackages");
460
+ } catch {
461
+ return undefined;
462
+ }
463
+ };
464
+
465
+ const restore = () => {
466
+ const active = installed;
467
+ installed = null;
468
+ if (!active) return;
469
+ try {
470
+ // Compare by marker, never by reference: the traceable Proxy returns
471
+ // a new function wrapper on every read.
472
+ if (!isOfficialMetaWrapper(active.service.metaOf)) return;
473
+ try {
474
+ // Class instances keep the real method on their prototype, so
475
+ // deleting the own property restores the original behavior.
476
+ delete active.service.metaOf;
477
+ } catch {
478
+ // Frozen/exotic service object; fall back to the saved method.
479
+ }
480
+ if (isOfficialMetaWrapper(active.service.metaOf)) active.service.metaOf = active.raw;
481
+ } catch (error) {
482
+ warn("failed to restore pluginPackages.metaOf: " + String((error && error.message) || error));
483
+ }
484
+ };
485
+
486
+ const tryPatch = () => {
487
+ if (!enabled) return;
488
+ try {
489
+ const service = currentService();
490
+ if (!service || (typeof service !== "object" && typeof service !== "function")) return;
491
+ // Already ours? The traceable Proxy view may be a different object on
492
+ // every ctx.get, so identity cannot decide: the marker does.
493
+ if (isOfficialMetaWrapper(service.metaOf)) {
494
+ if (!installed) installed = { service, raw: service.metaOf, wrapper: service.metaOf };
495
+ return;
496
+ }
497
+ // A new service instance replaced the old one: restore it first.
498
+ if (installed) restore();
499
+ const raw = service.metaOf;
500
+ if (typeof raw !== "function") return;
501
+ const original = raw.bind(service);
502
+ const wrapper = markOfficialMetaWrapper(function (specifier, parentURL) {
503
+ let base;
504
+ try {
505
+ base = original(specifier, parentURL);
506
+ } catch {
507
+ base = undefined;
508
+ }
509
+ try {
510
+ const pluginName = bareNameOf(specifier);
511
+ if (pluginName === "") return base;
512
+ const label = readCache(home).entries[pluginName];
513
+ return label ? mergeDisplayMeta(base, label, pluginName) : base;
514
+ } catch {
515
+ return base;
516
+ }
517
+ });
518
+ try {
519
+ service.metaOf = wrapper;
520
+ } catch (error) {
521
+ warn("could not wrap pluginPackages.metaOf: " + String((error && error.message) || error));
522
+ return;
523
+ }
524
+ if (!isOfficialMetaWrapper(service.metaOf)) {
525
+ warn("could not wrap pluginPackages.metaOf: the assignment had no effect");
526
+ return;
527
+ }
528
+ installed = { service, raw, wrapper };
529
+ } catch (error) {
530
+ warn("pluginPackages.metaOf hook skipped: " + String((error && error.message) || error));
531
+ }
532
+ };
533
+
534
+ const status = () => ({
535
+ enabled,
536
+ patched: installed !== null,
537
+ service: Boolean(currentService()),
538
+ });
539
+
540
+ if (!enabled) return { dispose: () => {}, tryPatch: () => {}, status };
541
+ tryPatch();
542
+ return { dispose: restore, tryPatch, status };
543
+ }
544
+
545
+ /** Overlay a cached label on one `listBundles()` entry (meta is readPluginMeta output). */
546
+ function overlayBundleMeta(bundle, home) {
547
+ if (!bundle || typeof bundle !== "object") return bundle;
548
+ const pluginName = bareNameOf(bundle.name);
549
+ if (pluginName === "") return bundle;
550
+ const label = readCache(home).entries[pluginName];
551
+ if (!label) return bundle;
552
+ try {
553
+ bundle.meta = mergeDisplayMeta(bundle.meta, label, pluginName);
554
+ } catch {
555
+ // Keep the host's own metadata for this bundle.
556
+ }
557
+ return bundle;
558
+ }
559
+
560
+ /**
561
+ * Patch the `pluginManager` service so package cards (which read
562
+ * `readPluginMeta` directly, bypassing `pluginPackages.metaOf`) also show the
563
+ * Chinese names. Same marker/idempotency/restore discipline as
564
+ * `installOfficialMeta`; every failure keeps the host's own metadata.
565
+ */
566
+ export function installBundleMeta(ctx, home) {
567
+ const enabled = displayOverrideEnabled();
568
+ const warn = (message) => {
569
+ try {
570
+ ctx.logger?.warn?.("[plugin-labels] " + message);
571
+ } catch {
572
+ // A logger failure must not break the host.
573
+ }
574
+ };
575
+ let installed = null; // { service, raw, wrapper }
576
+
577
+ const currentService = () => {
578
+ try {
579
+ return ctx.get("pluginManager");
580
+ } catch {
581
+ return undefined;
582
+ }
583
+ };
584
+
585
+ const restore = () => {
586
+ const active = installed;
587
+ installed = null;
588
+ if (!active) return;
589
+ try {
590
+ if (!isBundleMetaWrapper(active.service.listBundles)) return;
591
+ try {
592
+ delete active.service.listBundles;
593
+ } catch {
594
+ // Frozen/exotic service object; fall back to the saved method.
595
+ }
596
+ if (isBundleMetaWrapper(active.service.listBundles)) active.service.listBundles = active.raw;
597
+ } catch (error) {
598
+ warn("failed to restore pluginManager.listBundles: " + String((error && error.message) || error));
599
+ }
600
+ };
601
+
602
+ const tryPatch = () => {
603
+ if (!enabled) return;
604
+ try {
605
+ const service = currentService();
606
+ if (!service || (typeof service !== "object" && typeof service !== "function")) return;
607
+ if (isBundleMetaWrapper(service.listBundles)) {
608
+ if (!installed) installed = { service, raw: service.listBundles, wrapper: service.listBundles };
609
+ return;
610
+ }
611
+ if (installed) restore();
612
+ const raw = service.listBundles;
613
+ if (typeof raw !== "function") return;
614
+ const wrapper = markBundleMetaWrapper(function (...args) {
615
+ let result;
616
+ try {
617
+ result = raw.apply(service, args);
618
+ } catch (error) {
619
+ return Promise.reject(error);
620
+ }
621
+ // listBundles is async today but tolerate a sync array too.
622
+ return Promise.resolve(result).then(
623
+ (value) => {
624
+ try {
625
+ if (!Array.isArray(value)) return value;
626
+ for (const bundle of value) {
627
+ try {
628
+ overlayBundleMeta(bundle, home);
629
+ } catch {
630
+ // Keep this bundle's own metadata.
631
+ }
632
+ }
633
+ return value;
634
+ } catch {
635
+ return value;
636
+ }
637
+ },
638
+ (error) => {
639
+ throw error;
640
+ },
641
+ );
642
+ });
643
+ try {
644
+ service.listBundles = wrapper;
645
+ } catch (error) {
646
+ warn("could not wrap pluginManager.listBundles: " + String((error && error.message) || error));
647
+ return;
648
+ }
649
+ if (!isBundleMetaWrapper(service.listBundles)) {
650
+ warn("could not wrap pluginManager.listBundles: the assignment had no effect");
651
+ return;
652
+ }
653
+ installed = { service, raw, wrapper };
654
+ } catch (error) {
655
+ warn("pluginManager.listBundles hook skipped: " + String((error && error.message) || error));
656
+ }
657
+ };
658
+
659
+ const status = () => ({
660
+ enabled,
661
+ patched: installed !== null,
662
+ service: Boolean(currentService()),
663
+ });
664
+
665
+ if (!enabled) return { dispose: () => {}, tryPatch: () => {}, status };
666
+ tryPatch();
667
+ return { dispose: restore, tryPatch, status };
668
+ }
669
+
670
+ /** Read the configured default route; fall back to the official DeepSeek route. */
671
+ function modelOf(ctx) {
672
+ try {
673
+ // The settings service has no get(); the configured default route lives
674
+ // on the agentDefaultModel service (official dsh-headless plugin does
675
+ // exactly this: ctx.get("agentDefaultModel").currentSelection()).
676
+ const service = ctx.get("agentDefaultModel");
677
+ const current = service && typeof service.currentSelection === "function" ? service.currentSelection() : undefined;
678
+ const pick = (value) => (typeof value === "string" ? value : value && typeof value.id === "string" ? value.id : "");
679
+ const provider = pick(current && current.provider);
680
+ const model = pick(current && current.model);
681
+ if (provider !== "" && model !== "") return { provider, model };
682
+ } catch {
683
+ // Fall through to the default route.
684
+ }
685
+ return { provider: "deepseek-official", model: "deepseek-flash" };
686
+ }
687
+
688
+ function pendingOf(plugins, cache) {
689
+ return plugins.filter((plugin) => !cache.entries[plugin.name]);
690
+ }
691
+
692
+ function buildUserText(pending) {
693
+ return JSON.stringify(
694
+ {
695
+ instruction: "Label every plugin below. Reply with the strict JSON object only.",
696
+ plugins: pending.map((plugin) => ({
697
+ name: plugin.name,
698
+ version: plugin.version,
699
+ description: plugin.description || "(no description; infer from the package name)",
700
+ })),
701
+ },
702
+ null,
703
+ 2,
704
+ );
705
+ }
706
+
707
+ /** Drain one `llm.stream` result into its final text, rejecting on bad terminals. */
708
+ async function collectText(stream) {
709
+ let text = "";
710
+ let finish;
711
+ for await (const chunk of stream) {
712
+ if (!chunk || typeof chunk !== "object") continue;
713
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
714
+ else if (chunk.type === "finish") finish = chunk.reason;
715
+ }
716
+ if (finish && finish.kind !== "stop") {
717
+ const failure = finish.failure || {};
718
+ const detail = [failure.code, failure.message].filter((part) => typeof part === "string" && part !== "").join(": ");
719
+ throw new Error("model ended with " + finish.kind + (detail === "" ? "" : " (" + detail + ")"));
720
+ }
721
+ if (text.trim() === "") throw new Error("model returned no text");
722
+ return text;
723
+ }
724
+
725
+ /** Parse the model's strict-JSON answer, keeping only names we asked about. */
726
+ function parseTranslations(text, pending) {
727
+ let body = text.trim();
728
+ const fenced = body.match(/```(?:json)?\s*([\s\S]*?)```/);
729
+ if (fenced) body = fenced[1].trim();
730
+ const start = body.indexOf("{");
731
+ const end = body.lastIndexOf("}");
732
+ if (start < 0 || end <= start) throw new Error("model output contains no JSON object");
733
+ const parsed = JSON.parse(body.slice(start, end + 1));
734
+ const items = parsed && Array.isArray(parsed.items) ? parsed.items : [];
735
+ const wanted = new Set(pending.map((plugin) => plugin.name));
736
+ const out = {};
737
+ for (const item of items) {
738
+ if (!item || typeof item !== "object") continue;
739
+ const pluginName = typeof item.name === "string" ? item.name : "";
740
+ if (!wanted.has(pluginName)) continue;
741
+ const title = typeof item.title === "string" ? item.title.trim() : "";
742
+ const oneLine = typeof item.oneLine === "string" ? item.oneLine.trim() : "";
743
+ if (title === "" || oneLine === "") continue;
744
+ out[pluginName] = { title: title.slice(0, MAX_TITLE), oneLine: oneLine.slice(0, MAX_ONE_LINE) };
745
+ }
746
+ return out;
747
+ }
748
+
749
+ function handleList(home, req, res, officialMetaStatus) {
750
+ if (!isLoopback(req)) {
751
+ json(res, 403, { ok: false, error: "loopback only" });
752
+ return;
753
+ }
754
+ if (!isSameOriginRequest(req) || req.method !== "GET") {
755
+ json(res, 403, { ok: false, error: "cross-site or non-GET request rejected" });
756
+ return;
757
+ }
758
+ try {
759
+ const { profileDir, plugins } = readProfile(home);
760
+ const cache = readCache(home);
761
+ const items = plugins.map((plugin) => ({
762
+ name: plugin.name,
763
+ version: plugin.version,
764
+ description: plugin.description,
765
+ kind: plugin.kind,
766
+ label: cache.entries[plugin.name] || null,
767
+ }));
768
+ // Diagnostics for the experimental official-page override; clients ignore
769
+ // unknown fields, so this stays backward compatible.
770
+ let officialMeta = { enabled: true, patched: false, service: false, bundles: false };
771
+ if (typeof officialMetaStatus === "function") {
772
+ try {
773
+ officialMeta = officialMetaStatus();
774
+ } catch {
775
+ // Diagnostics must never fail the list request.
776
+ }
777
+ }
778
+ json(res, 200, {
779
+ ok: true,
780
+ home,
781
+ profileDir,
782
+ count: items.length,
783
+ translated: items.filter((item) => item.label !== null).length,
784
+ pending: items.filter((item) => item.label === null).length,
785
+ plugins: items,
786
+ officialMeta,
787
+ });
788
+ } catch (error) {
789
+ respondError(res, error);
790
+ }
791
+ }
792
+
793
+ /**
794
+ * Translate missing labels (or an explicit name list) with the live LLM service.
795
+ * Pure of HTTP: loopback handlers add the fencing and JSON envelope, and the
796
+ * startup auto-translate reuses this core unchanged.
797
+ * @returns {{added: number, skipped: number, remaining: number, total: number, labels: object}}
798
+ */
799
+ export async function translatePending(ctx, home, options) {
800
+ const names = Array.isArray(options && options.names) ? options.names.filter((item) => typeof item === "string") : [];
801
+ const { plugins } = readProfile(home);
802
+ const cache = readCache(home);
803
+ // Explicit names mean "translate these again" (even if a label exists);
804
+ // no names means the automatic mode that only fills missing entries.
805
+ const targets = names.length > 0
806
+ ? plugins.filter((plugin) => names.includes(plugin.name))
807
+ : pendingOf(plugins, cache);
808
+ const pending = targets.slice(0, MAX_PENDING_PER_CALL);
809
+ const remaining = Math.max(0, targets.length - pending.length);
810
+ const total = targets.length;
811
+ if (pending.length === 0) {
812
+ return { added: 0, skipped: 0, remaining: 0, total, labels: cache.entries };
813
+ }
814
+ const { provider, model } = modelOf(ctx);
815
+ const llm = ctx.get("llm");
816
+ if (!llm || typeof llm.stream !== "function") throw new Error("the llm service is not available in this profile");
817
+ const stream = llm.stream({
818
+ provider,
819
+ model,
820
+ temperature: 0,
821
+ system: SYSTEM_PROMPT,
822
+ messages: [{ role: "user", content: [{ type: "text", text: buildUserText(pending) }] }],
823
+ // A stalled provider must not leave the caller busy forever.
824
+ signal: AbortSignal.timeout(TRANSLATE_TIMEOUT_MS),
825
+ });
826
+ const text = await collectText(stream);
827
+ const translations = parseTranslations(text, pending);
828
+ const at = new Date().toISOString();
829
+ let added = 0;
830
+ for (const [pluginName, value] of Object.entries(translations)) {
831
+ // Automatic mode only fills gaps: a manual label always wins over a
832
+ // later AI run. An explicit per-plugin re-translate may overwrite.
833
+ if (names.length === 0 && cache.entries[pluginName]) continue;
834
+ cache.entries[pluginName] = { title: value.title, oneLine: value.oneLine, source: "ai", model: provider + "/" + model, at };
835
+ added += 1;
836
+ }
837
+ if (added > 0) writeCache(home, cache);
838
+ return { added, skipped: pending.length - added, remaining, total, labels: cache.entries };
839
+ }
840
+
841
+ /** HTTP translate handler: fencing + JSON envelope around `translatePending`. */
842
+ async function translateOnce(ctx, home, req, res) {
843
+ if (!isLoopback(req)) {
844
+ json(res, 403, { ok: false, error: "loopback only" });
845
+ return;
846
+ }
847
+ const payload = await readJsonBody(req);
848
+ const names = Array.isArray(payload.names) ? payload.names.filter((item) => typeof item === "string") : [];
849
+ const result = await translatePending(ctx, home, { names });
850
+ json(res, 200, { ok: true, added: result.added, skipped: result.skipped, remaining: result.remaining, labels: result.labels });
851
+ }
852
+
853
+ /** Set one manual label, or reset one entry back to "translate me again". */
854
+ async function handleLabel(home, req, res) {
855
+ if (!isLoopback(req)) {
856
+ json(res, 403, { ok: false, error: "loopback only" });
857
+ return;
858
+ }
859
+ const payload = await readJsonBody(req);
860
+ const pluginName = typeof payload.name === "string" ? payload.name.trim() : "";
861
+ const { plugins } = readProfile(home);
862
+ const installed = plugins.find((plugin) => plugin.name === pluginName);
863
+ if (!installed) throw badRequest("unknown installed plugin: " + (pluginName || "(empty)"));
864
+
865
+ const cache = readCache(home);
866
+ const action = typeof payload.action === "string" ? payload.action : "set";
867
+ if (action === "reset") {
868
+ delete cache.entries[pluginName];
869
+ writeCache(home, cache);
870
+ json(res, 200, { ok: true, name: pluginName, label: null, labels: cache.entries });
871
+ return;
872
+ }
873
+ const title = typeof payload.title === "string" ? payload.title.trim() : "";
874
+ const oneLine = typeof payload.oneLine === "string" ? payload.oneLine.trim() : "";
875
+ if (title === "" || oneLine === "") throw badRequest("title and oneLine are both required");
876
+ cache.entries[pluginName] = {
877
+ title: title.slice(0, MAX_TITLE),
878
+ oneLine: oneLine.slice(0, MAX_ONE_LINE),
879
+ source: "manual",
880
+ at: new Date().toISOString(),
881
+ };
882
+ writeCache(home, cache);
883
+ json(res, 200, { ok: true, name: pluginName, label: cache.entries[pluginName], labels: cache.entries });
884
+ }
885
+
886
+ /**
887
+ * Single-flight: a second translate request while one is running would spend
888
+ * tokens twice and race the atomic cache rename, so refuse it instead.
889
+ */
890
+ let translateInFlight = false;
891
+
892
+ /**
893
+ * One startup pass that fills missing labels. Never throws (a failed auto-run
894
+ * must not affect the host) and respects the same single-flight lock as HTTP.
895
+ * @returns {{added?: number, skipped?: boolean|number, reason?: string, remaining?: number}}
896
+ */
897
+ export async function runAutoTranslate(ctx, home) {
898
+ const log = (level, message) => {
899
+ try {
900
+ ctx.logger?.[level]?.("[plugin-labels] " + message);
901
+ } catch {
902
+ // A logger failure must not break the host.
903
+ }
904
+ };
905
+ if (!autoTranslateEnabled()) return { skipped: true, reason: "disabled" };
906
+ try {
907
+ const { plugins } = readProfile(home);
908
+ const cache = readCache(home);
909
+ const missing = pendingOf(plugins, cache).length;
910
+ if (missing === 0) {
911
+ log("info", "auto-translate: nothing pending");
912
+ return { added: 0, skipped: false, total: 0 };
913
+ }
914
+ if (translateInFlight) return { skipped: true, reason: "busy" };
915
+ translateInFlight = true;
916
+ let result;
917
+ try {
918
+ result = await translatePending(ctx, home, {});
919
+ } finally {
920
+ translateInFlight = false;
921
+ }
922
+ log("info", "auto-translate added " + result.added + " label(s); " + result.remaining + " pending left");
923
+ return result;
924
+ } catch (error) {
925
+ log("warn", "auto-translate failed: " + String((error && error.message) || error));
926
+ return { skipped: true, reason: "error", error: String((error && error.message) || error) };
927
+ }
928
+ }
929
+
930
+ /** Guarded translate entry point. */
931
+ async function handleTranslate(ctx, home, req, res) {
932
+ if (!isLoopback(req)) {
933
+ json(res, 403, { ok: false, error: "loopback only" });
934
+ return;
935
+ }
936
+ if (translateInFlight) {
937
+ json(res, 409, { ok: false, error: "a translation is already running" });
938
+ return;
939
+ }
940
+ translateInFlight = true;
941
+ try {
942
+ await translateOnce(ctx, home, req, res);
943
+ } finally {
944
+ translateInFlight = false;
945
+ }
946
+ }
947
+ /** Merge an exported labels JSON into the cache. */
948
+ async function handleImport(home, req, res) {
949
+ if (!isLoopback(req)) {
950
+ json(res, 403, { ok: false, error: "loopback only" });
951
+ return;
952
+ }
953
+ const payload = await readJsonBody(req);
954
+ const entries = payload.entries !== null && typeof payload.entries === "object" ? payload.entries : payload;
955
+ if (entries === null || typeof entries !== "object" || Array.isArray(entries)) throw badRequest("entries must be an object");
956
+ const { plugins } = readProfile(home);
957
+ const installed = new Set(plugins.map((plugin) => plugin.name));
958
+ const cache = readCache(home);
959
+ const now = new Date().toISOString();
960
+ let applied = 0;
961
+ let skipped = 0;
962
+ for (const [pluginName, value] of Object.entries(entries)) {
963
+ if (!installed.has(pluginName) || RESERVED_KEYS.has(pluginName) || value === null || typeof value !== "object" || Array.isArray(value)) {
964
+ skipped += 1;
965
+ continue;
966
+ }
967
+ const title = typeof value.title === "string" ? value.title.trim() : "";
968
+ const oneLine = typeof value.oneLine === "string" ? value.oneLine.trim() : "";
969
+ if (title === "" || oneLine === "") {
970
+ skipped += 1;
971
+ continue;
972
+ }
973
+ // Keep the origin of each label: a round-trip must not turn AI labels
974
+ // into edited ones (or the reverse); unknown sources normalize to "ai".
975
+ const source = value.source === "manual" ? "manual" : "ai";
976
+ const label = {
977
+ title: title.slice(0, MAX_TITLE),
978
+ oneLine: oneLine.slice(0, MAX_ONE_LINE),
979
+ source,
980
+ at: typeof value.at === "string" && value.at !== "" ? value.at : now,
981
+ };
982
+ if (source === "ai" && typeof value.model === "string" && value.model !== "") label.model = value.model;
983
+ cache.entries[pluginName] = label;
984
+ applied += 1;
985
+ }
986
+ if (applied > 0) writeCache(home, cache);
987
+ json(res, 200, { ok: true, applied, skipped, labels: cache.entries });
988
+ }
989
+ /** Wrap an async handler so a rejection always becomes a JSON answer. */
990
+ function guarded(res, req, work) {
991
+ if (!isSameOriginRequest(req)) {
992
+ json(res, 403, { ok: false, error: "cross-site request rejected" });
993
+ return;
994
+ }
995
+ if (req.method !== "POST") {
996
+ json(res, 405, { ok: false, error: "POST required" });
997
+ return;
998
+ }
999
+ return Promise.resolve()
1000
+ .then(work)
1001
+ // Explicit 4xx statuses (400/413/...) survive; unexpected failures are
1002
+ // answered with a stable 500 body that leaks neither stack nor paths.
1003
+ .catch((error) => respondError(res, error));
1004
+ }
1005
+
1006
+ export function apply(ctx) {
1007
+ const home = resolveHome();
1008
+ // Display overrides: the official Plugins page rows/details (pluginPackages)
1009
+ // and the plugin package cards (pluginManager.listBundles). Installed before
1010
+ // the routes; the service listener re-applies them after a service swap.
1011
+ const officialMeta = installOfficialMeta(ctx, home);
1012
+ const bundleMeta = installBundleMeta(ctx, home);
1013
+ ctx.effect(() => () => {
1014
+ officialMeta.dispose();
1015
+ bundleMeta.dispose();
1016
+ }, "plugin-labels: restore official plugin metadata wrappers");
1017
+ ctx.effect(() => {
1018
+ try {
1019
+ if (typeof ctx.on !== "function") return () => {};
1020
+ return ctx.on("internal/service", (serviceName) => {
1021
+ if (serviceName === "pluginPackages") officialMeta.tryPatch();
1022
+ if (serviceName === "pluginManager") bundleMeta.tryPatch();
1023
+ });
1024
+ } catch {
1025
+ return () => {};
1026
+ }
1027
+ }, "plugin-labels: watch the official metadata services");
1028
+ // Auto-translate: one pass a few seconds after startup fills missing labels,
1029
+ // so a newly installed plugin gets a Chinese name without any UI. Never
1030
+ // blocks startup; DSH_PLUGIN_LABELS_AUTO_TRANSLATE=0 turns it off.
1031
+ if (autoTranslateEnabled()) {
1032
+ ctx.effect(() => {
1033
+ const timer = setTimeout(() => {
1034
+ Promise.resolve()
1035
+ .then(() => runAutoTranslate(ctx, home))
1036
+ .catch(() => {});
1037
+ }, AUTO_TRANSLATE_DELAY_MS);
1038
+ try {
1039
+ if (typeof timer.unref === "function") timer.unref();
1040
+ } catch {
1041
+ // Some timer implementations cannot be unref'd.
1042
+ }
1043
+ return () => {
1044
+ try {
1045
+ clearTimeout(timer);
1046
+ } catch {
1047
+ // Already fired or cleared.
1048
+ }
1049
+ };
1050
+ }, "plugin-labels: auto-translate new plugins");
1051
+ }
1052
+ ctx.effect(() => ctx.webServer.register({
1053
+ kind: "exact",
1054
+ path: LIST_PATH,
1055
+ handler: (req, res) => handleList(home, req, res, () => ({
1056
+ ...officialMeta.status(),
1057
+ bundles: bundleMeta.status().patched,
1058
+ })),
1059
+ }), "plugin-labels: installed plugin list route");
1060
+ ctx.effect(() => ctx.webServer.register({
1061
+ kind: "exact",
1062
+ path: TRANSLATE_PATH,
1063
+ handler: (req, res) => guarded(res, req, () => handleTranslate(ctx, home, req, res)),
1064
+ }), "plugin-labels: translate route");
1065
+ ctx.effect(() => ctx.webServer.register({
1066
+ kind: "exact",
1067
+ path: LABEL_PATH,
1068
+ handler: (req, res) => guarded(res, req, () => handleLabel(home, req, res)),
1069
+ }), "plugin-labels: manual label route");
1070
+ ctx.effect(() => ctx.webServer.register({
1071
+ kind: "exact",
1072
+ path: IMPORT_PATH,
1073
+ handler: (req, res) => guarded(res, req, () => handleImport(home, req, res)),
1074
+ }), "plugin-labels: import labels route");
1075
+ }