image-20260325150236446

Pi 的 SessionManager 负责管理 AI 对话的完整生命周期——从创建、持久化、恢复,到分支与压缩。每一次对话都以 append-only 的 .jsonl 文件保存在磁盘上,session 内部以树状结构组织,支持从任意历史节点分叉出新的对话分支。

本文从源码角度,依次梳理 SessionManager 的创建方式、文件格式、entry 类型、写入机制、上下文构建,以及分支和历史树导航的完整流程。


SessionManager 的创建方式

SessionManager 的 constructor 是 private 的,外部只能通过静态工厂方法创建实例,每个方法对应一种 CLI 使用场景:

方法 对应 CLI 场景 说明
SessionManager.create(cwd) 默认(新 session) sessionFile=undefined,按时间戳 + UUID 生成新文件名,懒创建
SessionManager.open(path) --session <id> 加载指定文件,从 header 读取 cwd
SessionManager.continueRecent(cwd) --continue 找最近修改的 .jsonl 文件打开,找不到则新建
SessionManager.inMemory() --no-session persist=false,不写磁盘
SessionManager.forkFrom(src, cwd) --fork <id> 复制源文件所有 entry 到新文件,parentSession 记录来源

constructor 内部做两件事:若需要持久化则创建目录;若传入了 sessionFile 则加载已有文件(含版本迁移),否则调 newSession() 初始化一个空 session。


Session 文件格式

每个 session 是一个 .jsonl 文件,每行一个 JSON 对象。第一行是 SessionHeader,后续每行是一个 SessionEntry。

SessionHeader

{
    type: "session";
    version?: number;        // 当前版本为 3,v1 没有此字段
    id: string;              // session UUID
    timestamp: string;       // 创建时间 ISO 字符串
    cwd: string;             // 创建时的工作目录
    parentSession?: string;  // fork 来源的 session 文件路径
}

parentSession 在两种情况下有值:

  • --fork CLI:跨项目复制时记录源文件路径
  • /fork TUI 命令:在同项目内从历史节点创建新 session 文件时记录原文件路径

SessionEntry 公共字段

{
    type: string;
    id: string;              // 8 位 hex 短 ID(碰撞时回退到完整 UUID)
    parentId: string | null; // 父 entry 的 id,null 表示根节点
    timestamp: string;
}

parentId 构成树状结构,支持分支。leafId 指针记录当前激活的末端节点,沿 leafId → root 回溯即为当前对话路径。


Session 文件命名规则

目录命名

session 文件按 cwd 分组存放,目录由 getDefaultSessionDir() 生成:

const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = join(agentDir, "sessions", safePath);

规则:去掉 cwd 开头的 / 或 \,将所有 /、\、: 替换为 -,前后加 -- 包裹。

cwd = /Users/fanfei/monorepo/pi-mono
→ ~/.pi/agent/sessions/--Users-fanfei-monorepo-pi-mono--/

文件命名

由 newSession() 生成:

const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.sessionFile = join(sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);

new Date().toISOString() 生成的 2026-03-24T14:08:44.123Z,将 : 和 . 替换为 - 后得到:

2026-03-24T14-08-44-123Z_a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl

完整路径示例:

~/.pi/agent/sessions/--Users-fanfei-monorepo-pi-mono--/2026-03-24T14-08-44-123Z_a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl

设计意图:

  • 目录按 cwd 分组:同一项目的 session 集中存放,方便 --continue 找最近的 session
  • 文件名含时间戳:按字母序排列即为时间序,无需读取文件内容即可排序
  • UUID 保证唯一性:同一毫秒内也不会冲突

SessionEntry 类型

SessionMessageEntry

{ type: "message", message: AgentMessage }

核心 entry。保存一条对话消息,对应 AgentMessage 的所有角色:user、assistant、toolResult,以及内部类型 compactionSummary、branchSummary、bashExecution、custom。

每次对话产生的消息都会写一条这个 entry,是 session 恢复时重建对话历史的主要来源。

ThinkingLevelChangeEntry

{ type: "thinking_level_change", thinkingLevel: string }

记录 thinking level(off / minimal / medium / high / xhigh)的变更时间点。session 恢复时从最后一条此 entry 读取 thinking level,而非每次都用 settings 默认值。

ModelChangeEntry

{ type: "model_change", provider: string, modelId: string }

记录模型切换事件。session 恢复时从最后一条此 entry 知道应恢复到哪个模型。新 session 创建时写入一条初始记录。

CompactionEntry

{
    type: "compaction",
    summary: string,           // 压缩生成的摘要文本(注入给 LLM)
    firstKeptEntryId: string,  // 压缩后保留的第一条 entry 的 id
    tokensBefore: number,      // 压缩前的 token 数
    details?: T,               // 扩展可存储额外数据(如结构化索引)
    fromHook?: boolean         // 是否由扩展生成
}

context 压缩的结果记录。当对话历史太长(接近 context window 上限)时触发,把历史浓缩成摘要。firstKeptEntryId 是关键字段:恢复时这条 id 之前的消息都被丢弃,只保留摘要 + 之后的消息。

BranchSummaryEntry

{
    type: "branch_summary",
    fromId: string,    // 分叉点的 entry id
    summary: string,   // 被放弃分支的摘要
    details?: T,
    fromHook?: boolean
}

分支摘要。用户从历史节点导航到新分支时,LLM 对被放弃的旧分支生成摘要并注入新分支,保留上下文。与 CompactionEntry 的区别:compaction 是线性压缩当前对话,branch_summary 是跨分支的上下文传递。

CustomEntry

{
    type: "custom",
    customType: string,  // 扩展自定义的类型标识
    data?: T
}

扩展专用的持久化存储,不参与 LLM context。 扩展在 session 中存储私有状态(如索引、版本标记),session 恢复时扫描这些 entry 重建内部状态。

CustomMessageEntry

{
    type: "custom_message",
    customType: string,
    content: string | (TextContent | ImageContent)[],
    details?: T,     // 扩展私有元数据(不进 LLM)
    display: boolean // 是否在 TUI 中渲染
}

扩展专用,参与 LLM context(被 buildSessionContext() 转换为 user 消息)。display: false 可实现"给 LLM 看但界面不显示"的隐藏注入。

LabelEntry

{
    type: "label",
    targetId: string,
    label: string | undefined  // undefined 表示删除标签
}

用户给某条 entry 打书签。label: undefined 通过追加新 entry 实现删除,符合 append-only 设计。

SessionInfoEntry

{ type: "session_info", name?: string }

session 级别的元数据。目前只有 name 字段,对应 /session rename 设置的名称,显示在 session 列表中。

汇总

Entry 类型 进 LLM context 用途
message ✓ 对话消息(user/assistant/tool)
thinking_level_change ✗ 恢复 thinking level
model_change ✗ 恢复使用的模型
compaction ✓(摘要部分) 历史压缩记录
branch_summary ✓(摘要部分) 分支上下文摘要
custom ✗ 扩展私有状态存储
custom_message ✓ 扩展注入 LLM 的消息
label ✗ 用户书签/标注
session_info ✗ session 元数据(名称等)

Session Entry 写入逻辑

写入路径

所有写入都经过同一条路径:

appendXxx()
    ↓
_appendEntry()  ← 更新内存(fileEntries / byId / leafId)
    ↓
_persist()
    ├── persist=false?      → 跳过(--no-session in-memory 模式)
    ├── 无 assistant 消息?  → 积压在内存,flushed=false
    ├── flushed=false?      → 一次性 flush 所有积压 entry(appendFileSync × N)
    └── flushed=true?       → 增量追加单条(appendFileSync × 1)

特殊情况:
_rewriteFile()  ← 版本迁移或文件损坏时全量覆写(writeFileSync)

懒写入设计:新 session 等到第一条 assistant 消息出现后才真正落盘,避免产生只有用户消息、没有 AI 响应的空 session 文件。

各 Entry 类型的写入时机

sdk.ts(session 初始化)

  • appendModelChange — 新 session 创建时写入初始模型
  • appendThinkingLevelChange — 新 session 创建时写入初始 thinking level(续写 session 若无记录则补写)

agent-session.ts(主体)

方法 触发时机
appendMessage Agent message_end 事件自动写入;bash 执行完成后写入执行记录
appendThinkingLevelChange /thinking 命令、setThinkingLevel()、applyThinkingLevel()
appendModelChange setModel()、switchModel()(/model)、cycleModel()(Ctrl+P)
appendCompaction /compact 命令、自动压缩完成后
appendCustomEntry 扩展调用 persistCustomEntry() 存储私有状态
appendCustomMessageEntry 扩展主动注入 custom message;Agent 事件检测到 custom 消息
appendSessionInfo /session rename;扩展设置 session 名称
appendLabelChange setLabel();分支后给 summary entry 打标签;扩展操作 label
branchWithSummary 历史树导航时选择生成摘要(内含 branch_summary entry)

interactive-mode.ts(UI 层)

  • appendLabelChange — 用户在 TUI 中直接操作 label
  • appendSessionInfo — TUI 中设置 session 名称

Session 文件与 LLM 上下文的关系

核心设计

session 文件是只写的持久化日志,Agent 内存(_state.messages)是读写的工作状态。LLM 每次收到的上下文来自 Agent 内存,不是实时从 session 文件读取。

启动 / 切换 session
    ↓
buildSessionContext()    ← 读 session 文件,构造 AgentMessage[]
    ↓
agent.replaceMessages()  ← 装入 Agent 内存

─────────────────────────────────────
正常对话过程(不读文件):

用户发消息
    ↓
agent.prompt()           ← 用 _state.messages 构造 LLM 请求
    ↓
LLM 响应 → message_end 事件
    ↓
agent._state.messages.push()    ← 追加到内存
sessionManager.appendMessage()  ← 同时 append 写入文件(持久化)

buildSessionContext() 的调用时机

只在需要重建 Agent 内存状态时调用,调用后立即执行 agent.replaceMessages():

触发场景
createAgentSession() 初始化时,有已有 session 则恢复历史消息
newSession() setup 回调后,同步 agent 状态
手动或自动压缩完成后,用压缩后的上下文替换 agent 消息
switchSession() 切换到另一个 session 文件后
fork() 创建新 session 文件后
navigateTree() 在历史树中导航后

buildSessionContext() 内部逻辑

从 session entry 树重建 AgentMessage[] 的过程:

  1. 找到 leaf:从 leafId 出发(未指定时取最后一条 entry)
  2. 回溯路径:沿 parentId 链从 leaf 走到 root,收集完整路径
  3. 扫描路径提取元数据:
    • thinking_level_change → 更新 thinkingLevel
    • model_change 或 assistant 消息 → 更新 model
    • compaction → 记录压缩点
  4. 构建消息列表:
    • 有 compaction:先注入压缩摘要消息,再从 firstKeptEntryId 开始追加保留的消息,最后追加压缩点之后的所有消息
    • 无 compaction:按路径顺序追加所有 message、custom_message、branch_summary entry(label、model_change、thinking_level_change 不进消息列表)

为什么 compaction 之前还要保留一部分消息?

firstKeptEntryId 划了一条线:线之前的消息被压进摘要丢弃,线之后到 compaction entry 之间的消息原样保留。这是因为 compaction 只压缩较早的历史,最近的消息(如正在进行中的 tool call 序列、刚刚发生的操作)不适合被压缩——压缩后语义会损失,LLM 需要看完整的原始内容才能正确继续对话。

简言之:摘要覆盖较早的历史,最近的消息原样保留,保证 LLM 对近期上下文有完整感知。


Fork、/fork、Branch 的区别

三个概念名字相似,但操作层次完全不同。

--fork CLI flag → SessionManager.forkFrom()

跨项目复制 session。把另一个项目的整个 session 文件完整复制到当前项目目录,产生新文件,新 header 的 parentSession 记录源文件路径,两者之后完全独立。

TUI /fork 命令 → AgentSession.fork()

同项目内,从选中的某条用户消息处创建新 session 文件:

  • 选中消息有父节点 → createBranchedSession(parentId):把 root 到该消息父节点的路径复制到新文件
  • 选中消息是第一条 → newSession():全新空 session

选中的用户消息本身不复制,而是填回编辑器供重新编辑发送。

TUI /tree 历史树导航 → SessionManager.branch()

不产生新文件,在同一 .jsonl 文件内移动 leafId 指针,之后新消息从新位置长出新分支,旧分支仍保留在文件中。

对比表

--fork CLI /fork TUI /tree 导航
产生新文件 ✓(跨项目复制) ✓(同项目新文件) ✗(同一文件内)
历史来源 完整复制源文件 复制到选中消息之前的历史 leafId 指针移动
选中消息本身 保留 不复制,填回编辑器 保留(作为新分支起点)
典型用途 把别的项目的对话带过来 从某个问题重新开始问 探索不同回答方向

/tree 导航完整流程

用户执行 /tree 命令并选中历史节点后:

步骤一:询问是否生成摘要

TUI 弹出选项:No summary / Summarize / Summarize with custom prompt。若 settings 中设置了 branchSummarySkipPrompt 则跳过。

步骤二:navigateTree(targetId, { summarize }) 核心逻辑

  • 收集被放弃的分支:collectEntriesForBranchSummary() 找出当前 leaf 到目标节点公共祖先之间将被抛弃的 entry

  • 通知扩展:触发 session_before_tree 事件,扩展可取消、提供自定义摘要或修改摘要指令

  • 生成摘要(可选):调用 LLM 对被放弃的分支生成摘要,记录涉及的 readFiles / modifiedFiles

  • 确定新 leafId:

    目标节点类型 新 leafId 额外处理
    user 消息 该节点的 parentId 用户消息文本填回编辑器
    custom_message 该节点的 parentId 内容填回编辑器
    其他(assistant 等) 目标节点本身 无

    选择用户消息时 leaf 落在父节点,因为用户将重新编辑并发送这条消息作为新 entry。

  • 移动 leaf 指针:

    • 有摘要 → branchWithSummary(newLeafId, summaryText),写入 branch_summary entry
    • 无摘要且 leafId=null → resetLeaf()
    • 无摘要且 leafId≠null → branch(newLeafId),仅移动指针不写新 entry
  • 重建 Agent 内存:buildSessionContext() + agent.replaceMessages()

  • 通知扩展:触发 session_tree 事件

步骤三:TUI 刷新界面

重新渲染对话历史,若有 editorText 则填回编辑器。