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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +174 -2
- package/cordis.patch.yml +18 -0
- package/lib/client.js +1414 -0
- package/lib/index.d.ts +324 -0
- package/lib/index.js +902 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +95 -4
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 };
|