@liuyoumi/codex-history 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -2
- package/README.en.md +29 -11
- package/README.md +29 -11
- package/dist/cli.js +391 -127
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.4 - 2026-05-29
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Add `purge-orphans` for guarded cleanup of missing-rollout threads and logs-only orphan records.
|
|
8
|
+
- Report estimated local disk space affected by orphan cleanup.
|
|
9
|
+
|
|
10
|
+
## 0.1.3 - 2026-05-29
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Remove purge backup creation and document `purge` as a permanent local deletion flow.
|
|
15
|
+
- Report the CLI version from `package.json` instead of a stale hard-coded value.
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
- Refresh the README cover image to show only the terminal window.
|
|
20
|
+
- Add agent workflow guidance for future implementation work.
|
|
21
|
+
- Clarify the boundary between `AGENTS.md` and `docs/agent-workflow.md`.
|
|
22
|
+
|
|
3
23
|
## 0.1.2 - 2026-05-28
|
|
4
24
|
|
|
5
25
|
### Documentation
|
|
6
26
|
|
|
7
27
|
- Use Chinese as the default README language.
|
|
8
28
|
- Add an English README translation.
|
|
9
|
-
- Refresh the README cover image to focus on a single guarded `purge` command flow.
|
|
10
29
|
- Add README badges and clearer macOS, Windows, and Linux support notes.
|
|
11
30
|
|
|
12
31
|
## 0.1.1 - 2026-05-28
|
|
@@ -28,7 +47,7 @@ Initial release candidate.
|
|
|
28
47
|
- `purge <id>` execution by full id or unique short id prefix.
|
|
29
48
|
- Interactive purge confirmation by typing the standard short id.
|
|
30
49
|
- `purge <id> --force` for non-interactive execution.
|
|
31
|
-
- Guarded purge execution with
|
|
50
|
+
- Guarded purge execution with explicit confirmation.
|
|
32
51
|
- SQLite cleanup for supported Codex stores.
|
|
33
52
|
- JSON and JSONL state cleanup for supported Codex files.
|
|
34
53
|
- Rollout jsonl and shell snapshot deletion.
|
package/README.en.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<img alt="npm" src="https://img.shields.io/npm/v/@liuyoumi/codex-history?style=flat-square&color=cb9b27" />
|
|
5
5
|
<img alt="platform" src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-2ea043?style=flat-square" />
|
|
6
6
|
<img alt="local only" src="https://img.shields.io/badge/local%20data-only-6e7681?style=flat-square" />
|
|
7
|
-
<img alt="
|
|
7
|
+
<img alt="confirm" src="https://img.shields.io/badge/confirm-before%20purge-0969da?style=flat-square" />
|
|
8
8
|
<img alt="license" src="https://img.shields.io/npm/l/@liuyoumi/codex-history?style=flat-square" />
|
|
9
9
|
</p>
|
|
10
10
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
A small CLI for finding and removing local Codex conversation history.
|
|
20
20
|
|
|
21
|
-
`codex-history` works on local Codex data files on your machine. It lists conversations using the same short titles shown by Codex when available, lets you narrow the list with `--grep`, and deletes
|
|
21
|
+
`codex-history` works on local Codex data files on your machine. It lists conversations using the same short titles shown by Codex when available, lets you narrow the list with `--grep`, and deletes resolved conversations or orphaned data only after confirmation.
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
@@ -34,7 +34,7 @@ npx @liuyoumi/codex-history doctor
|
|
|
34
34
|
|
|
35
35
|
## Supported Platforms
|
|
36
36
|
|
|
37
|
-
`codex-history` has been checked on macOS, Windows, and Linux for install, `doctor`, `list` / `--grep`, and guarded `purge` workflows with confirmation
|
|
37
|
+
`codex-history` has been checked on macOS, Windows, and Linux for install, `doctor`, `list` / `--grep`, and guarded `purge` workflows with confirmation.
|
|
38
38
|
|
|
39
39
|
| Platform | Status |
|
|
40
40
|
| --- | --- |
|
|
@@ -51,6 +51,7 @@ codex-history doctor
|
|
|
51
51
|
codex-history list
|
|
52
52
|
codex-history list --grep "Astro"
|
|
53
53
|
codex-history purge 019e6885
|
|
54
|
+
codex-history purge-orphans
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
`purge` prints the resolved conversation and asks you to type the standard short id before it deletes anything:
|
|
@@ -63,7 +64,7 @@ id: 019e6885-b5ae-7ae0-a50d-ce5f75b0ac08
|
|
|
63
64
|
cwd: /Users/me/Projects/example
|
|
64
65
|
updated: 2026-05-28T03:16:01.959Z
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
This cannot be undone.
|
|
67
68
|
Type 019e6885 to confirm:
|
|
68
69
|
```
|
|
69
70
|
|
|
@@ -75,6 +76,7 @@ Type 019e6885 to confirm:
|
|
|
75
76
|
| `codex-history list` | List local conversations. |
|
|
76
77
|
| `codex-history list --grep <keyword>` | Filter conversations by title, id, or cwd. |
|
|
77
78
|
| `codex-history purge <id>` | Remove one resolved local conversation after confirmation. |
|
|
79
|
+
| `codex-history purge-orphans` | Remove orphaned local data after confirmation. |
|
|
78
80
|
|
|
79
81
|
### `doctor`
|
|
80
82
|
|
|
@@ -139,7 +141,27 @@ For scripts or non-interactive shells, use `--force`:
|
|
|
139
141
|
codex-history purge 019e6885 --force
|
|
140
142
|
```
|
|
141
143
|
|
|
142
|
-
`--force` skips only the interactive short-id confirmation. It still keeps schema validation,
|
|
144
|
+
`--force` skips only the interactive short-id confirmation. It still keeps schema validation, active-thread protection, and post-purge verification.
|
|
145
|
+
|
|
146
|
+
### `purge-orphans`
|
|
147
|
+
|
|
148
|
+
Remove orphaned local data: conversations whose `state_5.sqlite.threads.rollout_path` points to a missing session or archived session file, and logs-only records that remain in `logs_2.sqlite.logs` without a matching thread in the `threads` table.
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
codex-history purge-orphans
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The command first prints a cleanup plan, affected SQLite row counts, files to delete, and an estimated local disk space impact. You must type `purge-orphans` before it executes.
|
|
155
|
+
|
|
156
|
+
For scripts or non-interactive shells, use `--force`:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
codex-history purge-orphans --force
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`--force` skips only interactive confirmation. It still keeps schema validation, active-thread protection, and post-purge verification. `purge-orphans` does not support JSON output.
|
|
163
|
+
|
|
164
|
+
Space reporting is an estimate. After SQLite rows are deleted, database files may not shrink until Codex or another SQLite maintenance step runs vacuum.
|
|
143
165
|
|
|
144
166
|
## Options
|
|
145
167
|
|
|
@@ -151,6 +173,7 @@ codex-history --json purge 019e6885 --force
|
|
|
151
173
|
|
|
152
174
|
- `--codex-home` defaults to `~/.codex`.
|
|
153
175
|
- `--json` prints machine-readable output. For `purge`, JSON output requires `--force` because interactive confirmation is text-only.
|
|
176
|
+
- `purge-orphans` does not support JSON output.
|
|
154
177
|
- Color is enabled only in interactive terminals and respects `NO_COLOR`.
|
|
155
178
|
|
|
156
179
|
## Safety
|
|
@@ -160,7 +183,6 @@ Before deleting, `codex-history`:
|
|
|
160
183
|
- validates the supported Codex data model
|
|
161
184
|
- resolves the target to exactly one conversation
|
|
162
185
|
- refuses the currently active thread when detectable
|
|
163
|
-
- creates a mandatory backup under `~/.codex-history/backups`
|
|
164
186
|
- removes known references from supported local Codex stores
|
|
165
187
|
- verifies supported stores after mutation
|
|
166
188
|
|
|
@@ -174,13 +196,9 @@ If Codex Desktop is already showing the conversation you purge, quit or restart
|
|
|
174
196
|
|
|
175
197
|
No. It only modifies supported local files on your machine.
|
|
176
198
|
|
|
177
|
-
### Where are backups stored?
|
|
178
|
-
|
|
179
|
-
Backups are written under `~/.codex-history/backups`.
|
|
180
|
-
|
|
181
199
|
### Can I recover a purged conversation?
|
|
182
200
|
|
|
183
|
-
|
|
201
|
+
Not with this tool. Treat `purge` as destructive.
|
|
184
202
|
|
|
185
203
|
### Do I need to restart Codex after purging?
|
|
186
204
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<img alt="npm" src="https://img.shields.io/npm/v/@liuyoumi/codex-history?style=flat-square&color=cb9b27" />
|
|
5
5
|
<img alt="platform" src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-2ea043?style=flat-square" />
|
|
6
6
|
<img alt="local only" src="https://img.shields.io/badge/local%20data-only-6e7681?style=flat-square" />
|
|
7
|
-
<img alt="
|
|
7
|
+
<img alt="confirm" src="https://img.shields.io/badge/confirm-before%20purge-0969da?style=flat-square" />
|
|
8
8
|
<img alt="license" src="https://img.shields.io/npm/l/@liuyoumi/codex-history?style=flat-square" />
|
|
9
9
|
</p>
|
|
10
10
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
一个用来查找并删除本地 Codex 对话历史的小型命令行工具。
|
|
20
20
|
|
|
21
|
-
`codex-history` 只处理你机器上的本地 Codex 数据。它会尽量使用 Codex 对话列表里显示的短标题,支持用 `--grep`
|
|
21
|
+
`codex-history` 只处理你机器上的本地 Codex 数据。它会尽量使用 Codex 对话列表里显示的短标题,支持用 `--grep` 缩小范围,并且只会在你确认目标后删除明确解析出来的对话或孤儿数据。
|
|
22
22
|
|
|
23
23
|
## 安装
|
|
24
24
|
|
|
@@ -34,7 +34,7 @@ npx @liuyoumi/codex-history doctor
|
|
|
34
34
|
|
|
35
35
|
## 支持平台
|
|
36
36
|
|
|
37
|
-
`codex-history` 已在 macOS、Windows、Linux 上完成基础流程验证,包括安装、`doctor`、`list` / `--grep
|
|
37
|
+
`codex-history` 已在 macOS、Windows、Linux 上完成基础流程验证,包括安装、`doctor`、`list` / `--grep`,以及带确认保护的 `purge`。
|
|
38
38
|
|
|
39
39
|
| 平台 | 状态 |
|
|
40
40
|
| --- | --- |
|
|
@@ -51,6 +51,7 @@ codex-history doctor
|
|
|
51
51
|
codex-history list
|
|
52
52
|
codex-history list --grep "Astro"
|
|
53
53
|
codex-history purge 019e6885
|
|
54
|
+
codex-history purge-orphans
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
`purge` 会先展示解析到的对话信息,并要求你输入标准短 id,确认后才会删除:
|
|
@@ -63,7 +64,7 @@ id: 019e6885-b5ae-7ae0-a50d-ce5f75b0ac08
|
|
|
63
64
|
cwd: /Users/me/Projects/example
|
|
64
65
|
updated: 2026-05-28T03:16:01.959Z
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
This cannot be undone.
|
|
67
68
|
Type 019e6885 to confirm:
|
|
68
69
|
```
|
|
69
70
|
|
|
@@ -75,6 +76,7 @@ Type 019e6885 to confirm:
|
|
|
75
76
|
| `codex-history list` | 列出本地对话。 |
|
|
76
77
|
| `codex-history list --grep <keyword>` | 按标题、id 或 cwd 过滤对话。 |
|
|
77
78
|
| `codex-history purge <id>` | 确认后删除一条解析到的本地对话。 |
|
|
79
|
+
| `codex-history purge-orphans` | 确认后清理本地孤儿数据。 |
|
|
78
80
|
|
|
79
81
|
### `doctor`
|
|
80
82
|
|
|
@@ -139,7 +141,27 @@ codex-history purge 019e6885
|
|
|
139
141
|
codex-history purge 019e6885 --force
|
|
140
142
|
```
|
|
141
143
|
|
|
142
|
-
`--force` 只跳过交互式短 id
|
|
144
|
+
`--force` 只跳过交互式短 id 确认,不会跳过数据结构校验、active thread 保护和删除后的验证。
|
|
145
|
+
|
|
146
|
+
### `purge-orphans`
|
|
147
|
+
|
|
148
|
+
清理本地孤儿数据:包括 `state_5.sqlite.threads.rollout_path` 指向的 session / archived session 文件已经不存在的对话,以及只残留在 `logs_2.sqlite.logs` 中、但 `threads` 表里已经没有对应 thread 的日志记录。
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
codex-history purge-orphans
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
该命令会先展示清理计划、受影响的 SQLite 行数、将删除的文件数量,以及估算的本地磁盘空间影响。确认后需要输入 `purge-orphans` 才会执行。
|
|
155
|
+
|
|
156
|
+
脚本或非交互环境可以使用 `--force`:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
codex-history purge-orphans --force
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`--force` 只跳过交互确认,不会跳过数据结构校验、active thread 保护和删除后的验证。`purge-orphans` 不支持 JSON 输出。
|
|
163
|
+
|
|
164
|
+
空间统计是估算值。SQLite 删除记录后,数据库文件可能不会立刻缩小,直到 Codex 或其他 SQLite 维护步骤执行 vacuum。
|
|
143
165
|
|
|
144
166
|
## 选项
|
|
145
167
|
|
|
@@ -151,6 +173,7 @@ codex-history --json purge 019e6885 --force
|
|
|
151
173
|
|
|
152
174
|
- `--codex-home` 默认是 `~/.codex`。
|
|
153
175
|
- `--json` 输出机器可读的 JSON。`purge` 使用 JSON 输出时必须加 `--force`,因为交互确认只适合文本模式。
|
|
176
|
+
- `purge-orphans` 不支持 JSON 输出。
|
|
154
177
|
- 颜色只会在交互式终端中启用,并遵守 `NO_COLOR`。
|
|
155
178
|
|
|
156
179
|
## 安全机制
|
|
@@ -160,7 +183,6 @@ codex-history --json purge 019e6885 --force
|
|
|
160
183
|
- 校验当前 Codex 本地数据结构是否受支持
|
|
161
184
|
- 将目标解析到唯一一条对话
|
|
162
185
|
- 在可检测时拒绝删除当前 active thread
|
|
163
|
-
- 在 `~/.codex-history/backups` 下创建强制备份
|
|
164
186
|
- 从受支持的本地 Codex 数据中移除已知引用
|
|
165
187
|
- 删除后扫描受支持的数据存储,确认目标引用已经移除
|
|
166
188
|
|
|
@@ -174,13 +196,9 @@ codex-history --json purge 019e6885 --force
|
|
|
174
196
|
|
|
175
197
|
不会。它只修改你机器上受支持的本地文件。
|
|
176
198
|
|
|
177
|
-
### 备份在哪里?
|
|
178
|
-
|
|
179
|
-
备份会写入 `~/.codex-history/backups`。
|
|
180
|
-
|
|
181
199
|
### 删除后还能恢复吗?
|
|
182
200
|
|
|
183
|
-
|
|
201
|
+
不能通过这个工具恢复。请把 `purge` 当作破坏性操作对待。
|
|
184
202
|
|
|
185
203
|
### 删除后需要重启 Codex 吗?
|
|
186
204
|
|
package/dist/cli.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { spawnSync } from "child_process";
|
|
6
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
6
7
|
import { createInterface } from "readline/promises";
|
|
7
8
|
|
|
8
9
|
// src/core/schema.ts
|
|
9
|
-
import {
|
|
10
|
-
import { dirname } from "path";
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
11
|
|
|
12
12
|
// src/core/paths.ts
|
|
13
13
|
import { existsSync } from "fs";
|
|
@@ -22,13 +22,10 @@ function expandHome(input) {
|
|
|
22
22
|
}
|
|
23
23
|
return input;
|
|
24
24
|
}
|
|
25
|
-
function resolvePaths(codexHomeInput
|
|
25
|
+
function resolvePaths(codexHomeInput) {
|
|
26
26
|
const codexHome = path.resolve(expandHome(codexHomeInput ?? "~/.codex"));
|
|
27
|
-
const toolHome = path.resolve(expandHome(toolHomeInput ?? process.env.CODEX_HISTORY_HOME ?? "~/.codex-history"));
|
|
28
27
|
return {
|
|
29
28
|
codexHome,
|
|
30
|
-
toolHome,
|
|
31
|
-
backupHome: path.join(toolHome, "backups"),
|
|
32
29
|
stateDb: path.join(codexHome, "state_5.sqlite"),
|
|
33
30
|
logsDb: path.join(codexHome, "logs_2.sqlite"),
|
|
34
31
|
goalsDb: path.join(codexHome, "goals_1.sqlite"),
|
|
@@ -122,18 +119,15 @@ function runDoctor(paths) {
|
|
|
122
119
|
checks.push(checkJsonFile(paths.globalState, "global_state"));
|
|
123
120
|
checks.push(checkJsonFile(paths.globalStateBackup, "global_state_backup"));
|
|
124
121
|
checks.push(checkJsonlFile(paths.sessionIndex, "session_index"));
|
|
125
|
-
checks.push(checkBackupHome(paths.backupHome));
|
|
126
122
|
return {
|
|
127
123
|
supported: !checks.some((check) => check.status === "error"),
|
|
128
124
|
codexHome: paths.codexHome,
|
|
129
125
|
checks
|
|
130
126
|
};
|
|
131
127
|
}
|
|
132
|
-
function validateSupportedDataModel(paths
|
|
128
|
+
function validateSupportedDataModel(paths) {
|
|
133
129
|
const report = runDoctor(paths);
|
|
134
|
-
const errors = report.checks.filter(
|
|
135
|
-
(check) => check.status === "error" && (options.requireBackupHome || check.name !== "backup_home")
|
|
136
|
-
);
|
|
130
|
+
const errors = report.checks.filter((check) => check.status === "error");
|
|
137
131
|
if (errors.length > 0) {
|
|
138
132
|
const details = errors.map((check) => `${check.name}: ${check.detail}`).join("; ");
|
|
139
133
|
throw new Error(`Unsupported Codex data model: ${details}`);
|
|
@@ -255,23 +249,6 @@ function checkJsonlFile(filePath, name) {
|
|
|
255
249
|
};
|
|
256
250
|
}
|
|
257
251
|
}
|
|
258
|
-
function checkBackupHome(backupHome) {
|
|
259
|
-
try {
|
|
260
|
-
mkdirSync(dirname(backupHome), { recursive: true });
|
|
261
|
-
mkdirSync(backupHome, { recursive: true });
|
|
262
|
-
return {
|
|
263
|
-
name: "backup_home",
|
|
264
|
-
status: "ok",
|
|
265
|
-
detail: backupHome
|
|
266
|
-
};
|
|
267
|
-
} catch (error) {
|
|
268
|
-
return {
|
|
269
|
-
name: "backup_home",
|
|
270
|
-
status: "error",
|
|
271
|
-
detail: error instanceof Error ? error.message : String(error)
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
252
|
|
|
276
253
|
// src/commands/doctor.ts
|
|
277
254
|
function doctorCommand(paths) {
|
|
@@ -393,7 +370,7 @@ function listCommand(paths, options = {}) {
|
|
|
393
370
|
}
|
|
394
371
|
|
|
395
372
|
// src/core/executor.ts
|
|
396
|
-
import { existsSync as
|
|
373
|
+
import { existsSync as existsSync8 } from "fs";
|
|
397
374
|
|
|
398
375
|
// src/safety/active-thread.ts
|
|
399
376
|
import { existsSync as existsSync4, statSync } from "fs";
|
|
@@ -450,71 +427,8 @@ function assertThreadIsNotActive(target) {
|
|
|
450
427
|
return checks;
|
|
451
428
|
}
|
|
452
429
|
|
|
453
|
-
// src/safety/backup.ts
|
|
454
|
-
import { copyFileSync, existsSync as existsSync5, mkdirSync as mkdirSync2, statSync as statSync2, writeFileSync } from "fs";
|
|
455
|
-
import path2 from "path";
|
|
456
|
-
function createBackup(paths, plan) {
|
|
457
|
-
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "-");
|
|
458
|
-
const backupDir = path2.join(paths.backupHome, `${timestamp}-${plan.target.id}`);
|
|
459
|
-
mkdirSync2(backupDir, { recursive: true });
|
|
460
|
-
const candidates = /* @__PURE__ */ new Set();
|
|
461
|
-
candidates.add(paths.stateDb);
|
|
462
|
-
addIfPresent(candidates, `${paths.stateDb}-wal`);
|
|
463
|
-
addIfPresent(candidates, `${paths.stateDb}-shm`);
|
|
464
|
-
addIfPresent(candidates, paths.logsDb);
|
|
465
|
-
addIfPresent(candidates, `${paths.logsDb}-wal`);
|
|
466
|
-
addIfPresent(candidates, `${paths.logsDb}-shm`);
|
|
467
|
-
addIfPresent(candidates, paths.goalsDb);
|
|
468
|
-
addIfPresent(candidates, `${paths.goalsDb}-wal`);
|
|
469
|
-
addIfPresent(candidates, `${paths.goalsDb}-shm`);
|
|
470
|
-
addIfPresent(candidates, paths.sessionIndex);
|
|
471
|
-
addIfPresent(candidates, paths.globalState);
|
|
472
|
-
addIfPresent(candidates, paths.globalStateBackup);
|
|
473
|
-
addIfPresent(candidates, plan.target.rolloutPath);
|
|
474
|
-
for (const store of plan.stores) {
|
|
475
|
-
if (store.store === "shell_snapshot") {
|
|
476
|
-
addIfPresent(candidates, store.path);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
const entries = [];
|
|
480
|
-
for (const originalPath of candidates) {
|
|
481
|
-
if (!existsSync5(originalPath)) {
|
|
482
|
-
continue;
|
|
483
|
-
}
|
|
484
|
-
const backupPath = uniqueBackupPath(backupDir, originalPath);
|
|
485
|
-
mkdirSync2(path2.dirname(backupPath), { recursive: true });
|
|
486
|
-
copyFileSync(originalPath, backupPath);
|
|
487
|
-
const stats = statSync2(originalPath);
|
|
488
|
-
entries.push({
|
|
489
|
-
originalPath,
|
|
490
|
-
backupPath,
|
|
491
|
-
size: stats.size,
|
|
492
|
-
mtimeMs: stats.mtimeMs
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
const manifest = {
|
|
496
|
-
threadId: plan.target.id,
|
|
497
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
498
|
-
backupDir,
|
|
499
|
-
entries
|
|
500
|
-
};
|
|
501
|
-
writeFileSync(path2.join(backupDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
502
|
-
return manifest;
|
|
503
|
-
}
|
|
504
|
-
function addIfPresent(paths, filePath) {
|
|
505
|
-
if (existsSync5(filePath)) {
|
|
506
|
-
paths.add(filePath);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
function uniqueBackupPath(backupDir, originalPath) {
|
|
510
|
-
const parsed = path2.parse(originalPath);
|
|
511
|
-
const safeBase = `${parsed.name}${parsed.ext}`.replaceAll(/[^a-zA-Z0-9._-]/g, "_");
|
|
512
|
-
const hash = Buffer.from(originalPath).toString("base64url").slice(0, 12);
|
|
513
|
-
return path2.join(backupDir, `${hash}-${safeBase}`);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
430
|
// src/safety/verify.ts
|
|
517
|
-
import { existsSync as
|
|
431
|
+
import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
|
|
518
432
|
function verifyPurge(paths, plan) {
|
|
519
433
|
const remainingReferences = [];
|
|
520
434
|
const threadId = plan.target.id;
|
|
@@ -524,7 +438,7 @@ function verifyPurge(paths, plan) {
|
|
|
524
438
|
checkTextFile(paths.sessionIndex, "session_index", threadId, remainingReferences);
|
|
525
439
|
checkTextFile(paths.globalState, "global_state", threadId, remainingReferences);
|
|
526
440
|
checkTextFile(paths.globalStateBackup, "global_state_backup", threadId, remainingReferences);
|
|
527
|
-
if (
|
|
441
|
+
if (existsSync5(plan.target.rolloutPath)) {
|
|
528
442
|
remainingReferences.push({
|
|
529
443
|
store: "rollout_jsonl",
|
|
530
444
|
path: plan.target.rolloutPath,
|
|
@@ -532,7 +446,7 @@ function verifyPurge(paths, plan) {
|
|
|
532
446
|
});
|
|
533
447
|
}
|
|
534
448
|
for (const store of plan.stores) {
|
|
535
|
-
if (store.store === "shell_snapshot" &&
|
|
449
|
+
if (store.store === "shell_snapshot" && existsSync5(store.path)) {
|
|
536
450
|
remainingReferences.push({
|
|
537
451
|
store: "shell_snapshot",
|
|
538
452
|
path: store.path,
|
|
@@ -546,7 +460,7 @@ function verifyPurge(paths, plan) {
|
|
|
546
460
|
};
|
|
547
461
|
}
|
|
548
462
|
function checkSqlite(filePath, store, threadId, remainingReferences) {
|
|
549
|
-
if (!
|
|
463
|
+
if (!existsSync5(filePath)) {
|
|
550
464
|
return;
|
|
551
465
|
}
|
|
552
466
|
const db = openReadonlyDatabase(filePath);
|
|
@@ -578,7 +492,7 @@ function checkSqlite(filePath, store, threadId, remainingReferences) {
|
|
|
578
492
|
}
|
|
579
493
|
}
|
|
580
494
|
function checkTextFile(filePath, store, threadId, remainingReferences) {
|
|
581
|
-
if (!
|
|
495
|
+
if (!existsSync5(filePath)) {
|
|
582
496
|
return;
|
|
583
497
|
}
|
|
584
498
|
const content = readFileSync3(filePath, "utf8");
|
|
@@ -592,9 +506,9 @@ function checkTextFile(filePath, store, threadId, remainingReferences) {
|
|
|
592
506
|
}
|
|
593
507
|
|
|
594
508
|
// src/stores/files.ts
|
|
595
|
-
import { existsSync as
|
|
509
|
+
import { existsSync as existsSync6, unlinkSync } from "fs";
|
|
596
510
|
function deleteFileIfExists(filePath) {
|
|
597
|
-
if (!
|
|
511
|
+
if (!existsSync6(filePath)) {
|
|
598
512
|
return {
|
|
599
513
|
path: filePath,
|
|
600
514
|
deleted: false
|
|
@@ -608,9 +522,9 @@ function deleteFileIfExists(filePath) {
|
|
|
608
522
|
}
|
|
609
523
|
|
|
610
524
|
// src/stores/json-state.ts
|
|
611
|
-
import { existsSync as
|
|
525
|
+
import { existsSync as existsSync7, readFileSync as readFileSync4, writeFileSync } from "fs";
|
|
612
526
|
function removeThreadFromSessionIndex(filePath, threadId) {
|
|
613
|
-
if (!
|
|
527
|
+
if (!existsSync7(filePath)) {
|
|
614
528
|
return { path: filePath, changed: false };
|
|
615
529
|
}
|
|
616
530
|
const original = readFileSync4(filePath, "utf8");
|
|
@@ -626,12 +540,12 @@ function removeThreadFromSessionIndex(filePath, threadId) {
|
|
|
626
540
|
` : "";
|
|
627
541
|
const changed = next !== original;
|
|
628
542
|
if (changed) {
|
|
629
|
-
|
|
543
|
+
writeFileSync(filePath, next);
|
|
630
544
|
}
|
|
631
545
|
return { path: filePath, changed };
|
|
632
546
|
}
|
|
633
547
|
function removeThreadFromGlobalState(filePath, threadId) {
|
|
634
|
-
if (!
|
|
548
|
+
if (!existsSync7(filePath)) {
|
|
635
549
|
return { path: filePath, changed: false };
|
|
636
550
|
}
|
|
637
551
|
const original = readFileSync4(filePath, "utf8");
|
|
@@ -639,7 +553,7 @@ function removeThreadFromGlobalState(filePath, threadId) {
|
|
|
639
553
|
const nextValue = removeThreadReferences(parsed, threadId);
|
|
640
554
|
const next = JSON.stringify(nextValue.value, null, 2) + "\n";
|
|
641
555
|
if (nextValue.changed) {
|
|
642
|
-
|
|
556
|
+
writeFileSync(filePath, next);
|
|
643
557
|
}
|
|
644
558
|
return {
|
|
645
559
|
path: filePath,
|
|
@@ -679,9 +593,8 @@ function removeThreadReferences(value, threadId) {
|
|
|
679
593
|
}
|
|
680
594
|
|
|
681
595
|
// src/core/executor.ts
|
|
682
|
-
function executePurge(paths, plan) {
|
|
683
|
-
const activeThreadChecks = assertThreadIsNotActive(plan.target);
|
|
684
|
-
const backup = createBackup(paths, plan);
|
|
596
|
+
function executePurge(paths, plan, options = {}) {
|
|
597
|
+
const activeThreadChecks = options.skipActiveThreadCheck ? [] : assertThreadIsNotActive(plan.target);
|
|
685
598
|
const sqlite = purgeSqlite(paths, plan.target.id);
|
|
686
599
|
const json = [
|
|
687
600
|
removeThreadFromSessionIndex(paths.sessionIndex, plan.target.id),
|
|
@@ -697,7 +610,6 @@ function executePurge(paths, plan) {
|
|
|
697
610
|
mode: "executed",
|
|
698
611
|
plan,
|
|
699
612
|
activeThreadChecks,
|
|
700
|
-
backup,
|
|
701
613
|
sqlite,
|
|
702
614
|
json,
|
|
703
615
|
files,
|
|
@@ -706,7 +618,7 @@ function executePurge(paths, plan) {
|
|
|
706
618
|
}
|
|
707
619
|
function purgeSqlite(paths, threadId) {
|
|
708
620
|
const results = [];
|
|
709
|
-
if (
|
|
621
|
+
if (existsSync8(paths.stateDb)) {
|
|
710
622
|
const db = openWritableDatabase(paths.stateDb);
|
|
711
623
|
try {
|
|
712
624
|
const transaction = db.transaction(() => {
|
|
@@ -727,7 +639,7 @@ function purgeSqlite(paths, threadId) {
|
|
|
727
639
|
db.close();
|
|
728
640
|
}
|
|
729
641
|
}
|
|
730
|
-
if (
|
|
642
|
+
if (existsSync8(paths.logsDb)) {
|
|
731
643
|
const db = openWritableDatabase(paths.logsDb);
|
|
732
644
|
try {
|
|
733
645
|
const transaction = db.transaction(() => {
|
|
@@ -738,7 +650,7 @@ function purgeSqlite(paths, threadId) {
|
|
|
738
650
|
db.close();
|
|
739
651
|
}
|
|
740
652
|
}
|
|
741
|
-
if (
|
|
653
|
+
if (existsSync8(paths.goalsDb)) {
|
|
742
654
|
const db = openWritableDatabase(paths.goalsDb);
|
|
743
655
|
try {
|
|
744
656
|
const transaction = db.transaction(() => {
|
|
@@ -773,8 +685,8 @@ function purgeFiles(plan) {
|
|
|
773
685
|
}
|
|
774
686
|
|
|
775
687
|
// src/core/planner.ts
|
|
776
|
-
import { existsSync as
|
|
777
|
-
import
|
|
688
|
+
import { existsSync as existsSync9, readdirSync, statSync as statSync2 } from "fs";
|
|
689
|
+
import path2 from "path";
|
|
778
690
|
function resolvePurgeTarget(paths, threadId) {
|
|
779
691
|
if (!threadId.trim()) {
|
|
780
692
|
throw new UsageError("Provide a thread id or unique short id prefix.");
|
|
@@ -801,7 +713,7 @@ function buildPurgePlan(paths, target) {
|
|
|
801
713
|
for (const snapshot of findShellSnapshots(paths.shellSnapshotsDir, target.id)) {
|
|
802
714
|
stores.push(planDeleteFile(snapshot, "shell_snapshot"));
|
|
803
715
|
}
|
|
804
|
-
if (!
|
|
716
|
+
if (!existsSync9(target.rolloutPath)) {
|
|
805
717
|
warnings.push(`rollout file is missing: ${target.rolloutPath}`);
|
|
806
718
|
}
|
|
807
719
|
return {
|
|
@@ -812,7 +724,7 @@ function buildPurgePlan(paths, target) {
|
|
|
812
724
|
};
|
|
813
725
|
}
|
|
814
726
|
function planStateDb(filePath, threadId) {
|
|
815
|
-
if (!
|
|
727
|
+
if (!existsSync9(filePath)) {
|
|
816
728
|
return [missingStore("state_db", filePath)];
|
|
817
729
|
}
|
|
818
730
|
const db = openReadonlyDatabase(filePath);
|
|
@@ -845,7 +757,7 @@ function planStateDb(filePath, threadId) {
|
|
|
845
757
|
}
|
|
846
758
|
}
|
|
847
759
|
function planLogsDb(filePath, threadId) {
|
|
848
|
-
if (!
|
|
760
|
+
if (!existsSync9(filePath)) {
|
|
849
761
|
return [missingStore("logs_db", filePath)];
|
|
850
762
|
}
|
|
851
763
|
const db = openReadonlyDatabase(filePath);
|
|
@@ -856,7 +768,7 @@ function planLogsDb(filePath, threadId) {
|
|
|
856
768
|
}
|
|
857
769
|
}
|
|
858
770
|
function planGoalsDb(filePath, threadId) {
|
|
859
|
-
if (!
|
|
771
|
+
if (!existsSync9(filePath)) {
|
|
860
772
|
return [missingStore("goals_db", filePath)];
|
|
861
773
|
}
|
|
862
774
|
const db = openReadonlyDatabase(filePath);
|
|
@@ -874,7 +786,7 @@ function countTable(db, filePath, tableName, columnName, threadId, storeName) {
|
|
|
874
786
|
path: filePath,
|
|
875
787
|
action: "inspect",
|
|
876
788
|
detail: `table or column unavailable: ${tableName}.${columnName}`,
|
|
877
|
-
exists:
|
|
789
|
+
exists: existsSync9(filePath)
|
|
878
790
|
};
|
|
879
791
|
}
|
|
880
792
|
return countPlan(storeName ?? `state_db.${tableName}`, filePath, count, "delete matching rows");
|
|
@@ -895,7 +807,7 @@ function planRewrite(filePath, store, detail) {
|
|
|
895
807
|
path: filePath,
|
|
896
808
|
action: "rewrite",
|
|
897
809
|
detail,
|
|
898
|
-
exists:
|
|
810
|
+
exists: existsSync9(filePath)
|
|
899
811
|
};
|
|
900
812
|
}
|
|
901
813
|
function planDeleteFile(filePath, store) {
|
|
@@ -904,8 +816,8 @@ function planDeleteFile(filePath, store) {
|
|
|
904
816
|
path: filePath,
|
|
905
817
|
action: "delete_file",
|
|
906
818
|
detail: "delete file",
|
|
907
|
-
exists:
|
|
908
|
-
count:
|
|
819
|
+
exists: existsSync9(filePath),
|
|
820
|
+
count: existsSync9(filePath) ? statSync2(filePath).size : 0
|
|
909
821
|
};
|
|
910
822
|
}
|
|
911
823
|
function missingStore(store, filePath) {
|
|
@@ -918,10 +830,10 @@ function missingStore(store, filePath) {
|
|
|
918
830
|
};
|
|
919
831
|
}
|
|
920
832
|
function findShellSnapshots(shellSnapshotsDir, threadId) {
|
|
921
|
-
if (!
|
|
833
|
+
if (!existsSync9(shellSnapshotsDir)) {
|
|
922
834
|
return [];
|
|
923
835
|
}
|
|
924
|
-
return readdirSync(shellSnapshotsDir).filter((entry) => entry.startsWith(`${threadId}.`) && entry.endsWith(".sh")).map((entry) =>
|
|
836
|
+
return readdirSync(shellSnapshotsDir).filter((entry) => entry.startsWith(`${threadId}.`) && entry.endsWith(".sh")).map((entry) => path2.join(shellSnapshotsDir, entry));
|
|
925
837
|
}
|
|
926
838
|
|
|
927
839
|
// src/commands/purge.ts
|
|
@@ -931,10 +843,211 @@ function planPurgeCommand(paths, threadId) {
|
|
|
931
843
|
return buildPurgePlan(paths, target);
|
|
932
844
|
}
|
|
933
845
|
function executePurgePlanCommand(paths, plan) {
|
|
934
|
-
validateSupportedDataModel(paths
|
|
846
|
+
validateSupportedDataModel(paths);
|
|
935
847
|
return executePurge(paths, plan);
|
|
936
848
|
}
|
|
937
849
|
|
|
850
|
+
// src/core/orphans.ts
|
|
851
|
+
import { existsSync as existsSync10 } from "fs";
|
|
852
|
+
function buildPurgeOrphansPlan(paths) {
|
|
853
|
+
validateSupportedDataModel(paths);
|
|
854
|
+
const threads = listThreads(paths, { all: true });
|
|
855
|
+
const threadIds = new Set(threads.map((thread) => thread.id));
|
|
856
|
+
const orphanThreads = threads.filter((thread) => !existsSync10(thread.rolloutPath));
|
|
857
|
+
const threadPlans = orphanThreads.map((thread) => buildPurgePlan(paths, thread));
|
|
858
|
+
const logThreadRows = readLogThreadRows(paths);
|
|
859
|
+
const logsOnlyOrphans = findLogsOnlyOrphans(logThreadRows, threadIds);
|
|
860
|
+
const impact = buildImpact(
|
|
861
|
+
threadPlans,
|
|
862
|
+
logsOnlyOrphans,
|
|
863
|
+
new Set(orphanThreads.map((thread) => thread.id)),
|
|
864
|
+
logThreadRows
|
|
865
|
+
);
|
|
866
|
+
return {
|
|
867
|
+
mode: "planned",
|
|
868
|
+
orphanThreads,
|
|
869
|
+
threadPlans,
|
|
870
|
+
logsOnlyOrphans,
|
|
871
|
+
impact,
|
|
872
|
+
warnings: threadPlans.flatMap((plan) => plan.warnings)
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
function hasPurgeOrphansWork(plan) {
|
|
876
|
+
return plan.orphanThreads.length > 0 || plan.logsOnlyOrphans.length > 0;
|
|
877
|
+
}
|
|
878
|
+
function executePurgeOrphans(paths, plan) {
|
|
879
|
+
validateSupportedDataModel(paths);
|
|
880
|
+
for (const threadPlan of plan.threadPlans) {
|
|
881
|
+
if (existsSync10(threadPlan.target.rolloutPath)) {
|
|
882
|
+
throw new SafetyRefusalError(
|
|
883
|
+
`Refusing to purge orphan candidate because rollout file now exists: ${threadPlan.target.rolloutPath}`
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const activeThreadChecks = plan.threadPlans.flatMap((threadPlan) => assertThreadIsNotActive(threadPlan.target));
|
|
888
|
+
const purgeReports = plan.threadPlans.map(
|
|
889
|
+
(threadPlan) => executePurge(paths, threadPlan, { skipActiveThreadCheck: true })
|
|
890
|
+
);
|
|
891
|
+
const logsOnly = purgeLogsOnlyOrphans(paths, plan.logsOnlyOrphans);
|
|
892
|
+
checkpointWal(paths.logsDb);
|
|
893
|
+
const remainingReferences = [
|
|
894
|
+
...purgeReports.flatMap((report) => report.verification.remainingReferences),
|
|
895
|
+
...verifyLogsOnlyOrphans(paths, plan.logsOnlyOrphans)
|
|
896
|
+
];
|
|
897
|
+
return {
|
|
898
|
+
mode: "executed",
|
|
899
|
+
plan,
|
|
900
|
+
activeThreadChecks,
|
|
901
|
+
purgeReports,
|
|
902
|
+
logsOnly,
|
|
903
|
+
verification: {
|
|
904
|
+
success: remainingReferences.length === 0,
|
|
905
|
+
remainingReferences
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
function findLogsOnlyOrphans(rows, threadIds) {
|
|
910
|
+
return rows.filter((row) => !threadIds.has(row.threadId)).sort((a, b) => a.threadId.localeCompare(b.threadId));
|
|
911
|
+
}
|
|
912
|
+
function readLogThreadRows(paths) {
|
|
913
|
+
if (!existsSync10(paths.logsDb)) {
|
|
914
|
+
return [];
|
|
915
|
+
}
|
|
916
|
+
const db = openReadonlyDatabase(paths.logsDb);
|
|
917
|
+
try {
|
|
918
|
+
if (!tableExists(db, "logs")) {
|
|
919
|
+
return [];
|
|
920
|
+
}
|
|
921
|
+
const columns = tableColumns(db, "logs");
|
|
922
|
+
if (!columns.includes("thread_id")) {
|
|
923
|
+
return [];
|
|
924
|
+
}
|
|
925
|
+
const estimatedExpr = columns.includes("estimated_bytes") ? "coalesce(sum(estimated_bytes), 0) as estimatedBytes" : "null as estimatedBytes";
|
|
926
|
+
const rows = db.prepare(
|
|
927
|
+
`select thread_id as threadId, count(*) as rows, ${estimatedExpr}
|
|
928
|
+
from logs
|
|
929
|
+
where thread_id is not null and thread_id != ''
|
|
930
|
+
group by thread_id`
|
|
931
|
+
).all();
|
|
932
|
+
return rows.map((row) => ({
|
|
933
|
+
threadId: row.threadId,
|
|
934
|
+
rows: Number(row.rows),
|
|
935
|
+
estimatedBytes: row.estimatedBytes === null ? null : Number(row.estimatedBytes)
|
|
936
|
+
}));
|
|
937
|
+
} finally {
|
|
938
|
+
db.close();
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
function buildImpact(threadPlans, logsOnlyOrphans, orphanThreadIds, logThreadRows) {
|
|
942
|
+
const sqliteRows = /* @__PURE__ */ new Map();
|
|
943
|
+
let filesToDelete = 0;
|
|
944
|
+
let fileBytesToDelete = 0;
|
|
945
|
+
for (const plan of threadPlans) {
|
|
946
|
+
for (const store of plan.stores) {
|
|
947
|
+
if (isSqliteRowChange(store) && store.count && store.count > 0) {
|
|
948
|
+
sqliteRows.set(store.store, (sqliteRows.get(store.store) ?? 0) + store.count);
|
|
949
|
+
}
|
|
950
|
+
if (store.action === "delete_file" && store.exists) {
|
|
951
|
+
filesToDelete += 1;
|
|
952
|
+
fileBytesToDelete += store.count ?? 0;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const logsOnlyRows = logsOnlyOrphans.reduce((sum, orphan) => sum + orphan.rows, 0);
|
|
957
|
+
if (logsOnlyRows > 0) {
|
|
958
|
+
sqliteRows.set("logs_db.logs", (sqliteRows.get("logs_db.logs") ?? 0) + logsOnlyRows);
|
|
959
|
+
}
|
|
960
|
+
const affectedLogRows = logThreadRows.filter(
|
|
961
|
+
(row) => orphanThreadIds.has(row.threadId) || logsOnlyOrphans.some((orphan) => orphan.threadId === row.threadId)
|
|
962
|
+
);
|
|
963
|
+
const estimatedLogPayloadBytes = sumNullable(affectedLogRows.map((orphan) => orphan.estimatedBytes));
|
|
964
|
+
const estimatedLocalDiskBytesAffected = estimatedLogPayloadBytes === null ? null : fileBytesToDelete + estimatedLogPayloadBytes;
|
|
965
|
+
const sqlite = [...sqliteRows.entries()].map(([store, rows]) => ({ store, rows })).sort((a, b) => a.store.localeCompare(b.store));
|
|
966
|
+
return {
|
|
967
|
+
sqliteRowsAffected: sqlite.reduce((sum, item) => sum + item.rows, 0),
|
|
968
|
+
sqlite,
|
|
969
|
+
filesToDelete,
|
|
970
|
+
fileBytesToDelete,
|
|
971
|
+
estimatedLogPayloadBytes,
|
|
972
|
+
estimatedLocalDiskBytesAffected
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
function isSqliteRowChange(store) {
|
|
976
|
+
if (store.action === "delete_rows") {
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
return store.store === "state_db.agent_job_items";
|
|
980
|
+
}
|
|
981
|
+
function sumNullable(values) {
|
|
982
|
+
if (values.length === 0) {
|
|
983
|
+
return 0;
|
|
984
|
+
}
|
|
985
|
+
if (values.some((value) => value === null)) {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
return values.reduce((sum, value) => sum + value, 0);
|
|
989
|
+
}
|
|
990
|
+
function purgeLogsOnlyOrphans(paths, logsOnlyOrphans) {
|
|
991
|
+
if (logsOnlyOrphans.length === 0 || !existsSync10(paths.logsDb)) {
|
|
992
|
+
return { store: "logs_db.logs", changedRows: 0 };
|
|
993
|
+
}
|
|
994
|
+
const db = openWritableDatabase(paths.logsDb);
|
|
995
|
+
try {
|
|
996
|
+
if (!tableExists(db, "logs") || !tableColumns(db, "logs").includes("thread_id")) {
|
|
997
|
+
return { store: "logs_db.logs", changedRows: 0 };
|
|
998
|
+
}
|
|
999
|
+
let changedRows = 0;
|
|
1000
|
+
const transaction = db.transaction(() => {
|
|
1001
|
+
const statement = db.prepare(`delete from ${quoteIdentifier("logs")} where ${quoteIdentifier("thread_id")} = ?`);
|
|
1002
|
+
for (const orphan of logsOnlyOrphans) {
|
|
1003
|
+
const info = statement.run(orphan.threadId);
|
|
1004
|
+
changedRows += Number(info.changes);
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
transaction();
|
|
1008
|
+
return { store: "logs_db.logs", changedRows };
|
|
1009
|
+
} finally {
|
|
1010
|
+
db.close();
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
function verifyLogsOnlyOrphans(paths, logsOnlyOrphans) {
|
|
1014
|
+
if (logsOnlyOrphans.length === 0 || !existsSync10(paths.logsDb)) {
|
|
1015
|
+
return [];
|
|
1016
|
+
}
|
|
1017
|
+
const db = openReadonlyDatabase(paths.logsDb);
|
|
1018
|
+
try {
|
|
1019
|
+
if (!tableExists(db, "logs") || !tableColumns(db, "logs").includes("thread_id")) {
|
|
1020
|
+
return [];
|
|
1021
|
+
}
|
|
1022
|
+
const statement = db.prepare(
|
|
1023
|
+
`select count(*) as count from ${quoteIdentifier("logs")} where ${quoteIdentifier("thread_id")} = ?`
|
|
1024
|
+
);
|
|
1025
|
+
return logsOnlyOrphans.flatMap((orphan) => {
|
|
1026
|
+
const row = statement.get(orphan.threadId);
|
|
1027
|
+
if (row.count === 0) {
|
|
1028
|
+
return [];
|
|
1029
|
+
}
|
|
1030
|
+
return [
|
|
1031
|
+
{
|
|
1032
|
+
store: "logs_db.logs",
|
|
1033
|
+
path: paths.logsDb,
|
|
1034
|
+
detail: `${row.count} row(s) still reference logs-only orphan ${orphan.threadId}`
|
|
1035
|
+
}
|
|
1036
|
+
];
|
|
1037
|
+
});
|
|
1038
|
+
} finally {
|
|
1039
|
+
db.close();
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// src/commands/purge-orphans.ts
|
|
1044
|
+
function planPurgeOrphansCommand(paths) {
|
|
1045
|
+
return buildPurgeOrphansPlan(paths);
|
|
1046
|
+
}
|
|
1047
|
+
function executePurgeOrphansPlanCommand(paths, plan) {
|
|
1048
|
+
return executePurgeOrphans(paths, plan);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
938
1051
|
// src/core/output.ts
|
|
939
1052
|
function printOutput(value, mode) {
|
|
940
1053
|
if (mode === "json") {
|
|
@@ -962,8 +1075,9 @@ function shortId(id) {
|
|
|
962
1075
|
|
|
963
1076
|
// src/cli.ts
|
|
964
1077
|
var TITLE_MAX_LENGTH = 80;
|
|
1078
|
+
var packageVersion = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
965
1079
|
var program = new Command();
|
|
966
|
-
program.name("codex-history").description("Inspect and safely purge local Codex conversation history.").version(
|
|
1080
|
+
program.name("codex-history").description("Inspect and safely purge local Codex conversation history.").version(packageVersion).option("--codex-home <path>", "Path to Codex home directory", "~/.codex").option("--json", "Print machine-readable JSON output");
|
|
967
1081
|
program.command("doctor").description("Check whether the local Codex data model is supported.").action(
|
|
968
1082
|
() => runCommand(() => {
|
|
969
1083
|
const report = doctorCommand(currentPaths());
|
|
@@ -1002,6 +1116,23 @@ program.command("purge").argument("<threadId>", "Codex thread id or unique short
|
|
|
1002
1116
|
return formatPurgeResult(executePurgePlanCommand(paths, plan));
|
|
1003
1117
|
})
|
|
1004
1118
|
);
|
|
1119
|
+
program.command("purge-orphans").option("--force", "Skip interactive confirmation").description("Purge orphaned local Codex data after target confirmation.").action(
|
|
1120
|
+
(options) => runCommand(async () => {
|
|
1121
|
+
if (currentOutputModeIsJson()) {
|
|
1122
|
+
throw new UsageError("purge-orphans does not support JSON output.");
|
|
1123
|
+
}
|
|
1124
|
+
const paths = currentPaths();
|
|
1125
|
+
const plan = planPurgeOrphansCommand(paths);
|
|
1126
|
+
const force = Boolean(options.force);
|
|
1127
|
+
if (!hasPurgeOrphansWork(plan)) {
|
|
1128
|
+
return formatPurgeOrphansPlan(plan);
|
|
1129
|
+
}
|
|
1130
|
+
if (!force) {
|
|
1131
|
+
await confirmPurgeOrphans(plan);
|
|
1132
|
+
}
|
|
1133
|
+
return formatPurgeOrphansResult(executePurgeOrphansPlanCommand(paths, plan));
|
|
1134
|
+
})
|
|
1135
|
+
);
|
|
1005
1136
|
await program.parseAsync();
|
|
1006
1137
|
function currentOutputMode() {
|
|
1007
1138
|
return program.opts().json ? "json" : "text";
|
|
@@ -1061,6 +1192,25 @@ async function confirmPurge(plan) {
|
|
|
1061
1192
|
readline.close();
|
|
1062
1193
|
}
|
|
1063
1194
|
}
|
|
1195
|
+
async function confirmPurgeOrphans(plan) {
|
|
1196
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1197
|
+
throw new SafetyRefusalError("purge-orphans requires an interactive terminal. Use --force to skip confirmation.");
|
|
1198
|
+
}
|
|
1199
|
+
const expected = "purge-orphans";
|
|
1200
|
+
process.stdout.write(formatPurgeOrphansConfirmation(plan));
|
|
1201
|
+
const readline = createInterface({
|
|
1202
|
+
input: process.stdin,
|
|
1203
|
+
output: process.stdout
|
|
1204
|
+
});
|
|
1205
|
+
try {
|
|
1206
|
+
const answer = await readline.question(`Type ${colorize("yellow", expected)} to confirm: `);
|
|
1207
|
+
if (answer.trim() !== expected) {
|
|
1208
|
+
throw new SafetyRefusalError("Confirmation did not match. No local Codex data was modified.");
|
|
1209
|
+
}
|
|
1210
|
+
} finally {
|
|
1211
|
+
readline.close();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1064
1214
|
function formatDoctor(report) {
|
|
1065
1215
|
if (currentOutputMode() === "json") {
|
|
1066
1216
|
return report;
|
|
@@ -1132,7 +1282,6 @@ function formatPurgeResult(result) {
|
|
|
1132
1282
|
"",
|
|
1133
1283
|
`Target: ${displayTitle(result.plan.target.title)}`,
|
|
1134
1284
|
`Thread id: ${result.plan.target.id}`,
|
|
1135
|
-
`Backup: ${colorize("dim", result.backup.backupDir)}`,
|
|
1136
1285
|
"",
|
|
1137
1286
|
"SQLite changes:",
|
|
1138
1287
|
...result.sqlite.map((change) => `- ${change.store}: ${change.changedRows} row(s)`),
|
|
@@ -1157,6 +1306,46 @@ function formatPurgeResult(result) {
|
|
|
1157
1306
|
}
|
|
1158
1307
|
return lines.join("\n");
|
|
1159
1308
|
}
|
|
1309
|
+
function formatPurgeOrphansPlan(plan) {
|
|
1310
|
+
if (!hasPurgeOrphansWork(plan)) {
|
|
1311
|
+
return "No orphaned local Codex data found.";
|
|
1312
|
+
}
|
|
1313
|
+
return formatPurgeOrphansConfirmation(plan).trimEnd();
|
|
1314
|
+
}
|
|
1315
|
+
function formatPurgeOrphansResult(result) {
|
|
1316
|
+
const sqliteChanges = aggregateOrphanSqliteChanges(result);
|
|
1317
|
+
const filesDeleted = result.purgeReports.flatMap((report) => report.files).filter((file) => file.deleted).length;
|
|
1318
|
+
const jsonChanged = result.purgeReports.flatMap((report) => report.json).filter((change) => change.changed).length;
|
|
1319
|
+
const lines = [
|
|
1320
|
+
colorize("green", "Purge-orphans executed."),
|
|
1321
|
+
"",
|
|
1322
|
+
"Summary:",
|
|
1323
|
+
`- Orphaned threads purged: ${result.plan.orphanThreads.length}`,
|
|
1324
|
+
`- Logs-only orphan thread ids cleaned: ${result.plan.logsOnlyOrphans.length}`,
|
|
1325
|
+
`- SQLite row changes: ${sqliteChanges.reduce((sum, change) => sum + change.rows, 0)}`,
|
|
1326
|
+
`- JSON/JSONL file changes: ${jsonChanged}`,
|
|
1327
|
+
`- Files deleted: ${filesDeleted}`,
|
|
1328
|
+
`- Estimated local disk space affected: ${formatNullableBytes(result.plan.impact.estimatedLocalDiskBytesAffected)}`,
|
|
1329
|
+
"",
|
|
1330
|
+
"SQLite changes:",
|
|
1331
|
+
...formatSqliteRows(sqliteChanges),
|
|
1332
|
+
"",
|
|
1333
|
+
colorize("dim", "SQLite database files may not shrink until vacuumed by Codex or another SQLite maintenance step."),
|
|
1334
|
+
"",
|
|
1335
|
+
`Verification: ${result.verification.success ? colorize("green", "passed") : colorize("red", "failed")}`
|
|
1336
|
+
];
|
|
1337
|
+
if (result.verification.remainingReferences.length > 0) {
|
|
1338
|
+
lines.push(
|
|
1339
|
+
"",
|
|
1340
|
+
"Remaining references:",
|
|
1341
|
+
...result.verification.remainingReferences.map(
|
|
1342
|
+
(reference) => `- ${reference.store}: ${reference.path} (${reference.detail})`
|
|
1343
|
+
)
|
|
1344
|
+
);
|
|
1345
|
+
process.exitCode = 1;
|
|
1346
|
+
}
|
|
1347
|
+
return lines.join("\n");
|
|
1348
|
+
}
|
|
1160
1349
|
function formatPurgeConfirmation(plan) {
|
|
1161
1350
|
return [
|
|
1162
1351
|
"About to purge this local Codex conversation:",
|
|
@@ -1166,10 +1355,85 @@ function formatPurgeConfirmation(plan) {
|
|
|
1166
1355
|
`cwd: ${colorize("dim", plan.target.cwd)}`,
|
|
1167
1356
|
`updated: ${formatDate(plan.target.updatedAtMs)}`,
|
|
1168
1357
|
"",
|
|
1169
|
-
colorize("dim", "
|
|
1358
|
+
colorize("dim", "This cannot be undone."),
|
|
1359
|
+
""
|
|
1360
|
+
].join("\n");
|
|
1361
|
+
}
|
|
1362
|
+
function formatPurgeOrphansConfirmation(plan) {
|
|
1363
|
+
return [
|
|
1364
|
+
"About to purge orphaned local Codex data:",
|
|
1365
|
+
"",
|
|
1366
|
+
`Orphaned threads: ${plan.orphanThreads.length}`,
|
|
1367
|
+
`Logs-only orphan thread ids: ${plan.logsOnlyOrphans.length}`,
|
|
1368
|
+
"",
|
|
1369
|
+
"SQLite row changes:",
|
|
1370
|
+
...formatSqliteRows(plan.impact.sqlite),
|
|
1371
|
+
"",
|
|
1372
|
+
"Files:",
|
|
1373
|
+
`- files to delete: ${plan.impact.filesToDelete}`,
|
|
1374
|
+
`- file bytes to delete: ${formatBytes(plan.impact.fileBytesToDelete)}`,
|
|
1375
|
+
"",
|
|
1376
|
+
"Estimated cleanup impact:",
|
|
1377
|
+
`- estimated log payload affected: ${formatNullableBytes(plan.impact.estimatedLogPayloadBytes)}`,
|
|
1378
|
+
`- estimated local disk space affected: ${formatNullableBytes(plan.impact.estimatedLocalDiskBytesAffected)}`,
|
|
1379
|
+
"",
|
|
1380
|
+
...formatOrphanExamples(plan),
|
|
1381
|
+
colorize("dim", "SQLite database files may not shrink until vacuumed by Codex or another SQLite maintenance step."),
|
|
1382
|
+
colorize("dim", "This cannot be undone."),
|
|
1170
1383
|
""
|
|
1171
1384
|
].join("\n");
|
|
1172
1385
|
}
|
|
1386
|
+
function formatOrphanExamples(plan) {
|
|
1387
|
+
const examples = [
|
|
1388
|
+
...plan.orphanThreads.slice(0, 5).map(
|
|
1389
|
+
(thread) => `- ${shortId(thread.id)} ${displayTitle(thread.title)} ${colorize("dim", thread.cwd)}`
|
|
1390
|
+
),
|
|
1391
|
+
...plan.logsOnlyOrphans.slice(0, Math.max(0, 5 - Math.min(plan.orphanThreads.length, 5))).map(
|
|
1392
|
+
(orphan) => `- logs-only: ${shortId(orphan.threadId)} (${orphan.rows} row(s))`
|
|
1393
|
+
)
|
|
1394
|
+
];
|
|
1395
|
+
if (examples.length === 0) {
|
|
1396
|
+
return [];
|
|
1397
|
+
}
|
|
1398
|
+
const total = plan.orphanThreads.length + plan.logsOnlyOrphans.length;
|
|
1399
|
+
const lines = ["Examples:", ...examples, ""];
|
|
1400
|
+
if (total > examples.length) {
|
|
1401
|
+
lines.push(`Showing ${examples.length} of ${total} orphan candidate(s).`, "");
|
|
1402
|
+
}
|
|
1403
|
+
return lines;
|
|
1404
|
+
}
|
|
1405
|
+
function aggregateOrphanSqliteChanges(result) {
|
|
1406
|
+
const changes = /* @__PURE__ */ new Map();
|
|
1407
|
+
for (const report of result.purgeReports) {
|
|
1408
|
+
for (const change of report.sqlite) {
|
|
1409
|
+
changes.set(change.store, (changes.get(change.store) ?? 0) + change.changedRows);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
changes.set(result.logsOnly.store, (changes.get(result.logsOnly.store) ?? 0) + result.logsOnly.changedRows);
|
|
1413
|
+
return [...changes.entries()].filter(([, rows]) => rows > 0).map(([store, rows]) => ({ store, rows })).sort((a, b) => a.store.localeCompare(b.store));
|
|
1414
|
+
}
|
|
1415
|
+
function formatSqliteRows(rows) {
|
|
1416
|
+
if (rows.length === 0) {
|
|
1417
|
+
return ["- none"];
|
|
1418
|
+
}
|
|
1419
|
+
return rows.map((row) => `- ${row.store}: ${row.rows} row(s)`);
|
|
1420
|
+
}
|
|
1421
|
+
function formatNullableBytes(value) {
|
|
1422
|
+
return value === null ? "unavailable" : formatBytes(value);
|
|
1423
|
+
}
|
|
1424
|
+
function formatBytes(value) {
|
|
1425
|
+
if (value < 1024) {
|
|
1426
|
+
return `${value} B`;
|
|
1427
|
+
}
|
|
1428
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
1429
|
+
let size = value / 1024;
|
|
1430
|
+
let unitIndex = 0;
|
|
1431
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
1432
|
+
size /= 1024;
|
|
1433
|
+
unitIndex += 1;
|
|
1434
|
+
}
|
|
1435
|
+
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
|
1436
|
+
}
|
|
1173
1437
|
function formatCheckStatus(status) {
|
|
1174
1438
|
const label = status.toUpperCase().padEnd(7);
|
|
1175
1439
|
if (status === "ok") {
|