dsh-config-manager 0.1.70 → 0.1.71
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/client.js +392 -392
- package/lib/foreign/claude-sessions.d.ts +9 -1
- package/lib/foreign/claude-sessions.js +10 -2
- package/lib/foreign/session-ir.js +46 -10
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/package.json +1 -1
- package/src/foreign/claude-sessions-bytes.test.ts +20 -7
- package/src/foreign/claude-sessions.test.ts +26 -1
- package/src/foreign/claude-sessions.ts +10 -2
- package/src/foreign/reasonix.test.ts +5 -3
- package/src/foreign/session-ir.test.ts +54 -1
- package/src/foreign/session-ir.ts +44 -9
- package/src/index.ts +1 -1
- package/src/routes/foreign.test.ts +62 -4
|
@@ -30,7 +30,15 @@
|
|
|
30
30
|
import type { Stats } from 'node:fs';
|
|
31
31
|
import type { DshSessionHeader, DshSessionRow, IrParseStats, IrSession } from './session-ir.ts';
|
|
32
32
|
export type { DshSessionHeader, DshSessionRow } from './session-ir.ts';
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* 本模块已实现行式的 DSH 会话格式版本(其它版本一律拒绝,不猜)。
|
|
35
|
+
*
|
|
36
|
+
* **v3 与 v4 的行式是同一套**:`synthesizeDshRows` 产出的行在两代 DSH 自己的真 codec 上逐条复验过
|
|
37
|
+
* (v3 档 = 本机 CLI 档案树的 dsh-session;v4 档 = 桌面端 0.2.0-rc.2,均 `equivalentToReadPath`)。
|
|
38
|
+
* 所以**按目标机的版本写**:目标是 v3 就写 v3、是 v4 就写 v4。两条反向都不行 —— 一律写最高版本
|
|
39
|
+
* 会让 v3 主机读不了(反向不可读);只认 v3 则让 v4 目标整批被跳过(2026-10-08 真机事故:
|
|
40
|
+
* 桌面端 v4 目标下每一条会话都被 `session-format-unsupported` 拒掉,产出的包里没有 sessions 分区)。
|
|
41
|
+
*/
|
|
34
42
|
export declare const SUPPORTED_DSH_SESSION_FORMAT_VERSIONS: readonly number[];
|
|
35
43
|
export interface ClaudeSessionFile {
|
|
36
44
|
/** 文件名(不含 .jsonl)——同时作为 DSH 侧的会话 id(DSH 要求目录名与 header.id 逐字相同) */
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { projectKeyOf } from '../core/session-select.js';
|
|
2
2
|
import { encodeZstdFrame } from '../utils/zstd-frame.js';
|
|
3
3
|
import { irBump, irEarlier, irMessage, irMessageIdOf, irNum, irSafeTime, irStr, irTextBlock, irTextOfBlocks, irToolCallBlock, irToolResultBlock, isSafeIrId, parseJsonlObjects, synthesizeDshRows, } from './session-ir.js';
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* 本模块已实现行式的 DSH 会话格式版本(其它版本一律拒绝,不猜)。
|
|
6
|
+
*
|
|
7
|
+
* **v3 与 v4 的行式是同一套**:`synthesizeDshRows` 产出的行在两代 DSH 自己的真 codec 上逐条复验过
|
|
8
|
+
* (v3 档 = 本机 CLI 档案树的 dsh-session;v4 档 = 桌面端 0.2.0-rc.2,均 `equivalentToReadPath`)。
|
|
9
|
+
* 所以**按目标机的版本写**:目标是 v3 就写 v3、是 v4 就写 v4。两条反向都不行 —— 一律写最高版本
|
|
10
|
+
* 会让 v3 主机读不了(反向不可读);只认 v3 则让 v4 目标整批被跳过(2026-10-08 真机事故:
|
|
11
|
+
* 桌面端 v4 目标下每一条会话都被 `session-format-unsupported` 拒掉,产出的包里没有 sessions 分区)。
|
|
12
|
+
*/
|
|
13
|
+
export const SUPPORTED_DSH_SESSION_FORMAT_VERSIONS = [3, 4];
|
|
6
14
|
/** 单帧的软上限(字节):把多条事件行合并进一帧,避免「一行一帧」产生上千个帧 */
|
|
7
15
|
const FRAME_TARGET_BYTES = 64 * 1024;
|
|
8
16
|
/** 该会话的请求头能力声明(从重构前的内联字面量原样搬出,取值一字未改) */
|
|
@@ -339,17 +339,53 @@ export function synthesizeDshRows(session, hints, stats, now) {
|
|
|
339
339
|
messageIndex++;
|
|
340
340
|
}
|
|
341
341
|
closeTurn();
|
|
342
|
-
|
|
342
|
+
/**
|
|
343
|
+
* 标题行:位置与 `messageSeqs` 必须满足 DSH 的 `session/title` 硬规则(`assertTitleSources`;
|
|
344
|
+
* **v3 与 v4 是同一份校验**,真机实测两代在严格档报同一条):
|
|
345
|
+
* ① `messageSeqs` 为空 **⟺** `source.kind === 'user'`(用户消息标题不引用,其它来源必须引用);
|
|
346
|
+
* ② 非空时每个 seq 必须**早于本条事件**、且指向 `source.kind === 'user'` 的 `user/message`,互不重复。
|
|
347
|
+
*
|
|
348
|
+
* 所以标题行**不能**停在 seq 0:非 user 来源(本仓两处调用方都给 `{kind:'fallback'}`)在 seq 0
|
|
349
|
+
* 没有任何「更早的人类消息」可引用,只写 `messageSeqs: []` 会同时违反①。真机日志同形 ——
|
|
350
|
+
* 实测 `{"type":"session/title","seq":12,"messageSeqs":[7],"source":{"kind":"fallback"}}`(7 是首条人类消息)。
|
|
351
|
+
* 因此把标题行插在**首条人类 user/message 之后**,再按行序统一重编 seq(仍是连续递增)。
|
|
352
|
+
*
|
|
353
|
+
* 来源是 fallback 而整场又没有人类 user/message 时**不产标题行**:标题是装饰性的,
|
|
354
|
+
* 宁可让这条会话按 `session-empty` 如实跳过,也不编一条指不到人的引用(那是会被拒读的行)。
|
|
355
|
+
*/
|
|
356
|
+
const titleSource = hints.titleSource;
|
|
357
|
+
const titleSourceKind = typeof titleSource === 'object' && titleSource !== null
|
|
358
|
+
? titleSource.kind
|
|
359
|
+
: undefined;
|
|
343
360
|
if (hints.title !== '') {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
361
|
+
if (titleSourceKind === 'user') {
|
|
362
|
+
rows.unshift({
|
|
363
|
+
type: 'session/title',
|
|
364
|
+
seq: 0,
|
|
365
|
+
time: session.createdAt,
|
|
366
|
+
data: { title: hints.title, messageSeqs: [], source: hints.titleSource },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
const at = rows.findIndex((row) => {
|
|
371
|
+
if (row.type !== 'user/message')
|
|
372
|
+
return false;
|
|
373
|
+
const source = row.data['source'];
|
|
374
|
+
return typeof source === 'object' && source !== null && source.kind === 'user';
|
|
375
|
+
});
|
|
376
|
+
if (at >= 0) {
|
|
377
|
+
rows.splice(at + 1, 0, {
|
|
378
|
+
type: 'session/title',
|
|
379
|
+
seq: 0,
|
|
380
|
+
// at 是「插入前」首条人类消息的 seq(未插入时行序即 seq),引用它必然早于本行
|
|
381
|
+
data: { title: hints.title, messageSeqs: [at], source: hints.titleSource },
|
|
382
|
+
time: session.createdAt,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
rows.forEach((row, index) => {
|
|
387
|
+
row.seq = index;
|
|
388
|
+
});
|
|
353
389
|
}
|
|
354
390
|
return rows;
|
|
355
391
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -76,7 +76,7 @@ export declare const name = "config-manager";
|
|
|
76
76
|
/** Services required before the engine can mount (present in every profile). */
|
|
77
77
|
export declare const inject: string[];
|
|
78
78
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
79
|
-
export declare const PLUGIN_VERSION = "0.1.
|
|
79
|
+
export declare const PLUGIN_VERSION = "0.1.71";
|
|
80
80
|
/**
|
|
81
81
|
* Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
|
|
82
82
|
* 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
|
package/lib/index.js
CHANGED
|
@@ -127,7 +127,7 @@ export const name = 'config-manager';
|
|
|
127
127
|
/** Services required before the engine can mount (present in every profile). */
|
|
128
128
|
export const inject = ['settings', 'credentials'];
|
|
129
129
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
130
|
-
export const PLUGIN_VERSION = '0.1.
|
|
130
|
+
export const PLUGIN_VERSION = '0.1.71';
|
|
131
131
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
132
132
|
const PLUGIN_NAME = 'dsh-config-manager';
|
|
133
133
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-config-manager",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.71",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Backup, restore, export, import, migrate and sync your DeepSeek Harness (DSH) configuration: settings, plugins, MCP servers, skills, agent presets and workspaces. One-click migration to another machine. DSH 配置一键备份、恢复、导出、导入、迁移与同步插件。",
|
|
6
6
|
"icon": "./icon.svg",
|
|
@@ -186,14 +186,27 @@ const GOLDEN: Record<string, string> = {
|
|
|
186
186
|
* assistant 行补 `stream: []` 与 content 里的 `tool-call` 块;tool/result 块改为
|
|
187
187
|
* `tool-result`/`toolCallId`/`isError` 且 callId 非空)后,字节必然变化。
|
|
188
188
|
* 复验:DSH 自己的 `adoptSessionEvent` 逐行校验 hermes 71 会话 / 29687 行 → 0 拒绝。
|
|
189
|
+
*
|
|
190
|
+
* 2026-10-08 有意更新(**理由**):标题行按 DSH 的 `session/title` 硬规则重做(`assertTitleSources`:
|
|
191
|
+
* `messageSeqs` 为空 **⟺** `source.kind === 'user'`,非空时每个 seq 必须**早于**本条且指向
|
|
192
|
+
* `source.kind === 'user'` 的 `user/message`)。旧实现把标题停在 seq 0 且恒写 `messageSeqs: []`,
|
|
193
|
+
* 于非 user 来源同时违反该规则:真 codec 的严格档两代都报 `messageSeqs must be empty exactly for
|
|
194
|
+
* a user title`,且 **v3→v4 迁移直接拒收**(`finish-failed: … refuses the transformed artifact`)——
|
|
195
|
+
* 后果是 v3 标记的产物在 v4 主机上整批读不出来。现在标题行插在首条人类 user/message 之后并引用它
|
|
196
|
+
* (真机日志同形:`{"seq":12,"messageSeqs":[7],"source":{"kind":"fallback"}}`)。
|
|
197
|
+
* **附带的行为变化**:`title-only-no-events` 从「只有标题行的会话」变成 `skip:session-empty` ——
|
|
198
|
+
* 整场没有任何人类 user/message 时,非 user 来源的标题**无解**(引用不到任何人),宁可如实跳过,
|
|
199
|
+
* 也不产出违反①的标题行。
|
|
200
|
+
* 复验:修后同一批产物对**两代真 codec**逐条 `verifySessionLogBytes` → `verified/strong/equivalentToReadPath` 全 true
|
|
201
|
+
* (v4 目标档用桌面端 app.asar 内的 catalog;v3 目标档用本机 CLI 档案树)。
|
|
189
202
|
*/
|
|
190
|
-
'basic-with-malformed-line': '
|
|
191
|
-
'frames-crossing-64k': '
|
|
192
|
-
'orphan-empty-and-unknown': '
|
|
193
|
-
'title-only-no-events': '
|
|
194
|
-
'no-uuid-fallback-ids': '
|
|
195
|
-
'timestamps-numeric-and-unparsable': '
|
|
196
|
-
'unicode-and-max-id': '
|
|
203
|
+
'basic-with-malformed-line': '896:b2fa752b2b5d2a9f470a9c49cb40d6f0dfafe40d1ea3fd6e81afc62ac189759d',
|
|
204
|
+
'frames-crossing-64k': '7518:d8be900e191b4a2c2c70819790bc0434bc8bffcbfbafbb00016b09bc04edaa8a',
|
|
205
|
+
'orphan-empty-and-unknown': '339:7492bd869daacad62ee75688ad8816e23af259215a360d01de9917cdffadc727',
|
|
206
|
+
'title-only-no-events': 'skip:session-empty',
|
|
207
|
+
'no-uuid-fallback-ids': '743:1c6ea7a8e76016376da2bff5f50e72e824eb7bce9724af4bee40d41d07d0b6df',
|
|
208
|
+
'timestamps-numeric-and-unparsable': '728:64308bcdea6fc439ed190dd56cad74354e9f53f1274821d97a57bc519f3505ce',
|
|
209
|
+
'unicode-and-max-id': '729:a8a5abaca4dc7ab15c499b1c38655972283def0bedd3bb73bc87558e22ef51c7',
|
|
197
210
|
'skip-unsupported-version': 'skip:session-format-unsupported',
|
|
198
211
|
'skip-unsafe-id': 'skip:session-unsafe-id',
|
|
199
212
|
'skip-missing-cwd': 'skip:session-missing-cwd',
|
|
@@ -71,12 +71,23 @@ test('t1 形态:路径 / header / 事件序列', () => {
|
|
|
71
71
|
assert.equal(header['createdAt'], Date.parse('2026-10-01T10:00:00.000Z'));
|
|
72
72
|
|
|
73
73
|
const types = shape.rows.map((r) => (r === null ? 'null' : (r as Record<string, unknown>)['type']));
|
|
74
|
+
// 标题行**跟在首条人类 user/message 之后**(见 synthesizeDshRows 的标题行注释:非 user 来源的标题
|
|
75
|
+
// 必须引用一条**更早**的人类消息,停在 seq 0 就无解 —— DSH 的 assertTitleSources 会拒读)
|
|
74
76
|
assert.deepEqual(types, [
|
|
75
|
-
'
|
|
77
|
+
'user/message', 'session/title', 'turn/start', 'request/header', 'step/start',
|
|
76
78
|
'assistant/message', 'tool/call', 'tool/result', 'step/end',
|
|
77
79
|
'step/start', 'assistant/message', 'step/end', 'turn/end',
|
|
78
80
|
]);
|
|
79
81
|
|
|
82
|
+
// 标题行必须满足 DSH 的 assertTitleSources:非 user 来源 → messageSeqs 非空且引用更早的人类消息
|
|
83
|
+
const titleIndex = shape.rows.findIndex((r) => (r as Record<string, unknown> | null)?.['type'] === 'session/title');
|
|
84
|
+
assert.equal(titleIndex, 1);
|
|
85
|
+
const titleRow = shape.rows[titleIndex] as Record<string, unknown>;
|
|
86
|
+
const titleData = titleRow['data'] as Record<string, unknown>;
|
|
87
|
+
assert.deepEqual(titleData['messageSeqs'], [0], '必须引用首条人类 user/message 的 seq');
|
|
88
|
+
assert.deepEqual(titleData['source'], { kind: 'fallback' });
|
|
89
|
+
assert.ok((titleRow['seq'] as number) > 0, '被引用的 seq 必须早于标题行本身');
|
|
90
|
+
|
|
80
91
|
// seq 必须从 0 连续递增(DSH 按位置校验引用)
|
|
81
92
|
const seqs = shape.rows.map((r) => (r as Record<string, unknown>)['seq']);
|
|
82
93
|
assert.deepEqual(seqs, seqs.map((_, i) => i));
|
|
@@ -103,9 +114,23 @@ test('t1 形态:路径 / header / 事件序列', () => {
|
|
|
103
114
|
assert.equal(s.info.ignored['unparsable'], 1);
|
|
104
115
|
});
|
|
105
116
|
|
|
117
|
+
test('t1b v4 目标:按目标版本写出(header/文件名同步,不是一律写最高版本)', () => {
|
|
118
|
+
const result = transcodeClaudeSession({ id: ID, text: sessionText() }, { formatVersion: 4, now: 1_700_000_000_000 });
|
|
119
|
+
assert.ok(result.session, 'v4 目标必须能转码(2026-10-08 桌面端 v4 整批被跳过的回归护栏)');
|
|
120
|
+
const s = result.session;
|
|
121
|
+
assert.equal(s.relativePath, projectKeyOf(CWD) + '/' + ID + '/session.v4.jsonl.zstd');
|
|
122
|
+
assert.equal(s.relativePath, '--D-proj-app--/' + ID + '/session.v4.jsonl.zstd');
|
|
123
|
+
const shape = readSessionLogShapeFromBytes(s.data);
|
|
124
|
+
assert.equal(shape.ok, true, 'v4 产物同样必须是 DSH 可扫的 zstd 帧序列');
|
|
125
|
+
if (!shape.ok) return;
|
|
126
|
+
assert.equal((shape.headerValue as Record<string, unknown>)['version'], 4, 'header.version 必须等于目标版本');
|
|
127
|
+
});
|
|
128
|
+
|
|
106
129
|
test('t2 拒绝面:不认识的格式版本 / 缺 cwd / 非法 id 一律跳过并报码', () => {
|
|
107
130
|
const base = { id: ID, text: sessionText() };
|
|
108
131
|
assert.equal(transcodeClaudeSession(base, { formatVersion: 2 }).skip?.code, 'session-format-unsupported');
|
|
132
|
+
// 比本模块已实现的最高版本更高(未来格式)同样**如实跳过**,绝不按最高版本硬写
|
|
133
|
+
assert.equal(transcodeClaudeSession(base, { formatVersion: 5 }).skip?.code, 'session-format-unsupported');
|
|
109
134
|
assert.equal(transcodeClaudeSession({ ...base, id: 'a/b' }, { formatVersion: 3 }).skip?.code, 'session-unsafe-id');
|
|
110
135
|
const noCwd = [line({ type: 'user', uuid: 'aaaaaaaa-0000-4000-8000-000000000001', message: { role: 'user', content: 'hi' } })].join(NL);
|
|
111
136
|
assert.equal(transcodeClaudeSession({ id: ID, text: noCwd }, { formatVersion: 3 }).skip?.code, 'session-missing-cwd');
|
|
@@ -59,8 +59,16 @@ import type {
|
|
|
59
59
|
|
|
60
60
|
export type { DshSessionHeader, DshSessionRow } from './session-ir.ts';
|
|
61
61
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
62
|
+
/**
|
|
63
|
+
* 本模块已实现行式的 DSH 会话格式版本(其它版本一律拒绝,不猜)。
|
|
64
|
+
*
|
|
65
|
+
* **v3 与 v4 的行式是同一套**:`synthesizeDshRows` 产出的行在两代 DSH 自己的真 codec 上逐条复验过
|
|
66
|
+
* (v3 档 = 本机 CLI 档案树的 dsh-session;v4 档 = 桌面端 0.2.0-rc.2,均 `equivalentToReadPath`)。
|
|
67
|
+
* 所以**按目标机的版本写**:目标是 v3 就写 v3、是 v4 就写 v4。两条反向都不行 —— 一律写最高版本
|
|
68
|
+
* 会让 v3 主机读不了(反向不可读);只认 v3 则让 v4 目标整批被跳过(2026-10-08 真机事故:
|
|
69
|
+
* 桌面端 v4 目标下每一条会话都被 `session-format-unsupported` 拒掉,产出的包里没有 sessions 分区)。
|
|
70
|
+
*/
|
|
71
|
+
export const SUPPORTED_DSH_SESSION_FORMAT_VERSIONS: readonly number[] = [3, 4];
|
|
64
72
|
|
|
65
73
|
/** 单帧的软上限(字节):把多条事件行合并进一帧,避免「一行一帧」产生上千个帧 */
|
|
66
74
|
const FRAME_TARGET_BYTES = 64 * 1024;
|
|
@@ -316,9 +316,11 @@ test('reasonix t9 createdAt 兜底 stem 内嵌时刻(YYYYMMDDHHMM);usage s
|
|
|
316
316
|
const session = sessionFilesOf(result)[0];
|
|
317
317
|
assert.ok(session !== undefined);
|
|
318
318
|
const rows = rowsOf(session.data);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
319
|
+
// 标题行承载会话创建时间;行序是「首条人类 user/message → session/title」(见 synthesizeDshRows
|
|
320
|
+
// 的标题行注释:非 user 来源的标题必须引用一条更早的人类消息),所以不能再看 rows[0]。
|
|
321
|
+
const title = rows.find((r) => r['type'] === 'session/title');
|
|
322
|
+
assert.ok(title !== undefined);
|
|
323
|
+
assert.equal(title['time'], reasonixStemTime(stem), '会话创建时间必须落成记录时间而非导入时刻');
|
|
322
324
|
const assistant = rows.find((r) => r['type'] === 'assistant/message');
|
|
323
325
|
assert.ok(assistant !== undefined);
|
|
324
326
|
assert.deepEqual(dataOf(assistant)['usage'], {
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
irToolResultBlock,
|
|
26
26
|
synthesizeDshRows,
|
|
27
27
|
} from './session-ir.ts';
|
|
28
|
-
import type { DshSessionRow, SynthesisStats } from './session-ir.ts';
|
|
28
|
+
import type { DshSessionRow, IrMessage, SynthesisStats } from './session-ir.ts';
|
|
29
29
|
|
|
30
30
|
const NOW = 1700000000000;
|
|
31
31
|
|
|
@@ -134,3 +134,56 @@ test('合成行:无主结果不产出空 callId,按 orphan-tool-result 计
|
|
|
134
134
|
}
|
|
135
135
|
assert.equal(s.ignored['orphan-tool-result'], 1, '无主结果必须计数可见');
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
/* ---------------- 标题行(DSH 的 assertTitleSources 是硬校验,v3/v4 同款) ---------------- */
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 标题行规则的最小语料。
|
|
142
|
+
*
|
|
143
|
+
* 为什么单列一组用例(2026-10-08 真机事故):旧实现把标题停在 seq 0 且恒写 `messageSeqs: []`,
|
|
144
|
+
* 而 DSH 的规则是「`messageSeqs` 为空 **⟺** `source.kind === 'user'`,非空时每个 seq 必须**早于**
|
|
145
|
+
* 本条事件且指向 `source.kind === 'user'` 的 `user/message`」。真 codec 的严格档两代都报这条,
|
|
146
|
+
* 且 **v3→v4 迁移直接拒收整份产物**(`finish-failed: … refuses the transformed artifact`)。
|
|
147
|
+
*/
|
|
148
|
+
function synthTitle(titleSource: unknown, messages: readonly IrMessage[]): DshSessionRow[] {
|
|
149
|
+
return synthesizeDshRows(
|
|
150
|
+
{ id: 's1', cwd: 'D:/proj', createdAt: NOW, records: messages.length, messages },
|
|
151
|
+
{ title: '标题', titleSource, emptyBlocks: [], provider: 'hermes', reasoningEffort: 'medium', maxTokens: 8192 },
|
|
152
|
+
stats(),
|
|
153
|
+
NOW,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
test('标题行:非 user 来源必须引用**更早**的人类 user/message(且 seq 仍连续)', () => {
|
|
158
|
+
const rows = synthTitle({ kind: 'fallback' }, [
|
|
159
|
+
irMessage(irFallbackMessageId(0), 'user', [irTextBlock('你好')], { time: NOW }),
|
|
160
|
+
irMessage(irFallbackMessageId(1), 'assistant', [irTextBlock('在')], { time: NOW + 1 }),
|
|
161
|
+
]);
|
|
162
|
+
const title = rows.find((r) => r.type === 'session/title');
|
|
163
|
+
assert.ok(title !== undefined, '有标题就必须产标题行');
|
|
164
|
+
const data = title.data as { messageSeqs: number[]; source: { kind: string } };
|
|
165
|
+
assert.equal(data.source.kind, 'fallback');
|
|
166
|
+
assert.deepEqual(data.messageSeqs, [0], '必须引用首条人类 user/message 的 seq');
|
|
167
|
+
assert.ok(title.seq > data.messageSeqs[0]!, '被引用的 seq 必须**早于**标题行本身');
|
|
168
|
+
const cited = rows.find((r) => r.seq === data.messageSeqs[0]);
|
|
169
|
+
assert.equal(cited?.type, 'user/message', '只能引用 user/message(DSH 按位置与类型双重校验)');
|
|
170
|
+
assert.deepEqual(rows.map((r) => r.seq), rows.map((_, i) => i), 'seq 必须仍从 0 连续递增');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('标题行:user 来源 → messageSeqs 必须为空(空 ⟺ user),可停在 seq 0', () => {
|
|
174
|
+
const rows = synthTitle({ kind: 'user' }, [
|
|
175
|
+
irMessage(irFallbackMessageId(0), 'user', [irTextBlock('你好')], { time: NOW }),
|
|
176
|
+
]);
|
|
177
|
+
const title = rows[0];
|
|
178
|
+
assert.equal(title?.type, 'session/title');
|
|
179
|
+
assert.equal(title?.seq, 0);
|
|
180
|
+
assert.deepEqual((title?.data as { messageSeqs: unknown }).messageSeqs, [], 'user 标题不引用任何 seq');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('标题行:非 user 来源且整场没有人类 user/message → 不产标题行(不编指不到人的引用)', () => {
|
|
184
|
+
const rows = synthTitle({ kind: 'fallback' }, [
|
|
185
|
+
irMessage(irFallbackMessageId(0), 'assistant', [irTextBlock('只有助手')], { time: NOW }),
|
|
186
|
+
]);
|
|
187
|
+
assert.equal(rows.some((r) => r.type === 'session/title'), false);
|
|
188
|
+
assert.ok(rows.length > 0, '助手消息本身仍要产出(标题只是装饰)');
|
|
189
|
+
});
|
|
@@ -525,16 +525,51 @@ export function synthesizeDshRows(
|
|
|
525
525
|
}
|
|
526
526
|
closeTurn();
|
|
527
527
|
|
|
528
|
-
|
|
528
|
+
/**
|
|
529
|
+
* 标题行:位置与 `messageSeqs` 必须满足 DSH 的 `session/title` 硬规则(`assertTitleSources`;
|
|
530
|
+
* **v3 与 v4 是同一份校验**,真机实测两代在严格档报同一条):
|
|
531
|
+
* ① `messageSeqs` 为空 **⟺** `source.kind === 'user'`(用户消息标题不引用,其它来源必须引用);
|
|
532
|
+
* ② 非空时每个 seq 必须**早于本条事件**、且指向 `source.kind === 'user'` 的 `user/message`,互不重复。
|
|
533
|
+
*
|
|
534
|
+
* 所以标题行**不能**停在 seq 0:非 user 来源(本仓两处调用方都给 `{kind:'fallback'}`)在 seq 0
|
|
535
|
+
* 没有任何「更早的人类消息」可引用,只写 `messageSeqs: []` 会同时违反①。真机日志同形 ——
|
|
536
|
+
* 实测 `{"type":"session/title","seq":12,"messageSeqs":[7],"source":{"kind":"fallback"}}`(7 是首条人类消息)。
|
|
537
|
+
* 因此把标题行插在**首条人类 user/message 之后**,再按行序统一重编 seq(仍是连续递增)。
|
|
538
|
+
*
|
|
539
|
+
* 来源是 fallback 而整场又没有人类 user/message 时**不产标题行**:标题是装饰性的,
|
|
540
|
+
* 宁可让这条会话按 `session-empty` 如实跳过,也不编一条指不到人的引用(那是会被拒读的行)。
|
|
541
|
+
*/
|
|
542
|
+
const titleSource = hints.titleSource;
|
|
543
|
+
const titleSourceKind = typeof titleSource === 'object' && titleSource !== null
|
|
544
|
+
? (titleSource as { kind?: unknown }).kind
|
|
545
|
+
: undefined;
|
|
529
546
|
if (hints.title !== '') {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
547
|
+
if (titleSourceKind === 'user') {
|
|
548
|
+
rows.unshift({
|
|
549
|
+
type: 'session/title',
|
|
550
|
+
seq: 0,
|
|
551
|
+
time: session.createdAt,
|
|
552
|
+
data: { title: hints.title, messageSeqs: [], source: hints.titleSource },
|
|
553
|
+
});
|
|
554
|
+
} else {
|
|
555
|
+
const at = rows.findIndex((row) => {
|
|
556
|
+
if (row.type !== 'user/message') return false;
|
|
557
|
+
const source = row.data['source'];
|
|
558
|
+
return typeof source === 'object' && source !== null && (source as { kind?: unknown }).kind === 'user';
|
|
559
|
+
});
|
|
560
|
+
if (at >= 0) {
|
|
561
|
+
rows.splice(at + 1, 0, {
|
|
562
|
+
type: 'session/title',
|
|
563
|
+
seq: 0,
|
|
564
|
+
// at 是「插入前」首条人类消息的 seq(未插入时行序即 seq),引用它必然早于本行
|
|
565
|
+
data: { title: hints.title, messageSeqs: [at], source: hints.titleSource },
|
|
566
|
+
time: session.createdAt,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
rows.forEach((row, index) => {
|
|
571
|
+
row.seq = index;
|
|
572
|
+
});
|
|
538
573
|
}
|
|
539
574
|
|
|
540
575
|
return rows;
|
package/src/index.ts
CHANGED
|
@@ -166,7 +166,7 @@ export const name = 'config-manager'
|
|
|
166
166
|
export const inject = ['settings', 'credentials']
|
|
167
167
|
|
|
168
168
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
169
|
-
export const PLUGIN_VERSION = '0.1.
|
|
169
|
+
export const PLUGIN_VERSION = '0.1.71'
|
|
170
170
|
|
|
171
171
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
172
172
|
const PLUGIN_NAME = 'dsh-config-manager'
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
* 全量构造需要一个完整 DSH 宿主。这里只放本组用到的字段 —— 组文件在构建期只解构 env,
|
|
26
26
|
* 其余依赖都在 handler 内,所以桩足够驱动真实行为(与 route-fence 的 stubEnv 同思路)。
|
|
27
27
|
*/
|
|
28
|
-
async function stubEnv(): Promise<Record<string, unknown>> {
|
|
28
|
+
async function stubEnv(sessionFormatVersion?: number): Promise<Record<string, unknown>> {
|
|
29
29
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dcm-foreign-route-'));
|
|
30
30
|
return {
|
|
31
31
|
tmpDir,
|
|
@@ -42,7 +42,8 @@ async function stubEnv(): Promise<Record<string, unknown>> {
|
|
|
42
42
|
host: {
|
|
43
43
|
homeDir: path.join(tmpDir, 'dsh-home-NOT-user-home'),
|
|
44
44
|
dshVersion: 'test',
|
|
45
|
-
|
|
45
|
+
// 缺省 = 读不到目标版本(内核据此逐条报 session-format-version-unknown,绝不猜)
|
|
46
|
+
sessionFormatVersion,
|
|
46
47
|
},
|
|
47
48
|
};
|
|
48
49
|
}
|
|
@@ -246,9 +247,9 @@ function importRoute(env: Record<string, unknown>) {
|
|
|
246
247
|
*/
|
|
247
248
|
async function callImport(
|
|
248
249
|
payload: unknown,
|
|
249
|
-
opts: { userHome?: string } = {},
|
|
250
|
+
opts: { userHome?: string; sessionFormatVersion?: number } = {},
|
|
250
251
|
): Promise<{ status: number; body: ForeignImportBody; raw: string }> {
|
|
251
|
-
const env = await stubEnv()
|
|
252
|
+
const env = await stubEnv(opts.sessionFormatVersion)
|
|
252
253
|
const prevProfile = process.env['USERPROFILE']
|
|
253
254
|
const prevHome = process.env['HOME']
|
|
254
255
|
if (opts.userHome !== undefined) {
|
|
@@ -383,3 +384,60 @@ test('foreign-import:真实 fixture 产包 → 分区非空、路径受控、
|
|
|
383
384
|
await fs.rm(userHome, { recursive: true, force: true })
|
|
384
385
|
}
|
|
385
386
|
})
|
|
387
|
+
|
|
388
|
+
/** 在受控 userHome 下造一条最小 Claude Code 会话(cwd 写在记录里,DSH 按它归位目录键)。 */
|
|
389
|
+
async function writeClaudeSession(userHome: string, id: string, cwd: string): Promise<void> {
|
|
390
|
+
const dir = path.join(userHome, '.claude', 'projects', 'D--proj-app')
|
|
391
|
+
await fs.mkdir(dir, { recursive: true })
|
|
392
|
+
const lines = [
|
|
393
|
+
JSON.stringify({
|
|
394
|
+
type: 'user',
|
|
395
|
+
uuid: 'aaaaaaaa-0000-4000-8000-000000000001',
|
|
396
|
+
timestamp: '2026-10-01T10:00:00.000Z',
|
|
397
|
+
cwd,
|
|
398
|
+
message: { role: 'user', content: '帮我看看这个 bug' },
|
|
399
|
+
}),
|
|
400
|
+
JSON.stringify({
|
|
401
|
+
type: 'assistant',
|
|
402
|
+
uuid: 'aaaaaaaa-0000-4000-8000-000000000002',
|
|
403
|
+
timestamp: '2026-10-01T10:00:05.000Z',
|
|
404
|
+
cwd,
|
|
405
|
+
message: { model: 'claude-sonnet-4', content: [{ type: 'text', text: '修好了' }] },
|
|
406
|
+
}),
|
|
407
|
+
]
|
|
408
|
+
await fs.writeFile(path.join(dir, id + '.jsonl'), lines.join('\n') + '\n', 'utf8')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
test('foreign-import:目标机是 v4 时必须照 v4 产出会话(2026-10-08 桌面端整批被跳过的回归护栏)', async () => {
|
|
412
|
+
/**
|
|
413
|
+
* 真机事故:DSH 桌面端(0.2.0-rc.2)的会话格式是 v4,而转码器只认 v3 ⇒ 每一条会话都被
|
|
414
|
+
* `session-format-unsupported` 跳过、产出的包里**没有 sessions 分区**,用户看到「历史对话不见了」。
|
|
415
|
+
* 本用例把「宿主给的是 v4 就照 v4 产出」钉在**路由层**(GUI 走的正是这条端点)。
|
|
416
|
+
*/
|
|
417
|
+
const userHome = await fs.mkdtemp(path.join(os.tmpdir(), 'dcm-foreign-v4-'))
|
|
418
|
+
try {
|
|
419
|
+
await writeClaudeSession(userHome, '11111111-1111-4111-8111-111111111111', 'D:/proj/app')
|
|
420
|
+
|
|
421
|
+
const v4 = await callImport({ source: 'claude-code' }, { userHome, sessionFormatVersion: 4 })
|
|
422
|
+
assert.equal(v4.status, 200, 'v4 目标下必须产包:' + v4.raw)
|
|
423
|
+
assert.ok((v4.body.sections ?? []).includes('sessions'), 'v4 目标必须产出 sessions 分区:' + v4.raw)
|
|
424
|
+
assert.equal(v4.body.counts?.['sessions.transcoded'], 1)
|
|
425
|
+
assert.equal(
|
|
426
|
+
(v4.body.skipped ?? []).some((s) => s.code === 'session-format-unsupported'),
|
|
427
|
+
false,
|
|
428
|
+
'不得再整批报 session-format-unsupported',
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
// 读不到目标版本时**不猜**:内核如实报码、一个会话都不转 → 整包没有分区(400 nothing-to-import)。
|
|
432
|
+
// 这是「绝不猜版本」的既有语义,与上面那条并排钉住,避免修复被误读成「以后可以直接硬写 v4」。
|
|
433
|
+
const unknown = await callImport({ source: 'claude-code' }, { userHome })
|
|
434
|
+
assert.equal(unknown.status, 400, unknown.raw)
|
|
435
|
+
assert.equal(unknown.body.code, 'nothing-to-import')
|
|
436
|
+
assert.ok(
|
|
437
|
+
(unknown.body.skipped ?? []).some((s) => s.code === 'session-format-version-unknown'),
|
|
438
|
+
'读不到版本必须如实报码:' + unknown.raw,
|
|
439
|
+
)
|
|
440
|
+
} finally {
|
|
441
|
+
await fs.rm(userHome, { recursive: true, force: true })
|
|
442
|
+
}
|
|
443
|
+
})
|