@dafantong01/dsh-memory-palace 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/archive.js ADDED
@@ -0,0 +1,483 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { createRequire } from 'node:module'
5
+
6
+ /** DSH 主目录。 */
7
+ export function dshHome() {
8
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
9
+ }
10
+
11
+ /**
12
+ * 按会话 id 找到落盘日志。
13
+ * 不猜工作区目录编码,直接扫 sessions/<group>/<sessionId>/,找不到再放宽到任意文件名。
14
+ */
15
+ export function resolveSessionLog(sessionId) {
16
+ const root = path.join(dshHome(), 'sessions')
17
+ if (!sessionId || !fs.existsSync(root)) return null
18
+ for (const group of fs.readdirSync(root)) {
19
+ const dir = path.join(root, group, sessionId)
20
+ if (!fs.existsSync(dir)) continue
21
+ for (const name of fs.readdirSync(dir)) {
22
+ if (/^session(?:\.v\d+)?\.jsonl(\.zstd)?$/.test(name)) return path.join(dir, name)
23
+ }
24
+ }
25
+ return null
26
+ }
27
+
28
+ /** 把会话日志整份解成文本(zstd 用 fzstd,多帧)。 */
29
+ export async function decompressLog(logPath) {
30
+ const buf = fs.readFileSync(logPath)
31
+ if (/\.zstd$/i.test(logPath)) {
32
+ let decompress
33
+ try {
34
+ const req = createRequire(path.join(dshHome(), 'profiles', 'desktop', 'package.json'))
35
+ ;({ decompress } = req('fzstd'))
36
+ } catch {
37
+ const mod = await import('fzstd')
38
+ decompress = mod.decompress
39
+ }
40
+ return Buffer.from(decompress(buf)).toString('utf8')
41
+ }
42
+ return buf.toString('utf8')
43
+ }
44
+
45
+ /** 解压 + 逐行解析。DSH 日志是多帧 zstd,node:zlib 只解第一帧,必须用 fzstd。 */
46
+ export async function decodeEvents(logPath) {
47
+ const text = await decompressLog(logPath)
48
+ const events = []
49
+ for (const line of text.split('\n')) {
50
+ if (!line.trim()) continue
51
+ try { events.push(JSON.parse(line)) } catch { /* 跳过坏行 */ }
52
+ }
53
+ return events
54
+ }
55
+
56
+ /** token 估算:CJK 1 token/字,ASCII 约 1 token/4 字符(与 DSH 导入器的口径一致)。 */
57
+ export function estimateTokens(text) {
58
+ if (typeof text !== 'string' || text.length === 0) return 0
59
+ let cjk = 0
60
+ let ascii = 0
61
+ for (const ch of text) {
62
+ const cp = ch.codePointAt(0)
63
+ if ((cp >= 0x3400 && cp <= 0x4dbf) || (cp >= 0x4e00 && cp <= 0x9fff)
64
+ || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0x3000 && cp <= 0x303f)
65
+ || (cp >= 0xff00 && cp <= 0xffef) || (cp >= 0x20000 && cp <= 0x2a6df)) cjk++
66
+ else ascii++
67
+ }
68
+ return cjk + Math.ceil(ascii / 4)
69
+ }
70
+
71
+ /** 事件流 → 轮次数组。保留完整内容:用户文本、助手文本/思考/工具调用、工具结果。 */
72
+ /** 附件占位符:只留引用文本,绝不解析、绝不读附件本体;附件被删也只是一行字。 */
73
+ function imagePlaceholder(b) {
74
+ const a = b?.attachment ?? {}
75
+ const id = a.attachmentId ?? a.sha256 ?? '未知'
76
+ const dim = a.width && a.height ? `${a.width}×${a.height}` : '尺寸未知'
77
+ return `[图片 ${id} ${dim}|归档只保留索引,附件本体若被删除则此处无法还原]`
78
+ }
79
+ function filePlaceholder(b) {
80
+ const a = b?.attachment ?? {}
81
+ const name = a.name ?? a.attachmentId ?? '未知'
82
+ return `[文件 ${name}|归档只保留索引,附件本体若被删除则此处无法还原]`
83
+ }
84
+
85
+ /**
86
+ * 追加月度摘要:<ns>/<YYYY-MM>/_summary.md,每月一份、只追加不改旧内容。
87
+ * 每段前缀是归档日期 + 本次归档的轮次范围。
88
+ */
89
+ export function appendMonthSummary({ nsDir, month, label = '', text, now = Date.now() }) {
90
+ const dir = path.join(nsDir, month)
91
+ fs.mkdirSync(dir, { recursive: true })
92
+ const file = path.join(dir, '_summary.md')
93
+ const head = fs.existsSync(file) ? '' : `# 记忆摘要 · ${month}\n\n> 本文件只追加,不修改历史内容;每段对应一次归档里的一个类别。\n`
94
+ // 正文里以 ## 开头的行加引用前缀,避免被当成新的摘要段(否则 category 过滤会误切)。
95
+ const safeText = String(text).replace(/^## /gm, '> ## ')
96
+ const block = `${head}\n## ${fmtTime(now)}${label ? ' · ' + label : ''}\n\n${safeText}\n`
97
+ fs.appendFileSync(file, block, 'utf8')
98
+ return file
99
+ }
100
+
101
+ /** 从会话日志里取真实上下文窗口(最后一次 request/context 事件)。 */
102
+ export function detectContextWindow(events) {
103
+ for (let i = events.length - 1; i >= 0; i--) {
104
+ const e = events[i]
105
+ if (e?.type !== 'request/context') continue
106
+ const cw = e.data?.contextWindow ?? e.data?.context?.contextWindow
107
+ if (typeof cw === 'number' && cw > 0) return cw
108
+ }
109
+ return null
110
+ }
111
+
112
+ export function buildTurns(events, opts = {}) {
113
+ const includeReasoning = opts.includeReasoning !== false
114
+ const turns = []
115
+ const nameById = new Map()
116
+ let cur = null
117
+ for (const e of events) {
118
+ const t = e?.type
119
+ if (t === 'turn/start') {
120
+ cur = { turn: e.data?.turn ?? turns.length + 1, startedAt: e.time ?? null, endedAt: null, items: [] }
121
+ turns.push(cur)
122
+ continue
123
+ }
124
+ if (t === 'tool/call') {
125
+ if (e.data?.callId && e.data?.name) nameById.set(e.data.callId, e.data.name)
126
+ continue
127
+ }
128
+ if (!cur) continue
129
+ if (t === 'turn/end') { cur.endedAt = e.time ?? null; continue }
130
+ if (t === 'user/message') {
131
+ // 只收真正的用户输入;runtime-context / skill-catalog / mnemon 快照等注入内容不进归档,
132
+ // 否则一份归档里大半是系统噪声,还会让 token 估算失真。
133
+ if ((e.data?.source?.kind ?? 'user') !== 'user') continue
134
+ const parts = []
135
+ for (const b of e.data?.content ?? []) {
136
+ if (b?.type === 'text' && typeof b.text === 'string') parts.push(b.text)
137
+ else if (b?.type === 'image') parts.push(imagePlaceholder(b))
138
+ else if (b?.type === 'file') parts.push(filePlaceholder(b))
139
+ }
140
+ const text = parts.join('\n')
141
+ if (text) cur.items.push({ kind: 'user', time: e.time ?? null, text })
142
+ continue
143
+ }
144
+ if (t === 'assistant/message') {
145
+ for (const b of e.data?.message?.content ?? []) {
146
+ if (b?.type === 'text' && b.text) cur.items.push({ kind: 'text', time: e.time ?? null, text: b.text })
147
+ else if (b?.type === 'reasoning' && b.text && includeReasoning) cur.items.push({ kind: 'reasoning', time: e.time ?? null, text: b.text })
148
+ else if (b?.type === 'image') cur.items.push({ kind: 'attachment', time: e.time ?? null, text: imagePlaceholder(b) })
149
+ else if (b?.type === 'tool-call') {
150
+ if (b.id && b.name) nameById.set(b.id, b.name)
151
+ cur.items.push({ kind: 'tool-call', time: e.time ?? null, name: b.name ?? 'tool', args: b.arguments ?? '' })
152
+ }
153
+ }
154
+ continue
155
+ }
156
+ if (t === 'tool/result') {
157
+ const d = e.data ?? {}
158
+ const callId = d.toolCallId ?? d.message?.toolCallId
159
+ const name = nameById.get(callId) ?? d.name ?? d.tool ?? 'tool'
160
+ cur.items.push({
161
+ kind: 'tool-result',
162
+ time: e.time ?? null,
163
+ name,
164
+ error: d.isError === true || d.message?.isError === true,
165
+ text: stringifyResult(d),
166
+ })
167
+ }
168
+ }
169
+ return turns.filter((x) => x.items.length > 0)
170
+ }
171
+
172
+ function stringifyResult(d) {
173
+ const c = d.message?.content ?? d.content ?? d.result ?? d.output
174
+ if (typeof c === 'string') return c
175
+ if (Array.isArray(c)) {
176
+ return c.map((b) => {
177
+ if (typeof b === 'string') return b
178
+ if (b?.type === 'image') return imagePlaceholder(b)
179
+ if (b?.type === 'file') return filePlaceholder(b)
180
+ return b?.text ?? JSON.stringify(b)
181
+ }).join('\n')
182
+ }
183
+ if (c === undefined) return JSON.stringify(d).slice(0, 20000)
184
+ return JSON.stringify(c)
185
+ }
186
+
187
+ /** 一轮的 UTF-8 字节数。 */
188
+ export function turnBytes(turn) {
189
+ return Buffer.byteLength(renderTurn(turn), 'utf8')
190
+ }
191
+
192
+ const pad = (n, w = 2) => String(n).padStart(w, '0')
193
+ export function fmtTime(ms) {
194
+ if (!ms) return '未知时间'
195
+ const d = new Date(ms)
196
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
197
+ }
198
+ export function fmtDay(ms) {
199
+ const d = new Date(ms)
200
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`
201
+ }
202
+
203
+ /** 归档子目录按月分:YYYY-MM。 */
204
+ export function fmtMonth(ms) {
205
+ const d = new Date(ms)
206
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
207
+ }
208
+
209
+ /**
210
+ * 追加一行索引;失败时写进 .pending-index.jsonl(待补清单),绝不抛错打断归档。
211
+ * 数据优先:文件已经在盘上,索引最多迟到,不会丢。
212
+ */
213
+ export function appendIndexRow(indexPath, row) {
214
+ try {
215
+ // 上次若崩在半行,先补一个换行,避免新行与残行粘连成一行坏 JSON。
216
+ try {
217
+ const st = fs.statSync(indexPath)
218
+ if (st.size > 0) {
219
+ const fd = fs.openSync(indexPath, 'r')
220
+ const buf = Buffer.alloc(1)
221
+ fs.readSync(fd, buf, 0, 1, st.size - 1)
222
+ fs.closeSync(fd)
223
+ if (buf[0] !== 10) fs.appendFileSync(indexPath, '\n', 'utf8')
224
+ }
225
+ } catch { /* 文件不存在就算了 */ }
226
+ fs.appendFileSync(indexPath, JSON.stringify(row) + '\n', 'utf8')
227
+ return { ok: true }
228
+ } catch (err) {
229
+ try {
230
+ fs.appendFileSync(path.join(path.dirname(indexPath), '.pending-index.jsonl'), JSON.stringify(row) + '\n', 'utf8')
231
+ return { ok: false, pending: true, error: String(err?.message ?? err) }
232
+ } catch {
233
+ return { ok: false, pending: false, error: String(err?.message ?? err) }
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * 补索引:把 .pending-index.jsonl 里、文件确实存在的行补进 index.jsonl,然后清空待补清单。
240
+ * 返回 { added, dropped };找不到文件的行会被丢弃(文件都没了,索引行没有意义)。
241
+ */
242
+ export function reconcilePendingIndex(nsDir) {
243
+ const indexPath = path.join(nsDir, 'index.jsonl')
244
+ const pendingPath = path.join(nsDir, '.pending-index.jsonl')
245
+ if (!fs.existsSync(pendingPath)) return { added: 0, dropped: 0, ok: true }
246
+ let lines = []
247
+ try {
248
+ lines = fs.readFileSync(pendingPath, 'utf8').replace(/^\uFEFF/, '').split('\n').filter(Boolean)
249
+ } catch {
250
+ return { added: 0, dropped: 0, ok: false }
251
+ }
252
+ const existing = new Set()
253
+ if (fs.existsSync(indexPath)) {
254
+ try {
255
+ for (const l of fs.readFileSync(indexPath, 'utf8').replace(/^\uFEFF/, '').split('\n').filter(Boolean)) {
256
+ try { existing.add(JSON.parse(l).file) } catch { /* ignore */ }
257
+ }
258
+ } catch { /* ignore */ }
259
+ }
260
+ let added = 0
261
+ let dropped = 0
262
+ try {
263
+ for (const l of lines) {
264
+ let row
265
+ try { row = JSON.parse(l) } catch { dropped += 1; continue }
266
+ const abs = path.join(nsDir, String(row.file).replace(/\\/g, '/'))
267
+ if (!fs.existsSync(abs)) { dropped += 1; continue }
268
+ if (existing.has(row.file)) continue
269
+ fs.appendFileSync(indexPath, JSON.stringify(row) + '\n', 'utf8')
270
+ existing.add(row.file)
271
+ added += 1
272
+ }
273
+ fs.writeFileSync(pendingPath, '', 'utf8')
274
+ return { added, dropped, ok: true }
275
+ } catch {
276
+ return { added, dropped, ok: false }
277
+ }
278
+ }
279
+
280
+ export function fmtStamp(ms) {
281
+ const d = new Date(ms)
282
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
283
+ }
284
+
285
+ /** 单轮 → Markdown 片段。角色用中英双标,避免读档时分不清谁说的。 */
286
+ export function renderTurn(turn) {
287
+ const TEXT_CAP = 200000
288
+ const cap = (s) => (String(s).length > TEXT_CAP ? `${String(s).slice(0, TEXT_CAP)}\n…(本条超长,已截断)` : String(s))
289
+ const out = [`## TURN ${turn.turn} · ${fmtTime(turn.startedAt)}`]
290
+ for (const it of turn.items) {
291
+ if (it.kind === 'user') out.push(`### 用户(user) · ${fmtTime(it.time)}`, '', cap(it.text), '')
292
+ else if (it.kind === 'text') out.push(`### 助手(assistant) · ${fmtTime(it.time)}`, '', cap(it.text), '')
293
+ else if (it.kind === 'attachment') out.push(`### 附件(attachment) · ${fmtTime(it.time)}`, '', it.text, '')
294
+ else if (it.kind === 'reasoning') out.push(`### 助手思考(assistant reasoning) · ${fmtTime(it.time)}`, '', '<details><summary>展开</summary>', '', cap(it.text), '', '</details>', '')
295
+ else if (it.kind === 'tool-call') out.push(`### 助手调用工具(tool call:${it.name}) · ${fmtTime(it.time)}`, '', '```json', String(it.args).slice(0, 200000), '```', '')
296
+ else if (it.kind === 'tool-result') out.push(`### 工具返回(tool result:${it.name}${it.error ? ' · 报错' : ''}) · ${fmtTime(it.time)}`, '', '```', String(it.text).slice(0, 400000), '```', '')
297
+ }
298
+ return out.join('\n')
299
+ }
300
+
301
+ /** 一轮的 token 估算:逐条累加文本,避免整段 JSON.stringify(长会话下更省)。 */
302
+ export function turnTokens(turn) {
303
+ let sum = 0
304
+ for (const it of turn.items) {
305
+ if (typeof it.text === 'string') sum += estimateTokens(it.text)
306
+ else if (typeof it.args === 'string') sum += estimateTokens(it.args)
307
+ }
308
+ return sum
309
+ }
310
+
311
+ /**
312
+ * 决定保留尾部:至少保留最近 retainTurns 轮,且至少保留最近 retainBytes 字节的对话。
313
+ * 两条都成立时取更靠前的起点(保留得更多),避免字节阈值把轮数挤没。
314
+ * 返回保留的起始索引(>= 该索引的轮次不归档)。
315
+ */
316
+ export function retainedFrom(turns, retainTurns, retainBytes) {
317
+ const n = turns.length
318
+ const byTurns = Math.max(0, n - Math.max(0, retainTurns))
319
+ let acc = 0
320
+ let byBytes = n
321
+ for (let i = n - 1; i >= 0; i--) {
322
+ acc += turnBytes(turns[i])
323
+ byBytes = i
324
+ if (acc >= retainBytes) break
325
+ }
326
+ return Math.min(byTurns, byBytes)
327
+ }
328
+
329
+ /**
330
+ * 按字节上限决定保留窗口(整轮粒度)。
331
+ * 严格不超过 capBytes:跨过上限的那一轮被划到归档侧,而不是留下;
332
+ * 唯一的例外是单轮本身就超过上限——此时至少保留最后 1 轮,避免归档“当前正在说的这句”。
333
+ */
334
+ export function retainedFromCapped(turns, retainTurns, capBytes) {
335
+ const n = turns.length
336
+ const byTurns = Math.max(0, n - Math.max(0, retainTurns))
337
+ if (!(capBytes > 0)) return byTurns
338
+ let acc = 0
339
+ let byBytes = n
340
+ for (let i = n - 1; i >= 0; i--) {
341
+ acc += turnBytes(turns[i])
342
+ if (acc > capBytes) {
343
+ // 加上这一轮就超了:从它之后开始保留(但至少留最后一轮)。
344
+ byBytes = Math.min(i + 1, n - 1)
345
+ break
346
+ }
347
+ byBytes = i
348
+ }
349
+ return Math.max(byTurns, byBytes)
350
+ }
351
+
352
+ /** 轮次区间的人话表达:连续用 a–b,跳号就逐个列出来。 */
353
+ export function fmtTurnSpan(turns) {
354
+ const nums = turns.map((x) => (typeof x === 'number' ? x : x.turn))
355
+ if (nums.length === 0) return 'turn -'
356
+ let contiguous = true
357
+ for (let i = 1; i < nums.length; i++) if (nums[i] !== nums[i - 1] + 1) { contiguous = false; break }
358
+ if (contiguous) return `turn ${nums[0]}–${nums[nums.length - 1]}(${nums.length} 轮)`
359
+ return `turn ${nums.join(', ')}(${nums.length} 轮,不连续)`
360
+ }
361
+ /** 原子写:先写临时文件再 rename,避免留下半截文件。 */
362
+ function writeFileAtomic(file, data) {
363
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
364
+ fs.writeFileSync(tmp, data, 'utf8')
365
+ try {
366
+ fs.renameSync(tmp, file)
367
+ } catch (err) {
368
+ try { fs.unlinkSync(tmp) } catch { /* ignore */ }
369
+ throw err
370
+ }
371
+ }
372
+
373
+ /** 按单文件字节上限把轮次切块;返回每块(至少一块)。 */
374
+ function chunkTurns(turns, maxBytes) {
375
+ const chunks = []
376
+ let cur = []
377
+ let size = 0
378
+ for (const t of turns) {
379
+ const b = turnBytes(t)
380
+ if (cur.length > 0 && size + b > maxBytes) {
381
+ chunks.push(cur)
382
+ cur = []
383
+ size = 0
384
+ }
385
+ cur.push(t)
386
+ size += b
387
+ }
388
+ if (cur.length > 0) chunks.push(cur)
389
+ return chunks.length > 0 ? chunks : [turns]
390
+ }
391
+
392
+ /**
393
+ * 写入归档文件 + 索引。返回 { files, bytes, fromTurn, toTurn, parts }。
394
+ * 顺序:先原子写 md,再 append 索引,最后由调用方写水位。
395
+ * 任一步失败都不会留下“半截文件”;索引缺行最多导致下次重复归档,不会丢数据。
396
+ */
397
+ export function writeArchive({ nsDir, nsLabel = '', selfNameSlug, category, turns, meta, maxFileBytes = 5_000_000, now = Date.now() }) {
398
+ const month = fmtMonth(now)
399
+ // 会话命名空间下:<YYYY-MM>/<category>/;索引里存相对命名空间的路径,改名不影响。
400
+ const dir = path.join(nsDir, month, category)
401
+ fs.mkdirSync(dir, { recursive: true })
402
+ // 一次归档调用 = 一个文件;同一次调用里同分类的所有轮次都进这一个文件。
403
+ const stamp = fmtStamp(now)
404
+ const chunks = chunkTurns(turns, maxFileBytes)
405
+ const parts = chunks.length
406
+ const files = []
407
+ let totalBytes = 0
408
+
409
+ chunks.forEach((chunk, i) => {
410
+ // 同秒多次调用时追加 -2、-3…;分卷再加 -pN。
411
+ let name = `${selfNameSlug}-${category}-${stamp}${parts > 1 ? `-p${i + 1}` : ''}.md`
412
+ let file = path.join(dir, name)
413
+ let n = 1
414
+ while (fs.existsSync(file)) {
415
+ n += 1
416
+ name = `${selfNameSlug}-${category}-${stamp}${parts > 1 ? `-p${i + 1}` : ''}-${n}.md`
417
+ file = path.join(dir, name)
418
+ }
419
+
420
+ const fromTurn = chunk[0].turn
421
+ const toTurn = chunk[chunk.length - 1].turn
422
+ const lastEnd = chunk[chunk.length - 1].endedAt ?? chunk[chunk.length - 1].startedAt
423
+ const title = meta.sessionTitle && meta.sessionTitle !== meta.sessionId
424
+ ? `${meta.sessionTitle}(${meta.sessionId})`
425
+ : `${meta.sessionId}`
426
+ const head = [
427
+ `# 记忆归档 · ${category}`,
428
+ '',
429
+ `- 记忆库:${nsLabel || nsDir}`,
430
+ `- 来源会话:${title}`,
431
+ `- 覆盖轮次:${fmtTurnSpan(chunk)}`,
432
+ `- 时间范围:${fmtTime(chunk[0].startedAt)} → ${fmtTime(lastEnd)}`,
433
+ `- 归档时间:${fmtTime(now)}`,
434
+ parts > 1 ? `- 分卷:${i + 1} / ${parts}` : null,
435
+ meta.reason ? `- 触发原因:${meta.reason}` : null,
436
+ '',
437
+ '> 本文件为只读归档:原文按轮次时间正序保存,请勿修改。',
438
+ '',
439
+ ].filter((line) => line !== null).join('\n')
440
+ const body = chunk.map(renderTurn).join('\n\n---\n\n')
441
+ writeFileAtomic(file, `${head}\n${body}\n`)
442
+ const bytes = fs.statSync(file).size
443
+ totalBytes += bytes
444
+ const relNs = path.relative(nsDir, file).replace(/\\/g, '/')
445
+ files.push(nsLabel ? `${nsLabel}/${relNs}` : relNs)
446
+
447
+ const indexPath = path.join(nsDir, 'index.jsonl')
448
+ const turnNums = chunk.map((t) => t.turn)
449
+ let contiguous = true
450
+ for (let k = 1; k < turnNums.length; k++) if (turnNums[k] !== turnNums[k - 1] + 1) { contiguous = false; break }
451
+ const row = {
452
+ file: relNs,
453
+ category, sessionId: meta.sessionId, fromTurn, toTurn,
454
+ turns: contiguous ? undefined : turnNums,
455
+ startTime: fmtTime(chunk[0].startedAt), endTime: fmtTime(lastEnd),
456
+ archivedAt: fmtTime(now), bytes, part: parts > 1 ? i + 1 : undefined, parts: parts > 1 ? parts : undefined,
457
+ }
458
+ appendIndexRow(indexPath, row)
459
+ })
460
+
461
+ return {
462
+ files,
463
+ file: files[0],
464
+ bytes: totalBytes,
465
+ fromTurn: turns[0].turn,
466
+ toTurn: turns[turns.length - 1].turn,
467
+ parts,
468
+ }
469
+ }
470
+
471
+ /** 索引里是否已经归档过同一会话的同一轮次区间(去重)。 */
472
+ export function alreadyArchived(cwd, rootDirName, sessionId, fromTurn, toTurn) {
473
+ const indexPath = path.join(cwd, rootDirName, 'index.jsonl')
474
+ if (!fs.existsSync(indexPath)) return false
475
+ const tail = fs.readFileSync(indexPath, 'utf8').trim().split('\n').slice(-500)
476
+ for (const line of tail) {
477
+ try {
478
+ const r = JSON.parse(line)
479
+ if (r.sessionId === sessionId && r.fromTurn === fromTurn && r.toTurn === toTurn) return true
480
+ } catch { /* ignore */ }
481
+ }
482
+ return false
483
+ }