# 第四章：会话管理与上下文压缩

> 对应源文件：
> - `packages/coding-agent/src/core/session-manager.ts`
> - `packages/coding-agent/src/core/compaction/compaction.ts`

## 会话管理

### 存储格式：JSONL + 树形结构

每个会话是一个 `.jsonl` 文件。每行一条 JSON 记录：

```jsonl
{"type":"session","version":3,"id":"0192...","timestamp":"2024-01-15T10:30:00Z","cwd":"/project"}
{"type":"message","id":"a1b2c3d4","parentId":null,"message":{"role":"user","content":"hello",...},"timestamp":"..."}
{"type":"message","id":"e5f6g7h8","parentId":"a1b2c3d4","message":{"role":"assistant","content":[...],...},"timestamp":"..."}
{"type":"thinking_level_change","id":"i9j0k1l2","parentId":"e5f6g7h8","thinkingLevel":"high","timestamp":"..."}
```

### 为什么是树形结构

传统聊天记录是线性的：A → B → C → D → E。

Pi 支持**分支**。你可以从 C 点创建一个新分支：

```
A → B → C → D → E          (主分支)
         └→ F → G → H      (新分支)
```

每条记录通过 `id` 和 `parentId` 建立父子关系。最后一条记录叫 **leaf**（叶子节点）。从 leaf 回溯到根节点，就是当前对话的完整路径。

### 记录类型

| 类型 | 说明 |
|------|------|
| `session` | 文件头部，包含版本号、ID、cwd |
| `message` | 消息（user/assistant/toolResult/bashExecution/custom/...） |
| `thinking_level_change` | 思考级别变更记录 |
| `model_change` | 模型切换记录 |
| `compaction` | 压缩摘要（包含被压缩消息的 summary） |
| `branch_summary` | 分支切换时的摘要 |
| `custom` | 扩展自定义数据（不参与 LLM 上下文） |
| `custom_message` | 扩展自定义消息（参与 LLM 上下文） |
| `label` | 用户定义的书签/标签 |
| `session_info` | 会话元数据（如显示名称） |

### buildSessionContext -- 从树构建 LLM 上下文

这是最关键的函数：给定一棵记录树和一个 leaf ID，构建 LLM 需要的消息列表。

```
步骤：
1. 从 leaf 回溯到根，得到路径 [root, ..., leaf]
2. 沿途收集 settings（thinking_level、model）
3. 找到最新的 compaction 记录（如果有）
4. 构建消息列表：
   - 如果有 compaction:
     a. 先放 compaction summary（作为用户消息）
     b. 再放 firstKeptEntryId 到 compaction 之间的保留消息
     c. 再放 compaction 之后的所有消息
   - 如果没有 compaction:
     a. 直接放所有消息
```

### SessionManager 类

`SessionManager` 管理文件 I/O 和树形操作：

| 创建方式 | 说明 |
|----------|------|
| `SessionManager.create(cwd)` | 新建会话 |
| `SessionManager.open(path)` | 打开已有会话文件 |
| `SessionManager.continueRecent(cwd)` | 继续最近的会话 |
| `SessionManager.inMemory()` | 内存中不持久化 |
| `SessionManager.forkFrom(path, cwd)` | 从已有会话 fork |

核心操作：

| 方法 | 说明 |
|------|------|
| `append(entry)` | 追加记录（自动设 id 和 parentId） |
| `setLeaf(id)` | 导航到指定记录（改变 leaf 指针） |
| `buildSessionContext()` | 构建当前路径的 LLM 上下文 |
| `getTree()` | 获取完整的树结构 |
| `getBranch(id)` | 获取从根到指定节点的路径 |

文件是 **append-only**（只追加）。`setLeaf` 不会删除任何记录，只是改变"当前位置"。历史记录永远保留。

### 数据迁移

session 文件有版本号。当格式升级时，加载时自动迁移：

- **v1 → v2**：添加 `id`/`parentId` 树结构（旧版是线性的）
- **v2 → v3**：重命名 `hookMessage` 角色为 `custom`

迁移是原地进行的：加载时检测版本，修改内存中的数据，重写文件。

---

## 上下文压缩（Compaction）

### 为什么需要

LLM 有 context window 限制（比如 Claude 的 200K tokens）。长对话会超出限制，导致 API 调用失败。

### 什么时候触发

```typescript
shouldCompact(contextTokens, contextWindow, settings) {
  return contextTokens > contextWindow - reserveTokens;
}
```

默认 `reserveTokens = 16384`。也就是说，当已用 token 数超过 `contextWindow - 16384` 时触发。

### 压缩流程

```
1. 准备阶段（prepareCompaction）
   ├── 找到上一次压缩记录（如果有）
   ├── 估算当前 token 数
   ├── 找到切割点（保留最近 ~20000 tokens 的消息）
   ├── 分出两部分：
   │   ├── messagesToSummarize（旧消息，将被摘要替代）
   │   └── keptMessages（新消息，保持不变）
   └── 提取文件操作记录（read/edit/write 过哪些文件）

2. 生成摘要（compact / generateSummary）
   ├── 把旧消息序列化为文本
   ├── 如果有上一次的摘要 → 用"更新摘要"的 prompt
   ├── 如果没有 → 用"初始摘要"的 prompt
   ├── 调用 LLM 生成结构化摘要：
   │   ## Goal
   │   ## Constraints & Preferences
   │   ## Progress
   │   ## Key Decisions
   │   ## Next Steps
   │   ## Critical Context
   └── 附加文件操作列表（哪些文件读过/改过）

3. 保存（session-manager 处理）
   ├── 创建 CompactionEntry
   │   ├── summary: 生成的摘要文本
   │   ├── firstKeptEntryId: 保留部分的第一条记录 ID
   │   ├── tokensBefore: 压缩前的 token 数
   │   └── details: { readFiles, modifiedFiles }
   └── 追加到 session 文件

4. 重建上下文
   ├── buildSessionContext 检测到 compaction 记录
   ├── 用 summary 替代旧消息
   └── 上下文大幅缩小
```

### 切割点算法

```
从最新消息往前累计 token 数
当累计超过 keepRecentTokens (20000) 时，找到最近的合法切割点

合法切割点：user 消息、assistant 消息、custom 消息
不合法切割点：toolResult 消息（必须跟在 tool call 之后）

特殊情况 - 分裂 turn：
如果切割点在一个 turn 的中间（比如在某个 tool result 之后、
下一个 assistant 消息之前），需要对 turn 的"前半段"也生成
一个摘要，叫做 "turn prefix summary"。
```

### 增量压缩

如果之前已经压缩过，新的压缩使用**增量更新** prompt：

```
"这是新的对话消息，请更新之前的摘要。
 保留所有旧信息，添加新进展，
 把完成的任务从 In Progress 移到 Done..."
```

这比每次重新摘要整个对话要高效得多。

---

## 系统提示构建

`system-prompt.ts` 负责动态组装 system prompt：

```
buildSystemPrompt({
  selectedTools: ["read", "bash", "edit", "write"],
  toolSnippets: { read: "Read file contents", ... },
  promptGuidelines: ["Use read instead of cat"],
  contextFiles: [{ path: "AGENTS.md", content: "..." }],
  skills: [{ name: "code-review", ... }],
  cwd: "/project",
})
```

输出结构：

```
You are an expert coding assistant...

Available tools:
- read: Read file contents
- bash: Run shell commands
- edit: Edit file contents
- write: Write file contents

Guidelines:
- Use read to examine files instead of cat or sed
- Be concise in your responses
- Show file paths clearly when working with files

Pi documentation...

# Project Context
## AGENTS.md
(项目级规则的内容)

# Skills
(可用的 skill 指令)

Current date: 2024-01-15
Current working directory: /project
```

如果用户通过 `--system-prompt` 提供了自定义 prompt，则替代默认 prompt（但仍然附加 context files 和 skills）。

---

## 小结

```
会话管理
├── JSONL 文件格式（每行一条记录）
├── 树形结构（id + parentId）
│   ├── 支持分支和导航
│   └── append-only 不丢失历史
├── buildSessionContext → 从树构建 LLM 上下文
└── 版本迁移（v1 → v2 → v3）

上下文压缩
├── 触发条件：超过 contextWindow - reserveTokens
├── 切割策略：保留最近 ~20000 tokens
├── 摘要生成：结构化 LLM 摘要 + 文件操作列表
├── 增量更新：基于上次摘要追加修改
└── 存储：CompactionEntry 追加到 session 文件

系统提示
├── 动态组装：根据启用的工具生成
├── 上下文文件：.pi/ 或 AGENTS.md
├── Skills：可复用的指令片段
└── 可自定义：--system-prompt 覆盖
```
