فهرست منبع

chore: remove local planning docs from pr

fanghanjun 6 ماه پیش
والد
کامیت
39f20a005b

+ 0 - 52
AGENTS.md

@@ -1,52 +0,0 @@
-# AGENTS.md
-
-InkOS 专业 agent 设计采用渐进式披露:先读 `docs/agents/agent-extraction-guide.md`,再按需深入 `docs/agents/<AgentName>.md`。
-
-默认使用中文回答问题,并使用中文撰写计划和 SPEC 文件。
-
-InkOS pi-agent 持久化模块设计在 `docs/superpowers/specs/2026-04-27-pi-agent-jsonl-persistence-design.md`,需要了解 JSONL transcript、requestId、toolResult、thinking、cache 和 legacy migration 时先读该文件。
-
-## 回答结构规范
-
-回答技术问题时要体现清晰的思考过程,而非堆叠语义相近的描述。
-
-1. **先抽象分类**:先把问题归纳成少数几个大类,说明每一类解决什么核心矛盾。
-2. **再建立推演链路**:按“问题背景 -> 关键数据结构 -> 执行步骤 -> 结果影响”的顺序解释机制。
-3. **用证据支撑判断**:涉及源码时给出关键函数、字段、调用链或文件位置,让结论可以被验证。
-4. **解释因果关系**:说明某个设计为什么能产生对应结果,以及缺少这个设计会触发什么失败场景。
-5. **避免空泛对比**:不要用简单二分对比替代解释;对比只能作为结论,不能作为证明过程。
-6. **控制信息密度**:每一层只放支撑当前结论所需的信息,细节服务于推理,不做无目的罗列。
-
-## TypeScript 代码实践
-
-回答或改写 TypeScript 代码时,先说明数据如何从“不可信输入”变成“可信领域对象”。每个判断都要回答四件事:它消除什么非法状态;由哪个类型、schema 或函数保证;下游因此获得什么前置条件;缺少它会触发什么失败场景。
-
-### 1. 核心分类
-
-1. **边界收敛**:外部输入不可信。文件、网络、JSON、数据库、第三方库返回值可以先是 `unknown`,但必须在模块边界通过 schema、parser 或 type guard 收窄。
-2. **领域建模**:业务状态要可区分。字段集合随 `role`、`type`、`kind` 改变时,用 discriminated union,不用 optional 字段堆出一个宽接口。
-3. **合法状态约束落地**:系统声称某份数据合法时必须满足的规则要有归属。单个对象自身就能判断的规则,放进类型或 schema;必须结合事件顺序、上下文或外部状态才能判断的规则,放进命名清楚的校验、转换或清理函数。
-4. **演进验证**:新增分支不能静默漏处理。union 要穷尽处理,非法状态要有测试覆盖。
-
-### 2. 推演链路
-
-解释 TypeScript 代码时按这条链路展开:
-
-```text
-问题背景:哪些输入不可信,或哪些业务状态容易混在一起。
-关键数据结构:raw input、validated event、domain message、cleaned message 分别是什么。
-执行步骤:哪个函数负责读取,哪个函数负责收窄,哪个函数负责合法状态校验,哪个函数返回可信结果。
-结果影响:下游少做哪些重复判断;缺少该设计时,非法状态会在哪里爆炸。
-```
-
-例如 transcript 恢复:`readTranscriptEvents()` 读取并解析事件,`committedMessageEvents()` 排除未提交请求,`cleanRestoredAgentMessages()` 清理孤立 `toolResult`、空 assistant 和非法 trailing thinking。推理重点不是“调用了这些函数”,而是这些函数分别阻断了哪些非法历史进入模型上下文。
-
-### 3. 代码准则
-
-1. 宽类型止于边界:`unknown`、`any`、`Record<string, unknown>` 不进入核心业务流程。
-2. 互斥状态用 union:不要用大量 optional 字段模拟状态机。
-3. 合法状态约束集中实现:不要把“必须 committed”“必须有对应 toolCall”“assistant 不能为空”散落成临时 `if`。
-4. 类型断言要有证据:`as SomeType` 前面必须有 schema、parser 或 guard 支撑。
-5. 分支处理要穷尽:用 `switch`、明确窄化和 `assertNever` 暴露新增分支。
-6. 函数名表达可信度:`parse*` 表示可能失败,`normalize*` 表示转换形状,`clean*` 表示执行合法性修复。
-7. 测试覆盖非法状态:恢复、迁移、IO、LLM message、tool loop 必须测坏数据、缺字段、未提交请求、孤立 `toolResult`、空 assistant、trailing thinking。

+ 0 - 630
docs/superpowers/plans/2026-04-27-agent-model-switch-context-projection.md

@@ -1,630 +0,0 @@
-# Agent Native Google 与模型切换上下文修复计划
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-## 目标
-
-修复 InkOS pi-agent 在 Google Gemini / DeepSeek / MiniMax 等模型之间切换时的上下文污染、工具调用失败和错误信息失真问题。
-
-## 执行记录
-
-2026-04-27 已执行:
-
-- Google service 默认调用协议已切到 pi-ai native `google-generative-ai`。
-- Agent cache identity 已从 `modelId` 升级为 `api + provider + baseUrl + modelId`。
-- JSONL transcript 继续保存 raw `AgentMessage`;恢复时按目标模型协议投影上下文。
-- 同模型同协议保留 native thinking/tool history;跨模型/跨协议把旧工具结果文本化为普通 user 语义上下文。
-- Studio `/api/v1/agent` 已优先透传 final assistant error,避免被 fallback probe 改写成 `LLM returned empty response`。
-
-验证结果:
-
-- `pnpm --filter @actalk/inkos-core exec vitest run src/__tests__/service-resolver.test.ts src/__tests__/provider.test.ts src/__tests__/effective-llm-config.test.ts src/__tests__/config-loader.test.ts` 通过。
-- `pnpm --filter @actalk/inkos-core exec vitest run src/__tests__/session-transcript-restore.test.ts src/__tests__/agent-session.test.ts` 通过。
-- `pnpm --filter @actalk/inkos-studio exec vitest run src/api/server.test.ts` 通过。
-- `pnpm --filter @actalk/inkos-core typecheck` 通过。
-- `pnpm --filter @actalk/inkos-studio typecheck` 通过。
-- `git diff --check` 通过。
-- 真实 `createLLMClient + chatCompletion` 调 `google/gemini-pro-latest` 三次,3/3 HTTP success,3/3 包含 `【完整结束】`。
-- 真实 Studio agent + Gemini 工具调用普通提示三次,3/3 HTTP 200,未出现 XML `<function-calls>` 泄漏,JSONL 中 assistant tool call 为 `api: "google-generative-ai"` / `provider: "google"`。
-- 严格“最终回复必须完全等于固定字符串”的工具提示下,观测到 1 次 native Google provider 返回 `An unknown error occurred`;transcript 显示这是 pi-ai Google provider 在 `stopReason: "error"` 且无内容时给出的 generic upstream error,不是 JSONL 恢复污染,也不是 OpenAI-compatible `MALFORMED_FUNCTION_CALL`。
-
-根因分两层:
-
-1. **Google 传输协议选错**:pi-ai 已有 native `google-generative-ai` provider,但 InkOS 当前 `google` service 走 `openai-completions + https://generativelanguage.googleapis.com/v1beta/openai`。这会让 Gemini 工具调用落在 OpenAI-compatible 适配层,现场已复现 `function_call_filter: MALFORMED_FUNCTION_CALL`、XML `<function-calls>` 文本泄漏和空 assistant。
-2. **Agent cache identity 太窄**:当前 cache 只比较 `modelId`。同一个 `gemini-pro-latest` 从 OpenAI-compatible 切到 native Google 时,模型 id 不变,但 `api/provider/baseUrl` 已经变了,继续复用旧 Agent 会保留错误协议的内存态。
-
-最终状态:
-
-- Google service 默认走 pi-ai native `google-generative-ai`。
-- Agent cache 使用完整模型协议身份:`api + provider + baseUrl + modelId`。
-- JSONL transcript 继续保存 raw `AgentMessage`。
-- 恢复上下文时,同一完整模型协议身份保留原生 history;跨协议/跨模型只保留语义文本。
-- OpenAI-compatible Gemini 只作为 legacy/custom fallback,不再作为 InkOS Google 默认路径。
-- Studio API 透传 pi-agent-core / pi-ai 的真实上游错误,不再全部折叠成 `LLM returned empty response`。
-
-## 架构边界
-
-本计划只处理 Studio agent chat 这条链路:
-
-`Studio /api/v1/agent -> resolveServiceModel/createLLMClient -> runAgentSession -> pi-agent-core Agent -> pi-ai streamSimple -> JSONL transcript`
-
-不处理专业 agent pipeline 的写作质量策略,不调整章节生成的业务 prompt。
-
-## 关键设计
-
-### 1. Google Provider 默认 native
-
-当前事实:
-
-- pi-ai `@mariozechner/pi-ai` 已注册 `api: "google-generative-ai"`。
-- `getModel("google", "gemini-2.5-flash")` 返回:
-  - `api: "google-generative-ai"`
-  - `provider: "google"`
-  - `baseUrl: "https://generativelanguage.googleapis.com/v1beta"`
-- InkOS 当前 `packages/core/src/llm/providers/endpoints/google.ts` 仍配置为 `openai-completions` 和 `/v1beta/openai`。
-
-修改原则:
-
-- `google` endpoint 的主调用协议改为 native `google-generative-ai`。
-- `modelsBaseUrl` 可以继续指向 OpenAI-compatible `/v1beta/openai`,只服务模型列表探测;模型调用必须走 native baseUrl。
-- 不新增 `maxTokensField` 之类 OpenAI-compatible 参数。`maxTokens` 仍由 model card 的 `maxOutput` 决定。
-
-### 2. Cache Identity 从 modelId 升级为模型协议身份
-
-旧规则:
-
-```ts
-cached.modelId !== requestedModelId
-```
-
-新规则:
-
-```ts
-agentModelIdentity(model) =
-  `${model.api}::${model.provider}::${model.baseUrl ?? ""}::${model.id}`
-```
-
-推导:
-
-1. `modelId` 只描述模型名称。
-2. agent 的 LLM 调用行为由 `api/provider/baseUrl/modelId` 共同决定。
-3. Google 从 OpenAI-compatible 切到 native 后,`modelId` 不变,`api/baseUrl/provider` 改变。
-4. cache 比较必须覆盖所有影响 LLM message schema 的字段。
-
-### 3. Transcript 恢复只在同协议身份下保留原生状态
-
-保留规则:
-
-- `assistant.api === target.api`
-- `assistant.provider === target.provider`
-- `assistant.model === target.id`
-
-跨协议/跨模型规则:
-
-- `user` 文本保留。
-- assistant 可见 `text` 保留。
-- `thinking` 丢弃,尤其是 DeepSeek `reasoning_content` 和 Gemini thought signature。
-- `toolCall + toolResult` 合并为普通 `user` 上下文文本,例如 `[Tool results] read(tool-1): ...`。
-- synthetic bridge 文本不进入最终 LLM context。
-
-原因:
-
-1. thinking / reasoning signature 是 provider 协议状态,不是可跨 provider 携带的语义。
-2. tool call id 只在对应 assistant tool call 的协议回合里有意义。
-3. 跨模型恢复的目标是保存“模型已经看过哪些工具结果”的语义,不恢复旧协议的 pending state。
-
-### 4. OpenAI-compatible Gemini 只保留 legacy 防线
-
-`convertAgentMessagesForModel()` 对 `baseUrl.includes("generativelanguage.googleapis.com") && api === "openai-completions"` 的文本化逻辑可以保留,但它是 fallback:
-
-- 用于旧 transcript、custom provider 或临时回滚。
-- native Google 不应该触发这层文本化。
-- 真实工具调用由 pi-ai Google provider 的 `part.functionCall -> toolCall` 处理。
-
-## 文件结构
-
-- Modify: `packages/core/src/llm/providers/types.ts`
-  - `ApiProtocol` 增加 `google-generative-ai`。
-- Modify: `packages/core/src/llm/providers/endpoints/google.ts`
-  - Google 主协议切到 native。
-  - 保留 `modelsBaseUrl` 或测试中明确 fallback 行为。
-- Modify: `packages/core/src/llm/service-presets.ts`
-  - `resolveServicePiProvider("google")` 返回 `google`。
-- Modify: `packages/core/src/llm/service-resolver.ts`
-  - `resolveServiceModel("google", ...)` 产出 native `Model<Api>`。
-  - OpenAI-compatible compat 只在 `api === "openai-completions"` 时注入。
-- Modify: `packages/core/src/llm/provider.ts`
-  - `createLLMClient()` 构造的 Google `_piModel` 使用 native `api/provider/baseUrl`。
-- Modify: `packages/core/src/agent/agent-session.ts`
-  - cache identity 使用完整模型协议身份。
-  - legacy Gemini OpenAI-compatible `convertToLlm` 只作为 fallback。
-  - final assistant error 继续透传。
-- Modify: `packages/core/src/interaction/session-transcript-restore.ts`
-  - 按目标模型身份恢复 raw transcript。
-- Modify: `packages/studio/src/api/server.ts`
-  - `/api/v1/agent` 使用 resolved native Google model。
-  - 保留真实错误返回。
-- Tests:
-  - `packages/core/src/__tests__/service-resolver.test.ts`
-  - `packages/core/src/__tests__/provider.test.ts`
-  - `packages/core/src/__tests__/effective-llm-config.test.ts`
-  - `packages/core/src/__tests__/config-loader.test.ts`
-  - `packages/core/src/__tests__/agent-session.test.ts`
-  - `packages/core/src/__tests__/session-transcript-restore.test.ts`
-  - `packages/studio/src/api/server.test.ts`
-
----
-
-## Task 1: 用测试锁定 Google native provider
-
-**Files:**
-- Modify: `packages/core/src/__tests__/service-resolver.test.ts`
-- Modify: `packages/core/src/__tests__/provider.test.ts`
-- Modify: `packages/core/src/__tests__/effective-llm-config.test.ts`
-- Modify: `packages/core/src/__tests__/config-loader.test.ts`
-
-- [ ] **Step 1: service resolver 测试 Google native identity**
-
-更新 `resolveServiceModel("google", "gemini-pro-latest", root)` 断言:
-
-```ts
-expect(result.model.api).toBe("google-generative-ai");
-expect(result.model.provider).toBe("google");
-expect(result.model.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
-```
-
-同时断言不再带 OpenAI-compatible compat:
-
-```ts
-expect(result.model.compat).toBeUndefined();
-```
-
-- [ ] **Step 2: provider client 测试 Google native `_piModel`**
-
-在 `provider.test.ts` 覆盖:
-
-```ts
-const client = createLLMClient({
-  service: "google",
-  provider: "openai",
-  model: "gemini-pro-latest",
-  apiKey: "test",
-});
-
-expect(client._piModel?.api).toBe("google-generative-ai");
-expect(client._piModel?.provider).toBe("google");
-expect(client._piModel?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
-```
-
-- [ ] **Step 3: 更新配置类测试的 baseUrl 期望**
-
-把 `effective-llm-config.test.ts` 和 `config-loader.test.ts` 里 Google service 的默认 baseUrl 期望从:
-
-```ts
-https://generativelanguage.googleapis.com/v1beta/openai
-```
-
-改为:
-
-```ts
-https://generativelanguage.googleapis.com/v1beta
-```
-
-- [ ] **Step 4: 运行失败测试**
-
-```bash
-pnpm --filter @actalk/inkos-core exec vitest run \
-  src/__tests__/service-resolver.test.ts \
-  src/__tests__/provider.test.ts \
-  src/__tests__/effective-llm-config.test.ts \
-  src/__tests__/config-loader.test.ts
-```
-
-Expected before implementation: Google 相关断言失败。
-
----
-
-## Task 2: 实现 Google native provider
-
-**Files:**
-- Modify: `packages/core/src/llm/providers/types.ts`
-- Modify: `packages/core/src/llm/providers/endpoints/google.ts`
-- Modify: `packages/core/src/llm/service-presets.ts`
-- Modify: `packages/core/src/llm/service-resolver.ts`
-- Modify: `packages/core/src/llm/provider.ts`
-
-- [ ] **Step 1: 扩展 InkOS provider schema**
-
-在 `ApiProtocol` 增加:
-
-```ts
-| "google-generative-ai"
-```
-
-- [ ] **Step 2: 修改 Google endpoint**
-
-把 `GOOGLE` endpoint 改为:
-
-```ts
-api: "google-generative-ai",
-baseUrl: "https://generativelanguage.googleapis.com/v1beta",
-modelsBaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
-```
-
-删除 Google endpoint 上 OpenAI-compatible 专用的:
-
-```ts
-compat: { supportsStore: false, requiresAssistantAfterToolResult: true }
-```
-
-该 compat 只属于 `/v1beta/openai`,不属于 native Google。
-
-- [ ] **Step 3: 修正 service -> pi provider 映射**
-
-`resolveServicePiProvider("google")` 必须返回:
-
-```ts
-"google"
-```
-
-`SERVICE_TO_PI_PROVIDER` 也要包含:
-
-```ts
-google: "google"
-```
-
-- [ ] **Step 4: 修正 `createLLMClient()` 的 pi provider 选择**
-
-`packages/core/src/llm/provider.ts` 当前默认会把非特殊 provider 映射成 `openai`。增加 Google 分支:
-
-```ts
-if (inkosProvider?.id === "google") piProvider = "google";
-```
-
-该分支必须早于默认 `else piProvider = provider`。
-
-- [ ] **Step 5: 修正 `resolveServiceModel()` 的 model 合成**
-
-`resolveServiceModel()` 对 Google 应优先使用:
-
-```ts
-getModel("google", modelId)
-```
-
-如果 pi-ai 内置 model 能命中,保留它的 `reasoning/input/contextWindow/maxTokens`,再覆盖 `id/name/baseUrl` 以匹配 InkOS provider bank。
-
-- [ ] **Step 6: 运行 Task 1 测试**
-
-```bash
-pnpm --filter @actalk/inkos-core exec vitest run \
-  src/__tests__/service-resolver.test.ts \
-  src/__tests__/provider.test.ts \
-  src/__tests__/effective-llm-config.test.ts \
-  src/__tests__/config-loader.test.ts
-```
-
-Expected: pass。
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add \
-  packages/core/src/llm/providers/types.ts \
-  packages/core/src/llm/providers/endpoints/google.ts \
-  packages/core/src/llm/service-presets.ts \
-  packages/core/src/llm/service-resolver.ts \
-  packages/core/src/llm/provider.ts \
-  packages/core/src/__tests__/service-resolver.test.ts \
-  packages/core/src/__tests__/provider.test.ts \
-  packages/core/src/__tests__/effective-llm-config.test.ts \
-  packages/core/src/__tests__/config-loader.test.ts
-git commit -m "fix: use native google provider"
-```
-
----
-
-## Task 3: 升级 Agent cache identity
-
-**Files:**
-- Modify: `packages/core/src/agent/agent-session.ts`
-- Modify: `packages/core/src/__tests__/agent-session.test.ts`
-
-- [ ] **Step 1: 写 cache identity 回归测试**
-
-覆盖三种情况:
-
-1. 同 `sessionId + bookId + readPermission + api/provider/baseUrl/modelId`:复用 Agent。
-2. 同 `modelId` 但 `api` 从 `openai-completions` 变为 `google-generative-ai`:重建 Agent。
-3. 同 `modelId/api` 但 `baseUrl` 改变:重建 Agent。
-
-断言方式:
-
-- 用 mock `streamSimple` 记录 `context.model` 或构造时状态。
-- 或检查 `streamCalls` 中第二次请求没有继承第一次 Agent 的旧 messages。
-
-- [ ] **Step 2: 实现 `agentModelIdentity()`**
-
-在 `agent-session.ts` 中增加局部函数:
-
-```ts
-function agentModelIdentity(model: Model<Api>): string {
-  return [
-    model.api,
-    model.provider,
-    model.baseUrl ?? "",
-    model.id,
-  ].join("::");
-}
-```
-
-- [ ] **Step 3: cache value 保存完整 identity**
-
-把 `CachedAgent` 中的:
-
-```ts
-modelId: string | undefined;
-```
-
-替换为:
-
-```ts
-modelIdentity: string;
-```
-
-创建 Agent 前先解析本次 `model`,用 `agentModelIdentity(model)` 比较 cache。
-
-- [ ] **Step 4: 保留原有 book/readPermission 失效规则**
-
-cache 失效条件为:
-
-```ts
-modelIdentityChanged || bookChanged || readPermissionChanged
-```
-
-- [ ] **Step 5: 运行 agent-session 测试**
-
-```bash
-pnpm --filter @actalk/inkos-core exec vitest run src/__tests__/agent-session.test.ts
-```
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add packages/core/src/agent/agent-session.ts packages/core/src/__tests__/agent-session.test.ts
-git commit -m "fix: key agent cache by model protocol"
-```
-
----
-
-## Task 4: 保留 raw JSONL,并按目标协议恢复上下文
-
-**Files:**
-- Modify: `packages/core/src/interaction/session-transcript-restore.ts`
-- Modify: `packages/core/src/__tests__/session-transcript-restore.test.ts`
-- Modify: `packages/core/src/agent/agent-session.ts`
-- Modify: `packages/core/src/__tests__/agent-session.test.ts`
-
-- [ ] **Step 1: 测同协议保留 native Google tool/thought**
-
-构造 assistant:
-
-```ts
-{
-  role: "assistant",
-  api: "google-generative-ai",
-  provider: "google",
-  model: "gemini-pro-latest",
-  content: [
-    { type: "thinking", thinking: "plan", thinkingSignature: "google-signature" },
-    { type: "toolCall", id: "tool-1", name: "ls", arguments: { subdir: "story/roles" } },
-  ],
-}
-```
-
-目标模型同为 `api/provider/id` 时,恢复结果必须保留原始 blocks。
-
-- [ ] **Step 2: 测跨协议文本化**
-
-用旧 OpenAI-compatible Gemini assistant:
-
-```ts
-api: "openai-completions",
-provider: "openai",
-model: "gemini-pro-latest",
-content: [{ type: "toolCall", id: "tool-1", name: "ls", arguments: {} }]
-```
-
-目标模型是 native Google 或 DeepSeek 时:
-
-- 不出现 `toolCall`。
-- 不出现 `toolResult` role。
-- 出现 `[Tool results]` user 文本。
-
-- [ ] **Step 3: 测 DeepSeek reasoning 不跨到 Google**
-
-DeepSeek 历史中 `thinkingSignature: "reasoning_content"`,目标模型为 native Google 时:
-
-- `reasoning_content` 不进入目标 context。
-- assistant 可见 text 保留。
-- 工具结果文本化。
-
-- [ ] **Step 4: 实现恢复投影**
-
-恢复规则:
-
-- `isSameAssistantModel(message, target)` 只比较 `api/provider/model`。
-- 同模型:保留 raw assistant/toolResult。
-- 异模型:丢弃 thinking,保留 text,工具结果合并为 user 文本。
-- synthetic bridge 文本过滤。
-
-注意:不要把只服务单一线性流程的步骤拆成多个无复用价值的顶层 helper;保持函数内局部 helper。
-
-- [ ] **Step 5: legacy OpenAI-compatible Gemini runtime projection 只作为 fallback**
-
-`agent-session.ts` 中如果保留 `convertAgentMessagesForModel()`,触发条件必须严格为:
-
-```ts
-model.api === "openai-completions" &&
-model.baseUrl?.includes("generativelanguage.googleapis.com")
-```
-
-native Google 不触发该分支。
-
-- [ ] **Step 6: 运行恢复与 agent 测试**
-
-```bash
-pnpm --filter @actalk/inkos-core exec vitest run \
-  src/__tests__/session-transcript-restore.test.ts \
-  src/__tests__/agent-session.test.ts
-```
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add \
-  packages/core/src/interaction/session-transcript-restore.ts \
-  packages/core/src/__tests__/session-transcript-restore.test.ts \
-  packages/core/src/agent/agent-session.ts \
-  packages/core/src/__tests__/agent-session.test.ts
-git commit -m "fix: project restored history by model protocol"
-```
-
----
-
-## Task 5: 真实错误透传
-
-**Files:**
-- Modify: `packages/core/src/agent/agent-session.ts`
-- Modify: `packages/core/src/__tests__/agent-session.test.ts`
-- Modify: `packages/studio/src/api/server.ts`
-- Modify: `packages/studio/src/api/server.test.ts`
-
-- [ ] **Step 1: `AgentSessionResult` 保留 final assistant error**
-
-如果最终 assistant:
-
-```ts
-stopReason === "error" || stopReason === "aborted"
-```
-
-且带 `errorMessage`,`runAgentSession()` 返回:
-
-```ts
-errorMessage
-```
-
-- [ ] **Step 2: Studio API 优先返回真实错误**
-
-`/api/v1/agent` 中,如果:
-
-```ts
-!result.responseText && result.errorMessage
-```
-
-返回:
-
-```ts
-status 502
-error.code = "AGENT_LLM_ERROR"
-response = result.errorMessage
-```
-
-该分支必须早于 fallback probe 和 `LLM returned empty response`。
-
-- [ ] **Step 3: 覆盖真实错误测试**
-
-测试至少覆盖:
-
-- `400 The reasoning_content ... must be passed back`
-- `Provider finish_reason: function_call_filter: MALFORMED_FUNCTION_CALL`
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add \
-  packages/core/src/agent/agent-session.ts \
-  packages/core/src/__tests__/agent-session.test.ts \
-  packages/studio/src/api/server.ts \
-  packages/studio/src/api/server.test.ts
-git commit -m "fix: surface agent upstream errors"
-```
-
----
-
-## Task 6: 验证真实 Google 场景
-
-**Files:**
-- No code changes expected.
-
-- [ ] **Step 1: 单纯 Google 文本流三次**
-
-使用 InkOS `createLLMClient + chatCompletion` 调 `google/gemini-pro-latest` 三次,提示词要求输出结束标记:
-
-```text
-【完整结束】
-```
-
-Expected:
-
-- 3/3 HTTP success。
-- 3/3 包含结束标记。
-- 不出现 `LLM returned empty response from stream`。
-
-- [ ] **Step 2: Studio agent + Gemini 工具调用三次**
-
-对新 session 发送三次:
-
-```text
-请调用工具查看当前书的 story/roles 目录。工具调用完成后,只回复:Google agent 测试通过。【完整结束】
-```
-
-Expected:
-
-- 3/3 HTTP 200。
-- 3/3 包含结束标记。
-- 不出现 XML `<function-calls>`。
-- 不出现 `MALFORMED_FUNCTION_CALL`。
-- JSONL 中 assistant tool call 的 `api` 为 `google-generative-ai`,`provider` 为 `google`。
-
-- [ ] **Step 3: 污染 transcript 恢复验证**
-
-用旧 session `1777003372863-8uq0sb` 或构造等价 fixture,包含:
-
-- 旧 Gemini OpenAI-compatible `toolCall/toolResult`
-- DeepSeek `reasoning_content`
-- 空 error assistant
-- XML `<function-calls>` 文本
-
-分别切到:
-
-- native Google
-- DeepSeek
-
-Expected:
-
-- 请求不因为旧 thinking/tool protocol 400。
-- 恢复 context 中跨协议工具结果是普通 user 文本。
-- 旧 XML 文本不被当成可执行 tool call。
-
-- [ ] **Step 4: 类型检查和 diff 检查**
-
-```bash
-pnpm --filter @actalk/inkos-core typecheck
-pnpm --filter @actalk/inkos-studio typecheck
-git diff --check
-```
-
----
-
-## Self-Review Checklist
-
-- [ ] `google` service 默认 `api` 是 `google-generative-ai`。
-- [ ] `google` service 默认 `provider` 是 `google`。
-- [ ] `google` service 默认调用 baseUrl 是 `https://generativelanguage.googleapis.com/v1beta`。
-- [ ] OpenAI-compatible `/v1beta/openai` 不再用于默认 Gemini agent 调用。
-- [ ] Agent cache identity 覆盖 `api/provider/baseUrl/modelId`。
-- [ ] 同协议同模型恢复保留 native thinking/tool history。
-- [ ] 跨协议恢复不传递 DeepSeek `reasoning_content` 或 Gemini thought signature。
-- [ ] 跨协议工具结果降级为普通 user 语义文本。
-- [ ] `LLM returned empty response` 不再吞掉真实上游错误。
-- [ ] 真实 Gemini 工具调用三次稳定通过。

+ 0 - 1498
docs/superpowers/plans/2026-04-27-pi-agent-jsonl-persistence.md

@@ -1,1498 +0,0 @@
-# Pi-Agent JSONL 持久化 Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** 把 InkOS pi-agent session 持久化改成单层 JSONL transcript,并让恢复后的 `Agent.state.messages` 保留 raw `AgentMessage`、toolResult 和 thinking。
-
-**Architecture:** core 新增 transcript schema、codec、restore、legacy migration 四个小模块;`book-session-store` 从 transcript 派生旧 API 形状;`runAgentSession` 负责从 transcript 恢复 Agent、订阅 pi-agent-core `message_end` 写入 transcript,并用 `request_committed` 做恢复栅栏。Studio API 保持现有响应形状,但停止把 user/assistant 追加写入 legacy `BookSession` JSON。
-
-**Tech Stack:** TypeScript、Zod、Vitest、Node `fs/promises`、`@mariozechner/pi-agent-core`、`@mariozechner/pi-ai`。
-
----
-
-## 文件结构
-
-- Create: `packages/core/src/interaction/session-transcript-schema.ts`
-  - 定义 transcript event schema、类型、role helper、raw `AgentMessage` 容器。
-- Create: `packages/core/src/interaction/session-transcript.ts`
-  - 负责 `.inkos/sessions/{sessionId}.jsonl` 路径、append/read、seq 分配、per-session append queue。
-- Create: `packages/core/src/interaction/session-transcript-restore.ts`
-  - 负责 committed request 恢复、模型合法性清理、UI `BookSession` 派生。
-- Create: `packages/core/src/interaction/session-transcript-legacy.ts`
-  - 负责 legacy `.json` 到 JSONL 的一次性兼容迁移。
-- Modify: `packages/core/src/interaction/book-session-store.ts`
-  - 保持原导出 API,但实现改为优先 JSONL,legacy JSON 只作为输入。
-- Modify: `packages/core/src/agent/agent-session.ts`
-  - 删除 plain message 恢复主路径,改为 raw transcript restore/write。
-- Modify: `packages/core/src/index.ts`
-  - 导出 transcript helper 和类型。
-- Modify: `packages/studio/src/api/server.ts`
-  - `/api/v1/agent` 不再构造 `initialMessages`,不再 append/persist legacy BookSession JSON。
-- Test: `packages/core/src/__tests__/session-transcript.test.ts`
-- Test: `packages/core/src/__tests__/session-transcript-restore.test.ts`
-- Test: `packages/core/src/__tests__/book-session-store.test.ts`
-- Test: `packages/core/src/__tests__/agent-session.test.ts`
-- Test: `packages/studio/src/api/server.test.ts`
-
----
-
-### Task 1: JSONL transcript schema 和 codec
-
-**Files:**
-- Create: `packages/core/src/interaction/session-transcript-schema.ts`
-- Create: `packages/core/src/interaction/session-transcript.ts`
-- Create: `packages/core/src/__tests__/session-transcript.test.ts`
-
-- [ ] **Step 1: 写失败测试**
-
-在 `packages/core/src/__tests__/session-transcript.test.ts` 新增:
-
-```ts
-import { describe, expect, it, beforeEach, afterEach } from "vitest";
-import { mkdtemp, rm, readFile, mkdir, writeFile } from "node:fs/promises";
-import { join } from "node:path";
-import { tmpdir } from "node:os";
-import {
-  appendTranscriptEvent,
-  nextTranscriptSeq,
-  readTranscriptEvents,
-  transcriptPath,
-} from "../interaction/session-transcript.js";
-import type { MessageEvent, RequestCommittedEvent, RequestStartedEvent } from "../interaction/session-transcript-schema.js";
-
-describe("session transcript codec", () => {
-  let projectRoot: string;
-
-  beforeEach(async () => {
-    projectRoot = await mkdtemp(join(tmpdir(), "inkos-transcript-"));
-  });
-
-  afterEach(async () => {
-    await rm(projectRoot, { recursive: true, force: true });
-  });
-
-  it("一行写入一个 JSON event 并保留 raw AgentMessage 字段", async () => {
-    const started: RequestStartedEvent = {
-      type: "request_started",
-      version: 1,
-      sessionId: "s1",
-      requestId: "r1",
-      seq: 1,
-      timestamp: 100,
-      input: "继续写",
-    };
-    const message: MessageEvent = {
-      type: "message",
-      version: 1,
-      sessionId: "s1",
-      requestId: "r1",
-      uuid: "m1",
-      parentUuid: null,
-      seq: 2,
-      role: "assistant",
-      timestamp: 101,
-      message: {
-        role: "assistant",
-        content: [
-          { type: "thinking", thinking: "推理", signature: "sig-1" },
-          { type: "text", text: "正文" },
-        ],
-        provider: "anthropic",
-        api: "anthropic-messages",
-        model: "claude",
-        usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
-        stopReason: "stop",
-        timestamp: 101,
-      },
-    };
-    await appendTranscriptEvent(projectRoot, started);
-    await appendTranscriptEvent(projectRoot, message);
-
-    const raw = await readFile(transcriptPath(projectRoot, "s1"), "utf-8");
-    expect(raw.trim().split("\n")).toHaveLength(2);
-
-    const events = await readTranscriptEvents(projectRoot, "s1");
-    expect(events).toHaveLength(2);
-    expect((events[1] as MessageEvent).message).toMatchObject({
-      role: "assistant",
-      content: [
-        { type: "thinking", thinking: "推理", signature: "sig-1" },
-        { type: "text", text: "正文" },
-      ],
-    });
-  });
-
-  it("跳过坏行并保留合法 event", async () => {
-    const dir = join(projectRoot, ".inkos", "sessions");
-    await mkdir(dir, { recursive: true });
-    await writeFile(
-      join(dir, "s1.jsonl"),
-      [
-        JSON.stringify({ type: "request_started", version: 1, sessionId: "s1", requestId: "r1", seq: 1, timestamp: 1, input: "hi" }),
-        "{bad json",
-        JSON.stringify({ type: "request_committed", version: 1, sessionId: "s1", requestId: "r1", seq: 2, timestamp: 2 }),
-      ].join("\n"),
-    );
-
-    const events = await readTranscriptEvents(projectRoot, "s1");
-    expect(events.map((event) => event.type)).toEqual(["request_started", "request_committed"]);
-  });
-
-  it("按已有 transcript 分配单调递增 seq", async () => {
-    const committed: RequestCommittedEvent = {
-      type: "request_committed",
-      version: 1,
-      sessionId: "s1",
-      requestId: "r1",
-      seq: 7,
-      timestamp: 100,
-    };
-    await appendTranscriptEvent(projectRoot, committed);
-    await expect(nextTranscriptSeq(projectRoot, "s1")).resolves.toBe(8);
-  });
-});
-```
-
-- [ ] **Step 2: 运行测试确认失败**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- session-transcript.test.ts
-```
-
-预期: FAIL,错误包含 `Cannot find module '../interaction/session-transcript.js'`。
-
-- [ ] **Step 3: 实现 schema**
-
-创建 `packages/core/src/interaction/session-transcript-schema.ts`:
-
-```ts
-import { z } from "zod";
-
-export const TranscriptRoleSchema = z.enum(["user", "assistant", "toolResult", "system"]);
-export type TranscriptRole = z.infer<typeof TranscriptRoleSchema>;
-
-const BaseEventSchema = z.object({
-  version: z.literal(1),
-  sessionId: z.string().min(1),
-  seq: z.number().int().nonnegative(),
-  timestamp: z.number().int().nonnegative(),
-});
-
-export const SessionCreatedEventSchema = BaseEventSchema.extend({
-  type: z.literal("session_created"),
-  bookId: z.string().nullable(),
-  title: z.string().nullable().default(null),
-  createdAt: z.number().int().nonnegative(),
-  updatedAt: z.number().int().nonnegative(),
-});
-
-export const SessionMetadataUpdatedEventSchema = BaseEventSchema.extend({
-  type: z.literal("session_metadata_updated"),
-  bookId: z.string().nullable().optional(),
-  title: z.string().nullable().optional(),
-  updatedAt: z.number().int().nonnegative(),
-});
-
-export const RequestStartedEventSchema = BaseEventSchema.extend({
-  type: z.literal("request_started"),
-  requestId: z.string().min(1),
-  input: z.string(),
-});
-
-export const RequestCommittedEventSchema = BaseEventSchema.extend({
-  type: z.literal("request_committed"),
-  requestId: z.string().min(1),
-});
-
-export const RequestFailedEventSchema = BaseEventSchema.extend({
-  type: z.literal("request_failed"),
-  requestId: z.string().min(1),
-  error: z.string(),
-});
-
-export const MessageEventSchema = BaseEventSchema.extend({
-  type: z.literal("message"),
-  requestId: z.string().min(1),
-  uuid: z.string().min(1),
-  parentUuid: z.string().min(1).nullable(),
-  role: TranscriptRoleSchema,
-  piTurnIndex: z.number().int().nonnegative().optional(),
-  toolCallId: z.string().min(1).optional(),
-  sourceToolAssistantUuid: z.string().min(1).optional(),
-  legacyDisplay: z.object({
-    thinking: z.string().optional(),
-    toolExecutions: z.array(z.unknown()).optional(),
-  }).optional(),
-  message: z.unknown(),
-});
-
-export const TranscriptEventSchema = z.discriminatedUnion("type", [
-  SessionCreatedEventSchema,
-  SessionMetadataUpdatedEventSchema,
-  RequestStartedEventSchema,
-  RequestCommittedEventSchema,
-  RequestFailedEventSchema,
-  MessageEventSchema,
-]);
-
-export type SessionCreatedEvent = z.infer<typeof SessionCreatedEventSchema>;
-export type SessionMetadataUpdatedEvent = z.infer<typeof SessionMetadataUpdatedEventSchema>;
-export type RequestStartedEvent = z.infer<typeof RequestStartedEventSchema>;
-export type RequestCommittedEvent = z.infer<typeof RequestCommittedEventSchema>;
-export type RequestFailedEvent = z.infer<typeof RequestFailedEventSchema>;
-export type MessageEvent = z.infer<typeof MessageEventSchema>;
-export type TranscriptEvent = z.infer<typeof TranscriptEventSchema>;
-```
-
-- [ ] **Step 4: 实现 codec**
-
-创建 `packages/core/src/interaction/session-transcript.ts`:
-
-```ts
-import { appendFile, mkdir, readFile } from "node:fs/promises";
-import { join } from "node:path";
-import { TranscriptEventSchema, type TranscriptEvent } from "./session-transcript-schema.js";
-
-const SESSIONS_DIR = ".inkos/sessions";
-const appendQueues = new Map<string, Promise<void>>();
-
-export function sessionsDir(projectRoot: string): string {
-  return join(projectRoot, SESSIONS_DIR);
-}
-
-export function transcriptPath(projectRoot: string, sessionId: string): string {
-  return join(sessionsDir(projectRoot), `${sessionId}.jsonl`);
-}
-
-export function legacyBookSessionPath(projectRoot: string, sessionId: string): string {
-  return join(sessionsDir(projectRoot), `${sessionId}.json`);
-}
-
-export async function readTranscriptEvents(projectRoot: string, sessionId: string): Promise<TranscriptEvent[]> {
-  let raw: string;
-  try {
-    raw = await readFile(transcriptPath(projectRoot, sessionId), "utf-8");
-  } catch {
-    return [];
-  }
-  const events: TranscriptEvent[] = [];
-  for (const line of raw.split(/\r?\n/)) {
-    if (!line.trim()) continue;
-    try {
-      const parsed = TranscriptEventSchema.safeParse(JSON.parse(line));
-      if (parsed.success) events.push(parsed.data);
-    } catch {
-      continue;
-    }
-  }
-  return events.sort((a, b) => a.seq - b.seq);
-}
-
-export async function nextTranscriptSeq(projectRoot: string, sessionId: string): Promise<number> {
-  const events = await readTranscriptEvents(projectRoot, sessionId);
-  return events.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
-}
-
-export async function appendTranscriptEvent(projectRoot: string, event: TranscriptEvent): Promise<void> {
-  const key = `${projectRoot}:${event.sessionId}`;
-  const previous = appendQueues.get(key) ?? Promise.resolve();
-  const next = previous.then(async () => {
-    await mkdir(sessionsDir(projectRoot), { recursive: true });
-    await appendFile(transcriptPath(projectRoot, event.sessionId), `${JSON.stringify(event)}\n`, "utf-8");
-  });
-  appendQueues.set(key, next.catch(() => undefined));
-  await next;
-}
-```
-
-- [ ] **Step 5: 运行测试确认通过**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- session-transcript.test.ts
-```
-
-预期: PASS。
-
-- [ ] **Step 6: 提交**
-
-```bash
-git add packages/core/src/interaction/session-transcript-schema.ts packages/core/src/interaction/session-transcript.ts packages/core/src/__tests__/session-transcript.test.ts
-git commit -m "feat: add session transcript codec"
-```
-
----
-
-### Task 2: committed restore、thinking 和 toolResult 清理
-
-**Files:**
-- Create: `packages/core/src/interaction/session-transcript-restore.ts`
-- Create: `packages/core/src/__tests__/session-transcript-restore.test.ts`
-
-- [ ] **Step 1: 写失败测试**
-
-创建 `packages/core/src/__tests__/session-transcript-restore.test.ts`:
-
-```ts
-import { describe, expect, it, beforeEach, afterEach } from "vitest";
-import { mkdtemp, rm } from "node:fs/promises";
-import { join } from "node:path";
-import { tmpdir } from "node:os";
-import { appendTranscriptEvent } from "../interaction/session-transcript.js";
-import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
-import type { MessageEvent } from "../interaction/session-transcript-schema.js";
-
-const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
-
-describe("session transcript restore", () => {
-  let projectRoot: string;
-
-  beforeEach(async () => {
-    projectRoot = await mkdtemp(join(tmpdir(), "inkos-restore-"));
-  });
-
-  afterEach(async () => {
-    await rm(projectRoot, { recursive: true, force: true });
-  });
-
-  it("只恢复已 committed request 内的 message", async () => {
-    await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: "s1", requestId: "r1", seq: 1, timestamp: 1, input: "hi" });
-    await appendTranscriptEvent(projectRoot, { type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "u1", parentUuid: null, seq: 2, role: "user", timestamp: 2, message: { role: "user", content: "hi", timestamp: 2 } } as MessageEvent);
-    await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId: "s1", requestId: "r1", seq: 3, timestamp: 3 });
-    await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: "s1", requestId: "r2", seq: 4, timestamp: 4, input: "lost" });
-    await appendTranscriptEvent(projectRoot, { type: "message", version: 1, sessionId: "s1", requestId: "r2", uuid: "u2", parentUuid: "u1", seq: 5, role: "user", timestamp: 5, message: { role: "user", content: "lost", timestamp: 5 } } as MessageEvent);
-
-    const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
-    expect(restored).toHaveLength(1);
-    expect(restored[0]).toMatchObject({ role: "user", content: "hi" });
-  });
-
-  it("保留 committed toolResult 和 assistant thinking signature", async () => {
-    await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: "s1", requestId: "r1", seq: 1, timestamp: 1, input: "tool" });
-    await appendTranscriptEvent(projectRoot, {
-      type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "a1", parentUuid: null, seq: 2, role: "assistant", timestamp: 2,
-      toolCallId: "tool-1",
-      message: { role: "assistant", content: [{ type: "thinking", thinking: "需要查资料", signature: "sig" }, { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "a.md" } }], api: "anthropic-messages", provider: "anthropic", model: "claude", usage, stopReason: "tool_use", timestamp: 2 },
-    } as MessageEvent);
-    await appendTranscriptEvent(projectRoot, {
-      type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "t1", parentUuid: "a1", seq: 3, role: "toolResult", timestamp: 3,
-      toolCallId: "tool-1",
-      sourceToolAssistantUuid: "a1",
-      message: { role: "toolResult", toolCallId: "tool-1", toolName: "read", content: [{ type: "text", text: "资料" }], details: { path: "a.md" }, isError: false, timestamp: 3 },
-    } as MessageEvent);
-    await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId: "s1", requestId: "r1", seq: 4, timestamp: 4 });
-
-    const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
-    expect(restored).toHaveLength(2);
-    expect(restored[0]).toMatchObject({ role: "assistant", content: [{ type: "thinking", thinking: "需要查资料", signature: "sig" }] });
-    expect(restored[1]).toMatchObject({ role: "toolResult", toolCallId: "tool-1", toolName: "read" });
-  });
-
-  it("移除最后 assistant message 的 trailing thinking block", async () => {
-    await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: "s1", requestId: "r1", seq: 1, timestamp: 1, input: "hi" });
-    await appendTranscriptEvent(projectRoot, {
-      type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "a1", parentUuid: null, seq: 2, role: "assistant", timestamp: 2,
-      message: { role: "assistant", content: [{ type: "text", text: "回答" }, { type: "thinking", thinking: "尾部", signature: "sig" }], api: "anthropic-messages", provider: "anthropic", model: "claude", usage, stopReason: "stop", timestamp: 2 },
-    } as MessageEvent);
-    await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId: "s1", requestId: "r1", seq: 3, timestamp: 3 });
-
-    const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
-    expect((restored[0] as any).content).toEqual([{ type: "text", text: "回答" }]);
-  });
-});
-```
-
-- [ ] **Step 2: 运行测试确认失败**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- session-transcript-restore.test.ts
-```
-
-预期: FAIL,错误包含 `Cannot find module '../interaction/session-transcript-restore.js'`。
-
-- [ ] **Step 3: 实现 restore 和清理**
-
-创建 `packages/core/src/interaction/session-transcript-restore.ts`:
-
-```ts
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import { readTranscriptEvents } from "./session-transcript.js";
-import type { MessageEvent, TranscriptEvent } from "./session-transcript-schema.js";
-
-function isObject(value: unknown): value is Record<string, unknown> {
-  return !!value && typeof value === "object";
-}
-
-function hasTextContent(message: Record<string, unknown>): boolean {
-  const content = message.content;
-  return Array.isArray(content) && content.some((block) => isObject(block) && block.type === "text" && typeof block.text === "string" && block.text.length > 0);
-}
-
-function hasToolCallContent(message: Record<string, unknown>): boolean {
-  const content = message.content;
-  return Array.isArray(content) && content.some((block) => isObject(block) && block.type === "toolCall" && typeof block.id === "string");
-}
-
-function toolCallIds(message: Record<string, unknown>): string[] {
-  const content = message.content;
-  if (!Array.isArray(content)) return [];
-  return content
-    .filter((block): block is Record<string, unknown> => isObject(block) && block.type === "toolCall" && typeof block.id === "string")
-    .map((block) => block.id as string);
-}
-
-export function cleanRestoredAgentMessages(messages: AgentMessage[]): AgentMessage[] {
-  const availableToolCalls = new Set<string>();
-  for (const message of messages) {
-    if (isObject(message) && message.role === "assistant") {
-      for (const id of toolCallIds(message)) availableToolCalls.add(id);
-    }
-  }
-
-  const cleaned = messages.filter((message) => {
-    if (!isObject(message)) return false;
-    if (message.role === "toolResult") {
-      return typeof message.toolCallId === "string" && availableToolCalls.has(message.toolCallId);
-    }
-    if (message.role === "assistant") {
-      return hasTextContent(message) || hasToolCallContent(message);
-    }
-    return message.role === "user" || message.role === "system";
-  });
-
-  for (let i = cleaned.length - 1; i >= 0; i--) {
-    const message = cleaned[i] as Record<string, unknown>;
-    if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
-    const filtered = message.content.filter((block) => !(isObject(block) && (block.type === "thinking" || block.type === "redacted_thinking")));
-    cleaned[i] = { ...message, content: filtered } as AgentMessage;
-    break;
-  }
-
-  return cleaned;
-}
-
-export function committedMessageEvents(events: TranscriptEvent[]): MessageEvent[] {
-  const committed = new Set(
-    events
-      .filter((event) => event.type === "request_committed")
-      .map((event) => event.requestId),
-  );
-  return events
-    .filter((event): event is MessageEvent => event.type === "message" && committed.has(event.requestId))
-    .sort((a, b) => a.seq - b.seq);
-}
-
-export async function restoreAgentMessagesFromTranscript(projectRoot: string, sessionId: string): Promise<AgentMessage[]> {
-  const events = await readTranscriptEvents(projectRoot, sessionId);
-  return cleanRestoredAgentMessages(committedMessageEvents(events).map((event) => event.message as AgentMessage));
-}
-```
-
-- [ ] **Step 4: 运行测试确认通过**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- session-transcript-restore.test.ts
-```
-
-预期: PASS。
-
-- [ ] **Step 5: 提交**
-
-```bash
-git add packages/core/src/interaction/session-transcript-restore.ts packages/core/src/__tests__/session-transcript-restore.test.ts
-git commit -m "feat: restore committed transcript messages"
-```
-
----
-
-### Task 3: 从 transcript 派生 BookSession 视图并迁移 legacy JSON
-
-**Files:**
-- Modify: `packages/core/src/interaction/session-transcript-restore.ts`
-- Create: `packages/core/src/interaction/session-transcript-legacy.ts`
-- Modify: `packages/core/src/__tests__/session-transcript-restore.test.ts`
-- Modify: `packages/core/src/__tests__/book-session-store.test.ts`
-
-- [ ] **Step 1: 写失败测试**
-
-在 `packages/core/src/__tests__/session-transcript-restore.test.ts` 增加:
-
-```ts
-import { deriveBookSessionFromTranscript } from "../interaction/session-transcript-restore.js";
-
-it("从 transcript 派生 BookSession UI 视图", async () => {
-  await appendTranscriptEvent(projectRoot, { type: "session_created", version: 1, sessionId: "s1", seq: 1, timestamp: 1, bookId: "book-a", title: null, createdAt: 1, updatedAt: 1 });
-  await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: "s1", requestId: "r1", seq: 2, timestamp: 2, input: "第一条问题" });
-  await appendTranscriptEvent(projectRoot, { type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "u1", parentUuid: null, seq: 3, role: "user", timestamp: 3, message: { role: "user", content: "第一条问题", timestamp: 3 } } as MessageEvent);
-  await appendTranscriptEvent(projectRoot, {
-    type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "a1", parentUuid: "u1", seq: 4, role: "assistant", timestamp: 4,
-    message: { role: "assistant", content: [{ type: "thinking", thinking: "思考" }, { type: "text", text: "回答" }], api: "anthropic-messages", provider: "anthropic", model: "claude", usage, stopReason: "stop", timestamp: 4 },
-  } as MessageEvent);
-  await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId: "s1", requestId: "r1", seq: 5, timestamp: 5 });
-
-  const session = await deriveBookSessionFromTranscript(projectRoot, "s1");
-  expect(session).toMatchObject({
-    sessionId: "s1",
-    bookId: "book-a",
-    title: "第一条问题",
-    messages: [
-      { role: "user", content: "第一条问题" },
-      { role: "assistant", content: "回答", thinking: "思考" },
-    ],
-  });
-});
-```
-
-在 `packages/core/src/__tests__/book-session-store.test.ts` 增加 legacy migration 断言:
-
-```ts
-it("读取 legacy JSON 时迁移为 JSONL 并保留 UI thinking", async () => {
-  const session = {
-    ...createBookSession("book-a", "legacy-1"),
-    messages: [
-      { role: "user" as const, content: "继续", timestamp: 10 },
-      { role: "assistant" as const, content: "好的", thinking: "旧思考", timestamp: 11 },
-    ],
-  };
-  await persistBookSession(tempDir, session);
-
-  const loaded = await loadBookSession(tempDir, "legacy-1");
-  expect(loaded).toMatchObject({
-    sessionId: "legacy-1",
-    bookId: "book-a",
-    messages: [
-      { role: "user", content: "继续" },
-      { role: "assistant", content: "好的", thinking: "旧思考" },
-    ],
-  });
-  await expect(readFile(join(tempDir, ".inkos", "sessions", "legacy-1.jsonl"), "utf-8")).resolves.toContain("request_committed");
-});
-```
-
-- [ ] **Step 2: 运行测试确认失败**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- session-transcript-restore.test.ts book-session-store.test.ts
-```
-
-预期: FAIL,错误包含 `deriveBookSessionFromTranscript is not a function`。
-
-- [ ] **Step 3: 实现 BookSession 派生**
-
-在 `packages/core/src/interaction/session-transcript-restore.ts` 增加:
-
-```ts
-import { BookSessionSchema, type BookSession, type InteractionMessage, type ToolExecution } from "./session.js";
-
-function textFromContent(content: unknown): string {
-  if (typeof content === "string") return content;
-  if (!Array.isArray(content)) return "";
-  return content
-    .filter((block): block is { type: string; text: string } => isObject(block) && block.type === "text" && typeof block.text === "string")
-    .map((block) => block.text)
-    .join("");
-}
-
-function thinkingFromContent(content: unknown): string | undefined {
-  if (!Array.isArray(content)) return undefined;
-  const value = content
-    .filter((block): block is Record<string, unknown> => isObject(block) && block.type === "thinking")
-    .map((block) => typeof block.thinking === "string" ? block.thinking : "")
-    .join("");
-  return value || undefined;
-}
-
-function firstUserMessageTitle(messages: InteractionMessage[]): string | null {
-  for (const message of messages) {
-    if (message.role !== "user") continue;
-    const oneLine = message.content.trim().replace(/\s+/g, " ");
-    if (!oneLine) return null;
-    return oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
-  }
-  return null;
-}
-
-function messageEventToInteractionMessage(event: MessageEvent): InteractionMessage | null {
-  const raw = event.message as Record<string, unknown>;
-  if (!isObject(raw)) return null;
-  if (event.role === "toolResult") return null;
-  if (event.role === "user") {
-    const content = textFromContent(raw.content);
-    return content ? { role: "user", content, timestamp: event.timestamp } : null;
-  }
-  if (event.role === "assistant") {
-    const content = textFromContent(raw.content);
-    const thinking = thinkingFromContent(raw.content) ?? event.legacyDisplay?.thinking;
-    if (!content && !thinking) return null;
-    return {
-      role: "assistant",
-      content,
-      ...(thinking ? { thinking } : {}),
-      ...(event.legacyDisplay?.toolExecutions ? { toolExecutions: event.legacyDisplay.toolExecutions as ToolExecution[] } : {}),
-      timestamp: event.timestamp,
-    };
-  }
-  return null;
-}
-
-export async function deriveBookSessionFromTranscript(projectRoot: string, sessionId: string): Promise<BookSession | null> {
-  const events = await readTranscriptEvents(projectRoot, sessionId);
-  if (events.length === 0) return null;
-
-  const created = events.find((event) => event.type === "session_created");
-  let bookId = created?.type === "session_created" ? created.bookId : null;
-  let title = created?.type === "session_created" ? created.title : null;
-  let createdAt = created?.type === "session_created" ? created.createdAt : events[0]?.timestamp ?? Date.now();
-  let updatedAt = created?.type === "session_created" ? created.updatedAt : events[events.length - 1]?.timestamp ?? createdAt;
-
-  for (const event of events) {
-    if (event.type === "session_metadata_updated") {
-      if ("bookId" in event) bookId = event.bookId ?? bookId;
-      if ("title" in event) title = event.title ?? title;
-      updatedAt = event.updatedAt;
-    }
-  }
-
-  const messages = committedMessageEvents(events)
-    .map(messageEventToInteractionMessage)
-    .filter((message): message is InteractionMessage => message !== null)
-    .sort((a, b) => a.timestamp - b.timestamp);
-
-  if (title === null) {
-    title = firstUserMessageTitle(messages);
-  }
-
-  return BookSessionSchema.parse({
-    sessionId,
-    bookId,
-    title,
-    messages,
-    draftRounds: [],
-    events: [],
-    createdAt,
-    updatedAt,
-  });
-}
-```
-
-- [ ] **Step 4: 实现 legacy 转换**
-
-创建 `packages/core/src/interaction/session-transcript-legacy.ts`:
-
-```ts
-import { readFile } from "node:fs/promises";
-import { randomUUID } from "node:crypto";
-import { BookSessionSchema, type BookSession } from "./session.js";
-import { appendTranscriptEvent, legacyBookSessionPath, nextTranscriptSeq, readTranscriptEvents } from "./session-transcript.js";
-import type { MessageEvent } from "./session-transcript-schema.js";
-
-const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
-
-export async function readLegacyBookSession(projectRoot: string, sessionId: string): Promise<BookSession | null> {
-  try {
-    const raw = await readFile(legacyBookSessionPath(projectRoot, sessionId), "utf-8");
-    return BookSessionSchema.parse(JSON.parse(raw));
-  } catch {
-    return null;
-  }
-}
-
-export async function migrateLegacyBookSessionToTranscript(projectRoot: string, session: BookSession): Promise<void> {
-  const existing = await readTranscriptEvents(projectRoot, session.sessionId);
-  if (existing.length > 0) return;
-  let seq = await nextTranscriptSeq(projectRoot, session.sessionId);
-  await appendTranscriptEvent(projectRoot, {
-    type: "session_created",
-    version: 1,
-    sessionId: session.sessionId,
-    seq: seq++,
-    timestamp: session.createdAt,
-    bookId: session.bookId,
-    title: session.title,
-    createdAt: session.createdAt,
-    updatedAt: session.updatedAt,
-  });
-  const requestId = `legacy-${session.sessionId}`;
-  await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: session.sessionId, requestId, seq: seq++, timestamp: session.createdAt, input: "" });
-  let parentUuid: string | null = null;
-  for (const legacyMessage of session.messages) {
-    const uuid = randomUUID();
-    const message = legacyMessage.role === "assistant"
-      ? { role: "assistant", content: [{ type: "text", text: legacyMessage.content }], api: "anthropic-messages", provider: "legacy", model: "unknown", usage, stopReason: "stop", timestamp: legacyMessage.timestamp }
-      : { role: legacyMessage.role, content: legacyMessage.content, timestamp: legacyMessage.timestamp };
-    const event: MessageEvent = {
-      type: "message",
-      version: 1,
-      sessionId: session.sessionId,
-      requestId,
-      uuid,
-      parentUuid,
-      seq: seq++,
-      role: legacyMessage.role === "assistant" ? "assistant" : legacyMessage.role,
-      timestamp: legacyMessage.timestamp,
-      legacyDisplay: legacyMessage.role === "assistant" && legacyMessage.thinking ? { thinking: legacyMessage.thinking } : undefined,
-      message,
-    };
-    await appendTranscriptEvent(projectRoot, event);
-    parentUuid = uuid;
-  }
-  await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId: session.sessionId, requestId, seq: seq++, timestamp: session.updatedAt });
-}
-```
-
-- [ ] **Step 5: 运行测试确认通过**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- session-transcript-restore.test.ts book-session-store.test.ts
-```
-
-预期: PASS。
-
-- [ ] **Step 6: 提交**
-
-```bash
-git add packages/core/src/interaction/session-transcript-restore.ts packages/core/src/interaction/session-transcript-legacy.ts packages/core/src/__tests__/session-transcript-restore.test.ts packages/core/src/__tests__/book-session-store.test.ts
-git commit -m "feat: derive sessions from transcripts"
-```
-
----
-
-### Task 4: book-session-store 切换到 JSONL canonical path
-
-**Files:**
-- Modify: `packages/core/src/interaction/book-session-store.ts`
-- Modify: `packages/core/src/__tests__/book-session-store.test.ts`
-
-- [ ] **Step 1: 写失败测试**
-
-在 `packages/core/src/__tests__/book-session-store.test.ts` 增加:
-
-```ts
-it("createAndPersistBookSession 为新 session 写 JSONL 而不是 legacy JSON", async () => {
-  const session = await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
-  expect(session.sessionId).toBe("123456-abcdef");
-
-  await expect(readFile(join(tempDir, ".inkos", "sessions", "123456-abcdef.jsonl"), "utf-8")).resolves.toContain("session_created");
-  await expect(readFile(join(tempDir, ".inkos", "sessions", "123456-abcdef.json"), "utf-8")).rejects.toThrow();
-});
-
-it("renameBookSession 追加 metadata event 并从 JSONL 派生新标题", async () => {
-  await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
-  const renamed = await renameBookSession(tempDir, "123456-abcdef", "新标题");
-  expect(renamed!.title).toBe("新标题");
-
-  const loaded = await loadBookSession(tempDir, "123456-abcdef");
-  expect(loaded!.title).toBe("新标题");
-});
-
-it("listBookSessions 同时列出 JSONL session 和未迁移 legacy JSON session", async () => {
-  await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
-  const legacy = { ...createBookSession("book-a", "legacy-1"), updatedAt: 999 };
-  await writeFile(join(tempDir, ".inkos", "sessions", "legacy-1.json"), JSON.stringify(legacy));
-
-  const list = await listBookSessions(tempDir, "book-a");
-  expect(list.map((entry) => entry.sessionId).sort()).toEqual(["123456-abcdef", "legacy-1"]);
-});
-```
-
-- [ ] **Step 2: 运行测试确认失败**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- book-session-store.test.ts
-```
-
-预期: FAIL,新 session 仍然写 `.json`。
-
-- [ ] **Step 3: 改写 store**
-
-在 `packages/core/src/interaction/book-session-store.ts` 中保留导出名,改为调用 transcript helper。核心实现形状:
-
-```ts
-import { readdir, unlink } from "node:fs/promises";
-import { join } from "node:path";
-import { createBookSession, type BookSession } from "./session.js";
-import { appendTranscriptEvent, legacyBookSessionPath, nextTranscriptSeq, readTranscriptEvents, sessionsDir, transcriptPath } from "./session-transcript.js";
-import { deriveBookSessionFromTranscript } from "./session-transcript-restore.js";
-import { migrateLegacyBookSessionToTranscript, readLegacyBookSession } from "./session-transcript-legacy.js";
-
-export async function loadBookSession(projectRoot: string, sessionId: string): Promise<BookSession | null> {
-  const fromTranscript = await deriveBookSessionFromTranscript(projectRoot, sessionId);
-  if (fromTranscript) return fromTranscript;
-  const legacy = await readLegacyBookSession(projectRoot, sessionId);
-  if (!legacy) return null;
-  await migrateLegacyBookSessionToTranscript(projectRoot, legacy);
-  return deriveBookSessionFromTranscript(projectRoot, sessionId);
-}
-
-export async function persistBookSession(projectRoot: string, session: BookSession): Promise<void> {
-  const existing = await readTranscriptEvents(projectRoot, session.sessionId);
-  if (existing.length === 0) {
-    await migrateLegacyBookSessionToTranscript(projectRoot, session);
-    return;
-  }
-  const seq = await nextTranscriptSeq(projectRoot, session.sessionId);
-  await appendTranscriptEvent(projectRoot, {
-    type: "session_metadata_updated",
-    version: 1,
-    sessionId: session.sessionId,
-    seq,
-    timestamp: Date.now(),
-    bookId: session.bookId,
-    title: session.title,
-    updatedAt: session.updatedAt,
-  });
-}
-```
-
-然后逐个改写:
-
-```ts
-export async function createAndPersistBookSession(projectRoot: string, bookId: string | null, sessionId?: string): Promise<BookSession> {
-  if (sessionId) {
-    const existing = await loadBookSession(projectRoot, sessionId);
-    if (existing) return existing;
-  }
-  const session = createBookSession(bookId, sessionId);
-  await appendTranscriptEvent(projectRoot, {
-    type: "session_created",
-    version: 1,
-    sessionId: session.sessionId,
-    seq: 1,
-    timestamp: session.createdAt,
-    bookId,
-    title: session.title,
-    createdAt: session.createdAt,
-    updatedAt: session.updatedAt,
-  });
-  return session;
-}
-
-export async function renameBookSession(projectRoot: string, sessionId: string, title: string): Promise<BookSession | null> {
-  const session = await loadBookSession(projectRoot, sessionId);
-  if (!session) return null;
-  const updatedAt = Date.now();
-  await appendTranscriptEvent(projectRoot, {
-    type: "session_metadata_updated",
-    version: 1,
-    sessionId,
-    seq: await nextTranscriptSeq(projectRoot, sessionId),
-    timestamp: updatedAt,
-    title,
-    updatedAt,
-  });
-  return loadBookSession(projectRoot, sessionId);
-}
-```
-
-`listBookSessions()` 扫描 `.jsonl` 和 `.json`,对 `.json` 调用 `loadBookSession()` 触发迁移后再派生 summary;`deleteBookSession()` 同时删除 `.jsonl` 和 `.json`;`migrateBookSession()` 写 `session_metadata_updated` 的 `bookId`。
-
-- [ ] **Step 4: 运行测试确认通过**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- book-session-store.test.ts session-transcript.test.ts session-transcript-restore.test.ts
-```
-
-预期: PASS。
-
-- [ ] **Step 5: 提交**
-
-```bash
-git add packages/core/src/interaction/book-session-store.ts packages/core/src/__tests__/book-session-store.test.ts
-git commit -m "feat: use jsonl for book sessions"
-```
-
----
-
-### Task 5: runAgentSession 从 JSONL 恢复 raw AgentMessage 并写入 transcript
-
-**Files:**
-- Modify: `packages/core/src/agent/agent-session.ts`
-- Modify: `packages/core/src/__tests__/agent-session.test.ts`
-
-- [ ] **Step 1: 写失败测试**
-
-在 `packages/core/src/__tests__/agent-session.test.ts` 的 FakeAgent 中改造 `subscribe()`,让测试能模拟 pi-agent-core event:
-
-```ts
-class FakeAgent {
-  listeners: Array<(event: any) => void> = [];
-  state: any;
-  transformContext: any;
-  streamFn: any;
-  getApiKey: any;
-  constructor(options: any) {
-    this.state = {
-      model: options.initialState?.model,
-      systemPrompt: options.initialState?.systemPrompt,
-      tools: options.initialState?.tools ?? [],
-      messages: options.initialState?.messages ?? [],
-    };
-    this.transformContext = options.transformContext;
-    this.streamFn = options.streamFn;
-    this.getApiKey = options.getApiKey;
-    agentInstances.push(this);
-  }
-  subscribe(listener: (event: any) => void) {
-    this.listeners.push(listener);
-    return () => {
-      this.listeners = this.listeners.filter((item) => item !== listener);
-    };
-  }
-  emit(event: any) {
-    for (const listener of this.listeners) listener(event);
-  }
-  async prompt(userMessage: string) {
-    const now = Date.now();
-    const user = { role: "user", content: userMessage, timestamp: now };
-    const assistant = {
-      role: "assistant",
-      content: [{ type: "text", text: "ok" }],
-      api: "anthropic-messages",
-      provider: "anthropic",
-      model: "fake",
-      usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
-      stopReason: "stop",
-      timestamp: now + 1,
-    };
-    this.emit({ type: "turn_start" });
-    this.emit({ type: "message_end", message: user });
-    this.state.messages.push(user);
-    this.emit({ type: "message_end", message: assistant });
-    this.state.messages.push(assistant);
-    this.emit({ type: "turn_end", message: assistant, toolResults: [] });
-    this.emit({ type: "agent_end", messages: [user, assistant] });
-  }
-}
-```
-
-新增测试:
-
-```ts
-it("把 message_end 写入 JSONL,并在 cache 失效后恢复 raw AgentMessage", async () => {
-  const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
-  const pipeline = {} as any;
-
-  await runAgentSession(
-    { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
-    "hi",
-  );
-
-  evictAgentCache("s1");
-
-  await runAgentSession(
-    { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
-    "again",
-  );
-
-  expect(agentInstances).toHaveLength(2);
-  expect(agentInstances[1].state.messages[0]).toMatchObject({ role: "user", content: "hi" });
-  expect(agentInstances[1].state.messages[1]).toMatchObject({ role: "assistant", content: [{ type: "text", text: "ok" }] });
-});
-
-it("恢复 transcript 中的 toolResult message", async () => {
-  const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
-  const pipeline = {} as any;
-
-  await runAgentSession(
-    { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
-    "hi",
-  );
-  const first = agentInstances[0];
-  const assistant = {
-    role: "assistant",
-    content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "a.md" } }],
-    api: "anthropic-messages",
-    provider: "anthropic",
-    model: "fake",
-    usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
-    stopReason: "tool_use",
-    timestamp: Date.now(),
-  };
-  const toolResult = { role: "toolResult", toolCallId: "tool-1", toolName: "read", content: [{ type: "text", text: "file" }], details: { path: "a.md" }, isError: false, timestamp: Date.now() + 1 };
-  first.emit({ type: "message_end", message: assistant });
-  first.emit({ type: "message_end", message: toolResult });
-  first.state.messages.push(assistant, toolResult);
-
-  evictAgentCache("s1");
-
-  await runAgentSession(
-    { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
-    "again",
-  );
-
-  expect(agentInstances[1].state.messages.some((message: any) => message.role === "toolResult" && message.toolCallId === "tool-1")).toBe(true);
-});
-```
-
-- [ ] **Step 2: 运行测试确认失败**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- agent-session.test.ts
-```
-
-预期: FAIL,第二个 Agent 没有从 JSONL 恢复历史。
-
-- [ ] **Step 3: 改 AgentSession 类型和 cache**
-
-在 `packages/core/src/agent/agent-session.ts` 中:
-
-```ts
-import { randomUUID } from "node:crypto";
-import { appendTranscriptEvent, nextTranscriptSeq, readTranscriptEvents } from "../interaction/session-transcript.js";
-import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
-```
-
-调整接口:
-
-```ts
-export interface AgentSessionResult {
-  responseText: string;
-  messages: AgentMessage[];
-}
-
-interface CachedAgent {
-  agent: Agent;
-  bookId: string | null;
-  modelId: string | null;
-  lastCommittedSeq: number;
-  lastActive: number;
-}
-```
-
-新增 helper:
-
-```ts
-async function latestCommittedSeq(projectRoot: string, sessionId: string): Promise<number> {
-  const events = await readTranscriptEvents(projectRoot, sessionId);
-  return events
-    .filter((event) => event.type === "request_committed")
-    .reduce((max, event) => Math.max(max, event.seq), 0);
-}
-```
-
-- [ ] **Step 4: 实现恢复和写入**
-
-在创建 Agent 时改成:
-
-```ts
-const restoredMessages = await restoreAgentMessagesFromTranscript(projectRoot, sessionId);
-const agent = new Agent({
-  initialState: {
-    model,
-    systemPrompt: buildAgentSystemPrompt(bookId, language),
-    tools: [...],
-    messages: restoredMessages,
-  },
-  transformContext: createBookContextTransform(bookId, projectRoot),
-  streamFn: streamSimple,
-  getApiKey: ...
-});
-```
-
-执行 prompt 前后加入 request event 和 message event:
-
-```ts
-const requestId = randomUUID();
-let seq = await nextTranscriptSeq(projectRoot, sessionId);
-await appendTranscriptEvent(projectRoot, {
-  type: "request_started",
-  version: 1,
-  sessionId,
-  requestId,
-  seq: seq++,
-  timestamp: Date.now(),
-  input: userMessage,
-});
-
-let parentUuid: string | null = null;
-let piTurnIndex = 0;
-let lastAssistantUuid: string | null = null;
-
-const persistEvent = async (event: AgentEvent) => {
-  if (event.type === "turn_start") piTurnIndex += 1;
-  if (event.type !== "message_end") return;
-  const message = event.message as any;
-  const uuid = randomUUID();
-  const role = message.role;
-  const toolCallId = role === "toolResult" ? message.toolCallId : Array.isArray(message.content) ? message.content.find((block: any) => block.type === "toolCall")?.id : undefined;
-  await appendTranscriptEvent(projectRoot, {
-    type: "message",
-    version: 1,
-    sessionId,
-    requestId,
-    uuid,
-    parentUuid: role === "toolResult" && lastAssistantUuid ? lastAssistantUuid : parentUuid,
-    seq: seq++,
-    role,
-    timestamp: message.timestamp ?? Date.now(),
-    piTurnIndex,
-    ...(toolCallId ? { toolCallId } : {}),
-    ...(role === "toolResult" && lastAssistantUuid ? { sourceToolAssistantUuid: lastAssistantUuid } : {}),
-    message,
-  });
-  if (role === "assistant") lastAssistantUuid = uuid;
-  parentUuid = uuid;
-};
-```
-
-subscription 同时持久化和转发:
-
-```ts
-unsubscribe = agent.subscribe(async (event: AgentEvent) => {
-  await persistEvent(event);
-  onEvent?.(event);
-});
-```
-
-commit/fail:
-
-```ts
-try {
-  await agent.prompt(userMessage);
-  await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId, requestId, seq: seq++, timestamp: Date.now() });
-} catch (error) {
-  await appendTranscriptEvent(projectRoot, { type: "request_failed", version: 1, sessionId, requestId, seq: seq++, timestamp: Date.now(), error: error instanceof Error ? error.message : String(error) });
-  throw error;
-} finally {
-  unsubscribe?.();
-}
-```
-
-返回:
-
-```ts
-return { responseText, messages: agent.state.messages.slice() };
-```
-
-- [ ] **Step 5: 运行测试确认通过**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- agent-session.test.ts session-transcript.test.ts session-transcript-restore.test.ts
-```
-
-预期: PASS。
-
-- [ ] **Step 6: 提交**
-
-```bash
-git add packages/core/src/agent/agent-session.ts packages/core/src/__tests__/agent-session.test.ts
-git commit -m "feat: persist agent messages to transcript"
-```
-
----
-
-### Task 6: Studio API 停止写 legacy BookSession message
-
-**Files:**
-- Modify: `packages/studio/src/api/server.ts`
-- Modify: `packages/studio/src/api/server.test.ts`
-
-- [ ] **Step 1: 写失败测试**
-
-在 `packages/studio/src/api/server.test.ts` 的 `/api/v1/agent` 测试里加入:
-
-```ts
-it("does not append or persist legacy BookSession messages after agent success", async () => {
-  runAgentSessionMock.mockResolvedValueOnce({
-    responseText: "Agent response.",
-    messages: [
-      { role: "user", content: "continue", timestamp: 1 },
-      { role: "assistant", content: [{ type: "text", text: "Agent response." }], timestamp: 2 },
-    ],
-  });
-  loadBookSessionMock
-    .mockResolvedValueOnce({
-      sessionId: "agent-session-1",
-      bookId: "demo-book",
-      title: null,
-      messages: [],
-      events: [],
-      draftRounds: [],
-      createdAt: 1,
-      updatedAt: 1,
-    })
-    .mockResolvedValueOnce({
-      sessionId: "agent-session-1",
-      bookId: "demo-book",
-      title: "continue",
-      messages: [
-        { role: "user", content: "continue", timestamp: 1 },
-        { role: "assistant", content: "Agent response.", timestamp: 2 },
-      ],
-      events: [],
-      draftRounds: [],
-      createdAt: 1,
-      updatedAt: 2,
-    });
-
-  const { createStudioServer } = await import("./server.js");
-  const app = createStudioServer(cloneProjectConfig() as never, root);
-
-  const response = await app.request("http://localhost/api/v1/agent", {
-    method: "POST",
-    headers: { "Content-Type": "application/json" },
-    body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book", sessionId: "agent-session-1" }),
-  });
-
-  expect(response.status).toBe(200);
-  expect(appendBookSessionMessageMock).not.toHaveBeenCalled();
-  expect(persistBookSessionMock).not.toHaveBeenCalled();
-  expect(runAgentSessionMock).toHaveBeenCalledWith(
-    expect.objectContaining({ sessionId: "agent-session-1" }),
-    "continue",
-  );
-});
-```
-
-- [ ] **Step 2: 运行测试确认失败**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-studio test -- server.test.ts
-```
-
-预期: FAIL,因为当前 server 会调用 `appendBookSessionMessage` 和 `persistBookSession`。
-
-- [ ] **Step 3: 修改 `/api/v1/agent` 主路径**
-
-在 `packages/studio/src/api/server.ts`:
-
-1. 删除 `initialMessages` 构造。
-2. 调用 `runAgentSession(config, instruction)`,不传第三个参数。
-3. 删除 “Persist user + assistant messages to BookSession” 块。
-4. 成功后重新 `loadBookSession(root, bookSession.sessionId)` 获取 transcript 派生 session。
-5. 如果派生 session title 从 null 变为非空,继续 `broadcast("session:title", ...)`。
-
-核心形状:
-
-```ts
-const beforeTitle = bookSession.title;
-const result = await runAgentSession(
-  {
-    model,
-    apiKey: agentApiKey,
-    pipeline,
-    projectRoot: root,
-    bookId: activeBookId ?? null,
-    sessionId: bookSession.sessionId,
-    language: config.language ?? "zh",
-    onEvent: ...
-  },
-  instruction,
-);
-
-bookSession = await loadBookSession(root, bookSession.sessionId) ?? bookSession;
-if (beforeTitle === null && bookSession.title) {
-  broadcast("session:title", { sessionId: bookSession.sessionId, title: bookSession.title });
-}
-```
-
-fallback plain chat 分支需要写一个 synthetic assistant response。新增 helper 从 core 导出后在 server 调用:
-
-```ts
-await appendManualSessionMessages(root, bookSession.sessionId, [
-  { role: "assistant", content: [{ type: "text", text: fallback.content }], timestamp: Date.now() },
-]);
-bookSession = await loadBookSession(root, bookSession.sessionId) ?? bookSession;
-```
-
-在本任务把 `appendManualSessionMessages` 补到 `session-transcript.ts`:
-
-```ts
-export async function appendManualSessionMessages(projectRoot: string, sessionId: string, messages: unknown[]): Promise<void> {
-  const requestId = randomUUID();
-  let seq = await nextTranscriptSeq(projectRoot, sessionId);
-  await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId, requestId, seq: seq++, timestamp: Date.now(), input: "" });
-  let parentUuid: string | null = null;
-  for (const message of messages) {
-    const record = message as { role: "user" | "assistant" | "toolResult" | "system"; timestamp?: number };
-    const uuid = randomUUID();
-    await appendTranscriptEvent(projectRoot, { type: "message", version: 1, sessionId, requestId, uuid, parentUuid, seq: seq++, role: record.role, timestamp: record.timestamp ?? Date.now(), message: record });
-    parentUuid = uuid;
-  }
-  await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId, requestId, seq: seq++, timestamp: Date.now() });
-}
-```
-
-- [ ] **Step 4: 运行测试确认通过**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-studio test -- server.test.ts
-```
-
-预期: PASS。
-
-- [ ] **Step 5: 提交**
-
-```bash
-git add packages/studio/src/api/server.ts packages/studio/src/api/server.test.ts packages/core/src/interaction/session-transcript.ts packages/core/src/index.ts
-git commit -m "feat: derive studio agent sessions from transcript"
-```
-
----
-
-### Task 7: 导出 API、类型检查和回归测试
-
-**Files:**
-- Modify: `packages/core/src/index.ts`
-- Modify: `packages/core/src/__tests__/book-session-store.test.ts`
-- Modify: `packages/studio/src/api/server.test.ts`
-
-- [ ] **Step 1: 写导出测试**
-
-在 `packages/core/src/__tests__/session-transcript.test.ts` 增加从 package index 导入的 smoke test:
-
-```ts
-it("从 core index 导出 transcript helper", async () => {
-  const core = await import("../index.js");
-  expect(typeof core.readTranscriptEvents).toBe("function");
-  expect(typeof core.restoreAgentMessagesFromTranscript).toBe("function");
-});
-```
-
-- [ ] **Step 2: 修改 index exports**
-
-在 `packages/core/src/index.ts` 增加:
-
-```ts
-export {
-  appendManualSessionMessages,
-  appendTranscriptEvent,
-  legacyBookSessionPath,
-  nextTranscriptSeq,
-  readTranscriptEvents,
-  sessionsDir,
-  transcriptPath,
-} from "./interaction/session-transcript.js";
-export {
-  cleanRestoredAgentMessages,
-  committedMessageEvents,
-  deriveBookSessionFromTranscript,
-  restoreAgentMessagesFromTranscript,
-} from "./interaction/session-transcript-restore.js";
-export {
-  MessageEventSchema,
-  RequestCommittedEventSchema,
-  RequestFailedEventSchema,
-  RequestStartedEventSchema,
-  SessionCreatedEventSchema,
-  SessionMetadataUpdatedEventSchema,
-  TranscriptEventSchema,
-  type MessageEvent,
-  type RequestCommittedEvent,
-  type RequestFailedEvent,
-  type RequestStartedEvent,
-  type SessionCreatedEvent,
-  type SessionMetadataUpdatedEvent,
-  type TranscriptEvent,
-} from "./interaction/session-transcript-schema.js";
-```
-
-- [ ] **Step 3: 运行 core 全量相关测试**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- session-transcript.test.ts session-transcript-restore.test.ts book-session-store.test.ts agent-session.test.ts
-```
-
-预期: PASS。
-
-- [ ] **Step 4: 运行 typecheck**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core typecheck
-pnpm --filter @actalk/inkos-studio typecheck
-```
-
-预期: PASS。
-
-- [ ] **Step 5: 提交**
-
-```bash
-git add packages/core/src/index.ts packages/core/src/__tests__/session-transcript.test.ts
-git commit -m "chore: export transcript APIs"
-```
-
----
-
-### Task 8: 最终验证和文档同步
-
-**Files:**
-- Modify: `docs/superpowers/specs/2026-04-27-pi-agent-jsonl-persistence-design.md`
-- Modify: `AGENTS.md`
-
-- [ ] **Step 1: 确认 SPEC 已记录 legacy display 和 fallback 补写**
-
-确认 `docs/superpowers/specs/2026-04-27-pi-agent-jsonl-persistence-design.md` 包含以下两点:
-
-```md
-`MessageEvent.legacyDisplay` 只用于 legacy JSON 迁移后的 UI 派生,例如旧 `InteractionMessage.thinking`。它不进入模型恢复路径,避免把无 signature 的旧 thinking 伪造成可回放的 provider thinking block。
-
-如果 Studio plain chat fallback 需要补写 assistant 文本,使用 synthetic committed request 写入 transcript。
-```
-
-- [ ] **Step 2: 运行完整相关测试**
-
-运行:
-
-```bash
-pnpm --filter @actalk/inkos-core test -- session-transcript.test.ts session-transcript-restore.test.ts book-session-store.test.ts agent-session.test.ts
-pnpm --filter @actalk/inkos-studio test -- server.test.ts
-pnpm --filter @actalk/inkos-core typecheck
-pnpm --filter @actalk/inkos-studio typecheck
-```
-
-预期: 全部 PASS。
-
-- [ ] **Step 3: 检查不会继续写 legacy JSON**
-
-运行:
-
-```bash
-rg -n "persistBookSession\\(|appendBookSessionMessage\\(" packages/studio/src/api/server.ts packages/core/src/agent packages/core/src/interaction
-```
-
-预期: `packages/studio/src/api/server.ts` 不再有 agent 成功路径对 `appendBookSessionMessage()` 或 `persistBookSession()` 的调用;`book-session-store.ts` 可以保留兼容导出。
-
-- [ ] **Step 4: 查看 git diff**
-
-运行:
-
-```bash
-git diff --stat
-git status --short
-```
-
-预期: 只包含本计划涉及的 core、studio、测试和文档文件。
-
-- [ ] **Step 5: 提交最终文档同步**
-
-```bash
-git add docs/superpowers/specs/2026-04-27-pi-agent-jsonl-persistence-design.md AGENTS.md
-git commit -m "docs: sync transcript persistence notes"
-```

+ 0 - 377
docs/superpowers/specs/2026-04-27-pi-agent-jsonl-persistence-design.md

@@ -1,377 +0,0 @@
-# InkOS Pi-Agent JSONL 持久化设计
-
-状态:已确认进入 SPEC
-日期:2026-04-27
-
-## 1. 目标
-
-InkOS pi-agent session 持久化收敛为一层:追加式 JSONL transcript,路径为 `.inkos/sessions/{sessionId}.jsonl`。
-
-transcript 是 agent 历史的唯一持久事实源。UI session 对象、聊天消息、thinking 展示、工具执行面板、session 列表摘要、恢复后的 `Agent.state.messages` 都从 transcript 派生。
-
-这个设计替换当前两层行为:`BookSession` JSON 持久化 UI 形状的消息,`agentCache` 在短时间内保存更完整的内存态 `AgentMessage` 历史。
-
-## 2. 源码对齐
-
-### 2.1 Claude Code Main
-
-Claude Code 用 JSONL transcript entry 存储对话历史。恢复主链围绕 `uuid`、`parentUuid`、`sessionId` 展开,没有把 transcript 级 `turnId` 作为恢复主结构。
-
-关键源码位置:
-
-- `/Users/fanghanjun/claude-code-main/src/types/logs.ts`:`SerializedMessage` 携带 `sessionId`、`timestamp`、`version`、`cwd` 等元数据。
-- `/Users/fanghanjun/claude-code-main/src/types/logs.ts`:`TranscriptMessage` 增加 `parentUuid`、`isSidechain` 等图结构字段。
-- `/Users/fanghanjun/claude-code-main/src/utils/sessionStorage.ts`:`insertMessageChain()` 写入消息并分配 `parentUuid`。
-- `/Users/fanghanjun/claude-code-main/src/utils/sessionStorage.ts`:tool result 通过 `sourceToolAssistantUUID` 绑定到发出 tool use 的 assistant message。
-- `/Users/fanghanjun/claude-code-main/src/utils/sessionStorage.ts`:`buildConversationChain()` 从 leaf message 沿 `parentUuid` 回溯恢复主链。
-- `/Users/fanghanjun/claude-code-main/src/utils/sessionStorage.ts`:`recoverOrphanedParallelToolResults()` 修复并行工具调用导致的 sibling assistant/tool-result 分支遗漏。
-
-Claude Code 在 query 和 compact tracking 中有 `turnId`,但 transcript 恢复不依赖它。InkOS 应采用同类结构:用图字段和顺序字段支撑持久恢复,用 request 级标识做分组和诊断。
-
-### 2.2 pi-agent-core
-
-pi-agent-core 有内部 `turn_start` / `turn_end` 事件。这个 turn 边界表示一次 assistant response cycle,粒度小于一次用户请求。
-
-一次 `agent.prompt()` 可以包含多个 pi-agent turn:
-
-1. user prompt 进入 `currentContext.messages`。
-2. assistant 生成 `toolCall`。
-3. 工具执行返回 `toolResult` message。
-4. `toolResult` 被 push 进 `currentContext.messages`。
-5. 下一次 assistant response 带着 tool result 继续运行。
-
-关键源码位置:
-
-- `node_modules/.../@mariozechner/pi-agent-core/dist/agent-loop.js`:`runAgentLoop()` 构造 `currentContext.messages = [...context.messages, ...prompts]`。
-- `node_modules/.../@mariozechner/pi-agent-core/dist/agent-loop.js`:`runLoop()` 围绕每次 assistant response cycle 发出 `turn_start` 和 `turn_end`。
-- `node_modules/.../@mariozechner/pi-agent-core/dist/agent-loop.js`:`emitToolCallOutcome()` 构造 `role: "toolResult"` message。
-- `node_modules/.../@mariozechner/pi-agent-core/dist/agent.js`:`processEvents()` 在每个 `message_end` 时把 message push 到 `Agent.state.messages`。
-
-InkOS 外层用户请求字段命名为 `requestId`。这样可以让 pi-agent-core 的内部 turn 语义继续保留给 `piTurnIndex`。
-
-## 3. 核心决策
-
-1. 使用 JSONL,一行一个事件。
-2. 使用 `.inkos/sessions/{sessionId}.jsonl` 作为 canonical path。
-3. 保留 `.inkos/sessions/{sessionId}.json` 的 legacy 读取能力。
-4. 新 session 和已迁移 session 停止写完整 `BookSession` JSON。
-5. 持久化原始 `AgentMessage`,避免写入压平后的 UI message。
-6. 使用 `requestId` 表示一次 InkOS 用户请求。
-7. 使用可选 `piTurnIndex` 表示 pi-agent-core 内部 turn 分组。
-8. 使用 `uuid`、`parentUuid`、`seq` 支撑持久排序和未来图恢复。
-9. 使用 `request_committed` 作为恢复栅栏。
-10. UI 状态从 transcript event 派生,不单独持久化 `UiMessageEvent`。
-
-## 4. 事件模型
-
-### 4.1 事件联合类型
-
-```ts
-type TranscriptEvent =
-  | SessionCreatedEvent
-  | SessionMetadataUpdatedEvent
-  | RequestStartedEvent
-  | MessageEvent
-  | RequestCommittedEvent
-  | RequestFailedEvent
-```
-
-### 4.2 消息事件
-
-```ts
-type MessageEvent = {
-  type: "message"
-  version: 1
-
-  sessionId: string
-  requestId: string
-
-  uuid: string
-  parentUuid: string | null
-  seq: number
-
-  role: "user" | "assistant" | "toolResult" | "system"
-  timestamp: number
-
-  piTurnIndex?: number
-  toolCallId?: string
-  sourceToolAssistantUuid?: string
-  legacyDisplay?: {
-    thinking?: string
-    toolExecutions?: unknown[]
-  }
-
-  message: AgentMessage
-}
-```
-
-`message` 必须保留 pi-agent-core 原始 `AgentMessage`。assistant 的 `thinking`、`text`、`toolCall` content block,以及 `toolResult.content`、`toolResult.details`、`toolResult.isError` 都必须完整写入。
-
-`legacyDisplay` 只服务于旧 JSON 迁移后的 UI 派生,例如旧 `InteractionMessage.thinking`。它不进入模型恢复路径,避免把没有 provider signature 的旧 thinking 伪造成可回放给模型的 signed thinking block。
-
-### 4.3 请求事件
-
-```ts
-type RequestStartedEvent = {
-  type: "request_started"
-  version: 1
-  sessionId: string
-  requestId: string
-  seq: number
-  timestamp: number
-  input: string
-}
-
-type RequestCommittedEvent = {
-  type: "request_committed"
-  version: 1
-  sessionId: string
-  requestId: string
-  seq: number
-  timestamp: number
-}
-
-type RequestFailedEvent = {
-  type: "request_failed"
-  version: 1
-  sessionId: string
-  requestId: string
-  seq: number
-  timestamp: number
-  error: string
-}
-```
-
-`request_committed` 是恢复时使用的 durable boundary。`request_started` 后面的 message event 只有在匹配的 `request_committed` 存在时才进入模型恢复。
-
-## 5. 写入路径
-
-写入路径在 `runAgentSession()` 内订阅 pi-agent-core event。
-
-每次 `/api/v1/agent` 请求执行以下步骤:
-
-1. 分配 `requestId`。
-2. append `request_started`。
-3. 执行 `agent.prompt(instruction)`。
-4. 每次收到 pi-agent-core `message_end` 时 append 一个 `message` event。
-5. 根据 pi-agent-core `turn_start` / `turn_end` 在内存中维护 `piTurnIndex`。
-6. `agent.prompt()` 正常结束后 append `request_committed`。
-7. 执行失败或中断时 append `request_failed`。
-
-带工具调用的请求会形成这类事件序列:
-
-```text
-request_started
-message(user)
-message(assistant: thinking + toolCall)
-message(toolResult)
-message(assistant: thinking + text)
-request_committed
-```
-
-writer 必须按 session 串行化 append。第一版使用进程内 per-session queue 即可满足 Studio 单 Node 进程模型。每次 append 写入一个 JSON object,再写入 `\n`。
-
-## 6. 恢复路径
-
-恢复路径从 JSONL transcript 构造 `Agent.state.messages`。
-
-算法:
-
-1. 读取 `.inkos/sessions/{sessionId}.jsonl`。
-2. 按文件顺序解析合法 JSONL event。
-3. 构造已 committed 的 `requestId` 集合。
-4. 收集 `requestId` 已 committed 的 `message` event。
-5. 按 `seq` 排序。
-6. 执行模型合法性清理。
-7. 返回 `messageEvent.message[]`。
-8. 在 `agent.prompt()` 前赋值给 `agent.state.messages`。
-
-第一版使用 committed `seq` 顺序恢复。schema 同时写入 `uuid`、`parentUuid`、`toolCallId`、`sourceToolAssistantUuid`,后续可以增加 Claude Code 风格 leaf recovery,文件格式无需迁移。
-
-## 7. thinking 持久化
-
-thinking 必须作为 assistant 原始 content 持久化,不能只存 UI 字符串。
-
-正确 transcript 数据形状:
-
-```ts
-{
-  role: "assistant",
-  content: [
-    { type: "thinking", thinking: "...", signature: "..." },
-    { type: "text", text: "..." }
-  ]
-}
-```
-
-UI thinking 面板从 assistant thinking block 派生。
-
-模型恢复使用合法性清理后的原始 assistant message。清理需要覆盖:
-
-1. 丢弃没有有效 sibling assistant content 的孤立 thinking-only assistant message。
-2. 当 provider 拒绝以 thinking 结尾的 history 时,从最后一条 assistant message 移除 trailing thinking block。
-3. 当 model/provider/auth fallback 使 signature 失效时,移除带 signature 的 thinking block。
-
-legacy `.json` session 可能有 `InteractionMessage.thinking`,但该字符串没有 provider signature。迁移可以保留它用于 UI 展示,不能把它伪造成可回放给模型的 signed thinking block。
-
-## 8. tool result 持久化
-
-tool result 必须作为一等 `message` event 进入 transcript。
-
-当前 InkOS 信息损耗点在 `agentMessagesToPlain()`:它跳过 `ToolResult` message。这个行为导致两种上下文结果:
-
-1. cache 存活时,模型能看到之前的 tool result,因为 `Agent.state.messages` 仍在内存中。
-2. cache 过期或进程重启后,模型只能看到压平后的 user/assistant 文本。
-
-JSONL transcript 通过持久化原始 message 消除这种差异。恢复后的 `Agent.state.messages` 包含 committed request 结束时内存中存在的 user、assistant、toolResult message。
-
-`restoreAgentMessagesFromTranscript()` 只恢复 committed 且清理后的 raw `AgentMessage`。
-provider-specific 兼容投影发生在 `adaptRestoredAgentMessagesForModel()`:
-跨模型会把外来 tool call/result 文本化;只有目标 provider 的 `compat.requiresAssistantAfterToolResult`
-为 true 时,才在 restored `toolResult` 后补 synthetic assistant bridge。
-
-`sourceToolAssistantUuid` 在可获得时指向发出对应 tool call 的 assistant message。后续图恢复可以借此把 tool result 绑定到来源 tool use,降低对顺序恢复的依赖。
-
-## 9. 缓存角色
-
-`agentCache` 保留为加速层,不承担持久化职责。
-
-缓存值调整为:
-
-```ts
-type CachedAgent = {
-  agent: Agent
-  bookId: string | null
-  modelId: string | null
-  lastCommittedSeq: number
-  lastActive: number
-}
-```
-
-复用条件:
-
-1. `sessionId` 匹配。
-2. `bookId` 匹配。
-3. `modelId` 匹配。
-4. transcript 最新 committed seq 等于 `lastCommittedSeq`。
-
-任一条件不满足时,从 JSONL 重建 Agent。cache eviction 不会丢上下文,因为 JSONL 是持久事实源。
-
-## 10. UI 派生
-
-不引入独立的 `AgentMessageEvent | UiMessageEvent` 双事件线。
-
-UI 状态从 transcript event 派生:
-
-- user chat bubble:`message.role === "user"`。
-- assistant chat bubble:assistant text block。
-- thinking display:assistant thinking block。
-- tool execution panel:assistant toolCall block 按 `toolCallId` 关联 `toolResult`。
-- title:第一条 user message 或 `session_metadata_updated`。
-- book binding:`session_created` 和 `session_metadata_updated`。
-- session list summary:最后一条可见 user/assistant 文本与 metadata。
-
-纯 `toolResult` message 不作为独立 user message 展示。它服务于工具执行面板和模型恢复。
-
-## 11. 旧数据兼容
-
-现有 `.inkos/sessions/{sessionId}.json` 文件作为 legacy session 读取。
-
-迁移行为:
-
-1. JSONL 存在时优先读取 JSONL。
-2. 只有 JSON 存在时,读取 legacy `BookSession`。
-3. 把 legacy user/assistant message 转成 transcript `message` event。
-4. 把生成的 request 标记为 committed。
-5. 保留 `sessionId`、`bookId`、`title`、`createdAt`、`updatedAt` 等 metadata。
-6. 保留 legacy assistant `thinking` 给 UI 展示。
-7. 不生成 legacy 数据里不存在的 tool result、tool call、signed thinking。
-8. 迁移成功后,运行时只写 JSONL。
-
-旧 JSON 文件可以继续留在磁盘上。迁移后它不再被更新。
-
-如果 Studio plain chat fallback 需要补写 assistant 文本,使用 synthetic committed request 写入 transcript。该补写仍然产生 `request_started -> message -> request_committed`,不恢复 legacy JSON 写入路径。
-
-## 12. 错误处理
-
-格式错误的 JSONL 行不能导致 session 加载失败。reader 记录 warning 并跳过不可解析行。
-
-缺少 `request_committed` 的 request 被视为 interrupted tail:
-
-- 不进入模型恢复。
-- 保留给诊断。
-- 后续可以在 UI 中展示为 interrupted request。
-
-如果最后一条 committed message 让模型 history 处于非法状态,恢复前执行清理。第一版清理 unresolved tool use、孤立 tool result、孤立 thinking-only message、空 assistant message、trailing thinking。
-
-## 13. 测试策略
-
-这个改动必须用 TDD。核心风险是重启、cache 过期、工具调用循环后发生静默上下文丢失。
-
-测试分层:
-
-1. JSONL codec 单测
-   - 一行 append 一个 event。
-   - 保留原始 `AgentMessage` 内的未知字段。
-   - 拒绝非法 schema 形状。
-   - 分配单调递增 `seq`。
-
-2. restore 单测
-   - 只恢复 committed request。
-   - 忽略 interrupted tail。
-   - 保留 user、assistant、toolResult 顺序。
-   - 返回原始 `AgentMessage[]`。
-
-3. thinking 单测
-   - 保留 assistant thinking text。
-   - 保留 provider signature 字段。
-   - 不把 legacy UI thinking 转成 signed model thinking。
-   - 过滤非法 trailing thinking 或孤立 thinking。
-
-4. tool loop 单测
-   - 持久化 `assistant(toolCall) -> toolResult -> assistant(text)`。
-   - 恢复 toolResult 到 `Agent.state.messages`。
-   - 通过 `toolCallId` 绑定 toolResult 和 toolCall。
-
-5. legacy migration 单测
-   - 读取旧 BookSession JSON。
-   - 派生 UI 兼容 message。
-   - 迁移后写入 JSONL。
-   - 停止写 legacy JSON。
-
-6. cache 单测
-   - 最新 committed seq 匹配时复用 cache。
-   - cache 落后时从 JSONL 重建。
-   - book 或 model 变化时重建。
-
-7. API 集成测试
-   - `/api/v1/agent` 写 transcript event。
-   - session list/detail API 从 JSONL 派生。
-   - 前端响应形状保持兼容。
-
-## 14. 实现边界
-
-预期 core 模块:
-
-- `packages/core/src/interaction/session-transcript.ts`
-- `packages/core/src/interaction/session-transcript-schema.ts`
-- `packages/core/src/interaction/session-transcript-restore.ts`
-- `packages/core/src/interaction/session-transcript-legacy.ts`
-
-预期集成点:
-
-- `packages/core/src/agent/agent-session.ts`
-- `packages/core/src/interaction/book-session-store.ts`
-- `packages/studio/src/api/server.ts`
-
-第一版不实现 compaction、branching UI、remote sync、完整 Claude Code graph recovery。schema 预留后续能力所需字段。
-
-## 15. 验收标准
-
-1. 新 session 写入 `.inkos/sessions/{sessionId}.jsonl`。
-2. 已迁移或新建 session 不再写 `.inkos/sessions/{sessionId}.json`。
-3. Studio 重启后能恢复包含 tool result 的原始 `AgentMessage[]`。
-4. assistant thinking block 经过 JSONL 写入和恢复后仍然存在。
-5. legacy JSON session 仍然可以加载和展示。
-6. cache 过期不会改变模型可见的 conversation context。
-7. 测试覆盖 committed restore、interrupted tail 排除、tool result 恢复、thinking 持久化、legacy migration、cache invalidation。