# @zoytown/dsh-replay

[English](README.md) | 中文

一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件：把一场会话导出成**一个自包含的 HTML 文件** —— 完整的对话记录，工具卡片保真还原，并能按会话当时真实的节奏回放。

harness 自带的 `/export` 下载的是原始日志 ZIP，那是给机器看的。这个是给人看的。

![一份导出的 dsh 会话，以独立 HTML 页面打开：标题「Fix RangeError on unterminated strings」，右上是回放按钮、进度条与倍速选择；下方是轮次、工具调用次数、时长与输出 token 的概览数字；再往下是记录正文——用户提问、带可折叠推理块的助手回复，以及一张 bash 卡片，展示复现出的调用栈与非零退出码](assets/artifact.webp)

## 它能做什么

- **单个文件，零依赖。** CSS、JavaScript、图片全部内联。双击就能打开，能塞进 IM，能扔上任意静态托管；离线可用，不需要装任何东西。
- **真实回放。** 每条会话事件都带毫秒时间戳，逐个流式 chunk 也不例外，所以回放还原的是输出当时真正的节奏。超过 `gapCeilingMs` 的停顿会被压缩，回放不会卡在思考时间上；其中较长的那些（超过上限 4 倍）会在正文里标出真实时长。
- **先可读，再可放。** 页面打开就是一份完整记录：可搜索、可 `Ctrl+F`。回放是一个按钮，不是一道门槛。（正文由页面自带脚本从内嵌 JSON 渲染；不执行 JavaScript 的阅读器仍能拿到标题、时间与概览数字，但拿不到对话正文。）
- **工具卡片保真。** 每张卡片都由工具自己的 presenter 生成 —— 终端输出与退出码、行内 diff、搜索命中、文件读取、web 结果 —— 并且在会话当时真正运行的那个注册表 scope 下解析。
- **子 agent 内联。** 被委派的会话作为可折叠块出现在主时间线里，带着它自己的工具调用。
- **脱敏带预览。** 凭据与家目录路径默认遮蔽；预览会列出每一处命中，误判可以在写盘前逐条取消勾选。
- **浅色 / 深色 / 跟随系统**三态主题，响应式一直适配到手机宽度，并尊重 `prefers-reduced-motion`。

## 安装

```bash
dsh plugin --profile web add @zoytown/dsh-replay
```

然后重启 `dsh web`。设置面板左侧一级导航会出现「**会话回放**」入口。

卸载：

```bash
dsh plugin --profile web remove @zoytown/dsh-replay
```

## 使用

**从设置页。** 打开 设置 → 会话回放，选一场会话，检查脱敏预览，选择 HTML 或 Markdown，点导出。写入路径会显示出来，旁边有复制按钮。

![dsh 设置面板中的「会话回放」页：左侧一级导航里，「会话回放」与「通用设置 / 模型 / 插件 / Agent 预设」并列；中间是会话列表，右侧是 HTML / Markdown 格式选择、「包含子 agent 会话」勾选框、会话概览，以及列出六条命中凭据的脱敏预览——每条都带勾选框，导出前可以放过误判](assets/settings-replay.webp)

**从输入框。** `/share` 导出当前会话：

```
/share            → HTML
/share markdown   → Markdown
```

**从模型。** 插件注册了一个 `session_export` 工具，你可以直接让 agent「把刚才这段导出来」。它只写本地文件并返回路径 —— 不上传、不外发，也不能自己选择目标路径；而且这条路径上**脱敏恒定开启**（无视 `redaction.enabled`），因为没有人看过预览。

文件落在 `$DSH_HOME/replay/`（默认 `~/.dsh/replay/`），命名为 `<会话标题>-<UTC 时间戳>.<扩展名>`。

## 配置

所有对外的可调项都是 `cordis.yml` 字段。少数内部上限是固定的（工具结果 2 万字符、子 agent 嵌套 3 层、时长标记的 4 倍阈值），已列在「已知限制」里。

```yaml
- id: dsh-replay
  name: '@zoytown/dsh-replay'
  config:
    gapCeilingMs: 1200        # 回放时原样保留的最长停顿
    includeSubagents: true    # 默认内联被委派的子会话
    redaction:
      enabled: true
      rules: []               # 追加的正则规则
```

| 字段 | 默认值 | 含义 |
|---|---|---|
| `gapCeilingMs` | `1200` | 超过这个长度的停顿在回放时被压缩。超过它 4 倍的停顿还会在正文里显示一条 `N later` 标记，长间隔不会被无声吞掉。 |
| `includeSubagents` | `true` | 把子 agent 会话作为可折叠块内联。 |
| `redaction.enabled` | `true` | 是否扫描人工触发的导出。`session_export` 工具无视此开关，恒定脱敏。 |
| `redaction.rules` | `[]` | 追加到内置规则之后的正则来源。非法正则会让插件加载直接失败，而不是静默地不生效。 |
| `dshHome` | `$DSH_HOME` | 覆盖 `replay/` 目录所在的 home。 |

### 默认会遮蔽什么

家目录路径（缩写为 `~`），以及匹配以下形态的值：OpenAI 风格 key、GitHub token、AWS access key id、Slack token、Google API key、`Bearer` token、JWT、PEM 私钥整块，以及变量名含 `PASSWORD` / `SECRET` / `TOKEN` / `API_KEY` / `ACCESS_KEY` / `PRIVATE_KEY` 的赋值右值。

规则有意偏激进：误伤在预览里点一下就能撤销，而漏放的凭据已经发出去了。**脱敏是安全网，不是保证 —— 把记录发给任何人之前，请先读一遍预览。**

## 隐私

这个插件不向任何地方发送数据。它通过 `ctx.sessionQuery` 读会话、渲染文件、写到你自己的磁盘。没有遥测、没有上传通道、没有任何形式的网络访问 —— 导出的页面本身也是零外部请求，断网打开即可自行验证。

它对会话严格只读：从不追加事件，也不碰 agent loop。

## 环境要求

- Node `^22.19 || >=24`
- profile 需挂载 `@deepseek-ai/dsh-base`（它提供 `ctx.sessionQuery`）。自带的 `web` profile 满足条件。

设置页、`/share` 命令、`session_export` 工具三者各自独立挂载：没有设置面板的组合仍然有命令，headless 组合仍然有工具。

## 开发

```bash
pnpm install
pnpm run typecheck
pnpm run build
```

用 `--patch` overlay 挂载本地源码。任意位置写一个文件（里面的路径必须是绝对路径）：

```yaml
- insert:
    - id: dsh-replay-dev
      name: '/absolute/path/to/deepseek-harness-replay/src/index.ts'
      config:
        gapCeilingMs: 500
```

然后带上它启动：

```bash
dsh web --patch /absolute/path/to/that-overlay.yml
```

查看组合后的完整配置树（含每一行来自哪一层）：

```bash
dsh --profile web --dump-config
```

## 常见问题

### 导出的文件会联网或回传吗？

不会。页面**零外部请求** —— 没有 CDN、没有字体下载、没有统计。样式、脚本、图片在导出时就已内联，所以在一台没有网络、也没装 dsh 的机器上渲染结果完全一致。关掉 Wi-Fi 打开它即可自行验证。

### 导出的文件可以放心分享吗？

先读脱敏预览。内置规则会遮蔽家目录路径和常见凭据格式，但那是模式匹配，不是保证：格式特殊的密钥、或者敏感的业务内容，都会原样通过。请把导出文件当作一段终端录屏来对待。

### 回放是真实节奏还是模拟的？

真实的。每条会话事件（包括逐个流式 chunk）在日志里都带毫秒时间戳，所以回放还原的是当时真正的输出节奏。唯一一处有意偏离：超过 `gapCeilingMs`（默认 1200 毫秒）的停顿会被压缩，免得回放卡在人的思考时间上。其中超过上限 4 倍的（默认 4.8 秒）会额外打出一条 `N later` 标记显示真实时长；更短的压缩不打标记 —— 否则几乎每条消息之间都会多出一行。

### 它和内置的 `/export` 有什么区别？

`/export` 下载的是原始会话日志 ZIP —— JSONL、附件、子会话 —— 面向工具链和排障。`/share` 产出的是给人读的文档。两者互补，都仍然可用。

### 导出的文件放在哪？能改吗？

`$DSH_HOME/replay/`（默认 `~/.dsh/replay/`）。目录是固定的，文件名由会话标题加 UTC 时间戳推导而来；**任何调用方都不能选择路径** —— 设置页不能，模型也不能。要整体换位置，用配置项 `dshHome`。

### 模型会自己导出会话吗？

只有你让它做的时候才会。`session_export` 工具只写本地文件并返回路径；它没有上传通道、没有目标路径参数，而且这条路径上恒定脱敏（无视 `redaction.enabled`）。目前没有「保留设置页与 `/share`、单独去掉这个工具」的开关 —— 三者一起挂载；不过它发不出任何东西，最坏情况也只是往你自己的磁盘写一个已脱敏的文件。

### 装插件之前录下的会话还能导出吗？

能。一切都从持久化的会话日志重建，所以只要 harness 还存着，几个月前结束的会话同样可以导出。

## 已知限制

- **Markdown 是降级，不是第二套渲染器。** 它承载不了回放与交互卡片；终端输出与 diff 会退化成围栏代码块，子 agent 退化成嵌套引用。在意保真就用 HTML。
- **卡片的上限取决于工具自己的 presenter。** 没声明 presenter 的工具会渲染成它面向模型的结果文本。这是有意的优雅降级，不是 bug。
- **图片需要 attachment 服务。** 没挂载它的组合里，图片块渲染成带标注的占位符而不是图片。
- **子 agent 嵌套上限 3 层**（递归护栏）；更深的委派只显示工具调用，不内联记录。
- **超大会话会产生大文件。** 每条工具调用的**面向模型结果文本**上限 2 万字符，超出会截断并标注丢了多少；但工具自己的卡片内容（终端输出、文件读取、diff）是完整嵌入的，只在屏幕上截断显示，所以大量文件读取的会话仍可能达到几 MB。
- **代码与 diff 块暂无语法高亮** —— 目前是等宽字体加增删配色，没有做词法着色。
- **三个上限是固定的，不可配置**：面向模型的结果文本单条 2 万字符、子 agent 内联嵌套 3 层、停顿要超过 `gapCeilingMs` 的 4 倍才会显示时长标记。

## 明确不支持

- **不做上传、托管、分享链接。** 插件写一个本地文件就结束了。没有云端、没有账号、没有分享 URL。
- **不做遥测。** 无论怎么配置，都不会向任何地方上报。
- **不做编辑或重跑会话。** 这是对历史的只读视图；它从不追加事件，也不碰 agent loop。
- **不做按轮次区间导出。** 一次导出覆盖整场会话（可选择排除子 agent），不支持只导第 N 到第 M 轮。
- **不出 PDF。** 用浏览器对 HTML 打印成 PDF 即可；样式表带打印模式，会隐藏回放控件并避免卡片被跨页切开。

---

*本文事实核验于 DeepSeek Harness `0.1.0-rc.7`，日期 2026-08-19。harness 处于开发者预览期且明确会有破坏性变更；如果升级 harness 后卡片变成了通用样式，先查这一条。*

## 许可

MIT
