@liuyoumi/codex-history 0.1.3 → 0.2.0
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 +13 -0
- package/README.en.md +23 -4
- package/README.md +23 -4
- package/dist/cli.js +357 -55
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-05-29
|
|
4
|
+
|
|
5
|
+
### Removed
|
|
6
|
+
|
|
7
|
+
- Remove the public `--json` output mode so the CLI stays focused on human-reviewed cleanup workflows.
|
|
8
|
+
|
|
9
|
+
## 0.1.4 - 2026-05-29
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Add `purge-orphans` for guarded cleanup of missing-rollout threads and logs-only orphan records.
|
|
14
|
+
- Report estimated local disk space affected by orphan cleanup.
|
|
15
|
+
|
|
3
16
|
## 0.1.3 - 2026-05-29
|
|
4
17
|
|
|
5
18
|
### Changed
|
package/README.en.md
CHANGED
|
@@ -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
|
|
|
@@ -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:
|
|
@@ -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
|
|
|
@@ -141,16 +143,33 @@ codex-history purge 019e6885 --force
|
|
|
141
143
|
|
|
142
144
|
`--force` skips only the interactive short-id confirmation. It still keeps schema validation, active-thread protection, and post-purge verification.
|
|
143
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.
|
|
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.
|
|
165
|
+
|
|
144
166
|
## Options
|
|
145
167
|
|
|
146
168
|
```bash
|
|
147
169
|
codex-history --codex-home /path/to/.codex list
|
|
148
|
-
codex-history --json list --grep "Astro"
|
|
149
|
-
codex-history --json purge 019e6885 --force
|
|
150
170
|
```
|
|
151
171
|
|
|
152
172
|
- `--codex-home` defaults to `~/.codex`.
|
|
153
|
-
- `--json` prints machine-readable output. For `purge`, JSON output requires `--force` because interactive confirmation is text-only.
|
|
154
173
|
- Color is enabled only in interactive terminals and respects `NO_COLOR`.
|
|
155
174
|
|
|
156
175
|
## Safety
|
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -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,确认后才会删除:
|
|
@@ -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
|
|
|
@@ -141,16 +143,33 @@ codex-history purge 019e6885 --force
|
|
|
141
143
|
|
|
142
144
|
`--force` 只跳过交互式短 id 确认,不会跳过数据结构校验、active thread 保护和删除后的验证。
|
|
143
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 保护和删除后的验证。
|
|
163
|
+
|
|
164
|
+
空间统计是估算值。SQLite 删除记录后,数据库文件可能不会立刻缩小,直到 Codex 或其他 SQLite 维护步骤执行 vacuum。
|
|
165
|
+
|
|
144
166
|
## 选项
|
|
145
167
|
|
|
146
168
|
```bash
|
|
147
169
|
codex-history --codex-home /path/to/.codex list
|
|
148
|
-
codex-history --json list --grep "Astro"
|
|
149
|
-
codex-history --json purge 019e6885 --force
|
|
150
170
|
```
|
|
151
171
|
|
|
152
172
|
- `--codex-home` 默认是 `~/.codex`。
|
|
153
|
-
- `--json` 输出机器可读的 JSON。`purge` 使用 JSON 输出时必须加 `--force`,因为交互确认只适合文本模式。
|
|
154
173
|
- 颜色只会在交互式终端中启用,并遵守 `NO_COLOR`。
|
|
155
174
|
|
|
156
175
|
## 安全机制
|
package/dist/cli.js
CHANGED
|
@@ -593,8 +593,8 @@ function removeThreadReferences(value, threadId) {
|
|
|
593
593
|
}
|
|
594
594
|
|
|
595
595
|
// src/core/executor.ts
|
|
596
|
-
function executePurge(paths, plan) {
|
|
597
|
-
const activeThreadChecks = assertThreadIsNotActive(plan.target);
|
|
596
|
+
function executePurge(paths, plan, options = {}) {
|
|
597
|
+
const activeThreadChecks = options.skipActiveThreadCheck ? [] : assertThreadIsNotActive(plan.target);
|
|
598
598
|
const sqlite = purgeSqlite(paths, plan.target.id);
|
|
599
599
|
const json = [
|
|
600
600
|
removeThreadFromSessionIndex(paths.sessionIndex, plan.target.id),
|
|
@@ -847,12 +847,209 @@ function executePurgePlanCommand(paths, plan) {
|
|
|
847
847
|
return executePurge(paths, plan);
|
|
848
848
|
}
|
|
849
849
|
|
|
850
|
-
// src/core/
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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();
|
|
855
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
|
+
|
|
1051
|
+
// src/core/output.ts
|
|
1052
|
+
function printOutput(value) {
|
|
856
1053
|
if (typeof value === "string") {
|
|
857
1054
|
if (value.length === 0) {
|
|
858
1055
|
return;
|
|
@@ -876,7 +1073,7 @@ function shortId(id) {
|
|
|
876
1073
|
var TITLE_MAX_LENGTH = 80;
|
|
877
1074
|
var packageVersion = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
878
1075
|
var program = new Command();
|
|
879
|
-
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")
|
|
1076
|
+
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");
|
|
880
1077
|
program.command("doctor").description("Check whether the local Codex data model is supported.").action(
|
|
881
1078
|
() => runCommand(() => {
|
|
882
1079
|
const report = doctorCommand(currentPaths());
|
|
@@ -906,33 +1103,37 @@ program.command("purge").argument("<threadId>", "Codex thread id or unique short
|
|
|
906
1103
|
const paths = currentPaths();
|
|
907
1104
|
const plan = planPurgeCommand(paths, threadId);
|
|
908
1105
|
const force = Boolean(options.force);
|
|
909
|
-
if (currentOutputModeIsJson() && !force) {
|
|
910
|
-
throw new UsageError("JSON purge output requires --force because interactive confirmation is text-only.");
|
|
911
|
-
}
|
|
912
1106
|
if (!force) {
|
|
913
1107
|
await confirmPurge(plan);
|
|
914
1108
|
}
|
|
915
1109
|
return formatPurgeResult(executePurgePlanCommand(paths, plan));
|
|
916
1110
|
})
|
|
917
1111
|
);
|
|
1112
|
+
program.command("purge-orphans").option("--force", "Skip interactive confirmation").description("Purge orphaned local Codex data after target confirmation.").action(
|
|
1113
|
+
(options) => runCommand(async () => {
|
|
1114
|
+
const paths = currentPaths();
|
|
1115
|
+
const plan = planPurgeOrphansCommand(paths);
|
|
1116
|
+
const force = Boolean(options.force);
|
|
1117
|
+
if (!hasPurgeOrphansWork(plan)) {
|
|
1118
|
+
return formatPurgeOrphansPlan(plan);
|
|
1119
|
+
}
|
|
1120
|
+
if (!force) {
|
|
1121
|
+
await confirmPurgeOrphans(plan);
|
|
1122
|
+
}
|
|
1123
|
+
return formatPurgeOrphansResult(executePurgeOrphansPlanCommand(paths, plan));
|
|
1124
|
+
})
|
|
1125
|
+
);
|
|
918
1126
|
await program.parseAsync();
|
|
919
|
-
function currentOutputMode() {
|
|
920
|
-
return program.opts().json ? "json" : "text";
|
|
921
|
-
}
|
|
922
1127
|
function currentPaths() {
|
|
923
1128
|
return resolvePaths(program.opts().codexHome);
|
|
924
1129
|
}
|
|
925
1130
|
async function runCommand(produce) {
|
|
926
1131
|
try {
|
|
927
|
-
printOutput(await produce()
|
|
1132
|
+
printOutput(await produce());
|
|
928
1133
|
} catch (error) {
|
|
929
1134
|
const exitCode = error instanceof CodexHistoryError ? error.exitCode : 1;
|
|
930
1135
|
const message = error instanceof Error ? error.message : String(error);
|
|
931
|
-
|
|
932
|
-
console.error(JSON.stringify({ error: message, exitCode }, null, 2));
|
|
933
|
-
} else {
|
|
934
|
-
console.error(`${colorizeError("red", "Error:")} ${message}`);
|
|
935
|
-
}
|
|
1136
|
+
console.error(`${colorizeError("red", "Error:")} ${message}`);
|
|
936
1137
|
process.exitCode = exitCode;
|
|
937
1138
|
}
|
|
938
1139
|
}
|
|
@@ -950,10 +1151,7 @@ function parsePretty(value) {
|
|
|
950
1151
|
throw new Error(`Expected pretty format oneline, medium, or full; got: ${value}`);
|
|
951
1152
|
}
|
|
952
1153
|
function shouldUsePager(options) {
|
|
953
|
-
return Boolean(options.limit === void 0 && process.stdout.isTTY
|
|
954
|
-
}
|
|
955
|
-
function currentOutputModeIsJson() {
|
|
956
|
-
return currentOutputMode() === "json";
|
|
1154
|
+
return Boolean(options.limit === void 0 && process.stdout.isTTY);
|
|
957
1155
|
}
|
|
958
1156
|
async function confirmPurge(plan) {
|
|
959
1157
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
@@ -974,10 +1172,26 @@ async function confirmPurge(plan) {
|
|
|
974
1172
|
readline.close();
|
|
975
1173
|
}
|
|
976
1174
|
}
|
|
977
|
-
function
|
|
978
|
-
if (
|
|
979
|
-
|
|
1175
|
+
async function confirmPurgeOrphans(plan) {
|
|
1176
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1177
|
+
throw new SafetyRefusalError("purge-orphans requires an interactive terminal. Use --force to skip confirmation.");
|
|
1178
|
+
}
|
|
1179
|
+
const expected = "purge-orphans";
|
|
1180
|
+
process.stdout.write(formatPurgeOrphansConfirmation(plan));
|
|
1181
|
+
const readline = createInterface({
|
|
1182
|
+
input: process.stdin,
|
|
1183
|
+
output: process.stdout
|
|
1184
|
+
});
|
|
1185
|
+
try {
|
|
1186
|
+
const answer = await readline.question(`Type ${colorize("yellow", expected)} to confirm: `);
|
|
1187
|
+
if (answer.trim() !== expected) {
|
|
1188
|
+
throw new SafetyRefusalError("Confirmation did not match. No local Codex data was modified.");
|
|
1189
|
+
}
|
|
1190
|
+
} finally {
|
|
1191
|
+
readline.close();
|
|
980
1192
|
}
|
|
1193
|
+
}
|
|
1194
|
+
function formatDoctor(report) {
|
|
981
1195
|
const lines = [
|
|
982
1196
|
`${colorize("dim", "Codex home:")} ${colorize("dim", report.codexHome)}`,
|
|
983
1197
|
`${colorize("dim", "Supported:")} ${report.supported ? colorize("green", "yes") : colorize("red", "no")}`,
|
|
@@ -989,9 +1203,6 @@ function formatDoctor(report) {
|
|
|
989
1203
|
return lines.join("\n");
|
|
990
1204
|
}
|
|
991
1205
|
function formatThreads(threads, pretty = "oneline", usePager = false) {
|
|
992
|
-
if (currentOutputMode() === "json") {
|
|
993
|
-
return { count: threads.length, threads: threads.map(toPublicThread) };
|
|
994
|
-
}
|
|
995
1206
|
if (threads.length === 0) {
|
|
996
1207
|
return "No Codex conversations found.";
|
|
997
1208
|
}
|
|
@@ -1037,9 +1248,6 @@ function pageText(text) {
|
|
|
1037
1248
|
return "";
|
|
1038
1249
|
}
|
|
1039
1250
|
function formatPurgeResult(result) {
|
|
1040
|
-
if (currentOutputMode() === "json") {
|
|
1041
|
-
return sanitizePurgeResult(result);
|
|
1042
|
-
}
|
|
1043
1251
|
const lines = [
|
|
1044
1252
|
colorize("green", "Purge executed."),
|
|
1045
1253
|
"",
|
|
@@ -1069,6 +1277,46 @@ function formatPurgeResult(result) {
|
|
|
1069
1277
|
}
|
|
1070
1278
|
return lines.join("\n");
|
|
1071
1279
|
}
|
|
1280
|
+
function formatPurgeOrphansPlan(plan) {
|
|
1281
|
+
if (!hasPurgeOrphansWork(plan)) {
|
|
1282
|
+
return "No orphaned local Codex data found.";
|
|
1283
|
+
}
|
|
1284
|
+
return formatPurgeOrphansConfirmation(plan).trimEnd();
|
|
1285
|
+
}
|
|
1286
|
+
function formatPurgeOrphansResult(result) {
|
|
1287
|
+
const sqliteChanges = aggregateOrphanSqliteChanges(result);
|
|
1288
|
+
const filesDeleted = result.purgeReports.flatMap((report) => report.files).filter((file) => file.deleted).length;
|
|
1289
|
+
const jsonChanged = result.purgeReports.flatMap((report) => report.json).filter((change) => change.changed).length;
|
|
1290
|
+
const lines = [
|
|
1291
|
+
colorize("green", "Purge-orphans executed."),
|
|
1292
|
+
"",
|
|
1293
|
+
"Summary:",
|
|
1294
|
+
`- Orphaned threads purged: ${result.plan.orphanThreads.length}`,
|
|
1295
|
+
`- Logs-only orphan thread ids cleaned: ${result.plan.logsOnlyOrphans.length}`,
|
|
1296
|
+
`- SQLite row changes: ${sqliteChanges.reduce((sum, change) => sum + change.rows, 0)}`,
|
|
1297
|
+
`- JSON/JSONL file changes: ${jsonChanged}`,
|
|
1298
|
+
`- Files deleted: ${filesDeleted}`,
|
|
1299
|
+
`- Estimated local disk space affected: ${formatNullableBytes(result.plan.impact.estimatedLocalDiskBytesAffected)}`,
|
|
1300
|
+
"",
|
|
1301
|
+
"SQLite changes:",
|
|
1302
|
+
...formatSqliteRows(sqliteChanges),
|
|
1303
|
+
"",
|
|
1304
|
+
colorize("dim", "SQLite database files may not shrink until vacuumed by Codex or another SQLite maintenance step."),
|
|
1305
|
+
"",
|
|
1306
|
+
`Verification: ${result.verification.success ? colorize("green", "passed") : colorize("red", "failed")}`
|
|
1307
|
+
];
|
|
1308
|
+
if (result.verification.remainingReferences.length > 0) {
|
|
1309
|
+
lines.push(
|
|
1310
|
+
"",
|
|
1311
|
+
"Remaining references:",
|
|
1312
|
+
...result.verification.remainingReferences.map(
|
|
1313
|
+
(reference) => `- ${reference.store}: ${reference.path} (${reference.detail})`
|
|
1314
|
+
)
|
|
1315
|
+
);
|
|
1316
|
+
process.exitCode = 1;
|
|
1317
|
+
}
|
|
1318
|
+
return lines.join("\n");
|
|
1319
|
+
}
|
|
1072
1320
|
function formatPurgeConfirmation(plan) {
|
|
1073
1321
|
return [
|
|
1074
1322
|
"About to purge this local Codex conversation:",
|
|
@@ -1082,6 +1330,81 @@ function formatPurgeConfirmation(plan) {
|
|
|
1082
1330
|
""
|
|
1083
1331
|
].join("\n");
|
|
1084
1332
|
}
|
|
1333
|
+
function formatPurgeOrphansConfirmation(plan) {
|
|
1334
|
+
return [
|
|
1335
|
+
"About to purge orphaned local Codex data:",
|
|
1336
|
+
"",
|
|
1337
|
+
`Orphaned threads: ${plan.orphanThreads.length}`,
|
|
1338
|
+
`Logs-only orphan thread ids: ${plan.logsOnlyOrphans.length}`,
|
|
1339
|
+
"",
|
|
1340
|
+
"SQLite row changes:",
|
|
1341
|
+
...formatSqliteRows(plan.impact.sqlite),
|
|
1342
|
+
"",
|
|
1343
|
+
"Files:",
|
|
1344
|
+
`- files to delete: ${plan.impact.filesToDelete}`,
|
|
1345
|
+
`- file bytes to delete: ${formatBytes(plan.impact.fileBytesToDelete)}`,
|
|
1346
|
+
"",
|
|
1347
|
+
"Estimated cleanup impact:",
|
|
1348
|
+
`- estimated log payload affected: ${formatNullableBytes(plan.impact.estimatedLogPayloadBytes)}`,
|
|
1349
|
+
`- estimated local disk space affected: ${formatNullableBytes(plan.impact.estimatedLocalDiskBytesAffected)}`,
|
|
1350
|
+
"",
|
|
1351
|
+
...formatOrphanExamples(plan),
|
|
1352
|
+
colorize("dim", "SQLite database files may not shrink until vacuumed by Codex or another SQLite maintenance step."),
|
|
1353
|
+
colorize("dim", "This cannot be undone."),
|
|
1354
|
+
""
|
|
1355
|
+
].join("\n");
|
|
1356
|
+
}
|
|
1357
|
+
function formatOrphanExamples(plan) {
|
|
1358
|
+
const examples = [
|
|
1359
|
+
...plan.orphanThreads.slice(0, 5).map(
|
|
1360
|
+
(thread) => `- ${shortId(thread.id)} ${displayTitle(thread.title)} ${colorize("dim", thread.cwd)}`
|
|
1361
|
+
),
|
|
1362
|
+
...plan.logsOnlyOrphans.slice(0, Math.max(0, 5 - Math.min(plan.orphanThreads.length, 5))).map(
|
|
1363
|
+
(orphan) => `- logs-only: ${shortId(orphan.threadId)} (${orphan.rows} row(s))`
|
|
1364
|
+
)
|
|
1365
|
+
];
|
|
1366
|
+
if (examples.length === 0) {
|
|
1367
|
+
return [];
|
|
1368
|
+
}
|
|
1369
|
+
const total = plan.orphanThreads.length + plan.logsOnlyOrphans.length;
|
|
1370
|
+
const lines = ["Examples:", ...examples, ""];
|
|
1371
|
+
if (total > examples.length) {
|
|
1372
|
+
lines.push(`Showing ${examples.length} of ${total} orphan candidate(s).`, "");
|
|
1373
|
+
}
|
|
1374
|
+
return lines;
|
|
1375
|
+
}
|
|
1376
|
+
function aggregateOrphanSqliteChanges(result) {
|
|
1377
|
+
const changes = /* @__PURE__ */ new Map();
|
|
1378
|
+
for (const report of result.purgeReports) {
|
|
1379
|
+
for (const change of report.sqlite) {
|
|
1380
|
+
changes.set(change.store, (changes.get(change.store) ?? 0) + change.changedRows);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
changes.set(result.logsOnly.store, (changes.get(result.logsOnly.store) ?? 0) + result.logsOnly.changedRows);
|
|
1384
|
+
return [...changes.entries()].filter(([, rows]) => rows > 0).map(([store, rows]) => ({ store, rows })).sort((a, b) => a.store.localeCompare(b.store));
|
|
1385
|
+
}
|
|
1386
|
+
function formatSqliteRows(rows) {
|
|
1387
|
+
if (rows.length === 0) {
|
|
1388
|
+
return ["- none"];
|
|
1389
|
+
}
|
|
1390
|
+
return rows.map((row) => `- ${row.store}: ${row.rows} row(s)`);
|
|
1391
|
+
}
|
|
1392
|
+
function formatNullableBytes(value) {
|
|
1393
|
+
return value === null ? "unavailable" : formatBytes(value);
|
|
1394
|
+
}
|
|
1395
|
+
function formatBytes(value) {
|
|
1396
|
+
if (value < 1024) {
|
|
1397
|
+
return `${value} B`;
|
|
1398
|
+
}
|
|
1399
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
1400
|
+
let size = value / 1024;
|
|
1401
|
+
let unitIndex = 0;
|
|
1402
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
1403
|
+
size /= 1024;
|
|
1404
|
+
unitIndex += 1;
|
|
1405
|
+
}
|
|
1406
|
+
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
|
1407
|
+
}
|
|
1085
1408
|
function formatCheckStatus(status) {
|
|
1086
1409
|
const label = status.toUpperCase().padEnd(7);
|
|
1087
1410
|
if (status === "ok") {
|
|
@@ -1121,7 +1444,7 @@ function errorColorsEnabled() {
|
|
|
1121
1444
|
return streamColorsEnabled(process.stderr);
|
|
1122
1445
|
}
|
|
1123
1446
|
function streamColorsEnabled(stream) {
|
|
1124
|
-
return !
|
|
1447
|
+
return !("NO_COLOR" in process.env) && Boolean(stream.isTTY);
|
|
1125
1448
|
}
|
|
1126
1449
|
function displayTitle(title) {
|
|
1127
1450
|
const normalized = title.trim().replaceAll(/\s+/g, " ");
|
|
@@ -1133,24 +1456,3 @@ function displayTitle(title) {
|
|
|
1133
1456
|
}
|
|
1134
1457
|
return `${normalized.slice(0, TITLE_MAX_LENGTH - 3)}...`;
|
|
1135
1458
|
}
|
|
1136
|
-
function toPublicThread(thread) {
|
|
1137
|
-
return {
|
|
1138
|
-
id: thread.id,
|
|
1139
|
-
title: displayTitle(thread.title),
|
|
1140
|
-
titleTruncated: thread.title.trim().replaceAll(/\s+/g, " ").length > TITLE_MAX_LENGTH,
|
|
1141
|
-
rolloutPath: thread.rolloutPath,
|
|
1142
|
-
createdAtMs: thread.createdAtMs,
|
|
1143
|
-
updatedAtMs: thread.updatedAtMs,
|
|
1144
|
-
cwd: thread.cwd,
|
|
1145
|
-
archived: thread.archived
|
|
1146
|
-
};
|
|
1147
|
-
}
|
|
1148
|
-
function sanitizePurgeResult(result) {
|
|
1149
|
-
return {
|
|
1150
|
-
...result,
|
|
1151
|
-
plan: {
|
|
1152
|
-
...result.plan,
|
|
1153
|
-
target: toPublicThread(result.plan.target)
|
|
1154
|
-
}
|
|
1155
|
-
};
|
|
1156
|
-
}
|