Codex 桌面端对话存储全解析与彻底删除工具
起因是一个很具体的现象:桌面端的「归档」一直提示失败,换一条对话还是失败,重启也没用。
顺着这个问题查下去,发现真正有价值的东西不在「归档坏了」本身,而在于:一条对话在本地到底被存成了多少份、分散在多少个地方。把这张地图画清楚之后,归档失败的原因、为什么应用自带的「删除」删不干净、以及一个彻底删除的工具该怎么写,就都顺理成章了。
本文分两部分:前半部分是实测出来的本地存储结构,后半部分是据此写的清理工具。
文中的盘符、目录名、线程 ID 和对话标题均为脱敏示例,请替换成自己的实际值。
一、先确定「桌面端」是哪个程序
这一点比想象中重要。OpenAI 现在有好几个同名或近名的客户端,路径也不一样:
| 形态 | 进程名 | 本地数据根 |
|---|---|---|
| 桌面客户端(ChatGPT + Codex 合一) | ChatGPT.exe、codex.exe | %USERPROFILE%\.codex |
| VS Code 扩展 | codex.exe | 同样是 %USERPROFILE%\.codex |
| 网页版 | 浏览器标签 | 不落本地会话,只有浏览器缓存 |
判断方法很直接:
1 | Get-Process | Where-Object { $_.ProcessName -match 'chatgpt|codex' } | |
如果 Path 落在 WindowsApps 下面(形如 ...\WindowsApps\OpenAI.Codex_<版本>_x64__<哈希>\app\ChatGPT.exe),那就是打包安装的桌面客户端。
它的本地数据根是 %USERPROFILE%\.codex,不是 AppData 里的某个目录。这一点容易猜错——.codex 是个隐藏目录,很多人第一次找根本不会往家目录想。
二、归档为什么一直失败
归档是本地操作,不是云端操作
这一点从日志能直接确认。翻客户端日志目录,归档动作打出的是:
1 | [electron-message-handler] Archive requested conversationId=<线程ID> source=recent_tasks_menu |
而整份日志里没有任何 backend-api/conversation 之类的云端请求。也就是说:桌面端侧栏里那条对话的归档,走的是本地 thread/archive,没有碰云端会话。
归档的动作是「把文件搬走」
进一步能看出,thread/archive 需要把这条对话的 rollout 文件搬走:
1 | %USERPROFILE%\.codex\sessions\2026\09\24\rollout-2026-09-24T17-07-29-<线程ID>.jsonl |
于是失败条件就很清楚了——数据库里还有这条线程的记录,但它指向的文件已经不存在了:
1 | state_5.sqlite → threads 表里 9 条记录 |
这也解释了一个反直觉的现象:这些对话在界面上能正常打开、能看历史,但就是归档不了。原因在下一节。
文件是怎么没的
这点只能给推断,不能当结论。几条证据指向客户端的 rollout 迁移流程:
state_5.sqlite里有rollout_migration_state表,记录legacy_to_paginated_v1迁移检查到哪条线程(last_checked_thread_id/last_checked_thread_created_at);- 该水位线指向的线程,创建时间晚于那 3 条出问题的线程,说明它们确实在迁移的扫描范围内;
- 记录跳过的表
rollout_migration_skipped_rollouts为空,rollout-migrations/目录也是空的。
具体是谁删的、为什么没同步清理数据库记录,我没有官方文档可查。但从结果看,文件被消费掉、threads 行留了下来。
顺带一个实用发现:原始的 rollout 文件已经没了,但它原本多大是能算出来的。
thread_history_1.sqlite的thread_history_projection_state表里有一列next_rollout_byte_offset,记录投影已经读到原文件的第几个字节。
这三个值分别是 2493389 / 93247 / 372304 字节——这就是三个文件的原始大小。
三、完整存储地图:一条对话可能散落在 20 个地方
这是全文最想留下来的东西。桌面端并没有把一条对话存成「一个文件」或「一张表」,而是拆得很细。下表是逐项实测出来的,其中 goals / queue 之类的几项在多数情况下是空的,但清理时不能假设它们一定为空:
| # | 位置 | 存什么 |
|---|---|---|
| 1 | state_5.sqlite → threads | 主记录:ID、标题、rollout_path、时间、归档位、项目归属 |
| 2 | state_5.sqlite → thread_dynamic_tools | 该对话的动态工具定义 |
| 3 | state_5.sqlite → thread_spawn_edges | 父子线程边(子代理) |
| 4 | state_5.sqlite → thread_attachments | 附件 |
| 5 | state_5.sqlite → rollout_migration_state | 迁移水位线,可能指向某个线程 |
| 6 | thread_history_1.sqlite → thread_items | 消息条目(界面显示的实际来源) |
| 7 | thread_history_1.sqlite → thread_turns | 轮次元信息 |
| 8 | thread_history_1.sqlite → thread_history_projection_state | 投影进度(字节偏移 + 序号) |
| 9 | thread_history_1.sqlite → thread_realtime_items | 实时会话条目 |
| 10 | sqlite\codex-dev.db → local_thread_catalog | 侧栏列表的数据源 |
| 11 | sqlite\codex-dev.db → thread_timeline_ledger 等 | 目录同步状态 |
| 12 | session_index.jsonl | 线程 ID → 标题的轻量索引 |
| 13 | sessions\**\rollout-*.jsonl | 原始会话流水(归档操作的对象) |
| 14 | .codex-global-state.json | 客户端全局状态,十几个键家族都按线程 ID 索引 |
| 15 | memories* | 由对话提炼的记忆与摘要 |
| 16 | visualizations\<年>\<月>\<日>\<线程ID>\ | 对话产生的可视化产物 |
| 17 | cap_sid | 路径 → 沙箱 SID 的映射,含上面那些目录 |
| 18 | thread-writer-locks\<线程ID>.lock | 写入锁 |
| 19 | goals_1.sqlite / queue_1.sqlite | 目标与队列(通常是空的) |
| 20 | logs_2.sqlite、.sandbox\*.log | 日志(含正文片段) |
第 14 项值得单独展开,因为它是残留最多的地方。.codex-global-state.json 里按线程 ID 索引的键家族至少有这些:
1 | prompt-history.<线程ID> 输入历史 |
注意最后一行:ID 还会以数组元素的形式出现,所以清理逻辑必须能递归处理任意嵌套的 JSON,不能只盯着键名。
四、两个关键机制
1. 界面能显示,靠的是数据库;能归档,靠的是文件
这是理解整件事的钥匙。
thread_history_1.sqlite 的 thread_items 是一份投影(projection)——客户端把 rollout 流水按字节逐个读进来,解析成消息条目存进 SQLite。界面渲染历史时读的是这张表,不是那个 jsonl 文件。
所以当 rollout 文件丢失后:
thread_items里的消息还在 → 界面照常显示,对话能打开;- 归档要找文件搬走 → 找不到,失败。
两者一旦脱钩,就会出现「能看不能删」这种别扭状态。
2. 删除之后,正文还躺在 SQLite 的空闲页里
SQLite 删行只是把页标记为空闲,页里的字节不会立刻被抹掉。可以用 PRAGMA freelist_count 看当前有多少空闲页:
1 | PRAGMA freelist_count; -- 空闲页数 |
只要空闲页不回收,对话正文理论上仍然可以从数据库文件里被扫描出来。所以「彻底删除」的最后一步必须是 VACUUM——它会重建整个数据库文件,把这些字节真正清掉。
验证方法很朴素:删完之后直接把数据库当二进制文件搜一下关键字。
1 | raw = open(db_path, "rb").read() |
五、应用自带的删除为什么不彻底
实测方式很直接:客户端里早就删掉的旧对话,拿它的 ID 回全局状态里搜,仍然能搜到:
1 | prompt-history.<已删除的线程ID> ← 还在 |
(有意思的是 thread-descriptions-v1 那条确实被清掉了,说明自带删除清了一部分键,但没清全。)
这就意味着:如果你在意的是「这条对话在我机器上不要留下任何痕迹」,自带删除是不够的。
六、工具设计
需求很明确:给定一条或多条对话,把上面那张地图里的痕迹一次性清干净。工具叫 codex-chat-cleaner,单文件 Python,只用标准库。
1. 先找 rollout,再确定要删哪些 ID
这是设计里最容易踩坑的地方。
一条对话可能派生出「子代理」线程(例如自动审查用的 guardian)。它们的 rollout 文件长这样:
1 | {"type":"session_meta","payload":{ |
也就是说,子代理在 threads 表里是一条独立的行。
如果只删父对话、却把子代理的 rollout 文件也一起删掉,就会制造出两个新的「有记录没文件」的孤儿——正好复现本文开头那个归档失败的成因。
所以流程必须是:先扫 rollout 找出子代理 ID,把它们加入删除集合,再统一删数据库。而不是反过来。
1 | def find_rollouts(codex_home, ids, want_children): |
2. 迁移水位线要回退,不能留悬空引用
如果被删线程恰好是 rollout_migration_state 指向的那一条,水位线就指向了一个不存在的线程。工具会把它回退到「创建时间不晚于原水位线、且仍然存活」的最近一条;一条都不剩就置空。
这条纯粹是为了不留悬空引用——语义是我从表结构和实测行为推断的,不是官方文档,所以实现上只做「不指向被删线程」这一点,不改变迁移本身的其它行为。
3. 删除范围做成开关
不是所有痕迹都同等重要,所以范围是可配的:
| 范围 | 默认 | 内容 |
|---|---|---|
| 核心记录 | 锁定 | 线程行 / 消息历史 / rollout 文件 / 列表索引 |
| 全局状态残留 | 开 | .codex-global-state.json 的十几个键家族 |
| 记忆与摘要 | 开 | memories_1.sqlite + memories/*.md + rollout_summaries/*.md |
| 可视化 / 权限 / 锁 | 开 | visualizations 目录、cap_sid 条目、锁文件 |
| 子代理 | 开 | 该对话派生的 guardian 等 |
| 应用日志 | 关 | logs_2.sqlite、.sandbox/*.log、客户端调试日志 |
日志默认关,是因为排查问题时它有用;而且日志里混着其它对话的内容,误伤成本高。
4. 递归清理全局状态
全局状态的形状不固定——键名里可能有 ID、值可能是 ID、数组元素可能是 ID、值里的路径也可能含 ID。所以清理函数必须递归处理所有形态:
1 | def purge_json(node, ids, stats): |
一个刻意的取舍:数组被清空时保留空数组,不连键一起删。因为不知道客户端是否会因为缺键而报错,留个空数组更稳。
5. 记忆文件按「块」回收,不是按行
memories/MEMORY.md 是有层级的:
1 | # Task Group: <项目> |
只删那行 thread_id= 是不够的——### keywords 里的关键词、以及 ## User preferences、## Reusable knowledge 这些小节的正文,都还是从这条对话提炼出来的。
所以回收逻辑是按层级判断:
##小节里rollout_summaries/条目若全部指向被删线程 → 整个小节删掉;- 其余仍含被删 ID 的行 → 逐行删掉;
# Task Group块清理后不再剩任何## Task→ 整块删掉(此时它的偏好、知识、失败记录等小节,都只是已消失 Task 的附属内容)。
同理,raw_memories.md 是按 ## Thread `<线程ID>` 分块的,整块删除即可。
6. 安全措施
- 检测进程:
ChatGPT.exe/codex.exe在跑就拒绝执行。客户端会把全局状态从内存整体重写,开着改等于白改。 - 先备份再动手:所有受影响的文件复制到
backups\<UTC时间戳>\,包含原库(连-wal/-shm)、被删文件的副本、线程原始行快照(thread_snapshot.json)、以及路径清单(manifest.json)。 - 事务 + 原子写:数据库改动包在
BEGIN IMMEDIATE里,出错回滚;JSON 用「临时文件 +os.replace」写,不会留半截文件。 - 提交后 VACUUM:清掉空闲页里的正文残留。
- 默认干跑:先列出将执行的每一条(哪张表删几行、删哪些文件、全局状态移除几个键),要手输
DELETE才真正执行。
七、使用
1 | cd D:\tools\codex-chat-cleaner |
TUI 的按键:
| 键 | 作用 |
|---|---|
↑ ↓ | 移动光标 |
Space | 勾选 / 取消 |
a | 全选 / 全不选 |
Enter | 进入预览确认页 |
s | 删除范围设置 |
r | 残留自检 |
q / Esc | 退出 |
列表长这样,rollout 缺失的会直接标出来——这类正是会归档失败的:
1 | Codex 对话清理器 (4 条对话) |
残留自检怎么读
删完会自动全盘再扫一遍。有两类命中是正常的,工具会标注出来:
其他对话正文提及 <ID>(正常,未改动)——别的对话里提到过这个 ID。那段正文属于别的对话,不该动;logs_2.sqlite [logs.thread_id]——日志行,默认保留。
真正要看的是输出的「其中需关注 N 处」,正常情况下应为 0。
八、实测结果
拿三条「有记录、没文件」的对话跑完整流程:
| 项目 | 前 | 后 |
|---|---|---|
threads | 9 | 4(三条目标 + 两个子代理一并清除) |
| 迁移水位线 | 指向被删的子代理线程 | 回退到存活线程 |
thread_items | 247 | 78 |
thread_turns | 7 | 1 |
thread_history_projection_state | 8 | 3 |
local_thread_catalog(侧栏数据源) | 4 | 1 |
stage1_outputs | 1 | 0 |
.codex-global-state.json | 31 个键 + 3 个数组项命中 | 0 |
MEMORY.md | 整块来自被删对话 | 空 |
session_index.jsonl | 4 条 | 1 条 |
SQLite freelist_count | — | 0(VACUUM 生效) |
删完后按二进制搜数据库文件,state_5.sqlite、codex-dev.db、memories_1.sqlite、session_index.jsonl、cap_sid 全部为 0 命中。
唯一「残留」的是 thread_history_1.sqlite 里的一批字节——追下去发现它们全部属于另一条仍然存活的对话的消息正文(那条对话的正文里提到了这些 ID)。这类命中是正常的,工具的自检会把它标成「其他对话正文提及」。
九、边界、风险与恢复
这个工具不能做到的:
- 不恢复已经丢失的 rollout 内容。归档失败那几条的文件是真没了,删除只是把残缺记录一并清掉;
- 不动云端会话。本文全程是本地数据,网页端账号里的对话不受影响;
- 不修客户端自身的 bug。它清的是残留,不是修行为。
风险与恢复:
删除不可逆(除备份外)。出问题时:
1 | # 1. 先退出客户端 |
备份里的 thread_snapshot.json 保存了被删线程在 threads / thread_items / thread_turns 里的完整原始行,必要时可以手工 INSERT 回去。
一个已知限制: 子代理 ID 只能从 rollout 文件里反查。如果先删了一次(当时没开日志范围),之后想再清日志里属于子代理的那些行,需要手动把子代理 ID 也传进去:
1 | python cleaner.py --logs --yes ` |
十、下载
- 脚本:https://imgs.wybyte.top/file/codex-chat-cleaner/cleaner.py
- 说明文档:https://imgs.wybyte.top/file/codex-chat-cleaner/README.md
README 里记录了完整的落点清单和恢复步骤。
附录:几个值得留意的实现细节
1. 删库时顺手 VACUUM 会拖慢速度,但省不掉。 只在被改动的小库上做(几百 KB 到几 MB),30MB 的日志库单独给开关。
2. TUI 的宽度计算要按东亚字符算。 中文标题按 1 个字符算宽度会导致整列错位:
1 | def char_w(ch): |
按键用标准库 msvcrt.getwch(),方向键是 \x00 / \xe0 前缀加一个字母,不需要任何第三方依赖。
3. 描述和预览共用同一份「计划对象」。 先构造出 Plan(里面是一串 Op),预览页遍历它打印,执行页遍历它运行。这样预览看到的就是真正会执行的,不会出现两边逻辑漂移。
4. 扫残留时要区分「该删的」和「别人正文里提到的」。 后者如果一并删掉,就等于改了另一条对话的历史,所以自检里必须把两类分开报。
相关阅读
- 清理 Codex 对话残留失效索引:解决侧边栏打不开或反复报错 —— 处理的是「孤儿索引」,本文的工具是在它的基础上把范围扩到了全局状态、记忆、日志等全部落点。
