dsh-recall-plugin 1.2.2 → 1.4.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/store.js CHANGED
@@ -1,249 +1,342 @@
1
- /**
2
- * dsh-recall-plugin — 执行与存储层(ctx 绑定的工厂,无模块级副作用)
3
- *
4
- * 职责:提供 runShell(宿主身份执行 + 统一编码保证)、会话根目录解析、
5
- * git 可执行文件探测、home/降级存储解析与迁移、影子仓库初始化(ensureGit)。
6
- * 按 process.platform 选择脚本模板(scripts.pwsh.js / scripts.posix.js),
7
- * 两套模板导出同名接口,本文件用 rt.scripts 统一下发。
8
- * 产出共享 state(各 Map 缓存)供 snapshots.js / maintenance.js 复用;
9
- * 由 lib/index.js 在 apply(ctx) 里装配,插件卸载时随 Fiber 一起丢弃。
10
- */
11
-
12
- import os from 'node:os'
13
- import crypto from 'node:crypto'
14
- import * as pwshScripts from './scripts.pwsh.js'
15
- import * as posixScripts from './scripts.posix.js'
16
-
17
- // home 不可写时迁移重试的节流间隔:避免每条消息都白试一次注定失败的迁移
18
- const HOME_RETRY_MS = 300000
19
-
20
- export function createRuntime(ctx) {
21
- const shell = ctx.shell
22
- const sessions = ctx.sessions
23
-
24
- const isWin = process.platform === 'win32'
25
- const SEP = isWin ? '\\' : '/'
26
- const scripts = isWin ? pwshScripts : posixScripts
27
-
28
- const state = {
29
- roots: new Map(),
30
- stores: new Map(),
31
- snapshots: new Map(),
32
- queue: Promise.resolve(),
33
- indexLoaded: new Set(),
34
- gitReady: new Set(),
35
- cutSeqCache: new Map(),
36
- homeRetryAt: new Map(),
37
- gcLastAt: new Map(),
38
- gcCount: new Map(),
39
- gitExe: null,
40
- posixHomeBase: null
41
- }
42
-
43
- // 所有 shell 调用都以宿主身份(danger-full-access)执行,不借用会话沙箱。
44
- // 为什么安全:DSH 沙箱约束的是「模型驱动」的文件效果,而本插件的命令全部
45
- // 是宿主侧固定模板(建仓/快照/索引/回退),命令串里唯一变量是插件自己
46
- // 推导的路径(会话 cwd、哈希出的 store 路径、消息 ID),模型无法注入任何
47
- // 内容;快照落盘的也只是会话本就有权读取的工作区文件副本,不扩大能力。
48
- // 为什么必须如此:若按会话解析策略,workspace-write/read-only 会话写不了
49
- // home,快照被迫降级进项目目录(污染);read-only 会话连项目都写不了,
50
- // 回退恢复直接失败。pwsh-sandbox / bash-sandbox 对 danger-full-access
51
- // 直接不约束(等价本地执行器),无沙箱后端的部署则忽略该字段,两边都成立。
52
- async function runShell(command, opts) {
53
- const sp = ctx.get('sandboxPolicy')
54
- const spec = shell.resolve({
55
- // 编码前导:pwsh 侧统一 UTF-8 输出(中文机器 GBK 代码页不再乱码);
56
- // bash 侧 LC_ALL=C 确定序。各模板自带,这里统一前置注入。
57
- command: scripts.UTF8_PRELUDE + '\n' + command,
58
- timeoutMs: (opts && opts.timeoutMs) || 300000,
59
- stdoutMaxBytes: (opts && opts.stdoutMaxBytes) || 4194304,
60
- // stdin 是官方 ShellExecRequest 契约字段(bash-local/pwsh 均实现),
61
- // POSIX 侧用它传 index.json 全文,绕开 argv 长度上限
62
- ...((opts && opts.stdin !== undefined) ? { stdin: opts.stdin } : {}),
63
- sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: (sp && sp.workspaceRoot) || process.cwd() }
64
- })
65
- const res = await shell.run(spec)
66
- const out = (res && res.stdout && res.stdout.text) || ''
67
- if (res && res.exitCode !== 0) {
68
- const err = ((res && res.stderr && res.stderr.text) || '').trim() || ('exit ' + String(res.exitCode))
69
- throw new Error(err.slice(0, 1500))
70
- }
71
- return out
72
- }
73
-
74
- async function resolveRoot(sessionId) {
75
- const key = sessionId ? String(sessionId) : 'fallback'
76
- const cached = state.roots.get(key)
77
- if (cached) return cached
78
- let root = null
79
- if (sessionId) {
80
- const session = sessions.get(sessionId)
81
- if (session && session.header && session.header.cwd) root = session.header.cwd
82
- }
83
- if (!root) {
84
- const sp = ctx.get('sandboxPolicy')
85
- if (sp && sp.workspaceRoot) root = sp.workspaceRoot
86
- }
87
- if (root) {
88
- // 尾分隔符归一(win32 "D:\" 三字符盘根;POSIX 保 "/" 根):
89
- // cwd 是否带尾斜杠由上游决定,不归一会让哈希输入不一致(换 store
90
- // 目录),也会让排除扫描的 ${f#"$root"/} 前缀剥离错一位。
91
- root = root.replace(/[\\/]+$/, '') || (isWin ? root : '/')
92
- if (isWin && root.length === 2) root += '\\'
93
- state.roots.set(key, root)
94
- }
95
- return root
96
- }
97
-
98
- // 解析 git 可执行文件路径:求值一次并缓存,脚本里用绝对路径调用,
99
- // 避免每条命令依赖 PATH(DSH 进程 PATH 可能不含 git)。
100
- async function resolveGit() {
101
- if (state.gitExe !== null) return state.gitExe
102
- try {
103
- const path = scripts.stripBom(await runShell(scripts.resolveGitScript(), { stdoutMaxBytes: 4096 })).trim()
104
- state.gitExe = path || ''
105
- } catch (error) {
106
- state.gitExe = ''
107
- }
108
- return state.gitExe
109
- }
110
-
111
- // win32:哈希在 PowerShell 里算(SHA256 Create 兼容 PS 5.1),连带
112
- // $env:DSH_HOME / $env:USERPROFILE 的解析都在 shell 侧完成。
113
- async function homeDirForWin(root) {
114
- const envHome = (process.env && process.env.DSH_HOME) || ''
115
- const text = scripts.stripBom(await runShell(scripts.homeDirScript(root, envHome), { stdoutMaxBytes: 4096 })).trim()
116
- if (!text) return null
117
- // 折叠 Join-Path 可能带出的连续反斜杠;开头的双反斜杠是 UNC 前缀
118
- // (DSH_HOME/主目录指到网络盘),折叠掉会把 \\server\share 变成无效
119
- // \server\share,必须原样保留。
120
- if (/^\\\\/.test(text)) return '\\\\' + text.slice(2).replace(/\\{2,}/g, '\\')
121
- return text.replace(/\\{2,}/g, '\\')
122
- }
123
-
124
- // POSIX:shell 侧只探 bash env 里显式的 $DSH_HOME(DSH 执行器洗刷
125
- // DSH_* 变量后通常为空);为空时依次回退 Node 主进程的 DSH_HOME
126
- // (宿主进程 env,用户导出可见)与 os.homedir()。哈希用 Node crypto
127
- // 统一算,规避 Linux sha256sum / macOS shasum 的二选一移植成本。
128
- async function homeDirForPosix(root) {
129
- if (state.posixHomeBase === null) {
130
- let probed = ''
131
- try {
132
- probed = (await runShell(scripts.probeHomeScript(), { stdoutMaxBytes: 4096 })).trim()
133
- } catch (error) {
134
- probed = ''
135
- }
136
- state.posixHomeBase = probed || process.env.DSH_HOME || os.homedir()
137
- }
138
- const hash = crypto.createHash('sha256').update(root, 'utf8').digest('hex')
139
- return state.posixHomeBase.replace(/\/+$/, '') + '/dsh-recall-snapshots/' + hash
140
- }
141
-
142
- async function homeDirFor(root) {
143
- return isWin ? homeDirForWin(root) : homeDirForPosix(root)
144
- }
145
-
146
- // store 形态装配:exclude.txt 是用户自定义排除文件,home 存储时放在
147
- // dsh-recall-snapshots 根(所有项目共享一份全局配置);降级存储时放
148
- // store 目录内部——降级目录本身已被排除规则覆盖,不再往项目根塞文件。
149
- // git init <dir> 会把真实 git-dir 建在 <dir>/.git,所以 repo 是仓库
150
- // 工作目录、git 是真实 git-dir——冒烟测试踩过的坑。
151
- function makeStore(dir, home) {
152
- const excludeFile = home
153
- ? dir.slice(0, dir.lastIndexOf(SEP)) + SEP + 'exclude.txt'
154
- : dir + SEP + 'exclude.txt'
155
- return { dir, repo: dir + SEP + 'git', git: dir + SEP + 'git' + SEP + '.git', home, excludeFile }
156
- }
157
-
158
- // 存储根:优先放 DSH home(保持项目目录干净)。shell 以宿主身份执行,
159
- // 受限会话(workspace-write/read-only)也能写 home;只有 home 本身不可写
160
- // (如 DSH_HOME 指向只读/网络盘)才降级到项目内(功能优先于干净)。
161
- async function resolveStore(root) {
162
- const cached = state.stores.get(root)
163
- if (cached) return cached
164
- let homeDir = null
165
- try {
166
- homeDir = await homeDirFor(root)
167
- } catch (error) {
168
- homeDir = null
169
- }
170
- if (homeDir) {
171
- try {
172
- await runShell(scripts.mkdirScript(homeDir), { stdoutMaxBytes: 4096 })
173
- const store = makeStore(homeDir, true)
174
- state.stores.set(root, store)
175
- return store
176
- } catch (error) {
177
- console.error('recall home store unavailable, falling back to workspace:', String(error))
178
- }
179
- }
180
- const fallback = root + SEP + '.dsh-recall-snapshots'
181
- await runShell(scripts.mkdirScript(fallback), { stdoutMaxBytes: 4096 })
182
- const store = makeStore(fallback, false)
183
- state.stores.set(root, store)
184
- return store
185
- }
186
-
187
- // 旧版迁移:宿主身份执行前的版本在受限会话里会把影子仓库降级到项目内,
188
- // 这里在下一条消息快照前把它整体迁回 home 并删除项目内目录,恢复
189
- // 「项目目录干净」。失败节流 5 分钟,避免 home 不可写时每条消息白试。
190
- async function tryUpgradeToHome(root) {
191
- const store = state.stores.get(root)
192
- if (!store || store.home) return store
193
- const now = Date.now()
194
- const last = state.homeRetryAt.get(root) || 0
195
- if (now - last < HOME_RETRY_MS) return store
196
- state.homeRetryAt.set(root, now)
197
- let homeDir = null
198
- try {
199
- homeDir = await homeDirFor(root)
200
- } catch (error) {
201
- homeDir = null
202
- }
203
- if (!homeDir) return store
204
- try {
205
- await runShell(scripts.mkdirScript(homeDir), { stdoutMaxBytes: 4096 })
206
- await runShell(scripts.migrateScript(store.dir, homeDir), { timeoutMs: 300000, stdoutMaxBytes: 4096 })
207
- const upgraded = makeStore(homeDir, true)
208
- state.stores.set(root, upgraded)
209
- state.gitReady.delete(store.git)
210
- // store 的 gc 节流凭据随之作废,清掉避免新 store 误读
211
- state.gcLastAt.delete(store.git)
212
- state.gcCount.delete(store.git)
213
- console.error('recall store upgraded to home:', root)
214
- return upgraded
215
- } catch (error) {
216
- console.error('recall home upgrade failed:', String(error))
217
- return store
218
- }
219
- }
220
-
221
- // 建立影子仓库(幂等:gitReady 命中后直接跳过,省掉每条消息一次的
222
- // config/exclude 重写)。同时回读 gc.stamp 种子化 gc 节流:让「上次 gc
223
- // 时间」跨重启续存,避免天天重启的机器每开机都来一次全量 gc。
224
- async function ensureGit(root, store) {
225
- if (state.gitReady.has(store.git)) return true
226
- const gitExe = await resolveGit()
227
- if (!gitExe) return false
228
- try {
229
- const out = scripts.stripBom(await runShell(scripts.ensureGitScript(store, gitExe), { stdoutMaxBytes: 4096 }))
230
- state.gitReady.add(store.git)
231
- const m = out.match(/GIT_OK\s+(\d+)/)
232
- state.gcLastAt.set(store.git, m ? parseInt(m[1], 10) * 1000 : Date.now())
233
- return true
234
- } catch (error) {
235
- console.error('recall ensureGit failed:', String(error))
236
- return false
237
- }
238
- }
239
-
240
- // 迁移收尾:删除旧版 blobs 格式的项目内 .dsh-recall-snapshots 目录,
241
- // 仅在 home 存储可用时执行——降级场景下该目录就是新 store,不能删。
242
- function cleanupLegacy(root) {
243
- const store = state.stores.get(root)
244
- if (!store || !store.home) return
245
- runShell(scripts.legacyRmScript(root + SEP + '.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
246
- }
247
-
248
- return { state, isWin, scripts, runShell, resolveRoot, resolveGit, homeDirFor, resolveStore, tryUpgradeToHome, ensureGit, cleanupLegacy }
249
- }
1
+ /**
2
+ * dsh-recall-plugin — 执行与存储层(ctx 绑定的工厂,无模块级副作用)
3
+ *
4
+ * 职责:提供 runShell(宿主身份执行 + 统一编码保证)、会话根目录解析、
5
+ * git 可执行文件探测、home/降级存储解析与迁移、影子仓库初始化(ensureGit)。
6
+ * 按 process.platform 选择脚本模板(scripts.pwsh.js / scripts.posix.js),
7
+ * 两套模板导出同名接口,本文件用 rt.scripts 统一下发。
8
+ * 产出共享 state(各 Map 缓存)供 snapshots.js / maintenance.js 复用;
9
+ * 由 lib/index.js 在 apply(ctx) 里装配,插件卸载时随 Fiber 一起丢弃。
10
+ */
11
+
12
+ import os from 'node:os'
13
+ import crypto from 'node:crypto'
14
+ import * as pwshScripts from './scripts.pwsh.js'
15
+ import * as posixScripts from './scripts.posix.js'
16
+
17
+ // home 不可写时迁移重试的节流间隔:避免每条消息都白试一次注定失败的迁移
18
+ const HOME_RETRY_MS = 300000
19
+
20
+ // 最近错误环形缓冲容量:设置页排障用,20 条足够回溯一轮快照/gc 的失败
21
+ const ERROR_BUFFER_MAX = 20
22
+
23
+ export function createRuntime(ctx, config) {
24
+ const shell = ctx.shell
25
+ const sessions = ctx.sessions
26
+
27
+ const isWin = process.platform === 'win32'
28
+ const SEP = isWin ? '\\' : '/'
29
+ const scripts = isWin ? pwshScripts : posixScripts
30
+
31
+ const state = {
32
+ roots: new Map(),
33
+ stores: new Map(),
34
+ snapshots: new Map(),
35
+ queue: Promise.resolve(),
36
+ indexLoaded: new Set(),
37
+ gitReady: new Set(),
38
+ cutSeqCache: new Map(),
39
+ homeRetryAt: new Map(),
40
+ gcLastAt: new Map(),
41
+ gcCount: new Map(),
42
+ gitExe: null,
43
+ posixHomeBase: null,
44
+ homeContainer: null,
45
+ errors: []
46
+ }
47
+
48
+ // 最近错误环形缓冲:Host 侧所有失败原本只进 console.error(宿主进程
49
+ // 日志,用户在页面上不可见),这里留最近 20 条经 /api/recall/status
50
+ // 下发给设置页展示。同时转发 console.error 保持原有宿主日志不变。
51
+ function recordError(text) {
52
+ const message = String(text)
53
+ state.errors.push({ time: Date.now(), message })
54
+ if (state.errors.length > ERROR_BUFFER_MAX) state.errors.splice(0, state.errors.length - ERROR_BUFFER_MAX)
55
+ console.error(message)
56
+ }
57
+
58
+ // 两套脚本模板的「命令函数」同名导出是跨平台正确性的硬约束(store.js
59
+ // 按平台单选 rt.scripts,调用方统一 S.*):单侧漏导出只会在另一平台
60
+ // 用户机器上以「不是函数」的怪异方式暴雷。装配时比对一次。豁免项:
61
+ // 平台专属导出(homeDirScript $h 链只在 pwsh 侧需要——POSIX 的 home
62
+ // 基底走 probeHomeScript + Node 侧推导;常量与转义工具不承载命令)。
63
+ ;(function checkScriptParity() {
64
+ // fileWriteCmd 仅 pwsh 版存在:POSIX 的文本落盘走 stdin(store.js
65
+ // writeTextViaShell POSIX 分支不经命令行传参),不需要该模板函数
66
+ const SKIP = new Set(['homeDirScript', 'probeHomeScript', 'fileWriteCmd'])
67
+ const pwshKeys = Object.keys(pwshScripts).filter((k) => !SKIP.has(k) && typeof pwshScripts[k] === 'function')
68
+ const posixKeys = Object.keys(posixScripts).filter((k) => !SKIP.has(k) && typeof posixScripts[k] === 'function')
69
+ const missing = pwshKeys.filter((k) => posixKeys.indexOf(k) < 0)
70
+ if (missing.length) recordError('recall script parity: posix 缺少导出 ' + missing.join(', '))
71
+ })()
72
+
73
+ // 所有 shell 调用都以宿主身份(danger-full-access)执行,不借用会话沙箱。
74
+ // 为什么安全:DSH 沙箱约束的是「模型驱动」的文件效果,而本插件的命令全部
75
+ // 是宿主侧固定模板(建仓/快照/索引/回退),命令串里唯一变量是插件自己
76
+ // 推导的路径(会话 cwd、哈希出的 store 路径、消息 ID),模型无法注入任何
77
+ // 内容;快照落盘的也只是会话本就有权读取的工作区文件副本,不扩大能力。
78
+ // 为什么必须如此:若按会话解析策略,workspace-write/read-only 会话写不了
79
+ // home,快照被迫降级进项目目录(污染);read-only 会话连项目都写不了,
80
+ // 回退恢复直接失败。pwsh-sandbox / bash-sandbox 对 danger-full-access
81
+ // 直接不约束(等价本地执行器),无沙箱后端的部署则忽略该字段,两边都成立。
82
+ async function runShell(command, opts) {
83
+ const sp = ctx.get('sandboxPolicy')
84
+ const spec = shell.resolve({
85
+ // 编码前导:pwsh 侧统一 UTF-8 输出(中文机器 GBK 代码页不再乱码);
86
+ // bash 侧 LC_ALL=C 确定序。各模板自带,这里统一前置注入。
87
+ command: scripts.UTF8_PRELUDE + '\n' + command,
88
+ timeoutMs: (opts && opts.timeoutMs) || 300000,
89
+ stdoutMaxBytes: (opts && opts.stdoutMaxBytes) || 4194304,
90
+ // stdin 是官方 ShellExecRequest 契约字段(bash-local/pwsh 均实现),
91
+ // POSIX 侧用它传 index.json 全文,绕开 argv 长度上限
92
+ ...((opts && opts.stdin !== undefined) ? { stdin: opts.stdin } : {}),
93
+ sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: (sp && sp.workspaceRoot) || process.cwd() }
94
+ })
95
+ const res = await shell.run(spec)
96
+ const out = (res && res.stdout && res.stdout.text) || ''
97
+ if (res && res.exitCode !== 0) {
98
+ const err = ((res && res.stderr && res.stderr.text) || '').trim() || ('exit ' + String(res.exitCode))
99
+ throw new Error(err.slice(0, 1500))
100
+ }
101
+ return out
102
+ }
103
+
104
+ async function resolveRoot(sessionId) {
105
+ const key = sessionId ? String(sessionId) : 'fallback'
106
+ const cached = state.roots.get(key)
107
+ if (cached) return cached
108
+ let root = null
109
+ if (sessionId) {
110
+ const session = sessions.get(sessionId)
111
+ if (session && session.header && session.header.cwd) root = session.header.cwd
112
+ }
113
+ if (!root) {
114
+ const sp = ctx.get('sandboxPolicy')
115
+ if (sp && sp.workspaceRoot) root = sp.workspaceRoot
116
+ }
117
+ if (root) {
118
+ // 尾分隔符归一(win32 "D:\" 三字符盘根;POSIX 保 "/" 根):
119
+ // cwd 是否带尾斜杠由上游决定,不归一会让哈希输入不一致(换 store
120
+ // 目录),也会让排除扫描的 ${f#"$root"/} 前缀剥离错一位。
121
+ root = root.replace(/[\\/]+$/, '') || (isWin ? root : '/')
122
+ if (isWin && root.length === 2) root += '\\'
123
+ state.roots.set(key, root)
124
+ }
125
+ return root
126
+ }
127
+
128
+ // 解析 git 可执行文件路径:求值一次并缓存,脚本里用绝对路径调用,
129
+ // 避免每条命令依赖 PATH(DSH 进程 PATH 可能不含 git)。
130
+ async function resolveGit() {
131
+ if (state.gitExe !== null) return state.gitExe
132
+ try {
133
+ const path = scripts.stripBom(await runShell(scripts.resolveGitScript(), { stdoutMaxBytes: 4096 })).trim()
134
+ state.gitExe = path || ''
135
+ } catch (error) {
136
+ state.gitExe = ''
137
+ }
138
+ return state.gitExe
139
+ }
140
+
141
+ // win32:哈希在 PowerShell 里算(SHA256 Create 兼容 PS 5.1),连带
142
+ // $env:DSH_HOME / $env:USERPROFILE 的解析都在 shell 侧完成。
143
+ async function homeDirForWin(root) {
144
+ const envHome = (process.env && process.env.DSH_HOME) || ''
145
+ const text = scripts.stripBom(await runShell(scripts.homeDirScript(root, envHome), { stdoutMaxBytes: 4096 })).trim()
146
+ if (!text) return null
147
+ // 折叠 Join-Path 可能带出的连续反斜杠;开头的双反斜杠是 UNC 前缀
148
+ // (DSH_HOME/主目录指到网络盘),折叠掉会把 \\server\share 变成无效
149
+ // \server\share,必须原样保留。
150
+ if (/^\\\\/.test(text)) return '\\\\' + text.slice(2).replace(/\\{2,}/g, '\\')
151
+ return text.replace(/\\{2,}/g, '\\')
152
+ }
153
+
154
+ // POSIX:shell 侧只探 bash env 里显式的 $DSH_HOME(DSH 执行器洗刷
155
+ // DSH_* 变量后通常为空);为空时依次回退 Node 主进程的 DSH_HOME
156
+ // (宿主进程 env,用户导出可见)与 os.homedir()。哈希用 Node crypto
157
+ // 统一算,规避 Linux sha256sum / macOS shasum 的二选一移植成本。
158
+ async function posixHomeBaseResolve() {
159
+ if (state.posixHomeBase === null) {
160
+ let probed = ''
161
+ try {
162
+ probed = (await runShell(scripts.probeHomeScript(), { stdoutMaxBytes: 4096 })).trim()
163
+ } catch (error) {
164
+ probed = ''
165
+ }
166
+ state.posixHomeBase = probed || process.env.DSH_HOME || os.homedir()
167
+ }
168
+ return state.posixHomeBase
169
+ }
170
+
171
+ async function homeDirForPosix(root) {
172
+ const base = await posixHomeBaseResolve()
173
+ const hash = crypto.createHash('sha256').update(root, 'utf8').digest('hex')
174
+ return base.replace(/\/+$/, '') + '/dsh-recall-snapshots/' + hash
175
+ }
176
+
177
+ async function homeDirFor(root) {
178
+ return isWin ? homeDirForWin(root) : homeDirForPosix(root)
179
+ }
180
+
181
+ // 快照容器目录(<homeBase>/dsh-recall-snapshots,不含哈希子目录):
182
+ // 设置页 exclude-get 的磁盘兜底用——冷启动时会话注册表为空(惰性
183
+ // 载入),但容器目录可能早已存在,此时共享 exclude.txt 仍应可编辑。
184
+ // 目录结构固定 <base>/dsh-recall-snapshots/<hash>,所以容器就是
185
+ // homeDirFor 结果的父目录:JS 侧 slice 推导,不再走第二条 shell 解析链
186
+ // (旧实现里 homeDirScript 与 homeContainerScript 的 $h 链靠注释人工
187
+ // 对齐,存在漂移风险)。失败返回 null 且不缓存,下次调用自然重试。
188
+ async function resolveHomeContainer() {
189
+ if (state.homeContainer) return state.homeContainer
190
+ let container = null
191
+ try {
192
+ const probeRoot = Array.from(state.roots.values())[0] || process.cwd()
193
+ const homeDir = await homeDirFor(probeRoot)
194
+ if (homeDir) container = homeDir.slice(0, homeDir.length - 65)
195
+ } catch (error) {
196
+ container = null
197
+ }
198
+ if (container) state.homeContainer = container
199
+ return container
200
+ }
201
+
202
+ // store 形态装配:exclude.txt 是用户自定义排除文件,home 存储时放在
203
+ // dsh-recall-snapshots 根(所有项目共享一份全局配置);降级存储时放
204
+ // store 目录内部——降级目录本身已被排除规则覆盖,不再往项目根塞文件。
205
+ // git init <dir> 会把真实 git-dir 建在 <dir>/.git,所以 repo 是仓库
206
+ // 工作目录、git 是真实 git-dir——冒烟测试踩过的坑。
207
+ // maxFileBytes config 注入 store:脚本模板(snapshot/diff/rollback
208
+ // 的超大文件剔除)按调用时从 store 读取,用户改 config 后下一条命令
209
+ // 即生效,无需重启。
210
+ function makeStore(dir, home) {
211
+ const excludeFile = home
212
+ ? dir.slice(0, dir.lastIndexOf(SEP)) + SEP + 'exclude.txt'
213
+ : dir + SEP + 'exclude.txt'
214
+ return { dir, repo: dir + SEP + 'git', git: dir + SEP + 'git' + SEP + '.git', home, excludeFile, maxFileBytes: config.maxFileBytes }
215
+ }
216
+
217
+ // store 级元数据 root.txt:内容为工作区绝对路径。store 目录名是 root 的
218
+ // 单向 SHA256,反解不了——「快照管理」跨工作区展示时靠它把哈希目录映射
219
+ // 回工作区名。best-effort(失败不阻断主流程),旧 store 在 resolveStore
220
+ // 再次被调用(重启后首个 init/快照/管理列表)时自然补写,存量自愈。
221
+ function persistRootHint(store, root) {
222
+ writeTextViaShell(store.dir + SEP + 'root.txt', root).catch(() => {})
223
+ }
224
+
225
+ // 存储根:优先放 DSH home(保持项目目录干净)。shell 以宿主身份执行,
226
+ // 受限会话(workspace-write/read-only)也能写 home;只有 home 本身不可写
227
+ // (如 DSH_HOME 指向只读/网络盘)才降级到项目内(功能优先于干净)。
228
+ async function resolveStore(root) {
229
+ const cached = state.stores.get(root)
230
+ if (cached) return cached
231
+ let homeDir = null
232
+ try {
233
+ homeDir = await homeDirFor(root)
234
+ } catch (error) {
235
+ homeDir = null
236
+ }
237
+ if (homeDir) {
238
+ try {
239
+ await runShell(scripts.mkdirScript(homeDir), { stdoutMaxBytes: 4096 })
240
+ const store = makeStore(homeDir, true)
241
+ state.stores.set(root, store)
242
+ persistRootHint(store, root)
243
+ return store
244
+ } catch (error) {
245
+ recordError('recall home store unavailable, falling back to workspace: ' + String(error))
246
+ }
247
+ }
248
+ const fallback = root + SEP + '.dsh-recall-snapshots'
249
+ await runShell(scripts.mkdirScript(fallback), { stdoutMaxBytes: 4096 })
250
+ const store = makeStore(fallback, false)
251
+ state.stores.set(root, store)
252
+ persistRootHint(store, root)
253
+ return store
254
+ }
255
+
256
+ // 旧版迁移:宿主身份执行前的版本在受限会话里会把影子仓库降级到项目内,
257
+ // 这里在下一条消息快照前把它整体迁回 home 并删除项目内目录,恢复
258
+ // 「项目目录干净」。失败节流 5 分钟,避免 home 不可写时每条消息白试。
259
+ async function tryUpgradeToHome(root) {
260
+ const store = state.stores.get(root)
261
+ if (!store || store.home) return store
262
+ const now = Date.now()
263
+ const last = state.homeRetryAt.get(root) || 0
264
+ if (now - last < HOME_RETRY_MS) return store
265
+ state.homeRetryAt.set(root, now)
266
+ let homeDir = null
267
+ try {
268
+ homeDir = await homeDirFor(root)
269
+ } catch (error) {
270
+ homeDir = null
271
+ }
272
+ if (!homeDir) return store
273
+ try {
274
+ await runShell(scripts.mkdirScript(homeDir), { stdoutMaxBytes: 4096 })
275
+ await runShell(scripts.migrateScript(store.dir, homeDir), { timeoutMs: 300000, stdoutMaxBytes: 4096 })
276
+ const upgraded = makeStore(homeDir, true)
277
+ state.stores.set(root, upgraded)
278
+ persistRootHint(upgraded, root)
279
+ state.gitReady.delete(store.git)
280
+ // 旧 store 的 gc 节流凭据随之作废,清掉避免新 store 误读
281
+ state.gcLastAt.delete(store.git)
282
+ state.gcCount.delete(store.git)
283
+ console.error('recall store upgraded to home:', root)
284
+ return upgraded
285
+ } catch (error) {
286
+ recordError('recall home upgrade failed: ' + String(error))
287
+ return store
288
+ }
289
+ }
290
+
291
+ // 任意长度文本落盘(index.json / exclude.txt 共用):win32 走 base64
292
+ // 分块内联(每块 20000 字符,规避 Windows 命令行 32767 上限——DSH 的
293
+ // pwsh 执行器把命令串作为 -Command 的单个 argv 元素 spawn,快照攒到
294
+ // 几百条就超限),首块覆盖、续块追加;POSIX 用官方 ShellExecRequest
295
+ // 的 stdin 契约字段直写全文,不经命令行传参,天然没有 argv 上限。
296
+ // 空内容也落一次写(清空配置/空索引是合法状态),所以 base64 为空串
297
+ // 时仍发一块空 piece,而不是整段跳过留下旧文件。
298
+ async function writeTextViaShell(file, text) {
299
+ const body = String(text == null ? '' : text)
300
+ if (isWin) {
301
+ const b64 = Buffer.from(body, 'utf8').toString('base64')
302
+ const chunks = b64 ? b64.match(/.{1,20000}/g) : ['']
303
+ let first = true
304
+ for (const chunk of chunks) {
305
+ const piece = "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + chunk + "')) | "
306
+ await runShell(scripts.fileWriteCmd(file, piece, first), { stdoutMaxBytes: 4096 })
307
+ first = false
308
+ }
309
+ } else {
310
+ await runShell('cat > ' + scripts.psq(file), { stdin: body, stdoutMaxBytes: 4096 })
311
+ }
312
+ }
313
+
314
+ // 建立影子仓库(幂等:gitReady 命中后直接跳过,省掉每条消息一次的
315
+ // config/exclude 重写)。同时回读 gc.stamp 种子化 gc 节流:让「上次 gc
316
+ // 时间」跨重启续存,避免天天重启的机器每开机都来一次全量 gc。
317
+ async function ensureGit(root, store) {
318
+ if (state.gitReady.has(store.git)) return true
319
+ const gitExe = await resolveGit()
320
+ if (!gitExe) return false
321
+ try {
322
+ const out = scripts.stripBom(await runShell(scripts.ensureGitScript(store, gitExe, config.baseExcludes), { stdoutMaxBytes: 4096 }))
323
+ state.gitReady.add(store.git)
324
+ const m = out.match(/GIT_OK\s+(\d+)/)
325
+ state.gcLastAt.set(store.git, m ? parseInt(m[1], 10) * 1000 : Date.now())
326
+ return true
327
+ } catch (error) {
328
+ recordError('recall ensureGit failed: ' + String(error))
329
+ return false
330
+ }
331
+ }
332
+
333
+ // 迁移收尾:删除旧版 blobs 格式的项目内 .dsh-recall-snapshots 目录,
334
+ // 仅在 home 存储可用时执行——降级场景下该目录就是新 store,不能删。
335
+ function cleanupLegacy(root) {
336
+ const store = state.stores.get(root)
337
+ if (!store || !store.home) return
338
+ runShell(scripts.legacyRmScript(root + SEP + '.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
339
+ }
340
+
341
+ return { state, isWin, scripts, recordError, runShell, writeTextViaShell, resolveRoot, resolveGit, homeDirFor, resolveHomeContainer, resolveStore, tryUpgradeToHome, ensureGit, cleanupLegacy }
342
+ }