dsh-recall-plugin 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,1129 +1,426 @@
1
- /**
2
- * dsh-recall-plugin — Host 半入口(持久插件形态,bundle 行挂载)
3
- *
4
- * 职责:装配各域模块(config 配置域 / store 执行存储层 / snapshots 快照域 /
5
- * maintenance 维护域),通过 webServer 注册 /api/recall/* HTTP API
6
- * 供 Client 半调用(init / snapshot-info / preview / execute /
7
- * exclude-get / exclude-set / manage / status),并接线 session/event
8
- * 快照触发与启动预热。
9
- *
10
- * 这是持久 npm 插件包的主入口(exports["."]),由 cordis.patch.yml
11
- * insert 行挂载进 profile composition,DSH 重启后自动生效。
12
- * 文件拆分见 lib/ 下各模块头注释;本文件只做接线,不承载业务逻辑。
13
- */
14
-
15
- import { createConfig, Config, DEFAULTS } from './config.js'
16
- import { createRuntime } from './store.js'
17
- import { createSnapshots } from './snapshots.js'
18
- import { createMaintenance } from './maintenance.js'
19
- import { installSettingsSection } from '@deepseek-ai/dsh-settings'
20
-
21
- export const name = 'dsh-recall-plugin'
22
-
23
- // 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
24
- // webServer(Client 半的 HTTP API 通道)。agents(dsh-base 无条件装配的
25
- // agent 注册表)为 P0-1 运行中 agent 拦截读运行状态所需——cordis 4 要求
26
- // 服务在 inject 中声明才可经 ctx.agents 访问,漏声明会抛
27
- // "cannot get property ... without inject" 导致检查静默 fail-open(冒烟发现)。
28
- // 其余服务按需 ctx.get。
29
- export const inject = ['shell', 'sessions', 'webServer', 'agents']
30
-
31
- // 入口配置 schema:cordis 加载器据此校验 insert 行 config 并填充默认值,
32
- // 非法配置在插件加载时响亮失败(官方「插件配置」文档要求)。
33
- export { Config }
34
-
35
- // config 由 cordis.patch.yml 的 insert 行 config 键下发(schema 默认值兜底),
36
- // 设置页「插件配置」卡片的用户覆盖经 settings namespace 热更新进 cfg
37
- // (见下方 installSettingsSection 接线)
38
- export function apply(ctx, config) {
39
- const webServer = ctx.webServer
40
-
41
- const cfg = createConfig(config)
42
- const rt = createRuntime(ctx, cfg)
43
- const snaps = createSnapshots(ctx, rt, cfg)
44
- const maint = createMaintenance(ctx, rt, snaps, cfg)
45
- const state = rt.state
46
-
47
- // ---- settings namespace「dsh-recall」:设置页「插件配置」分区正规接入 ----
48
- // installSettingsSection(dsh-settings 官方辅助):settings 服务挂载后以
49
- // 真 Config schema 注册 namespace、组合 base 取入口 config;服务卸载时
50
- // 源回退入口 config。解析层 = schema 默认 → 组合 base → 用户文档(设置
51
- // 卡片写入、dsh-settings 持久化),变更经 watch 热更新进运行中的 cfg
52
- // (Object.assign 原地改,各域按调用时读取立即生效)。环境变量仍最高
53
- // 优先(createConfig 内处理)。settings 服务未组装(非 web 部署)时
54
- // 整段静默不运行,插件照常以入口配置工作。
55
- let readSettings = () => config
56
- function applyResolvedConfig(resolved) {
57
- Object.assign(cfg, createConfig(resolved && typeof resolved === 'object' ? resolved : {}))
58
- }
59
- try {
60
- installSettingsSection(ctx, 'dsh-recall', Config, config, {
61
- setSource: (fn) => { readSettings = fn },
62
- onChange: () => applyResolvedConfig(readSettings()),
63
- })
64
- } catch (error) {
65
- rt.recordError('recall settings namespace skipped: ' + String(error))
66
- }
67
-
68
- // 平台门控:win32 走 PowerShell 模板,linux/darwin 走 bash 模板
69
- // (ctx.shell DSH 平台层单选挂载 pwsh/bash 执行器,见 dsh-shell README)。
70
- // 其余平台干净短路:init 返回 unsupported,Client 弹一次性提示;
71
- // 其余端点因无快照自然返回「没有可用快照」,全程零文件副作用。
72
- const supported = process.platform === 'win32' || process.platform === 'linux' || process.platform === 'darwin'
73
-
74
- // ---- HTTP API(Client 半经由 fetch 调用;动态插件的 harness RPC 在此换成 webServer 路由)----
75
-
76
- // 请求体上限:端点里 exclude-set 接受用户任意文本,无上限时可被无限
77
- // POST 撑爆内存。1MB 远超正常配置体量,超限干净报错而不是悄悄截断
78
- // (半截 JSON 会在 parse 处抛更晦涩的错)。
79
- const MAX_BODY_BYTES = 1048576
80
-
81
- // 快照管理列表的结果缓存:磁盘 dump + 冷会话标题即便已批量/并行化,
82
- // 也不是零成本(1 条 shell + 若干日志解压)。设置页打开、删除后刷新
83
- // 都会重拉,30s 缓存让二次打开即时;delete 与新快照落地时失效。
84
- let listCache = { at: 0, items: null }
85
- // 排除配置枚举缓存(30s):exclude-get 首次要遍历工作区、逐文件 shell 读,
86
- // 设置页反复打开时不该每次重算;exclude-set 成功写入后立即失效。
87
- let excludeCache = { at: 0, payload: null }
88
-
89
- // 会话标题缓存(apply 级跨请求共享):冷会话标题要 readSession 整日志
90
- // 解压 + 重放校验(大日志 10 秒级),绝不能挡列表首屏——list 只查
91
- // live/缓存(同步、瞬时),冷标题由 Client 拿到列表后异步调 titles 补。
92
- // 值为 null 表示「查过、确实没有」(已删除会话),同样命中缓存。
93
- const sessionTitles = new Map()
94
- function titleFromEvents(events) {
95
- if (!Array.isArray(events)) return null
96
- for (let i = events.length - 1; i >= 0; i--) {
97
- const e = events[i]
98
- if (e && e.type === 'session/title' && e.data && typeof e.data.title === 'string' && e.data.title) return e.data.title
99
- }
100
- return null
101
- }
102
- // 消息文本缓存(apply 级跨请求共享):冷会话 readSession 整日志解压很贵,
103
- // 与标题同款两段式——live 秒回,冷会话由 Client 异步调 titles 端点补齐。
104
- // 值为 null 表示「查过、确实没有」,同样命中缓存。
105
- const messageTexts = new Map()
106
- function messageTextFromEvents(events, messageId) {
107
- if (!Array.isArray(events) || !messageId) return null
108
- for (const e of events) {
109
- if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
110
- const blocks = Array.isArray(e.data.content) ? e.data.content : []
111
- const text = blocks
112
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
113
- .map((b) => b.text)
114
- .join('')
115
- return text || null
116
- }
117
- }
118
- return null
119
- }
120
- function liveMessageTextFast(sessionId, messageId) {
121
- if (!sessionId || !messageId) return null
122
- const key = String(sessionId) + '\u0000' + String(messageId)
123
- if (messageTexts.has(key)) return messageTexts.get(key)
124
- let text = null
125
- try {
126
- const live = ctx.sessions.get(sessionId)
127
- if (live) text = messageTextFromEvents(live.events, messageId)
128
- } catch (error) { text = null }
129
- if (text !== null) messageTexts.set(key, text)
130
- return text
131
- }
132
- function liveTitleFast(sessionId) {
133
- if (!sessionId) return null
134
- if (sessionTitles.has(sessionId)) return sessionTitles.get(sessionId)
135
- let t = null
136
- try {
137
- const live = ctx.sessions.get(sessionId)
138
- if (live) t = titleFromEvents(live.events)
139
- } catch (error) { t = null }
140
- if (t !== null) sessionTitles.set(sessionId, t)
141
- return t
142
- }
143
-
144
- async function readJsonBody(req) {
145
- const chunks = []
146
- let size = 0
147
- for await (const chunk of req) {
148
- size += chunk.length
149
- if (size > MAX_BODY_BYTES) throw new Error('BODY_TOO_LARGE')
150
- chunks.push(chunk)
151
- }
152
- const text = Buffer.concat(chunks).toString('utf8')
153
- if (!text.trim()) return {}
154
- return JSON.parse(text)
155
- }
156
-
157
- function sendJson(res, status, body) {
158
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
159
- res.end(JSON.stringify(body))
160
- }
161
-
162
- // 枚举当前全部已知 exclude 文件并按路径去重:home 存储全局共享一份
163
- // (所有工作区通常合并成一条),降级存储各自独立。根来源取并集——
164
- // 会话注册表(当前活跃的工作区)+ state.stores 缓存(历史会话预热过、
165
- // 可能已关闭的工作区),让设置页也能编辑非活跃项目的排除配置。
166
- // exclude-get 直接消费结果;exclude-set 用它做路径白名单校验,客户端
167
- // 只能回传 get 下发过的路径,堵死「借 API 写任意文件」的通道。
168
- async function listExcludeFiles() {
169
- const roots = new Set(state.stores.keys())
170
- for (const session of ctx.sessions.list()) {
171
- const cwd = session && session.header && session.header.cwd
172
- if (cwd) roots.add(cwd)
173
- }
174
- const byFile = new Map()
175
- // 并行解析全部 root:冷启动时每个 root 首次 resolveStore 可能触发 shell
176
- // 建目录/写 root.txt,串行会随工作区数量线性变慢;Promise.all 让它们并发。
177
- await Promise.all(Array.from(roots).map(async (root) => {
178
- try {
179
- const store = await rt.resolveStore(root)
180
- if (store && !byFile.has(store.excludeFile)) byFile.set(store.excludeFile, { store, roots: [] })
181
- byFile.get(store.excludeFile).roots.push(root)
182
- } catch (error) {
183
- /* 单个根解析失败只影响它自己,不拖垮整个列表 */
184
- }
185
- }))
186
- // 磁盘兜底:冷启动时会话注册表为空(惰性载入),但 home 容器目录可能
187
- // 早已存在(历史快照)。容器在 ⇒ 共享 exclude.txt 可编辑(哪怕从未
188
- // 写过、内容为空);容器不在 ⇒ 全新安装,让设置页显示引导文案。
189
- // 注册表扫描命中的同路径条目优先(roots 信息更全),这里只补缺。
190
- try {
191
- const container = await rt.resolveHomeContainer()
192
- if (container) {
193
- const probe = rt.scripts.stripBom(await rt.runShell(rt.scripts.dirExistsScript(container), { stdoutMaxBytes: 4096 })).trim()
194
- if (probe === 'YES') {
195
- const excludeFile = container + (rt.isWin ? '\\' : '/') + 'exclude.txt'
196
- if (!byFile.has(excludeFile)) {
197
- // 伪 store:仅承载 readExclude/writeExclude 用到的 excludeFile
198
- // 与 home 两个字段;不进 state.stores(无对应 root,不污染缓存)
199
- byFile.set(excludeFile, { store: { dir: container, home: true, excludeFile }, roots: [] })
200
- }
201
- }
202
- }
203
- } catch (error) {
204
- /* 兜底失败退回注册表结果 */
205
- }
206
- return byFile
207
- }
208
-
209
- // 工作区 cwd 全集:live 注册表只是子集(ctx.sessions 是纯内存 Map,web
210
- // 侧栏拉会话列表走 persistence 只读路径、不 resume,注册表可以一直空
211
- // 着),sessionQuery.listSessions 是「live + 磁盘冷元数据」的完整语料
212
- // ——每个会话 header 都带创建时的 cwd。manage list 与 delete 兜底共用。
213
- async function collectCwds() {
214
- const cwds = new Set()
215
- for (const session of ctx.sessions.list()) {
216
- const cwd = session && session.header && session.header.cwd
217
- if (cwd) cwds.add(cwd)
218
- }
219
- try {
220
- const querySvc = ctx.get('sessionQuery')
221
- if (querySvc && typeof querySvc.listSessions === 'function') {
222
- for (const record of await querySvc.listSessions()) {
223
- const cwd = record && record.header && record.header.cwd
224
- if (cwd) cwds.add(cwd)
225
- }
226
- }
227
- } catch (error) { /* 冷元数据不可用时退回 live 注册表 */ }
228
- return cwds
229
- }
230
-
231
- // 归一化 cwd/root 路径用于跨会话同工作区比对:Windows 大小写不敏感 +
232
- // 去掉尾部分隔符,避免 D:\Foo 与 d:\foo\ 误判为不同目录。
233
- function normalizeWorkdir(path) {
234
- if (!path) return ''
235
- let p = String(path)
236
- return (process.platform === 'win32' ? p.toLowerCase() : p).replace(/[\\/]+$/, '')
237
- }
238
-
239
- // 回退前重保护检查(P0-1):目标工作区有 agent 正在跑时拒绝预览/撤回。
240
- // 保守策略——不做自动取消(不替用户做决定),仅拦下操作并提示先停止。
241
- // API 已在 dsh-agent .d.ts 公开面核验:AgentRegistry.get(id) / list() 均公开,
242
- // Agent.status ∈ 'idle' | 'running',Agent.session.header.cwd 是活动会话的工作区。
243
- // 守卫式访问只为防御「未来版本改名 / agent 服务未装配」,失败视为「不忙」
244
- // (fail-open,不阻断主流程,只损失保护)。
245
- function agentBusy(sessionId, root) {
246
- let reg = null
247
- try { reg = ctx.agents } catch (error) { return false }
248
- if (!reg) return false
249
- try {
250
- if (typeof reg.list === 'function') {
251
- for (const agent of reg.list()) {
252
- if (!agent || agent.status !== 'running') continue
253
- // 发起会话自身的 agent(覆盖最常见场景:本会话 agent 在跑)
254
- if (sessionId && String(agent.id) === String(sessionId)) return true
255
- // 跨会话同工作区:另一会话的 agent 在同一个目录跑也会被文件回退波及
256
- const cwd = agent.session && agent.session.header && agent.session.header.cwd
257
- if (root && cwd && normalizeWorkdir(cwd) === normalizeWorkdir(root)) return true
258
- }
259
- return false
260
- }
261
- if (sessionId && typeof reg.get === 'function') {
262
- const agent = reg.get(sessionId)
263
- return Boolean(agent && agent.status === 'running')
264
- }
265
- } catch (error) { /* fail-open */ }
266
- return false
267
- }
268
-
269
- // 解析 storesDumpScript 的定界输出:dir → { root, entries }。逐行状态机
270
- // (==DIR / ROOT / INDEXBEGIN..INDEXEND),单个 store JSON 损坏只丢它自己。
271
- function parseStoresDump(text) {
272
- const map = new Map()
273
- let cur = null
274
- let inIndex = false
275
- let indexLines = []
276
- function flush() {
277
- if (!cur) return
278
- const raw = indexLines.join('\n').trim()
279
- if (raw) {
280
- try {
281
- const arr = JSON.parse(raw)
282
- if (Array.isArray(arr)) cur.entries = arr
283
- } catch (error) { /* index 损坏按无索引处理 */ }
284
- }
285
- map.set(cur.dir, cur)
286
- cur = null
287
- }
288
- for (const line of String(text).split(/\r?\n/)) {
289
- if (line.indexOf('==DIR ') === 0) { flush(); cur = { dir: line.slice(6).trim(), root: null, entries: null }; inIndex = false; indexLines = []; continue }
290
- if (!cur) continue
291
- if (line.indexOf('ROOT ') === 0) { const v = line.slice(5).trim(); cur.root = v || null; continue }
292
- if (line === 'INDEXBEGIN') { inIndex = true; indexLines = []; continue }
293
- if (line === 'INDEXEND') { inIndex = false; continue }
294
- if (inIndex) indexLines.push(line)
295
- }
296
- flush()
297
- return map
298
- }
299
-
300
- // 一条 shell dump 全部 store 元数据(容器子目录 + 降级候选目录的
301
- // root.txt 与 index.json),manage list 与 delete 兜底共用。
302
- async function dumpStores() {
303
- const container = await rt.resolveHomeContainer()
304
- const extras = Array.from(await collectCwds()).map((cwd) => cwd + (rt.isWin ? '\\' : '/') + '.dsh-recall-snapshots')
305
- try {
306
- const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || '', extras), { timeoutMs: 120000, stdoutMaxBytes: 8388608 }))
307
- return parseStoresDump(text)
308
- } catch (error) {
309
- return new Map()
310
- }
311
- }
312
-
313
- // 磁盘反查某快照归属的 store:dump 全部 index 后按 id 查找,root 取
314
- // 条目自带字段 → root.txt → 内存映射。delete 的兜底路径用它消灭
315
- // 「列表可见但内存缺失 误报不存在」。
316
- async function locateSnapshotOnDisk(id) {
317
- if (!id) return null
318
- const dump = await dumpStores()
319
- const hints = new Map()
320
- for (const [root, st] of state.stores.entries()) {
321
- if (st && st.dir) hints.set(st.dir, root)
322
- }
323
- for (const [dir, info] of dump) {
324
- const hit = (info.entries || []).find((e) => e && e.id === id)
325
- if (!hit) continue
326
- const root = (typeof hit.root === 'string' && hit.root) || info.root || hints.get(dir) || null
327
- if (!root) continue
328
- try {
329
- const store = await rt.resolveStore(root)
330
- if (store) return { store, root }
331
- } catch (error) { /* 单个 root 解析失败继续找 */ }
332
- }
333
- return null
334
- }
335
-
336
- // 统一错误映射:业务失败与系统异常分离,文案与诊断解耦。code
337
- // Client 做分支判断(BODY_TOO_LARGE 等),message 直接展示。
338
- function errBody(error) {
339
- const text = String(error && error.message ? error.message : error)
340
- if (text === 'BODY_TOO_LARGE') return { ok: false, code: 'BODY_TOO_LARGE', message: '请求体超过 1MB 上限' }
341
- return { ok: false, code: 'ERROR', message: text }
342
- }
343
-
344
- // ---- 端点表:name handler(args) 回包体。统一 try/catch 与入队
345
- // 策略写在这里,端点主体只写业务。queued 标记的端点与快照/gc 共用同
346
- // 一条串行队列——preview/execute 内部都跑 git add -A,不入队会与
347
- // 进行中的快照争 index.lock(曾只在 snapshot-info 入队,是并发隐患)。
348
- // 队列入队即占住后续快照,队列失败不堵队(catch 就地消化)。
349
- function enqueue(task) {
350
- const run = state.queue.then(task)
351
- state.queue = run.catch(() => {})
352
- return run
353
- }
354
-
355
- // 通用并发限制器:冷会话标题/消息文本补齐会 readSession 整日志解压,
356
- // 首次大量冷数据时全量 Promise.all 会同时压垮磁盘/CPU。限制同时最多
357
- // CONCURRENCY 个任务,剩余排队执行;这是纯内存调度,不依赖额外依赖。
358
- async function runLimited(tasks, concurrency) {
359
- const limit = concurrency > 0 ? concurrency : 4
360
- let index = 0
361
- const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
362
- while (index < tasks.length) {
363
- const task = tasks[index++]
364
- await task()
365
- }
366
- })
367
- await Promise.all(workers)
368
- }
369
-
370
- // 收集全量快照记录(内存 + 磁盘 dump 并集),供树形管理的按工作区/
371
- // 会话批量删除使用。返回 Map<消息ID, {id, root, sessionId, time}>;
372
- // 磁盘条目缺 root 时按 store 的 root.txt/内存映射补全(与 manage list
373
- // 同一条解析链)。去重只按 id——同一消息 ID 全局唯一。
374
- async function collectAllSnapshotRecords() {
375
- const records = new Map()
376
- function add(id, root, sessionId, time) {
377
- if (!id || typeof id !== 'string') return
378
- const old = records.get(id)
379
- if (!old) {
380
- records.set(id, {
381
- id,
382
- root: root || null,
383
- sessionId: sessionId || null,
384
- time: typeof time === 'number' ? time : 0
385
- })
386
- return
387
- }
388
- // 同一消息 ID 可能出现磁盘先占位、内存后补全的情况:用更全的
389
- // root/sessionId/time 覆盖旧值,避免树形节点归到「未知」导致批量
390
- // 删除按工作区/会话匹配不到。
391
- if (!old.root && root) old.root = root
392
- if (!old.sessionId && sessionId) old.sessionId = sessionId
393
- if (!old.time && time) old.time = time
394
- }
395
- // 内存视图:当前会话/预热过的 store 已载入,秒回
396
- for (const [id, s] of state.snapshots.entries()) {
397
- if (s) add(id, s.root, s.sessionId, s.time)
398
- }
399
- // 磁盘全量:冷启动/非活跃工作区的快照也在这里
400
- const dump = await dumpStores()
401
- const hints = new Map()
402
- for (const [root, st] of state.stores.entries()) {
403
- if (st && st.dir) hints.set(st.dir, root)
404
- }
405
- for (const [dir, info] of dump) {
406
- const baseRoot = info.root || hints.get(dir) || null
407
- for (const e of info.entries || []) {
408
- if (!e || typeof e.id !== 'string') continue
409
- add(e.id, (typeof e.root === 'string' && e.root) || baseRoot, e.sessionId, e.time)
410
- }
411
- }
412
- return records
413
- }
414
-
415
- // 按过滤条件批量删除快照(工作区/会话两个树节点共用):先收集匹配
416
- // id 并按 root 分组,再整体进串行队列——与快照/gc 互斥,避免 git 锁
417
- // 竞态。每个 root 先 purge tag 再补载索引后重写 index.json,防止冷启动
418
- // 时用残缺内存覆盖同 store 其余磁盘快照。
419
- async function deleteSnapshotsByFilter(match, sessionId) {
420
- const records = await collectAllSnapshotRecords()
421
- const byRoot = new Map()
422
- for (const rec of records.values()) {
423
- if (!match(rec) || !rec.root) continue
424
- if (!byRoot.has(rec.root)) byRoot.set(rec.root, [])
425
- byRoot.get(rec.root).push(rec.id)
426
- }
427
- let deleted = 0
428
- await enqueue(async () => {
429
- for (const [root, rootIds] of byRoot) {
430
- let store = state.stores.get(root)
431
- if (!store) {
432
- try { store = await rt.resolveStore(root) } catch (error) { store = null }
433
- }
434
- if (!store) continue
435
- try {
436
- if (state.gitExe) {
437
- // tag 分块删除:win32 命令行有 32767 字符上限,整批传大量 tag 会
438
- // 在长历史工作区上爆掉;与 maintenance.purgeSession 同款 100 个/块。
439
- const tags = rootIds.map((id) => 'snap-' + id)
440
- for (let i = 0; i < tags.length; i += 100) {
441
- await rt.runShell(rt.scripts.purgeTagsScript(store, state.gitExe, tags.slice(i, i + 100)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
442
- }
443
- }
444
- if (!state.indexLoaded.has(root)) {
445
- try { await snaps.loadIndex(root, sessionId) } catch (error) { /* 载入失败照常重写,退化为旧行为 */ }
446
- }
447
- for (const id of rootIds) state.snapshots.delete(id)
448
- await snaps.saveIndex(root, sessionId)
449
- deleted += rootIds.length
450
- } catch (error) {
451
- // 单个 root 失败不阻断其他 root:与 maintenance.purgeSession 同款
452
- // best-effort,错误进状态页可见的错误缓冲,剩余 root 继续清理。
453
- rt.recordError('recall batch delete failed for ' + root + ': ' + String(error))
454
- }
455
- }
456
- listCache.items = null
457
- })
458
- return deleted
459
- }
460
-
461
- // 删除所有工作区的全部快照。树形管理的「工作区/会话」批量删除以
462
- // index.json 中的记录为目标,适合保留其他节点;但「全部删除」必须把 git
463
- // tag 当作真相源:index 可能因旧版/崩溃/手动修复而为空或过期,不能因为
464
- // 索引里没有条目就漏删真实快照。磁盘枚举到的 store 即使 root.txt 丢失也
465
- // 直接按目录操作,避免 resolveStore 新建同 root 的另一个空 store。
466
- async function deleteAllSnapshots() {
467
- return enqueue(async () => {
468
- const stores = new Map()
469
- for (const [root, store] of state.stores.entries()) {
470
- if (store && store.dir) stores.set(store.dir, { store, root })
471
- }
472
- const dump = await dumpStores()
473
- for (const [dir, info] of dump.entries()) {
474
- const known = stores.get(dir)
475
- if (known) {
476
- if (!known.root && info.root) known.root = info.root
477
- known.entries = info.entries || []
478
- } else {
479
- stores.set(dir, {
480
- // 全局删除只动该目录下的 git/index;不必、也不能依赖可反解的 root。
481
- store: rt.storeFromDir(dir, false),
482
- root: info.root || null,
483
- entries: info.entries || []
484
- })
485
- }
486
- }
487
-
488
- if (stores.size === 0) return { deleted: 0, stores: 0, failed: 0 }
489
-
490
- const gitExe = await rt.resolveGit()
491
- if (!gitExe) {
492
- const message = '未检测到 git CLI,无法验证并删除快照 tag'
493
- rt.recordError('recall delete all failed: ' + message)
494
- return { deleted: 0, stores: 0, failed: stores.size || 1, message }
495
- }
496
-
497
- let deleted = 0
498
- let clearedStores = 0
499
- let failed = 0
500
- for (const { store, root } of stores.values()) {
501
- try {
502
- // 先列出实际 tag;不要使用 entries 推导 tag,entries 是可丢失缓存。
503
- const output = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 120000, stdoutMaxBytes: 4194304 })
504
- const tags = rt.scripts.stripBom(output).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf('snap-') === 0)
505
- for (let i = 0; i < tags.length; i += 100) {
506
- await rt.runShell(rt.scripts.purgeTagsScript(store, gitExe, tags.slice(i, i + 100)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
507
- }
508
- // purgeTagsScript 为幂等 best-effort,故必须回读校验,避免脚本吞掉
509
- // 个别失败后仍错误地把 index.json 清空。
510
- const remainedOutput = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 120000, stdoutMaxBytes: 4194304 })
511
- const remained = rt.scripts.stripBom(remainedOutput).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf('snap-') === 0)
512
- if (remained.length) throw new Error('仍有 ' + remained.length + ' 个快照 tag 未删除')
513
-
514
- // tag 清理被确认后才清空索引。直接写已枚举的 store,兼容 root.txt
515
- // 缺失/错位的旧仓库;不能调用 saveIndex(root),后者会重新按 root 寻址。
516
- await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', '[]')
517
- for (const tag of tags) state.snapshots.delete(tag.slice('snap-'.length))
518
- if (root) {
519
- for (const [id, snap] of state.snapshots.entries()) {
520
- if (snap && snap.root === root) state.snapshots.delete(id)
521
- }
522
- state.indexLoaded.add(root)
523
- }
524
- deleted += tags.length
525
- clearedStores += 1
526
- } catch (error) {
527
- failed += 1
528
- rt.recordError('recall delete all failed for ' + store.dir + ': ' + String(error))
529
- }
530
- }
531
- // list 既合并内存也 dump 磁盘;无论完全/部分完成都必须失效,才能让
532
- // 成功删除的 store 立即从树上消失,而失败 store 仍保留供用户重试。
533
- listCache.items = null
534
- return { deleted, stores: clearedStores, failed }
535
- })
536
- }
537
-
538
- const endpoints = {
539
- 'init': async (args) => {
540
- if (!supported) {
541
- return { ok: false, root: null, notice: { unsupported: true } }
542
- }
543
- const sessionId = args && args.sessionId ? String(args.sessionId) : null
544
- const root = await rt.resolveRoot(sessionId)
545
- let notice = null
546
- if (root) {
547
- let store = await rt.resolveStore(root)
548
- store = await rt.tryUpgradeToHome(root)
549
- await rt.ensureGit(root, store)
550
- await snaps.loadIndex(root, sessionId)
551
- await snaps.rebuildOrphans(root, sessionId)
552
- rt.cleanupLegacy(root)
553
- // 降级状态随 init 下发,Client 弹一次性提示(每次页面加载各弹一次):
554
- // gitMissing=未检测到 git CLI(撤回按钮不出现);homeFallback=home
555
- // 不可写,快照降级存进项目内 .dsh-recall-snapshots。
556
- notice = {
557
- gitMissing: state.gitExe === '',
558
- homeFallback: store ? !store.home : false
559
- }
560
- }
561
- // 顺带下发客户端行为开关(fillDraft 等):Client 无须为读配置单开请求,
562
- // init 是每会话必经的预热通道
563
- return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal } }
564
- },
565
-
566
- 'snapshot-info': async (args) => {
567
- const id = args && args.messageId ? String(args.messageId) : ''
568
- const snap = state.snapshots.get(id)
569
- // 失败/跳过/熔断反馈(issue #7 失败可见性):客户端轮询到 failed 即
570
- // 终止轮询并 toast,不再空等 20 次;has 时附带 skipped 让用户知道
571
- // fail-open 跳过了哪些路径
572
- const feedback = await snaps.feedbackFor(args && args.sessionId, id)
573
- return { has: Boolean(snap), time: snap ? snap.time : null, id, ...feedback }
574
- },
575
-
576
- 'preview': async (args) => {
577
- const id = args && args.messageId ? String(args.messageId) : ''
578
- const sessionId = args && args.sessionId ? String(args.sessionId) : null
579
- // P0-1:目标工作区 agent 运行中直接拒绝预览(避免用户确认时文件被
580
- // agent 改动,预览清单与实际回退内容脱节)。同会话优先命中(最常见
581
- // 场景),快照存在时叠加跨会话同工作区检查。
582
- const snap = state.snapshots.get(id)
583
- if (agentBusy(sessionId, snap ? snap.root : null)) return { ok: false, code: 'AGENT_BUSY', message: 'Agent 正在运行中,请先停止后再撤回' }
584
- const result = await enqueue(() => snaps.diffFor(id))
585
- if (result === null) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
586
- const snap2 = state.snapshots.get(id)
587
- const cutSeq = await snaps.resolveCutSeq(sessionId, id)
588
- return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq }
589
- },
590
-
591
- 'execute': async (args) => {
592
- const id = args && args.messageId ? String(args.messageId) : ''
593
- const sessionId = args && args.sessionId ? String(args.sessionId) : null
594
- const result = await enqueue(async () => {
595
- const snap = state.snapshots.get(id)
596
- if (!snap) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
597
- const store = state.stores.get(snap.root)
598
- if (!store) return { ok: false, code: 'NO_STORE', message: '快照存储不可用' }
599
- // P0-1:队列内第一步——执行前再查一次 agent 状态。检查放在互斥
600
- // 队列内,检查后紧接执行,中间不可能插进别的操作,窗口为零。
601
- if (agentBusy(sessionId, snap.root)) return { ok: false, code: 'AGENT_BUSY', message: 'Agent 正在运行中,请先停止后再撤回' }
602
- // P0-3:preview→execute 失效校验。只由带 previewTotal 的新版
603
- // Client 触发(老版本/直调 API 不带则跳过,向后兼容)。校验失败
604
- // 连安全快照都不打——省一次全量 add。同数不同文件的边缘情形由
605
- // 下方 pre-rollback 安全快照兜底。
606
- if (args && typeof args.previewTotal === 'number') {
607
- const fresh = await snaps.diffFor(id)
608
- if (!fresh || fresh.total !== args.previewTotal) {
609
- return { ok: false, code: 'STALE', message: '预览后项目文件发生了变化,请重新预览确认' }
610
- }
611
- }
612
- // 回退前自动打安全快照:回退覆盖工作区且不回写 index(旧的
613
- // 「当前状态」从此无任何快照可找回),用消息 ID 打 tag 会与该消息
614
- // 的既有快照碰撞,故用独立前缀的时间戳 tag——不进 index.json
615
- // (列表不展示),但孤儿重建/手动 git tag 仍能找到它,误回退后
616
- // 用户可让插件从该 tag 恢复,堵住唯一的不可逆操作缺口。
617
- const safetyId = 'pre-rollback-' + Date.now()
618
- try {
619
- await rt.runShell(rt.scripts.snapshotScript(snap.root, store, state.gitExe, safetyId, cfg.baseExcludes), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
620
- } catch (error) {
621
- // 安全快照失败不阻断回退本身:用户已确认覆盖,记录后照原计划执行
622
- rt.recordError('recall safety snapshot failed: ' + String(error))
623
- }
624
- return snaps.rollbackFor(id)
625
- })
626
- if (!result.ok) return result
627
- // 文件回退后再解析切点:切点只依赖会话日志,与快照是否删除无关(命中缓存,瞬时)
628
- const cutSeq = await snaps.resolveCutSeq(sessionId, id)
629
- return { ok: true, count: result.count, cutSeq }
630
- },
631
-
632
- 'exclude-get': async () => {
633
- // 设置页「撤回设置」标签的配置读取。不支持平台照常短路:Client
634
- // 显示不可用提示而不是空白表单,与 init 的 notice 语义对齐。
635
- if (!supported) return { ok: false, unsupported: true }
636
- // 30s 结果缓存:首次进入要并行 resolveStore + 逐文件 shell 读,
637
- // 二次打开/切标签不应重复付出这份代价;exclude-set 写入后失效。
638
- if (excludeCache.payload && Date.now() - excludeCache.at < 30000) return excludeCache.payload
639
- const byFile = await listExcludeFiles()
640
- // 并行读取各 exclude 文件内容:每个文件一条 shell,串行会放大延迟
641
- const files = await Promise.all(Array.from(byFile.entries()).map(async ([path, info]) => {
642
- let content = ''
643
- try { content = await snaps.readExclude(info.store) } catch (error) { content = '' }
644
- return { path, home: Boolean(info.store.home), roots: info.roots, content }
645
- }))
646
- const payload = { ok: true, files }
647
- excludeCache = { at: Date.now(), payload }
648
- return payload
649
- },
650
-
651
- 'exclude-set': async (args) => {
652
- if (!supported) return { ok: false, unsupported: true }
653
- const path = args && args.path ? String(args.path) : ''
654
- const content = args && typeof args.content === 'string' ? args.content : ''
655
- // 路径白名单:重新枚举当前已知 exclude 文件并要求精确命中,
656
- // 客户端伪造的任意路径在这里被拒(见 listExcludeFiles 注释)
657
- const byFile = await listExcludeFiles()
658
- const info = byFile.get(path)
659
- if (!info) return { ok: false, code: 'UNKNOWN_PATH', message: '未知的排除文件路径' }
660
- await snaps.writeExclude(info.store, content)
661
- // 写入后立即失效:设置页保存后刷新必须看到最新内容
662
- excludeCache.payload = null
663
- return { ok: true }
664
- },
665
-
666
- // 设置页「插件配置」卡片读配置:resolved 全量值 + 用户已覆盖字段(来自
667
- // settings.describe 的 user 层,字段在层里出现即用户覆盖)+ env 锁定
668
- // 字段(环境变量优先级最高,设置改不动)+ 可写性(只读 provider 禁存)。
669
- 'config-get': async () => {
670
- const envLocks = {
671
- gcSnaps: Boolean(process.env && process.env.DSH_RECALL_GC_SNAPS),
672
- gcHours: Boolean(process.env && process.env.DSH_RECALL_GC_HOURS),
673
- }
674
- let overridden = {}
675
- let writable = false
676
- try {
677
- const settings = ctx.get('settings')
678
- if (settings && typeof settings.describe === 'function') {
679
- const list = settings.describe()
680
- const ours = (Array.isArray(list) ? list : []).find((d) => d && d.ns === 'dsh-recall')
681
- if (ours && ours.user && typeof ours.user === 'object') overridden = ours.user
682
- writable = settings.writable !== false
683
- }
684
- } catch (error) { /* describe 不可用按「无覆盖」处理 */ }
685
- return {
686
- ok: true,
687
- values: {
688
- gcSnaps: cfg.gcSnaps,
689
- gcHours: cfg.gcHours,
690
- maxFileBytes: cfg.maxFileBytes,
691
- maxSnapshotsPerWorkspace: cfg.maxSnapshotsPerWorkspace,
692
- baseExcludes: cfg.baseExcludes.slice(),
693
- refillDraft: cfg.refillDraft,
694
- snapshotEnabled: cfg.snapshotEnabled,
695
- archiveOriginal: cfg.archiveOriginal,
696
- retentionDays: cfg.retentionDays,
697
- },
698
- overridden,
699
- envLocks,
700
- writable,
701
- }
702
- },
703
-
704
- // 设置页「插件配置」卡片存配置:白名单字段 + 类型清洗后经 settings.update
705
- // 写进用户层(schema 校验失败会在持久化前 reject,错误信息回显卡片),
706
- // watch 链路把新值热更新进 cfg,无需重启。
707
- 'config-set': async (args) => {
708
- const patch = args && args.patch && typeof args.patch === 'object' ? args.patch : {}
709
- const clean = {}
710
- if (patch.gcSnaps !== undefined) clean.gcSnaps = Number(patch.gcSnaps)
711
- if (patch.gcHours !== undefined) clean.gcHours = Number(patch.gcHours)
712
- if (patch.maxFileBytes !== undefined) clean.maxFileBytes = Number(patch.maxFileBytes)
713
- if (patch.maxSnapshotsPerWorkspace !== undefined) {
714
- const n = Number(patch.maxSnapshotsPerWorkspace)
715
- // 0 或负值 = 不限制(schema 由 number 校验,非法 NaN 在 settings.write 层被拒)
716
- if (!Number.isFinite(n)) return { ok: false, code: 'BAD_TYPE', message: '快照总量上限必须是数字' }
717
- clean.maxSnapshotsPerWorkspace = Math.max(0, n)
718
- }
719
- if (patch.refillDraft !== undefined) clean.refillDraft = Boolean(patch.refillDraft)
720
- if (patch.snapshotEnabled !== undefined) clean.snapshotEnabled = Boolean(patch.snapshotEnabled)
721
- if (patch.archiveOriginal !== undefined) clean.archiveOriginal = Boolean(patch.archiveOriginal)
722
- if (patch.retentionDays !== undefined) {
723
- const n = Number(patch.retentionDays)
724
- // 0/负值 = 不启用(schema 校验 base 由 number 承担,NaN 由 settings.write 拒)
725
- if (!Number.isFinite(n) || n < 0) return { ok: false, code: 'BAD_TYPE', message: '保留天数必须是 >= 0 的数字(0 表示不启用)' }
726
- clean.retentionDays = Math.trunc(n)
727
- }
728
- if (patch.baseExcludes !== undefined) {
729
- if (!Array.isArray(patch.baseExcludes)) return { ok: false, code: 'BAD_TYPE', message: 'baseExcludes 必须是字符串数组' }
730
- clean.baseExcludes = patch.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
731
- }
732
- if (!Object.keys(clean).length) return { ok: false, code: 'EMPTY_PATCH', message: '没有可写入的配置字段' }
733
- let settings = null
734
- try { settings = ctx.get('settings') } catch (error) { settings = null }
735
- if (!settings || typeof settings.update !== 'function') {
736
- return { ok: false, code: 'SETTINGS_UNAVAILABLE', message: '设置服务不可用:请在 profile 的 cordis.patch.yml 按 id: recall 覆盖配置' }
737
- }
738
- try {
739
- await settings.update('dsh-recall', clean)
740
- } catch (error) {
741
- return { ok: false, code: 'SETTINGS_WRITE_FAILED', message: '配置写入失败:' + String(error && error.message ? error.message : error) }
742
- }
743
- return { ok: true }
744
- },
745
-
746
- // 设置页「快照管理」卡片:列表 / 磁盘占用 / 单条删除 / 手动 gc。
747
- // 全部走串行队列——删除 tag 与 gc 与快照争的是同一个 git 仓库。
748
- 'manage': async (args) => {
749
- if (!supported) return { ok: false, unsupported: true }
750
- const op = args && args.op ? String(args.op) : 'list'
751
- const sessionId = args && args.sessionId ? String(args.sessionId) : null
752
- if (op === 'list') {
753
- // 结果缓存(30s + 删除/新快照失效):设置页反复打开、删除后刷新
754
- // 都会重拉列表,缓存让二次打开零 shell。缓存存的是全量排序数组,
755
- // 响应按请求的 limit 切片——「加载更多」无需重扫磁盘(S1-2)。
756
- const limitRaw = args && args.limit !== undefined ? Number(args.limit) : 200
757
- const safeLimit = Math.min(Math.max(Number.isFinite(limitRaw) ? Math.trunc(limitRaw) : 200, 1), 2000)
758
- if (listCache.items && Date.now() - listCache.at < 30000) {
759
- return { ok: true, items: listCache.items.slice(0, safeLimit), total: listCache.items.length }
760
- }
761
- const allItems = []
762
-
763
- // 磁盘全量:一条 shell dump(dumpStores 见其注释——旧实现每目录
764
- // 2-3 条 shell 串行跑,20 秒级慢的根因)。root 解析链:条目自带
765
- // root(新数据)→ root.txt → 内存 store 映射(store 目录名是
766
- // root 的单向 SHA256,磁盘上只有持久化记录能反查)。
767
- // 标题只查 live/缓存(liveTitleFast,同步瞬时)——冷会话标题由
768
- // Client 拿到列表后异步调 titles 补齐,列表首屏不等日志解压。
769
- const dump = await dumpStores()
770
- const hints = new Map()
771
- for (const [root, st] of state.stores.entries()) {
772
- if (st && st.dir) hints.set(st.dir, root)
773
- }
774
- // 去重只用 id(消息 ID 全局唯一):带 root 进 key 会让同一快照
775
- // 因「磁盘来源 root 缺失 / 内存来源 root 齐全」出现两条重复行
776
- const byId = new Map()
777
- function push(id, time, root, sessionId) {
778
- if (!id || typeof id !== 'string') return
779
- const old = byId.get(id)
780
- if (!old) {
781
- const rec = {
782
- id,
783
- time: typeof time === 'number' ? time : 0,
784
- root: root || null,
785
- workspace: root ? root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : null,
786
- sessionId: sessionId || null,
787
- sessionTitle: liveTitleFast(sessionId)
788
- }
789
- // 消息文本只放已确认值:live 命中字符串则带,否则不设字段。
790
- // 客户端据此判断「还没请求冷日志」;messages 端点补齐后字符串
791
- // 或 null 都会写入,null 表示确实无文本,避免每次刷新重复请求。
792
- const liveText = liveMessageTextFast(sessionId, id)
793
- if (liveText) rec.messageText = liveText
794
- byId.set(id, rec)
795
- allItems.push(rec)
796
- return
797
- }
798
- // 与 collectAllSnapshotRecords 同款补全:磁盘先占位、内存后补全
799
- // root 时,若按「首次命中即丢弃」会让树形一级节点落进未知工作区。
800
- if (!old.root && root) { old.root = root; old.workspace = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || null }
801
- if (!old.sessionId && sessionId) { old.sessionId = sessionId; old.sessionTitle = liveTitleFast(sessionId) }
802
- if (!old.messageText && id) { old.messageText = liveMessageTextFast(sessionId, id) }
803
- if (!old.time && time) old.time = time
804
- }
805
- for (const [dir, info] of dump) {
806
- const baseRoot = info.root || hints.get(dir) || null
807
- for (const e of info.entries || []) {
808
- if (!e || typeof e.id !== 'string') continue
809
- push(e.id, e.time, (typeof e.root === 'string' && e.root) || baseRoot, e.sessionId)
810
- }
811
- }
812
- // 内存兜底(刚拍未落盘的保险,正常已被磁盘 dump 覆盖)
813
- for (const [id, s] of state.snapshots.entries()) {
814
- push(id, s.time, s.root, s.sessionId)
815
- }
816
-
817
- allItems.sort((a, b) => (b.time || 0) - (a.time || 0))
818
- listCache = { at: Date.now(), items: allItems }
819
- return { ok: true, items: allItems.slice(0, safeLimit), total: allItems.length }
820
- }
821
- if (op === 'titles') {
822
- // 冷会话标题补齐(Client 异步二次请求):readSession 整日志解压 +
823
- // 重放校验,大日志 10 秒级——独立于列表让首屏即时。并发交给
824
- // sessionQuery 自带的 inspect 并发闸,这里全量并行发车。
825
- if (!supported) return { ok: false, unsupported: true }
826
- const ids = Array.from(new Set(
827
- (Array.isArray(args && args.sessionIds) ? args.sessionIds.map(String) : []).filter(Boolean)
828
- )).slice(0, 100)
829
- const out = {}
830
- // 并发限 4:冷标题 readSession 是重 IO,全量并发会把首次设置页
831
- // 的 shell/日志解压都挤在一起;限制后列表本身不受影响,标题渐进补齐。
832
- await runLimited(ids.map((sid) => async () => {
833
- if (out[sid] !== undefined) return
834
- let title = liveTitleFast(sid)
835
- if (title === null) {
836
- const query = ctx.get('sessionQuery')
837
- if (query && typeof query.readSession === 'function') {
838
- try {
839
- const log = await query.readSession(sid)
840
- title = titleFromEvents(log && log.events)
841
- } catch (error) { title = null }
842
- }
843
- }
844
- sessionTitles.set(sid, title)
845
- out[sid] = title
846
- }), 4)
847
- return { ok: true, titles: out }
848
- }
849
- if (op === 'messages') {
850
- // 冷会话消息文本补齐:与 titles 同款两段式,独立端点避免和标题
851
- // 请求的 sessionIds/messageIds 对应关系纠缠。输入 [{sessionId, messageId}],
852
- // 缺 live 文本的消息才需要补;同一会话多个消息共享一次 readSession。
853
- if (!supported) return { ok: false, unsupported: true }
854
- const reqs = Array.isArray(args && args.requests) ? args.requests.slice(0, 200) : []
855
- const bySession = new Map()
856
- for (const r of reqs) {
857
- const sid = r && r.sessionId ? String(r.sessionId) : null
858
- const mid = r && r.messageId ? String(r.messageId) : null
859
- if (!sid || !mid) continue
860
- if (!bySession.has(sid)) bySession.set(sid, [])
861
- bySession.get(sid).push(mid)
862
- }
863
- const texts = {}
864
- await runLimited(Array.from(bySession.entries()).map(([sid, mids]) => async () => {
865
- // 该会话所有消息都已缓存(含 null)时,不必 readSession 冷读
866
- const allCached = mids.every((mid) => messageTexts.has(String(sid) + '\u0000' + String(mid)))
867
- let log = null
868
- if (!allCached) {
869
- const query = ctx.get('sessionQuery')
870
- if (query && typeof query.readSession === 'function') {
871
- try {
872
- log = await query.readSession(sid)
873
- } catch (error) { log = null }
874
- }
875
- }
876
- for (const mid of mids) {
877
- const key = String(sid) + '\u0000' + String(mid)
878
- // 缓存命中(含 null)直接复用,避免已确认无文本的消息反复冷读
879
- if (messageTexts.has(key)) {
880
- texts[mid] = messageTexts.get(key)
881
- continue
882
- }
883
- let text = liveMessageTextFast(sid, mid)
884
- if (text === null && log && Array.isArray(log.events)) {
885
- text = messageTextFromEvents(log.events, mid)
886
- }
887
- // 与 sessionTitles 同款缓存策略:null 也缓存,避免冷会话反复
888
- // readSession 查同一个查不到文本的消息。
889
- messageTexts.set(key, text)
890
- texts[mid] = text
891
- }
892
- }), 4)
893
- return { ok: true, messageTexts: texts }
894
- }
895
- if (op === 'usage') {
896
- let bytes = 0
897
- // 存储健康统计(S2-4):仅对内存已知 store 计数——与汇总同源,
898
- // 冷启动预热未完成时不完整,属已知限制(见 plan-settings-ux S2-4)
899
- let homeStores = 0
900
- let fallbackStores = 0
901
- if (sessionId) {
902
- // 旧调用方(带会话上下文):单工作区占用
903
- const root = await rt.resolveRoot(sessionId)
904
- if (!root) return { ok: false, code: 'NO_ROOT', message: '无法解析当前工作区' }
905
- const store = state.stores.get(root)
906
- if (!store) return { ok: false, code: 'NO_STORE', message: '当前工作区尚未创建快照存储' }
907
- if (store.home) homeStores++
908
- else fallbackStores++
909
- const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
910
- bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
911
- } else {
912
- // 新调用方(settings.plugin.item 卡片无会话上下文):全部已知
913
- // store 汇总。store 全集取内存缓存(启动预热填齐);单个失败
914
- // 不影响汇总,best-effort。
915
- for (const store of state.stores.values()) {
916
- if (!store || !store.dir) continue
917
- if (store.home) homeStores++
918
- else fallbackStores++
919
- try {
920
- const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
921
- bytes += parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
922
- } catch (error) { /* 单 store 失败跳过 */ }
923
- }
924
- }
925
- return { ok: true, bytes, gitAvailable: state.gitExe !== '', homeStores, fallbackStores }
926
- }
927
- if (op === 'delete') {
928
- // 统一删除入口:scope=workspace 删除整个工作区全部快照;
929
- // scope=session 删除某会话全部快照;scope=snapshot(缺省)单条删除。
930
- // 树形管理每级右侧都有删除按钮,三种粒度共用此端点。
931
- const scope = args && args.scope ? String(args.scope) : 'snapshot'
932
- const root = args && args.root ? String(args.root) : null
933
- const targetSessionId = args && args.sessionId ? String(args.sessionId) : null
934
- const id = args && args.messageId ? String(args.messageId) : ''
935
- if (scope === 'workspace') {
936
- if (!root) return { ok: false, code: 'NO_ROOT', message: '缺少工作区路径' }
937
- const deleted = await deleteSnapshotsByFilter((rec) => rec.root === root, sessionId)
938
- return { ok: true, deleted }
939
- }
940
- if (scope === 'session') {
941
- if (!targetSessionId) return { ok: false, code: 'NO_SESSION', message: '缺少会话 ID' }
942
- // 树形中会话挂在具体工作区下,客户端会传 root 限定范围;不传则
943
- // 保持旧语义(删该会话全部工作区的快照),兼容老调用方。
944
- const deleted = await deleteSnapshotsByFilter(
945
- (rec) => rec.sessionId === targetSessionId && (!root || rec.root === root),
946
- sessionId
947
- )
948
- return { ok: true, deleted }
949
- }
950
- // 管理列表来自磁盘(跨工作区全量),而内存 state.snapshots 只含
951
- // 当前工作区 + 预热过的——冷启动或别的会话先点开列表时,列表里有、
952
- // 内存里没有,只查内存会把可删的快照误报「不存在」。解析链:
953
- // 内存命中 → Client 透传的条目 root → 磁盘 index 反查归属 store。
954
- let snap = state.snapshots.get(id) || null
955
- let snapRoot = snap ? snap.root : root
956
- let store = null
957
- if (snapRoot) {
958
- try { store = await rt.resolveStore(snapRoot) } catch (error) { store = null }
959
- }
960
- if (!store) {
961
- // 兜底:扫 home 容器与降级目录的 index.json,找到含该 id 的 store
962
- const found = await locateSnapshotOnDisk(id)
963
- if (found) { store = found.store; snapRoot = found.root }
964
- }
965
- if (!store) return { ok: false, code: 'NO_SNAPSHOT', message: '该快照不存在' }
966
- const finalStore = store
967
- const finalRoot = snapRoot
968
- await enqueue(async () => {
969
- if (state.gitExe) {
970
- await rt.runShell(rt.scripts.purgeTagsScript(finalStore, state.gitExe, ['snap-' + id]), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
971
- }
972
- // 兜底路径到这里时内存可能还没载入过该 root 的索引——直接
973
- // saveIndex 会用「只有内存条目」的列表覆盖 index.json,把同
974
- // store 其余磁盘快照一并抹掉。先 loadIndex 补齐内存视图(幂等,
975
- // indexLoaded 命中则零成本),再删目标条目后重写。
976
- if (!state.indexLoaded.has(finalRoot)) {
977
- try { await snaps.loadIndex(finalRoot, sessionId) } catch (error) { /* 载入失败照常重写,退化为旧行为 */ }
978
- }
979
- state.snapshots.delete(id)
980
- await snaps.saveIndex(finalRoot, sessionId)
981
- // 列表缓存失效:Client 删除后会立刻 refresh,必须看到最新状态
982
- listCache.items = null
983
- })
984
- return { ok: true }
985
- }
986
- if (op === 'deleteAll') {
987
- const result = await deleteAllSnapshots()
988
- if (result.failed > 0) {
989
- return {
990
- ok: false,
991
- code: 'PARTIAL_DELETE',
992
- deleted: result.deleted,
993
- message: result.message || ('已删除 ' + result.deleted + ' 条快照,但有 ' + result.failed + ' 个存储未完成;请查看最近错误后重试')
994
- }
995
- }
996
- return { ok: true, deleted: result.deleted, stores: result.stores }
997
- }
998
- if (op === 'gc') {
999
- // 带会话上下文:只 gc 该会话的工作区;无上下文(设置卡片):
1000
- // 全部已知 store 逐个 gc。两者都排进串行队列,与快照互斥。
1001
- const done = sessionId
1002
- ? await enqueue(() => maint.runGc(sessionId, true))
1003
- : await enqueue(() => maint.runGcAll())
1004
- return { ok: true, gc: Boolean(done) }
1005
- }
1006
- return { ok: false, code: 'UNKNOWN_OP', message: '未知的管理操作: ' + op }
1007
- },
1008
-
1009
- // 设置页「插件配置」卡片恢复默认:整段清空 user 层回组合 base——官方
1010
- // settings RPC 的 replace 明确是「restoration/reset 路径」(section:{}
1011
- // 重置为组合默认,见 dsh-host-apiproxy api/settings.d.ts S1-3 核验)。
1012
- // 比逐字段写默认值干净:重置后字段不再被标 user-overridden,schema 或
1013
- // cordis patch 行默认值变更时 reset 跟随,不冻结历史值。老版本服务
1014
- // 没有 replace 时降级 settings.update 写 DEFAULTS(user 层仍出现标记,
1015
- // 行为等价,缺陷见 plan-settings-ux S1-3)。
1016
- 'config-reset': async () => {
1017
- let settings = null
1018
- try { settings = ctx.get('settings') } catch (error) { settings = null }
1019
- if (!settings || typeof settings.update !== 'function') {
1020
- return { ok: false, code: 'SETTINGS_UNAVAILABLE', message: '设置服务不可用:请在 profile 的 cordis.patch.yml 按 id: recall 覆盖配置' }
1021
- }
1022
- try {
1023
- if (typeof settings.replace === 'function') {
1024
- await settings.replace('dsh-recall', {})
1025
- } else {
1026
- await settings.update('dsh-recall', Object.assign({}, DEFAULTS, { baseExcludes: DEFAULTS.baseExcludes.slice() }))
1027
- }
1028
- } catch (error) {
1029
- return { ok: false, code: 'SETTINGS_WRITE_FAILED', message: '恢复默认失败:' + String(error && error.message ? error.message : error) }
1030
- }
1031
- // 重置后热更运行中的 cfg(与 config-set 同链路的 watch 触发,这里做
1032
- // 双保险:descriptor 已变更,applyResolvedConfig 立即落地)
1033
- applyResolvedConfig(readSettings())
1034
- return { ok: true }
1035
- },
1036
-
1037
- // 设置页排障:最近错误(Host 侧 console.error 的页面可见副本)。
1038
- // S3-5:支持 { op: 'clear' } 清空页面可见缓冲——只清 state.errors,
1039
- // 不影响 console 本身的留痕;清空后设置页「最近错误」为空。
1040
- 'status': async (args) => {
1041
- if (args && args.op === 'clear') {
1042
- state.errors.length = 0
1043
- return { ok: true, errors: [] }
1044
- }
1045
- return { ok: true, errors: state.errors.slice(-20).reverse() }
1046
- }
1047
- }
1048
-
1049
- ctx.effect(() => webServer.register({
1050
- kind: 'prefix',
1051
- path: '/api/recall',
1052
- handler: async (req, res) => {
1053
- const path = (req.url || '').split('?')[0]
1054
- const name = path.replace(/^\/api\/recall\/?/, '').split('/')[0]
1055
- const endpoint = endpoints[name]
1056
- if (!endpoint) {
1057
- sendJson(res, 404, { ok: false, code: 'UNKNOWN_ENDPOINT', message: 'unknown endpoint: ' + name })
1058
- return
1059
- }
1060
- try {
1061
- const args = await readJsonBody(req)
1062
- sendJson(res, 200, await endpoint(args))
1063
- } catch (error) {
1064
- sendJson(res, 200, errBody(error))
1065
- }
1066
- }
1067
- }))
1068
-
1069
- // 快照事件与启动预热仅在受支持平台注册(见上方 supported 短路说明)
1070
- if (!supported) return
1071
-
1072
- // 每条用户消息触发快照(子代理会话跳过);快照完成后串行接一次维护
1073
- // (定期 gc / 会话清理)——排在同一条队列里,与快照天然互斥,无 git 锁竞态
1074
- ctx.on('session/event', (session, event) => {
1075
- if (!event || event.type !== 'user/message') return
1076
- const data = event.data
1077
- if (!data || typeof data.id !== 'string' || !data.id) return
1078
- const source = data.source
1079
- if (!source || source.kind !== 'user') return
1080
- if (session && session.header && session.header.origin === 'subagent') return
1081
- const messageId = data.id
1082
- const time = event.time
1083
- state.queue = state.queue
1084
- // 快照总开关(S2-1):cfg 按调用时读取,设置页热更即时生效。
1085
- // 关闭时只冻结新建,maybeMaintain 照常跑——已停增的存储仍需被
1086
- // gc/清理治理。
1087
- .then(() => (cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null))
1088
- .then(() => maint.maybeMaintain(session.id))
1089
- .then(() => { listCache.items = null })
1090
- .catch((error) => rt.recordError('recall snapshot error: ' + String(error)))
1091
- })
1092
-
1093
- // 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,
1094
- // 并清理旧版项目内 blobs 目录(home 可用时)。
1095
- // 不触发维护(gc/清理):开机预热应尽量轻,重活等第一条消息再按节流来。
1096
- // 冷启动时 ctx.sessions.list() 常为空(惰性载入),必须叠加
1097
- // sessionQuery.listSessions() 冷元数据,否则设置页首次打开仍要现场建 store。
1098
- // apply 不是 async,这里用 IIFE 把冷元数据扫描包成异步任务。
1099
- ;(async () => {
1100
- const warmupRoots = new Map()
1101
- for (const session of ctx.sessions.list()) {
1102
- const cwd = session && session.header && session.header.cwd
1103
- if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, session.id)
1104
- }
1105
- const querySvc = ctx.get('sessionQuery')
1106
- if (querySvc && typeof querySvc.listSessions === 'function') {
1107
- try {
1108
- const records = await querySvc.listSessions()
1109
- for (const record of records || []) {
1110
- // listSessions 记录形如 {header, live, persisted},会话 id 在
1111
- // header.id——此前误用顶层 record.id(恒 undefined),预热重建
1112
- // 的孤儿快照 sessionId 记为空,树形管理里会落进「已删除会话」。
1113
- const id = record && record.header && record.header.id ? record.header.id : null
1114
- const cwd = record && record.header && record.header.cwd
1115
- if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, id)
1116
- }
1117
- } catch (error) { /* 冷元数据不可用则退回 live 注册表 */ }
1118
- }
1119
- for (const [cwd, sessionId] of warmupRoots) {
1120
- Promise.resolve(rt.resolveStore(cwd))
1121
- .then(() => rt.tryUpgradeToHome(cwd))
1122
- .then((store) => rt.ensureGit(cwd, store))
1123
- .then(() => snaps.loadIndex(cwd, sessionId))
1124
- .then(() => snaps.rebuildOrphans(cwd, sessionId))
1125
- .then(() => rt.cleanupLegacy(cwd))
1126
- .catch(() => {})
1127
- }
1128
- })()
1129
- }
1
+ /**
2
+ * dsh-recall-plugin — Host 入口(持久插件形态,bundle 行挂载)
3
+ *
4
+ * 职责:装配各域模块(config / store / snapshots / maintenance / session-info /
5
+ * routes-core / routes-manage),通过 webServer 注册 /api/recall/* HTTP API
6
+ * 供 Client 半调用,并接线 session/event 快照触发与启动预热。
7
+ *
8
+ * 这是持久 npm 插件包的主入口(exports["."]),由 cordis.patch.yml 的
9
+ * insert 行挂载进 profile composition,DSH 重启后自动生效。业务逻辑已拆到
10
+ * lib/ 各域模块(routes-core / routes-manage / session-info),本文件只做
11
+ * 接线与 store 发现/执行工具,不承载端点业务。
12
+ */
13
+
14
+ import { createConfig, Config, DEFAULTS } from './config.js'
15
+ import { createRuntime } from './store.js'
16
+ import { createSnapshots, rescueRollback } from './snapshots.js'
17
+ import { createMaintenance } from './maintenance.js'
18
+ import { createSessionInfo, titleFromEvents, messageTextFromEvents } from './session-info.js'
19
+ import { createRoutesCore } from './routes-core.js'
20
+ import { createRoutesManage } from './routes-manage.js'
21
+ import { installSettingsSection } from '@deepseek-ai/dsh-settings'
22
+ import * as E from './errors.js'
23
+
24
+ export const name = 'dsh-recall-plugin'
25
+
26
+ // 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
27
+ // webServer(Client 半的 HTTP API 通道)。agents(dsh-base 无条件装配的
28
+ // agent 注册表)为 P0-1 运行中 agent 拦截读运行状态所需——cordis 4 要求
29
+ // 服务在 inject 中声明才可经 ctx.agents 访问,漏声明会抛
30
+ // "cannot get property ... without inject" 导致检查静默 fail-open(冒烟发现)。
31
+ // 其余服务按需 ctx.get。
32
+ export const inject = ['shell', 'sessions', 'webServer', 'agents']
33
+
34
+ // 入口配置 schema:cordis 加载器据此校验 insert 行 config 并填充默认值,
35
+ // 非法配置在插件加载时响亮失败(官方「插件配置」文档要求)。
36
+ export { Config }
37
+
38
+ // config cordis.patch.yml 的 insert 行 config 键下发(schema 默认值兜底),
39
+ // 设置页「插件配置」卡片的用户覆盖经 settings namespace 热更新进 cfg
40
+ // (见下方 installSettingsSection 接线)
41
+ export function apply(ctx, config) {
42
+ const webServer = ctx.webServer
43
+
44
+ const cfg = createConfig(config)
45
+ const rt = createRuntime(ctx, cfg)
46
+ const snaps = createSnapshots(ctx, rt, cfg)
47
+ const maint = createMaintenance(ctx, rt, snaps, cfg)
48
+ const state = rt.state
49
+
50
+ // ---- settings namespace「dsh-recall」:设置页「插件配置」分区正规接入 ----
51
+ // installSettingsSection(dsh-settings 官方辅助):settings 服务挂载后以
52
+ // Config schema 注册 namespace、组合 base 取入口 config;服务卸载时
53
+ // 源回退入口 config。解析层 = schema 默认 → 组合 base → 用户文档(设置
54
+ // 卡片写入、dsh-settings 持久化),变更经 watch 热更新进运行中的 cfg。
55
+ let readSettings = () => config
56
+ function applyResolvedConfig(resolved) {
57
+ Object.assign(cfg, createConfig(resolved && typeof resolved === 'object' ? resolved : {}))
58
+ }
59
+ try {
60
+ installSettingsSection(ctx, 'dsh-recall', Config, config, {
61
+ setSource: (fn) => { readSettings = fn },
62
+ onChange: () => applyResolvedConfig(readSettings()),
63
+ })
64
+ } catch (error) {
65
+ rt.recordError('recall settings namespace skipped: ' + String(error))
66
+ }
67
+
68
+ // 平台门控:win32 走 PowerShell 模板,linux/darwin 走 bash 模板。
69
+ // 其余平台干净短路:init 返回 unsupported,Client 弹一次性提示。
70
+ const supported = process.platform === 'win32' || process.platform === 'linux' || process.platform === 'darwin'
71
+
72
+ // 请求体上限:端点里 exclude-set 接受用户任意文本,无上限时可被无限
73
+ // POST 撑爆内存。1MB 远超正常配置体量,超限干净报错而不是悄悄截断。
74
+ const MAX_BODY_BYTES = 1048576
75
+
76
+ // 快照管理列表的结果缓存(apply 级跨请求共享):30s 缓存让二次打开即时;
77
+ // delete 与新快照落地时失效。listCache/excludeCache 是可变 holder——routes
78
+ // 层改属性(items/payload),本文件的事件接线读同一引用。
79
+ const listCache = { at: 0, items: null }
80
+ // 排除配置枚举缓存(30s):exclude-set 成功写入后立即失效。
81
+ const excludeCache = { at: 0, payload: null }
82
+
83
+ // 会话标题/文本两段式读取(live 秒回,冷会话由 Client 异步补齐)
84
+ const sessionInfo = createSessionInfo(ctx)
85
+
86
+ async function readJsonBody(req) {
87
+ const chunks = []
88
+ let size = 0
89
+ for await (const chunk of req) {
90
+ size += chunk.length
91
+ if (size > MAX_BODY_BYTES) throw new Error(E.RECALL_BODY_TOO_LARGE)
92
+ chunks.push(chunk)
93
+ }
94
+ const text = Buffer.concat(chunks).toString('utf8')
95
+ if (!text.trim()) return {}
96
+ return JSON.parse(text)
97
+ }
98
+
99
+ function sendJson(res, status, body) {
100
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
101
+ res.end(JSON.stringify(body))
102
+ }
103
+
104
+ // 统一错误映射:业务失败与系统异常分离,文案与诊断解耦。code
105
+ // Client 做分支判断,message 直接展示。
106
+ function errBody(error) {
107
+ const text = String(error && error.message ? error.message : error)
108
+ if (text === E.RECALL_BODY_TOO_LARGE) return { ok: false, code: E.RECALL_BODY_TOO_LARGE, message: '请求体超过 1MB 上限' }
109
+ return { ok: false, code: E.RECALL_ERROR, message: text }
110
+ }
111
+
112
+ // 队列入队即占住后续快照,队列失败不堵队(catch 就地消化)。
113
+ function enqueue(task) {
114
+ const run = state.queue.then(task)
115
+ state.queue = run.catch(() => {})
116
+ return run
117
+ }
118
+
119
+ // 通用并发限制器:冷会话标题/消息文本补齐会 readSession 整日志解压,
120
+ // 全量 Promise.all 会同时压垮磁盘/CPU,限制同时最多 concurrency 个任务。
121
+ async function runLimited(tasks, concurrency) {
122
+ const limit = concurrency > 0 ? concurrency : 4
123
+ let index = 0
124
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
125
+ while (index < tasks.length) {
126
+ const task = tasks[index++]
127
+ await task()
128
+ }
129
+ })
130
+ await Promise.all(workers)
131
+ }
132
+
133
+ // 归一化 cwd/root 路径用于跨会话同工作区比对:Windows 大小写不敏感 +
134
+ // 去掉尾部分隔符,避免 D:\Foo 与 d:\foo\ 误判为不同目录。
135
+ function normalizeWorkdir(path) {
136
+ if (!path) return ''
137
+ let p = String(path)
138
+ return (process.platform === 'win32' ? p.toLowerCase() : p).replace(/[\\/]+$/, '')
139
+ }
140
+
141
+ // 回退前重保护检查(P0-1):目标工作区有 agent 正在跑时拒绝预览/撤回。
142
+ // 保守策略——不做自动取消,仅拦下操作并提示先停止。守卫式访问只为防御
143
+ // 「未来版本改名 / agent 服务未装配」,失败视为「不忙」(fail-open)。
144
+ function agentBusy(sessionId, root) {
145
+ let reg = null
146
+ try { reg = ctx.agents } catch (error) { return false }
147
+ if (!reg) return false
148
+ try {
149
+ if (typeof reg.list === 'function') {
150
+ for (const agent of reg.list()) {
151
+ if (!agent || agent.status !== 'running') continue
152
+ // 发起会话自身的 agent(覆盖最常见场景:本会话 agent 在跑)
153
+ if (sessionId && String(agent.id) === String(sessionId)) return true
154
+ // 跨会话同工作区:另一会话的 agent 在同一个目录跑也会被文件回退波及
155
+ const cwd = agent.session && agent.session.header && agent.session.header.cwd
156
+ if (root && cwd && normalizeWorkdir(cwd) === normalizeWorkdir(root)) return true
157
+ }
158
+ return false
159
+ }
160
+ if (sessionId && typeof reg.get === 'function') {
161
+ const agent = reg.get(sessionId)
162
+ return Boolean(agent && agent.status === 'running')
163
+ }
164
+ } catch (error) { /* fail-open */ }
165
+ return false
166
+ }
167
+
168
+ // 枚举当前全部已知 exclude 文件并按路径去重。exclude-get 直接消费结果;
169
+ // exclude-set 用它做路径白名单校验,堵死「借 API 写任意文件」的通道。
170
+ async function listExcludeFiles() {
171
+ const roots = new Set(state.stores.keys())
172
+ for (const session of ctx.sessions.list()) {
173
+ const cwd = session && session.header && session.header.cwd
174
+ if (cwd) roots.add(cwd)
175
+ }
176
+ const byFile = new Map()
177
+ await Promise.all(Array.from(roots).map(async (root) => {
178
+ try {
179
+ const store = await rt.resolveStore(root)
180
+ if (store && !byFile.has(store.excludeFile)) byFile.set(store.excludeFile, { store, roots: [] })
181
+ byFile.get(store.excludeFile).roots.push(root)
182
+ } catch (error) {
183
+ /* 单个根解析失败只影响它自己,不拖垮整个列表 */
184
+ }
185
+ }))
186
+ // 磁盘兜底:冷启动时会话注册表为空(惰性载入),但 home 容器目录可能
187
+ // 早已存在(历史快照)。容器在 ⇒ 共享 exclude.txt 可编辑。
188
+ try {
189
+ const container = await rt.resolveHomeContainer()
190
+ if (container) {
191
+ const probe = rt.scripts.stripBom(await rt.runShell(rt.scripts.dirExistsScript(container), { stdoutMaxBytes: 4096 })).trim()
192
+ if (probe === 'YES') {
193
+ const excludeFile = container + (rt.isWin ? '\\' : '/') + 'exclude.txt'
194
+ if (!byFile.has(excludeFile)) {
195
+ // store:仅承载 readExclude/writeExclude 用到的 excludeFile home
196
+ byFile.set(excludeFile, { store: { dir: container, home: true, excludeFile }, roots: [] })
197
+ }
198
+ }
199
+ }
200
+ } catch (error) {
201
+ /* 兜底失败退回注册表结果 */
202
+ }
203
+ return byFile
204
+ }
205
+
206
+ // 工作区 cwd 全集:live 注册表只是子集,sessionQuery.listSessions 是
207
+ // 「live + 磁盘冷元数据」的完整语料。manage list 与 delete 兜底共用。
208
+ async function collectCwds() {
209
+ const cwds = new Set()
210
+ for (const session of ctx.sessions.list()) {
211
+ const cwd = session && session.header && session.header.cwd
212
+ if (cwd) cwds.add(cwd)
213
+ }
214
+ try {
215
+ const querySvc = ctx.get('sessionQuery')
216
+ if (querySvc && typeof querySvc.listSessions === 'function') {
217
+ for (const record of await querySvc.listSessions()) {
218
+ const cwd = record && record.header && record.header.cwd
219
+ if (cwd) cwds.add(cwd)
220
+ }
221
+ }
222
+ } catch (error) { /* 冷元数据不可用时退回 live 注册表 */ }
223
+ return cwds
224
+ }
225
+
226
+ // 解析 storesDumpScript 的定界输出:dir → { root, entries }。逐行状态机
227
+ // (==DIR / ROOT / INDEXBEGIN..INDEXEND),单个 store JSON 损坏只丢它自己。
228
+ function parseStoresDump(text) {
229
+ const map = new Map()
230
+ let cur = null
231
+ let inIndex = false
232
+ let indexLines = []
233
+ function flush() {
234
+ if (!cur) return
235
+ const raw = indexLines.join('\n').trim()
236
+ if (raw) {
237
+ try {
238
+ const arr = JSON.parse(raw)
239
+ if (Array.isArray(arr)) cur.entries = arr
240
+ } catch (error) { /* index 损坏按无索引处理 */ }
241
+ }
242
+ map.set(cur.dir, cur)
243
+ cur = null
244
+ }
245
+ for (const line of String(text).split(/\r?\n/)) {
246
+ if (line.indexOf('==DIR ') === 0) { flush(); cur = { dir: line.slice(6).trim(), root: null, entries: null }; inIndex = false; indexLines = []; continue }
247
+ if (!cur) continue
248
+ if (line.indexOf('ROOT ') === 0) { const v = line.slice(5).trim(); cur.root = v || null; continue }
249
+ if (line === 'INDEXBEGIN') { inIndex = true; indexLines = []; continue }
250
+ if (line === 'INDEXEND') { inIndex = false; continue }
251
+ if (inIndex) indexLines.push(line)
252
+ }
253
+ flush()
254
+ return map
255
+ }
256
+
257
+ // 一条 shell dump 全部 store 元数据(容器子目录 + 降级候选目录的 root.txt
258
+ // 与 index.json),manage list 与 delete 兜底共用。
259
+ async function dumpStores() {
260
+ const container = await rt.resolveHomeContainer()
261
+ const extras = Array.from(await collectCwds()).map((cwd) => cwd + (rt.isWin ? '\\' : '/') + '.dsh-recall-snapshots')
262
+ try {
263
+ const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || '', extras), { timeoutMs: 120000, stdoutMaxBytes: 8388608 }))
264
+ return parseStoresDump(text)
265
+ } catch (error) {
266
+ return new Map()
267
+ }
268
+ }
269
+
270
+ // 磁盘反查某快照归属的 store:dump 全部 index 后按 id 查找。delete
271
+ // 兜底路径用它消灭「列表可见但内存缺失 ⇒ 误报不存在」。
272
+ async function locateSnapshotOnDisk(id) {
273
+ if (!id) return null
274
+ const dump = await dumpStores()
275
+ const hints = new Map()
276
+ for (const [root, st] of state.stores.entries()) {
277
+ if (st && st.dir) hints.set(st.dir, root)
278
+ }
279
+ for (const [dir, info] of dump) {
280
+ const hit = (info.entries || []).find((e) => e && e.id === id)
281
+ if (!hit) continue
282
+ const root = (typeof hit.root === 'string' && hit.root) || info.root || hints.get(dir) || null
283
+ if (!root) continue
284
+ try {
285
+ const store = await rt.resolveStore(root)
286
+ if (store) return { store, root }
287
+ } catch (error) { /* 单个 root 解析失败继续找 */ }
288
+ }
289
+ return null
290
+ }
291
+
292
+ // 收集全量快照记录(内存 + 磁盘 dump 并集),供树形管理的按工作区/会话
293
+ // 批量删除使用。去重只按 id——同一消息 ID 全局唯一。
294
+ async function collectAllSnapshotRecords() {
295
+ const records = new Map()
296
+ function add(id, root, sessionId, time) {
297
+ if (!id || typeof id !== 'string') return
298
+ const old = records.get(id)
299
+ if (!old) {
300
+ records.set(id, {
301
+ id,
302
+ root: root || null,
303
+ sessionId: sessionId || null,
304
+ time: typeof time === 'number' ? time : 0
305
+ })
306
+ return
307
+ }
308
+ // 同一消息 ID 可能出现磁盘先占位、内存后补全的情况:用更全的
309
+ // root/sessionId/time 覆盖旧值,避免树形节点归到「未知」导致批量
310
+ // 删除按工作区/会话匹配不到。
311
+ if (!old.root && root) old.root = root
312
+ if (!old.sessionId && sessionId) old.sessionId = sessionId
313
+ if (!old.time && time) old.time = time
314
+ }
315
+ for (const [id, s] of state.snapshots.entries()) {
316
+ if (s) add(id, s.root, s.sessionId, s.time)
317
+ }
318
+ const dump = await dumpStores()
319
+ const hints = new Map()
320
+ for (const [root, st] of state.stores.entries()) {
321
+ if (st && st.dir) hints.set(st.dir, root)
322
+ }
323
+ for (const [dir, info] of dump) {
324
+ const baseRoot = info.root || hints.get(dir) || null
325
+ for (const e of info.entries || []) {
326
+ if (!e || typeof e.id !== 'string') continue
327
+ add(e.id, (typeof e.root === 'string' && e.root) || baseRoot, e.sessionId, e.time)
328
+ }
329
+ }
330
+ return records
331
+ }
332
+
333
+ // ---- 端点表组装:核心路由 + 管理路由,合并进单一 endpoints 对象供
334
+ // webServer 前缀路由分发(端点名是 path 第一段,故无跨域命名冲突)。
335
+ const deps = {
336
+ ctx, rt, snaps, maint, state, cfg, supported,
337
+ enqueue, agentBusy, runLimited, readJsonBody, sendJson, errBody,
338
+ listExcludeFiles, dumpStores, locateSnapshotOnDisk, collectAllSnapshotRecords,
339
+ listCache, excludeCache, sessionInfo, titleFromEvents, messageTextFromEvents,
340
+ // readSettings 传活绑定而非当前引用(A1):dsh-settings 服务晚挂载时
341
+ // setSource 会重绑定 readSettings——按值捕获的副本停在旧闭包(入口
342
+ // config),config-reset 会按旧值「恢复默认」。活绑定让消费者每次调用
343
+ // 都取到当前闭包。
344
+ applyResolvedConfig, readSettings: () => readSettings(), DEFAULTS, rescueRollback, E,
345
+ }
346
+ const endpoints = {
347
+ ...createRoutesCore(deps),
348
+ ...createRoutesManage(deps),
349
+ }
350
+
351
+ ctx.effect(() => webServer.register({
352
+ kind: 'prefix',
353
+ path: '/api/recall',
354
+ handler: async (req, res) => {
355
+ const path = (req.url || '').split('?')[0]
356
+ const name = path.replace(/^\/api\/recall\/?/, '').split('/')[0]
357
+ const endpoint = endpoints[name]
358
+ if (!endpoint) {
359
+ sendJson(res, 404, { ok: false, code: E.RECALL_UNKNOWN_ENDPOINT, message: 'unknown endpoint: ' + name })
360
+ return
361
+ }
362
+ try {
363
+ const args = await readJsonBody(req)
364
+ sendJson(res, 200, await endpoint(args))
365
+ } catch (error) {
366
+ sendJson(res, 200, errBody(error))
367
+ }
368
+ }
369
+ }))
370
+
371
+ // 快照事件与启动预热仅在受支持平台注册(见上方 supported 短路说明)
372
+ if (!supported) return
373
+
374
+ // 每条用户消息触发快照(子代理会话跳过);快照完成后串行接一次维护
375
+ // (定期 gc / 会话清理)——排在同一条队列里,与快照天然互斥,无 git 锁竞态
376
+ ctx.on('session/event', (session, event) => {
377
+ if (!event || event.type !== 'user/message') return
378
+ const data = event.data
379
+ if (!data || typeof data.id !== 'string' || !data.id) return
380
+ const source = data.source
381
+ if (!source || source.kind !== 'user') return
382
+ if (session && session.header && session.header.origin === 'subagent') return
383
+ const messageId = data.id
384
+ const time = event.time
385
+ state.queue = state.queue
386
+ // 快照总开关:cfg 按调用时读取,设置页热更即时生效。关闭时只冻结新建,
387
+ // maybeMaintain 照常跑——已停增的存储仍需被 gc/清理治理。
388
+ .then(() => (cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null))
389
+ .then(() => maint.maybeMaintain(session.id))
390
+ .then(() => { listCache.items = null })
391
+ .catch((error) => rt.recordError('recall snapshot error: ' + String(error)))
392
+ })
393
+
394
+ // 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,并清理旧版
395
+ // 项目内 blobs 目录(home 可用时)。不触发维护(开机预热应尽量轻)。
396
+ ;(async () => {
397
+ const warmupRoots = new Map()
398
+ for (const session of ctx.sessions.list()) {
399
+ const cwd = session && session.header && session.header.cwd
400
+ if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, session.id)
401
+ }
402
+ const querySvc = ctx.get('sessionQuery')
403
+ if (querySvc && typeof querySvc.listSessions === 'function') {
404
+ try {
405
+ const records = await querySvc.listSessions()
406
+ for (const record of records || []) {
407
+ // listSessions 记录形如 {header, live, persisted},会话 id
408
+ // header.id——此前误用顶层 record.id(恒 undefined),预热重建的
409
+ // 孤儿快照 sessionId 记为空,树形管理里会落进「已删除会话」。
410
+ const id = record && record.header && record.header.id ? record.header.id : null
411
+ const cwd = record && record.header && record.header.cwd
412
+ if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, id)
413
+ }
414
+ } catch (error) { /* 冷元数据不可用则退回 live 注册表 */ }
415
+ }
416
+ for (const [cwd, sessionId] of warmupRoots) {
417
+ Promise.resolve(rt.resolveStore(cwd))
418
+ .then(() => rt.tryUpgradeToHome(cwd))
419
+ .then((store) => rt.ensureGit(cwd, store))
420
+ .then(() => snaps.loadIndex(cwd, sessionId))
421
+ .then(() => snaps.rebuildOrphans(cwd, sessionId))
422
+ .then(() => rt.cleanupLegacy(cwd))
423
+ .catch(() => {})
424
+ }
425
+ })()
426
+ }