- Startseite
- Plugins
- Sitzungen & Nachrichten
- dsh-session-md-export
dsh-session-md-export
vi0let-dev/dsh-session-md-export
Export DeepSeek Harness (dsh) sessions as readable Markdown. Pick individual messages from one session, or merge several into a single .md / split them into a ZIP.
Installation
dsh plugin --profile web add github:vi0let-dev/dsh-session-md-exportREADME
dsh-session-md-export
把 DeepSeek Harness(dsh)的会话导出成可读的 Markdown。
Harness 自带的导出(/export、会话菜单里的「下载 Session 日志」)给的是一个装着原始 JSONL 日志的 ZIP——
那是给回放和排查用的,不是给人读的。这个插件补上缺的那一半:把会话折成轮次 + 条目的对话记录,
渲染成 Markdown 文档。
| 需求 | 这个插件怎么做 |
|---|---|
| 导出成 md | 每个会话渲染成一份 Markdown:YAML front matter(机器可读)+ 标题 + 元信息表 + 目录 + 逐轮逐条正文 |
| 单会话可选内容 | 选中一个会话后进入逐条挑选:每一轮、每一条(用户 / 助手 / 工具调用 / 工具结果 / 注入上下文 / 系统提示 / 流程记录)都能单独勾选 |
| 多会话合并或分开 | 选中多个会话后由你决定:合并成一个 .md,或分开导出成 .zip(每个会话一个文件,外加一份索引 README.md) |
界面
侧栏 → 设置 → Markdown 导出。三步:
第 1 步 · 选择会话
按工作区分组的全部会话(标题、最近活动、占用大小、会话 id),可按标题 / 会话 ID / 工作目录筛选, 有全选与清空。
- 勾选一个 → 下一步是逐条挑选内容。
- 勾选多个 → 下一步是按类别统一选择,再下一步决定合并还是分开。
第 2 步 · 选择内容
单会话:每一轮一个分组,每一条一行——复选框 + 类型 + 工具名 + #seq + 字符数 + 内容预览。
另有按类别的批量开关(用户 4/4、工具调用 42/42 这种),组内也有全选。
默认只勾选「你问的」和「助手答的」——也就是问答本身:
| 类别 | 默认 | 是什么 |
|---|---|---|
| 🧑 用户 | 开 | 你提的问题 |
| 🤖 助手 | 开 | 助手给你的回答(不含思考过程与工具调用) |
| 🔧 工具调用 | 关 | 助手请求调用的工具与参数——属于「自言自语」 |
| 📤 工具结果 | 关 | 工具返回的内容,通常是日志或文件正文——属于「自言自语」 |
| 📥 注入上下文 | 关 | DSH 自动注入的运行上下文、技能目录等 |
| ⚙️ 系统提示 | 关 | 系统提示词,即助手收到的初始指令 |
| • 流程记录 | 关 | 压缩、命令等流程记录 |
思考过程另有独立开关(默认关),它也不是「助手答的」,而是助手给自己的推理。
为什么默认这么窄:三类被排除的东西——工具调用、工具结果、思考过程——都是助手为自己产生的, 不是它给你看的东西;而它们的篇幅远超对话本身。实测一个会话:1404 条里只有 443 条是问答, 默认导出 47 KB,全选则 2.3 MB。想要过程的话,上面的类别开关就在旁边,一下就能打开。
注意「工具调用」关掉不会让工具调用的内容消失:助手条目里内嵌的那份也不会再渲染, 因为在导出里工具调用本来就由它自己那一行负责——早先两个地方都渲染,导致每条命令在文件里 出现两次(实测某个参数串重复了 33 次)。打开该类别,它只出现一次,在那行自己的标题下。
多会话:按类别统一开关,应用到选中的每个会话,并显示各自的命中条数。 (跨会话逐条勾选不是真实工作流,所以这里不假装提供。)
第 3 步 · 导出方式
多会话时是两张互斥的卡片,各自写明会得到什么文件:
| 选择 | 结果 |
|---|---|
| 合并为一个文件 | 一个 .md,每个会话一个 ## 分节。适合整体阅读或全文搜索。 |
| 分开为单文件(ZIP) | 一个 .zip,里面每个会话一份 .md(01 标题.md、02 标题.md…)外加索引 README.md。 |
还有 Markdown 预览:不下载,先在弹窗里看一眼实际会生成什么。
输出长什么样
---
title: "会话标题"
session_id: "session-00000000-1111-2222-3333-444444444444"
cwd: "C:\\workspace\\my-project"
created_at: "2026-01-16 20:40:38"
exported_at: "2026-01-16 21:02:03"
exporter: "dsh-session-md-export"
session_format_version: 4
turns: "1"
items: 254
omitted_items: 1180
tool_calls: 0
tokens: "in 6643, out 12718"
---
# 会话标题
| Field | Value |
| --- | --- |
| Session | `session-00000000-1111-2222-3333-444444444444` |
| Working directory | `C:\workspace\my-project` |
| Turns | 1 |
| Items | 254 |
| Tool calls | 0 |
| Span | 2026-01-16 20:45:06 → 2026-01-16 21:02:01 (16m 56s) |
| Omitted | 1180 item(s) not in this export |
## Turn 1
*2026-01-16 20:45:06 · 16m 56s*
### 🧑 User
*2026-01-16 20:45:06*
帮我看看这个模块,然后补一份说明文档。
### 🤖 Assistant (108 out)
*2026-01-16 20:45:08*
I'll start by understanding the plugin system.
### 🤖 Assistant (250 out)
*2026-01-16 20:45:12*
Let me explore the plugin architecture.
### 🤖 Assistant (410 out)
*2026-01-16 20:45:23*
Here is what I found: the module does X, and the documentation is below.
上面这份就是默认设置的产物:只有问答。注意第三段助手消息其实是「调用了工具、然后根据结果作答」—— 默认导出里只留下它给你的那段答案,中间那些工具调用与日志不会挤进来。
把类别开关打开后,同一份导出会多出这些(每条工具调用只出现一次,不再在助手段落里重复):
### 🤖 Assistant (108 out, 160 reasoning)
*2026-01-16 20:45:08*
<details>
<summary>Reasoning</summary>
```text
The user wants me to look at the module.
```
</details>
I'll start by understanding the plugin system.
### 🔧 Tool call — `pwsh`
*2026-01-16 20:45:08*
Call id: `call_00_j4Ax36cQ3gmJLxsHVJxm1707`
```json
{
"command": "pwd",
"description": "Show working directory"
}
```
### 📤 Tool result — `pwsh`
*2026-01-16 20:45:08*
Call id: `call_00_j4Ax36cQ3gmJLxsHVJxm1707`
```text
/workspace/my-project
```
三条渲染规则值得说明:
- 助手正文按 Markdown 原样输出,它自己的标题、列表、代码块都保留;
- 工具参数与工具输出一律加围栏——它们是任意文本,常常自带三个反引号。围栏长度会自动比内容里 最长的一段反引号多一个,所以内容永远撑不破围栏、不会污染后文;
- 附件不会被静默丢掉:图片 / 文件会渲染成带 attachment id 的引用,说明「未嵌入本次导出」。
思考过程放在 <details> 折叠块里(正文外),默认关闭,可在第 2 步的「包含思考过程」打开。
一个只有工具调用、没有正文的助手回合会被整段略过,而不是留下一个空标题或占位说明——
实测某会话 648 个助手回合里有 252 个是这样,逐条写「此处省略」只会给正文加几百行噪音。
省略的总量已经在文档里说明:front matter 的 omitted_items 和元信息表的 Omitted 一行。
安装
两种方式二选一。装完都需要完全退出并重启 DSH(组合包那一行不会热加载),
再按 Ctrl+Shift+R 硬刷新页面。
本插件没有发布到 npm,所以「只填包名」(例如直接填
dsh-session-md-export)会失败—— 请用下面的 GitHub 地址或本地路径。
方式一:在界面里装(推荐)
DSH 自带插件管理器,不需要开命令行。
1. 打开插件页
侧栏 → 插件。页面分「官方」与「已安装」两组。
2. 点「添加插件」
在页面里点 添加插件,弹出对话框。输入框的标签是 包名或地址(提示文字写着 「输入插件的包名、GitHub 仓库地址或本地目录路径」)。
3. 填入 GitHub 地址
github:vi0let-dev/dsh-session-md-export
输入框下方有个可展开的 插件安装引导和示例;对话框里也说明了:包名就是本 README 里
dsh plugin add 后面那一段。这里填的是 github: 开头的仓库地址,不是裸包名。
想装本地目录(自己改代码、改完即时生效)就改填绝对路径:
D:\src\dsh-session-md-export
4. 点「安装」
按钮会依次变成 正在检查… → 正在准备安装… → 插件安装中…, 中途可以展开 查看安装详情 看 pnpm 的实时输出。装完显示 已安装。
如果卡在 GitHub:对话框会出现 无法访问 GitHub 或 连接 GitHub 超时, 并给出 改用国内镜像。注意镜像只代理 npm 源,不代理 GitHub 仓库下载—— 本插件只以 GitHub 分发,所以那条路走不通时要靠本机网络或代理访问 GitHub。
5. 点「立即启用」
安装完成后对话框会给出 立即启用。点它会把这个组合包加入 profile 并启用。 直接关掉对话框则它会保持「已安装但未启用」——那种状态下插件不会运行, 侧栏里也不会出现它的入口。
顺便确认一下 profile 的 dsh.profile.bundles 里有 dsh-session-md-export
(%USERPROFILE%\.dsh\profiles\desktop\package.json)。「立即启用」会自己写进去;
万一没写,手动补一行再重启:
{
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-session-md-export" // ← 必须有这一行,否则 Host 侧不会被加载
]
}
}
}
6. 完全退出并重启 DSH,然后硬刷新
组合包那一行只在启动时加载,所以必须重启宿主,光刷新页面没用。
重启后按 Ctrl+Shift+R。打开 设置 → Markdown 导出 应该能看到本插件的页面。
方式二:命令行
# 从 GitHub 直装(推荐:装到 profile 自己的 node_modules)
dsh plugin --profile desktop add github:vi0let-dev/dsh-session-md-export
# 锁定版本(tag 或 commit)
dsh plugin --profile desktop add github:vi0let-dev/dsh-session-md-export#v1.0.0
# 本地目录:装成软链,改代码立即生效
dsh plugin --profile desktop add "D:\src\dsh-session-md-export"
--profile 填你实际在用的那个:桌面端默认 desktop,dsh web 是 web。
装完同样确认 dsh.profile.bundles 里有这一行,然后重启 + 硬刷新。
⚠️ 两种装法的差别值得记一下:GitHub 直装是副本(换了代码要重新 add 一次才生效), 本地目录是软链(指向你的工作副本)。用软链时不要用
Remove-Item -Recurse去删 profile 里的node_modules\dsh-session-md-export——那可能顺着链接删到你项目里的东西; 要摘链接用cmd /c rmdir "<profile>\node_modules\dsh-session-md-export"。
装完没出现入口?
- 确认插件启用后重启了 DSH,并按
Ctrl+Shift+R硬刷新过; - 确认装进了你正在用的那个 profile(插件页顶部会显示它管理的 profile);
- 打开 设置,看导航里有没有 Markdown 导出;
- 还不行就看 Host 日志有没有
session-md-export: routes mounted at …—— 没有的话多半是dsh.profile.bundles里缺那一行。
环境要求
- DeepSeek Harness 0.2.0-rc.2 桌面端或
dsh web。 - Node.js
^22.19.0 || >=24.0.0(随 DSH 自带的即可)。 会话日志是 Zstandard 压缩的,node:zlib的 zstd 支持是硬需求;缺了插件仍会加载, 但界面会明确告警、读不了日志。 - 无构建步骤、无第三方依赖:ZIP 是自己按格式写的(
node:zlib之上),client.js是手写的 ModuleLoader bundle,只用外壳本来就提供的react。 这样从 GitHub 直装不会撞上 pnpm 的allowBuilds构建授权拦截。
会话日志是怎么读的
这一步有个坑,值得写下来,因为朴素实现会读到空会话却毫无报错。
session.v4.jsonl.zstd 不是一个 zstd 流,而是一个拼接帧容器:持久化后端把每一批持久事件
各压成一个独立可解码、带校验和的帧,这样追加写入和尾部恢复都不必重读整个文件。
于是 zstdDecompressSync(整个文件) 只会解出第一帧——而第一帧恰好就是那一行 header。
不明就里的人会以为这个会话是空的。
所以帧必须先按结构定位、再逐帧解码。src/zstd.js 里的扫描器按帧头与块头走字节,
不做解压(因此定位很便宜),校验和交给解码器。这与后端自己的 scanZstdFrames 一致。
尾部残缺的帧是正常状态(日志正在被追加),会用 ZSTD_e_flush 尽量抢救出已写入的部分。
会话内容是直接读磁盘的:$DSH_HOME/sessions/<组>/<id>/session.v<N>.jsonl[.zstd]。
ctx.sessionQuery 更"正统",但它只在挂了查询后端时才存在——一个在最小组合里会静默失效的导出工具,
比一个自己读格式的工具更糟。标题与 cwd 取自投影缓存 storages/session_projcache/(侧栏同源,
不必为了一个名字去解压日志)。工作区账本只读不写。
一些行为细节
- 被替换(模型侧重写)的事件不入导出。带
surfaceOp: { op: 'replace' }的事件是模型侧的重写,dsh-session明确说过折叠后的表层「不是人类转录的正确来源——落地的替换会抹掉用户已经看过的对话」。 所以它们被排除,并在界面上报出条数。 - 显式空选择会被尊重。不传
items表示「用默认」;传空数组表示「什么都不要」—— 两者是不同的意图,混为一谈会让用户刚清空的勾选又冒出来。空选择导出会得到一份写明 「未选择任何内容」的文档,而不是一份看起来正常其实没内容的文件。 - 会话 id 先过正则再拼路径,扫不到日志的会话(账本里有登记、磁盘上没有)不会出现在列表里—— 它没有内容可导。
- 导出是纯读操作:不会写任何会话、账本或项目文件。
- 多会话合并时每个会话仍是独立分节(
## 1. 标题),所以合并后的文件还能按标题重新拆开。
安全边界
/api/dsh-session-md-export/* 只是一条读路由,但它能读到全部对话历史,所以三道闸:
- 回环对端:非回环连接直接 403;
- 回环
Host:挡 DNS rebinding——攻击者把域名解析到 127.0.0.1 后,那个请求对他自己的页面 来说是同源的,标志头也不会触发预检,此时Host不是回环字面量,因此被拒; - 标志头
x-dsh-session-md-export: 1:跨站请求想带自定义头必须先发 CORS 预检, 而本服务从不回应预检。
关于下载方式的选择:没有用「POST 一个隐藏表单到 iframe」那条路(它能让浏览器直接把响应流到磁盘),
因为表单 POST 无法设置自定义头,那样就得给 /export 单独开一个豁免——而那正是标志头要堵的洞
(否则任意网页都能 POST 到 127.0.0.1 触发一次历史下载)。导出是文本,多一次内存拷贝不值得换这个洞。
隐私
导出内容当然是你自己的对话——那是功能本身。这一节说的是「别的地方」:
-
零网络:Host 侧不联网,没有遥测、没有更新检查、没有上报;浏览器侧只
fetch自己的同源路由。 -
不落盘:插件是纯读的,导出走 HTTP 响应交给浏览器,不写任何文件(会话、账本、项目目录都不碰)。 仓库里有一条自测专门盯着这点:
src/里出现任何写 API 都会让测试失败。 -
不写日志正文:Host 只记
模式 + 会话数 + 条目数 + 字节数。 刻意不记导出文件名——文件名由会话标题派生,而标题常常就是首条 prompt 的原文, 可能带路径、人名或文档标题。 -
账号名不进日志、不进界面:
$DSH_HOME是C:\Users\<账号>\.dsh,账号名就写在里面。 这个路径本身有用(能回答「在读哪个 home」,多 profile 时尤其),所以保留结构、只抹掉人: 日志和「设置」页脚显示的都是~\.dsh形式。界面会被截图,这一条是有意为之。 -
报错里没有绝对路径:Node 自己的文件系统错误会把完整路径拼进 message (
ENOENT: … open 'C:\Users\<账号>\.dsh\sessions\…'),而这条 message 会作为 JSON 的error字段回给浏览器。所以所有对外错误都过一遍safeMessage:绝对路径压成<path>\<文件名>,errno和文件名这些真正有诊断价值的部分保留。 -
自测与文档不含真实数据:夹具全部是合成的(
session-aaaaaaaa-…、C:\workspace\…、会话甲),README 的示例输出也是编的。自测守这件事:- 仓库里不许出现当前会话库里真实存在的 session id;
- 不许出现真实账号名形式的路径(占位符如
C:\Users\x允许); src/的 logger 调用不许插值标题或文件名;verify-live.mjs/verify-bundle.mjs必须默认打码(见「自测」一节);safeMessage必须真的把路径压掉,且路由的error体里不许出现盘符路径;src/不许出现写 API。
第 1 条是动态的:它读当前
$DSH_HOME,所以开发时跑一次真实导出、再把 id 粘进测试, 会立刻被拦下。没有真实会话库时(CI、新机器)退化成形状检查。
仓库里没有 .git,所以不存在需要清理的历史提交。
已经落在磁盘上的痕迹:跑过 verify-live.mjs 的话,系统临时目录里会留下真实会话的导出产物
(脚本结束时会提醒)。删掉那个目录即可,它默认不落在插件目录里,也不会被提交。
自测
node test/smoke.mjs
每个用例都在临时目录里搭一个假的 DSH_HOME,所以随时可跑,不会碰真实会话库,
DSH 开着也没关系。夹具日志用与真实后端相同的拼接帧容器写(一帧一批),
因为一个「单个 zstd 流」的夹具会在真机上失败却测试通过。
覆盖五层:
- zstd / sessions —— 多帧扫描、尾部残缺恢复、帧损坏报错不静默、账本残留不入列表;
- transcript / markdown —— 折叠规则、默认选择、替换事件排除、围栏加宽、 围栏配平、合并文档标题不重复、空选择有话说;
- zip —— 中央目录回读校验 CRC、名字消毒(
../../etc/passwd不会逃出归档)、重名去重; - routes —— 三道闸在真实 HTTP 服务器上跑一遍,并验证被拒的请求确实没有副作用;
- privacy —— 见上一节那六条:无真实 session id、无真实账号路径、无正文日志、无绝对路径报错、
验证脚本默认打码、
src/无写 API。
另有两个手动验证脚本,故意不进 npm test,因为它们会读真实会话库:
# 起一个临时回环服务器,把每条路由和两种导出都跑一遍,产物写到临时目录
node test/verify-live.mjs
# 确认运行中的 DSH 真的在提供本插件的浏览器半身(需要 DSH 正在运行)
node test/verify-bundle.mjs
verify-live.mjs 默认把用户数据全部打码:会话标题、会话 id、内容预览、导出文件名一律只显示
<N chars>,正文样本也不打印。要看真实内容得显式加 --reveal:
node test/verify-live.mjs --reveal # 打印真实标题/id/预览与正文样本
node test/verify-live.mjs --quiet # 连结构性摘要也省掉
打码为什么连前几个字符都不留:这些字符串经常是中文,两个字就能带出完整词义
(制作… 已经说明了「在做某个东西」);而 C:\Users\<账号>\.dsh 这种路径,账号名在前两段里。
只报长度就足以证明「字段读到了、非空、接对了」,既够用又不泄露。
输出目录默认落在系统临时目录,不是插件目录——否则跑一次就会把真实会话导出留在仓库里。
verify-bundle.mjs 值得说明一下:它按 dsh-client-modules 自己的算法(sha1 拼 size/mtime/ctime)
算出当前 revision,再请求 /plugins/??<包名>/client.js&rev=<rev>。这个 combo URL 形状不是猜的——
写成 /plugins/<包名>/client.js 会 404,连已经跑得好好的 dsh-session-trash 也一起 404,
所以「404」本身并不能说明插件有问题。脚本会在两种情况下都打印对照结果。
卸载
界面里:侧栏 → 插件 → 已安装里找到 dsh-session-md-export → 卸载。
命令行:
dsh plugin --profile desktop remove dsh-session-md-export
插件不写任何自己的持久状态,卸载后没有任何残留目录要清。
实现
| 文件 | 职责 |
|---|---|
index.js | Host 入口:挂载路由。inject 为空,webServer 按需解析,缺了只是没有界面 |
src/zstd.js | 拼接帧扫描与解码,含尾部残缺恢复 |
src/sessions.js | 会话发现、日志读取、投影缓存与工作区账本 |
src/transcript.js | 事件流 → 轮次 + 可选条目 |
src/markdown.js | Markdown 渲染(front matter、元信息、围栏加宽、合并) |
src/zip.js | ZIP 写入与回读校验(stored / deflate、UTF-8 名、CRC-32) |
src/export.js | 导出规划:合并 vs 分开,以及归档索引 |
src/routes.js | 三条同源路由 + 三道安全闸 |
client.js | 浏览器半身:三步界面、逐条挑选、预览、下载 |
依赖的 DSH 内部面:settings.section 插槽、ctx.webServer.register({ kind: 'prefix' })、 | |
ctx.inject、ctx.effect,以及磁盘上的会话日志格式与投影缓存字段。DSH 大版本升级后请至少跑一遍 | |
npm test,并实测一次单会话与多会话导出。 |
许可证
MIT
Ähnliche Plugins
dsh-web-ui (dsh-chat-recovery)
zhu1090093659/dsh-web-ui
billion-context
ranxianglei/billion-context
dsh-synapse
liangmianya/dsh-synapse
dsh-chat-import
nwflower/dsh-chat-import