mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
feat(office): add progressive skills and direct dsoffice commands
This commit is contained in:
1 parent
fddd704c7f
commit
379f9cd47b
308 files changed
+5344
-1012
No files matched your search
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-progressive-office-skills.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md
|
||||
/agent-note-progressive-office-skills-with-ordinary-shell-execution:
|
||||
en: 42e2f062c37e9ef1
|
||||
zh: 5cd3d421adec1cac
|
||||
/agent-note-progressive-office-skills-with-ordinary-shell-execution/problem:
|
||||
en: a9984a594cdac98d
|
||||
zh: 38768e7699bc8d39
|
||||
/agent-note-progressive-office-skills-with-ordinary-shell-execution/decision:
|
||||
en: 2853f160f97f418e
|
||||
zh: 5d32d39d0075399c
|
||||
/agent-note-progressive-office-skills-with-ordinary-shell-execution/alternatives-considered:
|
||||
en: 6cdd4d8c54ac419a
|
||||
zh: 1cfeb74d50993632
|
||||
/agent-note-progressive-office-skills-with-ordinary-shell-execution/consequences:
|
||||
en: bf19d4ac50eade4f
|
||||
zh: 62e455faaed1dc58
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Progressive Office skills with ordinary shell execution
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-progressive-office-skills.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Combined authoring and rendering instructions spend context on operations unrelated to ordinary editing. Rendering tool activation adds a second discovery path and durable state even though shell tools already own execution permissions and process management. A missing renderer can prevent loading otherwise usable editing guidance.
|
||||
|
||||
## Decision
|
||||
|
||||
The [skill registry](../../../../packages/skill/skill/README.md) preserves optional `modelCatalogVisibility` metadata and retains every winning candidate. Model catalogs filter `unlisted` entries after scope and same-name resolution; named loading and invocation permissions remain independent. Filesystem frontmatter accepts the same field. Loaded skill instructions can name deferred skills without making a tool registration or activation state.
|
||||
|
||||
The [Office provider](../../../../packages/skill/skill-office/README.md) advertises only a compact router. Format-specific authoring and rendering, recalculation, and PDF export are separately callable unlisted skills. The model loads them by name and selects rendering for appearance-focused changes unless the user explicitly skips it. Rendering uses a private, randomly named temporary directory inside the task workspace and removes it after inspection. Skills invoke `office-check` and `dsoffice` through ordinary Bash or PowerShell. Common base composition enables immutable skills, the Office command plugin, and the general dependency query only for a supplied runtime. Ordinary Web does not supply or download interpreters. Web presets own tool exposure and minimal presets retain only shell tools.
|
||||
|
||||
The command plugin contributes local launchers through [shell-env](../../../../packages/shell/shell-env/README.md) to each shell call's `PATH`. Carriers own interpreter and resource locations; those paths stay outside skill instructions. Loading skills and registering commands do not prepare engines or authoring payloads. Standalone rendering, PDF export, and recalculation require only their operation skill. This keeps instruction loading independent of installed runtime layout while shell execution retains its existing permissions and process management.
|
||||
|
||||
Office dependency ranges allow patch updates; the root lock records the resolved adapter and engine versions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep a dedicated renderer tool.** A small typed schema constrains rendering arguments, but requires its own execution configuration, scoped registration, projection, and activation lifecycle. The CLI operation skills accept shell quoting and model-selected arguments in exchange for reusing the existing approval, sandbox, cancellation, and output mechanisms.
|
||||
|
||||
**Hide skills by denying model invocation.** This also prevents the router from loading them. Visibility must affect discovery without granting or revoking permission. Filtering before winner selection would incorrectly reveal overridden skills.
|
||||
|
||||
**Automatically inject guides for file extensions.** Extensions do not distinguish text edits, recalculation, conversion, and requested visual checks. Model-selected routing keeps unrelated operations out of context and permits explicit direct invocation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Instruction loading appends ordinary logged skill results; completion does not erase them. Historical tool records remain readable and existing declaration reconciliation handles resumed sessions without a Session format change or agent-loop modification. A dedicated renderer would need a new independently justified user or security requirement before reintroduction.
|
||||
|
||||
Verification covers visibility defaults, scoped overrides, denied and explicit invocation, authoring without executable paths, native/PTC requests, resume and fork, ordinary and minimal profile composition, resource packaging, context budgets, and real-model operation selection. Rendering failures preserve usable sources and stop visual checks; successful commands alone cannot establish usable images.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Office 技能渐进加载与普通 shell 执行
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-progressive-office-skills.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
合并的创作和渲染说明会为普通编辑无关的操作消耗上下文。渲染工具激活增加第二条发现路径和持久状态,而 shell 工具已经负责执行权限及进程管理。缺少渲染器也可能阻止加载原本可用的编辑指南。
|
||||
|
||||
## 决策
|
||||
|
||||
[技能注册表](../../../../packages/skill/skill/README.zh.md) 保留可选的 `modelCatalogVisibility` 元数据,并保留全部胜出候选项。模型目录在作用域和同名解析后过滤 `unlisted` 条目;具名加载与调用权限保持独立。文件系统 frontmatter 接受同一字段。已加载技能说明可以指向延后加载的技能,无需注册工具或引入激活状态。
|
||||
|
||||
[Office 提供者](../../../../packages/skill/skill-office/README.zh.md) 仅展示精简路由。各格式创作以及渲染、重计算、PDF 导出分别作为可具名调用的 unlisted 技能。模型按名称加载;以外观为目标的修改会选择渲染,除非用户明确要求跳过。渲染使用任务工作区内随机命名的私有临时目录,并在检查后删除。技能通过普通 Bash 或 PowerShell 调用 `office-check` 和 `dsoffice`。公共基础组合仅在提供运行时时启用不可变技能、Office 命令插件和通用依赖查询。普通 Web 不提供或下载解释器。Web 预设负责工具展示,minimal 预设仍只保留 shell 工具。
|
||||
|
||||
命令插件通过 [shell-env](../../../../packages/shell/shell-env/README.zh.md) 将本地启动器加入每次 shell 调用的 `PATH`。载体负责解释器和资源位置;这些路径不进入技能说明。加载技能和注册命令不准备引擎或创作 payload。独立渲染、PDF 导出和重算只需对应操作技能。这使说明加载独立于已安装运行时的布局,同时 shell 执行保留现有权限和进程管理。
|
||||
|
||||
Office 依赖范围允许补丁版本更新;根锁文件记录实际解析的适配器与引擎版本。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留专属渲染工具。** 小型类型化 schema 能约束渲染参数,但需要专属执行配置、作用域注册、投影和激活生命周期。CLI 操作技能接受 shell 引用和模型选择参数的成本,以复用现有审批、沙箱、取消及输出机制。
|
||||
|
||||
**通过禁止模型调用来隐藏技能。** 这也会阻止路由加载它们。可见性必须只影响发现,不授予或撤销权限。在选择胜出项之前过滤会错误地暴露被覆盖技能。
|
||||
|
||||
**按文件扩展名自动注入指南。** 扩展名无法区分文本编辑、重计算、转换和用户要求的视觉检查。模型选路使无关操作不进入上下文,并允许显式直接调用。
|
||||
|
||||
## 影响
|
||||
|
||||
说明加载追加普通的已记录技能结果;操作完成不会擦除这些结果。历史工具记录仍可读取,恢复会话使用现有声明更新机制,无需修改 Session 格式或 agent loop。重新引入专属渲染器需要新的、独立成立的用户或安全要求。
|
||||
|
||||
验证覆盖可见性默认值、作用域覆盖、拒绝及显式调用、无可执行文件路径时的创作、native/PTC 请求、恢复和 fork、常规及 minimal 组合、资源打包、上下文预算和真实模型操作选择。渲染失败时保留可用源文件并停止视觉检查;命令成功本身不能证明图片可用。
|
||||
@@ -269,6 +269,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm
|
||||
| Package | License | Role |
|
||||
| --- | --- | --- |
|
||||
| [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend |
|
||||
| [`pillow`](https://github.com/python-pillow/Pillow) | MIT-CMU | Python project dependency |
|
||||
| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` |
|
||||
| [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only |
|
||||
| [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
|
||||
|
||||
@@ -116,6 +116,12 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_skill
|
||||
plugin_dsh_base_skill_filesystem["skill-filesystem<br/>@deepseek-ai/dsh-skill-filesystem"]
|
||||
cfg --> plugin_dsh_base_skill_filesystem
|
||||
plugin_dsh_base_skill_office["skill-office<br/>@deepseek-ai/dsh-skill-office"]
|
||||
cfg --> plugin_dsh_base_skill_office
|
||||
plugin_dsh_base_office_commands["office-commands<br/>@deepseek-ai/dsh-skill-office/commands"]
|
||||
cfg --> plugin_dsh_base_office_commands
|
||||
plugin_dsh_base_workspace_dependencies["workspace-dependencies<br/>@deepseek-ai/dsh-tool-workspace-dependencies"]
|
||||
cfg --> plugin_dsh_base_workspace_dependencies
|
||||
plugin_dsh_base_tool_skill["tool-skill<br/>@deepseek-ai/dsh-tool-skill"]
|
||||
cfg --> plugin_dsh_base_tool_skill
|
||||
plugin_dsh_base_commands["commands<br/>@deepseek-ai/dsh-commands"]
|
||||
@@ -254,6 +260,9 @@ flowchart LR
|
||||
| `agent-instructions` | `@deepseek-ai/dsh-agent-instructions` |
|
||||
| `skill` | `@deepseek-ai/dsh-skill` |
|
||||
| `skill-filesystem` | `@deepseek-ai/dsh-skill-filesystem` |
|
||||
| `skill-office` | `@deepseek-ai/dsh-skill-office` |
|
||||
| `office-commands` | `@deepseek-ai/dsh-skill-office/commands` |
|
||||
| `workspace-dependencies` | `@deepseek-ai/dsh-tool-workspace-dependencies` |
|
||||
| `tool-skill` | `@deepseek-ai/dsh-tool-skill` |
|
||||
| `commands` | `@deepseek-ai/dsh-commands` |
|
||||
| `command-feedback` | `@deepseek-ai/dsh-command-feedback` |
|
||||
|
||||
@@ -115,6 +115,12 @@ describe('dsh badge assembled snapshot', () => {
|
||||
},
|
||||
],
|
||||
"isError": false,
|
||||
"meta": {
|
||||
"skill": {
|
||||
"name": "dsh-badge",
|
||||
"provider": "dsh-badge",
|
||||
},
|
||||
},
|
||||
"value": {
|
||||
"content": "# dsh Badge
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli
|
||||
if (address === null || typeof address === 'string') throw new Error('model fixture did not bind')
|
||||
const home = join(root, 'home')
|
||||
const cliPatch = join(root, 'cli.patch.yml')
|
||||
await writeFile(cliPatch, JSON.stringify(mode === 'python-only-no-cli' ? [{ id: 'skill-office', config: { assetRoot: join(root, 'resources', 'office-skills'), cli: false } }] : []))
|
||||
await writeFile(cliPatch, JSON.stringify(mode === 'python-only-no-cli' ? [{ id: 'skill-office', config: { assetRoot: join(root, 'resources', 'office-skills') } }, { id: 'office-commands', config: { command: false } }] : []))
|
||||
const officeLaunch = resolveExampleLaunch({
|
||||
srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib',
|
||||
configArgs: ['--profile', 'sdk', '--patch', cliPatch],
|
||||
@@ -494,8 +494,11 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli
|
||||
}, () => stderr)
|
||||
const names = (requests[0]!.tools as { name: string }[]).map(value => value.name)
|
||||
expect(names.includes('load_workspace_dependencies')).toBe(enabled)
|
||||
expect(JSON.stringify(requests[0]!.messages).includes('`office`')).toBe(enabled && mode !== 'missing-assets')
|
||||
expect(names).not.toContain('office_tools')
|
||||
expect(names).not.toContain('render_document')
|
||||
for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
|
||||
expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets' && mode !== 'python-only')
|
||||
expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(false)
|
||||
}
|
||||
if (mode === 'wrong-type') {
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -524,7 +527,7 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli
|
||||
expect.objectContaining({ role: 'user', content }),
|
||||
]))
|
||||
}
|
||||
if (mode === 'python-only') expect(stderr).toContain('node')
|
||||
if (mode === 'python-only') expect(stderr).not.toContain('ENOENT')
|
||||
if (mode === 'missing-assets') expect(stderr).toContain('check_office.py')
|
||||
await send(3, 'shutdown')
|
||||
const exit = await child
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Agent discovery of the installed Office CLI through the shipped SDK profile.
|
||||
* Defaults to built Node packages. DSH_OFFICE_TEST_EXECUTABLE/ENTRY select a
|
||||
* relocated Node or Electron carrier; CARRIER=sdk selects the standalone executable.
|
||||
* NODE/CLI/ASSETS under the same prefix supply Desktop resources outside ASAR.
|
||||
* NODE/CLI/ASSETS under the same prefix select Desktop command and skill resources outside ASAR.
|
||||
* API=openai-completions selects a development gateway instead of the default Messages API.
|
||||
*/
|
||||
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile, chmod } from 'node:fs/promises'
|
||||
@@ -29,16 +29,22 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent
|
||||
'word/document.xml': strToU8('<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Office CLI discovery</w:t></w:r></w:p><w:sectPr/></w:body></w:document>'),
|
||||
}))
|
||||
const patch = join(root, 'office.patch.yml')
|
||||
const node = process.env.DSH_OFFICE_TEST_NODE, cli = process.env.DSH_OFFICE_TEST_CLI
|
||||
const assetRoot = process.env.DSH_OFFICE_TEST_ASSETS
|
||||
await writeFile(patch, JSON.stringify([
|
||||
...(process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? [{ id: 'llm-deepseek', disabled: true }, { id: 'llm-pi-ai', config: { providers: { 'office-test': { api: 'openai-completions', apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: process.env.DEEPSEEK_BASE_URL, compat: { thinkingFormat: 'deepseek' }, models: [{ id: process.env.MODEL_NAME ?? 'deepseek-v4-flash', contextWindow: 128000, maxTokens: 8000 }] } } } }] : []),
|
||||
{ id: 'session-persistence-jsonl', config: { root: join(root, 'sessions'), compression: 'none' } },
|
||||
...carrier === 'sdk' ? [] : [{ insert: [{
|
||||
id: 'office-cli-discovery', name: '@deepseek-ai/dsh-skill-office', config: {
|
||||
...(process.env.DSH_OFFICE_TEST_NODE ? { node: process.env.DSH_OFFICE_TEST_NODE } : {}),
|
||||
...(process.env.DSH_OFFICE_TEST_CLI ? { cli: process.env.DSH_OFFICE_TEST_CLI } : {}),
|
||||
...(process.env.DSH_OFFICE_TEST_ASSETS ? { assetRoot: process.env.DSH_OFFICE_TEST_ASSETS } : {}),
|
||||
{ id: 'skill-office', disabled: false },
|
||||
{ id: 'office-commands', disabled: false },
|
||||
...assetRoot === undefined ? [] : [{ id: 'skill-office', config: { assetRoot } }],
|
||||
...node === undefined && cli === undefined && assetRoot === undefined ? [] : [{
|
||||
id: 'office-commands', config: {
|
||||
...node === undefined && cli === undefined ? {} : {
|
||||
command: [node ?? process.execPath, cli ?? fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/cli'))],
|
||||
},
|
||||
...assetRoot === undefined ? {} : { assetRoot },
|
||||
},
|
||||
}] }],
|
||||
}],
|
||||
]))
|
||||
// A private POSIX tool directory also excludes /usr/bin/node on Linux.
|
||||
const path = join(root, 'bin')
|
||||
@@ -54,7 +60,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent
|
||||
await writeFile(decoy, '#!/bin/sh\nprintf invoked > ' + "'" + decoyMarker.replaceAll("'", "'\\''") + "'" + '\nexit 86\n')
|
||||
await chmod(decoy, 0o755)
|
||||
}
|
||||
const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v libreoffice-kit'], { env: { PATH: path }, reject: false })
|
||||
const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v dsoffice'], { env: { PATH: path }, reject: false })
|
||||
expect(lookup.exitCode, 'the fixture must not expose Node or the Office CLI through PATH').not.toBe(0)
|
||||
const child = execa(executable, [
|
||||
...carrier === 'sdk' ? [] : [entry], '--profile', 'sdk', '--patch', patch,
|
||||
@@ -103,10 +109,12 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent
|
||||
const commands = calls.filter(call => call.name === 'bash')
|
||||
expect(commands.length, diagnostic).toBeGreaterThan(0)
|
||||
expect(calls.findIndex(call => call.name === 'skill'), diagnostic).toBeLessThan(calls.findIndex(call => call.name === 'bash'))
|
||||
const commandText = JSON.stringify(commands)
|
||||
for (const fragment of ['cli.js', 'capabilities', 'convert']) expect(commandText, diagnostic).toContain(fragment)
|
||||
const commandText = commands.map((call) => {
|
||||
if (typeof call.arguments !== 'string') throw new Error('Shell tool call did not contain serialized arguments')
|
||||
return (JSON.parse(call.arguments) as { command: string }).command
|
||||
}).join('\n')
|
||||
for (const operation of ['capabilities', 'convert']) expect(commandText, diagnostic).toMatch(new RegExp(`\\bdsoffice["']?\\s+${operation}\\b`, 'u'))
|
||||
expect(existsSync(decoyMarker), 'the agent must not invoke system Office when the bundled CLI is supplied').toBe(false)
|
||||
expect(commandText, 'the skill supplies an entry; searching for other Office binaries is unnecessary').not.toMatch(/(?:which|whereis|command -v|find).{0,100}(?:libreoffice|soffice)/iu)
|
||||
const capabilities = JSON.parse(await readFile(join(root, 'capabilities.json'), 'utf8')) as { runtime: { version: string; cliPath: string } }
|
||||
expect(capabilities.runtime.version).toBe('0.1.5')
|
||||
expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u)
|
||||
|
||||
@@ -20,7 +20,7 @@ const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
|
||||
interface DefaultWeb {
|
||||
root: string
|
||||
url: string
|
||||
request: (command: 'roster' | 'mount-experimental' | 'mount-experimental-entry') => Promise<RuntimeRoster>
|
||||
request: (command: 'roster' | 'mount-experimental' | 'mount-experimental-entry' | 'office-catalog' | 'office-dependencies' | 'office-render') => Promise<RuntimeRoster>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ interface DefaultWeb {
|
||||
export async function withDefaultWeb(
|
||||
test: TestContext,
|
||||
inspect: (app: DefaultWeb) => Promise<void>,
|
||||
options: { patches?: readonly string[]; prepare?: (root: string) => Promise<void> } = {},
|
||||
options: { patches?: readonly string[]; prepare?: (root: string) => Promise<void>; env?: NodeJS.ProcessEnv } = {},
|
||||
): Promise<void> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-web-default-isolation-'))
|
||||
let removal: Promise<void> | undefined
|
||||
@@ -65,6 +65,11 @@ export async function withDefaultWeb(
|
||||
'--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open',
|
||||
],
|
||||
env: {
|
||||
...options.env,
|
||||
DSH_PRIMARY_RUNTIME: options.env?.DSH_PRIMARY_RUNTIME,
|
||||
DSH_BUNDLED_PRIMARY_RUNTIME: options.env?.DSH_BUNDLED_PRIMARY_RUNTIME,
|
||||
DSH_BUNDLED_PRIMARY_RUNTIME_ROOT: options.env?.DSH_BUNDLED_PRIMARY_RUNTIME_ROOT,
|
||||
DSH_BUNDLED_OFFICE_CLI: options.env?.DSH_BUNDLED_OFFICE_CLI,
|
||||
NODE_OPTIONS: undefined,
|
||||
NODE_PATH: undefined,
|
||||
TSX_TSCONFIG_PATH: undefined,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/** Test-only IPC observer mounted beside the unchanged default Web composition. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import type {} from '@deepseek-ai/dsh-shell'
|
||||
import type {} from '@deepseek-ai/dsh-shell-env'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { runtimeRoster } from '../runtime-roster.ts'
|
||||
|
||||
/** Services whose live state the observer reads. */
|
||||
@@ -18,7 +26,7 @@ export function apply(ctx: Context, config: { negativeControl: string }): void {
|
||||
process.emit('SIGTERM')
|
||||
return
|
||||
}
|
||||
if (message !== 'roster' && message !== 'mount-experimental' && message !== 'mount-experimental-entry') return
|
||||
if (message !== 'roster' && message !== 'mount-experimental' && message !== 'mount-experimental-entry' && message !== 'office-catalog' && message !== 'office-dependencies' && message !== 'office-render') return
|
||||
void (async () => {
|
||||
if (message === 'mount-experimental') {
|
||||
const namespace: unknown = await import(config.negativeControl)
|
||||
@@ -26,7 +34,50 @@ export function apply(ctx: Context, config: { negativeControl: string }): void {
|
||||
}
|
||||
if (message === 'mount-experimental-entry') await ctx.loader.create({ name: config.negativeControl })
|
||||
await ctx.loader.await()
|
||||
process.send!({ command: message, roster: runtimeRoster(ctx) })
|
||||
const roster = runtimeRoster(ctx)
|
||||
if (message === 'office-render') {
|
||||
const shell = ctx.get('shell'), shellEnv = ctx.get('shellEnv')
|
||||
if (shell === undefined || shellEnv === undefined) throw new Error('Office shell unavailable')
|
||||
roster.officeShell = []
|
||||
for (const command of ['dsoffice render --input input.docx --output-dir rendered --pages 1',
|
||||
'dsoffice convert --input input.docx --output converted.pdf']) {
|
||||
const execution: ToolExecution = { name: 'pwsh', arguments: { command }, token: Symbol('office-smoke') as ToolExecution['token'],
|
||||
callId: 'office-smoke' as ToolCallId, rootCallId: 'office-smoke' as ToolCallId, signal: new AbortController().signal }
|
||||
const result = await (await shell.execute(shell.resolve({ command, workdir: process.cwd(), timeoutMs: 60_000,
|
||||
dshEnv: shellEnv.collect(execution), sandboxPolicy: { mode: 'workspace-write', workspaceRoot: process.cwd() },
|
||||
}))).result()
|
||||
roster.officeShell.push({ exitCode: result.exitCode, timedOut: result.timedOut,
|
||||
...(result.sandbox === undefined ? {} : { sandboxMode: result.sandbox.mode }), stderr: result.stderr.text })
|
||||
if (result.exitCode !== 0 || result.timedOut) break
|
||||
}
|
||||
}
|
||||
if (message === 'office-catalog' || message === 'office-dependencies') {
|
||||
const skills = ctx.get('skills'), tools = ctx.get('tools'), agents = ctx.get('agents'), presets = ctx.get('agentPresets')
|
||||
if (skills === undefined || tools === undefined || agents === undefined || presets === undefined) {
|
||||
throw new Error('Office registries unavailable')
|
||||
}
|
||||
const handle = await agents.create({
|
||||
sessionId: `office-${message}` as SessionId,
|
||||
meta: { agentPreset: 'standard' },
|
||||
setup: agentCtx => presets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
if (message === 'office-catalog') {
|
||||
roster.office = {
|
||||
names: (await skills.list({ scope: handle.agent })).filter(skill => skill.modelCatalogVisibility !== 'unlisted').map(skill => skill.name),
|
||||
tools: tools.schemas(handle.agent).map(tool => tool.name),
|
||||
instructions: (await skills.get('office-docx', { scope: handle.agent }))?.content ?? '',
|
||||
}
|
||||
} else {
|
||||
const result = await tools.execute({ name: 'load_workspace_dependencies', callId: 'office-runtime' as ToolCallId,
|
||||
arguments: {}, signal: new AbortController().signal, agent: handle.agent })
|
||||
const value = result.value
|
||||
roster.dependencies = { isError: result.isError,
|
||||
...(value !== null && typeof value === 'object' && !Array.isArray(value) && typeof value.python === 'string' ? { python: value.python } : {}) }
|
||||
}
|
||||
} finally { await handle.dispose() }
|
||||
}
|
||||
process.send!({ command: message, roster })
|
||||
})().catch((error: unknown) => {
|
||||
process.send!({ command: message, error: error instanceof Error ? error.stack : String(error) })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Web rendering is independent of optional authoring runtimes. */
|
||||
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { expect, it } from 'vitest'
|
||||
import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../../../../../packages/skill/tool-workspace-dependencies/src/index.ts'
|
||||
import { withDefaultWeb } from './default-web-process.ts'
|
||||
|
||||
it.for(['web', 'desktop-resources', 'explicit-runtime', 'disabled'] as const)('keeps Office rendering available while scoping authoring runtimes (%s)', async (carrier, test) => {
|
||||
const enabled = carrier === 'desktop-resources' || carrier === 'explicit-runtime'
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
DSH_PRIMARY_RUNTIME: undefined, DSH_BUNDLED_PRIMARY_RUNTIME: undefined, DSH_BUNDLED_PRIMARY_RUNTIME_ROOT: undefined,
|
||||
}
|
||||
await withDefaultWeb(test, async ({ root, request }) => {
|
||||
const roster = await request('office-catalog')
|
||||
expect(roster.office?.names.filter(name => name.startsWith('office'))).toEqual(['office'])
|
||||
expect(roster.office?.tools.includes('load_workspace_dependencies')).toBe(enabled)
|
||||
expect(roster.office?.tools).not.toContain('office_tools')
|
||||
expect(roster.office?.tools).not.toContain('render_document')
|
||||
expect(roster.plugins.some(plugin => plugin.name === 'office-commands')).toBe(true)
|
||||
await expect(readFile(join(root, 'home', 'profiles', 'web', 'runtimes'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(roster.office?.instructions).toContain('python-docx')
|
||||
expect(roster.office?.instructions).toContain('office-check')
|
||||
expect(roster.office?.instructions).toContain('If `office-check` is unavailable')
|
||||
expect(roster.office?.instructions).not.toContain('carrier-runtime')
|
||||
if (!enabled) return
|
||||
const target = carrier === 'desktop-resources' ? join(root, 'installed') : join(root, 'carrier-runtime', 'primary-runtime')
|
||||
const manifest = JSON.parse(await readFile(join(root, 'carrier-runtime', 'primary-runtime', 'runtime.json'), 'utf8')) as PrimaryRuntimeManifest
|
||||
expect((await request('office-dependencies')).dependencies).toEqual({ isError: false, python: workspaceDependencyPaths(target, manifest).python })
|
||||
}, {
|
||||
env,
|
||||
async prepare(root) {
|
||||
if (carrier === 'web') return
|
||||
const source = join(root, 'carrier-runtime', 'primary-runtime')
|
||||
const manifest: PrimaryRuntimeManifest = { desktopVersion: 'test', platform: process.platform, arch: process.arch,
|
||||
python: '3.12.14', node: '24.11.0', pnpm: '11.7.0', pythonPackages: {} }
|
||||
const paths = workspaceDependencyPaths(source, manifest)
|
||||
for (const path of [paths.python, paths.node!, paths.pnpm!]) {
|
||||
await mkdir(dirname(path), { recursive: true }); await writeFile(path, 'carrier runtime fixture')
|
||||
}
|
||||
await mkdir(paths.pythonPackages, { recursive: true }); await mkdir(paths.nodePackages!, { recursive: true })
|
||||
await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest))
|
||||
await cp(fileURLToPath(new URL('../../../../../../packages/skill/skill-office/assets', import.meta.url)), join(dirname(source), 'office-skills'), { recursive: true })
|
||||
if (carrier === 'explicit-runtime') env.DSH_PRIMARY_RUNTIME = source
|
||||
else {
|
||||
env.DSH_BUNDLED_PRIMARY_RUNTIME = source
|
||||
env.DSH_BUNDLED_PRIMARY_RUNTIME_ROOT = join(root, 'installed')
|
||||
if (carrier === 'disabled') env.DSH_PRIMARY_RUNTIME = ''
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('renders and converts with the bundled Kit inside the default Windows sandbox', async (test) => {
|
||||
await withDefaultWeb(test, async ({ root, request }) => {
|
||||
const result = await request('office-render')
|
||||
for (const shell of result.officeShell!) {
|
||||
expect(shell.timedOut, shell.stderr).toBe(false)
|
||||
expect(shell.exitCode, shell.stderr).toBe(0)
|
||||
expect(shell.sandboxMode).toBe('workspace-write')
|
||||
}
|
||||
expect(result.officeShell).toHaveLength(2)
|
||||
const png = await readFile(join(root, 'rendered', 'page-0001.png'))
|
||||
expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||
expect((await readFile(join(root, 'converted.pdf'))).subarray(0, 5).toString()).toBe('%PDF-')
|
||||
}, { async prepare(root) {
|
||||
await cp(fileURLToPath(new URL('../../../../../../scripts/fixtures/office-render/color-blocks.docx', import.meta.url)), join(root, 'input.docx'))
|
||||
} })
|
||||
})
|
||||
@@ -15,6 +15,9 @@ export interface RuntimeRoster {
|
||||
plugins: Array<{ name: string; owner: string | undefined; state: number; modules: string[] }>
|
||||
modules: string[]
|
||||
client: WebBootGraph
|
||||
dependencies?: { isError: boolean; python?: string }
|
||||
office?: { names: string[]; tools: string[]; instructions: string }
|
||||
officeShell?: Array<{ exitCode: number | null; timedOut: boolean; sandboxMode?: string; stderr: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -196,11 +196,28 @@ function toolParameterNames(ctx: Context, agent: Agent, toolName: string): strin
|
||||
}
|
||||
|
||||
let ctx: Context
|
||||
let officeHome: string
|
||||
beforeAll(async () => {
|
||||
officeHome = await mkdtemp(join(tmpdir(), 'dsh-web-office-home-'))
|
||||
vi.stubEnv('DSH_HOME', officeHome)
|
||||
for (const key of ['DSH_PRIMARY_RUNTIME', 'DSH_BUNDLED_PRIMARY_RUNTIME', 'DSH_BUNDLED_OFFICE_CLI']) vi.stubEnv(key, undefined)
|
||||
ctx = await bootWeb(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')))
|
||||
}, 120_000)
|
||||
|
||||
describe('the shipped Web composition', () => {
|
||||
it('exposes Office rendering without a bundled authoring runtime', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('web-office-without-python'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, handle.agent)).not.toContain('load_workspace_dependencies')
|
||||
const skills = await ctx.skills.list({ scope: handle.agent })
|
||||
expect(skills.find(skill => skill.name === 'office')?.modelCatalogVisibility).toBe('listed')
|
||||
expect((await ctx.skills.get('office-render', { scope: handle.agent }))?.content).toContain('dsoffice render')
|
||||
} finally { await handle.dispose() }
|
||||
})
|
||||
|
||||
it('exposes only the directory tool in the global layer', () => {
|
||||
// Directory selection is global; action tools belong to their Agent presets.
|
||||
expect(toolNames(ctx)).toEqual(['working_directory'])
|
||||
@@ -465,9 +482,12 @@ describe('the shipped Web composition', () => {
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The host (global) view carries the deployment-level provider alone:
|
||||
// local discovery moved behind the presets with `skill-filesystem`.
|
||||
expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual(['dsh-badge'])
|
||||
// Host skills exclude the preset's workspace-local discovery.
|
||||
expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual([
|
||||
...(process.platform === 'win32' ? ['diagnose-windows-sandbox-acl'] : []),
|
||||
'dsh-badge', 'office', 'office-docx', 'office-export-pdf', 'office-pptx',
|
||||
'office-recalculate', 'office-render', 'office-xlsx',
|
||||
])
|
||||
|
||||
// The standard agent's view merges the global layer with its preset's
|
||||
// own local discovery over the session cwd.
|
||||
@@ -908,4 +928,10 @@ describe('a session keeps the preset it was created with', () => {
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => { await ctx?.fiber.dispose() })
|
||||
afterAll(async () => {
|
||||
try { await ctx?.fiber.dispose() }
|
||||
finally {
|
||||
vi.unstubAllEnvs()
|
||||
if (officeHome !== undefined) await rm(officeHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -22,8 +22,6 @@
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:*",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:*",
|
||||
"@deepseek-ai/dsh-schedule": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*",
|
||||
"@deepseek-ai/dsh-skill-office": "workspace:*",
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-connection'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-deepseek-account'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import * as desktopOffice from './office.ts'
|
||||
import { desktopOfficeEnvironment } from './office.ts'
|
||||
|
||||
import { installDesktopUpdateTaskControl } from './update-tasks.ts'
|
||||
import { installDesktopQuitInspection } from './quit-inspection.ts'
|
||||
@@ -21,6 +21,11 @@ async function main(): Promise<void> {
|
||||
installOfficeEngineResolution(runtimeDir)
|
||||
const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
|
||||
const profile = loadProfileDirectory('dsh', projectDir, installAnchor)
|
||||
Object.assign(process.env, desktopOfficeEnvironment({
|
||||
runtimeDir,
|
||||
source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'),
|
||||
root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),
|
||||
}))
|
||||
reportSkippedBundles('dsh', profile)
|
||||
const application = runProfile({
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
@@ -92,11 +97,6 @@ async function main(): Promise<void> {
|
||||
const { ctx } = await application
|
||||
control.updateTasks = installDesktopUpdateTaskControl(ctx)
|
||||
control.quitInspection = installDesktopQuitInspection(ctx)
|
||||
await ctx.plugin(desktopOffice, {
|
||||
runtimeDir,
|
||||
source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'),
|
||||
root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),
|
||||
})
|
||||
installPlatformSessionPublisher(ctx, (session) => {
|
||||
if (process.connected) process.send?.({ type: 'platform-session', session })
|
||||
})
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
/** Desktop Office skills and bundled authoring dependencies. */
|
||||
/** Desktop resource paths consumed by the shared Web Office configuration. */
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { dirname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { runtimeArchivePath } from './office-engine.ts'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import * as officeSkills from '@deepseek-ai/dsh-skill-office'
|
||||
import * as workspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
|
||||
/** 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. */
|
||||
@@ -21,18 +16,17 @@ export interface Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Resolve carrier defaults before the shared profile evaluates its configuration.
|
||||
* @param config - Bundled payload, installation root, and application dependency directory.
|
||||
* @returns Resource environment consumed by patchable Web bundle rows.
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
await ctx.plugin(workspaceDependencies, config)
|
||||
export function desktopOfficeEnvironment(config: Config): Record<string, string> {
|
||||
const archive = runtimeArchivePath(config.runtimeDir) === undefined ? undefined : dirname(realpathSync(config.runtimeDir))
|
||||
const manifest = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json'))
|
||||
const packageRoot = dirname(archive === undefined ? manifest : join(`${archive}.unpacked`, relative(archive, manifest)))
|
||||
await ctx.plugin(officeSkills, {
|
||||
assetRoot: join(dirname(config.source), 'office-skills'),
|
||||
node: join(config.source, 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node'),
|
||||
cli: join(packageRoot, 'lib', 'cli.js'),
|
||||
})
|
||||
return {
|
||||
DSH_BUNDLED_PRIMARY_RUNTIME: config.source,
|
||||
DSH_BUNDLED_PRIMARY_RUNTIME_ROOT: config.root,
|
||||
DSH_BUNDLED_OFFICE_CLI: join(packageRoot, 'lib', 'cli.js'),
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,17 @@
|
||||
import { cp, mkdir, 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'
|
||||
/** Desktop resource resolution feeding the shared, patchable Web composition. */
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { expect, it } from 'vitest'
|
||||
import * as desktopOffice from '../src/office.ts'
|
||||
import { desktopOfficeEnvironment } from '../src/office.ts'
|
||||
|
||||
it('loads Desktop Office CLI paths 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 })
|
||||
const nodeDirectory = join(root, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin')
|
||||
await mkdir(nodeDirectory, { recursive: true })
|
||||
await cp(process.execPath, join(nodeDirectory, process.platform === 'win32' ? 'node.exe' : 'node'))
|
||||
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'))}`,
|
||||
` runtimeDir: ${JSON.stringify(join(root, 'dsh'))}`, '',
|
||||
].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((await ctx.skills.get('office-pptx'))?.content).toContain('libreofficeKit')
|
||||
expect((await ctx.skills.get('office-pptx'))?.content).toContain(JSON.stringify(nodeDirectory).slice(1, -1))
|
||||
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 })
|
||||
}
|
||||
it('supplies carrier resource paths without installing tools or changing the environment', () => {
|
||||
const before = { ...process.env }
|
||||
const root = process.cwd()
|
||||
const source = join(root, 'runtime', 'primary-runtime')
|
||||
expect(desktopOfficeEnvironment({ source, root: join(root, 'installed'), runtimeDir: join(root, 'dsh') })).toEqual({
|
||||
DSH_BUNDLED_PRIMARY_RUNTIME: source,
|
||||
DSH_BUNDLED_PRIMARY_RUNTIME_ROOT: join(root, 'installed'),
|
||||
DSH_BUNDLED_OFFICE_CLI: join(dirname(fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json'))), 'lib', 'cli.js'),
|
||||
})
|
||||
expect(process.env).toEqual(before)
|
||||
})
|
||||
@@ -13,9 +13,7 @@
|
||||
{ "path": "../../packages/jobs/jobs" },
|
||||
{ "path": "../../packages/schedule/schedule" },
|
||||
{ "path": "../../packages/workspace/workspace" },
|
||||
{ "path": "../../packages/skill/skill-office" },
|
||||
{ "path": "../../packages/util/home-paths" },
|
||||
{ "path": "../../packages/skill/tool-workspace-dependencies" },
|
||||
{
|
||||
"path": "../cli"
|
||||
},
|
||||
|
||||
@@ -64,7 +64,7 @@ Signed Windows packaging scans the primary runtime and application production de
|
||||
|
||||
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.
|
||||
Desktop exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries and the shared structural checker; operation skills invoke the stable `dsoffice` command. Skills live in `runtime/office-skills` outside ASAR so Python can read resources. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations.
|
||||
|
||||
The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. 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.
|
||||
|
||||
@@ -278,9 +278,9 @@ Desktop packs workspace packages locally and installs external dependencies thro
|
||||
|
||||
The [Electron shell file list](scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](tests/shell-file-selection.spec.ts) check retained bytes and exclusions.
|
||||
|
||||
The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in).
|
||||
The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies the Office command provider with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in).
|
||||
|
||||
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
|
||||
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also runs `dsoffice` capabilities and DOCX conversion through the registered shell environment with an empty caller PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
|
||||
|
||||
Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产
|
||||
|
||||
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 展示精简的 `office` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库和共享结构检查器;操作技能调用稳定的 `dsoffice` 命令。技能位于 ASAR 外的 `runtime/office-skills`,便于 Python 读取资源。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。
|
||||
|
||||
该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
|
||||
|
||||
@@ -280,9 +280,9 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安
|
||||
|
||||
[Electron 外壳文件清单](scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。
|
||||
|
||||
[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。
|
||||
[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向 Office 命令提供方提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。
|
||||
|
||||
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。
|
||||
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。Host smoke 还在调用方 PATH 为空时,通过已注册的 shell 环境运行 `dsoffice` 的 capabilities 和 DOCX 转换。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。
|
||||
|
||||
Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Boot the materialized target runtime without access to a user's Harness profile. */
|
||||
|
||||
import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -40,6 +40,7 @@ export async function smokeDesktopRuntime(
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
writeFileSync(join(home, 'npmrc'), '')
|
||||
if (process.platform !== 'win32') symlinkSync('/bin/bash', join(home, 'bash'))
|
||||
createPluginProfile(profile)
|
||||
const pluginName = 'desktop-runtime-smoke-plugin'
|
||||
const plugin = join(home, pluginName)
|
||||
@@ -85,8 +86,7 @@ export function apply(ctx) {
|
||||
}))
|
||||
writeFileSync(join(plugin, 'index.js'), `
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { inspect, promisify } from 'node:util'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { inspect } from 'node:util'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
export function apply(ctx) {
|
||||
if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance')
|
||||
@@ -104,15 +104,27 @@ export function apply(ctx) {
|
||||
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office-cli',
|
||||
async handler(_request, response) {
|
||||
try {
|
||||
const skill = await ctx.skills.get('office-docx')
|
||||
const json = skill?.content.match(/\\n(\\{\\n[\\s\\S]+)$/u)?.[1]
|
||||
if (json === undefined) throw new Error('Office skill did not supply CLI paths')
|
||||
const { libreofficeKit: { node, cli } } = JSON.parse(json)
|
||||
const options = { cwd: ${JSON.stringify(home)}, env: { ...process.env, PATH: '' }, timeout: 120_000 }
|
||||
const capabilities = await promisify(execFile)(node, [cli, 'capabilities'], options)
|
||||
const skill = await ctx.skills.get('office-export-pdf')
|
||||
if (!skill?.content.includes('dsoffice convert')) throw new Error('Office skill did not supply the direct command')
|
||||
const managed = ctx.shellEnv.collect({})
|
||||
const delimiter = process.platform === 'win32' ? ';' : ':'
|
||||
const dshEnv = { ...managed, PATH: [managed.PATH.split(delimiter)[0], ${JSON.stringify(home)}].join(delimiter) }
|
||||
const output = ${JSON.stringify(join(home, 'cli.pdf'))}
|
||||
await promisify(execFile)(node, [cli, 'convert', '--input', ${JSON.stringify(join(home, 'input.docx'))}, '--output', output], options)
|
||||
response.end(JSON.stringify({ capabilities: JSON.parse(capabilities.stdout), pdf: (await readFile(output)).toString('base64') }))
|
||||
async function run(command) {
|
||||
const execution = await ctx.shell.execute(ctx.shell.resolve({ command,
|
||||
workdir: ${JSON.stringify(home)}, timeoutMs: 120_000, dshEnv,
|
||||
sandboxPolicy: { mode: 'workspace-write', workspaceRoot: ${JSON.stringify(home)} },
|
||||
env: { PATH: '', OFFICE_SMOKE_INPUT: ${JSON.stringify(join(home, 'input.docx'))}, OFFICE_SMOKE_OUTPUT: output },
|
||||
}))
|
||||
const result = await execution.result()
|
||||
if (result.exitCode !== 0 || result.timedOut || result.aborted) throw new Error(JSON.stringify(result))
|
||||
return result.stdout.text
|
||||
}
|
||||
const capabilities = await run('dsoffice capabilities')
|
||||
await run(process.platform === 'win32'
|
||||
? 'dsoffice convert --input "$env:OFFICE_SMOKE_INPUT" --output "$env:OFFICE_SMOKE_OUTPUT"'
|
||||
: 'dsoffice convert --input "$OFFICE_SMOKE_INPUT" --output "$OFFICE_SMOKE_OUTPUT"')
|
||||
response.end(JSON.stringify({ capabilities: JSON.parse(capabilities), pdf: (await readFile(output)).toString('base64') }))
|
||||
} catch (error) {
|
||||
response.statusCode = 500
|
||||
response.end(inspect(error, { depth: 5 }))
|
||||
@@ -135,7 +147,7 @@ export function apply(ctx) {
|
||||
}
|
||||
}
|
||||
`)
|
||||
writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills, pluginManager]\n')
|
||||
writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills, shellEnv, shell, pluginManager]\n')
|
||||
cpSync(plugin, join(profile, 'node_modules', pluginName), { recursive: true })
|
||||
const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
|
||||
@@ -107,6 +107,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await writeComposerDraft(page, input, '/cpt')
|
||||
await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
|
||||
'CompactCompact older conversation history',
|
||||
'office-pptxCreate, read, edit, and validate PowerPoint presentations (.pptx).',
|
||||
'office-export-pdfConvert an Office document to a requested PDF with LibreOffice Kit.',
|
||||
])
|
||||
const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
|
||||
|
||||
@@ -2983,7 +2983,7 @@ export interface Config extends SessionTitleLlmConfig {
|
||||
|
||||
## `@deepseek-ai/dsh-shell-env`
|
||||
|
||||
- `source`: [`packages/shell/shell-env/src/index.ts:30`](../packages/shell/shell-env/src/index.ts)
|
||||
- `source`: [`packages/shell/shell-env/src/index.ts:31`](../packages/shell/shell-env/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — the built-in facts resolve without defaults). */
|
||||
@@ -2999,7 +2999,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-skill`
|
||||
|
||||
- `source`: [`packages/skill/skill/src/index.ts:278`](../packages/skill/skill/src/index.ts)
|
||||
- `source`: [`packages/skill/skill/src/index.ts:280`](../packages/skill/skill/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Skill registry configuration. */
|
||||
@@ -3016,7 +3016,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-skill-filesystem`
|
||||
|
||||
- `inject`: `skills`
|
||||
- `source`: [`packages/skill/skill-filesystem/src/index.ts:48`](../packages/skill/skill-filesystem/src/index.ts)
|
||||
- `source`: [`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Local filesystem skill provider configuration. */
|
||||
@@ -3055,17 +3055,13 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-skill-office`
|
||||
|
||||
- `inject`: `skills`
|
||||
- `source`: [`packages/skill/skill-office/src/index.ts:16`](../packages/skill/skill-office/src/index.ts)
|
||||
- `source`: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts)
|
||||
|
||||
```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. */
|
||||
/** Absolute assets directory containing skill folders and shared scripts; defaults to carrier resources or packaged assets. */
|
||||
assetRoot?: string
|
||||
/** Standalone Node executable; defaults to the current executable outside Electron and SEA. */
|
||||
node?: string
|
||||
/** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */
|
||||
cli?: string | false
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-skill-office -->
|
||||
|
||||
@@ -2985,7 +2985,7 @@ export interface Config extends SessionTitleLlmConfig {
|
||||
|
||||
## `@deepseek-ai/dsh-shell-env`
|
||||
|
||||
- `source`: [`packages/shell/shell-env/src/index.ts:30`](../packages/shell/shell-env/src/index.ts)
|
||||
- `source`: [`packages/shell/shell-env/src/index.ts:31`](../packages/shell/shell-env/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — the built-in facts resolve without defaults). */
|
||||
@@ -3001,7 +3001,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-skill`
|
||||
|
||||
- `source`: [`packages/skill/skill/src/index.ts:278`](../packages/skill/skill/src/index.ts)
|
||||
- `source`: [`packages/skill/skill/src/index.ts:280`](../packages/skill/skill/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Skill registry configuration. */
|
||||
@@ -3018,7 +3018,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-skill-filesystem`
|
||||
|
||||
- `inject`: `skills`
|
||||
- `source`: [`packages/skill/skill-filesystem/src/index.ts:48`](../packages/skill/skill-filesystem/src/index.ts)
|
||||
- `source`: [`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Local filesystem skill provider configuration. */
|
||||
@@ -3057,17 +3057,13 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-skill-office`
|
||||
|
||||
- `inject`: `skills`
|
||||
- `source`: [`packages/skill/skill-office/src/index.ts:16`](../packages/skill/skill-office/src/index.ts)
|
||||
- `source`: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts)
|
||||
|
||||
```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. */
|
||||
/** Absolute assets directory containing skill folders and shared scripts; defaults to carrier resources or packaged assets. */
|
||||
assetRoot?: string
|
||||
/** Standalone Node executable; defaults to the current executable outside Electron and SEA. */
|
||||
node?: string
|
||||
/** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */
|
||||
cli?: string | false
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-skill-office -->
|
||||
|
||||
@@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
|
||||
@@ -469,7 +469,6 @@ flowchart TD
|
||||
pkg_subprocess --> pkg_http_proxy
|
||||
pkg_remote_mock --> pkg_typert_protocol
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_skill_office --> pkg_skill
|
||||
pkg_web_fetch_http --> pkg_http_proxy
|
||||
pkg_web_fetch_http --> pkg_timeout
|
||||
pkg_web_fetch_http --> pkg_web
|
||||
@@ -1030,6 +1029,11 @@ flowchart TD
|
||||
pkg_token_meter --> pkg_llm_retry
|
||||
pkg_token_meter --> pkg_session
|
||||
pkg_token_meter --> pkg_session_projection
|
||||
pkg_skill_office --> pkg_app_boot
|
||||
pkg_skill_office --> pkg_home_paths
|
||||
pkg_skill_office --> pkg_shell_env
|
||||
pkg_skill_office --> pkg_skill
|
||||
pkg_skill_office --> pkg_tool_workspace_dependencies
|
||||
pkg_session_query --> pkg_brand
|
||||
pkg_session_query --> pkg_llm
|
||||
pkg_session_query --> pkg_session
|
||||
@@ -1574,7 +1578,6 @@ flowchart TD
|
||||
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
|
||||
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
|
||||
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
@@ -1712,6 +1715,7 @@ flowchart TD
|
||||
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
|
||||
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) |
|
||||
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
|
||||
| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
|
||||
|
||||
@@ -471,7 +471,6 @@ flowchart TD
|
||||
pkg_subprocess --> pkg_http_proxy
|
||||
pkg_remote_mock --> pkg_typert_protocol
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_skill_office --> pkg_skill
|
||||
pkg_web_fetch_http --> pkg_http_proxy
|
||||
pkg_web_fetch_http --> pkg_timeout
|
||||
pkg_web_fetch_http --> pkg_web
|
||||
@@ -1032,6 +1031,11 @@ flowchart TD
|
||||
pkg_token_meter --> pkg_llm_retry
|
||||
pkg_token_meter --> pkg_session
|
||||
pkg_token_meter --> pkg_session_projection
|
||||
pkg_skill_office --> pkg_app_boot
|
||||
pkg_skill_office --> pkg_home_paths
|
||||
pkg_skill_office --> pkg_shell_env
|
||||
pkg_skill_office --> pkg_skill
|
||||
pkg_skill_office --> pkg_tool_workspace_dependencies
|
||||
pkg_session_query --> pkg_brand
|
||||
pkg_session_query --> pkg_llm
|
||||
pkg_session_query --> pkg_session
|
||||
@@ -1576,7 +1580,6 @@ flowchart TD
|
||||
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
|
||||
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
|
||||
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
@@ -1714,6 +1717,7 @@ flowchart TD
|
||||
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
|
||||
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) |
|
||||
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
|
||||
| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
|
||||
|
||||
@@ -1173,8 +1173,8 @@
|
||||
en: de04d42a8b0004df
|
||||
zh: db64863dd7da1e26
|
||||
/session-persistence-event-catalog/resolved-persistence-types/skillinvocationsource:
|
||||
en: ecc15ebadb77afdf
|
||||
zh: 97ac27d6a4175f6f
|
||||
en: dca39cb6b8d0ee21
|
||||
zh: 91d08af5ba3bb94a
|
||||
/session-persistence-event-catalog/resolved-persistence-types/streamchunk:
|
||||
en: 0d571899222eb04f
|
||||
zh: de28c6d67d1763c1
|
||||
|
||||
@@ -4591,7 +4591,7 @@ Sources: [`packages/skill/tool-skill/src/index.ts:35`](../packages/skill/tool-sk
|
||||
|
||||
SHA-256: `672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db`
|
||||
|
||||
Sources: [`packages/skill/skill/src/index.ts:146`](../packages/skill/skill/src/index.ts)
|
||||
Sources: [`packages/skill/skill/src/index.ts:148`](../packages/skill/skill/src/index.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
@@ -4593,7 +4593,7 @@ SHA-256: `fc2ae6962f9f8801c2b1e414668e1e17cbb7544c4f39381198977a38bd10c093`
|
||||
|
||||
SHA-256: `672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db`
|
||||
|
||||
来源:[`packages/skill/skill/src/index.ts:146`](../packages/skill/skill/src/index.ts)
|
||||
来源:[`packages/skill/skill/src/index.ts:148`](../packages/skill/skill/src/index.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+1
-1
@@ -23534,7 +23534,7 @@
|
||||
"packages/skill/skill/src/index.ts#SkillInvocationSource"
|
||||
],
|
||||
"sources": [
|
||||
"packages/skill/skill/src/index.ts:146"
|
||||
"packages/skill/skill/src/index.ts:148"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
en: abe338b6c6d1ebd1
|
||||
zh: c604db2de10a23c4
|
||||
/skills/local-discovery-priority:
|
||||
en: accf8ad4c73c41ad
|
||||
zh: 141294fe8dab8ecf
|
||||
en: c447e2d8366f4315
|
||||
zh: 0123b388ac65d8b5
|
||||
/skills/skill-identity:
|
||||
en: 61295a9cf1c51588
|
||||
zh: 1c4c1582688d2310
|
||||
/skills/summaries-candidates-and-complete-definitions:
|
||||
en: 8819c25fac497a1f
|
||||
zh: 5795f0d16f09750e
|
||||
en: e29078ea527832dc
|
||||
zh: bd518ebe210b9464
|
||||
/skills/lookup-and-configuration:
|
||||
en: e60215d73b0abe75
|
||||
zh: a086985044324141
|
||||
|
||||
@@ -80,6 +80,8 @@ The project root is the nearest ancestor containing `.git`; without one, the cur
|
||||
|
||||
Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete without hiding readable candidates from direct loads; project-scoped watchers use a configured bounded LRU.
|
||||
|
||||
`SkillInvocationSource` records the name of loaded instruction context. The [skill loader](../../packages/skill/tool-skill/README.md) persists direct loads as tool-result metadata and nested loads as instruction context.
|
||||
|
||||
## Skill identity
|
||||
|
||||
Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider accepts directory bundles (`<name>/SKILL.md`) and flat Markdown files (`<name>.md`). Nested recursive `**/SKILL.md` discovery is not supported.
|
||||
@@ -91,18 +93,23 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | '
|
||||
|
||||
## Summaries, candidates, and complete definitions
|
||||
|
||||
`SkillSummary` is the registry's invocation-neutral summary shape. Consumers choose which entries and fields to render; the model session catalog uses only model-invocable `name` and `description`, never the body or absolute file path. `SkillInvocationPolicy` normalizes the two independent invocation controls into positive booleans, and every resolved summary, candidate, and definition carries it without turning arbitrary frontmatter into the domain model.
|
||||
`SkillSummary` is the registry's invocation-neutral summary shape. Consumers choose which entries and fields to render; the model session catalog uses only the `name` and `description` of model-invocable winners whose `modelCatalogVisibility` is not `unlisted`, never the body or absolute file path. `SkillInvocationPolicy` normalizes the two independent invocation controls into positive booleans, and every resolved summary, candidate, and definition carries it without turning arbitrary frontmatter into the domain model.
|
||||
|
||||
```ts type-equiv
|
||||
/** Invocation controls shared by skill discovery consumers. */
|
||||
interface SkillInvocationPolicy {
|
||||
/** Whether model-facing catalogs and loaders include this skill. */
|
||||
/** Whether model-facing loaders may invoke this skill. */
|
||||
readonly modelInvocable: boolean
|
||||
/** Whether human-facing command catalogs and loaders include this skill. */
|
||||
readonly userInvocable: boolean
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Whether a model catalog advertises a skill; invocation permissions are independent. */
|
||||
type ModelCatalogVisibility = 'listed' | 'unlisted'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
|
||||
interface SkillSummary {
|
||||
@@ -116,6 +123,8 @@ interface SkillSummary {
|
||||
readonly whenToUse?: string
|
||||
/** Resolved model and user invocation controls. */
|
||||
readonly invocation: SkillInvocationPolicy
|
||||
/** Model catalog visibility; omission is listed. Applied after same-name winner selection. */
|
||||
readonly modelCatalogVisibility?: ModelCatalogVisibility
|
||||
/** Discovery source that produced this winning skill. */
|
||||
readonly source: SkillSource
|
||||
/** Provider that owns this skill body. */
|
||||
|
||||
@@ -80,6 +80,8 @@ interface SkillProviderControl {
|
||||
|
||||
Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径与目录相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整,但不会在直接加载时隐藏可读候选项;项目作用域 watcher 使用按配置设限的 LRU。
|
||||
|
||||
`SkillInvocationSource` 记录已加载指令上下文的名称。[技能加载器](../../packages/skill/tool-skill/README.zh.md)将直接加载持久化为工具结果元数据,将嵌套加载持久化为指令上下文。
|
||||
|
||||
## skill 身份
|
||||
|
||||
skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方接受目录包(`<name>/SKILL.md`)和扁平 Markdown 文件(`<name>.md`)。嵌套递归的 `**/SKILL.md` 发现不受支持。
|
||||
@@ -91,18 +93,23 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | '
|
||||
|
||||
## 摘要、候选项与完整定义
|
||||
|
||||
`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用模型可调用 skill 的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 将两个独立调用控制规范化为正向布尔值,且每个已解析的摘要、候选项和定义都携带该策略,而不会把任意 frontmatter 纳入领域模型。
|
||||
`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用允许模型调用且 `modelCatalogVisibility` 不为 `unlisted` 的获胜技能的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 将两个独立调用控制规范化为正向布尔值,且每个已解析的摘要、候选项和定义都携带该策略,而不会把任意 frontmatter 纳入领域模型。
|
||||
|
||||
```ts type-equiv
|
||||
/** Invocation controls shared by skill discovery consumers. */
|
||||
interface SkillInvocationPolicy {
|
||||
/** Whether model-facing catalogs and loaders include this skill. */
|
||||
/** Whether model-facing loaders may invoke this skill. */
|
||||
readonly modelInvocable: boolean
|
||||
/** Whether human-facing command catalogs and loaders include this skill. */
|
||||
readonly userInvocable: boolean
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Whether a model catalog advertises a skill; invocation permissions are independent. */
|
||||
type ModelCatalogVisibility = 'listed' | 'unlisted'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
|
||||
interface SkillSummary {
|
||||
@@ -116,6 +123,8 @@ interface SkillSummary {
|
||||
readonly whenToUse?: string
|
||||
/** Resolved model and user invocation controls. */
|
||||
readonly invocation: SkillInvocationPolicy
|
||||
/** Model catalog visibility; omission is listed. Applied after same-name winner selection. */
|
||||
readonly modelCatalogVisibility?: ModelCatalogVisibility
|
||||
/** Discovery source that produced this winning skill. */
|
||||
readonly source: SkillSource
|
||||
/** Provider that owns this skill body. */
|
||||
@@ -173,7 +182,7 @@ interface SkillDefinition extends SkillSummary {
|
||||
}
|
||||
```
|
||||
|
||||
运行时 skill 输入可以省略调用控制和提供方标签。注册表会一次性补全这两项默认值,随后使用与提供方相同的完整定义形状和先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。
|
||||
提供方和运行时输入可省略目录可见性;注册表将其默认为 `listed`。未列出的技能仍遵循相同调用权限。运行时 skill 输入还可以省略调用控制和提供方标签。注册表会一次性补全这两项默认值,随后使用与提供方相同的完整定义形状和先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。
|
||||
|
||||
```ts type-equiv
|
||||
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
|
||||
|
||||
@@ -20,8 +20,8 @@ type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Trusted DeepSeek Harness variables for one child-process execution. */
|
||||
type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
|
||||
/** Trusted Harness variables and optional local command PATH for one child-process execution. */
|
||||
type DshEnvironment = Readonly<Record<DshEnvironmentKey, string> & { PATH?: string }>
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
|
||||
@@ -20,8 +20,8 @@ type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Trusted DeepSeek Harness variables for one child-process execution. */
|
||||
type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
|
||||
/** Trusted Harness variables and optional local command PATH for one child-process execution. */
|
||||
type DshEnvironment = Readonly<Record<DshEnvironmentKey, string> & { PATH?: string }>
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
|
||||
@@ -1765,7 +1765,7 @@ Load the full instructions for a skill. Call it before acting on a task that nam
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The exact skill name from the available skills list."
|
||||
"description": "The exact skill name from the catalog, loaded skill instructions, or an explicit user request."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1771,7 +1771,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The exact skill name from the available skills list."
|
||||
"description": "The exact skill name from the catalog, loaded skill instructions, or an explicit user request."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -306,6 +306,21 @@
|
||||
- id: skill-filesystem
|
||||
name: '@deepseek-ai/dsh-skill-filesystem'
|
||||
|
||||
- id: skill-office
|
||||
name: '@deepseek-ai/dsh-skill-office'
|
||||
disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) && !process.env.DSH_BUNDLED_OFFICE_CLI"
|
||||
|
||||
- id: office-commands
|
||||
name: '@deepseek-ai/dsh-skill-office/commands'
|
||||
disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) && !process.env.DSH_BUNDLED_OFFICE_CLI"
|
||||
|
||||
- id: workspace-dependencies
|
||||
name: '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)"
|
||||
config:
|
||||
source: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '')"
|
||||
root: !!js "process.env.DSH_PRIMARY_RUNTIME === undefined ? process.env.DSH_BUNDLED_PRIMARY_RUNTIME_ROOT : undefined"
|
||||
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"@deepseek-ai/dsh-config-editor": "workspace:*",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-otel": "workspace:*",
|
||||
"@deepseek-ai/dsh-skill-office": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*",
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-working-directory": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -17,20 +17,3 @@
|
||||
|
||||
- id: hmr
|
||||
disabled: true
|
||||
|
||||
# The packaged carrier supplies a default; DSH_PRIMARY_RUNTIME overrides it,
|
||||
# including an empty-string opt-out. Each row can be patched independently.
|
||||
- insert:
|
||||
- id: workspace-dependencies
|
||||
name: '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)"
|
||||
config:
|
||||
source: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '')"
|
||||
|
||||
- id: skill-office
|
||||
name: '@deepseek-ai/dsh-skill-office'
|
||||
disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) && !process.env.DSH_BUNDLED_OFFICE_CLI"
|
||||
config:
|
||||
assetRoot: !!js "process.env.DSH_BUNDLED_OFFICE_SKILLS ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')"
|
||||
cli: !!js "process.env.DSH_BUNDLED_OFFICE_CLI === '' ? false : process.env.DSH_BUNDLED_OFFICE_CLI"
|
||||
node: !!js "process.env.DSH_BUNDLED_OFFICE_NODE ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')"
|
||||
@@ -37,9 +37,7 @@
|
||||
"@deepseek-ai/dsh-cmdline": "workspace:*",
|
||||
"@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"commander": "^15.0.0",
|
||||
"@deepseek-ai/dsh-skill-office": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*"
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
/deepseek-ai-dsh-web-app/use-this-package/running-over-ssh:
|
||||
en: df205a26b6d49168
|
||||
zh: e46ed7cf9e06248a
|
||||
/deepseek-ai-dsh-web-app/use-this-package/office-and-script-runtimes:
|
||||
en: ad6970f8f3eef1a5
|
||||
zh: da58325723c5b0f4
|
||||
/deepseek-ai-dsh-web-app/use-this-package/per-session-agent-setup:
|
||||
en: ccd6fc264b51e44b
|
||||
zh: 3265c68477a89ec9
|
||||
|
||||
@@ -84,6 +84,10 @@ The printed URL contains a process credential; share it only with intended users
|
||||
|
||||
When you launch `dsh --profile web` over SSH, the URL line still prints but the browser is not opened for you: the SSH client or editor owns the local forwarding address. Without an advertised root the printed URL names the remote host's bind-address endpoint — loopback for a loopback bind — which you reach through your forwarding address. With `--public-url` the printed URL is the authenticated advertised root; the browser handoff stays suppressed, because opening a browser on the remote host cannot reach your screen.
|
||||
|
||||
### Office and script runtimes
|
||||
|
||||
Web supplies LibreOffice Kit for GUI previews and shell rendering. Office skills and `dsoffice` are enabled independently of authoring runtimes; Kit uses the host Node.js unless Desktop supplies one. Ordinary Web does not bundle or download Python, Node.js, or pnpm. `office-check` and `load_workspace_dependencies` require a carrier runtime or an existing payload selected by `DSH_PRIMARY_RUNTIME`; an empty value disables these runtime defaults. With a supplied runtime, standard, PTC, and Creator presets expose the dependency query; minimal does not.
|
||||
|
||||
### Per-session agent setup
|
||||
|
||||
Each browser session selects a shipped preset (`standard` by default). The Agent presets settings page changes the default and edits preset child plugins; saves persist in `$DSH_HOME/profiles/web/cordis.patch.yml`. Creator's plugin-management tool is enabled only when the Host provides an editable profile.
|
||||
|
||||
@@ -84,6 +84,10 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-
|
||||
|
||||
通过 SSH 启动 `dsh --profile web` 时,URL 行仍会打印,但不会为你打开浏览器:本地转发地址由 SSH 客户端或编辑器持有。没有公告根时,打印的 URL 指向远端宿主机的绑定地址端点——绑定 loopback 时即 loopback——你通过自己的转发地址访问它。配置了 `--public-url` 时,打印的 URL 是带认证的公告根;浏览器交接仍被抑制,因为在远端宿主机上打开浏览器无法到达你的屏幕。
|
||||
|
||||
### Office 与脚本运行时
|
||||
|
||||
Web 自带 LibreOffice Kit,用于 GUI 预览和 shell 渲染。Office 技能和 `dsoffice` 的启用不依赖创作运行时;除 Desktop 提供 Node.js 外,Kit 使用宿主 Node.js。普通 Web 不捆绑或下载 Python、Node.js 或 pnpm。`office-check` 和 `load_workspace_dependencies` 需要载体运行时,或由 `DSH_PRIMARY_RUNTIME` 选择的现有 payload;空值禁用这些运行时默认项。提供运行时后,standard、PTC 和 Creator 预设展示依赖查询,minimal 不展示。
|
||||
|
||||
### 按会话的 agent 设置
|
||||
|
||||
每个浏览器会话选择一个随发行版交付的 preset(默认 `standard`)。Agent 预设设置页可更改默认项并编辑预设的子插件;保存结果持久化到 `$DSH_HOME/profiles/web/cordis.patch.yml`。只有 Host 提供可编辑的 profile 时,Creator 的插件管理工具才会启用。
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
|
||||
# ── surface-specific values the base deliberately omits ─────────────────────
|
||||
|
||||
- id: skill-office
|
||||
disabled: false
|
||||
|
||||
- id: office-commands
|
||||
disabled: false
|
||||
|
||||
- id: system-prompt
|
||||
config:
|
||||
personaPrefix: >-
|
||||
@@ -505,6 +511,9 @@
|
||||
- id: tool-skill
|
||||
disabled: true
|
||||
|
||||
- id: workspace-dependencies
|
||||
disabled: true
|
||||
|
||||
# The goal service and session driver stay on the host plane, where Gateway
|
||||
# remotes resolve them. Presets own the human command and model-facing tool.
|
||||
|
||||
|
||||
@@ -180,7 +180,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
|
||||
@@ -136,6 +136,12 @@
|
||||
config:
|
||||
customSkillDirs:
|
||||
- !!js process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('@deepseek-ai/dsh-agent-preset/package.json')), 'skills')
|
||||
- id: workspace-dependencies
|
||||
name: '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)"
|
||||
config:
|
||||
source: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '')"
|
||||
root: !!js "process.env.DSH_PRIMARY_RUNTIME === undefined ? process.env.DSH_BUNDLED_PRIMARY_RUNTIME_ROOT : undefined"
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
- id: present
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
name: '@deepseek-ai/dsh-tool-schedule'
|
||||
- id: skill-filesystem
|
||||
name: '@deepseek-ai/dsh-skill-filesystem'
|
||||
- id: workspace-dependencies
|
||||
name: '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)"
|
||||
config:
|
||||
source: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '')"
|
||||
root: !!js "process.env.DSH_PRIMARY_RUNTIME === undefined ? process.env.DSH_BUNDLED_PRIMARY_RUNTIME_ROOT : undefined"
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
- id: command-goal
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
name: '@deepseek-ai/dsh-tool-schedule'
|
||||
- id: skill-filesystem
|
||||
name: '@deepseek-ai/dsh-skill-filesystem'
|
||||
- id: workspace-dependencies
|
||||
name: '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)"
|
||||
config:
|
||||
source: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '')"
|
||||
root: !!js "process.env.DSH_PRIMARY_RUNTIME === undefined ? process.env.DSH_BUNDLED_PRIMARY_RUNTIME_ROOT : undefined"
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
- id: command-goal
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: ca75c51c89c2c9b0
|
||||
zh: 64715dc7e6d02f71
|
||||
/deepseek-ai-dsh-experimental-webworker-runtime/known-limitations-and-deferred-work:
|
||||
en: 1bc41bcc7a7e065e
|
||||
zh: 9e9558ff06c508bc
|
||||
en: 4b1cc7b194107818
|
||||
zh: 9f7b6aa6b4fcb726
|
||||
/deepseek-ai-dsh-experimental-webworker-runtime/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: e88aeb617ffb74ea
|
||||
@@ -58,7 +58,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Desktop product telemetry is unavailable**: `got.post` reports an explicit worker-host refusal. Got and its Node HTTP dependencies are excluded from the browser image; Desktop reporting remains disabled in the preview composition.
|
||||
- **Host package commands are unavailable**: `execa` reports an explicit worker-host refusal; the preview cannot run pnpm, install plugins, or install native dependencies.
|
||||
- **PTC Node programs are unavailable**: the process shim exposes `/dsh/bin/node` as its executable identity so the provider can activate, but the Worker has neither a Node executable nor `stripTypeScriptTypes`. Program execution fails before launching a child.
|
||||
- **Office conversion requires a Node Host**: the LibreOffice kit replacement rejects converter creation with `unavailable`. The browser image excludes the kit and its engine dependencies; the Office preview reports that conversion is unavailable.
|
||||
- **Office operations require a Node Host**: the deployment disables `office-commands` because the browser has no Node/Python executables or Office engine assets. The LibreOffice kit replacement rejects converter creation with `unavailable`. The browser image excludes the kit and its engine dependencies; the Office preview reports that conversion is unavailable.
|
||||
- **Filesystem watchers observe only the mounted VFS**: image seeding is silent and the VFS has no symlinks or external writers. `persistent`, `ref()`, and `unref()` preserve the Node API but cannot control a dedicated Worker's lifetime because browsers expose no ref-counted event loop.
|
||||
- **Worker confinement is a VFS boundary, not kernel Landlock**: `read-only` and `workspace-write` run the unchanged `@deepseek-ai/node-addon-system/landlock-run` JavaScript and launcher argv, but the process layer implements the logical `landlock-run` executable and enforces its grants on every shell filesystem request. `full` therefore covers the Worker command table and mounted VFS only; it does not claim arbitrary native-process execution or Linux kernel isolation.
|
||||
- **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there.
|
||||
|
||||
@@ -58,7 +58,7 @@ kind: "package-library"
|
||||
- **桌面产品埋点不可用**:`got.post` 明确报告 worker host 不支持该调用。浏览器镜像不包含 Got 及其 Node HTTP 依赖;预览组合不启用桌面上报。
|
||||
- **宿主包管理命令不可用**:`execa` 明确报告 worker host 不支持该调用;预览无法运行 pnpm、安装插件或安装原生依赖。
|
||||
- **PTC Node 程序不可用**:process shim 用 `/dsh/bin/node` 表示可执行文件身份,使 provider 能够激活,但 Worker 既没有 Node 可执行文件,也没有 `stripTypeScriptTypes`。程序执行会在启动子进程前失败。
|
||||
- **Office 转换需要 Node Host**:LibreOffice kit 的替代模块以 `unavailable` 拒绝创建转换器。浏览器镜像排除 kit 及其引擎依赖;Office 预览提示转换不可用。
|
||||
- **Office 操作需要 Node Host**:部署禁用 `office-commands`,因为浏览器没有 Node/Python 可执行文件或 Office 引擎资源。LibreOffice kit 的替代模块以 `unavailable` 拒绝创建转换器。浏览器镜像排除 kit 及其引擎依赖;Office 预览提示转换不可用。
|
||||
- **文件 watcher 只能观察已挂载的 VFS**:镜像 seed 不产生事件,VFS 也没有符号链接或外部写入方。`persistent`、`ref()` 和 `unref()` 保留 Node API,但浏览器没有引用计数事件循环,因此这些接口不能控制 dedicated Worker 的生存期。
|
||||
- **Worker confinement 是 VFS 边界,不是内核 Landlock**:`read-only` 和 `workspace-write` 运行未经修改的 `@deepseek-ai/node-addon-system/landlock-run` JavaScript 与 launcher argv,进程层则实现逻辑 `landlock-run` 可执行文件,并在 shell 的每次文件系统请求上执行其授权。`full` 仅覆盖 Worker 命令表和已挂载 VFS,不表示能够执行任意 native 进程,也不表示 Linux 内核隔离。
|
||||
- **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。
|
||||
|
||||
@@ -416,6 +416,10 @@ function bootPatches(
|
||||
if (jsonl !== undefined) {
|
||||
patches.push({ id: 'session-persistence-jsonl', config: { ...configOf(jsonl), compression: 'none' } })
|
||||
}
|
||||
// Office launchers require Node/Python executables and engine assets absent from the browser.
|
||||
if (find(rows, 'office-commands') !== undefined) {
|
||||
patches.push({ id: 'office-commands', disabled: true })
|
||||
}
|
||||
const dir = join(root, IMAGE_HOME_DIRECTORY, 'profiles/preview')
|
||||
const profile: ProfileContext = {
|
||||
name: 'preview', dir, patchPath: join(dir, 'cordis.patch.yml'),
|
||||
|
||||
@@ -4827,7 +4827,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'BashEnvContributor',
|
||||
declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>;\n resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>;\n}',
|
||||
declaration: 'export interface BashEnvContributor {\n name: string;\n commandDirectories?: readonly string[];\n variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>;\n resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashEnvVariable',
|
||||
@@ -5291,7 +5291,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'DshEnvironment',
|
||||
declaration: 'export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>;',
|
||||
declaration: 'export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string> & {\n PATH?: string;\n}>;',
|
||||
},
|
||||
{
|
||||
name: 'DshEnvironmentKey',
|
||||
@@ -6017,6 +6017,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ModelCatalogModel',
|
||||
declaration: 'export interface ModelCatalogModel {\n readonly id: string;\n readonly name: string;\n readonly description?: string;\n readonly reasoning?: ModelReasoning;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ModelCatalogVisibility',
|
||||
declaration: 'export type ModelCatalogVisibility = \'listed\' | \'unlisted\';',
|
||||
},
|
||||
{
|
||||
name: 'ModelMessageSource',
|
||||
declaration: 'export interface ModelMessageSource extends AssistantProviderMetadata {\n kind: \'model\';\n}',
|
||||
@@ -7399,7 +7403,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SkillSummary',
|
||||
declaration: 'export interface SkillSummary {\n readonly path?: string;\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
|
||||
declaration: 'export interface SkillSummary {\n readonly path?: string;\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation: SkillInvocationPolicy;\n readonly modelCatalogVisibility?: ModelCatalogVisibility;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillViewOptions',
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 47119e8b4e884a5d
|
||||
zh: f9dfb155f06c4b73
|
||||
/deepseek-ai-dsh-pwsh-local/use-this-package/running-commands:
|
||||
en: d0efd217ca8ce07d
|
||||
zh: 76eda93aa7300f87
|
||||
en: 6659d1bbc7af561d
|
||||
zh: 8ccb16ceb39d67f2
|
||||
/deepseek-ai-dsh-pwsh-local/use-this-package/background-processes:
|
||||
en: 3c580842cc8452af
|
||||
zh: 5a156670a7cacc2a
|
||||
@@ -36,14 +36,14 @@
|
||||
en: 05e2abe4a25886fd
|
||||
zh: 3f7f1326dc3c1d3f
|
||||
/deepseek-ai-dsh-pwsh-local/understand-the-implementation/design-concept:
|
||||
en: d065be99a0cea908
|
||||
zh: 50eee843e3b6860c
|
||||
en: 09017c5641b0a5ec
|
||||
zh: 944ddfb922cf72f3
|
||||
/deepseek-ai-dsh-pwsh-local/understand-the-implementation/source-map:
|
||||
en: aa160f9ec57ff627
|
||||
zh: 048d3292dddf1d8b
|
||||
/deepseek-ai-dsh-pwsh-local/understand-the-implementation/main-flow:
|
||||
en: 1cb78cfddb484576
|
||||
zh: 94e16b60c9cd458b
|
||||
en: d7ea7ad3e310aaeb
|
||||
zh: 8af73c715f5c56df
|
||||
/deepseek-ai-dsh-pwsh-local/understand-the-implementation/invariants-and-ownership:
|
||||
en: db6d1d14205d38ed
|
||||
zh: 148b3764daee54e8
|
||||
|
||||
@@ -59,6 +59,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
Run a command by awaiting the execution's `result()` projection; a nonzero exit, a timeout, or a cancellation resolves descriptively, and only infrastructure failures reject. The command string rides as one argument to `-Command`: PowerShell parses the text itself and no intermediate shell exists, so there is no shell-quoting layer to escape and native Win32 paths pass through unchanged. Every command pins UTF-8 output first, so non-ASCII output is not garbled even on the Windows PowerShell 5.1 fallback. The environment is model-friendly: `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb` — a POSIX concept), with explicit caller-provided entries still winning.
|
||||
|
||||
Each child process selects `ExecutionPolicy Bypass` so host-generated scripts can run without changing the user's persistent policy. Machine and user Group Policy still take precedence.
|
||||
|
||||
```text
|
||||
const execution = await ctx.shell.execute(ctx.shell.resolve({ command: 'Get-ChildItem' }))
|
||||
const result = await execution.result()
|
||||
@@ -86,7 +88,7 @@ This section explains the design of the executor and points at the code that rea
|
||||
|
||||
### Design concept
|
||||
|
||||
The executor is the PowerShell Service Provider for the `ctx.shell` seam built on the subprocess capability: it owns everything pwsh-shaped — executable resolution, command defaulting and caps, deadline fusion and cause classification, UTF-8 output pinning, the model-friendly terminal environment, and the background read merge — while managed-range mechanics (bounded spill-backed output, credential scrub, termination escalation, quiescence, and disposal) belong to the subprocess service. Every call spawns a fresh non-interactive `pwsh -Command` with `-NoLogo -NoProfile -NonInteractive`, so commands are deterministic and profile state never leaks between calls.
|
||||
The executor is the PowerShell Service Provider for the `ctx.shell` seam built on the subprocess capability: it owns everything pwsh-shaped — executable resolution, command defaulting and caps, deadline fusion and cause classification, UTF-8 output pinning, the model-friendly terminal environment, and the background read merge — while managed-range mechanics (bounded spill-backed output, credential scrub, termination escalation, quiescence, and disposal) belong to the subprocess service. Every call spawns a fresh non-interactive `pwsh -Command` with `-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass`, so commands are deterministic and profile state never leaks between calls.
|
||||
|
||||
### Source map
|
||||
|
||||
@@ -98,7 +100,7 @@ The executor is the PowerShell Service Provider for the `ctx.shell` seam built o
|
||||
|
||||
### Main flow
|
||||
|
||||
A call runs through three steps: `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config (capping the per-call `timeoutMs` override); the executor builds the pwsh argv — `pwsh -NoLogo -NoProfile -NonInteractive -Command <encoding preamble + command>` — fuses the config-clamped timeout with the caller's abort signal into one deadline, and spawns through `ctx.subprocess` with explicit byte caps and the `graceMs`; the settled outcome is classified and projected into a `ShellRunResult`. Windows reports forced termination as exit 1 without a signal, so signal-stamped facts are POSIX-only there; the timeout/abort classification is platform-independent.
|
||||
A call runs through three steps: `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config (capping the per-call `timeoutMs` override); the executor builds the pwsh argv — `pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command <encoding preamble + command>` — fuses the config-clamped timeout with the caller's abort signal into one deadline, and spawns through `ctx.subprocess` with explicit byte caps and the `graceMs`; the settled outcome is classified and projected into a `ShellRunResult`. Windows reports forced termination as exit 1 without a signal, so signal-stamped facts are POSIX-only there; the timeout/abort classification is platform-independent.
|
||||
|
||||
The foreground deadline starts before argv preparation and retains the same signal and remaining budget through execution. Preparation timeout returns empty output, `timedOut: true`, and null `exitCode` and `signal`; caller cancellation before process publication still rejects. Late preparation success or failure cannot trigger a spawn.
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ kind: "package-reference"
|
||||
|
||||
通过等待执行句柄的 `result()` 投影来运行命令;非零退出、超时或取消都会返回描述性结果,只有基础设施失败才会导致调用被拒绝。命令字符串作为单个参数传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层,原生 Win32 路径也原样通过。每条命令都先固定 UTF-8 输出,因此即使在 Windows PowerShell 5.1 兜底上,非 ASCII 输出也不会乱码。环境默认面向模型:`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`——那是 POSIX 概念),调用方显式提供的条目仍然优先。
|
||||
|
||||
每个子进程使用 `ExecutionPolicy Bypass`,使主机生成的脚本可以运行,而不修改用户的持久策略。计算机和用户组策略仍然优先。
|
||||
|
||||
```text
|
||||
const execution = await ctx.shell.execute(ctx.shell.resolve({ command: 'Get-ChildItem' }))
|
||||
const result = await execution.result()
|
||||
@@ -86,7 +88,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
|
||||
|
||||
### 设计概念
|
||||
|
||||
本执行器是基于 subprocess 能力的 `ctx.shell` seam 的 PowerShell Service Provider:它负责所有 pwsh 层职责——可执行文件解析、命令默认化与上限、deadline 融合与原因分类、UTF-8 输出固定、面向模型的终端环境,以及后台读取合并——而 managed-range 机制(有界 spill 输出、凭据清除、终止升级、完全停稳与 dispose(资源释放))属于 subprocess 服务。每次调用都 spawn 全新的非交互 `pwsh -Command`,并带 `-NoLogo -NoProfile -NonInteractive`,因此命令是确定性的,profile 状态绝不会在调用之间泄漏。
|
||||
本执行器是基于 subprocess 能力的 `ctx.shell` seam 的 PowerShell Service Provider:它负责所有 pwsh 层职责——可执行文件解析、命令默认化与上限、deadline 融合与原因分类、UTF-8 输出固定、面向模型的终端环境,以及后台读取合并——而 managed-range 机制(有界 spill 输出、凭据清除、终止升级、完全停稳与 dispose(资源释放))属于 subprocess 服务。每次调用都 spawn 全新的非交互 `pwsh -Command`,并带 `-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass`,因此命令是确定性的,profile 状态绝不会在调用之间泄漏。
|
||||
|
||||
### 源码地图
|
||||
|
||||
@@ -98,7 +100,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
|
||||
|
||||
### 主要流程
|
||||
|
||||
一次调用分三步:`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`(并限制每次调用的 `timeoutMs` 覆盖值);执行器构建 pwsh argv——`pwsh -NoLogo -NoProfile -NonInteractive -Command <编码 preamble + 命令>`——把按配置钳位的超时与调用方的中止信号融合为一个 deadline,再以显式字节上限与 `graceMs` 通过 `ctx.subprocess` spawn;结算的结果被分类并投影为 `ShellRunResult`。Windows 把强制终止报告为退出码 1 且无信号,因此带信号标记的事实在那里仅限 POSIX;超时/取消分类则与平台无关。
|
||||
一次调用分三步:`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`(并限制每次调用的 `timeoutMs` 覆盖值);执行器构建 pwsh argv——`pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command <编码 preamble + 命令>`——把按配置钳位的超时与调用方的中止信号融合为一个 deadline,再以显式字节上限与 `graceMs` 通过 `ctx.subprocess` spawn;结算的结果被分类并投影为 `ShellRunResult`。Windows 把强制终止报告为退出码 1 且无信号,因此带信号标记的事实在那里仅限 POSIX;超时/取消分类则与平台无关。
|
||||
|
||||
前台 deadline 从 argv 准备开始,并在准备与执行之间保持同一信号和剩余预算。准备阶段超时返回空输出、`timedOut: true`,且 `exitCode` 和 `signal` 均为 `null`;调用方在发布进程前取消仍会拒绝调用。准备晚到的成功或失败不会触发 spawn。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Local PowerShell Service Provider for the bash capability seam. Each command runs
|
||||
* as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` in a managed
|
||||
* as `pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command <command>` in a managed
|
||||
* process spawned through `ctx.subprocess`; the executor owns command
|
||||
* defaulting, deadlines and cause classification, the model-friendly terminal
|
||||
* environment, and the model-facing stdout/stderr merge for background reads.
|
||||
@@ -191,7 +191,8 @@ export class PwshLocalExecutor extends ShellExecutor {
|
||||
* `@deepseek-ai/dsh-pwsh-sandbox`).
|
||||
*/
|
||||
protected argv(spec: ShellExecSpec): string[] {
|
||||
return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`]
|
||||
return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command',
|
||||
`${ENCODING_PREAMBLE}${spec.command}`]
|
||||
}
|
||||
|
||||
/** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */
|
||||
|
||||
@@ -237,8 +237,10 @@ describe('spawn construction (pure, every platform)', () => {
|
||||
await run(ctx.shell, ctx.shell.resolve({ command: 'Write-Output 你好' }))
|
||||
expect(subprocess.specs).toHaveLength(1)
|
||||
const { argv } = subprocess.specs[0]!
|
||||
expect(argv.slice(0, 5)).toEqual([expect.any(String), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
||||
expect(argv[5]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`)
|
||||
expect(argv.slice(0, 7)).toEqual([
|
||||
expect.any(String), '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command',
|
||||
])
|
||||
expect(argv[7]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`)
|
||||
expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding')
|
||||
expect(ENCODING_PREAMBLE).toContain('$OutputEncoding')
|
||||
})
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/shell/shell-env/README.md
|
||||
/:
|
||||
en: c54342d0151b089c
|
||||
zh: 9ad6284f24c5e3f6
|
||||
en: 03aa15ebe8f97a43
|
||||
zh: b606e28eb19c933e
|
||||
/deepseek-ai-dsh-shell-env:
|
||||
en: d26b77029fa722ab
|
||||
zh: 8e6bed763372b561
|
||||
/deepseek-ai-dsh-shell-env/summary:
|
||||
en: 021be213f32988e1
|
||||
zh: f3ce1ef82377b4f4
|
||||
en: f27f8c4cdea7bcd4
|
||||
zh: 33cf3b28330bdf0f
|
||||
/deepseek-ai-dsh-shell-env/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
@@ -19,16 +19,16 @@
|
||||
zh: 7753b97c91743901
|
||||
/deepseek-ai-dsh-shell-env/use-this-package/what-every-shell-call-receives:
|
||||
en: eae86664fefc6690
|
||||
zh: 05a4e5946c8d2b87
|
||||
zh: b8829aae034599c1
|
||||
/deepseek-ai-dsh-shell-env/use-this-package/adding-your-own-environment-facts:
|
||||
en: 21ef223626e35905
|
||||
zh: 5c468055099b0135
|
||||
en: 8e3813746d322857
|
||||
zh: 91f10c71a21d8e80
|
||||
/deepseek-ai-dsh-shell-env/use-this-package/choosing-the-harness-home:
|
||||
en: 0041438f2b7f7e4f
|
||||
zh: 93fa165b07adcdf4
|
||||
/deepseek-ai-dsh-shell-env/use-this-package/what-can-go-wrong:
|
||||
en: 42c75945b581b7e6
|
||||
zh: 6b5c487cfa28b206
|
||||
en: bf8db851cc8d373d
|
||||
zh: 27e046770564c60c
|
||||
/deepseek-ai-dsh-shell-env/understand-the-implementation:
|
||||
en: f782557ca8da3138
|
||||
zh: e8f0082b9692cacc
|
||||
@@ -39,20 +39,20 @@
|
||||
en: 72eba3bfadcfa581
|
||||
zh: dcd83e02a1910f27
|
||||
/deepseek-ai-dsh-shell-env/understand-the-implementation/collection:
|
||||
en: 0c2d7d5b2131630f
|
||||
zh: b44d4f0ddf89421b
|
||||
en: 24b540add25646f3
|
||||
zh: ba759ae3164cb1a3
|
||||
/deepseek-ai-dsh-shell-env/further-exploration:
|
||||
en: 6a4175df7b7341c7
|
||||
zh: 7885831274008502
|
||||
/deepseek-ai-dsh-shell-env/model-experience:
|
||||
en: 8acf3cd9aeef5f55
|
||||
zh: 69725e78f73c2f16
|
||||
en: 6e1e1b75c1fc4e94
|
||||
zh: a8cb0c273e040975
|
||||
/deepseek-ai-dsh-shell-env/model-experience/kv-cache-effect:
|
||||
en: e95e649f0410fa75
|
||||
zh: b3d64877420b4c0d
|
||||
/deepseek-ai-dsh-shell-env/known-limitations-and-deferred-work:
|
||||
en: be9888161315f026
|
||||
zh: 97d1d05c1c322596
|
||||
en: 2a4da893f45fa8c4
|
||||
zh: a06bd59769c78e53
|
||||
/deepseek-ai-dsh-shell-env/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "The managed DSH_* shell environment for users and maintainers choosing, configuring, or extending the environment every model shell call runs with."
|
||||
description: "Configure per-call Harness shell variables and directories for deployment-supplied commands."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-shell-env` provides the trusted `DSH_*` environment that every model shell call — bash or pwsh — runs with: built-in facts such as `DSH_HOME`, `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and the launched profile's `DSH_PROFILE` and `DSH_PROFILE_DIR`. Plugin authors can register their own facts with declared keys, collected per execution and disposed with their plugin; duplicate ownership or undeclared runtime keys fail loudly instead of silently overwriting. The registry changes nothing else the model sees — the shell tools own their own schemas and prompts. Choose it in any composition that mounts a model shell tool; configuration only picks the Harness home directory.
|
||||
`dsh-shell-env` gives Bash and PowerShell tool calls current Harness home, session, and profile variables. Plugins can contribute declared `DSH_*` values and directories for deployment-supplied commands. Each call receives the current registrations without changing `process.env` or the user's shell configuration. Duplicate variable ownership and undeclared values fail loudly. Registrations follow plugin lifetimes; the shell tools retain their schemas and prompts.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -52,6 +52,8 @@ export function apply(ctx: Context): void {
|
||||
|
||||
Contributors must declare every key they return; returning an undeclared or non-string value fails the call. Registration is disposed with the registering plugin, so hot-reloading a plugin removes its facts.
|
||||
|
||||
Contributors may also declare absolute `commandDirectories`. The registry prepends these directories to the local shell's `PATH` in contributor-name order, preserving each contributor's directory order and removing repeated directories. The inherited `PATH` follows them. With no command directories, it leaves `PATH` unchanged. Directory registration does not install commands or verify their contents; the contributing plugin owns those files and their lifetime. The [Office commands plugin](../../skill/skill-office/README.md) supplies `dsoffice` and `office-check` this way.
|
||||
|
||||
### Choosing the Harness home
|
||||
|
||||
The single config field picks the home directory exposed as `DSH_HOME`; the default resolution order is the `dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`.
|
||||
@@ -64,7 +66,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
### What can go wrong
|
||||
|
||||
Two contributors declaring the same key, or a contributor claiming a reserved built-in (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, `DSH_PROFILE_DIR`), fails plugin load loudly. A `DSH_*` key must be all-caps with underscores (for example `DSH_REGION`), and a missing description fails registration.
|
||||
Two contributors declaring the same key, or a contributor claiming a reserved built-in (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, `DSH_PROFILE_DIR`), fails plugin load loudly. A `DSH_*` key must be all-caps with underscores (for example `DSH_REGION`), and a missing description fails registration. Relative command directories also fail registration.
|
||||
|
||||
-----
|
||||
|
||||
@@ -90,7 +92,7 @@ This section explains the design decisions behind the registry and points at the
|
||||
|
||||
### Collection
|
||||
|
||||
`collect(execution)` starts from the built-ins, adds the session id when the execution carries an agent, then merges each registered contributor's resolved values sorted by contributor name. The result is a frozen, key-sorted snapshot passed through `ShellExecRequest.dshEnv`. `list()` enumerates declarations without running resolvers, so it cannot reflect execution-dependent values.
|
||||
`collect(execution)` combines built-in facts, contributor values, and any command-directory `PATH` into a frozen snapshot passed through `ShellExecRequest.dshEnv`. This trusted snapshot takes precedence over ordinary caller environment entries. `list()` enumerates variable declarations without running resolvers; it does not include command directories or execution-dependent values.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -113,7 +115,7 @@ Read these pages when the package-level contract is not enough. They move from t
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which expose this registry's managed `DSH_*` facts in every shell-tool call.
|
||||
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which expose this registry's managed `DSH_*` facts and command lookup paths in every shell-tool call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -127,6 +129,7 @@ The managed environment never enters the request prefix, so it does not invalida
|
||||
These limits define when the registry is a poor fit or needs care. They are current package constraints, not a task backlog.
|
||||
|
||||
- **`list()` enumerates plugin-contributed variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`, `DSH_PROFILE`, `DSH_PROFILE_DIR`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog.
|
||||
- **Command directories belong to the local host.** A remote execution provider needs commands installed and configured in its own execution environment; local directory contributions do not provision the remote host.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "受管 DSH_* shell 环境,供选择、配置或扩展每次模型 shell 调用所运行环境的使用者与维护者阅读。"
|
||||
description: "配置每次 Harness shell 调用的变量,以及部署提供的命令目录。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-shell-env` 提供每次模型 shell 调用——bash 或 pwsh——所运行的受信 `DSH_*` 环境:内置事实如 `DSH_HOME`、`DSH_SHELL=1`、agent(智能体)的 `DSH_SESSION_ID`,以及所启动 profile 的 `DSH_PROFILE` 与 `DSH_PROFILE_DIR`。插件作者可以注册自己的事实,带声明键、按每次执行收集,并随插件释放;重复所有权或未声明的运行时键会明确报错,而不是静默覆盖。注册表不会改变模型看到的其他任何内容——shell 工具拥有各自的 schema 与提示词。任何挂载了模型 shell 工具的组合都适合选择它;配置只决定 Harness 主目录。
|
||||
`dsh-shell-env` 为 Bash 和 PowerShell 工具调用提供当前的 Harness 主目录、会话与 profile 变量。插件可以贡献已声明的 `DSH_*` 值,以及部署提供的命令目录。每次调用都会收到当前注册内容,不修改 `process.env` 或用户 shell 配置。变量所有权重复或返回未声明的值会明确报错。注册项随插件生命周期释放;shell 工具保留各自的 schema 与提示词。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 每次 shell 调用都会收到什么
|
||||
|
||||
每次调用都会收到 `DSH_HOME`(Harness 主目录的绝对路径)、`DSH_SHELL=1`,agent 调用还会收到 `DSH_SESSION_ID`(调用方会话的 id)。启动器提供了 profile 上下文时,每次调用还会收到 `DSH_PROFILE`(profile 名)与 `DSH_PROFILE_DIR`(其绝对目录;其 `node_modules` 只存放 profile 自行安装的包,harness 自带的组合包从 dsh 安装目录解析);不带 profile 启动的组合则两者都没有。
|
||||
每次调用都会收到 `DSH_HOME`(Harness 主目录的绝对路径)、`DSH_SHELL=1`,agent(智能体)调用还会收到 `DSH_SESSION_ID`(调用方会话的 id)。启动器提供了 profile 上下文时,每次调用还会收到 `DSH_PROFILE`(profile 名)与 `DSH_PROFILE_DIR`(其绝对目录;其 `node_modules` 只存放 profile 自行安装的包,harness 自带的组合包从 dsh 安装目录解析);不带 profile 启动的组合则两者都没有。
|
||||
|
||||
### 添加你自己的环境事实
|
||||
|
||||
@@ -52,6 +52,8 @@ export function apply(ctx: Context): void {
|
||||
|
||||
contributor 必须声明它返回的每个键;返回未声明或非字符串的值会让该次调用失败。注册随注册插件的释放而释放,因此热重载插件会移除它的事实。
|
||||
|
||||
contributor 还可以声明绝对路径的 `commandDirectories`。注册表按 contributor 名称顺序将这些目录前置到本地 shell 的 `PATH`,保留各 contributor 内的目录顺序并去除重复目录,随后接上继承的 `PATH`。没有命令目录时,`PATH` 保持不变。目录注册不安装命令或验证其内容;贡献插件负责这些文件及其生命周期。[Office 命令插件](../../skill/skill-office/README.zh.md) 通过这种方式提供 `dsoffice` 和 `office-check`。
|
||||
|
||||
### 选择 Harness 主目录
|
||||
|
||||
唯一配置字段决定暴露为 `DSH_HOME` 的主目录;默认解析顺序为 `dshHome` 配置、环境变量 `$DSH_HOME`,然后是 `~/.dsh`。
|
||||
@@ -64,7 +66,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串
|
||||
|
||||
### 可能出什么问题
|
||||
|
||||
两个 contributor 声明同一个键,或 contributor 声称拥有保留内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE`、`DSH_PROFILE_DIR`),都会导致插件加载时明确报错。`DSH_*` 键必须全大写并带下划线(例如 `DSH_REGION`),缺少描述也会让注册失败。
|
||||
两个 contributor 声明同一个键,或 contributor 声称拥有保留内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE`、`DSH_PROFILE_DIR`),都会导致插件加载时明确报错。`DSH_*` 键必须全大写并带下划线(例如 `DSH_REGION`),缺少描述也会让注册失败。相对命令目录也会导致注册失败。
|
||||
|
||||
-----
|
||||
|
||||
@@ -90,7 +92,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串
|
||||
|
||||
### 收集
|
||||
|
||||
`collect(execution)` 从内置键出发,当执行携带 agent 时加入会话 id,再按 contributor 名称排序合并每个已注册 contributor 解析出的值。结果是一个冻结、按键排序的快照,通过 `ShellExecRequest.dshEnv` 传递。`list()` 枚举声明而不运行 resolver,因此无法反映依赖执行的值。
|
||||
`collect(execution)` 将内置事实、contributor 值和命令目录的 `PATH` 合并为冻结快照,通过 `ShellExecRequest.dshEnv` 传递。该受信快照优先于普通调用方环境项。`list()` 枚举变量声明而不运行 resolver;不包含命令目录或依赖执行的值。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -113,7 +115,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
通过 shell 工具(`dsh-tool-bash`、`dsh-tool-pwsh`)间接产生影响;这些工具把本注册表的受管 `DSH_*` 事实暴露在每次 shell 工具调用中。
|
||||
通过 shell 工具(`dsh-tool-bash`、`dsh-tool-pwsh`)间接产生影响;这些工具在每次调用中提供本注册表的受管 `DSH_*` 事实和命令查找路径。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -127,6 +129,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串
|
||||
这些限制说明注册表何时不合适或需要小心使用。它们是当前包约束,不是任务积压。
|
||||
|
||||
- **`list()` 只枚举插件贡献的变量**——注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PROFILE`、`DSH_PROFILE_DIR`)不包含在内,因此诊断、提示词或 UI 代码不得把 `list()` 当作完整的环境目录。
|
||||
- **命令目录属于本地主机。** 远程执行提供方需要在自己的执行环境中安装和配置命令;本地目录贡献不会为远程主机准备资源。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import { delimiter, isAbsolute } from 'node:path'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell'
|
||||
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-shell'
|
||||
@@ -51,6 +52,8 @@ export interface BashEnvVariable {
|
||||
export interface BashEnvContributor {
|
||||
/** Stable contributor name used in diagnostics and duplicate detection. */
|
||||
name: string
|
||||
/** Absolute command directories prepended to the local shell PATH; no process-global environment is changed. */
|
||||
commandDirectories?: readonly string[]
|
||||
/** Complete set of `DSH_*` keys this contributor may return. */
|
||||
variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
|
||||
/**
|
||||
@@ -119,6 +122,9 @@ export class ShellEnvRegistry extends Service {
|
||||
if (this.contributors.has(contributor.name)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" is already registered`)
|
||||
}
|
||||
if (contributor.commandDirectories?.some(directory => !isAbsolute(directory))) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" requires absolute command directories`)
|
||||
}
|
||||
|
||||
const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
|
||||
for (const [key, variable] of variables) {
|
||||
@@ -181,7 +187,14 @@ export class ShellEnvRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
|
||||
const directories = [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))
|
||||
.flatMap(contributor => contributor.commandDirectories ?? [])
|
||||
return Object.freeze({
|
||||
...Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))),
|
||||
...(directories.length === 0 ? {} : {
|
||||
PATH: [...new Set(directories), ...(process.env.PATH ? [process.env.PATH] : [])].join(delimiter),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { delimiter, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -164,6 +164,39 @@ describe('ShellEnvRegistry', () => {
|
||||
variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/must describe/)
|
||||
expect(() => registry.register({
|
||||
name: 'relative-command-directory', commandDirectories: ['relative/bin'], variables: {}, resolve: () => ({}),
|
||||
})).toThrow(/absolute command directories/)
|
||||
})
|
||||
|
||||
it('prepends unique command directories in contributor order without changing the process PATH', async () => {
|
||||
const ambient = resolve('ambient-bin'), first = resolve('first-bin'), second = resolve('second-bin')
|
||||
vi.stubEnv('PATH', ambient)
|
||||
const ctx = new Context()
|
||||
try {
|
||||
const registry = new ShellEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
registry.register({ name: 'b', commandDirectories: [second, first], variables: {}, resolve: () => ({}) })
|
||||
const dispose = registry.register({ name: 'a', commandDirectories: [first], variables: {}, resolve: () => ({}) })
|
||||
expect(registry.collect(execution()).PATH).toBe([first, second, ambient].join(delimiter))
|
||||
expect(process.env.PATH).toBe(ambient)
|
||||
dispose()
|
||||
expect(registry.collect(execution()).PATH).toBe([second, first, ambient].join(delimiter))
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes only contributed directories when the inherited PATH is empty', async () => {
|
||||
vi.stubEnv('PATH', '')
|
||||
const ctx = new Context()
|
||||
try {
|
||||
const registry = new ShellEnvRegistry(ctx, { dshHome: './test-dsh-home' }), bin = resolve('command-bin')
|
||||
registry.register({ name: 'local-command', commandDirectories: [bin], variables: {}, resolve: () => ({}) })
|
||||
expect(registry.collect(execution()).PATH).toBe(bin)
|
||||
expect(process.env.PATH).toBe('')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects undeclared variables returned by a contributor', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
en: e0af59069c89278b
|
||||
zh: 913d0088b2a7296d
|
||||
/deepseek-ai-dsh-skill-filesystem/summary:
|
||||
en: ef659bb1fd748f41
|
||||
en: bdcd9ccd7a1b58e5
|
||||
zh: 9d40999be52526f5
|
||||
/deepseek-ai-dsh-skill-filesystem/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 012a123601c7d8f2
|
||||
zh: 0e13f5e0d2e4ea7a
|
||||
/deepseek-ai-dsh-skill-filesystem/use-this-package/skill-format:
|
||||
en: 78ac4e731c6a24c1
|
||||
zh: e8c33db4aa0cc527
|
||||
en: 46f7c278b2424c4b
|
||||
zh: 06b354fc9b80e539
|
||||
/deepseek-ai-dsh-skill-filesystem/use-this-package/roots-and-priority:
|
||||
en: 265594f30ba5d848
|
||||
zh: 5de6a94864cab8d6
|
||||
@@ -33,7 +33,7 @@
|
||||
en: 77acbd43e89b6043
|
||||
zh: 8169bbbc55f3f114
|
||||
/deepseek-ai-dsh-skill-filesystem/use-this-package/observable-success-and-failures:
|
||||
en: 272693c35012ffb5
|
||||
en: a980728e4d8b3cad
|
||||
zh: bbbe788b11ef567f
|
||||
/deepseek-ai-dsh-skill-filesystem/understand-the-implementation:
|
||||
en: a95eb63f5a570c79
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Agents can use local skills from the repository, a custom directory, or the user's agent configuration: author a skill as a directory bundle with a `SKILL.md` or a flat `<name>.md` file under any scanned root, and it appears in the session catalog. The provider discovers the project, custom, and user roots, parses each skill's YAML frontmatter, and watches the directories, so new, renamed, or deleted skills reach agents without a restart. Choose it when skills live on disk — the registry (`dsh-skill`) accepts any provider, and another provider can supply skills from elsewhere.
|
||||
Agents can use local skills from the repository, a custom directory, or the user's agent configuration: author a skill as a directory bundle with a `SKILL.md` or a flat `<name>.md` file under any scanned root, and listed, model-invocable skills appear in the session catalog. The provider discovers the project, custom, and user roots, parses each skill's YAML frontmatter, and watches the directories, so new, renamed, or deleted skills reach agents without a restart. Choose it when skills live on disk — the registry (`dsh-skill`) accepts any provider, and another provider can supply skills from elsewhere.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -41,6 +41,8 @@ Catalog entries and loaded skills expose the resolved instruction-file path, so
|
||||
|
||||
The catalog and the body have separate lifecycles: discovery parses frontmatter into the catalog entry, and every load re-reads the current file, so editing a skill body needs no versioning or cache invalidation.
|
||||
|
||||
`modelCatalogVisibility: unlisted` hides a skill from the model catalog while preserving named tool and explicit user invocation. Omission defaults to `listed` in the registry. Only `listed` and `unlisted` are accepted; invalid values skip the skill with a warning. Invocation permission fields remain independent.
|
||||
|
||||
### Roots and priority
|
||||
|
||||
Default roots are scanned in this provider's rank order:
|
||||
@@ -82,7 +84,7 @@ Existing roots are watched, so adding, renaming, or deleting a skill (or editing
|
||||
|
||||
### Observable success and failures
|
||||
|
||||
A valid skill under any scanned root appears in the session catalog sorted by name, and loading it returns the current file body. A file without valid frontmatter, an invalid name, or an invalid invocation value is skipped with a warning, so the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from an invalid one. Unexpected discovery or read failures leave the catalog observation incomplete rather than replacing the last-good view with a misleading deletion.
|
||||
A valid listed, model-invocable skill under any scanned root appears in the session catalog sorted by name, and loading it returns the current file body. A file without valid frontmatter, an invalid name, or an invalid invocation value is skipped with a warning, so the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from an invalid one. Unexpected discovery or read failures leave the catalog observation incomplete rather than replacing the last-good view with a misleading deletion.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ skill 可以是被扫描根目录顶层的目录 bundle `<name>/SKILL.md`,也
|
||||
|
||||
目录与正文具有独立的生命周期:发现阶段把 frontmatter 解析进目录条目,每次加载都会重新读取当前文件,因此编辑 skill 正文无需版本化或缓存失效。
|
||||
|
||||
`modelCatalogVisibility: unlisted` 将技能从模型目录隐藏,但保留具名工具调用和用户显式调用。省略时由注册表默认成 `listed`。仅接受 `listed` 与 `unlisted`;无效值会跳过技能并发出警告。调用权限字段保持独立。
|
||||
|
||||
### 根目录与优先级
|
||||
|
||||
默认根按该提供方的 rank 顺序扫描:
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isSkillName,
|
||||
type SkillCandidate,
|
||||
type SkillDefinition,
|
||||
type ModelCatalogVisibility,
|
||||
type SkillInvocationPolicy,
|
||||
type SkillLookupOptions,
|
||||
type SkillProvider,
|
||||
@@ -112,6 +113,7 @@ interface ParsedSkill extends SkillText {
|
||||
description: string
|
||||
whenToUse?: string
|
||||
invocation: SkillInvocationPolicy
|
||||
modelCatalogVisibility?: ModelCatalogVisibility
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -215,6 +217,7 @@ export class FileSystemSkillProvider implements SkillProvider {
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
invocation: parsed.invocation,
|
||||
...parsed.modelCatalogVisibility !== undefined ? { modelCatalogVisibility: parsed.modelCatalogVisibility } : {},
|
||||
source: candidate.source,
|
||||
provider: this.name,
|
||||
resourceBase: { kind: 'directory', path: locator.directory },
|
||||
@@ -745,6 +748,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context, provider: string): Pr
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
invocation: parsed.invocation,
|
||||
...parsed.modelCatalogVisibility !== undefined ? { modelCatalogVisibility: parsed.modelCatalogVisibility } : {},
|
||||
provider,
|
||||
source: root.source,
|
||||
rank: root.rank,
|
||||
@@ -829,8 +833,13 @@ async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal,
|
||||
return undefined
|
||||
}
|
||||
let invocation
|
||||
let modelCatalogVisibility
|
||||
try {
|
||||
invocation = parseInvocationPolicy(parsed.data)
|
||||
modelCatalogVisibility = parsed.data.modelCatalogVisibility
|
||||
if (modelCatalogVisibility !== undefined && modelCatalogVisibility !== 'listed' && modelCatalogVisibility !== 'unlisted') {
|
||||
throw new TypeError('frontmatter field "modelCatalogVisibility" must be listed or unlisted')
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: invalid invocation frontmatter: ${errorMessage(error)}`)
|
||||
return undefined
|
||||
@@ -840,6 +849,7 @@ async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal,
|
||||
description,
|
||||
...optionalString(parsed.data, 'whenToUse'),
|
||||
invocation,
|
||||
...modelCatalogVisibility !== undefined ? { modelCatalogVisibility } : {},
|
||||
...optionalMetadata(parsed.data),
|
||||
path: raw.path,
|
||||
content: parsed.body.trim(),
|
||||
|
||||
@@ -366,6 +366,8 @@ describe('FileSystemSkillProvider', () => {
|
||||
['legacy-user', 'userInvocable: false'],
|
||||
['bad-string', 'disable-model-invocation: maybe'],
|
||||
['bad-value', 'user-invocable: null'],
|
||||
['bad-visibility', 'modelCatalogVisibility: hidden'],
|
||||
['null-visibility', 'modelCatalogVisibility: null'],
|
||||
] as const
|
||||
for (const [name, field] of invalid) {
|
||||
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${name}\n${field}\n---\n\nBad.\n`)
|
||||
@@ -920,3 +922,15 @@ describe('FileSystemSkillProvider', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['listed', 'unlisted'])('parses %s catalog visibility independently of invocation permissions', async (visibility) => {
|
||||
const home = await tempDir('skill-visibility')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await mkdir(root, { recursive: true })
|
||||
await writeFile(join(root, 'nested.md'), `---\nname: nested\ndescription: Nested operation\nmodelCatalogVisibility: ${visibility}\n---\nBody.\n`)
|
||||
const ctx = await setupLocal(home)
|
||||
expect((await ctx.skills.list())[0]).toMatchObject({
|
||||
modelCatalogVisibility: visibility, invocation: { modelInvocable: true, userInvocable: true },
|
||||
})
|
||||
expect(await ctx.skills.get('nested')).toMatchObject({ modelCatalogVisibility: visibility, content: 'Body.' })
|
||||
})
|
||||
@@ -3,41 +3,41 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/skill/skill-office/README.md
|
||||
/:
|
||||
en: cc4e875ac37d9354
|
||||
zh: e121819cde2ce76c
|
||||
en: b65118b967447a79
|
||||
zh: 83afe7dbc1a82c44
|
||||
/deepseek-ai-dsh-skill-office:
|
||||
en: f855ac5e12b4e604
|
||||
zh: b2e875d31d56cb2e
|
||||
/deepseek-ai-dsh-skill-office/summary:
|
||||
en: 4fe3cb4544ce7d9d
|
||||
zh: 2749b1d0ffa468a1
|
||||
en: 68c013ddabd0fda4
|
||||
zh: 31454a3bb339ec05
|
||||
/deepseek-ai-dsh-skill-office/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 43ad67236ccda19a
|
||||
en: c361195a73cf5755
|
||||
zh: 745b04e7be0be369
|
||||
/deepseek-ai-dsh-skill-office/use-this-package:
|
||||
en: 6987594a0cf6e53d
|
||||
zh: ecf4c4f52e7a50eb
|
||||
/deepseek-ai-dsh-skill-office/use-this-package/minimal-configuration:
|
||||
en: 088f223318dff9d7
|
||||
zh: ce944bf61fb1f821
|
||||
en: c3670b2a86f30e63
|
||||
zh: 12d2336d012eaf50
|
||||
/deepseek-ai-dsh-skill-office/use-this-package/progressive-loading:
|
||||
en: 4a7b735989c18499
|
||||
zh: 0116d85b408816c9
|
||||
/deepseek-ai-dsh-skill-office/use-this-package/structural-checks:
|
||||
en: 1bb872d63bdd78b7
|
||||
zh: 355dee19bdfb3798
|
||||
en: 81efb4310276e7a9
|
||||
zh: 7c7a0fa8f2512c08
|
||||
/deepseek-ai-dsh-skill-office/understand-the-implementation:
|
||||
en: 9c45ee3de51adb4d
|
||||
zh: 0f24d44dfa58df25
|
||||
en: 153fbaa3f9c0a259
|
||||
zh: 1603b772085f6b0a
|
||||
/deepseek-ai-dsh-skill-office/further-exploration:
|
||||
en: b08fc992559d8fda
|
||||
zh: ad74e037ecd81c0b
|
||||
en: a93f275e608a6bab
|
||||
zh: 191e8d36c433e8a2
|
||||
/deepseek-ai-dsh-skill-office/model-experience:
|
||||
en: f9fe51841bd9de8d
|
||||
zh: 773af8cc0e0335e4
|
||||
en: bbd979fd20e7b308
|
||||
zh: 7e6b6e04f2aedb02
|
||||
/deepseek-ai-dsh-skill-office/model-experience/kv-cache-effect:
|
||||
en: c77beb1c0d28f221
|
||||
zh: 99643101d2b915b9
|
||||
en: dc9e9205d989f1d0
|
||||
zh: 01bdbbfba743813a
|
||||
/deepseek-ai-dsh-skill-office/known-limitations-and-deferred-work:
|
||||
en: 22d0a8196c2c48c3
|
||||
zh: ef2e38a0a91476a8
|
||||
en: fd1f488986e15054
|
||||
zh: ac723bc3e8d48f71
|
||||
/deepseek-ai-dsh-skill-office/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
zh: 8ec3e73528ddb1a6
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Bundled Word, PowerPoint, and Excel instructions for deployments providing Office file authoring and structural checks."
|
||||
description: "Progressive Office authoring and CLI operation skills for document workflows."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,11 +9,12 @@ 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. When supplied by the environment, the LibreOffice Kit CLI directly renders selected pages or worksheet ranges, converts requested PDFs, and recalculates workbooks into new files. Visual QA requires image input support, reuses unchanged-source previews, and ends when applicable checks pass; ordinary delivery does not require installing a renderer.
|
||||
The initial catalog lists one short `office` summary. Its router selects an authoring skill or a separate rendering, recalculation, or PDF export skill. Skills use `office-check` and `dsoffice` through the existing shell tools without exposing interpreter or package paths. Web enables the skills and Kit command without a Python runtime. Other base compositions enable them when a carrier supplies a runtime or the deployment explicitly selects one. Ordinary Web does not provide or download script runtimes; its checker requires an explicitly supplied environment, and minimal presets retain their shell-only tool set.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Progressive loading](#progressive-loading)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Further Exploration](#further-exploration)
|
||||
- [Model Experience](#model-experience)
|
||||
@@ -25,29 +26,71 @@ Agents can load Word, PowerPoint, and Excel workflows that use the bundled Pytho
|
||||
<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
|
||||
Mount the skill provider beside the skill registry and `dsh-tool-skill`, and the command plugin beside `dsh-shell-env`. The provider supplies instructions and checker resources; the command plugin makes `dsoffice` and `office-check` available to local Bash and PowerShell tool calls.
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-skill-office'
|
||||
- name: '@deepseek-ai/dsh-skill-office/commands'
|
||||
```
|
||||
|
||||
| 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. |
|
||||
| `node` | Current standalone Node process | Absolute Node executable; Electron and SDK executables must supply a standalone Node. |
|
||||
| `cli` | Installed kit’s `lib/cli.js` | Absolute CLI entry; `false` explicitly disables the CLI. |
|
||||
| `assetRoot` | Carrier resources or packaged `assets/` | Absolute directory containing the seven skill folders and shared `scripts/`. |
|
||||
|
||||
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.
|
||||
Invalid skill assets reject registration. Loading instructions does not resolve interpreters or prepare engines. Bash or PowerShell owns approvals, sandboxing, cancellation, and output management. Resources and execution tools must refer to the same host.
|
||||
|
||||
Loaded skills append an `Installed LibreOffice Kit` section with absolute `libreofficeKit.node` and `libreofficeKit.cli` paths. Agents use the bundled binaries unless the user explicitly opts out, passing the CLI as Node’s first argument without relying on the working directory or searching PATH. A bundled CLI failure is reported rather than replaced with a system executable. Activation checks both paths are files. npm deployments use their installed kit by default; Desktop supplies standalone Node and the complete unpacked Office dependencies; the Python SDK supplies standalone Node and its adjacent Office directory. Custom Python-only deployments must set `cli: false` or supply standalone Node.
|
||||
The command plugin accepts separate configuration:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `command` | Carrier or installed Kit | Executable and fixed arguments for `dsoffice`; `false` omits that command. |
|
||||
| `checkCommand` | Configured Python checker | Executable and fixed arguments for `office-check`; `false` omits that command. |
|
||||
| `assetRoot` | Carrier resources or packaged `assets/` | Directory containing the shared checker and skills. |
|
||||
| `runtime` | Explicit carrier resources | [Workspace runtime configuration](../tool-workspace-dependencies/README.md) used to prepare the checker interpreter. |
|
||||
|
||||
Command overrides require an absolute executable as their first argument. Launchers receive the caller's remaining arguments unchanged and live only while the command plugin is mounted. Windows launchers preserve empty arguments, embedded quotes, trailing backslashes, Unicode paths, and shell pipelines on Windows PowerShell 5.1 and PowerShell 7; PowerShell arrays supplied as one argument become comma-separated text, and pipeline text uses UTF-8 without a byte-order mark. A missing executable returns a nonzero exit code. The shell environment prepends their private directory to each tool call's `PATH`; it does not change the process or user's shell configuration. Disabling a command does not prevent skill loading. `office-check` validates or copies the supplied Python payload on first use when necessary; it is omitted when neither a runtime nor `checkCommand` is supplied. It never downloads an interpreter. The general `load_workspace_dependencies` tool supplies authoring interpreters and libraries independently.
|
||||
|
||||
`DSH_PRIMARY_RUNTIME` selects authoring and checker resources. Kit commands use the carrier's Node.js when supplied, otherwise the host Node.js, so Python-only authoring payloads are supported.
|
||||
|
||||
<a id="progressive-loading"></a>
|
||||
### Progressive loading
|
||||
|
||||
The model chooses skills from the task; the host does not inject complete instructions based on file extensions. Subskills are unlisted in the initial catalog but remain callable by name through `skill`, including explicit user invocation. Requested appearance changes, including spreadsheet cell fills and text alignment, require rendering even without an explicit visual-check request; an explicit request to skip rendering takes precedence. Pure operations can enter the corresponding skill directly.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Initial catalog: office summary"]
|
||||
A -->|Non-Office task| N["Continue without loading Office instructions"]
|
||||
A -->|Office task| B["skill(office): task routing"]
|
||||
|
||||
B -->|Create or edit| C["skill(office-docx / office-pptx / office-xlsx)"]
|
||||
C --> D["Author and validate content, structure and data"]
|
||||
D -->|No additional operation needed| F["Deliver"]
|
||||
|
||||
B -->|Images, visual inspection, or appearance changes| R["skill(office-render)"]
|
||||
D -->|Images, visual inspection, or appearance changes| R
|
||||
R --> RC["Shell: render into a private workspace temporary directory"]
|
||||
RC -->|Usable preview| V["Inspect requested images"]
|
||||
V --> F
|
||||
RC -->|Failed or blank preview| L["Report content checks and unfinished visual inspection separately"]
|
||||
L --> F
|
||||
|
||||
B -->|Formula recalculation| X["skill(office-recalculate)"]
|
||||
D -->|Formula recalculation| X
|
||||
X --> XC["Shell: CLI recalculate and verify values"]
|
||||
XC --> F
|
||||
|
||||
B -->|PDF export| P["skill(office-export-pdf)"]
|
||||
D -->|PDF export| P
|
||||
P --> PC["Shell: CLI convert"]
|
||||
PC --> F
|
||||
```
|
||||
|
||||
New files and content, data, or formula edits do not require rendering solely because they include incidental basic formatting. Preview images use a private, randomly named temporary directory inside the workspace and are removed after inspection. Loaded instructions remain in session history after an operation finishes. The diagram describes ordinary presets across supported clients; it does not change the shell-only definition of minimal or sdk-minimal.
|
||||
|
||||
### Structural checks
|
||||
|
||||
The shared Python checker reads DOCX, PPTX, or XLSX without modifying the source. It recognizes Transitional and Strict OOXML namespaces, 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, corrupt or 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.
|
||||
|
||||
Excel data and formula tasks skip visual inspection; formatting, layout, chart appearance, printing, or known display problems require checking relevant regions. Basic styling does not itself trigger inspection, and formula recalculation and requested exports remain independent operations. Blank or transparent previews do not pass visual inspection. A failed preview ends visual checking without diagnostic workbooks, PDF conversion, or unrequested print-setting changes; the usable workbook is delivered with the inspection limitation.
|
||||
`office-check <file> --out <report.json>` reads DOCX, PPTX, and XLSX without changing the source. The shared Python checker validates ZIP/XML and internal relationships, summarizes document structure, and optionally checks required text or slide/sheet counts. It requires only the Python standard library. Invalid files and report-write failures return a JSON failure report. Structural success does not establish appearance, feature preservation, or calculated formula values.
|
||||
|
||||
-----
|
||||
|
||||
@@ -57,12 +100,18 @@ Excel data and formula tasks skip visual inspection; formatting, layout, chart a
|
||||
<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.
|
||||
The provider reads descriptions at registration and bodies on demand. Only `office` has `modelCatalogVisibility: listed`; the six subskills use `unlisted`. Registry precedence, scope, and invocation permissions apply to every skill. Each loaded skill includes its resource directory for relative references. Disposal unregisters all candidates.
|
||||
|
||||
The provider registers no tools or activation projection. Loaded skill results remain logged through the generic skill loader. Resume and fork use existing session history and tool declaration reconciliation. The command plugin hides carrier-specific executable paths behind local launchers and registers their directory with the shell environment; disposal removes both the contribution and the directory. Windows launcher directories support long `DSH_HOME` paths; `PATH` entries use ordinary absolute paths. When an executable path exceeds the Windows process launch limit, the plugin temporarily links its parent directory through a short path under the system temporary directory and removes that link on disposal.
|
||||
|
||||
PowerShell 5.1 pipeline launches temporarily replace and restore .NET Framework's cached input encoding to prevent its native-process writer from prepending a byte-order mark. Setting UTF-8 through the public encoding setter requires a console and resets buffered input; the launcher preserves the existing input reader.
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Provider registration and configured resource paths. |
|
||||
| [`assets/`](assets/) | Three workflows and the read-only OOXML checker. |
|
||||
| [`src/index.ts`](src/index.ts) | Resource registration and unchanged skill bodies. |
|
||||
| [`src/commands.ts`](src/commands.ts) | Local launchers, carrier selection, and shell environment contribution. |
|
||||
| [`src/check.ts`](src/check.ts) | Private checker invocation using the shared workspace runtime resolver. |
|
||||
| [`assets/`](assets/) | Router, six subskills, and read-only OOXML checker. |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -72,7 +121,8 @@ The provider registers three bundled candidates and reads their descriptions fro
|
||||
## Further Exploration
|
||||
|
||||
- [Skill registry](../skill/README.md) — discovery and precedence.
|
||||
- [Skill tool](../tool-skill/README.md) — model-visible catalogs and bodies.
|
||||
- [Skill tool](../tool-skill/README.md) — model catalogs and loaded bodies.
|
||||
- [Shell environment](../../shell/shell-env/README.md) — command directories and managed variables.
|
||||
- [File delivery](../../deliverables/tool-present/README.md) — current source-path delivery.
|
||||
|
||||
-----
|
||||
@@ -80,19 +130,19 @@ The provider registers three bundled candidates and reads their descriptions fro
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-skill`, which renders the catalog entries and selected instruction body.
|
||||
Indirectly, through `dsh-tool-skill`, which renders the initial Office summary and returns routing or selected subskill instructions from `skill(name)`.
|
||||
|
||||
#### 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.
|
||||
Skill results append to history without changing tool declarations, and loaded text or images remain after an operation finishes. Character estimates use `ceil(characters / 4)`: the summary is at most 50 tokens and the router body at most 200; authoring bodies are at least 35% smaller than the combined authoring and operation guides. Skill wrappers, the generic catalog, shell declarations, and `load_workspace_dependencies` are separate costs, and these estimates are not server usage.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- The provider depends on LibreOffice Kit but does not install Python or authoring libraries. 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.
|
||||
- The provider does not install authoring libraries. The checker needs Python 3.9 or later.
|
||||
- LibreOffice output can differ from native Office applications; structural checks cannot certify appearance.
|
||||
- Legacy, encrypted, and macro-enabled files need an appropriate separate workflow.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "随包附带的 Word、PowerPoint 和 Excel 指令,供需要 Office 文件编写与结构检查能力的部署使用。"
|
||||
description: "为文档工作流提供渐进加载的 Office 创作与 CLI 操作技能。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,15 +9,16 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
Agent(智能体)可以加载 Word、PowerPoint 和 Excel 工作流,默认使用内置 Python 环境,并遵循用户或 AGENTS.md 明确指定的环境。这些 skill(技能)涵盖创建、局部编辑、结构检查和文件交付。环境提供 LibreOffice Kit CLI 时,可直接渲染选定页面或工作表区域、转换所需 PDF,并将重新计算后的工作簿保存为新文件。视觉检查要求模型支持图片输入,复用源文件未变时的预览,并在适用检查通过后结束;普通交付不要求安装渲染器。
|
||||
初始目录只列出简短的 `office` 摘要。路由选择创作技能,或独立的渲染、重计算、PDF 导出技能。技能通过现有 shell 工具使用 `office-check` 和 `dsoffice`,不暴露解释器或包路径。Web 无需 Python 运行时即可启用技能和 Kit 命令。其他基础组合在载体提供运行时或部署显式选择运行时时启用它们。普通 Web 不提供或下载脚本运行时;检查器需要显式提供环境,minimal 预设仍只提供 shell 工具。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [了解实现](#understand-the-implementation)
|
||||
- [延伸阅读](#further-exploration)
|
||||
- [渐进加载](#progressive-loading)
|
||||
- [理解实现](#understand-the-implementation)
|
||||
- [进一步探索](#further-exploration)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与暂缓事项](#known-limitations-and-deferred-work)
|
||||
- [已知限制与后续工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
@@ -25,80 +26,129 @@ Agent(智能体)可以加载 Word、PowerPoint 和 Excel 工作流,默认
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
将本提供方与 skill 注册表及 `dsh-tool-skill` 一同挂载,即可在会话目录中提供 `office-docx`、`office-pptx` 和 `office-xlsx`。提供方携带指令和脚本;部署提供解释器、编写库、执行工具与文件交付工具。
|
||||
|
||||
### 最小配置
|
||||
在技能注册表和 `dsh-tool-skill` 旁挂载技能提供者,在 `dsh-shell-env` 旁挂载命令插件。提供者负责说明和检查器资源;命令插件让本地 Bash 和 PowerShell 工具调用可以使用 `dsoffice` 与 `office-check`。
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-skill-office'
|
||||
- name: '@deepseek-ai/dsh-skill-office/commands'
|
||||
```
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `assetRoot` | 包内的 `assets/` | 包含三个 skill 文件夹和共享 `scripts/` 的绝对资源目录;部署可将其放在应用归档之外。 |
|
||||
| `node` | 当前独立 Node 进程 | Node 可执行文件的绝对路径;Electron 和独立 SDK 可执行文件必须显式提供。 |
|
||||
| `cli` | 已安装 kit 的 `lib/cli.js` | CLI 入口的绝对路径;`false` 显式禁用 CLI。 |
|
||||
| `assetRoot` | 载体资源或包内 `assets/` | 包含七个技能目录及共享 `scripts/` 的绝对路径。 |
|
||||
|
||||
相对路径、资源缺失或 skill 文件的 YAML frontmatter 缺少描述都会导致激活失败。卸载插件会移除其候选项。项目和用户 skill 的优先级仍由 skill 注册表负责。
|
||||
无效技能资源会阻止注册。加载说明不解析解释器或准备引擎。Bash 或 PowerShell 负责审批、沙箱、取消和输出管理。资源与执行工具必须位于同一主机。
|
||||
|
||||
加载的技能在 `Installed LibreOffice Kit` 段落中提供 `libreofficeKit.node` 和 `libreofficeKit.cli` 的绝对路径。除非用户明确要求不用内置二进制,Agent 将内置 CLI 作为 Node 的第一个参数运行,无需依赖工作目录或搜索 PATH。内置 CLI 失败时报告错误,不替换为系统可执行文件。激活时检查两个路径是否为实际文件。npm 部署默认使用已安装的 kit;Desktop 提供独立 Node 和 ASAR 外的完整 Office 依赖;Python SDK 提供独立 Node 和可执行文件旁的 Office 目录。仅有 Python 的自定义部署必须配置 `cli: false`,或提供独立 Node。
|
||||
命令插件接受独立配置:
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `command` | 载体或已安装 Kit | `dsoffice` 的可执行程序及固定参数;`false` 不提供该命令。 |
|
||||
| `checkCommand` | 配置的 Python 检查器 | `office-check` 的可执行程序及固定参数;`false` 不提供该命令。 |
|
||||
| `assetRoot` | 载体资源或包内 `assets/` | 包含共享检查器和技能的目录。 |
|
||||
| `runtime` | 显式载体资源 | 用于准备检查器解释器的[工作区运行时配置](../tool-workspace-dependencies/README.zh.md)。 |
|
||||
|
||||
命令覆盖的首个参数必须是可执行程序的绝对路径。启动器原样接收调用方的其余参数,仅在命令插件挂载期间存在。Windows 启动器在 Windows PowerShell 5.1 和 PowerShell 7 下保留空参数、内嵌引号、末尾反斜杠、Unicode 路径和 shell 管道;作为单个参数传入的 PowerShell 数组会转为逗号分隔文本,管道文本使用不带字节顺序标记的 UTF-8。可执行程序不存在时,命令返回非零退出码。shell 环境将启动器的私有目录前置到每次工具调用的 `PATH`;不修改进程或用户 shell 配置。禁用命令不阻止技能加载。必要时,`office-check` 在首次使用时校验或复制提供的 Python payload;未提供运行时和 `checkCommand` 时,不注册此命令。它不会下载解释器。通用 `load_workspace_dependencies` 工具独立提供创作解释器和库。
|
||||
|
||||
`DSH_PRIMARY_RUNTIME` 选择创作和检查器资源。Kit 命令使用载体提供的 Node.js,否则使用宿主 Node.js,因此支持仅含 Python 的创作载荷。
|
||||
|
||||
<a id="progressive-loading"></a>
|
||||
### 渐进加载
|
||||
|
||||
模型依据任务选择技能;宿主不会根据文件扩展名注入完整说明。子技能不显示在初始目录中,但仍可通过 `skill` 按名称调用,包括用户显式调用。用户要求的外观修改,包括电子表格单元格填充和文本对齐,即使没有明确要求视觉检查,也需要渲染;用户明确要求跳过渲染时以该要求为准。纯操作任务可以直接进入对应技能。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Initial catalog: office summary"]
|
||||
A -->|Non-Office task| N["Continue without loading Office instructions"]
|
||||
A -->|Office task| B["skill(office): task routing"]
|
||||
|
||||
B -->|Create or edit| C["skill(office-docx / office-pptx / office-xlsx)"]
|
||||
C --> D["Author and validate content, structure and data"]
|
||||
D -->|No additional operation needed| F["Deliver"]
|
||||
|
||||
B -->|Images, visual inspection, or appearance changes| R["skill(office-render)"]
|
||||
D -->|Images, visual inspection, or appearance changes| R
|
||||
R --> RC["Shell: render into a private workspace temporary directory"]
|
||||
RC -->|Usable preview| V["Inspect requested images"]
|
||||
V --> F
|
||||
RC -->|Failed or blank preview| L["Report content checks and unfinished visual inspection separately"]
|
||||
L --> F
|
||||
|
||||
B -->|Formula recalculation| X["skill(office-recalculate)"]
|
||||
D -->|Formula recalculation| X
|
||||
X --> XC["Shell: CLI recalculate and verify values"]
|
||||
XC --> F
|
||||
|
||||
B -->|PDF export| P["skill(office-export-pdf)"]
|
||||
D -->|PDF export| P
|
||||
P --> PC["Shell: CLI convert"]
|
||||
PC --> F
|
||||
```
|
||||
|
||||
新建文件以及内容、数据或公式修改,不会仅因附带基础格式调整而需要渲染。预览图片保存在工作区内随机命名的私有临时目录中,并在检查后删除。操作结束后,已加载的说明仍属于会话历史。图中描述各端常规预设,不改变 minimal 或 sdk-minimal 的纯 shell 定义。
|
||||
|
||||
### 结构检查
|
||||
|
||||
共享 Python 检查器读取 DOCX、PPTX 或 XLSX,不修改源文件。它识别 Transitional 与 Strict OOXML 命名空间,验证 ZIP/XML 和包内引用关系,报告文档结构,并可检查必需文本或幻灯片/工作表数量。DOCX 文本断言覆盖正文、分节引用的页眉和页脚,以及正文引用的脚注和尾注;批注、词库文本与未引用的部件或脚注/尾注不能满足断言。它只使用 Python 标准库。无效包、损坏或加密的 ZIP 成员和报告文件写入失败都会在标准输出中产生 JSON 失败报告。检查通过不代表外观、特性保留或公式计算结果已得到验证。
|
||||
|
||||
Excel 数据和公式任务跳过视觉检查;涉及格式、排版、图表外观、打印或已知显示问题时,检查相关区域。基础样式设置本身不触发视觉检查,公式重算和用户要求的导出仍是独立操作。 空白或透明预览不能通过视觉检查。预览失败时结束视觉检查,不创建诊断工作簿、不转换 PDF,也不擅自修改打印设置;交付可用工作簿并说明检查限制。
|
||||
`office-check <file> --out <report.json>` 读取 DOCX、PPTX 和 XLSX,不修改源文件。共享 Python 检查器验证 ZIP/XML 和内部关系、汇总文档结构,并可检查必需文本或幻灯片/工作表数量。它仅依赖 Python 标准库。无效文件和报告写入失败会返回 JSON 失败报告。结构检查成功不代表外观、功能保留或公式计算值通过验证。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 了解实现
|
||||
## 理解实现
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
<summary>实现细节 — 点击展开</summary>
|
||||
|
||||
提供方注册三个内置候选项,并在激活时从随包 YAML frontmatter 读取描述。加载后的指令正文不包含这些元数据。加载后的每个 skill 暴露自己的文件系统目录,因此执行工具可以定位共享检查器,而不依赖任务工作目录。可配置的外部资源支持 Python 无法读取应用归档的分发方式。
|
||||
提供者在注册时读取摘要,按需读取正文。只有 `office` 使用 `modelCatalogVisibility: listed`;六个子技能使用 `unlisted`。注册表优先级、作用域和调用权限适用于全部技能。加载结果包含用于解析相对引用的资源目录。卸载提供者会移除全部候选技能。
|
||||
|
||||
提供者不注册工具或激活投影。通用技能加载器记录已加载技能的结果。恢复和 fork 使用现有会话历史及工具声明更新机制。命令插件用本地启动器封装载体专属可执行路径,并将其目录注册到 shell 环境;释放时移除该贡献与目录。Windows 启动器目录支持较长的 `DSH_HOME` 路径;`PATH` 条目使用普通绝对路径。可执行程序路径超过 Windows 进程启动长度限制时,插件在系统临时目录下建立通往其父目录的短路径链接,并在释放时移除该链接。
|
||||
|
||||
PowerShell 5.1 管道调用临时替换并恢复 .NET Framework 缓存的输入编码,防止其原生进程写入器添加字节顺序标记。通过公开的编码设置器设置 UTF-8 需要控制台并会重置已缓冲的输入;启动器保留现有输入读取器。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 提供方注册与配置资源路径。 |
|
||||
| [`assets/`](assets/) | 三种工作流和只读 OOXML 检查器。 |
|
||||
| [`src/index.ts`](src/index.ts) | 注册资源并返回原始技能正文。 |
|
||||
| [`src/commands.ts`](src/commands.ts) | 本地启动器、载体选择和 shell 环境贡献。 |
|
||||
| [`src/check.ts`](src/check.ts) | 使用共享工作区运行时解析器的私有检查器调用。 |
|
||||
| [`assets/`](assets/) | 路由、六个子技能与只读 OOXML 检查器。 |
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 延伸阅读
|
||||
## 进一步探索
|
||||
|
||||
- [skill 注册表](../skill/README.zh.md)——发现与优先级。
|
||||
- [skill 工具](../tool-skill/README.zh.md)——模型可见目录与正文。
|
||||
- [文件交付](../../deliverables/tool-present/README.zh.md)——当前源路径交付。
|
||||
- [技能注册表](../skill/README.zh.md) — 发现和优先级。
|
||||
- [技能工具](../tool-skill/README.zh.md) — 模型目录和加载正文。
|
||||
- [Shell 环境](../../shell/shell-env/README.zh.md) — 命令目录和受管变量。
|
||||
- [文件交付](../../deliverables/tool-present/README.zh.md) — 当前源文件路径交付。
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-skill` 间接呈现,由其渲染目录项与选中的指令正文。
|
||||
间接影响:`dsh-tool-skill` 渲染初始 Office 摘要,并返回 `skill(name)` 选择的路由或子技能说明。
|
||||
|
||||
#### KV 缓存影响
|
||||
#### KV Cache 影响
|
||||
|
||||
挂载提供方会增加三个目录项;加载 skill 时,其正文进入既有 skill 工具的插入位置。提供方不另增提示词分区。
|
||||
技能结果追加到历史中且不改变工具声明,操作完成后已加载文本或图片仍保留在历史中。字符估算采用 `ceil(字符数 / 4)`:摘要最多 50 token,路由正文最多 200 token;创作正文比合并后的创作与操作指南至少减少 35%。技能包装、通用目录、shell 声明和 `load_workspace_dependencies` 单独计费;这些估算不等同于服务端 usage。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
## 已知限制与后续工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 提供方依赖 LibreOffice Kit,但不安装 Python 或编写库。检查器要求 Python 3.9 或更高版本。
|
||||
- 结构检查不判断分页、裁切、字体、图表外观或 Excel 重算。
|
||||
- 检查器只接受 DOCX、PPTX 和 XLSX;传统格式、加密文件和启用宏的格式需要合适的其他工作流。
|
||||
- 提供者不安装创作库。检查器需要 Python 3.9 或更新版本。
|
||||
- LibreOffice 输出可能不同于原生 Office 应用;结构检查无法认证外观。
|
||||
- 旧格式、加密和启用宏的文件需要适用的独立工作流。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者的工作上下文——点击展开</summary>
|
||||
<summary>维护者工作上下文 — 点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
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. Load this skill before running Office commands. Use only bundled LibreOffice unless the user explicitly opts out; without that opt-out, do not search for another LibreOffice executable.
|
||||
description: Create, read, edit, and validate Word documents (.docx).
|
||||
modelCatalogVisibility: unlisted
|
||||
---
|
||||
|
||||
# Word documents
|
||||
@@ -31,27 +32,16 @@ python-docx does not paginate or render documents. Do not represent ordinary rep
|
||||
|
||||
## Check and deliver
|
||||
|
||||
Run the shared checker with the selected Python executable; `<skill-directory>` is this loaded skill's resource base:
|
||||
Run the shared structural checker when `office-check` is available:
|
||||
|
||||
```text
|
||||
<python> <skill-directory>/../scripts/check_office.py <document.docx> --out <checks.json>
|
||||
office-check <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.
|
||||
|
||||
Render for a requested image/PDF deliverable or an actionable layout check. Before generating images only for inspection, establish that the current model accepts images. If image input is unavailable, finish structural and content checks and state that visual layout was not inspected; do not generate unreadable previews, retry `read_image`, or choose a second model.
|
||||
If `office-check` is unavailable, use an already configured environment to check the ZIP/XML package and internal relationships. Report the missing environment if those checks cannot run; do not claim structural validation.
|
||||
|
||||
Use the bundled LibreOffice binaries unless the user explicitly asks not to use them. Without that explicit opt-out, never search for or invoke system LibreOffice, `soffice`, an application-installed binary, or a downloaded replacement. If the bundled CLI is unavailable or fails, report that failure instead of substituting another executable.
|
||||
|
||||
Use the `libreofficeKit.node` and `libreofficeKit.cli` absolute paths supplied in the loaded skill's **Installed LibreOffice Kit** section. Run Node with the CLI entry as its first argument, then `capabilities --json` once. For the bundled runtime, these paths work from the task directory; do not search PATH, guess a package directory, use `npx`, or install a renderer. Quote each path separately; PowerShell also requires the `&` invocation operator. The examples below use `<node>` and `<cli>` for those two returned paths. Direct rendering avoids an intermediate PDF:
|
||||
|
||||
```sh
|
||||
"<node>" "<cli>" render --input report.docx --output-dir preview-v1 --pages 1,3 --dpi 144
|
||||
"<node>" "<cli>" convert --input report.docx --output report.pdf
|
||||
```
|
||||
|
||||
Choose pages relevant to the requested edit; inspect all pages when whole-document layout is required. Batch selected pages in one call and use the returned manifest's page count, image paths, and missing-font diagnostics. Output directories and converted files must be new. Reuse images for the same saved document; render again only after a source change, for an unrendered page, or to diagnose a concrete failure. Check page breaks, clipped text, headings, and table widths. Do not repeatedly export unchanged documents or infer visual overflow solely from guessed text geometry.
|
||||
|
||||
If this deployment explicitly disables the CLI but `render_document` is provided, use it without `pages` first and read its returned images only on a ready result; `status: "skipped"` ends visual checking. If neither renderer is available or rendering fails, preserve the usable source and report the inspection limit. Do not search for COM automation, install conversion packages, or build another rendering pipeline for routine QA. LibreOffice pagination can differ from Microsoft Word. Once the requested content and applicable checks pass, deliver the document.
|
||||
Deliver after the requested content and structural checks pass. Creating a file or changing content with incidental basic formatting does not require additional operations. Load `office-render` for requested images or visual inspection and for appearance-focused styling, spacing, sizing, or alignment changes unless the user explicitly skips rendering. For selected pages in PowerShell, use `dsoffice render --input "report.docx" --output-dir "<new-directory>" --pages "1,3,4"`; quote the comma-separated page list. For PDF output load `office-export-pdf`.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: office-export-pdf
|
||||
description: Convert an Office document to a requested PDF with LibreOffice Kit.
|
||||
modelCatalogVisibility: unlisted
|
||||
---
|
||||
|
||||
# Export an Office document to PDF
|
||||
|
||||
Use the installed LibreOffice Kit CLI through the shell with `dsoffice`. Prefer bundled LibreOffice unless the user requests another installation. If the Office sidecar is missing, follow [sidecar setup](../office/references/sidecar.md): ask whether the user wants to download it and install only after they agree. Do not invoke system soffice, use npx, or install a replacement to work around a failed conversion with an installed sidecar.
|
||||
|
||||
Convert the final saved Office file to a new PDF in the task workspace. Preserve the source. Quote each input and output path.
|
||||
|
||||
```text
|
||||
dsoffice convert --input <final-office-file> --output <new-file.pdf>
|
||||
```
|
||||
|
||||
Check the command result, nonempty output, and `%PDF-` file signature. If a PDF reader library is already available, compare page count and extractable text with the requested content. If it is unavailable, report the extent of validation; do not generate images or install extra tools to validate a conversion. Spreadsheet PDF pages follow workbook print settings; change those settings only when requested. Conversion may differ from the native application's pagination or fonts.
|
||||
|
||||
If export fails, preserve the usable source and report the export limitation. Do not repeatedly convert an unchanged source or use conversion to investigate a failed preview. For PDF export alone, stop after these checks. Only a request for images or visual inspection calls for `office-render`; load it before that separate operation. Deliver the requested PDF through `present` when available, keeping the file in place.
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
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. Load this skill before running Office commands. Use only bundled LibreOffice unless the user explicitly opts out; without that opt-out, do not search for another LibreOffice executable.
|
||||
description: Create, read, edit, and validate PowerPoint presentations (.pptx).
|
||||
modelCatalogVisibility: unlisted
|
||||
---
|
||||
|
||||
# PowerPoint presentations
|
||||
@@ -51,31 +52,20 @@ for shape in presentation.slides[0].shapes:
|
||||
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 that text and images fit the slide dimensions, titles form a useful sequence, and chart labels agree with source values. Paragraph alignment does not move a text box: when a title must be centered on the slide, center both its paragraph and the text box against `presentation.slide_width`, then reopen to verify both. 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:
|
||||
Run the shared structural checker when `office-check` is available:
|
||||
|
||||
```text
|
||||
<python> <skill-directory>/../scripts/check_office.py <presentation.pptx> --out <checks.json>
|
||||
office-check <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.
|
||||
|
||||
Render for requested images/PDFs or an actionable layout check. Before generating images only for inspection, establish that the current model accepts images. If it does not, finish structural and content checks and state that visual layout was not inspected; do not rasterize slides the model cannot read, retry `read_image`, or choose a second model.
|
||||
If `office-check` is unavailable, use an already configured environment to check the ZIP/XML package and internal relationships. Report the missing environment if those checks cannot run; do not claim structural validation.
|
||||
|
||||
Use the bundled LibreOffice binaries unless the user explicitly asks not to use them. Without that explicit opt-out, never search for or invoke system LibreOffice, `soffice`, an application-installed binary, or a downloaded replacement. If the bundled CLI is unavailable or fails, report that failure instead of substituting another executable.
|
||||
|
||||
Use the `libreofficeKit.node` and `libreofficeKit.cli` absolute paths supplied in the loaded skill's **Installed LibreOffice Kit** section. Run Node with the CLI entry as its first argument, then `capabilities --json` once. For the bundled runtime, these paths work from the task directory; do not search PATH, guess a package directory, use `npx`, or install a renderer. Quote each path separately; PowerShell also requires the `&` invocation operator. The examples below use `<node>` and `<cli>` for those two returned paths. Render slides directly:
|
||||
|
||||
```sh
|
||||
"<node>" "<cli>" render --input report.pptx --output-dir preview-v1 --pages 1,3,4 --dpi 144
|
||||
"<node>" "<cli>" convert --input report.pptx --output report.pdf
|
||||
```
|
||||
|
||||
Batch affected slides in one call; inspect every slide for a new deck or a whole-deck design review. Read the returned manifest and selected image paths, including missing-font diagnostics. Outputs must be new directories/files. Retain and reuse previews for the same saved source; rerender only after edits, for missing slides, or to diagnose a concrete failure. Check clipping, alignment, contrast, and chart labels. Do not substitute guessed geometry for visual evidence or repeat successful exports of unchanged slides.
|
||||
|
||||
If this deployment explicitly disables the CLI but `render_document` is provided, call it without `pages` first and inspect images only on a ready result; `status: "skipped"` ends visual checking. If rendering is unavailable or fails, retain the usable source and report the inspection limit. Do not search for COM automation, install conversion packages, or build another renderer for routine QA. LibreOffice previews do not certify pixel-identical PowerPoint or Keynote output, animation, or media playback. Deliver once the requested content and applicable checks pass.
|
||||
Deliver after the requested content and structural checks pass. Creating a file or changing content with incidental basic formatting does not require additional operations. Load `office-render` for requested images or visual inspection and for appearance-focused styling, spacing, sizing, or alignment changes unless the user explicitly skips rendering. For selected slides in PowerShell, use `dsoffice render --input "report-edited.pptx" --output-dir "<new-directory>" --pages "1,3,4"`; quote the comma-separated page list. For PDF output load `office-export-pdf`.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: office-recalculate
|
||||
description: Refresh spreadsheet formula caches and verify calculated values with LibreOffice Kit.
|
||||
modelCatalogVisibility: unlisted
|
||||
---
|
||||
|
||||
# Recalculate spreadsheet formulas
|
||||
|
||||
Use the installed LibreOffice Kit CLI through the shell with `dsoffice`. Prefer bundled LibreOffice unless the user requests another installation. If the Office sidecar is missing, follow [sidecar setup](../office/references/sidecar.md): ask whether the user wants to download it and install only after they agree. Do not invoke system soffice, use npx, or install a replacement to work around a failed recalculation with an installed sidecar.
|
||||
|
||||
Keep the original workbook and write a new file in the task workspace. Quote each input and output path.
|
||||
|
||||
```text
|
||||
dsoffice recalculate --input <workbook.xlsx> --output <recalculated.xlsx>
|
||||
```
|
||||
|
||||
Verify the saved result with openpyxl, using `load_workspace_dependencies` for the Python path unless a project environment is required. Open it with both `data_only=False` and `data_only=True`. Check that formulas survive and cached results match the requested logic, expected totals, and cell types. Check required unchanged sheets, styles, and data. Recalculation does not validate business logic or guarantee Excel-specific feature compatibility.
|
||||
|
||||
If no supported recalculation engine is available or the command fails, preserve the workbook and explain that calculated values could not be refreshed. Do not replace requested formulas with constants or present stale/absent caches as fresh results. Deliver the verified output through `present` when available, keeping the file in place.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: office-render
|
||||
description: Render requested document images or inspect visual layout with LibreOffice Kit.
|
||||
modelCatalogVisibility: unlisted
|
||||
---
|
||||
|
||||
# Document images and visual inspection
|
||||
|
||||
Use the installed LibreOffice Kit CLI through the shell with `dsoffice`. Prefer bundled LibreOffice unless the user requests another installation. If the Office sidecar is missing, follow [sidecar setup](../office/references/sidecar.md): ask whether the user wants to download it and install only after they agree. Do not invoke system soffice, use npx, install a replacement renderer, or build an alternative rendering pipeline to work around a failed render with an installed sidecar.
|
||||
|
||||
Before generating images only for inspection, establish that the current model accepts images. Otherwise finish the applicable content and structure checks and report the requested visual check as unavailable. Do not generate unreadable previews, retry image reads, or choose a second model. Requested image exports can still be delivered.
|
||||
|
||||
## Render the saved deliverable
|
||||
|
||||
Keep the saved deliverable in the task workspace. Create a private, uniquely named temporary parent inside that workspace and pass a child path that does not yet exist as `--output-dir`; the CLI creates that child. On POSIX, use `preview_root=$(mktemp -d "./.dsh-office.XXXXXXXX")` (owner-only permissions) and `--output-dir "$preview_root/render"`. On PowerShell, create a parent named `.dsh-office-` plus `[guid]::NewGuid().ToString('N')` under the task workspace and pass an uncreated `render` child; it inherits the workspace ACL. Quote each input and output path. Retain the parent until image inspection finishes, then remove only that parent, including after a failed render. Never use a fixed directory name or remove the workspace itself.
|
||||
|
||||
```text
|
||||
dsoffice render --input <final-file> --output-dir <new-directory> --pages "1,3,4"
|
||||
dsoffice render --input <final-workbook.xlsx> --output-dir <new-directory> --sheet <exact-name> --range <A1-range>
|
||||
```
|
||||
|
||||
For DOCX, PPTX, or PDF, batch requested pages/slides in one call. In PowerShell, quote comma-separated `--pages` values so they remain one argument. Inspect all pages only for a requested whole-document review. For worksheets, choose the exact affected range, including chart/drawing regions; worksheet numbers are not PDF page numbers. Inspect the final saved file, reuse existing images for an unchanged source, and rerender only after requested source changes or for previously unrendered regions. Change print areas or scaling only when the user requests print layout.
|
||||
|
||||
Read the returned JSON manifest for image paths, page counts, fragment rectangles, and missing-font diagnostics. Use the session's image-reading tool to inspect the requested images. Check clipping, alignment, headings, table widths, contrast, chart labels, and number formats as relevant to the task. A successful command or image read alone does not establish a usable preview; guessed text geometry does not establish overflow.
|
||||
|
||||
## Failed or blank previews
|
||||
|
||||
For populated content, a fully transparent or uniformly blank image is a renderer failure. If a pixel check is needed, composite RGBA onto white first; raw RGB under transparent pixels can look nonblank. Check once and do not reread the same image. Stop visual checks for the task immediately after a blank result. This overrides the rerender-after-edit guidance above: do not render or read images again, even after editing or copying the source, or try another range, page, sheet, DPI, output format, diagnostic export, or print setting. Preserve the usable source and requested outputs and finish applicable content/data checks.
|
||||
|
||||
If content and structure checks pass, name the saved file and verified edits as needed, then include exactly one visual-status sentence: "Content and structure checks passed; visual inspection was not completed." in English, or "内容与结构检查通过;视觉检查未完成。" in Chinese. Do not add a render or preview bullet, explain why inspection stopped, or describe the image or pixels. Report content or structure failures separately; never say all checks passed or claim visual verification.
|
||||
|
||||
LibreOffice output can differ from Word, PowerPoint, or Excel. Images do not certify animation/media playback, refresh formula caches in the source workbook, or guarantee native application fidelity. Deliver requested files through `present` when available; keep the files in place. Do not deliver inspection images or reports unless requested.
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: office-xlsx
|
||||
description: Read, create, and modify Excel workbooks (.xlsx), including data, formulas, formatting, and pandas analysis. Use for Excel inputs or deliverables. Load before running Office commands. Data and formula tasks skip visual inspection; inspect only for formatting or layout needs. Use only bundled LibreOffice unless the user explicitly opts out; without that opt-out, do not search for another LibreOffice executable.
|
||||
description: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).
|
||||
modelCatalogVisibility: unlisted
|
||||
---
|
||||
|
||||
# Excel workbooks
|
||||
@@ -35,40 +36,24 @@ 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. Read the installed CLI paths and capabilities as described below. When it supports `recalculate`, use `"<node>" "<cli>" recalculate --input report.xlsx --output recalculated.xlsx` to save formulas and refreshed cached results to a new workbook. Reopen that result with both `data_only=False` and `data_only=True` to check formulas and calculated values against the requested logic. Recalculation does not validate business logic or guarantee Excel-specific feature compatibility. Without a recalculation engine, state the limit; do not replace requested formulas with constants or report old cached values as newly calculated results.
|
||||
Writing a formula does not calculate its result. openpyxl and XlsxWriter do not evaluate formulas; `data_only=True` returns cached values that may be absent or stale. When calculated results must be refreshed, load `office-recalculate`. Do not replace requested formulas with constants or report stale 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:
|
||||
Run the shared structural checker when `office-check` is available:
|
||||
|
||||
```text
|
||||
<python> <skill-directory>/../scripts/check_office.py <workbook.xlsx> --out <checks.json>
|
||||
office-check <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.
|
||||
|
||||
For data and formula tasks, deliver after structural and data checks pass. Skip preview rendering and visual inspection, and do not add an unrequested caveat that visual inspection was omitted. Basic styling such as bold headers or number formats does not itself require visual inspection. Formula recalculation is independent of rendering and remains necessary when calculated results must be refreshed.
|
||||
If `office-check` is unavailable, use an already configured environment to check the ZIP/XML package and internal relationships. Report the missing environment if those checks cannot run; do not claim structural validation.
|
||||
|
||||
Inspect relevant regions when the task concerns formatting, layout, chart appearance, print layout, or a known display problem. Before generating inspection images, establish that the current model accepts images. If it does not, finish structural and data checks and report the requested visual check as unavailable; do not rasterize unreadable previews, retry `read_image`, or choose a second model. Export requested images or PDFs even when visual inspection is unnecessary.
|
||||
Load `office-render` for requested images or visual inspection and whenever the user's primary task targets appearance, including cell fill colors, text alignment, spacing, or sizing. These appearance changes require rendering even without a separate visual-check request, unless the user explicitly skips it. New workbooks and data or formula edits do not require rendering solely because they include incidental basic formatting. In particular, bold headers and number formats accompanying data edits do not require loading `office-render` or running its commands.
|
||||
|
||||
A successful command or image read does not prove that a preview contains workbook content. For a populated range, a fully transparent or uniformly blank image is a renderer failure, not evidence that the workbook needs fixing. Stop all visual QA for that workbook immediately; do not try a wider range, another sheet, a different DPI, or another output format. Check suspicious previews with the bundled Pillow library, compositing RGBA onto white before comparing pixels. Do not create diagnostic workbooks, repeatedly retry rendering, modify print settings, or convert to PDF solely to investigate a failed preview. Preserve the requested workbook changes, finish structural and data checks, and report the visual check as unavailable. Only change print areas or scaling when the user requests printed layout.
|
||||
|
||||
## LibreOffice operations
|
||||
|
||||
Use the bundled LibreOffice binaries unless the user explicitly asks not to use them. Without that explicit opt-out, never search for or invoke system LibreOffice, `soffice`, an application-installed binary, or a downloaded replacement. If the bundled CLI is unavailable or fails, report that failure instead of substituting another executable.
|
||||
|
||||
When recalculation, a requested export, or visual inspection is needed, use the `libreofficeKit.node` and `libreofficeKit.cli` absolute paths supplied in the loaded skill's **Installed LibreOffice Kit** section. Run Node with the CLI entry as its first argument, then `capabilities --json` once. For the bundled runtime, these paths work from the task directory; do not search PATH, guess a package directory, use `npx`, or install a renderer. Quote each path separately; PowerShell also requires the `&` invocation operator. The examples below use `<node>` and `<cli>` for those two returned paths. For worksheet rendering, select an exact sheet name and the relevant cell range:
|
||||
|
||||
```sh
|
||||
"<node>" "<cli>" render --input report.xlsx --output-dir preview-v1 --sheet Summary --range A1:D20 --dpi 144
|
||||
```
|
||||
|
||||
For a user-requested PDF export, use `"<node>" "<cli>" convert --input report.xlsx --output report.pdf`. PDF conversion is not a fallback for failed visual QA.
|
||||
|
||||
Use `--sheet`/`--range` for worksheets, not `--pages`. Direct worksheet images cover cell regions; PDF pages follow print settings and are not worksheet numbers. Include chart/drawing regions explicitly. Read the manifest's image paths, fragment rectangles, and missing-font diagnostics; large regions may split across images. Outputs must be new directories/files. Reuse previews for the same saved workbook and inspect additional regions only when needed. Check widths, formats, clipping, and charts; set print areas/scaling when printed layout is requested. Render each relevant region once. Rerender only after requested source changes; an empty or failed preview ends visual checking for the whole workbook.
|
||||
|
||||
When visual inspection is needed and this deployment explicitly disables the CLI but provides `render_document`, call it without `pages` first and inspect returned images only on a ready result; `status: "skipped"` ends visual checking. If required rendering is unavailable or fails, preserve the usable workbook and report the inspection or export limit. Do not search for COM automation, install conversion packages, or build another renderer for routine QA. Preview rendering does not refresh the original workbook's formula caches or certify native Excel appearance. Deliver once the requested data and applicable checks pass.
|
||||
Load `office-recalculate` when refreshed calculated values are needed and `office-export-pdf` for PDF output. Deliver after structural and data checks pass and the required operations finish.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: office
|
||||
description: Create, edit, inspect, or convert Office documents, spreadsheets, presentations, and PDFs. Load to choose the relevant workflow.
|
||||
---
|
||||
|
||||
# Office task routing
|
||||
|
||||
Load the format skill before creating, reading, or editing Office files. Standalone rendering, PDF export, and recalculation need only their operation skill.
|
||||
|
||||
- Create, read, or edit: `office-docx` (Word), `office-pptx` (PowerPoint), or `office-xlsx` (Excel).
|
||||
- Images, visual inspection, or tasks primarily about layout, spacing, sizing, or alignment: `office-render`, even without an explicit visual-check request.
|
||||
- Refresh spreadsheet formula results: `office-recalculate`.
|
||||
- Export an Office file to PDF: `office-export-pdf`.
|
||||
|
||||
Honor requests to skip rendering. New files, content edits, formulas, and incidental formatting such as bold headers or number formats need no render unless visual inspection is requested. Do not reload skills.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Missing Office sidecar
|
||||
|
||||
These instructions apply in Web, Desktop, and SDK sessions. First check whether an existing LibreOffice Kit installation can serve the task; a missing `dsoffice` command on PATH alone does not establish that the sidecar is absent. If the required sidecar is missing and the user has not already authorized its download, use the available ask-question tool to ask whether they want to download the Office conversion engine. If that tool is unavailable, ask in the conversation. Wait for explicit agreement before downloading or installing; if the user declines, continue work that does not need the engine and report the unavailable operation.
|
||||
|
||||
After agreement, install `@deepseek-ai/libreoffice-kit` together with the matching engine package, using the engine version declared by the main package's optional dependencies. Use an available npm or pnpm in a task-local tools directory, with `--ignore-scripts`: the packages provide prebuilt binaries and do not need installation scripts. Keep the package manager's cache or store in that writable task directory too (for npm, pass `--cache <task-tools-directory>/.npm-cache`). Select the package for the operating system and architecture of the Node.js process that will run the CLI, including when Node runs under emulation:
|
||||
|
||||
| Runtime platform | Engine package |
|
||||
| --- | --- |
|
||||
| Windows x64 | `@deepseek-ai/libreoffice-kit-win32-x64` |
|
||||
| Windows ARM64 | `@deepseek-ai/libreoffice-kit-win32-arm64` |
|
||||
| macOS Apple Silicon | `@deepseek-ai/libreoffice-kit-darwin-arm64` |
|
||||
| macOS Intel | `@deepseek-ai/libreoffice-kit-darwin-x64` |
|
||||
| No matching native package, including Linux | `@deepseek-ai/libreoffice-kit-wasm` |
|
||||
|
||||
Prefer the matching native package; otherwise use WASM when its package supports the current platform. Check package platform restrictions before installation rather than forcing an incompatible package. The published WASM package currently declares Linux support.
|
||||
|
||||
Reuse the bundled Node.js and pnpm paths from `load_workspace_dependencies` when available, or the user's configured Node.js and package manager. Permission to download the Office sidecar does not include installing Python, Node.js, or a package manager. If those prerequisites are missing, explain what is needed. Run the new installation's `dsoffice` entry, or its `lib/cli.js` with the selected Node executable, by path; do not assume installation changed the session's PATH. Check `dsoffice capabilities` before continuing the requested operation.
|
||||
@@ -18,23 +18,44 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
"./package.json": "./package.json",
|
||||
"./commands": {
|
||||
"types": "./lib/types/commands.d.ts",
|
||||
"default": "./lib/commands.js"
|
||||
},
|
||||
"./check": {
|
||||
"types": "./lib/types/check.d.ts",
|
||||
"default": "./lib/check.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/commands.js",
|
||||
"lib/check.js",
|
||||
"assets",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-skill": "workspace:*",
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-shell-env": "workspace:*",
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:*",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-skill": "workspace:*",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:~"
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:~",
|
||||
"@deepseek-ai/dsh-shell-env": "workspace:*",
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:*",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*",
|
||||
"tsx": "^4.22.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/libreoffice-kit": "^0.1.5",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Private launcher for the packaged OOXML checker and shared interpreter preparation. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { Config, resolveWorkspaceDependencies } from '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
|
||||
/**
|
||||
* Run the checker with a prepared Python interpreter, preserving its output and exit status.
|
||||
* @param args - Absolute checker path, base64url runtime JSON, then checker arguments.
|
||||
*/
|
||||
export async function runOfficeCheck(args: string[]): Promise<void> {
|
||||
const [checker, serialized, ...forwarded] = args
|
||||
if (checker === undefined || !isAbsolute(checker) || serialized === undefined) throw new Error('office-check: missing launcher resources')
|
||||
const value: unknown = JSON.parse(Buffer.from(serialized, 'base64url').toString('utf8'))
|
||||
const config = z.resolve(value, Config, {})[0] as Config
|
||||
const controller = new AbortController()
|
||||
const abort = () => { controller.abort(new Error('Office check cancelled')) }
|
||||
process.once('SIGINT', abort)
|
||||
process.once('SIGTERM', abort)
|
||||
try {
|
||||
const runtime = await resolveWorkspaceDependencies(config, controller.signal)
|
||||
controller.signal.throwIfAborted()
|
||||
const child = spawn(runtime.python, [checker, ...forwarded], { stdio: 'inherit', signal: controller.signal })
|
||||
process.exitCode = await new Promise<number>((resolve, reject) => {
|
||||
let failure: Error | undefined
|
||||
child.once('error', (error) => { failure = error })
|
||||
child.once('close', (code) => { if (failure === undefined) resolve(code ?? 1); else reject(failure) })
|
||||
})
|
||||
} finally {
|
||||
process.off('SIGINT', abort)
|
||||
process.off('SIGTERM', abort)
|
||||
}
|
||||
}
|
||||
|
||||
// Worker image modules compile to CommonJS, which cannot contain top-level await.
|
||||
/* v8 ignore next -- commands.built.e2e.ts invokes this entry as the main built Node program. */
|
||||
if (import.meta.main) {
|
||||
void runOfficeCheck(process.argv.slice(2)).catch((error: unknown) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** Local Office command launchers contributed to model shell calls. */
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, isAbsolute, join, toNamespacedPath } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import type {} from '@deepseek-ai/dsh-shell-env'
|
||||
import { Config as RuntimeConfigSchema, type Config as RuntimeConfig } from '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
|
||||
/** Local interpreter and resource selection; commands execute in the same host as these resources. */
|
||||
export interface Config {
|
||||
/** dsoffice argv; defaults to the carrier or installed Kit. False disables dsoffice. */
|
||||
command?: string[] | false
|
||||
/** Python checker command argv; defaults to the supplied runtime; absent without a runtime. */
|
||||
checkCommand?: string[] | false
|
||||
/** Directory containing the shared scripts/ and Office skills. */
|
||||
assetRoot?: string
|
||||
/** Workspace interpreter selection; defaults to an explicitly supplied carrier payload. */
|
||||
runtime?: RuntimeConfig
|
||||
}
|
||||
|
||||
/** Validate local command configuration. */
|
||||
export const Config: z<Config> = z.object({
|
||||
command: z.union([z.array(z.string()).min(1), z.const(false)]),
|
||||
checkCommand: z.union([z.array(z.string()).min(1), z.const(false)]),
|
||||
assetRoot: z.string().min(1),
|
||||
runtime: RuntimeConfigSchema.extra('default', undefined),
|
||||
})
|
||||
|
||||
/** Cordis plugin identity. */
|
||||
export const name = 'office-commands'
|
||||
/** Registry that publishes the launcher directory for each shell execution. */
|
||||
export const inject = ['shellEnv']
|
||||
|
||||
function quote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` }
|
||||
|
||||
function writeLauncher(directory: string, name: string, command: readonly string[], sourceConfig?: string): void {
|
||||
const executable = command[0]
|
||||
if (executable === undefined || !isAbsolute(executable)) throw new Error(`office-commands: ${name} requires an absolute executable`)
|
||||
if (command.some(argument => /[\0\r\n]/u.test(argument))) throw new Error('office-commands: command arguments must not contain NUL or newlines')
|
||||
const script = join(directory, name)
|
||||
const environment = sourceConfig === undefined ? '' : `export TSX_TSCONFIG_PATH=${quote(sourceConfig)}\n`
|
||||
writeFileSync(script, `#!/bin/sh\n${environment}exec ${command.map(quote).join(' ')} "$@"\n`, { mode: 0o700, flag: 'wx' })
|
||||
chmodSync(script, 0o700)
|
||||
if (process.platform === 'win32') {
|
||||
const literal = (value: string): string => `'${value.replaceAll("'", "''")}'`
|
||||
const args = command.slice(1).map(literal).join(', ')
|
||||
const environment = sourceConfig === undefined ? '' : `$env:TSX_TSCONFIG_PATH = '${sourceConfig.replaceAll("'", "''")}'\n`
|
||||
// Windows PowerShell counts escaped quotes when deciding whether to wrap an argument; doubled quotes keep each value enclosed.
|
||||
const nativeArguments = String.raw`$quoteReplacement = if ($PSVersionTable.PSVersion.Major -le 5) { '$1$1""' } else { '$1$1\"' }
|
||||
$nativeArgs = @(foreach ($argument in $commandArgs) {
|
||||
$value = if ($argument -is [array]) { $argument -join ',' } else { [string]$argument }
|
||||
'"' + ($value -replace '(\\*)"', $quoteReplacement -replace '(\\+)$', '$1$1') + '"'
|
||||
})`
|
||||
// Framework Process.Start flushes Console.InputEncoding's preamble before PowerShell writes pipeline text.
|
||||
// The private cache avoids the public setter's no-console failure and leaves Console.In's buffered data intact.
|
||||
const inputEncoding = `$inputEncodingField = $null
|
||||
if ($MyInvocation.ExpectingInput -and $PSVersionTable.PSVersion.Major -le 5) {
|
||||
$inputEncodingField = [Console].GetField('_inputEncoding', 'NonPublic,Static')
|
||||
if ($null -eq $inputEncodingField) { throw 'office-commands: Windows PowerShell input encoding field is unavailable' }
|
||||
$savedInputEncoding = $inputEncodingField.GetValue($null)
|
||||
}`
|
||||
writeFileSync(`${script}.ps1`, `\ufeff${environment}$PSNativeCommandArgumentPassing = 'Legacy'\n`
|
||||
+ '$OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n'
|
||||
+ `$commandArgs = @(${args}) + $args\n${nativeArguments}\n${inputEncoding}\n$global:LASTEXITCODE = 127\ntry {\n`
|
||||
+ ' if ($null -ne $inputEncodingField) { $inputEncodingField.SetValue($null, $OutputEncoding) }\n'
|
||||
+ ` if ($MyInvocation.ExpectingInput) { $input | & ${literal(executable)} @nativeArgs }\n`
|
||||
+ ` else { & ${literal(executable)} @nativeArgs }\n`
|
||||
+ '} finally {\n if ($null -ne $inputEncodingField) { $inputEncodingField.SetValue($null, $savedInputEncoding) }\n}\n'
|
||||
+ 'exit $LASTEXITCODE\n', { flag: 'wx' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish stable Office commands without preparing interpreters or engines at activation.
|
||||
* @param ctx - Local shell environment.
|
||||
* @param config - Command overrides and interpreter resources.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const bundled = process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME
|
||||
const carrierRuntime = process.env.DSH_BUNDLED_PRIMARY_RUNTIME
|
||||
const node = process.env.DSH_BUNDLED_OFFICE_NODE ?? (carrierRuntime ? join(carrierRuntime, 'dependencies/node/bin', process.platform === 'win32' ? 'node.exe' : 'node') : process.execPath)
|
||||
const assetRoot = config.assetRoot ?? process.env.DSH_BUNDLED_OFFICE_SKILLS ?? (bundled ? join(dirname(bundled), 'office-skills') : fileURLToPath(new URL('../assets/', import.meta.url)))
|
||||
if (!isAbsolute(assetRoot)) throw new Error('office-commands: assetRoot must be an absolute directory')
|
||||
const runtime = config.runtime ?? (bundled ? { source: bundled } : undefined)
|
||||
const kit = config.command ?? (process.env.DSH_BUNDLED_OFFICE_CLI === '' ? false : [node, process.env.DSH_BUNDLED_OFFICE_CLI
|
||||
?? join(dirname(fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json'))), 'lib/cli.js')])
|
||||
const source = import.meta.url.endsWith('.ts')
|
||||
const checker = join(assetRoot, 'scripts/check_office.py')
|
||||
const check = config.checkCommand ?? (runtime === undefined ? false : bundled && config.runtime === undefined
|
||||
? [join(bundled, 'dependencies/python', process.platform === 'win32' ? 'python.exe' : 'bin/python3'), checker]
|
||||
: [
|
||||
node,
|
||||
/* v8 ignore start -- source-unit coverage cannot change import.meta.url; commands.built.e2e.ts executes check.js without tsx. */
|
||||
...(source ? ['--import', import.meta.resolve('tsx/esm')] : []),
|
||||
fileURLToPath(new URL(source ? './check.ts' : './check.js', import.meta.url)),
|
||||
/* v8 ignore stop */
|
||||
checker, Buffer.from(JSON.stringify(runtime)).toString('base64url'),
|
||||
])
|
||||
const parent = join(resolveDshHome(), 'commands')
|
||||
mkdirSync(parent, { recursive: true, mode: 0o700 })
|
||||
// Windows mkdtemp needs extended paths; shell PATH keeps ordinary absolute directories.
|
||||
const directory = join(parent, basename(mkdtempSync(toNamespacedPath(join(parent, 'office-')))))
|
||||
const shortcutDirectories: string[] = []
|
||||
const shortCommand = (command: readonly string[]): readonly string[] => {
|
||||
const executable = command[0]
|
||||
if (process.platform !== 'win32' || executable === undefined || executable.length < 260
|
||||
|| !isAbsolute(executable) || !existsSync(toNamespacedPath(dirname(executable)))) return command
|
||||
const shortcutDirectory = mkdtempSync(join(tmpdir(), 'dsh-office-exe-'))
|
||||
shortcutDirectories.push(shortcutDirectory)
|
||||
const junction = join(shortcutDirectory, 'bin')
|
||||
symlinkSync(toNamespacedPath(dirname(executable)), junction, 'junction')
|
||||
return [join(junction, basename(executable)), ...command.slice(1)]
|
||||
}
|
||||
const removeDirectories = () => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
for (const shortcutDirectory of shortcutDirectories) rmSync(shortcutDirectory, { recursive: true, force: true })
|
||||
}
|
||||
try {
|
||||
if (kit !== false) writeLauncher(directory, 'dsoffice', shortCommand(kit))
|
||||
if (check !== false) writeLauncher(directory, 'office-check', shortCommand(check),
|
||||
source && (!bundled || config.runtime !== undefined) && config.checkCommand === undefined ? fileURLToPath(new URL('../../../../tsconfig.base.json', import.meta.url)) : undefined)
|
||||
ctx.effect(() => () => { removeDirectories() })
|
||||
ctx.shellEnv.register({ name, commandDirectories: [directory], variables: {}, resolve: () => ({}) })
|
||||
} catch (error) {
|
||||
removeDirectories()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -2,31 +2,24 @@
|
||||
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { isSea } from 'node:sea'
|
||||
import { dirname, isAbsolute, join, sep } 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
|
||||
const SKILL_NAMES = ['office', 'office-docx', 'office-pptx', 'office-xlsx', 'office-render', 'office-recalculate', 'office-export-pdf'] as const
|
||||
|
||||
/** Office skill resource location. */
|
||||
export interface Config {
|
||||
/** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */
|
||||
/** Absolute assets directory containing skill folders and shared scripts; defaults to carrier resources or packaged assets. */
|
||||
assetRoot?: string
|
||||
/** Standalone Node executable; defaults to the current executable outside Electron and SEA. */
|
||||
node?: string
|
||||
/** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */
|
||||
cli?: string | false
|
||||
}
|
||||
|
||||
/** Validated resource configuration. */
|
||||
export const Config: z<Config> = z.object({
|
||||
assetRoot: z.string().min(1),
|
||||
node: z.string().min(1),
|
||||
cli: z.union([z.string().min(1), z.const(false)]),
|
||||
})
|
||||
|
||||
/** Cordis plugin identity. */
|
||||
@@ -44,44 +37,32 @@ function parseSkill(raw: string, path: string): { description: string; content:
|
||||
return { description, content: raw.slice(frontmatter[0].length).trim() }
|
||||
}
|
||||
|
||||
function officeRuntime(config: Config): string {
|
||||
if (config.cli === false) return '\n\nLibreOffice Kit is disabled in this deployment.'
|
||||
if (config.node === undefined && (isSea() || process.versions.electron !== undefined)) {
|
||||
throw new Error('skill-office: packaged applications must supply a standalone Node executable')
|
||||
}
|
||||
const node = config.node ?? process.execPath
|
||||
let cli = config.cli
|
||||
if (cli === undefined) {
|
||||
const manifestPath = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json'))
|
||||
cli = join(dirname(manifestPath), 'lib', 'cli.js')
|
||||
}
|
||||
for (const path of [node, cli]) {
|
||||
if (!isAbsolute(path) || !statSync(path).isFile()) throw new Error(`skill-office: expected an absolute executable file: ${path}`)
|
||||
}
|
||||
return `\n\n## Installed LibreOffice Kit\n\nUse these absolute paths for every LibreOffice Kit command. Pass the CLI entry as the first argument to Node.\n\n${JSON.stringify({ libreofficeKit: { node, cli } }, undefined, 2)}`
|
||||
function resolveAssetRoot(config: Config): string {
|
||||
const bundled = process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME
|
||||
return config.assetRoot ?? process.env.DSH_BUNDLED_OFFICE_SKILLS ?? (bundled ? join(dirname(bundled), 'office-skills') : fileURLToPath(new URL('../assets/', import.meta.url)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param input - Optional packaged resource override.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const assetRoot = config.assetRoot ?? fileURLToPath(new URL('../assets/', import.meta.url))
|
||||
export function apply(ctx: Context, input: Config = {}): void {
|
||||
const assetRoot = resolveAssetRoot(input)
|
||||
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 runtime = officeRuntime(config)
|
||||
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,
|
||||
modelCatalogVisibility: skillName === 'office' ? 'listed' : 'unlisted',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
provider: 'dsh-office', source: 'bundled', rank: BUNDLED_SKILL_RANK,
|
||||
resourceBase: { kind: 'directory', path: directory }, locator: path,
|
||||
resourceBase: { kind: 'directory', path: directory.split(sep).join('/') }, locator: path,
|
||||
}
|
||||
})
|
||||
const provider: SkillProvider = {
|
||||
@@ -90,7 +71,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
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 + runtime }
|
||||
const content = parseSkill(raw, locator as string).content
|
||||
return { ...summary, content }
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider(() => provider)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/** Checker process arguments, exit status, cancellation, and signal-handler ownership. */
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { resolveWorkspaceDependencies } from '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
import { runOfficeCheck } from '../src/check.ts'
|
||||
|
||||
vi.mock('node:child_process', { spy: true })
|
||||
|
||||
vi.mock('@deepseek-ai/dsh-tool-workspace-dependencies', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@deepseek-ai/dsh-tool-workspace-dependencies')>(),
|
||||
resolveWorkspaceDependencies: vi.fn(),
|
||||
}))
|
||||
|
||||
let root: string
|
||||
let originalExitCode: typeof process.exitCode
|
||||
let originalInterrupt: NodeJS.SignalsListener[]
|
||||
let originalTerminate: NodeJS.SignalsListener[]
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-office-check-'))
|
||||
originalExitCode = process.exitCode
|
||||
originalInterrupt = process.listeners('SIGINT')
|
||||
originalTerminate = process.listeners('SIGTERM')
|
||||
vi.mocked(resolveWorkspaceDependencies).mockResolvedValue({ python: process.execPath, pythonPackages: root, pythonDistributions: {} })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
expect(process.listeners('SIGINT')).toEqual(originalInterrupt)
|
||||
expect(process.listeners('SIGTERM')).toEqual(originalTerminate)
|
||||
} finally {
|
||||
process.exitCode = originalExitCode
|
||||
vi.resetAllMocks()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function config(): string {
|
||||
return Buffer.from(JSON.stringify({ source: root })).toString('base64url')
|
||||
}
|
||||
|
||||
function interruptOwnedCheck(): void {
|
||||
const listener = process.listeners('SIGINT').find(listener => !originalInterrupt.includes(listener))
|
||||
if (listener === undefined) throw new Error('Checker did not register its signal handler')
|
||||
Reflect.apply(listener, process, [])
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ args: [] }, { args: ['relative-checker', 'e30'] }, { args: [join(tmpdir(), 'checker.py')] },
|
||||
])('rejects missing launcher resources: $args', async ({ args }) => {
|
||||
await expect(runOfficeCheck(args)).rejects.toThrow('missing launcher resources')
|
||||
expect(resolveWorkspaceDependencies).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['not JSON', JSON.stringify({ source: 3 })])('rejects invalid runtime configuration before preparation: %s', async (serialized) => {
|
||||
await expect(runOfficeCheck([join(root, 'checker.py'), Buffer.from(serialized).toString('base64url')])).rejects.toThrow()
|
||||
expect(resolveWorkspaceDependencies).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards checker arguments and preserves the completed process exit status', async () => {
|
||||
const checker = join(root, 'checker.mjs'), output = join(root, 'arguments.json')
|
||||
await writeFile(checker, "import { writeFileSync } from 'node:fs'; writeFileSync(process.argv[2], JSON.stringify(process.argv.slice(3))); process.exitCode = 7;\n")
|
||||
await runOfficeCheck([checker, config(), output, 'space and 中文', "apostrophe'value"])
|
||||
expect(JSON.parse(await readFile(output, 'utf8'))).toEqual(['space and 中文', "apostrophe'value"])
|
||||
expect(process.exitCode).toBe(7)
|
||||
expect(resolveWorkspaceDependencies).toHaveBeenCalledWith({ source: root }, expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('propagates runtime preparation failures without leaving signal handlers', async () => {
|
||||
vi.mocked(resolveWorkspaceDependencies).mockRejectedValue(new Error('payload unavailable'))
|
||||
await expect(runOfficeCheck([join(root, 'checker.py'), config()])).rejects.toThrow('payload unavailable')
|
||||
})
|
||||
|
||||
it('propagates process-start errors after the child closes', async () => {
|
||||
vi.mocked(resolveWorkspaceDependencies).mockResolvedValue({ python: join(root, 'missing-python'), pythonPackages: root, pythonDistributions: {} })
|
||||
await expect(runOfficeCheck([join(root, 'checker.py'), config()])).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('reports a signal-terminated checker as unsuccessful', async () => {
|
||||
const checker = join(root, 'checker.mjs')
|
||||
await writeFile(checker, 'setInterval(() => {}, 1000);\n')
|
||||
const { spawn: spawnProcess } = await vi.importActual<typeof import('node:child_process')>('node:child_process')
|
||||
const spawned = Promise.withResolvers<ChildProcess>()
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
const child = spawnProcess(process.execPath, [checker], { stdio: 'inherit' })
|
||||
child.once('spawn', () => { spawned.resolve(child) })
|
||||
return child
|
||||
})
|
||||
const pending = runOfficeCheck([checker, config()])
|
||||
const child = await spawned.promise
|
||||
try {
|
||||
expect(child.kill('SIGTERM')).toBe(true)
|
||||
await pending
|
||||
expect(child.signalCode).toBe('SIGTERM')
|
||||
expect(process.exitCode).toBe(1)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
await pending.catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels pending preparation before starting a checker', async () => {
|
||||
const prepared = Promise.withResolvers<Awaited<ReturnType<typeof resolveWorkspaceDependencies>>>()
|
||||
vi.mocked(resolveWorkspaceDependencies).mockReturnValue(prepared.promise)
|
||||
const pending = runOfficeCheck([join(root, 'checker.mjs'), config()])
|
||||
interruptOwnedCheck()
|
||||
prepared.resolve({ python: process.execPath, pythonPackages: root, pythonDistributions: {} })
|
||||
await expect(pending).rejects.toThrow('Office check cancelled')
|
||||
})
|
||||
|
||||
it('waits for the cancelled checker to exit before releasing its signal handlers', async () => {
|
||||
const checker = join(root, 'checker.mjs'), ready = join(root, 'ready')
|
||||
await writeFile(checker, "import { writeFileSync } from 'node:fs'; writeFileSync(process.argv[2], String(process.pid)); setInterval(() => {}, 1000);\n")
|
||||
const pending = runOfficeCheck([checker, config(), ready])
|
||||
try {
|
||||
await vi.waitFor(async () => { expect(await readFile(ready, 'utf8')).toMatch(/^\d+$/u) }, { timeout: 10_000 })
|
||||
interruptOwnedCheck()
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
const pid = Number(await readFile(ready, 'utf8'))
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
} finally {
|
||||
const listener = process.listeners('SIGINT').find(listener => !originalInterrupt.includes(listener))
|
||||
if (listener !== undefined) Reflect.apply(listener, process, [])
|
||||
await pending.catch(() => undefined)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
/** Published checker bootstrap smoke; CI supplies built package exports before this keyless lane. */
|
||||
import { existsSync } from 'node:fs'
|
||||
import { copyFile, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
const packageRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const built = ['lib/commands.js', 'lib/check.js'].every(path => existsSync(join(packageRoot, path)))
|
||||
|
||||
it.skipIf(!built)('reports checker bootstrap failures with an unsuccessful exit', async () => {
|
||||
const result = await execa(process.execPath, [join(packageRoot, 'lib/check.js')], {
|
||||
env: { NODE_OPTIONS: '', TSX_TSCONFIG_PATH: '' },
|
||||
timeout: 30_000, killSignal: 'SIGKILL', reject: false,
|
||||
})
|
||||
expect(result.timedOut, result.stderr).toBe(false)
|
||||
expect(result.signal, result.stderr).toBeUndefined()
|
||||
expect(result.exitCode, result.stderr).toBe(1)
|
||||
expect(result.stderr).toContain('office-check: missing launcher resources')
|
||||
expect(result.stdout).toBe('')
|
||||
})
|
||||
|
||||
it.skipIf(!built)('runs the emitted checker launcher and preserves its arguments and exit status', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-built-office-'))
|
||||
try {
|
||||
const runtime = join(root, 'runtime'), assets = join(root, 'assets')
|
||||
const python = join(runtime, 'dependencies/python', process.platform === 'win32' ? 'python.exe' : 'bin/python3')
|
||||
await mkdir(dirname(python), { recursive: true })
|
||||
// The fixture executable speaks the checker's argv/exit protocol; the carrier smoke owns Python libraries.
|
||||
if (process.platform === 'win32') await copyFile(process.execPath, python)
|
||||
else await symlink(process.execPath, python)
|
||||
await mkdir(join(runtime, 'dependencies/python', ...(process.platform === 'win32' ? ['Lib'] : ['lib/python3.12']), 'site-packages'), { recursive: true })
|
||||
await writeFile(join(runtime, 'runtime.json'), JSON.stringify({ desktopVersion: 'test', platform: process.platform, arch: process.arch, python: '3.12.14', pythonPackages: {} }))
|
||||
await mkdir(join(assets, 'scripts'), { recursive: true })
|
||||
await writeFile(join(assets, 'scripts/check_office.py'), "require('node:fs').writeFileSync(process.argv[2], JSON.stringify(process.argv.slice(3))); process.exitCode = 7;\n")
|
||||
const script = `
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { delimiter, join } from 'node:path';
|
||||
import { Context } from '@deepseek-ai/cordis';
|
||||
import * as ShellEnv from '@deepseek-ai/dsh-shell-env';
|
||||
import * as Commands from '@deepseek-ai/dsh-skill-office/commands';
|
||||
import { execa } from 'execa';
|
||||
const root = process.argv[1], ctx = new Context();
|
||||
try {
|
||||
await ctx.plugin(ShellEnv);
|
||||
await ctx.plugin(Commands, { command: false, assetRoot: join(root, 'assets'), runtime: { source: join(root, 'runtime') } });
|
||||
const env = ctx.shellEnv.collect({ signal: new AbortController().signal, token: Symbol(), callId: 'smoke', rootCallId: 'smoke', name: 'bash', arguments: {} });
|
||||
const directory = env.PATH.split(delimiter)[0];
|
||||
const launcher = join(directory, process.platform === 'win32' ? 'office-check.ps1' : 'office-check');
|
||||
const source = await readFile(launcher, 'utf8');
|
||||
assert(source.includes('check.js'));
|
||||
assert(!source.includes('tsx'));
|
||||
const args = [join(root, 'arguments.json'), 'space and 中文', "apostrophe'value"];
|
||||
if (process.platform === 'win32') {
|
||||
const { LocalSubprocessRuntime } = await import('@deepseek-ai/dsh-subprocess-local');
|
||||
const { PwshLocalExecutor } = await import('@deepseek-ai/dsh-pwsh-local');
|
||||
await ctx.plugin(LocalSubprocessRuntime);
|
||||
await ctx.plugin(PwshLocalExecutor, { pwshPath: 'powershell.exe' });
|
||||
const command = 'office-check ' + args.map(value => "'" + value.replaceAll("'", "''") + "'").join(' ') + '; exit $LASTEXITCODE';
|
||||
const result = await (await ctx.shell.execute(ctx.shell.resolve({ command,
|
||||
env: { PSExecutionPolicyPreference: 'Restricted' }, dshEnv: env }))).result();
|
||||
assert.equal(result.exitCode, 7, result.stderr.text);
|
||||
} else {
|
||||
const result = await execa(launcher, args, { env, reject: false });
|
||||
assert.equal(result.exitCode, 7, result.stderr);
|
||||
}
|
||||
assert.deepEqual(JSON.parse(await readFile(args[0], 'utf8')), args.slice(1));
|
||||
process.stdout.write('built checker passed');
|
||||
} finally { await ctx.fiber.dispose(); }
|
||||
`
|
||||
const result = await execa(process.execPath, ['--input-type=module', '-e', script, root], {
|
||||
cwd: packageRoot,
|
||||
env: {
|
||||
DSH_HOME: join(root, 'home'), DSH_PRIMARY_RUNTIME: '', DSH_BUNDLED_PRIMARY_RUNTIME: '',
|
||||
NODE_OPTIONS: '', TSX_TSCONFIG_PATH: '',
|
||||
},
|
||||
timeout: 30_000, killSignal: 'SIGKILL', reject: false,
|
||||
})
|
||||
expect(result.timedOut, result.stderr).toBe(false)
|
||||
expect(result.signal, result.stderr).toBeUndefined()
|
||||
expect(result.exitCode, result.stderr).toBe(0)
|
||||
expect(result.stdout).toBe('built checker passed')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,303 @@
|
||||
/** Shell command discovery, argument preservation, and launcher ownership. */
|
||||
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, dirname, join, toNamespacedPath } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import * as ShellEnv from '@deepseek-ai/dsh-shell-env'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import * as Commands from '../src/commands.ts'
|
||||
|
||||
const contexts: Context[] = []
|
||||
let root: string
|
||||
const execution: ToolExecution = {
|
||||
signal: new AbortController().signal,
|
||||
token: Symbol('office-command') as ToolExecution['token'],
|
||||
callId: ToolCallId('office-command'), rootCallId: ToolCallId('office-command'),
|
||||
name: 'bash', arguments: { command: 'dsoffice capabilities' },
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-office-commands-'))
|
||||
vi.stubEnv('DSH_HOME', root)
|
||||
for (const key of ['DSH_PRIMARY_RUNTIME', 'DSH_BUNDLED_PRIMARY_RUNTIME', 'DSH_BUNDLED_OFFICE_CLI', 'DSH_BUNDLED_OFFICE_NODE', 'DSH_BUNDLED_OFFICE_SKILLS']) vi.stubEnv(key, undefined)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
|
||||
vi.unstubAllEnvs()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function context() {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(ShellEnv)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function directory(ctx: Context): string {
|
||||
const path = ctx.shellEnv.collect(execution).PATH
|
||||
if (path === undefined) throw new Error('Missing Office command directory')
|
||||
return path.split(delimiter)[0]!
|
||||
}
|
||||
|
||||
async function checkerRuntime(ctx: Context): Promise<unknown> {
|
||||
const script = await readFile(join(directory(ctx), 'office-check'), 'utf8')
|
||||
const serialized = / '([A-Za-z0-9_-]+)' "\$@"\n$/u.exec(script)?.[1]
|
||||
if (serialized === undefined) throw new Error('Missing serialized checker runtime')
|
||||
return JSON.parse(Buffer.from(serialized, 'base64url').toString('utf8'))
|
||||
}
|
||||
|
||||
it.skipIf(process.platform === 'win32')('executes the installed capabilities command without Node on PATH', async () => {
|
||||
vi.stubEnv('PATH', '')
|
||||
const ctx = await context()
|
||||
await ctx.plugin(Commands)
|
||||
const env = ctx.shellEnv.collect(execution)
|
||||
const result = await execa('dsoffice', ['capabilities'], { cwd: root, env, extendEnv: false })
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ runtime: {
|
||||
cliPath: fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/cli')),
|
||||
} })
|
||||
expect(process.env.PATH).toBe('')
|
||||
expect(await readdir(root)).toEqual(['commands'])
|
||||
await expect(readFile(join(directory(ctx), 'office-check'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('preserves configured and forwarded shell arguments literally', async () => {
|
||||
const ctx = await context()
|
||||
const fixed = ['', "apostrophe' and space", '$(printf substituted)', '`printf substituted`', '%PATH%', 'double"quote']
|
||||
const command = [process.execPath, '-e', 'process.stdout.write(JSON.stringify(process.argv.slice(1)))', ...fixed]
|
||||
await ctx.plugin(Commands, { command, checkCommand: command })
|
||||
for (const name of ['dsoffice', 'office-check']) {
|
||||
const result = await execa(join(directory(ctx), name), ['forwarded value', ''], { env: { PATH: '' }, extendEnv: false })
|
||||
expect(JSON.parse(result.stdout)).toEqual([...fixed, 'forwarded value', ''])
|
||||
}
|
||||
})
|
||||
|
||||
it('owns separate launcher directories under a long home until each contributing context is disposed', async () => {
|
||||
const home = join(root, 'a'.repeat(120), 'b'.repeat(120))
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
expect(home.length).toBeGreaterThan(260)
|
||||
const first = await context(), second = await context()
|
||||
const [one, two] = await Promise.all([
|
||||
first.plugin(Commands, { command: false, checkCommand: false }),
|
||||
second.plugin(Commands, { command: false, checkCommand: false }),
|
||||
])
|
||||
const firstDirectory = directory(first), secondDirectory = directory(second)
|
||||
expect(firstDirectory).not.toBe(secondDirectory)
|
||||
expect(dirname(firstDirectory)).toBe(join(home, 'commands'))
|
||||
expect(dirname(secondDirectory)).toBe(join(home, 'commands'))
|
||||
await one.dispose()
|
||||
await expect(readdir(firstDirectory)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(first.shellEnv.collect(execution)).not.toHaveProperty('PATH')
|
||||
expect(await readdir(secondDirectory)).toEqual([])
|
||||
await two.dispose()
|
||||
await expect(readdir(secondDirectory)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('defers missing executable failures to invocation and permits each command to be disabled', async () => {
|
||||
const ctx = await context()
|
||||
await ctx.plugin(Commands, { command: [join(root, 'missing-kit')], checkCommand: false })
|
||||
const files = await readdir(directory(ctx))
|
||||
expect(files.sort()).toEqual(process.platform === 'win32' ? ['dsoffice', 'dsoffice.ps1'] : ['dsoffice'])
|
||||
const disabled = await context()
|
||||
await disabled.plugin(Commands, { command: false, checkCommand: [join(root, 'missing-python')] })
|
||||
expect(await readdir(directory(disabled))).toContain('office-check')
|
||||
})
|
||||
|
||||
it.each([[], ['relative-command'], [process.execPath, 'line\nbreak'], [process.execPath, 'nul\0byte']].map(command => ({ command })))(
|
||||
'rejects an invalid command and removes its partial directory: $command', async ({ command }) => {
|
||||
const ctx = await context()
|
||||
expect(() => { Commands.apply(ctx, { command: [process.execPath], checkCommand: command }) })
|
||||
.toThrow(/absolute executable|NUL or newlines/)
|
||||
expect(await readdir(join(root, 'commands'))).toEqual([])
|
||||
expect(ctx.shellEnv.collect(execution)).not.toHaveProperty('PATH')
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects relative asset roots before publishing commands', async () => {
|
||||
const ctx = await context()
|
||||
expect(() => { Commands.apply(ctx, { assetRoot: 'relative-assets' }) }).toThrow('assetRoot must be an absolute directory')
|
||||
expect(ctx.shellEnv.collect(execution)).not.toHaveProperty('PATH')
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('runs Kit with the host Node when the selected authoring environment contains only Python', async () => {
|
||||
vi.stubEnv('DSH_PRIMARY_RUNTIME', join(root, 'python-only'))
|
||||
const ctx = await context()
|
||||
await ctx.plugin(Commands)
|
||||
const result = await execa('dsoffice', ['capabilities'], {
|
||||
cwd: root, env: { ...ctx.shellEnv.collect(execution), PATH: directory(ctx) }, extendEnv: false,
|
||||
})
|
||||
expect(JSON.parse(result.stdout)).toHaveProperty('runtime.cliPath')
|
||||
expect(await readFile(join(directory(ctx), 'office-check'), 'utf8')).toContain(join(root, 'python-only/dependencies/python'))
|
||||
})
|
||||
|
||||
it.each(['win32', 'linux'])('uses bundled interpreters and explicit resources without preparing the payload on %s', async (platformName) => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
Object.defineProperty(process, 'platform', { value: platformName })
|
||||
try {
|
||||
const bundled = join(root, 'carrier', 'primary-runtime')
|
||||
const cli = join(root, 'carrier', 'kit-cli.js'), assets = join(root, 'custom-assets')
|
||||
vi.stubEnv('DSH_BUNDLED_PRIMARY_RUNTIME', bundled)
|
||||
vi.stubEnv('DSH_BUNDLED_OFFICE_CLI', cli)
|
||||
const ctx = await context()
|
||||
await ctx.plugin(Commands, { assetRoot: assets })
|
||||
const kit = await readFile(join(directory(ctx), 'dsoffice'), 'utf8')
|
||||
expect(kit).toContain(cli)
|
||||
expect(kit).toContain(join(bundled, 'dependencies/node/bin', platformName === 'win32' ? 'node.exe' : 'node'))
|
||||
const checker = await readFile(join(directory(ctx), 'office-check'), 'utf8')
|
||||
expect(checker).toContain(join(bundled, 'dependencies/python', platformName === 'win32' ? 'python.exe' : 'bin/python3'))
|
||||
expect(checker).toContain(join(assets, 'scripts/check_office.py'))
|
||||
await expect(readdir(join(root, 'carrier'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses explicit runtime configuration with the source checker launcher', async () => {
|
||||
vi.stubEnv('DSH_PRIMARY_RUNTIME', join(root, 'primary-runtime'))
|
||||
const runtime = { source: join(root, 'custom-runtime'), root: join(root, 'installed') }
|
||||
const ctx = await context()
|
||||
await ctx.plugin(Commands, { runtime })
|
||||
expect(await checkerRuntime(ctx)).toEqual(runtime)
|
||||
})
|
||||
|
||||
it('does not acquire a checker runtime for a carrierless profile', async () => {
|
||||
const ctx = await context(), profile = join(root, 'profiles', 'headless')
|
||||
ctx.provide('profileContext', {
|
||||
name: 'headless', dir: profile, patchPath: join(profile, 'cordis.patch.yml'), installAnchor: join(root, 'package.json'),
|
||||
cwd: root, home: root, startedBundles: [], overlays: [], telemetryDisabledEnv: undefined,
|
||||
})
|
||||
await ctx.plugin(Commands)
|
||||
await expect(readFile(join(directory(ctx), 'office-check'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(readdir(join(profile, 'runtimes'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('writes PowerShell launchers with literal arguments and the selected checker environment', async () => {
|
||||
const ctx = await context(), configured = await context()
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
try {
|
||||
const bundled = join(root, 'primary-runtime')
|
||||
vi.stubEnv('DSH_BUNDLED_PRIMARY_RUNTIME', bundled)
|
||||
Commands.apply(ctx, { command: [process.execPath, 'percent%value', 'double"quote', "apostrophe'value"] })
|
||||
const launcher = await readFile(join(directory(ctx), 'dsoffice.ps1'))
|
||||
expect([...launcher.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf])
|
||||
expect(launcher.toString('utf8')).toContain("$commandArgs = @('percent%value', 'double\"quote', 'apostrophe''value') + $args")
|
||||
expect(launcher.toString('utf8')).toContain("$PSNativeCommandArgumentPassing = 'Legacy'")
|
||||
expect(await readFile(join(directory(ctx), 'office-check.ps1'), 'utf8')).toContain('python.exe')
|
||||
Commands.apply(configured, { runtime: { source: join(root, 'custom-runtime') } })
|
||||
expect(await readFile(join(directory(configured), 'office-check.ps1'), 'utf8')).toContain('$env:TSX_TSCONFIG_PATH')
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
})
|
||||
|
||||
it('removes short executable paths without removing the original interpreter', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
try {
|
||||
const runtime = join(root, 'a'.repeat(120), 'b'.repeat(120))
|
||||
await mkdir(toNamespacedPath(runtime), { recursive: true })
|
||||
const node = join(runtime, 'node.exe')
|
||||
expect(node.length).toBeGreaterThan(260)
|
||||
await writeFile(toNamespacedPath(node), 'fixture')
|
||||
const ctx = await context()
|
||||
const commands = await ctx.plugin(Commands, { command: [node], checkCommand: false })
|
||||
const launcher = await readFile(join(directory(ctx), 'dsoffice.ps1'), 'utf8')
|
||||
const shortNode = /& '([^']+)' @nativeArgs/u.exec(launcher)?.[1]
|
||||
if (shortNode === undefined) throw new Error('Missing executable path in the PowerShell launcher')
|
||||
expect(shortNode).not.toBe(node)
|
||||
expect(await readFile(shortNode, 'utf8')).toBe('fixture')
|
||||
await commands.dispose()
|
||||
await expect(readFile(shortNode)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await readFile(toNamespacedPath(node), 'utf8')).toBe('fixture')
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32').each([undefined, 'powershell.exe'])(
|
||||
'preserves native arguments and pipelines through the Windows shell executor: %s', async (pwshPath) => {
|
||||
const { LocalSubprocessRuntime } = await import('@deepseek-ai/dsh-subprocess-local')
|
||||
const { PwshLocalExecutor } = await import('@deepseek-ai/dsh-pwsh-local')
|
||||
const runtime = join(root, 'runtime 中文', 'a'.repeat(220))
|
||||
await mkdir(toNamespacedPath(runtime), { recursive: true })
|
||||
const node = join(runtime, 'node.exe'), fixture = join(runtime, 'arguments.mjs')
|
||||
expect(node.length).toBeGreaterThan(260)
|
||||
await copyFile(process.execPath, toNamespacedPath(node))
|
||||
await writeFile(toNamespacedPath(fixture), "import { readFileSync } from 'node:fs'; process.stdout.write(JSON.stringify({ args: process.argv.slice(2), stdin: readFileSync(0, 'utf8') })); process.stderr.write('fixture stderr'); process.exitCode = 7;\n")
|
||||
const argumentsToPreserve = ['', 'space and 中文', "apostrophe'value", 'double"quote', 'quote" with space',
|
||||
'slash\\" quote', 'trailing\\', 'space trailing\\', '%PATH%', '!literal!', '&<|>']
|
||||
const quoted = argumentsToPreserve.map(value => `'${value.replaceAll("'", "''")}'`).join(' ')
|
||||
if (pwshPath === undefined) vi.stubEnv('DSH_HOME', join(root, 'a'.repeat(120), 'b'.repeat(120)))
|
||||
const ctx = await context()
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(PwshLocalExecutor, pwshPath === undefined ? {} : { pwshPath })
|
||||
const commands = await ctx.plugin(Commands, { command: [node, fixture, ...argumentsToPreserve], checkCommand: false })
|
||||
const commandDirectory = directory(ctx)
|
||||
if (pwshPath === undefined) expect(commandDirectory.length).toBeGreaterThan(260)
|
||||
// Seeding the Framework cache forces its BOM independently of the runner's code page and attached console.
|
||||
const result = await (await ctx.shell.execute(ctx.shell.resolve({
|
||||
command: `$OutputEncoding = [System.Text.UTF8Encoding]::new($true)
|
||||
if ($PSVersionTable.PSVersion.Major -le 5) {
|
||||
[Console].GetField('_inputEncoding', 'NonPublic,Static').SetValue($null, $OutputEncoding)
|
||||
}
|
||||
$originalInputEncoding = [Console]::InputEncoding
|
||||
$originalInputReader = [Console]::In
|
||||
'piped 中文' | dsoffice ${quoted} | Out-String
|
||||
if (-not [object]::ReferenceEquals($originalInputEncoding, [Console]::InputEncoding)) { throw 'Input encoding was not restored' }
|
||||
if (-not [object]::ReferenceEquals($originalInputReader, [Console]::In)) { throw 'Input reader was replaced' }
|
||||
exit $LASTEXITCODE`,
|
||||
env: { PSExecutionPolicyPreference: 'Restricted' }, dshEnv: ctx.shellEnv.collect(execution),
|
||||
}))).result()
|
||||
expect(result.exitCode, result.stderr.text).toBe(7)
|
||||
expect(result.stderr.text).toContain('fixture stderr')
|
||||
const output = JSON.parse(result.stdout.text) as { args: string[]; stdin: string }
|
||||
expect(output.args).toEqual([...argumentsToPreserve, ...argumentsToPreserve])
|
||||
expect(output.stdin.trimEnd()).toBe('piped 中文')
|
||||
const pages = await (await ctx.shell.execute(ctx.shell.resolve({
|
||||
command: 'dsoffice --pages 1,3,4; exit $LASTEXITCODE',
|
||||
dshEnv: ctx.shellEnv.collect(execution),
|
||||
}))).result()
|
||||
expect(pages.exitCode, pages.stderr.text).toBe(7)
|
||||
expect((JSON.parse(pages.stdout.text) as { args: string[] }).args).toEqual([...argumentsToPreserve, '--pages', '1,3,4'])
|
||||
await commands.dispose()
|
||||
await expect(readdir(commandDirectory)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(ctx.shellEnv.collect(execution)).not.toHaveProperty('PATH')
|
||||
},
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('reports a missing Windows executable as a failed shell command', async () => {
|
||||
const { LocalSubprocessRuntime } = await import('@deepseek-ai/dsh-subprocess-local')
|
||||
const { PwshLocalExecutor } = await import('@deepseek-ai/dsh-pwsh-local')
|
||||
const ctx = await context()
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(PwshLocalExecutor, { pwshPath: 'powershell.exe' })
|
||||
await ctx.plugin(Commands, { command: [join(root, 'missing.exe')], checkCommand: false })
|
||||
const result = await (await ctx.shell.execute(ctx.shell.resolve({
|
||||
command: 'dsoffice --pages 1,3,4; exit $LASTEXITCODE',
|
||||
dshEnv: ctx.shellEnv.collect(execution),
|
||||
}))).result()
|
||||
expect(result.exitCode).toBe(127)
|
||||
expect(result.stderr.text).toContain('missing.exe')
|
||||
})
|
||||
|
||||
it('uses the downloaded sidecar Node and disables Kit for an authoring-only carrier', async () => {
|
||||
const node = join(root, 'sidecar/node'), cli = join(root, 'sidecar/cli.js')
|
||||
vi.stubEnv('DSH_BUNDLED_OFFICE_NODE', node)
|
||||
vi.stubEnv('DSH_BUNDLED_OFFICE_CLI', cli)
|
||||
const ctx = await context()
|
||||
await ctx.plugin(Commands)
|
||||
const launcher = await readFile(join(directory(ctx), 'dsoffice'), 'utf8')
|
||||
expect(launcher).toContain(node)
|
||||
expect(launcher).toContain(cli)
|
||||
vi.stubEnv('DSH_BUNDLED_OFFICE_CLI', '')
|
||||
const disabled = await context()
|
||||
await disabled.plugin(Commands)
|
||||
expect(await readdir(directory(disabled))).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
/** Shared model configuration and usage reporting for real Office workflows. */
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { expect } from 'vitest'
|
||||
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { estimateMessage, estimateToolsTokens } from '../../../../llm/token-meter/src/estimate.ts'
|
||||
|
||||
/** @returns Model provider patches for the configured development or official API. */
|
||||
export function officeModelPatches() {
|
||||
const model = process.env.MODEL_NAME ?? 'deepseek-flash'
|
||||
const completions = process.env.DSH_OFFICE_TEST_API === 'openai-completions'
|
||||
return [
|
||||
...completions ? [
|
||||
{ id: 'llm-deepseek', disabled: true },
|
||||
{ id: 'llm-pi-ai', config: { providers: { 'office-test': {
|
||||
api: 'openai-completions', apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: process.env.DEEPSEEK_BASE_URL,
|
||||
compat: { thinkingFormat: 'deepseek' },
|
||||
models: [{ id: model, contextWindow: 128000, maxTokens: 8192, input: ['text', 'image'] }],
|
||||
} } } },
|
||||
] : [{ id: 'llm-deepseek', config: { thinking: 'enabled', reasoningEffort: 'high', maxTokens: 8192,
|
||||
models: [{ id: model, contextWindow: 128000, maxTokens: 8192, inputModalities: ['text', 'image'] }],
|
||||
} }],
|
||||
{ id: 'agent-default-model', config: { provider: completions ? 'office-test' : 'deepseek-official', model } },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Write character estimates and server usage separately when a report directory is configured.
|
||||
* @param events - Completed recorded session.
|
||||
* @param name - Scenario report filename without extension.
|
||||
* @returns Resolves after the optional report is written.
|
||||
*/
|
||||
export async function reportOfficeUsage(events: SessionEvent[], name: string): Promise<void> {
|
||||
const root = process.env.DSH_OFFICE_USAGE_REPORT
|
||||
if (root === undefined) return
|
||||
const requests = events.flatMap((event, index) => {
|
||||
if (event.type !== 'assistant/message') return []
|
||||
const prefix = events.slice(0, index)
|
||||
const session = Session.create(SessionId('office-usage'), prefix)
|
||||
const messages = session.deriveMessages()
|
||||
const header = prefix.findLast(row => row.type === 'request/header')
|
||||
return [{ step: event.data.step, serverUsage: event.data.usage,
|
||||
estimatedRequestTokens: messages.reduce((total, message) => total + estimateMessage(message), 0)
|
||||
+ estimateToolsTokens(header?.type === 'request/header' ? header.data.header : undefined),
|
||||
loadedSkills: [...new Set(messages.flatMap(message => message.content.flatMap(block => block.type === 'text'
|
||||
? [...block.text.matchAll(/<skill_content name="([^"]+)">/g)].map(match => match[1]) : [])))],
|
||||
}]
|
||||
})
|
||||
await mkdir(root, { recursive: true })
|
||||
await writeFile(join(root, `${name}.json`), JSON.stringify(requests, null, 2) + '\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a non-rendering task has received no rendering commands or detailed guidance.
|
||||
* @param events - Recorded task events, including nested skill and shell results.
|
||||
*/
|
||||
export function expectNoRenderContext(events: SessionEvent[]): void {
|
||||
const messages = Session.create(SessionId('office-context'), events).deriveMessages()
|
||||
expect(JSON.stringify(messages)).not.toMatch(/imageRendering|maxPixels|fragment rectangles|render --input|--output-dir|uniformly blank/)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Record real CLI operations and the appearance of rendered pixels. */
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/**
|
||||
* Install a CLI wrapper in a caller-owned test directory.
|
||||
* @param cwd - Private fixture directory owned and removed by the calling test.
|
||||
* @param python - Bundled Python executable used to inspect preview pixels.
|
||||
* @param blank - Replace successful render images with fully transparent pixels.
|
||||
* @param calls - Operation-log path in an existing writable directory; defaults beside the wrapper.
|
||||
* @returns Path to the wrapper and its operation log.
|
||||
*/
|
||||
export async function renderCliFixture(
|
||||
cwd: string, python: string, blank: boolean, calls = join(cwd, '.fixture', 'calls.jsonl'),
|
||||
): Promise<{ cli: string; calls: string }> {
|
||||
const cli = join(cwd, '.fixture', 'cli.js')
|
||||
const kit = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/cli'))
|
||||
const inspectPixels = [
|
||||
'from pathlib import Path',
|
||||
'from PIL import Image',
|
||||
'import sys',
|
||||
"paths = list(Path(sys.argv[1]).rglob('*.png'))",
|
||||
'assert paths',
|
||||
'all_blank = True',
|
||||
'for path in paths:',
|
||||
' with Image.open(path) as image:',
|
||||
' size = image.size',
|
||||
" if sys.argv[2] == 'true':",
|
||||
" Image.new('RGBA', size, (0, 0, 0, 0)).save(path)",
|
||||
' with Image.open(path) as image:',
|
||||
" rgba = image.convert('RGBA')",
|
||||
" composite = Image.new('RGBA', rgba.size, 'white')",
|
||||
' composite.alpha_composite(rgba)',
|
||||
" all_blank &= all(low == high for low, high in composite.convert('RGB').getextrema())",
|
||||
'print(str(all_blank).lower())',
|
||||
].join('\n')
|
||||
await mkdir(dirname(cli))
|
||||
await writeFile(cli, `
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { appendFileSync } = require('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
const result = spawnSync(process.execPath, [${JSON.stringify(kit)}, ...args], { encoding: 'utf8' });
|
||||
let pixelsBlank = null;
|
||||
if (result.status === 0 && args[0] === 'render') {
|
||||
const pixels = spawnSync(${JSON.stringify(python)}, ['-c', ${JSON.stringify(inspectPixels)}, args[args.indexOf('--output-dir') + 1], String(${blank})], { encoding: 'utf8' });
|
||||
if (pixels.status !== 0) throw new Error(pixels.stderr);
|
||||
pixelsBlank = pixels.stdout.trim() === 'true';
|
||||
}
|
||||
if (result.status === 0 && args[0] === 'capabilities') {
|
||||
const capabilities = JSON.parse(result.stdout);
|
||||
capabilities.runtime.cliPath = __filename;
|
||||
result.stdout = JSON.stringify(capabilities);
|
||||
}
|
||||
appendFileSync(${JSON.stringify(calls)}, JSON.stringify({ args, status: result.status, pixelsBlank }) + '\\n');
|
||||
process.stdout.write(result.stdout);
|
||||
process.stderr.write(result.stderr);
|
||||
process.exitCode = result.status ?? 1;
|
||||
`)
|
||||
return { cli, calls }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/** Model request contents for independent Office authoring and operation skills. */
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import { PtcRuntime, type PtcRunRequest, type PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime'
|
||||
import SkillRegistry from '@deepseek-ai/dsh-skill'
|
||||
import * as ToolSkill from '../../tool-skill/src/index.ts'
|
||||
import * as SkillOffice from '../src/index.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
const contexts: Context[] = []
|
||||
afterEach(async () => { for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() })
|
||||
|
||||
class SkillProgram extends PtcRuntime {
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fixture'
|
||||
resolve(request: PtcRunRequest): PtcRunSpec { return { ...request, cwd: process.cwd(), timeoutMs: 120_000 } }
|
||||
async run(request: PtcRunSpec) {
|
||||
return { logs: [], value: await request.bindings[0]!.functions.skill!({ name: request.program }) }
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(mode: 'native' | 'ptc', names: string[]) {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
provideWorkingDirectoryFixture(ctx)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SystemPrompt, { personaPrefix: '', personaSuffix: '' })
|
||||
await ctx.plugin(ToolRuntime, { mode })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SkillRegistry)
|
||||
await ctx.plugin(ToolSkill)
|
||||
await ctx.plugin(SkillOffice)
|
||||
if (mode === 'ptc') await ctx.plugin(SkillProgram)
|
||||
const adapter = new MockAdapter([
|
||||
...names.map((name, index) => mode === 'native'
|
||||
? toolCallResponse(`skill-${index}`, 'skill', { name })
|
||||
: toolCallResponse(`skill-${index}`, 'run_code', { code: name, description: 'Load task instructions' })),
|
||||
textResponse('Done.'), textResponse('Continued.'), textResponse('Fork continued.'),
|
||||
])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const { agent } = await ctx.agents.create({ sessionId: SessionId('progressive-office'), agentOptions: { provider: 'mock', model: 'model' } })
|
||||
return { ctx, agent, adapter }
|
||||
}
|
||||
|
||||
async function send(agent: Agent, text = 'Continue the task'): Promise<void> {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
}
|
||||
|
||||
it.each(['native', 'ptc'] as const)('loads the router and authoring body in %s; resume and fork retain those instructions', async (mode) => {
|
||||
const { ctx, agent, adapter } = await setup(mode, ['office', 'office-docx'])
|
||||
await send(agent, 'Edit the Word document text.')
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
const requests = adapter.requests.map(request => JSON.stringify(request))
|
||||
expect(requests[0]).toContain('Create, edit, inspect, or convert Office')
|
||||
expect(requests[0]).not.toContain('office-docx')
|
||||
expect(requests[1]).toContain('Office task routing')
|
||||
expect(requests[1]).not.toContain('python-docx')
|
||||
expect(requests[2]).toContain('python-docx')
|
||||
expect(requests[2]).toContain('dsoffice render')
|
||||
expect(requests[0]).not.toContain('dsoffice')
|
||||
expect(requests[1]).not.toContain('dsoffice')
|
||||
for (const request of requests) {
|
||||
expect(request).not.toMatch(/uniformly blank|fragment rectangles/)
|
||||
}
|
||||
const seed = agent.session.snapshotEvents()
|
||||
const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('resumed-office'), agentOptions: { provider: 'mock', model: 'model' }, seed })
|
||||
await send(resumed)
|
||||
const { agent: forked } = await ctx.agents.create({ sessionId: SessionId('forked-office'), agentOptions: { provider: 'mock', model: 'model' }, seed, meta: { parentSession: agent.id } })
|
||||
await send(forked)
|
||||
for (const request of adapter.requests.slice(3)) {
|
||||
const text = JSON.stringify(request)
|
||||
expect(text).toContain('python-docx')
|
||||
expect(text).toContain('dsoffice render')
|
||||
expect(text).not.toMatch(/uniformly blank|fragment rectangles/)
|
||||
}
|
||||
expect(agent.session.snapshotEvents()).toEqual(seed)
|
||||
})
|
||||
|
||||
it.each(['office-render', 'office-recalculate', 'office-export-pdf'])('loads %s directly without unrelated operation or authoring bodies', async (name) => {
|
||||
const { agent, adapter } = await setup('native', [name])
|
||||
await send(agent)
|
||||
const loaded = JSON.stringify(adapter.requests.at(-1))
|
||||
expect(loaded).toContain('dsoffice')
|
||||
expect(loaded).not.toContain('Office task routing')
|
||||
expect(loaded).not.toContain('# Word documents')
|
||||
if (name !== 'office-render') expect(loaded).not.toMatch(/render --input|uniformly blank|fragment rectangles|DPI/)
|
||||
if (name !== 'office-recalculate') expect(loaded).not.toContain('recalculate --input')
|
||||
if (name !== 'office-export-pdf') expect(loaded).not.toContain('convert --input')
|
||||
})
|
||||
|
||||
it('loads an explicitly invoked authoring skill without the router', async () => {
|
||||
const { agent, adapter } = await setup('native', [])
|
||||
await send(agent, '/office-xlsx Change A1 to 42.')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const text = JSON.stringify(adapter.requests[0])
|
||||
expect(text).toContain('# Excel workbooks')
|
||||
expect(text).not.toMatch(/Office task routing|dsoffice/)
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
/** Actual skill discovery and CLI rendering through the shipped headless composition. */
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { expect, it } from 'vitest'
|
||||
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { expectNoRenderContext, officeModelPatches, reportOfficeUsage } from './fixtures/real-office.ts'
|
||||
|
||||
const repo = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const runtime = process.env.DSH_PRIMARY_RUNTIME
|
||||
const python = runtime && join(runtime, 'dependencies/python', process.platform === 'win32' ? 'python.exe' : 'bin/python3')
|
||||
|
||||
const launch = {
|
||||
binScript: join(repo, 'apps/cli/src/bin.ts'), sourceImport: 'tsx/esm' as const, tsconfigPath: join(repo, 'tsconfig.base.json'),
|
||||
configPath: 'office.patch.json', processTimeoutMs: 110_000,
|
||||
env: { DSH_PRIMARY_RUNTIME: runtime, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native',
|
||||
HTTP_PROXY: process.env.DSH_OFFICE_TEST_PROXY, HTTPS_PROXY: process.env.DSH_OFFICE_TEST_PROXY },
|
||||
}
|
||||
|
||||
async function prepareOffice(cwd: string) {
|
||||
await writeFile(join(cwd, 'office.patch.json'), JSON.stringify([
|
||||
...officeModelPatches(),
|
||||
{ id: 'session-persistence-jsonl', config: { root: join(cwd, '.sessions'), compression: 'none' } },
|
||||
]))
|
||||
}
|
||||
|
||||
async function sessionEvents(cwd: string) {
|
||||
const logs = await readdir(join(cwd, '.sessions'), { recursive: true })
|
||||
const path = logs.find(p => p.endsWith('session.v4.jsonl'))
|
||||
expect(path).toBeDefined()
|
||||
const events = parseSessionLog(await readFile(join(cwd, '.sessions', path!), 'utf8'))
|
||||
return events
|
||||
}
|
||||
|
||||
it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each([{ format: 'docx', mode: 'native' }, { format: 'pdf', mode: 'native' }, { format: 'docx', mode: 'ptc' }])('loads the operation skill and renders $format through the shell in $mode mode', async ({ format, mode }) => {
|
||||
await runLoaderSmoke({
|
||||
label: 'Office CLI rendering', tempDirPrefix: 'dsh-office-render-',
|
||||
...launch, env: { ...launch.env, DSH_TOOLS_MODE: mode },
|
||||
binArgs: ['--profile', 'headless', '--patch', 'office.patch.json',
|
||||
`Export page 1 of input.${format} as PNG into preview and check that the exported image is readable. Report briefly in English.`],
|
||||
async prepare(cwd) {
|
||||
await execa(python!, ['-c', "from docx import Document\nd=Document()\nd.add_heading('Quarterly revenue',0)\nd.add_paragraph('Q1: 12; Q2: 18; Total: 30')\nd.save('input.docx')"], { cwd })
|
||||
if (format === 'pdf') await execa(python!, ['-c', "from PIL import Image, ImageDraw\nim=Image.new('RGB',(300,200),'white')\nImageDraw.Draw(im).rectangle((30,30,150,100),fill='black')\nim.save('input.pdf','PDF')"], { cwd })
|
||||
await prepareOffice(cwd)
|
||||
},
|
||||
async inspect(cwd) {
|
||||
const events = await sessionEvents(cwd)
|
||||
await reportOfficeUsage(events, `render-${format}-${mode}`)
|
||||
const calls = events.flatMap((event) => {
|
||||
if (event.type === 'tool/call') return [event.data]
|
||||
if (event.type === 'tool/ptc-dispatch') return [{ name: event.data.name, arguments: JSON.stringify(event.data.arguments) }]
|
||||
return []
|
||||
})
|
||||
const skills = calls.filter(call => call.name === 'skill').map(call => JSON.parse(call.arguments) as { name: string })
|
||||
expect(skills.some(skill => skill.name === 'office-render'), JSON.stringify(skills)).toBe(true)
|
||||
const commands = calls.filter(call => call.name === 'bash' || call.name === 'pwsh').map(call => (JSON.parse(call.arguments) as { command: string }).command).join('\n')
|
||||
expect(commands).toMatch(/\bdsoffice["']?\s+render\b/u)
|
||||
expect(commands).toContain('--pages')
|
||||
expect(commands).toContain('--output-dir')
|
||||
const pixels = await execa(python!, ['-c', "from pathlib import Path\nfrom PIL import Image\nfiles=list(Path('preview').glob('*.png'))\nassert files\nfor f in files:\n im=Image.open(f).convert('RGBA')\n visible=Image.alpha_composite(Image.new('RGBA',im.size,'white'),im).convert('RGB')\n print('content' if any(a!=b for a,b in visible.getextrema()) else 'blank')"], { cwd })
|
||||
expect(pixels.exitCode).toBe(0)
|
||||
if (format === 'pdf') expect(pixels.stdout.trim()).toBe('content')
|
||||
const authoring = calls.findIndex(call => call.name === 'skill' && /office-(docx|pptx|xlsx)/u.test(call.arguments))
|
||||
if (pixels.stdout.includes('blank')) {
|
||||
if (authoring !== -1) {
|
||||
const rendered = calls.findIndex(call => (call.name === 'bash' || call.name === 'pwsh') && /\bdsoffice["']?\s+render\b/u.test(call.arguments))
|
||||
expect(authoring, 'content checks after a blank preview may need the format skill').toBeGreaterThan(rendered)
|
||||
}
|
||||
const final = events.filter(e => e.type === 'assistant/message').at(-1)?.data.message.content
|
||||
.filter(b => b.type === 'text').map(b => b.text).join(' ')
|
||||
expect(final).toMatch(/visual (?:inspection|check)[^.!?]{0,100}(?:not completed|incomplete|could not be completed|unverified)/iu)
|
||||
} else {
|
||||
expect(authoring, JSON.stringify(skills)).toBe(-1)
|
||||
}
|
||||
expect(calls.some(e => e.name === 'read_image')).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime)('keeps Office skills and the renderer unloaded for an ordinary task', async () => {
|
||||
await runLoaderSmoke({
|
||||
...launch,
|
||||
label: 'ordinary task with Office routing', tempDirPrefix: 'dsh-office-unused-',
|
||||
binArgs: ['--profile', 'headless', '--patch', 'office.patch.json', 'What is 17 times 23? Reply with just the integer.'],
|
||||
prepare: prepareOffice,
|
||||
async inspect(cwd) {
|
||||
const events = await sessionEvents(cwd)
|
||||
await reportOfficeUsage(events, 'non-office')
|
||||
expectNoRenderContext(events)
|
||||
const calls = events.filter(event => event.type === 'tool/call')
|
||||
expect(calls.filter(event => event.data.name === 'skill')).toEqual([])
|
||||
const answer = events.filter(event => event.type === 'assistant/message').at(-1)?.data.message.content
|
||||
.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
expect(answer?.trim()).toBe('391')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime)('exports PDF through the operation skill and preserves the source', async () => {
|
||||
let original: Buffer | undefined
|
||||
await runLoaderSmoke({
|
||||
...launch,
|
||||
label: 'Office PDF export', tempDirPrefix: 'dsh-office-export-',
|
||||
binArgs: ['--profile', 'headless', '--patch', 'office.patch.json',
|
||||
'Convert input.docx to result.pdf, preserving input.docx. Do not change the document content. Respond briefly in English.'],
|
||||
async prepare(cwd) {
|
||||
await execa(python!, ['-c', "from docx import Document\nd=Document()\nd.add_paragraph('Quarterly revenue: 30')\nd.save('input.docx')"], { cwd })
|
||||
original = await readFile(join(cwd, 'input.docx'))
|
||||
await prepareOffice(cwd)
|
||||
},
|
||||
async inspect(cwd) {
|
||||
const events = await sessionEvents(cwd)
|
||||
await reportOfficeUsage(events, 'pdf-export-native')
|
||||
expectNoRenderContext(events)
|
||||
const calls = events.flatMap(event => event.type === 'tool/call' ? [event.data]
|
||||
: event.type === 'tool/ptc-dispatch' ? [{ name: event.data.name, arguments: JSON.stringify(event.data.arguments) }] : [])
|
||||
const skills = calls.filter(call => call.name === 'skill').map(call => (JSON.parse(call.arguments) as { name: string }).name)
|
||||
expect(skills).toContain('office-export-pdf')
|
||||
expect(skills).not.toEqual(expect.arrayContaining(['office-render']))
|
||||
expect(skills.some(name => ['office-docx', 'office-pptx', 'office-xlsx'].includes(name))).toBe(false)
|
||||
const commands = calls.filter(call => call.name === 'bash' || call.name === 'pwsh')
|
||||
.map(call => (JSON.parse(call.arguments) as { command: string }).command).join('\n')
|
||||
expect(commands).toMatch(/\bdsoffice["']?\s+convert\b/u)
|
||||
expect(commands).not.toMatch(/\bdsoffice["']?\s+render\b/u)
|
||||
expect((await readFile(join(cwd, 'result.pdf'))).subarray(0, 5).toString()).toBe('%PDF-')
|
||||
expect(await readFile(join(cwd, 'input.docx'))).toEqual(original)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,39 @@
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { join, sep } 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 { isSea } from 'node:sea'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { execa } from 'execa'
|
||||
|
||||
vi.mock('node:sea', () => ({ isSea: vi.fn(() => false) }))
|
||||
|
||||
const assets = fileURLToPath(new URL('../assets/', import.meta.url))
|
||||
const names = ['office-docx', 'office-pptx', 'office-xlsx']
|
||||
const names = ['office', 'office-docx', 'office-export-pdf', 'office-pptx', 'office-recalculate', 'office-render', 'office-xlsx']
|
||||
|
||||
describe('bundled Office skills', () => {
|
||||
it('keeps routing and authoring within the progressive context budgets', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(SkillRegistry)
|
||||
await ctx.plugin(SkillOffice)
|
||||
const catalog = await ctx.skills.list()
|
||||
const listed = catalog.filter(skill => skill.modelCatalogVisibility === 'listed')
|
||||
expect(listed.map(skill => skill.name)).toEqual(['office'])
|
||||
const route = (await ctx.skills.get('office'))!
|
||||
expect(Math.ceil(route.description.length / 4)).toBeLessThanOrEqual(50)
|
||||
expect(Math.ceil(route.content.length / 4)).toBeLessThanOrEqual(200)
|
||||
for (const [name, previousTokens] of [['office-docx', 1688], ['office-pptx', 1879], ['office-xlsx', 2330]] as const) {
|
||||
const skill = (await ctx.skills.get(name))!
|
||||
expect(Math.ceil(skill.content.length / 4)).toBeLessThanOrEqual(Math.floor(previousTokens * 0.65))
|
||||
expect(skill.content).not.toMatch(/read_image|transparent|DPI/)
|
||||
if (name === 'office-xlsx') expect(skill.content).not.toContain('dsoffice')
|
||||
else expect(skill.content).toContain('--pages "1,3,4"')
|
||||
}
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('loads each packaged body and removes all candidates on disposal', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
@@ -28,11 +45,11 @@ describe('bundled Office skills', () => {
|
||||
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) })
|
||||
expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: join(assets, skill.name).split(sep).join('/') })
|
||||
const raw = await readFile(join(assets, skill.name, 'SKILL.md'), 'utf8')
|
||||
expect(loaded?.content).toContain(raw.slice(raw.indexOf('\n---\n') + 5).trim())
|
||||
expect(loaded?.content).toContain(JSON.stringify(process.execPath))
|
||||
expect(loaded?.content).toContain('libreofficeKit')
|
||||
const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, '').trim()
|
||||
expect(loaded?.content).toBe(body)
|
||||
expect(skill.modelCatalogVisibility).toBe(skill.name === 'office' ? 'listed' : 'unlisted')
|
||||
}
|
||||
await fiber.dispose()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
@@ -64,24 +81,51 @@ describe('bundled Office skills', () => {
|
||||
])
|
||||
ctx.loader.internal = {
|
||||
version: 'v2',
|
||||
loadCache: new Map(),
|
||||
register() { throw new Error('unexpected register') },
|
||||
getOrCreateModuleJob() { throw new Error('unexpected module job') },
|
||||
resolveSync() { throw new Error('unexpected resolution') },
|
||||
load() { throw new Error('unexpected load') },
|
||||
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') })
|
||||
expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: join(external, 'office-xlsx').split(sep).join('/') })
|
||||
const raw = await readFile(join(external, 'office-xlsx', 'SKILL.md'), 'utf8')
|
||||
expect(loaded?.content).toContain(raw.slice(raw.indexOf('\n---\n') + 5).trim())
|
||||
const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, '').trim()
|
||||
expect(loaded?.content).toBe(body)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])('loads carrier resources without its interpreters or renderer (explicit resources: %s)', async (explicit) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-office-carrier-'))
|
||||
const ctx = new Context()
|
||||
try {
|
||||
const relocated = join(root, 'office-skills')
|
||||
await cp(assets, relocated, { recursive: true })
|
||||
vi.stubEnv('DSH_PRIMARY_RUNTIME', undefined)
|
||||
vi.stubEnv('DSH_BUNDLED_PRIMARY_RUNTIME', explicit ? undefined : join(root, 'primary-runtime'))
|
||||
vi.stubEnv('DSH_BUNDLED_OFFICE_SKILLS', explicit ? relocated : undefined)
|
||||
await ctx.plugin(SkillRegistry)
|
||||
await ctx.plugin(SkillOffice)
|
||||
for (const name of names) {
|
||||
expect((await ctx.skills.get(name))?.resourceBase).toEqual({ kind: 'directory', path: join(relocated, name).split(sep).join('/') })
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
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'],
|
||||
@@ -93,10 +137,10 @@ describe('bundled Office skills', () => {
|
||||
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, cli: false })
|
||||
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) + '\n\nLibreOffice Kit is disabled in this deployment.')
|
||||
expect(skill?.content).toBe(body.replaceAll('\n', newline))
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -128,64 +172,3 @@ describe('bundled Office skills', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unavailable CLI and Node paths before exposing an executable command', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-office-command-'))
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(SkillRegistry)
|
||||
for (const config of [{ node: 'node' }, { cli: 'cli.js' }, { node: root }, { cli: root }, { cli: join(root, 'missing.js') }]) {
|
||||
expect(() => { SkillOffice.apply(ctx, config) }).toThrow()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
}
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('executes capabilities using only paths returned by the loaded skill from a separate workspace', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'office CLI 工作目录 '))
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(SkillRegistry)
|
||||
await ctx.plugin(SkillOffice)
|
||||
const skill = await ctx.skills.get('office-docx')
|
||||
const json = skill?.content.match(/\n(\{\n[\s\S]+)$/u)?.[1]
|
||||
expect(json).toBeDefined()
|
||||
const { libreofficeKit } = JSON.parse(json!) as { libreofficeKit: { node: string; cli: string } }
|
||||
const result = await execa(libreofficeKit.node, [libreofficeKit.cli, 'capabilities'], {
|
||||
cwd: root, env: { PATH: '' }, timeout: 20_000, killSignal: 'SIGKILL', reject: false,
|
||||
})
|
||||
expect(result.timedOut, result.stderr).toBe(false)
|
||||
expect(result.signal, result.stderr).toBeUndefined()
|
||||
expect(result.exitCode, result.stderr).toBe(0)
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ runtime: { version: '0.1.5', cliPath: libreofficeKit.cli } })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('requires standalone Node in Electron and SEA, while permitting explicit CLI opt-out', async () => {
|
||||
const ctx = new Context()
|
||||
const original = Object.getOwnPropertyDescriptor(process.versions, 'electron')
|
||||
try {
|
||||
await ctx.plugin(SkillRegistry)
|
||||
vi.mocked(isSea).mockReturnValue(true)
|
||||
expect(() => { SkillOffice.apply(ctx) }).toThrow('standalone Node')
|
||||
vi.mocked(isSea).mockReturnValue(false)
|
||||
Object.defineProperty(process.versions, 'electron', { value: '44.0.0', configurable: true })
|
||||
expect(() => { SkillOffice.apply(ctx) }).toThrow('standalone Node')
|
||||
const fiber = await ctx.plugin(SkillOffice, { cli: false })
|
||||
expect((await ctx.skills.get('office-docx'))?.content).toContain('disabled in this deployment')
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(SkillOffice, { node: process.execPath })
|
||||
expect((await ctx.skills.get('office-docx'))?.content).toContain(JSON.stringify(process.execPath))
|
||||
} finally {
|
||||
vi.mocked(isSea).mockReturnValue(false)
|
||||
if (original === undefined) Reflect.deleteProperty(process.versions, 'electron')
|
||||
else Object.defineProperty(process.versions, 'electron', original)
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
/** Real Office authoring tasks with explicit, excluded, and implicit visual inspection. */
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, relative, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { expect, it } from 'vitest'
|
||||
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { officeModelPatches, reportOfficeUsage } from './fixtures/real-office.ts'
|
||||
import { renderCliFixture } from './fixtures/render-cli.ts'
|
||||
|
||||
const repo = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const assetRoot = fileURLToPath(new URL('../assets/', import.meta.url))
|
||||
const runtime = process.env.DSH_PRIMARY_RUNTIME
|
||||
const python = runtime && join(runtime, 'dependencies/python', process.platform === 'win32' ? 'python.exe' : 'bin/python3')
|
||||
|
||||
const formats = [
|
||||
{
|
||||
format: 'pptx', task: 'center the title on slide 1 and set its font color to #1F4E79',
|
||||
seed: "from pptx import Presentation\nfrom pptx.util import Inches\np=Presentation(); s=p.slides.add_slide(p.slide_layouts[6]); s.shapes.add_textbox(Inches(1),Inches(1),Inches(6),Inches(1)).text='Quarterly revenue'; p.save('input.pptx')",
|
||||
verify: "from pptx import Presentation\nfrom pptx.enum.text import PP_ALIGN\np=Presentation('result.pptx'); title=p.slides[0].shapes[0]; r=title.text_frame.paragraphs[0]; assert r.alignment == PP_ALIGN.CENTER; assert abs(2*title.left+title.width-p.slide_width) <= 1; assert str(r.runs[0].font.color.rgb).upper() == '1F4E79'",
|
||||
},
|
||||
{
|
||||
format: 'docx', task: 'center the title paragraph and set its font color to #1F4E79',
|
||||
seed: "from docx import Document\nd=Document(); d.add_paragraph('Quarterly revenue'); d.add_paragraph('Q1: 12; Q2: 18'); d.save('input.docx')",
|
||||
verify: "from docx import Document\nfrom docx.enum.text import WD_ALIGN_PARAGRAPH\nd=Document('result.docx'); p=d.paragraphs[0]; assert p.alignment == WD_ALIGN_PARAGRAPH.CENTER; assert p.runs[0].font.color.rgb == (31,78,121)",
|
||||
},
|
||||
{
|
||||
format: 'xlsx', task: 'center the text in Revenue!A1:B1 and set the cells\' fill color to #1F4E79',
|
||||
seed: "from openpyxl import Workbook\nw=Workbook(); s=w.active; s.title='Revenue'; s.append(['Quarter','Revenue']); s.append(['Q1',12]); s.append(['Q2',18]); w.save('input.xlsx')",
|
||||
verify: "from openpyxl import load_workbook\nw=load_workbook('result.xlsx'); s=w['Revenue']; assert all(s[c].alignment.horizontal == 'center' and s[c].fill.fgColor.rgb.endswith('1F4E79') for c in ('A1','B1')); assert s['B2'].value == 12 and s['B3'].value == 18",
|
||||
},
|
||||
] as const
|
||||
|
||||
const intents = [
|
||||
{ intent: 'explicit', suffix: 'Render the finished file and visually inspect the changed page or sheet.' },
|
||||
{ intent: 'excluded', suffix: 'Do not render or visually inspect the finished file.' },
|
||||
{ intent: 'implicit', suffix: '' },
|
||||
] as const
|
||||
|
||||
const cases = [
|
||||
...formats.flatMap(format => intents.map(intent => ({ ...format, ...intent }))),
|
||||
{ ...formats[0], intent: 'blank', suffix: intents[0].suffix },
|
||||
] as const
|
||||
|
||||
function within(parent: string, path: string): boolean {
|
||||
const part = relative(parent, path)
|
||||
return part === '' || part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)
|
||||
}
|
||||
|
||||
it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(cases)('$format $intent visual intent', async ({ format, task, seed, verify, intent, suffix }) => {
|
||||
const controlRoot = await mkdtemp(join(tmpdir(), 'dsh-office-control-test-'))
|
||||
try {
|
||||
const previewRoot = await mkdtemp(join(tmpdir(), 'dsh-office-preview-test-'))
|
||||
try {
|
||||
const configPath = join(controlRoot, 'office.patch.json')
|
||||
const sessionRoot = join(controlRoot, 'sessions')
|
||||
// The model can remove its entire preview directory during cleanup.
|
||||
const callLogPath = join(controlRoot, 'cli-calls.jsonl')
|
||||
await runLoaderSmoke({
|
||||
label: `Office ${format} ${intent} visual intent`, tempDirPrefix: `dsh-office-${format}-${intent}-`,
|
||||
binScript: join(repo, 'apps/cli/src/bin.ts'), sourceImport: 'tsx/esm', tsconfigPath: join(repo, 'tsconfig.base.json'),
|
||||
configPath, processTimeoutMs: 110_000,
|
||||
binArgs: ['--profile', 'headless', '--patch', configPath,
|
||||
`In input.${format}, ${task}; save result.${format}. ${suffix} Respond briefly in English.`],
|
||||
// The CLI fixture and its log live outside the workspace; bwrap's private /tmp hides both.
|
||||
env: { DSH_PRIMARY_RUNTIME: runtime, DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_HOME: join(controlRoot, 'home'), DSH_AGENTS_HOME: join(controlRoot, 'agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native',
|
||||
TMPDIR: previewRoot, TMP: previewRoot, TEMP: previewRoot,
|
||||
HTTP_PROXY: process.env.DSH_OFFICE_TEST_PROXY, HTTPS_PROXY: process.env.DSH_OFFICE_TEST_PROXY },
|
||||
async prepare(cwd) {
|
||||
await execa(python!, ['-c', seed], { cwd })
|
||||
const fixture = await renderCliFixture(controlRoot, python!, intent === 'blank', callLogPath)
|
||||
const testAssetRoot = process.env.DSH_OFFICE_TEST_ASSET_ROOT ?? assetRoot
|
||||
await writeFile(configPath, JSON.stringify([
|
||||
{ id: 'skill-office', config: { assetRoot: testAssetRoot } },
|
||||
{ id: 'office-commands', config: { assetRoot: testAssetRoot, command: [process.execPath, fixture.cli] } },
|
||||
...officeModelPatches(),
|
||||
{ id: 'session-persistence-jsonl', config: { root: sessionRoot, compression: 'none' } },
|
||||
]))
|
||||
},
|
||||
async inspect(cwd) {
|
||||
const logs = await readdir(sessionRoot, { recursive: true })
|
||||
const log = logs.find(path => path.endsWith('session.v4.jsonl'))
|
||||
expect(log, `No recorded session for ${format} ${intent} in ${sessionRoot}`).toBeDefined()
|
||||
const bytes = await readFile(join(sessionRoot, log!))
|
||||
const traceDir = process.env.DSH_OFFICE_TRACE_DIR
|
||||
if (traceDir !== undefined) {
|
||||
await mkdir(traceDir, { recursive: true })
|
||||
await writeFile(join(traceDir, `${format}-${intent}.jsonl`), bytes)
|
||||
}
|
||||
const events = parseSessionLog(bytes.toString('utf8'))
|
||||
await reportOfficeUsage(events, `${format}-${intent}`)
|
||||
const calls = events.filter(event => event.type === 'tool/call').map(event => event.data)
|
||||
const loaded = calls.filter(call => call.name === 'skill').map(call => (JSON.parse(call.arguments) as { name: string }).name)
|
||||
const imageReads = calls.filter(call => call.name === 'read_image')
|
||||
const shellCommands = calls.filter(call => call.name === 'bash' || call.name === 'pwsh')
|
||||
.map(call => (JSON.parse(call.arguments) as { command: string }).command)
|
||||
const answer = events.findLast(event => event.type === 'assistant/message')
|
||||
const text = answer?.type === 'assistant/message'
|
||||
? answer.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') : undefined
|
||||
const callLog = await readFile(callLogPath, 'utf8').catch((error: unknown) => {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return ''
|
||||
throw error
|
||||
})
|
||||
const renderCalls = callLog.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as { args: string[]; status: number | null; pixelsBlank: boolean | null })
|
||||
.filter(call => call.args[0] === 'render')
|
||||
const diagnostic = JSON.stringify({
|
||||
scenario: `${format} ${intent}`, loadedSkills: loaded,
|
||||
shellCommands: shellCommands.map(command => command.slice(0, 600)),
|
||||
renderCalls, imageReads: imageReads.length,
|
||||
answer: text?.slice(0, 800) ?? '(missing)',
|
||||
}, null, 2)
|
||||
if (text === undefined) throw new Error(`Agent did not finish with an answer: ${diagnostic}`)
|
||||
const checked = await execa(python!, ['-c', verify], { cwd, reject: false })
|
||||
expect(checked.exitCode, `${checked.stderr}\n${diagnostic}`).toBe(0)
|
||||
expect(loaded, diagnostic).toContain(`office-${format}`)
|
||||
if (intent === 'excluded') {
|
||||
expect(loaded, diagnostic).not.toContain('office-render')
|
||||
expect(renderCalls, diagnostic).toEqual([])
|
||||
expect(imageReads, diagnostic).toEqual([])
|
||||
} else {
|
||||
expect(loaded, diagnostic).toContain('office-render')
|
||||
expect(renderCalls, diagnostic).toHaveLength(1)
|
||||
expect(imageReads.length, diagnostic).toBeLessThanOrEqual(1)
|
||||
for (const call of renderCalls) {
|
||||
const output = call.args[call.args.indexOf('--output-dir') + 1]
|
||||
expect(output, diagnostic).toBeDefined()
|
||||
expect(isAbsolute(output!), diagnostic).toBe(true)
|
||||
expect(within(cwd, output!), diagnostic).toBe(true)
|
||||
expect(call.status, diagnostic).toBe(0)
|
||||
}
|
||||
if (renderCalls[0]?.pixelsBlank || imageReads.length === 0) {
|
||||
expect(text.match(/Content and structure checks passed; visual inspection was not completed\./gu), diagnostic).toHaveLength(1)
|
||||
expect(text, diagnostic).not.toMatch(/all checks passed|visually (?:verified|validated|confirmed)/iu)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await rm(previewRoot, { recursive: true, force: true })
|
||||
}
|
||||
} finally {
|
||||
await rm(controlRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,22 +1,22 @@
|
||||
/** Real-agent Excel validation through the shipped headless profile; requires a built primary runtime. */
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { readImageFile } from '@deepseek-ai/dsh-attachment-local'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { expectNoRenderContext, officeModelPatches, reportOfficeUsage } from './fixtures/real-office.ts'
|
||||
import { renderCliFixture } from './fixtures/render-cli.ts'
|
||||
|
||||
const repo = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const assetRoot = fileURLToPath(new URL('../assets/', import.meta.url))
|
||||
// DSH_PRIMARY_RUNTIME names the same runtime.json + dependencies/ payload used by shipped deployments.
|
||||
const runtime = process.env.DSH_PRIMARY_RUNTIME
|
||||
const python = runtime && join(runtime, 'dependencies/python', process.platform === 'win32' ? 'python.exe' : 'bin/python3')
|
||||
const model = process.env.MODEL_NAME ?? 'deepseek-flash'
|
||||
// Development gateways can expose Chat Completions without the official Messages API.
|
||||
const completions = process.env.DSH_OFFICE_TEST_API === 'openai-completions'
|
||||
|
||||
const seed = `
|
||||
import sys
|
||||
from openpyxl import Workbook
|
||||
@@ -52,88 +52,72 @@ const cases = [
|
||||
},
|
||||
{
|
||||
name: 'layout edits inspect the affected worksheet region',
|
||||
task: 'Fix the cramped layout of Revenue!A1:B3: make the column headers fully readable and the amounts easy to read. Adjust column widths and header styling.',
|
||||
task: 'Fix the cramped layout of Revenue!A1:B3: make the column headers fully readable and the amounts easy to read. Adjust column widths and header styling, then visually inspect the final affected range.',
|
||||
kind: 'layout',
|
||||
},
|
||||
{
|
||||
name: 'blank previews end visual checking without changing print settings',
|
||||
task: 'Fix the cramped layout of Revenue!A1:B3: make the column headers fully readable and the amounts easy to read. Adjust column widths and header styling.',
|
||||
task: 'Fix the cramped layout of Revenue!A1:B3: make the column headers fully readable and the amounts easy to read. Adjust column widths and header styling, then visually inspect the final affected range.',
|
||||
kind: 'blank',
|
||||
},
|
||||
] as const
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime)('Excel validation scope', () => {
|
||||
it.each(cases)('$name', async ({ task, kind }) => {
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(['native', 'ptc'])('Excel validation scope (%s)', (mode) => {
|
||||
it.each(mode === 'native' ? cases : cases.filter(({ kind }) => kind === 'layout'))('$name', async ({ task, kind }) => {
|
||||
let events: SessionEvent[] = []
|
||||
let original: Buffer | undefined
|
||||
let previewHasContent = false
|
||||
let fixtureCalls: { args: string[]; status: number | null }[] = []
|
||||
const controlRoot = await mkdtemp(join(tmpdir(), 'dsh-xlsx-control-test-'))
|
||||
onTestFinished(() => rm(controlRoot, { recursive: true, force: true }))
|
||||
const configPath = join(controlRoot, 'office.patch.json')
|
||||
const sessionRoot = join(controlRoot, 'sessions'), imageRoot = join(controlRoot, 'image-store')
|
||||
await runLoaderSmoke({
|
||||
label: `Excel ${kind} validation`,
|
||||
tempDirPrefix: 'dsh-xlsx-validation-',
|
||||
binScript: join(repo, 'apps/cli/src/bin.ts'),
|
||||
sourceImport: 'tsx/esm',
|
||||
tsconfigPath: join(repo, 'tsconfig.base.json'),
|
||||
configPath: 'office.patch.json',
|
||||
binArgs: ['--profile', 'headless', '--patch', 'office.patch.json',
|
||||
configPath,
|
||||
binArgs: ['--profile', 'headless', '--patch', configPath,
|
||||
`${task} Use input.xlsx and save the finished workbook as result.xlsx. Preserve the Notes sheet and leave input.xlsx unchanged. Respond briefly in English.`,
|
||||
],
|
||||
processTimeoutMs: 110_000,
|
||||
env: {
|
||||
DSH_PRIMARY_RUNTIME: runtime,
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_HOME: join(controlRoot, 'home'),
|
||||
DSH_AGENTS_HOME: join(controlRoot, 'agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_TOOLS_MODE: 'native',
|
||||
DSH_TOOLS_MODE: mode,
|
||||
HTTP_PROXY: process.env.DSH_OFFICE_TEST_PROXY,
|
||||
HTTPS_PROXY: process.env.DSH_OFFICE_TEST_PROXY,
|
||||
},
|
||||
async prepare(cwd) {
|
||||
await execa(python!, ['-c', seed, kind], { cwd })
|
||||
original = await readFile(join(cwd, 'input.xlsx'))
|
||||
const fixtureCli = join(cwd, '.fixture', 'cli.js')
|
||||
if (kind === 'blank') {
|
||||
await mkdir(dirname(fixtureCli))
|
||||
const kit = join(dirname(fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json'))), 'lib/cli.js')
|
||||
await writeFile(fixtureCli, `
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { appendFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
const result = spawnSync(process.execPath, [${JSON.stringify(kit)}, ...args], { encoding: 'utf8' });
|
||||
if (result.status === 0 && args[0] === 'render') {
|
||||
const blank = spawnSync(${JSON.stringify(python)}, ['-c', ${JSON.stringify("from pathlib import Path\nfrom PIL import Image\nimport sys\nfor p in Path(sys.argv[1]).rglob('*.png'):\n with Image.open(p) as im: size = im.size\n Image.new('RGBA', size, (0, 0, 0, 0)).save(p)")}, args[args.indexOf('--output-dir') + 1]], { encoding: 'utf8' });
|
||||
if (blank.status !== 0) throw new Error(blank.stderr);
|
||||
}
|
||||
if (result.status === 0 && args[0] === 'capabilities') {
|
||||
const capabilities = JSON.parse(result.stdout);
|
||||
capabilities.runtime.cliPath = __filename;
|
||||
result.stdout = JSON.stringify(capabilities);
|
||||
}
|
||||
appendFileSync(join(__dirname, 'calls.jsonl'), JSON.stringify({ args, status: result.status }) + '\\n');
|
||||
process.stdout.write(result.stdout);
|
||||
process.stderr.write(result.stderr);
|
||||
process.exitCode = result.status ?? 1;
|
||||
`)
|
||||
}
|
||||
await writeFile(join(cwd, 'office.patch.json'), JSON.stringify([
|
||||
{ insert: [
|
||||
{ id: 'office-validation', name: '@deepseek-ai/dsh-skill-office', ...kind === 'blank' ? { config: { cli: fixtureCli } } : {} },
|
||||
{ id: 'office-dependencies', name: '@deepseek-ai/dsh-tool-workspace-dependencies', config: { source: runtime } },
|
||||
] },
|
||||
...completions ? [
|
||||
{ id: 'llm-deepseek', disabled: true },
|
||||
{ id: 'llm-pi-ai', config: { providers: { 'office-test': {
|
||||
api: 'openai-completions', apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: process.env.DEEPSEEK_BASE_URL,
|
||||
compat: { thinkingFormat: 'deepseek' },
|
||||
models: [{ id: model, contextWindow: 128000, maxTokens: 8192, input: ['text', 'image'] }],
|
||||
} } } },
|
||||
] : [{ id: 'llm-deepseek', config: { thinking: 'enabled', reasoningEffort: 'high', maxTokens: 8192,
|
||||
models: [{ id: model, contextWindow: 128000, maxTokens: 8192, inputModalities: ['text', 'image'] }],
|
||||
} }],
|
||||
{ id: 'agent-default-model', config: { provider: completions ? 'office-test' : 'deepseek-official', model } },
|
||||
{ id: 'attachment-local', config: { dshHome: join(cwd, '.image-store') } },
|
||||
{ id: 'session-persistence-jsonl', config: { root: join(cwd, '.sessions'), compression: 'none' } },
|
||||
const fixture = kind === 'blank' || kind === 'formula'
|
||||
? await renderCliFixture(controlRoot, python!, kind === 'blank') : undefined
|
||||
await writeFile(configPath, JSON.stringify([
|
||||
{ id: 'skill-office', config: { assetRoot } },
|
||||
{ id: 'office-commands', config: { assetRoot, ...fixture ? { command: [process.execPath, fixture.cli] } : {} } },
|
||||
...officeModelPatches(),
|
||||
{ id: 'attachment-local', config: { dshHome: imageRoot } },
|
||||
{ id: 'session-persistence-jsonl', config: { root: sessionRoot, compression: 'none' } },
|
||||
]))
|
||||
},
|
||||
async inspect(cwd) {
|
||||
const paths = await readdir(sessionRoot, { recursive: true })
|
||||
const logs = paths.filter(path => /session\.v\d+\.jsonl$/u.test(path))
|
||||
expect(logs).toHaveLength(1)
|
||||
const bytes = await readFile(join(sessionRoot, logs[0]!))
|
||||
const traceDir = process.env.DSH_OFFICE_TRACE_DIR
|
||||
if (traceDir !== undefined) {
|
||||
await mkdir(traceDir, { recursive: true })
|
||||
await writeFile(join(traceDir, `xlsx-${kind}-${mode}.jsonl`), bytes)
|
||||
}
|
||||
events = parseSessionLog(bytes.toString('utf8'))
|
||||
await reportOfficeUsage(events, `${kind}-${mode}`)
|
||||
expect(await readFile(join(cwd, 'input.xlsx'))).toEqual(original)
|
||||
const verified = await execa(python!, ['-c', `
|
||||
from openpyxl import load_workbook
|
||||
@@ -162,19 +146,18 @@ else:
|
||||
assert load_workbook('result.xlsx', data_only=True)['Revenue']['B4'].value == 38
|
||||
`], { cwd })
|
||||
expect(verified.exitCode).toBe(0)
|
||||
const paths = await readdir(join(cwd, '.sessions'), { recursive: true })
|
||||
const logs = paths.filter(path => /session\.v\d+\.jsonl$/u.test(path))
|
||||
expect(logs).toHaveLength(1)
|
||||
events = parseSessionLog(await readFile(join(cwd, '.sessions', logs[0]!), 'utf8'))
|
||||
if (kind === 'blank') {
|
||||
fixtureCalls = (await readFile(join(cwd, '.fixture/calls.jsonl'), 'utf8')).trim().split('\n')
|
||||
if (kind === 'blank' || kind === 'formula') {
|
||||
fixtureCalls = (await readFile(join(controlRoot, '.fixture/calls.jsonl'), 'utf8')).trim().split('\n')
|
||||
.map(line => JSON.parse(line) as { args: string[]; status: number | null })
|
||||
expect(fixtureCalls.some(call => call.args[0] === 'convert')).toBe(false)
|
||||
if (kind === 'blank') expect(fixtureCalls.some(call => call.args[0] === 'convert')).toBe(false)
|
||||
else expect(fixtureCalls.some(call => call.args[0] === 'recalculate' && call.status === 0)).toBe(true)
|
||||
}
|
||||
const imageBlocks = events.flatMap(event => event.type === 'tool/result'
|
||||
? event.data.message.content.filter(block => block.type === 'image') : [])
|
||||
? event.data.message.content.filter(block => block.type === 'image')
|
||||
: event.type === 'user/message' && event.data.source.kind === 'ptc-mode'
|
||||
? event.data.content.filter(block => block.type === 'image') : [])
|
||||
if (kind === 'layout' && imageBlocks.length === 0) {
|
||||
const kit = join(dirname(fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json'))), 'lib/cli.js')
|
||||
const kit = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/cli'))
|
||||
const previewDir = join(cwd, '.verification-preview')
|
||||
await execa(process.execPath, [kit, 'render', '--input', join(cwd, 'result.xlsx'),
|
||||
'--output-dir', previewDir, '--sheet', 'Revenue', '--range', 'A1:B3'], { cwd })
|
||||
@@ -186,36 +169,52 @@ else:
|
||||
}
|
||||
}
|
||||
for (const block of imageBlocks) {
|
||||
const stored = await readImageFile(join(cwd, '.image-store/attachments/v1'), block.attachment)
|
||||
const stored = await readImageFile(join(imageRoot, 'attachments/v1'), block.attachment)
|
||||
const pixels = await execa(python!, ['-c', previewPixels], { input: Buffer.from(stored.data) })
|
||||
previewHasContent ||= pixels.stdout === 'content'
|
||||
}
|
||||
},
|
||||
})
|
||||
const calls = events.flatMap(event => event.type === 'tool/call' ? [event.data] : [])
|
||||
expect(calls.some(call => call.name === 'skill' && call.arguments.includes('office-xlsx'))).toBe(true)
|
||||
expect(calls.some(call => call.name === 'load_workspace_dependencies')).toBe(true)
|
||||
const calls = events.flatMap(event => event.type === 'tool/call' ? [event.data] : event.type === 'tool/ptc-dispatch' ? [{ name: event.data.name, arguments: JSON.stringify(event.data.arguments), callId: event.data.rootCallId }] : [])
|
||||
const loaded = calls.filter(call => call.name === 'skill').map(call => (JSON.parse(call.arguments) as { name: string }).name)
|
||||
const commands = calls.filter(call => call.name === 'bash' || call.name === 'pwsh')
|
||||
.map(call => (JSON.parse(call.arguments) as { command: string }).command).join('\n')
|
||||
expect(commands).toContain('check_office.py')
|
||||
expect(commands).not.toMatch(/\b(?:which|whereis|command -v)[^\n;]*\b(?:libreoffice|soffice)\b/iu)
|
||||
expect(commands).not.toMatch(/\bfind[^\n;]*-(?:i?name|path)\s+["']?[*?]*(?:libreoffice|soffice)\b/iu)
|
||||
if (kind === 'formula') expect(commands).toMatch(/cli\.js[\s\S]*recalculate/u)
|
||||
const answer = events.findLast(event => event.type === 'assistant/message')
|
||||
const diagnostic = JSON.stringify({ loadedSkills: loaded,
|
||||
commands: calls.filter(call => call.name === 'bash' || call.name === 'pwsh')
|
||||
.map(call => (JSON.parse(call.arguments) as { command: string }).command.slice(0, 600)),
|
||||
answer: answer?.type === 'assistant/message'
|
||||
? answer.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n').slice(0, 800) : undefined })
|
||||
expect(loaded, diagnostic).toContain('office-xlsx')
|
||||
expect(calls.some(call => call.name === 'load_workspace_dependencies'), diagnostic).toBe(true)
|
||||
expect(commands, diagnostic).toContain('office-check')
|
||||
if (kind === 'formula') {
|
||||
expect(loaded, diagnostic).toContain('office-recalculate')
|
||||
}
|
||||
if (kind === 'layout' || kind === 'blank') {
|
||||
expect(commands).toMatch(/cli\.js[\s\S]*render/u)
|
||||
expect(calls.some(call => call.name === 'skill' && call.arguments.includes('office-render'))).toBe(true)
|
||||
expect(commands).toMatch(/\bdsoffice["']?\s+render\b/u)
|
||||
expect(commands).toContain('--sheet')
|
||||
expect(commands).toContain('--range')
|
||||
expect(commands).not.toMatch(/cli\.js["']?\s+convert\b/u)
|
||||
expect(commands).not.toMatch(/\bdsoffice["']?\s+convert\b/u)
|
||||
const reads = calls.filter(call => call.name === 'read_image')
|
||||
const results = events.flatMap(event => event.type === 'tool/result' ? [event.data.message] : [])
|
||||
if (previewHasContent) expect(results.some(result => !result.isError && reads.some(call => call.callId === result.toolCallId)
|
||||
&& result.content.some(block => block.type === 'image'))).toBe(true)
|
||||
if (previewHasContent) {
|
||||
expect(reads.length).toBeGreaterThan(0)
|
||||
const visibleImage = mode === 'native'
|
||||
? results.some(result => !result.isError && reads.some(call => call.callId === result.toolCallId)
|
||||
&& result.content.some(block => block.type === 'image'))
|
||||
: events.some(event => event.type === 'user/message' && event.data.source.kind === 'ptc-mode'
|
||||
&& event.data.content.some(block => block.type === 'image'))
|
||||
expect(visibleImage).toBe(true)
|
||||
}
|
||||
if (kind === 'blank') expect(previewHasContent).toBe(false)
|
||||
if (!previewHasContent) {
|
||||
const answer = events.findLast(event => event.type === 'assistant/message')
|
||||
if (answer?.type !== 'assistant/message') throw new Error('Agent did not finish with an answer')
|
||||
const text = answer.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
expect(text).toMatch(/blank|transparent|unavailable|could not|couldn't|unable|failed/iu)
|
||||
expect(text).toMatch(/(?:content|structure)[^.!?]{0,100}pass/iu)
|
||||
expect(text).toMatch(/visual (?:inspection|check)[^.!?]{0,100}(?:not completed|incomplete|could not be completed|unverified)/iu)
|
||||
expect(text).not.toMatch(/visually (?:verified|validated|confirmed)|visual (?:check|inspection) (?:passed|successful)/iu)
|
||||
expect(reads.length).toBeLessThanOrEqual(1)
|
||||
if (kind === 'blank') {
|
||||
@@ -225,8 +224,10 @@ else:
|
||||
}
|
||||
}
|
||||
} else {
|
||||
expect(calls.some(call => call.name === 'read_image' || call.name === 'render_document')).toBe(false)
|
||||
expect(commands).not.toMatch(/\b(?:render|convert)\b|\.pdf\b|\.png\b/iu)
|
||||
expectNoRenderContext(events)
|
||||
expect(calls.some(call => call.name === 'read_image')).toBe(false)
|
||||
expect(calls.some(call => call.name === 'skill' && call.arguments.includes('office-render'))).toBe(false)
|
||||
expect(commands).not.toMatch(/(?<![\w/.-])(?:render|convert)(?![\w/.-])|\.pdf\b|\.png\b/iu)
|
||||
const answer = events.findLast(event => event.type === 'assistant/message')
|
||||
if (answer?.type !== 'assistant/message') throw new Error('Agent did not finish with an answer')
|
||||
const text = answer.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
|
||||
@@ -19,6 +19,18 @@
|
||||
},
|
||||
{
|
||||
"path": "../skill"
|
||||
},
|
||||
{
|
||||
"path": "../../shell/shell-env"
|
||||
},
|
||||
{
|
||||
"path": "../../util/home-paths"
|
||||
},
|
||||
{
|
||||
"path": "../../boot/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../tool-workspace-dependencies"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: 'lib/types/index.js', commands: 'lib/types/commands.js', check: 'lib/types/check.js' },
|
||||
outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, dts: false, clean: false,
|
||||
})
|
||||
@@ -24,8 +24,8 @@
|
||||
en: b8f130e081d14307
|
||||
zh: d227ad3298b20238
|
||||
/deepseek-ai-dsh-skill/use-this-package/what-the-registry-gives-you:
|
||||
en: 0cb7936c76a84e45
|
||||
zh: a1fb8572481af54c
|
||||
en: 4dbf3ca47735b797
|
||||
zh: 21ad23304f22e8b5
|
||||
/deepseek-ai-dsh-skill/use-this-package/observable-success-and-failures:
|
||||
en: b7331b6a9c0d5bb2
|
||||
zh: 3ea0785c3a8c7330
|
||||
|
||||
@@ -52,7 +52,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
- **Embedded skills.** Plugins register an in-memory skill with `ctx.skills.register(...)`; the registry fills in a default invocation policy and the `runtime` provider label. Same-name runtime registrations in one layer are first-wins with a warning.
|
||||
- **Provider registration.** A provider contributes its catalog with `ctx.skills.registerProvider(...)`; registration is synchronous, and the returned disposer removes the provider. `runtime` is a reserved provider name.
|
||||
|
||||
An invocation policy on every skill decides which surfaces may advertise and load it: `modelInvocable` for model-facing tools and catalogs, `userInvocable` for human-facing commands. The registry keeps all four combinations, so one discovery result can serve both surfaces without conflating their catalogs.
|
||||
Invocation permissions decide whether the model or user may load a skill. `modelCatalogVisibility` independently controls model discovery: `listed` advertises the winner, while `unlisted` permits named loading without a catalog entry. Omitted values behave as `listed` without adding metadata to existing provider definitions or summaries. The registry selects scoped and same-name winners before consumers filter visibility; a hidden winner never reveals a lower-priority listed candidate. The permission combinations below apply regardless of visibility.
|
||||
|
||||
| Policy | Model | User |
|
||||
|---|---|---|
|
||||
|
||||
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user