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

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 (144) 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/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,672 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2.1.0 — 会话格式库(T10,母体只读会话目录的纯函数核心)。
4
+ //
5
+ // 本模块**零 import**:不依赖 node:fs / node:path / node:zlib / node:buffer。
6
+ // 所有 IO 与 zstd 解码通过参数注入(fsApi / zstdDecompressSync),因此可以在
7
+ // 单测里用假 fs、在浏览器兼容层里复用;只有文本解码用了 Web 标准 TextDecoder。
8
+ //
9
+ // 格式(本机实测 $DSH_HOME,2026-11-01):
10
+ // $DSH_HOME/storages/workspace.json
11
+ // { unit, global:{ archivedSessionIds[], pinnedSessionIds[], workspaceIds[], defaultWorkspaceId },
12
+ // tables:{ workspaces:{ <workspaceId>:{ path, title, sessionIds[], createdAt, updatedAt } } } }
13
+ // $DSH_HOME/sessions/<workspaceKey>/<sessionId>/session.v4.jsonl.zstd
14
+ // workspaceKey 由工作区绝对路径编码(`E:\GPT` → `--E-GPT--`,只用于显示/定位,不可反解);
15
+ // 日志是**多帧 zstd 拼接**:第一帧通常只有一行 session 头,后续帧是事件。
16
+ //
17
+ // 安全规则:任何来自前端的值只当 sessionId 用(正则校验,拒绝路径元字符),绝不拼路径。
18
+
19
+ /** 允许作为会话目录名的 id:`session-<uuid>` 或裸 `<uuid>`(本机 v4 两种都存在)。 */
20
+ export const SESSION_ID_RE = /^(?:session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
21
+
22
+ /** 会话日志文件名(v4 格式)。 */
23
+ export const SESSION_LOG_FILE = 'session.v4.jsonl.zstd'
24
+
25
+ /** 工作区索引文件名。 */
26
+ export const WORKSPACE_INDEX_FILE = 'workspace.json'
27
+
28
+ /** zstd 帧 magic 28 B5 2F FD。 */
29
+ export const ZSTD_MAGIC = Object.freeze([0x28, 0xb5, 0x2f, 0xfd])
30
+
31
+ /** 头读默认预算(契约 §T10)。 */
32
+ export const HEAD_DEFAULTS = Object.freeze({
33
+ maxCompressedBytes: 262144,
34
+ maxLines: 50,
35
+ maxChars: 65536,
36
+ })
37
+
38
+ /** digest 上限:32 KiB(超限 halt,不静默截断)。 */
39
+ export const DIGEST_MAX_BYTES = 32 * 1024
40
+
41
+ let sharedDecoder = null
42
+ function decodeUtf8(bytes) {
43
+ if (typeof TextDecoder !== 'function') {
44
+ // 极端宿主没有 TextDecoder:逐字节 Latin-1 兜底(不抛,可能非 UTF-8 文本降级)
45
+ let s = ''
46
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
47
+ return s
48
+ }
49
+ if (!sharedDecoder) sharedDecoder = new TextDecoder('utf-8', { fatal: false })
50
+ return sharedDecoder.decode(bytes)
51
+ }
52
+
53
+ function clampInt(value, min, max, fallback) {
54
+ const n = Math.floor(Number(value))
55
+ if (!Number.isFinite(n)) return fallback
56
+ return Math.min(max, Math.max(min, n))
57
+ }
58
+
59
+ /** workspace 目录名只接受单段名字(readdir 已保证,但外部传入也挡一层)。 */
60
+ function isSingleSegmentName(name) {
61
+ return typeof name === 'string' && name !== '' && name !== '.' && name !== '..'
62
+ && !name.includes('/') && !name.includes('\\') && !name.includes('\0')
63
+ }
64
+
65
+ /** 宽松的 sessionId 校验:只允许 `session-` + 36 个 hex/'-' 或裸 uuid。 */
66
+ export function isValidSessionId(value) {
67
+ return typeof value === 'string' && value.length > 0 && value.length <= 64 && SESSION_ID_RE.test(value)
68
+ }
69
+
70
+ /** 返回空工作区索引(错误路径也保持同一形状)。 */
71
+ export function emptyWorkspaceIndex(degraded = []) {
72
+ return {
73
+ workspaces: [],
74
+ global: {
75
+ archivedSessionIds: [],
76
+ pinnedSessionIds: [],
77
+ workspaceIds: [],
78
+ defaultWorkspaceId: null,
79
+ initialized: false,
80
+ },
81
+ degraded: [...degraded],
82
+ }
83
+ }
84
+
85
+ /** 字符串数组归一化:trim、去空、去重(可选正则过滤)。 */
86
+ function cleanList(value, filter = null) {
87
+ if (!Array.isArray(value)) return []
88
+ const seen = new Set()
89
+ const out = []
90
+ for (const item of value) {
91
+ if (typeof item !== 'string') continue
92
+ const s = item.trim()
93
+ if (!s || seen.has(s)) continue
94
+ if (filter && !filter(s)) continue
95
+ seen.add(s)
96
+ out.push(s)
97
+ }
98
+ return out
99
+ }
100
+
101
+ /**
102
+ * 容错解析 workspace.json。
103
+ * @param {string|object} raw JSON 文本或已解析对象
104
+ * @returns {{workspaces:{id,path,title,sessionIds}[], global:{archivedSessionIds,pinnedSessionIds,workspaceIds,defaultWorkspaceId,initialized}, degraded:string[]}}
105
+ */
106
+ export function parseWorkspaceIndex(raw) {
107
+ if (raw === undefined || raw === null) return emptyWorkspaceIndex(['empty'])
108
+ let doc = raw
109
+ if (typeof raw === 'string') {
110
+ const text = raw.replace(/^\uFEFF/, '').trim()
111
+ if (!text) return emptyWorkspaceIndex(['empty'])
112
+ try {
113
+ doc = JSON.parse(text)
114
+ } catch {
115
+ return emptyWorkspaceIndex(['invalid-json'])
116
+ }
117
+ }
118
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return emptyWorkspaceIndex(['invalid-shape'])
119
+
120
+ const degraded = []
121
+ const tables = doc.tables && typeof doc.tables === 'object' && doc.tables.workspaces
122
+ ? doc.tables.workspaces
123
+ : null
124
+ const rawWorkspaces = tables && typeof tables === 'object' && !Array.isArray(tables)
125
+ ? tables
126
+ : (doc.workspaces && typeof doc.workspaces === 'object' && !Array.isArray(doc.workspaces) ? doc.workspaces : null)
127
+ if (!rawWorkspaces) degraded.push('no-workspaces-table')
128
+
129
+ const workspaces = []
130
+ const seenPaths = new Set()
131
+ for (const [id, rec] of Object.entries(rawWorkspaces ?? {})) {
132
+ if (!rec || typeof rec !== 'object') continue
133
+ const workspacePath = typeof rec.path === 'string' ? rec.path.trim() : ''
134
+ if (!workspacePath) continue
135
+ if (seenPaths.has(workspacePath)) continue
136
+ seenPaths.add(workspacePath)
137
+ workspaces.push({
138
+ id: String(id),
139
+ path: workspacePath,
140
+ title: typeof rec.title === 'string' && rec.title.trim() ? rec.title.trim() : workspacePath,
141
+ sessionIds: cleanList(rec.sessionIds),
142
+ createdAt: typeof rec.createdAt === 'string' ? rec.createdAt : null,
143
+ updatedAt: typeof rec.updatedAt === 'string' ? rec.updatedAt : null,
144
+ })
145
+ }
146
+ workspaces.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
147
+
148
+ const g = doc.global && typeof doc.global === 'object' ? doc.global : {}
149
+ return {
150
+ workspaces,
151
+ global: {
152
+ archivedSessionIds: cleanList(g.archivedSessionIds, isValidSessionId),
153
+ pinnedSessionIds: cleanList(g.pinnedSessionIds, isValidSessionId),
154
+ workspaceIds: cleanList(g.workspaceIds),
155
+ defaultWorkspaceId: typeof g.defaultWorkspaceId === 'string' ? g.defaultWorkspaceId : null,
156
+ initialized: g.initialized === true,
157
+ },
158
+ degraded,
159
+ }
160
+ }
161
+
162
+ /**
163
+ * 工作区绝对路径 → 会话目录名(仅显示/定位,**不可反解**)。
164
+ *
165
+ * 实测对照(必须与 DSH 完全一致):
166
+ * `E:\GPT` → `--E-GPT--`
167
+ * `C:\Users\a\b\c` → `--C-Users-a-b-c--`
168
+ * `/Users/fang/Desktop/DS` → `--Users-fang-Desktop-DS--` ← POSIX 绝对路径
169
+ *
170
+ * v3.1.35 修复:POSIX 绝对路径以 `/` 开头,若只去尾部斜杠,
171
+ * 开头的 `/` 会被折叠成 `-`,结果变成 `---Users-fang-Desktop-DS--`(多一个前导 -),
172
+ * 与 DSH 的 `--Users-fang-Desktop-DS--` 不匹配 —— 导入会话时合并工作区索引必然落空,
173
+ * 分身的 workspace.json 因此为空,Web UI 里既没有工作区也没有会话(MacMini 实测)。
174
+ *
175
+ * 规则:非法字符连续段折叠为单个 '-',随后去掉首尾多余的 '-',最后首尾各包 '--'。
176
+ */
177
+ export function encodeWorkspaceKey(workspacePath) {
178
+ const raw = String(workspacePath ?? '').trim()
179
+ if (!raw) return ''
180
+ const body = raw
181
+ .replace(/[^A-Za-z0-9_-]+/g, '-') // 分隔符/盘符冒号等折叠
182
+ .replace(/^-+/, '') // 去掉 POSIX 前导斜杠产生的前导 '-'
183
+ .replace(/-+$/, '') // 去掉尾部斜杠产生的尾部 '-'
184
+ if (!body) return ''
185
+ return `--${body}--`
186
+ }
187
+
188
+ /** 'a/b' 拼接(不引入 node:path;Windows 也接受正斜杠)。 */
189
+ function joinRel(base, ...rest) {
190
+ const parts = [String(base ?? '').replace(/[\\/]+$/, ''), ...rest.map((p) => String(p ?? '').replace(/^[\\/]+/g, '').replace(/[\\/]+$/, ''))]
191
+ return parts.filter((p) => p !== '').join('/')
192
+ }
193
+
194
+ /** 帧边界:从 buf 里找 zstd magic 出现的位置。 */
195
+ function findMagicOffsets(bytes) {
196
+ const offsets = []
197
+ const b0 = ZSTD_MAGIC[0]
198
+ const b1 = ZSTD_MAGIC[1]
199
+ const b2 = ZSTD_MAGIC[2]
200
+ const b3 = ZSTD_MAGIC[3]
201
+ for (let i = 0; i + 3 < bytes.length; i++) {
202
+ if (bytes[i] === b0 && bytes[i + 1] === b1 && bytes[i + 2] === b2 && bytes[i + 3] === b3) offsets.push(i)
203
+ if (offsets.length >= 64) break // 头读预算内 64 帧足够;再多也只解第一帧
204
+ }
205
+ return offsets
206
+ }
207
+
208
+ /**
209
+ * 取第一帧的字节切片(多帧 zstd 只解第一帧;不得整文件解压)。
210
+ * 候选边界 = 第 2 个及之后的 magic 位置;按升序尝试单帧解压,第一个成功者即第一帧边界。
211
+ * 没有候选 / 候选都失败 → 返回整段(由调用方单帧解压,通常是真第一帧或会抛错降级)。
212
+ * @returns {{slice:Uint8Array, hasMoreFrames:boolean, boundary:number|null}}
213
+ */
214
+ function firstFrameSlice(bytes, zstdDecompressSync) {
215
+ const offsets = findMagicOffsets(bytes)
216
+ const candidates = offsets.filter((off) => off > 0)
217
+ for (const off of candidates) {
218
+ try {
219
+ zstdDecompressSync(bytes.subarray(0, off))
220
+ return { slice: bytes.subarray(0, off), hasMoreFrames: true, boundary: off }
221
+ } catch {
222
+ /* 这个 magic 是压缩数据里的巧合,试下一个候选 */
223
+ }
224
+ }
225
+ return { slice: bytes, hasMoreFrames: false, boundary: null }
226
+ }
227
+
228
+ /**
229
+ * 有界头读:只读文件前 maxCompressedBytes 字节,只解第一帧 zstd,返回文本行。
230
+ * @param {object} options
231
+ * @param {string} options.file 会话日志绝对路径
232
+ * @param {number} [options.maxCompressedBytes=262144] 读取/解压预算(压缩字节)
233
+ * @param {number} [options.maxLines=50] 最多返回的行数
234
+ * @param {number} [options.maxChars=65536] 最多返回的字符数
235
+ * @param {object} options.fsApi 注入的 fs:{statSync?, openSync, readSync, closeSync}
236
+ * @param {Function} options.zstdDecompressSync 注入的 zlib.zstdDecompressSync
237
+ * @returns {{lines:string[], degraded:string[], bytesRead:number}}
238
+ */
239
+ export function readBoundedHead(options = {}) {
240
+ const {
241
+ file,
242
+ maxCompressedBytes = HEAD_DEFAULTS.maxCompressedBytes,
243
+ maxLines = HEAD_DEFAULTS.maxLines,
244
+ maxChars = HEAD_DEFAULTS.maxChars,
245
+ fsApi,
246
+ zstdDecompressSync,
247
+ } = options ?? {}
248
+ const degraded = []
249
+ const out = { lines: [], degraded, bytesRead: 0 }
250
+ const note = (tag) => {
251
+ if (!degraded.includes(tag)) degraded.push(tag)
252
+ }
253
+
254
+ if (typeof file !== 'string' || !file) {
255
+ note('no-file')
256
+ return out
257
+ }
258
+ if (!fsApi || typeof fsApi.openSync !== 'function' || typeof fsApi.readSync !== 'function' || typeof fsApi.closeSync !== 'function') {
259
+ note('no-fs-api')
260
+ return out
261
+ }
262
+ const budget = clampInt(maxCompressedBytes, 4, 64 * 1024 * 1024, HEAD_DEFAULTS.maxCompressedBytes)
263
+ const lineBudget = clampInt(maxLines, 1, 100000, HEAD_DEFAULTS.maxLines)
264
+ const charBudget = clampInt(maxChars, 0, 16 * 1024 * 1024, HEAD_DEFAULTS.maxChars)
265
+
266
+ let fd = null
267
+ try {
268
+ let fileSize = null
269
+ if (typeof fsApi.statSync === 'function') {
270
+ try {
271
+ const st = fsApi.statSync(file)
272
+ if (st && Number.isFinite(st.size)) fileSize = st.size
273
+ } catch {
274
+ /* stat 失败会在 open 阶段再次暴露 */
275
+ }
276
+ }
277
+ if (fileSize === 0) {
278
+ note('empty')
279
+ return out
280
+ }
281
+ fd = fsApi.openSync(file, 'r')
282
+ const toRead = fileSize === null ? budget : Math.min(fileSize, budget)
283
+ if (!(toRead > 0)) {
284
+ note('empty')
285
+ return out
286
+ }
287
+ const buffer = new Uint8Array(toRead)
288
+ const read = fsApi.readSync(fd, buffer, 0, toRead, 0)
289
+ out.bytesRead = Number.isFinite(read) ? read : 0
290
+ if (out.bytesRead <= 0) {
291
+ note('empty')
292
+ return out
293
+ }
294
+ if (fileSize !== null && out.bytesRead < fileSize) note('compressed-limit')
295
+ if (out.bytesRead < toRead) note('short-read')
296
+ const view = buffer.subarray(0, out.bytesRead)
297
+
298
+ const looksZstd = view.length >= 4
299
+ && view[0] === ZSTD_MAGIC[0] && view[1] === ZSTD_MAGIC[1]
300
+ && view[2] === ZSTD_MAGIC[2] && view[3] === ZSTD_MAGIC[3]
301
+ let text = ''
302
+ let moreBytesOnDisk = fileSize !== null && fileSize > out.bytesRead
303
+ if (looksZstd) {
304
+ if (typeof zstdDecompressSync !== 'function') {
305
+ note('no-zstd')
306
+ return out
307
+ }
308
+ const frame = firstFrameSlice(view, zstdDecompressSync)
309
+ if (frame.hasMoreFrames) moreBytesOnDisk = true
310
+ if (moreBytesOnDisk) note('first-frame-only')
311
+ try {
312
+ const decoded = zstdDecompressSync(frame.slice)
313
+ // 注意:Node 的 zstdDecompressSync 对「被截断的帧」不抛错,而是返回 0 字节。
314
+ // 会话日志帧不会是空内容,因此 0 字节 + 非空输入一律按解压失败降级,绝不假装成功。
315
+ const bytes = decoded instanceof Uint8Array ? decoded : new Uint8Array(decoded)
316
+ if (bytes.length === 0 && frame.slice.length > 0) {
317
+ note('decompress-failed')
318
+ return out
319
+ }
320
+ text = decodeUtf8(bytes)
321
+ } catch {
322
+ note('decompress-failed')
323
+ return out
324
+ }
325
+ } else {
326
+ // 非 zstd(例如测试人造纯文本):按 UTF-8 文本读,如实标记降级而不是假装成功。
327
+ note('not-zstd')
328
+ text = decodeUtf8(view)
329
+ }
330
+
331
+ let lines = text.split('\n')
332
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
333
+ if (lines.length > lineBudget) {
334
+ lines = lines.slice(0, lineBudget)
335
+ note('line-limit')
336
+ }
337
+ const kept = []
338
+ let chars = 0
339
+ for (const line of lines) {
340
+ if (charBudget <= 0) {
341
+ note('char-limit')
342
+ break
343
+ }
344
+ if (chars + line.length + 1 <= charBudget) {
345
+ kept.push(line)
346
+ chars += line.length + 1
347
+ continue
348
+ }
349
+ const remain = charBudget - chars
350
+ kept.push(remain > 1 ? line.slice(0, remain - 1) : '')
351
+ note('char-limit')
352
+ break
353
+ }
354
+ out.lines = kept
355
+ return out
356
+ } catch {
357
+ note('read-failed')
358
+ return out
359
+ } finally {
360
+ if (fd !== null) {
361
+ try {
362
+ fsApi.closeSync(fd)
363
+ } catch {
364
+ /* close 失败不影响已读结果 */
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ export const TITLE_DEFAULTS = Object.freeze({
371
+ maxFrames: 64,
372
+ maxCompressedBytes: 2 * 1024 * 1024,
373
+ maxEvents: 4000,
374
+ })
375
+
376
+ /**
377
+ * 有界多帧事件读:一次只读文件前 maxCompressedBytes 字节,逐帧解压直到 maxFrames 帧。
378
+ * 会话标题(session/title 事件)与首条用户消息通常在前几十帧内;**绝不整文件解压**。
379
+ * @returns {{events:object[], lines:string[], degraded:string[], framesDecoded:number, bytesRead:number}}
380
+ */
381
+ export function readBoundedEvents(options = {}) {
382
+ const {
383
+ file,
384
+ maxFrames = TITLE_DEFAULTS.maxFrames,
385
+ maxCompressedBytes = TITLE_DEFAULTS.maxCompressedBytes,
386
+ maxEvents = TITLE_DEFAULTS.maxEvents,
387
+ fsApi,
388
+ zstdDecompressSync,
389
+ } = options ?? {}
390
+ const degraded = []
391
+ const out = { events: [], lines: [], degraded, framesDecoded: 0, bytesRead: 0 }
392
+ const note = (tag) => { if (!degraded.includes(tag)) degraded.push(tag) }
393
+ if (typeof file !== 'string' || !file) { note('no-file'); return out }
394
+ if (!fsApi || typeof fsApi.openSync !== 'function') { note('no-fs-api'); return out }
395
+ if (typeof zstdDecompressSync !== 'function') { note('no-zstd'); return out }
396
+ const frameBudget = clampInt(maxFrames, 1, 4096, TITLE_DEFAULTS.maxFrames)
397
+ const byteBudget = clampInt(maxCompressedBytes, 4096, 64 * 1024 * 1024, TITLE_DEFAULTS.maxCompressedBytes)
398
+ const eventBudget = clampInt(maxEvents, 1, 200000, TITLE_DEFAULTS.maxEvents)
399
+ let fd = null
400
+ try {
401
+ let fileSize = null
402
+ try {
403
+ const st = fsApi.statSync(file)
404
+ if (st && Number.isFinite(st.size)) fileSize = st.size
405
+ } catch { /* open 阶段会再次暴露 */ }
406
+ if (fileSize === 0) { note('empty'); return out }
407
+ fd = fsApi.openSync(file, 'r')
408
+ const toRead = fileSize === null ? byteBudget : Math.min(fileSize, byteBudget)
409
+ if (!(toRead > 0)) { note('empty'); return out }
410
+ const buffer = new Uint8Array(toRead)
411
+ const got = fsApi.readSync(fd, buffer, 0, toRead, 0)
412
+ out.bytesRead = Number.isFinite(got) ? got : 0
413
+ if (out.bytesRead <= 0) { note('empty'); return out }
414
+ if (fileSize !== null && out.bytesRead < fileSize) note('compressed-limit')
415
+ const view = buffer.subarray(0, out.bytesRead)
416
+ const looksZstd = view.length >= 4
417
+ && view[0] === ZSTD_MAGIC[0] && view[1] === ZSTD_MAGIC[1]
418
+ && view[2] === ZSTD_MAGIC[2] && view[3] === ZSTD_MAGIC[3]
419
+ let text = ''
420
+ if (!looksZstd) {
421
+ note('not-zstd')
422
+ text = decodeUtf8(view)
423
+ } else {
424
+ const offsets = findMagicOffsets(view)
425
+ const bounds = [...new Set((offsets.length > 0 && offsets[0] === 0) ? offsets : [0, ...offsets])].sort((a, b) => a - b)
426
+ for (let i = 0; i < bounds.length && out.framesDecoded < frameBudget; i++) {
427
+ const frame = view.subarray(bounds[i], i + 1 < bounds.length ? bounds[i + 1] : view.length)
428
+ if (frame.length < 4) continue
429
+ try {
430
+ const decoded = zstdDecompressSync(frame)
431
+ const bytes = decoded instanceof Uint8Array ? decoded : new Uint8Array(decoded)
432
+ if (bytes.length === 0) { note('frame-empty'); continue }
433
+ text += decodeUtf8(bytes)
434
+ out.framesDecoded += 1
435
+ } catch {
436
+ note('frame-decode-failed')
437
+ break
438
+ }
439
+ }
440
+ if (out.framesDecoded < bounds.length) note('frame-limit')
441
+ }
442
+ const lines = text.split('\n')
443
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
444
+ for (const line of lines) {
445
+ if (!line.trim()) continue
446
+ let parsed = null
447
+ try { parsed = JSON.parse(line) } catch { note('line-unparsable'); continue }
448
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
449
+ out.events.push(parsed)
450
+ out.lines.push(line)
451
+ if (out.events.length >= eventBudget) { note('event-limit'); break }
452
+ }
453
+ return out
454
+ } catch {
455
+ note('read-failed')
456
+ return out
457
+ } finally {
458
+ if (fd !== null) { try { fsApi.closeSync(fd) } catch { /* ignore */ } }
459
+ }
460
+ }
461
+
462
+ /**
463
+ * 从事件里提取会话标题:最新 session/title > 首条用户消息(截断)> null。
464
+ * titleSource 让调用方如实标注标题来源(不假装有原生标题)。
465
+ */
466
+ export function extractSessionTitle(events = []) {
467
+ let title = null
468
+ let titleSource = null
469
+ let messageSeq = null
470
+ let preview = null
471
+ for (const event of (Array.isArray(events) ? events : [])) {
472
+ if (!event || typeof event !== 'object') continue
473
+ if (event.type === 'session/title') {
474
+ const data = event.data && typeof event.data === 'object' ? event.data : {}
475
+ const raw = typeof data.title === 'string' ? data.title.trim().replace(/\s+/g, ' ') : ''
476
+ if (raw) {
477
+ title = raw
478
+ titleSource = 'session/title'
479
+ const seqs = Array.isArray(data.messageSeqs) ? data.messageSeqs : []
480
+ messageSeq = Number.isFinite(seqs[0]) ? seqs[0] : null
481
+ }
482
+ }
483
+ if (!preview && event.type === 'user/message') {
484
+ const data = event.data && typeof event.data === 'object' ? event.data : event
485
+ const content = Array.isArray(data.content) ? data.content : []
486
+ const text = content
487
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
488
+ .map((b) => b.text)
489
+ .join(' ')
490
+ .trim()
491
+ .replace(/\s+/g, ' ')
492
+ if (text) preview = text.slice(0, 160)
493
+ }
494
+ }
495
+ if (!title && preview) {
496
+ title = preview.slice(0, 80)
497
+ titleSource = 'first-user'
498
+ }
499
+ return { title, titleSource, preview, messageSeq }
500
+ }
501
+
502
+ export const SEED_DEFAULTS = Object.freeze({
503
+ maxFrames: 512,
504
+ maxCompressedBytes: 16 * 1024 * 1024,
505
+ maxChars: 48 * 1024,
506
+ })
507
+
508
+ function messageTextOf(data) {
509
+ const content = Array.isArray(data?.content) ? data.content : []
510
+ return content
511
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
512
+ .map((block) => block.text.trim())
513
+ .filter(Boolean)
514
+ .join('\n\n')
515
+ }
516
+
517
+ /**
518
+ * 有界对话摘录:只取 user/assistant 的文本内容,按字符预算截断。
519
+ * 用于派生分身时的会话交接种子(不是完整日志转储)。
520
+ */
521
+ export function extractConversation(events = [], options = {}) {
522
+ const maxChars = clampInt(options.maxChars ?? SEED_DEFAULTS.maxChars, 1024, 256 * 1024, SEED_DEFAULTS.maxChars)
523
+ const parts = []
524
+ let used = 0
525
+ let messages = 0
526
+ let truncated = false
527
+ for (const event of (Array.isArray(events) ? events : [])) {
528
+ if (!event || typeof event !== 'object') continue
529
+ const role = event.type === 'user/message' ? '用户' : event.type === 'assistant/message' ? '助手' : null
530
+ if (!role) continue
531
+ const text = messageTextOf(event.data ?? event)
532
+ if (!text) continue
533
+ const block = `**${role}**:${text}\n\n`
534
+ if (used + block.length > maxChars) { truncated = true; break }
535
+ parts.push(block)
536
+ used += block.length
537
+ messages += 1
538
+ }
539
+ return { markdown: parts.join(''), messages, truncated, chars: used }
540
+ }
541
+
542
+ // ── 脱敏 ────────────────────────────────────────────────────────────────────
543
+ // 顺序有意义:先凭证类,再联系方式,最后绝对路径;每类替换后计数。
544
+ const REDACTION_RULES = [
545
+ // Bearer <token>
546
+ { re: /(\bBearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi, to: (_m, p1) => `${p1}<redacted>` },
547
+ // token / password / secret / api_key = value(含 JSON 的 "key":"value" 形式)
548
+ {
549
+ re: /(\b(?:access[_-]?token|refresh[_-]?token|token|api[_-]?key|secret|password|passwd)\b["']?\s*[:=]\s*["']?)([^"'\s,&}]{3,})/gi,
550
+ to: (_m, p1) => `${p1}<redacted>`,
551
+ },
552
+ // OpenAI 风格 sk- 键
553
+ { re: /\bsk-[A-Za-z0-9_-]{8,}/g, to: () => '<redacted>' },
554
+ // 邮箱(本地/域名段都限长:避免在超长无 @ 字符串上退化成 O(n²) 回溯)
555
+ { re: /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9][A-Za-z0-9.-]{0,253}\.[A-Za-z]{2,24}/g, to: () => '<email>' },
556
+ // 手机号(中国大陆 11 位 / +86 前缀)
557
+ { re: /(?<![\w.])(?:\+?86[-\s]?)?1[3-9]\d{9}(?![\w.])/g, to: () => '<phone>' },
558
+ // 国际号码
559
+ { re: /(?<![\w.])\+\d{1,3}[-\s]?\d{6,14}(?![\w.])/g, to: () => '<phone>' },
560
+ // Windows 绝对路径(必须先于 UNC:JSON 里的 `E:\\work\\x` 双反斜杠前缀也像 UNC)
561
+ { re: /[A-Za-z]:[\\/](?:[^\s"'<>|,;:]+[\\/])*[^\s"'<>|,;:]+/g, to: () => '<path>' },
562
+ // UNC 绝对路径
563
+ { re: /\\\\[A-Za-z0-9._$-]+\\[^\s"'<>|,;]+/g, to: () => '<path>' },
564
+ // POSIX 绝对路径(至少两段,避免误伤单段路由)
565
+ { re: /(?<=[\s"'(=:])\/(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+/g, to: () => '<path>' },
566
+ ]
567
+
568
+ /**
569
+ * 文本脱敏:token/Bearer/sk-/password/secret/api_key 值、邮箱、手机号、绝对路径。
570
+ * @param {string} text
571
+ * @returns {{text:string, redactions:number}}
572
+ */
573
+ export function redact(text) {
574
+ const input = String(text ?? '')
575
+ if (!input) return { text: input, redactions: 0 }
576
+ let current = input
577
+ let redactions = 0
578
+ for (const rule of REDACTION_RULES) {
579
+ current = current.replace(rule.re, (...args) => {
580
+ redactions += 1
581
+ return rule.to(...args)
582
+ })
583
+ }
584
+ return { text: current, redactions }
585
+ }
586
+
587
+ /**
588
+ * 只 stat 扫描会话目录(不解压、不写):返回会话元数据列表。
589
+ * @param {object} options
590
+ * @param {string} options.sessionsDir
591
+ * @param {string[]} [options.workspaceIds] 限定扫描的工作区目录名;空/缺省 = 扫描全部
592
+ * @param {string[]} [options.archivedSessionIds]
593
+ * @param {string[]} [options.pinnedSessionIds]
594
+ * @param {object} options.fsApi 注入的 fs:{readdirSync, statSync}
595
+ * @returns {{sessionId:string, workspaceId:string, updatedAt:number, bytes:number, archived:boolean, pinned:boolean, relPath:string}[]}
596
+ */
597
+ export function scanSessionDirs(options = {}) {
598
+ const {
599
+ sessionsDir,
600
+ workspaceIds = [],
601
+ archivedSessionIds = [],
602
+ pinnedSessionIds = [],
603
+ fsApi,
604
+ } = options ?? {}
605
+ if (typeof sessionsDir !== 'string' || !sessionsDir) return []
606
+ if (!fsApi || typeof fsApi.readdirSync !== 'function' || typeof fsApi.statSync !== 'function') return []
607
+
608
+ const archived = new Set(Array.isArray(archivedSessionIds) ? archivedSessionIds : [])
609
+ const pinned = new Set(Array.isArray(pinnedSessionIds) ? pinnedSessionIds : [])
610
+ const wanted = Array.isArray(workspaceIds)
611
+ ? workspaceIds.filter(isSingleSegmentName)
612
+ : []
613
+ const result = []
614
+
615
+ const readDir = (dir) => {
616
+ try {
617
+ return fsApi.readdirSync(dir, { withFileTypes: true })
618
+ } catch {
619
+ return []
620
+ }
621
+ }
622
+
623
+ const workspaceKeys = wanted.length > 0
624
+ ? wanted
625
+ : readDir(sessionsDir)
626
+ .filter((entry) => entry && entry.isDirectory && entry.isDirectory())
627
+ .map((entry) => entry.name)
628
+ .filter(isSingleSegmentName)
629
+
630
+ for (const workspaceKey of workspaceKeys) {
631
+ const wsDir = joinRel(sessionsDir, workspaceKey)
632
+ for (const entry of readDir(wsDir)) {
633
+ const sessionId = entry && entry.name
634
+ if (!entry || !entry.isDirectory || !entry.isDirectory()) continue
635
+ if (!isValidSessionId(sessionId)) continue
636
+ const relPath = joinRel(workspaceKey, sessionId, SESSION_LOG_FILE)
637
+ const logFile = joinRel(sessionsDir, workspaceKey, sessionId, SESSION_LOG_FILE)
638
+ let stat
639
+ try {
640
+ stat = fsApi.statSync(logFile)
641
+ } catch {
642
+ continue // 没有 v4 日志的目录不可恢复,不列进目录
643
+ }
644
+ if (!stat || !Number.isFinite(stat.size)) continue
645
+ result.push({
646
+ sessionId,
647
+ workspaceId: workspaceKey,
648
+ updatedAt: Number.isFinite(stat.mtimeMs) ? Math.round(stat.mtimeMs) : 0,
649
+ bytes: stat.size,
650
+ archived: archived.has(sessionId),
651
+ pinned: pinned.has(sessionId),
652
+ relPath,
653
+ })
654
+ }
655
+ }
656
+ result.sort((a, b) => b.updatedAt - a.updatedAt || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0))
657
+ return result
658
+ }
659
+
660
+ export const _internals = {
661
+ extractConversation,
662
+ SEED_DEFAULTS,
663
+ readBoundedEvents,
664
+ extractSessionTitle,
665
+ TITLE_DEFAULTS,
666
+ findMagicOffsets,
667
+ firstFrameSlice,
668
+ joinRel,
669
+ cleanList,
670
+ decodeUtf8,
671
+ REDACTION_RULES,
672
+ }