dsh-recall-plugin 1.0.4 → 1.2.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 ADDED
@@ -0,0 +1,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
+ 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "1.0.4",
3
+ "version": "1.2.0",
4
4
  "description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -17,8 +17,7 @@
17
17
  "./package.json": "./package.json"
18
18
  },
19
19
  "files": [
20
- "lib/index.js",
21
- "lib/client.js",
20
+ "lib",
22
21
  "cordis.patch.yml",
23
22
  "README.md",
24
23
  "LICENSE"