跳过主要内容
V

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.

安装

dsh plugin --profile web add github:vi0let-dev/dsh-session-md-export

README

dsh-session-md-export — 把 DSH 会话导出成 Markdown

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
```

三条渲染规则值得说明:

  1. 助手正文按 Markdown 原样输出,它自己的标题、列表、代码块都保留;
  2. 工具参数与工具输出一律加围栏——它们是任意文本,常常自带三个反引号。围栏长度会自动比内容里 最长的一段反引号多一个,所以内容永远撑不破围栏、不会污染后文;
  3. 附件不会被静默丢掉:图片 / 文件会渲染成带 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"。

装完没出现入口?

  1. 确认插件启用后重启了 DSH,并按 Ctrl+Shift+R 硬刷新过;
  2. 确认装进了你正在用的那个 profile(插件页顶部会显示它管理的 profile);
  3. 打开 设置,看导航里有没有 Markdown 导出;
  4. 还不行就看 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/* 只是一条读路由,但它能读到全部对话历史,所以三道闸:

  1. 回环对端:非回环连接直接 403;
  2. 回环 Host:挡 DNS rebinding——攻击者把域名解析到 127.0.0.1 后,那个请求对他自己的页面 来说是同源的,标志头也不会触发预检,此时 Host 不是回环字面量,因此被拒;
  3. 标志头 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 的示例输出也是编的。自测守这件事:

    1. 仓库里不许出现当前会话库里真实存在的 session id;
    2. 不许出现真实账号名形式的路径(占位符如 C:\Users\x 允许);
    3. src/ 的 logger 调用不许插值标题或文件名;
    4. verify-live.mjs / verify-bundle.mjs 必须默认打码(见「自测」一节);
    5. safeMessage 必须真的把路径压掉,且路由的 error 体里不许出现盘符路径;
    6. 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.jsHost 入口:挂载路由。inject 为空,webServer 按需解析,缺了只是没有界面
src/zstd.js拼接帧扫描与解码,含尾部残缺恢复
src/sessions.js会话发现、日志读取、投影缓存与工作区账本
src/transcript.js事件流 → 轮次 + 可选条目
src/markdown.jsMarkdown 渲染(front matter、元信息、围栏加宽、合并)
src/zip.jsZIP 写入与回读校验(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

相关插件