dsh-prompt-customizer 0.2.6 → 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,255 +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.
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
- const index = sections.findIndex((section) => section.name === item.name)
45
- if (index >= 0) {
46
- // Existing section: apply the order override to control splice order.
47
- // Only override text when the user actually provided replacement text.
48
- sections[index] = item.text
49
- ? { ...sections[index], text: item.text, order: item.order }
50
- : { ...sections[index], order: item.order }
51
- } else {
52
- sections.push({ name: item.name, order: item.order, text: item.text })
53
- }
54
- }
55
- sections.sort((a, b) => a.order - b.order)
56
-
57
- let tools = assembled.tools
58
- if (include) tools = tools.filter((tool) => include.has(tool.name))
59
- else if (exclude.size > 0) tools = tools.filter((tool) => !exclude.has(tool.name))
60
-
61
- return { ...assembled, sections, tools }
62
- })
63
-
64
- // 3) inventory route on the shared webserver (present in every web profile).
65
- // Use ctx.get() so this stays optional — accessing ctx.webServer without
66
- // declaring it in `inject` would throw "cannot get property without inject".
67
- const webserver = ctx.get('webServer')
68
- if (webserver) {
69
- const dispose = webserver.register({
70
- kind: 'exact',
71
- path: '/api/prompt-customizer/inventory',
72
- handler: async (_req, res) => {
73
- try {
74
- writeJson(res, 200, await buildInventory(ctx, read()))
75
- } catch (error) {
76
- writeJson(res, 500, {
77
- ok: false,
78
- error: String(error && error.message ? error.message : error),
79
- })
80
- }
81
- },
82
- })
83
- ctx.effect(() => dispose, 'prompt-customizer: inventory route')
84
-
85
- // Preview route: render the FINAL assembled system prompt (after every
86
- // plugin, including our own block/replace/inject filters) so the user can
87
- // confirm no injected section from other plugins pollutes it.
88
- // Optional `?scope=<agentPresetId>` previews a specific agent preset's
89
- // assembly; omitted, it uses the standard agent (same as the inventory).
90
- const disposePreview = webserver.register({
91
- kind: 'exact',
92
- path: '/api/prompt-customizer/preview',
93
- handler: async (req, res) => {
94
- try {
95
- const scopeKey = await previewScope(ctx, req)
96
- writeJson(res, 200, await buildPreview(ctx, scopeKey))
97
- } catch (error) {
98
- writeJson(res, 500, {
99
- ok: false,
100
- error: String(error && error.message ? error.message : error),
101
- })
102
- }
103
- },
104
- })
105
- ctx.effect(() => disposePreview, 'prompt-customizer: preview route')
106
- }
107
- }
108
-
109
- /** Resolve `value` but never wait longer than `ms`; fall back to `fallback`. */
110
- function withTimeout(value, ms, fallback) {
111
- return Promise.race([
112
- Promise.resolve(value),
113
- new Promise((resolve) => setTimeout(() => resolve(fallback), ms)),
114
- ])
115
- }
116
-
117
- /**
118
- * Resolve the `standard` agent's standing scope key when a roster is present.
119
- *
120
- * `standingKeyFor` mounts a whole agent preset (compose plugins) which can be
121
- * slow or depend on services that are not ready yet — for a read-only inventory
122
- * browse we must never let that hang the request. If it does not settle within
123
- * a short budget we fall back to the global layer (scopeKey = undefined).
124
- */
125
- async function resolveStandardScope(ctx) {
126
- try {
127
- const presets = ctx.get('agentPresets')
128
- if (presets && typeof presets.standingKeyFor === 'function') {
129
- return await withTimeout(presets.standingKeyFor('standard'), 1500, undefined)
130
- }
131
- } catch { /* no roster — fall back to the global layer */ }
132
- return undefined
133
- }
134
-
135
- /** Enumerate the effective sections and tools with their blocked/hidden flags. */
136
- async function buildInventory(ctx, cfg) {
137
- const denied = new Set(cfg.sections ?? [])
138
- const replace = cfg.replace ?? {}
139
- const injectList = cfg.inject ?? []
140
- const injectOrder = new Map(injectList.map((item) => [item.name, item.order]))
141
- const te = cfg.tools ?? {}
142
- const exclude = new Set(te.exclude ?? [])
143
- const include = Array.isArray(te.include) && te.include.length > 0 ? new Set(te.include) : null
144
-
145
- const scopeKey = await resolveStandardScope(ctx)
146
-
147
- // Resolve a section's text. Dynamic sections register a function; call it
148
- // with a best-effort context (same shape the assemble passes) so the
149
- // inventory can echo the real generated content for replacement. Fall back
150
- // to a marker if the function throws or returns a pending value.
151
- const resolveText = (section) => {
152
- if (typeof section.text === 'function') {
153
- try {
154
- const value = section.text({ scope: scopeKey })
155
- // Async dynamic sections return a thenable we cannot echo synchronously.
156
- if (value && typeof value.then === 'function') return '<动态生成>'
157
- return String(value ?? '')
158
- } catch { return '<动态生成>' }
159
- }
160
- return String(section.text ?? '')
161
- }
162
-
163
- const sectionsMap = ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections)
164
- const sections = [...sectionsMap.entries()]
165
- .map(([name, section]) => ({
166
- name,
167
- order: injectOrder.has(name) ? injectOrder.get(name) : (typeof section.order === 'number' ? section.order : 0),
168
- text: resolveText(section),
169
- active: !denied.has(name),
170
- replaced: Object.hasOwn(replace, name),
171
- }))
172
- .sort((a, b) => a.order - b.order)
173
-
174
- let tools = []
175
- // Enumerate tools from the resolved scope, falling back to the global layer
176
- // when the scope key was unavailable (e.g. the standing mount timed out), so
177
- // the catalog is never empty just because agent composition was not ready.
178
- try {
179
- const byName = new Map()
180
- for (const scope of [scopeKey, undefined]) {
181
- let schemas = []
182
- try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
183
- for (const tool of schemas) {
184
- if (tool.name && tool.name !== undefined && !byName.has(tool.name)) byName.set(tool.name, tool)
185
- }
186
- if (byName.size > 0) break
187
- }
188
- tools = [...byName.values()]
189
- .map((tool) => ({
190
- name: tool.name,
191
- description: typeof tool.description === 'string' ? tool.description : '',
192
- hidden: include ? !include.has(tool.name) : exclude.has(tool.name),
193
- }))
194
- .sort((a, b) => a.name.localeCompare(b.name))
195
- } catch { /* tools registry unresolved — return empty list */ }
196
-
197
- return { sections, tools, config: cfg }
198
- }
199
-
200
- /**
201
- * Resolve the scope key a preview request targets. An explicit `?scope=<id>`
202
- * requests that agent preset's standing scope; otherwise fall back to the
203
- * `standard` agent (same default the inventory uses). Always resolves within
204
- * the same short timeout so a slow standing mount cannot hang the request.
205
- */
206
- async function previewScope(ctx, req) {
207
- const id = req.query && req.query.scope
208
- if (typeof id === 'string' && id.length > 0) {
209
- try {
210
- const presets = ctx.get('agentPresets')
211
- if (presets && typeof presets.standingKeyFor === 'function') {
212
- const key = await withTimeout(presets.standingKeyFor(id), 1500, undefined)
213
- if (key) return key
214
- }
215
- } catch { /* unknown preset — fall back to the standard agent */ }
216
- }
217
- return resolveStandardScope(ctx)
218
- }
219
-
220
- /**
221
- * Render the FINAL system prompt for a scope: run the full assemble waterfall
222
- * (so every plugin's filter, including our own, is applied), then render the
223
- * surviving sections to text. This is exactly what the model would see.
224
- */
225
- async function buildPreview(ctx, scopeKey) {
226
- const assembled = await ctx.systemPrompt.assemble({ scope: scopeKey })
227
- const sections = Array.isArray(assembled.sections)
228
- ? assembled.sections.map((section) => ({ name: section.name, text: String(section.text ?? '') }))
229
- : []
230
- return {
231
- ok: true,
232
- scope: scopeKey,
233
- sections,
234
- text: renderPreviewText(assembled),
235
- tools: Array.isArray(assembled.tools)
236
- ? assembled.tools.map((tool) => ({
237
- name: typeof tool.name === 'string' ? tool.name : String(tool.name),
238
- description: typeof tool.description === 'string' ? tool.description : '',
239
- }))
240
- : [],
241
- }
242
- }
243
-
244
- /** Join the surviving sections into the final prompt text (like dsh's renderPrompt). */
245
- function renderPreviewText(assembled) {
246
- return (Array.isArray(assembled.sections) ? assembled.sections : [])
247
- .map((section) => String(section.text ?? '').trim())
248
- .filter((text) => text.length > 0)
249
- .join('\n\n')
250
- }
251
-
252
- function writeJson(res, status, body) {
253
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
254
- 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))
255
271
  }