dsh-deepseek-import 0.1.0 → 0.1.1

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 CHANGED
@@ -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`
package/README.zh.md CHANGED
@@ -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
  ```
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',
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-deepseek-import",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
+