dsh-paoding 0.2.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/README_en.md +95 -0
  4. package/bin/dsh-paoding.mjs +12 -0
  5. package/cordis.patch.yml +6 -0
  6. package/docs/architecture.md +292 -0
  7. package/docs/architecture_en.md +363 -0
  8. package/docs/configuration.md +551 -0
  9. package/docs/configuration_en.md +560 -0
  10. package/docs/images/1.png +0 -0
  11. package/docs/images/2.png +0 -0
  12. package/docs/images/3.png +0 -0
  13. package/docs/images/4.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/installation.md +364 -0
  17. package/docs/installation_en.md +367 -0
  18. package/docs/orchestration.md +254 -0
  19. package/docs/orchestration_en.md +255 -0
  20. package/install.sh +31 -0
  21. package/package.json +29 -0
  22. package/plugins/paoding-config-ui/api-core.mjs +100 -0
  23. package/plugins/paoding-config-ui/index.mjs +327 -0
  24. package/plugins/paoding-config-ui/lib/base.css +615 -0
  25. package/plugins/paoding-config-ui/lib/client.js +1154 -0
  26. package/plugins/paoding-config-ui/package.json +23 -0
  27. package/plugins/paoding-config-ui/runtime-inventory.mjs +212 -0
  28. package/presets/orchestrator/agent.cordis.yml +323 -0
  29. package/presets/orchestrator/preset.yml +6 -0
  30. package/presets/orchestrator/restrict.mjs +191 -0
  31. package/tools/install.mjs +24 -0
  32. package/tools/lib/alloc.mjs +138 -0
  33. package/tools/lib/cli.mjs +522 -0
  34. package/tools/lib/compose.mjs +647 -0
  35. package/tools/lib/config.mjs +297 -0
  36. package/tools/lib/host.mjs +288 -0
  37. package/tools/lib/skills.mjs +146 -0
  38. package/tools/lib/spans.mjs +188 -0
  39. package/tools/lib/state.mjs +328 -0
  40. package/tools/lib/util.mjs +26 -0
  41. package/tools/lib/wizard.mjs +516 -0
  42. package/tools/lib/yaml.mjs +266 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * dsh-paoding 配置后端核心 —— DSH Web 插件 (plugins/paoding-config-ui)
3
+ * 的路由层适配:复用 tools/install.mjs 的检测与生成管线。
4
+ *
5
+ * 提供:检测状态收集(缓存)、状态序列化、生成/安装。零运行时依赖。
6
+ */
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import { collectState, generateAndInstall } from '../../tools/install.mjs'
10
+
11
+ export function resolveDshHome() {
12
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
13
+ }
14
+
15
+ export function resolveConfigFile() {
16
+ return process.env.DSH_PAODING_CONFIG || path.join(resolveDshHome(), 'dsh-paoding.config.yml')
17
+ }
18
+
19
+ // ── state cache ─────────────────────────────────────────────────────────────
20
+
21
+ let stateCache = null
22
+ let stateError = null
23
+
24
+ export async function getState(runtimeFacts = null) {
25
+ // 传入运行时事实(rescan / 首屏首次加载)时总是先带着最新事实重刷一次,
26
+ // 再返回缓存;不传则沿用旧语义(命中缓存直接返回,未命中再刷)。
27
+ if (runtimeFacts !== null) {
28
+ await refreshState(runtimeFacts)
29
+ if (stateError) throw stateError
30
+ return stateCache
31
+ }
32
+ if (stateCache) return stateCache
33
+ await refreshState()
34
+ if (stateError) throw stateError
35
+ return stateCache
36
+ }
37
+
38
+ /** 是否有可用的状态缓存(供路由层决定是否值得带运行时事实重刷)。 */
39
+ export function hasStateCache() {
40
+ return stateCache !== null
41
+ }
42
+
43
+ export async function refreshState(runtimeFacts = null) {
44
+ stateError = null
45
+ try {
46
+ stateCache = await collectState({
47
+ dshHome: resolveDshHome(),
48
+ configFile: resolveConfigFile(),
49
+ cwd: process.env.DSH_PAODING_CWD || process.cwd(),
50
+ // 运行时事实透传给 collectState(不传时默认 null,走纯文件扫描旧逻辑)
51
+ runtimeFacts: runtimeFacts ?? null,
52
+ })
53
+ } catch (err) {
54
+ stateCache = null
55
+ stateError = err
56
+ }
57
+ }
58
+
59
+ /** Serialize the state into a JSON-safe view for the UI. */
60
+ export function serializeState(s) {
61
+ const staticBase = {}
62
+ for (const role of Object.keys(s.staticBase)) staticBase[role] = [...s.staticBase[role]]
63
+
64
+ const blocks = {}
65
+ for (const [role, b] of s.blocks) blocks[role] = [...b.names]
66
+
67
+ const personas = {}
68
+ for (const [role, p] of s.personaBlocks) personas[role] = p.text
69
+
70
+ return {
71
+ dshHome: s.dshHome,
72
+ configFile: s.configFile,
73
+ effectiveProfile: s.effectiveProfile,
74
+ inventory: [...s.inventory].sort(),
75
+ mcpReports: s.mcpReports,
76
+ pluginReports: s.pluginReports,
77
+ scanned: s.scanned,
78
+ skills: s.skills,
79
+ staticBase,
80
+ blocks,
81
+ personas,
82
+ restrictBase: [...s.restrictBase],
83
+ existing: s.existing,
84
+ suggested: s.suggested,
85
+ // 主 persona 尾部追加的默认常量(供 UI「人设追加」默认展示 / 恢复默认)
86
+ mainPersonaExtraDefault: s.mainPersonaExtraDefault ?? null,
87
+ // 运行时识别结果透传(字段缺失视为未启用运行时检测,与旧版 UI 行为一致)
88
+ runtimePlugins: Array.isArray(s.runtimePlugins) ? s.runtimePlugins : [],
89
+ runtimeOk: !!s.runtimeOk,
90
+ runtimeError: s.runtimeError ?? null,
91
+ }
92
+ }
93
+
94
+ /** Compose + (optionally) install from a UI-supplied assignments object. */
95
+ export function installAssignments(state, assignments, { dryRun = false, saveConfigOnWrite = false } = {}) {
96
+ if (!assignments || typeof assignments !== 'object') {
97
+ throw new Error('missing assignments object')
98
+ }
99
+ return generateAndInstall(state, assignments, { dryRun, saveConfigOnWrite })
100
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * paoding-config-ui — DSH Web 插件 (cordis)
3
+ *
4
+ * 把 dsh-paoding 可视化配置器整合进 DSH Web GUI(设置页分区,无独立服务端):
5
+ * - Node 侧:注册 /api/paoding/* 前缀路由(state/models/preview/apply/rescan +
6
+ * client.css 静态样式),经 ./api-core.mjs 复用 tools/install.mjs 的
7
+ * 检测与生成管线。
8
+ * - Client 侧:lib/client.js 将配置面板挂到设置页的 settings.section 槽位。
9
+ *
10
+ * 挂载(~/.dsh/cordis.patch.yml):
11
+ * - id: paoding-config-ui
12
+ * name: paoding-config-ui # 解析自 $DSH_HOME/node_modules(或插件市场安装)
13
+ *
14
+ * 借鉴 dsh-better-sidebar 的机制(详见其 docs/plans 与 AGENTS.md):
15
+ * - 客户端 bundle 以 window.__ModuleLoader__.load({id, factory}) 注册,由
16
+ * dsh-client-modules 节点半扫描 dsh.client 声明并以 /plugins/<id>/client.js
17
+ * 下发;bundle 的注册 id 必须等于包名(图行 id)。
18
+ * - 设置分区经 ctx.slots.inject('settings.section') + slots.register() 挂载,
19
+ * id/order/label 驱动设置壳导航(与 ui-settings-general 的 general 分区同款)。
20
+ * - 宿主路由自带信任围栏:/api/paoding 长于 /api 前缀,webserver 最长前缀
21
+ * 优先会命中本插件而绕过 /api 网关的围栏,故在此复刻同一围栏语义
22
+ * (Host 回环或 webRuntime.trustedHosts,拒绝 cross-site 浏览器请求)。
23
+ */
24
+ import { readFile } from 'node:fs/promises'
25
+ import { getState, refreshState, hasStateCache, serializeState, installAssignments } from './api-core.mjs'
26
+ import { collectRuntimeFacts } from './runtime-inventory.mjs'
27
+
28
+ export const name = 'paoding-config-ui'
29
+ // apiProxy:宿主 API 代理服务——models 路由经 ctx.apiProxy.llm.models({})
30
+ // 取 DSH 已配置/已注册的模型目录(设置页模型选择器同款目录,JSON-safe 纯
31
+ // 对象;失败组在 failures 里,不抛错)。
32
+ export const inject = ['webServer', 'apiProxy']
33
+
34
+ // ── 浏览器信任围栏(复刻 dsh-client-connection 的 /api 网关围栏语义,
35
+ // 参考 dsh-better-sidebar/src/trust-fence.ts,BSD-3-Clause 同源实现)──────
36
+
37
+ function header(headers, name) {
38
+ const value = headers[name]
39
+ return typeof value === 'string' ? value : undefined
40
+ }
41
+
42
+ function parseAuthority(authority) {
43
+ try { return new URL(`http://${authority}`) } catch { return undefined }
44
+ }
45
+
46
+ function isLoopbackHostname(hostname) {
47
+ if (hostname === 'localhost' || hostname === '[::1]') return true
48
+ const parts = hostname.split('.')
49
+ return parts.length === 4 && parts[0] === '127'
50
+ && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
51
+ }
52
+
53
+ /** Host 头是否落在部署信任列表(精确 host:port 或裸 hostname)。 */
54
+ function isTrustedAuthority(hostUrl, trustedHosts) {
55
+ return (trustedHosts ?? []).some((entry) => {
56
+ const entryUrl = parseAuthority(entry)
57
+ if (entryUrl === undefined) return false
58
+ return entryUrl.port === ''
59
+ ? entryUrl.hostname === hostUrl.hostname
60
+ : entryUrl.host === hostUrl.host
61
+ })
62
+ }
63
+
64
+ /** DNS-rebinding / cross-site 防御(非认证):Host 回环或受信 + 非 cross-site。 */
65
+ function isTrustedApiRequest(req, trustedHosts) {
66
+ const host = header(req.headers, 'host')
67
+ if (host === undefined) return false
68
+ const hostUrl = parseAuthority(host)
69
+ if (hostUrl === undefined) return false
70
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
71
+ if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
72
+ const origin = header(req.headers, 'origin')
73
+ if (origin === undefined) return true
74
+ try { return new URL(origin).hostname === hostUrl.hostname } catch { return false }
75
+ }
76
+
77
+ /** Read a JSON request body (bounded). */
78
+ function readBody(req) {
79
+ return new Promise((resolve, reject) => {
80
+ const chunks = []
81
+ let size = 0
82
+ req.on('data', (c) => {
83
+ size += c.length
84
+ if (size > 5 * 1024 * 1024) {
85
+ reject(new Error('body too large'))
86
+ req.destroy()
87
+ return
88
+ }
89
+ chunks.push(c)
90
+ })
91
+ req.on('end', () => {
92
+ try {
93
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {})
94
+ } catch {
95
+ reject(new Error('invalid JSON body'))
96
+ }
97
+ })
98
+ req.on('error', reject)
99
+ })
100
+ }
101
+
102
+ function json(res, status, payload) {
103
+ const text = JSON.stringify(payload, null, 2)
104
+ res.writeHead(status, {
105
+ 'content-type': 'application/json; charset=utf-8',
106
+ 'content-length': Buffer.byteLength(text),
107
+ })
108
+ res.end(text)
109
+ }
110
+
111
+ export function apply(ctx) {
112
+ const { webServer } = ctx
113
+ // webRuntime 由 dsh-web-app 的 web-runtime 行提供;非 web 部署下缺失时退化为
114
+ // 仅回环信任(每次请求读实时值,trustedHosts 变化无需重启插件)。
115
+ const trustedHostsOf = () => ctx.get('webRuntime')?.trustedHosts ?? []
116
+
117
+ const disposer = webServer.register({
118
+ kind: 'prefix',
119
+ path: '/api/paoding',
120
+ handler: async (req, res) => {
121
+ if (!isTrustedApiRequest(req, trustedHostsOf())) {
122
+ json(res, 403, { error: 'forbidden' })
123
+ return
124
+ }
125
+ const url = new URL(req.url, 'http://dsh.internal')
126
+ const route = url.pathname.replace(/^\/api\/paoding\/?/, '')
127
+
128
+ try {
129
+ // ── 静态样式:面板 CSS 正文(lib/base.css)─────────────────────────
130
+ // 客户端 mountCss 只注入 <link href="/api/paoding/client.css">,正文
131
+ // 由此按请求现读——改 base.css 无需重启插件,GUI 刷新即生效(响应带
132
+ // no-cache)。落在同前缀信任围栏内(上方 isTrustedApiRequest 已把关)。
133
+ if (route === 'client.css' && req.method === 'GET') {
134
+ try {
135
+ const css = await readFile(new URL('./lib/base.css', import.meta.url), 'utf8')
136
+ res.writeHead(200, {
137
+ 'content-type': 'text/css; charset=utf-8',
138
+ 'content-length': Buffer.byteLength(css),
139
+ 'cache-control': 'no-cache',
140
+ })
141
+ res.end(css)
142
+ } catch (err) {
143
+ json(res, 500, { error: `client.css unavailable: ${err.message}` })
144
+ }
145
+ return
146
+ }
147
+
148
+ if (route === 'state' && req.method === 'GET') {
149
+ // 仅缓存缺失时带实时运行时事实做一次全量检测(成本同旧版首次 GET),
150
+ // 命中缓存直接返回——运行时注册表只在 DSH 重启/插件启停时变化,
151
+ // 没必要让每次打开面板都重握手 MCP。
152
+ if (!hasStateCache()) {
153
+ const facts = await collectRuntimeFacts(ctx)
154
+ await refreshState(facts)
155
+ }
156
+ const s = await getState()
157
+ json(res, 200, serializeState(s))
158
+ return
159
+ }
160
+
161
+ if (route === 'models' && req.method === 'GET') {
162
+ // 模型目录:DSH 设置页模型选择器同款目录(deepseek-official 三模型、
163
+ // pi-ai 各路由等),JSON-safe 纯对象。与 state 缓存无关,每次实调——
164
+ // 目录发现可能稍慢,属正常。失败组在 failures 里,不抛错;这里自捕
165
+ // 一层,保证错误也走 { ok: false, error } 形状(外层 catch 是 { error })。
166
+ try {
167
+ // apiProxy 的 llm.* 是裸 RPC handler:入参 { rpcId, payload } 信封、
168
+ // 返回 { rpcId, result: { ok, value | error } } 信封(ok()/err() 同源
169
+ // 形状),不是直接吐 { groups, failures }——必须拆一层信封再取 value。
170
+ const envelope = await ctx.apiProxy.llm.models({ rpcId: 'paoding-models', payload: {} })
171
+ const result = envelope && typeof envelope.result === 'object' && envelope.result !== null
172
+ ? envelope.result
173
+ : null
174
+ if (!result || !result.ok) {
175
+ const message = result && result.error && result.error.message ? result.error.message : '模型目录调用失败'
176
+ throw new Error(message)
177
+ }
178
+ const catalog = result.value
179
+ if (!catalog || !Array.isArray(catalog.groups)) throw new Error('模型目录响应缺少 groups')
180
+ json(res, 200, { ok: true, groups: catalog.groups, failures: Array.isArray(catalog.failures) ? catalog.failures : [] })
181
+ } catch (err) {
182
+ json(res, 500, { ok: false, error: err.message })
183
+ }
184
+ return
185
+ }
186
+
187
+ if (route === 'rescan' && req.method === 'POST') {
188
+ // 重新检测 = 采集实时事实 → 带事实重刷状态(getState 不再自己重刷)
189
+ const facts = await collectRuntimeFacts(ctx)
190
+ await refreshState(facts)
191
+ const s = await getState()
192
+ json(res, 200, serializeState(s))
193
+ return
194
+ }
195
+
196
+ // ── TEMP PROBE (generic runtime detection groundwork; remove later) ──
197
+ // 崩溃安全:任何一步失败都记入 errors 而非 500;访问路径分别报告
198
+ // reflect.get / ctx.get / ctx[key] 三种取法的可用性(cordis 的 ctx[key]
199
+ // 受 inject 护栏约束,ctx.reflect.get 是官方无 inject 读取口)。
200
+ if (route === 'probe' && req.method === 'GET') {
201
+ const cap = (arr, n) => (Array.isArray(arr) ? arr.slice(0, n) : arr)
202
+ const methodNamesOf = (svc) => {
203
+ const methods = []
204
+ for (let p = Object.getPrototypeOf(svc); p && p !== Object.prototype && methods.length < 60; p = Object.getPrototypeOf(p)) {
205
+ for (const m of Object.getOwnPropertyNames(p)) {
206
+ if (m !== 'constructor' && typeof svc[m] === 'function' && !methods.includes(m)) methods.push(m)
207
+ }
208
+ }
209
+ return methods
210
+ }
211
+ const probe = { errors: [] }
212
+ const tryReport = (label, fn) => {
213
+ try { probe[label] = fn() } catch (err) { probe[label] = 'error: ' + String(err?.message ?? err) }
214
+ }
215
+
216
+ // 1) 每类服务三种取法的可用性 + 方法表面
217
+ // ⚠️ 切勿把服务实例本身放进结果:ctx 服务是 cordis 代理,JSON
218
+ // stringify 访问其 toJSON 会触发 inject 护栏抛错。只留纯数据。
219
+ const svcNames = ['tools', 'pluginInventory', 'loader', 'commands', 'mnemon', 'codegraph',
220
+ 'agentLoop', 'systemPrompt', 'settings', 'webRuntime', 'webServer', 'llm', 'mcp', 'attachments', 'reflect']
221
+ const services = {}
222
+ for (const key of svcNames) {
223
+ const row = {}
224
+ let found = undefined
225
+ for (const [path, access] of [
226
+ ['reflect', () => ctx.reflect?.get?.(key, false)],
227
+ ['get', () => ctx.get?.(key)],
228
+ ['prop', () => ctx[key]],
229
+ ]) {
230
+ try {
231
+ const v = access()
232
+ if (v !== undefined && v !== null) {
233
+ row[path] = 'ok'
234
+ if (found === undefined) found = v
235
+ } else {
236
+ row[path] = 'missing'
237
+ }
238
+ } catch (err) { row[path] = 'throw: ' + String(err?.message ?? err) }
239
+ }
240
+ if (found !== undefined) row.methods = methodNamesOf(found)
241
+ services[key] = row
242
+ }
243
+ probe.services = services
244
+
245
+ // 2) pluginInventory.list() 直接采样
246
+ tryReport('pluginInventory', () => {
247
+ const pi = ctx.reflect?.get?.('pluginInventory', false) ?? ctx.get?.('pluginInventory')
248
+ if (!pi || typeof pi.list !== 'function') return 'no service / no list()'
249
+ const entries = pi.list()?.entries
250
+ if (!Array.isArray(entries)) return 'list() shape: ' + typeof entries
251
+ return entries.slice(0, 60).map((e) => ({ entryId: e.entryId, moduleName: e.moduleName, enabled: e.enabled, fiberPhase: e.fiberPhase }))
252
+ })
253
+
254
+ // 3) tools 服务的枚举口 + 工具名采样
255
+ tryReport('toolsMethods', () => {
256
+ const t = ctx.reflect?.get?.('tools', false) ?? ctx.get?.('tools')
257
+ return t ? methodNamesOf(t) : 'no service'
258
+ })
259
+ for (const [label, method] of [
260
+ ['toolsSchemas', 'schemas'],
261
+ ['toolsEntries', 'entries'],
262
+ ]) {
263
+ tryReport(label, () => {
264
+ const t = ctx.reflect?.get?.('tools', false) ?? ctx.get?.('tools')
265
+ if (!t || typeof t[method] !== 'function') return 'no ' + method + '()'
266
+ const result = t[method]()
267
+ if (result == null || typeof result[Symbol.iterator] !== 'function') return 'not iterable: ' + typeof result
268
+ const names = []
269
+ for (const item of result) {
270
+ const name = typeof item === 'string' ? item : (item && typeof item === 'object' ? (typeof item.name === 'string' ? item.name : (Array.isArray(item) ? item[0] : undefined)) : undefined)
271
+ if (typeof name === 'string') names.push(name)
272
+ if (names.length >= 120) break
273
+ }
274
+ return { count: names.length, sample: cap(names, 120) }
275
+ })
276
+ }
277
+
278
+ json(res, 200, probe)
279
+ return
280
+ }
281
+
282
+ if ((route === 'preview' || route === 'apply') && req.method === 'POST') {
283
+ const body = await readBody(req)
284
+ const s = await getState()
285
+ const dryRun = route === 'preview'
286
+ try {
287
+ const result = installAssignments(s, body.assignments, {
288
+ dryRun,
289
+ saveConfigOnWrite: !dryRun,
290
+ })
291
+ // apply 落盘后必须重刷状态缓存:stateCache 仍是应用前的快照(existing
292
+ // 还是旧 config 的 roles),浏览器刷新读到的就是它——角色专用模型 /
293
+ // 显示名等会“看起来保存丢失、回落默认”。这里与 rescan 同款带运行时
294
+ // 事实重刷;best-effort:刷新失败不影响 apply 已成功的落盘结果
295
+ // (refreshState 自捕错误置空缓存,下次 GET state 会带事实重建)。
296
+ if (!dryRun && result.wrote) {
297
+ try {
298
+ const facts = await collectRuntimeFacts(ctx)
299
+ await refreshState(facts)
300
+ } catch (err) {
301
+ ctx.logger?.warn?.('paoding-config-ui: post-apply state refresh failed: ' + String(err?.message ?? err))
302
+ }
303
+ }
304
+ json(res, 200, {
305
+ dryRun,
306
+ wrote: result.wrote,
307
+ staleNote: result.staleNote,
308
+ roleResults: result.roleResults,
309
+ text: result.text,
310
+ configFile: s.configFile,
311
+ })
312
+ } catch (err) {
313
+ json(res, 422, { error: err.message })
314
+ }
315
+ return
316
+ }
317
+
318
+ json(res, 404, { error: `no such route: ${req.method} /api/paoding/${route}` })
319
+ } catch (err) {
320
+ json(res, 500, { error: err.message })
321
+ }
322
+ },
323
+ })
324
+
325
+ ctx.logger?.info?.('paoding-config-ui: /api/paoding/* routes registered')
326
+ return disposer
327
+ }