dsh-plugin-term-dictionary 0.0.0-stage → 1.1.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,1110 @@
1
+ /**
2
+ * Host half of the term dictionary.
3
+ *
4
+ * It owns three things the page cannot own by itself:
5
+ * 1. the durable dictionary file, under `$DSH_HOME/dsh-plugin-term-dictionary/`;
6
+ * 2. an HTTP surface on the Web carrier's server, which the page reads and
7
+ * writes through relative `fetch` calls;
8
+ * 3. an optional model call that writes a term's explanation.
9
+ *
10
+ * The merge rules are not re-implemented here: `lib/dictionary.js` is loaded
11
+ * through `createRequire` so the host and the page apply exactly the same
12
+ * normalization and merge, and an edit made offline in the page cannot be
13
+ * mangled when it reaches the file.
14
+ *
15
+ * Only `node:` built-ins are imported. A workspace bundle is installed as a link
16
+ * with no `node_modules` of its own, so a bare `@deepseek-ai/*` import would fail
17
+ * to resolve — and the model, when it is needed, is reached through the
18
+ * injected `llm` service rather than through a package import.
19
+ */
20
+
21
+ import { createRequire } from "node:module";
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
23
+ import { homedir, tmpdir } from "node:os";
24
+ import { dirname, join } from "node:path";
25
+ import { decodePackCode, encodePackCode, sha256Hex } from "./pack-code.js";
26
+
27
+ /**
28
+ * The package's own CommonJS core, shared verbatim with the browser half.
29
+ *
30
+ * Every core file lives under `lib/core/`, whose own package.json declares
31
+ * `"type": "commonjs"`. That scope marker is load-bearing twice over: the parent
32
+ * package is ESM (so a plain `.js` here would be parsed as a module and this
33
+ * host half's `import` would fail), and the browser bundle's module table needs
34
+ * callable `module.exports` values. The host half stays ESM, so it reaches the
35
+ * core through `createRequire`.
36
+ */
37
+ const require = createRequire(import.meta.url);
38
+ const dictionary = require("./core/dictionary.js");
39
+ const entries = require("./core/entries.js");
40
+ const packCore = require("./core/pack.js");
41
+
42
+ /** Route prefix owned by this plugin. The browser half spells the same string. */
43
+ const ROUTE_PREFIX = "/dsh-term-dictionary";
44
+
45
+ /** Largest accepted request body. A dictionary is small; anything larger is a bug. */
46
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
47
+
48
+ /** Deadline for one explanation call. */
49
+ const EXPLAIN_TIMEOUT_MS = 45_000;
50
+
51
+ /** Upper bound on the explanation the model may return. */
52
+ const EXPLAIN_MAX_TOKENS = 700;
53
+
54
+ /**
55
+ * The categories the host's feedback API accepts, spelled exactly as the host declares them.
56
+ *
57
+ * A closed set here too: the host validates its own, and passing an id it does not know would fail a
58
+ * remark for a reason the user cannot see. `product-interaction` is the default because that is what a
59
+ * dictionary entry's remark is about.
60
+ */
61
+ const FEEDBACK_CATEGORIES = [
62
+ "other",
63
+ "task-result",
64
+ "instruction-following",
65
+ "product-interaction",
66
+ "service-stability",
67
+ "resource-cost",
68
+ "security-privacy-permission"
69
+ ];
70
+
71
+ /** Upper bound on one feedback remark: it is a line or two, not a document. */
72
+ const MAX_FEEDBACK_CHARS = 500;
73
+
74
+ /** How long a fetched index is reused before a source is asked again. */
75
+ const INDEX_TTL_MS = 60 * 60 * 1000;
76
+
77
+ /** How long a fetched pack is reused. Longer, because a pack is content rather than a listing. */
78
+ const PACK_TTL_MS = 24 * 60 * 60 * 1000;
79
+
80
+ /** Size caps on what a source may hand back, so one URL cannot fill the disk or the heap. */
81
+ const MAX_INDEX_BYTES = 1024 * 1024;
82
+ const MAX_PACK_BYTES = 4 * 1024 * 1024;
83
+
84
+ /** How long one fetch may take. Shorter than the model call: a static file is not thinking. */
85
+ const FETCH_TIMEOUT_MS = 15_000;
86
+
87
+ /** Test seam: injected by the tests, absent in the real host. */
88
+ let runtimeOverrides = null;
89
+
90
+ /**
91
+ * The plugin's own version, for the packs it publishes.
92
+ *
93
+ * Read from the package rather than written here, so a published pack names the generation that built
94
+ * it without a second place to keep the number in step. Best effort: a profile that cannot read its
95
+ * own package.json still builds packs, they just do not name a build.
96
+ *
97
+ * @returns the version, or "".
98
+ */
99
+ let cachedVersion = null;
100
+ function pluginVersion() {
101
+ if (cachedVersion !== null) return cachedVersion;
102
+ cachedVersion = "";
103
+ try {
104
+ const parsed = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
105
+ if (typeof parsed?.version === "string") cachedVersion = parsed.version;
106
+ } catch (error) {
107
+ void error;
108
+ }
109
+ return cachedVersion;
110
+ }
111
+
112
+ /**
113
+ * Load the shared dictionary core. Indirection through `createRequire` keeps the
114
+ * browser-facing file free of build tooling while giving the host the same code.
115
+ * @returns the dictionary module.
116
+ */
117
+ function core() {
118
+ return runtimeOverrides?.dictionary ?? dictionary;
119
+ }
120
+
121
+ /**
122
+ * Resolve the directory plugin-owned data lives in.
123
+ *
124
+ * The configured path wins. Otherwise the first candidate that can actually be
125
+ * created and written to is used, because `$DSH_HOME` is not always writable —
126
+ * an installation directory under a protected root, or a sandboxed process, can
127
+ * refuse it. Falling back keeps the dictionary working instead of failing every
128
+ * save, and the chosen path is reported to the page so the user can see where the
129
+ * data went.
130
+ *
131
+ * @param configured - a path from the plugin config, when the user set one.
132
+ * @param logger - optional warning sink.
133
+ * @returns `{ directory, writable, candidates }`.
134
+ */
135
+ function resolveDataDirectory(configured, logger) {
136
+ const home = (process.env.DSH_HOME ?? "").trim() || join(homedir(), ".dsh");
137
+ const primary = typeof configured === "string" && configured.trim() !== "" ? configured.trim() : join(home, "dsh-plugin-term-dictionary");
138
+ const candidates = configured !== undefined && configured !== null && configured !== ""
139
+ ? [primary]
140
+ : [primary, join(tmpdir(), "dsh-plugin-term-dictionary")];
141
+ for (const directory of candidates) {
142
+ try {
143
+ mkdirSync(directory, { recursive: true });
144
+ const probe = join(directory, ".write-probe");
145
+ writeFileSync(probe, "", "utf8");
146
+ rmSync(probe, { force: true });
147
+ return { directory, writable: true, candidates };
148
+ } catch (error) {
149
+ logger?.warn?.(
150
+ `term-dictionary: ${directory} is not writable (${error instanceof Error ? error.message : String(error)}); trying the next location`
151
+ );
152
+ }
153
+ }
154
+ // Nothing was writable: report it and keep the dictionary in memory so the
155
+ // session still works, rather than throwing during activation.
156
+ logger?.warn?.(`term-dictionary: no writable data directory among ${candidates.join(", ")}; the dictionary will not persist`);
157
+ return { directory: primary, writable: false, candidates };
158
+ }
159
+
160
+ /** The file system surface `lib/dictionary.js` expects. */
161
+ const fileSystem = { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync };
162
+
163
+ /**
164
+ * Read a request body with a hard size ceiling.
165
+ * @param req - the incoming request.
166
+ * @returns the decoded UTF-8 body.
167
+ * @throws {Error} when the body exceeds {@link MAX_BODY_BYTES}.
168
+ */
169
+ async function readBody(req) {
170
+ const chunks = [];
171
+ let size = 0;
172
+ for await (const chunk of req) {
173
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
174
+ size += buffer.length;
175
+ if (size > MAX_BODY_BYTES) throw new Error("request body too large");
176
+ chunks.push(buffer);
177
+ }
178
+ return Buffer.concat(chunks).toString("utf8");
179
+ }
180
+
181
+ /**
182
+ * Write one JSON response.
183
+ * @param res - the response to own.
184
+ * @param status - HTTP status code.
185
+ * @param payload - a JSON-serializable value.
186
+ */
187
+ function sendJson(res, status, payload) {
188
+ res.statusCode = status;
189
+ res.setHeader("content-type", "application/json; charset=utf-8");
190
+ res.setHeader("cache-control", "no-store");
191
+ res.end(JSON.stringify(payload));
192
+ }
193
+
194
+ /**
195
+ * Parse a JSON request body.
196
+ * @param req - the incoming request.
197
+ * @returns the parsed value, or null when the body is not a JSON object.
198
+ */
199
+ async function readJson(req) {
200
+ const text = await readBody(req);
201
+ if (text.trim() === "") return null;
202
+ const value = JSON.parse(text);
203
+ return value !== null && typeof value === "object" ? value : null;
204
+ }
205
+
206
+ /**
207
+ * Ask the model service for one term's explanation.
208
+ *
209
+ * The prompt is deliberately narrow: the model answers with a single JSON object,
210
+ * and every field is re-validated here, so a chatty or malformed answer degrades
211
+ * to "no explanation" instead of polluting the dictionary.
212
+ *
213
+ * @param llm - the model service; the caller proves it is mounted before calling.
214
+ * @param config - the resolved plugin config (`provider`, `model`).
215
+ * @param selection - the profile's default model service, when mounted.
216
+ * @param term - the term to explain.
217
+ * @param context - the sentence the term appeared in.
218
+ * @param logger - optional warning sink.
219
+ * @returns the definition fields, or throws with a readable message.
220
+ */
221
+ async function explainTerm(llm, config, selection, term, context, logger, preferences) {
222
+ const targets = await resolveTargets(llm, config, selection, logger);
223
+ let lastError = null;
224
+ for (let at = 0; at < targets.length; at++) {
225
+ const target = targets[at];
226
+ try {
227
+ return await streamExplanation(llm, target, term, context, logger, preferences);
228
+ } catch (error) {
229
+ const message = error instanceof Error ? error.message : String(error);
230
+ // A route that cannot authenticate is not a dead end. The next candidate is
231
+ // precisely what the user means by "the model I am already signed in to", and
232
+ // reporting the credential error instead is what produced a failure message that
233
+ // named a route the user never chose.
234
+ if (!isCredentialFailure(message) || at === targets.length - 1) throw error;
235
+ logger?.warn?.(`term-dictionary: route ${target.provider} cannot authenticate (${message}); trying the next route`);
236
+ lastError = error;
237
+ }
238
+ }
239
+ throw lastError ?? new Error("no model route could be used");
240
+ }
241
+
242
+ /**
243
+ * Stream one explanation through one already-chosen route.
244
+ *
245
+ * Split out of {@link explainTerm} so that a credential failure can be caught per route:
246
+ * the retry has to wrap the whole call, including stream iteration, because the adapter
247
+ * reports a missing key as a terminal `finish` chunk rather than by throwing at dispatch.
248
+ *
249
+ * @param llm - the model service.
250
+ * @param target - the route, from {@link resolveTargets}.
251
+ * @param term - the term to explain.
252
+ * @param context - the text the term appeared in.
253
+ * @param logger - optional warning sink.
254
+ * @param preferences - the user's `{ lang, depth, retry }`.
255
+ * @returns the definition fields, or throws with a readable message.
256
+ */
257
+ async function streamExplanation(llm, target, term, context, logger, preferences) {
258
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
259
+ const timer = controller === null ? null : setTimeout(() => controller.abort(), EXPLAIN_TIMEOUT_MS);
260
+ try {
261
+ const stream = llm.stream({
262
+ provider: target.provider,
263
+ model: target.model,
264
+ messages: [
265
+ {
266
+ role: "user",
267
+ content: [{ type: "text", text: buildPrompt(term, context) }]
268
+ }
269
+ ],
270
+ system: buildSystemPrompt(preferences),
271
+ temperature: 0.2,
272
+ maxTokens: EXPLAIN_MAX_TOKENS,
273
+ ...(controller === null ? {} : { signal: controller.signal })
274
+ });
275
+ let text = "";
276
+ let failure = null;
277
+ for await (const chunk of stream) {
278
+ if (chunk === null || typeof chunk !== "object") continue;
279
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
280
+ else if (chunk.type === "finish") failure = finishFailure(chunk.reason);
281
+ }
282
+ if (failure !== null) throw new Error(failure);
283
+ const parsed = parseExplanation(text);
284
+ if (parsed === null) throw new Error("the model did not return a usable explanation");
285
+ return parsed;
286
+ } finally {
287
+ if (timer !== null) clearTimeout(timer);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Decide which provider/model pair to call.
293
+ *
294
+ * The order of preference is the whole point of this function:
295
+ *
296
+ * 1. the plugin's own `config.provider` / `config.model`, when set;
297
+ * 2. **the profile's own default model selection** (`agentDefaultModel`), which is
298
+ * the route the agent in this window is already talking through — so it is a route
299
+ * that is known to be configured and credentialed in *this* deployment;
300
+ * 3. the first registered provider, with the first model it advertises.
301
+ *
302
+ * Step 2 exists because step 3 alone picked the wrong adapter. `listProviders()`
303
+ * returns providers in registration order, and in this profile the official
304
+ * API-key route (`deepseek-official`) is registered before the signed-in account
305
+ * route (`deepseek-account`). Generating an explanation therefore failed with
306
+ *
307
+ * llm-deepseek: no API key for provider route "deepseek-official";
308
+ * store DEEPSEEK_API_KEY through the credentials service
309
+ *
310
+ * while the window itself was happily using `deepseek-account` — the profile's
311
+ * configured provider. Asking the deployment what it uses is the only way to be right
312
+ * about which route can actually answer.
313
+ *
314
+ * @param llm - the model service.
315
+ * @param config - resolved plugin config.
316
+ * @param selection - the profile's default model service, when one is mounted.
317
+ * @param logger - optional warning sink.
318
+ * @returns every route to try, best first.
319
+ * @throws {Error} when nothing can be routed.
320
+ */
321
+ async function resolveTargets(llm, config, selection, logger) {
322
+ const configured = typeof config.provider === "string" ? config.provider.trim() : "";
323
+ const preferred = readSelection(selection, logger);
324
+ const providers = typeof llm.listProviders === "function" ? llm.listProviders() : [];
325
+ const order = [];
326
+ /** Add a route once, ignoring blanks and duplicates. */
327
+ const push = (provider) => {
328
+ const name = typeof provider === "string" ? provider.trim() : "";
329
+ if (name !== "" && !order.includes(name)) order.push(name);
330
+ };
331
+ if (configured !== "") {
332
+ // An explicit configuration tries ONE route. Falling through to another would ignore
333
+ // the operator's choice, and its failure is theirs to see.
334
+ push(configured);
335
+ } else {
336
+ // The selection is tried even when the catalog does not list it: a route this half
337
+ // cannot see is still the best available answer to "which model is this window using",
338
+ // and the old visibility test is exactly what threw it away and picked the API-key
339
+ // route instead.
340
+ if (preferred !== null) push(preferred.provider);
341
+ if (Array.isArray(providers)) for (const entry of providers) push(entry?.id);
342
+ }
343
+ if (order.length === 0) throw new Error("no model provider is available");
344
+ const targets = [];
345
+ for (const provider of order) {
346
+ const own = preferred !== null && preferred.provider === provider ? preferred.model : "";
347
+ targets.push(await targetFor(llm, config, provider, own));
348
+ }
349
+ return targets;
350
+ }
351
+
352
+ /**
353
+ * Whether a failure says "this route has no credential".
354
+ *
355
+ * Only this kind of failure is worth another route. A missing API key is a property of the
356
+ * ROUTE, not of the request, so the next candidate can legitimately succeed; every other
357
+ * failure (a refused request, an unknown model, a timeout) would be a real error sent to a
358
+ * second provider for no reason, so it is reported as it is.
359
+ *
360
+ * @param message - the failure text.
361
+ * @returns true when trying the next route is justified.
362
+ */
363
+ function isCredentialFailure(message) {
364
+ return /no api key|api key|apikey|credential|unauthoriz|unauthoris|unauthorized|not authenticated/i.test(String(message ?? ""));
365
+ }
366
+
367
+ /**
368
+ * Read the profile's default model selection, defensively.
369
+ *
370
+ * An unmounted owner, a service that throws, or a half-filled selection all yield null so the
371
+ * caller falls through to the catalog rather than failing the call.
372
+ *
373
+ * @param selection - the `agentDefaultModel` service, when mounted.
374
+ * @param logger - optional warning sink.
375
+ * @returns `{ provider, model }`, or null.
376
+ */
377
+ function readSelection(selection, logger) {
378
+ if (selection === null || selection === undefined) return null;
379
+ try {
380
+ const current = typeof selection.currentSelection === "function" ? selection.currentSelection() : null;
381
+ if (current !== null && typeof current === "object" && typeof current.provider === "string" && current.provider.trim() !== "") {
382
+ return {
383
+ provider: current.provider.trim(),
384
+ model: typeof current.model === "string" && current.model.trim() !== "" ? current.model.trim() : ""
385
+ };
386
+ }
387
+ } catch (error) {
388
+ logger?.warn?.(`term-dictionary: reading the default model selection failed: ${error instanceof Error ? error.message : String(error)}`);
389
+ }
390
+ return null;
391
+ }
392
+
393
+ /**
394
+ * Choose a model for one provider.
395
+ *
396
+ * @param llm - the model service.
397
+ * @param config - the resolved plugin config.
398
+ * @param provider - the route.
399
+ * @param preferredModel - the selection's model, when this IS the selection's route.
400
+ * @returns `{ provider, model }`.
401
+ */
402
+ async function targetFor(llm, config, provider, preferredModel) {
403
+ if (typeof config.model === "string" && config.model.trim() !== "") return { provider, model: config.model.trim() };
404
+ // A selection's model applies only to the selection's own provider: taking it across
405
+ // providers would ask one adapter for another's model id.
406
+ if (typeof preferredModel === "string" && preferredModel !== "") return { provider, model: preferredModel };
407
+ let models = [];
408
+ try {
409
+ models = await llm.listModels(provider);
410
+ } catch (error) {
411
+ models = [];
412
+ }
413
+ const model = Array.isArray(models) && typeof models[0]?.id === "string" ? models[0].id : "deepseek-flash";
414
+ return { provider, model };
415
+ }
416
+
417
+ /** The first route to try, for callers that take a single target. @returns the route. */
418
+ async function resolveTarget(llm, config, selection, logger) {
419
+ return (await resolveTargets(llm, config, selection, logger))[0];
420
+ }
421
+
422
+ /** The instruction the model answers. */
423
+ const SYSTEM_PROMPT = [
424
+ "You are building a glossary for a Chinese reader who is a software engineer.",
425
+ "Explain the given term as it is used in the given context.",
426
+ "Answer with ONE JSON object and nothing else, with exactly these keys:",
427
+ '{"zh": "...", "gloss": "...", "usage": "...", "domain": "..."}',
428
+ "- zh: the term's Chinese name, 2-8 Chinese characters.",
429
+ '- gloss: one or two Chinese sentences explaining what it means and why it matters.',
430
+ "- usage: one short Chinese sentence showing it in use, or an empty string.",
431
+ "- domain: one Chinese domain label such as 软件工程 / 人工智能 / 数据 / 网络 / 安全 / 运维.",
432
+ "Do not use markdown fences. Do not add keys. Do not answer in English.",
433
+ ].join("\n");
434
+
435
+ /**
436
+ * The system instruction for one explanation, given the user's preferences.
437
+ *
438
+ * The base prompt is kept and only narrowed or widened by a clause, because it is the text
439
+ * whose output shape the parser and the tests already agree on — rewriting it per depth would
440
+ * be four prompts to keep in sync. The English case is the exception: the base prompt ends with
441
+ * "Do not answer in English", so it cannot be reused, and English gets its own instruction
442
+ * while the JSON KEYS stay the same (they are field names, not prose).
443
+ *
444
+ * @param preferences - `{ lang, depth, retry }` as sent by the page.
445
+ * @returns the instruction.
446
+ */
447
+ function buildSystemPrompt(preferences) {
448
+ const depth = preferences?.depth === "brief" ? "brief" : preferences?.depth === "detailed" ? "detailed" : "normal";
449
+ // A second attempt at an entry the user called wrong. The sentence is FIXED and says nothing about
450
+ // what they objected to: their note is their own text, and feeding it to a model would turn a
451
+ // remark into an instruction. What the model can act on is simply that the last answer was
452
+ // rejected — which is the one thing the user's verdict does establish.
453
+ const retry = preferences?.retry === true;
454
+ const retryZh = retry ? "\n注意:上一个解释被使用者否掉了,请换一个更具体的说法,不要重复套话。" : "";
455
+ // `lang` is a CLOSED set here: exactly `en` selects the English instruction, and everything
456
+ // else — `zh`, `auto`, absent, unrecognized — produces the Chinese one.
457
+ //
458
+ // `auto` deliberately still means Chinese rather than "follow the UI": the page resolves
459
+ // `auto` against the active locale BEFORE sending, because only the page knows which locale
460
+ // is active. A request arriving with `auto` therefore comes from something other than this
461
+ // plugin's own client, and an interface language cannot be guessed at this end. Spelling
462
+ // that out is the point: the old `!== "en"` test made the same decision but read as an
463
+ // accident, which is how a closed set turns into a silent default nobody can find.
464
+ if (preferences?.lang !== "en") {
465
+ if (depth === "brief") return `${SYSTEM_PROMPT}\n再简短些:gloss 只写一句不超过 20 字的话,usage 留空。${retryZh}`;
466
+ if (depth === "detailed") return `${SYSTEM_PROMPT}\n再充实些:gloss 写两到三句,说明它是什么、怎么工作、为什么重要;usage 给一个完整例句。${retryZh}`;
467
+ return `${SYSTEM_PROMPT}${retryZh}`;
468
+ }
469
+ const gloss =
470
+ depth === "brief"
471
+ ? "one English sentence, at most 20 words."
472
+ : depth === "detailed"
473
+ ? "two or three English sentences covering what it is, how it works, and why it matters."
474
+ : "one or two English sentences explaining what it means and why it matters.";
475
+ const usage = depth === "brief" ? "an empty string." : "one short English sentence showing it in use, or an empty string.";
476
+ return [
477
+ "Explain the given term as it is used in the given context.",
478
+ "Answer with ONE JSON object and nothing else, with exactly these keys:",
479
+ '{"zh": "...", "gloss": "...", "usage": "...", "domain": "..."}',
480
+ "- zh: the term's short Chinese name, 2-8 Chinese characters. This field stays Chinese.",
481
+ `- gloss: ${gloss}`,
482
+ `- usage: ${usage}`,
483
+ "- domain: one short English label such as software engineering / AI / data / networking / security / operations.",
484
+ "Do not use markdown fences. Do not add keys.",
485
+ // Same fixed sentence as the Chinese branch, in the language the answer is written in.
486
+ ...(retry ? ["Note: the previous explanation was rejected by the user. Take a different, more specific angle rather than restating the obvious."] : [])
487
+ ].join("\n");
488
+ }
489
+
490
+ /** The per-term instruction. */
491
+ function buildPrompt(term, context) {
492
+ const trimmed = typeof context === "string" ? context.trim() : "";
493
+ return trimmed === ""
494
+ ? `术语:${term}`
495
+ : `术语:${term}\n它出现的上下文:${trimmed.slice(0, 600)}`;
496
+ }
497
+
498
+ /**
499
+ * Translate a terminal `finish` reason into a failure message.
500
+ * @param reason - the finish payload.
501
+ * @returns a message, or null when the stream ended normally.
502
+ */
503
+ function finishFailure(reason) {
504
+ if (reason === null || typeof reason !== "object") return null;
505
+ if (reason.kind === "error" || reason.kind === "aborted") {
506
+ const message = reason.failure !== null && typeof reason.failure === "object" && typeof reason.failure.message === "string"
507
+ ? reason.failure.message
508
+ : "the model call failed";
509
+ return message;
510
+ }
511
+ return null;
512
+ }
513
+
514
+ /**
515
+ * Pull the explanation object out of a model answer.
516
+ *
517
+ * The answer is untrusted: it may carry fences, prose, or a truncated object, so
518
+ * the first balanced `{...}` run is tried and every field is validated.
519
+ *
520
+ * @param text - the model's raw answer.
521
+ * @returns the four definition fields, or null when nothing usable is present.
522
+ */
523
+ function parseExplanation(text) {
524
+ if (typeof text !== "string" || text.trim() === "") return null;
525
+ const cleaned = text.replace(/```(?:json)?/gi, "").trim();
526
+ for (const candidate of balancedObjects(cleaned)) {
527
+ let parsed;
528
+ try {
529
+ parsed = JSON.parse(candidate);
530
+ } catch {
531
+ continue;
532
+ }
533
+ if (parsed === null || typeof parsed !== "object") continue;
534
+ const gloss = typeof parsed.gloss === "string" ? parsed.gloss.trim() : "";
535
+ if (gloss === "") continue;
536
+ return {
537
+ zh: typeof parsed.zh === "string" ? parsed.zh.trim().slice(0, 60) : "",
538
+ gloss: gloss.slice(0, entries.MAX_DEFINITION_CHARS),
539
+ usage: typeof parsed.usage === "string" ? parsed.usage.trim().slice(0, entries.MAX_DEFINITION_CHARS) : "",
540
+ domain: typeof parsed.domain === "string" ? parsed.domain.trim().slice(0, 24) : ""
541
+ };
542
+ }
543
+ return null;
544
+ }
545
+
546
+ /**
547
+ * Every top-level `{...}` run in a string, longest first.
548
+ * @param text - the text to scan.
549
+ * @returns candidate JSON objects as text.
550
+ */
551
+ function balancedObjects(text) {
552
+ const found = [];
553
+ let depth = 0;
554
+ let start = -1;
555
+ let inString = false;
556
+ let escaped = false;
557
+ for (let index = 0; index < text.length; index++) {
558
+ const character = text[index];
559
+ if (inString) {
560
+ if (escaped) escaped = false;
561
+ else if (character === "\\") escaped = true;
562
+ else if (character === '"') inString = false;
563
+ continue;
564
+ }
565
+ if (character === '"') {
566
+ inString = true;
567
+ continue;
568
+ }
569
+ if (character === "{") {
570
+ if (depth === 0) start = index;
571
+ depth++;
572
+ continue;
573
+ }
574
+ if (character === "}" && depth > 0) {
575
+ depth--;
576
+ if (depth === 0 && start >= 0) {
577
+ found.push(text.slice(start, index + 1));
578
+ start = -1;
579
+ }
580
+ }
581
+ }
582
+ return found.sort((left, right) => right.length - left.length);
583
+ }
584
+
585
+ /**
586
+ * Plugin entry. Registers the dictionary file store and the HTTP routes.
587
+ *
588
+ * @param ctx - the host plugin context.
589
+ * @param config - the resolved plugin config.
590
+ */
591
+ export function apply(ctx, config) {
592
+ const resolved = config ?? {};
593
+ const logger = ctx.logger;
594
+ const location = resolveDataDirectory(resolved.dataDir, logger);
595
+ const file = join(location.directory, "dictionary.json");
596
+ const store = dictionary.createFileStore(fileSystem, file, location.writable ? logger : null);
597
+ let state = store.load();
598
+
599
+ /** Persist the current document, in the background. */
600
+ function persist() {
601
+ void store.save(state).catch((error) => {
602
+ logger?.warn?.(`term-dictionary: saving failed: ${error instanceof Error ? error.message : String(error)}`);
603
+ });
604
+ }
605
+
606
+ /**
607
+ * Run one mutating operation and persist only when it changed something.
608
+ * @param operation - a function from document to `{ state, ... }`.
609
+ * @returns the operation's result.
610
+ */
611
+ function mutate(operation) {
612
+ const result = operation(state);
613
+ if (result !== null && typeof result === "object" && result.state !== undefined && result.state !== state) {
614
+ state = result.state;
615
+ persist();
616
+ }
617
+ return result;
618
+ }
619
+
620
+ ctx.effect(() => () => {
621
+ // Nothing owns an open handle, but persisting on unload keeps a queued write
622
+ // from being lost when the fiber is disposed mid-edit.
623
+ persist();
624
+ }, "term-dictionary: final flush");
625
+
626
+ // The model is optional. Declaring it in the root `inject` would keep the whole
627
+ // plugin dormant in a profile with no adapter — including the dictionary routes,
628
+ // which need no model at all. A child context gets the service when it appears
629
+ // and is torn down when it leaves, so the buttons that need a model simply
630
+ // report that none is mounted.
631
+ let model = null;
632
+ ctx.inject(["llm"], (llmCtx) => {
633
+ model = llmCtx.llm;
634
+ ctx.effect(() => () => {
635
+ model = null;
636
+ }, "term-dictionary: model service detached");
637
+ });
638
+
639
+ // The profile's own default model route, used to pick a provider that is actually
640
+ // configured and credentialed here. Optional for the same reason `llm` is: a
641
+ // composition without it must still serve the dictionary.
642
+ let defaultModel = null;
643
+ ctx.inject(["agentDefaultModel"], (selectionCtx) => {
644
+ defaultModel = selectionCtx.agentDefaultModel;
645
+ ctx.effect(() => () => {
646
+ defaultModel = null;
647
+ }, "term-dictionary: default model service detached");
648
+ });
649
+
650
+ // The host's own feedback channel, optional for the same reason `llm` is.
651
+ //
652
+ // `remote.sessionFeedback` is the CLIENT-side projection of this service, and asking the page for
653
+ // it would mean listing it in the client half's `inject` — where a service that never arrives parks
654
+ // the WHOLE package (the panel, the underlines, every gesture) waiting for it. A live desktop
655
+ // profile was measured with no `remote` service at all, so that inject would have killed the
656
+ // plugin on the machine it was written on. The optional child context is the runtime's own answer:
657
+ // the page talks to its own route, and a composition without the channel reports that it has none.
658
+ let feedback = null;
659
+ ctx.inject(["sessionFeedback"], (feedbackCtx) => {
660
+ feedback = feedbackCtx.sessionFeedback;
661
+ ctx.effect(() => () => {
662
+ feedback = null;
663
+ }, "term-dictionary: feedback channel detached");
664
+ });
665
+
666
+ const prefix = { kind: "prefix", path: ROUTE_PREFIX };
667
+
668
+ ctx.effect(
669
+ () =>
670
+ ctx.webServer.register({
671
+ ...prefix,
672
+ handler: async (req, res) => {
673
+ try {
674
+ await route(req, res);
675
+ } catch (error) {
676
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
677
+ }
678
+ }
679
+ }),
680
+ `term-dictionary: routes under ${ROUTE_PREFIX}`
681
+ );
682
+
683
+ /**
684
+ * Dispatch one request inside the plugin's prefix.
685
+ * @param req - the incoming request.
686
+ * @param res - the response to own.
687
+ */
688
+ async function route(req, res) {
689
+ const path = new URL(req.url ?? "/", "http://dsh.invalid").pathname.slice(ROUTE_PREFIX.length) || "/";
690
+ if (path === "/state" || path === "/") {
691
+ if (req.method !== "GET") return sendJson(res, 405, { error: "method not allowed" });
692
+ // `serializeState` emits only what a reader may see: live entries, the
693
+ // keys of deleted ones, and no tombstone bodies. The page needs the keys
694
+ // so a window that never held the tombstone still learns about the
695
+ // deletion, and `location` so a fallback directory or a read-only profile
696
+ // is visible rather than silent.
697
+ return sendJson(res, 200, {
698
+ ...dictionary.serializeState(dictionary.seal(state)),
699
+ location: { directory: location.directory, writable: location.writable, model: model !== null }
700
+ });
701
+ }
702
+ if (path === "/entries") {
703
+ if (req.method !== "POST") return sendJson(res, 405, { error: "method not allowed" });
704
+ const body = await readJson(req);
705
+ if (body === null) return sendJson(res, 400, { error: "a JSON object body is required" });
706
+ return handleEntries(res, body);
707
+ }
708
+ if (path === "/explain") {
709
+ if (req.method !== "POST") return sendJson(res, 405, { error: "method not allowed" });
710
+ const body = await readJson(req);
711
+ if (body === null) return sendJson(res, 400, { error: "a JSON object body is required" });
712
+ return handleExplain(res, body);
713
+ }
714
+ if (path === "/feedback") {
715
+ if (req.method !== "POST") return sendJson(res, 405, { error: "method not allowed" });
716
+ const body = await readJson(req);
717
+ if (body === null) return sendJson(res, 400, { error: "a JSON object body is required" });
718
+ return handleFeedback(res, body);
719
+ }
720
+ if (path === "/pack") {
721
+ if (req.method !== "POST") return sendJson(res, 405, { error: "method not allowed" });
722
+ const body = await readJson(req);
723
+ if (body === null) return sendJson(res, 400, { error: "a JSON object body is required" });
724
+ return handlePack(res, body);
725
+ }
726
+ if (path === "/source/index") {
727
+ if (req.method !== "GET") return sendJson(res, 405, { error: "method not allowed" });
728
+ return handleSourceIndex(res, new URL(req.url ?? "/", "http://dsh.invalid").searchParams);
729
+ }
730
+ if (path === "/source/pack") {
731
+ if (req.method !== "GET") return sendJson(res, 405, { error: "method not allowed" });
732
+ return handleSourcePack(res, new URL(req.url ?? "/", "http://dsh.invalid").searchParams);
733
+ }
734
+ return sendJson(res, 404, { error: `unknown route ${path}` });
735
+ }
736
+
737
+ /**
738
+ * `POST /entries` — the page's write path.
739
+ *
740
+ * Three actions: `replace` (the page's whole document, merged term by term),
741
+ * `record` (one sighting), and `remove`.
742
+ *
743
+ * @param res - the response to own.
744
+ * @param body - the parsed request body.
745
+ */
746
+ function handleEntries(res, body) {
747
+ const action = typeof body.action === "string" ? body.action : "replace";
748
+ if (action === "replace") {
749
+ if (body.state === undefined) return sendJson(res, 400, { error: "action replace requires state" });
750
+ state = core().mergeState(state, body.state);
751
+ persist();
752
+ return sendJson(res, 200, core().serializeState(core().seal(state))); }
753
+ if (action === "record") {
754
+ if (typeof body.term !== "string" || body.term.trim() === "") return sendJson(res, 400, { error: "action record requires term" });
755
+ const result = mutate((current) =>
756
+ core().recordSighting(
757
+ current,
758
+ {
759
+ term: body.term,
760
+ context: typeof body.context === "string" ? body.context : "",
761
+ glossary: body.glossary !== null && typeof body.glossary === "object" ? body.glossary : undefined,
762
+ sessionId: typeof body.sessionId === "string" ? body.sessionId : ""
763
+ },
764
+ { now: Date.now() }
765
+ )
766
+ );
767
+ return sendJson(res, 200, { ok: true, created: result.created === true, entry: result.entry ?? null });
768
+ }
769
+ if (action === "remove") {
770
+ const target = typeof body.id === "string" && body.id !== "" ? body.id : typeof body.term === "string" ? body.term : "";
771
+ if (target === "") return sendJson(res, 400, { error: "action remove requires id or term" });
772
+ const result = mutate((current) => core().removeEntry(current, target, { now: Date.now() }));
773
+ return sendJson(res, 200, { ok: true, removed: result.removed === true });
774
+ }
775
+ if (action === "update") {
776
+ if (typeof body.term !== "string" || body.term.trim() === "") return sendJson(res, 400, { error: "action update requires term" });
777
+ const result = mutate((current) =>
778
+ core().editEntry(
779
+ current,
780
+ body.term,
781
+ {
782
+ definition: body.definition !== null && typeof body.definition === "object" ? body.definition : undefined,
783
+ domain: typeof body.domain === "string" ? body.domain : undefined,
784
+ aliases: Array.isArray(body.aliases) ? body.aliases : undefined,
785
+ pinned: typeof body.pinned === "boolean" ? body.pinned : undefined
786
+ },
787
+ { now: Date.now() }
788
+ )
789
+ );
790
+ return sendJson(res, 200, { ok: true, entry: result.entry ?? null });
791
+ }
792
+ return sendJson(res, 400, { error: `unknown action ${action}` });
793
+ }
794
+
795
+ /**
796
+ * `POST /explain` — ask the model for one term's explanation and store it.
797
+ * @param res - the response to own.
798
+ * @param body - the parsed request body.
799
+ */
800
+ async function handleExplain(res, body) {
801
+ const term = typeof body.term === "string" ? body.term.trim() : "";
802
+ if (term === "") return sendJson(res, 400, { error: "term is required" });
803
+ if (model === null) return sendJson(res, 503, { error: "no model service is mounted in this profile" });
804
+ let definition;
805
+ try {
806
+ definition = await explainTerm(model, resolved, defaultModel, term, typeof body.context === "string" ? body.context : "", logger, {
807
+ lang: typeof body.lang === "string" ? body.lang : "auto",
808
+ depth: typeof body.depth === "string" ? body.depth : "normal",
809
+ // Strictly `true`, like every other field the page sends: a truthy string from some
810
+ // other caller must not be able to change the instruction.
811
+ retry: body.retry === true
812
+ });
813
+ } catch (error) {
814
+ return sendJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
815
+ }
816
+ const result = mutate((current) =>
817
+ core().editEntry(current, term, { definition, domain: definition.domain }, { now: Date.now() })
818
+ );
819
+ return sendJson(res, 200, {
820
+ ok: true,
821
+ definition,
822
+ source: "llm",
823
+ entry: result.entry ?? null
824
+ });
825
+ }
826
+
827
+ /**
828
+ * `POST /feedback` — record one remark into the host's own session feedback log.
829
+ *
830
+ * What this channel is: the host keeps the remark in THIS machine's session log, where it never
831
+ * enters a model's context. What it is NOT: a way to reach the plugin's author — nothing leaves the
832
+ * machine — which is exactly why the plugin also exports a report the user can send themselves.
833
+ *
834
+ * `unavailable` comes back as a 200 with `ok: false`, not as an HTTP error: a composition without
835
+ * the feedback channel is a normal state, and the page distinguishes "this build cannot" from "that
836
+ * session is gone" and from "the host refused".
837
+ *
838
+ * @param res - the response to own.
839
+ * @param body - the parsed request body.
840
+ */
841
+ async function handleFeedback(res, body) {
842
+ if (feedback === null) return sendJson(res, 200, { ok: false, error: "unavailable" });
843
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
844
+ if (sessionId === "") return sendJson(res, 400, { error: "sessionId is required" });
845
+ const text = typeof body.text === "string" ? body.text.trim().slice(0, MAX_FEEDBACK_CHARS) : "";
846
+ const category = FEEDBACK_CATEGORIES.includes(body.category) ? body.category : "product-interaction";
847
+ let outcome;
848
+ try {
849
+ outcome = await feedback.record({ sessionId, text, category });
850
+ } catch (error) {
851
+ return sendJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
852
+ }
853
+ if (outcome?.ok !== true) {
854
+ // The one failure worth naming: the page is holding a session id the host no longer has a
855
+ // live session for, which is a different thing from a channel that refused the remark.
856
+ return sendJson(res, 200, { ok: false, error: outcome?.error?.code === "session-not-found" ? "session-not-found" : "rejected" });
857
+ }
858
+ return sendJson(res, 200, { ok: true });
859
+ }
860
+
861
+ /**
862
+ * `POST /pack` — build a pack from this dictionary, or read one back from a share code.
863
+ *
864
+ * `action: "build"` publishes; `action: "decode"` reads. They share a route because they are the
865
+ * two directions of one thing, and because a caller that can do one usually wants the other.
866
+ *
867
+ * @param res - the response to own.
868
+ * @param body - the parsed request body.
869
+ */
870
+ async function handlePack(res, body) {
871
+ if (body.action === "decode") {
872
+ const decoded = decodePackCode(body.code);
873
+ if (decoded.ok !== true) return sendJson(res, 400, { error: decoded.error });
874
+ return sendJson(res, 200, { ok: true, pack: decoded.pack, summary: packCore.summarizePack(decoded.pack) });
875
+ }
876
+ if (body.action !== "build") return sendJson(res, 400, { error: "action must be build or decode" });
877
+ const built = packCore.buildPack(dictionary.serializeState(dictionary.seal(state)).entries, {
878
+ id: body.id,
879
+ name: body.name,
880
+ description: body.description,
881
+ author: body.author,
882
+ license: body.license,
883
+ homepage: body.homepage,
884
+ build: pluginVersion(),
885
+ scope: body.scope,
886
+ domains: body.domains,
887
+ now: Date.now()
888
+ });
889
+ // A pack with nothing in it is refused rather than published empty, and the reason says which
890
+ // emptiness it was: the whole dictionary is empty, or the chosen categories are.
891
+ if (built.ok !== true) return sendJson(res, 400, { error: built.error });
892
+ const text = packCore.packBytes(built.pack);
893
+ return sendJson(res, 200, {
894
+ ok: true,
895
+ pack: built.pack,
896
+ summary: packCore.summarizePack(built.pack),
897
+ sha256: sha256Hex(text),
898
+ bytes: Buffer.byteLength(text, "utf8"),
899
+ // The code only when it was asked for: encoding is cheap, but a response carrying a code
900
+ // nobody wanted makes every log line of this route enormous.
901
+ ...(body.code === true ? { code: encodePackCode(built.pack) } : {})
902
+ });
903
+ }
904
+
905
+ /**
906
+ * `GET /source/index` — the list of packs a source URL offers, through a cache.
907
+ *
908
+ * A source is somebody else's static file. The cache exists so that opening the packs page does not
909
+ * mean a request every time, and so an unreachable source still shows what it last said — marked
910
+ * stale rather than presented as current.
911
+ *
912
+ * @param res - the response to own.
913
+ * @param params - the query string.
914
+ */
915
+ async function handleSourceIndex(res, params) {
916
+ const url = params.get("url") ?? "";
917
+ const refusal = packCore.refuseUrl(url);
918
+ if (refusal !== null) return sendJson(res, 400, { error: refusal });
919
+ const cache = readCache();
920
+ const entry = cache.indexes?.[url];
921
+ const fresh = entry !== undefined && Date.now() - entry.fetchedAt < INDEX_TTL_MS;
922
+ if (fresh && params.get("refresh") !== "1") {
923
+ return sendJson(res, 200, { ok: true, index: entry.index, fetchedAt: entry.fetchedAt, cached: true });
924
+ }
925
+ const fetched = await fetchText(url, MAX_INDEX_BYTES);
926
+ if (fetched.ok !== true) {
927
+ // An unreachable source with a usable cache is not a failure the page should shout about: it
928
+ // is yesterday's list, and saying so is more useful than an error.
929
+ if (entry !== undefined) {
930
+ return sendJson(res, 200, { ok: true, index: entry.index, fetchedAt: entry.fetchedAt, cached: true, stale: true, error: fetched.error });
931
+ }
932
+ return sendJson(res, 200, { ok: false, error: fetched.error });
933
+ }
934
+ let raw;
935
+ try {
936
+ raw = JSON.parse(fetched.text);
937
+ } catch (error) {
938
+ void error;
939
+ return sendJson(res, 200, { ok: false, error: "not-json" });
940
+ }
941
+ const parsed = packCore.parseIndex(raw);
942
+ if (parsed.ok !== true) return sendJson(res, 200, { ok: false, error: parsed.error });
943
+ const fetchedAt = Date.now();
944
+ writeCache({ ...cache, indexes: { ...(cache.indexes ?? {}), [url]: { fetchedAt, index: parsed.index } } });
945
+ return sendJson(res, 200, { ok: true, index: parsed.index, fetchedAt, cached: false, dropped: parsed.dropped });
946
+ }
947
+
948
+ /**
949
+ * `GET /source/pack` — one pack from a source, checksum-verified when the index promised one.
950
+ *
951
+ * The checksum is the whole point of the index carrying one: the file comes from a URL a third
952
+ * party wrote, and without a digest there is nothing to compare it against.
953
+ *
954
+ * @param res - the response to own.
955
+ * @param params - the query string.
956
+ */
957
+ async function handleSourcePack(res, params) {
958
+ const url = params.get("url") ?? "";
959
+ const refusal = packCore.refuseUrl(url);
960
+ if (refusal !== null) return sendJson(res, 400, { error: refusal });
961
+ const promised = (params.get("sha256") ?? "").trim().toLowerCase();
962
+ const cache = readCache();
963
+ const entry = cache.packs?.[url];
964
+ const fresh = entry !== undefined && Date.now() - entry.fetchedAt < PACK_TTL_MS;
965
+ let text = null;
966
+ let fetchedAt = Date.now();
967
+ let cached = false;
968
+ if (fresh) {
969
+ // The cache is keyed by URL but the DIGEST is re-checked every time: a cached body is still a
970
+ // body from somewhere else, and the promise must hold for it too.
971
+ text = entry.text;
972
+ fetchedAt = entry.fetchedAt;
973
+ cached = true;
974
+ } else {
975
+ const fetched = await fetchText(url, MAX_PACK_BYTES);
976
+ if (fetched.ok !== true) return sendJson(res, 200, { ok: false, error: fetched.error });
977
+ text = fetched.text;
978
+ }
979
+ let digest = sha256Hex(text);
980
+ if (cached && promised !== "" && promised !== digest) {
981
+ // A cached copy that fails the promise is a STALE cached copy — somebody republished the file
982
+ // — and the digest is the authority, not the cache. Without this, republishing a pack made
983
+ // every reader see `checksum-mismatch` until the cache expired a day later, which looks
984
+ // exactly like a broken pack.
985
+ const refetched = await fetchText(url, MAX_PACK_BYTES);
986
+ if (refetched.ok !== true) return sendJson(res, 200, { ok: false, error: refetched.error });
987
+ text = refetched.text;
988
+ digest = sha256Hex(text);
989
+ fetchedAt = Date.now();
990
+ cached = false;
991
+ }
992
+ if (promised !== "" && promised !== digest) {
993
+ return sendJson(res, 200, { ok: false, error: "checksum-mismatch", sha256: digest });
994
+ }
995
+ let raw;
996
+ try {
997
+ raw = JSON.parse(text);
998
+ } catch (error) {
999
+ void error;
1000
+ return sendJson(res, 200, { ok: false, error: "not-json" });
1001
+ }
1002
+ const parsed = packCore.parsePack(raw);
1003
+ if (parsed.ok !== true) return sendJson(res, 200, { ok: false, error: parsed.error });
1004
+ if (!cached) writeCache({ ...cache, packs: { ...(cache.packs ?? {}), [url]: { fetchedAt, text } } });
1005
+ return sendJson(res, 200, { ok: true, pack: parsed.pack, summary: packCore.summarizePack(parsed.pack), sha256: digest, cached });
1006
+ }
1007
+
1008
+ /** The cache file's contents, or an empty object when there is none to read. */
1009
+ function readCache() {
1010
+ const file = join(location.directory, "sources.json");
1011
+ try {
1012
+ const raw = fileSystem.readFileSync(file, "utf8");
1013
+ const parsed = JSON.parse(raw);
1014
+ return parsed !== null && typeof parsed === "object" ? parsed : {};
1015
+ } catch (error) {
1016
+ // Absent, unreadable or corrupt all mean the same thing here: ask the source again.
1017
+ void error;
1018
+ return {};
1019
+ }
1020
+ }
1021
+
1022
+ /** Write the cache, or quietly do without one when the profile is read-only. */
1023
+ function writeCache(next) {
1024
+ if (!location.writable) return;
1025
+ const file = join(location.directory, "sources.json");
1026
+ try {
1027
+ fileSystem.mkdirSync(dirname(file), { recursive: true });
1028
+ // Written through a temporary file: a fetch that dies mid-write must not leave a cache the
1029
+ // next read cannot parse, which would silently turn every source into "unreachable".
1030
+ const temporary = `${file}.tmp`;
1031
+ fileSystem.writeFileSync(temporary, JSON.stringify(next), "utf8");
1032
+ fileSystem.renameSync(temporary, file);
1033
+ } catch (error) {
1034
+ logger?.warn?.(`term-dictionary: could not write the source cache (${error instanceof Error ? error.message : String(error)})`);
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Fetch one text document from a URL that has already passed {@link packCore.refuseUrl}.
1040
+ *
1041
+ * @param url - an https URL.
1042
+ * @param maxBytes - the size cap for this document.
1043
+ * @returns `{ ok: true, text }`, or `{ ok: false, error }` with `unreachable`, `http-<status>` or
1044
+ * `too-large`.
1045
+ */
1046
+ async function fetchText(url, maxBytes) {
1047
+ const doFetch = typeof runtimeOverrides?.fetch === "function" ? runtimeOverrides.fetch : globalThis.fetch;
1048
+ if (typeof doFetch !== "function") return { ok: false, error: "no-fetch" };
1049
+ try {
1050
+ const response = await doFetch(url, { redirect: "follow", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
1051
+ if (response?.ok !== true) return { ok: false, error: `http-${response?.status ?? 0}` };
1052
+ const text = await response.text();
1053
+ if (typeof text !== "string" || text === "") return { ok: false, error: "empty" };
1054
+ // Measured on the bytes, not on the character count: a page of Chinese is three times the
1055
+ // size its length suggests, and a cap that can be tripled is not a cap.
1056
+ if (Buffer.byteLength(text, "utf8") > maxBytes) return { ok: false, error: "too-large" };
1057
+ return { ok: true, text };
1058
+ } catch (error) {
1059
+ // Timeout, DNS, TLS and connection reset all arrive here. The page cannot act differently on
1060
+ // any of them, and a source that is briefly down is a normal state rather than a bug.
1061
+ void error;
1062
+ return { ok: false, error: "unreachable" };
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ /** The services this bundle needs before it may activate. */
1068
+ export const inject = ["webServer"];
1069
+ /*
1070
+ * There is deliberately NO `Config` export here.
1071
+ *
1072
+ * Cordis validates a row's `config` through `runtime.Config["~standard"].validate`,
1073
+ * i.e. it requires a **Standard Schema**. A JSON Schema object exported as `Config`
1074
+ * is not one: activation died with
1075
+ *
1076
+ * TypeError: Cannot read properties of undefined (reading 'validate')
1077
+ * at resolveConfig (.../cordis/lib/index.js)
1078
+ *
1079
+ * which looks like a broken plugin rather than a schema mismatch. The DSH-native way
1080
+ * to declare one is `z.object({...})` from `@deepseek-ai/schemastery`, but that is a
1081
+ * HOST package: it is not resolvable from a plugin linked into a profile, and no
1082
+ * third-party plugin installed here uses it. Declaring no schema leaves the row's
1083
+ * `config` untouched (`resolveConfig` returns it unchanged), and every field below is
1084
+ * read defensively in this file, so a missing or malformed value falls back instead
1085
+ * of failing:
1086
+ *
1087
+ * provider — must be a non-empty string to be used, else the profile's own default
1088
+ * model route is chosen, and only failing that the first registered
1089
+ * provider.
1090
+ * model — must be a non-empty string to be used, else the chosen route's own
1091
+ * default model.
1092
+ * dataDir — must be a non-empty string to be used, else $DSH_HOME/dsh-plugin-term-dictionary.
1093
+ */
1094
+
1095
+ /** Test seam: swap the shared core or inject fake services. */
1096
+ export function __testing(overrides) {
1097
+ runtimeOverrides = overrides ?? null;
1098
+ }
1099
+
1100
+ /**
1101
+ * Test seam: the provider/model precedence used by `POST /explain`.
1102
+ *
1103
+ * Exported because the rule it encodes was wrong once in a way no test could see: the
1104
+ * plugin asked the first registered provider, which in this profile is the API-key
1105
+ * route, and generation failed while the window was using a different, working route.
1106
+ */
1107
+ export const __resolveTarget = resolveTarget;
1108
+ export const __resolveTargets = resolveTargets;
1109
+ export const __isCredentialFailure = isCredentialFailure;
1110
+ export const __buildSystemPrompt = buildSystemPrompt;