dsh-deepseek-import 0.1.0 → 0.1.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/README.md +14 -1
- package/README.zh.md +5 -1
- package/dsh-import.mjs +28 -1
- package/edge-extension/content.js +22 -2
- package/edge-extension/merge.js +18 -1
- package/package.json +4 -2
- package/repair-session.mjs +220 -0
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ Double-click **`dsh-import.cmd`** in the workspace (or drop JSON files onto it).
|
|
|
54
54
|
|
|
55
55
|
- Lists your DSH workspaces; pick one by number
|
|
56
56
|
- Each conversation becomes one session: title, order and body (including code blocks and tables) are preserved
|
|
57
|
-
- **Attachments** (images, txt, pdf, docx…)
|
|
57
|
+
- **Attachments** (images, txt, pdf, docx…): saved under `<workspace>/.dsh-imported/<sessionId>/` with their original names **only when the data source carries them**; anything that cannot be downloaded degrades to `[附件未下载:name (size) 原链接]`. DeepSeek's IndexedDB cache copy usually carries no attachments
|
|
58
58
|
- **Thinking** goes to `thinking.md` in the same folder (never injected into the context)
|
|
59
59
|
- **Edited / regenerated versions**: only the selected branch stays in the body, all versions go to `revisions.md`
|
|
60
60
|
- **Interrupted outputs** keep only a numbered placeholder (`[中断输出 #27:未产生完整正文…]`); add `--keep-interrupted` to keep the partial text
|
|
@@ -134,6 +134,17 @@ node dsh-import.mjs export.json --from-match "some text you typed"
|
|
|
134
134
|
node dsh-import.mjs export.json --from-index 157
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
+
### Auditing imported sessions
|
|
138
|
+
|
|
139
|
+
DSH session format v4 requires the first model-visible node to be a `system/message`.
|
|
140
|
+
Imports from `0.1.0` predate that fix and would break the moment you kept chatting in them.
|
|
141
|
+
`0.1.1` writes the head on import, and this command repairs sessions created earlier:
|
|
142
|
+
|
|
143
|
+
```powershell
|
|
144
|
+
node dsh-import.mjs --audit # report only
|
|
145
|
+
node dsh-import.mjs --repair # insert/move the head, resequence, drop the projection cache
|
|
146
|
+
```
|
|
147
|
+
|
|
137
148
|
- `--from-match` does a case-insensitive substring match over **user messages**, first hit wins
|
|
138
149
|
- If the same text was edited and appears several times, the TUI lists them chronologically and you pick by number
|
|
139
150
|
- Choosing a branch point disables auto-trim (your choice wins); the full text still goes to `transcript.md`
|
|
@@ -177,6 +188,8 @@ Open the conversation and click **Capture chat**; then check the "来源" (sourc
|
|
|
177
188
|
|
|
178
189
|
## Known limitations
|
|
179
190
|
|
|
191
|
+
- **Citations and attachments depend on the data source**: DeepSeek's IndexedDB cache copy keeps the body and thinking only; the `references` table and `files` field are usually absent. `references.md` and the attachment folder are therefore created **only when the source carries them** — their absence is a normal result, not a failure.
|
|
192
|
+
|
|
180
193
|
- **Thinking is archived, not inlined**: DSH reasoning blocks carry provider signatures; faking them would poison later requests. Read `thinking.md`, or force inline with `--thinking`.
|
|
181
194
|
- **Attachments are not added to DSH's attachment store**: they are written to disk and referenced by path. When no download URL is available only the name and size survive.
|
|
182
195
|
- **Web-search citations**: the cache record has no `references` table, so `[reference:N]` markers stay as plain text with no links.
|
package/README.zh.md
CHANGED
|
@@ -57,7 +57,7 @@ DeepSeek 的长对话是**分页加载**的,每次只能看到一屏。扩展
|
|
|
57
57
|
|
|
58
58
|
- 会列出 DSH 里的工作区,选序号决定导入到哪个工作区
|
|
59
59
|
- 每个对话重建为一个会话,标题、顺序、正文(含代码块/表格等 Markdown)原样保留
|
|
60
|
-
- **附件**(图片、txt、pdf、docx
|
|
60
|
+
- **附件**(图片、txt、pdf、docx…):**仅当数据源带附件时**才落盘到 `<工作区>/.dsh-imported/<sessionId>/`,保留原文件名;下载不到时退化成 `[附件未下载:文件名(大小) 原链接]`,由你本机匹配。DeepSeek 的 IndexedDB 缓存副本通常不含附件
|
|
61
61
|
- **思考过程**写进同目录的 `thinking.md`(旁挂存档,不进入对话上下文)
|
|
62
62
|
- **编辑/重新生成留下的多个版本**:正文只保留当前分支,全部版本写进同目录的 `revisions.md`
|
|
63
63
|
- 改动 `workspace.json` 前自动备份为 `workspace.json.bak-<时间>`
|
|
@@ -120,6 +120,8 @@ node dsh-import.mjs 导出.json --keep-interrupted # 保留不完整的中断输
|
|
|
120
120
|
node dsh-import.mjs 导出.json --only "<对话 id>" # 只导指定对话(逗号分隔可多个)
|
|
121
121
|
node dsh-import.mjs 导出.json --from-match "某句话" # 从匹配到的用户消息开始分支
|
|
122
122
|
node dsh-import.mjs 导出.json --from-index 157 # 按消息序号分支
|
|
123
|
+
node dsh-import.mjs --audit # 体检已导入会话(system 头是否完好)
|
|
124
|
+
node dsh-import.mjs --repair # 批量修掉有隐患的会话
|
|
123
125
|
|
|
124
126
|
> --tail 与 --from-index/--from-match 互斥(一个是取最后 N 轮,一个是从指定位置开始),同时给出会直接报错退出;--from-* 只能用于单个对话。
|
|
125
127
|
```
|
|
@@ -223,6 +225,8 @@ node check-sessions.mjs
|
|
|
223
225
|
|
|
224
226
|
## 已知限制
|
|
225
227
|
|
|
228
|
+
- **引用与附件取决于数据源**:DeepSeek 的 IndexedDB 缓存副本只保留正文和思考,`references` 表与 `files` 字段通常不在里面。因此 `references.md` 和附件目录**只在数据源带这两类数据时才会生成**,没有生成是正常结果,不是失败。想找回图片只能回原网页另存。
|
|
229
|
+
|
|
226
230
|
- **思考过程旁挂**:写进 `thinking.md`,不进上下文。DSH 的 reasoning 块带 provider 签名,硬塞正文会污染后续请求;想看时直接翻文件,或加 `--thinking` 强制内联。
|
|
227
231
|
- **附件不进 DSH 附件库**:DSH 的图片是内容寻址附件(`~/.dsh/attachments/v1/objects/...`),伪造引用有损坏历史的风险;这里改为落盘 + 路径引用,模型可以用工具读。拿不到下载地址时只留文件名与大小。
|
|
228
232
|
- **正文完整性**:历史接口 `/api/v0/chat/history_messages` 返回的正文可能偏短;回答生成时的 `/api/v0/chat/completion` 是 `text/event-stream` 补丁流(`THINK` 片段=思考,其余片段=正文),扩展会边读边拼,生成完即写入捕获。所以**新回答**是完整的;很久以前的老回答以历史接口返回的内容为准。
|
package/dsh-import.mjs
CHANGED
|
@@ -85,6 +85,9 @@ function parseArgs(argv) {
|
|
|
85
85
|
else if (arg === '--keep-interrupted') opts.keepInterrupted = true;
|
|
86
86
|
else if (arg === '--tail') opts.tail = Number(argv[++i]);
|
|
87
87
|
else if (arg === '--no-auto-trim') opts.autoTrim = false;
|
|
88
|
+
else if (arg === '--no-system-head') opts.noSystemHead = true;
|
|
89
|
+
else if (arg === '--audit') opts.audit = true;
|
|
90
|
+
else if (arg === '--repair') { opts.audit = true; opts.applyRepair = true; }
|
|
88
91
|
else if (arg === '--no-sidecar' || arg === '--sidecar') opts.noSidecar = true;
|
|
89
92
|
else if (arg === '--sidecar-dir') opts.sidecarDir = argv[++i];
|
|
90
93
|
else if (arg === '--context-window') opts.contextWindow = Number(argv[++i]) || null;
|
|
@@ -362,6 +365,9 @@ function conversationsOf(file) {
|
|
|
362
365
|
|
|
363
366
|
// ---------------------------------------------------------------- 生成会话
|
|
364
367
|
|
|
368
|
+
const IMPORT_SYSTEM_NOTE =
|
|
369
|
+
'本会话的历史来自 DeepSeek 网页对话的导入,以下消息为用户与助手的原始记录,顺序与原文一致。';
|
|
370
|
+
|
|
365
371
|
const DATA_URL = /^data:([^;,]+);base64,(.*)$/s;
|
|
366
372
|
const MIME_EXT = {
|
|
367
373
|
'image/png': 'png',
|
|
@@ -418,7 +424,7 @@ function sidecarDir(workspacePath, sessionId) {
|
|
|
418
424
|
const ignore = path.join(root, '.gitignore');
|
|
419
425
|
if (!fs.existsSync(ignore)) {
|
|
420
426
|
try {
|
|
421
|
-
fs.writeFileSync(ignore, '# DSH 导入存档(思考 /
|
|
427
|
+
fs.writeFileSync(ignore, '# DSH 导入存档(思考 / 全文 / 多版本;数据源带引用或附件时另存)\n*\n');
|
|
422
428
|
} catch {
|
|
423
429
|
/* 没有写权限就算了 */
|
|
424
430
|
}
|
|
@@ -706,6 +712,21 @@ function buildEvents(conversation, workspacePath, sessionId) {
|
|
|
706
712
|
});
|
|
707
713
|
}
|
|
708
714
|
push('step/start', { turn, step });
|
|
715
|
+
// v4 不变量:surface 的第 0 个节点必须是受保护的 system 头。
|
|
716
|
+
// 导入的历史前面没有 system/message 时,会话能打开,但一旦继续对话、
|
|
717
|
+
// 运行时追加自己的 system/message 就会被校验器判为损坏。
|
|
718
|
+
if (turn === 1 && !opts.noSystemHead) {
|
|
719
|
+
push('system/message', {
|
|
720
|
+
turn,
|
|
721
|
+
step,
|
|
722
|
+
message: {
|
|
723
|
+
role: 'system',
|
|
724
|
+
content: [{ type: 'text', text: IMPORT_SYSTEM_NOTE }],
|
|
725
|
+
source: { kind: 'system-prompt' },
|
|
726
|
+
id: crypto.randomUUID(),
|
|
727
|
+
},
|
|
728
|
+
}, { surfaceOp: 'append' });
|
|
729
|
+
}
|
|
709
730
|
const seqNow = seq;
|
|
710
731
|
// 只在第一条用户消息尾部附上存档索引
|
|
711
732
|
if (sidecarNote && firstUserSeq === undefined) text += sidecarNote;
|
|
@@ -884,6 +905,12 @@ function writeTranscript(conversation, workspacePath, sessionId) {
|
|
|
884
905
|
// ---------------------------------------------------------------- 主流程
|
|
885
906
|
|
|
886
907
|
async function main() {
|
|
908
|
+
if (opts.audit) {
|
|
909
|
+
const { auditSessions } = await import('./repair-session.mjs');
|
|
910
|
+
auditSessions({ dryRun: opts.dryRun, apply: opts.applyRepair === true });
|
|
911
|
+
return { audited: true };
|
|
912
|
+
}
|
|
913
|
+
|
|
887
914
|
// ---------- 参数自检:互相排斥的组合直接拒绝 ----------
|
|
888
915
|
if (opts.tail !== undefined && opts.tail !== null) {
|
|
889
916
|
if (!Number.isInteger(opts.tail) || opts.tail < 1) {
|
|
@@ -437,11 +437,31 @@
|
|
|
437
437
|
try {
|
|
438
438
|
const cached = await readCachedHistory();
|
|
439
439
|
if (cached.ok && cached.messages?.length) {
|
|
440
|
-
|
|
440
|
+
// 缓存里没有附件(DeepSeek 不把 files 写进 IndexedDB)。
|
|
441
|
+
// 当前屏幕上已渲染的消息能拿到 <img>,按「角色+正文完全相同」贴回去,
|
|
442
|
+
// 只做并集、不新增消息,避免重复。
|
|
443
|
+
let withFiles = cached.messages;
|
|
444
|
+
try {
|
|
445
|
+
const domMessages = domExtract().messages || [];
|
|
446
|
+
const byKey = new Map(withFiles.map((m) => [`${m.role}|${String(m.content || '').trim()}`, m]));
|
|
447
|
+
let attached = 0;
|
|
448
|
+
for (const domMessage of domMessages) {
|
|
449
|
+
if (!(domMessage.files || []).length) continue;
|
|
450
|
+
const target = byKey.get(`${domMessage.role}|${String(domMessage.content || '').trim()}`);
|
|
451
|
+
if (!target) continue;
|
|
452
|
+
target.files = [...(target.files || []), ...domMessage.files];
|
|
453
|
+
attached += domMessage.files.length;
|
|
454
|
+
}
|
|
455
|
+
if (attached) withFiles = [...withFiles];
|
|
456
|
+
} catch {
|
|
457
|
+
/* DOM 读不到就维持原样 */
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
pushCapture(withFiles);
|
|
441
461
|
return {
|
|
442
462
|
ok: true,
|
|
443
463
|
source: 'cache',
|
|
444
|
-
messages:
|
|
464
|
+
messages: withFiles,
|
|
445
465
|
rounds: 0,
|
|
446
466
|
captures: 1,
|
|
447
467
|
delta: 0,
|
package/edge-extension/merge.js
CHANGED
|
@@ -16,8 +16,25 @@
|
|
|
16
16
|
return (message?.content || '').length + (message?.thinking || '').length;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** 同一消息的两份副本:正文取更全的,附件取并集 */
|
|
20
|
+
function unionFiles(a, b) {
|
|
21
|
+
const merged = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const file of [...(a || []), ...(b || [])]) {
|
|
24
|
+
const key = file?.dataUrl ? String(file.dataUrl).slice(0, 64) : file?.url || file?.name;
|
|
25
|
+
if (!key || seen.has(key)) continue;
|
|
26
|
+
seen.add(key);
|
|
27
|
+
merged.push(file);
|
|
28
|
+
}
|
|
29
|
+
return merged;
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
function richer(a, b) {
|
|
20
|
-
|
|
33
|
+
if (!a) return b;
|
|
34
|
+
if (!b) return a;
|
|
35
|
+
const winner = score(b) > score(a) ? b : a;
|
|
36
|
+
const files = unionFiles(a.files, b.files);
|
|
37
|
+
return files.length ? { ...winner, files } : winner;
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
function keyOf(message) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-deepseek-import",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "DeepSeek web chat → DeepSeek Harness sessions (Edge extension + importer CLI)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"dsh-import.cmd",
|
|
15
15
|
"move-session.mjs",
|
|
16
16
|
"remove-session.mjs",
|
|
17
|
+
"repair-session.mjs",
|
|
17
18
|
"audit-imports.mjs",
|
|
18
19
|
"edge-extension/",
|
|
19
20
|
"README.md",
|
|
@@ -39,6 +40,7 @@
|
|
|
39
40
|
},
|
|
40
41
|
"scripts": {
|
|
41
42
|
"list": "node dsh-import.mjs --list",
|
|
42
|
-
"tui": "node dsh-import-tui.mjs"
|
|
43
|
+
"tui": "node dsh-import-tui.mjs",
|
|
44
|
+
"audit": "node repair-session.mjs --audit"
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* repair-session.mjs — 给导入的会话补上 v4 要求的「受保护 system 头」
|
|
4
|
+
*
|
|
5
|
+
* 背景:DSH 会话格式 v4 要求 surface 的第 0 个节点是 system/message。
|
|
6
|
+
* 早期的导入器没写这一条,会话能打开,但一旦继续对话、运行时追加自己的
|
|
7
|
+
* system/message,就会报 `system/message requires a protected first surface head`。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node repair-session.mjs --audit # 体检所有会话
|
|
11
|
+
* node repair-session.mjs <session-dir> --dry-run
|
|
12
|
+
* node repair-session.mjs <session-dir>
|
|
13
|
+
* node repair-session.mjs --all [--dry-run] # 批量修所有需要修的
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import zlib from 'node:zlib';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
24
|
+
const SESSIONS = path.join(DSH_HOME, 'sessions');
|
|
25
|
+
const CACHE = path.join(DSH_HOME, 'storages', 'session_projcache', 'sessions');
|
|
26
|
+
|
|
27
|
+
const NOTE =
|
|
28
|
+
'本会话的历史来自 DeepSeek 网页对话的导入,以下消息为用户与助手的原始记录,顺序与原文一致。';
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const dryRun = args.includes('--dry-run');
|
|
32
|
+
const audit = args.includes('--audit');
|
|
33
|
+
const all = args.includes('--all');
|
|
34
|
+
const targets = args.filter((a) => !a.startsWith('--'));
|
|
35
|
+
|
|
36
|
+
function readFrames(file) {
|
|
37
|
+
const buf = fs.readFileSync(file);
|
|
38
|
+
const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
39
|
+
const offsets = [];
|
|
40
|
+
for (let i = -1; (i = buf.indexOf(magic, i + 1)) !== -1;) offsets.push(i);
|
|
41
|
+
const texts = [];
|
|
42
|
+
for (let k = 0; k < offsets.length; k++) {
|
|
43
|
+
texts.push(zlib.zstdDecompressSync(buf.subarray(offsets[k], offsets[k + 1] ?? buf.length)).toString('utf8'));
|
|
44
|
+
}
|
|
45
|
+
return texts;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeFrames(file, texts) {
|
|
49
|
+
const encoded = Buffer.concat(texts.map((text) => zlib.zstdCompressSync(Buffer.from(text, 'utf8'))));
|
|
50
|
+
fs.writeFileSync(file, encoded);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sessionFiles() {
|
|
54
|
+
const out = [];
|
|
55
|
+
if (!fs.existsSync(SESSIONS)) return out;
|
|
56
|
+
for (const project of fs.readdirSync(SESSIONS)) {
|
|
57
|
+
const dir = path.join(SESSIONS, project);
|
|
58
|
+
if (!fs.statSync(dir).isDirectory()) continue;
|
|
59
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
60
|
+
if (!entry.startsWith('session-')) continue;
|
|
61
|
+
const file = path.join(dir, entry, 'session.v4.jsonl.zstd');
|
|
62
|
+
if (fs.existsSync(file)) out.push({ id: entry, file });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 返回 { status, header, events, firstSurfaceIndex, systemIndexes } */
|
|
69
|
+
function inspect(file) {
|
|
70
|
+
const texts = readFrames(file);
|
|
71
|
+
const rows = texts.join('').split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
72
|
+
const header = rows[0];
|
|
73
|
+
const events = rows.slice(1);
|
|
74
|
+
const systemIndexes = events.map((e, i) => (e.type === 'system/message' ? i : -1)).filter((i) => i >= 0);
|
|
75
|
+
const surfaceTypes = new Set(['user/message', 'assistant/message', 'system/message', 'tool/result', 'developer/message']);
|
|
76
|
+
const firstSurfaceIndex = events.findIndex((e) => surfaceTypes.has(e.type));
|
|
77
|
+
return { texts, header, events, systemIndexes, firstSurfaceIndex };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeSystemEvent(turn, step) {
|
|
81
|
+
return {
|
|
82
|
+
type: 'system/message',
|
|
83
|
+
seq: 0,
|
|
84
|
+
time: Date.now(),
|
|
85
|
+
data: {
|
|
86
|
+
turn,
|
|
87
|
+
step,
|
|
88
|
+
message: {
|
|
89
|
+
role: 'system',
|
|
90
|
+
content: [{ type: 'text', text: NOTE }],
|
|
91
|
+
source: { kind: 'system-prompt' },
|
|
92
|
+
id: crypto.randomUUID(),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
surfaceOp: 'append',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function planRepair({ events, systemIndexes, firstSurfaceIndex }) {
|
|
100
|
+
if (firstSurfaceIndex < 0) return { action: 'skip', reason: '没有 surface 事件' };
|
|
101
|
+
if (systemIndexes.length && systemIndexes[0] === firstSurfaceIndex) {
|
|
102
|
+
return { action: 'ok' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const copy = events.map((e) => ({ ...e }));
|
|
106
|
+
if (systemIndexes.length) {
|
|
107
|
+
// 已有 system 但在后面:把它挪到 surface 最前面
|
|
108
|
+
const source = copy[systemIndexes[0]];
|
|
109
|
+
copy.splice(systemIndexes[0], 1);
|
|
110
|
+
const target = firstSurfaceIndex;
|
|
111
|
+
copy.splice(target, 0, {
|
|
112
|
+
...source,
|
|
113
|
+
data: { ...source.data, turn: 1, step: 1 },
|
|
114
|
+
surfaceOp: 'append',
|
|
115
|
+
});
|
|
116
|
+
return { action: 'move', events: copy };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 完全没有 system:插到第一个 surface 事件之前
|
|
120
|
+
copy.splice(firstSurfaceIndex, 0, makeSystemEvent(1, 1));
|
|
121
|
+
return { action: 'insert', events: copy };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resequence(events) {
|
|
125
|
+
const mapping = new Map();
|
|
126
|
+
events.forEach((event, index) => mapping.set(event.seq, index));
|
|
127
|
+
const next = events.map((event, index) => ({ ...event, seq: index }));
|
|
128
|
+
// 改写对 seq 的引用(session/title 的 messageSeqs)
|
|
129
|
+
for (const event of next) {
|
|
130
|
+
if (event.type === 'session/title' && Array.isArray(event.data?.messageSeqs)) {
|
|
131
|
+
event.data = {
|
|
132
|
+
...event.data,
|
|
133
|
+
messageSeqs: event.data.messageSeqs.map((seq) => mapping.get(seq) ?? seq),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return next;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sessionDirOf(file) {
|
|
141
|
+
return path.dirname(file);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function repair(file, plan) {
|
|
145
|
+
const { texts, header } = inspect(file);
|
|
146
|
+
const events = resequence(plan.events);
|
|
147
|
+
const lines = [JSON.stringify(header), ...events.map((e) => JSON.stringify(e))];
|
|
148
|
+
writeFrames(file, lines.map((line) => `${line}\n`));
|
|
149
|
+
|
|
150
|
+
const id = path.basename(sessionDirOf(file));
|
|
151
|
+
const cached = path.join(CACHE, `${id}.json`);
|
|
152
|
+
let cacheNote = '';
|
|
153
|
+
if (fs.existsSync(cached)) {
|
|
154
|
+
fs.rmSync(cached, { force: true });
|
|
155
|
+
cacheNote = ',投影缓存已清除';
|
|
156
|
+
}
|
|
157
|
+
return `${plan.action === 'insert' ? '已插入 system 头' : '已把 system 头移到最前'}(${events.length} 条事件${cacheNote})`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function auditSessions({ dryRun = false, apply = false } = {}) {
|
|
161
|
+
const list = sessionFiles();
|
|
162
|
+
const problems = [];
|
|
163
|
+
for (const item of list) {
|
|
164
|
+
let info;
|
|
165
|
+
try {
|
|
166
|
+
info = inspect(item.file);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
problems.push({ ...item, status: `读取失败:${error.message}` });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const plan = planRepair(info);
|
|
172
|
+
if (plan.action === 'ok') continue;
|
|
173
|
+
problems.push({ ...item, status: plan.action, plan });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.log(`会话总数 ${list.length},需要处理 ${problems.length}`);
|
|
177
|
+
for (const item of problems) {
|
|
178
|
+
console.log(` ${item.id} ${item.status}`);
|
|
179
|
+
if (dryRun || !apply || !item.plan) continue;
|
|
180
|
+
console.log(` → ${repair(item.file, item.plan)}`);
|
|
181
|
+
}
|
|
182
|
+
if (dryRun) console.log('\n[dry-run] 没有修改任何文件');
|
|
183
|
+
return problems;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function repairSessions(paths, { dryRun = false } = {}) {
|
|
187
|
+
for (const target of paths) {
|
|
188
|
+
const dir = target.endsWith('session.v4.jsonl.zstd') ? path.dirname(target) : target;
|
|
189
|
+
const file = path.join(dir, 'session.v4.jsonl.zstd');
|
|
190
|
+
if (!fs.existsSync(file)) {
|
|
191
|
+
console.error(`找不到 ${file}`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const info = inspect(file);
|
|
195
|
+
const plan = planRepair(info);
|
|
196
|
+
console.log(`${path.basename(dir)}: ${plan.action}`);
|
|
197
|
+
if (plan.action === 'ok' || plan.action === 'skip' || dryRun) continue;
|
|
198
|
+
console.log(' → ' + repair(file, plan));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------- 入口
|
|
203
|
+
|
|
204
|
+
const invokedDirectly =
|
|
205
|
+
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
206
|
+
|
|
207
|
+
if (invokedDirectly) {
|
|
208
|
+
if (audit || all) {
|
|
209
|
+
auditSessions({ dryRun, apply: all });
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!targets.length) {
|
|
214
|
+
console.error('用法:node repair-session.mjs --audit | <session-dir> [--dry-run] | --all');
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
repairSessions(targets, { dryRun });
|
|
219
|
+
}
|
|
220
|
+
|