起因是一个很具体的现象:桌面端的「归档」一直提示失败,换一条对话还是失败,重启也没用。

顺着这个问题查下去,发现真正有价值的东西不在「归档坏了」本身,而在于:一条对话在本地到底被存成了多少份、分散在多少个地方。把这张地图画清楚之后,归档失败的原因、为什么应用自带的「删除」删不干净、以及一个彻底删除的工具该怎么写,就都顺理成章了。

本文分两部分:前半部分是实测出来的本地存储结构,后半部分是据此写的清理工具。

文中的盘符、目录名、线程 ID 和对话标题均为脱敏示例,请替换成自己的实际值。

一、先确定「桌面端」是哪个程序

这一点比想象中重要。OpenAI 现在有好几个同名或近名的客户端,路径也不一样:

形态进程名本地数据根
桌面客户端(ChatGPT + Codex 合一)ChatGPT.exe、codex.exe%USERPROFILE%\.codex
VS Code 扩展codex.exe同样是 %USERPROFILE%\.codex
网页版浏览器标签不落本地会话,只有浏览器缓存

判断方法很直接:

1
2
Get-Process | Where-Object { $_.ProcessName -match 'chatgpt|codex' } |
Select-Object ProcessName, Id, Path

如果 Path 落在 WindowsApps 下面(形如 ...\WindowsApps\OpenAI.Codex_<版本>_x64__<哈希>\app\ChatGPT.exe),那就是打包安装的桌面客户端。

它的本地数据根是 %USERPROFILE%\.codex,不是 AppData 里的某个目录。这一点容易猜错——.codex 是个隐藏目录,很多人第一次找根本不会往家目录想。

二、归档为什么一直失败

归档是本地操作,不是云端操作

这一点从日志能直接确认。翻客户端日志目录,归档动作打出的是:

1
2
3
4
5
6
7
8
[electron-message-handler] Archive requested conversationId=<线程ID> source=recent_tasks_menu
[AppServerConnection] response_routed ... method=thread/archive ... errorCode=-32600
error [electron-message-handler] Request failed
error={"code":-32600,"message":"no rollout found for thread id <线程ID>"}
failureReason=rollout_not_found
info [electron-message-handler] Archive skipped because thread has no active rollout
warn [electron-message-handler] Failed to persist inactive thread archive
errorMessage="Inactive thread archive did not persist"

而整份日志里没有任何 backend-api/conversation 之类的云端请求。也就是说:桌面端侧栏里那条对话的归档,走的是本地 thread/archive,没有碰云端会话。

归档的动作是「把文件搬走」

进一步能看出,thread/archive 需要把这条对话的 rollout 文件搬走:

1
%USERPROFILE%\.codex\sessions\2026\09\24\rollout-2026-09-24T17-07-29-<线程ID>.jsonl

于是失败条件就很清楚了——数据库里还有这条线程的记录,但它指向的文件已经不存在了:

1
2
3
4
state_5.sqlite → threads 表里 9 条记录
其中 3 条:rollout_path 指向的文件不存在
→ 归档时找不到文件 → 报 no rollout found → 界面显示「归档失败」
其余 6 条:文件都在 → 归档正常(日志里 errorCode=null)

这也解释了一个反直觉的现象:这些对话在界面上能正常打开、能看历史,但就是归档不了。原因在下一节。

文件是怎么没的

这点只能给推断,不能当结论。几条证据指向客户端的 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 之类的几项在多数情况下是空的,但清理时不能假设它们一定为空:

#位置存什么
1state_5.sqlite → threads主记录:ID、标题、rollout_path、时间、归档位、项目归属
2state_5.sqlite → thread_dynamic_tools该对话的动态工具定义
3state_5.sqlite → thread_spawn_edges父子线程边(子代理)
4state_5.sqlite → thread_attachments附件
5state_5.sqlite → rollout_migration_state迁移水位线,可能指向某个线程
6thread_history_1.sqlite → thread_items消息条目(界面显示的实际来源)
7thread_history_1.sqlite → thread_turns轮次元信息
8thread_history_1.sqlite → thread_history_projection_state投影进度(字节偏移 + 序号)
9thread_history_1.sqlite → thread_realtime_items实时会话条目
10sqlite\codex-dev.db → local_thread_catalog侧栏列表的数据源
11sqlite\codex-dev.db → thread_timeline_ledger 等目录同步状态
12session_index.jsonl线程 ID → 标题的轻量索引
13sessions\**\rollout-*.jsonl原始会话流水(归档操作的对象)
14.codex-global-state.json客户端全局状态,十几个键家族都按线程 ID 索引
15memories*由对话提炼的记忆与摘要
16visualizations\<年>\<月>\<日>\<线程ID>\对话产生的可视化产物
17cap_sid路径 → 沙箱 SID 的映射,含上面那些目录
18thread-writer-locks\<线程ID>.lock写入锁
19goals_1.sqlite / queue_1.sqlite目标与队列(通常是空的)
20logs_2.sqlite、.sandbox\*.log日志(含正文片段)

第 14 项值得单独展开,因为它是残留最多的地方。.codex-global-state.json 里按线程 ID 索引的键家族至少有这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
prompt-history.<线程ID>                          输入历史
heartbeat-thread-permissions-by-id.<线程ID> 权限心跳
thread-descriptions-v1.<线程ID> 自动描述
thread-writable-roots.<线程ID> 可写根目录列表
thread-project-assignments.<线程ID> 项目归属
thread-project-membership-host-ids.<线程ID> 所属主机
client-thread-bindings-v1.<客户端线程ID> = <线程ID> 绑定关系
electron-remote-hosted-pip-task-visibility-state.<线程ID>
thread-reference-capability:<线程ID> 顶层键,形如 <前缀>:<ID>
thread-client-id-v1:local%3A<线程ID> 顶层键(ID 被 URL 编码)
thread-tab-routes-v1:<线程ID> 顶层键
codex-writing-block-deleted-thread-v1:<线程ID> 删除标记
app-server-projects-migration-by-host.*.pendingThreadAssignmentIds ← 数组里存裸 ID

注意最后一行:ID 还会以数组元素的形式出现,所以清理逻辑必须能递归处理任意嵌套的 JSON,不能只盯着键名。

四、两个关键机制

1. 界面能显示,靠的是数据库;能归档,靠的是文件

这是理解整件事的钥匙。

thread_history_1.sqlite 的 thread_items 是一份投影(projection)——客户端把 rollout 流水按字节逐个读进来,解析成消息条目存进 SQLite。界面渲染历史时读的是这张表,不是那个 jsonl 文件。

所以当 rollout 文件丢失后:

  • thread_items 里的消息还在 → 界面照常显示,对话能打开;
  • 归档要找文件搬走 → 找不到,失败。

两者一旦脱钩,就会出现「能看不能删」这种别扭状态。

2. 删除之后,正文还躺在 SQLite 的空闲页里

SQLite 删行只是把页标记为空闲,页里的字节不会立刻被抹掉。可以用 PRAGMA freelist_count 看当前有多少空闲页:

1
2
PRAGMA freelist_count;   -- 空闲页数
PRAGMA page_count; -- 总页数

只要空闲页不回收,对话正文理论上仍然可以从数据库文件里被扫描出来。所以「彻底删除」的最后一步必须是 VACUUM——它会重建整个数据库文件,把这些字节真正清掉。

验证方法很朴素:删完之后直接把数据库当二进制文件搜一下关键字。

1
2
raw = open(db_path, "rb").read()
print(raw.count(b"<关键字>")) # VACUUM 之后应为 0

五、应用自带的删除为什么不彻底

实测方式很直接:客户端里早就删掉的旧对话,拿它的 ID 回全局状态里搜,仍然能搜到:

1
2
3
4
prompt-history.<已删除的线程ID>                    ← 还在
heartbeat-thread-permissions-by-id.<已删除的线程ID> ← 还在
thread-reference-capability:<已删除的线程ID> ← 还在
codex-writing-block-deleted-thread-v1:<已删除的线程ID> ← 还在

(有意思的是 thread-descriptions-v1 那条确实被清掉了,说明自带删除清了一部分键,但没清全。)

这就意味着:如果你在意的是「这条对话在我机器上不要留下任何痕迹」,自带删除是不够的。

六、工具设计

需求很明确:给定一条或多条对话,把上面那张地图里的痕迹一次性清干净。工具叫 codex-chat-cleaner,单文件 Python,只用标准库。

1. 先找 rollout,再确定要删哪些 ID

这是设计里最容易踩坑的地方。

一条对话可能派生出「子代理」线程(例如自动审查用的 guardian)。它们的 rollout 文件长这样:

1
2
3
4
5
6
{"type":"session_meta","payload":{
"session_id":"<父线程ID>",
"id":"<子线程自己的ID>",
"parent_thread_id":"<父线程ID>",
"thread_source":"guardian_review"
}}

也就是说,子代理在 threads 表里是一条独立的行。

如果只删父对话、却把子代理的 rollout 文件也一起删掉,就会制造出两个新的「有记录没文件」的孤儿——正好复现本文开头那个归档失败的成因。

所以流程必须是:先扫 rollout 找出子代理 ID,把它们加入删除集合,再统一删数据库。而不是反过来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def find_rollouts(codex_home, ids, want_children):
"""返回 (本线程 rollout, 子代理 rollout, 子代理自己的线程 id)。"""
own, children, child_ids = set(), set(), set()
for root in (sessions_dir, archived_sessions_dir):
for dirpath, _dirnames, filenames in os.walk(root):
for fn in filenames:
if not fn.endswith(".jsonl"):
continue
sid, oid, parent = read_session_meta(os.path.join(dirpath, fn))
if oid in ids:
own.add(...)
elif sid in ids or parent in ids:
if want_children:
children.add(...)
if oid:
child_ids.add(oid) # ← 关键:把子线程也纳入删除范围
return own, children, child_ids

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def purge_json(node, ids, stats):
if isinstance(node, dict):
out = {}
for k, v in node.items():
if isinstance(k, str) and hit(k, ids): # 键名含 ID
stats["keys"] += 1
continue
if isinstance(v, str) and hit(v, ids): # 值就是 ID
stats["keys"] += 1
continue
out[k] = purge_json(v, ids, stats) # 继续往下钻
return out
if isinstance(node, list):
res = []
for x in node:
if isinstance(x, str) and hit(x, ids): # 数组元素是 ID
stats["items"] += 1
continue
res.append(purge_json(x, ids, stats))
return res
return node

一个刻意的取舍:数组被清空时保留空数组,不连键一起删。因为不知道客户端是否会因为缺键而报错,留个空数组更稳。

5. 记忆文件按「块」回收,不是按行

memories/MEMORY.md 是有层级的:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Task Group: <项目>
scope: ...
applies_to: ...

## Task 1: <任务标题>

### rollout_summary_files

- rollout_summaries/<文件>.md (..., thread_id=<线程ID>, ...)

### keywords

- <关键词>...

只删那行 thread_id= 是不够的——### keywords 里的关键词、以及 ## User preferences、## Reusable knowledge 这些小节的正文,都还是从这条对话提炼出来的。

所以回收逻辑是按层级判断:

  1. ## 小节里 rollout_summaries/ 条目若全部指向被删线程 → 整个小节删掉;
  2. 其余仍含被删 ID 的行 → 逐行删掉;
  3. # 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
2
3
4
5
6
7
cd D:\tools\codex-chat-cleaner

python cleaner.py --list # 列出所有对话(含 rollout 是否缺失)
python cleaner.py # 交互式 TUI
python cleaner.py --scan --id <线程ID> # 残留自检
python cleaner.py --id <线程ID> --dry-run # 只看计划
python cleaner.py --id <线程ID> # 执行(要手输 DELETE 确认)

TUI 的按键:

键作用
↑ ↓移动光标
Space勾选 / 取消
a全选 / 全不选
Enter进入预览确认页
s删除范围设置
r残留自检
q / Esc退出

列表长这样,rollout 缺失的会直接标出来——这类正是会归档失败的:

1
2
3
4
5
6
7
8
9
Codex 对话清理器  (4 条对话)
─────────────────────────────────────────────────────
[ ] 11111111 示例对话 A 09-28 08:40 78条
[x] aaaaaaaa 示例对话 B 09-24 17:07 53条 rollout缺失
[ ] bbbbbbbb 示例对话 C 09-24 14:00 5条 rollout缺失
[ ] cccccccc Guardian review 09-24 09:45 0条
─────────────────────────────────────────────────────
已选 1 条
↑↓ 移动 Space 勾选 a 全选/取消 Enter 预览 s 范围设置 r 残留自检 q 退出

残留自检怎么读

删完会自动全盘再扫一遍。有两类命中是正常的,工具会标注出来:

  1. 其他对话正文提及 <ID>(正常,未改动)——别的对话里提到过这个 ID。那段正文属于别的对话,不该动;
  2. logs_2.sqlite [logs.thread_id]——日志行,默认保留。

真正要看的是输出的「其中需关注 N 处」,正常情况下应为 0。

八、实测结果

拿三条「有记录、没文件」的对话跑完整流程:

项目前后
threads94(三条目标 + 两个子代理一并清除)
迁移水位线指向被删的子代理线程回退到存活线程
thread_items24778
thread_turns71
thread_history_projection_state83
local_thread_catalog(侧栏数据源)41
stage1_outputs10
.codex-global-state.json31 个键 + 3 个数组项命中0
MEMORY.md整块来自被删对话空
session_index.jsonl4 条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
2
3
4
5
# 1. 先退出客户端
# 2. 把 orig/ 里的文件按原名覆盖回去
Copy-Item "backups\<时间戳>\orig\state_5.sqlite" "$env:USERPROFILE\.codex\state_5.sqlite" -Force
# -wal / -shm 若有备份一并覆盖
# 3. 按 manifest.json 里的 removed_files[].path 把 files/ 里的文件放回原路径

备份里的 thread_snapshot.json 保存了被删线程在 threads / thread_items / thread_turns 里的完整原始行,必要时可以手工 INSERT 回去。

一个已知限制: 子代理 ID 只能从 rollout 文件里反查。如果先删了一次(当时没开日志范围),之后想再清日志里属于子代理的那些行,需要手动把子代理 ID 也传进去:

1
2
python cleaner.py --logs --yes `
--id <父线程ID> --id <子代理ID> --id <另一个子代理ID>

十、下载

README 里记录了完整的落点清单和恢复步骤。

附录:几个值得留意的实现细节

1. 删库时顺手 VACUUM 会拖慢速度,但省不掉。 只在被改动的小库上做(几百 KB 到几 MB),30MB 的日志库单独给开关。

2. TUI 的宽度计算要按东亚字符算。 中文标题按 1 个字符算宽度会导致整列错位:

1
2
def char_w(ch):
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1

按键用标准库 msvcrt.getwch(),方向键是 \x00 / \xe0 前缀加一个字母,不需要任何第三方依赖。

3. 描述和预览共用同一份「计划对象」。 先构造出 Plan(里面是一串 Op),预览页遍历它打印,执行页遍历它运行。这样预览看到的就是真正会执行的,不会出现两边逻辑漂移。

4. 扫残留时要区分「该删的」和「别人正文里提到的」。 后者如果一并删掉,就等于改了另一条对话的历史,所以自检里必须把两类分开报。

相关阅读