@liuyoumi/codex-history 0.1.3 → 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 CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## 0.1.3 - 2026-05-29
4
11
 
5
12
  ### 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,6 +143,26 @@ 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. `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.
165
+
144
166
  ## Options
145
167
 
146
168
  ```bash
@@ -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
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,6 +143,26 @@ 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 保护和删除后的验证。`purge-orphans` 不支持 JSON 输出。
163
+
164
+ 空间统计是估算值。SQLite 删除记录后,数据库文件可能不会立刻缩小,直到 Codex 或其他 SQLite 维护步骤执行 vacuum。
165
+
144
166
  ## 选项
145
167
 
146
168
  ```bash
@@ -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
  ## 安全机制
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,6 +847,207 @@ function executePurgePlanCommand(paths, plan) {
847
847
  return executePurge(paths, plan);
848
848
  }
849
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
+
850
1051
  // src/core/output.ts
851
1052
  function printOutput(value, mode) {
852
1053
  if (mode === "json") {
@@ -915,6 +1116,23 @@ program.command("purge").argument("<threadId>", "Codex thread id or unique short
915
1116
  return formatPurgeResult(executePurgePlanCommand(paths, plan));
916
1117
  })
917
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
+ );
918
1136
  await program.parseAsync();
919
1137
  function currentOutputMode() {
920
1138
  return program.opts().json ? "json" : "text";
@@ -974,6 +1192,25 @@ async function confirmPurge(plan) {
974
1192
  readline.close();
975
1193
  }
976
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
+ }
977
1214
  function formatDoctor(report) {
978
1215
  if (currentOutputMode() === "json") {
979
1216
  return report;
@@ -1069,6 +1306,46 @@ function formatPurgeResult(result) {
1069
1306
  }
1070
1307
  return lines.join("\n");
1071
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
+ }
1072
1349
  function formatPurgeConfirmation(plan) {
1073
1350
  return [
1074
1351
  "About to purge this local Codex conversation:",
@@ -1082,6 +1359,81 @@ function formatPurgeConfirmation(plan) {
1082
1359
  ""
1083
1360
  ].join("\n");
1084
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."),
1383
+ ""
1384
+ ].join("\n");
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
+ }
1085
1437
  function formatCheckStatus(status) {
1086
1438
  const label = status.toUpperCase().padEnd(7);
1087
1439
  if (status === "ok") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liuyoumi/codex-history",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "CLI for listing and safely purging local Codex conversation history",
5
5
  "type": "module",
6
6
  "bin": {