dsh-recall-plugin 2.2.0 → 2.3.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,411 +1,446 @@
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 { parseStoresDump, parseExcludeDump } from './dump-parse.js'
16
-
17
- // dump 解析纯函数住 dump-parse.js(避免 routes-manage 反向 import index 的
18
- // 循环依赖),这里 re-export 保持既有 import 路径稳定
19
- export { parseStoresDump, parseExcludeDump }
20
- import { createRuntime } from './store.js'
21
- import { createSnapshots, rescueRollback } from './snapshots.js'
22
- import { createMaintenance } from './maintenance.js'
23
- import { createSessionInfo, titleFromEvents, messageTextFromEvents } from './session-info.js'
24
- import { createRoutesCore } from './routes-core.js'
25
- import { createRoutesManage } from './routes-manage.js'
26
- import { installSettingsSection } from '@deepseek-ai/dsh-settings'
27
- import * as E from './errors.js'
28
-
29
- export const name = 'dsh-recall-plugin'
30
-
31
- // 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
32
- // webServer(Client 半的 HTTP API 通道)。agents(dsh-base 无条件装配的
33
- // agent 注册表)为 P0-1 运行中 agent 拦截读运行状态所需——cordis 4 要求
34
- // 服务在 inject 中声明才可经 ctx.agents 访问,漏声明会抛
35
- // "cannot get property ... without inject" 导致检查静默 fail-open(冒烟发现)。
36
- // 其余服务按需 ctx.get。
37
- export const inject = ['shell', 'sessions', 'webServer', 'agents']
38
-
39
- // 入口配置 schema:cordis 加载器据此校验 insert 行 config 并填充默认值,
40
- // 非法配置在插件加载时响亮失败(官方「插件配置」文档要求)。
41
- export { Config }
42
-
43
- // config 由 cordis.patch.yml 的 insert 行 config 键下发(schema 默认值兜底),
44
- // 设置页「插件配置」卡片的用户覆盖经 settings namespace 热更新进 cfg
45
- // (见下方 installSettingsSection 接线)
46
- export function apply(ctx, config) {
47
- const webServer = ctx.webServer
48
-
49
- const cfg = createConfig(config)
50
- const rt = createRuntime(ctx, cfg)
51
- const snaps = createSnapshots(ctx, rt, cfg)
52
- const maint = createMaintenance(ctx, rt, snaps, cfg)
53
- const state = rt.state
54
-
55
- // ---- settings namespace「dsh-recall」:设置页「插件配置」分区正规接入 ----
56
- // installSettingsSection(dsh-settings 官方辅助):settings 服务挂载后以
57
- // Config schema 注册 namespace、组合 base 取入口 config;服务卸载时
58
- // 源回退入口 config。解析层 = schema 默认 → 组合 base → 用户文档(设置
59
- // 卡片写入、dsh-settings 持久化),变更经 watch 热更新进运行中的 cfg。
60
- let readSettings = () => config
61
- function applyResolvedConfig(resolved) {
62
- Object.assign(cfg, createConfig(resolved && typeof resolved === 'object' ? resolved : {}))
63
- }
64
- try {
65
- installSettingsSection(ctx, 'dsh-recall', Config, config, {
66
- setSource: (fn) => { readSettings = fn },
67
- onChange: () => applyResolvedConfig(readSettings()),
68
- })
69
- } catch (error) {
70
- rt.recordError('recall settings namespace skipped: ' + String(error))
71
- }
72
-
73
- // 平台门控:win32 PowerShell 模板,linux/darwin 走 bash 模板。
74
- // 其余平台干净短路:init 返回 unsupported,Client 弹一次性提示。
75
- const supported = process.platform === 'win32' || process.platform === 'linux' || process.platform === 'darwin'
76
-
77
- // 请求体上限:端点里 exclude-set 接受用户任意文本,无上限时可被无限
78
- // POST 撑爆内存。1MB 远超正常配置体量,超限干净报错而不是悄悄截断。
79
- const MAX_BODY_BYTES = 1048576
80
-
81
- // 快照管理列表的结果缓存(apply 级跨请求共享):30s 缓存让二次打开即时;
82
- // delete 与新快照落地时失效。listCache/excludeCache 是可变 holder——routes
83
- // 层改属性(items/payload),本文件的事件接线读同一引用。
84
- // PF-6:事件接线不再清空 items 而是 stale 标记——list 端点先用旧 items
85
- // 立即应答(对话中打开快照管理不再等全量 dump),后台 dump 更新缓存
86
- // (refreshing 持有进行中的 promise 做 in-flight 去重,stale 期间重复
87
- // list 不重复起进程);Client 收到 stale 标记静默再拉一次。
88
- const listCache = { at: 0, items: null, stale: false, refreshing: null }
89
- // 排除配置枚举缓存(30s):exclude-set 成功写入后立即失效。
90
- const excludeCache = { at: 0, payload: null }
91
- // 全量磁盘占用缓存(30s,PF-3):删除/gc 后失效(占用变化必须立即可见),
92
- // 每条消息不失效——快照带来的增量由 TTL 到期自然覆盖,不然 TTL 形同虚设。
93
- const usageCache = { at: 0, payload: null }
94
-
95
- // 会话标题/文本两段式读取(live 秒回,冷会话由 Client 异步补齐)
96
- const sessionInfo = createSessionInfo(ctx)
97
-
98
- async function readJsonBody(req) {
99
- const chunks = []
100
- let size = 0
101
- for await (const chunk of req) {
102
- size += chunk.length
103
- if (size > MAX_BODY_BYTES) throw new Error(E.RECALL_BODY_TOO_LARGE)
104
- chunks.push(chunk)
105
- }
106
- const text = Buffer.concat(chunks).toString('utf8')
107
- if (!text.trim()) return {}
108
- return JSON.parse(text)
109
- }
110
-
111
- function sendJson(res, status, body) {
112
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
113
- res.end(JSON.stringify(body))
114
- }
115
-
116
- // 统一错误映射:业务失败与系统异常分离,文案与诊断解耦。code
117
- // Client 做分支判断,message 直接展示。
118
- function errBody(error) {
119
- const text = String(error && error.message ? error.message : error)
120
- if (text === E.RECALL_BODY_TOO_LARGE) return { ok: false, code: E.RECALL_BODY_TOO_LARGE, message: '请求体超过 1MB 上限' }
121
- return { ok: false, code: E.RECALL_ERROR, message: text }
122
- }
123
-
124
- // 队列入队即占住后续快照,队列失败不堵队(catch 就地消化)。
125
- function enqueue(task) {
126
- const run = state.queue.then(task)
127
- state.queue = run.catch(() => {})
128
- return run
129
- }
130
-
131
- // 通用并发限制器:冷会话标题/消息文本补齐会 readSession 整日志解压,
132
- // 全量 Promise.all 会同时压垮磁盘/CPU,限制同时最多 concurrency 个任务。
133
- async function runLimited(tasks, concurrency) {
134
- const limit = concurrency > 0 ? concurrency : 4
135
- let index = 0
136
- const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
137
- while (index < tasks.length) {
138
- const task = tasks[index++]
139
- await task()
140
- }
141
- })
142
- await Promise.all(workers)
143
- }
144
-
145
- // 归一化 cwd/root 路径用于跨会话同工作区比对:Windows 大小写不敏感 +
146
- // 去掉尾部分隔符,避免 D:\Foo 与 d:\foo\ 误判为不同目录。
147
- function normalizeWorkdir(path) {
148
- if (!path) return ''
149
- let p = String(path)
150
- return (process.platform === 'win32' ? p.toLowerCase() : p).replace(/[\\/]+$/, '')
151
- }
152
-
153
- // 回退前重保护检查(P0-1):目标工作区有 agent 正在跑时拒绝预览/撤回。
154
- // 保守策略——不做自动取消,仅拦下操作并提示先停止。守卫式访问只为防御
155
- // 「未来版本改名 / agent 服务未装配」,失败视为「不忙」(fail-open)。
156
- function agentBusy(sessionId, root) {
157
- let reg = null
158
- try { reg = ctx.agents } catch (error) { return false }
159
- if (!reg) return false
160
- try {
161
- if (typeof reg.list === 'function') {
162
- for (const agent of reg.list()) {
163
- if (!agent || agent.status !== 'running') continue
164
- // 发起会话自身的 agent(覆盖最常见场景:本会话 agent 在跑)
165
- if (sessionId && String(agent.id) === String(sessionId)) return true
166
- // 跨会话同工作区:另一会话的 agent 在同一个目录跑也会被文件回退波及
167
- const cwd = agent.session && agent.session.header && agent.session.header.cwd
168
- if (root && cwd && normalizeWorkdir(cwd) === normalizeWorkdir(root)) return true
169
- }
170
- return false
171
- }
172
- if (sessionId && typeof reg.get === 'function') {
173
- const agent = reg.get(sessionId)
174
- return Boolean(agent && agent.status === 'running')
175
- }
176
- } catch (error) { /* fail-open */ }
177
- return false
178
- }
179
-
180
- // 枚举当前全部已知 exclude 文件并按路径去重。exclude-get 直接消费结果;
181
- // exclude-set 用它做路径白名单校验,堵死「借 API 写任意文件」的通道。
182
- async function listExcludeFiles() {
183
- const roots = new Set(state.stores.keys())
184
- for (const session of ctx.sessions.list()) {
185
- const cwd = session && session.header && session.header.cwd
186
- if (cwd) roots.add(cwd)
187
- }
188
- const byFile = new Map()
189
- await Promise.all(Array.from(roots).map(async (root) => {
190
- try {
191
- const store = await rt.resolveStore(root)
192
- if (store && !byFile.has(store.excludeFile)) byFile.set(store.excludeFile, { store, roots: [] })
193
- byFile.get(store.excludeFile).roots.push(root)
194
- } catch (error) {
195
- /* 单个根解析失败只影响它自己,不拖垮整个列表 */
196
- }
197
- }))
198
- // 磁盘兜底:冷启动时会话注册表为空(惰性载入),但 home 容器目录可能
199
- // 早已存在(历史快照)。容器在 共享 exclude.txt 可编辑。
200
- try {
201
- const container = await rt.resolveHomeContainer()
202
- if (container) {
203
- const probe = rt.scripts.stripBom(await rt.runShell(rt.scripts.dirExistsScript(container), { stdoutMaxBytes: 4096 })).trim()
204
- if (probe === 'YES') {
205
- const excludeFile = container + (rt.isWin ? '\\' : '/') + 'exclude.txt'
206
- if (!byFile.has(excludeFile)) {
207
- // store:仅承载 readExclude/writeExclude 用到的 excludeFile home
208
- byFile.set(excludeFile, { store: { dir: container, home: true, excludeFile }, roots: [] })
209
- }
210
- }
211
- }
212
- } catch (error) {
213
- /* 兜底失败退回注册表结果 */
214
- }
215
- return byFile
216
- }
217
-
218
- // 工作区 cwd 全集:live 注册表只是子集,sessionQuery.listSessions 是
219
- // 「live + 磁盘冷元数据」的完整语料。manage list 与 delete 兜底共用。
220
- async function collectCwds() {
221
- const cwds = new Set()
222
- for (const session of ctx.sessions.list()) {
223
- const cwd = session && session.header && session.header.cwd
224
- if (cwd) cwds.add(cwd)
225
- }
226
- try {
227
- const querySvc = ctx.get('sessionQuery')
228
- if (querySvc && typeof querySvc.listSessions === 'function') {
229
- for (const record of await querySvc.listSessions()) {
230
- const cwd = record && record.header && record.header.cwd
231
- if (cwd) cwds.add(cwd)
232
- }
233
- }
234
- } catch (error) { /* 冷元数据不可用时退回 live 注册表 */ }
235
- return cwds
236
- }
237
-
238
- // 一条 shell dump 全部 store 元数据(容器子目录 + 降级候选目录的 root.txt
239
- // index.json),manage list delete 兜底共用。
240
- async function dumpStores() {
241
- const container = await rt.resolveHomeContainer()
242
- const extras = Array.from(await collectCwds()).map((cwd) => cwd + (rt.isWin ? '\\' : '/') + '.dsh-recall-snapshots')
243
- try {
244
- const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || '', extras), { timeoutMs: 120000, stdoutMaxBytes: 8388608 }))
245
- return parseStoresDump(text)
246
- } catch (error) {
247
- // 同 refreshListCacheInBackground:dump 失败按空 Map 继续是设计行为,
248
- // 但失败原因必须留痕,否则列表缺数据时无从排查
249
- console.error('recall stores dump failed:', String(error && error.stack || error))
250
- return new Map()
251
- }
252
- }
253
-
254
- // 磁盘反查某快照归属的 store:dump 全部 index 后按 id 查找。delete 的
255
- // 兜底路径用它消灭「列表可见但内存缺失 ⇒ 误报不存在」。
256
- async function locateSnapshotOnDisk(id) {
257
- if (!id) return null
258
- const dump = await dumpStores()
259
- const hints = new Map()
260
- for (const [root, st] of state.stores.entries()) {
261
- if (st && st.dir) hints.set(st.dir, root)
262
- }
263
- for (const [dir, info] of dump) {
264
- const hit = (info.entries || []).find((e) => e && e.id === id)
265
- if (!hit) continue
266
- const root = (typeof hit.root === 'string' && hit.root) || info.root || hints.get(dir) || null
267
- if (!root) continue
268
- try {
269
- const store = await rt.resolveStore(root)
270
- if (store) return { store, root }
271
- } catch (error) { /* 单个 root 解析失败继续找 */ }
272
- }
273
- return null
274
- }
275
-
276
- // 收集全量快照记录(内存 + 磁盘 dump 并集),供树形管理的按工作区/会话
277
- // 批量删除使用。去重只按 id——同一消息 ID 全局唯一。
278
- async function collectAllSnapshotRecords() {
279
- const records = new Map()
280
- function add(id, root, sessionId, time) {
281
- if (!id || typeof id !== 'string') return
282
- const old = records.get(id)
283
- if (!old) {
284
- records.set(id, {
285
- id,
286
- root: root || null,
287
- sessionId: sessionId || null,
288
- time: typeof time === 'number' ? time : 0
289
- })
290
- return
291
- }
292
- // 同一消息 ID 可能出现磁盘先占位、内存后补全的情况:用更全的
293
- // root/sessionId/time 覆盖旧值,避免树形节点归到「未知」导致批量
294
- // 删除按工作区/会话匹配不到。
295
- if (!old.root && root) old.root = root
296
- if (!old.sessionId && sessionId) old.sessionId = sessionId
297
- if (!old.time && time) old.time = time
298
- }
299
- for (const [id, s] of state.snapshots.entries()) {
300
- if (s) add(id, s.root, s.sessionId, s.time)
301
- }
302
- const dump = await dumpStores()
303
- const hints = new Map()
304
- for (const [root, st] of state.stores.entries()) {
305
- if (st && st.dir) hints.set(st.dir, root)
306
- }
307
- for (const [dir, info] of dump) {
308
- const baseRoot = info.root || hints.get(dir) || null
309
- for (const e of info.entries || []) {
310
- if (!e || typeof e.id !== 'string') continue
311
- add(e.id, (typeof e.root === 'string' && e.root) || baseRoot, e.sessionId, e.time)
312
- }
313
- }
314
- return records
315
- }
316
-
317
- // ---- 端点表组装:核心路由 + 管理路由,合并进单一 endpoints 对象供
318
- // webServer 前缀路由分发(端点名是 path 第一段,故无跨域命名冲突)。
319
- const deps = {
320
- ctx, rt, snaps, maint, state, cfg, supported,
321
- enqueue, agentBusy, runLimited, readJsonBody, sendJson, errBody,
322
- listExcludeFiles, dumpStores, locateSnapshotOnDisk, collectAllSnapshotRecords,
323
- listCache, excludeCache, usageCache, sessionInfo, titleFromEvents, messageTextFromEvents,
324
- // readSettings 传活绑定而非当前引用(A1):dsh-settings 服务晚挂载时
325
- // setSource 会重绑定 readSettings——按值捕获的副本停在旧闭包(入口
326
- // config),config-reset 会按旧值「恢复默认」。活绑定让消费者每次调用
327
- // 都取到当前闭包。
328
- applyResolvedConfig, readSettings: () => readSettings(), DEFAULTS, rescueRollback, E,
329
- }
330
- const endpoints = {
331
- ...createRoutesCore(deps),
332
- ...createRoutesManage(deps),
333
- }
334
-
335
- ctx.effect(() => webServer.register({
336
- kind: 'prefix',
337
- path: '/api/recall',
338
- handler: async (req, res) => {
339
- const path = (req.url || '').split('?')[0]
340
- const name = path.replace(/^\/api\/recall\/?/, '').split('/')[0]
341
- const endpoint = endpoints[name]
342
- if (!endpoint) {
343
- sendJson(res, 404, { ok: false, code: E.RECALL_UNKNOWN_ENDPOINT, message: 'unknown endpoint: ' + name })
344
- return
345
- }
346
- try {
347
- const args = await readJsonBody(req)
348
- sendJson(res, 200, await endpoint(args))
349
- } catch (error) {
350
- sendJson(res, 200, errBody(error))
351
- }
352
- }
353
- }))
354
-
355
- // 快照事件与启动预热仅在受支持平台注册(见上方 supported 短路说明)
356
- if (!supported) return
357
-
358
- // 每条用户消息触发快照(子代理会话跳过);快照完成后串行接一次维护
359
- // (定期 gc / 会话清理)——排在同一条队列里,与快照天然互斥,无 git 锁竞态
360
- ctx.on('session/event', (session, event) => {
361
- if (!event || event.type !== 'user/message') return
362
- const data = event.data
363
- if (!data || typeof data.id !== 'string' || !data.id) return
364
- const source = data.source
365
- if (!source || source.kind !== 'user') return
366
- if (session && session.header && session.header.origin === 'subagent') return
367
- const messageId = data.id
368
- const time = event.time
369
- state.queue = state.queue
370
- // 快照总开关:cfg 按调用时读取,设置页热更即时生效。关闭时只冻结新建,
371
- // maybeMaintain 照常跑——已停增的存储仍需被 gc/清理治理。
372
- .then(() => (cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null))
373
- .then(() => maint.maybeMaintain(session.id))
374
- // PF-6:不清 items 只标 stale——列表先按旧数据应答、后台补新(见 listCache 注释)
375
- .then(() => { listCache.stale = true })
376
- .catch((error) => rt.recordError('recall snapshot error: ' + String(error)))
377
- })
378
-
379
- // 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,并清理旧版
380
- // 项目内 blobs 目录(home 可用时)。不触发维护(开机预热应尽量轻)。
381
- ;(async () => {
382
- const warmupRoots = new Map()
383
- for (const session of ctx.sessions.list()) {
384
- const cwd = session && session.header && session.header.cwd
385
- if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, session.id)
386
- }
387
- const querySvc = ctx.get('sessionQuery')
388
- if (querySvc && typeof querySvc.listSessions === 'function') {
389
- try {
390
- const records = await querySvc.listSessions()
391
- for (const record of records || []) {
392
- // listSessions 记录形如 {header, live, persisted},会话 id 在
393
- // header.id——此前误用顶层 record.id(恒 undefined),预热重建的
394
- // 孤儿快照 sessionId 记为空,树形管理里会落进「已删除会话」。
395
- const id = record && record.header && record.header.id ? record.header.id : null
396
- const cwd = record && record.header && record.header.cwd
397
- if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, id)
398
- }
399
- } catch (error) { /* 冷元数据不可用则退回 live 注册表 */ }
400
- }
401
- for (const [cwd, sessionId] of warmupRoots) {
402
- Promise.resolve(rt.resolveStore(cwd))
403
- .then(() => rt.tryUpgradeToHome(cwd))
404
- .then((store) => rt.ensureGit(cwd, store))
405
- .then(() => snaps.loadIndex(cwd, sessionId))
406
- .then(() => snaps.rebuildOrphans(cwd, sessionId))
407
- .then(() => rt.cleanupLegacy(cwd))
408
- .catch(() => {})
409
- }
410
- })()
411
- }
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 { parseStoresDump, parseExcludeDump } from './dump-parse.js'
16
+
17
+ // dump 解析纯函数住 dump-parse.js(避免 routes-manage 反向 import index 的
18
+ // 循环依赖),这里 re-export 保持既有 import 路径稳定
19
+ export { parseStoresDump, parseExcludeDump }
20
+ import { createRuntime } from './store.js'
21
+ import { createSnapshots, rescueRollback } from './snapshots.js'
22
+ import { createMaintenance } from './maintenance.js'
23
+ import { createSessionInfo, titleFromEvents, messageTextFromEvents } from './session-info.js'
24
+ import { createRoutesCore } from './routes-core.js'
25
+ import { createRoutesManage } from './routes-manage.js'
26
+ import * as dshSettings from '@deepseek-ai/dsh-settings'
27
+ import * as E from './errors.js'
28
+
29
+ export const name = 'dsh-recall-plugin'
30
+
31
+ // 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
32
+ // webServer(Client 半的 HTTP API 通道)。agents(dsh-base 无条件装配的
33
+ // agent 注册表)为 P0-1 运行中 agent 拦截读运行状态所需——cordis 4 要求
34
+ // 服务在 inject 中声明才可经 ctx.agents 访问,漏声明会抛
35
+ // "cannot get property ... without inject" 导致检查静默 fail-open(冒烟发现)。
36
+ // 其余服务按需 ctx.get。
37
+ export const inject = ['shell', 'sessions', 'webServer', 'agents']
38
+
39
+ // 入口配置 schema:cordis 加载器据此校验 insert 行 config 并填充默认值,
40
+ // 非法配置在插件加载时响亮失败(官方「插件配置」文档要求)。
41
+ export { Config }
42
+
43
+ // config 由 cordis.patch.yml 的 insert 行 config 键下发(schema 默认值兜底),
44
+ // 设置页「插件配置」卡片的用户覆盖经 settings namespace 热更新进 cfg
45
+ // (见下方 installSettingsSection 接线)
46
+ export function apply(ctx, config) {
47
+ const webServer = ctx.webServer
48
+
49
+ const cfg = createConfig(config)
50
+ const rt = createRuntime(ctx, cfg)
51
+ const snaps = createSnapshots(ctx, rt, cfg)
52
+ const maint = createMaintenance(ctx, rt, snaps, cfg)
53
+ const state = rt.state
54
+
55
+ // ---- settings namespace「dsh-recall」:设置页「插件配置」分区正规接入 ----
56
+ // 官方 settings 辅助的版本兼容:0.1.2-alpha.2 起独立函数 installSettingsSection
57
+ // 移除、改为 SettingsProvider.installSection 方法(bash-local 等官方插件同款
58
+ // 迁移);0.1.2-alpha.1 及以前用独立函数。二者语义一致:settings 服务挂载后
59
+ // 以真 Config schema 注册 namespace、组合 base 取入口 config;服务卸载时源
60
+ // 回退入口 config。解析层 = schema 默认 → 组合 base → 用户文档(设置卡片
61
+ // 写入、dsh-settings 持久化),变更经 watch 热更新进运行中的 cfg。
62
+ // 分支判断不能只看静态导入包(插件 node_modules 固定为最新版 dsh-settings,
63
+ // 旧版 DSH 运行时会注入旧版实例),要按运行时注入实例的实际 API 分派:
64
+ // installSection 方法(0.1.2-alpha.2)或 register 核心 API(0.1.1-rc.2 及
65
+ // 以前,此时手动复刻独立函数接线语义——注册 namespace、源指向 scope、
66
+ // 卸载回退入口 config、watch 热更新)。
67
+ let readSettings = () => config
68
+ function applyResolvedConfig(resolved) {
69
+ Object.assign(cfg, createConfig(resolved && typeof resolved === 'object' ? resolved : {}))
70
+ }
71
+ const settingsHooks = {
72
+ setSource: (fn) => { readSettings = fn },
73
+ onChange: () => applyResolvedConfig(readSettings()),
74
+ }
75
+ try {
76
+ if (typeof dshSettings.installSettingsSection === 'function') {
77
+ // 包解析到旧版 dsh-settings:独立函数辅助
78
+ dshSettings.installSettingsSection(ctx, 'dsh-recall', Config, config, settingsHooks)
79
+ } else if (typeof ctx.inject === 'function') {
80
+ ctx.inject(['settings'], (settingsCtx) => {
81
+ const settingsService = settingsCtx.settings
82
+ if (typeof settingsService.installSection === 'function') {
83
+ // 0.1.2-alpha.2 起:settings 服务方法(inject 声明后取实例,方法
84
+ // 与独立函数同签名——register 语义/组合 base/卸载回退/onChange
85
+ // 触发全一致)
86
+ settingsService.installSection(ctx, 'dsh-recall', Config, config, settingsHooks)
87
+ } else if (typeof settingsService.register === 'function') {
88
+ // 0.1.1-rc.2 及以前:仅 register 核心 API,复刻独立函数接线语义
89
+ const scope = settingsService.register('dsh-recall', Config, { base: config })
90
+ settingsHooks.setSource(() => scope.get())
91
+ settingsHooks.onChange()
92
+ scope.watch(() => settingsHooks.onChange())
93
+ settingsCtx.effect(() => () => {
94
+ settingsHooks.setSource(() => config)
95
+ settingsHooks.onChange()
96
+ })
97
+ }
98
+ })
99
+ }
100
+ } catch (error) {
101
+ rt.recordError('recall settings namespace skipped: ' + String(error))
102
+ }
103
+
104
+ // 平台门控:win32 走 PowerShell 模板,linux/darwin 走 bash 模板。
105
+ // 其余平台干净短路:init 返回 unsupported,Client 弹一次性提示。
106
+ const supported = process.platform === 'win32' || process.platform === 'linux' || process.platform === 'darwin'
107
+
108
+ // 请求体上限:端点里 exclude-set 接受用户任意文本,无上限时可被无限
109
+ // POST 撑爆内存。1MB 远超正常配置体量,超限干净报错而不是悄悄截断。
110
+ const MAX_BODY_BYTES = 1048576
111
+
112
+ // 快照管理列表的结果缓存(apply 级跨请求共享):30s 缓存让二次打开即时;
113
+ // delete 与新快照落地时失效。listCache/excludeCache 是可变 holder——routes
114
+ // 层改属性(items/payload),本文件的事件接线读同一引用。
115
+ // PF-6:事件接线不再清空 items 而是 stale 标记——list 端点先用旧 items
116
+ // 立即应答(对话中打开快照管理不再等全量 dump),后台 dump 更新缓存
117
+ // (refreshing 持有进行中的 promise 做 in-flight 去重,stale 期间重复
118
+ // list 不重复起进程);Client 收到 stale 标记静默再拉一次。
119
+ const listCache = { at: 0, items: null, stale: false, refreshing: null }
120
+ // 排除配置枚举缓存(30s):exclude-set 成功写入后立即失效。
121
+ const excludeCache = { at: 0, payload: null }
122
+ // 全量磁盘占用缓存(30s,PF-3):删除/gc 后失效(占用变化必须立即可见),
123
+ // 每条消息不失效——快照带来的增量由 TTL 到期自然覆盖,不然 TTL 形同虚设。
124
+ const usageCache = { at: 0, payload: null }
125
+
126
+ // 会话标题/文本两段式读取(live 秒回,冷会话由 Client 异步补齐)
127
+ const sessionInfo = createSessionInfo(ctx)
128
+
129
+ async function readJsonBody(req) {
130
+ const chunks = []
131
+ let size = 0
132
+ for await (const chunk of req) {
133
+ size += chunk.length
134
+ if (size > MAX_BODY_BYTES) throw new Error(E.RECALL_BODY_TOO_LARGE)
135
+ chunks.push(chunk)
136
+ }
137
+ const text = Buffer.concat(chunks).toString('utf8')
138
+ if (!text.trim()) return {}
139
+ return JSON.parse(text)
140
+ }
141
+
142
+ function sendJson(res, status, body) {
143
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
144
+ res.end(JSON.stringify(body))
145
+ }
146
+
147
+ // 统一错误映射:业务失败与系统异常分离,文案与诊断解耦。code
148
+ // Client 做分支判断,message 直接展示。
149
+ function errBody(error) {
150
+ const text = String(error && error.message ? error.message : error)
151
+ if (text === E.RECALL_BODY_TOO_LARGE) return { ok: false, code: E.RECALL_BODY_TOO_LARGE, message: '请求体超过 1MB 上限' }
152
+ return { ok: false, code: E.RECALL_ERROR, message: text }
153
+ }
154
+
155
+ // 队列入队即占住后续快照,队列失败不堵队(catch 就地消化)。
156
+ function enqueue(task) {
157
+ const run = state.queue.then(task)
158
+ state.queue = run.catch(() => {})
159
+ return run
160
+ }
161
+
162
+ // 通用并发限制器:冷会话标题/消息文本补齐会 readSession 整日志解压,
163
+ // 全量 Promise.all 会同时压垮磁盘/CPU,限制同时最多 concurrency 个任务。
164
+ async function runLimited(tasks, concurrency) {
165
+ const limit = concurrency > 0 ? concurrency : 4
166
+ let index = 0
167
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
168
+ while (index < tasks.length) {
169
+ const task = tasks[index++]
170
+ await task()
171
+ }
172
+ })
173
+ await Promise.all(workers)
174
+ }
175
+
176
+ // 归一化 cwd/root 路径用于跨会话同工作区比对:Windows 大小写不敏感 +
177
+ // 去掉尾部分隔符,避免 D:\Foo 与 d:\foo\ 误判为不同目录。
178
+ function normalizeWorkdir(path) {
179
+ if (!path) return ''
180
+ let p = String(path)
181
+ return (process.platform === 'win32' ? p.toLowerCase() : p).replace(/[\\/]+$/, '')
182
+ }
183
+
184
+ // 回退前重保护检查(P0-1):目标工作区有 agent 正在跑时拒绝预览/撤回。
185
+ // 保守策略——不做自动取消,仅拦下操作并提示先停止。守卫式访问只为防御
186
+ // 「未来版本改名 / agent 服务未装配」,失败视为「不忙」(fail-open)。
187
+ function agentBusy(sessionId, root) {
188
+ let reg = null
189
+ try { reg = ctx.agents } catch (error) { return false }
190
+ if (!reg) return false
191
+ try {
192
+ if (typeof reg.list === 'function') {
193
+ for (const agent of reg.list()) {
194
+ if (!agent || agent.status !== 'running') continue
195
+ // 发起会话自身的 agent(覆盖最常见场景:本会话 agent 在跑)
196
+ if (sessionId && String(agent.id) === String(sessionId)) return true
197
+ // 跨会话同工作区:另一会话的 agent 在同一个目录跑也会被文件回退波及
198
+ const cwd = agent.session && agent.session.header && agent.session.header.cwd
199
+ if (root && cwd && normalizeWorkdir(cwd) === normalizeWorkdir(root)) return true
200
+ }
201
+ return false
202
+ }
203
+ if (sessionId && typeof reg.get === 'function') {
204
+ const agent = reg.get(sessionId)
205
+ return Boolean(agent && agent.status === 'running')
206
+ }
207
+ } catch (error) { /* fail-open */ }
208
+ return false
209
+ }
210
+
211
+ // 枚举当前全部已知 exclude 文件并按路径去重。exclude-get 直接消费结果;
212
+ // exclude-set 用它做路径白名单校验,堵死「借 API 写任意文件」的通道。
213
+ async function listExcludeFiles() {
214
+ const roots = new Set(state.stores.keys())
215
+ for (const session of ctx.sessions.list()) {
216
+ const cwd = session && session.header && session.header.cwd
217
+ if (cwd) roots.add(cwd)
218
+ }
219
+ const byFile = new Map()
220
+ await Promise.all(Array.from(roots).map(async (root) => {
221
+ try {
222
+ const store = await rt.resolveStore(root)
223
+ if (store && !byFile.has(store.excludeFile)) byFile.set(store.excludeFile, { store, roots: [] })
224
+ byFile.get(store.excludeFile).roots.push(root)
225
+ } catch (error) {
226
+ /* 单个根解析失败只影响它自己,不拖垮整个列表 */
227
+ }
228
+ }))
229
+ // 磁盘兜底:冷启动时会话注册表为空(惰性载入),但 home 容器目录可能
230
+ // 早已存在(历史快照)。容器在 共享 exclude.txt 可编辑。
231
+ try {
232
+ const container = await rt.resolveHomeContainer()
233
+ if (container) {
234
+ const probe = rt.scripts.stripBom(await rt.runShell(rt.scripts.dirExistsScript(container), { stdoutMaxBytes: 4096 })).trim()
235
+ if (probe === 'YES') {
236
+ const excludeFile = container + (rt.isWin ? '\\' : '/') + 'exclude.txt'
237
+ if (!byFile.has(excludeFile)) {
238
+ // store:仅承载 readExclude/writeExclude 用到的 excludeFile home
239
+ byFile.set(excludeFile, { store: { dir: container, home: true, excludeFile }, roots: [] })
240
+ }
241
+ }
242
+ }
243
+ } catch (error) {
244
+ /* 兜底失败退回注册表结果 */
245
+ }
246
+ return byFile
247
+ }
248
+
249
+ // 工作区 cwd 全集:live 注册表只是子集,sessionQuery.listSessions
250
+ // 「live + 磁盘冷元数据」的完整语料。manage list 与 delete 兜底共用。
251
+ async function collectCwds() {
252
+ const cwds = new Set()
253
+ for (const session of ctx.sessions.list()) {
254
+ const cwd = session && session.header && session.header.cwd
255
+ if (cwd) cwds.add(cwd)
256
+ }
257
+ try {
258
+ const querySvc = ctx.get('sessionQuery')
259
+ if (querySvc && typeof querySvc.listSessions === 'function') {
260
+ for (const record of await querySvc.listSessions()) {
261
+ const cwd = record && record.header && record.header.cwd
262
+ if (cwd) cwds.add(cwd)
263
+ }
264
+ }
265
+ } catch (error) { /* 冷元数据不可用时退回 live 注册表 */ }
266
+ return cwds
267
+ }
268
+
269
+ // 一条 shell dump 全部 store 元数据(容器子目录 + 降级候选目录的 root.txt
270
+ // index.json),manage list delete 兜底共用。
271
+ async function dumpStores() {
272
+ const container = await rt.resolveHomeContainer()
273
+ const extras = Array.from(await collectCwds()).map((cwd) => cwd + (rt.isWin ? '\\' : '/') + '.dsh-recall-snapshots')
274
+ try {
275
+ const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || '', extras), { timeoutMs: 120000, stdoutMaxBytes: 8388608 }))
276
+ return parseStoresDump(text)
277
+ } catch (error) {
278
+ // refreshListCacheInBackground:dump 失败按空 Map 继续是设计行为,
279
+ // 但失败原因必须留痕,否则列表缺数据时无从排查
280
+ console.error('recall stores dump failed:', String(error && error.stack || error))
281
+ return new Map()
282
+ }
283
+ }
284
+
285
+ // 磁盘反查某快照归属的 store:dump 全部 index 后按 id 查找。delete 的
286
+ // 兜底路径用它消灭「列表可见但内存缺失 误报不存在」。
287
+ async function locateSnapshotOnDisk(id) {
288
+ if (!id) return null
289
+ const dump = await dumpStores()
290
+ const hints = new Map()
291
+ for (const [root, st] of state.stores.entries()) {
292
+ if (st && st.dir) hints.set(st.dir, root)
293
+ }
294
+ for (const [dir, info] of dump) {
295
+ const hit = (info.entries || []).find((e) => e && e.id === id)
296
+ if (!hit) continue
297
+ // root 优先取 root.txt 权威值(info.root),index.json 条目的 hit.root
298
+ // 可能有丢反斜杠的历史坏数据(否则解析到错误 hash 的 store 删错目录)
299
+ const root = info.root || hints.get(dir) || (typeof hit.root === 'string' && hit.root) || null
300
+ if (!root) continue
301
+ try {
302
+ const store = await rt.resolveStore(root)
303
+ if (store) return { store, root }
304
+ } catch (error) { /* 单个 root 解析失败继续找 */ }
305
+ }
306
+ return null
307
+ }
308
+
309
+ // 收集全量快照记录(内存 + 磁盘 dump 并集),供树形管理的按工作区/会话
310
+ // 批量删除使用。去重只按 id——同一消息 ID 全局唯一。
311
+ async function collectAllSnapshotRecords() {
312
+ const records = new Map()
313
+ function add(id, root, sessionId, time) {
314
+ if (!id || typeof id !== 'string') return
315
+ const old = records.get(id)
316
+ if (!old) {
317
+ records.set(id, {
318
+ id,
319
+ root: root || null,
320
+ sessionId: sessionId || null,
321
+ time: typeof time === 'number' ? time : 0
322
+ })
323
+ return
324
+ }
325
+ // 同一消息 ID 可能出现磁盘先占位、内存后补全的情况:用更全的
326
+ // root/sessionId/time 覆盖旧值,避免树形节点归到「未知」导致批量
327
+ // 删除按工作区/会话匹配不到。
328
+ if (!old.root && root) old.root = root
329
+ if (!old.sessionId && sessionId) old.sessionId = sessionId
330
+ if (!old.time && time) old.time = time
331
+ }
332
+ for (const [id, s] of state.snapshots.entries()) {
333
+ if (s) add(id, s.root, s.sessionId, s.time)
334
+ }
335
+ const dump = await dumpStores()
336
+ const hints = new Map()
337
+ for (const [root, st] of state.stores.entries()) {
338
+ if (st && st.dir) hints.set(st.dir, root)
339
+ }
340
+ for (const [dir, info] of dump) {
341
+ const baseRoot = info.root || hints.get(dir) || null
342
+ for (const e of info.entries || []) {
343
+ if (!e || typeof e.id !== 'string') continue
344
+ // root 优先取 root.txt 权威值(info.root)——index.json 条目的 e.root
345
+ // 可能带历史丢反斜杠的坏数据(删除/展示按错误 root 解析错 store)
346
+ add(e.id, baseRoot || (typeof e.root === 'string' && e.root) || null, e.sessionId, e.time)
347
+ }
348
+ }
349
+ return records
350
+ }
351
+
352
+ // ---- 端点表组装:核心路由 + 管理路由,合并进单一 endpoints 对象供
353
+ // webServer 前缀路由分发(端点名是 path 第一段,故无跨域命名冲突)。
354
+ const deps = {
355
+ ctx, rt, snaps, maint, state, cfg, supported,
356
+ enqueue, agentBusy, runLimited, readJsonBody, sendJson, errBody,
357
+ listExcludeFiles, dumpStores, locateSnapshotOnDisk, collectAllSnapshotRecords,
358
+ listCache, excludeCache, usageCache, sessionInfo, titleFromEvents, messageTextFromEvents,
359
+ // readSettings 传活绑定而非当前引用(A1):dsh-settings 服务晚挂载时
360
+ // setSource 会重绑定 readSettings——按值捕获的副本停在旧闭包(入口
361
+ // config),config-reset 会按旧值「恢复默认」。活绑定让消费者每次调用
362
+ // 都取到当前闭包。
363
+ applyResolvedConfig, readSettings: () => readSettings(), DEFAULTS, rescueRollback, E,
364
+ }
365
+ const endpoints = {
366
+ ...createRoutesCore(deps),
367
+ ...createRoutesManage(deps),
368
+ }
369
+
370
+ ctx.effect(() => webServer.register({
371
+ kind: 'prefix',
372
+ path: '/api/recall',
373
+ handler: async (req, res) => {
374
+ const path = (req.url || '').split('?')[0]
375
+ const name = path.replace(/^\/api\/recall\/?/, '').split('/')[0]
376
+ const endpoint = endpoints[name]
377
+ if (!endpoint) {
378
+ sendJson(res, 404, { ok: false, code: E.RECALL_UNKNOWN_ENDPOINT, message: 'unknown endpoint: ' + name })
379
+ return
380
+ }
381
+ try {
382
+ const args = await readJsonBody(req)
383
+ sendJson(res, 200, await endpoint(args))
384
+ } catch (error) {
385
+ sendJson(res, 200, errBody(error))
386
+ }
387
+ }
388
+ }))
389
+
390
+ // 快照事件与启动预热仅在受支持平台注册(见上方 supported 短路说明)
391
+ if (!supported) return
392
+
393
+ // 每条用户消息触发快照(子代理会话跳过);快照完成后串行接一次维护
394
+ // (定期 gc / 会话清理)——排在同一条队列里,与快照天然互斥,无 git 锁竞态
395
+ ctx.on('session/event', (session, event) => {
396
+ if (!event || event.type !== 'user/message') return
397
+ const data = event.data
398
+ if (!data || typeof data.id !== 'string' || !data.id) return
399
+ const source = data.source
400
+ if (!source || source.kind !== 'user') return
401
+ if (session && session.header && session.header.origin === 'subagent') return
402
+ const messageId = data.id
403
+ const time = event.time
404
+ state.queue = state.queue
405
+ // 快照总开关:cfg 按调用时读取,设置页热更即时生效。关闭时只冻结新建,
406
+ // maybeMaintain 照常跑——已停增的存储仍需被 gc/清理治理。
407
+ .then(() => (cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null))
408
+ .then(() => maint.maybeMaintain(session.id))
409
+ // PF-6:不清 items 只标 stale——列表先按旧数据应答、后台补新(见 listCache 注释)
410
+ .then(() => { listCache.stale = true })
411
+ .catch((error) => rt.recordError('recall snapshot error: ' + String(error)))
412
+ })
413
+
414
+ // 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,并清理旧版
415
+ // 项目内 blobs 目录(home 可用时)。不触发维护(开机预热应尽量轻)。
416
+ ;(async () => {
417
+ const warmupRoots = new Map()
418
+ for (const session of ctx.sessions.list()) {
419
+ const cwd = session && session.header && session.header.cwd
420
+ if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, session.id)
421
+ }
422
+ const querySvc = ctx.get('sessionQuery')
423
+ if (querySvc && typeof querySvc.listSessions === 'function') {
424
+ try {
425
+ const records = await querySvc.listSessions()
426
+ for (const record of records || []) {
427
+ // listSessions 记录形如 {header, live, persisted},会话 id 在
428
+ // header.id——此前误用顶层 record.id(恒 undefined),预热重建的
429
+ // 孤儿快照 sessionId 记为空,树形管理里会落进「已删除会话」。
430
+ const id = record && record.header && record.header.id ? record.header.id : null
431
+ const cwd = record && record.header && record.header.cwd
432
+ if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, id)
433
+ }
434
+ } catch (error) { /* 冷元数据不可用则退回 live 注册表 */ }
435
+ }
436
+ for (const [cwd, sessionId] of warmupRoots) {
437
+ Promise.resolve(rt.resolveStore(cwd))
438
+ .then(() => rt.tryUpgradeToHome(cwd))
439
+ .then((store) => rt.ensureGit(cwd, store))
440
+ .then(() => snaps.loadIndex(cwd, sessionId))
441
+ .then(() => snaps.rebuildOrphans(cwd, sessionId))
442
+ .then(() => rt.cleanupLegacy(cwd))
443
+ .catch(() => {})
444
+ }
445
+ })()
446
+ }