dsh-ppt-studio 1.1.1 → 1.1.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.
Files changed (58) hide show
  1. package/README.md +41 -18
  2. package/agent-presets/ppt/agent.cordis.yml +22 -15
  3. package/cordis.patch.yml +19 -14
  4. package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +1 -1
  5. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +9 -5
  6. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +153 -0
  7. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +34 -3
  8. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +5 -3
  9. package/examples/fx/fx-pro/preview/04_pages_04_chart.html +1 -1
  10. package/examples/fx/fx-pro/preview/deck.html +1 -1
  11. package/examples/fx/fx-pro/preview/measured.json +1 -1
  12. package/examples/fx/fx-pro.pptx +0 -0
  13. package/examples/fx/seed.pptx +0 -0
  14. package/examples/fx/single.pptx +0 -0
  15. package/examples/fx/spliced.pptx +0 -0
  16. package/examples/smoke/imported/deck.yaml +6 -6
  17. package/examples/smoke/imported/import-styles.json +1 -33
  18. package/examples/smoke/imported/loss-audit.json +99 -0
  19. package/examples/smoke/imported/pages/slide_03.yaml +5 -36
  20. package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -1
  21. package/examples/smoke/imported/preview-rt/deck.html +1 -1
  22. package/examples/smoke/imported/preview-rt/layout.json +26 -78
  23. package/examples/smoke/imported/reference/previews/03.png +0 -0
  24. package/examples/smoke/imported/source.pptx +0 -0
  25. package/examples/smoke/out-smoke.pptx +0 -0
  26. package/examples/smoke/preview/03_pages_03_chart.html +1 -1
  27. package/examples/smoke/preview/deck.html +1 -1
  28. package/lib/capabilities.js +189 -0
  29. package/lib/commands.js +12 -11
  30. package/lib/home.js +32 -0
  31. package/lib/index.js +278 -112
  32. package/lib/office-qa.js +111 -0
  33. package/lib/pptd/export-pptx.js +203 -13
  34. package/lib/pptd/import-pptx.js +12 -0
  35. package/lib/pptd/loss-audit.js +363 -0
  36. package/lib/pptd/nativeChart.js +454 -0
  37. package/lib/pptd/schema.js +48 -1
  38. package/lib/pptd/svgCharts.js +180 -37
  39. package/lib/pptxPy.js +96 -11
  40. package/lib/preset-delivery.js +107 -9
  41. package/lib/preview-server.js +41 -16
  42. package/lib/router.js +3 -2
  43. package/lib/scaffold.js +1 -1
  44. package/lib/state.js +8 -6
  45. package/lib/templates.js +9 -6
  46. package/lib/tools.js +102 -25
  47. package/lib/verify.js +12 -1
  48. package/package.json +2 -1
  49. package/scripts/audit-manual-facts.mjs +4 -2
  50. package/scripts/e2e-1.0.mjs +2 -2
  51. package/scripts/fixtures/workflow-baseline.json +10 -10
  52. package/scripts/install.mjs +13 -11
  53. package/scripts/smoke.mjs +473 -66
  54. package/scripts/verify-loss-audit.mjs +245 -0
  55. package/scripts/verify-native-charts.mjs +529 -0
  56. package/scripts/verify-preset-scope.mjs +593 -0
  57. package/skills/ppt-studio-data/SKILL.md +5 -7
  58. package/skills/ppt-studio-manual/SKILL.md +2 -2
package/lib/index.js CHANGED
@@ -1,37 +1,43 @@
1
1
  /**
2
2
  * dsh-ppt-studio —— PPT 工作室插件(host)。
3
3
  *
4
- * 装配模型(2026-09-24 **第三次修订:适配 DSH 0.1.7-rc.1**):
5
- * `apply()` 做四件事——① 装配防重;② **全局管道**(/ppt-preview 路由、预设**声明**、语义路由);
6
- * ③ 工作流提示段注入(`system-prompt/assemble`);④ **在本 ctx 注册全部能力面**:
7
- * 22 个 `ppt_*` 工具 / `/ppt` 命令面 / `ppt_state` / 4 本内嵌技能。
8
- * ⇒ **装上插件,所有会话都能用**(= 1.0.0 的行为)。
4
+ * 装配模型(2026-09-26 **第四次修订:按预设隔离**):
5
+ * `apply()` 按**作用域档**分两种:
6
+ * · **profile 档**(装它的那一层,`config.scope` 缺省/`auto`/`profile`):
7
+ * 全局管道(`/ppt-preview` 路由)+ 预设**声明**(想隔离时追加一行**自定位行**);
8
+ * 隔离生效时**不注册任何能力面**——本档只负责"让预设存在 + 判定健康 + 出问题时兜底"。
9
+ * · **preset 档**(由自定位行挂进「PPT 工作室」预设的那一份,`config.scope === 'preset'`):
10
+ * 22 个 `ppt_*` 工具 / `/ppt` 命令面 / `ppt_state` / 4 本内嵌技能 / 语义路由 / 工作流提示段,
11
+ * **全部注册在预设作用域** ⇒ 只对该预设的会话可见(用户要求:其他预设完全不受影响)。
12
+ * ⇒ 「PPT 工作室」预设第一次成为**真正的开关**(此前它只提供人格,工具全局可见)。
9
13
  *
10
- * 0.1.7 的那一处真适配是**预设交付**:宿主不再扫描 `<dshHome>/.agent-presets/`(上游原文
11
- * "the harness discovers no preset on disk"),预设改为向 `agentPresets` 注册表**声明**。
12
- * 详见 src/preset-delivery.js 顶部(含 `!!js` 保真与相对名解析这两条源码级细节)。
14
+ * 为什么这次能做成而 2026-09-18 那次失败(详细判据见 git 历史与 docs/03):
15
+ * ① 当年想"插件自己在运行时按 agent 门控":空白会话切预设时 agent 已存在,
16
+ * `agent/created` 监听者不会为它触发 ⇒ 工具永不出现。**现在不经过 agent 生命周期**:
17
+ * 预设的常驻组合在**声明时**就挂载(`dsh-agent-preset-registry` 声明即挂载),
18
+ * 切换预设时宿主把 agent 的 scope 父指针 `rebind` 到新预设代 ⇒ 可见性自动跟随。
19
+ * ② 当年"预设行挂载"被"行按 harness base 解析"封死(裸包名/相对名解析不到 ⇒ 预设 broken)。
20
+ * **绝对值 `file://` 行不受 baseUrl 影响**(Loader 对非 `.` 开头的名字原样 `import(name)`)
21
+ * ⇒ 用 `import.meta.url` 现算本包入口路径即可,且升级换代不会陈旧。
22
+ * 两条都在 2026-09-26 用真实宿主 + 隔离 DSH_HOME 实测通过(工具/技能/提示段可见性矩阵、
23
+ * 切换预设、负面对照)。
13
24
  *
14
- * 为什么放弃了"只让「PPT 工作室」预设看到"(两件事,都有实测与源码依据):
15
- * ① **切换预设拿不到**:空白会话切预设时 `agent-presets.swap` 确实会 `recompose(agent.ctx, id)`,
16
- * 但那一刻该 agent **已经存在**,而我们的 `agent/created` 监听者是在这次组合里才注册的
17
- * ⇒ 它永远不会为这个已有 agent 触发 ⇒ "先建会话再切到该预设"永远看不到工具
18
- * (用户实测:只有一开始就建在该预设上才有工具)。
19
- * ② **技能在父层读不到**:技能注册表分层,技能工具在**预设层**读;我们只能注册到 `agent.ctx`(子层)
20
- * 或 profile 根 ⇒ 预设层读不到 ⇒ 技能永不出现。而"用预设行挂载"这条路同样封死
21
- * (行里的裸包名按 **harness base** 解析,不是 profile ⇒ 预设被判 `broken` ⇒ 选择器不显示它)。
22
- * ⇒ 在这版 DSH 上,"按预设隔离"无法可靠交付。宁可**功能完整、行为可预期**,也不要"看起来隔离、
23
- * 实际一半会话没能力面"。隔离代码留在 git 历史(`4ea7037` 起的提交)里,等 DSH 提供稳定的
24
- * "预设已组合/已切换"信号再恢复。
25
- *
26
- * 「PPT 工作室」预设仍然存在并自交付:它负责**身份/人格**(名字、简介、standard 能力面副本)。
25
+ * 向后兼容与降级(`scope: 'auto'` 是缺省值):
26
+ * · 预设声明失败、注册表不可见(headless/sdk/minimal 等无 `agentPresets` 的部署)、
27
+ * 或"声明成功但预设档实例没装配" ⇒ **自动回落到全局能力面**(功能完整,只是没隔离),
28
+ * 并把原因写进 `ppt_state` 的 `presetDelivery.isolation`。
29
+ * · `config.scope === 'profile'` = 传统档,一律全局(给明确不需要隔离的用户)。
27
30
  */
28
31
  import { registerTools, defineTool } from './tools.js'
29
32
  import { registerCommands } from './commands.js'
30
33
  import { loadSession, saveSession } from './state.js'
31
34
  import { isPptIntent, isPptOff, isQuickIntent, detectTaskType, workflowSection } from './router.js'
32
- import { registerPreviewRoute } from './preview-server.js'
35
+ import { registerPreviewRoute, previewRoot } from './preview-server.js'
36
+ import { userTemplatesDir } from './templates.js'
37
+ import { dshHome, pptStudioDir } from './home.js'
38
+ import { detectCapabilities, probeOfficeSkills } from './capabilities.js'
33
39
  import { registerManualSkill, manualSkillStatus } from './skill.js'
34
- import { declareAgentPreset, presetDeliveryStatus } from './preset-delivery.js'
40
+ import { declareAgentPreset, presetDeliveryStatus, notePresetStatus } from './preset-delivery.js'
35
41
  import { existsSync, statSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from 'node:fs'
36
42
  import { join } from 'node:path'
37
43
 
@@ -45,8 +51,9 @@ function loggerOf(ctx) {
45
51
 
46
52
  /**
47
53
  * 按需诊断日志(**默认关闭**):`PPT_STUDIO_DEBUG=1`,或存在 `<dshHome>/ppt-studio/debug.on` 时启用。
48
- *
49
- * 为什么需要它(2026-09-18 的真实排查困境):本插件的"不生效"有三种完全不同的原因——
54
+ * 为什么落到**文件**而不是只打日志:
55
+ * 桌面端"装上了但工具不可见"这类事故,现象是"会话里一个工具都没有"——那时连诊断工具本身都没挂上,
56
+ * 三者在 UI 上长得一模一样:
50
57
  * ① 插件没被加载(装法/装配问题);② 加载了,但门控判成"这个 agent 不属于本插件";
51
58
  * ③ 门控放行,但能力面挂到 agent 作用域时失败。
52
59
  * 三者在 UI 上**长得一模一样**(都看不到工具),而会话里一个工具都没有时**什么都问不了**
@@ -55,8 +62,8 @@ function loggerOf(ctx) {
55
62
  */
56
63
  function diag(event, detail = '') {
57
64
  try {
58
- const home = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || '', '.dsh')
59
- const dir = join(home, 'ppt-studio')
65
+ // 用户目录口径统一(2026-09-26):不再在此各自实现 `DSH_HOME || USERPROFILE/HOME`,见 src/home.js。
66
+ const dir = pptStudioDir()
60
67
  const on = process.env.PPT_STUDIO_DEBUG === '1' || existsSync(join(dir, 'debug.on'))
61
68
  if (!on) return
62
69
  mkdirSync(dir, { recursive: true })
@@ -68,71 +75,88 @@ function diag(event, detail = '') {
68
75
  } catch { /* 诊断永不抛 */ }
69
76
  }
70
77
 
71
- export function apply(ctx, config = {}) {
72
- diag('apply', `baseUrl=${String(ctx?.baseUrl ?? '(无)').slice(0, 90)}`
73
- + `|agentPresets=${(() => { try { return ctx.get('agentPresets') ? '可见' : '不可见' } catch { return '取用抛错' } })()}`
74
- + `|presetIds=${JSON.stringify(config?.presetIds ?? ['ppt'])}|pid=${process.pid}`)
75
- // ── 装配防重(2026-09-14 新增 profile bundle 安装路径后必需)────────────────────────
76
- // 同一个包可能在**同一进程**里被挂两次:
77
- // ① profile bundle 行(包的 cordis.patch.yml,`dsh plugin add` 装完即挂);
78
- // ② 预设内的插件行(`--isolate` 模式把本包挂进「PPT 工作室」预设的组合里)。
79
- // 首个挂载真正装配,后续只计数(注册仍归各自 ctx.effect 所有,owner ctx 卸载即释放)。
80
- // 第二条在本版本上更重要:预设声明会随第二次装配**重复注册**,而注册表对重复 id 直接
81
- // throw(`Duplicate agent preset`)——防重同时挡住了这个双挂载事故。
82
- const CORE = globalThis.__pptCoreReg ?? { count: 0 }
83
- globalThis.__pptCoreReg = CORE
84
- if (CORE.count > 0) {
85
- CORE.count++
86
- try {
87
- loggerOf(ctx)?.warn?.('[ppt-studio] 本包在同一进程被挂载了两次(profile bundle 行 + 预设内的插件行?)——'
88
- + '已按"首个生效"跳过本次装配。两者是替代关系,建议二选一:`dsh plugin --profile <p> remove <包>` 或改用 `--isolate` 安装(见 README §0.7)。')
89
- } catch { /* 无 logger 时静默 */ }
90
- ctx.effect(() => () => { CORE.count-- }, 'ppt-studio: duplicate mount (ref)')
91
- return
92
- }
93
- CORE.count++
94
- // 注意 Cordis 语义:`ctx.effect(cb)` **立即执行 cb**,并用 cb 的**返回值**当 disposer。
95
- // 所以计数递减必须写成"返回一个函数"(写成 `() => { count-- }` 会当场递减,等于没防重——
96
- // 这个错误由 smoke 的行为断言当场抓到)。
97
- ctx.effect(() => () => { CORE.count-- }, 'ppt-studio: core mount (ref)')
78
+ /** 作用域档:`profile` = 装它的那一层(默认档:声明预设 + 全局管道);`preset` = 由自定位行挂进「PPT 工作室」的那一份。 */
79
+ const TIER_PROFILE = 'profile'
80
+ const TIER_PRESET = 'preset'
98
81
 
99
- // ── 全局管道(与会话无关)──────────────────────────────────────────────────────
100
- // 2026-09-26 桌面端事故(宿主警告原文:"1 entry did not activate ppt-studio (dsh-ppt-studio):
101
- // TypeError: ws.register(...).then is not a function"):`registerPreviewRoute` 里一处"对同步返回值
102
- // 调 .then"的 TypeError 从 apply **同步抛出** ⇒ 整个插件条目激活失败,用户连 ppt_state 都调不到
103
- // (能看到的只有那一行宿主警告)。根因已在 preview-server.js 修掉;这里再兜一层,理由与下方 faces 一致:
104
- // 全局管道是**附加能力**,坏掉只该降级(预览链接/预设声明失效),绝不该让整个能力面消失。
105
- const pipeline = []
106
- const pipeStep = (label, fn) => {
107
- try { return fn() } catch (e) { pipeline.push(`${label}: ${String(e?.message ?? e)}`); return undefined }
108
- }
109
- // 诊断要把"跳过了"和"注册了"分开记:`registerPreviewRoute` 在拿不到 webServer 时返回 null
110
- // (极简装配、或组合顺序排在 webServer 之前都会这样)——这与"注册失败"是两回事。
111
- let routeState = 'pending(inject)'
112
- pipeStep('previewRoute', () => {
113
- // webServer 可能比本插件**晚激活**:2026-09-26 真宿主实测(隔离 DSH_HOME + `dsh --profile web`,
114
- // 插件自己的落盘诊断原文 `预览路由=skipped(no webServer)`)——直接 `ctx.get('webServer')` 在那种
115
- // 组合顺序下拿不到服务,路由会被**静默跳过**,用户点预览链接必然 404。
116
- // 与 agentPresets 同一姿势:用 `inject` 等它出现再注册,服务消失时子 ctx 一并释放。
117
- if (typeof ctx.inject === 'function') {
118
- ctx.inject(['webServer'], (child) => {
119
- const disposer = registerPreviewRoute(child)
120
- routeState = disposer ? 'active' : 'skipped(子 ctx 无 webServer)'
121
- diag('previewRoute', `inject 就绪 → ${routeState}`)
122
- return disposer ?? undefined
123
- })
124
- } else {
125
- routeState = registerPreviewRoute(ctx) ? 'active' : 'skipped(宿主无 inject)'
82
+ /**
83
+ * 挂载防重(2026-09-26 改为**按作用域**分档)。
84
+ *
85
+ * 为什么必须改:本包现在会**有意**在同一进程里挂两次——profile 档(声明预设、全局管道)
86
+ * + 预设档(由自定位行挂进「PPT 工作室」,能力面注册在预设作用域)。旧的"首个生效"守卫会让
87
+ * 第二个实例**整个不装配**(且是静默的:用户只会看到"预设里什么都没有")。
88
+ * 键的取法:profile 档固定一把;预设档按 **ctx 身份**取(`WeakMap` 发号)——这样预设被重挂
89
+ * (新 generation)时新实例能装配,而同一 ctx 的重复 apply 仍被挡住。键随各自 ctx 释放而回收。
90
+ */
91
+ const MOUNTS = { get current() { return mounts() } }
92
+
93
+ /** 每次调用重取(**不要**在模块顶层捕获):既有的测试/诊断习惯是 `delete globalThis.__pptCoreReg` 重置,
94
+ * 捕获常量会让那种重置失效(smoke 有 4 处这么用)。 */
95
+ function mounts() {
96
+ const m = (globalThis.__pptCoreReg ??= { count: 0, keys: new Set(), ids: new WeakMap(), nextId: 1 })
97
+ m.keys ??= new Set()
98
+ m.ids ??= new WeakMap()
99
+ m.nextId ??= 1
100
+ return m
101
+ }
102
+
103
+ function scopeKeyOf(ctx) {
104
+ if (ctx === null || typeof ctx !== 'object') return 'anon'
105
+ const m = mounts()
106
+ let id = m.ids.get(ctx)
107
+ if (id === undefined) { id = m.nextId++; m.ids.set(ctx, id) }
108
+ return id
109
+ }
110
+
111
+ function claimMount(ctx, tier) {
112
+ const m = mounts()
113
+ const key = tier === TIER_PRESET ? `preset#${scopeKeyOf(ctx)}` : 'profile'
114
+ if (m.keys.has(key)) return { claimed: false, key }
115
+ m.keys.add(key)
116
+ m.count++
117
+ // Cordis 语义:`ctx.effect(cb)` 立即执行 cb,用 cb 的**返回值**当 disposer ⇒ 这里必须"返回一个函数"。
118
+ ctx.effect(() => () => { const cur = mounts(); cur.keys.delete(key); cur.count-- }, `ppt-studio: mount (${key})`)
119
+ return { claimed: true, key }
120
+ }
121
+
122
+ /**
123
+ * 隔离生效信号:**预设档实例真的装配了**才会置位(进程内)。
124
+ * 为什么不用"审计注册表"判定健康:`dsh-agent-preset-registry` 的 docstring 明确警告
125
+ * `diagnostic()/list()` **不得在宿主行自己的激活过程中调用**(它会 `await loader.await()` 等整棵树落定
126
+ * ⇒ 与我们自己的激活互相等待 = 死锁风险)。而 `register()` 自身 `await record.ready`
127
+ * ⇒ 它 resolve 时预设行**已经 import 并 apply 过**,所以"声明成功"几乎等价于"预设档实例已装配";
128
+ * 这里再用信号确认一次(400ms 宽限),拿不到就回落——宁可没隔离,也不能没能力。
129
+ */
130
+ const SCOPE_SIGNAL_GRACE_MS = 400
131
+ /** 声明迟迟不落地(注册表在本部署里根本不存在 ⇒ `inject` 回调永不触发)时的兜底:到点直接回落。 */
132
+ const SCOPE_SETTLE_TIMEOUT_MS = 3000
133
+
134
+ /** 同 mounts():每次重取,便于测试用 `delete globalThis.__pptScopeApplied` 重置。 */
135
+ function scopeSignal() {
136
+ return (globalThis.__pptScopeApplied ??= { at: 0, presetId: '', pid: 0 })
137
+ }
138
+
139
+ function publishScopeApplied(config) {
140
+ const s = scopeSignal()
141
+ s.at = Date.now()
142
+ s.presetId = String(config?.presetIds?.[0] ?? 'ppt')
143
+ s.pid = process.pid
144
+ }
145
+
146
+ function awaitScopeSignal(timeoutMs, since) {
147
+ const deadline = Date.now() + timeoutMs
148
+ return new Promise((resolve) => {
149
+ const tick = () => {
150
+ if (scopeSignal().at >= since) return resolve(true)
151
+ if (Date.now() >= deadline) return resolve(false)
152
+ setTimeout(tick, 25)
126
153
  }
154
+ tick()
127
155
  })
128
- // 声明「PPT 工作室」预设(0.1.7 起:注册表声明,不再写预设目录——目录已无发现路径)。
129
- // 失败只告警不抛;状态经 ppt_state 的 presetDelivery 暴露,避免"预设没出现"只能翻日志。
130
- pipeStep('agentPreset', () => declareAgentPreset(ctx, config))
131
- if (pipeline.length) loggerOf(ctx)?.warn?.(`[ppt-studio] 全局管道部分失败(已降级,能力面仍可用):${pipeline.join(';')}`)
132
- diag('pipeline', `预览路由=${routeState}|预设声明=${pipeline.some((l) => l.startsWith('agentPreset')) ? '失败' : 'OK'}`
133
- + (pipeline.length ? `|失败=${pipeline.join('; ')}` : ''))
156
+ }
134
157
 
135
- // 语义路由:user/message → 激活/退出(auto 模式)。只写会话状态文件,不暴露任何能力面。
158
+ /** 语义路由:user/message → 激活/退出(auto 模式)。只写会话状态文件,不暴露任何能力面。 */
159
+ function registerRouting(ctx) {
136
160
  ctx.on('session/event', async (session, event) => {
137
161
  if (event.type !== 'user/message') return
138
162
  const data = event.data ?? {}
@@ -159,7 +183,8 @@ export function apply(ctx, config = {}) {
159
183
  } catch { /* 防缺失目录等异常 */ }
160
184
  })
161
185
 
162
- // 工作流提示词注入(**不按预设门控**——见下"回滚"说明)
186
+ // 工作流提示词注入:**只在注册它的那一层生效**——隔离生效时这一层就是**预设作用域**,
187
+ // 于是其他预设的会话连提示段都不会被注入(用户要求"其他预设完全不受影响")。
163
188
  ctx.on('system-prompt/assemble', async (assembly, context, next) => {
164
189
  const assembled = await next()
165
190
  const agent = context.agent
@@ -174,27 +199,141 @@ export function apply(ctx, config = {}) {
174
199
  sections.push(workflowSection(state.taskType ?? 'unknown', state))
175
200
  return { ...assembled, sections }
176
201
  })
202
+ }
177
203
 
178
- // ── 能力面:注册在**本 ctx**(= 装它的那一层)——2026-09-18 第二轮回滚 ──────────────────
179
- // 背景(用户实测 + DSH 源码实锤,两件事一起推翻了"按预设隔离"这条路):
180
- // ① **切换预设拿不到能力面**:空白会话切预设(`agent-presets.swap`)确实会
181
- // `recompose(agent.ctx, id)` 重新组合,但那一刻**该 agent 已经存在**——我们的
182
- // `agent/created` 监听者是在这次组合中才注册的,**永远不会**为这个已有 agent 触发。
183
- // 于是"先建会话再切过去"= 工具永远不出现(用户原话:只有一开始就是该预设才有工具)。
184
- // ② **技能在预设层读不到**:注册表是**分层**的(`dsh-skill` 的 preset 层),而技能工具
185
- // (`tool-skill`)在**预设层**读;我们注册进 `agent.ctx`(子层)⇒ 父层读不到 ⇒ 技能永远不出现。
186
- // 在 profile bundle 装法下我们的 ctx 就是 profile 根,**够不到预设层**,
187
- // 而预设行又无法解析本包(行按 harness base 解析,见 preset 模板末尾说明)⇒ 这条路封死。
188
- // 结论:本 DSH 版本上"只让某个预设看到工具/技能"**无法可靠交付**。按用户指示回滚到
189
- // 「装上插件 → 所有会话都能用」这一久经验证的行为(= 1.0.0 的行为),并把预设保留为**身份/人格**。
204
+ /** 能力面:22 个 `ppt_*` 工具 / `/ppt` 命令面 / `ppt_state` / 4 本内嵌技能,注册进**本 ctx 所在的作用域**。 */
205
+ function registerFaces(ctx, tier) {
190
206
  const faces = []
191
207
  const step = (label, fn) => { try { fn() } catch (e) { faces.push(`${label}: ${String(e?.message ?? e)}`) } }
192
208
  step('tools', () => registerTools(ctx))
193
209
  step('commands', () => registerCommands(ctx))
194
- step('ppt_state', () => statusToolFor(ctx))
210
+ step('ppt_state', () => statusToolFor(ctx, tier))
195
211
  step('skills', () => registerManualSkill(ctx))
196
212
  if (faces.length) loggerOf(ctx)?.warn?.(`[ppt-studio] 部分能力面注册失败:${faces.join(';')}`)
197
- diag('faces', `tools/commands/ppt_state/skills 已注册在 apply ctx(回滚为全局可见)|失败=${faces.length ? faces.join(';') : '无'}`)
213
+ return faces
214
+ }
215
+
216
+ export function apply(ctx, config = {}) {
217
+ const tier = config?.scope === TIER_PRESET ? TIER_PRESET : TIER_PROFILE
218
+ // `scope` 缺省 = 'auto':**想隔离**(追加自定位行 + 预设档实例),但不健康时自动回落全局。
219
+ // 只有显式 `scope: 'profile'` 才是"一律全局"(传统档,给明确不需要隔离的用户)。
220
+ const wantIsolation = config?.scope !== TIER_PROFILE
221
+ diag('apply', `tier=${tier}|scope=${String(config?.scope ?? 'auto')}|baseUrl=${String(ctx?.baseUrl ?? '(无)').slice(0, 90)}`
222
+ + `|agentPresets=${(() => { try { return ctx.get('agentPresets') ? '可见' : '不可见' } catch { return '取用抛错' } })()}`
223
+ + `|presetIds=${JSON.stringify(config?.presetIds ?? ['ppt'])}|pid=${process.pid}`)
224
+
225
+ const claim = claimMount(ctx, tier)
226
+ if (!claim.claimed) {
227
+ try {
228
+ loggerOf(ctx)?.warn?.(`[ppt-studio] 同一作用域重复装配已跳过(key=${claim.key})——`
229
+ + 'profile 档与预设档是两个不同作用域,各自只该装配一次。')
230
+ } catch { /* 无 logger 时静默 */ }
231
+ return
232
+ }
233
+
234
+ // ── 预设档:能力面 + 语义路由 + 提示段**全部注册在预设作用域** ──────────────────────
235
+ // 这一份实例是"预设常驻组合"的成员,它的 ctx 就是预设层 ⇒ 只对该预设的会话可见。
236
+ if (tier === TIER_PRESET) {
237
+ publishScopeApplied(config)
238
+ registerRouting(ctx)
239
+ const faces = registerFaces(ctx)
240
+ diag('faces', `【preset 档】tools/commands/ppt_state/skills 注册在**预设作用域**`
241
+ + `(只对「${String(config?.presetIds?.[0] ?? 'ppt')}」预设的会话可见)|失败=${faces.length ? faces.join('; ') : '无'}`)
242
+ return
243
+ }
244
+
245
+ // ── profile 档:全局管道(HTTP 预览路由只此一处)+ 预设声明 ─────────────────────────
246
+ // 2026-09-26 桌面端事故(宿主警告原文:"1 entry did not activate ppt-studio (dsh-ppt-studio):
247
+ // TypeError: ws.register(...).then is not a function"):`registerPreviewRoute` 里一处"对同步返回值
248
+ // 调 .then"的 TypeError 从 apply **同步抛出** ⇒ 整个插件条目激活失败,用户连 ppt_state 都调不到
249
+ // (能看到的只有那一行宿主警告)。根因已在 preview-server.js 修掉;这里再兜一层,理由与 faces 一致:
250
+ // 全局管道是**附加能力**,坏掉只该降级(预览链接/预设声明失效),绝不该让整个能力面消失。
251
+ const pipeline = []
252
+ const pipeStep = (label, fn) => {
253
+ try { return fn() } catch (e) { pipeline.push(`${label}: ${String(e?.message ?? e)}`); return undefined }
254
+ }
255
+ // 诊断要把"跳过了"和"注册了"分开记:`registerPreviewRoute` 在拿不到 webServer 时返回 null
256
+ // (极简装配、或组合顺序排在 webServer 之前都会这样)——这与"注册失败"是两回事。
257
+ let routeState = 'pending(inject)'
258
+ pipeStep('previewRoute', () => {
259
+ // webServer 可能比本插件**晚激活**:2026-09-26 真宿主实测(隔离 DSH_HOME + `dsh --profile web`,
260
+ // 插件自己的落盘诊断原文 `预览路由=skipped(no webServer)`)——直接 `ctx.get('webServer')` 在那种
261
+ // 组合顺序下拿不到服务,路由会被**静默跳过**,用户点预览链接必然 404。
262
+ // 与 agentPresets 同一姿势:用 `inject` 等它出现再注册,服务消失时子 ctx 一并释放。
263
+ if (typeof ctx.inject === 'function') {
264
+ ctx.inject(['webServer'], (child) => {
265
+ const disposer = registerPreviewRoute(child)
266
+ routeState = disposer ? 'active' : 'skipped(子 ctx 无 webServer)'
267
+ diag('previewRoute', `inject 就绪 → ${routeState}`)
268
+ return disposer ?? undefined
269
+ })
270
+ } else {
271
+ routeState = registerPreviewRoute(ctx) ? 'active' : 'skipped(宿主无 inject)'
272
+ }
273
+ })
274
+
275
+ // 声明「PPT 工作室」预设(0.1.7 起:注册表声明,不再写预设目录——目录已无发现路径)。
276
+ // 想隔离时**追加自定位行**:预设的常驻组合会再挂本包一次(预设档实例),能力面因此落进预设作用域;
277
+ // 本档只留"声明 + 全局管道 + 健康判定 + 兜底"。失败只告警不抛。
278
+ const isolationSince = Date.now()
279
+ let isolationSettled = false
280
+ let declarationSettled = false
281
+ const engageFallback = (why) => {
282
+ if (isolationSettled) return
283
+ isolationSettled = true
284
+ try {
285
+ loggerOf(ctx)?.warn?.(`[ppt-studio] 预设隔离未生效(${why})⇒ 自动回落到**全局能力面**:`
286
+ + '功能完整,但其他预设的会话也会看到 ppt_* 工具与技能。诊断见 ppt_state 的 presetDelivery。')
287
+ notePresetStatus({ isolation: 'profile-fallback', reason: `隔离未生效(${why})⇒ 已回落全局能力面` })
288
+ } catch { /* 忽略 */ }
289
+ registerRouting(ctx)
290
+ const faces = registerFaces(ctx, TIER_PROFILE)
291
+ diag('fallback', `隔离未生效(${why})→ 能力面注册在 profile 根|失败=${faces.length ? faces.join('; ') : '无'}`)
292
+ if (pipeline.length) loggerOf(ctx)?.warn?.(`[ppt-studio] 全局管道部分失败(已降级,能力面仍可用):${pipeline.join(';')}`)
293
+ }
294
+
295
+ pipeStep('agentPreset', () => declareAgentPreset(ctx, config, {
296
+ selfRow: wantIsolation,
297
+ onSettled: (health) => {
298
+ declarationSettled = true
299
+ if (!wantIsolation) return
300
+ void (async () => {
301
+ // 声明失败 / 注册表不可见 ⇒ 立刻回落(不留"装上了却什么都没有"的窗口)。
302
+ if (!health.ok) { engageFallback(health.reason); return }
303
+ // 声明成功 ⇒ 预设已被挂载过一次;再用「预设档实例已装配」信号确认(400ms 宽限)。
304
+ const seen = await awaitScopeSignal(SCOPE_SIGNAL_GRACE_MS, isolationSince)
305
+ if (seen) {
306
+ isolationSettled = true
307
+ notePresetStatus({ isolation: 'preset', reason: `隔离生效:能力面注册在「PPT 工作室」预设作用域(其他预设零影响)|pid=${scopeSignal().pid}` })
308
+ diag('isolation', `隔离生效:预设档实例已装配(pid=${scopeSignal().pid})→ profile 档不注册任何能力面`)
309
+ return
310
+ }
311
+ engageFallback('声明成功但预设档实例未装配')
312
+ })()
313
+ },
314
+ }))
315
+ // **兜底超时**(2026-09-26 实测发现的真漏洞):`ctx.inject(['agentPresets'], cb)` 在"该服务在本部署里
316
+ // 根本不存在"时**回调永不触发**(不是"触发并给 undefined")⇒ 声明既不成功也不失败 ⇒ 只按 `onSettled`
317
+ // 判定的话,这些部署(极简/sdk/无注册表)会"装上了却**零能力面**"。所以再加一道时间兜底:
318
+ // 超时仍未见声明落地 ⇒ 立刻回落全局能力面。代价:这类部署启动后最多晚 SCOPE_SETTLE_TIMEOUT_MS 才有工具。
319
+ if (wantIsolation) {
320
+ setTimeout(() => {
321
+ if (declarationSettled || isolationSettled) return
322
+ engageFallback(`agentPresets 注册表在 ${SCOPE_SETTLE_TIMEOUT_MS}ms 内不可见(inject 回调未触发)`)
323
+ }, SCOPE_SETTLE_TIMEOUT_MS)
324
+ }
325
+ if (pipeline.length) loggerOf(ctx)?.warn?.(`[ppt-studio] 全局管道部分失败(已降级,能力面仍可用):${pipeline.join(';')}`)
326
+ diag('pipeline', `预览路由=${routeState}|预设声明=${pipeline.some((l) => l.startsWith('agentPreset')) ? '失败' : 'OK'}`
327
+ + `|隔离=${wantIsolation ? '待判定(自定位行)' : '未启用(scope=profile)'}`
328
+ + (pipeline.length ? `|失败=${pipeline.join('; ')}` : ''))
329
+
330
+ // ── 传统档(`scope: 'profile'`):不隔离,能力面直接注册在装它的那一层 ────────────────
331
+ if (!wantIsolation) {
332
+ notePresetStatus({ isolation: 'profile' })
333
+ registerRouting(ctx)
334
+ const faces = registerFaces(ctx, TIER_PROFILE)
335
+ diag('faces', `【profile 档·传统】tools/commands/ppt_state/skills 注册在 apply ctx(全局可见)|失败=${faces.length ? faces.join('; ') : '无'}`)
336
+ }
198
337
  }
199
338
 
200
339
  /**
@@ -205,16 +344,23 @@ export function apply(ctx, config = {}) {
205
344
  * 已经存在——我们的 `agent/created` 监听者是这次组合才注册的,**不会**为它触发 ⇒ 工具永不出现
206
345
  * (用户实测:只有一开始就是该预设才有工具);
207
346
  * ② 技能注册表是**分层**的,技能工具在**预设层**读,而我们只能注册到 `agent.ctx`(子层)或
208
- * profile 根 ⇒ 父层读不到 ⇒ 技能永不出现;而"预设行挂载"这条路又被
209
- * "行按 harness base 解析"封死(见 agent-presets/ppt/agent.cordis.yml 末尾说明)。
347
+ * profile 根 ⇒ 父层读不到 ⇒ 技能永不出现。
210
348
  * 代码留在 git 历史里(提交 4ea7037 起)。
211
349
  *
212
- * 【2026-09-24 补注(0.1.7-rc.1)】当时封死"预设行挂载"的那条判据**依然成立**:注册表 mount 预设时
350
+ * 【2026-09-24 补注(0.1.7-rc.1)】当时"预设行挂载"这条路也被判封死:注册表 mount 预设时
213
351
  * 用 `scope.ctx.extend({ baseUrl: <注册表所在 ctx 的 baseUrl> })`(= harness base),裸包名
214
- * `dsh-ppt-studio` 在那里解析不到 ⇒ 预设会被判 broken。所以现在的 `--isolate` 走的是
215
- * 另一条路:**不挂 profile bundle**,把插件行写进预设声明的 `plugins` 里,并给插件行
216
- * `autoPreset:false`(预设已由该声明本身提供,插件不能再声明一次)。本注释里的
217
- * `mountForAgent` 是"插件自己在运行时按 agent 门控"那套,与上面这条不同,仍然不恢复。
352
+ * `dsh-ppt-studio` 在那里解析不到 ⇒ 预设会被判 broken。
353
+ *
354
+ * 【2026-09-26 最终结论(0.1.7-rc.2,源码 + 真宿主实测)】**上面两条都不是"隔离不可行",而是
355
+ * "实现姿势不对"**:
356
+ * · ① 只对"插件自己按 agent 动态门控"成立;走**预设行挂载**不经过 agent 生命周期,切换预设时
357
+ * 宿主把 agent 的 scope 父指针 `rebind` 到新预设代,可见性自动跟随。
358
+ * · ② 只对**裸包名/相对名**成立;**绝对 `file://` 行**不看 baseUrl(Loader 对非 `.` 开头的名字
359
+ * 原样 `import(name)`)⇒ 用 `import.meta.url` 现算本包入口即可,升级换代不陈旧。
360
+ * · 技能分层的方向是对的**但不是障碍**:读路径按"查看方 scope 的链"合并(含 agent 自己那层),
361
+ * 所以注册进**预设作用域**正是技能能被该预设看到的**充要姿势**。
362
+ * 于是本文件重新实现了隔离(preset 档 + 自定位行 + 健康自证 + 自动回落);`mountForAgent` 那套
363
+ * "自己按 agent 门控"的写法仍不恢复。
218
364
  */
219
365
 
220
366
  function extractText(data) {
@@ -225,7 +371,7 @@ function extractText(data) {
225
371
  }
226
372
 
227
373
  /** 调试工具(agent 可见):当前工作流状态。 */
228
- export function statusToolFor(ctx) {
374
+ export function statusToolFor(ctx, tier = TIER_PROFILE) {
229
375
  return ctx.effect(() => ctx.tools.register(defineTool({
230
376
  name: 'ppt_state',
231
377
  description: '查看/更新 PPT 工作流会话状态(档位/激活)。debug/自优化用',
@@ -263,7 +409,27 @@ export function statusToolFor(ctx) {
263
409
  } catch (error) {
264
410
  delivery.visible = `查询失败:${error?.message ?? error}`
265
411
  }
266
- return JSON.stringify({ session: sid, state, manualSkill: delivery, presetDelivery: presetDeliveryStatus() }, null, 2)
412
+ // 路径自检(2026-09-26,方案 B 第二批):用户目录口径统一后,把解析结果一并暴露——
413
+ // 排查"会话状态/模板/预览各落在哪"不用再猜;也避免再次出现 state/preview 忽略 DSH_HOME 那类分裂。
414
+ const paths = {
415
+ dshHome: dshHome(),
416
+ pptStudio: pptStudioDir(),
417
+ sessionState: pptStudioDir(),
418
+ templates: userTemplatesDir(),
419
+ preview: previewRoot(),
420
+ }
421
+ // 装配自检(2026-09-26,第四次修订):本次实例处在哪个作用域、隔离是否生效、预设是否健康——
422
+ // "其他预设看到不该看的东西 / 预设里什么都没有"这两类事故都靠这几个字段定位。
423
+ const assembly = { tier, isolation: presetDeliveryStatus().isolation }
424
+ // 能力自检(2026-09-26,第二轮):捆绑 Python / LibreOffice kit / 官方 office 技能 / primary-runtime
425
+ // 各在不在——兜底与 QA 通道**先探测再说话**(web/headless 没有这些,绝不指引模型调用不存在的东西)。
426
+ const capabilities = detectCapabilities()
427
+ let officeSkills
428
+ try { officeSkills = await probeOfficeSkills(ctx) } catch (e) { officeSkills = { available: false, found: [], reason: String(e?.message ?? e) } }
429
+ return JSON.stringify({
430
+ session: sid, state, manualSkill: delivery, presetDelivery: presetDeliveryStatus(), assembly,
431
+ capabilities: { ...capabilities, officeSkills }, paths,
432
+ }, null, 2)
267
433
  },
268
434
  })), 'ppt-studio: ppt_state')
269
435
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 官方 QA 通道(2026-09-26,第二轮 ②)——**第三方自证**,能力探测驱动。
3
+ *
4
+ * 为什么需要:我们自己的 parity/audit 都是"自己写、自己查"。官方 office 技能带来两件外部工具:
5
+ * ① `check_office.py`:ZIP/XML/rels **完整性** + 文本抽取 + `--contains/--count` 断言(只读、不渲染);
6
+ * ② LibreOffice kit CLI:**不依赖 Office/COM** 的渲染与转 PDF(`render`/`convert`/`recalculate`)。
7
+ * 但它们在**桌面端/SDK** 才有(web/headless 出厂组合不挂 `dsh-skill-office`,npm 版也没有 Python 分发)
8
+ * ⇒ 所以这里一律"**先探测、探不到就说清原因**",绝不假装跑过。三个函数都返回
9
+ * `{ available, ok, reason, … }`,调用方据此决定是打一行自证、还是打一行"本部署不可用"。
10
+ */
11
+ import { spawnSync } from 'node:child_process'
12
+ import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import { findAnyPython, findBundledNode, findLibreofficeCli, findOfficeAssets } from './capabilities.js'
15
+
16
+ /**
17
+ * 官方结构检查:`<bundledPython> <check_office.py> <file> [--contains T]… [--count N]`。
18
+ * `check_office.py` 把 JSON 报告写到 stdout:`{format, verdict:'pass'|'fail', checks, summary}`,
19
+ * 退出码 0=通过 / 1=失败 / 2=参数错误。
20
+ * @returns {{available: boolean, ok?: boolean, verdict?: string, summary?: unknown, checks?: unknown, reason: string, raw?: string}}
21
+ */
22
+ export function runOfficeStructureCheck(file, { contains = [], count = 0, timeoutMs = 60000 } = {}) {
23
+ const assets = findOfficeAssets()
24
+ if (assets === null) return { available: false, reason: '本部署没有官方 office 资产(桌面端/SDK 才有:resources/runtime/office-skills)' }
25
+ if (assets.checker === null) return { available: false, reason: `office 资产在(${assets.skillsDir})但没有 scripts/check_office.py` }
26
+ const py = findAnyPython()
27
+ if (py === null) return { available: false, reason: '没有任何可用的 Python(check_office.py 需要它;捆绑 Python 与 PATH 都没探到)' }
28
+ if (!existsSync(file)) return { available: true, ok: false, reason: `待检文件不存在:${file}` }
29
+ const args = [assets.checker, file]
30
+ for (const t of contains) args.push('--contains', String(t))
31
+ if (count > 0) args.push('--count', String(count))
32
+ const r = spawnSync(py.path, args, { encoding: 'utf8', timeout: timeoutMs })
33
+ if (r.error !== undefined) return { available: true, ok: false, reason: `执行失败:${r.error?.message ?? r.error}` }
34
+ let report = null
35
+ try { report = JSON.parse(String(r.stdout ?? '')) } catch { /* 解析失败走下面的兜底 */ }
36
+ const ok = r.status === 0 && report?.verdict === 'pass'
37
+ const failed = report?.checks?.filter?.((c) => c?.status === 'fail')?.map?.((c) => `${c.id}: ${c.detail}`) ?? []
38
+ return {
39
+ available: true,
40
+ ok,
41
+ verdict: report?.verdict ?? (r.status === 0 ? 'pass' : 'fail'),
42
+ summary: report?.summary ?? null,
43
+ checks: report?.checks ?? null,
44
+ reason: ok
45
+ ? 'pass'
46
+ : (r.status === 2 ? '命令行参数错误' : `verdict=${report?.verdict ?? 'unknown'}(exit ${r.status ?? '?'})${failed.length ? `|失败项:${failed.slice(0, 3).join(';')}` : ''}`),
47
+ raw: String(r.stderr || r.stdout || '').slice(0, 400),
48
+ }
49
+ }
50
+
51
+ /**
52
+ * LibreOffice kit 渲染(**无 Office 也能出 PNG**):`<bundledNode> <cli> render --input F --output-dir D [--pages …] --dpi N`。
53
+ * 注意:LibreOffice 的排版/字体与 PowerPoint 是两套引擎,**不能**当"逐像素等于 PowerPoint"的判据,
54
+ * 只能作为"没有 Office 时的替代真渲染"(交付说明里要如实标注用的是哪一个引擎)。
55
+ */
56
+ export function runLibreOfficeRender(file, outDir, { pages = '', dpi = 144, timeoutMs = 300000 } = {}) {
57
+ const cli = findLibreofficeCli()
58
+ if (cli === null) return { available: false, reason: '本部署没有 LibreOffice kit(桌面端才有:app.asar.unpacked 下的 @deepseek-ai/libreoffice-kit)' }
59
+ const node = findBundledNode()
60
+ if (node === null) return { available: false, reason: '没有捆绑 Node(kit CLI 需要它作为第一参数)' }
61
+ if (!existsSync(file)) return { available: true, ok: false, reason: `待渲染文件不存在:${file}` }
62
+ // kit 的硬契约(官方技能原文 "Outputs must be new directories/files"):**输出目录必须不存在**,
63
+ // 预建目录会让它直接 `EEXIST: file already exists, mkdir …` 失败(2026-09-26 实测踩到)。
64
+ // 所以这里先清掉旧目录,再让 kit 自己创建。
65
+ try { rmSync(outDir, { recursive: true, force: true }) } catch { /* 由下面的 spawn 失败兜住 */ }
66
+ const args = [cli, 'render', '--input', file, '--output-dir', outDir]
67
+ if (pages) args.push('--pages', String(pages))
68
+ args.push('--dpi', String(dpi))
69
+ const r = spawnSync(node, args, { encoding: 'utf8', timeout: timeoutMs })
70
+ let files = []
71
+ try { files = readdirSync(outDir).filter((f) => /\.png$/i.test(f)).sort().map((f) => join(outDir, f)) } catch { files = [] }
72
+ // manifest.json 是 kit 的**权威页码映射**(`images[].index` 是本次输出的序号、`.page` 才是原页号;
73
+ // 文件名是 `page-0001.png` 这种**序号**名 ⇒ 只看文件名会把"第 2 页"当成"第 1 页")。顺带带回缺字诊断。
74
+ let images = []
75
+ let missingFonts = []
76
+ let manifest = null
77
+ try {
78
+ manifest = JSON.parse(readFileSync(join(outDir, 'manifest.json'), 'utf8'))
79
+ images = Array.isArray(manifest?.images) ? manifest.images : []
80
+ missingFonts = Array.isArray(manifest?.missingFonts) ? manifest.missingFonts : []
81
+ } catch { /* 没有 manifest 就只报文件列表 */ }
82
+ const ok = r.error === undefined && r.status === 0 && files.length > 0
83
+ return {
84
+ available: true,
85
+ ok,
86
+ files,
87
+ images,
88
+ missingFonts,
89
+ reason: ok ? `ok(${files.length} 张${missingFonts.length ? `,缺字 ${missingFonts.length} 项` : ''})` : (r.error !== undefined ? `执行失败:${r.error?.message ?? r.error}` : `exit ${r.status ?? '?'}${String(r.stderr ?? '').trim() ? `:${String(r.stderr).trim().slice(0, 300)}` : ''}`),
90
+ raw: String(r.stdout || r.stderr || '').slice(0, 400),
91
+ }
92
+ }
93
+
94
+ /** 一句话描述"本部署能不能做第三方自证"(用于导出报告里的一行说明,避免每处都拼字符串)。 */
95
+ export function officeQaStatus() {
96
+ const assets = findOfficeAssets()
97
+ const py = findAnyPython()
98
+ const cli = findLibreofficeCli()
99
+ const parts = []
100
+ if (assets?.checker && py !== null) parts.push('check_office.py')
101
+ if (cli && findBundledNode() !== null) parts.push('LibreOffice kit')
102
+ if (parts.length === 0) {
103
+ const why = []
104
+ if (assets === null) why.push('无官方 office 资产')
105
+ else if (assets.checker === null) why.push('有资产但缺 scripts/check_office.py')
106
+ if (py === null) why.push('无 Python')
107
+ if (cli === null) why.push('无 LibreOffice kit')
108
+ return { available: false, detail: `无(${why.join('、')})` }
109
+ }
110
+ return { available: true, detail: parts.join(' + ') }
111
+ }