# pi-roleplay：Session、资产注入与 LLM Payload

本文用一次**真实执行**解释 pi-roleplay 如何把 Markdown 角色资产、Pi session 和最终 Provider payload 连接起来。它不是根据类型定义虚构的示例：文中的 session、payload、模型回复和 token usage 均取自一次真实的 Pi 0.80.10 + DeepSeek V4 Flash 调用；本机绝对路径已替换为 `<repo>`、`<pi-package-root>`
与 `<skills-root>` 占位符，没有保存认证信息。该次演示记录于 Pi 0.80.10，本包当前的开发与校验基线是
Pi 0.81.1（peer 范围 `>=0.79.0 <0.82.0`）；下文凡涉及 Extension API 能力的结论均已按 0.81.1 复核。

## 0. 先看结论

同一轮对话实际上存在三份不同的数据视图：

| 视图 | 负责什么 | 本轮是否有角色卡 | 是否有世界书正文 | 是否持久化 |
|---|---|---:|---:|---:|
| 角色资产目录 | 创作源与检索索引 | 是 | 是 | 文件持久化 |
| Pi session JSONL | 对话、模型选择、插件状态、分支 | 否，只保存 `characterId` 与结构化状态 | 否 | 是 |
| 最终 LLM payload | 模型这一请求真正看到的内容 | 是，位于 system | 是，位于首条 user | 否；Telemetry 可观测 |

> 最重要的区别：**Session 不是最终 Prompt 的副本。** Session 保存可恢复的对话与状态；插件在每次调用前动态组装角色 system prompt 和世界上下文。

```mermaid
flowchart LR
    A[Markdown 资产] --> B[pi-roleplay 资产目录]
    S[Pi Session JSONL] --> R[当前分支恢复]
    B --> C[角色 Prompt 构造]
    B --> W[世界条目检索]
    R --> C
    R --> W
    C --> P[Provider Payload]
    W --> P
    D[DeepSeek V4 适配] --> P
    P --> L[LLM]
    P -. includePayloads .-> T[Pi Telemetry / Langfuse]
    L --> S
    L --> T
```

---

## 1. 实际演示环境与证据

### 1.1 执行条件

| 项目 | 实际值 |
|---|---|
| Pi | `0.80.10`（该次演示的版本；当前开发基线为 `0.81.1`） |
| 模型 | `tokenhub/deepseek-v4-flash-202605` |
| Thinking | `high` |
| 角色 | `alice` / 爱丽丝 |
| 世界 | `astra` / 阿斯特拉王国 |
| Telemetry | `@amaster.ai/pi-telemetry@0.1.6` |
| Exporter | 本地 Langfuse `3.222.0` |
| `includePayloads` | `true` |

测试输入：

```text
我们已抵达王都塞勒恩，城门旁有人未经许可施展魔法。请以角色身份回应，只写两句话，不要调用工具。
```

为了保证观测顺序，演示显式按以下顺序加载扩展：

```text
pi-roleplay → E2E probe → pi-telemetry
```

因此 Telemetry 的 `before_provider_request` 位于 pi-roleplay 的 payload 修改之后，Langfuse generation input 就是发送前的最终 payload。

### 1.2 仓库中的实际证据

```text
docs/evidence/deepseek-v4-flash-202605/
├── session.actual.jsonl
├── payload.actual.redacted.json
└── telemetry.actual.redacted.json
```

- [`session.actual.jsonl`](evidence/deepseek-v4-flash-202605/session.actual.jsonl)：Pi 实际落盘的 session；`cwd` 已替换为 `<repo>`。
- [`payload.actual.redacted.json`](evidence/deepseek-v4-flash-202605/payload.actual.redacted.json)：Langfuse generation observation 的实际 input；本机绝对路径已替换为 `<pi-package-root>` 与 `<skills-root>` 占位符。
- [`telemetry.actual.redacted.json`](evidence/deepseek-v4-flash-202605/telemetry.actual.redacted.json)：trace、输出、usage、模型和 stop reason。

这三份证据不含 API Key、Langfuse Secret Key、HTTP Authorization header、用户名、邮箱或主机名。文件中保留的
UUID 是该次演示 session、trace 与 response 的随机标识，不指向任何账号。

> 证据目录只存在于本仓库，不随 npm 包发布（见 `package.json` 的 `files`）。从 npm 安装的用户
> 看不到这三个文件；本节其余部分已把关键片段直接内联，不依赖这些附件。

---

## 2. 整体架构：四个平面

pi-roleplay 可以从四个平面理解。

```mermaid
flowchart TB
    subgraph Assets[资产平面]
      C[CHARACTER.md]
      E[examples.md]
      W[WORLD.md]
      WE[entries/**/*.md]
    end

    subgraph Session[持久化平面]
      H[Session Header]
      M[model_change / thinking_level_change]
      RS[custom: pi-roleplay-state]
      MSG[message: user / assistant / toolResult]
    end

    subgraph Runtime[运行时组装平面]
      BA[before_agent_start]
      CT[context]
      BP[before_provider_request]
    end

    subgraph Observe[观测平面]
      PR[Probe JSONL]
      LF[Pi Telemetry / Langfuse]
    end

    C --> BA
    E --> BA
    W --> CT
    WE --> CT
    RS --> BA
    MSG --> CT
    BA --> BP
    CT --> BP
    BP --> LLM[Provider / LLM]
    BP -.-> PR
    BP -.-> LF
    LLM -. response + usage .-> LF
    LLM --> MSG
```

### 2.1 资产平面

资产是 Markdown 创作源：

```text
roleplay/
├── characters/alice/
│   ├── CHARACTER.md
│   └── examples.md
└── worlds/astra/
    ├── WORLD.md
    └── entries/
        ├── core/tone.md
        ├── geography/capital.md
        └── rules/magic.md
```

它们描述初始角色与世界 canon，不因一轮剧情自动改写。

### 2.2 持久化平面

Pi 把 session 存为 JSONL entry tree。pi-roleplay 通过 `pi.appendEntry()` 追加 10 种 custom entry：

```json
{
  "type": "custom",
  "customType": "pi-roleplay-state",
  "data": { "characterId": "alice" }
}
```

| customType | 写入时机 | 体积特征 |
|---|---|---|
| `pi-roleplay-state` | `/rp use` 绑定角色身份 | 极小，只含 `characterId` |
| `pi-roleplay-commit` | `/rp state set\|correct`、`/rp event add`、`/rp memory add` | 小，单次变更加 reason 与 provenance |
| `pi-roleplay-review-decision` | `/rp review accept\|reject` | 小 |
| `pi-roleplay-review-amendment` | `/rp review amend` | 小，保留原提案 |
| `pi-roleplay-turn-status` | `agent_settled` 检出未 finalize 的回合 | 小 |
| `pi-roleplay-repair` | `/rp turn repair` | 小到中等，取决于补交的 proposal |
| `pi-roleplay-sidecar` | 主模型漏调 finalize 后的隔离提取成功 | 小到中等，与一次工具结果相当 |
| `pi-roleplay-audit` | inline 调用、主模型漏调、sidecar 成功或失败 | 极小，只含来源、模型、耗时、计数及脱敏错误 |
| `pi-roleplay-checkpoint` | `/rp checkpoint`、compaction 后、每 50 Commit | **大：完整 state、全部 events/memories、pending reviews、turn status 与资产 hash** |
| `pi-roleplay-archive` | `/rp archive` | **大：完整去重 Event/Memory 归档** |

上表的第 1 行是本节演示 session 中唯一出现的 entry（该演示没有触发状态工具）。角色卡正文和世界正文
始终不写入 session。

做 session 体积估算或隐私评估时必须以完整的 10 种为准：`pi-roleplay-checkpoint` 与
`pi-roleplay-archive` 会把当前 state、事件与记忆整份写入 JSONL，因此它们（而不是 `pi-roleplay-state`）
才是 session 增长和剧情内容落盘的主要来源。这些 entry 都是 `type: "custom"`，不进入 LLM context，
但确实以明文留在 session 文件中。

### 2.3 运行时组装平面

每轮调用中，三个 hook 各司其职：

1. `before_agent_start`：将角色卡前置到 system prompt；
2. `context`：根据当前消息检索并临时注入世界书；
3. `before_provider_request`：仅对 DeepSeek V4 修改最终第一条 user。

### 2.4 观测平面

- Probe 用于 E2E 断言和本地证据采集；
- Pi Telemetry 将 trace、generation input/output、usage 写到 Langfuse；
- Telemetry 不是角色运行所必需，只负责观测。

---

## 3. 资产如何被加载

### 3.1 资产根目录和覆盖顺序

插件依次加载：

```text
1. ~/.pi/agent/roleplay/       用户全局资产
2. <cwd>/.pi/roleplay/         受信任项目资产
```

后加载的同 ID 资产覆盖先加载资产，因此可信项目可以覆盖全局角色或世界。

```mermaid
flowchart LR
    U[用户全局资产] --> C[AssetCatalog]
    P[受信任项目资产] -->|同 ID 覆盖| C
    C --> CH[characters Map]
    C --> WO[worlds Map]
```

### 3.2 角色卡

`CHARACTER.md` 启动时完整读取，解析成 `CharacterCard`：

```text
frontmatter
├── id: alice
├── name: 爱丽丝
├── worlds: [astra]
├── budgets
└── models.deepseekV4FirstUserImmersion

body
└── 身份、外貌、性格、语言、知识边界、关系、原则
```

`examples.md` 按角色配置读取，并受 `budgets.examples` 限制。

### 3.3 世界书的惰性读取

世界书故意不在启动时读取所有正文：

```mermaid
flowchart LR
    A[启动] --> B[完整读取 WORLD.md]
    A --> C[每条 entry 最多读取 64 KiB frontmatter]
    C --> D[建立 keys / aliases / priority 索引]
    U[最近消息] --> E[关键词评分与预算选择]
    D --> E
    E -->|命中后| F[读取 entry 完整正文]
    F --> G[roleplay_world_context]
```

本次输入同时包含：

```text
王都 / 塞勒恩 / 魔法 / 施展
```

因此实际命中：

| 条目 | 触发原因 | priority |
|---|---|---:|
| `astra-tone` | `constant: true` | 100 |
| `astra-capital` | 王都、塞勒恩 | 80 |
| `astra-magic` | 魔法、施展 | 70 |

`WORLD.md` 作为小型世界核心始终随关联世界注入。

---

## 4. Pi Agent Session 的真实结构

## 4.1 Session 是 JSONL entry tree

Pi session 第一行是 header，后续每行一个 entry：

```text
SessionHeader
Entry
Entry
Entry
...
```

除 header 外，entry 通过 `id` / `parentId` 形成树，而不是只能线性追加的聊天数组。

```mermaid
flowchart LR
    MC[model_change<br/>67888ec3] --> TL[thinking_level_change<br/>310ac296]
    TL --> ST[custom<br/>pi-roleplay-state<br/>b19e088d]
    ST --> U[message user<br/>65f557f4]
    U --> A[message assistant<br/>2ae9b8eb]
    U -. /tree 可创建 .-> B[另一条分支]
```

当前分支是从 leaf 沿 `parentId` 回溯到根得到的。插件用：

```ts
ctx.sessionManager.getBranch()
```

恢复当前分支最后选择的角色，而不是读取物理文件的最后一行。

## 4.2 本次实际 Session

真实文件为 [`session.actual.jsonl`](evidence/deepseek-v4-flash-202605/session.actual.jsonl)，共 6 行。以下是字段完全相同的排版版。

### 第 1 行：Header

```json
{
  "type": "session",
  "version": 3,
  "id": "019f824a-70fd-71dc-985c-1921869555d5",
  "timestamp": "2026-07-21T01:29:04.509Z",
  "cwd": "<repo>"
}
```

Header 不是树节点，没有 `id` / `parentId` entry 关系。

### 第 2～4 行：模型、思考等级、角色状态

```json
{
  "type": "model_change",
  "id": "67888ec3",
  "parentId": null,
  "provider": "tokenhub",
  "modelId": "deepseek-v4-flash-202605"
}
```

```json
{
  "type": "thinking_level_change",
  "id": "310ac296",
  "parentId": "67888ec3",
  "thinkingLevel": "high"
}
```

```json
{
  "type": "custom",
  "customType": "pi-roleplay-state",
  "data": { "characterId": "alice" },
  "id": "b19e088d",
  "parentId": "310ac296"
}
```

`custom` entry 会随 session 和分支持久化，但不会被 Pi 转成 LLM message。因此它适合插件状态，不消耗上下文 token。

### 第 5 行：原始用户消息

```json
{
  "type": "message",
  "id": "65f557f4",
  "parentId": "b19e088d",
  "message": {
    "role": "user",
    "content": [
      {
        "type": "text",
        "text": "我们已抵达王都塞勒恩，城门旁有人未经许可施展魔法。请以角色身份回应，只写两句话，不要调用工具。"
      }
    ]
  }
}
```

注意：这里**没有**：

```text
<roleplay_character>
<roleplay_world_context>
【角色沉浸要求】
```

这证明运行时注入没有污染原始 user session entry。

### 第 6 行：真实助手消息

Session 保存了 assistant 的 thinking/text、模型、usage 和 response ID。本次实际值：

```json
{
  "role": "assistant",
  "content": [
    { "type": "thinking", "thinkingSignature": "reasoning_content" },
    {
      "type": "text",
      "text": "我停下脚步，目光投向城门方向扬起的尘土。（王都的第一道见面礼，竟然是个不懂规矩的术士。）"
    }
  ],
  "api": "openai-completions",
  "provider": "tokenhub",
  "model": "deepseek-v4-flash-202605",
  "usage": {
    "input": 84,
    "output": 113,
    "cacheRead": 3712,
    "reasoning": 85,
    "totalTokens": 3909
  },
  "stopReason": "stop",
  "responseId": "7c22ab3b-ec8a-45b8-9bdf-16b314259a0d"
}
```

完整 thinking 和 cost 可查看证据文件。

## 4.3 Session entry 与 LLM context 的映射

| Session entry | 进入 LLM context | 说明 |
|---|---:|---|
| `session` header | 否 | 文件元数据 |
| `model_change` | 否 | 恢复模型选择 |
| `thinking_level_change` | 否 | 恢复 thinking level |
| `custom` | 否 | 扩展状态；本插件保存角色 ID |
| `message` | 是 | user / assistant / toolResult 等 |
| `custom_message` | 是 | 扩展可持久化的上下文消息；本插件当前未使用 |
| `compaction` | 是，转换为摘要 | 长上下文压缩 |
| `branch_summary` | 是，转换为摘要 | 分支切换摘要 |

---

## 5. 一轮请求如何被组装

```mermaid
sequenceDiagram
    participant User as 用户
    participant Pi as Pi Agent
    participant RP as pi-roleplay
    participant Session as SessionManager
    participant Provider as Provider
    participant Tel as Pi Telemetry

    User->>Pi: 输入剧情消息
    Pi->>Session: append message:user 原文
    Pi->>RP: before_agent_start(systemPrompt)
    RP-->>Pi: 角色卡 + examples + runtime boundary + Pi prompt
    Pi->>RP: context(session messages copy)
    RP->>RP: 检索 tone / capital / magic
    RP-->>Pi: 世界上下文前置到最新 user
    Pi->>Provider: 序列化 OpenAI-completions payload
    Pi->>RP: before_provider_request(payload)
    RP-->>Pi: 第一条 user 末尾追加 DeepSeek 指令
    Pi->>Tel: before_provider_request(final payload)
    Pi->>Provider: 发送最终 payload
    Provider-->>Pi: thinking + text + usage
    Pi->>Session: append message:assistant
    Pi->>Tel: message_end(output + usage)
```

这里有两个重要细节：

1. `context` 可能在一次 agent run 中被调用多次，例如模型调用工具后继续生成；所以它操作的是消息副本，且用 marker 防止同一份副本重复注入。
2. DeepSeek 适配必须在 `before_provider_request` 执行，因为只有那里能修改 Provider 序列化完成后的第一条 user。

---

## 6. 最终 Payload：Telemetry 的实际观测

完整脱敏 payload 位于 [`payload.actual.redacted.json`](evidence/deepseek-v4-flash-202605/payload.actual.redacted.json)。顶层实际结构为：

```json
{
  "model": "deepseek-v4-flash-202605",
  "messages": [
    { "role": "system", "content": "..." },
    { "role": "user", "content": ["...", "...", "..."] }
  ],
  "stream": true,
  "stream_options": { "include_usage": true },
  "store": false,
  "max_completion_tokens": 384000,
  "tools": ["read", "bash", "edit", "write"],
  "thinking": { "type": "enabled" },
  "reasoning_effort": "high"
}
```

上面的 `tools` 为便于阅读的概括；实际文件保存了四个完整 function schema。

## 6.1 System message 的组成

Telemetry 记录的第一条 message 是：

```text
role = system
content =
  <roleplay_character id="alice" name="爱丽丝">
    CHARACTER.md 正文
    <roleplay_examples>examples.md</roleplay_examples>
    <roleplay_runtime_boundary>...</roleplay_runtime_boundary>
  </roleplay_character>

  以下是 Pi 的权威运行时能力与项目指令：

  Pi 原始 system prompt
    ├── 工具说明
    ├── Guidelines
    ├── Pi docs 路径
    ├── Skills
    └── cwd
```

```mermaid
flowchart TB
    C[CHARACTER.md] --> RP[roleplay_character]
    E[examples.md] --> RP
    B[固定 runtime boundary] --> RP
    PP[Pi 原始 system prompt] --> S[最终 system message]
    RP --> S
```

角色卡被前置，但 Pi 的工具协议、项目说明、Skills 和 cwd 没有被覆盖。

## 6.2 第一条 User message 的三段内容

本次 Telemetry 实际看到的 `messages[1].content` 有三个 text block，顺序是：

```text
content[0]  动态世界上下文
content[1]  用户原文
content[2]  DeepSeek V4 沉浸指令
```

```mermaid
flowchart TB
    U[Session 中的 user 原文] --> C1[content 1: 用户原文]
    WC[WORLD.md + tone + capital + magic] --> C0[content 0: roleplay_world_context]
    DS[DeepSeek V4 adapter] --> C2[content 2: 沉浸要求]
    C0 --> FU[最终第一条 user]
    C1 --> FU
    C2 --> FU
```

### `content[0]`：实际世界上下文

```xml
<roleplay_world_context>
<world_core>
# 阿斯特拉王国

这是一个低魔奇幻世界。魔法真实存在，但稀少、昂贵，并受王室与教会共同管制。

## 全局规则

- 世界中不存在现代电子技术。
- 普通人知道魔法存在，但很少亲眼见过。
- 信息传播依赖信件、商队和官方公告。
- 角色不能无理由获得远方的即时信息。
</world_core>

<world_entries>
<world_entry id="astra-tone" priority="100">...</world_entry>
<world_entry id="astra-capital" priority="80">...</world_entry>
<world_entry id="astra-magic" priority="70">...</world_entry>
</world_entries>
</roleplay_world_context>
```

这次比问题中给出的例子多出 `astra-capital` 和 `astra-magic`，因为演示输入明确包含王都、塞勒恩、魔法和施展。

### `content[1]`：Session 中的原文

```text
我们已抵达王都塞勒恩，城门旁有人未经许可施展魔法。请以角色身份回应，只写两句话，不要调用工具。
```

它与 session 第 5 行完全一致。

### `content[2]`：DeepSeek 专属后缀

```text
【角色沉浸要求】在你的思考过程（<think>标签内）中，请遵守以下规则：
1. 请以角色第一人称进行内心独白……
2. 用第一人称描写角色的内心感受……
3. 思考内容应沉浸在角色中……
```

适配条件是：

```text
model.api == openai-completions
且 provider/model identity 匹配 deepseek-v4
且角色未关闭 deepSeekV4FirstUserImmersion
```

注入函数先检查 marker，保证重复经过 hook 时不会添加第二份。

---

## 7. Session 与 Payload 的逐字段对照

这是理解插件机制最直观的对照表。下表按本节演示 session 的实际内容列出；该演示没有触发状态工具，
因此其中只出现 `pi-roleplay-state` 一种 custom entry。

| 信息 | 资产文件 | Session JSONL | 最终 payload | Langfuse |
|---|---:|---:|---:|---:|
| `characterId = alice` | 是 | `custom.data.characterId` | 间接决定 system | 可从 system 观察 |
| Alice 角色正文 | `CHARACTER.md` | 否 | `messages[0].content` | generation input |
| 对话示例 | `examples.md` | 否 | `messages[0].content` | generation input |
| Pi 工具与 Skills prompt | Pi 动态生成 | 否 | `messages[0].content` | generation input |
| 用户原话 | 否 | `message:user` | 第一条 user 的中间 block | trace input + generation input |
| `WORLD.md` 世界核心 | `WORLD.md` | 否 | 第一条 user 的首 block | generation input |
| `astra-tone` | entry Markdown | 否 | 第一条 user 的首 block | generation input |
| `astra-capital` | entry Markdown | 否 | 第一条 user 的首 block | generation input |
| `astra-magic` | entry Markdown | 否 | 第一条 user 的首 block | generation input |
| DeepSeek 沉浸指令 | 插件常量 | 否 | 第一条 user 的末 block | generation input |
| assistant thinking/text | 否 | `message:assistant` | Provider response | generation output |
| usage / cost | 否 | assistant message | Provider response | generation usage |

一旦剧情触发状态工具或手动写入命令，session 侧还会出现下列行；它们是 Current View 的来源，但不会
以原样进入 payload：

| 信息 | 资产文件 | Session JSONL | 最终 payload | Langfuse |
|---|---:|---:|---:|---:|
| Current State / Event / Memory | 否 | `toolResult.details.roleplayCommit` 与 `pi-roleplay-commit` | 经检索裁剪后进入 Current View | generation input |
| 审批与修订记录 | 否 | `pi-roleplay-review-decision` / `-review-amendment` | 否，只影响 reducer 结果 | 否 |
| incomplete 标记与修复 | 否 | `pi-roleplay-turn-status` / `-repair` | 否 | 否 |
| Checkpoint / Archive 快照 | 否 | `pi-roleplay-checkpoint` / `-archive`，含完整 state、events、memories | 否，只用于恢复 | 否 |

因此不能通过只读 session 来获得“这一请求最终发送了什么”；必须同时观察运行时 hook 或 Telemetry
generation input。反过来也不成立：Checkpoint 与 Archive 让 session 中的剧情事实明显多于任何单次
payload，所以“payload 里没有”不等于“没有落盘”。

---

## 8. Pi Telemetry 在这里做了什么

项目中的 Pi Telemetry 是扩展 `@amaster.ai/pi-telemetry@0.1.6`，不是 Pi 自带的匿名安装统计。其 trace 生命周期是：

```mermaid
sequenceDiagram
    participant I as input
    participant T as turn_start
    participant P as before_provider_request
    participant R as Provider response
    participant M as message_end
    participant L as Langfuse

    I->>L: 建立 traceId 边界
    T->>L: chat_turn_started
    P->>L: generation started + final payload
    R->>L: HTTP 失败时记录状态
    M->>L: generation completed + output + usage
    M->>L: chat_turn_completed
```

本次 Langfuse 实际记录：

```json
{
  "traceId": "8b0dd5265b58bd3ad3b09827ed9fad03",
  "generationId": "99c048d559ab0d7f",
  "model": "deepseek-v4-flash-202605",
  "stopReason": "stop",
  "usage": {
    "input": 84,
    "output": 113,
    "cache_read": 3712,
    "total": 3909
  }
}
```

注意存在两个不同的“session ID”：

| ID | 实际值 | 来源与语义 |
|---|---|---|
| Pi session UUID | `019f824a-70fd-71dc-985c-1921869555d5` | JSONL header；对话持久化身份 |
| Telemetry session ID | `483c473d-80f3-4c83-a0fa-855d25d9b8ae` | telemetry extension 生成；用于关联 traces |

它们不是同一个命名空间，不应混用。

### 隐私边界

当：

```json
{ "includePayloads": true }
```

Telemetry 会记录：

- system prompt；
- 世界书正文；
- user 输入；
- 工具 schema、参数和输出；
- assistant thinking/text；
- usage 和 cost。

角色卡或世界书包含秘密时，应改成：

```json
{ "includePayloads": false }
```

或使用单独的 redact exporter。不要把认证文件、API Key 或私密角色卡提交到证据目录。

关闭 Telemetry 只切断观测面，不影响持久化面。剧情事实仍以明文写入 session JSONL：`pi-roleplay-commit`
记录逐条变更，`pi-roleplay-checkpoint` 与 `pi-roleplay-archive` 更会把当前 state、全部 events 和
memories 整份写入。因此评估隐私时必须把 session 文件本身算作敏感数据，共享 session、提交证据或上报
问题前需要单独脱敏；`includePayloads: false` 对此没有帮助。

---

## 9. 当前持久化策略与状态提交

## 9.1 当前已经实现

```text
Initial Definition
├── CHARACTER.md
├── examples.md
├── WORLD.md
└── entries/**/*.md

Session
├── custom: pi-roleplay-state { characterId }
├── toolResult.details.roleplayCommit
│   ├── events
│   ├── stateChanges
│   ├── memories
│   ├── uncertainties
│   └── stateAfter + revision + hashes
└── custom: -commit / -review-decision / -review-amendment
           / -turn-status / -repair / -checkpoint / -archive

Runtime
├── 角色 → system
├── Current State / Events / Memories → context
├── 世界 → context
└── DeepSeek 指令 → provider payload
```

LLM 在最终角色回复中调用 `roleplay_finalize_turn`。插件只接受有本轮精确 evidence quote、允许路径和足够 confidence 的变化。恢复 Session 时 branch reducer 直接重建状态，不再次调用 LLM。

## 9.2 当前已实现的审批与完整性检测

```text
高风险字段 pending review
/rp review list|accept|reject
branch-local review decision
accepted review → 独立 Turn Commit
agent_settled incomplete turn marker
structured validation issues
manual hash-verified checkpoint
checkpoint + later commit replay
```

`roleplay_finalize_turn` 的模型输出是严格 JSON Tool Arguments schema v2。状态变化不接受模型提供 JSON Pointer；模型只能从 `target.domain` 与对应字段枚举中选择，插件再生成内部 canonical path。例如 `{domain:"location",field:"current"}` 编译为 `/location/current`。旧 v1 Session 的 path 仅在 reducer/validator 兼容层读取。

关系、知识、目标和任务标志不会自动进入 Current State。它们先保存在原工具结果的 `pendingReviews` 中；用户可用 `/rp review amend` 仅修改 `value/from/durability`，原提案继续保留；接受或拒绝时追加 `pi-roleplay-review-decision`。接受会生成来源为 `review-accept` 的新 Commit，拒绝不改变 revision。

当 `roleplay_finalize_turn` 处于 active tools 中、角色回合已有最终 assistant message、但整轮 settled 后
没有该工具结果时，插件先复用当前模型与认证运行隔离 sidecar。它关闭 thinking、强制只调用 finalize，
并且只收到本轮最终 user/assistant 原文和 Current State。成功结果以 `pi-roleplay-sidecar` 原子 entry
落盘并复用灰色摘要；模型/认证/输出不可用时才追加 `pi-roleplay-turn-status`，状态为 `incomplete`，
footer 显示「剧情未同步」。inline 或 sidecar 的所有变化都被证据校验拒绝时，该回合记为
`finalized-rejected`，并当场以 warning 通知用户、在状态栏标出「剧情记录失败」。

`/rp turn repair` 对这两类回合都可用：使用原始 user/assistant evidence 运行同一 Validator，并追加 `pi-roleplay-repair`；原 marker 保留。不带参数时展示该回合的完整原文与可执行的 proposal 骨架。只有真正写入了 commit 或 pending review 的修复才算用掉该回合的修复机会——一条都没写入的尝试记为 `repair-failed`，只作审计，改好后可以重试。

sidecar-on-missing 使用 `ctx.model`、`ctx.modelRegistry.getApiKeyAndHeaders(model)` 与
`@earendil-works/pi-ai` 的 `complete()` 发起隔离补全。它不写入伪造的对话消息；结构化结果经过与
inline tool 相同的 schema、evidence、path、confidence、revision 和 hash 校验后，才写入当前 branch。
高风险字段仍进入 Pending Review。`sendUserMessage()` 会污染剧情 Session，因此不用于这条链路。

每次 inline finalize、主模型漏调和 sidecar 尝试都会追加 branch-local
`pi-roleplay-audit`。记录包含来源、结果、模型/API 标识、耗时、提案/落盘数量及脱敏错误，不包含
提示词、认证、对话原文或完整工具参数。`/rp status` 显示当前 branch 的紧凑调用指标和最近路径；
`/rp inspect audit` 显示聚合指标及最近 30 条记录。旧 Session 在该功能安装前的回合不会被反推计数。

`/rp checkpoint` 会追加 schema v2 `pi-roleplay-checkpoint`，无损保存 revision、Current State、state hash、完整 Events/Memories、pending reviews、turn status、Commit ID 索引、Review Amendment、Repair、Archive 指针及角色/世界资产 hash。Reducer 使用当前 branch 上最新且 hash 有效的 checkpoint，并只重放其后的 Commit；v1 Checkpoint 在内存中迁移，不改写旧 entry。每 50 Commit 自动创建周期 Checkpoint。

Compaction 自动流程已经接入：

```text
session_before_compact
→ reduce(event.branchEntries)
→ 保存 character + revision + stateHash guard
→ Pi 创建 compaction entry
→ session_compact
→ reduce(ctx.sessionManager.getBranch())
→ 三项一致才 append branch-local checkpoint
```

如果任一校验不一致，插件不会生成快照，并显示错误通知。

## 9.3 当前尚未实现

```text
真正的统一 contextTotal 预算（见下）
custom entry 的 TUI 卡片渲染（已有 /rp inspect state 替代）
世界条目激活快照
world entry 的 parents / recursiveDepth 递归激活
```

「待审项编辑」「补偿式 Commit 纠错」「schema migration」已经完成，分别对应 `/rp review amend`、
`/rp state correct` 与 reducer 的 v1→v2 内存迁移，不再属于本节。

`budgets.contextTotal` 目前只约束世界书，不约束 Current View：Current View 的上限是
`currentState + events + memories` 三个子预算之和，世界书才拿 `contextTotal` 减去 Current View 实际
用量后的余额。因此把 `contextTotal` 配成小于三者之和时，Current View 会超出总预算，世界书则被压到 0。
默认值（子预算合计 4700，`contextTotal` 7000）不会触发该情况。

Initial Definition 与世界 canon 始终禁止修改。

## 9.4 后续长期架构

```mermaid
flowchart TB
    ID[Initial Definition<br/>Markdown canon] --> ASM[运行时组装]
    CS[Current State<br/>custom entry] --> ASM
    EL[Event Log<br/>custom entry] --> MR[Memory Retrieval]
    MM[Memories<br/>custom entry] --> MR
    MR --> ASM
    ASM --> PAY[LLM Payload]
```

推荐 session entry：

```json
{
  "type": "custom",
  "customType": "pi-roleplay-current-state",
  "data": {
    "location": "sehlen-south-gate",
    "relationship": { "user": "cautious-cooperation" },
    "inventory": ["sealed-letter"]
  }
}
```

这种状态天然跟随 Pi entry tree。用户从 `/tree` 回到更早节点时，插件沿新 branch 恢复那个分支最后一份状态，不会把另一条剧情线的关系和物品带过来。

这里必须区分三种用户意图：

```text
回到过去并重写剧情  → Pi /tree
从旧节点独立分叉    → Pi /fork 或 /clone
当前时间线纠正错误  → 未来的 Correction Commit
```

不能用补偿 Commit 模拟 `/tree`。fork 后也不能复制 old leaf 的内存状态，而必须在新扩展实例中对新 Session 的 `getBranch()` 重新运行 reducer。

Pi 的 `branch_summary` 会进入通用模型上下文，并可能描述被放弃的分支。它只属于 alternate-timeline reference，不是当前角色 canon；reducer 永不从摘要提取状态。角色模式下，`context` hook 会从发往模型的瞬时消息副本中移除 `branchSummary`，同时保留 Session tree 中的原 entry，便于审计和以后重新导航。完整设计见 [`SESSION-TREE-INTEGRATION.md`](SESSION-TREE-INTEGRATION.md)。

大型世界书正文仍不应每轮写入 session；若需要严格审计，可以只在条目集合或资产 hash 改变时写一条世界快照索引：

```json
{
  "type": "custom",
  "customType": "pi-roleplay-world-snapshot",
  "data": {
    "worldId": "astra",
    "entryIds": ["astra-tone", "astra-capital", "astra-magic"],
    "contentHash": "sha256:..."
  }
}
```

---

## 10. 恢复、分支、工具调用和压缩

### 恢复 Session

```text
session_start
  → 读取当前 branch
  → 找最后一条 pi-roleplay-state
  → 恢复 activeCharacter
  → 重新读取当前资产
  → 下一轮重新构造 system 和世界上下文
```

### `/tree` 分支

`session_tree` 后插件重新解析当前 branch 的 Session Role Identity 并运行 reducer。角色身份在首次角色回复或结构化状态记录后锁定；`/rp use` 不能在已绑定 branch 中热切换。若 `/tree` 回到绑定点之前，该旧 branch 尚未进入角色剧情，可以重新选择角色。每条 branch 的 Current State、Events、Memories、Reviews 和 Checkpoint 都只从自身 root-to-leaf 路径恢复。

若用户选择为旧 branch 生成 summary，摘要可能描述另一条时间线。摘要可作为“曾探索过的替代路线”参考，但不能成为当前结构化剧情事实；Current Character View 始终优先。

### 工具调用后的后续 LLM 请求

一次用户消息可能包含多个 turn：

```text
LLM 请求 → toolCall → toolResult → LLM 请求
```

每次请求前 `context` 都会运行，确保世界书仍存在；marker 检查避免同一上下文副本重复前置。DeepSeek 后缀也有独立 marker，避免重复追加。

### Compaction

Pi session 可以出现 `compaction` entry。它会改变用于 LLM 的历史构建方式，但不会把 `custom` entry直接变成消息。长期状态实现时，应显式从当前 branch 恢复结构化 state/memory，而不能只依赖对话摘要。

---

## 11. 调试与复现实验

### 查看插件层

在 Pi TUI：

```text
/rp status
/rp inspect character
/rp inspect world
/rp inspect system
```

### 严格 E2E

```bash
pnpm test:e2e:deepseek -- --keep
```

它会：

1. 建立隔离的 `PI_CODING_AGENT_DIR`；
2. 复制示例资产和现有模型认证；
3. 让 Pi 自己解析当前 `deepseek-v4-flash`；
4. 创建真实持久化 session；
5. 捕获完整 system 与最终 payload；
6. 断言 session 中存在角色状态、user 和 assistant；
7. 断言世界核心、constant 条目、关键词条目和 DeepSeek 后缀；
8. 要求模型成功返回非空文本。

### 状态恢复 E2E

```bash
pnpm test:e2e:state
```

这个测试真实执行两次 Pi：第一次让 DeepSeek 完成剪发并通过 `roleplay_finalize_turn` 将短发状态写入 Session；第二次用 `--session` 重开同一文件，询问洗头速度。测试断言恢复后的回答使用短发状态，且无需重新提取旧剪发历史。

### Session Tree 生命周期 E2E

```bash
pnpm test:e2e:session-tree
```

该测试构造真实持久化 Pi Session，然后通过扩展命令调用 `navigateTree`、`fork` 和 `compact`。它不直接编辑 JSONL，并断言 `/tree` 对应 hooks、fork/clone replacement lifecycle、`parentSession`、branch state 与自动 compaction checkpoint。

### Session 角色锁 E2E

```bash
pnpm test:e2e:role-lock
```

该测试创建 Alice 与 Bob 两张角色卡，在 Alice 已产生首个回复的真实 Session 中执行 `/rp use bob`，并断言当前 branch 不会追加 Bob selection。Reducer 另有 legacy Alice → Bob → Alice 记录隔离测试，确保不同 `characterId` 的 Commit 不共享 revision/state。

### 显式写入、Correction、归档和长期性能

```text
/rp event add <json>
/rp state set <json>
/rp memory add <json>
/rp state correct <json>
/rp review amend <review-id> <json>
/rp turn repair <proposal-json>
/rp archive
```

所有操作只追加当前 branch 的 custom entry。Correction 不模拟 `/tree`；Archive 完整保存按正式 ID 去重的 Event/Memory，ID 相同但内容冲突会进入 diagnostics，原 Commit 不删除。`pnpm test:e2e:features` 验证命令事务，`pnpm test:e2e:long-session` 验证 1200 Commit、多 Checkpoint、v1→v2 migration、Archive 及 reducer 性能。

### TUI 能力边界

`pi-roleplay-sidecar` custom entry 已通过 `pi.registerEntryRenderer()` 渲染成与 inline tool 相同的
灰色回合摘要；它不参与 LLM context，也不会伪造 assistant message。其他 custom entry 的检查面仍是
`/rp inspect state` 编辑器视图和 footer status。`/rp status` 提供当前 revision 及事件、状态变化、
记忆、存疑计数；`/rp inspect state` 逐条展示可见明细、来源 revision、evidence 和当前 State，并说明
revision 是每个成功剧情 Commit 加一的 branch-local 状态版本号。

### 使用 Langfuse

本次演示使用的是只绑定本机的 Langfuse。打开本地 Langfuse，在 trace 中查看：

```text
chat-turn
└── llm-generation [main] [request]
    ├── Input   最终 Provider payload
    ├── Output  thinking / text / usage
    └── Metadata model / responseId / stopReason
```

请勿把 `.pi/settings.json` 中的 Langfuse Secret Key 或任何 Provider API Key 写进文档、日志或提交记录。

---

## 12. 一句话心智模型

```text
Markdown 决定“角色和世界是什么”
Session 记录“这一条剧情分支发生了什么、选了谁”
Hooks 决定“这一轮给模型组装什么”
Provider payload 是“模型真正看到什么”
Telemetry 证明“最终实际发送和返回了什么”
```
