dsh-baize-session
Verifieddsh-baize-session · v0.1.2 · MIT · Web UI
Pick messages out of DeepSeek Harness sessions and relocate them into another session's opening context (Baize).
Install
dsh plugin add dsh-baize-session Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Readme
dsh-baize-session
English | 简体中文
English:
dsh-baize-session(Baize) is a dsh plugin that moves context between conversations, and manages the conversations of a workspace (archive / restore / delete / relocate).
dsh 插件,围绕对话做两件事:
- 在对话之间搬运上下文——在你正待着的会话里挑出若干条消息,带到另一个会话:已存在的,或另一个工作区里全新开的。
- 管理工作区里的对话——在一个标签页里列出全部(含已归档),归档、还原、真正删除,以及迁移到其他工作区。
名字取自白泽——传说中通晓万物的神兽。dsh-baize-rules 注入的是要求,这个插件搬运的是上下文。

- 挑中的消息会被改写成文本,合成一条
user/message,来源标记为source.kind='plugin'、plugin='baize-session'——而不是重放事件。任意挑选的历史无法重放:会话存储要求 seed 必须从 seq 0 连续,一段不完整的历史不是合法前缀。 - 注入块的结尾是
当前工作区是 <路径>。请在此基础上继续。——明确告诉模型这段引用的历史来自别的目录,全程不做任何路径改写。 - 没有选中内容就不写入:篮子为空时直接拒绝,绝不会注入一个空壳。
- 所有破坏性操作都有闸门:会话必须先归档才能删除,而归档的会话根本不能迁移。
功能
| 功能 | 说明 |
|---|---|
| 就在当前会话里挑 | 「整理」面板把本会话的消息列成勾选表格。点行看完整正文,点复选框才选中 |
| 随处收集 | 每条助手回复旁有 + 按钮,不离开聊天流就能收进来。篮子按会话独立存放 |
| 两种落点 | 在选定工作区里新建对话(内容作为该会话的 seed),或追加到当前已打开的会话 |
| 天然跨工作区 | 新建会话时以目标工作区为 cwd,因此它落在那个工作区的会话目录下——跨工作区搬运不需要任何路径改写 |
| 用名字而不是 id | 选择器显示会话自己的标题(日志里的 session/title),没有标题时退回首行正文;原始 id 放在该行的详情里 |
| 按来源标注角色 | user / assistant / context / tool,配色取自官方「轨迹」视图的 kind tag。context 这一档很关键:人类输入和系统注入在 dsh 里是同一个 user/message 事件类型 |
| Token 预算 | 写入前先估算;超过 maxInjectTokens 会带着实测数字拒绝,而不是悄悄截断 |
| 工作区管理 | 「工作区」面板列出当前工作区的全部对话(含已归档),每行可归档 / 还原 / 迁移 / 删除,另有多选批量操作 |
| 打开中的对话也能迁移 | 还在内存里的会话可以迁移:先 flush,再在协调器的每-id 锁内改写工件,并让后续写入指向新位置 |
| 只把真实对话作为目标 | 「整理」面板的目标选择器会过滤掉空会话与已归档会话——与官方侧边栏的 sessionVisible 规则一致 |
| 一套运行时,两个入口 | 面板与 /baize-session 命令驱动同一套运行时,两者不可能出现分歧 |
| 自己不留任何数据文件 | 插件不在磁盘上保存自己的状态;它改动的东西(会话工件、工作区注册表)都属于 dsh |
安装
dsh 插件通过 npm 分发,用
dsh plugin装进某个 profile。
# 从 npm 装进 web profile(请使用实际已发布的版本号)
dsh plugin --profile web add [email protected]
pm2 restart dsh # 用 pm2 托管 dsh 时执行
dsh --profile web
peer 依赖(@deepseek-ai/*)由 dsh profile 提供;若有缺失,pnpm 会依照 profile 目录下的 peerDependencies 解析。插件同时声明了 dsh.bundle(通过 cordis.patch.yml 把自己挂进插件树)与 dsh.client(浏览器端),两半都靠它才会加载。
卸载
dsh plugin --profile web remove dsh-baize-session
pm2 restart dsh # 用 pm2 托管 dsh 时执行
如果 profile 的 dsh.profile.bundles 里还残留这一行,从
$DSH_HOME/profiles/web/package.json 里删掉它再重启一次。除此之外不会留下任何东西:本插件不写自己的数据(见数据位置)。
不打扰正在运行的 dsh 试装
装进一个单独的 profile,当前运行的 dsh 完全不受影响:
dsh plugin --profile smoke add [email protected]
dsh --profile smoke --dump-config # 只组装并打印配置,不启动 dsh
本地开发(link)
// $DSH_HOME/profiles/web/package.json
"dependencies": {
"dsh-baize-session": "link:/home/abc/work/plugin/dsh-baize-session"
}
然后在 profile 目录里 pnpm install,并把 dsh-baize-session 加进 dsh.profile.bundles。
快速上手
打开任意会话,点聊天视图旁边的 整理 标签页。这一个标签页里有两个面板,用标题行上的子标签切换:
[对话] [轨迹] [规则] [整理] ← 顶层标签栏(本插件只占「整理」)
──────────────────────────────────
整理 | 工作区 ← 两个面板
整理 —— 把消息搬进另一个对话
① 目标工作区 [ /home/abc/work/plugin ▾ ]
② 目标对话 [ 新建对话 ▾ ]
─────────────────────────────────────────────────────────────
已选 0 段 [ 清空 ] [ 迁移 ]
─────────────────────────────────────────────────────────────
12 USER 消息正文的第一行…… ← 点行看全文
13 CONTEXT Current runtime context. This snapshot…
14 ASSISTANT 回复正文的第一行……
- 选工作区(当前工作区默认选中并排在最前;末项可以手填绝对路径)。
- 选目标对话——
新建对话,或该工作区下的任意真实对话。 - 勾选要带走的条目。点行只是看全文,不会选中它。
- 点迁移。

工作区 —— 管理这个工作区里的对话

工作区:/home/abc/current/test [ 全部 7 ] [ 未归档 6 ] [ 已归档 1 ]
─────────────────────────────────────────────────────────────────────────────
已选 0 个 [ 归档 ] [ 还原 ] [ 迁移 ] [ 删除 ]
─────────────────────────────────────────────────────────────────────────────
☑ 问候与自我介绍 已对话 未打开 09-15 17:58 · 24 条消息 [ 归档 ] [ 迁移 ]
☑ 测试 已对话 进行中 09-20 12:42 · 31 条消息 [ 归档 ] [ 迁移 ]
☐ 已归档的对话 已对话 已归档 09-20 12:42 · 6 条消息 [ 还原 ] [ 删除 ]
| 操作 | 位置 | 规则 |
|---|---|---|
| 归档 | 行内按钮,或批量条 | 任意对话,打开或关闭的都可以 |
| 还原 | 行内按钮,或批量条 | 仅限已归档的对话 |
| 迁移到其他工作区 | 行内按钮,或批量条 | 不能是已归档的;打开中的对话也可以迁移 |
| 删除(连同磁盘日志一起删) | 行内按钮,或批量条 | 仅限已归档,且需要二次点击确认 |
点击某一行(按钮与复选框以外的任意位置)会展开它的详情行:完整 session id、创建时间、消息数、工作区路径,以及当前是否打开。
同样的操作也可以纯键盘完成:
/baize-session # 等同 info:本会话 id、工作区、消息与篮子计数
/baize-session take 12 14 # 按 seq 收集(从当前会话)
/baize-session list # 按工作区分组列出所有真实对话
/baize-session to /home/abc/work/led # 在那里新建一个会话并写入篮子
/baize-session add session-1a2b… # 或追加到一个已打开的会话
/baize-session drop # 清空篮子
命令
| 子命令 | 语法 | 用途 |
|---|---|---|
| info | /baize-session [info|status] |
显示本会话 id、工作区、消息/篮子计数,以及最近 listLimit 条消息及其 seq |
| take | /baize-session take <seq…> |
按 seq 从当前会话收集消息 |
| list | /baize-session list |
按工作区分组列出真实对话(空会话与已归档只在末尾以计数提示,不列出),带名称与 id |
| drop | /baize-session [drop|clear] |
清空篮子 |
| to | /baize-session [to|move] <绝对路径> |
在该工作区新建会话,并把篮子写入作为开场上下文 |
| add | /baize-session [add|append] <会话id> |
把篮子追加进一个已存在的会话——该会话必须处于打开(在内存中)状态 |
对话的类型与状态
「工作区」面板每一行都带两个彼此独立的徽章,因为它们回答的是不同问题:
| 列 | 取值 | 回答的问题 | 判据 |
|---|---|---|---|
| 类型 | 空对话 / 已对话 / 子代理 |
这里面有对话了吗? | blank——从没记录过 turn/start |
| 状态 | 进行中 / 已打开 / 未打开 / 已归档 |
它现在在哪? | 挂着 agent / 在内存里 / 已落盘 / 在归档集合里 |
它们不是互相替代的关系。空对话也可能处于进行中——会话一被创建 agent 就挂上了,而第一条 prompt 还没发;有内容的对话也可能处于未打开。把两者折进一个徽章,说清了一件事就丢掉了另一件。
状态列里 已归档 优先于其它值(侧边栏隐藏会话的依据就是它);类型列里 子代理 优先(子代理会话不能迁移)。
面板
两个面板都在本插件占用的同一个 conversation.view 标签页里(id baize-session-tidy,order 40)——纯增量,不替换任何官方组件。+ 按钮注册在 conversation.chat.assistant-actions(id baize-session-collect,order 20)。
共同行为:
| 操作 | 结果 |
|---|---|
| 点一行 | 展开/收起该行的完整正文(整理)或详情行(工作区)。两者都不会选中它 |
| 点复选框 | 选中/取消选中;详情保持原样 |
| 操作条 | 常驻,两个面板共用同一套 .baize-bar。未选中任何内容时,按钮是禁用而不是隐藏 |
| 下拉 | 自绘浮层(原生 <select> 与 dsw 主题不搭);点外部或按 Esc 关闭 |
「整理」面板的特点:内容列表始终是当前对话;消息表格随窗口高度伸缩(没有固定高度)。
「工作区」面板的特点:标题+筛选行与子标签行都是 32px,因此没有任何一行比邻居低;类型列与状态列各固定 76px、时间列固定 150px,所以它们纵向对齐,不会随标签长短漂移。
消息行按来源打标:
| 徽章 | 含义 | 配色来源(官方「轨迹」视图) |
|---|---|---|
USER |
人类输入(source.kind === 'user') |
.user —— state-business-primary / state-business-tertiary |
ASSISTANT |
模型回复 | .assistantVioletBright |
CONTEXT |
由插件或 harness 注入——运行时快照、<system-reminder>、记忆库注入,以及本插件自己写入的整理块 |
.contextGreen |
TOOL |
工具结果(tool/result 事件) |
.toolAmber |
user/message 不等于"用户说过这句话":dsh 把人类输入与注入的上下文记成同一个事件类型,只靠 data.source.kind 区分。抽样一个真实会话,其分布是 {user: 4, plugin: 10}——那次对话里大部分内容根本没人打过字,这正是要给两者分别打标的原因。
注入的内容
每次迁移写入一条 user/message,用 createUserMessage() 构造,并以 surface 事件必需的标记 { surfaceOp: 'append' } 发布:
以下是本会话开始前,我(用户)从其它会话整理过来的上下文,供你参考:
【来自会话 06094031 / 工作区 /home/abc/work/plugin】
user: token 预算是在哪强制的?
assistant: 在 relocate.ts 里,写入之前——它是抛错而不是截断。
---
当前工作区是 /home/abc/work/led。请在此基础上继续。
- 每条都保留来源标注(
user:/assistant:/context:/tool:),让接收方模型能分清哪些是提问、哪些是注入。 - 内容按来源会话分组,并保持收集顺序。
- 目标是已存在的会话时,这段内容作为普通消息追加;目标是新会话时,同一条消息成为该会话的 seed。之所以用 seed,是因为 dsh 按 checkpoint 落盘:一个没人说过话的全新会话,走
append是永远落不到磁盘的。 - 新会话随后会被挂到它所属的工作区(
workspace.attachSession),这正是它能出现在侧边栏的原因。
哪些会列、哪些不会
两个面板的过滤规则故意不同:
| 整理 —— 目标选择器 | 工作区 —— 表格 | |
|---|---|---|
| 空会话(从没跑过 turn) | 隐藏 | 列出,类型 空对话 |
| 已归档 | 隐藏 | 列出,状态 已归档(除非你用筛选排除) |
| 子代理子会话 | 隐藏 | 列出,类型 子代理 |
| 其它 | 列出,显示名字 | 列出,显示名字 |
「整理」的选择器回答的是"这段内容能落到哪",所以只给出合理的目标;「工作区」的表格回答的是"这里有什么",所以全部列出并允许你操作。两者取自同一份会话列表,因此不可能互相矛盾。
操作会拒绝的情况
| 操作 | 何时被拒 | 提示 |
|---|---|---|
| 迁移进已有会话 | 目标不在内存里(未打开) | 目标会话不在内存中,无法追加(先在侧边栏打开它)。 |
| 迁移 | 篮子为空,或估算超过 maxInjectTokens |
拒绝信息里带实测大小 |
| 迁移 | 目标路径是相对路径 | 目标路径必须是绝对路径:… |
| 归档 / 还原 | 当前 dsh 的注册表没有暴露写入接口(版本过旧) | 当前 dsh 版本未暴露…接口 |
| 删除 | 会话不是已归档 | 只能删除已归档的对话:请先归档,再删除。 |
| 删除 | 请求缺少 confirm: true |
删除需要显式确认(confirm: true)。 |
| 迁移 | 会话已归档 | 已归档的对话不能迁移:请先还原它。 |
| 迁移 | 会话是子代理子会话 | 子代理会话不支持跨工作区迁移。 |
| 迁移 | 定位不到持久化工件 | 当前持久化后端不支持定位会话工件,无法跨工作区迁移。 |
| 迁移 | 没有任何已知信息能把后续写入指向新工件 | 当前运行时既不暴露 live 写入器、也不暴露持久化写入状态… |
迁移的实际步骤(顺序不能乱)——会话通过 cwd 归属于某个工作区,而注册表拒绝把「存储 cwd 与工作区路径不一致」的会话挂上去,所以先改工件、后换记账:
- 若会话打开中,先把缓冲事件 flush 到当前工件。
- 在持久化协调器的每-id 锁内(后端提供锁时)读取工件,只改写头行的
cwd(保留version),写到该 cwd 推导出的路径——经临时文件 + 原子重命名,原始文件先"停靠"起来,以便失败时能把字节放回去。 - 同步搬走内存里的指针:live 写入状态(
meta.cwd)、内存中的 session header、注册表的 header/path 索引——若记账交换失败,它们一起回滚。 - 从旧工作区 detach,attach 到新工作区,然后删掉已清空的旧目录。
- 任一步失败,原始字节回到原始路径。这一点比听起来更重要:dsh 启动时会校验"会话位置必须与 header 的
cwd一致",不一致会让它拒绝启动整棵插件树。
配置(Config)
| 字段 | 类型 | 默认值 | 含义 |
|---|---|---|---|
maxInjectTokens |
number,必填 |
8000,由本包的 cordis.patch.yml 设置 |
估算超过这么多 token 就拒绝写入。拒绝信息里带上实测大小,所以"选太多"是可见的,而不是被悄悄裁掉 |
listLimit |
number,可选 |
15,代码内默认(patch 并未设置) |
/baize-session info 列出最近多少条消息 |
包里实际发布的 patch 就是下面这样——要覆盖 listLimit,加在同一个 config 块里即可:
# cordis.patch.yml
- insert:
- id: baize-session
name: 'dsh-baize-session'
config:
maxInjectTokens: 8000
HTTP API
浏览器端无法读取会话、创建会话,也无法触碰工作区注册表,因此面板与宿主 web 服务器上的 /baize-session.api 通信。它委托给命令所用的同一套运行时。
| 请求 | Body / 查询 | 返回 |
|---|---|---|
GET /baize-session.api |
?sessionId=<id>(必填);&source=<id> 列出另一个会话的消息;&view=workspace 改为返回工作区面板 |
PanelState:{ sessionId, cwd, messages, basket, projects, sessions, maxInjectTokens };带 view=workspace 时返回 WorkspacePanel:{ workspace?, workspaces, sessions } |
POST /baize-session.api |
{ sessionId, op: 'take', seqs?, messageIds?, sourceId? } |
{ ok, added, missing, state } |
{ sessionId, op: 'untake', seq?, messageId?, sourceId? } |
{ ok, removed, state } |
|
{ sessionId, op: 'drop' } |
{ ok, state } |
|
{ sessionId, op: 'estimate', target } |
{ ok, tokens } |
|
{ sessionId, op: 'relocate', target } |
{ ok, sessionId, mode, tokens, sources, state } |
|
{ sessionId, op: 'archive' | 'restore', targetId | targetIds } |
{ ok, results, panel } |
|
{ sessionId, op: 'deleteSession', targetIds, confirm: true } |
{ ok, results, panel } |
|
{ sessionId, op: 'moveSession', targetIds, toWorkspaceId } |
{ ok, results, panel } |
target为{ kind: 'new', cwd }或{ kind: 'existing', sessionId }。- 工作区相关的 op 里,
targetId(单行)与targetIds(多选)可以互换。 - 批量结果逐条回报(
results: [{ id, ok, error? }]),所以一条被拒不会中断其余;信封上的ok: false表示每一条都失败了。每个工作区 op 都连着返回刷新后的面板,因此界面每个动作只需一次往返。 - 用户能自行处理的拒绝(相对路径、空篮子、追加目标未打开、超出预算、未归档、缺少确认、迁移已归档)以
400返回,消息可直接展示给用户;其余为500。
数据位置
本插件不保存自己的任何状态——没有配置文件、没有缓存、没有索引。篮子只活在进程内存里,重启即空;这正是"为这一次搬运而选"的应有范围。
它改动的东西属于 dsh:
| 路径 | 读取 | 写入 |
|---|---|---|
$DSH_HOME/sessions/<转义后的 cwd>/<会话 id>/session.jsonl.zstd |
会话日志——已关闭的用 inspect 读回,既不提交恢复也不发布 |
仅由迁移(改写 header 的 cwd 并移动文件)与删除(移除)写入 |
$DSH_HOME/storages/workspace.json |
工作区记录、注册表全局的会话归档集合 | 仅由归档 / 还原 / 迁移写入 |
其它位置一律不碰,卸载后也不留残留。
模块结构
src/core.ts 纯逻辑:事件读取与作者分类、消息列表、renderInjection、会话命名、工件编码/改写辅助
src/relocate.ts 搬运运行时:篮子、面板状态、take/untake/drop/estimate/relocate、工作区与持久化访问
src/workspace.ts 工作区运行时:对话列表、归档/还原、删除(agent 收尾、live store detach、工件移除)、跨工作区迁移(改写 header、原子换名、记账交换)
src/api.ts 宿主 HTTP API:/baize-session.api 的 GET 状态(两个面板)+ POST 操作分发
src/index.ts apply:两套运行时 + /baize-session 命令 + API 挂载(inject: commands/sessions/tokenMeter/webServer/sessionPersistence)
lib/client.js 浏览器端,手写产物:「整理」视图及其两个面板 + 「+」槽位,window.__ModuleLoader__.load({ id, factory })
test/core.spec.ts 纯函数的单元测试(fixture 照抄真机日志形状)
test/workspace.spec.ts 迁移所依赖的工件编解码测试(zstd 帧布局、头行改写、往返一致)
test/client.smoke.mjs 用 react-test-renderer 跑真实客户端产物并断言其行为
cordis.patch.yml 挂载元数据(插入 baize-session 插件行与默认配置)
对外入口(见 package.json 的 exports):.(index)、./client、./src/*、./package.json。
开发与即时反馈
pnpm test # vitest 单元测试,随后跑客户端冒烟测试
pnpm test:client # 只跑客户端冒烟测试(node test/client.smoke.mjs)
pnpm test:watch # 保存即重跑单元测试
pnpm build # tsc -p tsconfig.build.json → lib/
pnpm typecheck # tsc --noEmit
| 改的是 | 位置 | 怎么看到效果 |
|---|---|---|
宿主(src/*.ts) |
经 pnpm build 产出 lib/*.js |
构建后重启 dsh(宿主加载的是 lib/) |
浏览器(lib/client.js) |
手写,无构建步骤 | 刷新页面即可——产物从磁盘按内容哈希版本号直接提供 |
lib/client.js 是手写的:官方的 clientBundle tsdown 预设没有发布,所以 dsh 仓库之外的插件要自己写
window.__ModuleLoader__.load({ id, factory }) 这层壳,只把种子模块(react、ui-primitives)留作外部依赖。它没有任何类型检查或打包环节,因此 test/client.smoke.mjs 会带着打桩后的宿主 API 用 react-test-renderer 加载真实产物,断言两个面板的版式、交互顺序、徽章与它们的 CSS 规则。
破坏性路径怎么测:删除与迁移会改写持久状态,因此它们只在专门造的测试对话上演练,且必须在隔离的 dsh 实例里——绝不拿任何人在意的会话试。迁移的验证方式还包括:迁移后继续往该会话写入,确认消息落在新路径。
发布
# 1. 升级 package.json 里的版本号,并同步两个 README 的安装示例
# 2. 本地验证
pnpm build && pnpm typecheck && pnpm test
# 3. 提交、打标签、推送
git add -A && git commit -m "release: vX.Y.Z"
git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin main && git push origin vX.Y.Z
# 4. 发布——真正出货的一步
npm publish --access public
凭据。
npm publish使用你自己的 npm 凭据(~/.npmrc或环境变量)。绝不要提交 token,也不要把它粘进 README 或 CI 日志。