生命不息,折腾不止。上一篇教你在 Agent 动手前拦它,这一篇钻到它「记性」的底层——把一整轮对话自动压成一份带 SHA-256 防伪哈希的日报,交差、交接、审计一条龙。

上一课我们写了个钩子插件,在 tools/pre-execute 上挡在 Agent 前面当门卫。那次用的是「运行时事件」——瞬时的、只活在进程里的信号。但 dsh 真正有意思的东西藏在更底层:它的会话根本不是一条聊天记录数组,而是一条只追加、永不改写的日志。今天钻进去,顺手把「会话」炼成「交付物」。

一、先破一个成见:dsh 的「记忆」不是聊天记录,是事件流

很多人第一反应是把 Harness 的会话理解成 ChatGPT 那种 messages: [...] 数组——用户一句、模型一句,堆起来就完事。dsh 不是这么干的,它走的是**事件溯源(event sourcing)**这条路:

会话状态是一份由类型化 SessionEvent 组成的仅追加日志(append-only log),是 Agent 整个交互历史的唯一真相源。模型看到的消息历史,是从这份日志派生出来的,从来不单独存第二份。

翻译成人话:日志是「账本」,模型能看到的对话是「从账本算出来的报表」。「回放」不是把报表调出来,而是拿同一批事件重新算一遍。

这对我们有什么好处?只要把「记账」这件事盯住,你就能拿到 Agent 做过的一切——说过什么、调过什么工具、哪一步改了主意,全都流水一样躺在日志里。想自动写日报、交接文档、审计轨迹,这层就是地基。

二、两条事件总线,务必要分清

动手前必须画清一条线,否则会写出「监听了半天啥也没收到」的灵异 bug。dsh 里存在两个事件平面:

平面 性质 典型 咋发 咋听
A:Cordis 进程内(live) 瞬时、插件间通信 tools/pre-execute、agent/error ctx.emit('xxx') ctx.on('xxx')
B:SessionEvent 持久日志(durable) 落盘、可重放的事实 user/message、tool/result、turn/end session.append(type, data) 只能通过 session/event 间接观察

关键结论:持久日志里的事件,你没法用 ctx.on('turn/end') 直接订阅——它压根不是 Cordis 事件。想观察「每一条落盘事实」,唯一的入口就是 session/event 这一条监听:

1
2
3
ctx.on('session/event', (session, event) => {
console.log('新事件落盘:', event.type, 'seq =', event.seq)
})

这个事件是 emit 模式——fire-and-forget,提交后异步回调。官方源码里 invokeContainedSessionObservers 把每个监听的异常单独 catch 掉、只 logger.warn,所以细节上记住两点就够:

  1. 先 commit、后通知:日志已经写进去了,回调才触发;回调里抛错不会把已经落盘的事件「吐」回去,错会被日志吃掉并单独记下来。
  2. 事件信封字段:每个落盘事件都带 type(类型)、seq(单调递增序号)、time(epoch 毫秒时间戳)、data(载荷);其中「会产生模型消息」的那几类才额外带 surfaceOp(它怎么进到对话表面)和 sourceEventSeqs(它由哪些事件生成)。

哪些类型会产生模型可见消息?官方把它们叫 SurfaceEventType,完整清单是五个:system/message(系统提示词)、developer/message(开发者指令)、user/message(用户输入)、assistant/message(模型回复)、tool/result(工具结果)。对写日报来说,我们主要盯后三个。其余像 turn/start、turn/end、step/start 这些,都是 log-only——会进日志、能重放,但不会变成模型上下文。

三、手写一个「会话日报」插件

理解了地基,直接上活。目标插件叫 session-diary:盯住会话事件流,一轮对话(turn)结束时,自动把它压成一份 Markdown 日报,算好 SHA-256 防伪哈希,落到 reports/ 目录。

第 1 步:订阅 session/event,按 event.type 分拣。

消息文本具体藏在哪,官方类型定义里每一类消息都是「分段数组」,纯文本段是 { type: 'text', text: '...' } 这样。精确字段以官方 packages/core/session/src/types.ts 为准,这里写一个防御性的 extractText 兜住:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { Context } from '@deepseek-ai/cordis'
import { createHash } from 'node:crypto'
import { writeFileSync } from 'node:fs'

export const name = 'session-diary'

export function apply(ctx: Context) {
// 每个会话一个缓冲区,攒一轮的对话文本
const buffers = new Map<string, string[]>()

ctx.on('session/event', (session, event) => {
// 只收集携带对话内容的 surface 事件
if (event.type === 'user/message') {
const buf = buffers.get(session.id) ?? []
buf.push('[用户] ' + extractText(event))
buffers.set(session.id, buf)
} else if (event.type === 'assistant/message') {
const buf = buffers.get(session.id) ?? []
buf.push('[助手] ' + extractText(event))
buffers.set(session.id, buf)
}

// 一轮结束 → 生成日报(turn/end 的 data 是 { turn, reason })
if (event.type === 'turn/end') {
const buf = buffers.get(session.id) ?? []
const markdown = buf.join('\n\n')
const sha256 = createHash('sha256').update(markdown).digest('hex')
const path = `reports/${session.id}-turn${event.data.turn}.md`

writeFileSync(path, [
`# 会话日报 · 第 ${event.data.turn} 轮`,
`> 会话 \`${session.id}\``,
'',
'## 对话内容',
markdown,
'',
'## 防伪凭据',
'```',
`sha256: ${sha256}`,
'```',
].join('\n'))

buffers.set(session.id, [])
}
})
}

function extractText(event: any): string {
// 消息内容都是分段数组,纯文本是 { type: 'text', text: '...' }
// 精确结构以官方 packages/core/session/src/types.ts 为准
const data = event.data
const blocks = data.content ?? data.message?.content ?? []
return (Array.isArray(blocks) ? blocks : [])
.filter((b: any) => b?.type === 'text')
.map((b: any) => b.text ?? '')
.join('')
}

第 2 步:挂载跑起来。

上两课讲过挂载套路,照搬即可(--patch 覆盖层挂载,或打包后 dsh plugin add):

1
2
3
4
5
6
7
8
# 覆盖层方式:把插件入口写进 patch 文件
cat > diary-patch.yml <<'EOF'
- insert:
- id: session-diary
name: '/绝对路径/session-diary/index.js'
EOF

dsh --profile web --patch ./diary-patch.yml

跑起来后,随便聊一轮,切到 reports/ 目录,就能看到自动生成的 xxx-turn1.md,底部带着一串 sha256。

四、凭据怎么算、怎么验:一步锁死防伪,以及一个不能踩的坑

日报能不能当「交接文档」,光有内容是没分量的,得能证明「这份东西从生成后没被人动过」。答案就是哈希:

  • 算:对日报正文算 sha256,createHash('sha256').update(markdown).digest('hex') 得到 64 位十六进制串,写进日报尾部。
  • 验:任何人拿到文档,把正文再算一遍,跟尾部留的哈希一比对,一致就说明原封未动,改一个标点都对不上。

这里我想跟你多啰嗦一句「把这个事件写回日志」这件事——因为它藏着一个真实的大坑,我差点栽进去。事件溯源的理想做法当然是「生成日报」也 session.append 回日志,让日报本身进入账本、可重放可审计。但你要是在下游插件(不在 dsh 仓库里、自己写的)里这么干,会踩到官方至今还在讨论的坑(GitHub discussion #802 / #5474):

下游插件可以靠 declaration merging 扩展 SessionEventMap,让 session.append('report/generated', ...) 编译通过、当场也能跑。但重启后,持久化加载会撞见一个它不认识的事件类型——而 append 又没法给这个事件打上 ignorable: true 的跳过标记——于是整个会话直接拒绝加载,抛 SessionFormatUnsupportedError,你辛苦聊出来的会话打不开了。

换句话说,自定义事件 type 目前只在 dsh 仓库内部可靠,仓库外的插件写了会炸。所以稳妥做法就是本课这个 demo 的路子:只读不写——观察日志生成日报,日报落成普通文件,别反过来往账本里塞自造的事件类型。这事官方那边正在推 IgnorableSessionEventMap 的解决方案(#5463),等它落地了再回填「写回日志」的优雅版本。

真要上生产、要周报还要顺手出公众号文章,别重复造轮子,社区已经有现成的 dsh-report-studio(GitHub 搜 ciceroyang/dsh-report-studio)——它把「会话 → 日报/周报/交接文档 + 报告与产物双哈希凭据」这套做全了,v0.2 起还支持直接读多帧 zstd 历史日志做跨会话聚合。我们今天的 session-diary 是它的一套最小可懂版,看懂了这个,再去读它的源码就通透了。

五、实测踩坑清单

兄弟带你踩过的坑,记下来:

  1. 下游插件别 append 自定义事件类型。编译能过、当场不报错,但重启后整个会话拒绝加载(上文 ignorable 那个坑)。要么只读不写,要么等官方的 IgnorableSessionEventMap 落地。
  2. 载荷必须可 JSON 序列化。Session.append 在源头就强校验,塞了个 undefined 或函数进去,当场抛异常、事件进不了日志——也算好事,保证账本干净。
  3. session/event 是 emit,回调出错不影响已提交的 append。别指望在回调里「拦截」掉一个已经落盘的事件,那已经晚了。要否决得去上一课的 tools/pre-execute(waterfall)那一层。
  4. log-only 事件进不了模型上下文。你在日志里能看见 turn/start,但它不会变成模型能看到的消息。给模型「加新的可见信息」和「写自定义日志事件」是两码事,前者有它单独的接缝,别绕。
  5. live 与 durable 别串台。ctx.on('turn/end') 永远收不到持久日志里的事件,正解只有一个入口:ctx.on('session/event'),再在回调里用 event.type 分拣。
  6. 消息结构以官方 types.ts 为准。user/message 里的 content 和 assistant/message 里的 message.content 路径不一样、字段也在变(rc 阶段),别死记一个结构,写个防御性的 extractText 兜底。

六、小结

这一课我们从「消息数组」的成见里爬出来,看清了 dsh 的会话本质是一条 append-only 事件日志;学会了用 session/event 这一个入口观察所有落盘事实,分清 surface 事件和 log-only 事件,再动手写了个把一轮对话炼成带 SHA-256 防伪凭据日报的插件。事件溯源这层打通了,后面不管是写审计、写回放、写跨会话聚合,都只是在这条流水线上接不同出口。

生命不息,折腾不止。下一课我们解决一个现实焦虑:事件日志只会越攒越长,模型上下文迟早装不下——dsh 是怎么在「保住真相」和「压小体积」之间做自动摘要的?明天钻进它的压缩(compaction)机制,看 compaction/start、compaction/summary、compaction/end 这一串事件怎么把旧历史「瘦身」又不丢审计轨迹。