@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5

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 (147) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,121 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // v3.0.8 MCP stdio server: JSON-RPC 2.0, 8 default tools + opId polling.
3
+ import path from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { TOOLS, OpStore, listTools } from './tools.mjs'
6
+
7
+ const META_TOOLS = [
8
+ { name: 'get_op', description: '轮询长任务 opId(不假取消)', inputSchema: { type: 'object', properties: { opId: { type: 'string' } }, required: ['opId'] } },
9
+ { name: 'abandon_op', description: '放弃轮询;不宣称已停止服务端任务', inputSchema: { type: 'object', properties: { opId: { type: 'string' } }, required: ['opId'] } },
10
+ ]
11
+
12
+ export function createHandlers({ callTool, clock = Date.now } = {}) {
13
+ const ops = new OpStore({ clock })
14
+ const defs = [...listTools(), ...META_TOOLS]
15
+ const names = new Set(defs.map((tool) => tool.name))
16
+ const call = async (name, args = {}) => {
17
+ if (name === 'get_op') return ops.get(args.opId)
18
+ if (name === 'abandon_op') return ops.abandon(args.opId)
19
+ if (!names.has(name)) throw new Error('tool-not-found: ' + name)
20
+ const spec = TOOLS.find((tool) => tool.name === name)
21
+ if (spec?.kind === 'op') return ops.start(name, args, () => callTool(name, args))
22
+ return callTool(name, args)
23
+ }
24
+ return { defs, call }
25
+ }
26
+
27
+ export function createServer({ callTool } = {}) {
28
+ const handlers = createHandlers({ callTool })
29
+ return async function handleRequest(request) {
30
+ const { id = null, method, params } = request || {}
31
+ const reply = (result) => ({ jsonrpc: '2.0', id, result })
32
+ const fail = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } })
33
+ try {
34
+ if (method === 'initialize') {
35
+ return reply({
36
+ protocolVersion: '2024-11-05',
37
+ capabilities: { tools: {} },
38
+ serverInfo: { name: 'dsh-fang', version: '3.0.8' },
39
+ })
40
+ }
41
+ if (method === 'ping') return reply({})
42
+ if (method === 'tools/list') {
43
+ return reply({ tools: handlers.defs.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) })
44
+ }
45
+ if (method === 'tools/call') {
46
+ const name = String(params?.name || '')
47
+ const result = await handlers.call(name, params?.arguments || {})
48
+ return reply({ content: [{ type: 'text', text: JSON.stringify(result) }], isError: Boolean(result && result.ok === false) })
49
+ }
50
+ return fail(-32601, 'method not found: ' + method)
51
+ } catch (error) {
52
+ return fail(-32603, String(error?.message ?? error))
53
+ }
54
+ }
55
+ }
56
+
57
+ const SENSITIVE_KEYS = new Set(['token', 'authtoken', 'datadir', 'clipath', 'template', 'password', 'secret', 'cookie'])
58
+
59
+ function sanitize(value) {
60
+ if (Array.isArray(value)) return value.map(sanitize)
61
+ if (!value || typeof value !== 'object') return value
62
+ const out = {}
63
+ for (const [key, child] of Object.entries(value)) {
64
+ if (SENSITIVE_KEYS.has(String(key).toLowerCase())) continue
65
+ out[key] = sanitize(child)
66
+ }
67
+ return out
68
+ }
69
+
70
+ export function createHttpCallTool({ baseUrl, token, fetchImpl = fetch }) {
71
+ const base = String(baseUrl || '').replace(/\/+$/, '')
72
+ const route = {
73
+ status: ['GET', '/fang/api/status'],
74
+ tasks: ['GET', '/fang/api/tasks'],
75
+ task_status: ['GET', '/fang/api/tasks/status'],
76
+ essence_list: ['GET', '/fang/api/essence/list'],
77
+ upgrade_status: ['GET', '/fang/api/upgrade/status'],
78
+ compat: ['GET', '/fang/api/compat'],
79
+ derive: ['POST', '/fang/api/derive'],
80
+ archive: ['POST', '/fang/api/tasks/archive'],
81
+ }
82
+ return async function callTool(name, args = {}) {
83
+ const entry = route[name]
84
+ if (!entry) throw new Error('tool-not-configured: ' + name)
85
+ const [method, routePath] = entry
86
+ let url = base + routePath
87
+ if (name === 'task_status') url += '?taskId=' + encodeURIComponent(String(args.taskId || ''))
88
+ const headers = { accept: 'application/json' }
89
+ if (token) headers['x-fang-auth'] = token
90
+ const init = { method, headers }
91
+ if (method === 'POST') {
92
+ headers['content-type'] = 'application/json'
93
+ init.body = JSON.stringify(args)
94
+ }
95
+ const response = await fetchImpl(url, init)
96
+ const body = await response.json().catch(() => ({}))
97
+ return { ok: response.ok && body.ok !== false, status: response.status, body: sanitize(body) }
98
+ }
99
+ }
100
+
101
+ async function main() {
102
+ const readline = await import('node:readline')
103
+ const baseUrl = process.env.FANG_URL || process.env.FANG_UPSTREAM || 'http://127.0.0.1:61758'
104
+ const token = process.env.FANG_TOKEN || process.env.DSH_FANG_AUTH_TOKEN || ''
105
+ const handle = createServer({ callTool: createHttpCallTool({ baseUrl, token }) })
106
+ const rl = readline.createInterface({ input: process.stdin })
107
+ rl.on('line', async (line) => {
108
+ if (!line.trim()) return
109
+ let request
110
+ try { request = JSON.parse(line) } catch {
111
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }) + '\n')
112
+ return
113
+ }
114
+ const response = await handle(request)
115
+ if (response) process.stdout.write(JSON.stringify(response) + '\n')
116
+ })
117
+ }
118
+
119
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
120
+ main().catch((error) => { console.error(error); process.exit(1) })
121
+ }
@@ -0,0 +1,65 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // v3.0.8 MCP tool registry: 8 default tools, 4 high-risk hidden, opId idempotency, no fake cancel.
3
+ import crypto from 'node:crypto'
4
+
5
+ export const HIGH_RISK_TOOLS = Object.freeze(['promote', 'delete', 'restart', 'mirror_upgrade'])
6
+
7
+ export const TOOLS = Object.freeze([
8
+ { name: 'status', description: '母体/宿主状态(只读)', kind: 'sync', inputSchema: { type: 'object', properties: {} } },
9
+ { name: 'tasks', description: '分身任务列表(只读)', kind: 'sync', inputSchema: { type: 'object', properties: {} } },
10
+ { name: 'task_status', description: '单个分身任务状态(只读)', kind: 'sync', inputSchema: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] } },
11
+ { name: 'derive', description: '派生新分身', kind: 'op', inputSchema: { type: 'object', properties: { title: { type: 'string' } } } },
12
+ { name: 'archive', description: '归档分身(保留 home)', kind: 'op', inputSchema: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] } },
13
+ { name: 'essence_list', description: '元主/原身列表(只读)', kind: 'sync', inputSchema: { type: 'object', properties: {} } },
14
+ { name: 'upgrade_status', description: '升级状态(只读)', kind: 'sync', inputSchema: { type: 'object', properties: {} } },
15
+ { name: 'compat', description: 'compat 门禁状态(只读)', kind: 'sync', inputSchema: { type: 'object', properties: {} } },
16
+ ...HIGH_RISK_TOOLS.map((name) => ({
17
+ name, description: '高风险工具(默认不暴露)', kind: 'op', highRisk: true,
18
+ inputSchema: { type: 'object', properties: {} },
19
+ })),
20
+ ])
21
+
22
+ export function listTools({ includeHighRisk = false } = {}) {
23
+ return TOOLS.filter((tool) => includeHighRisk || !tool.highRisk)
24
+ }
25
+
26
+ export class OpStore {
27
+ constructor({ clock = Date.now, timeoutMs = 120_000 } = {}) {
28
+ this.clock = clock
29
+ this.timeoutMs = timeoutMs
30
+ this.ops = new Map()
31
+ this.keys = new Map()
32
+ }
33
+
34
+ start(name, args = {}, run) {
35
+ const key = args.idempotencyKey ? `${name}:${args.idempotencyKey}` : ''
36
+ if (key && this.keys.has(key)) return { ok: true, opId: this.keys.get(key), deduped: true }
37
+ const opId = 'op_' + crypto.randomBytes(8).toString('hex')
38
+ this.ops.set(opId, { opId, name, state: 'running', abandoned: false, startedAt: this.clock(), result: null, error: null })
39
+ if (key) this.keys.set(key, opId)
40
+ Promise.resolve().then(run).then((result) => {
41
+ const op = this.ops.get(opId)
42
+ if (!op || op.state !== 'running') return
43
+ op.state = 'completed'; op.result = result ?? null
44
+ }).catch((error) => {
45
+ const op = this.ops.get(opId)
46
+ if (!op || op.state !== 'running') return
47
+ op.state = 'failed'; op.error = String(error?.message ?? error)
48
+ })
49
+ return { ok: true, opId, deduped: false }
50
+ }
51
+
52
+ get(opId) {
53
+ const op = this.ops.get(String(opId))
54
+ if (!op) return { ok: false, error: 'op-not-found' }
55
+ if (op.state === 'running' && this.clock() - op.startedAt > this.timeoutMs) op.state = 'timeout'
56
+ return { ok: true, opId: op.opId, name: op.name, state: op.state, abandoned: op.abandoned, result: op.result, error: op.error }
57
+ }
58
+
59
+ abandon(opId) {
60
+ const op = this.ops.get(String(opId))
61
+ if (!op) return { ok: false, error: 'op-not-found' }
62
+ op.abandoned = true
63
+ return { ok: true, opId: op.opId, state: op.state, abandoned: true }
64
+ }
65
+ }
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // tools/recover-queue.mjs — 从瓮城的 composer journal 里找回草稿与排队消息。
4
+ //
5
+ // 用法:
6
+ // node tools/recover-queue.mjs # 扫描默认目录,列出可恢复内容
7
+ // node tools/recover-queue.mjs --data-dir <dir> # 指定 fang dataDir(可多次)
8
+ // node tools/recover-queue.mjs --scan-all # 扫描 ~/.dsh/fang 下所有 dataDir
9
+ // node tools/recover-queue.mjs --json # 输出 JSON(便于脚本处理)
10
+ // node tools/recover-queue.mjs --restore <url> --token <token> --session <sessionId> --index <n>
11
+ // 把第 n 条快照(默认最新)的队列写回官方队列:POST <url>/fang/api/composer/queue
12
+ // --dry-run 与 --restore 搭配,只打印将要发送的内容
13
+ //
14
+ // 设计原则:只读扫描 + 显式恢复;默认不发送任何消息。
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+
19
+ const args = process.argv.slice(2)
20
+ const readFlag = (name) => {
21
+ const index = args.indexOf(name)
22
+ return index >= 0 ? String(args[index + 1] ?? '') : ''
23
+ }
24
+ const has = (name) => args.includes(name)
25
+
26
+ const jsonOut = has('--json')
27
+ const scanAll = has('--scan-all')
28
+ const dryRun = has('--dry-run')
29
+ const dataDirs = []
30
+ for (let i = 0; i < args.length; i += 1) if (args[i] === '--data-dir' && args[i + 1]) dataDirs.push(args[i + 1])
31
+ if (scanAll || dataDirs.length === 0) {
32
+ const roots = [path.join(os.homedir(), '.dsh', 'fang')]
33
+ for (const root of roots) {
34
+ try {
35
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
36
+ if (!entry.isDirectory()) continue
37
+ const candidate = path.join(root, entry.name)
38
+ // fang dataDir 里应有 endpoint.json / audit.jsonl / composer-journal.json 之一
39
+ const markers = ['composer-journal.json', 'composer-journal-history.jsonl', 'audit.jsonl', 'endpoint.json']
40
+ if (markers.some((marker) => fs.existsSync(path.join(candidate, marker)))) dataDirs.push(candidate)
41
+ }
42
+ } catch { /* 没有 fang 目录 */ }
43
+ }
44
+ }
45
+
46
+ const readJson = (file) => {
47
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
48
+ }
49
+ const readLines = (file) => {
50
+ try {
51
+ return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((line) => {
52
+ try { return JSON.parse(line) } catch { return null }
53
+ }).filter(Boolean)
54
+ } catch { return [] }
55
+ }
56
+
57
+ const report = []
58
+ for (const dir of dataDirs) {
59
+ const journalFile = path.join(dir, 'composer-journal.json')
60
+ const historyFile = path.join(dir, 'composer-journal-history.jsonl')
61
+ const journal = readJson(journalFile)
62
+ const entries = []
63
+ if (journal && typeof journal === 'object') {
64
+ for (const [key, entry] of Object.entries(journal)) {
65
+ const queue = Array.isArray(entry?.queue) ? entry.queue : []
66
+ if (!entry?.draft && queue.length === 0) continue
67
+ entries.push({ key, draft: String(entry?.draft || ''), queue, updatedAt: entry?.updatedAt || '', source: 'journal' })
68
+ }
69
+ }
70
+ for (const snapshot of readLines(historyFile)) {
71
+ const queue = Array.isArray(snapshot?.queue) ? snapshot.queue : []
72
+ if (queue.length === 0) continue
73
+ entries.push({
74
+ key: String(snapshot?.key || ''),
75
+ draft: String(snapshot?.draft || ''),
76
+ queue,
77
+ updatedAt: String(snapshot?.updatedAt || ''),
78
+ sessionKey: String(snapshot?.sessionKey || ''),
79
+ source: 'history',
80
+ })
81
+ }
82
+ entries.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
83
+ report.push({ dataDir: dir, journalFile, historyFile, entries })
84
+ }
85
+
86
+ const restoreUrl = readFlag('--restore').replace(/\/+$/, '')
87
+ const restoreToken = readFlag('--token') || String(process.env.FANG_TOKEN ?? '')
88
+ const restoreSession = readFlag('--session')
89
+ const indexFlag = Number(readFlag('--index') || 0)
90
+
91
+ if (!jsonOut) {
92
+ console.log(`扫描目录 ${dataDirs.length} 个`)
93
+ for (const item of report) {
94
+ console.log(`\n=== ${item.dataDir}`)
95
+ console.log(` journal: ${fs.existsSync(item.journalFile) ? item.journalFile : '(none)'}`)
96
+ console.log(` history: ${fs.existsSync(item.historyFile) ? item.historyFile : '(none)'}`)
97
+ if (item.entries.length === 0) { console.log(' 可恢复条目: 0'); continue }
98
+ item.entries.forEach((entry, index) => {
99
+ console.log(` [${index}] ${entry.source} · key=${JSON.stringify(entry.key)} · ${entry.updatedAt} · queue=${entry.queue.length}${entry.draft ? ' · draft=' + JSON.stringify(entry.draft.slice(0, 40)) : ''}`)
100
+ for (const queueItem of entry.queue.slice(0, 6)) {
101
+ const text = String(queueItem?.text ?? '')
102
+ const broken = /\?{3,}/.test(text)
103
+ console.log(` • ${JSON.stringify(text.slice(0, 120))}${broken ? ' ⚠ 疑似乱码(原文已丢失)' : ''}`)
104
+ }
105
+ })
106
+ }
107
+ }
108
+
109
+ if (jsonOut) {
110
+ console.log(JSON.stringify({ at: new Date().toISOString(), dataDirs, report }, null, 2))
111
+ if (!restoreUrl) process.exit(0)
112
+ }
113
+
114
+ if (restoreUrl) {
115
+ const flattened = report.flatMap((item) => item.entries.map((entry) => ({ ...entry, dataDir: item.dataDir })))
116
+ const target = flattened[indexFlag]
117
+ if (!target) { console.error(`没有第 ${indexFlag} 条可恢复记录(共 ${flattened.length} 条)`); process.exit(1) }
118
+ if (!restoreSession) { console.error('恢复需要 --session <sessionId>(官方队列按会话恢复)'); process.exit(1) }
119
+ const items = target.queue.map((queueItem) => ({ text: String(queueItem?.text ?? '').slice(0, 4000) })).filter((item) => item.text.trim())
120
+ if (items.length === 0) { console.error('该快照没有可恢复的队列内容'); process.exit(1) }
121
+ const payload = { sessionId: restoreSession, items }
122
+ console.log(`\n准备恢复 ${items.length} 条 → ${restoreUrl}/fang/api/composer/queue(session=${restoreSession})`)
123
+ if (dryRun) { console.log('--dry-run:未发送'); process.exit(0) }
124
+ if (!restoreToken) { console.error('恢复需要 --token 或环境变量 FANG_TOKEN'); process.exit(1) }
125
+ const res = await fetch(`${restoreUrl}/fang/api/composer/queue`, {
126
+ method: 'POST',
127
+ headers: { 'content-type': 'application/json', 'x-fang-auth': restoreToken },
128
+ body: JSON.stringify(payload),
129
+ })
130
+ const text = await res.text()
131
+ console.log(`HTTP ${res.status} ${text.slice(0, 200)}`)
132
+ process.exit(res.ok ? 0 : 1)
133
+ }