@240xu/dsh-message-ops 0.2.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/LICENSE +21 -0
- package/README.md +132 -0
- package/cordis.patch.yml +5 -0
- package/package.json +51 -0
- package/src/branch.js +54 -0
- package/src/client.js +560 -0
- package/src/index.js +436 -0
- package/src/ops-core.js +269 -0
- package/src/session-file.js +230 -0
- package/test/fence.test.js +208 -0
- package/test/ops-core.test.js +216 -0
- package/test/session-file.test.js +110 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 240xu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# @240xu/dsh-message-ops
|
|
2
|
+
|
|
3
|
+
DSH web 插件:**消息回滚 + 消息删除 + 消息分支** 三合一。在会话头部添加分支图标按钮,
|
|
4
|
+
侧栏会话行 "..." 菜单注入「消息操作」项,打开统一操作对话框:
|
|
5
|
+
|
|
6
|
+
- **回滚(Revert)**:遮蔽所选消息及其后的全部可见内容(DSH 原生 surface replace 语义)。
|
|
7
|
+
日志 append-only,原事件完整保留,语境可通过重新发送恢复。
|
|
8
|
+
- **删除(Delete)**:仅遮蔽所选的那一条消息,其余内容不变(同样是 surface replace)。
|
|
9
|
+
- **分支(Branch)**:把所选消息(含)之前的全部事件复制为一个**新会话**,
|
|
10
|
+
新会话 header 携带 `parentSession=<原会话 id>`,原会话一个字节都不动 —— 唯一的非破坏操作。
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
零 npm 依赖(只用 `node:fs` / `node:zlib` / `node:path` / `node:url`;zstd 压缩走
|
|
15
|
+
Node 内建 `zlib.zstdCompressSync`,需 **Node ≥ 23.5**,Termux 与 Windows 通用)。
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
# Termux / Linux
|
|
19
|
+
dsh plugin --profile web add @240xu/dsh-message-ops
|
|
20
|
+
|
|
21
|
+
# Windows
|
|
22
|
+
dsh plugin --profile web add @240xu/dsh-message-ops
|
|
23
|
+
# 或本地目录:
|
|
24
|
+
dsh plugin --profile web add file:C:/path/to/dsh-message-ops
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
重启 DSH web 实例后生效。
|
|
28
|
+
|
|
29
|
+
## 安全设计
|
|
30
|
+
|
|
31
|
+
- 回滚/删除不重写日志:通过一条承载 `surfaceOp: replace` 的 system 事件实现,
|
|
32
|
+
约束由 DSH 引擎 `assertProvenance` 校验,违规如实上报(HTTP 409)。
|
|
33
|
+
- 回滚/删除前必须勾选风险确认;运行中的会话一律拒绝(提示先停止)。
|
|
34
|
+
- 分支先写临时文件再原子 rename,半写日志不会被会话扫描读到。
|
|
35
|
+
|
|
36
|
+
## 端点
|
|
37
|
+
|
|
38
|
+
| 方法 | 路径 | 说明 |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| GET | `/api/message-ops/messages?sessionId=<id>` | 消息级列表(磁盘真相 + 可见性标注 + running 状态) |
|
|
41
|
+
| POST | `/api/message-ops/revert` | `{sessionId, seq}` 回滚到该条(含) |
|
|
42
|
+
| POST | `/api/message-ops/delete` | `{sessionId, seq}` 仅遮蔽该条 |
|
|
43
|
+
| POST | `/api/message-ops/branch` | `{sessionId, upToSeq}` 分支新会话,返回 `{newId, dir, keptEvents}` |
|
|
44
|
+
| GET | `/api/message-ops/export?sessionId=<id>&seq=<可选>` | 导出 Markdown(seq ≤ 上界,缺省全部),附件下载 |
|
|
45
|
+
| POST | `/api/message-ops/restore` | `{sessionId, seq}` 回滚恢复:seq 为某次 revert/delete 标记事件的 seq |
|
|
46
|
+
|
|
47
|
+
## 0.2.2 前端收尾(评审 M1/M2)
|
|
48
|
+
|
|
49
|
+
- **M2 观察范围收窄**:侧栏行菜单注入的 `MutationObserver` 仍观察
|
|
50
|
+
`document.body`(菜单由宿主 React 动态渲染,安装期无稳定锚点),但回调改为
|
|
51
|
+
精确过滤——只有新增节点本身是(或包含)`[role=menu]` 时才调度探测;聊天流
|
|
52
|
+
渲染、流式 chunk 等海量 mutation 零探测成本;同帧多次命中合并为一次探测。
|
|
53
|
+
- **M1 分批渲染**:消息列表先渲 50 条,顶部「显示更多(剩余 N 条)」按钮每次
|
|
54
|
+
渐进展开 50 条(上限仍为最近 200 条),避免 200 行单选列表一次性进 DOM;
|
|
55
|
+
打开新会话时分页重置。
|
|
56
|
+
|
|
57
|
+
## 0.2.1 评审修复(架构评审 arch-review)
|
|
58
|
+
|
|
59
|
+
- **P0 信任围栏**:全部 6 条 `/api/message-ops/*` 路由接入三层信任判定
|
|
60
|
+
(回环 Host 挡 DNS rebinding + `sec-fetch-site: cross-site` 拒绝 + Origin
|
|
61
|
+
同源校验),非回环/跨站请求一律 403。写操作(revert/delete/branch/restore)
|
|
62
|
+
另要求 `Content-Type: application/json`(text/plain 绕预检的洞 → 415)。
|
|
63
|
+
**为何不引入一次性 CSRF token**:浏览器对所有 POST(含 text/plain)都附带
|
|
64
|
+
Origin 头,Origin 同源校验已覆盖跨站 POST;自定义头天然触发预检、与 Origin
|
|
65
|
+
校验等价——token 只增加握手复杂度而不增加安全性,围栏已足够(论证见
|
|
66
|
+
`src/ops-core.js` 的 `isJsonContentType` 注释)。
|
|
67
|
+
- **P1 写端 replace 拼写前向兼容**:`applySurfaceReplace` 写入时先按当前
|
|
68
|
+
运行时 `{startSeq,endSeq}` 形状写;若引擎报 `invalid replace surfaceOp`
|
|
69
|
+
(validateNext 在事件入 log 前抛出,无半写风险)自动降级 `{start,end}`
|
|
70
|
+
重试并记住拼写。当前与未来 dsh cohort 都不炸;读端双拼写兼容已统一收敛到
|
|
71
|
+
`session-file.readReplaceOp` 单点(computeShadowed / planRestore 共用)。
|
|
72
|
+
- **P1 大日志让出**:新增 `readSessionFileAsync`,逐帧解压每 8 帧
|
|
73
|
+
`setImmediate` 让出事件循环;messages/export/restore 路由改走该路径。
|
|
74
|
+
帧数超过 500 阈值的 export 在 Markdown 末尾追加耗时提示(partial 语义,
|
|
75
|
+
内容完整无截断)。
|
|
76
|
+
- **P2**:`readJsonBody` 加 1MB 上限(超限 413);`findSessionDirs` 多
|
|
77
|
+
project slug 命中同一 id 时显式报 409 歧义而非静默取第一个;branch 产物
|
|
78
|
+
不进会话注册表——需刷新会话列表后才可见(此为宿主扫描行为,见 branch 命令
|
|
79
|
+
返回后请刷新列表)。
|
|
80
|
+
|
|
81
|
+
## 0.2.0 新增
|
|
82
|
+
|
|
83
|
+
### Agent 工具 `message_ops`
|
|
84
|
+
|
|
85
|
+
模型侧可直接调用五操作:`action: list | revert | delete | branch | restore | export`,参数
|
|
86
|
+
`sessionId`(必填)、`seq`、`upToSeq`。工具与 HTTP 路由共用同一套核心逻辑
|
|
87
|
+
(`src/ops-core.js`),行为与错误语义完全一致;失败以文本形式返回,不中断回合。
|
|
88
|
+
|
|
89
|
+
工具注册是**容错**的:本插件 `inject` 保持为空(tools 是可选增强而非硬依赖,
|
|
90
|
+
cordis 的 inject 是硬依赖声明,缺服务会让整个插件永不加载)。apply 时探测
|
|
91
|
+
`ctx.get('tools')`,有则注册;没有则通过 `ctx.inject(['tools'], …)` 等它出现;
|
|
92
|
+
`@deepseek-ai/dsh-tools` 包不可解析时静默跳过工具,HTTP 面完全不受影响。
|
|
93
|
+
因此 `@deepseek-ai/dsh-tools` 以 peerDependencies 形式声明(`^0.1.0-rc.6`)。
|
|
94
|
+
|
|
95
|
+
### 导出 Markdown
|
|
96
|
+
|
|
97
|
+
`GET /api/message-ops/export?sessionId=<id>&seq=<可选上界(含)>`,`Content-Disposition`
|
|
98
|
+
附件下载。user/assistant/system 消息按角色小节展开(`## [seq N] role`),工具调用折叠为
|
|
99
|
+
单行引用(含 80 字符参数摘要);带 `parentSession` 的分支会话在头部标注来源。
|
|
100
|
+
|
|
101
|
+
### 回滚恢复(restore,重放语义)
|
|
102
|
+
|
|
103
|
+
`POST /api/message-ops/restore` 传 `{sessionId, seq}`,seq 指向一次 revert/delete 落定的
|
|
104
|
+
标记事件(`surfaceOp: replace`、携带 `sourceEventSeqs`)。
|
|
105
|
+
|
|
106
|
+
**引擎能力查证结论**(见 `src/ops-core.js` 头部注释):安装的运行时
|
|
107
|
+
`@deepseek-ai/dsh-session` 的 SurfaceOp 只有 `append` 与 `replace` 两个变体,
|
|
108
|
+
**不存在「解除遮蔽」操作**——surface replace 是 append-only 的永久遮蔽。因此恢复实现为
|
|
109
|
+
**重放(replay)而非解除遮蔽**:把被遮蔽区间的 user/assistant 消息文本重新 append 为新
|
|
110
|
+
事件,文本加 `[恢复]` 前缀,并先 append 一条 system 说明;tool/call 等不可安全重放的事件
|
|
111
|
+
跳过并在结果中计数。原遮蔽区间保持不可见(磁盘日志原样保留)。
|
|
112
|
+
|
|
113
|
+
**语义差异须知**:恢复出来的消息 seq 是新的、时间戳是新的、措辞带 `[恢复]` 标记,且不是
|
|
114
|
+
「回到当时」——后续上下文(中间隔着的其他对话)不会被抹掉。若想「干净地回到某点」,
|
|
115
|
+
请用 branch 从该 seq 分叉。
|
|
116
|
+
|
|
117
|
+
**兼容性备注**:运行时引擎当前 replace 形状为 `{op:'replace', startSeq, endSeq}`;
|
|
118
|
+
dsh-src 仓库较新副本已改名为 `start`/`end`。本插件写入用 `startSeq/endSeq`(对齐安装的
|
|
119
|
+
运行时),restore 读取端两种拼写都兼容,前向迁移无需改动。
|
|
120
|
+
|
|
121
|
+
### 开发
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
npm test # 20 个零依赖测试(node --test,Node 26 起目录参数已弃用,改用 glob)
|
|
125
|
+
node --check src/*.js
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
格式兼容性已对真实会话日志验证(多帧 v3 格式;旧单帧 `session.jsonl.zstd` 读取走同一帧扫描路径)。
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@240xu/dsh-message-ops",
|
|
3
|
+
"version": "0.2.2",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "240xu",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"dsh-plugin"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/240xu/dsh-message-ops.git"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src/",
|
|
17
|
+
"test/",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"main": "src/index.js",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./src/index.js",
|
|
25
|
+
"./client": "./src/client.js",
|
|
26
|
+
"./session-file": "./src/session-file.js",
|
|
27
|
+
"./branch": "./src/branch.js",
|
|
28
|
+
"./package.json": "./package.json",
|
|
29
|
+
"./ops-core": "./src/ops-core.js"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=23.5"
|
|
33
|
+
},
|
|
34
|
+
"dsh": {
|
|
35
|
+
"bundle": {
|
|
36
|
+
"patch": "./cordis.patch.yml"
|
|
37
|
+
},
|
|
38
|
+
"client": {
|
|
39
|
+
"inject": [
|
|
40
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
41
|
+
],
|
|
42
|
+
"platform": "web"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --test \"test/*.test.js\""
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/branch.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-message-ops — 消息分支(fork):把会话从某条消息处派生为新会话。
|
|
3
|
+
*
|
|
4
|
+
* 磁盘级、零破坏:读原日志 → 过滤 seq<=upToSeq 的事件 → 以新 session id
|
|
5
|
+
* 写入同 project 目录下的新会话目录。原会话日志一个字节都不动。
|
|
6
|
+
* 新 header 携带 parentSession=<原 id>,保留 cwd / agentPreset / version,
|
|
7
|
+
* 便于溯源与 UI 关联展示。分支是本插件唯一的非破坏操作,无需风险确认。
|
|
8
|
+
* @module dsh-message-ops/branch
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { readSessionFile, encodeSessionFile, newSessionId } from "./session-file.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 纯函数:从 {header, events} 规划分支产物(不碰磁盘,便于测试)。
|
|
17
|
+
* @param {{id:string, version?:number, cwd?:string, agentPreset?:string, delegationDepth?:number}} header
|
|
18
|
+
* @param {object[]} events 全部事件(含 seq)
|
|
19
|
+
* @param {number} upToSeq 保留到哪条(含)
|
|
20
|
+
* @returns {{header: object, events: object[]}} 新会话的 header 与事件
|
|
21
|
+
*/
|
|
22
|
+
export function planBranch(header, events, upToSeq) {
|
|
23
|
+
if (!header || header.type !== "session") throw new Error("invalid session header");
|
|
24
|
+
if (!Number.isSafeInteger(upToSeq) || upToSeq < 0) throw new Error("invalid upToSeq");
|
|
25
|
+
const kept = events.filter((e) => e && typeof e.seq === "number" && e.seq <= upToSeq);
|
|
26
|
+
if (kept.length === 0) throw new Error("no events at or before upToSeq");
|
|
27
|
+
const newHeader = {
|
|
28
|
+
...header,
|
|
29
|
+
id: newSessionId(),
|
|
30
|
+
createdAt: Date.now(),
|
|
31
|
+
parentSession: header.id,
|
|
32
|
+
};
|
|
33
|
+
delete newHeader.isSeeded;
|
|
34
|
+
return { header: newHeader, events: kept };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 落盘:在同 project 目录下创建 <newId>/session.v3.jsonl.zstd。
|
|
39
|
+
* @returns {{newId: string, dir: string, logPath: string, keptEvents: number}}
|
|
40
|
+
*/
|
|
41
|
+
export function applyBranch(logPath, upToSeq) {
|
|
42
|
+
const { header, events } = readSessionFile(logPath);
|
|
43
|
+
const { header: newHeader, events: kept } = planBranch(header, events, upToSeq);
|
|
44
|
+
const dir = path.dirname(path.resolve(logPath));
|
|
45
|
+
const newDir = path.join(path.dirname(dir), newHeader.id);
|
|
46
|
+
fs.mkdirSync(newDir, { recursive: true });
|
|
47
|
+
const newLogPath = path.join(newDir, "session.v3.jsonl.zstd");
|
|
48
|
+
const buf = encodeSessionFile(newHeader, kept);
|
|
49
|
+
// 原子写:先写临时名再 rename,避免半写日志被会话扫描读到。
|
|
50
|
+
const tmpPath = newLogPath + ".tmp-messageops";
|
|
51
|
+
fs.writeFileSync(tmpPath, buf);
|
|
52
|
+
fs.renameSync(tmpPath, newLogPath);
|
|
53
|
+
return { newId: newHeader.id, dir: newDir, logPath: newLogPath, keptEvents: kept.length };
|
|
54
|
+
}
|