dsh-structured 0.0.0-stage → 0.7.4

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