Просмотр исходного кода

feat(desktop): enable standalone Office skills

yudshj 1 неделя назад
Родитель
Сommit
fa518ecd89
64 измененных файлов с 1580 добавлено и 35 удалено
  1. 6 0
      .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.i18n.yaml
  2. 31 0
      .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md
  3. 31 0
      .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md
  4. 12 4
      apps/cli/tests/desktop-host.e2e.ts
  5. 2 1
      apps/desktop-host/package.json
  6. 2 2
      apps/desktop-host/src/index.ts
  7. 25 0
      apps/desktop-host/src/office.ts
  8. 58 0
      apps/desktop-host/tests/office.spec.ts
  9. 1 0
      apps/desktop-host/tsconfig.json
  10. 2 2
      apps/desktop/README.i18n.yaml
  11. 2 0
      apps/desktop/README.md
  12. 2 0
      apps/desktop/README.zh.md
  13. 17 2
      apps/desktop/scripts/prepare-primary-runtime.ts
  14. 9 0
      apps/desktop/scripts/smoke-primary-runtime.py
  15. 2 1
      apps/desktop/src/host-process.ts
  16. 24 2
      apps/desktop/tests/primary-runtime-preparation.spec.ts
  17. 2 2
      docs/capability-seams.i18n.yaml
  18. 3 1
      docs/capability-seams.md
  19. 3 1
      docs/capability-seams.zh.md
  20. 2 2
      docs/config-catalog.i18n.yaml
  21. 16 0
      docs/config-catalog.md
  22. 16 0
      docs/config-catalog.zh.md
  23. 2 2
      docs/persistence-catalog.i18n.yaml
  24. 3 3
      docs/persistence-catalog.md
  25. 3 3
      docs/persistence-catalog.zh.md
  26. 1 1
      docs/persistence-schema.json
  27. 2 1
      package.json
  28. 2 2
      packages/skill/README.i18n.yaml
  29. 2 1
      packages/skill/README.md
  30. 2 1
      packages/skill/README.zh.md
  31. 6 0
      packages/skill/skill-office/README.i18n.yaml
  32. 100 0
      packages/skill/skill-office/README.md
  33. 100 0
      packages/skill/skill-office/README.zh.md
  34. 46 0
      packages/skill/skill-office/assets/office-docx/SKILL.md
  35. 70 0
      packages/skill/skill-office/assets/office-pptx/SKILL.md
  36. 56 0
      packages/skill/skill-office/assets/office-xlsx/SKILL.md
  37. 228 0
      packages/skill/skill-office/assets/scripts/check_office.py
  38. 43 0
      packages/skill/skill-office/package.json
  39. 70 0
      packages/skill/skill-office/src/index.ts
  40. 228 0
      packages/skill/skill-office/tests/check_office_test.py
  41. 14 0
      packages/skill/skill-office/tests/checkers.spec.ts
  42. BIN
      packages/skill/skill-office/tests/fixtures/encrypted.xlsx
  43. 124 0
      packages/skill/skill-office/tests/skill-office.spec.ts
  44. 24 0
      packages/skill/skill-office/tsconfig.json
  45. 28 0
      pnpm-lock.yaml
  46. 8 0
      scripts/check-workspace-constraints.spec.ts
  47. 1 0
      scripts/check-workspace-constraints.ts
  48. 1 1
      scripts/gen-doc-graphs.ts
  49. 1 0
      scripts/verify-package-readme-model-experience.ts
  50. 3 0
      snapshots/session/headless.snapshot.ts
  51. 22 0
      snapshots/session/office-skills-no-renderer/cordis.snapshot.yml
  52. 14 0
      snapshots/session/office-skills-no-renderer/cordis.yml
  53. 11 0
      snapshots/session/office-skills-no-renderer/session.v3.jsonl
  54. 12 0
      snapshots/session/office-skills-no-renderer/snapshot.yml
  55. 1 0
      snapshots/session/office-skills-no-renderer/system-prompt.expected.md
  56. 1 0
      snapshots/session/office-skills-no-renderer/tool-schemas.expected.json
  57. 51 0
      snapshots/session/office-skills/cordis.snapshot.yml
  58. 5 0
      snapshots/session/office-skills/cordis.yml
  59. 11 0
      snapshots/session/office-skills/session.v3.jsonl
  60. 12 0
      snapshots/session/office-skills/snapshot.yml
  61. 1 0
      snapshots/session/office-skills/system-prompt.expected.md
  62. 1 0
      snapshots/session/office-skills/tool-schemas.expected.json
  63. 1 0
      tsconfig.base.json
  64. 1 0
      tsconfig.host.json

+ 6 - 0
.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md
+2026-09-15-bundled-office-skills.md: e041e52cbfc3d4d33870fa3c8faf5fac3c0b35a4
+2026-09-15-bundled-office-skills.zh.md: 3f36a0e6883f08df66c6781cbf8784a6a4c358b0

+ 31 - 0
.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md

@@ -0,0 +1,31 @@
+# Agent Note: Bundled Office skills with structural verification
+
+Status: implemented
+
+English | [中文](2026-09-15-bundled-office-skills.zh.md)
+
+## Problem
+
+Office tasks need format-specific editing guidance and dependable file checks. Requiring users to install interpreters, package managers, or rendering command-line tools interrupts ordinary document delivery. Structural heuristics can also reject valid merged tables, multi-section documents, or Chinese text without observing an actual layout defect.
+
+## Decision
+
+The [Office provider](../../../../packages/skill/skill-office/README.md) contributes three independently discoverable skills at the bundled rank. The default workflow uses `load_workspace_dependencies` and its Python executable; explicit user and AGENTS.md environment choices take precedence. A configurable absolute asset root lets Desktop expose Python-readable resources outside its application archive. Registration validates the required resources and YAML descriptions; loaded instructions exclude the metadata. Disposal removes every candidate.
+
+One standard-library checker validates ZIP/XML integrity and internal relationships, reports format-specific structure, and checks only explicit text or count assertions. DOCX table summaries count logical grid columns, including merged cells. Section geometry is reported rather than judged against the final section; font filenames do not establish glyph coverage. XLSX formula counts never imply recalculation. Text assertions follow section and note references and worksheet string indices, so retained headers, unused note definitions, comments, glossary entries, and unused strings cannot satisfy requested wording.
+
+Desktop mounts the skill provider and runtime query independently of document rendering. Word uses python-docx, PowerPoint creation and editing use python-pptx, and Excel uses openpyxl and pandas. The managed payload and the ordinary creation examples require neither a rendering engine nor a separate presentation authoring library.
+
+Office skills and the bundled-runtime query remain registered without a rendering service. Visual inspection depends on the active model accepting images and a rendering tool being available. Otherwise the skills complete structural and content checks and deliver with the unverified visual scope stated. `present` refers to the current workspace source file; it does not preserve a private copy.
+
+## Alternatives considered
+
+**Require a plan and a local renderer before every delivery.** Simple edits do not need a fixed planning artifact, and models without image input cannot judge rendered pages. Mandatory renderer installation would turn an optional quality signal into a dependency unrelated to many requests.
+
+**Judge layout using package structure and font-name guesses.** Merged cells, different section widths, font substitution, and application layout rules prevent those observations from establishing rendered correctness. The checker reports facts and leaves visual judgments to actual images.
+
+**Share one undifferentiated Office skill.** Format-specific discovery avoids loading spreadsheet formula guidance for a Word edit or presentation instructions for a cell update. The deterministic checker remains shared because all three formats use the same package relationship rules.
+
+## Consequences
+
+The provider supplies reusable instructions without selecting or installing a deployment runtime. The checker is portable wherever the supported Python standard library works, but it cannot establish Office rendering fidelity, advanced feature preservation, or formula results. Loader and disposal tests cover resource relocation, activation failures, and absent rendering services. A recorded Session pins the Office catalog and loaded instruction body. Checker tests cover structural failures and JSON diagnostics; native payload smoke executes the copied checker with the bundled interpreter.

+ 31 - 0
.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: Bundled Office skills with structural verification
+
+Status: implemented
+
+[English](2026-09-15-bundled-office-skills.md) | 中文
+
+## 问题
+
+Office 任务需要针对文件格式的编辑指引和可靠的文件检查。要求用户安装解释器、包管理器或渲染命令行工具,会中断普通文档交付。结构启发式规则也可能在未观察到实际版式缺陷时,误拒有效的合并表格、多分节文档或中文文本。
+
+## 决策
+
+[Office 提供方](../../../../packages/skill/skill-office/README.zh.md)以内置优先级提供三个可独立发现的 skill(技能)。默认工作流使用 `load_workspace_dependencies` 及其 Python 可执行文件;用户和 AGENTS.md 明确指定的环境优先。可配置的绝对资源根目录让 Desktop 在应用归档之外暴露 Python 可读取的资源。注册时验证必需资源与 YAML 描述;加载后的指令不包含元数据。卸载时移除全部候选项。
+
+一个仅依赖标准库的检查器验证 ZIP/XML 完整性与包内引用,报告各格式的结构,并只检查明确指定的文本或数量断言。DOCX 表格摘要计算逻辑网格列数,包含合并单元格。分节几何信息仅报告,不以最终分节判断全文;字体文件名不能证明字形覆盖范围。XLSX 公式数量不代表已执行重算。文本断言跟随分节与脚注/尾注引用及工作表字符串索引,因此保留的页眉、未使用的脚注/尾注定义、批注、词库条目和未使用的字符串不能满足措辞要求。
+
+Desktop 独立于文档渲染挂载技能提供者和运行时查询。Word 使用 python-docx,PowerPoint 创建和编辑使用 python-pptx,Excel 使用 openpyxl 和 pandas。受管理产物和普通创建示例都不要求渲染引擎或单独的演示文稿创作库。
+
+缺少渲染服务时,Office skills 与内置运行时查询仍保持注册。视觉检查取决于当前模型支持图片且有可用渲染工具。否则,skill 完成结构与内容检查,并在交付时说明未验证的视觉范围。`present` 引用工作区中的当前源文件,不保存私有副本。
+
+## 考虑过的替代方案
+
+**每次交付都强制要求计划与本地渲染器。** 简单编辑不需要固定计划文件,不支持图片的模型也无法判断渲染页面。强制安装渲染器会将可选质量信号变成与许多请求无关的依赖。
+
+**通过包结构与字体名猜测判断版式。** 合并单元格、不同分节宽度、字体替换和应用排版规则,使这些观察不足以证明渲染正确。检查器报告事实,将视觉判断留给实际图像。
+
+**共用一个不区分格式的 Office skill。** 按格式发现可避免在 Word 编辑时加载公式指引,或在单元格更新时加载演示文稿指令。确定性检查器仍然共享,因为三种格式采用相同的包引用规则。
+
+## 影响
+
+提供方提供可复用指令,不选择或安装部署运行时。检查器可在受支持的 Python 标准库可用之处运行,但不能证明 Office 渲染保真度、高级特性保留或公式结果。Loader 和卸载测试覆盖资源重定位、激活失败与渲染服务缺失。录制 Session 固定 Office 目录与加载后的指令正文。检查器测试覆盖结构错误与 JSON 诊断;原生产物冒烟检查通过内置解释器执行复制后的检查器。

+ 12 - 4
apps/cli/tests/desktop-host.e2e.ts

@@ -1,9 +1,9 @@
 /** Built Desktop Host lifecycle with Electron disconnecting before profile startup settles. */
 
 import { fork } from 'node:child_process'
-import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { finished } from 'node:stream/promises'
 import { expect, it, onTestFinished } from 'vitest'
@@ -11,7 +11,15 @@ import { expect, it, onTestFinished } from 'vitest'
 it.each([false, true])('settles startup after parent IPC disconnect (boot failure: %s)', async (fail) => {
   const root = mkdtempSync(join(tmpdir(), 'desktop-disconnect-'))
   const modules = join(root, 'node_modules', '@deepseek-ai')
-  for (const name of ['dsh-app-boot', 'dsh', 'dsh-home-paths', 'dsh-tools']) mkdirSync(join(modules, name), { recursive: true })
+  const hostDirectory = fileURLToPath(new URL('../../desktop-host/', import.meta.url))
+  const manifest = JSON.parse(readFileSync(join(hostDirectory, 'package.json'), 'utf8')) as { dependencies: Record<string, string> }
+  const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths', '@deepseek-ai/dsh-tools'])
+  for (const name of Object.keys(manifest.dependencies)) {
+    const destination = join(root, 'node_modules', name)
+    mkdirSync(dirname(destination), { recursive: true })
+    if (stubbed.has(name)) mkdirSync(destination)
+    else symlinkSync(realpathSync(join(hostDirectory, 'node_modules', name)), destination, 'junction')
+  }
   for (const [name, source] of [
     ['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`],
     ['dsh-tools', 'export const defineTool = value => value'],
@@ -35,7 +43,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
     }
   `)
   const entry = join(root, 'index.js')
-  copyFileSync(fileURLToPath(new URL('../../desktop-host/lib/index.js', import.meta.url)), entry)
+  copyFileSync(join(hostDirectory, 'lib', 'index.js'), entry)
   const child = fork(entry, [root, root], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
   let stderr = ''
   child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })

+ 2 - 1
apps/desktop-host/package.json

@@ -16,6 +16,7 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^"
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-skill-office": "workspace:^"
   }
 }

+ 2 - 2
apps/desktop-host/src/index.ts

@@ -6,7 +6,7 @@ import { runProfile } from '@deepseek-ai/dsh/profile-boot'
 import type {} from '@deepseek-ai/dsh-client-connection'
 import type {} from '@deepseek-ai/dsh-host-webserver'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
-import * as workspaceDependencies from './workspace-dependencies.ts'
+import * as desktopOffice from './office.ts'
 
 async function main(): Promise<void> {
   const runtimeDir = process.argv[2] as string
@@ -32,7 +32,7 @@ async function main(): Promise<void> {
   })
   process.once('disconnect', () => { void stop() })
   const { ctx } = await application
-  await ctx.plugin(workspaceDependencies, {
+  await ctx.plugin(desktopOffice, {
     source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'),
     root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),
   })

+ 25 - 0
apps/desktop-host/src/office.ts

@@ -0,0 +1,25 @@
+/** Desktop Office skills and bundled authoring dependencies. */
+
+import { dirname, join } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import * as officeSkills from '@deepseek-ai/dsh-skill-office'
+import * as workspaceDependencies from './workspace-dependencies.ts'
+
+/** Loader identity for the application-owned Office composition. */
+export const name = 'desktop-office'
+/** Application-selected bundled payload and installation directories. */
+export interface Config {
+  /** Bundled payload directory. Missing sibling `office-skills` resources fail Host startup. */
+  readonly source: string
+  readonly root: string
+}
+
+/**
+ * Enable offline Office authoring and structural checks in the Desktop profile.
+ * @param ctx - Profile scope; child plugins declare their own service requirements.
+ * @param config - Bundled payload source and Harness-home installation root.
+ */
+export async function apply(ctx: Context, config: Config): Promise<void> {
+  await ctx.plugin(workspaceDependencies, config)
+  await ctx.plugin(officeSkills, { assetRoot: join(dirname(config.source), 'office-skills') })
+}

+ 58 - 0
apps/desktop-host/tests/office.spec.ts

@@ -0,0 +1,58 @@
+import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import SkillRegistry from '@deepseek-ai/dsh-skill'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import { expect, it } from 'vitest'
+import * as desktopOffice from '../src/office.ts'
+
+it('loads Desktop Office skills without a document renderer and removes them on disposal', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'desktop-office-'))
+  const ctx = new Context()
+  try {
+    const assets = join(root, 'runtime', 'office-skills')
+    await cp(new URL('../../../packages/skill/skill-office/assets/', import.meta.url), assets, { recursive: true })
+    ctx.baseUrl = pathToFileURL(root).href + '/'
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.include = Include
+    expect('default' in desktopOffice).toBe(false)
+    expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice)
+    const modules = new Map<string, unknown>([
+      ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime],
+      ['skills', SkillRegistry], ['office', desktopOffice],
+    ])
+    ctx.loader.internal = {
+      version: 'v2',
+      async import(specifier: string) {
+        if (!modules.has(specifier)) throw new Error(`unexpected plugin ${specifier}`)
+        return modules.get(specifier)
+      },
+    } as unknown as NonNullable<typeof ctx.loader.internal>
+    const config = join(root, 'cordis.yml')
+    await writeFile(config, [
+      '- name: agents', '- name: systemPrompt', '- name: tools', '- name: skills', '- name: office',
+      '  config:', `    source: ${JSON.stringify(join(root, 'runtime', 'primary-runtime'))}`,
+      `    root: ${JSON.stringify(join(root, 'installed'))}`, '',
+    ].join('\n'))
+    await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } })
+    await ctx.loader.await()
+    for (const entry of ctx.loader.entries()) await entry.fiber?.await()
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office-docx', 'office-pptx', 'office-xlsx'])
+    expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx') })
+    expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies'])
+    const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'office')
+    expect(entry).toBeDefined()
+    await entry?.fiber?.dispose()
+    expect(await ctx.skills.list()).toEqual([])
+    expect(ctx.tools.schemas()).toEqual([])
+  } finally {
+    await ctx.fiber.dispose()
+    await rm(root, { recursive: true, force: true })
+  }
+})

+ 1 - 0
apps/desktop-host/tsconfig.json

@@ -8,6 +8,7 @@
     "src"
   ],
   "references": [
+    { "path": "../../packages/skill/skill-office" },
     { "path": "../../packages/util/home-paths" },
     { "path": "../../packages/core/tools" },
     {

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: bf4f22be5aafa1323433d2eb396e9a5417dc3511
-README.zh.md: 058e6b223a4522c993243b463bc7cbd5796529aa
+README.md: d19304ae123aefe57af649c5d6a02c4db867314e
+README.zh.md: 3d5b84c23701c4915fb1983fd6363c1ce2dc3059

+ 2 - 0
apps/desktop/README.md

@@ -18,6 +18,8 @@ The current Windows Python payload contains unsigned native extensions. Smart Ap
 
 Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory.
 
+Desktop registers `office-docx`, `office-pptx`, and `office-xlsx` by default. The skills use the bundled Python libraries for creation and focused edits, then reopen the files and run a shared structural checker before delivery. PowerPoint creation and editing use python-pptx. Skill resources are copied to `runtime/office-skills` outside ASAR so Python can read the checker. An available `render_document` tool can add visual inspection; its absence does not prevent authoring or delivery. See the [Office skill package](../../packages/skill/skill-office/README.md) for checks and limitations.
+
 The payload follows the Desktop release. `runtime.json` records the Desktop version, target, component and Python distribution versions, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names and conflicting numpy/pandas component and distribution versions reject the manifest. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running.
 
 The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.

+ 2 - 0
apps/desktop/README.zh.md

@@ -18,6 +18,8 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含
 
 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。
 
+Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技能使用内置 Python 库创建文件和进行定点编辑,随后重新打开文件,并在交付前运行共享结构检查器。PowerPoint 的创建和编辑使用 python-pptx。技能资源复制到 ASAR 外的 `runtime/office-skills`,让 Python 可以读取检查器。可用的 `render_document` 工具可以补充视觉检查;缺少该工具不妨碍创作或交付。检查范围与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。
+
 该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、组件与 Python 分发包版本,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复,或 numpy/pandas 的组件版本与分发包版本冲突时,清单会被拒绝。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
 
 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。

+ 17 - 2
apps/desktop/scripts/prepare-primary-runtime.ts

@@ -6,7 +6,7 @@ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } f
 import { cp } from 'node:fs/promises'
 import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
+import { dirname, join, resolve } from 'node:path'
 import extractZip from 'extract-zip'
 import { x as extractTar } from 'tar'
 import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../desktop-host/src/primary-runtime.ts'
@@ -75,6 +75,17 @@ export async function unpackPrimaryRuntimeWheel(archive: string, destination: st
   })
 }
 
+/**
+ * Copy the skill package's complete asset tree to ordinary filesystem resources.
+ * @param source - The package's assets directory.
+ * @param destination - Desktop runtime resource directory outside ASAR.
+ * @returns Resolves after replacing the external assets with the complete package tree.
+ */
+export async function prepareOfficeSkillAssets(source: string, destination: string): Promise<void> {
+  rmSync(destination, { recursive: true, force: true })
+  await cp(source, destination, { recursive: true, dereference: true })
+}
+
 /**
  * Materialize the selected Desktop target's primary runtime in its build resources.
  * @returns Resolves after dependency installation and native-target execution checks.
@@ -131,6 +142,9 @@ export async function preparePrimaryRuntime(): Promise<void> {
   } finally {
     rmSync(staging, { recursive: true, force: true })
   }
+  const hostRequire = createRequire(resolve(import.meta.dirname, '..', '..', 'desktop-host', 'package.json'))
+  await prepareOfficeSkillAssets(join(dirname(hostRequire.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'),
+    join(paths.runtime, 'office-skills'))
   smokePrimaryRuntime(join(paths.runtime, 'primary-runtime'))
 }
 
@@ -144,7 +158,8 @@ export function smokePrimaryRuntime(root: string): void {
   if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.')
   const entries = workspaceDependencyPaths(root, manifest)
   const options = { stdio: 'inherit', timeout: 120_000 } as const
-  execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), manifest.components.python], options)
+  execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages),
+    manifest.components.python, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], options)
   execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options)
   execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.components.node)}) process.exit(1)`], options)
   execFileSync(entries.node, [entries.pnpm, '--version'], options)

+ 9 - 0
apps/desktop/scripts/smoke-primary-runtime.py

@@ -4,6 +4,7 @@ import importlib.metadata
 import json
 from pathlib import Path
 import re
+import subprocess
 import sys
 import tempfile
 
@@ -83,6 +84,14 @@ def main():
         finally:
             reopened_workbook.close()
         assert pandas.read_excel(root / "workbook.xlsx")["Value"].iloc[0] == 42
+        for file, arguments in [
+            ("document.docx", ["--contains", "Office 文档"]),
+            ("presentation.pptx", ["--contains", "Office 演示", "--count", "1"]),
+            ("workbook.xlsx", ["--contains", "Value", "--count", "1"]),
+        ]:
+            checked = subprocess.run([sys.executable, "-I", "-B", sys.argv[3], str(root / file), *arguments],
+                                     check=False, capture_output=True, text=True, timeout=30)
+            assert checked.returncode == 0 and json.loads(checked.stdout)["verdict"] == "pass", checked.stdout + checked.stderr
     print("Office runtime versions and document round trips passed.")
 
 

+ 2 - 1
apps/desktop/src/host-process.ts

@@ -72,7 +72,8 @@ export class DesktopHostProcess {
    * @param inspectPort - Optional loopback inspector port for workspace development.
    * @param environment - Environment inherited by the Host and its plugin subprocesses.
    * @param onFailure - Receives the first unexpected child failure, including after readiness.
-   * @param primaryRuntime - Optional payload location for bundled script dependencies.
+   * @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling
+   *   `office-skills` resources fail Host startup.
    * @param profileResolution - Package resolution mode for the application-owned profile.
    */
   constructor(

+ 24 - 2
apps/desktop/tests/primary-runtime-preparation.spec.ts

@@ -1,10 +1,10 @@
 import { createHash } from 'node:crypto'
-import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { basename, join } from 'node:path'
 import { zipSync } from 'fflate'
 import { expect, it } from 'vitest'
-import { downloadPrimaryRuntimeAsset, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts'
+import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts'
 import lock from '../scripts/primary-runtime-lock.json' with { type: 'json' }
 
 const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64')
@@ -107,3 +107,25 @@ it('rejects library files requiring an unsupported installation scheme', async (
     await rm(root, { recursive: true, force: true })
   }
 })
+
+it('copies complete Office resources outside the application archive and removes obsolete assets', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'desktop-office-assets-'))
+  try {
+    const source = join(root, 'package', 'assets')
+    const destination = join(root, 'Contents', 'Resources', 'runtime', 'office-skills')
+    await mkdir(join(source, 'scripts'), { recursive: true })
+    await writeFile(join(source, 'scripts', 'check_office.py'), 'print("checker")\n')
+    for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
+      await mkdir(join(source, name))
+      await writeFile(join(source, name, 'SKILL.md'), `# ${name}\n`)
+    }
+    await prepareOfficeSkillAssets(source, destination)
+    await writeFile(join(destination, 'obsolete.py'), 'old helper')
+    await prepareOfficeSkillAssets(source, destination)
+    for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
+      expect(await readFile(join(destination, name, 'SKILL.md'), 'utf8')).toBe(`# ${name}\n`)
+    }
+    expect(await readFile(join(destination, 'scripts', 'check_office.py'), 'utf8')).toBe('print("checker")\n')
+    await expect(readFile(join(destination, 'obsolete.py'))).rejects.toMatchObject({ code: 'ENOENT' })
+  } finally { await rm(root, { recursive: true, force: true }) }
+})

+ 2 - 2
docs/capability-seams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: 5db5d11195355330b3c0bf3f58c928bd3533cf85
-capability-seams.zh.md: bfcd60916f615f173bc167623462841e25eca2a2
+capability-seams.md: b40adeafe7cec40dfbbe6a65788e5888097ef57f
+capability-seams.zh.md: b33986f205c2e1352343c7c5ad3700efb027eb61

+ 3 - 1
docs/capability-seams.md

@@ -144,6 +144,7 @@ flowchart LR
   svc_skills["ctx.skills<br/>Skill provider registry"]
   pkg_skill_badge["skill-badge"]
   pkg_skill_filesystem["skill-filesystem"]
+  pkg_skill_office["skill-office"]
   svc_agents["ctx.agents<br/>Agent service"]
   pkg_acp["acp"]
   pkg_agent_default_model["agent-default-model"]
@@ -344,6 +345,7 @@ flowchart LR
   pkg_skill --> svc_skills
   pkg_skill_badge --> svc_skills
   pkg_skill_filesystem --> svc_skills
+  pkg_skill_office --> svc_skills
   pkg_spill --> svc_spillStore
   pkg_spill_local --> svc_spillStore
   pkg_ssh --> svc_ssh
@@ -570,7 +572,7 @@ flowchart LR
 | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. |
 | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. |
 | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. |
-| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
+| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
 | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
 | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. |
 | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |

+ 3 - 1
docs/capability-seams.zh.md

@@ -146,6 +146,7 @@ flowchart LR
   svc_skills["ctx.skills<br/>Skill provider registry"]
   pkg_skill_badge["skill-badge"]
   pkg_skill_filesystem["skill-filesystem"]
+  pkg_skill_office["skill-office"]
   svc_agents["ctx.agents<br/>Agent service"]
   pkg_acp["acp"]
   pkg_agent_default_model["agent-default-model"]
@@ -346,6 +347,7 @@ flowchart LR
   pkg_skill --> svc_skills
   pkg_skill_badge --> svc_skills
   pkg_skill_filesystem --> svc_skills
+  pkg_skill_office --> svc_skills
   pkg_spill --> svc_spillStore
   pkg_spill_local --> svc_spillStore
   pkg_ssh --> svc_ssh
@@ -572,7 +574,7 @@ flowchart LR
 | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 |
 | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 |
 | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 |
-| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 |
+| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 |
 | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 |
 | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 |
 | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 |

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 21172068c5be7c3e7515228a728517fe073bb328
-config-catalog.zh.md: 8ce16c5e502edb6e4571b50dc28711776f2912eb
+config-catalog.md: 08c7559f2d83826a850b4eb5c18351bfd24e1707
+config-catalog.zh.md: f29345293fa50a38a631d73f9ba5094bc7fd47b3

+ 16 - 0
docs/config-catalog.md

@@ -2402,6 +2402,22 @@ export interface Config {
 
 Source: [`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts)
 
+<a id="deepseek-aidsh-skill-office"></a>
+
+## `@deepseek-ai/dsh-skill-office`
+
+Requires: `skills`
+
+```ts config-catalog
+/** Office skill resource location. */
+export interface Config {
+  /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */
+  assetRoot?: string
+}
+```
+
+Source: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts)
+
 <a id="deepseek-aidsh-spill-local"></a>
 
 ## `@deepseek-ai/dsh-spill-local`

+ 16 - 0
docs/config-catalog.zh.md

@@ -2404,6 +2404,22 @@ export interface Config {
 
 来源:[`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts)
 
+<a id="deepseek-aidsh-skill-office"></a>
+
+## `@deepseek-ai/dsh-skill-office`
+
+依赖:`skills`
+
+```ts config-catalog
+/** Office skill resource location. */
+export interface Config {
+  /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */
+  assetRoot?: string
+}
+```
+
+来源:[`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts)
+
 <a id="deepseek-aidsh-spill-local"></a>
 
 ## `@deepseek-ai/dsh-spill-local`

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 99c24562c178b35944e5616729076d015eb3f4c6
-persistence-catalog.zh.md: a2c9590a799a1bd63745afaaad989be051015ae0
+persistence-catalog.md: c01193930901e1b01def017ee5529b0e66efa5c1
+persistence-catalog.zh.md: fe11795b919e1f88cfc358896fbceb1388e22edc

+ 3 - 3
docs/persistence-catalog.md

@@ -1784,9 +1784,9 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
 | `kind` | required | `"plugin"` |
 | `plugin` | required | `string` |
 
-<a id="persistence-type-eventagentinboxspliceddatainserted0source10"></a>
+<a id="persistence-type-eventagentinboxspliceddatainserted0source11"></a>
 
-### `event:agent/inbox/spliced.data.inserted[0].source[10]`
+### `event:agent/inbox/spliced.data.inserted[0].source[11]`
 
 SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
 
@@ -5140,7 +5140,7 @@ One of:
 
 - [`packages/context/agent-instructions/src/state.ts#AgentInstructionSource`](#persistence-type-packagescontextagent-instructionssrcstatetsagentinstructionsource)
 - [`packages/llm/llm/src/message.ts#ToolMessageSource`](#persistence-type-packagesllmllmsrcmessagetstoolmessagesource)
-- [`event:agent/inbox/spliced.data.inserted[0].source[10]`](#persistence-type-eventagentinboxspliceddatainserted0source10)
+- [`event:agent/inbox/spliced.data.inserted[0].source[11]`](#persistence-type-eventagentinboxspliceddatainserted0source11)
 - [`event:agent/inbox/spliced.data.inserted[0].source[19]`](#persistence-type-eventagentinboxspliceddatainserted0source19)
 - [`packages/skill/tool-skill/src/index.ts#SkillCatalogSource`](#persistence-type-packagesskilltool-skillsrcindextsskillcatalogsource)
 - [`packages/skill/skill/src/index.ts#SkillInvocationSource`](#persistence-type-packagesskillskillsrcindextsskillinvocationsource)

+ 3 - 3
docs/persistence-catalog.zh.md

@@ -1786,9 +1786,9 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
 | `kind` | 必需 | `"plugin"` |
 | `plugin` | 必需 | `string` |
 
-<a id="persistence-type-eventagentinboxspliceddatainserted0source10"></a>
+<a id="persistence-type-eventagentinboxspliceddatainserted0source11"></a>
 
-### `event:agent/inbox/spliced.data.inserted[0].source[10]`
+### `event:agent/inbox/spliced.data.inserted[0].source[11]`
 
 SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
 
@@ -5142,7 +5142,7 @@ SHA-256: `4c965666d5b42116945bc356087cd5e1fd549e6dece560785ed60511059a8283`
 
 - [`packages/context/agent-instructions/src/state.ts#AgentInstructionSource`](#persistence-type-packagescontextagent-instructionssrcstatetsagentinstructionsource)
 - [`packages/llm/llm/src/message.ts#ToolMessageSource`](#persistence-type-packagesllmllmsrcmessagetstoolmessagesource)
-- [`event:agent/inbox/spliced.data.inserted[0].source[10]`](#persistence-type-eventagentinboxspliceddatainserted0source10)
+- [`event:agent/inbox/spliced.data.inserted[0].source[11]`](#persistence-type-eventagentinboxspliceddatainserted0source11)
 - [`event:agent/inbox/spliced.data.inserted[0].source[19]`](#persistence-type-eventagentinboxspliceddatainserted0source19)
 - [`packages/skill/tool-skill/src/index.ts#SkillCatalogSource`](#persistence-type-packagesskilltool-skillsrcindextsskillcatalogsource)
 - [`packages/skill/skill/src/index.ts#SkillInvocationSource`](#persistence-type-packagesskillskillsrcindextsskillinvocationsource)

+ 1 - 1
docs/persistence-schema.json

@@ -37978,7 +37978,7 @@
         ]
       },
       "names": [
-        "event:agent/inbox/spliced.data.inserted[0].source[10]"
+        "event:agent/inbox/spliced.data.inserted[0].source[11]"
       ],
       "sources": [
         "packages/api/session-controller/src/types.ts:380"

+ 2 - 1
package.json

@@ -239,6 +239,7 @@
     "tsx": "^4.22.4",
     "typescript": "^6.0.3",
     "vite-tsconfig-paths": "^6.1.1",
-    "vitest": "^4.1.8"
+    "vitest": "^4.1.8",
+    "@deepseek-ai/dsh-skill-office": "workspace:^"
   }
 }

+ 2 - 2
packages/skill/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/skill/README.md
-README.md: 963b8195b2240f22cb2df16ffccbb91365e62566
-README.zh.md: f11bd1be5ae005f23592ec479ae635ca28eebeeb
+README.md: 9546dfbfb2081480a1af8078d65481d0fc080ae3
+README.zh.md: eb1231b6fc7e110afdcc22fb25db3dd55b830092

+ 2 - 1
packages/skill/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-The skill family lets agents and users discover and load reusable task instructions only when needed. Use `skill/` to combine catalogs and expose one instruction set per name; choose `skill-filesystem` for project, custom, or user-directory discovery, and `skill-badge` for the optional official badge. Add `tool-skill` when models should receive a sorted, durable session catalog, load full instructions through the `skill` tool, or accept direct `/name` invocation. Different sources produce the same model-visible format, and model access requires at least one source.
+The skill family lets agents and users discover and load reusable task instructions only when needed. Use `skill/` to combine catalogs and expose one instruction set per name; choose `skill-filesystem` for project, custom, or user-directory discovery, `skill-badge` for the optional official badge, and `skill-office` for Word, PowerPoint, and Excel workflows. Add `tool-skill` when models should receive a sorted, durable session catalog, load full instructions through the `skill` tool, or accept direct `/name` invocation. Different sources produce the same model-visible format, and model access requires at least one source.
 
 ## Table of Contents
 
@@ -27,6 +27,7 @@ The skill family lets agents and users discover and load reusable task instructi
 | [`skill/`](skill/README.md) | Registry that merges skill catalogs from any provider and resolves the winning skill for a name | `ctx.skills` |
 | [`skill-filesystem/`](skill-filesystem/README.md) | Discovers skills from project, custom, and user directories and watches them for changes | registers on `ctx.skills` |
 | [`skill-badge/`](skill-badge/README.md) | Bundles the official "powered by dsh" badge skill, disabled by default | registers on `ctx.skills` |
+| [`skill-office/`](skill-office/README.md) | Bundles Word, PowerPoint, and Excel workflows with structural file checks | registers on `ctx.skills` |
 | [`tool-skill/`](tool-skill/README.md) | Publishes the session skill catalog and the model-facing `skill` loader tool | registers on `ctx.tools` |
 
 -----

+ 2 - 1
packages/skill/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-group"
 
 ## 概述
 
-skill 家族让 agent 和用户仅在需要时发现并加载可复用的任务指令。使用 `skill/` 合并目录并为每个名称提供一组指令;需要从项目、自定义或用户目录发现 skill 时选择 `skill-filesystem`,需要可选的官方徽章时选择 `skill-badge`。需要让模型获得排序且持久的会话目录、通过 `skill` 工具加载完整指令,或接受 `/name` 直接调用时,请添加 `tool-skill`。不同来源生成相同的模型可见格式,启用模型访问前必须配置至少一个来源。
+skill 家族让 agent 和用户仅在需要时发现并加载可复用的任务指令。使用 `skill/` 合并目录并为每个名称提供一组指令;需要从项目、自定义或用户目录发现 skill 时选择 `skill-filesystem`,需要可选的官方徽章时选择 `skill-badge`,需要 Word、PowerPoint 和 Excel 工作流时选择 `skill-office`。需要让模型获得排序且持久的会话目录、通过 `skill` 工具加载完整指令,或接受 `/name` 直接调用时,请添加 `tool-skill`。不同来源生成相同的模型可见格式,启用模型访问前必须配置至少一个来源。
 
 ## 目录
 
@@ -27,6 +27,7 @@ skill 家族让 agent 和用户仅在需要时发现并加载可复用的任务
 | [`skill/`](skill/README.zh.md) | 合并任意提供方的 skill 目录、并按名称解析出胜出 skill 的注册表 | `ctx.skills` |
 | [`skill-filesystem/`](skill-filesystem/README.zh.md) | 从项目、自定义与用户目录发现 skill,并监视其变更 | 注册到 `ctx.skills` |
 | [`skill-badge/`](skill-badge/README.zh.md) | 随包附带官方「powered by dsh」徽章 skill,默认禁用 | 注册到 `ctx.skills` |
+| [`skill-office/`](skill-office/README.zh.md) | 随包提供 Word、PowerPoint 和 Excel 工作流及文件结构检查 | 注册到 `ctx.skills` |
 | [`tool-skill/`](tool-skill/README.zh.md) | 发布会话 skill 目录与面向模型的 `skill` 加载工具 | 注册到 `ctx.tools` |
 
 -----

+ 6 - 0
packages/skill/skill-office/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/skill/skill-office/README.md
+README.md: 7ed8f0c0d839db3c62718f5ce97a55875bd65ddc
+README.zh.md: 6b6e84bbd7db1a08a4c19c073759523b6cb4d1fd

+ 100 - 0
packages/skill/skill-office/README.md

@@ -0,0 +1,100 @@
+---
+description: "Bundled Word, PowerPoint, and Excel instructions for deployments providing Office file authoring and structural checks."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-skill-office
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Agents can load Word, PowerPoint, and Excel workflows that use the bundled Python environment by default and respect explicit user or AGENTS.md environment choices. The skills cover creation, focused edits, structural checks, and file delivery. Visual inspection is conditional on image-capable models and an available rendering tool; ordinary document delivery does not require installing a renderer.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Mount this provider beside the skill registry and `dsh-tool-skill` to expose `office-docx`, `office-pptx`, and `office-xlsx` in the session catalog. The provider supplies instructions and scripts; the deployment supplies its interpreters, authoring libraries, execution tools, and file delivery tool.
+
+### Minimal configuration
+
+```yaml
+- name: '@deepseek-ai/dsh-skill-office'
+```
+
+| Field | Default | Meaning |
+|---|---|---|
+| `assetRoot` | Packaged `assets/` | Absolute resource directory containing the three skill folders and shared `scripts/`; deployments can place it outside an application archive. |
+
+Relative paths, missing resources, and skill files without a YAML frontmatter description reject activation. Disposing the plugin removes its candidates. Project and user skill precedence remains owned by the skill registry.
+
+### Structural checks
+
+The shared Python checker reads DOCX, PPTX, or XLSX without modifying the source. It validates ZIP/XML and internal package relationships, reports document structure, and optionally checks required text or slide/sheet count. DOCX text assertions cover the main body, section-referenced headers and footers, and body-referenced footnotes and endnotes; comments, glossary text, and unreferenced parts or notes do not satisfy them. It uses only the Python standard library. Invalid packages, encrypted ZIP members, and report-file write failures produce a JSON failure report on stdout. A passing report does not establish appearance, feature preservation, or calculated formula results.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The provider registers three bundled candidates and reads their descriptions from the shipped YAML frontmatter at activation. Loaded instruction bodies exclude that metadata. Each loaded skill exposes its own filesystem directory, so the execution tool can resolve the shared checker without relying on the task working directory. Configurable external resources support carriers whose application archive is not readable by Python.
+
+| File | Responsibility |
+|---|---|
+| [`src/index.ts`](src/index.ts) | Provider registration and configured resource paths. |
+| [`assets/`](assets/) | Three workflows and the read-only OOXML checker. |
+| — | No runtime invariant companion is published: the provider owns immutable candidates, and the skill registry owns registration lifecycle and precedence. |
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Skill registry](../skill/README.md) — discovery and precedence.
+- [Skill tool](../tool-skill/README.md) — model-visible catalogs and bodies.
+- [File delivery](../../fs/tool-present/README.md) — current source-path delivery.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+Indirectly, through `dsh-tool-skill`, which renders the catalog entries and selected instruction body.
+
+#### KV Cache effect
+
+Mounting the provider adds three catalog entries; loading a skill adds its body at the existing skill-tool insertion point. The provider does not add separate prompt sections.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- The provider does not install Python, authoring libraries, or a rendering engine. The checker requires Python 3.9 or later.
+- Structural checks do not judge pagination, clipping, fonts, chart appearance, or Excel recalculation.
+- The checker accepts DOCX, PPTX, and XLSX only; legacy, encrypted, and macro-enabled formats require an appropriate separate workflow.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 100 - 0
packages/skill/skill-office/README.zh.md

@@ -0,0 +1,100 @@
+---
+description: "随包附带的 Word、PowerPoint 和 Excel 指令,供需要 Office 文件编写与结构检查能力的部署使用。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-skill-office
+
+[English](README.md) | 中文
+
+## 概述
+
+Agent(智能体)可以加载 Word、PowerPoint 和 Excel 工作流,默认使用内置 Python 环境,并遵循用户或 AGENTS.md 明确指定的环境。这些 skill(技能)涵盖创建、局部编辑、结构检查和文件交付。视觉检查以模型支持图片且有可用渲染工具为前提;普通文档交付不要求安装渲染器。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [了解实现](#understand-the-implementation)
+- [延伸阅读](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与暂缓事项](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+将本提供方与 skill 注册表及 `dsh-tool-skill` 一同挂载,即可在会话目录中提供 `office-docx`、`office-pptx` 和 `office-xlsx`。提供方携带指令和脚本;部署提供解释器、编写库、执行工具与文件交付工具。
+
+### 最小配置
+
+```yaml
+- name: '@deepseek-ai/dsh-skill-office'
+```
+
+| 字段 | 默认值 | 含义 |
+|---|---|---|
+| `assetRoot` | 包内的 `assets/` | 包含三个 skill 文件夹和共享 `scripts/` 的绝对资源目录;部署可将其放在应用归档之外。 |
+
+相对路径、资源缺失或 skill 文件的 YAML frontmatter 缺少描述都会导致激活失败。卸载插件会移除其候选项。项目和用户 skill 的优先级仍由 skill 注册表负责。
+
+### 结构检查
+
+共享 Python 检查器读取 DOCX、PPTX 或 XLSX,不修改源文件。它验证 ZIP/XML 和包内引用关系,报告文档结构,并可检查必需文本或幻灯片/工作表数量。DOCX 文本断言覆盖正文、分节引用的页眉和页脚,以及正文引用的脚注和尾注;批注、词库文本与未引用的部件或脚注/尾注不能满足断言。它只使用 Python 标准库。无效包、加密 ZIP 成员和报告文件写入失败都会在标准输出中产生 JSON 失败报告。检查通过不代表外观、特性保留或公式计算结果已得到验证。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 了解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+提供方注册三个内置候选项,并在激活时从随包 YAML frontmatter 读取描述。加载后的指令正文不包含这些元数据。加载后的每个 skill 暴露自己的文件系统目录,因此执行工具可以定位共享检查器,而不依赖任务工作目录。可配置的外部资源支持 Python 无法读取应用归档的分发方式。
+
+| 文件 | 职责 |
+|---|---|
+| [`src/index.ts`](src/index.ts) | 提供方注册与配置资源路径。 |
+| [`assets/`](assets/) | 三种工作流和只读 OOXML 检查器。 |
+| — | 不发布运行时不变量伴随入口:提供方拥有不可变候选项,skill 注册表负责注册生命周期和优先级。 |
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 延伸阅读
+
+- [skill 注册表](../skill/README.zh.md)——发现与优先级。
+- [skill 工具](../tool-skill/README.zh.md)——模型可见目录与正文。
+- [文件交付](../../fs/tool-present/README.zh.md)——当前源路径交付。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+通过 `dsh-tool-skill` 间接呈现,由其渲染目录项与选中的指令正文。
+
+#### KV 缓存影响
+
+挂载提供方会增加三个目录项;加载 skill 时,其正文进入既有 skill 工具的插入位置。提供方不另增提示词分区。
+
+## 已知限制与暂缓事项
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- 提供方不安装 Python、编写库或渲染引擎。检查器要求 Python 3.9 或更高版本。
+- 结构检查不判断分页、裁切、字体、图表外观或 Excel 重算。
+- 检查器只接受 DOCX、PPTX 和 XLSX;传统格式、加密文件和启用宏的格式需要合适的其他工作流。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 46 - 0
packages/skill/skill-office/assets/office-docx/SKILL.md

@@ -0,0 +1,46 @@
+---
+name: office-docx
+description: Create, read, edit, and check Word documents (.docx), including reports, letters, and formatted tables. Use when a DOCX file is an input or requested deliverable.
+---
+
+# Word documents
+
+Use `python-docx` for DOCX creation and ordinary edits. Follow an explicit user or applicable AGENTS.md requirement for a project environment or another library. Otherwise call `load_workspace_dependencies` and execute the returned Python path with its bundled libraries. Do not install packages or discover a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.
+
+Keep source scripts, intermediate files, and final documents in the task workspace; the runtime and this skill directory are read-only resources. Use the user's requested language and preserve an existing document's design unless a redesign is requested.
+
+## Create and edit
+
+For existing files, inspect paragraphs, runs, tables, sections, headers, and footers before changing the affected content. Save to a new file unless the user requests an in-place edit. Replacing a paragraph's `.text` destroys its run formatting; change the relevant runs when formatting must survive. Reconstructing the whole document can lose features outside python-docx's supported editing API.
+
+Use paragraph styles for headings and body text. Size tables for the section that contains them, and account for merged cells and nested tables. Chinese, Japanese, and Korean text may need an explicit `w:eastAsia` font assignment in addition to `run.font.name`; font names alone do not establish glyph availability or rendered appearance.
+
+For a new document, use the selected Python executable:
+
+```python
+from docx import Document
+
+document = Document()
+document.add_heading("Project report", level=0)
+document.add_paragraph("Summary", style="Heading 1")
+document.add_paragraph("The requested findings go here.")
+document.save("report.docx")
+```
+
+python-docx does not paginate or render documents. Do not represent ordinary replacement, colored text, or comments as tracked changes. When real revisions or unsupported OOXML features matter, preserve their package parts and verify the requested operation rather than silently discarding them.
+
+## Check and deliver
+
+Run the shared checker with the selected Python executable; `<skill-directory>` is this loaded skill's resource base:
+
+```text
+<python> <skill-directory>/../scripts/check_office.py <document.docx> --out <checks.json>
+```
+
+It checks ZIP/XML integrity and internal relationships, and reports paragraphs, logical table dimensions, and sections. Optional `--contains TEXT` arguments assert required text. A successful structural check does not verify pagination, clipping, fonts, or visual appearance. Compare the summary and reopened document with the user's request, including unchanged content that matters to an edit.
+
+If `render_document` is available and visual inspection is useful, call it on the final DOCX without `pages` to prepare page 1 and learn `pageCount`. It checks the current main model's actual image capability; do not choose a second model. On `status: "skipped"`, complete structural and content checks and deliver the document, briefly stating that visual layout was not inspected. On a ready result, call `read_image` on `pages[].imagePath` and request remaining pages in small batches. Check page breaks, clipped text, headings, table widths, and consistency with the requested format or source design. Fix the source and render affected pages again.
+
+Use the available rendering tool for this check. Review `warnings` such as missing fonts. LibreOffice pagination can differ from Microsoft Word. If rendering is unavailable or fails, preserve the usable document and report the inspection limit; do not require the user to install a renderer.
+
+Call `present({"files":[{"path":"report.docx"}]})` with the actual final DOCX path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and do not present temporary QA reports unless requested. If `present` is unavailable, provide the final workspace path using the session's supported file delivery method.

+ 70 - 0
packages/skill/skill-office/assets/office-pptx/SKILL.md

@@ -0,0 +1,70 @@
+---
+name: office-pptx
+description: Create, read, edit, and check PowerPoint presentations (.pptx), including slide text, tables, images, and charts. Use when a PPTX file is an input or requested deliverable.
+---
+
+# PowerPoint presentations
+
+Follow an explicit user or applicable AGENTS.md requirement for an environment or library. Otherwise call `load_workspace_dependencies` and use its Python executable and bundled presentation libraries. Do not install npm or pip packages or locate a system interpreter for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.
+
+Keep scripts and output files in the task workspace. The runtime and skill directory contain shared read-only resources. Match the requested slide language and the supplied presentation's design when editing it.
+
+## Create and edit
+
+Use `python-pptx` to inspect or modify an existing presentation. Inspect slide layouts, text runs, images, tables, and charts before editing. Change only the requested content, preserve mixed text formatting, and save to a new file unless the user requests an in-place edit. Rebuilding slides can discard unsupported animation, SmartArt, or other extension content.
+
+For a new deck, use `python-pptx` with editable text, tables, and charts. Use local image assets rather than network-dependent image URLs. Set slide dimensions, text sizes, and chart data explicitly.
+
+A minimal editable deck, run with the selected Python executable:
+
+```python
+from pptx import Presentation
+from pptx.chart.data import CategoryChartData
+from pptx.enum.chart import XL_CHART_TYPE
+from pptx.util import Inches, Pt
+
+presentation = Presentation()
+presentation.slide_width = Inches(13.333)
+presentation.slide_height = Inches(7.5)
+slide = presentation.slides.add_slide(presentation.slide_layouts[6])
+title = slide.shapes.add_textbox(Inches(0.6), Inches(0.4), Inches(12), Inches(0.8))
+run = title.text_frame.paragraphs[0].add_run()
+run.text = "Quarterly report"
+run.font.size = Pt(30)
+data = CategoryChartData()
+data.categories = ["Q1", "Q2"]
+data.add_series("Revenue", [12, 18])
+slide.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED,
+                       Inches(0.8), Inches(1.6), Inches(11.5), Inches(4.8), data)
+presentation.save("report.pptx")
+```
+
+Use `Inches` or `Cm` for positions and sizes and `Pt` for font sizes. To edit a generated title while retaining its other slides and charts:
+
+```python
+from pptx import Presentation
+
+presentation = Presentation("report.pptx")
+for shape in presentation.slides[0].shapes:
+    if shape.has_text_frame and shape.text == "Quarterly report":
+        shape.text_frame.paragraphs[0].runs[0].text = "Quarterly results"
+presentation.save("report-edited.pptx")
+```
+
+Check that text and images fit the slide dimensions, titles form a useful sequence, and chart labels agree with source values. A native chart's embedded workbook is part of the deliverable and must contain the intended data. Library support for writing PPTX is not a rendering engine or a guarantee that every PowerPoint feature survives editing.
+
+## Check and deliver
+
+Run the shared checker with the selected Python executable; `<skill-directory>` is this loaded skill's resource base:
+
+```text
+<python> <skill-directory>/../scripts/check_office.py <presentation.pptx> --out <checks.json>
+```
+
+It checks ZIP/XML integrity and internal relationships and reports slide count and extracted text. Use repeated `--contains TEXT` arguments for required slide text and `--count N` for a requested slide count; `--contains` excludes chart text and speaker notes. Reopen the file to check the requested edits, chart data, and notes. Structural success does not establish text fit, alignment, readable contrast, or rendering fidelity.
+
+If `render_document` is available and visual inspection is useful, call it on the final PPTX, initially omitting `pages` to prepare page 1 and obtain `pageCount`. The tool checks the current main model's actual image capability; do not choose a second model. If `status` is `skipped`, complete structural and content checks and deliver the PPTX, briefly stating that visual layout was not inspected. If ready, call `read_image` on its returned `pages[].imagePath`, then inspect remaining slides in small batches such as `pages: [2, 3, 4]`. Check clipping, alignment, contrast, chart labels, and consistency with the requested design or supplied reference; fix the source and render the affected pages again.
+
+Use the available rendering tool for this check. Inspect `warnings`, especially missing fonts. LibreOffice previews do not certify pixel-identical PowerPoint or Keynote output, animation, or media playback. If the rendering tool is unavailable or fails, retain the usable source and report the inspection limit; do not require the user to install another tool.
+
+Call `present({"files":[{"path":"report-edited.pptx"}]})` with the actual final PPTX path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and leave intermediate images and reports out of delivery unless requested. If `present` is unavailable, use the session's supported file delivery method.

+ 56 - 0
packages/skill/skill-office/assets/office-xlsx/SKILL.md

@@ -0,0 +1,56 @@
+---
+name: office-xlsx
+description: Read, create, and modify Excel workbooks (.xlsx), including cell values, formulas, formatting, and pandas analysis. Use when an Excel workbook is an input or deliverable.
+---
+
+# Excel workbooks
+
+Follow an explicit user or applicable AGENTS.md requirement for an environment or library. Otherwise call `load_workspace_dependencies` and run its Python executable with bundled `openpyxl` and `pandas`. Do not install packages or search for a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.
+
+Keep scripts, working files, and outputs in the task workspace. The runtime and skill directory are shared read-only resources. Save to a new workbook unless the user requests an in-place edit.
+
+## Read and modify
+
+Use `openpyxl` for existing XLSX workbooks and targeted cell or style changes. Load with `data_only=False` when formulas must survive. Inspect sheet names, the affected cell types, formulas, styles, merged ranges, and referenced ranges before editing. Check the saved file by reopening it, including unchanged content that the request requires preserving.
+
+Use pandas for data analysis and transformations. A DataFrame is not the workbook: exporting it over an existing file can lose sheets, formulas, charts, and formatting. Write analysis results back to the intended ranges with openpyxl. `XlsxWriter` can create new workbooks, but cannot read or modify existing ones.
+
+For a new workbook, the selected Python environment can write editable values, styles, and formulas directly:
+
+```python
+from openpyxl import Workbook
+from openpyxl.styles import Font
+
+workbook = Workbook()
+sheet = workbook.active
+sheet.title = "Revenue"
+for row in [("Quarter", "Revenue"), ("Q1", 12), ("Q2", 18), ("Total", "=SUM(B2:B3)")]:
+    sheet.append(row)
+for cell in sheet[1]:
+    cell.font = Font(bold=True)
+sheet.column_dimensions["A"].width = 18
+sheet.column_dimensions["B"].width = 18
+workbook.save("report.xlsx")
+```
+
+Preserve numbers, dates, booleans, and identifiers as the intended cell types; formatting is not a type conversion. For modifications, load the existing file with `openpyxl.load_workbook("input.xlsx", data_only=False)`, change the requested ranges, and save a separate result. Avoid a DataFrame round trip when workbook features must survive.
+
+Writing a formula does not calculate its result. openpyxl and XlsxWriter do not evaluate Excel formulas; `data_only=True` returns stored cached values that may be absent or stale. Verify formulas and inputs separately, and state when current results require recalculation in a spreadsheet application. Do not replace requested formulas with constants or report cached values as newly calculated results.
+
+Do not rename `.xls`, `.xlsb`, encrypted files, or macro-enabled files to `.xlsx`. They need an appropriate supported operation. `keep_vba=True` can preserve VBA package content in an XLSM workflow, but does not execute or edit macros and does not guarantee every advanced workbook feature survives. Preserve the original and verify such requirements explicitly.
+
+## Check and deliver
+
+Run the shared checker with the selected Python executable; `<skill-directory>` is this loaded skill's resource base:
+
+```text
+<python> <skill-directory>/../scripts/check_office.py <workbook.xlsx> --out <checks.json>
+```
+
+It checks ZIP/XML integrity and internal relationships, and reports sheet names, populated-cell counts, and formula counts. Repeated `--contains TEXT` arguments check string cells and sheet names, and `--count N` checks sheet count. `--contains` excludes numeric cells and does not validate formula results; verify those separately with openpyxl. The checker does not calculate formulas or judge workbook appearance. Compare relevant values, types, formulas, styles, and totals with the task's source data.
+
+If `render_document` is available and visual inspection is useful, call it on the final workbook without `pages` to prepare page 1 and learn `pageCount`. It checks the current main model's actual image capability; do not choose a second model. On `status: "skipped"`, complete structural and data checks and deliver the workbook, briefly stating that visual layout was not inspected. Otherwise call `read_image` on `pages[].imagePath` and request remaining pages in small batches. Check column widths, number formats, clipping, charts, and print areas; fix the workbook and review the affected pages again.
+
+Rendered pages follow spreadsheet print settings, so a page is not necessarily a worksheet. Set appropriate print areas and scaling when a readable printed layout is requested. Review `warnings` such as missing fonts. LibreOffice preview conversion neither updates the original workbook's cached formulas nor certifies native Excel calculation or appearance. If rendering is unavailable or fails, preserve the usable workbook and report the inspection limit; ordinary cell edits do not require installing an external renderer.
+
+Call `present({"files":[{"path":"report.xlsx"}]})` with the actual final workbook path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and omit intermediate scripts and reports unless requested. If `present` is unavailable, use the session's supported file delivery method.

+ 228 - 0
packages/skill/skill-office/assets/scripts/check_office.py

@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""Read-only OOXML checks and structural summaries; no rendering or formula evaluation.
+
+Run with INPUT.docx, INPUT.pptx, or INPUT.xlsx. Optional --contains assertions
+check extracted text; DOCX excludes comments, glossary text, and unreferenced parts or notes.
+--count checks slides or sheets. JSON escapes non-ASCII
+characters and goes to stdout and optionally --out. Exit 0 means the requested
+structural checks passed, 1 means a document, assertion, or report write failed, and 2 means
+invalid command-line arguments.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import posixpath
+import sys
+import zipfile
+from pathlib import Path
+from urllib.parse import unquote, urlsplit
+from xml.etree import ElementTree as ET
+
+W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
+A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
+P = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
+S = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
+R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
+MAIN_PARTS = {
+    ".docx": ("word/document.xml", "wordprocessingml.document.main+xml"),
+    ".pptx": ("ppt/presentation.xml", "presentationml.presentation.main+xml"),
+    ".xlsx": ("xl/workbook.xml", "spreadsheetml.sheet.main+xml"),
+}
+
+
+def relationship_target(part: str, target: str) -> str:
+    """Resolve a package relationship without fetching external resources."""
+    path = unquote(urlsplit(target).path)
+    return posixpath.normpath(path.lstrip("/") if path.startswith("/") else posixpath.join(posixpath.dirname(part), path))
+
+
+def relationships(part: str, xml: dict[str, ET.Element], types: set[str] | None = None) -> dict[str, str]:
+    path = posixpath.join(posixpath.dirname(part), "_rels", posixpath.basename(part) + ".rels")
+    root = xml.get(path)
+    if root is None:
+        return {}
+    return {
+        rel.attrib["Id"]: relationship_target(part, rel.attrib["Target"])
+        for rel in root if rel.get("TargetMode") != "External" and (types is None or rel.get("Type") in types)
+    }
+
+
+def related_xml(part: str, reference: str, links: dict[str, str], xml: dict[str, ET.Element]) -> ET.Element:
+    """Read a related XML part with diagnostics naming its source and reference."""
+    if reference not in links:
+        raise ValueError(f"{part} references missing relationship: {reference}")
+    target = links[reference]
+    if target not in xml:
+        raise ValueError(f"{part} relationship {reference} targets a non-XML member: {target}")
+    return xml[target]
+
+
+def inspect_docx(xml: dict[str, ET.Element]) -> tuple[dict, str]:
+    part = "word/document.xml"
+    root = xml[part]
+    body = root.find(f"{W}body")
+    if body is None:
+        raise ValueError("word/document.xml has no document body")
+    tables = []
+    for table in body.iter(f"{W}tbl"):
+        grid = table.findall(f"{W}tblGrid/{W}gridCol")
+        rows = table.findall(f"{W}tr")
+        # Merged cells span logical grid columns; counting physical cells loses them.
+        columns = len(grid) if grid else max((sum(
+            int(cell.find(f"{W}tcPr/{W}gridSpan").get(f"{W}val", "1"))
+            if cell.find(f"{W}tcPr/{W}gridSpan") is not None else 1
+            for cell in row.findall(f"{W}tc")
+        ) for row in rows), default=0)
+        tables.append({"rows": len(rows), "columns": columns})
+    sections = []
+    for section in body.iter(f"{W}sectPr"):
+        size = section.find(f"{W}pgSz")
+        margins = section.find(f"{W}pgMar")
+        sections.append({
+            "page_twips": {} if size is None else {key.removeprefix(W): value for key, value in size.attrib.items()},
+            "margins_twips": {} if margins is None else {key.removeprefix(W): value for key, value in margins.attrib.items()},
+        })
+    text_parts = [body]
+    for kind in ("header", "footer"):
+        links = relationships(part, xml, {f"{R[1:-1]}/{kind}"})
+        for section in body.iter(f"{W}sectPr"):
+            for reference in section.findall(f"{W}{kind}Reference"):
+                text_parts.append(related_xml(part, reference.attrib[f"{R}id"], links, xml))
+    for kind in ("footnote", "endnote"):
+        references = {node.attrib[f"{W}id"] for node in body.iter(f"{W}{kind}Reference")}
+        if not references:
+            continue
+        links = relationships(part, xml, {f"{R[1:-1]}/{kind}s"})
+        for reference in links:
+            tree = related_xml(part, reference, links, xml)
+            text_parts.extend(note for note in tree.findall(f"{W}{kind}") if note.get(f"{W}id") in references)
+    text = "\n".join("".join(node.text or "" for node in paragraph.iter(f"{W}t")) for tree in text_parts
+                     for paragraph in tree.iter(f"{W}p"))
+    return {"paragraphs": len(list(body.iter(f"{W}p"))), "tables": tables, "sections": sections}, text
+
+
+def inspect_pptx(xml: dict[str, ET.Element]) -> tuple[dict, str]:
+    part = "ppt/presentation.xml"
+    links = relationships(part, xml)
+    slides = xml[part].findall(f"{P}sldIdLst/{P}sldId")
+    texts = []
+    for slide in slides:
+        reference = slide.attrib[f"{R}id"]
+        tree = related_xml(part, reference, links, xml)
+        texts.append("\n".join("".join(node.text or "" for node in paragraph.iter(f"{A}t"))
+                               for paragraph in tree.iter(f"{A}p")))
+    return {"slides": len(slides)}, "\n".join(texts)
+
+
+def inspect_xlsx(xml: dict[str, ET.Element]) -> tuple[dict, str]:
+    part = "xl/workbook.xml"
+    links = relationships(part, xml)
+    sheets = []
+    texts = []
+    shared = xml.get("xl/sharedStrings.xml")
+    shared_strings = [] if shared is None else [
+        "".join(node.text or "" for node in item.iter(f"{S}t")) for item in shared.iter(f"{S}si")
+    ]
+    for sheet in xml[part].findall(f"{S}sheets/{S}sheet"):
+        reference = sheet.attrib[f"{R}id"]
+        tree = related_xml(part, reference, links, xml)
+        cells = list(tree.iter(f"{S}c"))
+        formulas = sum(cell.find(f"{S}f") is not None for cell in cells)
+        sheets.append({"name": sheet.attrib["name"], "cells": len(cells), "formulas": formulas})
+        for cell in cells:
+            if cell.get("t") == "s":
+                value = cell.findtext(f"{S}v", "")
+                location = f"{links[reference]} cell {cell.get('r', '(no reference)')}"
+                try:
+                    index = int(value)
+                except ValueError as error:
+                    raise ValueError(f"{location}: invalid shared string index {value!r}") from error
+                if not 0 <= index < len(shared_strings):
+                    raise ValueError(f"{location}: shared string index out of range: {index}")
+                texts.append(shared_strings[index])
+        texts.extend("".join(node.text or "" for node in cell.iter(f"{S}t")) for cell in cells)
+        texts.extend(cell.findtext(f"{S}v", "") for cell in cells if cell.get("t") == "str")
+        texts.append(sheet.attrib["name"])
+    return {"sheets": sheets, "formulas_evaluated": False}, "\n".join(texts)
+
+
+def inspect(path: Path) -> tuple[dict, str]:
+    """Validate package members and relationships before inspecting the main part."""
+    main, content_type = MAIN_PARTS[path.suffix.lower()]
+    with zipfile.ZipFile(path) as archive:
+        members = archive.namelist()
+        if len(members) != len(set(members)):
+            raise ValueError("ZIP contains duplicate member names")
+        corrupt = archive.testzip()
+        if corrupt is not None:
+            raise ValueError(f"ZIP member failed its CRC check: {corrupt}")
+        xml = {name: ET.fromstring(archive.read(name)) for name in members
+               if name.endswith((".xml", ".rels"))}
+    if main not in xml:
+        raise ValueError(f"missing main part: {main}")
+    types = xml.get("[Content_Types].xml")
+    if types is None or not any(node.get("PartName") == "/" + main
+                                and node.get("ContentType", "").endswith(content_type) for node in types):
+        raise ValueError(f"[Content_Types].xml does not declare {main} as {path.suffix.lower()}")
+    for name, tree in xml.items():
+        if not name.endswith(".rels"):
+            continue
+        source = "" if name == "_rels/.rels" else posixpath.join(posixpath.dirname(posixpath.dirname(name)), posixpath.basename(name)[:-5])
+        for rel in tree:
+            if rel.get("TargetMode") == "External":
+                continue
+            target = relationship_target(source, rel.attrib["Target"])
+            if target not in members:
+                raise ValueError(f"{name} references missing package member: {target}")
+    if path.suffix.lower() == ".docx":
+        return inspect_docx(xml)
+    if path.suffix.lower() == ".pptx":
+        return inspect_pptx(xml)
+    return inspect_xlsx(xml)
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("input", type=Path)
+    parser.add_argument("--out", type=Path)
+    parser.add_argument("--contains", action="append", default=[], metavar="TEXT")
+    parser.add_argument("--count", type=int, help="expected slide or sheet count")
+    args = parser.parse_args()
+    if args.input.suffix.lower() not in MAIN_PARTS:
+        parser.error("input must be .docx, .pptx, or .xlsx; converting the filename does not convert its contents")
+    if args.count is not None and (args.count < 0 or args.input.suffix.lower() == ".docx"):
+        parser.error("--count must be non-negative and applies only to slides or sheets")
+    if args.out is not None and args.out.resolve() == args.input.resolve():
+        parser.error("--out must differ from the input document")
+    checks = []
+    summary = {}
+    try:
+        summary, text = inspect(args.input)
+        checks.append({"id": "package", "status": "pass"})
+        for required in args.contains:
+            checks.append({"id": "contains", "status": "pass" if required in text else "fail", "text": required})
+        if args.count is not None:
+            actual = summary.get("slides", len(summary.get("sheets", [])))
+            checks.append({"id": "count", "status": "pass" if actual == args.count else "fail", "expected": args.count, "actual": actual})
+    except (OSError, ValueError, KeyError, RuntimeError, ET.ParseError, zipfile.BadZipFile) as error:
+        checks.append({"id": "package", "status": "fail", "detail": str(error)})
+    failed = any(check["status"] == "fail" for check in checks)
+    report = {"format": args.input.suffix.lower()[1:], "verdict": "fail" if failed else "pass", "checks": checks, "summary": summary}
+    output = json.dumps(report, ensure_ascii=True, indent=2) + "\n"
+    if args.out is not None:
+        try:
+            args.out.parent.mkdir(parents=True, exist_ok=True)
+            args.out.write_text(output, encoding="utf-8")
+        except OSError as error:
+            failed = True
+            report["verdict"] = "fail"
+            checks.append({"id": "output", "status": "fail", "detail": str(error)})
+            output = json.dumps(report, ensure_ascii=True, indent=2) + "\n"
+    sys.stdout.write(output)
+    return 1 if failed else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 43 - 0
packages/skill/skill-office/package.json

@@ -0,0 +1,43 @@
+{
+  "name": "@deepseek-ai/dsh-skill-office",
+  "description": "Bundled Word, PowerPoint, and Excel workflows and structural checks",
+  "version": "0.1.6-alpha.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/skill/skill-office"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "assets",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-skill": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-skill": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^"
+  },
+  "dependencies": {
+    "@deepseek-ai/schemastery": "workspace:^",
+    "yaml": "^2.4.2"
+  }
+}

+ 70 - 0
packages/skill/skill-office/src/index.ts

@@ -0,0 +1,70 @@
+/** Bundled Office workflows and filesystem resources for document authoring and checks. */
+
+import { readFileSync, statSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import { isAbsolute, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import { BUNDLED_SKILL_RANK, type SkillCandidate, type SkillProvider } from '@deepseek-ai/dsh-skill'
+import { parse as parseYaml } from 'yaml'
+
+const SKILL_NAMES = ['office-docx', 'office-pptx', 'office-xlsx'] as const
+
+/** Office skill resource location. */
+export interface Config {
+  /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */
+  assetRoot?: string
+}
+
+/** Validated resource configuration. */
+export const Config: z<Config> = z.object({ assetRoot: z.string().min(1) })
+
+/** Cordis plugin identity. */
+export const name = 'skill-office'
+/** Registry used by the bundled provider. */
+export const inject = ['skills']
+
+function parseSkill(raw: string, path: string): { description: string; content: string } {
+  const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw)
+  if (frontmatter?.[1] === undefined) throw new Error(`skill-office: ${path} has no YAML frontmatter`)
+  const metadata: unknown = parseYaml(frontmatter[1])
+  const description = typeof metadata === 'object' && metadata !== null && 'description' in metadata
+    ? metadata.description : undefined
+  if (typeof description !== 'string' || description.length === 0) throw new Error(`skill-office: ${path} has no description`)
+  return { description, content: raw.slice(frontmatter[0].length).trim() }
+}
+
+/**
+ * Register Office skills with resources readable by the script interpreter.
+ * @param ctx - Context carrying the skill registry.
+ * @param config - Optional external assets directory for packaged applications.
+ */
+export function apply(ctx: Context, config: Config = {}): void {
+  const assetRoot = config.assetRoot ?? fileURLToPath(new URL('../assets/', import.meta.url))
+  if (!isAbsolute(assetRoot)) throw new Error('skill-office: assetRoot must be an absolute directory')
+  if (!statSync(join(assetRoot, 'scripts', 'check_office.py')).isFile()) {
+    throw new Error('skill-office: assets must contain scripts/check_office.py')
+  }
+  const candidates: SkillCandidate[] = SKILL_NAMES.map((skillName) => {
+    const directory = join(assetRoot, skillName)
+    const path = join(directory, 'SKILL.md')
+    const { description } = parseSkill(readFileSync(path, 'utf8'), path)
+    return {
+      name: skillName, description,
+      invocation: { modelInvocable: true, userInvocable: true },
+      provider: 'dsh-office', source: 'bundled', rank: BUNDLED_SKILL_RANK,
+      resourceBase: { kind: 'directory', path: directory }, locator: path,
+    }
+  })
+  const provider: SkillProvider = {
+    name: 'dsh-office',
+    list: () => Promise.resolve(candidates),
+    async get(candidate, options) {
+      const { rank: _rank, locator, ...summary } = candidate
+      const raw = await readFile(locator as string, { encoding: 'utf8', signal: options.signal })
+      return { ...summary, content: parseSkill(raw, locator as string).content }
+    },
+  }
+  ctx.skills.registerProvider(() => provider)
+}

+ 228 - 0
packages/skill/skill-office/tests/check_office_test.py

@@ -0,0 +1,228 @@
+"""Exercise the distributed checker without requiring external Office libraries."""
+
+import json
+import subprocess
+import sys
+import tempfile
+import unittest
+import zipfile
+from pathlib import Path
+
+CHECKER = Path(__file__).resolve().parents[1] / "assets/scripts/check_office.py"
+W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+P = "http://schemas.openxmlformats.org/presentationml/2006/main"
+A = "http://schemas.openxmlformats.org/drawingml/2006/main"
+S = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
+R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+PKG = "http://schemas.openxmlformats.org/package/2006/relationships"
+
+
+class OfficeCheckTest(unittest.TestCase):
+    def setUp(self):
+        self.directory = tempfile.TemporaryDirectory(prefix="dsh-office-check-")
+        self.addCleanup(self.directory.cleanup)
+        self.root = Path(self.directory.name)
+
+    def package(self, suffix, parts):
+        primary, mime = {
+            "docx": ("word/document.xml", "wordprocessingml.document.main+xml"),
+            "pptx": ("ppt/presentation.xml", "presentationml.presentation.main+xml"),
+            "xlsx": ("xl/workbook.xml", "spreadsheetml.sheet.main+xml"),
+        }[suffix]
+        path = self.root / ("中文 document." + suffix)
+        with zipfile.ZipFile(path, "w") as archive:
+            archive.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
+                             f'<Override PartName="/{primary}" ContentType="application/vnd.openxmlformats-officedocument.{mime}"/></Types>')
+            for name, content in parts.items():
+                archive.writestr(name, content)
+        return path
+
+    def run_check(self, path, *args):
+        before = path.read_bytes()
+        output = self.root / "checks.json"
+        result = subprocess.run([sys.executable, str(CHECKER), str(path), "--out", str(output), *args],
+                                capture_output=True, text=True, timeout=10)
+        self.assertEqual(path.read_bytes(), before)
+        self.assertEqual(result.stderr, "", result.stderr)
+        report = json.loads(result.stdout)
+        self.assertEqual(report, json.loads(output.read_text(encoding="utf-8")))
+        return result.returncode, report
+
+    def test_docx_merged_cells_distinct_sections_and_cjk_are_not_layout_failures(self):
+        path = self.package("docx", {"word/document.xml": f'''<w:document xmlns:w="{W}"><w:body>
+          <w:p><w:r><w:t>中文报告</w:t></w:r><w:pPr><w:sectPr><w:pgSz w:w="16000"/><w:pgMar w:left="1000" w:right="1000"/></w:sectPr></w:pPr></w:p>
+          <w:tbl><w:tblGrid><w:gridCol w:w="5000"/><w:gridCol w:w="5000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p><w:r><w:t>合并标题</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
+          <w:sectPr><w:pgSz w:w="6000"/><w:pgMar w:left="1000" w:right="1000"/></w:sectPr>
+        </w:body></w:document>'''})
+        code, report = self.run_check(path, "--contains", "中文报告")
+        self.assertEqual(code, 0)
+        self.assertEqual(report["summary"]["tables"], [{"rows": 1, "columns": 2}])
+        self.assertEqual(len(report["summary"]["sections"]), 2)
+        code, report = self.run_check(path, "--contains", "Missing requested title")
+        self.assertEqual(code, 1)
+        self.assertEqual(report["checks"][-1]["status"], "fail")
+
+    def test_pptx_checks_slide_count_and_relationships(self):
+        parts = {
+            "ppt/presentation.xml": f'<p:presentation xmlns:p="{P}" xmlns:r="{R}"><p:sldIdLst><p:sldId id="256" r:id="r1"/></p:sldIdLst></p:presentation>',
+            "ppt/_rels/presentation.xml.rels": f'<Relationships xmlns="{PKG}"><Relationship Id="r1" Target="slides/slide1.xml"/></Relationships>',
+            "ppt/slides/slide1.xml": f'<p:sld xmlns:p="{P}" xmlns:a="{A}"><a:p><a:r><a:t>季度总结</a:t></a:r></a:p></p:sld>',
+        }
+        path = self.package("pptx", parts)
+        self.assertEqual(self.run_check(path, "--contains", "季度总结", "--count", "1")[0], 0)
+        self.assertEqual(self.run_check(path, "--count", "2")[0], 1)
+        parts["ppt/_rels/presentation.xml.rels"] = f'<Relationships xmlns="{PKG}"/>'
+        code, report = self.run_check(self.package("pptx", parts))
+        self.assertEqual(code, 1)
+        self.assertIn("ppt/presentation.xml", report["checks"][0]["detail"])
+        self.assertIn("r1", report["checks"][0]["detail"])
+        parts["ppt/_rels/presentation.xml.rels"] = f'<Relationships xmlns="{PKG}"><Relationship Id="r1" Target="slides/slide1.xml"/></Relationships>'
+        del parts["ppt/slides/slide1.xml"]
+        path = self.package("pptx", parts)
+        code, report = self.run_check(path)
+        self.assertEqual(code, 1)
+        self.assertIn("missing package member", report["checks"][0]["detail"])
+
+    def test_docx_content_follows_section_and_note_references(self):
+        parts = {
+            "word/document.xml": f'''<w:document xmlns:w="{W}" xmlns:r="{R}"><w:body>
+              <w:p><w:r><w:t>Main text</w:t><w:footnoteReference w:id="2"/><w:endnoteReference w:id="3"/></w:r></w:p>
+              <w:sectPr><w:headerReference w:type="default" r:id="header"/><w:footerReference w:type="default" r:id="footer"/></w:sectPr>
+            </w:body></w:document>''',
+            "word/_rels/document.xml.rels": f'''<Relationships xmlns="{PKG}">
+              <Relationship Id="header" Type="{R}/header" Target="custom/header.xml"/>
+              <Relationship Id="footer" Type="{R}/footer" Target="custom/footer.xml"/>
+              <Relationship Id="unused-header" Type="{R}/header" Target="custom/unused-header.xml"/>
+              <Relationship Id="unused-footer" Type="{R}/footer" Target="custom/unused-footer.xml"/>
+              <Relationship Id="notes" Type="{R}/footnotes" Target="custom/notes.xml"/>
+              <Relationship Id="endnotes" Type="{R}/endnotes" Target="custom/endnotes.xml"/>
+              <Relationship Id="comments" Type="{R}/comments" Target="comments.xml"/>
+              <Relationship Id="glossary" Type="{R}/glossaryDocument" Target="glossary/document.xml"/>
+            </Relationships>''',
+            "word/custom/header.xml": f'<w:hdr xmlns:w="{W}"><w:p><w:r><w:t>Linked header</w:t></w:r></w:p></w:hdr>',
+            "word/custom/footer.xml": f'<w:ftr xmlns:w="{W}"><w:p><w:r><w:t>Linked footer</w:t></w:r></w:p></w:ftr>',
+            "word/custom/unused-header.xml": f'<w:hdr xmlns:w="{W}"><w:p><w:r><w:t>Retained header only</w:t></w:r></w:p></w:hdr>',
+            "word/custom/unused-footer.xml": f'<w:ftr xmlns:w="{W}"><w:p><w:r><w:t>Retained footer only</w:t></w:r></w:p></w:ftr>',
+            "word/custom/notes.xml": f'''<w:footnotes xmlns:w="{W}">
+              <w:footnote w:id="2"><w:p><w:r><w:t>Linked note</w:t></w:r></w:p></w:footnote>
+              <w:footnote w:id="4"><w:p><w:r><w:t>Unused note only</w:t></w:r></w:p></w:footnote>
+            </w:footnotes>''',
+            "word/custom/endnotes.xml": f'''<w:endnotes xmlns:w="{W}">
+              <w:endnote w:id="3"><w:p><w:r><w:t>Linked endnote</w:t></w:r></w:p></w:endnote>
+              <w:endnote w:id="5"><w:p><w:r><w:t>Unused endnote only</w:t></w:r></w:p></w:endnote>
+            </w:endnotes>''',
+            "word/comments.xml": f'<w:comments xmlns:w="{W}"><w:comment><w:p><w:r><w:t>Comment only</w:t></w:r></w:p></w:comment></w:comments>',
+            "word/glossary/document.xml": f'<w:glossaryDocument xmlns:w="{W}"><w:p><w:r><w:t>Building block only</w:t></w:r></w:p></w:glossaryDocument>',
+            "word/header9.xml": f'<w:hdr xmlns:w="{W}"><w:p><w:r><w:t>Unused header only</w:t></w:r></w:p></w:hdr>',
+        }
+        path = self.package("docx", parts)
+        self.assertEqual(self.run_check(path, "--contains", "Main text", "--contains", "Linked header", "--contains", "Linked footer",
+                                        "--contains", "Linked note", "--contains", "Linked endnote")[0], 0)
+        for text in ["Comment only", "Building block only", "Unused header only", "Retained header only", "Retained footer only",
+                     "Unused note only", "Unused endnote only"]:
+            with self.subTest(text=text):
+                code, report = self.run_check(path, "--contains", text)
+                self.assertEqual(code, 1)
+                self.assertEqual(report["checks"][-1]["status"], "fail")
+
+    def test_relationships_to_non_xml_members_report_source_reference_and_target(self):
+        for suffix, main, references in [
+            ("pptx", "ppt/presentation.xml", f'<p:presentation xmlns:p="{P}" xmlns:r="{R}"><p:sldIdLst><p:sldId id="256" r:id="r1"/></p:sldIdLst></p:presentation>'),
+            ("xlsx", "xl/workbook.xml", f'<workbook xmlns="{S}" xmlns:r="{R}"><sheets><sheet name="Data" sheetId="1" r:id="r1"/></sheets></workbook>'),
+        ]:
+            with self.subTest(format=suffix):
+                folder, filename = main.split("/")
+                target = f"{folder}/media/image1.png"
+                path = self.package(suffix, {
+                    main: references,
+                    f"{folder}/_rels/{filename}.rels": f'<Relationships xmlns="{PKG}"><Relationship Id="r1" Target="media/image1.png"/></Relationships>',
+                    target: b"image bytes",
+                })
+                code, report = self.run_check(path)
+                self.assertEqual(code, 1)
+                detail = report["checks"][0]["detail"]
+                for expected in [main, "r1", target]:
+                    self.assertIn(expected, detail)
+
+    def test_xlsx_counts_formulas_without_claiming_recalculation(self):
+        path = self.package("xlsx", {
+            "xl/workbook.xml": f'<workbook xmlns="{S}" xmlns:r="{R}"><sheets><sheet name="Data" sheetId="1" r:id="r1"/></sheets></workbook>',
+            "xl/_rels/workbook.xml.rels": f'<Relationships xmlns="{PKG}"><Relationship Id="r1" Target="/xl/worksheets/sheet1.xml"/></Relationships>',
+            "xl/worksheets/sheet1.xml": f'<worksheet xmlns="{S}"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>收入</t></is></c><c r="B1"><v>4</v></c><c r="C1"><f>B1*2</f><v/></c></row></sheetData></worksheet>',
+        })
+        code, report = self.run_check(path, "--contains", "收入", "--count", "1")
+        self.assertEqual(code, 0)
+        self.assertEqual(report["summary"], {"sheets": [{"name": "Data", "cells": 3, "formulas": 1}], "formulas_evaluated": False})
+
+    def test_xlsx_checks_only_cell_referenced_shared_strings(self):
+        parts = {
+            "xl/workbook.xml": f'<workbook xmlns="{S}" xmlns:r="{R}"><sheets><sheet name="Data" sheetId="1" r:id="r1"/></sheets></workbook>',
+            "xl/_rels/workbook.xml.rels": f'<Relationships xmlns="{PKG}"><Relationship Id="r1" Target="/xl/worksheets/sheet1.xml"/></Relationships>',
+            "xl/sharedStrings.xml": f'<sst xmlns="{S}"><si><t>Deleted value</t></si><si><r><t>实际</t></r><r><t>内容</t></r></si></sst>',
+            "xl/worksheets/sheet1.xml": f'<worksheet xmlns="{S}"><sheetData><row r="1"><c r="A1" t="s"><v>1</v></c><c r="B1" t="inlineStr"><is><t>Inline value</t></is></c></row></sheetData></worksheet>',
+        }
+        path = self.package("xlsx", parts)
+        self.assertEqual(self.run_check(path, "--contains", "实际内容", "--contains", "Inline value")[0], 0)
+        code, report = self.run_check(path, "--contains", "Deleted value")
+        self.assertEqual(code, 1)
+        self.assertEqual(report["checks"][-1]["status"], "fail")
+        for index in ["-1", "2", "invalid", ""]:
+            with self.subTest(index=index):
+                parts["xl/worksheets/sheet1.xml"] = f'<worksheet xmlns="{S}"><sheetData><row r="1"><c r="A1" t="s"><v>{index}</v></c></row></sheetData></worksheet>'
+                code, report = self.run_check(self.package("xlsx", parts))
+                self.assertEqual(code, 1)
+                self.assertEqual(report["checks"][0]["status"], "fail")
+                self.assertIn("xl/worksheets/sheet1.xml", report["checks"][0]["detail"])
+                self.assertIn("A1", report["checks"][0]["detail"])
+        parts["xl/_rels/workbook.xml.rels"] = f'<Relationships xmlns="{PKG}"/>'
+        code, report = self.run_check(self.package("xlsx", parts))
+        self.assertEqual(code, 1)
+        self.assertIn("xl/workbook.xml", report["checks"][0]["detail"])
+        self.assertIn("r1", report["checks"][0]["detail"])
+
+    def test_xlsx_empty_or_missing_string_values_remain_json_reports(self):
+        for cell_type, expected in [("s", 1), ("str", 0)]:
+            for value in ["<v/>", ""]:
+                with self.subTest(cell_type=cell_type, value=value):
+                    path = self.package("xlsx", {
+                        "xl/workbook.xml": f'<workbook xmlns="{S}" xmlns:r="{R}"><sheets><sheet name="Data" sheetId="1" r:id="r1"/></sheets></workbook>',
+                        "xl/_rels/workbook.xml.rels": f'<Relationships xmlns="{PKG}"><Relationship Id="r1" Target="worksheets/sheet1.xml"/></Relationships>',
+                        "xl/worksheets/sheet1.xml": f'<worksheet xmlns="{S}"><sheetData><row r="1"><c r="A1" t="{cell_type}">{value}</c></row></sheetData></worksheet>',
+                    })
+                    code, report = self.run_check(path)
+                    self.assertEqual(code, expected)
+                    self.assertEqual(report["verdict"], "fail" if expected else "pass")
+                    if cell_type == "s":
+                        self.assertIn("A1: invalid shared string index ''", report["checks"][0]["detail"])
+
+    def test_output_directory_failure_is_json_and_preserves_the_document(self):
+        path = self.package("docx", {"word/document.xml": f'<w:document xmlns:w="{W}"><w:body/></w:document>'})
+        before = path.read_bytes()
+        result = subprocess.run([sys.executable, str(CHECKER), str(path), "--out", str(self.root)], capture_output=True, text=True, timeout=10)
+        self.assertEqual(result.returncode, 1)
+        self.assertEqual(result.stderr, "")
+        report = json.loads(result.stdout)
+        self.assertEqual(report["verdict"], "fail")
+        self.assertEqual(report["checks"][-1]["id"], "output")
+        self.assertEqual(path.read_bytes(), before)
+
+    def test_encrypted_package_returns_json_failure(self):
+        path = Path(__file__).parent / "fixtures/encrypted.xlsx"
+        code, report = self.run_check(path)
+        self.assertEqual(code, 1)
+        self.assertIn("encrypted", report["checks"][0]["detail"])
+
+    def test_invalid_zip_and_xml_fail_and_output_cannot_overwrite_input(self):
+        path = self.root / "broken.docx"
+        path.write_bytes(b"not an Office archive")
+        self.assertEqual(self.run_check(path)[0], 1)
+        path = self.package("docx", {"word/document.xml": "<broken>"})
+        self.assertEqual(self.run_check(path)[0], 1)
+        before = path.read_bytes()
+        result = subprocess.run([sys.executable, str(CHECKER), str(path), "--out", str(path)], capture_output=True, timeout=10)
+        self.assertEqual(result.returncode, 2)
+        self.assertEqual(path.read_bytes(), before)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 14 - 0
packages/skill/skill-office/tests/checkers.spec.ts

@@ -0,0 +1,14 @@
+import { spawnSync } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+import { expect, it } from 'vitest'
+
+it.each(['utf-8', 'cp1252'])('executes the shipped OOXML checker against valid, edited, and broken documents with %s stdout', (encoding) => {
+  const python = process.env.DSH_OFFICE_TEST_PYTHON ?? (process.platform === 'win32' ? 'python' : 'python3')
+  const result = spawnSync(python, [fileURLToPath(new URL('./check_office_test.py', import.meta.url))], {
+    env: { ...process.env, PYTHONIOENCODING: encoding },
+    encoding: 'utf8', timeout: 30_000,
+  })
+  expect(result.error).toBeUndefined()
+  expect(result.signal, result.stderr + result.stdout).toBeNull()
+  expect(result.status, result.stderr + result.stdout).toBe(0)
+})

BIN
packages/skill/skill-office/tests/fixtures/encrypted.xlsx


+ 124 - 0
packages/skill/skill-office/tests/skill-office.spec.ts

@@ -0,0 +1,124 @@
+import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import SkillRegistry from '@deepseek-ai/dsh-skill'
+import * as SkillOffice from '@deepseek-ai/dsh-skill-office'
+import { describe, expect, it } from 'vitest'
+
+const assets = fileURLToPath(new URL('../assets/', import.meta.url))
+const names = ['office-docx', 'office-pptx', 'office-xlsx']
+
+describe('bundled Office skills', () => {
+  it('loads each packaged body and removes all candidates on disposal', async () => {
+    const ctx = new Context()
+    try {
+      await ctx.plugin(SkillRegistry)
+      const fiber = await ctx.plugin(SkillOffice)
+      const catalog = await ctx.skills.list()
+      expect(catalog.map(skill => skill.name)).toEqual(names)
+      for (const skill of catalog) {
+        expect(skill.description.length).toBeLessThanOrEqual(500)
+        expect(skill).toMatchObject({ source: 'bundled', provider: 'dsh-office', invocation: { modelInvocable: true, userInvocable: true } })
+        const loaded = await ctx.skills.get(skill.name)
+        expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: join(assets, skill.name) })
+        const raw = await readFile(join(assets, skill.name, 'SKILL.md'), 'utf8')
+        expect(loaded?.content).toBe(raw.slice(raw.indexOf('\n---\n') + 5).trim())
+      }
+      await fiber.dispose()
+      expect(await ctx.skills.list()).toEqual([])
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('loads relocated resources through a real cordis.yml composition', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-office-skills-'))
+    const ctx = new Context()
+    try {
+      const external = join(root, '中文 assets')
+      await cp(assets, external, { recursive: true })
+      const configPath = join(root, 'cordis.yml')
+      await writeFile(configPath, [
+        "- name: '@deepseek-ai/dsh-skill'",
+        "- name: '@deepseek-ai/dsh-skill-office'",
+        '  config:',
+        `    assetRoot: ${JSON.stringify(external)}`,
+        '',
+      ].join('\n'))
+      ctx.baseUrl = pathToFileURL(root).href + '/'
+      await ctx.plugin(Loader)
+      ctx.loader.builtins.include = Include
+      const modules = new Map<string, unknown>([
+        ['@deepseek-ai/dsh-skill', SkillRegistry],
+        ['@deepseek-ai/dsh-skill-office', SkillOffice],
+      ])
+      ctx.loader.internal = {
+        version: 'v2',
+        async import(specifier: string) {
+          if (!modules.has(specifier)) throw new Error(`Unexpected Loader import: ${specifier}`)
+          return modules.get(specifier)
+        },
+      } as unknown as NonNullable<typeof ctx.loader.internal>
+      await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
+      await ctx.loader.await()
+      expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names)
+      const loaded = await ctx.skills.get('office-xlsx')
+      expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: join(external, 'office-xlsx') })
+      const raw = await readFile(join(external, 'office-xlsx', 'SKILL.md'), 'utf8')
+      expect(loaded?.content).toBe(raw.slice(raw.indexOf('\n---\n') + 5).trim())
+    } finally {
+      await ctx.fiber.dispose()
+      await rm(root, { recursive: true, force: true })
+    }
+  })
+
+  it.each([
+    ['description: "Quoted: description"', '\r\n', 'Quoted: description'],
+    ['description: >-\n  Folded\n  description', '\n', 'Folded description'],
+  ])('parses frontmatter %s and leaves body metadata-like lines intact', async (header, newline, description) => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-office-metadata-'))
+    const ctx = new Context()
+    try {
+      await cp(assets, root, { recursive: true })
+      const body = '# Office instructions\n\ndescription: instruction text'
+      await writeFile(join(root, 'office-docx', 'SKILL.md'), `---\n${header}\n---\n\n${body}\n`.replaceAll('\n', newline))
+      await ctx.plugin(SkillRegistry)
+      await ctx.plugin(SkillOffice, { assetRoot: root })
+      const skill = await ctx.skills.get('office-docx')
+      expect(skill?.description).toBe(description)
+      expect(skill?.content).toBe(body.replaceAll('\n', newline))
+    } finally {
+      await ctx.fiber.dispose()
+      await rm(root, { recursive: true, force: true })
+    }
+  })
+
+  it('rejects relative resource paths and incomplete asset trees before registration', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-office-invalid-'))
+    const ctx = new Context()
+    try {
+      await ctx.plugin(SkillRegistry)
+      expect(() => { SkillOffice.apply(ctx, { assetRoot: 'assets' }) }).toThrow('absolute directory')
+      await cp(assets, root, { recursive: true })
+      await rm(join(root, 'scripts', 'check_office.py'))
+      await cp(join(assets, 'office-docx'), join(root, 'scripts', 'check_office.py'), { recursive: true })
+      expect(() => { SkillOffice.apply(ctx, { assetRoot: root }) }).toThrow('scripts/check_office.py')
+      await rm(join(root, 'scripts', 'check_office.py'), { recursive: true })
+      await cp(join(assets, 'scripts', 'check_office.py'), join(root, 'scripts', 'check_office.py'))
+      await writeFile(join(root, 'office-docx', 'SKILL.md'), '# Missing frontmatter\ndescription: body text\n')
+      expect(() => { SkillOffice.apply(ctx, { assetRoot: root }) }).toThrow('has no YAML frontmatter')
+      for (const header of ['', 'null', 'scalar', 'name: office-docx', 'description: 3', 'description: ""']) {
+        await writeFile(join(root, 'office-docx', 'SKILL.md'), `---\n${header}\n---\n# Instructions\n`)
+        expect(() => { SkillOffice.apply(ctx, { assetRoot: root }) }).toThrow('has no description')
+      }
+      expect(await ctx.skills.list()).toEqual([])
+    } finally {
+      await ctx.fiber.dispose()
+      await rm(root, { recursive: true, force: true })
+    }
+  })
+})

+ 24 - 0
packages/skill/skill-office/tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../skill"
+    }
+  ]
+}

+ 28 - 0
pnpm-lock.yaml

@@ -48,6 +48,9 @@ importers:
       '@deepseek-ai/dsh-llm-replay':
         specifier: workspace:^
         version: link:packages/test-support/llm-replay
+      '@deepseek-ai/dsh-skill-office':
+        specifier: workspace:^
+        version: link:packages/skill/skill-office
       '@deepseek-ai/dsh-tool-session-query':
         specifier: workspace:^
         version: link:packages/session-query/tool-session-query
@@ -608,6 +611,9 @@ importers:
       '@deepseek-ai/dsh-host-webserver':
         specifier: workspace:^
         version: link:../../packages/host/webserver
+      '@deepseek-ai/dsh-skill-office':
+        specifier: workspace:^
+        version: link:../../packages/skill/skill-office
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../packages/core/tools
@@ -10006,6 +10012,28 @@ importers:
         specifier: workspace:^
         version: link:../skill
 
+  packages/skill/skill-office:
+    dependencies:
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
+      yaml:
+        specifier: ^2.4.2
+        version: 2.9.0
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-skill':
+        specifier: workspace:^
+        version: link:../skill
+
   packages/skill/tool-skill:
     dependencies:
       '@deepseek-ai/schemastery':

+ 8 - 0
scripts/check-workspace-constraints.spec.ts

@@ -198,3 +198,11 @@ it('requires the shared Web injection entry in the published payload', () => {
     ...manifest, files: ['lib/index.js', 'lib/**/*.css', 'lib/types/**/*.d.ts'],
   } })).toEqual([expect.stringContaining('package.json files must be')])
 })
+
+it('requires Office skill bodies and helpers in the published payload', () => {
+  const manifest = JSON.parse(readFileSync(new URL('../packages/skill/skill-office/package.json', import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
+  expect(checkWorkspaceManifest({ dir: 'packages/skill/skill-office', manifest })).toEqual([])
+  expect(checkWorkspaceManifest({ dir: 'packages/skill/skill-office', manifest: {
+    ...manifest, files: ['lib/index.js', 'lib/types/**/*.d.ts'],
+  } })).toEqual([expect.stringContaining('package.json files must be')])
+})

+ 1 - 0
scripts/check-workspace-constraints.ts

@@ -180,6 +180,7 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   // also shares its generated FFI code through a hashed runtime chunk.
   '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'],
   '@deepseek-ai/dsh-skill-badge': ['assets'],
+  '@deepseek-ai/dsh-skill-office': ['assets'],
   '@deepseek-ai/dsh-subprocess': ['lib/control.js'],
   // SSH launches a private helper and shares wire definitions and TLS setup
   // between that helper and the connection owner.

+ 1 - 1
scripts/gen-doc-graphs.ts

@@ -485,7 +485,7 @@ const SERVICE_ROLES: ServiceRole[] = [
     pkg: 'skill',
     title: 'Skill provider registry',
     mode: 'seam',
-    implementations: ['skill-badge', 'skill-filesystem'],
+    implementations: ['skill-badge', 'skill-filesystem', 'skill-office'],
     consumers: ['tool-skill'],
     note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.',
   },

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -175,6 +175,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/identity/anonymous-user-id': { kind: 'none', reason: 'The shared identifier reaches DeepSeek only as model-hidden HTTP metadata; it registers nothing model-facing.' },
   'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
   'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' },
+  'packages/skill/skill-office': { kind: 'indirect', reason: 'The bundled Office provider delegates model rendering to dsh-tool-skill.' },
   'packages/skill/skill-filesystem': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
   'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
   'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },

+ 3 - 0
snapshots/session/headless.snapshot.ts

@@ -459,6 +459,9 @@ async function seedWorkspace(scenario: HeadlessScenario, cwd: string): Promise<v
 }
 
 const workspaceSetups: Record<string, (cwd: string) => Promise<void>> = {
+  async 'office-skills'(cwd) {
+    await cp(join(repoRoot, 'packages/skill/skill-office/assets'), join(cwd, 'office-skills'), { recursive: true })
+  },
   async 'editing-cordis-skill'(cwd) {
     const target = join(cwd, '.dsh', 'skills', 'editing-cordis-compositions', 'SKILL.md')
     await mkdir(dirname(target), { recursive: true })

+ 22 - 0
snapshots/session/office-skills-no-renderer/cordis.snapshot.yml

@@ -0,0 +1,22 @@
+- id: llm-deepseek
+  disabled: true
+
+- insert:
+    - id: llm-replay
+      name: '@deepseek-ai/dsh-llm-replay'
+      config:
+        providers:
+          - id: deepseek-official
+            name: DeepSeek
+            models:
+              - id: deepseek-v4-flash
+                contextWindow: 1000000
+                defaultMaxTokens: 8192
+                reasoningEfforts: [off, low, high, max]
+                defaultReasoningEffort: high
+
+- insert:
+    - id: office-skills
+      name: '@deepseek-ai/dsh-skill-office'
+      config:
+        assetRoot: !!js process.cwd() + '/office-skills'

+ 14 - 0
snapshots/session/office-skills-no-renderer/cordis.yml

@@ -0,0 +1,14 @@
+- insert:
+    - id: office-skills
+      name: '@deepseek-ai/dsh-skill-office'
+      config:
+        assetRoot: !!js process.cwd() + '/office-skills'
+
+- id: llm-deepseek
+  config:
+    protocol: chat-completions
+    thinking: enabled
+    reasoningEffort: high
+    maxTokens: 8192
+    models:
+      - id: deepseek-v4-flash

Разница между файлами не показана из-за своего большого размера
+ 11 - 0
snapshots/session/office-skills-no-renderer/session.v3.jsonl


+ 12 - 0
snapshots/session/office-skills-no-renderer/snapshot.yml

@@ -0,0 +1,12 @@
+version: 1
+scenario: office-skills-no-renderer
+profile: headless
+composition: office-skills-no-renderer
+recording: live
+header:
+  class: office-skills-no-renderer
+  pin: true
+  systemPromptSource: text-turn
+  toolSchemasSource: text-turn
+workspace:
+  setup: office-skills

+ 1 - 0
snapshots/session/office-skills-no-renderer/system-prompt.expected.md

@@ -0,0 +1 @@
+../text-turn/system-prompt.expected.md

+ 1 - 0
snapshots/session/office-skills-no-renderer/tool-schemas.expected.json

@@ -0,0 +1 @@
+../text-turn/tool-schemas.expected.json

+ 51 - 0
snapshots/session/office-skills/cordis.snapshot.yml

@@ -0,0 +1,51 @@
+# Replay patch shared by the ordinary headless snapshot composition. The model
+# script comes from the scenario's committed session JSONL.
+
+- id: llm-deepseek
+  name: '@deepseek-ai/dsh-llm-deepseek'
+  disabled: true
+
+- id: plugin-package-inventory-deepseek
+  disabled: true
+
+- id: session-title-llm
+  disabled: true
+
+- id: session-persistence-jsonl
+  name: '@deepseek-ai/dsh-session-persistence-jsonl'
+  config:
+    root: !!js dshHomePath('sessions')
+    compression: none
+
+- id: sandbox
+  name: '@deepseek-ai/dsh-sandbox-local'
+  config:
+    runnerCommand:
+      - bash
+      - -c
+      - while [ "$1" != "--" ]; do shift; done; shift; exec "$@"
+      - passthrough-runner
+    runnerFailureSignatures:
+      - 'passthrough-runner: profile rejected'
+
+- insert:
+    - id: llm-replay
+      name: '@deepseek-ai/dsh-llm-replay'
+      config:
+        providers:
+          - id: deepseek-official
+            name: DeepSeek
+            models:
+              - id: deepseek-v4-flash
+              - id: deepseek-v4-pro
+          - id: deepseek-messages
+            name: DeepSeek Messages
+            models:
+              - id: deepseek-v4-flash
+              - id: deepseek-v4-pro
+
+- insert:
+    - id: office-skills
+      name: '@deepseek-ai/dsh-skill-office'
+      config:
+        assetRoot: !!js process.cwd() + '/office-skills'

+ 5 - 0
snapshots/session/office-skills/cordis.yml

@@ -0,0 +1,5 @@
+- insert:
+    - id: office-skills
+      name: '@deepseek-ai/dsh-skill-office'
+      config:
+        assetRoot: !!js process.cwd() + '/office-skills'

Разница между файлами не показана из-за своего большого размера
+ 11 - 0
snapshots/session/office-skills/session.v3.jsonl


+ 12 - 0
snapshots/session/office-skills/snapshot.yml

@@ -0,0 +1,12 @@
+version: 1
+scenario: office-skills
+profile: headless
+composition: office-skills
+recording: authored
+header:
+  class: office-skills
+  pin: true
+  systemPromptSource: text-turn
+  toolSchemasSource: text-turn
+workspace:
+  setup: office-skills

+ 1 - 0
snapshots/session/office-skills/system-prompt.expected.md

@@ -0,0 +1 @@
+../text-turn/system-prompt.expected.md

+ 1 - 0
snapshots/session/office-skills/tool-schemas.expected.json

@@ -0,0 +1 @@
+../text-turn/tool-schemas.expected.json

+ 1 - 0
tsconfig.base.json

@@ -421,6 +421,7 @@
       "@deepseek-ai/dsh-skill": ["./packages/skill/skill/src"],
       "@deepseek-ai/dsh-skill-badge": ["./packages/skill/skill-badge/src"],
       "@deepseek-ai/dsh-skill-filesystem": ["./packages/skill/skill-filesystem/src"],
+      "@deepseek-ai/dsh-skill-office": ["./packages/skill/skill-office/src"],
       "@deepseek-ai/dsh-spill": ["./packages/spill/spill/src"],
       "@deepseek-ai/dsh-spill-local": ["./packages/spill/spill-local/src"],
       "@deepseek-ai/dsh-spill-policy": ["./packages/spill/spill-policy/src"],

+ 1 - 0
tsconfig.host.json

@@ -149,6 +149,7 @@
     "scripts/client-bundle-purity.spec.ts"
   ],
   "references": [
+    { "path": "./packages/skill/skill-office" },
     { "path": "./packages/browser-use/browser-use" },
     { "path": "./packages/experimental/browser-use-runtime" },
     { "path": "./packages/experimental/browser-use-playwright-mcp" },

Некоторые файлы не были показаны из-за большого количества измененных файлов