dsh-structured 0.9.4 → 0.9.9

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,517 +1,518 @@
1
- // Generated from src/index.js by scripts/build.mjs — edit src/, then run `npm run build`.
2
- // dsh-structured host plugin: systemPrompt section + per-session switch + /structured command.
3
- /**
4
- * dsh-structured — 让每条实质回复默认结构化:首句给结论,内容用表格 / 清单 / 步骤排出来。
5
- *
6
- * 通道(刻意不做 agent/pre-step、不 append 消息、不碰工具权限与 agent 循环):
7
- * 1) systemPrompt.section:text 为函数,每次组装时按「当前会话」的开关求值;关闭返回空串,
8
- * 渲染层自动丢弃空 section,因此对 prompt 的增删即时生效。
9
- * 2) 会话级状态:$DSH_HOME/dsh-structured/state.json,tmp + rename 原子写,跨重启保留。
10
- * 3) host 命令 `/structured on|off|status`:作用于当前会话。
11
- * 4) 规则覆盖:$DSH_HOME/dsh-structured/style.md 存在且非空时整份替换内置文本(按 mtime+size 缓存)。
12
- *
13
- * fail-open 是红线:section 求值、style 读取、状态读写、命令处理中的任何异常都不得让回合失败,
14
- * 最坏情况只是本轮不注入(返回空串 / 返回错误提示文本)。
15
- */
16
-
17
- import {
18
- mkdirSync,
19
- readFileSync,
20
- renameSync,
21
- rmSync,
22
- statSync,
23
- writeFileSync,
24
- } from 'node:fs'
25
- import { homedir } from 'node:os'
26
- import { dirname, join } from 'node:path'
27
- import Schema from '@deepseek-ai/schemastery'
28
-
29
- /** Cordis 插件名。 */
30
- export const name = 'dsh-structured'
31
-
32
- /** 必需服务:系统提示词注册表。commands 属于可选能力(见 apply 内的 ctx.inject),
33
- * 缺失时只降级 /structured 命令,不牺牲注入——注入才是本插件的核心。 */
34
- export const inject = ['systemPrompt']
35
-
36
- /** 主 section 名(带插件前缀,避免与其他 section 重名)。 */
37
- export const SECTION_NAME = 'dsh-structured:style'
38
-
39
- /** 尾部提醒 section 名:放在 system prompt 末尾,对冲长上下文下的遵循衰减。 */
40
- export const REMINDER_NAME = 'dsh-structured:reminder'
41
-
42
- /** 主 section 兜底顺序:与第一方 STRUCTURED_OUTPUT 槽位对齐(本机实测 9900)。 */
43
- const SECTION_ORDER_FALLBACK = 9900
44
-
45
- /** 尾部提醒顺序:排在 harness source(10000)、web surface(10100)、persona suffix(10200)之后。 */
46
- const REMINDER_ORDER = 10300
47
-
48
- /** 会话条目上限:按最近使用淘汰,防止 state.json 无界增长。 */
49
- export const MAX_SESSION_ENTRIES = 500
50
-
51
- /** 把字段标记为 volatile,让 DSH 设置表单可以实时读写。 */
52
- const k = (x) => (typeof x.volatile === 'function' ? x.volatile() : x)
53
-
54
- /** volatile 字段在 host 侧是可读信号;普通字段原样返回。 */
55
- const readConfig = (x) => (x && typeof x.get === 'function' ? x.get() : x)
56
-
57
- /** 主题白名单:host 与 client 共用同一组值。 */
58
- export const THEMES = ['deepseek', 'codex', 'anthropic']
59
-
60
- /**
61
- * DSH 插件配置 schema:设置表单据此渲染,并持久化到 active profile 的 Cordis patch。
62
- * 不把主题/视觉写进 state.json,避免 structured-test 与 desktop 共享同一状态文件。
63
- */
64
- export const Config = Schema.object({
65
- theme: k(Schema.union(THEMES).default('deepseek').description('主题 / Theme: deepseek | codex | anthropic').loose()),
66
- visual: k(Schema.boolean().default(true).description('视觉层 / Visual layer: 关闭后不加载回复区样式')),
67
- defaultEnabled: k(Schema.boolean().default(true).description('新会话默认开启 / Default on for new sessions')),
68
- tailReminder: k(Schema.boolean().default(true).description('尾部提醒 / Tail reminder: 每轮追加一句结构化提醒')),
69
- includeSubagents: k(Schema.boolean().default(false).description('子代理注入 / Include subagents: 默认关闭')),
70
- genui: k(Schema.union(['auto', 'on', 'off']).default('auto').description('GenUI 联动 / GenUI routing: auto | on | off').loose()),
71
- })
72
-
73
- /**
74
- * 内置规则文本:中文,与 `~/.dsh/AGENTS.md` 第 7 节(响应格式)同源,只规定形状、不另立第二套写作规则。
75
- * 逐条对应:首句结论(结论卡)、组件化正文、禁过程叙事与复述、结构化不等于变长、支撑信息带证据、
76
- * 以及「全文最多两个强调块」的视觉热点约束。
77
- */
78
- export const BUILTIN_STYLE_TEXT = [
79
- '## 结构化输出(本会话已启用,硬约束)',
80
- '',
81
- '面向用户的实质回复按下面的形状组织。这是硬约束,不是风格建议。',
82
- '',
83
- '1. **首句给结论,并把它做成卡片**:第一段固定两行——`> **一句标题**`(加粗放大,≤60 个汉字或一句短句)→ `> 1–2 行正文`;标题即结论,细节放正文,不要写「结论」标签,不要再用普通段落重复。',
84
- '2. **按信息类型选组件**:3 条以上要点用清单或表格;每条实质回复 1 个主卡(首段标题无标签);判断/指标/重点卡用 `> **判断**` / `> **指标**` / `> **重点**`;最多再 1 个行动卡(`风险` / `下一步` / `验收`);表格前先给一行 `**表格结论:** ...`;状态行/状态单元格用 `✅ 完成` / `⚠️ 待做` / `❌ 阻塞` 开头;表格优先 ≤5 行;信息确实多时拆表或使用宿主支持的大表,不硬砍内容;不为了格式加内容,也不为了字数删信息;短答不套卡,其余保持中性。',
85
- '3. **一段一个主题**:同一信息只出现一次,不要正文和组件各说一遍;不写过程叙事(我先…/已经…/接下来…),不写收尾总结,不加「还需要我…吗?」。',
86
- '4. **清晰优先,不设硬性篇幅上限**:组件是帮读者快速定位结论的工具,不是加信息的借口;短答不套卡,长答按信息量组织;判断标准是读者只看首段标题 + 表格首列/列表首句,就能知道结论和下一步;细节写文件,回复保留结论与路径。',
87
- '5. **支撑信息带证据**:每条支撑尽量带数字、路径或结果;没有证据的判断明确标成推断。',
88
- '6. **可复制内容用代码块**:提示词、命令、配置、JSON、SQL 等需要复制的文本,用带语言标签的代码块包裹(如 `text` / `powershell`),不要用引用块或普通段落;代码块右上角有复制按钮。',
89
- '7. **代码/补丁例外**:当本轮主要是代码、代码 diff、文件修改或补丁时,代码块或 diff 卡可以直接作为第一块,不强制标题卡;不要为了结构化加标题卡、表格或「下一步」卡;仍不写过程叙事。',
90
- ].join('\n')
91
-
92
- /** GenUI 已安装时才追加的路由规则:交互/图表走 dsh-ui,普通卡片仍走 markdown。 */
93
- export const GENUI_ROUTING_TEXT = [
94
- '**GenUI 已安装**:只有图表、可排序表格、表单、超过 8 行的大表才用 ` ```dsh-ui ` fence;`dsh-ui` 只能出现在 markdown 标题卡与正文之后,**不得用它的 title 充当首卡,不得把结论/判断/风险/下一步做成 dsh-ui 组件**;宿主默认要出 UI 时以本节为准,不要为每条回复生成 JSON,也不要重复渲染同一内容。',
95
- ].join('\n')
96
-
97
- /** 尾部提醒:一句话重复契约中最容易衰减的四件事,长度控制在几十个 token。 */
98
- export const TAIL_REMINDER_TEXT = [
99
- '提醒:本轮最终回复第一段必须是 markdown 无标签标题卡(代码/diff 回复例外:代码块或 diff 卡可直接作首块,不强制标题卡):加粗标题(≤60 字)/ 正文两行(即使后文要用组件也一样);组件只能排在标题卡之后,不得用组件充当首卡;表格前给 `**表格结论:**`;状态用 `✅ 完成` / `⚠️ 待做`;全文最多两个强调块;表格优先 ≤5 行;不设硬性篇幅上限;清晰优先;可复制内容用代码块。',
100
- ].join('\n')
101
-
102
- /** DSH home:优先 DSH_HOME,缺省 ~/.dsh。 */
103
- export function dshHome() {
104
- return process.env.DSH_HOME || join(homedir(), '.dsh')
105
- }
106
-
107
- /** 插件状态目录:$DSH_HOME/dsh-structured。 */
108
- export function pluginDir() {
109
- return join(dshHome(), name)
110
- }
111
-
112
- /**
113
- * 检测 dsh-genui 是否已安装(软联动,不硬依赖)。
114
- * 只信 config 显式 on/off 与 host 的 clientModules 实时图;不读可能被继承污染的 profile env。
115
- * 任何读取失败都按“未安装”处理,绝不阻断注入。
116
- * @param {{ genui?: 'auto' | 'on' | 'off' }} [config]
117
- * @param {import('@deepseek-ai/cordis').Context} [ctx] 可选 host 上下文:查 clientModules 实时图。
118
- * @returns {boolean}
119
- */
120
- export function detectGenui(config = {}, ctx) {
121
- if (config?.genui === 'on') return true
122
- if (config?.genui === 'off') return false
123
- try {
124
- const modules = ctx?.get?.('clientModules')
125
- if (modules && typeof modules.clientPath === 'function') {
126
- for (const id of ['genui', '@changfenhuang/dsh-genui', 'dsh-genui']) {
127
- if (modules.clientPath(id)) return true
128
- }
129
- }
130
- if (modules && typeof modules.graph === 'function') {
131
- const graph = modules.graph()
132
- if (Array.isArray(graph?.entries) && graph.entries.some((entry) => String(entry?.id ?? '').includes('genui'))) return true
133
- }
134
- } catch {
135
- // clientModules 不可用(headless/装配早期):按未安装处理,避免误注入 dsh-ui。
136
- }
137
- return false
138
- }
139
-
140
- /** 状态文件:$DSH_HOME/dsh-structured/state.json。 */
141
- export function stateFile() {
142
- return join(pluginDir(), 'state.json')
143
- }
144
-
145
- /** 自定义规则文件:$DSH_HOME/dsh-structured/style.md。 */
146
- export function styleFile() {
147
- return join(pluginDir(), 'style.md')
148
- }
149
-
150
- /** style.md 的 mtime+size 缓存:文件未变时不重复读盘。 */
151
- let styleCache = { key: null, text: BUILTIN_STYLE_TEXT, source: 'built-in' }
152
-
153
- /**
154
- * 当前生效的规则正文与来源。任何读取异常都回落到内置文本——fail-open。
155
- * @returns {{ text: string, source: 'built-in' | 'custom' }}
156
- */
157
- export function activeStyle() {
158
- try {
159
- const stat = statSync(styleFile())
160
- const key = styleFile() + ':' + stat.mtimeMs + ':' + stat.size
161
- if (styleCache.key === key && styleCache.text !== null) return styleCache
162
- const raw = readFileSync(styleFile(), 'utf8').trim()
163
- styleCache = raw.length > 0
164
- ? { key, text: raw, source: 'custom' }
165
- : { key, text: BUILTIN_STYLE_TEXT, source: 'built-in' }
166
- return styleCache
167
- } catch {
168
- styleCache = { key: null, text: BUILTIN_STYLE_TEXT, source: 'built-in' }
169
- return styleCache
170
- }
171
- }
172
-
173
- /**
174
- * 从会话条目里读开关;显式记录优先,否则取新会话默认值。
175
- * @param {Record<string, { enabled: boolean, at: number }>} sessions
176
- * @param {boolean} defaultEnabled
177
- * @param {string | null} sessionId
178
- * @returns {boolean}
179
- */
180
- export function isEnabledFor(sessions, defaultEnabled, sessionId) {
181
- if (typeof sessionId !== 'string' || sessionId.length === 0) return defaultEnabled === true
182
- return sessions[sessionId]?.enabled ?? defaultEnabled === true
183
- }
184
-
185
- /**
186
- * 从模型组装上下文 / 命令调用里提取会话 id。
187
- * 实测:AssembleContext 带 `agent`(且 `scope` 就是 agent);CommandInvocation 直接带 `agent`。
188
- * @param {unknown} source
189
- * @returns {string | null}
190
- */
191
- export function sessionIdOf(source) {
192
- const agent = source?.agent ?? source?.scope
193
- const raw = agent?.session?.id ?? agent?.id
194
- return typeof raw === 'string' && raw.length > 0 && raw.length <= 512 ? raw : null
195
- }
196
-
197
- /**
198
- * 是否子代理会话:只认 origin/delegationDepth。
199
- * 注意:用户聊天 fork 出来的会话也有 parentSession,但不是子代理,不能跳过。
200
- * @param {unknown} source
201
- * @returns {boolean}
202
- */
203
- export function isSubagentContext(source) {
204
- const header = source?.agent?.session?.header ?? source?.scope?.session?.header
205
- if (!header || typeof header !== 'object') return false
206
- if (header.origin === 'subagent') return true
207
- return typeof header.delegationDepth === 'number' && header.delegationDepth > 0
208
- }
209
-
210
- /**
211
- * section 求值的唯一入口,所有异常在此吞掉并回落为空串——注入链路出错必须放行原请求。
212
- * @param {{
213
- * enabled: boolean,
214
- * eligible: boolean,
215
- * styleText: () => string,
216
- * onError?: (error: unknown) => void,
217
- * }} options
218
- * @returns {string}
219
- */
220
- export function safeSectionText(options) {
221
- try {
222
- if (!options.enabled || !options.eligible) return ''
223
- const text = options.styleText()
224
- return typeof text === 'string' ? text : ''
225
- } catch (error) {
226
- try {
227
- options.onError?.(error)
228
- } catch {
229
- // 日志本身失败也不能影响回合。
230
- }
231
- return ''
232
- }
233
- }
234
-
235
- /**
236
- * 读取状态文件。文件缺失、损坏、字段畸形时一律回落为空会话表(默认值仍由 config 决定)。
237
- * @returns {Record<string, { enabled: boolean, at: number }>}
238
- */
239
- export function loadSessions() {
240
- try {
241
- const raw = JSON.parse(readFileSync(stateFile(), 'utf8'))
242
- const sessions = {}
243
- for (const [sessionId, value] of Object.entries(raw?.sessions ?? {})) {
244
- if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 512) continue
245
- if (typeof value === 'boolean') {
246
- sessions[sessionId] = { enabled: value, at: 0 }
247
- continue
248
- }
249
- if (value && typeof value === 'object' && typeof value.enabled === 'boolean') {
250
- sessions[sessionId] = { enabled: value.enabled === true, at: Number(value.at) || 0 }
251
- }
252
- }
253
- return sessions
254
- } catch {
255
- return {}
256
- }
257
- }
258
-
259
- /**
260
- * 有界化会话表:先保住与默认值不同的显式翻转,再按最近使用补足。
261
- * @param {Record<string, { enabled: boolean, at: number }>} sessions
262
- * @param {boolean} defaultEnabled
263
- * @returns {Record<string, { enabled: boolean, at: number }>}
264
- */
265
- export function pruneSessions(sessions, defaultEnabled) {
266
- const entries = Object.entries(sessions ?? {})
267
- if (entries.length <= MAX_SESSION_ENTRIES) return sessions ?? {}
268
- const byRecent = (a, b) => (b[1]?.at || 0) - (a[1]?.at || 0)
269
- const explicit = entries.filter(([, value]) => value?.enabled !== defaultEnabled).sort(byRecent)
270
- const redundant = entries.filter(([, value]) => value?.enabled === defaultEnabled).sort(byRecent)
271
- return Object.fromEntries(explicit.concat(redundant).slice(0, MAX_SESSION_ENTRIES))
272
- }
273
-
274
- /**
275
- * 原子写状态:同目录 tmp 文件 + rename 覆盖;失败时清理 tmp 并把错误交回调用方。
276
- * @param {Record<string, { enabled: boolean, at: number }>} sessions
277
- * @param {boolean} defaultEnabled
278
- * @returns {{ ok: true, sessions: Record<string, { enabled: boolean, at: number }> } | { ok: false, sessions: Record<string, { enabled: boolean, at: number }>, error: unknown }}
279
- */
280
- export function saveSessions(sessions, defaultEnabled) {
281
- const pruned = pruneSessions(sessions, defaultEnabled)
282
- const file = stateFile()
283
- const tmp = file + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2)
284
- try {
285
- mkdirSync(dirname(file), { recursive: true })
286
- writeFileSync(tmp, JSON.stringify({ version: 1, sessions: pruned }, null, 2) + '\n', 'utf8')
287
- renameSync(tmp, file)
288
- return { ok: true, sessions: pruned }
289
- } catch (error) {
290
- try {
291
- rmSync(tmp, { force: true })
292
- } catch {
293
- // tmp 清理失败不改变结论。
294
- }
295
- return { ok: false, sessions, error }
296
- }
297
- }
298
-
299
- /**
300
- * 写入一个会话开关;持久化失败时仍保留进程内状态(下一轮立即生效),并把失败交回命令层提示。
301
- * @param {Record<string, { enabled: boolean, at: number }>} sessions
302
- * @param {boolean} defaultEnabled
303
- * @param {string} sessionId
304
- * @param {boolean} enabled
305
- */
306
- export function setEnabledFor(sessions, defaultEnabled, sessionId, enabled) {
307
- sessions[sessionId] = { enabled: enabled === true, at: Date.now() }
308
- return saveSessions(sessions, defaultEnabled)
309
- }
310
-
311
- /**
312
- * 生成 /structured 的状态文本(不含副作用)。
313
- * @param {{ sessionId: string | null, sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, genui?: boolean, visual?: boolean, theme?: string }} input
314
- * @returns {string}
315
- */
316
- export function statusText(input) {
317
- const style = activeStyle()
318
- const enabled = isEnabledFor(input.sessions, input.defaultEnabled, input.sessionId)
319
- const theme = THEMES.includes(input.theme) ? input.theme : 'deepseek'
320
- return [
321
- '结构化提示词:' + (enabled ? '已开启' : '已关闭') + '(本会话)',
322
- '新会话默认:' + (input.defaultEnabled ? '开启' : '关闭'),
323
- '规则来源:' + (style.source === 'custom' ? '自定义 ' + styleFile() : '内置(无标签标题卡 + 结构化组件)'),
324
- '主题:' + theme + '(由插件设置 theme 控制)',
325
- '视觉层:' + (input.visual === false ? '已关闭' : '已开启') + '(由插件设置 visual 控制;/structured 只控制提示词)',
326
- 'GenUI 联动:' + (input.genui ? '已检测(图表/交互走 dsh-ui,普通卡片走 fallback)' : '未检测(纯 markdown fallback)'),
327
- '状态文件:' + stateFile(),
328
- '用法:/structured on | off | status',
329
- ].join('\n')
330
- }
331
-
332
- /**
333
- * 处理 /structured 命令。handler 外层还会再兜一层,这里保持纯逻辑便于单测。
334
- * @param {{ rawInput?: unknown, agent?: { session?: { id?: unknown }, id?: unknown } }} invocation
335
- * @param {{ sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, onChange?: (sessionId: string, enabled: boolean, result: { ok: boolean, error?: unknown }) => void }} api
336
- * @returns {{ kind: 'success', text: string } | { kind: 'error', text: string }}
337
- */
338
- export function handleCommand(invocation, api) {
339
- const sessionId = sessionIdOf(invocation)
340
- const arg = String(invocation?.rawInput ?? '').trim().toLowerCase()
341
- if (arg === '' || arg === 'status') {
342
- return { kind: 'success', text: statusText({ sessionId, sessions: api.sessions, defaultEnabled: api.defaultEnabled, genui: api.genui, visual: api.visual, theme: api.theme }) }
343
- }
344
- if (arg !== 'on' && arg !== 'off') {
345
- return { kind: 'error', text: '未知参数:' + arg + '\n用法:/structured on | off | status' }
346
- }
347
- if (sessionId === null) {
348
- return { kind: 'error', text: '当前会话 id 不可用,未修改开关。' }
349
- }
350
- const enabled = arg === 'on'
351
- const result = setEnabledFor(api.sessions, api.defaultEnabled, sessionId, enabled)
352
- api.onChange?.(sessionId, enabled, result)
353
- const state = enabled ? '已开启' : '已关闭'
354
- if (result.ok) return { kind: 'success', text: state + '本会话的结构化输出(下一轮生效)。' }
355
- return {
356
- kind: 'success',
357
- text: state + '本会话的结构化输出(下一轮生效);状态写入失败,仅本进程生效:' + String(result.error),
358
- }
359
- }
360
-
361
- /**
362
- * 挂载结构化输出:提示词 section + 尾部提醒 + 会话开关 + /structured 命令。
363
- * @param {import('@deepseek-ai/cordis').Context} ctx host 根上下文。
364
- * @param {{ defaultEnabled?: boolean, tailReminder?: boolean, includeSubagents?: boolean, genui?: 'auto' | 'on' | 'off', visual?: boolean, theme?: 'deepseek' | 'codex' | 'anthropic' }} [config]
365
- */
366
- export function apply(ctx, config = {}) {
367
- const defaultEnabled = readConfig(config?.defaultEnabled) !== false
368
- const themeNow = () => {
369
- const theme = readConfig(config?.theme)
370
- return THEMES.includes(theme) ? theme : 'deepseek'
371
- }
372
- const visualNow = () => readConfig(config?.visual) !== false
373
- const tailReminder = readConfig(config?.tailReminder) !== false
374
- const includeSubagents = readConfig(config?.includeSubagents) === true
375
- const genuiConfig = { genui: readConfig(config?.genui) }
376
- const genui = detectGenui(genuiConfig, ctx)
377
- let genuiKnown = genui
378
- const genuiNow = () => {
379
- if (genuiKnown) return true
380
- genuiKnown = detectGenui(genuiConfig, ctx)
381
- return genuiKnown
382
- }
383
- const builtinStyleText = () => (genuiNow() ? BUILTIN_STYLE_TEXT + '\n\n' + GENUI_ROUTING_TEXT : BUILTIN_STYLE_TEXT)
384
- let sessions = pruneSessions(loadSessions(), defaultEnabled)
385
- const stateStampOf = () => {
386
- try {
387
- const stat = statSync(stateFile())
388
- return stat.mtimeMs + ':' + stat.size
389
- } catch {
390
- return 'missing'
391
- }
392
- }
393
- let stateStamp = stateStampOf()
394
- const refreshSessions = () => {
395
- const next = stateStampOf()
396
- if (next === stateStamp) return
397
- stateStamp = next
398
- try {
399
- sessions = pruneSessions(loadSessions(), defaultEnabled)
400
- } catch {
401
- // 外部状态读取失败时保留内存态,fail-open。
402
- }
403
- }
404
- const logger = ctx.logger ?? {}
405
- const warn = (message, error) => {
406
- try {
407
- logger.warn?.('[dsh-structured] ' + message, error)
408
- } catch {
409
- // 日志失败不影响插件行为。
410
- }
411
- }
412
-
413
- const sectionOrder = () => {
414
- try {
415
- const order = ctx.systemPrompt?.getSectionOrder?.('STRUCTURED_OUTPUT')
416
- return Number.isFinite(order) ? order : SECTION_ORDER_FALLBACK
417
- } catch {
418
- return SECTION_ORDER_FALLBACK
419
- }
420
- }
421
-
422
- const eligible = (context) => includeSubagents || !isSubagentContext(context)
423
- const enabledFor = (context) => {
424
- refreshSessions()
425
- return isEnabledFor(sessions, defaultEnabled, sessionIdOf(context))
426
- }
427
- const sectionText = (context, styleText, label) => {
428
- // 外层兜底也是 fail-open 的一部分:enabledFor / eligible 自身抛错时同样不能打断组装。
429
- try {
430
- return safeSectionText({
431
- enabled: enabledFor(context),
432
- eligible: eligible(context),
433
- styleText,
434
- onError: (error) => warn(label, error),
435
- })
436
- } catch (error) {
437
- warn(label + '(外层兜底)', error)
438
- return ''
439
- }
440
- }
441
-
442
- try {
443
- ctx.effect(
444
- () =>
445
- ctx.systemPrompt.section({
446
- name: SECTION_NAME,
447
- order: sectionOrder(),
448
- interpolate: false, // style.md 里的 {{...}} 不得触发严格插值而打断组装。
449
- text: (context) =>
450
- sectionText(
451
- context,
452
- () => {
453
- const style = activeStyle()
454
- return style.source === 'custom' ? style.text : builtinStyleText()
455
- },
456
- 'section 求值失败,已回退为空 section',
457
- ),
458
- }),
459
- 'dsh-structured: prompt section',
460
- )
461
- } catch (error) {
462
- warn('注册 system prompt section 失败,插件降级为不注入', error)
463
- }
464
-
465
- if (tailReminder) {
466
- try {
467
- ctx.effect(
468
- () =>
469
- ctx.systemPrompt.section({
470
- name: REMINDER_NAME,
471
- order: REMINDER_ORDER,
472
- interpolate: false,
473
- text: (context) =>
474
- sectionText(context, () => TAIL_REMINDER_TEXT, '尾部提醒求值失败,已回退为空 section'),
475
- }),
476
- 'dsh-structured: tail reminder',
477
- )
478
- } catch (error) {
479
- warn('注册尾部提醒失败,不影响主 section', error)
480
- }
481
- }
482
-
483
- try {
484
- ctx.effect(
485
- () =>
486
- ctx.commands.register({
487
- name: 'structured',
488
- description: '开关本会话的结构化提示词(视觉样式仍保留)',
489
- input: { hint: '[on|off|status]' },
490
- handler: (invocation) => {
491
- try {
492
- refreshSessions()
493
- return handleCommand(invocation, {
494
- sessions,
495
- defaultEnabled,
496
- genui: genuiNow(),
497
- visual: visualNow(),
498
- theme: themeNow(),
499
- onChange: (sessionId, enabled, result) => {
500
- warn(
501
- 'session ' + sessionId + ' structured ' + (enabled ? 'on' : 'off') + (result.ok ? '' : '(持久化失败)'),
502
- result.ok ? undefined : result.error,
503
- )
504
- },
505
- })
506
- } catch (error) {
507
- warn('/structured 处理失败', error)
508
- return { kind: 'error', text: '/structured 处理失败:' + String(error) }
509
- }
510
- },
511
- }),
512
- 'dsh-structured: /structured command',
513
- )
514
- } catch (error) {
515
- warn('注册 /structured 命令失败,开关不可用但注入不受影响', error)
516
- }
517
- }
1
+ // Generated from src/index.js by scripts/build.mjs — edit src/, then run `npm run build`.
2
+ // dsh-structured host plugin: systemPrompt section + per-session switch + /structured command.
3
+ /**
4
+ * dsh-structured — 让每条实质回复默认结构化:首句给结论,内容用表格 / 清单 / 步骤排出来。
5
+ *
6
+ * 通道(刻意不做 agent/pre-step、不 append 消息、不碰工具权限与 agent 循环):
7
+ * 1) systemPrompt.section:text 为函数,每次组装时按「当前会话」的开关求值;关闭返回空串,
8
+ * 渲染层自动丢弃空 section,因此对 prompt 的增删即时生效。
9
+ * 2) 会话级状态:$DSH_HOME/dsh-structured/state.json,tmp + rename 原子写,跨重启保留。
10
+ * 3) host 命令 `/structured on|off|status`:作用于当前会话。
11
+ * 4) 规则覆盖:$DSH_HOME/dsh-structured/style.md 存在且非空时整份替换内置文本(按 mtime+size 缓存)。
12
+ *
13
+ * fail-open 是红线:section 求值、style 读取、状态读写、命令处理中的任何异常都不得让回合失败,
14
+ * 最坏情况只是本轮不注入(返回空串 / 返回错误提示文本)。
15
+ */
16
+
17
+ import {
18
+ mkdirSync,
19
+ readFileSync,
20
+ renameSync,
21
+ rmSync,
22
+ statSync,
23
+ writeFileSync,
24
+ } from 'node:fs'
25
+ import { homedir } from 'node:os'
26
+ import { dirname, join } from 'node:path'
27
+ import Schema from '@deepseek-ai/schemastery'
28
+
29
+ /** Cordis 插件名。 */
30
+ export const name = 'dsh-structured'
31
+
32
+ /** 必需服务:系统提示词注册表。commands 属于可选能力(见 apply 内的 ctx.inject),
33
+ * 缺失时只降级 /structured 命令,不牺牲注入——注入才是本插件的核心。 */
34
+ export const inject = ['systemPrompt']
35
+
36
+ /** 主 section 名(带插件前缀,避免与其他 section 重名)。 */
37
+ export const SECTION_NAME = 'dsh-structured:style'
38
+
39
+ /** 尾部提醒 section 名:放在 system prompt 末尾,对冲长上下文下的遵循衰减。 */
40
+ export const REMINDER_NAME = 'dsh-structured:reminder'
41
+
42
+ /** 主 section 兜底顺序:与第一方 STRUCTURED_OUTPUT 槽位对齐(本机实测 9900)。 */
43
+ const SECTION_ORDER_FALLBACK = 9900
44
+
45
+ /** 尾部提醒顺序:排在 harness source(10000)、web surface(10100)、persona suffix(10200)之后。 */
46
+ const REMINDER_ORDER = 10300
47
+
48
+ /** 会话条目上限:按最近使用淘汰,防止 state.json 无界增长。 */
49
+ export const MAX_SESSION_ENTRIES = 500
50
+
51
+ /** 把字段标记为 volatile,让 DSH 设置表单可以实时读写。 */
52
+ const k = (x) => (typeof x.volatile === 'function' ? x.volatile() : x)
53
+
54
+ /** volatile 字段在 host 侧是可读信号;普通字段原样返回。 */
55
+ const readConfig = (x) => (x && typeof x.get === 'function' ? x.get() : x)
56
+
57
+ /** 主题白名单:host 与 client 共用同一组值。 */
58
+ export const THEMES = ['deepseek', 'codex', 'anthropic']
59
+
60
+ /**
61
+ * DSH 插件配置 schema:设置表单据此渲染,并持久化到 active profile 的 Cordis patch。
62
+ * 不把主题/视觉写进 state.json,避免 structured-test 与 desktop 共享同一状态文件。
63
+ */
64
+ export const Config = Schema.object({
65
+ theme: k(Schema.union(THEMES).default('deepseek').description('主题 / Theme: deepseek | codex | anthropic').loose()),
66
+ visual: k(Schema.boolean().default(true).description('视觉层 / Visual layer: 关闭后不加载回复区样式')),
67
+ defaultEnabled: k(Schema.boolean().default(true).description('新会话默认开启 / Default on for new sessions')),
68
+ tailReminder: k(Schema.boolean().default(true).description('尾部提醒 / Tail reminder: 每轮追加一句结构化提醒')),
69
+ includeSubagents: k(Schema.boolean().default(false).description('子代理注入 / Include subagents: 默认关闭')),
70
+ genui: k(Schema.union(['auto', 'on', 'off']).default('auto').description('GenUI 联动 / GenUI routing: auto | on | off').loose()),
71
+ })
72
+
73
+ /**
74
+ * 内置规则文本:中文,与 `~/.dsh/AGENTS.md` 第 7 节(响应格式)同源,只规定形状、不另立第二套写作规则。
75
+ * 逐条对应:首句结论(结论卡)、组件化正文、禁过程叙事与复述、结构化不等于变长、支撑信息带证据、
76
+ * 以及「全文最多两个强调块」的视觉热点约束。
77
+ */
78
+ export const BUILTIN_STYLE_TEXT = [
79
+ '## 结构化输出(本会话已启用,硬约束)',
80
+ '',
81
+ '面向用户的实质回复按下面的形状组织。这是硬约束,不是风格建议。',
82
+ '',
83
+ '1. **首句给结论,并把它做成卡片**:第一段固定两行——`> **一句标题**`(加粗放大,≤60 个汉字或一句短句)→ `> 1–2 行正文`;标题即结论,细节放正文,不要写「结论」标签,不要再用普通段落重复。',
84
+ '2. **按信息类型选组件**:3 条以上要点用清单或表格;每条实质回复 1 个主卡(首段标题无标签);判断/指标/重点卡用 `> **判断**` / `> **指标**` / `> **重点**`;最多再 1 个行动卡(`风险` / `下一步` / `验收`);表格前先给一行 `**表格结论:** ...`;状态行/状态单元格用 `✅ 完成` / `⚠️ 待做` / `❌ 阻塞` 开头;表格优先 ≤5 行;信息确实多时拆表或使用宿主支持的大表,不硬砍内容;不为了格式加内容,也不为了字数删信息;短答不套卡,其余保持中性。',
85
+ '3. **一段一个主题**:同一信息只出现一次,不要正文和组件各说一遍;不写过程叙事(我先…/已经…/接下来…),不写收尾总结,不加「还需要我…吗?」。',
86
+ '4. **清晰优先,不设硬性篇幅上限**:组件是帮读者快速定位结论的工具,不是加信息的借口;短答不套卡,长答按信息量组织;判断标准是读者只看首段标题 + 表格首列/列表首句,就能知道结论和下一步;细节写文件,回复保留结论与路径。',
87
+ '5. **支撑信息带证据**:每条支撑尽量带数字、路径或结果;没有证据的判断明确标成推断。',
88
+ '6. **可复制内容用代码块**:提示词、命令、配置、JSON、SQL 等需要复制的文本,用带语言标签的代码块包裹(如 `text` / `powershell`),不要用引用块或普通段落;代码块右上角有复制按钮。',
89
+ '7. **代码/补丁例外**:当本轮主要是代码、代码 diff、文件修改或补丁时,代码块或 diff 卡可以直接作为第一块,不强制标题卡;不要为了结构化加标题卡、表格或「下一步」卡;仍不写过程叙事。',
90
+ '8. **金额/美元符号**:金额或美元符号用 `\\$` 转义,或用反引号包裹;不要写裸 `$...$`,避免被宿主解析成行内公式。',
91
+ ].join('\n')
92
+
93
+ /** GenUI 已安装时才追加的路由规则:交互/图表走 dsh-ui,普通卡片仍走 markdown。 */
94
+ export const GENUI_ROUTING_TEXT = [
95
+ '**GenUI 已安装**:只有图表、可排序表格、表单、超过 8 行的大表才用 ` ```dsh-ui ` fence;`dsh-ui` 只能出现在 markdown 标题卡与正文之后,**不得用它的 title 充当首卡,不得把结论/判断/风险/下一步做成 dsh-ui 组件**;宿主默认要出 UI 时以本节为准,不要为每条回复生成 JSON,也不要重复渲染同一内容。',
96
+ ].join('\n')
97
+
98
+ /** 尾部提醒:一句话重复契约中最容易衰减的四件事,长度控制在几十个 token。 */
99
+ export const TAIL_REMINDER_TEXT = [
100
+ '提醒:本轮最终回复第一段必须是 markdown 无标签标题卡(代码/diff 回复例外:代码块或 diff 卡可直接作首块,不强制标题卡):加粗标题(≤60 字)/ 正文两行(即使后文要用组件也一样);组件只能排在标题卡之后,不得用组件充当首卡;表格前给 `**表格结论:**`;状态用 `✅ 完成` / `⚠️ 待做`;全文最多两个强调块;表格优先 ≤5 行;不设硬性篇幅上限;清晰优先;可复制内容用代码块。',
101
+ ].join('\n')
102
+
103
+ /** DSH home:优先 DSH_HOME,缺省 ~/.dsh。 */
104
+ export function dshHome() {
105
+ return process.env.DSH_HOME || join(homedir(), '.dsh')
106
+ }
107
+
108
+ /** 插件状态目录:$DSH_HOME/dsh-structured。 */
109
+ export function pluginDir() {
110
+ return join(dshHome(), name)
111
+ }
112
+
113
+ /**
114
+ * 检测 dsh-genui 是否已安装(软联动,不硬依赖)。
115
+ * 只信 config 显式 on/off 与 host 的 clientModules 实时图;不读可能被继承污染的 profile env。
116
+ * 任何读取失败都按“未安装”处理,绝不阻断注入。
117
+ * @param {{ genui?: 'auto' | 'on' | 'off' }} [config]
118
+ * @param {import('@deepseek-ai/cordis').Context} [ctx] 可选 host 上下文:查 clientModules 实时图。
119
+ * @returns {boolean}
120
+ */
121
+ export function detectGenui(config = {}, ctx) {
122
+ if (config?.genui === 'on') return true
123
+ if (config?.genui === 'off') return false
124
+ try {
125
+ const modules = ctx?.get?.('clientModules')
126
+ if (modules && typeof modules.clientPath === 'function') {
127
+ for (const id of ['genui', '@changfenhuang/dsh-genui', 'dsh-genui']) {
128
+ if (modules.clientPath(id)) return true
129
+ }
130
+ }
131
+ if (modules && typeof modules.graph === 'function') {
132
+ const graph = modules.graph()
133
+ if (Array.isArray(graph?.entries) && graph.entries.some((entry) => String(entry?.id ?? '').includes('genui'))) return true
134
+ }
135
+ } catch {
136
+ // clientModules 不可用(headless/装配早期):按未安装处理,避免误注入 dsh-ui。
137
+ }
138
+ return false
139
+ }
140
+
141
+ /** 状态文件:$DSH_HOME/dsh-structured/state.json。 */
142
+ export function stateFile() {
143
+ return join(pluginDir(), 'state.json')
144
+ }
145
+
146
+ /** 自定义规则文件:$DSH_HOME/dsh-structured/style.md。 */
147
+ export function styleFile() {
148
+ return join(pluginDir(), 'style.md')
149
+ }
150
+
151
+ /** style.md 的 mtime+size 缓存:文件未变时不重复读盘。 */
152
+ let styleCache = { key: null, text: BUILTIN_STYLE_TEXT, source: 'built-in' }
153
+
154
+ /**
155
+ * 当前生效的规则正文与来源。任何读取异常都回落到内置文本——fail-open。
156
+ * @returns {{ text: string, source: 'built-in' | 'custom' }}
157
+ */
158
+ export function activeStyle() {
159
+ try {
160
+ const stat = statSync(styleFile())
161
+ const key = styleFile() + ':' + stat.mtimeMs + ':' + stat.size
162
+ if (styleCache.key === key && styleCache.text !== null) return styleCache
163
+ const raw = readFileSync(styleFile(), 'utf8').trim()
164
+ styleCache = raw.length > 0
165
+ ? { key, text: raw, source: 'custom' }
166
+ : { key, text: BUILTIN_STYLE_TEXT, source: 'built-in' }
167
+ return styleCache
168
+ } catch {
169
+ styleCache = { key: null, text: BUILTIN_STYLE_TEXT, source: 'built-in' }
170
+ return styleCache
171
+ }
172
+ }
173
+
174
+ /**
175
+ * 从会话条目里读开关;显式记录优先,否则取新会话默认值。
176
+ * @param {Record<string, { enabled: boolean, at: number }>} sessions
177
+ * @param {boolean} defaultEnabled
178
+ * @param {string | null} sessionId
179
+ * @returns {boolean}
180
+ */
181
+ export function isEnabledFor(sessions, defaultEnabled, sessionId) {
182
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return defaultEnabled === true
183
+ return sessions[sessionId]?.enabled ?? defaultEnabled === true
184
+ }
185
+
186
+ /**
187
+ * 从模型组装上下文 / 命令调用里提取会话 id。
188
+ * 实测:AssembleContext 带 `agent`(且 `scope` 就是 agent);CommandInvocation 直接带 `agent`。
189
+ * @param {unknown} source
190
+ * @returns {string | null}
191
+ */
192
+ export function sessionIdOf(source) {
193
+ const agent = source?.agent ?? source?.scope
194
+ const raw = agent?.session?.id ?? agent?.id
195
+ return typeof raw === 'string' && raw.length > 0 && raw.length <= 512 ? raw : null
196
+ }
197
+
198
+ /**
199
+ * 是否子代理会话:只认 origin/delegationDepth。
200
+ * 注意:用户聊天 fork 出来的会话也有 parentSession,但不是子代理,不能跳过。
201
+ * @param {unknown} source
202
+ * @returns {boolean}
203
+ */
204
+ export function isSubagentContext(source) {
205
+ const header = source?.agent?.session?.header ?? source?.scope?.session?.header
206
+ if (!header || typeof header !== 'object') return false
207
+ if (header.origin === 'subagent') return true
208
+ return typeof header.delegationDepth === 'number' && header.delegationDepth > 0
209
+ }
210
+
211
+ /**
212
+ * section 求值的唯一入口,所有异常在此吞掉并回落为空串——注入链路出错必须放行原请求。
213
+ * @param {{
214
+ * enabled: boolean,
215
+ * eligible: boolean,
216
+ * styleText: () => string,
217
+ * onError?: (error: unknown) => void,
218
+ * }} options
219
+ * @returns {string}
220
+ */
221
+ export function safeSectionText(options) {
222
+ try {
223
+ if (!options.enabled || !options.eligible) return ''
224
+ const text = options.styleText()
225
+ return typeof text === 'string' ? text : ''
226
+ } catch (error) {
227
+ try {
228
+ options.onError?.(error)
229
+ } catch {
230
+ // 日志本身失败也不能影响回合。
231
+ }
232
+ return ''
233
+ }
234
+ }
235
+
236
+ /**
237
+ * 读取状态文件。文件缺失、损坏、字段畸形时一律回落为空会话表(默认值仍由 config 决定)。
238
+ * @returns {Record<string, { enabled: boolean, at: number }>}
239
+ */
240
+ export function loadSessions() {
241
+ try {
242
+ const raw = JSON.parse(readFileSync(stateFile(), 'utf8'))
243
+ const sessions = {}
244
+ for (const [sessionId, value] of Object.entries(raw?.sessions ?? {})) {
245
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 512) continue
246
+ if (typeof value === 'boolean') {
247
+ sessions[sessionId] = { enabled: value, at: 0 }
248
+ continue
249
+ }
250
+ if (value && typeof value === 'object' && typeof value.enabled === 'boolean') {
251
+ sessions[sessionId] = { enabled: value.enabled === true, at: Number(value.at) || 0 }
252
+ }
253
+ }
254
+ return sessions
255
+ } catch {
256
+ return {}
257
+ }
258
+ }
259
+
260
+ /**
261
+ * 有界化会话表:先保住与默认值不同的显式翻转,再按最近使用补足。
262
+ * @param {Record<string, { enabled: boolean, at: number }>} sessions
263
+ * @param {boolean} defaultEnabled
264
+ * @returns {Record<string, { enabled: boolean, at: number }>}
265
+ */
266
+ export function pruneSessions(sessions, defaultEnabled) {
267
+ const entries = Object.entries(sessions ?? {})
268
+ if (entries.length <= MAX_SESSION_ENTRIES) return sessions ?? {}
269
+ const byRecent = (a, b) => (b[1]?.at || 0) - (a[1]?.at || 0)
270
+ const explicit = entries.filter(([, value]) => value?.enabled !== defaultEnabled).sort(byRecent)
271
+ const redundant = entries.filter(([, value]) => value?.enabled === defaultEnabled).sort(byRecent)
272
+ return Object.fromEntries(explicit.concat(redundant).slice(0, MAX_SESSION_ENTRIES))
273
+ }
274
+
275
+ /**
276
+ * 原子写状态:同目录 tmp 文件 + rename 覆盖;失败时清理 tmp 并把错误交回调用方。
277
+ * @param {Record<string, { enabled: boolean, at: number }>} sessions
278
+ * @param {boolean} defaultEnabled
279
+ * @returns {{ ok: true, sessions: Record<string, { enabled: boolean, at: number }> } | { ok: false, sessions: Record<string, { enabled: boolean, at: number }>, error: unknown }}
280
+ */
281
+ export function saveSessions(sessions, defaultEnabled) {
282
+ const pruned = pruneSessions(sessions, defaultEnabled)
283
+ const file = stateFile()
284
+ const tmp = file + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2)
285
+ try {
286
+ mkdirSync(dirname(file), { recursive: true })
287
+ writeFileSync(tmp, JSON.stringify({ version: 1, sessions: pruned }, null, 2) + '\n', 'utf8')
288
+ renameSync(tmp, file)
289
+ return { ok: true, sessions: pruned }
290
+ } catch (error) {
291
+ try {
292
+ rmSync(tmp, { force: true })
293
+ } catch {
294
+ // tmp 清理失败不改变结论。
295
+ }
296
+ return { ok: false, sessions, error }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * 写入一个会话开关;持久化失败时仍保留进程内状态(下一轮立即生效),并把失败交回命令层提示。
302
+ * @param {Record<string, { enabled: boolean, at: number }>} sessions
303
+ * @param {boolean} defaultEnabled
304
+ * @param {string} sessionId
305
+ * @param {boolean} enabled
306
+ */
307
+ export function setEnabledFor(sessions, defaultEnabled, sessionId, enabled) {
308
+ sessions[sessionId] = { enabled: enabled === true, at: Date.now() }
309
+ return saveSessions(sessions, defaultEnabled)
310
+ }
311
+
312
+ /**
313
+ * 生成 /structured 的状态文本(不含副作用)。
314
+ * @param {{ sessionId: string | null, sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, genui?: boolean, visual?: boolean, theme?: string }} input
315
+ * @returns {string}
316
+ */
317
+ export function statusText(input) {
318
+ const style = activeStyle()
319
+ const enabled = isEnabledFor(input.sessions, input.defaultEnabled, input.sessionId)
320
+ const theme = THEMES.includes(input.theme) ? input.theme : 'deepseek'
321
+ return [
322
+ '结构化提示词:' + (enabled ? '已开启' : '已关闭') + '(本会话)',
323
+ '新会话默认:' + (input.defaultEnabled ? '开启' : '关闭'),
324
+ '规则来源:' + (style.source === 'custom' ? '自定义 ' + styleFile() : '内置(无标签标题卡 + 结构化组件)'),
325
+ '主题:' + theme + '(由插件设置 theme 控制)',
326
+ '视觉层:' + (input.visual === false ? '已关闭' : '已开启') + '(由插件设置 visual 控制;/structured 只控制提示词)',
327
+ 'GenUI 联动:' + (input.genui ? '已检测(图表/交互走 dsh-ui,普通卡片走 fallback)' : '未检测(纯 markdown fallback)'),
328
+ '状态文件:' + stateFile(),
329
+ '用法:/structured on | off | status',
330
+ ].join('\n')
331
+ }
332
+
333
+ /**
334
+ * 处理 /structured 命令。handler 外层还会再兜一层,这里保持纯逻辑便于单测。
335
+ * @param {{ rawInput?: unknown, agent?: { session?: { id?: unknown }, id?: unknown } }} invocation
336
+ * @param {{ sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, onChange?: (sessionId: string, enabled: boolean, result: { ok: boolean, error?: unknown }) => void }} api
337
+ * @returns {{ kind: 'success', text: string } | { kind: 'error', text: string }}
338
+ */
339
+ export function handleCommand(invocation, api) {
340
+ const sessionId = sessionIdOf(invocation)
341
+ const arg = String(invocation?.rawInput ?? '').trim().toLowerCase()
342
+ if (arg === '' || arg === 'status') {
343
+ return { kind: 'success', text: statusText({ sessionId, sessions: api.sessions, defaultEnabled: api.defaultEnabled, genui: api.genui, visual: api.visual, theme: api.theme }) }
344
+ }
345
+ if (arg !== 'on' && arg !== 'off') {
346
+ return { kind: 'error', text: '未知参数:' + arg + '\n用法:/structured on | off | status' }
347
+ }
348
+ if (sessionId === null) {
349
+ return { kind: 'error', text: '当前会话 id 不可用,未修改开关。' }
350
+ }
351
+ const enabled = arg === 'on'
352
+ const result = setEnabledFor(api.sessions, api.defaultEnabled, sessionId, enabled)
353
+ api.onChange?.(sessionId, enabled, result)
354
+ const state = enabled ? '已开启' : '已关闭'
355
+ if (result.ok) return { kind: 'success', text: state + '本会话的结构化输出(下一轮生效)。' }
356
+ return {
357
+ kind: 'success',
358
+ text: state + '本会话的结构化输出(下一轮生效);状态写入失败,仅本进程生效:' + String(result.error),
359
+ }
360
+ }
361
+
362
+ /**
363
+ * 挂载结构化输出:提示词 section + 尾部提醒 + 会话开关 + /structured 命令。
364
+ * @param {import('@deepseek-ai/cordis').Context} ctx host 根上下文。
365
+ * @param {{ defaultEnabled?: boolean, tailReminder?: boolean, includeSubagents?: boolean, genui?: 'auto' | 'on' | 'off', visual?: boolean, theme?: 'deepseek' | 'codex' | 'anthropic' }} [config]
366
+ */
367
+ export function apply(ctx, config = {}) {
368
+ const defaultEnabled = readConfig(config?.defaultEnabled) !== false
369
+ const themeNow = () => {
370
+ const theme = readConfig(config?.theme)
371
+ return THEMES.includes(theme) ? theme : 'deepseek'
372
+ }
373
+ const visualNow = () => readConfig(config?.visual) !== false
374
+ const tailReminder = readConfig(config?.tailReminder) !== false
375
+ const includeSubagents = readConfig(config?.includeSubagents) === true
376
+ const genuiConfig = { genui: readConfig(config?.genui) }
377
+ const genui = detectGenui(genuiConfig, ctx)
378
+ let genuiKnown = genui
379
+ const genuiNow = () => {
380
+ if (genuiKnown) return true
381
+ genuiKnown = detectGenui(genuiConfig, ctx)
382
+ return genuiKnown
383
+ }
384
+ const builtinStyleText = () => (genuiNow() ? BUILTIN_STYLE_TEXT + '\n\n' + GENUI_ROUTING_TEXT : BUILTIN_STYLE_TEXT)
385
+ let sessions = pruneSessions(loadSessions(), defaultEnabled)
386
+ const stateStampOf = () => {
387
+ try {
388
+ const stat = statSync(stateFile())
389
+ return stat.mtimeMs + ':' + stat.size
390
+ } catch {
391
+ return 'missing'
392
+ }
393
+ }
394
+ let stateStamp = stateStampOf()
395
+ const refreshSessions = () => {
396
+ const next = stateStampOf()
397
+ if (next === stateStamp) return
398
+ stateStamp = next
399
+ try {
400
+ sessions = pruneSessions(loadSessions(), defaultEnabled)
401
+ } catch {
402
+ // 外部状态读取失败时保留内存态,fail-open。
403
+ }
404
+ }
405
+ const logger = ctx.logger ?? {}
406
+ const warn = (message, error) => {
407
+ try {
408
+ logger.warn?.('[dsh-structured] ' + message, error)
409
+ } catch {
410
+ // 日志失败不影响插件行为。
411
+ }
412
+ }
413
+
414
+ const sectionOrder = () => {
415
+ try {
416
+ const order = ctx.systemPrompt?.getSectionOrder?.('STRUCTURED_OUTPUT')
417
+ return Number.isFinite(order) ? order : SECTION_ORDER_FALLBACK
418
+ } catch {
419
+ return SECTION_ORDER_FALLBACK
420
+ }
421
+ }
422
+
423
+ const eligible = (context) => includeSubagents || !isSubagentContext(context)
424
+ const enabledFor = (context) => {
425
+ refreshSessions()
426
+ return isEnabledFor(sessions, defaultEnabled, sessionIdOf(context))
427
+ }
428
+ const sectionText = (context, styleText, label) => {
429
+ // 外层兜底也是 fail-open 的一部分:enabledFor / eligible 自身抛错时同样不能打断组装。
430
+ try {
431
+ return safeSectionText({
432
+ enabled: enabledFor(context),
433
+ eligible: eligible(context),
434
+ styleText,
435
+ onError: (error) => warn(label, error),
436
+ })
437
+ } catch (error) {
438
+ warn(label + '(外层兜底)', error)
439
+ return ''
440
+ }
441
+ }
442
+
443
+ try {
444
+ ctx.effect(
445
+ () =>
446
+ ctx.systemPrompt.section({
447
+ name: SECTION_NAME,
448
+ order: sectionOrder(),
449
+ interpolate: false, // style.md 里的 {{...}} 不得触发严格插值而打断组装。
450
+ text: (context) =>
451
+ sectionText(
452
+ context,
453
+ () => {
454
+ const style = activeStyle()
455
+ return style.source === 'custom' ? style.text : builtinStyleText()
456
+ },
457
+ 'section 求值失败,已回退为空 section',
458
+ ),
459
+ }),
460
+ 'dsh-structured: prompt section',
461
+ )
462
+ } catch (error) {
463
+ warn('注册 system prompt section 失败,插件降级为不注入', error)
464
+ }
465
+
466
+ if (tailReminder) {
467
+ try {
468
+ ctx.effect(
469
+ () =>
470
+ ctx.systemPrompt.section({
471
+ name: REMINDER_NAME,
472
+ order: REMINDER_ORDER,
473
+ interpolate: false,
474
+ text: (context) =>
475
+ sectionText(context, () => TAIL_REMINDER_TEXT, '尾部提醒求值失败,已回退为空 section'),
476
+ }),
477
+ 'dsh-structured: tail reminder',
478
+ )
479
+ } catch (error) {
480
+ warn('注册尾部提醒失败,不影响主 section', error)
481
+ }
482
+ }
483
+
484
+ try {
485
+ ctx.effect(
486
+ () =>
487
+ ctx.commands.register({
488
+ name: 'structured',
489
+ description: '开关本会话的结构化提示词(视觉样式仍保留)',
490
+ input: { hint: '[on|off|status]' },
491
+ handler: (invocation) => {
492
+ try {
493
+ refreshSessions()
494
+ return handleCommand(invocation, {
495
+ sessions,
496
+ defaultEnabled,
497
+ genui: genuiNow(),
498
+ visual: visualNow(),
499
+ theme: themeNow(),
500
+ onChange: (sessionId, enabled, result) => {
501
+ warn(
502
+ 'session ' + sessionId + ' structured ' + (enabled ? 'on' : 'off') + (result.ok ? '' : '(持久化失败)'),
503
+ result.ok ? undefined : result.error,
504
+ )
505
+ },
506
+ })
507
+ } catch (error) {
508
+ warn('/structured 处理失败', error)
509
+ return { kind: 'error', text: '/structured 处理失败:' + String(error) }
510
+ }
511
+ },
512
+ }),
513
+ 'dsh-structured: /structured command',
514
+ )
515
+ } catch (error) {
516
+ warn('注册 /structured 命令失败,开关不可用但注入不受影响', error)
517
+ }
518
+ }