Session Memory

有些事实应当挺过总结,并跨会话存活。

追加式事实流 + 派生视图。

80 行事件日志

问题

到目前为止,messages 数组就是全部记忆。它有两个致命弱点:

  1. 易碎:进程崩溃,数组消失——Agent 忘记它做过的所有事;
  2. 不可派生:压缩后(s08)原始事实没了,只剩摘要——但"用户改了偏好""测试通过了""todo 完成了"这些事实本可以独立于对话文本而存在。

你需要的不是"记住更多对话",而是把事实与对话分离:事实追加进一个持久化的日志,对话文本只是从事实派生的一个视图。

解决方案

事件日志模式:

  • 每个事实是一条事件{ seq, type, payload, ts }),只追加、不修改;
  • 所有视图(消息历史、todo 状态、计划模式、会话标题)都从日志派生
  • 恢复 = 重放日志(或从检查点 + 增量重放)。
const log = []; // 追加式事件日志,持久化到磁盘
let seq = 0;

function append(type, payload) {
  log.push({ seq: seq++, type, payload, ts: Date.now() });
  persist(log); // 追加到磁盘文件
}

工作原理

第 1 步:把循环产生的每一类事实建模为事件类型。用户消息、模型消息、工具结果、todo 更新——各有各的事件:

append("user/message", { content: query });
// ... 循环内:
append("assistant/message", { content: msg.content, toolCalls: msg.tool_calls });
append("tool/result", { callId: call.id, output });
append("todo/update", { id, status });   // s05 的 todo 现在有了持久化底座

第 2 步:消息历史变成派生函数——不再是你手维护的数组,而是从日志投影:

function deriveMessages() {
  const msgs = [];
  for (const ev of log) {
    if (ev.type === "user/message") msgs.push({ role: "user", content: ev.payload.content });
    if (ev.type === "assistant/message") msgs.push({ role: "assistant", content: ev.payload.content, tool_calls: ev.payload.toolCalls });
    if (ev.type === "tool/result") msgs.push({ role: "tool", tool_call_id: ev.payload.callId, content: ev.payload.output });
  }
  return msgs;
}

第 3 步:任何状态都从日志折叠(fold)出来——todo 列表、会话标题、计划模式。状态是派生的,日志是唯一的:

function foldTodos() {
  const todos = new Map();
  for (const ev of log) if (ev.type === "todo/update") todos.set(ev.payload.id, ev.payload.status);
  return [...todos.entries()];
}

恢复会话 = 加载日志文件 → 重放(或在压缩后加载检查点 + 增量重放)→ 所有视图自动还原。压缩不再破坏记忆:s08 的摘要只是派生视图之一,原始事实(只要没被裁剪)仍在日志里。

试一下

运行 s09,验证持久化:

  • 跑一个会话(做点事、改个 todo),杀掉进程
  • 重新运行,用 resume 恢复——检查模型是否记得之前的 todo 和结论
  • 观察 session.log 文件的内容:是不是只追加、没有修改?

观察重点:事件类型设计有多重要?如果"模型回答"和"工具结果"是两条事件而不是一条,派生时谁先谁后?——事实的排序(seq)本身就是语义

以下内容基于 packages/core/sessiondocs/subsystems/session.md 的核查。DSH 的会话子系统就是你这个模式的完整生产化:

一、追加式事件日志

  • 每个 Session 是一条追加式的 SessionEvent 日志(ctx.sessions)——会话的唯一事实源
  • 消息历史通过 deriveMessages() 从日志派生;原始 assistant/chunk 事件保留回放与 UI 保真。
  • SessionEventMap 是可合并扩展的类型映射:新的事实类型 = 新的事件类型(s05 的 todo、s12 的计划模式、s14 的调度、s15 的目标都以 session 事件持久化)。

二、不变量:Model-visible ⟺ logged

任何到达模型请求的内容必须能从会话日志重建——运行时不变量强制检查。这就是为什么"新模型可见输入"必须配"新 session 事件":日志是记忆的唯一合法入口

三、会话生命周期

  • create(新建)、fork(从某 seq 分支出新会话,s20 子代理用它)、resume(加载持久化会话);
  • 持久化本身不在 session 包里——插件订阅 session/event,在 session/flush 时落盘(persistence)。存储与日志解耦,正是"日志是唯一事实源"的体现。
你的最小实现 DSH 的真实实现
log 数组 + persist() 手写落盘 事件日志 + 订阅式持久化插件 + 类型化事件映射
deriveMessages 手写投影 surface 投影层(高效派生与压缩的基座)
恢复 = 重放全部 fork/检查点/增量重放 + 修复(repair)

一句话:会话日志是 DSH 记忆的地基——s05 到 s20 里几乎所有"状态"(todo、计划、调度、目标)最后都长在它上面。这也是为什么这一课放在中间:它是后面所有课的地基