它没有改 DSH 一行内核,也没有新增一种事件类型。整支「Bot 团队」——主 Bot、Bot 间消息、群聊轮次、长期记忆、上下文续命——全部叠在普通的 Session 之上。这篇拆开看它到底怎么做到的。
插件的入口 index.js 极短:定义一个 21 项的 MODULES 数组,逐个调用 module.install(rt),把函数挂到一个共享的运行时对象 rt 上。模块之间不直接 import 互相调用,而是运行时通过 rt 取用——于是注册顺序就是行为顺序。
// index.js
export const inject = ['llm','tools','systemPrompt','sessionController',
'sessions','sessionTitle','workspaceRegistry','connection']
const MODULES = [diagnostics, store, models, turns, delivery, roster, agents,
prompt, tools, chatIndex, groups, memory, parts, images, secrets,
connectors, schedules, browser, usage, update, channel]
顺序被刻意安排:usage 排在所有会改写请求的监听器之后,这样它计量的 token 是最终真正发出去的请求。这类「顺序即语义」的取舍,是理解这套代码的钥匙。
state.json 里,重启即恢复——不新增任何 Session 事件类型。id 永远等于它第一段私聊 Session 的 id,之后换段不变;bot.sessionId 才指向当前段。历史段列表 bot.segments 归档保留、仍可检索。indexSessions() 重建三张内存映射:chatOwners(Session→Bot)、groupOwners(群会话→成员/群)、pastOwners(历史段→Bot)。之后每次 save() 都刷新。整个身份提示只注册一个 section(order: 640,interpolate: false),文本只依赖渲染它的那个 Session 自己的 Bot 记录。因为前缀逐字节稳定,provider 的 prompt cache 才能命中;名册怎么变、别的 Bot 怎么改,都不影响这段前缀。
更细的一步:群聊里成员用的隐藏 Session,渲染的提示词与它私聊时完全相同。于是私聊和群聊共享同一份缓存前缀,群聊回合也绝不会读到该成员的私人上下文。
DSH 的 Session 之间没有「同步调用」这回事。deliver() 做的就是把一段文本当成一条新的用户消息灌进对方 Session:
const agent = await ctx.sessionController.resolveAgent(sessionId)
agent.followup(createUserMessage({
content: [{ type: 'text', text: `[Message from ${fromName}]\n${text}` }],
source: { kind: SOURCE_KIND, form: 'relay', role, exchangeId, hop, replyTo },
}))
await ctx.sessions.flush(agent.session)
身份不是靠 API 参数传递的,而是靠消息头 + 一个结构化 source 对象双重编码:
| 消息头 | 含义 |
|---|---|
| [Message from X] | 发给这个 Bot 的请求,回复会回到 X |
| [Message from X · the user sees your reply here] | X 让它在自己的聊天里回答用户,回复不回传 |
| [Reply from X] | 另一个 Bot 的异步回复到了 |
| [Group chat "R" · new message] | 群聊回合(隐藏会话) |
turns.js 订阅 session/event,用一张进程内的 live 表跟踪每个 Session 的当前回合:
turn/start:记下回合号,认领触发它的那条 relay 消息。assistant/message:把这一回合产出的文本攒起来。turn/end:如果是被另一个 Bot 触发的请求,就用攒下的文本 deliver(role:'reply', hop+1) 回传。MAX_HOPS 限制 Bot 之间来回的层数,防止无限套娃。一个很容易忽略的健壮性细节:如果接收方的模型调用失败了,它会给发送方补一条明确的失败通知(could not answer … its model call failed)。否则发送方会永远等一个不会来的回复。而 AUTH 类失败的消息内容会被清空——因为 provider 有时会把 key 的一部分回显在错误里。
不是所有回合都该让用户看见。被 Bot 间消息触发的那些,会在 state.exchangeTurns[sessionId] 里打标,界面据此隐藏;群聊会话整体隐藏,所以无需打标。吉祥物「在干什么」则由实时的 tool/call 事件映射而来(sending / orbit / searching / working / thinking),只在进程内重建、从不落盘。
DSH 自带的压缩在 Bot 会话上被完全禁用。取而代之的是一条按模型窗口算出来的水位线:
usable = 声明窗口 − 回复预留(maxTokens),上限不低于窗口一半;模型没声明窗口按 500,000 计。// steward.js —— 只回一行 JSON,取「最后一个」带 action 的对象
{"action":"switch"|"compact", "reason":"一句话"}
// switch → 工作到了一个停顿点:换一段全新 Session
// compact → 还在进行中:就地压缩旧消息,无缝继续
旧消息被换成一条带编号的 [Checkpoint N]:管家写的摘要 +「发给你的消息,逐字保留」。用户最近的原话逐字留在摘要旁——借鉴 Codex 的做法。
空闲时用 同一段前缀让 Bot 自己写交接笔记(于是由缓存提供、不额外等待);新段以一个「交接包」开场:笔记 + 最新消息逐字 + 其余内容在哪。
无论哪种,用户看到的始终是一个连续聊天、没有分隔标记。而「压缩了几次」会被真的数出来:管家提示里会告诉它「这段已压缩 N 次」,越压越倾向于把合理的停顿点算作终点——因为「摘要的摘要会丢更多细节」。越界溢出(超过 hard line 甚至窗口)时,直接换段、连笔记都来不及写。
被换掉的内容并没有消失:每个 part 都写进本地全文索引,Bot 用 read_own_chat 带着词或 part N 就能翻回去,返回的行都带锚点,例如 [part 2 #1234 · 10-08 14:22],可再按锚点读上下文。
群聊是「一个共享的、有序的对话」。核心约束是:一个成员的回合只有在上一个发言者发完或弃权之后才开始。它会看到自上次看过以来新增的内容、当前发言顺序、以及自己在其中的位置——谁都不用数行数去猜轮到谁。
| 机制 | 实现 |
|---|---|
| 回合/增量 | 群日志带 seq;room.seen[memberId] 记每人读到哪,只补增量(上限 24 行,首次 12 行) |
| 沉默 | 回复 [PASS] 即弃权;正则同时认 【pass】、pass. 等变体 |
| 点名 | @名字 触发下一轮;按名字长度降序匹配,所以 @Mx 不会被误读成 @M;只有 ASCII 字母数字能延续一个名字 |
| @全体 | @all / @everyone / 所有人 / 全体 |
| 两种模式 | admin(默认):普通消息先给管理员,由它 @ 点名;everyone:每人按名册顺序、管理员优先依次作答 |
| 防死循环 | 每个成员每轮调用次数有上限(普通 2 / 管理员 4),额外的「接力」也有上限(4 / 4);自用户上次发言后,Bot 最多只能主动发起 N 轮 |
模型很爱模仿转写格式,把名字前缀也吐出来。所以有一条 stripOwnName():发送前老老实实把这行 Name: … 前缀剥掉。
每个成员在每个群里都有一个专属的隐藏 Session(room.sessions[botId]),它与该成员私聊渲染同一套提示词和工具。这带来两个后果:前缀缓存共享;群聊会话与私聊会话互相隔离——群里读不到成员的私人聊天。这个隐藏 Session 的第一条消息是一份该成员私聊的「简报」,因此固定不变、可缓存。
进群发言前,成员可以 read_local_chat 主动看自己的私聊(群会看到「它去查了」这一行),但只分享群需要的内容;在私聊里则用 read_group_chat 看群里说了什么。
群会话本身只是个「中继」:它的系统提示告诉它你不是参与者——对每条新用户消息调用一次 group_relay,然后以空回复结束回合,绝不自己加话。
记忆是纯 Markdown,格式取自 Agent Memory Repo:每行一条、结尾带 [source: …; added: …] 元数据;MEMORY.md 用 ## Index 把主题文件以 [[topic]] 链出去。
~/.dsh/bot/memory/
bots/<botId>/ 某个 Bot 自己的 MEMORY.md 与主题
team/ 全队共用、每个 Bot 都读
journal.jsonl 每一次改动,含改前/改后整行
snapshots/ 每个 Session 冻结下来的那一段
summaries/ 记忆面板的摘要(可删)
archive/ 已删除 Bot 的记忆
reviewed.json 复盘读到哪了
每回合结束后有一次 memory review:它读这段对话里新增的内容(≤16k 字符),自己决定保留、更新还是删除条目。这才是记忆真正可靠的原因——不依赖模型主动调用 remember。三个工具 remember / forget / recall 走的都是同一条写入路径,谁能在哪个作用域写、条目大小上限、拒绝记录密钥,全在这一层强制。
MEMORY.md ≤4000,单个主题 ≤16000,一个作用域 ≤40 主题。MEMORY.md(自己的 + 全队的)会随每次请求发送,所以合起来控制在约 8000 字符、几千 token 的量级。与前缀缓存同样的手法:每个 Session 在首次渲染时冻结自己那一份记忆 section,于是提示词逐字节稳定、缓存命中;改动从「下一段或下一个 checkpoint」才可见(而 recall 是实时的)。变更历史靠 journal.jsonl 的改前/改后,不需要 git。
Bot 要 key 的时候调 request_secret,用户在自己聊天里往一张卡上填。值只存在于 secrets.json(权限 600)和宿主进程内存中,Bot 通过环境变量 $DSH_SECRET_<NAME> 使用,从不经过模型。
redactor()作者把局限也写进了文档:只替换「完全一致的副本」,Bot 若把值编码、拆分、倒序仍可能打出来;Windows 下 600 权限位无效;非 HTTPS 页面传输是明文。结论写得很直白:别把重要密钥交给你不信任的 Bot。
state.json 装着整个团队:版本、workspace、主 Bot 列表、bots、rooms、agents、交换日志、隐藏回合表、待答问题、偏好、revision。做法很朴素但很稳:
// 原子写:先写临时文件,再 rename
const temp = `${statePath}.${process.pid}.tmp`
await writeFile(temp, snapshot, 'utf8')
await rename(temp, statePath) // 同一台机器上 rename 是原子的
写入被串到一个 promise 链上,避免并发覆盖;adopt() 里则集中处理所有历史数据迁移(旧的 lab 字段 → 各自的模型、单个 mainBotId → 数组、初代鲸鱼头像、旧主题名、群聊的 admin/mode…)。
两者默认共用 ~/.dsh,若同时内存里各存一份团队状态、各自整文件写回,就会互相覆盖。于是有一把 state.lock:一个很小的 JSON(pid、host、token),持有者每 15 秒 bump 一次 mtime,60 秒没 bump 就算过期。
SQLite FTS5,tokenize='trigram'——中文因此不需要分词;短于 3 字符的词退回 LIKE。BM25 排序。派生自 Session 日志,文件删了重建即可。
有日志的调用读日志(所以插件诞生前的调用也算数);没有日志的调用(checkpoint、交接、管家、读图、DSH 标题)边流边记。按「一刻钟」聚合——任何时区偏移都能整除 15 分钟。
给不能读图的模型,先用视觉模型把图转文字,按附件 id 缓存(ocr.json)。于是后续请求发送的是同样的文字,前缀缓存仍能命中——而不是每次换一段描述。
页面在 Electron 的 webview 里、不在宿主进程。read_browser 靠客户端持有的长轮询通道(browser-wait / browser-result)把读取请求送进去,2 秒重发,客户端 30 秒不轮询即视为离线。
用的是 DSH 自带的 schedule 服务(可选 bundle)。Bot 的聊天换段时,先把任务在新段上重建、再删旧的——失败就留在原处,不会丢。
侧边栏那个「原生 DSH 入口」只该看到原生能力。代码用 tools.restrict({deny}),并在 schema 未就绪时每 5 秒重试直到生效——避免它短暂拿到团队工具。
DSH 的 selectModel 会把选择顺带存成 profile 默认。Bot 的模型是它自己的,所以 DS Bot 在之后把用户的默认值写回去,并把选择串行化,保证 profile 写入按提交顺序落盘。
创建失败会回滚:把已建好的 Session 归档、删掉记录并持久化,否则 Agent 会在重启后带着「没被屏蔽工具」的状态复活。
这套实现最值得学的不是功能清单,而是三个反复出现的同一招:
exchangeTurns 打标隐藏、群会话整体隐藏、失败回合主动回调发送方。行为由机制保证,不靠模型自觉。但它是 v0.1、只测过 DSH 0.2.1-alpha.1。底座是 alpha,新版本可能直接弄坏插件;密钥明文落盘、群聊烧 token、跨机迁移未验证。现在适合拆着学、别托付关键活。
源码:github.com/FeiZhuLulu/DeepSeek-Bot(Apache-2.0,非 DeepSeek 官方)。本文基于 v0.1.0 源码,附带 git 版本 v0.1.0。