@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.
- package/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/APPROVAL-DRILL.md +42 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/approval-drill.mjs +111 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +582 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3979 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/approval-drill.mjs +21 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +425 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- 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
|
+
}
|