@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 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 one resolved conversation only after confirmation.
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/output.ts
851
- function printOutput(value, mode) {
852
- if (mode === "json") {
853
- console.log(JSON.stringify(value, null, 2));
854
- return;
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").option("--json", "Print machine-readable JSON output");
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(), currentOutputMode());
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
- if (currentOutputMode() === "json") {
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 && !currentOutputModeIsJson());
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 formatDoctor(report) {
978
- if (currentOutputMode() === "json") {
979
- return report;
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 !currentOutputModeIsJson() && !("NO_COLOR" in process.env) && Boolean(stream.isTTY);
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
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liuyoumi/codex-history",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for listing and safely purging local Codex conversation history",
5
5
  "type": "module",
6
6
  "bin": {