@capyclaw/ppt-workbench 0.0.0-stage → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +126 -3
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/contract/ppt-contract.json +1529 -0
- package/cordis.patch.yml +25 -0
- package/core/audit.js +500 -0
- package/core/chart.js +199 -0
- package/core/color.js +80 -0
- package/core/deck-layout.js +195 -0
- package/core/formula.js +80 -0
- package/core/hfmath/hershey.js +1763 -0
- package/core/hfmath/index.js +1176 -0
- package/core/hfmath/stretch.js +41 -0
- package/core/hfmath/symbols.js +408 -0
- package/core/ops/browser.js +28 -0
- package/core/ops/chart.js +64 -0
- package/core/ops/deck.js +21 -0
- package/core/ops/dispatcher.js +2 -0
- package/core/ops/element.js +55 -0
- package/core/ops/history.js +11 -0
- package/core/ops/import.js +95 -0
- package/core/ops/index.js +39 -0
- package/core/ops/playback.js +9 -0
- package/core/ops/recolor.js +33 -0
- package/core/ops/slide.js +66 -0
- package/core/ops/table.js +85 -0
- package/core/ops/text.js +35 -0
- package/core/ops/view.js +13 -0
- package/core/ops.js +12 -0
- package/core/quick.js +52 -0
- package/core/shape-library.js +122 -0
- package/core/table.js +178 -0
- package/core/text.js +200 -0
- package/editor/app.js +5366 -0
- package/editor/heartbeat-worker.js +116 -0
- package/editor/index.html +89 -0
- package/editor/ppt-full-app.js +376 -0
- package/lib/host.mjs +98 -0
- package/package.json +51 -4
- package/server/audit.mjs +2 -0
- package/server/bridge-client.mjs +174 -0
- package/server/constants.mjs +20 -0
- package/server/contract-snapshots.mjs +62 -0
- package/server/contract.mjs +24 -0
- package/server/deck-layout.mjs +2 -0
- package/server/http-utils.mjs +2 -0
- package/server/infra/http.mjs +113 -0
- package/server/infra/runtime/context.mjs +128 -0
- package/server/infra/store/blob-store.mjs +109 -0
- package/server/infra/tuning.mjs +82 -0
- package/server/infra/util/fs-lock.mjs +132 -0
- package/server/infra/workspace/manager.mjs +657 -0
- package/server/mcp/dispatch.mjs +2 -0
- package/server/mcp/handler.mjs +2 -0
- package/server/mcp/instructions.mjs +40 -0
- package/server/mcp-http.mjs +3 -0
- package/server/ppt-shim.mjs +156 -0
- package/server/relay.mjs +245 -0
- package/server/routes/assets.mjs +2 -0
- package/server/routes/bridge.mjs +47 -0
- package/server/routes/editor.mjs +9 -0
- package/server/routes/import.mjs +258 -0
- package/server/routes/mcp.mjs +17 -0
- package/server/runtime/recovery.mjs +9 -0
- package/server/runtime/watch.mjs +17 -0
- package/server/shared/SHARED_MANIFEST.json +47 -0
- package/server/shared/asset-service.mjs +217 -0
- package/server/shared/editor-host.mjs +125 -0
- package/server/shared/kit/editor/lease.mjs +139 -0
- package/server/shared/kit/service/api.mjs +45 -0
- package/server/shared/kit/service/http.mjs +113 -0
- package/server/shared/kit/service/mcp-http.mjs +142 -0
- package/server/shared/kit/service/single-instance.mjs +72 -0
- package/server/shared/kit/tools/validate.mjs +115 -0
- package/server/shared/kit/tuning.mjs +82 -0
- package/server/shared/kit/util/fs-lock.mjs +132 -0
- package/server/shared/service-host.mjs +511 -0
- package/server/shared/state-service.mjs +249 -0
- package/server/shared/tool-host.mjs +341 -0
- package/server/shared/tuning.mjs +63 -0
- package/server/single-instance.mjs +2 -0
- package/server/store/backends/file-backend.mjs +13 -0
- package/server/store/checkpoints.mjs +28 -0
- package/server/store/doc.mjs +52 -0
- package/server/store/index.mjs +34 -0
- package/server/store/journal.mjs +9 -0
- package/server/store/lease.mjs +2 -0
- package/server/store/sanitize.mjs +32 -0
- package/server/store.mjs +2 -0
- package/server/tools/capabilities/apply.mjs +33 -0
- package/server/tools/capabilities/audit.mjs +24 -0
- package/server/tools/capabilities/chart.mjs +83 -0
- package/server/tools/capabilities/compose.mjs +97 -0
- package/server/tools/capabilities/contract.mjs +34 -0
- package/server/tools/capabilities/deck.mjs +27 -0
- package/server/tools/capabilities/element.mjs +67 -0
- package/server/tools/capabilities/export.mjs +95 -0
- package/server/tools/capabilities/formula.mjs +72 -0
- package/server/tools/capabilities/history.mjs +29 -0
- package/server/tools/capabilities/image.mjs +152 -0
- package/server/tools/capabilities/imagesearch.mjs +152 -0
- package/server/tools/capabilities/import.mjs +180 -0
- package/server/tools/capabilities/index.mjs +58 -0
- package/server/tools/capabilities/line.mjs +58 -0
- package/server/tools/capabilities/notes.mjs +33 -0
- package/server/tools/capabilities/present.mjs +34 -0
- package/server/tools/capabilities/recolor.mjs +60 -0
- package/server/tools/capabilities/screenshot.mjs +86 -0
- package/server/tools/capabilities/shape.mjs +64 -0
- package/server/tools/capabilities/slide.mjs +81 -0
- package/server/tools/capabilities/style.mjs +48 -0
- package/server/tools/capabilities/table.mjs +127 -0
- package/server/tools/capabilities/task.mjs +52 -0
- package/server/tools/capabilities/text.mjs +82 -0
- package/server/tools/domains/color.mjs +4 -0
- package/server/tools/domains/image.mjs +49 -0
- package/server/tools/domains/index.mjs +7 -0
- package/server/tools/domains/pptx-export.mjs +566 -0
- package/server/tools/domains/recolor.mjs +25 -0
- package/server/tools/domains/remote-image.mjs +291 -0
- package/server/tools/domains/slide.mjs +21 -0
- package/server/tools/domains/text-style.mjs +11 -0
- package/server/tools/domains/tooling.mjs +13 -0
- package/server/tools/index.mjs +14 -0
- package/server/tools/kernel/define.mjs +5 -0
- package/server/tools/kernel/ports.mjs +42 -0
- package/server/tools/kernel/registry.mjs +17 -0
- package/server/tools.mjs +18 -0
- package/server/tuning.mjs +86 -0
- package/server/validate.mjs +2 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-state-service —— 插件运行状态标准件。
|
|
3
|
+
*
|
|
4
|
+
* 提供:
|
|
5
|
+
* - FileStateBackend:原子 JSON 状态 + append-only journal(2MB 轮转)+ 命名 checkpoint
|
|
6
|
+
* - PersistentStore:在任意 backend 上的通用状态门面
|
|
7
|
+
* - replayPending:崩溃后重放未完成批次
|
|
8
|
+
*
|
|
9
|
+
* 与 workspace-service 的分工:
|
|
10
|
+
* workspace 管“作品文件”(work manifest / 版本 / 归属);
|
|
11
|
+
* state 管“插件运行状态”(doc / journal / checkpoint / recovery)。
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { createServiceApi } from './kit/service/api.mjs'
|
|
16
|
+
|
|
17
|
+
export class FileStateBackend {
|
|
18
|
+
/* journalMaxBytes 默认 512KB 由 NSGA-II 进化搜索固化(evolution/,2026-11-01):
|
|
19
|
+
* 尾读 p95 4.3→1.9ms、磁盘占用 -33%、追加吞吐 +8%,恢复 0 失败。
|
|
20
|
+
*
|
|
21
|
+
* fsyncEvery 默认 16:状态写是「write tmp → rename」的原子替换,进程崩溃时
|
|
22
|
+
* 页缓存中的数据已对其它进程可见(rename 原子),只有掉电才需要 fsync 保证落盘。
|
|
23
|
+
* 实测每写一次 fsync 使 157KB 状态从 0.67ms 涨到 1.80ms(+1.13ms),而
|
|
24
|
+
* document-workbench 每个编辑批次会写 2 次状态——节流到每 16 次写一次 fsync,
|
|
25
|
+
* 把该链路成本从 ~3.6ms/批降到 ~1.5ms/批;checkpoint 仍强制 fsync(里程碑语义)。
|
|
26
|
+
* 设为 1 恢复「每次写都 fsync」的旧行为;设为 0 则完全不做 fsync。 */
|
|
27
|
+
constructor(file, { initialDoc = {}, journalMaxBytes = 512 * 1024, fsyncEvery = 16 } = {}) {
|
|
28
|
+
if (!file) throw new Error('FileStateBackend 需要 file')
|
|
29
|
+
this.file = file
|
|
30
|
+
this.dir = path.dirname(file)
|
|
31
|
+
this.journalFile = path.join(this.dir, 'journal.jsonl')
|
|
32
|
+
this.checkpointDir = path.join(this.dir, 'checkpoints')
|
|
33
|
+
this.initialDoc = initialDoc
|
|
34
|
+
this.journalMaxBytes = Math.max(64 * 1024, Number(journalMaxBytes) || 512 * 1024)
|
|
35
|
+
this.fsyncEvery = Math.max(0, Number(fsyncEvery) || 0)
|
|
36
|
+
this._writeCount = 0
|
|
37
|
+
/** 最近一次读状态失败的原因;ENOENT 不算错误。 */
|
|
38
|
+
this.lastStateError = null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
readState() {
|
|
42
|
+
let raw
|
|
43
|
+
try {
|
|
44
|
+
raw = fs.readFileSync(this.file, 'utf8')
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.code === 'ENOENT') return null
|
|
47
|
+
this.lastStateError = { code: 'STATE_IO', message: error.message }
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(raw)
|
|
52
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
53
|
+
return { ...structuredClone(this.initialDoc), ...parsed }
|
|
54
|
+
}
|
|
55
|
+
throw new Error('state 根节点不是 object')
|
|
56
|
+
} catch (error) {
|
|
57
|
+
/* 坏状态不能静默当成空库:先隔离原文件,避免下一次保存直接覆盖证据。 */
|
|
58
|
+
this.lastStateError = { code: 'STATE_CORRUPT', message: error.message }
|
|
59
|
+
const quarantine = `${this.file}.corrupt-${Date.now()}`
|
|
60
|
+
try { fs.renameSync(this.file, quarantine) } catch { /* 隔离失败也要保持只读语义 */ }
|
|
61
|
+
console.error(`[state-service] state 损坏,已隔离到 ${quarantine}: ${error.message}`)
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 强制把当前状态文件刷盘(checkpoint/退出等里程碑用;失败不抛)。 */
|
|
67
|
+
flush() {
|
|
68
|
+
try {
|
|
69
|
+
const fd = fs.openSync(this.file, 'r+')
|
|
70
|
+
try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
71
|
+
} catch { /* 文件不存在或文件系统不支持 fsync */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
writeState(doc) {
|
|
75
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true })
|
|
76
|
+
const tmp = `${this.file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
77
|
+
fs.writeFileSync(tmp, JSON.stringify(doc))
|
|
78
|
+
this._writeCount += 1
|
|
79
|
+
/* fsyncEvery=0 → 从不周期刷盘;=1 → 每次刷盘(旧行为);=N → 每 N 次写刷一次。 */
|
|
80
|
+
const due = this.fsyncEvery > 0 && (this.fsyncEvery === 1 || this._writeCount % this.fsyncEvery === 0)
|
|
81
|
+
if (due) {
|
|
82
|
+
try {
|
|
83
|
+
const fd = fs.openSync(tmp, 'r+')
|
|
84
|
+
try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
85
|
+
} catch { /* fsync 在部分文件系统不可用 */ }
|
|
86
|
+
}
|
|
87
|
+
fs.renameSync(tmp, this.file)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
appendJournal(event) {
|
|
91
|
+
try {
|
|
92
|
+
fs.mkdirSync(this.dir, { recursive: true })
|
|
93
|
+
fs.appendFileSync(this.journalFile, JSON.stringify({ ...event, ts: Date.now() / 1000 }) + '\n')
|
|
94
|
+
if (fs.statSync(this.journalFile).size > this.journalMaxBytes) {
|
|
95
|
+
const rotated = this.journalFile + '.1'
|
|
96
|
+
try { fs.rmSync(rotated, { force: true }) } catch { /* 忽略 */ }
|
|
97
|
+
try { fs.renameSync(this.journalFile, rotated) } catch (error) {
|
|
98
|
+
console.error('[state-service] journal 轮转失败(继续追加):', error.message)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch { /* journal 失败不阻塞主状态 */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
journalTail(limit = 8) {
|
|
105
|
+
const read = (file) => {
|
|
106
|
+
try {
|
|
107
|
+
return fs.readFileSync(file, 'utf8').trim().split(/\r?\n/).filter(Boolean)
|
|
108
|
+
} catch { return [] }
|
|
109
|
+
}
|
|
110
|
+
/* 轮转后的 .1 仍是最近历史的一部分,tail 必须跨文件读取。 */
|
|
111
|
+
const lines = [...read(this.journalFile + '.1'), ...read(this.journalFile)]
|
|
112
|
+
return lines.slice(-limit).map((line) => {
|
|
113
|
+
try { return JSON.parse(line) } catch { return { type: 'invalid', raw: line.slice(0, 120) } }
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
writeCheckpoint(name, payload) {
|
|
118
|
+
const safe = checkpointName(name)
|
|
119
|
+
fs.mkdirSync(this.checkpointDir, { recursive: true })
|
|
120
|
+
const file = path.join(this.checkpointDir, safe + '.json')
|
|
121
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
122
|
+
fs.writeFileSync(tmp, JSON.stringify(payload))
|
|
123
|
+
/* checkpoint 是恢复里程碑:无视 fsyncEvery 节流,强制落盘。 */
|
|
124
|
+
try {
|
|
125
|
+
const fd = fs.openSync(tmp, 'r+')
|
|
126
|
+
try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
127
|
+
} catch { /* fsync 在部分文件系统不可用 */ }
|
|
128
|
+
fs.renameSync(tmp, file)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
listCheckpoints() {
|
|
132
|
+
try {
|
|
133
|
+
return fs.readdirSync(this.checkpointDir)
|
|
134
|
+
.filter((name) => name.endsWith('.json'))
|
|
135
|
+
.map((name) => {
|
|
136
|
+
try {
|
|
137
|
+
const payload = JSON.parse(fs.readFileSync(path.join(this.checkpointDir, name), 'utf8'))
|
|
138
|
+
return {
|
|
139
|
+
name: payload.name,
|
|
140
|
+
seq: payload.seq,
|
|
141
|
+
appliedSeq: payload.appliedSeq,
|
|
142
|
+
pending: !!payload.pending,
|
|
143
|
+
title: payload.title,
|
|
144
|
+
slides: payload.slides,
|
|
145
|
+
createdAt: payload.createdAt,
|
|
146
|
+
meta: payload.meta || {},
|
|
147
|
+
}
|
|
148
|
+
} catch { return null }
|
|
149
|
+
})
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
152
|
+
} catch { return [] }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
readCheckpoint(name) {
|
|
156
|
+
return JSON.parse(fs.readFileSync(path.join(this.checkpointDir, checkpointName(name) + '.json'), 'utf8'))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function checkpointName(name) {
|
|
161
|
+
const safe = String(name || '')
|
|
162
|
+
if (!/^[a-zA-Z0-9._-]{1,64}$/.test(safe)) throw new Error('checkpoint 名称必须是 1-64 位字母/数字/._-')
|
|
163
|
+
return safe
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 通用状态门面:业务 store 可以继承它,只写领域方法。 */
|
|
167
|
+
export class PersistentStore {
|
|
168
|
+
constructor({ backend, initialDoc = {} }) {
|
|
169
|
+
if (!backend) throw new Error('PersistentStore 需要 backend')
|
|
170
|
+
this.backend = backend
|
|
171
|
+
this.doc = backend.readState() || { ...structuredClone(initialDoc) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get() { return this.doc }
|
|
175
|
+
|
|
176
|
+
save() {
|
|
177
|
+
this.backend.writeState(this.doc)
|
|
178
|
+
return this.doc
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
journal(event) {
|
|
182
|
+
this.backend.appendJournal(event)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
journalTail(limit = 8) {
|
|
186
|
+
return this.backend.journalTail(limit)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
checkpoint(name, payload, meta = {}) {
|
|
190
|
+
const safe = checkpointName(name)
|
|
191
|
+
const body = {
|
|
192
|
+
name: safe,
|
|
193
|
+
...payload,
|
|
194
|
+
meta: meta && typeof meta === 'object' ? meta : {},
|
|
195
|
+
createdAt: new Date().toISOString(),
|
|
196
|
+
}
|
|
197
|
+
this.backend.writeCheckpoint(safe, body)
|
|
198
|
+
this.journal({ type: 'checkpoint', name: safe })
|
|
199
|
+
return body
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
listCheckpoints() {
|
|
203
|
+
return this.backend.listCheckpoints()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
readCheckpoint(name) {
|
|
207
|
+
return this.backend.readCheckpoint(name)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 崩溃恢复:把未完成批次交给 apply 重放,然后回执。
|
|
213
|
+
* @param {{ store: object, pending: () => any, apply: (cmd:any) => Promise<void>|void }} options
|
|
214
|
+
*/
|
|
215
|
+
export async function replayPending({ store, pending, apply }) {
|
|
216
|
+
const item = pending()
|
|
217
|
+
if (!item) return { recovered: 0, seq: 0, error: '' }
|
|
218
|
+
let recovered = 0
|
|
219
|
+
let error = ''
|
|
220
|
+
for (const cmd of item.cmds || []) {
|
|
221
|
+
try {
|
|
222
|
+
await apply(cmd)
|
|
223
|
+
recovered++
|
|
224
|
+
} catch (e) {
|
|
225
|
+
error = String((e && e.message) || e)
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
store.applyServer(item.seq, recovered, error)
|
|
230
|
+
return { seq: item.seq, recovered, error }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function createFileBackend(file, options = {}) {
|
|
234
|
+
return new FileStateBackend(file, options)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function createStateHost(config = {}) {
|
|
238
|
+
return {
|
|
239
|
+
api: createServiceApi('state', '1.0.0', ['file-backend', 'journal', 'checkpoint', 'recovery']),
|
|
240
|
+
version: '0.1.0',
|
|
241
|
+
FileStateBackend,
|
|
242
|
+
PersistentStore,
|
|
243
|
+
createFileBackend,
|
|
244
|
+
createStore({ backend, initialDoc = config.initialDoc || {} }) {
|
|
245
|
+
return new PersistentStore({ backend, initialDoc })
|
|
246
|
+
},
|
|
247
|
+
replayPending,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-tool-host —— 工具面标准件。
|
|
3
|
+
*
|
|
4
|
+
* 把“能力声明 → 注册表 → 工具 schema/实现 → 契约报告 → MCP 协议壳”收成一套:
|
|
5
|
+
* defineTool / defineCapability / createRegistry / createMcpHandler /
|
|
6
|
+
* createCapabilityReport / buildInstructions
|
|
7
|
+
*
|
|
8
|
+
* 功能插件只写业务能力,不再复制 kernel/registry/dispatch/validate/contract。
|
|
9
|
+
*/
|
|
10
|
+
import { createMcpHandler as createKitMcpHandler } from './kit/service/mcp-http.mjs'
|
|
11
|
+
import { validateToolArguments } from './kit/tools/validate.mjs'
|
|
12
|
+
import { createServiceApi } from './kit/service/api.mjs'
|
|
13
|
+
|
|
14
|
+
const KINDS = new Set(['read', 'write', 'control'])
|
|
15
|
+
|
|
16
|
+
export function defineTool({
|
|
17
|
+
name,
|
|
18
|
+
description = '',
|
|
19
|
+
inputSchema = { type: 'object', properties: {}, additionalProperties: false },
|
|
20
|
+
kind = 'write',
|
|
21
|
+
since = '1.0.0',
|
|
22
|
+
handler,
|
|
23
|
+
} = {}) {
|
|
24
|
+
if (!name || typeof name !== 'string') throw new Error('defineTool 需要 name')
|
|
25
|
+
if (typeof handler !== 'function') throw new Error(`工具 ${name} 需要 handler`)
|
|
26
|
+
if (!KINDS.has(kind)) throw new Error(`工具 ${name} 的 kind 非法: ${kind}`)
|
|
27
|
+
return Object.freeze({ name, description, inputSchema, kind, since, handler })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defineCapability({
|
|
31
|
+
id,
|
|
32
|
+
title,
|
|
33
|
+
description = '',
|
|
34
|
+
tags = [],
|
|
35
|
+
requires = [],
|
|
36
|
+
dependsOn = [],
|
|
37
|
+
provides = {},
|
|
38
|
+
features = [],
|
|
39
|
+
tools = [],
|
|
40
|
+
} = {}) {
|
|
41
|
+
if (!id || typeof id !== 'string') throw new Error('defineCapability 需要 id')
|
|
42
|
+
if (!Array.isArray(tools) || !tools.length) throw new Error(`能力 ${id} 至少需要一个工具`)
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
id,
|
|
45
|
+
title: title || id,
|
|
46
|
+
description,
|
|
47
|
+
tags: Object.freeze([...tags]),
|
|
48
|
+
requires: Object.freeze([...requires]),
|
|
49
|
+
dependsOn: Object.freeze([...dependsOn]),
|
|
50
|
+
provides: Object.freeze({ ...provides }),
|
|
51
|
+
features: Object.freeze([...features]),
|
|
52
|
+
tools: Object.freeze([...tools]),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateCapabilities(capabilities) {
|
|
57
|
+
const ids = new Set()
|
|
58
|
+
const toolNames = new Set()
|
|
59
|
+
for (const cap of capabilities) {
|
|
60
|
+
if (ids.has(cap.id)) throw new Error(`重复的 capability id: ${cap.id}`)
|
|
61
|
+
ids.add(cap.id)
|
|
62
|
+
for (const tool of cap.tools) {
|
|
63
|
+
if (toolNames.has(tool.name)) throw new Error(`重复的工具名: ${tool.name}`)
|
|
64
|
+
toolNames.add(tool.name)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const cap of capabilities) {
|
|
68
|
+
for (const dep of cap.dependsOn) {
|
|
69
|
+
if (!ids.has(dep)) throw new Error(`能力 ${cap.id} 依赖不存在的能力: ${dep}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function selectCapabilities(capabilities, enabled) {
|
|
75
|
+
if (!enabled || !enabled.length) return capabilities
|
|
76
|
+
const byId = new Map(capabilities.map((cap) => [cap.id, cap]))
|
|
77
|
+
for (const id of enabled) {
|
|
78
|
+
if (!byId.has(id)) throw new Error(`enabled 里有未知能力: ${id}`)
|
|
79
|
+
}
|
|
80
|
+
const ids = new Set(enabled)
|
|
81
|
+
let changed = true
|
|
82
|
+
while (changed) {
|
|
83
|
+
changed = false
|
|
84
|
+
for (const cap of capabilities) {
|
|
85
|
+
if (!ids.has(cap.id)) continue
|
|
86
|
+
for (const dep of cap.dependsOn) {
|
|
87
|
+
if (!ids.has(dep)) { ids.add(dep); changed = true }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return capabilities.filter((cap) => ids.has(cap.id))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {Array} capabilities
|
|
96
|
+
* @param {{ enabled?: string[], ports?: object, report?: Function, reserved?: string[] }} options
|
|
97
|
+
*/
|
|
98
|
+
export function createRegistry(capabilities, { enabled = null, ports = null, report = null, reserved = [] } = {}) {
|
|
99
|
+
validateCapabilities(capabilities)
|
|
100
|
+
const selected = selectCapabilities(capabilities, enabled)
|
|
101
|
+
|
|
102
|
+
const kernelPorts = { ...(ports || {}) }
|
|
103
|
+
const reservedNames = new Set([...(reserved || []), 'contract'])
|
|
104
|
+
const providers = new Map()
|
|
105
|
+
for (const cap of selected) {
|
|
106
|
+
for (const [ns, api] of Object.entries(cap.provides || {})) {
|
|
107
|
+
if (reservedNames.has(ns)) throw new Error(`能力 ${cap.id} 不能覆盖内核端口: ${ns}`)
|
|
108
|
+
if (providers.has(ns)) throw new Error(`服务命名空间重复: ${ns}(${providers.get(ns).cap.id} / ${cap.id})`)
|
|
109
|
+
providers.set(ns, { cap, api })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const available = new Set([...Object.keys(kernelPorts), ...reservedNames, ...providers.keys()])
|
|
114
|
+
for (const cap of selected) {
|
|
115
|
+
for (const req of cap.requires) {
|
|
116
|
+
if (!available.has(req)) throw new Error(`能力 ${cap.id} 需要未提供的端口/服务: ${req}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ctx = { ...kernelPorts }
|
|
121
|
+
for (const [ns, { api }] of providers) {
|
|
122
|
+
ctx[ns] = {}
|
|
123
|
+
for (const [method, fn] of Object.entries(api)) {
|
|
124
|
+
if (typeof fn !== 'function') throw new Error(`服务 ${ns}.${method} 必须是函数`)
|
|
125
|
+
ctx[ns][method] = (...args) => fn(ctx, ...args)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const TOOLS = []
|
|
130
|
+
const TOOL_IMPL = {}
|
|
131
|
+
const TOOL_META = {}
|
|
132
|
+
const FEATURES = []
|
|
133
|
+
const CAPABILITIES = []
|
|
134
|
+
|
|
135
|
+
for (const cap of selected) {
|
|
136
|
+
for (const feature of cap.features) {
|
|
137
|
+
if (!FEATURES.includes(feature)) FEATURES.push(feature)
|
|
138
|
+
}
|
|
139
|
+
CAPABILITIES.push({
|
|
140
|
+
id: cap.id,
|
|
141
|
+
title: cap.title,
|
|
142
|
+
description: cap.description,
|
|
143
|
+
tags: [...cap.tags],
|
|
144
|
+
tools: cap.tools.map((tool) => tool.name),
|
|
145
|
+
})
|
|
146
|
+
for (const tool of cap.tools) {
|
|
147
|
+
TOOLS.push({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })
|
|
148
|
+
TOOL_META[tool.name] = { kind: tool.kind, since: tool.since, capability: cap.id }
|
|
149
|
+
TOOL_IMPL[tool.name] = (args) => tool.handler(args || {}, ctx)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const reportFn = report || createCapabilityReport
|
|
154
|
+
ctx.contract = {
|
|
155
|
+
report: (editorUrl = '', runtime = {}) => reportFn({
|
|
156
|
+
tools: TOOLS,
|
|
157
|
+
toolMeta: TOOL_META,
|
|
158
|
+
features: FEATURES,
|
|
159
|
+
capabilities: CAPABILITIES,
|
|
160
|
+
editorUrl,
|
|
161
|
+
runtime,
|
|
162
|
+
}),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { TOOLS, TOOL_IMPL, TOOL_META, FEATURES, CAPABILITIES, ctx }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 与 MCP 工具面解耦:调用前统一校验。 */
|
|
169
|
+
export function createToolDispatcher({ tools, callTool }) {
|
|
170
|
+
const toolMap = new Map((tools || []).map((tool) => [tool.name, tool]))
|
|
171
|
+
return {
|
|
172
|
+
listTools() {
|
|
173
|
+
return { tools }
|
|
174
|
+
},
|
|
175
|
+
async callTool(name, args = {}) {
|
|
176
|
+
const tool = toolMap.get(name)
|
|
177
|
+
if (!tool) {
|
|
178
|
+
return { content: [{ type: 'text', text: `未知工具: ${String(name)}` }], isError: true }
|
|
179
|
+
}
|
|
180
|
+
const check = validateToolArguments(tool, args)
|
|
181
|
+
if (!check.ok) {
|
|
182
|
+
return { content: [{ type: 'text', text: '参数不合法:' + check.errors.join(';') }], isError: true }
|
|
183
|
+
}
|
|
184
|
+
const out = await callTool(name, args)
|
|
185
|
+
if (out && out.content) return out
|
|
186
|
+
return { content: [{ type: 'text', text: out?.text || 'ok' }] }
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* MCP Streamable HTTP 处理器。registry 可直接传 createRegistry 的返回值。
|
|
193
|
+
* 兼容两种调用:
|
|
194
|
+
* { registry } 或 { tools, toolImpl }
|
|
195
|
+
* { tools, callTool }(旧 relay 形态)
|
|
196
|
+
* @param {{ registry?: object, tools?: Array, toolImpl?: object, callTool?: Function, serverInfo?: object, instructions?: string }} options
|
|
197
|
+
*/
|
|
198
|
+
export function createMcpHandler({ registry, tools, toolImpl, callTool, serverInfo, instructions } = {}) {
|
|
199
|
+
const list = tools || registry?.TOOLS || []
|
|
200
|
+
const impl = toolImpl || registry?.TOOL_IMPL || null
|
|
201
|
+
if (!Array.isArray(list) || !list.length) throw new Error('createMcpHandler: 没有工具可暴露')
|
|
202
|
+
if (!impl && typeof callTool !== 'function') throw new Error('createMcpHandler: 需要 toolImpl 或 callTool')
|
|
203
|
+
return createKitMcpHandler({
|
|
204
|
+
tools: list,
|
|
205
|
+
callTool: (name, args, context) => {
|
|
206
|
+
if (typeof callTool === 'function' && !impl) return callTool(name, args, context)
|
|
207
|
+
const fn = impl[name]
|
|
208
|
+
if (!fn) throw new Error(`未知工具: ${name}`)
|
|
209
|
+
return fn(args, context)
|
|
210
|
+
},
|
|
211
|
+
serverInfo: serverInfo || { name: 'dsh-tool-host', version: '0.1.0' },
|
|
212
|
+
instructions,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function createCapabilityReport({
|
|
217
|
+
tools = [],
|
|
218
|
+
toolMeta = {},
|
|
219
|
+
features = [],
|
|
220
|
+
capabilities = [],
|
|
221
|
+
contractVersion = '1.0.0',
|
|
222
|
+
packageVersion = '0.0.0',
|
|
223
|
+
editorUrl = '',
|
|
224
|
+
runtime = {},
|
|
225
|
+
} = {}) {
|
|
226
|
+
return {
|
|
227
|
+
contractVersion,
|
|
228
|
+
packageVersion,
|
|
229
|
+
editorUrl,
|
|
230
|
+
...runtime,
|
|
231
|
+
toolCount: tools.length,
|
|
232
|
+
tools: tools.map((tool) => ({
|
|
233
|
+
name: tool.name,
|
|
234
|
+
kind: toolMeta[tool.name]?.kind ?? 'write',
|
|
235
|
+
since: toolMeta[tool.name]?.since ?? '1.0.0',
|
|
236
|
+
capability: toolMeta[tool.name]?.capability ?? '',
|
|
237
|
+
})),
|
|
238
|
+
features,
|
|
239
|
+
capabilities: capabilities.map((cap) => ({
|
|
240
|
+
id: cap.id,
|
|
241
|
+
title: cap.title,
|
|
242
|
+
description: cap.description || '',
|
|
243
|
+
tags: cap.tags || [],
|
|
244
|
+
tools: cap.tools || [],
|
|
245
|
+
})),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 生成 MCP server instructions(≤32KB)。业务只提供数据,不再手写排版。
|
|
251
|
+
*/
|
|
252
|
+
export function buildInstructions({
|
|
253
|
+
title = '',
|
|
254
|
+
intro = '',
|
|
255
|
+
startup = [],
|
|
256
|
+
capabilities = [],
|
|
257
|
+
tools = [],
|
|
258
|
+
rules = [],
|
|
259
|
+
output = [],
|
|
260
|
+
} = {}) {
|
|
261
|
+
const rows = capabilities.length
|
|
262
|
+
? capabilities.map((cap) => `| ${cap.title} | ${cap.tools.map((name) => `\`${name}\``).join(', ')} | ${cap.description || ''} |`)
|
|
263
|
+
: [{
|
|
264
|
+
title: '工具',
|
|
265
|
+
tools: tools.map((tool) => tool.name),
|
|
266
|
+
description: '',
|
|
267
|
+
}].map((cap) => `| ${cap.title} | ${cap.tools.map((name) => `\`${name}\``).join(', ')} | |`)
|
|
268
|
+
|
|
269
|
+
const lines = []
|
|
270
|
+
if (title) lines.push(`# ${title}`, '')
|
|
271
|
+
if (intro) lines.push(intro, '')
|
|
272
|
+
if (startup.length) {
|
|
273
|
+
lines.push('## 启动检查')
|
|
274
|
+
startup.forEach((line, index) => lines.push(`${index + 1}. ${line}`))
|
|
275
|
+
lines.push('')
|
|
276
|
+
}
|
|
277
|
+
lines.push('## 能力面', '| 能力 | 工具 | 用途 |', '|---|---|---|', ...rows, '')
|
|
278
|
+
if (rules.length) {
|
|
279
|
+
lines.push('## 硬规则')
|
|
280
|
+
rules.forEach((line) => lines.push(`- ${line}`))
|
|
281
|
+
lines.push('')
|
|
282
|
+
}
|
|
283
|
+
if (output.length) {
|
|
284
|
+
lines.push('## 输出')
|
|
285
|
+
output.forEach((line) => lines.push(`- ${line}`))
|
|
286
|
+
}
|
|
287
|
+
return lines.join('\n')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 把能力注册表直接注册到 DSH 的 ctx.tools(同进程原生工具面)。
|
|
292
|
+
* 注册后不再需要单独的 loopback /mcp 端口和 @deepseek-ai/dsh-mcp-client row。
|
|
293
|
+
* @param {object} ctx DSH cordis context
|
|
294
|
+
* @param {object} registry createRegistry() 的返回值
|
|
295
|
+
* @param {{ namespace?: string }} options
|
|
296
|
+
* @returns {() => void} 反注册全部工具
|
|
297
|
+
*/
|
|
298
|
+
export function registerWithDsh(ctx, registry, { namespace = '' } = {}) {
|
|
299
|
+
if (!ctx?.tools?.register) throw new Error('registerWithDsh: 当前 DSH 没有 ctx.tools.register')
|
|
300
|
+
const disposers = []
|
|
301
|
+
for (const tool of registry?.TOOLS || []) {
|
|
302
|
+
const impl = registry.TOOL_IMPL?.[tool.name]
|
|
303
|
+
if (typeof impl !== 'function') throw new Error(`registerWithDsh: 工具缺少实现 ${tool.name}`)
|
|
304
|
+
const name = namespace ? `${namespace}_${tool.name}` : tool.name
|
|
305
|
+
disposers.push(ctx.tools.register({
|
|
306
|
+
name,
|
|
307
|
+
description: tool.description || '',
|
|
308
|
+
parameters: tool.inputSchema || { type: 'object', properties: {}, additionalProperties: false },
|
|
309
|
+
output: {
|
|
310
|
+
schema: { type: 'object', additionalProperties: true },
|
|
311
|
+
render: (args, value) => (value && Array.isArray(value.content)
|
|
312
|
+
? value.content
|
|
313
|
+
: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }]),
|
|
314
|
+
},
|
|
315
|
+
execute: (args) => impl(args),
|
|
316
|
+
}))
|
|
317
|
+
}
|
|
318
|
+
return () => {
|
|
319
|
+
for (const dispose of disposers) {
|
|
320
|
+
try { dispose() } catch { /* 忽略重复释放 */ }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function createToolHost(config = {}) {
|
|
326
|
+
return {
|
|
327
|
+
api: createServiceApi('toolHost', '1.0.0', ['dsl', 'registry', 'validate', 'contract', 'mcp', 'instructions', 'native-tools']),
|
|
328
|
+
version: '0.1.0',
|
|
329
|
+
defineTool,
|
|
330
|
+
defineCapability,
|
|
331
|
+
createRegistry: (capabilities, options = {}) => createRegistry(capabilities, { ...config, ...options }),
|
|
332
|
+
createMcpHandler,
|
|
333
|
+
createToolDispatcher,
|
|
334
|
+
createCapabilityReport,
|
|
335
|
+
buildInstructions,
|
|
336
|
+
registerWithDsh,
|
|
337
|
+
validateToolArguments,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export { validateToolArguments }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-service-host 运行时调优层。
|
|
3
|
+
*
|
|
4
|
+
* 默认值与进化搜索(evolution/ NSGA-II)的 founder 基线保持一致;
|
|
5
|
+
* 生产环境可用 DSH_TUNING_JSON 覆盖(按包名分组),测试/评估用 setTuning 注入。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
/* 端口被占时向后顺延扫描的次数上限(非 strictPort) */
|
|
10
|
+
portScanLimit: 50,
|
|
11
|
+
/* 复用既有实例的 /healthz 探测超时
|
|
12
|
+
* 200ms 由进化搜索固化(evolution/,2026-11-01):黑洞端口启动阻塞 425→227ms;
|
|
13
|
+
* 健康实例 RTT 1-3ms,仍有 60 倍以上余量。 */
|
|
14
|
+
reuseProbeTimeoutMs: 200,
|
|
15
|
+
/* 单实例锁:空/坏锁视为残留前的宽限 */
|
|
16
|
+
lockFreshGraceMs: 2000,
|
|
17
|
+
/* 优雅关闭:先等 N ms 再断开全部连接 */
|
|
18
|
+
closeGraceMs: 50,
|
|
19
|
+
/* 优雅关闭:强制完成的兜底时限 */
|
|
20
|
+
closeForceMs: 1000,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let overrides = {}
|
|
24
|
+
let envLoaded = false
|
|
25
|
+
|
|
26
|
+
function loadEnv() {
|
|
27
|
+
if (envLoaded) return
|
|
28
|
+
envLoaded = true
|
|
29
|
+
try {
|
|
30
|
+
const raw = process.env.DSH_TUNING_JSON
|
|
31
|
+
if (!raw) return
|
|
32
|
+
const parsed = JSON.parse(raw)
|
|
33
|
+
if (!parsed || typeof parsed !== 'object') return
|
|
34
|
+
const own = parsed['dsh-service-host'] && typeof parsed['dsh-service-host'] === 'object'
|
|
35
|
+
? parsed['dsh-service-host']
|
|
36
|
+
: parsed
|
|
37
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
38
|
+
if (own[key] !== undefined) overrides[key] = own[key]
|
|
39
|
+
}
|
|
40
|
+
} catch { /* 非法 JSON 静默忽略:调优失败不能阻塞启动 */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 当前生效参数(默认值 + env + setTuning 覆盖)。 */
|
|
44
|
+
export function tuning() {
|
|
45
|
+
loadEnv()
|
|
46
|
+
return { ...DEFAULTS, ...overrides }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 测试/评估注入。只接受已知键,避免拼写错误静默生效。 */
|
|
50
|
+
export function setTuning(patch = {}) {
|
|
51
|
+
loadEnv()
|
|
52
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
53
|
+
if (key in DEFAULTS) overrides[key] = value
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 恢复到默认值 + env 覆盖。 */
|
|
58
|
+
export function resetTuning() {
|
|
59
|
+
overrides = {}
|
|
60
|
+
envLoaded = false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const TUNING_DEFAULTS = { ...DEFAULTS }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** 文件后端:通用实现来自 dsh-state-service;保留 PPT 的初始文档。 */
|
|
2
|
+
import { FileStateBackend } from '../../shared/state-service.mjs'
|
|
3
|
+
import { getTuning } from '../../tuning.mjs'
|
|
4
|
+
import { EMPTY_DOC } from '../doc.mjs'
|
|
5
|
+
|
|
6
|
+
export class FileBridgeBackend extends FileStateBackend {
|
|
7
|
+
constructor(file) {
|
|
8
|
+
super(file, {
|
|
9
|
+
initialDoc: EMPTY_DOC(),
|
|
10
|
+
journalMaxBytes: getTuning().journalMaxBytes,
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** 兼容层:checkpoint 名称与载荷沿用 PPT 字段,底层由 DocumentSession 写入。 */
|
|
2
|
+
export { checkpointName } from 'dsh-document-workbench/session'
|
|
3
|
+
|
|
4
|
+
export function buildCheckpoint(doc, name, meta = {}) {
|
|
5
|
+
const pending = (doc.seq | 0) > (doc.applied_seq | 0)
|
|
6
|
+
const deck = doc.deck || { title: '未命名', slides: [] }
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
seq: doc.seq | 0,
|
|
10
|
+
appliedSeq: doc.applied_seq | 0,
|
|
11
|
+
pending,
|
|
12
|
+
title: doc.title || deck.title || '未命名',
|
|
13
|
+
slides: Array.isArray(deck.slides) ? deck.slides.length : 0,
|
|
14
|
+
deck,
|
|
15
|
+
meta: meta && typeof meta === 'object' ? meta : {},
|
|
16
|
+
createdAt: new Date().toISOString(),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function listCheckpoints(backend) {
|
|
21
|
+
return backend.listCheckpoints()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readCheckpoint(backend, name) {
|
|
25
|
+
const payload = backend.readCheckpoint(name)
|
|
26
|
+
if (!payload || !payload.deck) throw new Error('checkpoint 内容损坏: ' + name)
|
|
27
|
+
return payload
|
|
28
|
+
}
|