Pi 如何用 entry 树保存会话并构建 Context

📅
3 分钟阅读
·

本文是「Pi 源码拆解」系列第 3 篇。系列目录:

第 2 篇说明了运行时的结算条件:agent_end 发出后,需等待所有 listener settle,持久化写入也在结算范围内。本文分析 pi 如何记录会话数据,以及这些记录如何支持恢复、分支和 compaction。会话中的消息、模型切换、思考等级和工具集变更都会写成 entry。

消息数组缺少配置状态与分支关系

多数 harness 将会话保存为消息数组,模型、思考等级和激活工具等状态保存在其他运行时对象中。Claude Code 的会话文件也主要是消息日志,模型切换等状态变化不在日志中。

恢复会话时,旁路状态需要单独存取,否则会丢失。消息数组也无法说明一段回答生成时所用的模型和工具集。因此,历史消息不足以重放完整的会话状态。

pi 将这些状态变化也写成 entry。会话保存的对象是一棵 entry 树,模型切换、思考等级变更和消息处于同一层级,并全部落盘。

entry 的 parentId 表示分支路径

entry 类型共 11 种,定义在 packages/agent/src/harness/types.ts:453-464:message、thinking_level_change、model_change、active_tools_change、compaction、branch_summary、custom、custom_message、label、session_info、leaf。每个 entry 都带有 idparentIdtimestamptypes.ts:375-380)。parentId 只保存父节点 ID。从任意 entry 沿 parentId 回溯,可以得到从该节点到根的路径;多个 entry 使用同一个 parentId 时,记录构成树。

树的根节点为第一条 user message。pi 将 system prompt 保存在 AgentHarness.systemPrompt:每次 turn 通过 createTurnState() 单独解析,再将它作为请求的 systemPrompt,连同从 Session 重建的 messages 一起传给模型(packages/agent/src/harness/agent-harness.ts:395-438)。system prompt 参与每次模型调用,但不作为 message entry 写入 session 树;树中保存可回放的 transcript 与状态变化。

export interface SessionTreeEntryBase {
  type: string;
  id: string;
  parentId: string | null;
  timestamp: string;
}

export interface LeafEntry extends SessionTreeEntryBase {
  type: "leaf";
  targetId: string | null;
}
pi 的 entry 树结构

Session 要同时支持追加记录和跳回历史位置,因此区分运行时的当前位置 leafId 与持久化的 leaf 跳转记录。

  • Session.leafId 是内存中的当前写入位置。它保存一个 entry ID,buildContext() 从该 ID 沿 parentId 回溯,得到当前发送给模型的分支。正常对话时,它指向刚追加的普通 entry,例如最后一条 assistant message。
  • leaf entry 是写入 JSONL 的跳转记录。它也在旧分支上有自己的 idparentId,但 targetId 指向要跳转的历史节点。追加它之后,Session 将内存中的 leafId 更新为 targetId

例如当前停在旧分支的 e5-old 时,leafId = e5-old。执行 moveTo(e4) 会先将 e6 = { type: "leaf", parentId: "e5-old", targetId: "e4" } 追加到文件,记录跳转的起点和目标;随后内存中的 leafId 变为 e4。此后追加 e5 时,代码将 e5.parentId 填为当前的 e4,新分支从这里开始。跳转不会修改旧 entry,后续写入从目标节点创建另一条分支。

普通 entry 的 parentId 使用当前 leafId,提交后新的 id 成为 leafId;提交 leaf entry 后,leafId 改为其 targetId

private enqueueAppend<TEntry extends SessionTreeEntry>(
  createEntry: (base: Pick<SessionTreeEntry, "id" | "parentId" | "timestamp">) => TEntry,
): Promise<TEntry> {
  const commit = this.appendTail.then(async () => {
    const entry = createEntry({
      id: await this.createEntryId(),
      parentId: this.leafId,
      timestamp: new Date().toISOString(),
    });
    await this.storage.appendEntry(entry);
    this.leafId = entry.type === "leaf" ? entry.targetId : entry.id;
    return entry;
  });
  // ...
}

跳回 e4 后,moveTo(e4) 先追加一条 leaf 记录,后续 entry 再从 e4 创建新分支。leaf entry 可能保留在旧分支末尾,而 Session.leafId 已指向跳转目标;后续的 branch_summary 或 message 的 parentId 才构成新分叉。旧分支保留在同一 session 文件中,无需复制原有路径。/fork 创建独立会话文件时,才会复制选定路径。

async moveTo(entryId: string | null, summary?: Summary): Promise<string | undefined> {
  await this.setLeafId(entryId);
  if (!summary) return undefined;
  return this.appendTypedEntry((base) => ({
    ...base,
    type: "branch_summary",
    fromId: entryId ?? "root",
    summary: summary.summary,
  }));
}

并发写入通过 promise 链串行化。enqueueAppend 将每次追加接到 appendTail 末尾(session.ts:237-255),同一时刻只有一个 append 执行,因此 parentId 始终基于确定的 leafId 计算,无需锁。链尾的错误处理允许后续写入继续执行:appendTail 每次都以处理 reject 的 then 收尾(session.ts:250-253)。单次写盘失败会使当前调用失败,但不会阻塞后续追加。

buildContext()session.ts:205)从 leaf 沿 parentId 回溯。readPathToRootOrCompaction 会回溯到最近的 compaction 边界(packages/agent/src/harness/session/array-session-index.ts:167-189),再将路径投影为消息。defaultContextEntryTransform 将 compaction 之前的历史替换为摘要(session.ts:61-92);deriveSessionContextState 从头重放整条路径,遇到 thinking_level_change、model_change、active_tools_change 时更新当前值,最终得到 model、thinkingLevel、activeToolNames(session.ts:41-59)。entry 日志是恢复和推导 Session context 状态的依据。

JSONL 会话文件的追加与恢复

存储后端由可插拔的 SessionStorage 接口抽象(types.ts:559-571)。契约包括读取 entry、追加、按分支查询和回溯路径。JSONL 文件后端用于真实会话,内存后端用于测试(packages/agent/src/harness/session/memory-repo.ts),Session 类不依赖具体实现。

默认会话目录是 ~/.pi/agent/sessions/--<cwd>--/,每个工作目录对应一个子目录。cwd 会去掉开头的路径分隔符,并将 /\\: 替换为 -jsonl-repo.ts:186-188)。单个会话文件名为 <ISO 时间戳>_<session id>.jsonl,时间戳中的 :. 也替换为 -jsonl-repo.ts:190-205)。同一项目的会话按文件归档。

文件采用 JSONL 格式,每行一个 JSON 对象。第一行是声明文件版本和会话元数据的 header,后续每行是一条 entry。parentSession 仅在 fork 创建独立文件时写入,用于指回来源文件,不等同于树节点的 parentId。

{"type":"session","version":3,"id":"01...","timestamp":"2026-06-20T15:00:00.000Z","cwd":"/work/blog"}
{"type":"message","id":"e1","parentId":null,"timestamp":"...","message":{"role":"user","content":"解释这个项目"}}
{"type":"message","id":"e2","parentId":"e1","timestamp":"...","message":{"role":"assistant","content":[...]}}
{"type":"model_change","id":"e3","parentId":"e2","timestamp":"...","provider":"...","modelId":"..."}

文件顺序是 entry 写入 JSONL 的 append 顺序;会话顺序由 parentId 回溯确定。跳回历史节点时,leaf entry 仍追加在文件末尾,新分支的 entry 则以跳转目标为 parentId。顺序读取文件得到追加日志,按 parentId 查询才能得到当前分支或整棵树。

创建会话或 fork 时,JSONL 后端一次性写入 header 和当时已有的 entry。新会话通常只有 header(jsonl-repo.ts:374-385)。之后每条 entry 都通过 appendFile(JSON.stringify(entry) + "\\n") 直接追加(jsonl-repo.ts:275-294)。

重启时,后端按文件顺序解析 JSONL 中所有非空行,校验 header、JSON 和 entry ID 的唯一性,然后构造 new ArraySessionIndex(entries)jsonl-repo.ts:169-183:248-252)。ArraySessionIndex.replace() 不会按照 parentId 重排文件,而是顺序扫描追加日志,并构建三类内存数据:完整的 entries 数组用于树浏览,byId: Map<id, entry> 用于 O(1) 查找父节点,以及 name、label、usage/statistics 等投影(array-session-index.ts:82-99)。

恢复当前分支不需要保存子节点关系。parentId 只表示子节点到父节点的单向引用;分叉点也不存在唯一的下一级节点,例如 e3 可以同时拥有 e4 和 e9 两个 child。Pi 先通过追加日志末尾的状态恢复会话终点 leafId,再从终点沿 parentId 回溯并反转路径,不需要从根节点选择某个 child。

顺序扫描时,每读到普通 entry,nextLeafId 更新为 entry.id;每读到 leaf entry,nextLeafId 更新为 entry.targetId。文件最后一个 entry 所给出的普通 entry ID 或 leaf targetId,即恢复后的当前 leaf;readHead() 会检查该 ID 是否存在(array-session-index.ts:86-105)。例如文件顺序为 e1, e2, e3, e4, e5-old, e6 = { type: "leaf", targetId: "e3" }, e7:扫描 e6 时,终点从旧分支的 e5-old 切回 e3;扫描 e7 后终点更新为 e7。无需维护 e3.childIds = [e4, e7]。旧分支仍保存在 byId 和 entries 数组中。

取当前 context 时,代码从 leafId 开始调用 byId.get(current.parentId),回溯出 e7 → e3 → e2 → e1,再反转为根到叶的顺序(array-session-index.ts:167-185)。查询只需向上查找父节点。/tree 展示某节点的 child 时,才遍历完整的 entries 数组,筛选 parentId === 该节点 id 的记录,或由其他实现临时建立分组。ArraySessionIndex 没有维护 childIds,因为恢复 context 使用的是从确定 leaf 向上回溯的路径。

版本字段目前为 v3:v1 是线性序列,v2 增加 id/parentId 后成为树,v3 将 hookMessage 改名为 custompackages/coding-agent/docs/session-format.md:19-27)。旧文件加载时自动迁移。migrateV1ToV2 为每个 entry 补充 id 和 parentId,migrateV2ToV3 做角色改名(packages/coding-agent/src/core/session-manager.ts:231-292);迁移后会重写整个文件(session-manager.ts:917-919)。

写入路径还有缓冲。pendingSessionWrites 队列定义在 packages/agent/src/harness/agent-harness.ts:186。运行中的 mutation 不直接写盘:harness 忙碌时,setModelsetThinkingLevel 等调用只向队列写入记录,例如 setModel 的入队逻辑在 agent-harness.ts:951-955flushPendingSessionWrites() 定义在 agent-harness.ts:554-578,会在下一轮 turn 前、turn_endagent_end 和兜底执行路径中调用。turn_end 在 flush 后发送 save_point 事件(agent-harness.ts:586-597)。这些 flush 点以 turn 为单位完成持久化,减少运行中只写入半个 turn 的情况;相应地,写入流程增加了缓冲队列、flush 时机和 save point 事件。

分支导航、fork 与 branch_summary

树结构提供了分支导航需要的路径关系。/tree 打开树形选择器,在节点间跳转并支持按文本搜索。/fork 选中一条用户消息,将该消息之前的路径复制为新会话文件;选中对象必须是用户消息(packages/agent/src/harness/types.ts:532-533),新文件 header 的 parentSession 指向原文件。/clone 在当前位置复制整个会话(packages/coding-agent/src/core/slash-commands.ts:31-33)。进程级恢复由 pi -c(继续上次会话)和 pi -r(在选择器中选择会话,也可删除旧会话)提供(packages/coding-agent/src/cli/args.ts:85-88)。跳到用户消息节点时,若输入框为空,该消息文本会回填到输入框(packages/coding-agent/src/modes/interactive/interactive-mode.ts:1736-1740),修改后重新发送不会改变旧分支。label entry 用于标记树中的节点。

/tree 跳转时可以选择 summarize。harness 收集旧 leaf 到共同祖先之间的分支内容,请求模型生成摘要,再通过 moveTo 在新分支上写入 branch_summary entry(agent-harness.ts:876-925session.ts:400-421)。该摘要以 user 角色消息进入后续 context,开头包含出处说明:“The following is a summary of a branch that this conversation came back from”(packages/agent/src/harness/messages.ts:12)。摘要还记录被跳过分支中读过和改过的文件(packages/agent/src/harness/compaction/branch-summarization.ts:23-28)。

消息数组若要实现这一功能,需要额外定义被跳过分支的范围。entry 树中,该范围是旧 leaf 到共同祖先之间的路径;摘要功能本身仍依赖 UI、模型调用和 entry 写入。

branch_summary 的写入与恢复

分支摘要:旧分支保留,摘要作为新分支上的 entry 进入 context

navigateTree(targetId, { summarize: true }) 获取旧 leafId(e7)和目标 targetId(e3)后,调用 collectEntriesForBranchSummary。它先用 getBranch(oldLeafId) 获取旧 leaf 到根的路径,再用 getBranch(targetId) 获取目标路径,从目标路径末尾向前找到同时出现在旧路径中的节点,作为 commonAncestorIdbranch-summarization.ts:71-87)。随后从旧 leaf 沿 parentId 回溯到 commonAncestorId,将沿途 entry 反转为祖先到旧 leaf 的顺序,即要摘要的内容。在图中,范围为 e4 → e5 → e6 → e7

prepareBranchEntries 从尾部累加 message,跳过 toolResult,并累积读过和改过的文件清单(:127-166)。generateBranchSummary 将这些消息放入 <conversation> 块,拼接包含 Goal、Constraints、Progress、Key Decisions、Next Steps 的固定 prompt(:173-200),以 completeSimpleWithRetries 请求摘要模型。响应文本会拼接 BRANCH_SUMMARY_PREAMBLE(“The user explored a different conversation branch before returning here”)和文件清单(:264-267)。摘要模型只接收这段分支的内容,不接收目标分支之后的对话。

旧分支 entry 不会删除。navigateTree 计算 newLeafId:跳到用户消息时使用其 parentId,以便将该消息放回输入框;跳到其他节点时直接使用 targetId。随后调用 session.moveTo(newLeafId, summary...)agent-harness.ts:911-921)。moveTo 先通过 setLeafId(entryId) 将运行时 leafId 更新为 newLeafId,再用 appendTypedEntry 追加 branch_summary entry。该 entry 的 parentId 是更新后的 leafIdfromId 记录此次跳转目标(session.ts:400-421)。因此 branch_summary 挂在新分支上。被跳过的 e4..e7 仍保存在 JSONL、byIdentries 数组中,可通过 /tree 再次跳转。

BranchSummaryEntrySessionTreeEntryBaseidparentIdtimestamp 外,还保存 summaryfromIddetails{ readFiles, modifiedFiles })、usagefromHook。它和普通 entry 一样通过 appendFile(JSON.stringify(entry) + "\n") 追加到 JSONL 末尾。文件顺序中,它位于旧分支 entry 之后和新分支后续对话之前;parentId 树中,它的父节点是 e3,leaf entry 的父节点是 e7。

重启后,后端顺序扫描 JSONL。ArraySessionIndex.replace 将 entry 写入 byIdentries,同时维护 nextLeafId:普通 entry 使用 entry.id,leaf entry 使用 entry.targetIdarray-session-index.ts:86-105)。扫描结束后,leafId 位于最后追加的新分支上。取当前 context 时,代码从 leafId=e10 沿 parentId 回溯 e10 → e9(branch_summary) → e3 → e2 → e1buildContextEntries 将 branch_summary 投影为 role: branchSummary 消息,再由 convertToLlm 使用 BRANCH_SUMMARY_PREFIX + summary + BRANCH_SUMMARY_SUFFIXmessages.ts:141-146)包装为 user 角色文本并注入 context。旧分支 e4..e7 不在回溯路径中,因此不会进入当前 context,但仍可在 /tree 中查看。branch_summary 改变的是 context 投影,日志仍保留原始 entry。

Compaction 的触发、切割与摘要

长会话需要压缩以控制上下文长度。pi 的 compaction 代码主要位于 packages/agent/src/harness/compaction/compaction.ts。CLI 当前使用 packages/coding-agent/src/core/compaction/ 下的同源实现;仓库正从 coding-agent 向 agent 包迁移,两处逻辑一致,以下引用以 agent 包为准。

compaction 的切割与摘要流程

estimateContextTokens 不引入 tokenizer。它使用最后一条有效 assistant 消息的 provider usage 作为估算基准,该值反映了当时 provider 计算的上下文大小;该消息之后的 trailing 消息按字符数除以 4 估算,图片按 4800 字符折算(compaction.ts:232-260:268-284)。估算存在误差,但触发判断只需要量级正确,计算不依赖额外组件。

shouldCompact 是纯函数。当 contextTokens 超过 contextWindow - reserveTokens 时返回 true,默认 reserveTokens 为 16384,keepRecentTokens 为 20000(compaction.ts:263-266:174-178)。agent 包提供判定和手动 compact(),自动触发由上层 coding-agent 完成(packages/coding-agent/src/core/agent-session.ts:2038)。后者还处理最后一条消息为 error 或 usage 全零的情况:回退到估算值判断,避免持续报错的会话无法触发压缩(agent-session.ts:2019-2036)。

findCutPoint 从尾部向前累加 token,到达 keepRecent 预算后停止(compaction.ts:396-444)。findValidCutPoints 预先排除 toolResult(compaction.ts:344-346)。若从 toolResult 前面切割,保留侧会包含没有对应 tool call 的孤立 tool result,多数 provider 会拒绝请求。候选集合不包含这类位置。

预算耗尽点落在某个 turn 中间时,findTurnStartIndex 会找到该 turn 的起点(compaction.ts:369-383),并将其标记为 split turn。被切掉的 turn 前缀会用专用 prompt 单独摘要,内容是为保留的后半段提供所需上下文(TURN_PREFIX_SUMMARIZATION_PROMPTcompaction.ts:715-728),之后再与历史摘要拼接(compaction.ts:762-796)。这种处理避免将未完成 turn 的前缀和已完成历史混合,并减少将中间状态写成既定事实的风险。

路径上存在上一轮 compaction 时,代码从中取出摘要作为 previousSummary(compaction.ts:656-664)。本轮只将新增历史交给 UPDATE prompt,在旧摘要上更新:保留既有条目、将 In Progress 中已完成的项目移到 Done,并更新 Next Steps(compaction.ts:483-520)。全量重算需要重新读取全部历史,token 成本随会话长度线性增长;增量更新只处理上次压缩后的新增记录。

代码会扫描被压缩历史中的工具调用,提取读过和改过的文件,并将 <read-files><modified-files> 附在摘要文本末尾(compaction.ts:815-816packages/agent/src/harness/compaction/utils.ts:62-73),同时将结构化数据写入 entry 的 detailscompaction.ts:824)。摘要可能遗漏具体文件路径,文件清单保留继续执行任务时需要的操作记录。

摘要请求的 completeSimpleWithRetries 强制设置 cacheRetention: "none",并使用独立的 sessionId(compaction.ts:126-131)。代码注释说明摘要是一次性请求,写入的 prompt cache 不会复用,因此不计入主会话的缓存亲和和计价。

compaction 摘要会写成 compaction entry,原始历史不会删除,/tree 仍可跳回原文。compaction 影响当前 Context 中的历史投影,不修改日志。

切换 provider 时保留消息元数据

entry 树定义了会话记录的结构,模型调用还需要处理目标 provider。pi 的 AssistantMessage 带有 api、provider、model 三个字段(packages/ai/src/types.ts:399-413)。thinking 块携带 thinkingSignature,例如 Anthropic 的签名、OpenAI 的 reasoning item ID;安全过滤后的 thinking 标记为 redacted,加密 payload 原样保存在 signature 字段中(packages/ai/src/types.ts:344-353)。harness 不解释这些签名的内容,但会保存并回传,因为 provider 在多轮续接时需要它们。

消息保存出处后,会话可以在中途更换模型或 provider。model_change entry 记录切换动作;deriveSessionContextState 重放时也能从 assistant 消息本身恢复模型信息(session.ts:51-52)。将 A provider 格式的历史转换为 B provider 可接受的格式由 transformMessages()packages/ai/src/api/transform-messages.ts:64)处理,其中包括图片降级、ID 归一化和孤立 tool call 修补规则。

Session、Context 与模型消息的派生关系

entry 树、JSONL、compaction 和分支摘要处在不同层次。Session 保存完整且可持久化的会话历史;Context 按当前 leaf 从历史构建单次 turn 的状态快照;模型消息数组是 Context 交给 provider 的对话输入。数据按“日志 → 当前路径 → 模型请求”的方向派生,并非维护三份独立数据。

Session、Context 与模型消息的类和数据关系

Session 负责追加、跳转与路径读取

Sessionpackages/agent/src/harness/session/session.ts:152-422)不等同于文件或消息数组。它持有运行时 leafId 和串行写入使用的 appendTail,提供 appendMessage()moveTo()getBranch()buildContext() 等操作,并且仅依赖 SessionStorage 接口。每次追加由 enqueueAppend() 创建 id,将当时 leafId 写入新 entry 的 parentId,交由 storage 落盘,再把运行时 leaf 更新为新 entry,或更新为 leaf entry 的 targetId:237-254)。

SessionRepository 负责 Session 生命周期,接口包括 createopenlistdeleteforksession/repository.ts:22-32)。真实运行时由 JsonlSessionBackend 实现该接口,创建或打开 JSONL 文件,并为已打开文件维护 ArraySessionIndex;测试可以替换为内存后端。Session 因此不需要知道数据来自文件还是内存。

Storage 与 Index 保存 entry 并提供查询

SessionStorage 是单个已打开会话的读写契约:读取 head、按 ID 读取 entry、追加 entry、从 leaf 回溯路径、查询分支、读取 label/name/stats(types.ts:558-571)。JsonlSessionBackend.storage() 将调用转给对应文件的 ArraySessionIndex;写入时同时追加 JSONL 并更新索引(jsonl-repo.ts:395-415)。

ArraySessionIndex 是文件内容的内存索引。它保存按 append 顺序排列的 entries、按 ID 查找的 byId、扫描恢复的 leafId,以及 label、名字、token/cost 等投影(array-session-index.ts:60-99)。它可以从指定 leaf 沿 parentId 查询路径,但不控制当前 turn;当前 turn 由 SessionAgentHarness 组织。compaction 或分支摘要改变模型看到的内容时,原始 entry 仍保存在 index 和 JSONL 中。

Context 从当前路径重新计算

每次 turn 开始时,Session.buildContext() 从当前 leafId 调用 getBranch(),得到根到当前 leaf 的 SessionTreeEntry[],再调用 buildSessionContext()session.ts:140-150:201-206)。该过程包括:

  1. deriveSessionContextState() 按顺序重放路径中的 thinking_level_changemodel_changeactive_tools_change 等状态 entry,得出当前的 model、thinkingLevel、activeToolNames(:41-59)。
  2. defaultContextEntryTransform() 根据最近的 compaction entry 替换早期历史,只保留 compaction 摘要与尾部记录;存在额外 entryTransforms 时,也在此执行(:61-102)。
  3. sessionEntryToContextMessages() 将保留的 entry 转换为 AgentMessage[]:message entry 直接提供消息;compaction 和 branch_summary 变为摘要消息;状态、label、leaf 等不生成模型消息(:105-137)。

最终 SessionContext{ messages, model, thinkingLevel, activeToolNames }types.ts:466-471)。它在每次 buildContext() 时重新计算,不单独写入文件。切换 leaf、分支或新增 entry 后,下一次调用会得到相应的 Context。日志保存全部路径,Context 对应当前选择的一条路径。

AgentHarness 将 Context 组合为模型请求

AgentHarness.createTurnState() 在每轮开始时调用 this.session.buildContext(),将 context.messages 放入 turn state(agent-harness.ts:395-428)。随后 createContext() 拷贝这些 messages、绑定当前启用的 tools,再将单独生成的 systemPrompt 放入 AgentContext:431-440),最后交给 models.streamSimple()。第一个持久化树节点通常是 user message,原因是 system prompt 不属于 Session entry 树,也不作为普通历史消息放入 SessionContext.messages;harness 在每个 turn 单独生成该请求字段。

模型返回后,harness 将 user、assistant、tool call/result 和状态变化写成 entry,并更新 Session leaf。下一轮再次执行“全量日志 → 当前路径 → Context → 模型请求”的投影。模型消息数组不能反向作为事实来源重建 JSONL。

entry 树的恢复范围与实现成本

Pi 将会话历史保存为可追加、可回溯的 entry 树。消息、模型切换、思考等级、工具集和摘要使用同一种记录写入持久化日志。重启后,系统可从日志恢复当前分支及其状态;跳转历史节点不会覆盖旧记录;compaction 与分支摘要只改变当前 Context 的投影,不修改原始历史。

JSONL 和 entry 树保存完整记录,Session 负责追加和分支操作,SessionContext 在每个 turn 前从当前 leaf 构建模型输入。模型消息数组是派生结果。切换 provider、调整工具集或切换分支时,系统都从同一份 entry 历史重放状态。

这种设计需要处理追加顺序和缓冲时机;树导航和摘要需要额外的 UI 与模型调用;compaction 的可用性取决于摘要质量,文件操作清单只能补充摘要可能遗漏的路径信息。这些成本分别由 entry、索引、投影和独立请求流程实现,并可通过持久化记录检查和恢复。


1251 字 · 81 段落
ximing

Follow onGitHub

相关文章