@240xu/dsh-message-ops 0.2.2 → 0.2.4

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
@@ -44,6 +44,24 @@ dsh plugin --profile web add file:C:/path/to/dsh-message-ops
44
44
  | GET | `/api/message-ops/export?sessionId=<id>&seq=<可选>` | 导出 Markdown(seq ≤ 上界,缺省全部),附件下载 |
45
45
  | POST | `/api/message-ops/restore` | `{sessionId, seq}` 回滚恢复:seq 为某次 revert/delete 标记事件的 seq |
46
46
 
47
+ ## 0.2.3 格式兼容(compat-audit + 用户实测紧急修复)
48
+
49
+ - **P0 旧单帧 `session.jsonl.zstd` 读取必崩**:`readSessionFile` 旧实现把帧 0
50
+ 整段 `JSON.parse`——旧单帧格式整个文件是一帧、解压出多行 NDJSON,直接抛异常,
51
+ 与「旧格式兼容」声明矛盾。现改为**逐行扫描统一路径**:全部帧解压后按行扫描,
52
+ 首个 `type:"session"` 行作 header、其余行作事件,v3 多帧 / v4 多帧 / 旧单帧
53
+ 三种格式一条代码路径(撕裂行仍按完整前缀语义跳过)。
54
+ - **P0 v4 会话格式(用户实测 3080 服务暴露)**:DSH 已升级 v4 持久化
55
+ (`session.v4.jsonl.zstd`,结构同 v3,仅文件名与 `version` 数字不同)。
56
+ `findSessionDirs` 探测序列改为 **v4 → v3 → 旧单帧**(此前只找 v3,最新会话
57
+ 全部 "session log not found");`readSessionFile` 不做 version 硬校验;
58
+ `applyBranch` 分支写回沿用读到的原格式版本(`session.v<version>.jsonl.zstd`),
59
+ 不再写死 v3。
60
+ - **dedup**:`listMessages` 内联文本提取收敛到 `session-file.messageText`
61
+ 单点(ops-core 转出保持导入面兼容),消除双实现漂移面。
62
+ - 回归:40 项测试全绿,含旧单帧/ v4 fixture 往返、listMessages、
63
+ findSessionDirs 三格式发现与 v4 分支写回;真实 v4 会话日志已实测通过。
64
+
47
65
  ## 0.2.2 前端收尾(评审 M1/M2)
48
66
 
49
67
  - **M2 观察范围收窄**:侧栏行菜单注入的 `MutationObserver` 仍观察
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@240xu/dsh-message-ops",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Roll back, branch, and export any DSH conversation — from the UI or straight from the agent via a message_ops tool. Non-destructive: logs stay append-only and recoverable. 消息回滚/删除/分支/导出四合一,内置 message_ops agent 工具;非破坏语义,日志只追加可恢复。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,6 +46,6 @@
46
46
  "test": "node --test \"test/*.test.js\""
47
47
  },
48
48
  "peerDependencies": {
49
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
49
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.2.0-rc.1"
50
50
  }
51
51
  }
package/src/branch.js CHANGED
@@ -35,16 +35,19 @@ export function planBranch(header, events, upToSeq) {
35
35
  }
36
36
 
37
37
  /**
38
- * 落盘:在同 project 目录下创建 <newId>/session.v3.jsonl.zstd。
38
+ * 落盘:在同 project 目录下创建 <newId>/session.v<version>.jsonl.zstd。
39
+ * 写回沿用读到的原格式版本(v4/v3/旧单帧),不写死版本号。
39
40
  * @returns {{newId: string, dir: string, logPath: string, keptEvents: number}}
40
41
  */
41
42
  export function applyBranch(logPath, upToSeq) {
42
43
  const { header, events } = readSessionFile(logPath);
43
44
  const { header: newHeader, events: kept } = planBranch(header, events, upToSeq);
45
+ const version = Number(newHeader.version) === 4 ? 4 : Number(newHeader.version) === 3 ? 3 : null;
46
+ const logName = version ? `session.v${version}.jsonl.zstd` : "session.jsonl.zstd";
44
47
  const dir = path.dirname(path.resolve(logPath));
45
48
  const newDir = path.join(path.dirname(dir), newHeader.id);
46
49
  fs.mkdirSync(newDir, { recursive: true });
47
- const newLogPath = path.join(newDir, "session.v3.jsonl.zstd");
50
+ const newLogPath = path.join(newDir, logName);
48
51
  const buf = encodeSessionFile(newHeader, kept);
49
52
  // 原子写:先写临时名再 rename,避免半写日志被会话扫描读到。
50
53
  const tmpPath = newLogPath + ".tmp-messageops";
package/src/ops-core.js CHANGED
@@ -16,7 +16,11 @@
16
16
  * @module dsh-message-ops/ops-core
17
17
  */
18
18
 
19
- import { readReplaceOp } from "./session-file.js";
19
+ import { readReplaceOp, messageText } from "./session-file.js";
20
+
21
+ // messageText 的单点实现在 session-file.js(listMessages 同用);此处转出保持
22
+ // 既有导入面兼容(ops-core 的 messageText/exportMarkdown 调用方不受影响)。
23
+ export { messageText };
20
24
 
21
25
  /** 带语义状态码的操作错误(HTTP 直接映射,工具端转为失败文本)。 */
22
26
  export class OpsError extends Error {
@@ -26,17 +30,6 @@ export class OpsError extends Error {
26
30
  }
27
31
  }
28
32
 
29
- /** 从 message 事件提取首个非空 text 块(与 listMessages 同一规则)。 */
30
- export function messageText(e) {
31
- const msg = e && e.data && e.data.message;
32
- const content = msg && Array.isArray(msg.content) ? msg.content : (e && e.data && e.data.content);
33
- if (!Array.isArray(content)) return "";
34
- for (const c of content) {
35
- if (c && c.type === "text" && typeof c.text === "string" && c.text.trim()) return c.text;
36
- }
37
- return "";
38
- }
39
-
40
33
  /** planRevert:回撤语义 = 遮蔽 targetSeq 及其之后全部可见节点(连续到末尾)。 */
41
34
  export function planRevert(surface, targetSeq) {
42
35
  const nodes = surface && Array.isArray(surface.nodes) ? surface.nodes : [];
@@ -18,6 +18,17 @@ import { constants, zstdCompressSync, zstdDecompressSync } from "node:zlib";
18
18
 
19
19
  const ZSTD_MAGIC = 0xfd2fb528;
20
20
 
21
+ /** 从 message 事件提取首个非空 text 块(listMessages / ops-core 共用单点)。 */
22
+ export function messageText(e) {
23
+ const msg = e && e.data && e.data.message;
24
+ const content = msg && Array.isArray(msg.content) ? msg.content : (e && e.data && e.data.content);
25
+ if (!Array.isArray(content)) return "";
26
+ for (const c of content) {
27
+ if (c && c.type === "text" && typeof c.text === "string" && c.text.trim()) return c.text;
28
+ }
29
+ return "";
30
+ }
31
+
21
32
  /** 与 DSH 宿主一致:帧带 checksum(ZSTD_c_checksumFlag=1)。 */
22
33
  function compressFrame(input) {
23
34
  return zstdCompressSync(Buffer.from(input, "utf8"), {
@@ -49,25 +60,42 @@ export function scanZstdFrames(buf) {
49
60
  }
50
61
 
51
62
  /**
52
- * 读取会话日志 → { header, events }。撕裂的最终帧按宿主同款
53
- * 「完整前缀」语义忽略(完整帧全部恢复)。
63
+ * 逐行解帧:首个 type:"session" 行作 header,其余行作事件。
64
+ * 一条路径同时覆盖两种持久化格式(compat-audit P0):
65
+ * - v3 多帧:帧 0 = 恰一行 header,其后每帧一批事件行;
66
+ * - 旧单帧 session.jsonl.zstd:整个文件一帧,解压出多行 NDJSON,
67
+ * 首行 header、其余事件——旧实现把帧 0 整段 JSON.parse 会直接崩。
68
+ * 撕裂行(JSON.parse 失败)按宿主「完整前缀」语义跳过。
69
+ */
70
+ function collectHeaderAndEvents(text, events) {
71
+ let header = null;
72
+ for (const line of text.split("\n")) {
73
+ const t = line.trim();
74
+ if (!t) continue;
75
+ let json;
76
+ try { json = JSON.parse(t); } catch { continue; /* torn record: skip */ }
77
+ if (!header && json && typeof json === "object" && json.type === "session") { header = json; continue; }
78
+ events.push(json);
79
+ }
80
+ return header;
81
+ }
82
+
83
+ /**
84
+ * 读取会话日志 → { header, events }。两种格式(v3 多帧 / 旧单帧)同一条
85
+ * 逐行扫描路径,见 collectHeaderAndEvents。
54
86
  */
55
87
  export function readSessionFile(file) {
56
88
  const buf = fs.readFileSync(file);
57
89
  const { frames } = scanZstdFrames(buf);
58
90
  if (frames.length === 0) throw new Error("empty or header-less session log");
59
- const headerText = zstdDecompressSync(buf.subarray(frames[0].start, frames[0].end)).toString("utf8");
60
- const header = JSON.parse(headerText.trim());
61
- if (header.type !== "session") throw new Error("first frame is not a session header");
62
91
  const events = [];
63
- for (const f of frames.slice(1)) {
92
+ let header = null;
93
+ for (const f of frames) {
64
94
  const text = zstdDecompressSync(buf.subarray(f.start, f.end)).toString("utf8");
65
- for (const line of text.split("\n")) {
66
- const t = line.trim();
67
- if (!t) continue;
68
- try { events.push(JSON.parse(t)); } catch { /* torn record: skip */ }
69
- }
95
+ const found = collectHeaderAndEvents(text, events);
96
+ if (!header) header = found;
70
97
  }
98
+ if (!header) throw new Error(`session log ${file}: no {type:"session"} header line in any frame`);
71
99
  return { header, events };
72
100
  }
73
101
 
@@ -116,8 +144,14 @@ export function findSessionDirs(sessionId) {
116
144
  try {
117
145
  if (!fs.statSync(dir).isDirectory()) continue;
118
146
  } catch { continue; }
119
- const logPath = path.join(dir, "session.v3.jsonl.zstd");
120
- try { fs.statSync(logPath); } catch { continue; }
147
+ // 探测序列(用户实测 DSH 已升级 v4 格式):v4 → v3 → 旧单帧,
148
+ // 高版本优先;文件名与 header.version 对应,读取逻辑三格式同构。
149
+ let logPath = null;
150
+ for (const name of ["session.v4.jsonl.zstd", "session.v3.jsonl.zstd", "session.jsonl.zstd"]) {
151
+ const candidate = path.join(dir, name);
152
+ try { fs.statSync(candidate); logPath = candidate; break; } catch { /* probe next */ }
153
+ }
154
+ if (!logPath) continue;
121
155
  if (!found.some((f) => f.dir === dir)) found.push({ dir, logPath });
122
156
  }
123
157
  }
@@ -135,16 +169,8 @@ export function listMessages(events) {
135
169
  for (const e of events) {
136
170
  if (!e || typeof e.seq !== "number") continue;
137
171
  if (e.type === "user/message" || e.type === "assistant/message" || e.type === "system/message") {
172
+ const text = messageText(e);
138
173
  const msg = e.data && e.data.message;
139
- const content = msg && Array.isArray(msg.content) ? msg.content : (e.data && e.data.content);
140
- let text = "";
141
- if (Array.isArray(content)) {
142
- for (const c of content) {
143
- if (c && c.type === "text" && typeof c.text === "string" && c.text.trim()) {
144
- text = c.text; break;
145
- }
146
- }
147
- }
148
174
  messages.push({
149
175
  seq: e.seq,
150
176
  type: e.type,
@@ -208,23 +234,19 @@ export async function readSessionFileAsync(file, { framesPerYield = 8, frameBudg
208
234
  const buf = fs.readFileSync(file);
209
235
  const { frames } = scanZstdFrames(buf);
210
236
  if (frames.length === 0) throw new Error("empty or header-less session log");
211
- const headerText = zstdDecompressSync(buf.subarray(frames[0].start, frames[0].end)).toString("utf8");
212
- const header = JSON.parse(headerText.trim());
213
- if (header.type !== "session") throw new Error("first frame is not a session header");
214
237
  const events = [];
215
- const bodyFrames = frames.slice(1);
238
+ let header = null;
216
239
  let framesSinceYield = 0;
217
- for (const f of bodyFrames) {
240
+ for (const f of frames) {
218
241
  const text = zstdDecompressSync(buf.subarray(f.start, f.end)).toString("utf8");
219
- for (const line of text.split("\n")) {
220
- const t = line.trim();
221
- if (!t) continue;
222
- try { events.push(JSON.parse(t)); } catch { /* torn record: skip */ }
223
- }
242
+ const found = collectHeaderAndEvents(text, events);
243
+ if (!header) header = found;
224
244
  if (++framesSinceYield >= framesPerYield) {
225
245
  framesSinceYield = 0;
226
246
  await yieldToLoop();
227
247
  }
228
248
  }
229
- return { header, events, frameCount: bodyFrames.length, partial: bodyFrames.length > frameBudget };
249
+ if (!header) throw new Error(`session log ${file}: no {type:"session"} header line in any frame`);
250
+ const bodyFrames = frames.length - 1;
251
+ return { header, events, frameCount: bodyFrames, partial: bodyFrames > frameBudget };
230
252
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * dsh-message-ops — compat-audit P0 回归测试:旧单帧 session.jsonl.zstd
3
+ * 必须与 v3 多帧走同一读取路径(帧 0 是整段 NDJSON,不是单行 header)。
4
+ */
5
+ import test from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+ import { constants, zstdCompressSync } from 'node:zlib'
11
+
12
+ process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-msgops-legacy-'))
13
+
14
+ const { readSessionFile, readSessionFileAsync, encodeSessionFile, findSessionDirs, listMessages } =
15
+ await import('../src/session-file.js')
16
+ const { applyBranch } = await import('../src/branch.js')
17
+
18
+ const HEADER = { type: 'session', version: 3, id: 'session-22222222-2222-2222-2222-222222222222', createdAt: 1, cwd: '/tmp/x' }
19
+ const EVENTS = [
20
+ { type: 'user/message', seq: 0, surfaceOp: 'append', data: { message: { role: 'user', content: [{ type: 'text', text: '旧格式第一问' }] } } },
21
+ { type: 'assistant/message', seq: 1, surfaceOp: 'append', data: { message: { role: 'assistant', content: [{ type: 'text', text: '旧格式回复' }] } } },
22
+ ]
23
+
24
+ /** 旧单帧:整个文件 = 单个 zstd 帧,解压出 header 行 + 事件行 NDJSON。 */
25
+ function writeLegacySingleFrame(dir) {
26
+ fs.mkdirSync(dir, { recursive: true })
27
+ const ndjson = JSON.stringify(HEADER) + '\n' + EVENTS.map((e) => JSON.stringify(e)).join('\n') + '\n'
28
+ const frame = zstdCompressSync(Buffer.from(ndjson, 'utf8'), { params: { [constants.ZSTD_c_checksumFlag]: 1 } })
29
+ const logPath = path.join(dir, 'session.jsonl.zstd')
30
+ fs.writeFileSync(logPath, frame)
31
+ return logPath
32
+ }
33
+
34
+ test('readSessionFile:旧单帧格式往返(header + 事件完整恢复)', () => {
35
+ const p = writeLegacySingleFrame(path.join(process.env.DSH_HOME, 'sessions', 'legacy-a', HEADER.id))
36
+ const { header, events } = readSessionFile(p)
37
+ assert.equal(header.type, 'session')
38
+ assert.equal(header.id, HEADER.id)
39
+ assert.equal(events.length, 2)
40
+ assert.equal(events[0].data.message.content[0].text, '旧格式第一问')
41
+ })
42
+
43
+ test('readSessionFileAsync:旧单帧同样走逐行扫描路径', async () => {
44
+ const p = writeLegacySingleFrame(path.join(process.env.DSH_HOME, 'sessions', 'legacy-b', HEADER.id))
45
+ const { header, events, frameCount } = await readSessionFileAsync(p)
46
+ assert.equal(header.id, HEADER.id)
47
+ assert.equal(events.length, 2)
48
+ assert.equal(frameCount, 0) // 单帧格式:除承载 header+事件的帧外无其余帧
49
+ })
50
+
51
+ test('listMessages:旧单帧日志可提取消息(含 tool 摘要)', () => {
52
+ const dir = path.join(process.env.DSH_HOME, 'sessions', 'legacy-c', HEADER.id)
53
+ fs.mkdirSync(dir, { recursive: true })
54
+ const ndjson = JSON.stringify(HEADER) + '\n'
55
+ + JSON.stringify(EVENTS[0]) + '\n'
56
+ + JSON.stringify({ type: 'tool/call', seq: 1, data: { name: 'bash' } }) + '\n'
57
+ + JSON.stringify(EVENTS[1]) + '\n'
58
+ fs.writeFileSync(path.join(dir, 'session.jsonl.zstd'),
59
+ zstdCompressSync(Buffer.from(ndjson, 'utf8'), { params: { [constants.ZSTD_c_checksumFlag]: 1 } }))
60
+ const { header, events } = readSessionFile(path.join(dir, 'session.jsonl.zstd'))
61
+ assert.equal(header.id, HEADER.id)
62
+ const ms = listMessages(events)
63
+ assert.equal(ms.length, 3)
64
+ assert.equal(ms[0].snippet, '旧格式第一问')
65
+ assert.match(ms[1].snippet, /\[tool\] bash/)
66
+ })
67
+
68
+ test('findSessionDirs:旧文件名 session.jsonl.zstd 可发现(v3 缺席时)', () => {
69
+ const uniq = 'session-44444444-4444-4444-4444-444444444444'
70
+ writeLegacySingleFrame(path.join(process.env.DSH_HOME, 'sessions', 'legacy-d', uniq))
71
+ const dirs = findSessionDirs(uniq)
72
+ assert.equal(dirs.length, 1)
73
+ assert.match(dirs[0].logPath, /session\.jsonl\.zstd$/)
74
+ })
75
+
76
+ test('v3 多帧回归:新格式不受统一路径影响', () => {
77
+ const id = 'session-33333333-3333-3333-3333-333333333333'
78
+ const dir = path.join(process.env.DSH_HOME, 'sessions', 'v3-a', id)
79
+ fs.mkdirSync(dir, { recursive: true })
80
+ const p = path.join(dir, 'session.v3.jsonl.zstd')
81
+ fs.writeFileSync(p, encodeSessionFile({ ...HEADER, id }, [{ type: 'user/message', seq: 0, surfaceOp: 'append', data: { message: { role: 'user', content: [{ type: 'text', text: '多帧' }] } } }]))
82
+ const { header, events } = readSessionFile(p)
83
+ assert.equal(header.id, id)
84
+ assert.equal(events.length, 1)
85
+ // 同目录同时存在 v3 与旧格式时 v3 优先
86
+ fs.writeFileSync(path.join(dir, 'session.jsonl.zstd'),
87
+ zstdCompressSync(Buffer.from(JSON.stringify(HEADER) + '\n', 'utf8'), { params: { [constants.ZSTD_c_checksumFlag]: 1 } }))
88
+ const dirs = findSessionDirs(id)
89
+ assert.equal(dirs.length, 1)
90
+ assert.match(dirs[0].logPath, /session\.v3\.jsonl\.zstd$/)
91
+ })
92
+
93
+ // --- v4 会话格式(用户实测 DSH 已升级;结构同 v3,仅文件名与 version 数字不同) ---
94
+
95
+ const V4_HEADER = { type: 'session', version: 4, id: 'session-55555555-5555-5555-5555-555555555555', createdAt: 2, cwd: '/tmp/x' }
96
+
97
+ test('readSessionFile:v4 多帧格式往返(version 不硬校验)', () => {
98
+ const dir = path.join(process.env.DSH_HOME, 'sessions', 'v4-a', V4_HEADER.id)
99
+ fs.mkdirSync(dir, { recursive: true })
100
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl.zstd'), encodeSessionFile(V4_HEADER, [
101
+ { type: 'user/message', seq: 0, surfaceOp: 'append', data: { message: { role: 'user', content: [{ type: 'text', text: 'v4 会话第一问' }] } } },
102
+ { type: 'assistant/message', seq: 1, surfaceOp: 'append', data: { message: { role: 'assistant', content: [{ type: 'text', text: 'v4 回复' }] } } },
103
+ ]))
104
+ const { header, events } = readSessionFile(path.join(dir, 'session.v4.jsonl.zstd'))
105
+ assert.equal(header.version, 4)
106
+ assert.equal(events.length, 2)
107
+ })
108
+
109
+ test('readSessionFileAsync:v4 同样可读', async () => {
110
+ const p = path.join(process.env.DSH_HOME, 'sessions', 'v4-a', V4_HEADER.id, 'session.v4.jsonl.zstd')
111
+ const { header, events } = await readSessionFileAsync(p)
112
+ assert.equal(header.version, 4)
113
+ assert.equal(events.length, 2)
114
+ })
115
+
116
+ test('findSessionDirs:v4 文件名优先发现;同目录 v4 压过 v3/legacy', () => {
117
+ const dirs = findSessionDirs(V4_HEADER.id)
118
+ assert.equal(dirs.length, 1)
119
+ assert.match(dirs[0].logPath, /session\.v4\.jsonl\.zstd$/)
120
+ })
121
+
122
+ test('applyBranch:v4 会话分支写回 v4 文件名与 version=4', () => {
123
+ const id = 'session-66666666-6666-6666-6666-666666666666'
124
+ const dir = path.join(process.env.DSH_HOME, 'sessions', 'v4-b', id)
125
+ fs.mkdirSync(dir, { recursive: true })
126
+ const origLog = path.join(dir, 'session.v4.jsonl.zstd')
127
+ fs.writeFileSync(origLog, encodeSessionFile({ ...V4_HEADER, id }, [
128
+ { type: 'user/message', seq: 0, surfaceOp: 'append', data: { message: { role: 'user', content: [{ type: 'text', text: 'a' }] } } },
129
+ { type: 'user/message', seq: 1, surfaceOp: 'append', data: { message: { role: 'user', content: [{ type: 'text', text: 'b' }] } } },
130
+ ]))
131
+ const result = applyBranch(origLog, 0)
132
+ assert.match(result.logPath, /session\.v4\.jsonl\.zstd$/)
133
+ const { header, events } = readSessionFile(result.logPath)
134
+ assert.equal(header.version, 4)
135
+ assert.equal(header.parentSession, id)
136
+ assert.equal(events.length, 1)
137
+ })