dsh-system-prompt-insertion 0.0.1 → 0.2.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,902 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, unwatchFile, watchFile, writeFileSync } from "node:fs";
2
+ import Schema from "@deepseek-ai/schemastery";
3
+ import { dshHomeDisplay, dshHomePath, expandHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
4
+ import { dirname } from "node:path";
5
+ import { renderPrompt } from "@deepseek-ai/dsh-system-prompt";
6
+ // src/types.ts
7
+ /**
8
+ * The data model this plugin persists.
9
+ *
10
+ * ## One source of truth
11
+ *
12
+ * Everything the model sees comes from one JSON file
13
+ * (`$DSH_HOME/system-prompt-insertion.json`). The profile row's `config` only carries
14
+ * deployment-level switches (master switch, file override, soft size cap) — never
15
+ * prompt text — so there is exactly one place a section can be edited, and the
16
+ * Web editor and a hand-edited file cannot disagree.
17
+ *
18
+ * @module dsh-system-prompt-insertion/types
19
+ */
20
+ /** Where a section lands inside the assembled system prompt. */
21
+ const POSITIONS = [
22
+ "head",
23
+ "body",
24
+ "tail"
25
+ ];
26
+ /** A section id: lowercase alphanumerics and dashes, long enough to be unique. */
27
+ const SECTION_ID = /^[a-z0-9][a-z0-9-]{5,39}$/;
28
+ /** Section-name prefix; also how the preview tells our sections apart. */
29
+ const SECTION_PREFIX = "user:system-prompt-insertion:";
30
+ /** Upper bound on a stored section text, in UTF-8 bytes. */
31
+ const MAX_TEXT_BYTES = 262144;
32
+ /** Upper bound on stored sections; the editor warns instead of silently dropping. */
33
+ const MAX_SECTIONS = 32;
34
+ /** The empty state a fresh install starts from. */
35
+ function emptyState() {
36
+ return {
37
+ version: 1,
38
+ enabled: true,
39
+ sections: []
40
+ };
41
+ }
42
+ /** Thrown when a state document cannot be accepted. */
43
+ var StateValidationError = class extends Error {
44
+ path;
45
+ constructor(path, message) {
46
+ super(path === "" ? message : `${path}: ${message}`);
47
+ this.name = "StateValidationError";
48
+ this.path = path;
49
+ }
50
+ };
51
+ function fail(path, message) {
52
+ throw new StateValidationError(path, message);
53
+ }
54
+ function isRecord(value) {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
57
+ function readString(value, path) {
58
+ if (typeof value !== "string") fail(path, "must be a string");
59
+ return value;
60
+ }
61
+ function readBoolean(value, path, fallback) {
62
+ if (value === void 0) return fallback;
63
+ if (typeof value !== "boolean") fail(path, "must be a boolean");
64
+ return value;
65
+ }
66
+ function readPosition(value, path) {
67
+ if (value === void 0) return "tail";
68
+ if (typeof value !== "string" || !POSITIONS.includes(value)) fail(path, `must be one of ${POSITIONS.join(", ")}`);
69
+ return value;
70
+ }
71
+ function readOrder(value, path) {
72
+ if (value === void 0 || value === null) return null;
73
+ if (typeof value !== "number" || !Number.isFinite(value)) fail(path, "must be a finite number or null");
74
+ return value;
75
+ }
76
+ function readId(value, path) {
77
+ const id = readString(value, path);
78
+ if (!SECTION_ID.test(id)) fail(path, "must match [a-z0-9][a-z0-9-]{5,39}");
79
+ return id;
80
+ }
81
+ /**
82
+ * Validate and normalize one stored state document.
83
+ *
84
+ * Unknown keys inside a section are rejected rather than dropped: a section whose
85
+ * field was renamed by a future version must fail loudly instead of silently
86
+ * losing configuration.
87
+ *
88
+ * @param input - the parsed JSON value.
89
+ * @returns a fresh, fully normalized state.
90
+ * @throws StateValidationError when the document is not acceptable.
91
+ */
92
+ function normalizeState(input) {
93
+ if (!isRecord(input)) fail("", "state must be a JSON object");
94
+ if ((input.version ?? 1) !== 1) fail("version", "unsupported state version (expected 1)");
95
+ const enabled = readBoolean(input.enabled, "enabled", true);
96
+ const rawSections = input.sections ?? [];
97
+ if (!Array.isArray(rawSections)) fail("sections", "must be an array");
98
+ if (rawSections.length > 32) fail("sections", `holds more than 32 sections`);
99
+ const seen = /* @__PURE__ */ new Set();
100
+ return {
101
+ version: 1,
102
+ enabled,
103
+ sections: rawSections.map((raw, index) => {
104
+ const path = `sections[${index}]`;
105
+ if (!isRecord(raw)) fail(path, "must be an object");
106
+ for (const key of Object.keys(raw)) if (![
107
+ "id",
108
+ "title",
109
+ "text",
110
+ "position",
111
+ "order",
112
+ "enabled",
113
+ "interpolate"
114
+ ].includes(key)) fail(`${path}.${key}`, "unknown field");
115
+ const id = readId(raw.id, `${path}.id`);
116
+ if (seen.has(id)) fail(`${path}.id`, `duplicate section id "${id}"`);
117
+ seen.add(id);
118
+ const title = readString(raw.title ?? "", `${path}.title`).trim().slice(0, 80);
119
+ const text = readString(raw.text ?? "", `${path}.text`);
120
+ const bytes = Buffer.byteLength(text, "utf8");
121
+ if (bytes > 262144) fail(`${path}.text`, `is ${bytes} bytes; the limit is ${MAX_TEXT_BYTES}`);
122
+ return {
123
+ id,
124
+ title: title === "" ? `Section ${index + 1}` : title,
125
+ text,
126
+ position: readPosition(raw.position, `${path}.position`),
127
+ order: readOrder(raw.order, `${path}.order`),
128
+ enabled: readBoolean(raw.enabled, `${path}.enabled`, true),
129
+ interpolate: readBoolean(raw.interpolate, `${path}.interpolate`, false)
130
+ };
131
+ })
132
+ };
133
+ }
134
+ // src/store.ts
135
+ /**
136
+ * The persisted section list: one JSON file, re-read when it changes on disk.
137
+ *
138
+ * ## Why a file rather than the profile entry's `config`
139
+ *
140
+ * The editor writes on every save, and a profile patch is a YAML document shared
141
+ * with every other plugin in the layer — rewriting it from a plugin is a hostile
142
+ * thing to do. A dedicated file also means a hand edit (`vim`) and the Web editor
143
+ * are the same source of truth, and the prompt picks the edit up on the next
144
+ * step, with no reload.
145
+ *
146
+ * ## Change detection
147
+ *
148
+ * `current()` stats the file on every call. A section's text is a provider that
149
+ * runs at each assembly, so this is what makes an out-of-band edit visible
150
+ * without a restart — and the stat is cached by modification time, so the common
151
+ * case is one `statSync` per assembly, not per section.
152
+ *
153
+ * A file that fails to parse is NOT treated as empty: the last good state stays
154
+ * live (wiping someone's prompt because they were mid-save in an editor would be
155
+ * worse than serving a slightly stale one), and the parse error is surfaced to
156
+ * the UI through {@link PromptStore.lastError}.
157
+ *
158
+ * @module dsh-system-prompt-insertion/store
159
+ */
160
+ function stampOf(path) {
161
+ try {
162
+ const stat = statSync(path);
163
+ return {
164
+ mtimeMs: stat.mtimeMs,
165
+ size: stat.size
166
+ };
167
+ } catch {
168
+ return;
169
+ }
170
+ }
171
+ function sameStamp(a, b) {
172
+ if (a === void 0 || b === void 0) return a === b;
173
+ return a.mtimeMs === b.mtimeMs && a.size === b.size;
174
+ }
175
+ /**
176
+ * Render a path for the UI without leaking a machine-specific home directory.
177
+ *
178
+ * @param path - absolute path.
179
+ * @returns `~/.dsh/...` for paths under the default home, `$DSH_HOME/...` for a
180
+ * configured one, and the path unchanged otherwise.
181
+ */
182
+ function displayPath(path) {
183
+ const home = resolveDshHome();
184
+ const prefix = home.endsWith("/") ? home : `${home}/`;
185
+ if (path === home) return dshHomeDisplay(home);
186
+ if (path.startsWith(prefix)) return `${dshHomeDisplay(home)}${path.slice(home.length)}`;
187
+ return path;
188
+ }
189
+ /** The section store. */
190
+ var PromptStore = class {
191
+ path;
192
+ logger;
193
+ state = emptyState();
194
+ stamp;
195
+ source = "missing";
196
+ error;
197
+ loaded = false;
198
+ constructor(options) {
199
+ this.path = options.path;
200
+ this.logger = options.logger;
201
+ }
202
+ /** Absolute path of the JSON document. */
203
+ file() {
204
+ return this.path;
205
+ }
206
+ /** Display form of {@link file} for user-facing text. */
207
+ displayPath() {
208
+ return displayPath(this.path);
209
+ }
210
+ /** Where the current state came from. */
211
+ origin() {
212
+ return this.source;
213
+ }
214
+ /** The last load or parse error, if the file could not be read. */
215
+ lastError() {
216
+ return this.error;
217
+ }
218
+ /**
219
+ * The current state, re-reading the file when it changed on disk.
220
+ *
221
+ * @returns a frozen view; callers must not mutate it.
222
+ */
223
+ current() {
224
+ if (!this.loaded) {
225
+ this.loaded = true;
226
+ this.reload(stampOf(this.path));
227
+ return this.state;
228
+ }
229
+ const stamp = stampOf(this.path);
230
+ if (!sameStamp(stamp, this.stamp)) this.reload(stamp);
231
+ return this.state;
232
+ }
233
+ /**
234
+ * Validate, persist, and publish a new state.
235
+ *
236
+ * @param input - the candidate state (already parsed JSON).
237
+ * @returns the normalized state that was stored.
238
+ * @throws StateValidationError when the candidate is not acceptable.
239
+ */
240
+ save(input) {
241
+ const next = normalizeState(input);
242
+ mkdirSync(dirname(this.path), {
243
+ recursive: true,
244
+ mode: 448
245
+ });
246
+ const temporary = `${this.path}.${process.pid}.tmp`;
247
+ try {
248
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, {
249
+ encoding: "utf8",
250
+ mode: 384
251
+ });
252
+ renameSync(temporary, this.path);
253
+ } catch (error) {
254
+ try {
255
+ unlinkSync(temporary);
256
+ } catch {}
257
+ throw error;
258
+ }
259
+ try {
260
+ chmodSync(this.path, 384);
261
+ } catch {}
262
+ this.state = next;
263
+ this.stamp = stampOf(this.path);
264
+ this.source = "memory";
265
+ this.error = void 0;
266
+ this.loaded = true;
267
+ return next;
268
+ }
269
+ /** Re-read the file. A failed read keeps the previous state live. */
270
+ reload(stamp) {
271
+ if (stamp === void 0) {
272
+ this.state = emptyState();
273
+ this.stamp = void 0;
274
+ this.source = "missing";
275
+ this.error = void 0;
276
+ return;
277
+ }
278
+ try {
279
+ const text = readFileSync(this.path, "utf8");
280
+ this.state = normalizeState(JSON.parse(text));
281
+ this.stamp = stamp;
282
+ this.source = "file";
283
+ this.error = void 0;
284
+ } catch (error) {
285
+ this.stamp = stamp;
286
+ this.source = "unreadable";
287
+ this.error = error instanceof StateValidationError ? error.message : error instanceof Error ? error.message : String(error);
288
+ this.logger.warn(`system-prompt-insertion: cannot read ${displayPath(this.path)} (${this.error}); keeping the previous state`);
289
+ }
290
+ }
291
+ };
292
+ // src/orders.ts
293
+ /** First order inside the `head` band — right after the persona prefix. */
294
+ const HEAD_BASE = 10;
295
+ /** First order inside the `body` band — after every tool guidance section. */
296
+ const BODY_BASE = 5100;
297
+ /** Distance kept above the host's own last section. */
298
+ const TAIL_GAP = 300;
299
+ /** Spacing between two sections of the same band. */
300
+ const SECTION_STEP = 10;
301
+ /** Fallback for the host's last built-in section order (`DEPLOYMENT_PERSONA_SUFFIX`). */
302
+ const FALLBACK_TAIL_BASE = 10200;
303
+ /**
304
+ * Resolve the concrete order of one section.
305
+ *
306
+ * @param section - the stored section.
307
+ * @param ordinal - its index among ALL sections of the same band, in editor
308
+ * order. Disabled and empty entries keep their slot, so switching one off does
309
+ * not renumber its neighbours.
310
+ * @param bases - the band bases for this load.
311
+ * @returns the order to register.
312
+ */
313
+ function resolveSectionOrder(section, ordinal, bases) {
314
+ if (section.order !== null) return section.order;
315
+ switch (section.position) {
316
+ case "head": return 10 + ordinal * 10;
317
+ case "body": return BODY_BASE + ordinal * 10;
318
+ case "tail": return bases.tailBase + 300 + ordinal * 10;
319
+ }
320
+ }
321
+ /**
322
+ * Plan the registrations for one state: which sections are contributed, under
323
+ * which name and at which order.
324
+ *
325
+ * @param state - the stored state.
326
+ * @param bases - the band bases for this load.
327
+ * @param prefix - registered-name prefix (`user:system-prompt-insertion:` by default).
328
+ * @returns one plan per ENABLED, non-empty section, in editor order.
329
+ */
330
+ function planRegistrations(state, bases, prefix) {
331
+ if (!state.enabled) return [];
332
+ const ordinals = {
333
+ head: 0,
334
+ body: 0,
335
+ tail: 0
336
+ };
337
+ const plan = [];
338
+ for (const section of state.sections) {
339
+ const ordinal = ordinals[section.position]++;
340
+ if (!section.enabled || section.text.trim() === "") continue;
341
+ plan.push({
342
+ section,
343
+ name: `${prefix}${section.id}`,
344
+ order: resolveSectionOrder(section, ordinal, bases)
345
+ });
346
+ }
347
+ return plan;
348
+ }
349
+ // src/register.ts
350
+ /** Read the host's own last built-in section order at runtime. */
351
+ function resolveOrderBases(ctx) {
352
+ const suffix = ctx.systemPrompt.getSectionOrder("DEPLOYMENT_PERSONA_SUFFIX");
353
+ return { tailBase: typeof suffix === "number" && Number.isFinite(suffix) ? suffix : FALLBACK_TAIL_BASE };
354
+ }
355
+ /** Owns the registered sections of one plugin instance. */
356
+ var SectionRegistrar = class {
357
+ ctx;
358
+ store;
359
+ logger;
360
+ prefix;
361
+ disposers = [];
362
+ names = [];
363
+ orderByName = /* @__PURE__ */ new Map();
364
+ bases;
365
+ constructor(ctx, store, logger, prefix = SECTION_PREFIX) {
366
+ this.ctx = ctx;
367
+ this.store = store;
368
+ this.logger = logger;
369
+ this.prefix = prefix;
370
+ this.bases = resolveOrderBases(ctx);
371
+ }
372
+ /** The band bases currently in use (also reported to the preview). */
373
+ orderBases() {
374
+ return this.bases;
375
+ }
376
+ /** Names of the sections currently registered, for diagnostics and the preview. */
377
+ registeredNames() {
378
+ return [...this.names];
379
+ }
380
+ /** The concrete order each registered section was placed at. */
381
+ registeredOrders() {
382
+ return this.orderByName;
383
+ }
384
+ /**
385
+ * Rebuild the registration plan from the store.
386
+ *
387
+ * Registration is idempotent per plan: the previous plan is disposed first, so a
388
+ * removed section cannot linger. A section whose registration throws (a name
389
+ * collision with another plugin) is reported and skipped instead of taking the
390
+ * whole plugin down.
391
+ *
392
+ * @returns the state that was planned from.
393
+ */
394
+ sync() {
395
+ this.dispose();
396
+ this.bases = resolveOrderBases(this.ctx);
397
+ const state = this.store.current();
398
+ const plan = planRegistrations(state, this.bases, this.prefix);
399
+ for (const entry of plan) {
400
+ const { section } = entry;
401
+ try {
402
+ const dispose = this.ctx.systemPrompt.section({
403
+ name: entry.name,
404
+ order: entry.order,
405
+ text: () => {
406
+ return this.store.current().sections.find((candidate) => candidate.id === section.id)?.text ?? "";
407
+ },
408
+ ...section.interpolate ? {} : { interpolate: false }
409
+ });
410
+ this.disposers.push(dispose);
411
+ this.names.push(entry.name);
412
+ this.orderByName.set(entry.name, entry.order);
413
+ } catch (error) {
414
+ this.logger.warn(`system-prompt-insertion: cannot register "${section.id}" at order ${entry.order}: ${error instanceof Error ? error.message : String(error)}`);
415
+ }
416
+ }
417
+ return state;
418
+ }
419
+ /** Dispose every registration this instance owns. */
420
+ dispose() {
421
+ this.names.splice(0);
422
+ this.orderByName.clear();
423
+ for (const dispose of this.disposers.splice(0)) try {
424
+ dispose();
425
+ } catch {}
426
+ }
427
+ };
428
+ // src/preview.ts
429
+ /** Normalize a line for duplicate detection: trimmed, whitespace collapsed. */
430
+ function normalizeLine(line) {
431
+ return line.trim().replace(/\s+/g, " ");
432
+ }
433
+ /** Lines long enough that a repeat is a real repetition rather than boilerplate. */
434
+ const DUPLICATE_LINE_FLOOR = 30;
435
+ /** Complete `{{name}}` references, mirroring the registry's own grammar. */
436
+ const VARIABLE_REFERENCE = /\{\{([^{}]*)\}\}/g;
437
+ /** Lines of one text, indexed for duplicate detection. */
438
+ function longLines(text) {
439
+ const lines = [];
440
+ for (const raw of text.split("\n")) {
441
+ const line = normalizeLine(raw);
442
+ if (line.length >= DUPLICATE_LINE_FLOOR) lines.push(line);
443
+ }
444
+ return lines;
445
+ }
446
+ /** Warnings that depend only on the stored state. */
447
+ function stateWarnings(state, maxSectionBytes) {
448
+ const warnings = [];
449
+ for (const section of state.sections) {
450
+ const bytes = Buffer.byteLength(section.text, "utf8");
451
+ if (section.enabled && section.text.trim() === "") warnings.push({
452
+ code: "empty",
453
+ sectionId: section.id,
454
+ params: { title: section.title },
455
+ message: `"${section.title}" is enabled but empty.`
456
+ });
457
+ if (bytes > maxSectionBytes) warnings.push({
458
+ code: "oversize",
459
+ sectionId: section.id,
460
+ params: {
461
+ title: section.title,
462
+ bytes,
463
+ cap: maxSectionBytes
464
+ },
465
+ message: `"${section.title}" is ${bytes} bytes, above the ${maxSectionBytes}-byte soft cap.`
466
+ });
467
+ }
468
+ return warnings;
469
+ }
470
+ /** Warnings that compare our text with the rest of the assembled prompt. */
471
+ function duplicateWarnings(state, assembly) {
472
+ const foreign = /* @__PURE__ */ new Map();
473
+ for (const section of assembly.sections) {
474
+ if (section.name.startsWith("user:system-prompt-insertion:")) continue;
475
+ for (const line of longLines(section.text)) if (!foreign.has(line)) foreign.set(line, section.name);
476
+ }
477
+ const warnings = [];
478
+ const reported = /* @__PURE__ */ new Set();
479
+ for (const section of state.sections) {
480
+ if (!section.enabled) continue;
481
+ for (const line of longLines(section.text)) {
482
+ const owner = foreign.get(line);
483
+ if (owner === void 0) continue;
484
+ const key = `${section.id}:${owner}`;
485
+ if (reported.has(key)) continue;
486
+ reported.add(key);
487
+ warnings.push({
488
+ code: "duplicate",
489
+ sectionId: section.id,
490
+ params: {
491
+ title: section.title,
492
+ other: owner
493
+ },
494
+ message: `"${section.title}" repeats a line already in section "${owner}".`
495
+ });
496
+ }
497
+ }
498
+ return warnings;
499
+ }
500
+ /** Variables a section references, in first-seen order. */
501
+ function referencedVariables(text) {
502
+ const names = [];
503
+ for (const match of text.matchAll(VARIABLE_REFERENCE)) {
504
+ const name = match[1] ?? "";
505
+ if (!names.includes(name)) names.push(name);
506
+ }
507
+ return names;
508
+ }
509
+ /** Warnings about `{{...}}` references that would fail rendering. */
510
+ function variableWarnings(state, assembly) {
511
+ const warnings = [];
512
+ const known = new Set(Object.keys(assembly.variables));
513
+ for (const section of state.sections) {
514
+ if (!section.enabled || !section.interpolate) continue;
515
+ for (const name of referencedVariables(section.text)) {
516
+ if (known.has(name) && assembly.variables[name] !== void 0) continue;
517
+ warnings.push({
518
+ code: "unknown-variable",
519
+ sectionId: section.id,
520
+ params: {
521
+ title: section.title,
522
+ name,
523
+ known: [...known].join(", ") || "none"
524
+ },
525
+ message: `"${section.title}" references {{${name}}}, which this assembly does not define (known: ${[...known].join(", ") || "none"}). Turn interpolation off to keep it literal.`
526
+ });
527
+ }
528
+ }
529
+ return warnings;
530
+ }
531
+ /**
532
+ * Render one assembly the way the harness would, except that a reference with no
533
+ * value stays visible as `{{name}}` instead of throwing.
534
+ *
535
+ * A scope-less assembly legitimately has no `{{model}}`: the loop registers that
536
+ * variable per agent. The preview must still show the prompt, so the missing fact
537
+ * is displayed rather than crashing the panel; {@link buildPreview} reports the
538
+ * substitution separately.
539
+ *
540
+ * @param assembly - the assembled prompt.
541
+ * @returns the rendered text, plus the names left unresolved.
542
+ */
543
+ function renderTolerant(assembly) {
544
+ const unresolved = /* @__PURE__ */ new Set();
545
+ const variables = assembly.variables;
546
+ const parts = [];
547
+ for (const section of assembly.sections) {
548
+ let text = section.text;
549
+ if (section.interpolate !== false) text = text.replace(/\{\{([^{}]*)\}\}/g, (match, name) => {
550
+ if (!/^[a-z][a-z0-9_]*$/.test(name)) return match;
551
+ const value = variables[name];
552
+ if (value === void 0) {
553
+ unresolved.add(name);
554
+ return match;
555
+ }
556
+ return value;
557
+ });
558
+ if (text.length > 0) parts.push(text);
559
+ }
560
+ return {
561
+ text: parts.join("\n\n"),
562
+ unresolved: [...unresolved]
563
+ };
564
+ }
565
+ /**
566
+ * Build the preview for the current state.
567
+ *
568
+ * @param host - the store, registrar, host context and soft size cap.
569
+ * @returns the assembled prompt, its sections, and every warning.
570
+ */
571
+ async function buildPreview(host) {
572
+ const state = host.store.current();
573
+ const assembly = await host.ctx.systemPrompt.assemble({});
574
+ const warnings = [...stateWarnings(state, host.maxSectionBytes), ...duplicateWarnings(state, assembly)];
575
+ warnings.push(...variableWarnings(state, assembly));
576
+ const orders = host.registrar.registeredOrders();
577
+ const sections = assembly.sections.map((section, index) => {
578
+ const ours = section.name.startsWith(SECTION_PREFIX);
579
+ const order = ours ? orders.get(section.name) : void 0;
580
+ return {
581
+ name: section.name,
582
+ index,
583
+ ...order === void 0 ? {} : { order },
584
+ bytes: Buffer.byteLength(section.text, "utf8"),
585
+ ours,
586
+ text: section.text
587
+ };
588
+ });
589
+ const complete = sections.length === 1 && !sections[0]?.ours;
590
+ if (complete) warnings.push({
591
+ code: "hidden",
592
+ message: "The active agent preset replaces the whole system prompt with a single section, so these sections are not sent."
593
+ });
594
+ else {
595
+ const present = new Set(sections.map((section) => section.name));
596
+ for (const name of host.registrar.registeredNames()) {
597
+ if (present.has(name)) continue;
598
+ warnings.push({
599
+ code: "hidden",
600
+ sectionId: name.slice(29),
601
+ message: "This section is registered but not part of the current global assembly."
602
+ });
603
+ }
604
+ }
605
+ if (host.store.origin() === "unreadable") warnings.push({
606
+ code: "store-error",
607
+ message: host.store.lastError() ?? "The state file could not be read."
608
+ });
609
+ const tolerant = renderTolerant(assembly);
610
+ const rendered = tolerant.text;
611
+ let renderError;
612
+ try {
613
+ renderPrompt(assembly);
614
+ } catch (error) {
615
+ const message = error instanceof Error ? error.message : String(error);
616
+ if (message.includes("user:system-prompt-insertion:")) {
617
+ renderError = message;
618
+ warnings.push({
619
+ code: "render-error",
620
+ message
621
+ });
622
+ } else warnings.push({
623
+ code: "preview-partial",
624
+ params: { names: tolerant.unresolved.join(", ") || "—" },
625
+ message: `Previewed without an agent scope, so per-agent variables were left literal (${tolerant.unresolved.join(", ") || "none"}).`
626
+ });
627
+ }
628
+ return {
629
+ rendered,
630
+ bytes: Buffer.byteLength(rendered, "utf8"),
631
+ sections,
632
+ variables: Object.keys(assembly.variables),
633
+ warnings,
634
+ complete,
635
+ ...renderError === void 0 ? {} : { renderError }
636
+ };
637
+ }
638
+ // src/routes.ts
639
+ /** URL prefix of every route this plugin owns. */
640
+ const ROUTE_PREFIX = "/dsh-system-prompt-insertion";
641
+ /** Largest accepted request body (the state document is small; 512 KiB is slack). */
642
+ const MAX_BODY_BYTES = 524288;
643
+ function sendJson(res, status, payload) {
644
+ const body = JSON.stringify(payload);
645
+ res.statusCode = status;
646
+ res.setHeader("content-type", "application/json; charset=utf-8");
647
+ res.setHeader("cache-control", "no-store");
648
+ res.end(body);
649
+ }
650
+ /** Whether the request's Origin, when sent, is this same origin. */
651
+ function sameOrigin(req) {
652
+ const origin = req.headers.origin;
653
+ if (origin === void 0 || origin === "") return true;
654
+ const host = req.headers.host;
655
+ if (host === void 0) return false;
656
+ try {
657
+ return new URL(origin).host === host;
658
+ } catch {
659
+ return false;
660
+ }
661
+ }
662
+ /** Read a bounded JSON body. */
663
+ async function readJsonBody(req) {
664
+ const declared = Number(req.headers["content-length"] ?? "0");
665
+ if (Number.isFinite(declared) && declared > 524288) throw Object.assign(/* @__PURE__ */ new Error("request body is too large"), {
666
+ status: 413,
667
+ code: "too-large"
668
+ });
669
+ const chunks = [];
670
+ let total = 0;
671
+ for await (const chunk of req) {
672
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
673
+ total += buffer.length;
674
+ if (total > 524288) throw Object.assign(/* @__PURE__ */ new Error("request body is too large"), {
675
+ status: 413,
676
+ code: "too-large"
677
+ });
678
+ chunks.push(buffer);
679
+ }
680
+ const text = Buffer.concat(chunks).toString("utf8");
681
+ if (text.trim() === "") return {};
682
+ try {
683
+ return JSON.parse(text);
684
+ } catch {
685
+ throw Object.assign(/* @__PURE__ */ new Error("request body is not valid JSON"), {
686
+ status: 400,
687
+ code: "invalid-json"
688
+ });
689
+ }
690
+ }
691
+ /** Answer a request this plugin refuses to serve. */
692
+ function reject(res, status, code, message) {
693
+ sendJson(res, status, {
694
+ ok: false,
695
+ error: {
696
+ code,
697
+ message
698
+ }
699
+ });
700
+ }
701
+ /** Read the current state as the editor's payload. */
702
+ function statePayload(deps) {
703
+ const state = deps.store.current();
704
+ const error = deps.store.lastError();
705
+ return {
706
+ state,
707
+ path: deps.store.displayPath(),
708
+ origin: deps.store.origin(),
709
+ ...error === void 0 ? {} : { error },
710
+ orderBases: deps.registrar.orderBases(),
711
+ registered: deps.registrar.registeredNames()
712
+ };
713
+ }
714
+ /**
715
+ * Register the three routes on `ctx.webServer` when that service is present.
716
+ *
717
+ * The plugin stays fully functional without it (the host half needs only the
718
+ * prompt registry); a headless profile simply has no editor.
719
+ *
720
+ * @param ctx - the plugin context.
721
+ * @param deps - store, registrar, preview builder and logger.
722
+ */
723
+ function registerRoutes(ctx, deps) {
724
+ ctx.inject(["webServer"], (webCtx) => {
725
+ webCtx.effect(() => webCtx.webServer.register({
726
+ kind: "exact",
727
+ path: `${ROUTE_PREFIX}/state`,
728
+ handler: async (req, res) => {
729
+ if (!sameOrigin(req)) return reject(res, 403, "cross-origin", "cross-origin request refused");
730
+ if (req.method === "GET") {
731
+ sendJson(res, 200, {
732
+ ok: true,
733
+ ...statePayload(deps)
734
+ });
735
+ return;
736
+ }
737
+ if (req.method !== "POST") {
738
+ res.setHeader("allow", "GET, POST");
739
+ return reject(res, 405, "method-not-allowed", "use GET or POST");
740
+ }
741
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return reject(res, 415, "unsupported-media-type", "send application/json");
742
+ try {
743
+ const body = await readJsonBody(req);
744
+ const next = deps.store.save(body);
745
+ deps.registrar.sync();
746
+ deps.logger.info(`system-prompt-insertion: saved ${next.sections.length} section(s); ${deps.registrar.registeredNames().length} registered`);
747
+ sendJson(res, 200, {
748
+ ok: true,
749
+ ...statePayload(deps)
750
+ });
751
+ } catch (error) {
752
+ if (error instanceof StateValidationError) return reject(res, 422, "invalid-state", error.message);
753
+ const status = typeof error.status === "number" ? error.status : 500;
754
+ const code = typeof error.code === "string" ? error.code : "write-failed";
755
+ deps.logger.warn(`system-prompt-insertion: save failed (${code}): ${error instanceof Error ? error.message : String(error)}`);
756
+ reject(res, status, code, error instanceof Error ? error.message : String(error));
757
+ }
758
+ }
759
+ }), `system-prompt-insertion: ${ROUTE_PREFIX}/state`);
760
+ webCtx.effect(() => webCtx.webServer.register({
761
+ kind: "exact",
762
+ path: `${ROUTE_PREFIX}/preview`,
763
+ handler: async (req, res) => {
764
+ if (!sameOrigin(req)) return reject(res, 403, "cross-origin", "cross-origin request refused");
765
+ if (req.method !== "GET") {
766
+ res.setHeader("allow", "GET");
767
+ return reject(res, 405, "method-not-allowed", "use GET");
768
+ }
769
+ try {
770
+ sendJson(res, 200, {
771
+ ok: true,
772
+ ...await deps.preview()
773
+ });
774
+ } catch (error) {
775
+ deps.logger.warn(`system-prompt-insertion: preview failed: ${error instanceof Error ? error.message : String(error)}`);
776
+ reject(res, 500, "preview-failed", error instanceof Error ? error.message : String(error));
777
+ }
778
+ }
779
+ }), `system-prompt-insertion: ${ROUTE_PREFIX}/preview`);
780
+ });
781
+ }
782
+ // src/index.ts
783
+ /**
784
+ * `dsh-system-prompt-insertion` — user-owned text injected into the DeepSeek
785
+ * Harness system prompt.
786
+ *
787
+ * ## What it adds
788
+ *
789
+ * The harness assembles one system prompt per model step from ordered sections
790
+ * contributed by plugins. This plugin lets the USER inject their own text as
791
+ * those sections: written in the Web editor (or a JSON file), placed at the head,
792
+ * the middle, or the very tail of that prompt, and rendered on every step with
793
+ * no restart.
794
+ *
795
+ * A "section" is the harness's own unit of prompt composition, so user text lands
796
+ * in the same layer as the platform's own instructions — unlike `AGENTS.md`, which
797
+ * arrives as a *user-role* message whose frame explicitly says it does not
798
+ * override system instructions.
799
+ *
800
+ * ## Composition
801
+ *
802
+ * One profile row mounts both halves:
803
+ *
804
+ * 1. the host half loads the state file, registers one prompt section per enabled
805
+ * entry, and (when `ctx.webServer` exists) serves the editor's JSON routes;
806
+ * 2. the browser half registered by this package's `./client` export draws a
807
+ * left-column sidebar panel and talks to those routes.
808
+ *
809
+ * Nothing here is coupled to a specific agent preset: registration happens on the
810
+ * host context, so the global layer covers Web sessions, subagents and workflow
811
+ * children. A preset that takes over the whole prompt (`complete: true`, e.g. the
812
+ * built-in `minimal`) cannot be appended to, and the editor says so.
813
+ *
814
+ * ## Configuration
815
+ *
816
+ * The row's `config` holds deployment switches only — never prompt text — so the
817
+ * editor and a hand-edited file cannot disagree about what is live.
818
+ *
819
+ * @module dsh-system-prompt-insertion
820
+ */
821
+ /** Cordis plugin name. */
822
+ const name = "system-prompt-insertion";
823
+ /** The prompt registry must exist before a section can be registered. */
824
+ const inject = ["systemPrompt"];
825
+ /** How long to coalesce filesystem change notifications. */
826
+ const WATCH_DEBOUNCE_MS = 60;
827
+ /** How often the state file is polled for out-of-band edits. */
828
+ const WATCH_INTERVAL_MS = 1e3;
829
+ /** Schema for {@link Config}. */
830
+ const Config = Schema.object({
831
+ enabled: Schema.boolean().default(true).description("Add the stored text to the system prompt as sections."),
832
+ storeFile: Schema.string().default("").description("Path of the state file. Empty uses $DSH_HOME/system-prompt-insertion.json."),
833
+ maxSectionBytes: Schema.number().default(8192).description("Soft per-section size cap the editor reports; nothing is truncated.")
834
+ });
835
+ /**
836
+ * Resolve the row configuration into absolute options.
837
+ *
838
+ * @param config - the row configuration.
839
+ * @returns the paths and limits the plugin runs with.
840
+ */
841
+ function resolveOptions(config) {
842
+ const configured = config.storeFile.trim();
843
+ return {
844
+ storeFile: configured === "" ? dshHomePath("system-prompt-insertion.json") : expandHomePath(configured),
845
+ maxSectionBytes: Number.isFinite(config.maxSectionBytes) && config.maxSectionBytes > 0 ? config.maxSectionBytes : 8192
846
+ };
847
+ }
848
+ /**
849
+ * Mount the user-owned system-prompt text and its editor.
850
+ *
851
+ * @param ctx - the profile row's context.
852
+ * @param config - the validated row configuration.
853
+ */
854
+ function apply(ctx, config) {
855
+ const logger = ctx.logger;
856
+ if (!config.enabled) {
857
+ logger.info("system-prompt-insertion: disabled by configuration");
858
+ return;
859
+ }
860
+ const options = resolveOptions(config);
861
+ const store = new PromptStore({
862
+ path: options.storeFile,
863
+ logger
864
+ });
865
+ const registrar = new SectionRegistrar(ctx, store, logger);
866
+ ctx.effect(() => () => registrar.dispose(), "system-prompt-insertion: registrations");
867
+ ctx.effect(() => {
868
+ let timer;
869
+ const onChange = () => {
870
+ if (timer !== void 0) clearTimeout(timer);
871
+ timer = setTimeout(() => {
872
+ timer = void 0;
873
+ try {
874
+ registrar.sync();
875
+ logger.info(`system-prompt-insertion: reloaded ${registrar.registeredNames().length} section(s) after a file change`);
876
+ } catch (error) {
877
+ logger.warn(`system-prompt-insertion: reload failed: ${error instanceof Error ? error.message : String(error)}`);
878
+ }
879
+ }, WATCH_DEBOUNCE_MS);
880
+ timer.unref?.();
881
+ };
882
+ watchFile(options.storeFile, { interval: WATCH_INTERVAL_MS }, onChange);
883
+ return () => {
884
+ if (timer !== void 0) clearTimeout(timer);
885
+ unwatchFile(options.storeFile, onChange);
886
+ };
887
+ }, "system-prompt-insertion: state watcher");
888
+ registerRoutes(ctx, {
889
+ store,
890
+ registrar,
891
+ logger,
892
+ preview: () => buildPreview({
893
+ ctx,
894
+ store,
895
+ registrar,
896
+ maxSectionBytes: options.maxSectionBytes
897
+ })
898
+ });
899
+ const state = registrar.sync();
900
+ logger.info(`system-prompt-insertion: ${registrar.registeredNames().length} of ${state.sections.length} section(s) registered from ${store.displayPath()}`);
901
+ }
902
+ export { BODY_BASE, Config, FALLBACK_TAIL_BASE, HEAD_BASE, MAX_BODY_BYTES, MAX_SECTIONS, MAX_TEXT_BYTES, POSITIONS, PromptStore, ROUTE_PREFIX, SECTION_ID, SECTION_PREFIX, SECTION_STEP, SectionRegistrar, StateValidationError, TAIL_GAP, apply, buildPreview, displayPath, emptyState, inject, name, normalizeState, planRegistrations, registerRoutes, resolveOptions, resolveOrderBases, resolveSectionOrder };