dsh-tracepoint 0.0.0-stage → 0.6.2
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/CHANGELOG.md +124 -0
- package/LICENSE +21 -0
- package/README.md +227 -2
- package/assets/icon.png +0 -0
- package/cordis.patch.yml +3 -0
- package/index.js +1629 -0
- package/package.json +50 -4
package/index.js
ADDED
|
@@ -0,0 +1,1629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-trace —— 痕迹(v0.2)
|
|
3
|
+
*
|
|
4
|
+
* 动手前拍一个点;改坏了能退回去;退回本身也能退回。
|
|
5
|
+
*
|
|
6
|
+
* 三条底线(写死在代码里,不是设置项):
|
|
7
|
+
* 1. 不自动删任何快照。到上限就拒绝新快照,让你清(要清得显式点名 forget)。
|
|
8
|
+
* 2. 不整目录覆盖。restore 只碰那份 manifest 里记过的文件,不删你新增的。
|
|
9
|
+
* 3. 写文件一律"临时文件 → 校验 → rename",中途被杀也不会留半个文件。
|
|
10
|
+
*
|
|
11
|
+
* v0.2 加的四样:
|
|
12
|
+
* · diff 给内容(不只是文件名):+n/-n 和改动那一块的前后文
|
|
13
|
+
* · forget / gc:能按名字删一个点,能回收没人引用的块(都不自动)
|
|
14
|
+
* · 并发锁:两个 restore 同时跑会被挡住
|
|
15
|
+
* · 长路径(Windows >260)自动走 \\?\ 前缀
|
|
16
|
+
*
|
|
17
|
+
* v0.3:save({paths}) 只拍点名的那几个文件(毫秒级);局部快照的 diff 不报"新增"。
|
|
18
|
+
*
|
|
19
|
+
* v0.4:主动用兜底。写文件类工具跑完,如果这个文件不在拍过的点里,
|
|
20
|
+
* 就在结果末尾补一句「[痕迹]…」(每场最多 3 次,config.json 可关)。
|
|
21
|
+
* 不拦调用、不改结果本身、不假装成功。
|
|
22
|
+
*
|
|
23
|
+
* v0.5:再往前一步——把一句提醒**写进系统提示词**(位置在 write 工具说明前面)。
|
|
24
|
+
* 兜底只在写完那一刻出声,第一笔改动保不住;这一句在动手之前就在。
|
|
25
|
+
* 同一份 config.json 的 {"remind": false} 把这两层一起关掉(提示词要重开才消失)。
|
|
26
|
+
*
|
|
27
|
+
* v0.5.2:restore({paths:[...]}) 只退点名的文件(以前整份快照一起退);
|
|
28
|
+
* 没拍到的文件直说退不了,不假装成功。真用出来的:只想退卡片却动了 README。
|
|
29
|
+
*
|
|
30
|
+
* v0.6:第三层兜底——写文件类工具**真正执行前**自动拍点(tools/pre-execute 钩子)。
|
|
31
|
+
* 模型忘了拍点,插件自己拍,第一笔改动也退得回去。拍不成静默跳过,靠写完那层提醒补。
|
|
32
|
+
* 关掉:config.json 写 {"autoSave": false}。
|
|
33
|
+
*
|
|
34
|
+
* v0.6.1:0.6.0 的自动拍点在真宿主上**从没触发**——它只认 agent.cwd,而 DSH 0.2.2 的
|
|
35
|
+
* ReactLoopAgent 没有 cwd 字段,工作区根在 agent.session.header.cwd。两个都认。
|
|
36
|
+
*
|
|
37
|
+
* 发布命名:npm 包名 / 仓库名 = dsh-tracepoint(npm 防混淆拒绝 dsh-trace,与 dd-trace 太像)。
|
|
38
|
+
* 内部标识保留 dsh-trace:数据目录 ~/.dsh/dsh-trace、提示词段 dsh-trace:remind,
|
|
39
|
+
* 工具名 trace_anchor / trace_attempt 不变——既有快照与配置不因改名失效。
|
|
40
|
+
*
|
|
41
|
+
* v0.6.2:仅重写 npm 门面描述(「后悔药」与「安全网」),逻辑零改动。
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import fs from 'node:fs'
|
|
45
|
+
import os from 'node:os'
|
|
46
|
+
import path from 'node:path'
|
|
47
|
+
import crypto from 'node:crypto'
|
|
48
|
+
|
|
49
|
+
export const name = 'dsh-tracepoint'
|
|
50
|
+
export const inject = ['tools', 'systemPrompt']
|
|
51
|
+
|
|
52
|
+
const VERSION = '0.6.2'
|
|
53
|
+
const HOME = path.join(os.homedir(), '.dsh', 'dsh-trace')
|
|
54
|
+
const BLOB_DIR = path.join(HOME, 'blobs')
|
|
55
|
+
const MAN_DIR = path.join(HOME, 'manifests')
|
|
56
|
+
const ATTEMPT_FILE = path.join(HOME, 'attempts.jsonl')
|
|
57
|
+
const INDEX_FILE = path.join(HOME, 'index.json')
|
|
58
|
+
const TMP_PREFIX = '.dst-tmp-'
|
|
59
|
+
|
|
60
|
+
const MAX_BYTES = 500 * 1024 * 1024
|
|
61
|
+
const BIG_FILE = 50 * 1024 * 1024
|
|
62
|
+
const TEXT_DIFF_LIMIT = 256 * 1024
|
|
63
|
+
const EXCLUDES = new Set(['node_modules', '.git', '.obsidian', '备份', '.dsh-trace'])
|
|
64
|
+
const ATTEMPT_KEEP_DAYS = 90
|
|
65
|
+
const LOCK_TTL = 5 * 60 * 1000
|
|
66
|
+
const LOCK_BEAT = 20 * 1000
|
|
67
|
+
|
|
68
|
+
/* 活着的锁:长时间同步操作(GC 实测能跑 7 分钟)里要手动刷 mtime,
|
|
69
|
+
否则另一头会把还在用的锁当成死锁抢走(评审 2026-11-14 的 P1)。 */
|
|
70
|
+
const ACTIVE_LOCKS = new Set()
|
|
71
|
+
function beatLocks() {
|
|
72
|
+
if (ACTIVE_LOCKS.size === 0) return
|
|
73
|
+
const now = new Date()
|
|
74
|
+
for (const f of ACTIVE_LOCKS) {
|
|
75
|
+
try {
|
|
76
|
+
utimes(f, now, now)
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ---------------- 小工具 ---------------- */
|
|
82
|
+
|
|
83
|
+
const sha = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
|
|
84
|
+
const nowIso = () => new Date().toISOString().replace('T', ' ').slice(0, 16)
|
|
85
|
+
const mb = (n) => (n / 1048576).toFixed(1) + ' MB'
|
|
86
|
+
const KB = (n) => (n / 1024).toFixed(0) + ' KB'
|
|
87
|
+
|
|
88
|
+
/* Windows 长路径:超过 248 字符加 \\?\ 前缀,否则后端会 ENOENT */
|
|
89
|
+
function wp(p) {
|
|
90
|
+
if (process.platform !== 'win32') return p
|
|
91
|
+
const abs = path.isAbsolute(p) ? p : path.resolve(p)
|
|
92
|
+
if (abs.startsWith('\\\\?\\')) return abs
|
|
93
|
+
return abs.length >= 248 ? '\\\\?\\' + abs : abs
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ex = (p) => fs.existsSync(wp(p))
|
|
97
|
+
const st = (p) => fs.statSync(wp(p))
|
|
98
|
+
const rd = (p, o) => fs.readFileSync(wp(p), o)
|
|
99
|
+
const wr = (p, b, o) => fs.writeFileSync(wp(p), b, o)
|
|
100
|
+
const mk = (p, o) => fs.mkdirSync(wp(p), o)
|
|
101
|
+
const rn = (a, b) => fs.renameSync(wp(a), wp(b))
|
|
102
|
+
const rm = (p, o) => fs.rmSync(wp(p), o)
|
|
103
|
+
const listDir = (p, o) => fs.readdirSync(wp(p), o)
|
|
104
|
+
const chmod = (p, m) => fs.chmodSync(wp(p), m)
|
|
105
|
+
const utimes = (p, a, b) => fs.utimesSync(wp(p), a, b)
|
|
106
|
+
const appendF = (p, s) => fs.appendFileSync(wp(p), s, 'utf8')
|
|
107
|
+
|
|
108
|
+
function ensureDirs() {
|
|
109
|
+
mk(BLOB_DIR, { recursive: true })
|
|
110
|
+
mk(MAN_DIR, { recursive: true })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function tmpName(target) {
|
|
114
|
+
return path.join(path.dirname(target), TMP_PREFIX + crypto.randomBytes(6).toString('hex'))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* 原子写入:先写临时文件,校验通过再 rename 换上去 */
|
|
118
|
+
function atomicWrite(target, buf) {
|
|
119
|
+
mk(path.dirname(target), { recursive: true })
|
|
120
|
+
const t = tmpName(target)
|
|
121
|
+
wr(t, buf)
|
|
122
|
+
if (sha(rd(t)) !== sha(buf)) {
|
|
123
|
+
rm(t, { force: true })
|
|
124
|
+
throw new Error('TMP_VERIFY_FAILED')
|
|
125
|
+
}
|
|
126
|
+
rn(t, target)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* 并发锁:同一时刻只允许一个 save / 一个 restore。
|
|
130
|
+
锁里写一个随机 token:只删自己的锁;跑着的时候有心跳刷 mtime,
|
|
131
|
+
只有"进程真的没了"(mtime 停住超过 TTL)才会被别人接管。 */
|
|
132
|
+
function acquire(kind) {
|
|
133
|
+
ensureDirs()
|
|
134
|
+
const f = path.join(HOME, '.lock-' + kind)
|
|
135
|
+
const token = process.pid + ':' + crypto.randomBytes(6).toString('hex')
|
|
136
|
+
if (ex(f)) {
|
|
137
|
+
try {
|
|
138
|
+
if (Date.now() - st(f).mtimeMs < LOCK_TTL) throw new Error('BUSY:' + kind)
|
|
139
|
+
rm(f, { force: true })
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (String(e.message).startsWith('BUSY:')) throw e
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
wr(f, token, { flag: 'wx' })
|
|
146
|
+
} catch {
|
|
147
|
+
throw new Error('BUSY:' + kind)
|
|
148
|
+
}
|
|
149
|
+
ACTIVE_LOCKS.add(f)
|
|
150
|
+
const beat = setInterval(() => beatLocks(), LOCK_BEAT)
|
|
151
|
+
if (beat.unref) beat.unref()
|
|
152
|
+
let released = false
|
|
153
|
+
return () => {
|
|
154
|
+
if (released) return
|
|
155
|
+
released = true
|
|
156
|
+
ACTIVE_LOCKS.delete(f)
|
|
157
|
+
try {
|
|
158
|
+
clearInterval(beat)
|
|
159
|
+
} catch {}
|
|
160
|
+
try {
|
|
161
|
+
if (rd(f, 'utf8') === token) rm(f, { force: true })
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sweepTmp(root) {
|
|
167
|
+
const cutoff = Date.now() - 10 * 60 * 1000
|
|
168
|
+
let n = 0
|
|
169
|
+
const walk = (dir) => {
|
|
170
|
+
let ents
|
|
171
|
+
try {
|
|
172
|
+
ents = listDir(dir, { withFileTypes: true })
|
|
173
|
+
} catch {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
for (const e of ents) {
|
|
177
|
+
const p = path.join(dir, e.name)
|
|
178
|
+
if (e.isDirectory()) {
|
|
179
|
+
if (!EXCLUDES.has(e.name)) walk(p)
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (e.name.startsWith(TMP_PREFIX)) {
|
|
183
|
+
try {
|
|
184
|
+
if (st(p).mtimeMs < cutoff) {
|
|
185
|
+
rm(p, { force: true })
|
|
186
|
+
n++
|
|
187
|
+
}
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (root && ex(root)) walk(root)
|
|
193
|
+
return n
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function usedBytes(dir = HOME) {
|
|
197
|
+
let s = 0
|
|
198
|
+
let ents
|
|
199
|
+
try {
|
|
200
|
+
ents = listDir(dir, { withFileTypes: true })
|
|
201
|
+
} catch {
|
|
202
|
+
return 0
|
|
203
|
+
}
|
|
204
|
+
for (const e of ents) {
|
|
205
|
+
const p = path.join(dir, e.name)
|
|
206
|
+
if (e.isDirectory()) s += usedBytes(p)
|
|
207
|
+
else
|
|
208
|
+
try {
|
|
209
|
+
s += st(p).size
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
return s
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function inside(root, rel) {
|
|
216
|
+
const abs = path.resolve(root, rel)
|
|
217
|
+
const norm = path.resolve(root)
|
|
218
|
+
return abs === norm || abs.startsWith(norm + path.sep)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* 真实路径:把"最深一层还存在的祖先"换成它的真实路径,再拼回不存在的尾巴。
|
|
222
|
+
词法上在工作区里、实际被目录联接/符号链接指到外面去的情况,只有这条路能看出来。 */
|
|
223
|
+
function realPathOf(p) {
|
|
224
|
+
let cur = path.resolve(p)
|
|
225
|
+
const tail = []
|
|
226
|
+
for (;;) {
|
|
227
|
+
try {
|
|
228
|
+
const real = fs.realpathSync.native(cur)
|
|
229
|
+
return tail.length ? path.join(real, ...tail.reverse()) : real
|
|
230
|
+
} catch {
|
|
231
|
+
const parent = path.dirname(cur)
|
|
232
|
+
if (parent === cur) return path.resolve(p)
|
|
233
|
+
tail.push(path.basename(cur))
|
|
234
|
+
cur = parent
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b)
|
|
240
|
+
|
|
241
|
+
/* 真实目标还在不在 root 里(root 自己也可能是联接,所以两边都取真实路径) */
|
|
242
|
+
function realInside(root, target) {
|
|
243
|
+
const r = realPathOf(root)
|
|
244
|
+
const t = realPathOf(target)
|
|
245
|
+
if (samePath(r, t)) return true
|
|
246
|
+
const rel = path.relative(r, t)
|
|
247
|
+
if (!rel || path.isAbsolute(rel)) return false
|
|
248
|
+
return rel !== '..' && !rel.startsWith('..' + path.sep)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* ---------------- 文本差异(单块,不做完整 LCS) ---------------- */
|
|
252
|
+
|
|
253
|
+
function hunkOf(oldBuf, newBuf, ctx = 2, maxLines = 24) {
|
|
254
|
+
const a = oldBuf.toString('utf8').split('\n')
|
|
255
|
+
const b = newBuf.toString('utf8').split('\n')
|
|
256
|
+
let s = 0
|
|
257
|
+
while (s < a.length && s < b.length && a[s] === b[s]) s++
|
|
258
|
+
let ea = a.length - 1
|
|
259
|
+
let eb = b.length - 1
|
|
260
|
+
while (ea >= s && eb >= s && a[ea] === b[eb]) {
|
|
261
|
+
ea--
|
|
262
|
+
eb--
|
|
263
|
+
}
|
|
264
|
+
const removed = a.slice(s, ea + 1)
|
|
265
|
+
const added = b.slice(s, eb + 1)
|
|
266
|
+
const out = []
|
|
267
|
+
for (let i = Math.max(0, s - ctx); i < s; i++) out.push(' ' + a[i])
|
|
268
|
+
for (const l of removed.slice(0, maxLines)) out.push('- ' + l)
|
|
269
|
+
if (removed.length > maxLines) out.push(`- …(还有 ${removed.length - maxLines} 行被删)`)
|
|
270
|
+
for (const l of added.slice(0, maxLines)) out.push('+ ' + l)
|
|
271
|
+
if (added.length > maxLines) out.push(`+ …(还有 ${added.length - maxLines} 行新增)`)
|
|
272
|
+
const tail = Math.min(a.length, ea + 1 + ctx)
|
|
273
|
+
for (let i = ea + 1; i < tail; i++) out.push(' ' + a[i])
|
|
274
|
+
return { added: added.length, removed: removed.length, lines: out, at: s + 1 }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isProbablyText(buf) {
|
|
278
|
+
const n = Math.min(buf.length, 8000)
|
|
279
|
+
let bad = 0
|
|
280
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) bad++
|
|
281
|
+
return bad === 0
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ---------------- 增量索引 ----------------
|
|
285
|
+
只靠 size + mtime 判断"没变过",就不再重读那个文件。
|
|
286
|
+
代价:如果某个文件被改过、但大小和 mtime 都没变(极少见),会漏判。
|
|
287
|
+
换来的是:364MB 的工作区,第二次拍照从 20 秒降到几十毫秒。 */
|
|
288
|
+
|
|
289
|
+
function loadIndex() {
|
|
290
|
+
try {
|
|
291
|
+
const j = JSON.parse(rd(INDEX_FILE, 'utf8'))
|
|
292
|
+
if (j && j.version === 1 && j.entries) return j
|
|
293
|
+
} catch {}
|
|
294
|
+
return { version: 1, entries: {} }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function saveIndex(idx) {
|
|
298
|
+
try {
|
|
299
|
+
atomicWrite(INDEX_FILE, Buffer.from(JSON.stringify(idx)))
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ---------------- 拍点 ---------------- */
|
|
304
|
+
|
|
305
|
+
function takeSnapshot(root, label, auto = false, opts = {}) {
|
|
306
|
+
const release = acquire('save')
|
|
307
|
+
try {
|
|
308
|
+
ensureDirs()
|
|
309
|
+
const t0 = Date.now()
|
|
310
|
+
const files = []
|
|
311
|
+
const dirs = []
|
|
312
|
+
const excluded = []
|
|
313
|
+
const skipped = []
|
|
314
|
+
const unreadable = []
|
|
315
|
+
const concurrent = []
|
|
316
|
+
const idx = loadIndex()
|
|
317
|
+
let reused = 0
|
|
318
|
+
let hashed = 0
|
|
319
|
+
|
|
320
|
+
/** 收一个文件:读、哈希、落块、记账。走遍历和走 paths 都用这一份。 */
|
|
321
|
+
const addFile = (p, rel) => {
|
|
322
|
+
let st1
|
|
323
|
+
try {
|
|
324
|
+
st1 = st(p)
|
|
325
|
+
} catch {
|
|
326
|
+
unreadable.push(rel)
|
|
327
|
+
return false
|
|
328
|
+
}
|
|
329
|
+
if (st1.size > BIG_FILE) {
|
|
330
|
+
skipped.push({ rel, why: '超过 50MB' })
|
|
331
|
+
return false
|
|
332
|
+
}
|
|
333
|
+
const key = root + '|' + rel
|
|
334
|
+
const hit = idx.entries[key]
|
|
335
|
+
let h = null
|
|
336
|
+
if (
|
|
337
|
+
hit &&
|
|
338
|
+
hit.size === st1.size &&
|
|
339
|
+
hit.mtimeMs === st1.mtimeMs &&
|
|
340
|
+
ex(path.join(BLOB_DIR, hit.hash.slice(0, 2), hit.hash))
|
|
341
|
+
) {
|
|
342
|
+
h = hit.hash
|
|
343
|
+
reused++
|
|
344
|
+
} else {
|
|
345
|
+
let buf
|
|
346
|
+
try {
|
|
347
|
+
buf = rd(p)
|
|
348
|
+
} catch {
|
|
349
|
+
unreadable.push(rel)
|
|
350
|
+
return false
|
|
351
|
+
}
|
|
352
|
+
let st2 = null
|
|
353
|
+
try {
|
|
354
|
+
st2 = st(p)
|
|
355
|
+
} catch {}
|
|
356
|
+
if (!st2 || st2.size !== st1.size || st2.mtimeMs !== st1.mtimeMs || st2.size !== buf.length) {
|
|
357
|
+
concurrent.push(rel)
|
|
358
|
+
}
|
|
359
|
+
h = sha(buf)
|
|
360
|
+
const blob = path.join(BLOB_DIR, h.slice(0, 2), h)
|
|
361
|
+
if (!ex(blob)) atomicWrite(blob, buf)
|
|
362
|
+
idx.entries[key] = { size: st1.size, mtimeMs: st1.mtimeMs, hash: h }
|
|
363
|
+
hashed++
|
|
364
|
+
}
|
|
365
|
+
files.push({ rel, hash: h, size: st1.size, mode: st1.mode & 0o777, mtimeMs: st1.mtimeMs })
|
|
366
|
+
return true
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const walk = (dir) => {
|
|
370
|
+
let ents
|
|
371
|
+
try {
|
|
372
|
+
ents = listDir(dir, { withFileTypes: true })
|
|
373
|
+
} catch {
|
|
374
|
+
unreadable.push(path.relative(root, dir).split(path.sep).join('/') || '.')
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
for (const e of ents) {
|
|
378
|
+
const p = path.join(dir, e.name)
|
|
379
|
+
const rel = path.relative(root, p).split(path.sep).join('/')
|
|
380
|
+
if (EXCLUDES.has(e.name)) {
|
|
381
|
+
excluded.push(rel)
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
if (e.isSymbolicLink()) {
|
|
385
|
+
skipped.push({ rel, why: '符号链接(首版不支持)' })
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
if (e.isDirectory()) {
|
|
389
|
+
dirs.push(rel)
|
|
390
|
+
walk(p)
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
if (!e.isFile()) {
|
|
394
|
+
skipped.push({ rel, why: '非普通文件' })
|
|
395
|
+
continue
|
|
396
|
+
}
|
|
397
|
+
addFile(p, rel)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* paths 模式:只拍点名的那些文件。改两个文件就只存两个,
|
|
402
|
+
不再为了"万一"把 7000 个文件全读一遍(他 20:2x 问的 23 秒)。 */
|
|
403
|
+
const wanted = Array.isArray(opts.paths)
|
|
404
|
+
? opts.paths.filter((x) => typeof x === 'string' && x.trim())
|
|
405
|
+
: null
|
|
406
|
+
const requested = []
|
|
407
|
+
const missing = []
|
|
408
|
+
|
|
409
|
+
if (wanted) {
|
|
410
|
+
const seen = new Set()
|
|
411
|
+
for (const raw of wanted) {
|
|
412
|
+
const abs = path.isAbsolute(raw) ? raw : path.join(root, raw)
|
|
413
|
+
const rel = path.relative(root, abs).split(path.sep).join('/')
|
|
414
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
415
|
+
skipped.push({ rel: String(raw), why: '不在工作区里' })
|
|
416
|
+
continue
|
|
417
|
+
}
|
|
418
|
+
if (seen.has(rel)) continue
|
|
419
|
+
seen.add(rel)
|
|
420
|
+
requested.push(rel)
|
|
421
|
+
if (rel.split('/').some((x) => EXCLUDES.has(x))) {
|
|
422
|
+
skipped.push({ rel, why: '按约定排除(node_modules 之类)' })
|
|
423
|
+
continue
|
|
424
|
+
}
|
|
425
|
+
let s1
|
|
426
|
+
try {
|
|
427
|
+
/* lstat:看这个路径本身,别顺着符号链接去看它指向的东西 */
|
|
428
|
+
s1 = fs.lstatSync(wp(abs))
|
|
429
|
+
} catch {
|
|
430
|
+
missing.push(rel)
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
if (s1.isSymbolicLink()) {
|
|
434
|
+
skipped.push({ rel, why: '符号链接(首版不支持)' })
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
if (!realInside(root, abs)) {
|
|
438
|
+
skipped.push({ rel, why: '真实路径不在工作区里(上面某级目录是链接)' })
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
if (s1.isDirectory()) {
|
|
442
|
+
skipped.push({ rel, why: 'paths 只收文件,不收目录' })
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
// 把它的上级目录也记下来:退回时万一目录被删了,能补建
|
|
446
|
+
const parts = rel.split('/')
|
|
447
|
+
for (let i = 1; i < parts.length; i++) {
|
|
448
|
+
const d = parts.slice(0, i).join('/')
|
|
449
|
+
if (!dirs.includes(d)) dirs.push(d)
|
|
450
|
+
}
|
|
451
|
+
addFile(abs, rel)
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
walk(root)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const id = 's' + Date.now().toString(36) + crypto.randomBytes(2).toString('hex')
|
|
458
|
+
saveIndex(idx)
|
|
459
|
+
const incomplete =
|
|
460
|
+
concurrent.length > 0 || unreadable.length > 0 || skipped.some((s) => s.why === '超过 50MB')
|
|
461
|
+
|
|
462
|
+
const manifest = {
|
|
463
|
+
id,
|
|
464
|
+
version: VERSION,
|
|
465
|
+
label: label || '',
|
|
466
|
+
auto,
|
|
467
|
+
mode: wanted ? 'paths' : 'workspace',
|
|
468
|
+
requested,
|
|
469
|
+
missing,
|
|
470
|
+
at: Date.now(),
|
|
471
|
+
atText: nowIso(),
|
|
472
|
+
root,
|
|
473
|
+
status: incomplete ? 'incomplete' : 'complete',
|
|
474
|
+
files,
|
|
475
|
+
dirs,
|
|
476
|
+
excluded,
|
|
477
|
+
skipped,
|
|
478
|
+
unreadable,
|
|
479
|
+
concurrent,
|
|
480
|
+
reused,
|
|
481
|
+
hashed,
|
|
482
|
+
bytes: files.reduce((s, f) => s + f.size, 0),
|
|
483
|
+
}
|
|
484
|
+
atomicWrite(path.join(MAN_DIR, id + '.json'), Buffer.from(JSON.stringify(manifest)))
|
|
485
|
+
manifest.ms = Date.now() - t0
|
|
486
|
+
return manifest
|
|
487
|
+
} finally {
|
|
488
|
+
release()
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function loadManifest(id) {
|
|
493
|
+
if (!/^s[0-9a-z]+$/.test(String(id || ''))) throw new Error('BAD_ID')
|
|
494
|
+
const p = path.join(MAN_DIR, id + '.json')
|
|
495
|
+
if (!ex(p)) throw new Error('NOT_FOUND')
|
|
496
|
+
return JSON.parse(rd(p, 'utf8'))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function listManifests(limit = 60) {
|
|
500
|
+
ensureDirs()
|
|
501
|
+
return listDir(MAN_DIR)
|
|
502
|
+
.filter((f) => f.endsWith('.json'))
|
|
503
|
+
.map((f) => {
|
|
504
|
+
try {
|
|
505
|
+
return JSON.parse(rd(path.join(MAN_DIR, f), 'utf8'))
|
|
506
|
+
} catch {
|
|
507
|
+
return null
|
|
508
|
+
}
|
|
509
|
+
})
|
|
510
|
+
.filter(Boolean)
|
|
511
|
+
.sort((a, b) => b.at - a.at)
|
|
512
|
+
.slice(0, limit)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/* ---------------- 看改了什么 ---------------- */
|
|
516
|
+
|
|
517
|
+
function diffOf(m, { withContent = true, maxFiles = 6 } = {}) {
|
|
518
|
+
const out = { modified: [], deleted: [], added: [], same: 0, details: [] }
|
|
519
|
+
const seen = new Set()
|
|
520
|
+
for (const f of m.files) {
|
|
521
|
+
if (!inside(m.root, f.rel)) continue
|
|
522
|
+
const p = path.join(m.root, f.rel)
|
|
523
|
+
seen.add(f.rel)
|
|
524
|
+
if (!ex(p)) {
|
|
525
|
+
out.deleted.push(f.rel)
|
|
526
|
+
continue
|
|
527
|
+
}
|
|
528
|
+
let buf
|
|
529
|
+
try {
|
|
530
|
+
buf = rd(p)
|
|
531
|
+
} catch {
|
|
532
|
+
out.modified.push(f.rel)
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
if (sha(buf) === f.hash) {
|
|
536
|
+
out.same++
|
|
537
|
+
continue
|
|
538
|
+
}
|
|
539
|
+
out.modified.push(f.rel)
|
|
540
|
+
|
|
541
|
+
if (withContent && out.details.length < maxFiles) {
|
|
542
|
+
if (f.size > TEXT_DIFF_LIMIT) {
|
|
543
|
+
out.details.push({ rel: f.rel, tooBig: true, size: f.size })
|
|
544
|
+
} else if (!isProbablyText(buf)) {
|
|
545
|
+
out.details.push({ rel: f.rel, binary: true, size: buf.length })
|
|
546
|
+
} else {
|
|
547
|
+
try {
|
|
548
|
+
const oldBuf = rd(path.join(BLOB_DIR, f.hash.slice(0, 2), f.hash))
|
|
549
|
+
if (!isProbablyText(oldBuf)) out.details.push({ rel: f.rel, binary: true, size: buf.length })
|
|
550
|
+
else {
|
|
551
|
+
const h = hunkOf(oldBuf, buf)
|
|
552
|
+
out.details.push({ rel: f.rel, ...h, size: buf.length })
|
|
553
|
+
}
|
|
554
|
+
} catch {
|
|
555
|
+
out.details.push({ rel: f.rel, blobMissing: true })
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const walkAdd = (dir) => {
|
|
561
|
+
let ents
|
|
562
|
+
try {
|
|
563
|
+
ents = listDir(dir, { withFileTypes: true })
|
|
564
|
+
} catch {
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
for (const e of ents) {
|
|
568
|
+
if (EXCLUDES.has(e.name)) continue
|
|
569
|
+
const p = path.join(dir, e.name)
|
|
570
|
+
const rel = path.relative(m.root, p).split(path.sep).join('/')
|
|
571
|
+
if (e.isDirectory()) {
|
|
572
|
+
walkAdd(p)
|
|
573
|
+
continue
|
|
574
|
+
}
|
|
575
|
+
if (e.isFile() && !seen.has(rel)) out.added.push(rel)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// 局部快照(paths)没扫过整个工作区,不能把"没记录的文件"说成"新增"
|
|
579
|
+
if (m.mode !== 'paths') walkAdd(m.root)
|
|
580
|
+
out.scope = m.mode || 'workspace'
|
|
581
|
+
return out
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/* ---------------- 退回去 ---------------- */
|
|
585
|
+
|
|
586
|
+
function restoreFrom(m, { dryRun, coverId, only }) {
|
|
587
|
+
const report = { wrote: [], already: [], refused: [], failed: [], dirsCreated: 0, coverId }
|
|
588
|
+
|
|
589
|
+
if (!dryRun) {
|
|
590
|
+
if (!coverId) throw new Error('NO_COVER')
|
|
591
|
+
const cover = loadManifest(coverId)
|
|
592
|
+
if (cover.status !== 'complete') throw new Error('COVER_INCOMPLETE')
|
|
593
|
+
if (usedBytes() > MAX_BYTES) throw new Error('OVER_QUOTA')
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/* only:只退点名的这几个文件(他的原话:「我只想退卡片、不想动 README」)。
|
|
597
|
+
目录也只补建这些文件的上级,不去碰整份快照里的目录。 */
|
|
598
|
+
const files = only ? m.files.filter((f) => only.has(f.rel)) : m.files
|
|
599
|
+
const needDirs = new Set()
|
|
600
|
+
if (only) {
|
|
601
|
+
for (const f of files) {
|
|
602
|
+
const parts = f.rel.split('/')
|
|
603
|
+
for (let i = 1; i < parts.length; i++) needDirs.add(parts.slice(0, i).join('/'))
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
for (const d of m.dirs) {
|
|
607
|
+
if (only && !needDirs.has(d)) continue
|
|
608
|
+
if (!inside(m.root, d)) continue
|
|
609
|
+
const p = path.join(m.root, d)
|
|
610
|
+
if (!dryRun && !ex(p)) {
|
|
611
|
+
mk(p, { recursive: true })
|
|
612
|
+
report.dirsCreated++
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
for (const f of files) {
|
|
617
|
+
if (!inside(m.root, f.rel)) {
|
|
618
|
+
report.failed.push({ rel: f.rel, why: '路径越界' })
|
|
619
|
+
continue
|
|
620
|
+
}
|
|
621
|
+
const p = path.join(m.root, f.rel)
|
|
622
|
+
/* 词法上在里、真实路径也可能在外(父目录被换成目录联接)→ 先看真实路径,
|
|
623
|
+
再看目标本身是不是个链接;两样都不放过(评审 2026-11-14 的 P1)。 */
|
|
624
|
+
let lst = null
|
|
625
|
+
try {
|
|
626
|
+
lst = fs.lstatSync(wp(p))
|
|
627
|
+
} catch {}
|
|
628
|
+
if (lst && lst.isSymbolicLink()) {
|
|
629
|
+
report.refused.push({ rel: f.rel, why: '目标是符号链接,没敢覆盖(要退就先手动删掉这个链接)' })
|
|
630
|
+
continue
|
|
631
|
+
}
|
|
632
|
+
if (!realInside(m.root, p)) {
|
|
633
|
+
report.refused.push({ rel: f.rel, why: '真实路径不在工作区里(上面某级目录是链接),没写' })
|
|
634
|
+
continue
|
|
635
|
+
}
|
|
636
|
+
let cur = null
|
|
637
|
+
try {
|
|
638
|
+
cur = ex(p) ? sha(rd(p)) : null
|
|
639
|
+
} catch {
|
|
640
|
+
cur = null
|
|
641
|
+
}
|
|
642
|
+
if (cur === f.hash) {
|
|
643
|
+
report.already.push(f.rel)
|
|
644
|
+
continue
|
|
645
|
+
}
|
|
646
|
+
if (dryRun) {
|
|
647
|
+
report.wrote.push(f.rel)
|
|
648
|
+
continue
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
let before = null
|
|
652
|
+
try {
|
|
653
|
+
before = st(p)
|
|
654
|
+
} catch {}
|
|
655
|
+
try {
|
|
656
|
+
const blob = path.join(BLOB_DIR, f.hash.slice(0, 2), f.hash)
|
|
657
|
+
if (!ex(blob)) throw new Error('BLOB_MISSING')
|
|
658
|
+
const buf = rd(blob)
|
|
659
|
+
if (sha(buf) !== f.hash) throw new Error('BLOB_CORRUPT')
|
|
660
|
+
|
|
661
|
+
const t = tmpName(p)
|
|
662
|
+
mk(path.dirname(p), { recursive: true })
|
|
663
|
+
/* 写临时文件之前再确认一次:这一小会儿里父目录也可能被换成链接 */
|
|
664
|
+
if (!realInside(m.root, p)) throw new Error('OUTSIDE_REAL_ROOT')
|
|
665
|
+
wr(t, buf)
|
|
666
|
+
if (sha(rd(t)) !== f.hash) {
|
|
667
|
+
rm(t, { force: true })
|
|
668
|
+
throw new Error('TMP_VERIFY_FAILED')
|
|
669
|
+
}
|
|
670
|
+
let after = null
|
|
671
|
+
try {
|
|
672
|
+
after = st(p)
|
|
673
|
+
} catch {}
|
|
674
|
+
if (before && after && (before.size !== after.size || before.mtimeMs !== after.mtimeMs)) {
|
|
675
|
+
rm(t, { force: true })
|
|
676
|
+
throw new Error('TARGET_CHANGING')
|
|
677
|
+
}
|
|
678
|
+
rn(t, p)
|
|
679
|
+
try {
|
|
680
|
+
chmod(p, f.mode)
|
|
681
|
+
utimes(p, new Date(), new Date(f.mtimeMs))
|
|
682
|
+
} catch {}
|
|
683
|
+
if (sha(rd(p)) !== f.hash) throw new Error('POST_VERIFY_FAILED')
|
|
684
|
+
report.wrote.push(f.rel)
|
|
685
|
+
} catch (e) {
|
|
686
|
+
const code = String(e.message || e)
|
|
687
|
+
if (code === 'TARGET_CHANGING') report.refused.push({ rel: f.rel, why: '目标正在被改,没敢动' })
|
|
688
|
+
else if (code === 'OUTSIDE_REAL_ROOT') report.refused.push({ rel: f.rel, why: '真实路径不在工作区里,没写' })
|
|
689
|
+
else report.failed.push({ rel: f.rel, why: code })
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return report
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/* 一个文件都写不了的时候(真实路径越界/目标是链接/目标正在被改):把原因说清楚,
|
|
696
|
+
别把"没写"说成"本来就一致"——那是假成功。 */
|
|
697
|
+
function restoreBlockedText(head, r) {
|
|
698
|
+
const lines = []
|
|
699
|
+
if (r.refused.length) {
|
|
700
|
+
lines.push(
|
|
701
|
+
'没敢动 ' +
|
|
702
|
+
r.refused.length +
|
|
703
|
+
' 个:\n' +
|
|
704
|
+
r.refused
|
|
705
|
+
.slice(0, 8)
|
|
706
|
+
.map((x) => ' · ' + x.rel + (x.why ? ':' + x.why : ''))
|
|
707
|
+
.join('\n'),
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
if (r.failed.length) {
|
|
711
|
+
lines.push('失败 ' + r.failed.length + ' 个:\n' + r.failed.slice(0, 8).map((x) => ' · ' + x.rel + ':' + x.why).join('\n'))
|
|
712
|
+
}
|
|
713
|
+
return head + '\n\n没有退成——这些文件写不了:\n\n' + lines.join('\n\n') + '\n\n修好再来一次(这个操作是幂等的)。'
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* ---------------- 清(都不自动,要点名) ---------------- */
|
|
717
|
+
|
|
718
|
+
/* 一次性把所有被引用的块收集起来(别在循环里反复读 manifest,那是 O(n²)) */
|
|
719
|
+
function allReferencedHashes(skipId) {
|
|
720
|
+
const refs = new Set()
|
|
721
|
+
let n = 0
|
|
722
|
+
for (const f of listDir(MAN_DIR)) {
|
|
723
|
+
if (!f.endsWith('.json')) continue
|
|
724
|
+
if (skipId && f === skipId + '.json') continue
|
|
725
|
+
if ((++n & 255) === 0) beatLocks()
|
|
726
|
+
try {
|
|
727
|
+
const m = JSON.parse(rd(path.join(MAN_DIR, f), 'utf8'))
|
|
728
|
+
;(m.files || []).forEach((x) => refs.add(x.hash))
|
|
729
|
+
} catch {}
|
|
730
|
+
}
|
|
731
|
+
return refs
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function forgetSnapshot(id) {
|
|
735
|
+
const m = loadManifest(id)
|
|
736
|
+
rm(path.join(MAN_DIR, id + '.json'), { force: true })
|
|
737
|
+
const refs = allReferencedHashes()
|
|
738
|
+
const orphanSet = new Set()
|
|
739
|
+
for (const f of m.files) if (!refs.has(f.hash)) orphanSet.add(f.hash)
|
|
740
|
+
return { id, files: m.files.length, orphanBlobs: orphanSet.size }
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function gcBlobs() {
|
|
744
|
+
const t0 = Date.now()
|
|
745
|
+
const refs = allReferencedHashes()
|
|
746
|
+
let removed = 0
|
|
747
|
+
let bytes = 0
|
|
748
|
+
let bulkDirs = 0
|
|
749
|
+
|
|
750
|
+
/* 先按"前缀目录"整批看:一个目录里只要有一个块还被引用,就逐个处理;
|
|
751
|
+
整个目录都没被引用就直接整目录删——比一个个 rm 快几十倍。 */
|
|
752
|
+
let prefixes = []
|
|
753
|
+
try {
|
|
754
|
+
prefixes = listDir(BLOB_DIR, { withFileTypes: true }).filter((e) => e.isDirectory())
|
|
755
|
+
} catch {
|
|
756
|
+
return { removed: 0, bytes: 0, ms: Date.now() - t0, bulkDirs: 0 }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
for (const d of prefixes) {
|
|
760
|
+
/* 这一段是整份 GC 最慢的地方(一个前缀目录里可能几万个块):
|
|
761
|
+
每删一批就刷一下锁,别让 5 分钟的 TTL 把还活着的锁判死(评审 2026-11-14 的 P1)。 */
|
|
762
|
+
beatLocks()
|
|
763
|
+
const dir = path.join(BLOB_DIR, d.name)
|
|
764
|
+
let names = []
|
|
765
|
+
try {
|
|
766
|
+
names = listDir(dir, { withFileTypes: true }).filter((e) => e.isFile())
|
|
767
|
+
} catch {
|
|
768
|
+
continue
|
|
769
|
+
}
|
|
770
|
+
const keep = names.filter((e) => refs.has(e.name))
|
|
771
|
+
const drop = names.filter((e) => !refs.has(e.name))
|
|
772
|
+
drop.forEach((e) => {
|
|
773
|
+
try {
|
|
774
|
+
bytes += st(path.join(dir, e.name)).size
|
|
775
|
+
} catch {}
|
|
776
|
+
})
|
|
777
|
+
if (keep.length === 0) {
|
|
778
|
+
try {
|
|
779
|
+
rm(dir, { recursive: true, force: true })
|
|
780
|
+
removed += drop.length
|
|
781
|
+
bulkDirs++
|
|
782
|
+
} catch {}
|
|
783
|
+
} else {
|
|
784
|
+
let k = 0
|
|
785
|
+
for (const e of drop) {
|
|
786
|
+
try {
|
|
787
|
+
rm(path.join(dir, e.name), { force: true })
|
|
788
|
+
removed++
|
|
789
|
+
} catch {}
|
|
790
|
+
if ((++k & 1023) === 0) beatLocks()
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return { removed, bytes, ms: Date.now() - t0, bulkDirs }
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/* ---------------- 尝试簿 ---------------- */
|
|
798
|
+
|
|
799
|
+
function appendAttempt(rec) {
|
|
800
|
+
ensureDirs()
|
|
801
|
+
appendF(ATTEMPT_FILE, JSON.stringify(rec) + '\n')
|
|
802
|
+
try {
|
|
803
|
+
const cutoff = Date.now() - ATTEMPT_KEEP_DAYS * 86400000
|
|
804
|
+
const lines = rd(ATTEMPT_FILE, 'utf8')
|
|
805
|
+
.split('\n')
|
|
806
|
+
.filter(Boolean)
|
|
807
|
+
.filter((l) => {
|
|
808
|
+
try {
|
|
809
|
+
return JSON.parse(l).at >= cutoff
|
|
810
|
+
} catch {
|
|
811
|
+
return false
|
|
812
|
+
}
|
|
813
|
+
})
|
|
814
|
+
atomicWrite(ATTEMPT_FILE, Buffer.from(lines.join('\n') + '\n'))
|
|
815
|
+
} catch {}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function recallAttempts(paths, limit = 5) {
|
|
819
|
+
if (!ex(ATTEMPT_FILE)) return []
|
|
820
|
+
const want = new Set((paths || []).map(String))
|
|
821
|
+
const out = []
|
|
822
|
+
const lines = rd(ATTEMPT_FILE, 'utf8').split('\n').filter(Boolean)
|
|
823
|
+
for (const l of lines.reverse()) {
|
|
824
|
+
let r
|
|
825
|
+
try {
|
|
826
|
+
r = JSON.parse(l)
|
|
827
|
+
} catch {
|
|
828
|
+
continue
|
|
829
|
+
}
|
|
830
|
+
if (!r.outcome || r.outcome === 'ok') continue
|
|
831
|
+
if (want.size && !(r.paths || []).some((p) => want.has(p))) continue
|
|
832
|
+
out.push(r)
|
|
833
|
+
if (out.length >= limit) break
|
|
834
|
+
}
|
|
835
|
+
return out
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/* ---------------- 工具一:trace_anchor ---------------- */
|
|
839
|
+
|
|
840
|
+
const ANCHOR_DESC = [
|
|
841
|
+
'痕迹:动手之前拍一个点,改坏了能退回去。**只有改文件之前才用它**,只读操作不用拍。',
|
|
842
|
+
'',
|
|
843
|
+
'什么时候该按:',
|
|
844
|
+
'· 要用 write / edit / 批量替换 / 删文件 / 跑可能改文件的脚本之前 → 先 save',
|
|
845
|
+
'· 改完了想知道自己到底动了哪些、动了什么 → diff(它给的是内容差异,不只是文件名)',
|
|
846
|
+
'· 改坏了 / 他不满意 / 想换个做法 → restore',
|
|
847
|
+
'· 不确定要不要退 → 先 diff,再 restore(不带 confirm 时它只给清单,不写)',
|
|
848
|
+
'',
|
|
849
|
+
'动作:',
|
|
850
|
+
'· save —— 拍一个点。整个工作区要 2 毫秒到几十秒(第一次慢,之后约 1 秒)。',
|
|
851
|
+
' **只拍要改的那几个文件就快得多**:save({paths:["src/a.js","src/b.js"]}) —— 毫秒级,而且 restore 也只退这几个。',
|
|
852
|
+
'· list —— 看拍过哪些点。',
|
|
853
|
+
'· diff —— 看某个点到现在改了什么,带 +n/-n 和改动那几行的前后文。',
|
|
854
|
+
'· restore —— 退回去。**第一次不给 confirm 只返回清单,看完再带 confirm:true 才真写。**',
|
|
855
|
+
' 只想退某几个文件就带 paths:[...](必须是那个点里拍过的;没拍到的退不了)。',
|
|
856
|
+
'· forget —— 删掉某一个点(要 confirm)。块不会立刻回收,接着跑一次 gc。',
|
|
857
|
+
'· gc —— 回收没有任何点引用的块。只动自己的数据目录。',
|
|
858
|
+
' 先带 compact:true 可以只算不删;真删几百 MB 可能要一两分钟,那期间别重复调。',
|
|
859
|
+
'',
|
|
860
|
+
'拍照有多快:第二次以后靠增量索引,只 stat 不重读。实测 7000 个文件 / 364MB 的工作区,',
|
|
861
|
+
'第一次约 30 秒(要读全量并落盘),之后每次约 1 秒。',
|
|
862
|
+
'',
|
|
863
|
+
'三条底线(改不了):不自动删快照;不整目录覆盖(只碰这份快照记过的文件,不删你新增的);',
|
|
864
|
+
'写文件一律临时文件+校验+原子替换,中途被杀也不会留半个文件。',
|
|
865
|
+
'',
|
|
866
|
+
'源文件被排除或跳过时(node_modules、.git、超过 50MB、符号链接),快照会标成 incomplete 并当场告诉你。',
|
|
867
|
+
'incomplete 的快照仍然能退回,但它"不全"这件事你必须知道——所以 restore 的保护快照必须是 complete 的。',
|
|
868
|
+
'',
|
|
869
|
+
'没拍点就直接写文件时,write / edit 的结果末尾会多出一句「[痕迹]…」——',
|
|
870
|
+
'那是我在提醒你"这次改动退不回去",不是工具报错,也不是哪儿坏了。',
|
|
871
|
+
].join('\n')
|
|
872
|
+
|
|
873
|
+
const ANCHOR_PARAMS = {
|
|
874
|
+
type: 'object',
|
|
875
|
+
properties: {
|
|
876
|
+
action: {
|
|
877
|
+
type: 'string',
|
|
878
|
+
enum: ['save', 'list', 'diff', 'restore', 'forget', 'gc'],
|
|
879
|
+
description: '要做什么。',
|
|
880
|
+
},
|
|
881
|
+
root: {
|
|
882
|
+
type: 'string',
|
|
883
|
+
description: '工作区根目录(绝对路径)。save 时必填;其余动作可省略,沿用快照里记的那个根。',
|
|
884
|
+
},
|
|
885
|
+
label: { type: 'string', description: 'save 时给这个点起一句人话,比如"改导出功能之前"。' },
|
|
886
|
+
paths: {
|
|
887
|
+
type: 'array',
|
|
888
|
+
items: { type: 'string' },
|
|
889
|
+
description:
|
|
890
|
+
'save 时只拍这几个文件(相对 root 或绝对路径),毫秒级;给了它就只收这些,restore 也只退这些。restore 时给 paths 就只退点名的文件(必须是那个点里拍过的)。不给 = 整个工作区。',
|
|
891
|
+
},
|
|
892
|
+
id: { type: 'string', description: 'diff / restore / forget 时指定哪个点。' },
|
|
893
|
+
confirm: { type: 'boolean', description: 'restore / forget 专用。第一次别给,看完清单再给 true。' },
|
|
894
|
+
compact: {
|
|
895
|
+
type: 'boolean',
|
|
896
|
+
description: 'gc 专用。true 就只算要删多少、不真删。',
|
|
897
|
+
},
|
|
898
|
+
withContent: {
|
|
899
|
+
type: 'boolean',
|
|
900
|
+
description: 'diff 专用,默认 true。false 就只给文件名和条数(文件多时省 token)。',
|
|
901
|
+
},
|
|
902
|
+
},
|
|
903
|
+
required: ['action'],
|
|
904
|
+
additionalProperties: false,
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const ANCHOR = {
|
|
908
|
+
name: 'trace_anchor',
|
|
909
|
+
description: ANCHOR_DESC,
|
|
910
|
+
parameters: ANCHOR_PARAMS,
|
|
911
|
+
output: {
|
|
912
|
+
schema: {
|
|
913
|
+
type: 'object',
|
|
914
|
+
properties: {
|
|
915
|
+
ok: { type: 'boolean' },
|
|
916
|
+
action: { type: 'string' },
|
|
917
|
+
text: { type: 'string' },
|
|
918
|
+
needsConfirm: { type: 'boolean' },
|
|
919
|
+
id: { type: 'string' },
|
|
920
|
+
},
|
|
921
|
+
required: ['ok', 'text'],
|
|
922
|
+
},
|
|
923
|
+
render: (_a, v) => [{ type: 'text', text: (v && v.text) || '' }],
|
|
924
|
+
},
|
|
925
|
+
async execute(args) {
|
|
926
|
+
const a = args || {}
|
|
927
|
+
const action = a.action
|
|
928
|
+
|
|
929
|
+
if (action === 'list') {
|
|
930
|
+
const list = listManifests(60)
|
|
931
|
+
if (!list.length) return { ok: true, action, text: '还没拍过任何点。' }
|
|
932
|
+
const manual = list.filter((m) => !m.auto)
|
|
933
|
+
const autos = list.filter((m) => m.auto)
|
|
934
|
+
const line = (m) =>
|
|
935
|
+
`${m.id} ${m.atText} ${m.status === 'complete' ? '完整' : '不完整'} ${m.files.length} 个文件 ${mb(m.bytes)} ${m.label || ''}`
|
|
936
|
+
const body = manual.length ? manual.slice(0, 20).map(line).join('\n') : '(没有手动拍的点)'
|
|
937
|
+
const total = usedBytes()
|
|
938
|
+
return {
|
|
939
|
+
ok: true,
|
|
940
|
+
action,
|
|
941
|
+
text:
|
|
942
|
+
`拍过的点(新的在上):\n` +
|
|
943
|
+
body +
|
|
944
|
+
(autos.length
|
|
945
|
+
? `\n\n另有 ${autos.length} 张自动快照(写文件前自动拍点 / restore 前的保护):${autos
|
|
946
|
+
.slice(0, 5)
|
|
947
|
+
.map((m) => m.id)
|
|
948
|
+
.join('、')}${autos.length > 5 ? ' 等' : ''}`
|
|
949
|
+
: '') +
|
|
950
|
+
`\n\n占用合计 ${mb(total)} / 上限 ${mb(MAX_BYTES)}` +
|
|
951
|
+
`\n要腾地方:forget 掉某个点,再 gc 一次。`,
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (action === 'save') {
|
|
956
|
+
const root = a.root
|
|
957
|
+
if (!root) return { ok: false, action, text: 'save 需要给 root(工作区根目录的绝对路径)。' }
|
|
958
|
+
if (!ex(root) || !st(root).isDirectory()) {
|
|
959
|
+
return { ok: false, action, text: `这个目录不存在或者是文件:${root}` }
|
|
960
|
+
}
|
|
961
|
+
/* 局部快照不该为拍两个文件去扫整棵工作区(评审 2026-11-14 实测:3000 文件时 sweepTmp 占 22ms,
|
|
962
|
+
而且随工作区规模线性增长,与"paths 只 stat 点名的文件"的承诺不符)。
|
|
963
|
+
只拍点名文件时跳过;清残留临时文件的活留给全量快照。 */
|
|
964
|
+
const cleaned = Array.isArray(a.paths) && a.paths.length ? 0 : sweepTmp(root)
|
|
965
|
+
if (usedBytes() > MAX_BYTES) {
|
|
966
|
+
return {
|
|
967
|
+
ok: false,
|
|
968
|
+
action,
|
|
969
|
+
text: `存储到上限了(${mb(usedBytes())} / ${mb(MAX_BYTES)})。我不自动删,先 forget 掉几个再 gc。`,
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
let m
|
|
973
|
+
try {
|
|
974
|
+
m = takeSnapshot(root, a.label, false, { paths: Array.isArray(a.paths) ? a.paths : null })
|
|
975
|
+
} catch (e) {
|
|
976
|
+
const code = String(e.message || e)
|
|
977
|
+
if (code === 'BUSY:save') return { ok: false, action, text: '另一个快照正在拍,等它完了再来(最多几分钟)。' }
|
|
978
|
+
throw e
|
|
979
|
+
}
|
|
980
|
+
if (m.mode === 'paths' && m.files.length === 0) {
|
|
981
|
+
return {
|
|
982
|
+
ok: false,
|
|
983
|
+
action,
|
|
984
|
+
text:
|
|
985
|
+
`一个文件都没拍下来(paths 模式)。\n` +
|
|
986
|
+
(m.missing.length ? `不存在:${m.missing.join('、')}\n` : '') +
|
|
987
|
+
(m.skipped.length ? `跳过:${m.skipped.map((s) => s.rel + '(' + s.why + ')').join('、')}\n` : '') +
|
|
988
|
+
`路径要相对工作区根目录,或者写绝对路径。`,
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
const warn =
|
|
992
|
+
m.status === 'incomplete'
|
|
993
|
+
? `\n注意:这份不完整 —— ` +
|
|
994
|
+
[
|
|
995
|
+
m.concurrent.length ? `疑似正在被写 ${m.concurrent.length} 个` : '',
|
|
996
|
+
m.unreadable.length ? `读不了 ${m.unreadable.length} 个` : '',
|
|
997
|
+
m.skipped.length ? `跳过 ${m.skipped.length} 个(${m.skipped[0].why} 等)` : '',
|
|
998
|
+
]
|
|
999
|
+
.filter(Boolean)
|
|
1000
|
+
.join(';') +
|
|
1001
|
+
`\n退回时不会碰这些文件,别以为它们在快照里。`
|
|
1002
|
+
: ''
|
|
1003
|
+
const scope =
|
|
1004
|
+
m.mode === 'paths'
|
|
1005
|
+
? `只拍了点名的 ${m.files.length} 个文件 —— **restore 也只退这几个,别的文件一概不碰**。\n`
|
|
1006
|
+
: ''
|
|
1007
|
+
const gone = m.missing.length
|
|
1008
|
+
? `\n这几个现在还不存在,没有版本可存:${m.missing.join('、')}(退回时它们也不会被删掉)\n`
|
|
1009
|
+
: ''
|
|
1010
|
+
return {
|
|
1011
|
+
ok: true,
|
|
1012
|
+
action,
|
|
1013
|
+
id: m.id,
|
|
1014
|
+
text:
|
|
1015
|
+
scope +
|
|
1016
|
+
`拍好了:${m.id}${m.label ? '(' + m.label + ')' : ''}\n` +
|
|
1017
|
+
`${m.files.length} 个文件 / ${m.dirs.length} 个目录 / ${mb(m.bytes)},用时 ${m.ms}ms` +
|
|
1018
|
+
(m.reused ? `(按上次记录复用 ${m.reused} 个,只重读 ${m.hashed} 个)` : '') +
|
|
1019
|
+
`\n` +
|
|
1020
|
+
(m.mode === 'workspace' && m.hashed > m.files.length * 0.5 && m.ms > 3000
|
|
1021
|
+
? `(第一次拍要读全量所以慢;同一个工作区之后每次约 1 秒。\n想更快就只拍要改的那几个:save({paths:['src/a.js']}))\n`
|
|
1022
|
+
: '') +
|
|
1023
|
+
`状态:${m.status === 'complete' ? '完整' : '不完整'}` +
|
|
1024
|
+
(m.excluded.length ? `\n按约定排除:${m.excluded.length} 项(node_modules 之类)` : '') +
|
|
1025
|
+
gone +
|
|
1026
|
+
warn +
|
|
1027
|
+
(cleaned ? `\n顺手清掉 ${cleaned} 个上次残留的临时文件。` : ''),
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (action === 'gc') {
|
|
1032
|
+
/* 评审 2026-11-14:gc 以前没有锁,可能和 in-flight save 抢——
|
|
1033
|
+
save 先写 blob、后写 manifest,gc 恰在中间跑会把新 blob 当孤儿删掉。
|
|
1034
|
+
现在和 save 共用一把锁(同一时刻只允许一个快照/回收动作)。 */
|
|
1035
|
+
let releaseGc
|
|
1036
|
+
try {
|
|
1037
|
+
releaseGc = acquire('save')
|
|
1038
|
+
} catch (e) {
|
|
1039
|
+
if (String(e.message || e).startsWith('BUSY:')) {
|
|
1040
|
+
return { ok: false, action, text: '另一个快照正在拍(或回收正在跑),等它完了再来。' }
|
|
1041
|
+
}
|
|
1042
|
+
throw e
|
|
1043
|
+
}
|
|
1044
|
+
try {
|
|
1045
|
+
if (a.compact) {
|
|
1046
|
+
const refs = allReferencedHashes()
|
|
1047
|
+
let n = 0
|
|
1048
|
+
let bytes = 0
|
|
1049
|
+
let dirs = 0
|
|
1050
|
+
try {
|
|
1051
|
+
for (const d of listDir(BLOB_DIR, { withFileTypes: true })) {
|
|
1052
|
+
if (!d.isDirectory()) continue
|
|
1053
|
+
const dir = path.join(BLOB_DIR, d.name)
|
|
1054
|
+
const names = listDir(dir, { withFileTypes: true }).filter((e) => e.isFile())
|
|
1055
|
+
const drop = names.filter((e) => !refs.has(e.name))
|
|
1056
|
+
if (!drop.length) continue
|
|
1057
|
+
if (drop.length === names.length) dirs++
|
|
1058
|
+
for (const e of drop) {
|
|
1059
|
+
n++
|
|
1060
|
+
try {
|
|
1061
|
+
bytes += st(path.join(dir, e.name)).size
|
|
1062
|
+
} catch {}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
} catch {}
|
|
1066
|
+
return {
|
|
1067
|
+
ok: true,
|
|
1068
|
+
action,
|
|
1069
|
+
text: n
|
|
1070
|
+
? `要删 ${n} 个没人引用的块,约 ${mb(bytes)}(其中 ${dirs} 个目录会整个删掉)。\n真的删要一会儿——几百 MB 大概一两分钟。确认就再调一次不带 compact 的 gc。`
|
|
1071
|
+
: '没有可回收的块。',
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
const r = gcBlobs()
|
|
1075
|
+
return {
|
|
1076
|
+
ok: true,
|
|
1077
|
+
action,
|
|
1078
|
+
text:
|
|
1079
|
+
r.removed === 0
|
|
1080
|
+
? '没有可回收的块。'
|
|
1081
|
+
: `回收了 ${r.removed} 个没人引用的块,腾出 ${mb(r.bytes)},用了 ${(r.ms / 1000).toFixed(1)} 秒` +
|
|
1082
|
+
(r.bulkDirs ? `(其中 ${r.bulkDirs} 个目录整个删掉)` : '') +
|
|
1083
|
+
`。现在占用 ${mb(usedBytes())}。`,
|
|
1084
|
+
}
|
|
1085
|
+
} finally {
|
|
1086
|
+
releaseGc()
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (action === 'forget') {
|
|
1091
|
+
if (!a.id) return { ok: false, action, text: '要给 id。先 list 看有哪些。' }
|
|
1092
|
+
let m
|
|
1093
|
+
try {
|
|
1094
|
+
m = loadManifest(a.id)
|
|
1095
|
+
} catch {
|
|
1096
|
+
return { ok: false, action, text: `没有这个点:${a.id}` }
|
|
1097
|
+
}
|
|
1098
|
+
if (!a.confirm) {
|
|
1099
|
+
return {
|
|
1100
|
+
ok: true,
|
|
1101
|
+
action,
|
|
1102
|
+
id: a.id,
|
|
1103
|
+
needsConfirm: true,
|
|
1104
|
+
text:
|
|
1105
|
+
`要删的是:${m.id}(${m.atText}${m.label ? ' · ' + m.label : ''})\n` +
|
|
1106
|
+
`${m.files.length} 个文件 / ${mb(m.bytes)}\n\n` +
|
|
1107
|
+
`删掉这个点以后就退不回它了。**这一步不动你工作区里的任何文件**,只动我自己的存档。\n` +
|
|
1108
|
+
`确认要删,再调一次带 confirm:true。`,
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
/* 评审 2026-11-14:forget 也和 save 共用一把锁,避免删到正在拍的那份 */
|
|
1112
|
+
let releaseF
|
|
1113
|
+
try {
|
|
1114
|
+
releaseF = acquire('save')
|
|
1115
|
+
} catch (e) {
|
|
1116
|
+
if (String(e.message || e).startsWith('BUSY:')) {
|
|
1117
|
+
return { ok: false, action, text: '另一个快照正在拍(或回收正在跑),等它完了再来。' }
|
|
1118
|
+
}
|
|
1119
|
+
throw e
|
|
1120
|
+
}
|
|
1121
|
+
try {
|
|
1122
|
+
const r = forgetSnapshot(a.id)
|
|
1123
|
+
return {
|
|
1124
|
+
ok: true,
|
|
1125
|
+
action,
|
|
1126
|
+
id: a.id,
|
|
1127
|
+
text: `删掉了 ${r.id}。它的 ${r.files} 个文件记录没了,其中 ${r.orphanBlobs} 个内容块变成没人引用。\n接着跑一次 gc 就能把地方腾出来。`,
|
|
1128
|
+
}
|
|
1129
|
+
} finally {
|
|
1130
|
+
releaseF()
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (action === 'diff' || action === 'restore') {
|
|
1135
|
+
if (!a.id) return { ok: false, action, text: '要给 id。先 trace_anchor({action:"list"}) 看有哪些。' }
|
|
1136
|
+
let m
|
|
1137
|
+
try {
|
|
1138
|
+
m = loadManifest(a.id)
|
|
1139
|
+
} catch (e) {
|
|
1140
|
+
return {
|
|
1141
|
+
ok: false,
|
|
1142
|
+
action,
|
|
1143
|
+
text: String(e.message) === 'NOT_FOUND' ? `没有这个点:${a.id}` : '点 id 不合法。',
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
if (!ex(m.root)) return { ok: false, action, text: `那个工作区不在了:${m.root}` }
|
|
1147
|
+
|
|
1148
|
+
const d = diffOf(m, { withContent: a.withContent !== false })
|
|
1149
|
+
const scopeNote = d.scope === 'paths' ? `\n(这是个**局部快照**:只记了 ${m.files.length} 个文件,所以不报"新增"——没记录的不等于新加)` : ''
|
|
1150
|
+
const head =
|
|
1151
|
+
`对比 ${m.id}(${m.atText}${m.label ? ' · ' + m.label : ''})→ 现在\n` +
|
|
1152
|
+
`改了 ${d.modified.length} 个 / 删了 ${d.deleted.length} 个` +
|
|
1153
|
+
(d.scope === 'paths' ? '' : ` / 新增 ${d.added.length} 个`) +
|
|
1154
|
+
` / 没变 ${d.same} 个` +
|
|
1155
|
+
scopeNote
|
|
1156
|
+
|
|
1157
|
+
if (action === 'diff') {
|
|
1158
|
+
const cap = (arr) => arr.slice(0, 12).map((x) => ' · ' + (x.rel || x)).join('\n')
|
|
1159
|
+
const more = (arr) => (arr.length > 12 ? `\n …… 还有 ${arr.length - 12} 个` : '')
|
|
1160
|
+
const blocks = d.details
|
|
1161
|
+
.map(
|
|
1162
|
+
(x) =>
|
|
1163
|
+
`\n── ${x.rel} ${x.added != null ? `+${x.added}/-${x.removed}` : ''}${x.at ? `(第 ${x.at} 行起)` : ''}\n` +
|
|
1164
|
+
(x.binary
|
|
1165
|
+
? ' (二进制文件,只报条数,不塞内容)'
|
|
1166
|
+
: x.tooBig
|
|
1167
|
+
? ` (${KB(x.size)},太大了,只报条数)`
|
|
1168
|
+
: x.blobMissing
|
|
1169
|
+
? ' (原始内容丢了,只报条数)'
|
|
1170
|
+
: x.lines.slice(0, 26).join('\n')),
|
|
1171
|
+
)
|
|
1172
|
+
.join('')
|
|
1173
|
+
return {
|
|
1174
|
+
ok: true,
|
|
1175
|
+
action,
|
|
1176
|
+
id: m.id,
|
|
1177
|
+
text:
|
|
1178
|
+
head +
|
|
1179
|
+
(d.modified.length ? '\n\n改了:\n' + cap(d.modified) + more(d.modified) : '') +
|
|
1180
|
+
(d.deleted.length ? '\n\n删了:\n' + cap(d.deleted) + more(d.deleted) : '') +
|
|
1181
|
+
(d.added.length ? '\n\n新增(退回去时不会删它们):\n' + cap(d.added) + more(d.added) : '') +
|
|
1182
|
+
(blocks ? '\n\n内容差异:' + blocks : ''),
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/* restore:带 paths 就只退点名的这几个文件(退不了没拍过的) */
|
|
1187
|
+
let only = null
|
|
1188
|
+
if (Array.isArray(a.paths) && a.paths.length) {
|
|
1189
|
+
const wanted = a.paths.filter((x) => typeof x === 'string' && x.trim())
|
|
1190
|
+
only = new Set(
|
|
1191
|
+
wanted.map((raw) => {
|
|
1192
|
+
const abs = path.isAbsolute(raw) ? raw : path.join(m.root, raw)
|
|
1193
|
+
return path.relative(m.root, abs).split(path.sep).join('/')
|
|
1194
|
+
}),
|
|
1195
|
+
)
|
|
1196
|
+
const have = new Set(m.files.map((f) => f.rel))
|
|
1197
|
+
const missing = [...only].filter((x) => !have.has(x))
|
|
1198
|
+
if (missing.length) {
|
|
1199
|
+
const sample = m.files.slice(0, 10).map((f) => ' · ' + f.rel).join('\n')
|
|
1200
|
+
return {
|
|
1201
|
+
ok: false,
|
|
1202
|
+
action,
|
|
1203
|
+
id: m.id,
|
|
1204
|
+
text:
|
|
1205
|
+
`这个点里没有这几个文件,退不了:\n` +
|
|
1206
|
+
missing.map((x) => ' · ' + x).join('\n') +
|
|
1207
|
+
`\n\n可能是拍点之后才新建的——新建的文件本来就不在快照里,restore 也不会删它。\n` +
|
|
1208
|
+
`这个点里拍到的是这些:\n${sample}${m.files.length > 10 ? `\n …… 还有 ${m.files.length - 10} 个` : ''}`,
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/* restore */
|
|
1214
|
+
if (!a.confirm) {
|
|
1215
|
+
const plan = restoreFrom(m, { dryRun: true, only })
|
|
1216
|
+
if (!plan.wrote.length) {
|
|
1217
|
+
if (plan.refused.length || plan.failed.length) {
|
|
1218
|
+
return { ok: false, action, id: m.id, text: restoreBlockedText(head, plan) }
|
|
1219
|
+
}
|
|
1220
|
+
return { ok: true, action, id: m.id, text: head + '\n\n现在就和那个点一致,不用退。' }
|
|
1221
|
+
}
|
|
1222
|
+
return {
|
|
1223
|
+
ok: true,
|
|
1224
|
+
action,
|
|
1225
|
+
id: m.id,
|
|
1226
|
+
needsConfirm: true,
|
|
1227
|
+
text:
|
|
1228
|
+
head +
|
|
1229
|
+
(only ? `\n\n(只退点名的 ${only.size} 个文件,别的文件一概不碰)` : '') +
|
|
1230
|
+
`\n\n退回去会写 ${plan.wrote.length} 个文件,其余 ${plan.already.length} 个本来就一致。` +
|
|
1231
|
+
`\n它不会删你新增的文件,也不会碰这份快照没记过的东西。` +
|
|
1232
|
+
`\n写之前会先拍一张"回滚前的现场",那张拍不成就整个不执行。` +
|
|
1233
|
+
`\n\n要退的:\n${plan.wrote.slice(0, 12).map((x) => ' · ' + x).join('\n')}${plan.wrote.length > 12 ? `\n …… 还有 ${plan.wrote.length - 12} 个` : ''}` +
|
|
1234
|
+
`\n\n确认要退,就再调一次,带 confirm:true。`,
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/* 确认了但本来就不用退:不拍保护快照、不写着 0 个的"成功" */
|
|
1239
|
+
const pre = restoreFrom(m, { dryRun: true, only })
|
|
1240
|
+
if (!pre.wrote.length) {
|
|
1241
|
+
if (pre.refused.length || pre.failed.length) {
|
|
1242
|
+
return { ok: false, action, id: m.id, text: restoreBlockedText(head, pre) }
|
|
1243
|
+
}
|
|
1244
|
+
return {
|
|
1245
|
+
ok: true,
|
|
1246
|
+
action,
|
|
1247
|
+
id: m.id,
|
|
1248
|
+
text: head + '\n\n现在就和那个点一致,不用退。' + (only ? `(只看了点名的 ${only.size} 个)` : ''),
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const releaseR = acquire('restore')
|
|
1253
|
+
try {
|
|
1254
|
+
let cover
|
|
1255
|
+
try {
|
|
1256
|
+
/* 保护快照只需要"这次要写的这几个文件"的旧样子。
|
|
1257
|
+
整工作区快照会因为他那几个 1GB 视频被"超过 50MB 跳过"而永远标成不完整,
|
|
1258
|
+
于是一次 restore 都退不了(2026-11-13 23:2x 真机实测撞出来的)。
|
|
1259
|
+
restore 本来就只碰这份快照记过的文件,保护快照也只拍这些就够了。 */
|
|
1260
|
+
const coverPaths = only ? [...only] : (m.files || []).map((x) => x.rel).filter(Boolean)
|
|
1261
|
+
cover = takeSnapshot(m.root, '回滚前的现场(自动)', true, { paths: coverPaths })
|
|
1262
|
+
if (cover.status !== 'complete') {
|
|
1263
|
+
return {
|
|
1264
|
+
ok: false,
|
|
1265
|
+
action,
|
|
1266
|
+
text: `没退。保护快照不完整(这次要退的 ${coverPaths.length} 个文件里,有正在被写或读不了的),这时候退下去就没法再救回来。等几秒再试。`,
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
} catch (e) {
|
|
1270
|
+
return { ok: false, action, text: '没退。保护快照没拍成:' + String(e.message || e) }
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
let r
|
|
1274
|
+
try {
|
|
1275
|
+
r = restoreFrom(m, { dryRun: false, coverId: cover.id, only })
|
|
1276
|
+
} catch (e) {
|
|
1277
|
+
const code = String(e.message || e)
|
|
1278
|
+
const why =
|
|
1279
|
+
code === 'OVER_QUOTA'
|
|
1280
|
+
? '存储到上限了,先 forget 几个点再 gc。'
|
|
1281
|
+
: code === 'COVER_INCOMPLETE'
|
|
1282
|
+
? '保护快照不完整。'
|
|
1283
|
+
: code
|
|
1284
|
+
return { ok: false, action, text: '没退。' + why }
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const bad = r.failed.length + r.refused.length
|
|
1288
|
+
return {
|
|
1289
|
+
ok: bad === 0,
|
|
1290
|
+
action,
|
|
1291
|
+
id: m.id,
|
|
1292
|
+
text:
|
|
1293
|
+
`退回 ${m.id}:写了 ${r.wrote.length} 个,本来一致 ${r.already.length} 个` +
|
|
1294
|
+
(only ? `(只退点名的 ${only.size} 个)` : '') +
|
|
1295
|
+
(r.dirsCreated ? `,补建 ${r.dirsCreated} 个空目录` : '') +
|
|
1296
|
+
`\n保护快照:${cover.id}(想反悔就用它再退一次)` +
|
|
1297
|
+
(r.refused.length
|
|
1298
|
+
? `\n\n没敢动 ${r.refused.length} 个:\n` +
|
|
1299
|
+
r.refused
|
|
1300
|
+
.slice(0, 8)
|
|
1301
|
+
.map((x) => ' · ' + x.rel + (x.why ? ':' + x.why : ''))
|
|
1302
|
+
.join('\n')
|
|
1303
|
+
: '') +
|
|
1304
|
+
(r.failed.length
|
|
1305
|
+
? `\n\n失败 ${r.failed.length} 个:\n` + r.failed.slice(0, 8).map((x) => ' · ' + x.rel + ':' + x.why).join('\n')
|
|
1306
|
+
: '') +
|
|
1307
|
+
(bad ? `\n\n上面这些没退成,可以修好再退一次(这个操作是幂等的)。` : ''),
|
|
1308
|
+
}
|
|
1309
|
+
} catch (e) {
|
|
1310
|
+
const code = String(e.message || e)
|
|
1311
|
+
if (code === 'BUSY:restore') return { ok: false, action, text: '另一个退回正在跑,等它完(最多几分钟)。' }
|
|
1312
|
+
throw e
|
|
1313
|
+
} finally {
|
|
1314
|
+
releaseR()
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
return { ok: false, action, text: '不认识的 action:' + action }
|
|
1319
|
+
},
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/* ---------------- 工具二:trace_attempt ---------------- */
|
|
1323
|
+
|
|
1324
|
+
const ATTEMPT_DESC = [
|
|
1325
|
+
'尝试簿:把"我试了什么、怎么挂的"记下来,免得在同一个坑里试第三遍。',
|
|
1326
|
+
'',
|
|
1327
|
+
'· log —— 记一条。失败了就该记:动作是什么、报错原文、你判断的原因、什么条件下值得再试。',
|
|
1328
|
+
'· recall —— 按文件路径捞历史失败记录。**它只回记录,不判断、不拦你**,你自己决定要不要换个路子。',
|
|
1329
|
+
'',
|
|
1330
|
+
'一次就成的事不用记。反复挂、或者"看起来该成却没成"的,才值得记。',
|
|
1331
|
+
'注意:这里没有"自动判定这是不是同一个坑"的功能。第一版故意不做——判错了会让我不信它。',
|
|
1332
|
+
].join('\n')
|
|
1333
|
+
|
|
1334
|
+
const ATTEMPT = {
|
|
1335
|
+
name: 'trace_attempt',
|
|
1336
|
+
description: ATTEMPT_DESC,
|
|
1337
|
+
parameters: {
|
|
1338
|
+
type: 'object',
|
|
1339
|
+
properties: {
|
|
1340
|
+
action: { type: 'string', enum: ['log', 'recall'], description: '记一条,还是捞记录。' },
|
|
1341
|
+
did: { type: 'string', description: 'log 必填:你做了什么(一句话,具体到动作)。' },
|
|
1342
|
+
paths: { type: 'array', items: { type: 'string' }, description: '涉及的相对路径。' },
|
|
1343
|
+
error: { type: 'string', description: '报错原文,别转述。' },
|
|
1344
|
+
why: { type: 'string', description: '你判断的原因。' },
|
|
1345
|
+
retryWhen: { type: 'string', description: '什么条件下值得再试。' },
|
|
1346
|
+
outcome: { type: 'string', enum: ['ok', 'failed', 'partial'], description: '结果。默认 failed。' },
|
|
1347
|
+
},
|
|
1348
|
+
required: ['action'],
|
|
1349
|
+
additionalProperties: false,
|
|
1350
|
+
},
|
|
1351
|
+
output: {
|
|
1352
|
+
schema: {
|
|
1353
|
+
type: 'object',
|
|
1354
|
+
properties: { ok: { type: 'boolean' }, text: { type: 'string' } },
|
|
1355
|
+
required: ['ok', 'text'],
|
|
1356
|
+
},
|
|
1357
|
+
render: (_a, v) => [{ type: 'text', text: (v && v.text) || '' }],
|
|
1358
|
+
},
|
|
1359
|
+
async execute(args) {
|
|
1360
|
+
const a = args || {}
|
|
1361
|
+
if (a.action === 'log') {
|
|
1362
|
+
if (!a.did) return { ok: false, text: 'log 至少要给 did。' }
|
|
1363
|
+
appendAttempt({
|
|
1364
|
+
at: Date.now(),
|
|
1365
|
+
atText: nowIso(),
|
|
1366
|
+
did: a.did,
|
|
1367
|
+
paths: a.paths || [],
|
|
1368
|
+
error: a.error || '',
|
|
1369
|
+
why: a.why || '',
|
|
1370
|
+
retryWhen: a.retryWhen || '',
|
|
1371
|
+
outcome: a.outcome || 'failed',
|
|
1372
|
+
})
|
|
1373
|
+
return { ok: true, text: `记下了:${a.did}${a.why ? '(' + a.why + ')' : ''}` }
|
|
1374
|
+
}
|
|
1375
|
+
if (a.action === 'recall') {
|
|
1376
|
+
const rs = recallAttempts(a.paths, 5)
|
|
1377
|
+
if (!rs.length) return { ok: true, text: '这些文件上没有失败记录。' }
|
|
1378
|
+
return {
|
|
1379
|
+
ok: true,
|
|
1380
|
+
text:
|
|
1381
|
+
'历史上在这几个文件上挂过:\n' +
|
|
1382
|
+
rs
|
|
1383
|
+
.map(
|
|
1384
|
+
(r, i) =>
|
|
1385
|
+
`${i + 1}. ${r.atText} · ${r.did}\n ${r.error ? '报错:' + r.error.slice(0, 200) + '\n ' : ''}${r.why ? '原因:' + r.why + '\n ' : ''}${r.retryWhen ? '值得再试:' + r.retryWhen : ''}`.trim(),
|
|
1386
|
+
)
|
|
1387
|
+
.join('\n\n'),
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return { ok: false, text: '不认识的 action。' }
|
|
1391
|
+
},
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/* ---------------- 主动用兜底:没拍点,就在写文件的结果里补一句 ----------------
|
|
1395
|
+
*
|
|
1396
|
+
* 这插件最大的风险不是"退不回去",是"装了却想不起来用"。
|
|
1397
|
+
* 所以它要在模型动手的那一刻自己出声,而不是等人记得它。
|
|
1398
|
+
*
|
|
1399
|
+
* 只做一件事:写文件类工具跑完,如果这次写的文件不在拍过的点里,
|
|
1400
|
+
* 就在那份结果的末尾追加一段文字(不改结果本身、不拦调用、不假报成功)。
|
|
1401
|
+
*
|
|
1402
|
+
* 克制:每场最多 3 次,两次之间至少隔 2 次写。宁可漏,不要吵。
|
|
1403
|
+
* 关掉:~/.dsh/dsh-trace/config.json 写 {"remind": false}。
|
|
1404
|
+
*/
|
|
1405
|
+
|
|
1406
|
+
const WRITE_TOOL_RE = /^(write|edit|insert|apply_patch|multi_edit|str_replace.*)$/i
|
|
1407
|
+
const REMIND_MAX = 3
|
|
1408
|
+
const REMIND_GAP = 2
|
|
1409
|
+
const CONFIG_FILE = path.join(HOME, 'config.json')
|
|
1410
|
+
const SESSION_STATE = new WeakMap()
|
|
1411
|
+
|
|
1412
|
+
function remindEnabled() {
|
|
1413
|
+
if (!ex(CONFIG_FILE)) return true
|
|
1414
|
+
try {
|
|
1415
|
+
const cfg = JSON.parse(rd(CONFIG_FILE, 'utf8'))
|
|
1416
|
+
return !(cfg && cfg.remind === false)
|
|
1417
|
+
} catch {
|
|
1418
|
+
return true
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function autoSaveEnabled() {
|
|
1423
|
+
if (!ex(CONFIG_FILE)) return true
|
|
1424
|
+
try {
|
|
1425
|
+
const cfg = JSON.parse(rd(CONFIG_FILE, 'utf8'))
|
|
1426
|
+
return !(cfg && cfg.autoSave === false)
|
|
1427
|
+
} catch {
|
|
1428
|
+
return true
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function normRel(p) {
|
|
1433
|
+
return String(p || '')
|
|
1434
|
+
.replace(/\\/g, '/')
|
|
1435
|
+
.replace(/^\.\//, '')
|
|
1436
|
+
.toLowerCase()
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function relFor(root, p) {
|
|
1440
|
+
const n = normRel(p)
|
|
1441
|
+
if (!root) return n
|
|
1442
|
+
const r = normRel(root).replace(/\/+$/, '')
|
|
1443
|
+
if (n === r) return ''
|
|
1444
|
+
return n.startsWith(r + '/') ? n.slice(r.length + 1) : n
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/* 这个文件在不在拍过的点里:全工作区快照覆盖一切;paths 快照只认点名的 */
|
|
1448
|
+
function coveredBy(saves, target, root) {
|
|
1449
|
+
const t = relFor(root, target)
|
|
1450
|
+
if (!t) return false
|
|
1451
|
+
for (const s of saves) {
|
|
1452
|
+
if (!s.paths) return true
|
|
1453
|
+
if (s.paths.has(relFor(s.root, target))) return true
|
|
1454
|
+
}
|
|
1455
|
+
return false
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function stateOf(agent) {
|
|
1459
|
+
let s = SESSION_STATE.get(agent)
|
|
1460
|
+
if (!s) {
|
|
1461
|
+
s = { saves: [], writes: 0, nags: 0, lastNagAt: -999 }
|
|
1462
|
+
SESSION_STATE.set(agent, s)
|
|
1463
|
+
}
|
|
1464
|
+
return s
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function reminderFor(exec, result) {
|
|
1468
|
+
if (!exec || !exec.agent) return null
|
|
1469
|
+
const name = String(exec.name || '')
|
|
1470
|
+
const a = exec.arguments && typeof exec.arguments === 'object' ? exec.arguments : {}
|
|
1471
|
+
|
|
1472
|
+
if (name === 'trace_anchor') {
|
|
1473
|
+
if (a.action === 'save' && !(result && result.isError) && !(result && result.value && result.value.ok === false)) {
|
|
1474
|
+
stateOf(exec.agent).saves.push({
|
|
1475
|
+
root: a.root || null,
|
|
1476
|
+
paths: Array.isArray(a.paths) ? new Set(a.paths.map((p) => relFor(a.root, p))) : null,
|
|
1477
|
+
})
|
|
1478
|
+
}
|
|
1479
|
+
return null
|
|
1480
|
+
}
|
|
1481
|
+
if (!WRITE_TOOL_RE.test(name)) return null
|
|
1482
|
+
if (!remindEnabled()) return null
|
|
1483
|
+
|
|
1484
|
+
const s = stateOf(exec.agent)
|
|
1485
|
+
s.writes++
|
|
1486
|
+
const target = a.file_path || a.path || a.filePath || ''
|
|
1487
|
+
if (coveredBy(s.saves, target, null)) return null
|
|
1488
|
+
if (s.nags >= REMIND_MAX) return null
|
|
1489
|
+
if (s.writes - s.lastNagAt < REMIND_GAP) return null
|
|
1490
|
+
s.nags++
|
|
1491
|
+
s.lastNagAt = s.writes
|
|
1492
|
+
|
|
1493
|
+
const file = target ? String(target).replace(/\\/g, '/').split('/').pop() : '要改的文件'
|
|
1494
|
+
const text =
|
|
1495
|
+
s.saves.length === 0
|
|
1496
|
+
? `[痕迹]这次会话还没拍过点——刚才这次写退不回去。\n` +
|
|
1497
|
+
`下次动手前先 trace_anchor save({paths:["${file}"]}):只拍点名的文件是毫秒级,改坏了能退。`
|
|
1498
|
+
: `[痕迹]"${file}" 不在你拍过的点里,它退不回去。\n` +
|
|
1499
|
+
`要保它就 trace_anchor save({paths:["${file}"]}) 再写。`
|
|
1500
|
+
return { type: 'text', text: '\n\n' + text }
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/* ---------------- 自动拍点:写文件之前先把"现在的样子"存下来 ----------------
|
|
1504
|
+
*
|
|
1505
|
+
* 两层提醒(提示词 + 写完补一句)都只能"劝"模型想起来拍点,第一笔改动还是保不住。
|
|
1506
|
+
* 所以加第三层:写文件类工具**真正执行前**,如果这个文件还没进过任何点,
|
|
1507
|
+
* 就自动拍一张(paths 模式,毫秒级),第一笔改动也退得回去。
|
|
1508
|
+
*
|
|
1509
|
+
* 只拍"还没拍过的、真实存在的、≤50MB 的普通文件";拍不成(新文件/太大/在工作区外/
|
|
1510
|
+
* 根拿不到)就静默跳过,交给写完那层的提醒兜底。不拦调用、不改结果、失败也不抛。
|
|
1511
|
+
* 关掉:config.json 写 {"autoSave": false}。
|
|
1512
|
+
*/
|
|
1513
|
+
|
|
1514
|
+
function writeTargetOf(a) {
|
|
1515
|
+
return String((a && (a.file_path || a.path || a.filePath)) || '')
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function autoSaveBeforeWrite(exec) {
|
|
1519
|
+
if (!autoSaveEnabled()) return null
|
|
1520
|
+
const name = String(exec.name || '')
|
|
1521
|
+
if (!WRITE_TOOL_RE.test(name)) return null
|
|
1522
|
+
const a = exec.arguments && typeof exec.arguments === 'object' ? exec.arguments : {}
|
|
1523
|
+
const target = writeTargetOf(a)
|
|
1524
|
+
if (!target || !path.isAbsolute(target)) return null
|
|
1525
|
+
if (!ex(target)) return null // 新文件没有"旧样子",跳
|
|
1526
|
+
let stt
|
|
1527
|
+
try {
|
|
1528
|
+
stt = fs.lstatSync(wp(target))
|
|
1529
|
+
} catch {
|
|
1530
|
+
return null
|
|
1531
|
+
}
|
|
1532
|
+
if (!stt.isFile() || stt.size > BIG_FILE) return null
|
|
1533
|
+
|
|
1534
|
+
/* 根:宿主给的会话工作区根。DSH 0.2.2 的 Agent 上没有 cwd(cwd 在 agent.session.header.cwd,
|
|
1535
|
+
session header 是深冻结的创建元数据);旧宿主/测试可能仍放在 agent.cwd。两个都认。
|
|
1536
|
+
拿不到就跳过,靠写完那层提醒兜底。 */
|
|
1537
|
+
const ag = exec.agent
|
|
1538
|
+
const root =
|
|
1539
|
+
(ag && typeof ag.cwd === 'string' && ag.cwd) ||
|
|
1540
|
+
(ag && ag.session && ag.session.header && typeof ag.session.header.cwd === 'string' && ag.session.header.cwd) ||
|
|
1541
|
+
null
|
|
1542
|
+
if (!root || !path.isAbsolute(root)) return null
|
|
1543
|
+
try {
|
|
1544
|
+
if (!st(root).isDirectory()) return null
|
|
1545
|
+
} catch {
|
|
1546
|
+
return null
|
|
1547
|
+
}
|
|
1548
|
+
if (!realInside(root, target)) return null
|
|
1549
|
+
|
|
1550
|
+
const s = stateOf(exec.agent)
|
|
1551
|
+
if (coveredBy(s.saves, target, null)) return null
|
|
1552
|
+
|
|
1553
|
+
let m
|
|
1554
|
+
try {
|
|
1555
|
+
m = takeSnapshot(root, '自动拍点', true, { paths: [target] })
|
|
1556
|
+
} catch {
|
|
1557
|
+
return null
|
|
1558
|
+
}
|
|
1559
|
+
if (!m.files.length) return null // 没拍下来(排除/太大/读不了),别假装拍过
|
|
1560
|
+
s.saves.push({ root, paths: new Set([relFor(root, target)]) })
|
|
1561
|
+
return { id: m.id, rel: relFor(root, target) }
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/* ---------------- 系统提示词里那一句(v0.5) ----------------
|
|
1565
|
+
*
|
|
1566
|
+
* 兜底只能落在"写完之后",第一笔改动保不住。要在动手之前就在,就得进系统提示词。
|
|
1567
|
+
* 位置放在 write 工具说明的前一条(1200 是 TOOL_WRITE 的位置,1199 正好在它前面),
|
|
1568
|
+
* 模型读到 write 的时候,上一句就是"先拍个点"。
|
|
1569
|
+
*
|
|
1570
|
+
* 只有一句,不解释原理、不重复工具描述。{"remind": false} 时整条不注册。
|
|
1571
|
+
*/
|
|
1572
|
+
|
|
1573
|
+
const PROMPT_SECTION = 'dsh-trace:remind'
|
|
1574
|
+
const PROMPT_ORDER = 1199
|
|
1575
|
+
const PROMPT_TEXT =
|
|
1576
|
+
'写文件前会自动拍点,改坏了能 restore;想留个明确的检查点就手动 trace_anchor save({root, paths:["要改的文件"]})。'
|
|
1577
|
+
|
|
1578
|
+
function registerPromptHint(ctx) {
|
|
1579
|
+
if (!remindEnabled()) return
|
|
1580
|
+
const sp = ctx.systemPrompt
|
|
1581
|
+
if (!sp || typeof sp.section !== 'function') return
|
|
1582
|
+
const reg = () =>
|
|
1583
|
+
sp.section({
|
|
1584
|
+
name: PROMPT_SECTION,
|
|
1585
|
+
order: PROMPT_ORDER,
|
|
1586
|
+
text: PROMPT_TEXT,
|
|
1587
|
+
})
|
|
1588
|
+
if (typeof ctx.effect === 'function') ctx.effect(reg, 'dsh-trace.promptHint()')
|
|
1589
|
+
else reg()
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
export function apply(ctx) {
|
|
1593
|
+
ensureDirs()
|
|
1594
|
+
ctx.tools.register(ANCHOR)
|
|
1595
|
+
ctx.tools.register(ATTEMPT)
|
|
1596
|
+
|
|
1597
|
+
try {
|
|
1598
|
+
registerPromptHint(ctx)
|
|
1599
|
+
} catch {
|
|
1600
|
+
/* 没有提示词口(离线测试的假 ctx)就当没这一层,工具照用 */
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/* 宿主没有事件口时(离线测试的假 ctx)就只注册工具,不硬连 */
|
|
1604
|
+
if (typeof ctx.on !== 'function') return
|
|
1605
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
1606
|
+
/* 写之前自动拍点:拍得成就在本会话记一笔,拍不成就静默,交给写完那层提醒。 */
|
|
1607
|
+
try {
|
|
1608
|
+
autoSaveBeforeWrite(exec)
|
|
1609
|
+
} catch {
|
|
1610
|
+
/* 绝不因为自动拍点失败拦住这次写 */
|
|
1611
|
+
}
|
|
1612
|
+
return next()
|
|
1613
|
+
})
|
|
1614
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
1615
|
+
let extra = null
|
|
1616
|
+
try {
|
|
1617
|
+
extra = reminderFor(exec, result)
|
|
1618
|
+
} catch {
|
|
1619
|
+
extra = null
|
|
1620
|
+
}
|
|
1621
|
+
const downstream = await next()
|
|
1622
|
+
if (!extra || !downstream || typeof downstream !== 'object') return downstream
|
|
1623
|
+
if (downstream.kind === 'block' || Object.hasOwn(downstream, 'value')) return downstream
|
|
1624
|
+
const base = Array.isArray(downstream.content) ? downstream.content : (result && result.content) || []
|
|
1625
|
+
return { ...downstream, content: [...base, extra] }
|
|
1626
|
+
})
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
export { ANCHOR, ATTEMPT, VERSION, HOME, takeSnapshot, loadManifest, restoreFrom, diffOf, gcBlobs }
|