@lolkda/dsh-prompt-manager 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +606 -0
- package/client/client.js +2320 -0
- package/cordis.patch.yml +20 -0
- package/environment.md +24 -0
- package/lib/entries.js +303 -0
- package/lib/entries.js.map +1 -0
- package/lib/guard.js +134 -0
- package/lib/guard.js.map +1 -0
- package/lib/index.js +959 -0
- package/lib/index.js.map +1 -0
- package/lib/net.js +179 -0
- package/lib/net.js.map +1 -0
- package/lib/pack.js +327 -0
- package/lib/pack.js.map +1 -0
- package/lib/probe.js +251 -0
- package/lib/probe.js.map +1 -0
- package/lib/routes.js +718 -0
- package/lib/routes.js.map +1 -0
- package/lib/scripts.js +803 -0
- package/lib/scripts.js.map +1 -0
- package/lib/source.js +308 -0
- package/lib/source.js.map +1 -0
- package/lib/store.js +223 -0
- package/lib/store.js.map +1 -0
- package/lib/subscriptions.js +269 -0
- package/lib/subscriptions.js.map +1 -0
- package/lib/sync.js +646 -0
- package/lib/sync.js.map +1 -0
- package/lib/types/entries.d.ts +194 -0
- package/lib/types/entries.d.ts.map +1 -0
- package/lib/types/guard.d.ts +63 -0
- package/lib/types/guard.d.ts.map +1 -0
- package/lib/types/index.d.ts +176 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/net.d.ts +81 -0
- package/lib/types/net.d.ts.map +1 -0
- package/lib/types/pack.d.ts +298 -0
- package/lib/types/pack.d.ts.map +1 -0
- package/lib/types/probe.d.ts +150 -0
- package/lib/types/probe.d.ts.map +1 -0
- package/lib/types/routes.d.ts +85 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/scripts.d.ts +455 -0
- package/lib/types/scripts.d.ts.map +1 -0
- package/lib/types/source.d.ts +194 -0
- package/lib/types/source.d.ts.map +1 -0
- package/lib/types/store.d.ts +140 -0
- package/lib/types/store.d.ts.map +1 -0
- package/lib/types/subscriptions.d.ts +204 -0
- package/lib/types/subscriptions.d.ts.map +1 -0
- package/lib/types/sync.d.ts +248 -0
- package/lib/types/sync.d.ts.map +1 -0
- package/package.json +100 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { homedir, hostname, release, userInfo } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { activePresetOf, BUILTIN_PROMPTS, buildIndexSchema, builtinEntries, entryIdFor, MAX_ENTRIES, parseEntries, parsePresets, readBuiltinBody, } from './entries.js';
|
|
5
|
+
import { installPromptRoutes } from './routes.js';
|
|
6
|
+
import { sanitizeReferences } from './guard.js';
|
|
7
|
+
import { buildPack, planImport, writePackBodies, } from './pack.js';
|
|
8
|
+
import { PromptStore } from './store.js';
|
|
9
|
+
import { normalizeMirror, parseSources } from './source.js';
|
|
10
|
+
import { Subscriptions } from './subscriptions.js';
|
|
11
|
+
import { DEFAULT_PROBES, DEFAULT_PROBE_TEXTS, MAX_PROBES, normalizeProbes, runProbes, } from './probe.js';
|
|
12
|
+
import { cleanDrafts, normalizeScriptOverrides, PromptScripts, SCRIPTS_DIR_NAME, } from './scripts.js';
|
|
13
|
+
export { MAX_BODY_BYTES, MAX_ENTRIES, MAX_PRESETS } from './entries.js';
|
|
14
|
+
export { PromptStore } from './store.js';
|
|
15
|
+
export { ROUTE_PREFIX } from './routes.js';
|
|
16
|
+
export { MAX_PROBES } from './probe.js';
|
|
17
|
+
export { BUILTIN_PROMPTS } from './entries.js';
|
|
18
|
+
export { MAX_SCRIPTS, MAX_SCRIPT_BYTES, SCRIPTS_DIR_NAME } from './scripts.js';
|
|
19
|
+
/** Cordis plugin name. Distinct from the bare `prompt-manager` an unrelated package uses. */
|
|
20
|
+
export const name = 'dsh-prompt-manager';
|
|
21
|
+
/** The prompt registry this row contributes to. */
|
|
22
|
+
export const inject = ['systemPrompt'];
|
|
23
|
+
/** Section-name prefix of every entry this plugin registers. */
|
|
24
|
+
export const USER_SECTION_PREFIX = 'user:prompt-manager:';
|
|
25
|
+
/** Settings namespace carrying the entry index. */
|
|
26
|
+
export const SETTINGS_NAMESPACE = 'prompt-manager';
|
|
27
|
+
/** Directory name appended to the resolved Harness home holding the bodies. */
|
|
28
|
+
export const STORE_DIR_NAME = 'prompt-manager';
|
|
29
|
+
/** Valid prompt-variable names, mirroring the registry's own rule. */
|
|
30
|
+
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/;
|
|
31
|
+
/** Friendly names for the platforms this harness realistically runs on. */
|
|
32
|
+
const PLATFORM_NAMES = {
|
|
33
|
+
win32: 'Windows',
|
|
34
|
+
darwin: 'macOS',
|
|
35
|
+
linux: 'Linux',
|
|
36
|
+
freebsd: 'FreeBSD',
|
|
37
|
+
openbsd: 'OpenBSD',
|
|
38
|
+
netbsd: 'NetBSD',
|
|
39
|
+
sunos: 'Solaris',
|
|
40
|
+
aix: 'AIX',
|
|
41
|
+
};
|
|
42
|
+
/** Friendly platform name for the running process. */
|
|
43
|
+
function platformName() {
|
|
44
|
+
return PLATFORM_NAMES[process.platform] ?? process.platform;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* What a machine fact falls back to when the process cannot report it.
|
|
48
|
+
*
|
|
49
|
+
* A registered variable must never resolve to the empty string — the registry
|
|
50
|
+
* throws on an undefined value, and one throwing section fails the assembly, so
|
|
51
|
+
* every model step of the profile would fail. The wording matches the probe
|
|
52
|
+
* placeholders a missing tool gets.
|
|
53
|
+
*/
|
|
54
|
+
const UNKNOWN_FACT = '(unknown)';
|
|
55
|
+
/**
|
|
56
|
+
* Facts about the running process, as prompt-variable values.
|
|
57
|
+
*
|
|
58
|
+
* Every one of these is a process-level fact, fixed for as long as the profile
|
|
59
|
+
* runs. Deliberately absent: the *session's* working directory and the model in
|
|
60
|
+
* use. The registry's `AssembleContext` carries only a scope key and a signal,
|
|
61
|
+
* so those are not reachable from a variable provider — and publishing the host
|
|
62
|
+
* process's `process.cwd()` under the name `cwd` would invite exactly the wrong
|
|
63
|
+
* reading, since a session's workspace can be a different directory.
|
|
64
|
+
*
|
|
65
|
+
* @returns one value per environment variable this plugin registers.
|
|
66
|
+
*/
|
|
67
|
+
export function environmentFacts() {
|
|
68
|
+
return {
|
|
69
|
+
os: platformName(),
|
|
70
|
+
os_release: release(),
|
|
71
|
+
platform: process.platform,
|
|
72
|
+
arch: process.arch,
|
|
73
|
+
home: factOrUnknown(() => homedir()),
|
|
74
|
+
dsh_home: resolveHarnessHome(),
|
|
75
|
+
user: factOrUnknown(() => userInfo().username),
|
|
76
|
+
host: factOrUnknown(() => hostname()),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Read one machine fact, substituting {@link UNKNOWN_FACT} for anything the
|
|
81
|
+
* platform declines to answer — `os.userInfo()` throws on a system with no
|
|
82
|
+
* account, and an empty return value is just as unusable.
|
|
83
|
+
*
|
|
84
|
+
* @param read - the fact to read.
|
|
85
|
+
* @returns the value, or the placeholder.
|
|
86
|
+
*/
|
|
87
|
+
function factOrUnknown(read) {
|
|
88
|
+
try {
|
|
89
|
+
const value = read().trim();
|
|
90
|
+
return value.length > 0 ? value : UNKNOWN_FACT;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return UNKNOWN_FACT;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The harness home directory: `$DSH_HOME` when it names one, `~/.dsh` otherwise.
|
|
98
|
+
*
|
|
99
|
+
* The one place this is decided, so the `{{dsh_home}}` variable and the store
|
|
100
|
+
* directory can never disagree about where the harness keeps its files.
|
|
101
|
+
*
|
|
102
|
+
* @returns an absolute path.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveHarnessHome() {
|
|
105
|
+
const home = process.env['DSH_HOME']?.trim();
|
|
106
|
+
return home !== undefined && home.length > 0 ? home : join(homedir(), '.dsh');
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the directory holding the entry bodies and the settings files.
|
|
110
|
+
* @param config - plugin config; `storeDir` wins when it names a directory.
|
|
111
|
+
* @returns an absolute path, without the `sections` leaf.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveStoreDir(config = {}) {
|
|
114
|
+
const configured = config.storeDir?.trim();
|
|
115
|
+
if (configured !== undefined && configured.length > 0)
|
|
116
|
+
return configured;
|
|
117
|
+
return join(resolveHarnessHome(), STORE_DIR_NAME);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Load the schemastery factory a settings namespace needs.
|
|
121
|
+
*
|
|
122
|
+
* Read through `createRequire` rather than a static import: a deployment
|
|
123
|
+
* without the settings capability also has no schemastery, and this plugin must
|
|
124
|
+
* still mount there with its composed configuration.
|
|
125
|
+
*
|
|
126
|
+
* @returns the schema factory, or `undefined` when it cannot be resolved.
|
|
127
|
+
*/
|
|
128
|
+
function loadSchemaFactory() {
|
|
129
|
+
try {
|
|
130
|
+
const loaded = createRequire(import.meta.url)('@deepseek-ai/schemastery');
|
|
131
|
+
const candidate = typeof loaded === 'function'
|
|
132
|
+
? loaded
|
|
133
|
+
: loaded?.default;
|
|
134
|
+
if (typeof candidate !== 'function')
|
|
135
|
+
return undefined;
|
|
136
|
+
const factory = candidate;
|
|
137
|
+
if (typeof factory.object !== 'function' || typeof factory.array !== 'function')
|
|
138
|
+
return undefined;
|
|
139
|
+
return factory;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* This package's own manifest identity, read at most once.
|
|
147
|
+
*
|
|
148
|
+
* Read through `createRequire` because `package.json` sits beside `lib/` rather
|
|
149
|
+
* than inside the emitted program, and a deployment that ships only the built
|
|
150
|
+
* files must still be able to write a pack — empty header values are a cosmetic
|
|
151
|
+
* loss, not a failure. Both values are read rather than hardcoded so a rename can
|
|
152
|
+
* never leave the exported header naming a package that no longer exists.
|
|
153
|
+
*
|
|
154
|
+
* @returns the package name and version, empty strings when they cannot be read.
|
|
155
|
+
*/
|
|
156
|
+
function ownManifest() {
|
|
157
|
+
if (ownManifestCache !== undefined)
|
|
158
|
+
return ownManifestCache;
|
|
159
|
+
let manifest = { name: '', version: '' };
|
|
160
|
+
try {
|
|
161
|
+
const loaded = createRequire(import.meta.url)('../package.json');
|
|
162
|
+
const record = loaded;
|
|
163
|
+
manifest = {
|
|
164
|
+
name: typeof record.name === 'string' ? record.name : '',
|
|
165
|
+
version: typeof record.version === 'string' ? record.version : '',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
manifest = { name: '', version: '' };
|
|
170
|
+
}
|
|
171
|
+
ownManifestCache = manifest;
|
|
172
|
+
return manifest;
|
|
173
|
+
}
|
|
174
|
+
/** Cache for {@link ownManifest}. */
|
|
175
|
+
let ownManifestCache;
|
|
176
|
+
/**
|
|
177
|
+
* Report a non-fatal problem without ever breaking the mount.
|
|
178
|
+
* @param ctx - plugin context owning the logger.
|
|
179
|
+
* @param message - the detail to report.
|
|
180
|
+
*/
|
|
181
|
+
function warn(ctx, message) {
|
|
182
|
+
try {
|
|
183
|
+
ctx.logger?.warn(`dsh-prompt-manager: ${message}`);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* logging must never be the reason a session cannot assemble a prompt */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Message text of an unknown thrown value.
|
|
191
|
+
* @param error - the caught value.
|
|
192
|
+
* @returns a human-facing message.
|
|
193
|
+
*/
|
|
194
|
+
function messageOf(error) {
|
|
195
|
+
return error instanceof Error ? error.message : String(error);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Register the prompt sections, their variables, the settings index, and the
|
|
199
|
+
* body-file route.
|
|
200
|
+
*
|
|
201
|
+
* @param ctx - Cordis context carrying the `systemPrompt` service.
|
|
202
|
+
* @param config - optional overrides for variables and storage.
|
|
203
|
+
*/
|
|
204
|
+
export function apply(ctx, config = {}) {
|
|
205
|
+
/** Placeholder texts, shared by probes and scripts. */
|
|
206
|
+
const texts = { ...DEFAULT_PROBE_TEXTS, ...config.probeTexts };
|
|
207
|
+
/**
|
|
208
|
+
* The variables in force, keyed by reference name.
|
|
209
|
+
*
|
|
210
|
+
* The provider handed to the registry reads this map instead of a captured
|
|
211
|
+
* value, so refreshing a value costs one assignment here and nothing at the
|
|
212
|
+
* registry. That is what lets a script's output change without a restart, and
|
|
213
|
+
* it is also what guarantees every `{{name}}` resolves to a non-empty string
|
|
214
|
+
* for as long as it is registered.
|
|
215
|
+
*/
|
|
216
|
+
const variables = new Map();
|
|
217
|
+
/**
|
|
218
|
+
* How to unregister each variable's provider.
|
|
219
|
+
*
|
|
220
|
+
* Kept so a variable can be dropped when nothing references it any more — the
|
|
221
|
+
* disposer Cordis handed back when the provider was registered.
|
|
222
|
+
*/
|
|
223
|
+
const variableDisposers = new Map();
|
|
224
|
+
/**
|
|
225
|
+
* Whether any entry currently references one variable.
|
|
226
|
+
*
|
|
227
|
+
* Filled in once the index exists; the engine asks during mount, before the
|
|
228
|
+
* per-entry bodies have been read, so the default answers "no reference" —
|
|
229
|
+
* which at that point is true, because nothing has been declared yet.
|
|
230
|
+
*/
|
|
231
|
+
let isReferenced = () => false;
|
|
232
|
+
/**
|
|
233
|
+
* Scripts currently on disk.
|
|
234
|
+
*
|
|
235
|
+
* A variable keeps its name and value after the script that supplied it is
|
|
236
|
+
* deleted — a missing value would fail assembly — but a name whose script is
|
|
237
|
+
* gone for good is adoptable, so renaming a script does not leave its old
|
|
238
|
+
* variables owned by a file that no longer exists. Filled in once the engine
|
|
239
|
+
* exists; nothing declared before that can be a script variable.
|
|
240
|
+
*/
|
|
241
|
+
let onDiskScripts = () => [];
|
|
242
|
+
/**
|
|
243
|
+
* Offer one prompt variable, registering its provider the first time.
|
|
244
|
+
*
|
|
245
|
+
* A name another source already owns is refused rather than overwritten: the
|
|
246
|
+
* registry throws on a duplicate, and one contested name must not cost the
|
|
247
|
+
* whole mount.
|
|
248
|
+
*
|
|
249
|
+
* @param variable - the `{{name}}` to serve; validated by the caller.
|
|
250
|
+
* @param value - the value assemblies will see; never empty.
|
|
251
|
+
* @param source - which layer the value came from.
|
|
252
|
+
* @param detail - owning script name, for a script variable.
|
|
253
|
+
* @returns `assigned` when this source already owned the name, `declared` when
|
|
254
|
+
* it was free, `conflict` when something else owns it.
|
|
255
|
+
*/
|
|
256
|
+
function declareVariable(variable, value, source, detail) {
|
|
257
|
+
const existing = variables.get(variable);
|
|
258
|
+
if (existing !== undefined) {
|
|
259
|
+
const sameOwner = existing.source === source && existing.detail === detail;
|
|
260
|
+
const adoptable = !sameOwner
|
|
261
|
+
&& existing.source === 'script'
|
|
262
|
+
&& source === 'script'
|
|
263
|
+
&& existing.detail !== undefined
|
|
264
|
+
&& !onDiskScripts().includes(existing.detail);
|
|
265
|
+
if (!sameOwner && !adoptable)
|
|
266
|
+
return 'conflict';
|
|
267
|
+
existing.value = value;
|
|
268
|
+
existing.source = source;
|
|
269
|
+
existing.detail = detail;
|
|
270
|
+
existing.updatedAt = new Date().toISOString();
|
|
271
|
+
return 'assigned';
|
|
272
|
+
}
|
|
273
|
+
variables.set(variable, { value, source, detail, updatedAt: new Date().toISOString() });
|
|
274
|
+
try {
|
|
275
|
+
const dispose = ctx.effect(() => ctx.systemPrompt.variable(variable, () => variables.get(variable)?.value ?? texts.missing), `dsh-prompt-manager.variable(${variable})`);
|
|
276
|
+
variableDisposers.set(variable, dispose);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
variables.delete(variable);
|
|
280
|
+
warn(ctx, `cannot register the prompt variable ${variable}, so entries referencing it will not assemble: ${messageOf(error)}`);
|
|
281
|
+
return 'conflict';
|
|
282
|
+
}
|
|
283
|
+
return 'declared';
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Let go of one variable this plugin registered.
|
|
287
|
+
*
|
|
288
|
+
* Unregistering the provider as well as dropping the record, because a
|
|
289
|
+
* registered name with no value is worse than no name at all: the reference
|
|
290
|
+
* would resolve to the placeholder text instead of being reported as the
|
|
291
|
+
* unresolvable one it has become.
|
|
292
|
+
*
|
|
293
|
+
* @param variable - the `{{name}}` to drop.
|
|
294
|
+
* @param detail - the script that declared it; another owner's name is kept.
|
|
295
|
+
*/
|
|
296
|
+
function forgetVariable(variable, detail) {
|
|
297
|
+
const record = variables.get(variable);
|
|
298
|
+
if (record === undefined)
|
|
299
|
+
return;
|
|
300
|
+
if (record.source !== 'script' || record.detail !== detail)
|
|
301
|
+
return;
|
|
302
|
+
variables.delete(variable);
|
|
303
|
+
const dispose = variableDisposers.get(variable);
|
|
304
|
+
variableDisposers.delete(variable);
|
|
305
|
+
if (dispose !== undefined)
|
|
306
|
+
dispose();
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The layer a variable's value came from, in the words the log reader needs.
|
|
310
|
+
* @param record - the variable record in force.
|
|
311
|
+
* @returns a short label naming the owner.
|
|
312
|
+
*/
|
|
313
|
+
function ownerLabel(record) {
|
|
314
|
+
if (record.source === 'script')
|
|
315
|
+
return `脚本 ${record.detail ?? '?'}`;
|
|
316
|
+
if (record.source === 'config')
|
|
317
|
+
return 'config.variables';
|
|
318
|
+
if (record.source === 'probe')
|
|
319
|
+
return '探测';
|
|
320
|
+
return '环境变量';
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Offer one prompt variable, reporting a name this plugin already handed out.
|
|
324
|
+
*
|
|
325
|
+
* A name taken by another row is reported where the registry refuses it, so
|
|
326
|
+
* only the conflict this plugin resolves by itself needs a voice here — and it
|
|
327
|
+
* needs one: the losing layer is otherwise dropped in silence, and a person
|
|
328
|
+
* reading `{{os}}` in an entry would have no way to learn why their
|
|
329
|
+
* `variables` entry never took effect.
|
|
330
|
+
*
|
|
331
|
+
* @param variable - the `{{name}}` to serve; validated by the caller.
|
|
332
|
+
* @param value - the value assemblies will see.
|
|
333
|
+
* @param source - which layer the value came from.
|
|
334
|
+
*/
|
|
335
|
+
function declareOrReport(variable, value, source) {
|
|
336
|
+
if (declareVariable(variable, value, source) !== 'conflict')
|
|
337
|
+
return;
|
|
338
|
+
const owner = variables.get(variable);
|
|
339
|
+
if (owner === undefined)
|
|
340
|
+
return;
|
|
341
|
+
warn(ctx, `${variable} 已经由${ownerLabel(owner)}提供(当前值 ${JSON.stringify(owner.value)}),本次 ${ownerLabel({ source })} 提供的值被忽略`);
|
|
342
|
+
}
|
|
343
|
+
const facts = environmentFacts();
|
|
344
|
+
if (config.environment ?? true) {
|
|
345
|
+
for (const [variable, value] of Object.entries(facts))
|
|
346
|
+
declareOrReport(variable, value, 'environment');
|
|
347
|
+
}
|
|
348
|
+
for (const [variable, value] of Object.entries(config.variables ?? {})) {
|
|
349
|
+
if (!VARIABLE_NAME.test(variable)) {
|
|
350
|
+
throw new Error(`dsh-prompt-manager: invalid variable name ${JSON.stringify(variable)} (must match ${String(VARIABLE_NAME)})`);
|
|
351
|
+
}
|
|
352
|
+
declareOrReport(variable, value, 'config');
|
|
353
|
+
}
|
|
354
|
+
// A malformed probe is a composition mistake, so it fails the mount loudly
|
|
355
|
+
// rather than leaving a `{{name}}` that no assembly can resolve. Whether a
|
|
356
|
+
// probed tool exists, stays silent, or hangs is a value, not an error.
|
|
357
|
+
const probed = normalizeProbes(config.probes);
|
|
358
|
+
if (probed.problems.length > 0)
|
|
359
|
+
throw new Error(`dsh-prompt-manager: ${probed.problems.join('; ')}`);
|
|
360
|
+
const specs = {
|
|
361
|
+
...((config.probeDefaults ?? true) ? DEFAULT_PROBES : {}),
|
|
362
|
+
...probed.specs,
|
|
363
|
+
};
|
|
364
|
+
const probeNames = Object.keys(specs);
|
|
365
|
+
if (probeNames.length > MAX_PROBES) {
|
|
366
|
+
throw new Error(`dsh-prompt-manager: at most ${String(MAX_PROBES)} probes are allowed, got ${String(probeNames.length)}`);
|
|
367
|
+
}
|
|
368
|
+
if (probeNames.length > 0) {
|
|
369
|
+
const report = runProbes(specs, { texts, budgetMs: config.probeBudgetMs });
|
|
370
|
+
for (const outcome of report.outcomes)
|
|
371
|
+
declareOrReport(outcome.name, outcome.value, 'probe');
|
|
372
|
+
}
|
|
373
|
+
const store = new PromptStore(join(resolveStoreDir(config), 'sections'));
|
|
374
|
+
// Script overrides are composition config, so a malformed one fails the mount
|
|
375
|
+
// for the same reason a malformed probe does: it is the deployment's mistake,
|
|
376
|
+
// and leaving it silent would leave a script nobody can run.
|
|
377
|
+
const scriptOverrides = normalizeScriptOverrides(config.scripts);
|
|
378
|
+
if (scriptOverrides.problems.length > 0)
|
|
379
|
+
throw new Error(`dsh-prompt-manager: ${scriptOverrides.problems.join('; ')}`);
|
|
380
|
+
/**
|
|
381
|
+
* The user-script engine. It owns the files and the runs; every value it
|
|
382
|
+
* produces passes through {@link declareVariable}, so the plugin stays the
|
|
383
|
+
* only writer of its own variable registry.
|
|
384
|
+
*/
|
|
385
|
+
const scripts = new PromptScripts({
|
|
386
|
+
dir: () => join(resolveStoreDir(config), SCRIPTS_DIR_NAME),
|
|
387
|
+
overrides: () => scriptOverrides.overrides,
|
|
388
|
+
texts: () => texts,
|
|
389
|
+
declare: (variable, value, detail) => declareVariable(variable, value, 'script', detail),
|
|
390
|
+
owner: (variable) => {
|
|
391
|
+
const record = variables.get(variable);
|
|
392
|
+
if (record === undefined)
|
|
393
|
+
return undefined;
|
|
394
|
+
if (record.source !== 'script')
|
|
395
|
+
return record.source;
|
|
396
|
+
const detail = record.detail ?? 'script';
|
|
397
|
+
// A name left behind by a script that no longer exists is free again.
|
|
398
|
+
return onDiskScripts().includes(detail) ? detail : undefined;
|
|
399
|
+
},
|
|
400
|
+
referenced: (variable) => isReferenced(variable),
|
|
401
|
+
forget: (variable, detail) => { forgetVariable(variable, detail); },
|
|
402
|
+
warn: (message) => warn(ctx, message),
|
|
403
|
+
});
|
|
404
|
+
onDiskScripts = () => scripts.names();
|
|
405
|
+
ctx.effect(() => () => { scripts.dispose(); }, 'dsh-prompt-manager: script engine');
|
|
406
|
+
// The cached values are read synchronously, so a profile start serves what it
|
|
407
|
+
// saw last without executing anything; only scripts whose file changed while
|
|
408
|
+
// the profile was down are re-run, behind the mount.
|
|
409
|
+
cleanDrafts(scripts.dir);
|
|
410
|
+
const pendingScripts = scripts.mountDeclare();
|
|
411
|
+
if (pendingScripts.length > 0) {
|
|
412
|
+
void scripts.refresh(pendingScripts).catch((error) => {
|
|
413
|
+
warn(ctx, `a script refresh failed: ${messageOf(error)}`);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* The index in force. Seeded with the built-in entries so a deployment without
|
|
418
|
+
* a settings service still gets them; the settings sync below replaces this
|
|
419
|
+
* with the resolved document as soon as one is available.
|
|
420
|
+
*/
|
|
421
|
+
const active = builtinEntries();
|
|
422
|
+
/** Live lookup for section text callbacks. */
|
|
423
|
+
const byId = new Map();
|
|
424
|
+
/** Registered sections, keyed by entry id. */
|
|
425
|
+
const sections = new Map();
|
|
426
|
+
/** The resolved settings document, as the engine reads it. */
|
|
427
|
+
let resolved = {
|
|
428
|
+
entries: builtinEntries(),
|
|
429
|
+
presets: [],
|
|
430
|
+
activePreset: '',
|
|
431
|
+
sources: [],
|
|
432
|
+
mirror: '',
|
|
433
|
+
proxy: { kind: 'none', url: '' },
|
|
434
|
+
};
|
|
435
|
+
/**
|
|
436
|
+
* The preset in force, or `undefined` when the entries' own switches decide.
|
|
437
|
+
*
|
|
438
|
+
* Read per assembly like everything else here: activation is a settings write,
|
|
439
|
+
* so both the switch itself and a later edit of the preset land on the next
|
|
440
|
+
* model step with no re-registration — section text is a callback, and neither
|
|
441
|
+
* a preset's name nor its membership is part of a section's identity.
|
|
442
|
+
*/
|
|
443
|
+
let activePreset;
|
|
444
|
+
/** Whether the "no such preset" report has already been made for this mount. */
|
|
445
|
+
let presetReported = false;
|
|
446
|
+
/** The last set of missing preset members reported, so it is said once. */
|
|
447
|
+
let danglingReported = '';
|
|
448
|
+
/** Where each subscribed entry's body lives; refreshed when settings commit. */
|
|
449
|
+
let locations = new Map();
|
|
450
|
+
/** How the engine writes the index back; present only with a settings service. */
|
|
451
|
+
let writeEntries;
|
|
452
|
+
/**
|
|
453
|
+
* How an import lands the index and the preset list, in one settings write.
|
|
454
|
+
*
|
|
455
|
+
* Separate from {@link writeEntries} because an import has to place both
|
|
456
|
+
* fields together: a preset whose members were written while its entries were
|
|
457
|
+
* not is an index somebody has to repair by hand, whereas the reverse — bodies
|
|
458
|
+
* on disk that the index does not name — is repaired by importing again, since
|
|
459
|
+
* the ids come back the same.
|
|
460
|
+
*/
|
|
461
|
+
let writeIndex;
|
|
462
|
+
function field(name) {
|
|
463
|
+
return typeof resolved === 'object' && resolved !== null && !Array.isArray(resolved)
|
|
464
|
+
? resolved[name]
|
|
465
|
+
: undefined;
|
|
466
|
+
}
|
|
467
|
+
function sourcesInForce() {
|
|
468
|
+
return parseSources(field('sources'));
|
|
469
|
+
}
|
|
470
|
+
function proxyInForce() {
|
|
471
|
+
const raw = field('proxy');
|
|
472
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
473
|
+
return { kind: 'none', url: '' };
|
|
474
|
+
const record = raw;
|
|
475
|
+
return {
|
|
476
|
+
kind: typeof record['kind'] === 'string' ? record['kind'] : 'none',
|
|
477
|
+
url: typeof record['url'] === 'string' ? record['url'] : '',
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function mirrorInForce() {
|
|
481
|
+
return normalizeMirror(field('mirror')) ?? '';
|
|
482
|
+
}
|
|
483
|
+
/** The configured presets, narrowed from the settings document. */
|
|
484
|
+
function presetsInForce() {
|
|
485
|
+
return parsePresets(field('presets'));
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Adopt the preset `activePreset` names.
|
|
489
|
+
*
|
|
490
|
+
* A name that resolves to nothing — the preset was deleted, or a hand-edited
|
|
491
|
+
* document misspells it — falls back to the entries' own switches rather than
|
|
492
|
+
* freezing the prompt on whatever was active. That is a state somebody has to
|
|
493
|
+
* be able to see, so it is reported once instead of failing anything.
|
|
494
|
+
*
|
|
495
|
+
* @param document - the resolved settings document.
|
|
496
|
+
*/
|
|
497
|
+
function setActivePreset(document) {
|
|
498
|
+
const wanted = activePresetOf(typeof document === 'object' && document !== null && !Array.isArray(document)
|
|
499
|
+
? document['activePreset']
|
|
500
|
+
: undefined);
|
|
501
|
+
const presets = presetsInForce();
|
|
502
|
+
activePreset = wanted.length === 0 ? undefined : presets.find((preset) => preset.id === wanted);
|
|
503
|
+
if (wanted.length === 0 || activePreset !== undefined || presetReported)
|
|
504
|
+
return;
|
|
505
|
+
presetReported = true;
|
|
506
|
+
warn(ctx, `组合 ${wanted} 不存在(可能已被删除):本次挂载回到每条自己的开关`);
|
|
507
|
+
}
|
|
508
|
+
/** The next free placement for an entry the engine adds. */
|
|
509
|
+
function nextOrder() {
|
|
510
|
+
let highest = 0;
|
|
511
|
+
for (const entry of active)
|
|
512
|
+
if (entry.order > highest)
|
|
513
|
+
highest = entry.order;
|
|
514
|
+
return highest + 10;
|
|
515
|
+
}
|
|
516
|
+
const subscriptions = new Subscriptions({
|
|
517
|
+
sources: sourcesInForce,
|
|
518
|
+
proxy: proxyInForce,
|
|
519
|
+
mirror: mirrorInForce,
|
|
520
|
+
root: () => resolveStoreDir(config),
|
|
521
|
+
entries: () => active,
|
|
522
|
+
setEntries: async (next) => {
|
|
523
|
+
if (writeEntries === undefined)
|
|
524
|
+
throw new Error('订阅需要 settings 服务,当前部署没有挂载它');
|
|
525
|
+
await writeEntries(next);
|
|
526
|
+
},
|
|
527
|
+
nextOrder,
|
|
528
|
+
warn: (message) => warn(ctx, message),
|
|
529
|
+
});
|
|
530
|
+
/**
|
|
531
|
+
* The body that would reach the prompt for one entry. A subscribed entry reads
|
|
532
|
+
* its snapshot first — its body is upstream's, not this machine's — then a body
|
|
533
|
+
* written here, then the body this package ships for that id. Read per
|
|
534
|
+
* assembly, so an edited or freshly applied file lands on the next model step.
|
|
535
|
+
*/
|
|
536
|
+
function describe(id) {
|
|
537
|
+
if (locations.has(id)) {
|
|
538
|
+
const subscribed = subscriptions.readBody(id);
|
|
539
|
+
return subscribed === undefined
|
|
540
|
+
? { text: '', source: 'empty' }
|
|
541
|
+
: { text: subscribed, source: 'subscribed' };
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const stored = store.read(id);
|
|
545
|
+
if (stored !== undefined)
|
|
546
|
+
return { text: stored.body, source: 'user' };
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
warn(ctx, messageOf(error));
|
|
550
|
+
}
|
|
551
|
+
const builtin = readBuiltinBody(id);
|
|
552
|
+
if (builtin !== undefined)
|
|
553
|
+
return { text: builtin, source: 'builtin' };
|
|
554
|
+
return { text: '', source: 'empty' };
|
|
555
|
+
}
|
|
556
|
+
/** Every entry registers under the plugin's own prefix. */
|
|
557
|
+
function sectionNameFor(entry) {
|
|
558
|
+
return `${USER_SECTION_PREFIX}${entry.id}`;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Build the pack for one preset: its members, each carrying either its body or
|
|
562
|
+
* the source that owns it.
|
|
563
|
+
*
|
|
564
|
+
* That split is the whole point of the format. A local or built-in body is
|
|
565
|
+
* copied into the pack, because nothing else could reproduce it. A subscribed
|
|
566
|
+
* body is *named* rather than copied, because it belongs to a source the
|
|
567
|
+
* importing machine can configure for itself — copying it would silently
|
|
568
|
+
* divorce the entry from upstream. A member the index no longer has cannot be
|
|
569
|
+
* carried at all and is reported instead of quietly left out.
|
|
570
|
+
*
|
|
571
|
+
* @param presetId - the preset to export.
|
|
572
|
+
* @returns the pack, or `undefined` when no such preset exists here.
|
|
573
|
+
*/
|
|
574
|
+
function packFor(presetId) {
|
|
575
|
+
const preset = presetsInForce().find((candidate) => candidate.id === presetId);
|
|
576
|
+
if (preset === undefined)
|
|
577
|
+
return undefined;
|
|
578
|
+
const sources = sourcesInForce();
|
|
579
|
+
const members = [];
|
|
580
|
+
const missing = [];
|
|
581
|
+
const carried = new Set();
|
|
582
|
+
for (const id of preset.entries) {
|
|
583
|
+
if (carried.has(id))
|
|
584
|
+
continue;
|
|
585
|
+
const entry = byId.get(id);
|
|
586
|
+
if (entry === undefined) {
|
|
587
|
+
missing.push(id);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
carried.add(id);
|
|
591
|
+
const owner = entry.source !== undefined && entry.source.length > 0
|
|
592
|
+
? entry.source
|
|
593
|
+
: locations.get(id)?.slug;
|
|
594
|
+
if (owner !== undefined) {
|
|
595
|
+
const source = sources.find((candidate) => candidate.id === owner);
|
|
596
|
+
const ref = { slug: owner };
|
|
597
|
+
if (source !== undefined) {
|
|
598
|
+
ref.repo = source.repo;
|
|
599
|
+
ref.ref = source.ref;
|
|
600
|
+
}
|
|
601
|
+
const file = locations.get(id)?.path;
|
|
602
|
+
if (file !== undefined)
|
|
603
|
+
ref.file = file;
|
|
604
|
+
members.push({ id, title: entry.title, order: entry.order, enabled: entry.enabled, source: ref });
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const resolved = describe(id);
|
|
608
|
+
members.push({
|
|
609
|
+
id,
|
|
610
|
+
title: entry.title,
|
|
611
|
+
order: entry.order,
|
|
612
|
+
enabled: entry.enabled,
|
|
613
|
+
body: resolved.text,
|
|
614
|
+
origin: resolved.source === 'builtin' ? 'builtin' : 'local',
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
const own = ownManifest();
|
|
618
|
+
return buildPack({ preset, members, missing, pluginName: own.name, pluginVersion: own.version });
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Carry out an import: write the bodies the pack brought, then merge its
|
|
622
|
+
* entries and its preset into the index in a single settings write.
|
|
623
|
+
*
|
|
624
|
+
* Everything refusable is refused before a file is written, and a failure
|
|
625
|
+
* while writing takes back the files this import created, so a refused pack
|
|
626
|
+
* leaves the machine exactly as it was. The index lands last on purpose: a
|
|
627
|
+
* crash in between leaves body files nothing points at, which importing the
|
|
628
|
+
* same pack again repairs, while the opposite order would leave index records
|
|
629
|
+
* whose bodies never existed.
|
|
630
|
+
*
|
|
631
|
+
* @param pack - a pack that already passed {@link parsePack}.
|
|
632
|
+
* @returns what it did, or why it did nothing.
|
|
633
|
+
*/
|
|
634
|
+
async function importPack(pack) {
|
|
635
|
+
if (writeIndex === undefined) {
|
|
636
|
+
return { ok: false, code: 'bad-format', message: '导入需要 settings 服务来写索引,当前部署没有挂载它' };
|
|
637
|
+
}
|
|
638
|
+
const planned = planImport(pack, {
|
|
639
|
+
entryIds: takenIds(),
|
|
640
|
+
presetIds: presetsInForce().map((preset) => preset.id),
|
|
641
|
+
});
|
|
642
|
+
if (!planned.ok)
|
|
643
|
+
return planned;
|
|
644
|
+
const { plan } = planned;
|
|
645
|
+
writePackBodies(plan.entries, {
|
|
646
|
+
write: (id, body) => {
|
|
647
|
+
// `absent` rather than `any`: an id this import believes is free must not
|
|
648
|
+
// silently replace a body somebody put there a moment ago.
|
|
649
|
+
store.write(id, body, { kind: 'absent' });
|
|
650
|
+
},
|
|
651
|
+
remove: (id) => {
|
|
652
|
+
try {
|
|
653
|
+
store.remove(id);
|
|
654
|
+
}
|
|
655
|
+
catch (error) {
|
|
656
|
+
warn(ctx, `${id} 的正文回滚失败:${messageOf(error)}`);
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
const added = plan.entries.map((entry) => {
|
|
661
|
+
const record = {
|
|
662
|
+
id: entry.id,
|
|
663
|
+
title: entry.title,
|
|
664
|
+
order: entry.order,
|
|
665
|
+
enabled: entry.enabled,
|
|
666
|
+
};
|
|
667
|
+
if (entry.source !== undefined)
|
|
668
|
+
record.source = entry.source;
|
|
669
|
+
return record;
|
|
670
|
+
});
|
|
671
|
+
await writeIndex({ entries: [...active, ...added], presets: [...presetsInForce(), plan.preset] });
|
|
672
|
+
const report = {
|
|
673
|
+
entries: plan.entries.map((entry) => {
|
|
674
|
+
const created = {
|
|
675
|
+
id: entry.id,
|
|
676
|
+
title: entry.title,
|
|
677
|
+
};
|
|
678
|
+
if (entry.renamedFrom !== undefined)
|
|
679
|
+
created.renamedFrom = entry.renamedFrom;
|
|
680
|
+
return created;
|
|
681
|
+
}),
|
|
682
|
+
preset: plan.preset,
|
|
683
|
+
renamed: plan.renamed,
|
|
684
|
+
noBody: plan.noBody,
|
|
685
|
+
sourceDropped: plan.sourceDropped,
|
|
686
|
+
missingMembers: plan.missingMembers,
|
|
687
|
+
unregistered: unregisteredReferences(plan.entries.flatMap((entry) => entry.body ?? [])),
|
|
688
|
+
};
|
|
689
|
+
return { ok: true, report };
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Variable names the given bodies reference that this plugin does not supply.
|
|
693
|
+
*
|
|
694
|
+
* A report, never a refusal: another row may register a name, and a name that
|
|
695
|
+
* is merely unregistered today is a variable somebody is still about to write.
|
|
696
|
+
* The reference guard is what keeps such a body from failing an assembly.
|
|
697
|
+
*
|
|
698
|
+
* @param bodies - the text that arrived in a pack.
|
|
699
|
+
* @returns the distinct names, in the order they first appear.
|
|
700
|
+
*/
|
|
701
|
+
function unregisteredReferences(bodies) {
|
|
702
|
+
const found = [];
|
|
703
|
+
for (const body of bodies) {
|
|
704
|
+
for (const match of body.matchAll(/\{\{([a-z][a-z0-9_]*)\}\}/g)) {
|
|
705
|
+
const reference = match[1];
|
|
706
|
+
if (reference === undefined || variables.has(reference))
|
|
707
|
+
continue;
|
|
708
|
+
if (!found.includes(reference))
|
|
709
|
+
found.push(reference);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return found;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* The text one entry contributes right now, or `''` when it contributes none.
|
|
716
|
+
*
|
|
717
|
+
* An active preset answers "is this entry on" by itself, so switching one is a
|
|
718
|
+
* single settings write with no bookkeeping: every entry keeps the `enabled`
|
|
719
|
+
* value a person gave it, for the times when no preset is in force.
|
|
720
|
+
*
|
|
721
|
+
* @param id - entry id.
|
|
722
|
+
* @returns the interpolatable body, or the empty string.
|
|
723
|
+
*/
|
|
724
|
+
function render(id) {
|
|
725
|
+
const entry = byId.get(id);
|
|
726
|
+
if (entry === undefined)
|
|
727
|
+
return '';
|
|
728
|
+
const on = activePreset === undefined ? entry.enabled : activePreset.entries.includes(entry.id);
|
|
729
|
+
return on ? describe(id).text : '';
|
|
730
|
+
}
|
|
731
|
+
/** References already reported, so one bad body cannot flood the log. */
|
|
732
|
+
const reportedReferences = new Set();
|
|
733
|
+
// The text this plugin serves is interpolated by the registry, strictly: a
|
|
734
|
+
// reference it cannot resolve, or one whose shape is not a variable name,
|
|
735
|
+
// makes that assembly throw — and one throwing section fails the whole
|
|
736
|
+
// assembly, so every model step of the profile would fail until somebody
|
|
737
|
+
// edited the body back. A body can also be hand-edited in `sections/`, where
|
|
738
|
+
// no page gets to warn first. This hook therefore checks the text this plugin
|
|
739
|
+
// owns against the assembly's own variable table and escapes whatever the
|
|
740
|
+
// registry would refuse, leaving every resolvable reference alone.
|
|
741
|
+
ctx.effect(() => ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
|
|
742
|
+
const out = await next();
|
|
743
|
+
const sections = out.sections.map((section) => {
|
|
744
|
+
if (!section.name.startsWith(USER_SECTION_PREFIX))
|
|
745
|
+
return section;
|
|
746
|
+
const guarded = sanitizeReferences(section.text, out.variables ?? {});
|
|
747
|
+
if (guarded.escaped.length === 0)
|
|
748
|
+
return section;
|
|
749
|
+
for (const reference of guarded.escaped) {
|
|
750
|
+
const key = `${section.name}\u0000${reference}`;
|
|
751
|
+
if (reportedReferences.has(key))
|
|
752
|
+
continue;
|
|
753
|
+
reportedReferences.add(key);
|
|
754
|
+
warn(ctx, `${section.name} 的正文引用了 ${JSON.stringify(reference)}:注册表解析不了它,已按字面量渲染(改掉这处引用,或让某个来源注册这个名字)`);
|
|
755
|
+
}
|
|
756
|
+
if (reportedReferences.size > 512)
|
|
757
|
+
reportedReferences.clear();
|
|
758
|
+
return { ...section, text: guarded.text };
|
|
759
|
+
});
|
|
760
|
+
return { ...out, sections };
|
|
761
|
+
}), 'dsh-prompt-manager: reference guard');
|
|
762
|
+
/**
|
|
763
|
+
* Bring the registered sections in line with the index. An entry whose name
|
|
764
|
+
* or placement moved is re-registered, because both are fixed when the
|
|
765
|
+
* section is declared; adding, removing, enabling, and disabling need no
|
|
766
|
+
* other bookkeeping because section text is resolved per assembly.
|
|
767
|
+
*/
|
|
768
|
+
function reconcile(entries) {
|
|
769
|
+
byId.clear();
|
|
770
|
+
for (const entry of entries)
|
|
771
|
+
byId.set(entry.id, entry);
|
|
772
|
+
for (const [id, registered] of [...sections]) {
|
|
773
|
+
const entry = byId.get(id);
|
|
774
|
+
if (entry !== undefined && registered.name === sectionNameFor(entry) && registered.order === entry.order)
|
|
775
|
+
continue;
|
|
776
|
+
registered.disposer();
|
|
777
|
+
sections.delete(id);
|
|
778
|
+
}
|
|
779
|
+
for (const entry of entries) {
|
|
780
|
+
if (sections.has(entry.id))
|
|
781
|
+
continue;
|
|
782
|
+
const section = sectionNameFor(entry);
|
|
783
|
+
const entryOrder = entry.order;
|
|
784
|
+
const disposer = ctx.effect(() => ctx.systemPrompt.section({
|
|
785
|
+
name: section,
|
|
786
|
+
order: entryOrder,
|
|
787
|
+
text: () => render(entry.id),
|
|
788
|
+
}), `dsh-prompt-manager.section(${section})`);
|
|
789
|
+
sections.set(entry.id, { disposer, name: section, order: entryOrder });
|
|
790
|
+
}
|
|
791
|
+
reportDanglingMembers();
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Report a preset that names entries this machine does not have.
|
|
795
|
+
*
|
|
796
|
+
* This is the failure nobody notices by itself: the preset still switches, the
|
|
797
|
+
* remaining members still inject, and the missing ones simply stop appearing —
|
|
798
|
+
* the usual cause being an upstream rename, or a source whose subscription has
|
|
799
|
+
* not been applied yet. Reporting is keyed on the *set* of missing ids so a
|
|
800
|
+
* change to which entries are missing is reported again, while a preset left
|
|
801
|
+
* broken for a week says so once.
|
|
802
|
+
*/
|
|
803
|
+
function reportDanglingMembers() {
|
|
804
|
+
const missing = activePreset === undefined
|
|
805
|
+
? []
|
|
806
|
+
: activePreset.entries.filter((id) => !byId.has(id));
|
|
807
|
+
const signature = `${activePreset?.id ?? ''}:${missing.join(',')}`;
|
|
808
|
+
if (missing.length === 0 || signature === danglingReported)
|
|
809
|
+
return;
|
|
810
|
+
danglingReported = signature;
|
|
811
|
+
warn(ctx, `组合 ${activePreset?.id ?? ''} 里有 ${String(missing.length)} 条不在索引里:${missing.join('、')}`
|
|
812
|
+
+ '(订阅没拉回来,或上游改了文件名)。这些条目这一轮不注入。');
|
|
813
|
+
}
|
|
814
|
+
/** Ids a new entry may not take: the index, every stored body, and the built-ins. */
|
|
815
|
+
function takenIds() {
|
|
816
|
+
return [...new Set([
|
|
817
|
+
...active.map((entry) => entry.id),
|
|
818
|
+
...store.ids(),
|
|
819
|
+
...BUILTIN_PROMPTS.map((prompt) => prompt.id),
|
|
820
|
+
])];
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Which entry titles reference each variable.
|
|
824
|
+
*
|
|
825
|
+
* A reference to a name that is no longer registered makes assembly throw, so
|
|
826
|
+
* the page needs this in front of a delete rather than in the log afterwards.
|
|
827
|
+
*
|
|
828
|
+
* @returns variable name → titles of the entries that reference it.
|
|
829
|
+
*/
|
|
830
|
+
function referencesIn() {
|
|
831
|
+
const reference = /\{\{([a-z][a-z0-9_]*)\}\}/g;
|
|
832
|
+
const found = new Map();
|
|
833
|
+
for (const entry of active) {
|
|
834
|
+
let body;
|
|
835
|
+
try {
|
|
836
|
+
body = describe(entry.id).text;
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
for (const match of body.matchAll(reference)) {
|
|
842
|
+
const name = match[1];
|
|
843
|
+
if (name === undefined)
|
|
844
|
+
continue;
|
|
845
|
+
const titles = found.get(name) ?? [];
|
|
846
|
+
if (!titles.includes(entry.title))
|
|
847
|
+
titles.push(entry.title);
|
|
848
|
+
found.set(name, titles);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return found;
|
|
852
|
+
}
|
|
853
|
+
// From here on the index exists, so the script engine can ask whether a
|
|
854
|
+
// variable is still referenced before it lets one go.
|
|
855
|
+
isReferenced = (variable) => referencesIn().has(variable);
|
|
856
|
+
/**
|
|
857
|
+
* The variables in force, each with what references it.
|
|
858
|
+
* @returns one view per variable, sorted by name.
|
|
859
|
+
*/
|
|
860
|
+
function variableViews() {
|
|
861
|
+
const references = referencesIn();
|
|
862
|
+
return [...variables.entries()]
|
|
863
|
+
.map(([name, record]) => ({
|
|
864
|
+
name,
|
|
865
|
+
value: record.value,
|
|
866
|
+
source: record.source,
|
|
867
|
+
detail: record.detail,
|
|
868
|
+
updatedAt: record.updatedAt,
|
|
869
|
+
referencedBy: references.get(name) ?? [],
|
|
870
|
+
}))
|
|
871
|
+
.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
872
|
+
}
|
|
873
|
+
installPromptRoutes(ctx, {
|
|
874
|
+
store,
|
|
875
|
+
describe,
|
|
876
|
+
idFor: (title) => entryIdFor(title, takenIds()),
|
|
877
|
+
presetIds: () => presetsInForce().map((preset) => preset.id),
|
|
878
|
+
warn: (message) => warn(ctx, message),
|
|
879
|
+
subscriptions,
|
|
880
|
+
scripts,
|
|
881
|
+
variables: () => variableViews(),
|
|
882
|
+
packFor,
|
|
883
|
+
importPack,
|
|
884
|
+
});
|
|
885
|
+
/** Whether the cap has already been reported for this mount. */
|
|
886
|
+
let truncationReported = false;
|
|
887
|
+
/**
|
|
888
|
+
* Report an index the entry cap shortened.
|
|
889
|
+
*
|
|
890
|
+
* At most {@link MAX_ENTRIES} sections may be active, so entries past the cap
|
|
891
|
+
* never reach the prompt. The settings page reads the document rather than the
|
|
892
|
+
* narrowed index, so it would still list them — saying so once is the
|
|
893
|
+
* difference between a silent no-op and something a person can act on.
|
|
894
|
+
*
|
|
895
|
+
* @param document - the resolved settings document.
|
|
896
|
+
* @param kept - how many entries survived narrowing.
|
|
897
|
+
*/
|
|
898
|
+
function reportTruncation(document, kept) {
|
|
899
|
+
if (truncationReported)
|
|
900
|
+
return;
|
|
901
|
+
const raw = typeof document === 'object' && document !== null && !Array.isArray(document)
|
|
902
|
+
? document['entries']
|
|
903
|
+
: undefined;
|
|
904
|
+
if (!Array.isArray(raw) || raw.length <= kept)
|
|
905
|
+
return;
|
|
906
|
+
truncationReported = true;
|
|
907
|
+
warn(ctx, `索引里有 ${String(raw.length)} 条条目,上限是 ${String(MAX_ENTRIES)} 条:只有前 ${String(kept)} 条会进入提示词(设置页仍然列出全部)`);
|
|
908
|
+
}
|
|
909
|
+
const factory = loadSchemaFactory();
|
|
910
|
+
if (factory === undefined) {
|
|
911
|
+
warn(ctx, 'schemastery is unavailable, so prompt entries cannot be edited from Settings');
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
ctx.inject(['settings'], (scoped) => {
|
|
915
|
+
const settings = scoped.settings;
|
|
916
|
+
if (settings === undefined)
|
|
917
|
+
return;
|
|
918
|
+
let scope;
|
|
919
|
+
try {
|
|
920
|
+
scope = settings.register(SETTINGS_NAMESPACE, buildIndexSchema(factory), {
|
|
921
|
+
base: {
|
|
922
|
+
entries: builtinEntries(),
|
|
923
|
+
presets: [],
|
|
924
|
+
activePreset: '',
|
|
925
|
+
sources: [],
|
|
926
|
+
mirror: '',
|
|
927
|
+
proxy: { kind: 'none', url: '' },
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
catch (error) {
|
|
932
|
+
warn(ctx, `cannot register the ${SETTINGS_NAMESPACE} settings namespace: ${messageOf(error)}`);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
writeEntries = async (next) => {
|
|
936
|
+
await scope.update({ entries: next });
|
|
937
|
+
};
|
|
938
|
+
writeIndex = async (patch) => {
|
|
939
|
+
await scope.update({ entries: patch.entries, presets: patch.presets });
|
|
940
|
+
};
|
|
941
|
+
const sync = () => {
|
|
942
|
+
resolved = scope.get();
|
|
943
|
+
setActivePreset(resolved);
|
|
944
|
+
const entries = parseEntries(resolved);
|
|
945
|
+
reportTruncation(resolved, entries.length);
|
|
946
|
+
active.length = 0;
|
|
947
|
+
active.push(...entries);
|
|
948
|
+
locations = subscriptions.refreshLocations();
|
|
949
|
+
reconcile(active);
|
|
950
|
+
};
|
|
951
|
+
sync();
|
|
952
|
+
ctx.effect(() => scope.watch(sync), 'dsh-prompt-manager: settings watcher');
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
setActivePreset(resolved);
|
|
956
|
+
locations = subscriptions.refreshLocations();
|
|
957
|
+
reconcile(active);
|
|
958
|
+
}
|
|
959
|
+
//# sourceMappingURL=index.js.map
|