dsh-prompt-customizer 0.2.7 → 0.3.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 CHANGED
@@ -1,340 +1,271 @@
1
- /**
2
- * dsh-prompt-customizer — host half.
3
- *
4
- * - Registers the `prompt-customizer` settings namespace (live-applied).
5
- * - Hooks the `system-prompt/assemble` waterfall to block / replace / inject
6
- * prompt sections and to hide tools from the model catalog by name. Only the
7
- * model-facing list is affected; tools and routes keep working.
8
- * - Serves `GET /api/prompt-customizer/inventory` returning the effective
9
- * section and tool lists (for the `standard` agent when a roster exists),
10
- * each marked with whether it is currently blocked / hidden.
11
- */
12
-
13
- import { settingsNamespace } from '@deepseek-ai/dsh-settings'
14
- import { Config, NS } from './schema.js'
15
-
16
- export const name = 'prompt-customizer'
17
- export const inject = ['settings', 'systemPrompt', 'tools']
18
-
19
- export function apply(ctx, entry = {}) {
20
- // 1) settings namespace: base = composition entry, live-applied.
21
- const scope = ctx.settings.register(settingsNamespace(NS), Config, {
22
- base: entry,
23
- applies: 'live',
24
- })
25
- const read = () => scope.get() ?? {}
26
-
27
- // 2) assemble waterfall: apply filter/replace/inject live, choosing the
28
- // per-agent overlay when the assembling context carries an agent scope.
29
- ctx.on('system-prompt/assemble', async (assembly, context, next) => {
30
- const assembled = await next()
31
- const cfg = mergeForAgent(read(), scopeIdOf(context))
32
- return { ...assembled, ...applyFilter(assembled, cfg) }
33
- })
34
-
35
- // 3) inventory route on the shared webserver (present in every web profile).
36
- // Use ctx.get() so this stays optional — accessing ctx.webServer without
37
- // declaring it in `inject` would throw "cannot get property without inject".
38
- const webserver = ctx.get('webServer')
39
- if (webserver) {
40
- const dispose = webserver.register({
41
- kind: 'exact',
42
- path: '/api/prompt-customizer/inventory',
43
- handler: async (req, res) => {
44
- try {
45
- const scopeKey = await previewScope(ctx, req)
46
- const cfg = mergeForAgent(read(), agentIdOf(scopeKey))
47
- writeJson(res, 200, await buildInventory(ctx, cfg, scopeKey))
48
- } catch (error) {
49
- writeJson(res, 500, {
50
- ok: false,
51
- error: String(error && error.message ? error.message : error),
52
- })
53
- }
54
- },
55
- })
56
- ctx.effect(() => dispose, 'prompt-customizer: inventory route')
57
-
58
- // Preview route: render the FINAL assembled system prompt (after every
59
- // plugin, including our own block/replace/inject filters) so the user can
60
- // confirm no injected section from other plugins pollutes it.
61
- // Optional `?scope=<agentPresetId>` previews a specific agent preset's
62
- // assembly; omitted, it uses the standard agent (same as the inventory).
63
- const disposePreview = webserver.register({
64
- kind: 'exact',
65
- path: '/api/prompt-customizer/preview',
66
- handler: async (req, res) => {
67
- try {
68
- const scopeKey = await previewScope(ctx, req)
69
- writeJson(res, 200, await buildPreview(ctx, scopeKey))
70
- } catch (error) {
71
- writeJson(res, 500, {
72
- ok: false,
73
- error: String(error && error.message ? error.message : error),
74
- })
75
- }
76
- },
77
- })
78
- ctx.effect(() => disposePreview, 'prompt-customizer: preview route')
79
-
80
- // Agents route: list every agent preset (for the per-agent editor UI).
81
- const disposeAgents = webserver.register({
82
- kind: 'exact',
83
- path: '/api/prompt-customizer/agents',
84
- handler: async (_req, res) => {
85
- try {
86
- writeJson(res, 200, await listAgents(ctx))
87
- } catch (error) {
88
- writeJson(res, 500, {
89
- ok: false,
90
- error: String(error && error.message ? error.message : error),
91
- })
92
- }
93
- },
94
- })
95
- ctx.effect(() => disposeAgents, 'prompt-customizer: agents route')
96
- }
97
- }
98
-
99
- /**
100
- * Apply block / replace / inject / tool filtering to an assembled system
101
- * prompt. Pure and shared by the waterfall and the tests.
102
- * @returns `{ sections, tools }` to spread over the assembly.
103
- */
104
- export function applyFilter(assembled, cfg) {
105
- const denied = new Set(cfg.sections ?? [])
106
- const replace = cfg.replace ?? {}
107
- const injectList = cfg.inject ?? []
108
- const te = cfg.tools ?? {}
109
- const exclude = new Set(te.exclude ?? [])
110
- const include = Array.isArray(te.include) && te.include.length > 0 ? new Set(te.include) : null
111
-
112
- let sections = assembled.sections
113
- .filter((section) => !denied.has(section.name))
114
- .map((section) =>
115
- Object.hasOwn(replace, section.name) ? { ...section, text: replace[section.name] } : section,
116
- )
117
- for (const item of injectList) {
118
- const index = sections.findIndex((section) => section.name === item.name)
119
- if (index >= 0) {
120
- // Existing section: apply the order override to control splice order.
121
- // Only override text when the user actually provided replacement text.
122
- sections[index] = item.text
123
- ? { ...sections[index], text: item.text, order: item.order }
124
- : { ...sections[index], order: item.order }
125
- } else {
126
- sections.push({ name: item.name, order: item.order, text: item.text })
127
- }
128
- }
129
- sections.sort((a, b) => a.order - b.order)
130
-
131
- let tools = assembled.tools
132
- if (include) tools = tools.filter((tool) => include.has(tool.name))
133
- else if (exclude.size > 0) tools = tools.filter((tool) => !exclude.has(tool.name))
134
-
135
- return { sections, tools }
136
- }
137
-
138
- /** Extract the agent-preset id from an assemble context's scope, if any. */
139
- function scopeIdOf(context) {
140
- const scope = context && context.scope
141
- if (scope && typeof scope === 'object' && typeof scope.agentPreset === 'string') return scope.agentPreset
142
- return undefined
143
- }
144
-
145
- /** Extract the agent-preset id from a standing scope key. */
146
- function agentIdOf(scopeKey) {
147
- return scopeKey && typeof scopeKey === 'object' && typeof scopeKey.agentPreset === 'string'
148
- ? scopeKey.agentPreset
149
- : undefined
150
- }
151
-
152
- /**
153
- * Merge the shared default config with the given agent preset's overlay.
154
- * The overlay's own arrays/objects replace the default's per field; the
155
- * default applies to every agent the overlay does not mention.
156
- */
157
- export function mergeForAgent(raw, agentId) {
158
- const byAgent = raw.byAgent ?? {}
159
- const over = (agentId && byAgent[agentId]) || {}
160
- return {
161
- sections: over.sections ?? raw.sections ?? [],
162
- replace: { ...(raw.replace ?? {}), ...(over.replace ?? {}) },
163
- inject: mergeInject(raw.inject, over.inject),
164
- tools: {
165
- exclude: over.tools?.exclude ?? raw.tools?.exclude ?? [],
166
- include: over.tools?.include ?? raw.tools?.include ?? [],
167
- },
168
- }
169
- }
170
-
171
- /** Merge default + per-agent inject lists: same-name entries are overridden. */
172
- function mergeInject(base, over) {
173
- const map = new Map((base ?? []).map((item) => [item.name, item]))
174
- for (const item of over ?? []) map.set(item.name, item)
175
- return [...map.values()]
176
- }
177
-
178
- /** Resolve `value` but never wait longer than `ms`; fall back to `fallback`. */
179
- function withTimeout(value, ms, fallback) {
180
- return Promise.race([
181
- Promise.resolve(value),
182
- new Promise((resolve) => setTimeout(() => resolve(fallback), ms)),
183
- ])
184
- }
185
-
186
- /**
187
- * Resolve the `standard` agent's standing scope key when a roster is present.
188
- *
189
- * `standingKeyFor` mounts a whole agent preset (compose plugins) which can be
190
- * slow or depend on services that are not ready yet — for a read-only inventory
191
- * browse we must never let that hang the request. If it does not settle within
192
- * a short budget we fall back to the global layer (scopeKey = undefined).
193
- */
194
- async function resolveStandardScope(ctx) {
195
- try {
196
- const presets = ctx.get('agentPresets')
197
- if (presets && typeof presets.standingKeyFor === 'function') {
198
- return await withTimeout(presets.standingKeyFor('standard'), 1500, undefined)
199
- }
200
- } catch { /* no roster — fall back to the global layer */ }
201
- return undefined
202
- }
203
-
204
- /** Enumerate the effective sections and tools with their blocked/hidden flags. */
205
- async function buildInventory(ctx, cfg, scopeKey) {
206
- const denied = new Set(cfg.sections ?? [])
207
- const replace = cfg.replace ?? {}
208
- const injectList = cfg.inject ?? []
209
- const injectOrder = new Map(injectList.map((item) => [item.name, item.order]))
210
- const te = cfg.tools ?? {}
211
- const exclude = new Set(te.exclude ?? [])
212
- const include = Array.isArray(te.include) && te.include.length > 0 ? new Set(te.include) : null
213
-
214
- // Resolve a section's text. Dynamic sections register a function; call it
215
- // with a best-effort context (same shape the assemble passes) so the
216
- // inventory can echo the real generated content for replacement. Fall back
217
- // to a marker if the function throws or returns a pending value.
218
- const resolveText = (section) => {
219
- if (typeof section.text === 'function') {
220
- try {
221
- const value = section.text({ scope: scopeKey })
222
- // Async dynamic sections return a thenable we cannot echo synchronously.
223
- if (value && typeof value.then === 'function') return '<动态生成>'
224
- return String(value ?? '')
225
- } catch { return '<动态生成>' }
226
- }
227
- return String(section.text ?? '')
228
- }
229
-
230
- const sectionsMap = ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections)
231
- const sections = [...sectionsMap.entries()]
232
- .map(([name, section]) => ({
233
- name,
234
- order: injectOrder.has(name) ? injectOrder.get(name) : (typeof section.order === 'number' ? section.order : 0),
235
- text: resolveText(section),
236
- active: !denied.has(name),
237
- replaced: Object.hasOwn(replace, name),
238
- }))
239
- .sort((a, b) => a.order - b.order)
240
-
241
- let tools = []
242
- // Enumerate tools from the resolved scope, falling back to the global layer
243
- // when the scope key was unavailable (e.g. the standing mount timed out), so
244
- // the catalog is never empty just because agent composition was not ready.
245
- try {
246
- const byName = new Map()
247
- for (const scope of [scopeKey, undefined]) {
248
- let schemas = []
249
- try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
250
- for (const tool of schemas) {
251
- if (tool.name && tool.name !== undefined && !byName.has(tool.name)) byName.set(tool.name, tool)
252
- }
253
- if (byName.size > 0) break
254
- }
255
- tools = [...byName.values()]
256
- .map((tool) => ({
257
- name: tool.name,
258
- description: typeof tool.description === 'string' ? tool.description : '',
259
- hidden: include ? !include.has(tool.name) : exclude.has(tool.name),
260
- }))
261
- .sort((a, b) => a.name.localeCompare(b.name))
262
- } catch { /* tools registry unresolved — return empty list */ }
263
-
264
- return { sections, tools, config: cfg }
265
- }
266
-
267
- /**
268
- * Resolve the scope key a preview request targets. An explicit `?scope=<id>`
269
- * requests that agent preset's standing scope; otherwise fall back to the
270
- * `standard` agent (same default the inventory uses). Always resolves within
271
- * the same short timeout so a slow standing mount cannot hang the request.
272
- */
273
- async function previewScope(ctx, req) {
274
- const id = req.query && req.query.scope
275
- if (typeof id === 'string' && id.length > 0) {
276
- try {
277
- const presets = ctx.get('agentPresets')
278
- if (presets && typeof presets.standingKeyFor === 'function') {
279
- const key = await withTimeout(presets.standingKeyFor(id), 1500, undefined)
280
- if (key) return key
281
- }
282
- } catch { /* unknown preset — fall back to the standard agent */ }
283
- }
284
- return resolveStandardScope(ctx)
285
- }
286
-
287
- /** List every agent preset for the per-agent editor UI. */
288
- async function listAgents(ctx) {
289
- try {
290
- const presets = ctx.get('agentPresets')
291
- if (!presets || typeof presets.list !== 'function') return { ok: true, agents: [], defaultAgent: undefined }
292
- const list = await withTimeout(presets.list(), 1500, [])
293
- const agents = (list ?? []).map((preset) => ({
294
- id: preset.id,
295
- name: typeof preset.name === 'string' && preset.name.length > 0 ? preset.name : preset.id,
296
- description: typeof preset.description === 'string' ? preset.description : '',
297
- order: typeof preset.order === 'number' ? preset.order : 0,
298
- }))
299
- return { ok: true, agents, defaultAgent: typeof presets.defaultId === 'string' ? presets.defaultId : undefined }
300
- } catch (error) {
301
- return { ok: false, error: String(error && error.message ? error.message : error), agents: [] }
302
- }
303
- }
304
-
305
- /**
306
- * Render the FINAL system prompt for a scope: run the full assemble waterfall
307
- * (so every plugin's filter, including our own, is applied), then render the
308
- * surviving sections to text. This is exactly what the model would see.
309
- */
310
- async function buildPreview(ctx, scopeKey) {
311
- const assembled = await ctx.systemPrompt.assemble({ scope: scopeKey })
312
- const sections = Array.isArray(assembled.sections)
313
- ? assembled.sections.map((section) => ({ name: section.name, text: String(section.text ?? '') }))
314
- : []
315
- return {
316
- ok: true,
317
- scope: scopeKey,
318
- sections,
319
- text: renderPreviewText(assembled),
320
- tools: Array.isArray(assembled.tools)
321
- ? assembled.tools.map((tool) => ({
322
- name: typeof tool.name === 'string' ? tool.name : String(tool.name),
323
- description: typeof tool.description === 'string' ? tool.description : '',
324
- }))
325
- : [],
326
- }
327
- }
328
-
329
- /** Join the surviving sections into the final prompt text (like dsh's renderPrompt). */
330
- function renderPreviewText(assembled) {
331
- return (Array.isArray(assembled.sections) ? assembled.sections : [])
332
- .map((section) => String(section.text ?? '').trim())
333
- .filter((text) => text.length > 0)
334
- .join('\n\n')
335
- }
336
-
337
- function writeJson(res, status, body) {
338
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
339
- res.end(JSON.stringify(body))
1
+ /**
2
+ * dsh-prompt-customizer — host half.
3
+ *
4
+ * - Registers the `prompt-customizer` settings namespace (live-applied).
5
+ * - Hooks the `system-prompt/assemble` waterfall to block / replace / inject
6
+ * prompt sections and to hide tools from the model catalog by name. Only the
7
+ * model-facing list is affected; tools and routes keep working.
8
+ * - Serves `GET /api/prompt-customizer/inventory` returning the effective
9
+ * section and tool lists (for the `standard` agent when a roster exists),
10
+ * each marked with whether it is currently blocked / hidden.
11
+ */
12
+
13
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
14
+ import { Config, NS } from './schema.js'
15
+
16
+ export const name = 'prompt-customizer'
17
+ export const inject = ['settings', 'systemPrompt', 'tools']
18
+
19
+ export function apply(ctx, entry = {}) {
20
+ // 1) settings namespace: base = composition entry, live-applied.
21
+ const scope = ctx.settings.register(settingsNamespace(NS), Config, {
22
+ base: entry,
23
+ applies: 'live',
24
+ })
25
+ const read = () => scope.get() ?? {}
26
+
27
+ // 2) assemble waterfall: apply filter/replace/inject live.
28
+ ctx.on('system-prompt/assemble', async (assembly, context, next) => {
29
+ const assembled = await next()
30
+ const cfg = read()
31
+ const denied = new Set(cfg.sections ?? [])
32
+ const replace = cfg.replace ?? {}
33
+ const injectList = cfg.inject ?? []
34
+ const te = cfg.tools ?? {}
35
+ const exclude = new Set(te.exclude ?? [])
36
+ const include = Array.isArray(te.include) && te.include.length > 0 ? new Set(te.include) : null
37
+
38
+ let sections = assembled.sections
39
+ .filter((section) => !denied.has(section.name))
40
+ .map((section) =>
41
+ Object.hasOwn(replace, section.name) ? { ...section, text: replace[section.name] } : section,
42
+ )
43
+ for (const item of injectList) {
44
+ // Skip sections that are blocked — they were already filtered out above.
45
+ if (denied.has(item.name)) continue
46
+ const index = sections.findIndex((section) => section.name === item.name)
47
+ if (index >= 0) {
48
+ // Existing section: apply the order override to control splice order.
49
+ // Preserve any replacement text set above; only use item.text when no
50
+ // replacement exists and the user actually provided inject text.
51
+ const finalText = Object.hasOwn(replace, item.name)
52
+ ? sections[index].text
53
+ : (item.text ? item.text : sections[index].text)
54
+ sections[index] = { ...sections[index], text: finalText, order: item.order }
55
+ } else {
56
+ sections.push({ name: item.name, order: item.order, text: Object.hasOwn(replace, item.name) ? replace[item.name] : item.text })
57
+ }
58
+ }
59
+ sections.sort((a, b) => {
60
+ // If injectList is non-empty, use it as the authoritative order.
61
+ // Sections in injectList come first (in injectList order), then remaining
62
+ // sections sorted by their original order.
63
+ if (injectList.length > 0) {
64
+ const aIdx = injectList.findIndex((item) => item.name === a.name)
65
+ const bIdx = injectList.findIndex((item) => item.name === b.name)
66
+ if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx
67
+ if (aIdx >= 0) return -1
68
+ if (bIdx >= 0) return 1
69
+ }
70
+ return a.order - b.order
71
+ })
72
+
73
+ let tools = assembled.tools
74
+ if (include) tools = tools.filter((tool) => include.has(tool.name))
75
+ else if (exclude.size > 0) tools = tools.filter((tool) => !exclude.has(tool.name))
76
+
77
+ return { ...assembled, sections, tools }
78
+ })
79
+
80
+ // 3) inventory route on the shared webserver (present in every web profile).
81
+ // Use ctx.get() so this stays optional — accessing ctx.webServer without
82
+ // declaring it in `inject` would throw "cannot get property without inject".
83
+ const webserver = ctx.get('webServer')
84
+ if (webserver) {
85
+ const dispose = webserver.register({
86
+ kind: 'exact',
87
+ path: '/api/prompt-customizer/inventory',
88
+ handler: async (_req, res) => {
89
+ try {
90
+ writeJson(res, 200, await buildInventory(ctx, read()))
91
+ } catch (error) {
92
+ writeJson(res, 500, {
93
+ ok: false,
94
+ error: String(error && error.message ? error.message : error),
95
+ })
96
+ }
97
+ },
98
+ })
99
+ ctx.effect(() => dispose, 'prompt-customizer: inventory route')
100
+
101
+ // Preview route: render the FINAL assembled system prompt (after every
102
+ // plugin, including our own block/replace/inject filters) so the user can
103
+ // confirm no injected section from other plugins pollutes it.
104
+ // Optional `?scope=<agentPresetId>` previews a specific agent preset's
105
+ // assembly; omitted, it uses the standard agent (same as the inventory).
106
+ const disposePreview = webserver.register({
107
+ kind: 'exact',
108
+ path: '/api/prompt-customizer/preview',
109
+ handler: async (req, res) => {
110
+ try {
111
+ const scopeKey = await previewScope(ctx, req)
112
+ writeJson(res, 200, await buildPreview(ctx, scopeKey))
113
+ } catch (error) {
114
+ writeJson(res, 500, {
115
+ ok: false,
116
+ error: String(error && error.message ? error.message : error),
117
+ })
118
+ }
119
+ },
120
+ })
121
+ ctx.effect(() => disposePreview, 'prompt-customizer: preview route')
122
+ }
123
+ }
124
+
125
+ /** Resolve `value` but never wait longer than `ms`; fall back to `fallback`. */
126
+ function withTimeout(value, ms, fallback) {
127
+ return Promise.race([
128
+ Promise.resolve(value),
129
+ new Promise((resolve) => setTimeout(() => resolve(fallback), ms)),
130
+ ])
131
+ }
132
+
133
+ /**
134
+ * Resolve the `standard` agent's standing scope key when a roster is present.
135
+ *
136
+ * `standingKeyFor` mounts a whole agent preset (compose plugins) which can be
137
+ * slow or depend on services that are not ready yet — for a read-only inventory
138
+ * browse we must never let that hang the request. If it does not settle within
139
+ * a short budget we fall back to the global layer (scopeKey = undefined).
140
+ */
141
+ async function resolveStandardScope(ctx) {
142
+ try {
143
+ const presets = ctx.get('agentPresets')
144
+ if (presets && typeof presets.standingKeyFor === 'function') {
145
+ return await withTimeout(presets.standingKeyFor('standard'), 1500, undefined)
146
+ }
147
+ } catch { /* no roster — fall back to the global layer */ }
148
+ return undefined
149
+ }
150
+
151
+ /** Enumerate the effective sections and tools with their blocked/hidden flags. */
152
+ async function buildInventory(ctx, cfg) {
153
+ const denied = new Set(cfg.sections ?? [])
154
+ const replace = cfg.replace ?? {}
155
+ const injectList = cfg.inject ?? []
156
+ const injectOrder = new Map(injectList.map((item) => [item.name, item.order]))
157
+ const te = cfg.tools ?? {}
158
+ const exclude = new Set(te.exclude ?? [])
159
+ const include = Array.isArray(te.include) && te.include.length > 0 ? new Set(te.include) : null
160
+
161
+ const scopeKey = await resolveStandardScope(ctx)
162
+
163
+ // Resolve a section's text. Dynamic sections register a function; call it
164
+ // with a best-effort context (same shape the assemble passes) so the
165
+ // inventory can echo the real generated content for replacement. Fall back
166
+ // to a marker if the function throws or returns a pending value.
167
+ const resolveText = (section) => {
168
+ if (typeof section.text === 'function') {
169
+ try {
170
+ const value = section.text({ scope: scopeKey })
171
+ // Async dynamic sections return a thenable we cannot echo synchronously.
172
+ if (value && typeof value.then === 'function') return '<动态生成>'
173
+ return String(value ?? '')
174
+ } catch { return '<动态生成>' }
175
+ }
176
+ return String(section.text ?? '')
177
+ }
178
+
179
+ const sectionsMap = ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections)
180
+ const sections = [...sectionsMap.entries()]
181
+ .map(([name, section]) => ({
182
+ name,
183
+ order: injectOrder.has(name) ? injectOrder.get(name) : (typeof section.order === 'number' ? section.order : 0),
184
+ text: resolveText(section),
185
+ active: !denied.has(name),
186
+ replaced: Object.hasOwn(replace, name),
187
+ }))
188
+ .sort((a, b) => a.order - b.order)
189
+
190
+ let tools = []
191
+ // Enumerate tools from the resolved scope, falling back to the global layer
192
+ // when the scope key was unavailable (e.g. the standing mount timed out), so
193
+ // the catalog is never empty just because agent composition was not ready.
194
+ try {
195
+ const byName = new Map()
196
+ for (const scope of [scopeKey, undefined]) {
197
+ let schemas = []
198
+ try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
199
+ for (const tool of schemas) {
200
+ if (tool.name && tool.name !== undefined && !byName.has(tool.name)) byName.set(tool.name, tool)
201
+ }
202
+ if (byName.size > 0) break
203
+ }
204
+ tools = [...byName.values()]
205
+ .map((tool) => ({
206
+ name: tool.name,
207
+ description: typeof tool.description === 'string' ? tool.description : '',
208
+ hidden: include ? !include.has(tool.name) : exclude.has(tool.name),
209
+ }))
210
+ .sort((a, b) => a.name.localeCompare(b.name))
211
+ } catch { /* tools registry unresolved — return empty list */ }
212
+
213
+ return { sections, tools, config: cfg }
214
+ }
215
+
216
+ /**
217
+ * Resolve the scope key a preview request targets. An explicit `?scope=<id>`
218
+ * requests that agent preset's standing scope; otherwise fall back to the
219
+ * `standard` agent (same default the inventory uses). Always resolves within
220
+ * the same short timeout so a slow standing mount cannot hang the request.
221
+ */
222
+ async function previewScope(ctx, req) {
223
+ const id = req.query && req.query.scope
224
+ if (typeof id === 'string' && id.length > 0) {
225
+ try {
226
+ const presets = ctx.get('agentPresets')
227
+ if (presets && typeof presets.standingKeyFor === 'function') {
228
+ const key = await withTimeout(presets.standingKeyFor(id), 1500, undefined)
229
+ if (key) return key
230
+ }
231
+ } catch { /* unknown preset — fall back to the standard agent */ }
232
+ }
233
+ return resolveStandardScope(ctx)
234
+ }
235
+
236
+ /**
237
+ * Render the FINAL system prompt for a scope: run the full assemble waterfall
238
+ * (so every plugin's filter, including our own, is applied), then render the
239
+ * surviving sections to text. This is exactly what the model would see.
240
+ */
241
+ async function buildPreview(ctx, scopeKey) {
242
+ const assembled = await ctx.systemPrompt.assemble({ scope: scopeKey })
243
+ const sections = Array.isArray(assembled.sections)
244
+ ? assembled.sections.map((section) => ({ name: section.name, text: String(section.text ?? '') }))
245
+ : []
246
+ return {
247
+ ok: true,
248
+ scope: scopeKey,
249
+ sections,
250
+ text: renderPreviewText(assembled),
251
+ tools: Array.isArray(assembled.tools)
252
+ ? assembled.tools.map((tool) => ({
253
+ name: typeof tool.name === 'string' ? tool.name : String(tool.name),
254
+ description: typeof tool.description === 'string' ? tool.description : '',
255
+ }))
256
+ : [],
257
+ }
258
+ }
259
+
260
+ /** Join the surviving sections into the final prompt text (like dsh's renderPrompt). */
261
+ function renderPreviewText(assembled) {
262
+ return (Array.isArray(assembled.sections) ? assembled.sections : [])
263
+ .map((section) => String(section.text ?? '').trim())
264
+ .filter((text) => text.length > 0)
265
+ .join('\n\n')
266
+ }
267
+
268
+ function writeJson(res, status, body) {
269
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
270
+ res.end(JSON.stringify(body))
340
271
  }