让 Direct 首轮带上本轮附件的项目路径映射 (#223)
Project CI / Repository checks (push) Successful in 3m4s
Project CI / Frontend tests (push) Successful in 3m24s
Project CI / Backend tests (push) Successful in 6m10s
Project CI / Native shell tests (push) Successful in 15m8s

抽出 Home/Project 共用的 DirectCodexTurnAttachment 与渲染函数
Direct command 在进 Codex 前拼接有界 sidecar,jsonl 与气泡仍写用户原文
首页建项 latch 把导入附件传给 Direct 首轮,后续手打消息不带 attachments
做方案首轮仍走 Supervisor,不注入 sidecar
补齐 Rust 渲染测试与 home.suite 附件断言
记录路径映射合同与决策

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/223
Co-authored-by: 孔令弘 <ink29535@proton.me>
Co-committed-by: 孔令弘 <ink29535@proton.me>
This commit was merged in pull request #223.
This commit is contained in:
2026-08-31 19:47:23 +08:00
committed by 段舒康
parent 6d2c275d32
commit d7fc4c5b6f
19 changed files with 2923 additions and 170 deletions
@@ -0,0 +1,262 @@
# DirectProject 本轮附件路径映射
- 日期:2026-08-31
- 状态:现行合同(已按本文落地)
- 问题:Gitea issue #212(DirectProject 未消费用户上传权威文档)
- 关联入口:PR #210「批准 GDD 回填做游戏入口」(`feat/create_entrance`,未合入时仍按该 PR 的调用链理解)
- 原则:落地后代码简洁可维护,不为了 diff 最小而打补丁;附件一律同等对待,不给 GDD 开协议特例
## 0. 一句话
首页带进项目的附件已经复制并登记,但 Direct 首轮只拿到用户原文。本方案让 Direct 回合在发给 Codex 的 user prompt 末尾附上**有界路径映射**(原文件名 → 项目相对路径),不灌正文、不强制读取、不改做方案注入。
## 1. 目标与非目标
### 目标
1. Direct 首轮知道本轮用户附件的原文件名、项目相对路径、媒体类型、大小和导入状态。
2. 用户原文不被改写;路径映射是独立 sidecar。
3. 图片、Markdown、其它文件走同一条协议。
4. 做成游戏(PR #210:读 `game/fast_gdd.md` → 当成 `fast_gdd.md` 附件 → `createHomeDraftAutomatically`)自动吃到效果,因为那条链就是「带附件的 Direct 首轮」。
### 非目标
- 不改 PR #210 的按钮、固定 prompt、`startGameFromApprovedGdd`、读 `game/fast_gdd.md` 的方式。
- 不改 `approvedGddRef`、审批 receipt、策划项目里的 `game/fast_gdd.md` 投影。
- 不改上传命名 `assets/uploads/upload-<ts>-<name>`。
- 不把附件全文拼进 prompt,不按扩展名决定是否读取。
- 不把「没读到就阻断」做成门禁。
- 不扫 manifest 里历史 `kind=uploaded`。
- 不做 native 读取审计;该项由 [`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md) 承接。
- 不改 DirectHome 在「无项目路径」时的现有文案和列表格式。
- 不改 `enterCreatedHomeProject` 的空正文兜底句(与做方案共用)。
## 2. 现行断点
```text
Home 附件 / 做成游戏 File(fast_gdd.md)
→ upload_local_asset
→ LauncherProjectContext.attachments // 已有,只给资源画布
→ ProjectSupervisor // 无 attachments 字段
→ chat_with_game_creator_direct_codex
{ projectPath, prompt, clientTurnId, creationType? }
```
做成游戏的固定 prompt 仍写「附件中的 `fast_gdd.md`」,磁盘文件却是 `assets/uploads/upload-<ts>-fast_gdd.md`。映射没有进 Direct。
DirectHome 已有 `{ name, mediaType, size }` 元数据注入,但标明「尚未打开项目,内容尚不可读取」。项目已落盘后这条元数据被丢掉。
## 3. 目标合同
### 3.1 唯一 DTO
Home 与 Project 共用一个附件结构,缺省字段表示 Home 现状:
```ts
{
name: string; // 原文件名
mediaType: string;
size?: number; // 缺省按 0
localPath?: string; // 仅已落入项目时出现
status?: 'imported' | 'failed';
}
```
- 不把 `error` 字符串送给模型。
- 前端 `LauncherImportedAttachment` 继续给画布;invoke 前映射成上述瘦 DTO。
- `importHomeAttachments` 把 `File.size` 写入可选 `size`,不改 `upload_local_asset` 返回值。
Rust:
```rust
struct DirectCodexTurnAttachment {
name: String,
media_type: String,
#[serde(default)]
size: u64,
#[serde(default)]
local_path: Option<String>,
#[serde(default)]
status: Option<String>, // 只接受 imported | failed,其它忽略
}
```
`DirectCodexHomeAttachment` 删除,Home command 改用同一类型。现有 Home JSON(无 `localPath` / `status`)继续能反序列化。
### 3.2 渲染
一个函数 `render_direct_codex_user_prompt(prompt, attachments) -> Result<String, String>`:
| 输入 | 输出 |
|---|---|
| 无附件 | `prompt.trim()`;若也空则 `Err("聊天内容不能为空")` |
| 附件都没有 `localPath` 且都没有 `status` | 保持现有 Home 文案与行格式,测试须逐字兼容 |
| 任一条有 `localPath` 或 `status` | Project 头 + Project 行格式 |
Home 行(禁止改字):
```text
[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]
- {name};类型:{mediaType};大小:{n} 字节
```
Project 头与行(禁止出现 GDD / 规格 / 权威 / 必须读取):
```text
<用户原文>
[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]
- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported
```
规则:
- 条数上限仍为 8,超出写 `- 另有 N 个附件未展开`。
- 名字清洗沿用 Home:basename、去掉控制字符、最多 160 字、空则「未命名附件」。
- 媒体类型清洗沿用 Home。
- `localPath` 只接受项目相对 POSIX 路径:无 `..`、无盘符/根路径、首段不是 `.agent` / `.git`,`\` 归一为 `/`,最长 512;不合法则该条不输出路径,状态按 `failed`。
- 有附件时允许原文为空(Home 已如此)。Direct 内层若仍要求非空,在 command 边界先渲染再下传,避免空原文 + 有附件被拒。
不在 sidecar 里写「若用户要求按附件实施请先读取」。意图留在用户原文;做成游戏的固定 prompt 已经在说这件事。
### 3.3 谁渲染、谁看见
- sidecar **只在进 Codex 前由 Rust 拼装**。
- `.agent/conversations/project.jsonl` 继续写用户原文(现有 `append_local_conversation_message`)。
- 工作台气泡继续显示 latch / 输入框原文,不把 sidecar 画进 UI。
- 未完成首轮的 hydration 重放目前只带 prompt:本期不把附件写进 jsonl,进程重启后的未完成首轮可能丢映射。完整成功首轮不受影响。不为此新增会话 schema。
## 4. 数据流
```text
Home 上传 / 做成游戏 File
→ upload_local_asset(已有)
→ LauncherProjectContext.attachments(已有)
→ ProjectSupervisor.initialAttachments
→ 首轮 latch(与 prompt、creationType 同级)
→ 仅 chat_with_game_creator_direct_codex.attachments
→ render_direct_codex_user_prompt
→ 现有 Direct turn(cwd = 项目根)
```
Supervisor / 做方案首轮忽略 `attachments`,行为不变。
后续工作台手打消息不带 `attachments`。附件是这一轮带来的,不是项目终身上下文。
## 5. 代码落地(按最终结构,不按最小补丁)
### 5.1 Rust
新增 [`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs):
- DTO、清洗、上限常量、`render_direct_codex_user_prompt`
- 单元测试(见第 6 节)
[`agent.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent.rs) 增加 `mod direct_codex_attachments`。
[`direct_runtime.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs):
- 删除 Home 专用 struct / sanitizer / `render_direct_codex_home_user_prompt`
- `run_direct_game_creator_home_turn` 改为调用共享渲染
- `chat_with_game_creator_direct_codex` 增加 `attachments: Option<Vec<DirectCodexTurnAttachment>>`,先渲染再调用现有 `run_direct_game_creator_turn_at_with_creation_type_and_emitter`
- 把现有 Home 渲染测试迁到新文件;本文件不再保留一份平行实现
不要把 attachments 顺着 inner turn / emitter / CLI 往下传。CLI `run_direct_game_creator_turn_at` 不变。
### 5.2 前端
[`model.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/model.ts) `ProjectSupervisorComponentProps` 增加:
```ts
initialAttachments?: LauncherImportedAttachment[];
```
[`WorkspaceLauncher.tsx`](../../apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx):
```ts
initialAttachments={currentProjectContext.attachments}
```
[`App.tsx`](../../apps/ai-game-creator-shell/src/App.tsx):
- props / latch 增加 `attachments`(默认 `[]`)
- `executeChatAgentReply` 不要再加第 5 个位置参数,收成:
```ts
{
prompt: string;
clientTurnId?: string;
creationType?: HomeCreationType | null;
attachments?: DirectCodexTurnAttachment[];
}
```
- Direct invoke:有 `creationType` 才写该字段(现有);`attachments?.length` 才写 `attachments`
- Supervisor 分支完全不读 `attachments`
- 现有 `executeChatAgentReply(prompt)` / 恢复未完成 turn 的调用改为对象形式,不传 attachments
瘦映射不要写在 1 万行的 `App.tsx` 里,放到例如 [`apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts):`toDirectCodexTurnAttachments(imported)`,去掉 `error`,空 `localPath` 不输出该键。
[`useHomeProjectCreation.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts):`importHomeAttachments` 写入 `size: attachment.file.size`。不改 `initialPrompt` 兜底句,不改做成游戏(即便本分支尚未合入 PR #210,也不预埋 GDD 字段)。
[`types.ts`](../../apps/ai-game-creator-shell/src/app/types.ts):`LauncherImportedAttachment` 增加可选 `size?: number`。
### 5.3 文档
落地提交时(不是本方案文件自身):
- 本文件标为现行合同
- `docs/README.md`、`docs/project-memory/shared-memory/document-map.md` 增加条目
- `decision-log.md` 记一条:Direct 本轮附件只映射路径,不灌正文、不区别 GDD
- 不把 issue #212 技术说明改写成「已修复」,等代码合入后再改状态
## 6. 测试
### Rust(新文件)
1. 无附件:原文 trim 后原样返回;空原文报错。
2. Home 形态(无 path、无 status):与迁过来的两条现有测试逐字一致(含路径剥离、非法 mediaType、8 条上限)。
3. Project 形态:原文保留;含原名与 `assets/uploads/...`;**断言不得出现「GDD」「规格」「权威」**。
4. 同一列表里 png 与 md 行格式相同(只是 name/path/type 不同)。
5. `status=failed` 且无 path:有状态、无项目路径、无 error 正文。
6. 非法 `localPath`(`../`、`.agent/x`、绝对路径)不出现在输出中。
7. 空原文 + 有附件:成功,且含 Project 头。
### 前端
1. [`home.suite.ts`](../../apps/ai-game-creator-shell/tests/appSurface/home.suite.ts)「imports home attachments…」:Direct invoke 必须带 `attachments`,其中 `name` 为 `角色参考.png`、`localPath` 为 upload 返回路径、`status: 'imported'`。用 png 证明不是 md 特例。
2. 无附件的 Direct invoke 仍不得出现 `attachments` 键(或等价:不传该字段)。
3. `planningStartMode` 首轮仍走 Supervisor,`chat_with_game_creator_agent` 的 payload 不含附件 sidecar。
4. 工作台后发的普通消息:`chat_with_game_creator_direct_codex` 只有 `projectPath/prompt/clientTurnId`(及既有 creationType 规则),不带 attachments。
5. 若本分支已能跑 PR #210 的 home.suite / plan-gdd 做成游戏用例:只断言它仍调用 `createHomeDraftAutomatically` / 仍使用原固定 prompt;**不要**给做成游戏加第二条附件协议。sidecar 由通用 Direct 断言覆盖。
### 不测
- 不把「必须生成弹幕射击」写成单测。
- 不测 native `file.read` 是否进 `agent.db`。
## 7. 做成游戏为什么不用改
PR #210 `startGameFromApprovedGdd`:
1. 读策划项目 `game/fast_gdd.md`
2. `new File([content], 'fast_gdd.md')`
3. `createHomeDraftAutomatically({ creationType: 'game', prompt: APPROVED_GDD_BUILD_PROMPT, attachments }, 'direct-build')`
之后与首页拖一个 md 完全相同。sidecar 见到的是「原文件名 `fast_gdd.md` + 新项目 `assets/uploads/upload-…-fast_gdd.md`」。固定 prompt 继续说「读附件中的 fast_gdd.md」,映射补上真实路径。
## 8. 验收
1. 首页做游戏:上传任意文本或图片 + 一句话,Direct 首轮 prompt 含原名和 `assets/uploads/...`。
2. 做成游戏(PR #210 合入后或该分支上):固定 prompt 一字不改,同时出现改写后的项目路径。
3. 做方案首轮:Supervisor 行为与现在一致,无 sidecar。
4. 无附件:Direct 入参与现在一致。
5. `npm run check:encoding`、`git diff --check`、相关 `home.suite` / Direct Rust 测试通过。
## 9. 实现顺序
1. Rust 共享渲染 + 迁 Home 测试 + Direct command 接 `attachments`
2. 前端 latch / invoke / 映射 / `size`
3. 改 `home.suite` 附件断言
4. encoding 与定向测试
5. 合入时补 decision-log 与文档索引
@@ -0,0 +1,389 @@
# Direct 回合行为审计账本
- 日期:2026-08-31
- 状态:现行合同(已按本文落地)
- 问题:Gitea issue #212 的第二段(Direct 原生读 / 工具行为无法从项目产物判断);用于分析「附件已映射仍未按文档实施」
- 关联:[`【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`](./【技术方案】DirectProject本轮附件路径映射-2026-08-31.md)、[`【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md`](./【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md)
- 原则:落地后代码简洁可维护;审计是 Direct 行为时间线,不是 GDD 特例,也不替代 sidecar
## 0. 一句话
Direct GUI 回合已经能看见 Codex `item/completed`,但只收成 UI 活动词,隔离 `CODEX_HOME` 随后删除。本方案在项目内留下有界、可共享的回合账本:本轮提供了哪些附件路径、按什么顺序做了读/搜/列表/MCP/写文件,以及第一次定玩法的动作是什么。用来区分「没读附件」和「读了仍走默认收集类」,不灌正文、不强制读取、不拷会话目录。
## 1. 目标与非目标
### 目标
一次带 `clientTurnId` 的 DirectProject GUI 回合结束后,只凭项目目录应能回答:
1. 本轮 sidecar 是否发出,原名映射到哪些项目相对路径,文件当时的 `contentSha256`。
2. 模型是否用 native 命令 / 列表 / 搜索 / 看图 / MCP 打开过那些路径(路径 + 当时磁盘 hash,不是 stdout)。
3. **顺序**:读附件是在第一次美术 brief / 第一次写 `game/` 之前还是之后。
4. 第一次「定玩法」动作是什么(优先 `taonier_prepare_game_art.brief`,否则其它生成类 MCP 或对 `game/` 的写入)。
覆盖后续手打回合:只要 GUI Direct 有 `clientTurnId` 就记账本,附件可以为空。
### 非目标
- 不证明「理解并按 GDD 实施」。那是对照 `game/index.html`、美术产物做的产品判断;账本只提供行为时间线。
- 不把 issue #212 原文的 NLP / 「关键内容进入上下文」做成自动判决。
- 不灌附件正文进 prompt,不强制先读再继续,不为 GDD 开协议特例。
- 不改 sidecar 文案、jsonl 用户原文、工作台气泡。
- 不改做成游戏固定 prompt / PR #210 注入。
- 不复用 Supervisor `agent.runtime.action_receipt` / `file.read`。
- 不拷隔离 `CODEX_HOME`、不落 `auth.json`、不落 `aggregated_output` / MCP `result` / patch `diff` / `FunctionCallOutput` 正文。
- 不把原始 item JSON 送进 Tauri 前端事件(现有 `DirectCodexTurnObservation` 仍只允许安全活动词和流式正文)。
- 不扫 `kind=uploaded` 历史附件;只记本轮 sidecar 提供的集合。
- DirectHome、ToolHost、CLI `--direct-codex-chat`(无 `clientTurnId`)本期不写这份账本。
- 本期不改 UI,不在聊天面板展示审计。
- 不把 issue #212 标成已修复;sidecar 与本账本是两段工作。
## 2. 现状
```text
Codex item/completed
commandExecution / mcpToolCall / fileChange / imageView / …
│
├─ 现用:收成 Activity("validation"|"controlled-tool"|…)
│ → Tauri 进度,不落盘
└─ 不用:隔离 CODEX_HOME session(含 stdout)→ tempdir Drop 删除
```
项目里现有:
| 产物 | 记下的 | 缺的 |
|---|---|---|
| `.agent/conversations/project.jsonl` | 用户原文 + 助手终稿 | sidecar、工具调用 |
| `.agent/agent.db` | init / upload / 美术登记 / 对话指针 | native 读、MCP 调用、`agc_write_file` |
| `.agent/logs/command.log` | 权限确认 | 原生命令 |
| `asset.register` / `canvas.asset_generate` | 路径、切片、部分 `source.prompt` | 与读附件的先后 |
| 隔离 `CODEX_HOME` | Codex 自己的 session | 回合结束即删 |
Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Read | ListFiles | Search | Unknown`,`Read.path` 在协议侧会拼成 cwd 绝对路径。Direct cwd 就是项目根(`resolve_direct_codex_project_authority` 不再强制 `game/` 子目录)。抽取时把绝对路径收回项目相对 POSIX,失败则丢路径,不写宿主绝对路径。
`agc_write_file` 经 tool bridge 落盘,当前不写 `agent.db`。不给每个 MCP 单独打点;统一在 `item/completed` 抽一次。
## 3. 分析用判据(相对 issue 收窄)
落地后,对类似 `gameagent-9baa5293` 的 run,应能三分:
| 时间线 | 结论 | 下一刀不该打哪 |
|---|---|---|
| `offeredRead.read=false`,`firstDesign` 已是 `taonier_prepare_game_art` 且 brief 是收集类 | 没打开附件就定了玩法 | 不是「GDD 解析不够」 |
| 先 `Read` 且 hash 对上,brief 仍是收集类 | 读了但没用 | sidecar 已够;看四切片 / icon-spec「收集物」/ 完成合同 |
| 只有 `ListFiles` / `Search` 命中 uploads,没有 `Read` | 发现了没读正文 | 映射可能够,缺的是读 |
| `Read` 的 path 是 `fast_gdd.md` 而不是 `assets/uploads/…` | sidecar 没被当成磁盘路径 | 还是路径合同 |
不在账本里写「已遵循 GDD」或「未遵循 GDD」布尔。
## 4. 落点
两层,都在项目 `.agent/` 控制面内,模型读不到:
1. **权威时间线**(每回合一个 jsonl,只追加)
`.agent/runtime/direct-codex/turns/<clientTurnId>.jsonl`
2. **总索引一条摘要**(方便继续翻现有 `agent.db`)
`recordType: "direct.codex.turn"`
`clientTurnId` 沿用现有规则:trim 后 6–160 位 ASCII 字母数字或连字符,首位字母或数字。文件名用规范化后的 id,不再二次编码。
不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。
jsonl 每条自带 `recordedAtMs`(`unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead`。`agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。
## 5. 记录合同
camelCase JSON。禁止出现附件正文、命令 stdout、patch diff、宿主绝对路径、Token、URL 签名。
### 5.1 `turn_start`
在 sidecar **已经渲染之后**、Codex turn **启动之前**写入。`promptSha256` 哈希的是 **用户原文**(command 入参 `prompt`),不是带 sidecar 的全文。
```json
{
"recordType": "direct.codex.turn_start",
"clientTurnId": "Abc123-def",
"sidecarPresent": true,
"promptSha256": "<sha256 hex of original user text UTF-8>",
"promptChars": 120,
"attachments": [
{
"name": "fast_gdd.md",
"localPath": "assets/uploads/upload-1788164530559-fast_gdd.md",
"mediaType": "text/markdown",
"size": 8119,
"status": "imported",
"contentSha256": "<sha256 hex or omit>",
"hashSkipped": null
}
]
}
```
- `attachments` 清洗复用 sidecar:`sanitize_attachment_name` / `media_type` / `status` / `local_path`。把这些函数改成 `pub(crate)`,审计模块不要复制一份。
- 条数上限仍 8;超出只在 sidecar 文案里写「另有 N 个未展开」,账本 `attachments` 同样只留前 8,另加 `attachmentsOmitted: N`。
- `sidecarPresent`:本轮渲染走了 Project 头(任一条有合法 path 或 status)。Home 形态不会出现在本账本(Home 不记账)。
- `contentSha256`:对清洗后的 `localPath` 读项目文件做 SHA-256 小写 hex。文件不存在则省略 hash,`hashSkipped: "missing"`。超过 `DIRECT_CODEX_AUDIT_HASH_MAX_BYTES`(2 MiB)则 `hashSkipped: "too-large"`。`.agent` / `.git` / `..` 路径本来就不会出现在 sidecar 输出里。
无附件:`attachments: []`,`sidecarPresent: false`,仍然写 `turn_start`。
### 5.2 `item`
仅 `item/completed`。`item/started` 和 `outputDelta` 不落盘。
公共字段:
```json
{
"recordType": "direct.codex.item",
"clientTurnId": "Abc123-def",
"seq": 1,
"itemId": "item-…",
"itemType": "commandExecution",
"status": "completed"
}
```
`seq` 从 1 起,按成功写入的 item 递增。`itemType` 取 Codex `item.type` 原词;未知类型仍记账 `itemType`,不附带未清洗 payload。
按类型附加字段:
| `item.type` | 追加 | 禁止 |
|---|---|---|
| `commandExecution` | `command` 截断 240 字;`exitCode`;`durationMs`;`actions[]` | `aggregatedOutput` |
| `mcpToolCall` | `tool`、`server`(可省略默认 `agc_tools`)、`durationMs`、§5.4 参数 | `result`、`error` 原文(只留 `status` / `errorKind`) |
| `fileChange` | `changes: [{ path, kind }]`,`kind` 为 `add` / `delete` / `update` | `diff`、`movePath` 的宿主绝对路径(相对化失败则整条 change 丢 path) |
| `imageView` | `path` | 图像字节 |
| `functionCallOutput` | `name`、`namespace` | `output` |
| `webSearch` | `query` 截断 400 字 | 结果页正文 |
| `agentMessage` / `userMessage` / `plan` / `reasoning` / `contextCompaction` / `hookPrompt` | **整类跳过**(终稿已在 jsonl;推理正文不是本账本) | — |
| 其它未知 | 只留公共字段 | 原始 `item` 对象 |
`commandExecution.actions[]`:
```json
{ "type": "read", "path": "assets/uploads/upload-…-fast_gdd.md", "contentSha256": "…", "hashSkipped": null }
{ "type": "listFiles", "path": "assets" }
{ "type": "search", "query": "fast_gdd", "path": null }
{ "type": "unknown" }
```
- `Read.path` 先相对化再清洗;失败则该 action 记 `{ "type": "read", "pathRejected": true }`,不写绝对路径。
- 相对化成功后,对磁盘文件按 §5.1 同一套 hash 规则补 `contentSha256`。
- `command` 里若相对化失败,把 `command` 整段丢掉,改 `commandRedacted: true`(避免 `type C:\Users\…\fast_gdd.md` 进账本)。
### 5.3 MCP 参数白名单
只抄这些键,其它键丢弃。字符串再经 path 清洗或截断。
| 工具 | 落盘参数 | 正文类字段 |
|---|---|---|
| `agc_list_project_files` | `path`、`query`(120)、`kind`、`offset`、`limit` | 无 |
| `agc_write_file` | `path`、`contentChars`(`content` 的字符数,不是正文) | 不落 `content` |
| `taonier_prepare_game_art` | `mode`、`brief`(截断 4000)、`briefChars`、`briefSha256` | **要 brief 原文**(分析定玩法的吸烟枪;上限已是 MCP 合同) |
| `agc_generate_image` | `kind`、`aspectRatio`、`imageSize`、`assetName`、`outputPath`、`prompt` 截断 4000、`promptChars`、`promptSha256` | 不落 32k 全文 |
| `agc_edit_image` | `sourceLocalAssetId`、`assetName`、`prompt` 截断 4000、`promptChars`、`promptSha256` | 同上 |
| `agc_create_or_derive_resource` | `kind`、`mode`、`sourceLocalAssetId`、`assetName`、`prompt` 截断 4000、`promptChars`、`promptSha256` | MCP 上限已是 4000 |
| `agc_list_registered_assets` | `kind`、`assetId`、`includeSequenceFrames`、`offset`、`limit` | 无 |
| `agc_list_account_assets` | `folderId`、`query`、`offset`、`limit` | 无 |
| `agc_import_account_assets` | `assetIds`(最多 8 个 id,超出 `assetIdsOmitted`)、`localPaths`(清洗后相对路径,最多 8) | 无 |
| `agc_remove_background` | `sourceLocalAssetId`、`assetName` | 无 |
| `agc_browser_playtest` | `attempt` | 无 |
| `agc_web_search` | `query` 截断 400、`maxResults` | 无 |
| `agc_read_skill_resource` | `skillName`、`relativePath` | 不落 Skill 正文 |
| 未知 MCP 名 | 只留 `tool` + `status` | 不落 `arguments` |
`brief` / 截断后的 `prompt` 是 **模型自己写的设计文本**,不是用户 GDD 转储。这是分析「仍走收集类」的关键,允许进 jsonl。`agent.db` 摘要只留 `briefPreview` 240 字。
### 5.4 `turn_end`
派生摘要,不是第二真相。字段必须能从本文件已写入的 `turn_start` + `item` 重算出来。
```json
{
"recordType": "direct.codex.turn_end",
"clientTurnId": "Abc123-def",
"completed": true,
"itemCount": 17,
"itemsTruncated": false,
"offeredRead": [
{
"localPath": "assets/uploads/upload-1788164530559-fast_gdd.md",
"read": false
}
],
"firstDesign": {
"kind": "mcp:taonier_prepare_game_art",
"seq": 3,
"tool": "taonier_prepare_game_art",
"briefPreview": "俯视角收集冒险小游戏…"
}
}
```
`offeredRead.read=true` 当且仅当本 attempt 内存在 `actions.type=read` 或 `imageView` 或 MCP 参数里的 `path` / `localPaths`,清洗后与 `localPath` 字符串相等。hash 对不上仍记 `read: true`,另加 `contentSha256Match: false`(读了另一份同路径文件或读时文件已变)。没有 hash 可对则省略 `contentSha256Match`。
`firstDesign`:本 attempt 第一条满足任一条件的 item:
1. MCP:`taonier_prepare_game_art` / `agc_generate_image` / `agc_edit_image` / `agc_create_or_derive_resource`
2. `agc_write_file` 且 path 以 `game/` 开头或文件名是 `index.html`
3. `fileChange` 且任一条 change path 满足 2
列表、搜索、读、Skill 读取、账户素材查询、playtest、web_search **不算** firstDesign。没有则 `firstDesign: null`。
`kind` 取值:`mcp:<tool>` / `write:<path>` / `fileChange:<path>`。
### 5.5 `agent.db` 摘要
```json
{
"recordType": "direct.codex.turn",
"clientTurnId": "Abc123-def",
"turnLog": ".agent/runtime/direct-codex/turns/Abc123-def.jsonl",
"sidecarPresent": true,
"offeredCount": 1,
"offeredRead": [ { "localPath": "assets/uploads/…-fast_gdd.md", "read": false } ],
"firstDesign": { "kind": "mcp:taonier_prepare_game_art", "seq": 3, "briefPreview": "…" },
"itemCount": 17,
"itemsTruncated": false,
"completed": true,
"auditWriteFailed": false
}
```
`turnLog` 必须是项目相对 POSIX。不要把 jsonl 全文复制进 `agent.db`。单条仍受 Ordinary 1 MiB 限制;摘要本身应远小于此。
### 5.6 上限
| 项 | 值 |
|---|---|
| 每回合 item 条数 | 256;超出再写一条 `recordType: "direct.codex.items_truncated"`,之后 item 丢弃但仍把 `turn_end.itemsTruncated=true` |
| `command` | 240 字 |
| `brief` / 生成类 `prompt` 落盘 | 4000 字 |
| `briefPreview` | 240 字 |
| 文件 hash | 2 MiB |
| 附件条数 | 8(与 sidecar 相同) |
| jsonl 单行 | 沿用现有 jsonl 追加上限;超长截断正文类字段,不截断结构 |
## 6. 调用链
```text
chat_with_game_creator_direct_codex
规范化 clientTurnId
DirectCodexTurnAudit::start(root, clientTurnId, originalPrompt, attachments)
→ 写 turn_start(fail-open)
render_direct_codex_user_prompt // 现有 sidecar,不变
run_direct_game_creator_turn_at_with_creation_type_and_emitter(..., audit)
→ Codex collect 循环在 DirectProject + item/completed 调 audit.observe_item
Ok/Err 都 audit.finish(completed)
→ 写 turn_end + agent.db 摘要
```
- CLI `run_direct_game_creator_turn_at` **不** 接 audit(无 `clientTurnId`)。
- Home command 不接 audit。
- 不要把 attachments / audit 顺着 CLI inner、pool、ToolHost 往下传。
- `DirectCodexTurnObservation` **不** 增加原始 `params`。审计走独立 `DirectCodexTurnAudit`,避免 stdout 正文进入 Tauri 事件。
`direct_game_creator_codex_chat_at_with_optional_observer` 增加可选 `audit: Option<&mut DirectCodexTurnAudit>`,再传到 `run_turn_with_direct_observer`。仅 `workspace_mode == DirectProject` 且 `audit` 为 Some 时抽取。
`run_direct_game_creator_turn_inner` 的 UI observer 保持只处理 `AccumulatedText` / `Activity`。
回合失败(生成失败、浏览器试玩失败、回复落盘失败):只要 `start` 过就 `finish(false)`,保留已观察到的 item。Codex 尚未启动则 `itemCount=0`。
## 7. 失败语义
审计 **不得** 把做游戏打失败。所有写盘包在 sink 内:
- 单次追加失败:记内存 `audit_write_failed=true`,后续 item 仍尝试写;`finish` 时摘要带 `auditWriteFailed: true`。
- 连摘要都写不进去:只在 Direct debug / 现有进度通道能承受的前提下忽略;不新增用户可见报错文案。
- 不引入新的 Tauri 事件名。
与 `conversation.write` 失败不同:助手终稿落盘失败仍按现有逻辑拒绝返回。审计失败不走那条。
## 8. 安全
- 路径:与 sidecar 同一套相对 POSIX 清洗;相对化失败不写原绝对路径。
- 控制面:hash / 读文件只用 `resolve_local_project_path`;拒绝 `.agent` / `.git` / 敏感文件。这些路径若出现在 commandActions 里,只记 `pathRejected`。
- 不把 `aggregated_output`、MCP result、function output、diff 暂存在内存再截断——抽取函数根本不读这些键。
- jsonl 位于 `.agent/runtime/**`,现有 Direct 控制面边界禁止模型当普通项目文档读。
- 前端观察者和审计 sink 分叉,禁止图省事 `observer(Item { params })`。
## 9. 代码落地
新增 [`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs):
- `DirectCodexTurnAudit`
- `start` / `observe_item` / `finish`
- 相对化、hash、MCP 白名单、`firstDesign` / `offeredRead` 派生
- 单元测试(见 §11)
[`agent.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent.rs):`mod direct_codex_audit` + `pub(crate) use`。
[`direct_codex_attachments.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs):清洗函数改 `pub(crate)`,行为不变。
[`codex_app_server.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs):
- `run_turn_with_direct_observer` / `direct_game_creator_codex_chat_at_with_optional_observer` 增加 `audit: Option<&mut DirectCodexTurnAudit>`
- collect 循环 `Item { completed: true, .. }` 且 DirectProject 时 `audit.observe_item(&params)`
- **不要** 把 `params` 塞进 `DirectCodexTurnObservation`
- 现有 Home / ToolHost / 安全活动词测试保持逐字
[`direct_runtime.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs):
- `chat_with_game_creator_direct_codex` 创建 audit(原文 + attachments),Ok/Err 都 `finish`
- 把 audit 传入 `run_direct_game_creator_turn_at_with_creation_type_and_emitter`
- 该函数和 inner 增加可选 audit;CLI 入口签名不变
不要给 `agc_write_file` / 每个 MCP handler 再写一份平行审计。
前端、DTO、sidecar 文案、home.suite 附件断言:本期不改。不新增 UI。
文档:落地提交时把本文状态改为「现行合同(已按本文落地)」;`decision-log.md` 记一条;不要把 08-30 技术说明改成已修复。
## 10. 测试
全部是 Rust 单元测试,用 fixture item JSON,不拉真 Codex。
1. `turn_start`:原文 hash 稳定;sidecar 路径清洗后出现;非法 `../` 不进 attachments;无附件 `sidecarPresent=false`。
2. 附件文件写入临时项目后 `contentSha256` 与直接 hash 一致;缺文件 `hashSkipped=missing`;超过 2 MiB `too-large`。
3. `commandExecution` + `commandActions: [{type:read, path: <abs>}]` → 相对路径 + hash;`aggregatedOutput` 即使在 fixture 里也不出现在落盘 JSON。
4. `Read` 相对化失败 → `pathRejected`,落盘 JSON 不含 `C:\\` / `Users`。
5. `mcpToolCall` `taonier_prepare_game_art`:`brief` 保留;`result` 丢掉。
6. `agc_write_file`:有 path 与 `contentChars`,无 content。
7. `agc_generate_image`:32k prompt 只留 4000 + `promptChars` + sha256。
8. `fileChange`:path + kind,无 diff。
9. `offeredRead`:读路径等于 offered → `read=true`;只 list/search → `read=false`。
10. `firstDesign`:先 read 再 art → kind 是 mcp art,seq 是 art 那条;只有 read → `null`。
11. 第 257 条 item 触发 truncated,`turn_end.itemsTruncated=true`。
12. `agent.db` 摘要含 `turnLog` 相对路径、`offeredRead`、`firstDesign.briefPreview`。
13. 写盘注入失败:`finish` 不 panic、不返回 Err 给调用方(sink 方法是 `()`)。
14. 未知 `item.type` 只留公共字段。
15. 现有 DirectHome 活动词测试、sidecar 渲染测试不受影响。
不测:真模型是否读 GDD、是否生成弹幕射击、浏览器验收文案。
## 11. 验收(方案落地后的人工分析)
用一次「上传 md + 做成游戏 / 首页做游戏」的本地项目:
1. 存在 `.agent/runtime/direct-codex/turns/<clientTurnId>.jsonl`。
2. `agent.db` 有对应 `direct.codex.turn`。
3. `turn_start.attachments[].localPath` 与 sidecar 项目路径一致。
4. jsonl **没有** GDD 正文、没有 `aggregatedOutput`、没有 patch。
5. 能根据 `offeredRead` + `firstDesign` 填上 §3 四行表的其中一行,而不用猜隔离 session。
6. 工作台气泡仍是用户原文;jsonl 对话仍无 sidecar。
7. `npm run check:encoding`、`git diff --check`、相关 Rust 单测通过。
## 12. 实现顺序
1. `direct_codex_audit.rs` + 清洗函数 `pub(crate)` + fixture 测试
2. `codex_app_server` collect 接 sink;观察者枚举不变
3. GUI Direct command 创建 / finish sink
4. encoding 与定向 `cargo test`
5. 合入时改本文状态、decision-log、sidecar 文档里「native 审计另排期」那一行改为指向本文
## 13. 与 sidecar / issue 的边界
- sidecar:让模型 **知道路径**。已落地,合同不变。
- 本账本:让人 **看见模型做了什么**。不替代 sidecar,也不在本方案里做强制读取。
- issue #212 主问题仍是消费失败;本账本是为下一刀修复提供证据,不是把 212 关单。
@@ -206,6 +206,7 @@ Direct app-server 使用 ephemeral thread。stdout 中的 `item/started`、`item
- 策划 run ID:`gameagent-cd7f6c81`
- 做游戏 run ID:`gameagent-77b5aa31`
- 上传 GDD(项目相对路径):`assets/uploads/upload-1788083777445-fast_gdd.md`
- 审计账本方案(已落地,不关闭本 Issue):[`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md)
- 建议随 Issue 附上或引用对应 run 的以下复核材料:
- Direct 对话:`.agent/conversations/project.jsonl`
- Direct Agent DB:`.agent/agent.db`