@liuyoumi/codex-history 0.2.0 → 0.3.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-05-29
4
+
5
+ ### Added
6
+
7
+ - Allow `purge` to accept multiple thread ids or unique short id prefixes in one guarded batch.
8
+
3
9
  ## 0.2.0 - 2026-05-29
4
10
 
5
11
  ### Removed
package/README.en.md CHANGED
@@ -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 019e6885 019e6874
54
55
  codex-history purge-orphans
55
56
  ```
56
57
 
@@ -75,7 +76,7 @@ Type 019e6885 to confirm:
75
76
  | `codex-history doctor` | Check whether the local Codex data layout is supported. |
76
77
  | `codex-history list` | List local conversations. |
77
78
  | `codex-history list --grep <keyword>` | Filter conversations by title, id, or cwd. |
78
- | `codex-history purge <id>` | Remove one resolved local conversation after confirmation. |
79
+ | `codex-history purge <id...>` | Remove one or more resolved local conversations after confirmation. |
79
80
  | `codex-history purge-orphans` | Remove orphaned local data after confirmation. |
80
81
 
81
82
  ### `doctor`
@@ -129,19 +130,23 @@ When `list` runs in an interactive terminal without `--limit`, output is sent th
129
130
 
130
131
  ### `purge`
131
132
 
132
- Remove one local Codex conversation by full id or unique short id prefix.
133
+ Remove one or more local Codex conversations by full id or unique short id prefix.
133
134
 
134
135
  ```bash
135
136
  codex-history purge 019e6885
137
+ codex-history purge 019e6885 019e6874
136
138
  ```
137
139
 
140
+ When multiple targets are provided, the tool resolves all targets and prints a batch plan before deletion. If any id is missing, ambiguous, or active, the whole batch is refused before mutation. Interactive batch purge requires typing `purge-selected`.
141
+
138
142
  For scripts or non-interactive shells, use `--force`:
139
143
 
140
144
  ```bash
141
145
  codex-history purge 019e6885 --force
146
+ codex-history purge 019e6885 019e6874 --force
142
147
  ```
143
148
 
144
- `--force` skips only the interactive short-id confirmation. It still keeps schema validation, active-thread protection, and post-purge verification.
149
+ `--force` skips only interactive confirmation. It still keeps schema validation, active-thread protection, and post-purge verification.
145
150
 
146
151
  ### `purge-orphans`
147
152
 
package/README.md CHANGED
@@ -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 019e6885 019e6874
54
55
  codex-history purge-orphans
55
56
  ```
56
57
 
@@ -75,7 +76,7 @@ Type 019e6885 to confirm:
75
76
  | `codex-history doctor` | 检查当前本地 Codex 数据结构是否受支持。 |
76
77
  | `codex-history list` | 列出本地对话。 |
77
78
  | `codex-history list --grep <keyword>` | 按标题、id 或 cwd 过滤对话。 |
78
- | `codex-history purge <id>` | 确认后删除一条解析到的本地对话。 |
79
+ | `codex-history purge <id...>` | 确认后删除一条或多条解析到的本地对话。 |
79
80
  | `codex-history purge-orphans` | 确认后清理本地孤儿数据。 |
80
81
 
81
82
  ### `doctor`
@@ -129,19 +130,23 @@ codex-history list --grep "Astro 博客"
129
130
 
130
131
  ### `purge`
131
132
 
132
- 通过完整 id 或唯一短 id 前缀删除一条本地 Codex 对话。
133
+ 通过完整 id 或唯一短 id 前缀删除一条或多条本地 Codex 对话。
133
134
 
134
135
  ```bash
135
136
  codex-history purge 019e6885
137
+ codex-history purge 019e6885 019e6874
136
138
  ```
137
139
 
140
+ 删除多个目标时,工具会先解析全部目标并展示批量计划;只要任意一个 id 不存在、不唯一,或命中 active thread,整个批量操作都会拒绝执行,不会部分删除。交互式批量删除需要输入 `purge-selected` 确认。
141
+
138
142
  脚本或非交互环境可以使用 `--force`:
139
143
 
140
144
  ```bash
141
145
  codex-history purge 019e6885 --force
146
+ codex-history purge 019e6885 019e6874 --force
142
147
  ```
143
148
 
144
- `--force` 只跳过交互式短 id 确认,不会跳过数据结构校验、active thread 保护和删除后的验证。
149
+ `--force` 只跳过交互确认,不会跳过数据结构校验、active thread 保护和删除后的验证。
145
150
 
146
151
  ### `purge-orphans`
147
152
 
package/dist/cli.js CHANGED
@@ -837,15 +837,47 @@ function findShellSnapshots(shellSnapshotsDir, threadId) {
837
837
  }
838
838
 
839
839
  // src/commands/purge.ts
840
- function planPurgeCommand(paths, threadId) {
840
+ function planBatchPurgeCommand(paths, threadIds) {
841
841
  validateSupportedDataModel(paths);
842
- const target = resolvePurgeTarget(paths, threadId);
843
- return buildPurgePlan(paths, target);
842
+ const plans = [];
843
+ const seenTargets = /* @__PURE__ */ new Set();
844
+ const duplicateInputs = [];
845
+ for (const threadId of threadIds) {
846
+ const target = resolvePurgeTarget(paths, threadId);
847
+ if (seenTargets.has(target.id)) {
848
+ duplicateInputs.push({ input: threadId, targetId: target.id });
849
+ continue;
850
+ }
851
+ seenTargets.add(target.id);
852
+ plans.push(buildPurgePlan(paths, target));
853
+ }
854
+ return {
855
+ mode: "planned",
856
+ requestedCount: threadIds.length,
857
+ plans,
858
+ duplicateInputs
859
+ };
844
860
  }
845
861
  function executePurgePlanCommand(paths, plan) {
846
862
  validateSupportedDataModel(paths);
847
863
  return executePurge(paths, plan);
848
864
  }
865
+ function executeBatchPurgePlanCommand(paths, plan) {
866
+ validateSupportedDataModel(paths);
867
+ const activeThreadChecks = plan.plans.flatMap((item) => assertThreadIsNotActive(item.target));
868
+ const purgeReports = plan.plans.map((item) => executePurge(paths, item, { skipActiveThreadCheck: true }));
869
+ const remainingReferences = purgeReports.flatMap((report) => report.verification.remainingReferences);
870
+ return {
871
+ mode: "executed",
872
+ plan,
873
+ activeThreadChecks,
874
+ purgeReports,
875
+ verification: {
876
+ success: remainingReferences.length === 0,
877
+ remainingReferences
878
+ }
879
+ };
880
+ }
849
881
 
850
882
  // src/core/orphans.ts
851
883
  import { existsSync as existsSync10 } from "fs";
@@ -1098,15 +1130,22 @@ program.command("list").description("List local Codex conversations.").option("-
1098
1130
  )
1099
1131
  )
1100
1132
  );
1101
- program.command("purge").argument("<threadId>", "Codex thread id or unique short id prefix to purge").option("--force", "Skip interactive confirmation").description("Purge one local Codex conversation after target confirmation.").action(
1102
- (threadId, options) => runCommand(async () => {
1133
+ program.command("purge").argument("<threadIds...>", "Codex thread id(s) or unique short id prefix(es) to purge").option("--force", "Skip interactive confirmation").description("Purge local Codex conversation(s) after target confirmation.").action(
1134
+ (threadIds, options) => runCommand(async () => {
1103
1135
  const paths = currentPaths();
1104
- const plan = planPurgeCommand(paths, threadId);
1136
+ const plan = planBatchPurgeCommand(paths, threadIds);
1105
1137
  const force = Boolean(options.force);
1138
+ const useBatchConfirmation = plan.requestedCount > 1;
1139
+ if (plan.plans.length === 1 && !useBatchConfirmation) {
1140
+ if (!force) {
1141
+ await confirmPurge(plan.plans[0]);
1142
+ }
1143
+ return formatPurgeResult(executePurgePlanCommand(paths, plan.plans[0]));
1144
+ }
1106
1145
  if (!force) {
1107
- await confirmPurge(plan);
1146
+ await confirmBatchPurge(plan);
1108
1147
  }
1109
- return formatPurgeResult(executePurgePlanCommand(paths, plan));
1148
+ return formatBatchPurgeResult(executeBatchPurgePlanCommand(paths, plan));
1110
1149
  })
1111
1150
  );
1112
1151
  program.command("purge-orphans").option("--force", "Skip interactive confirmation").description("Purge orphaned local Codex data after target confirmation.").action(
@@ -1172,6 +1211,25 @@ async function confirmPurge(plan) {
1172
1211
  readline.close();
1173
1212
  }
1174
1213
  }
1214
+ async function confirmBatchPurge(plan) {
1215
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1216
+ throw new SafetyRefusalError("Batch purge requires an interactive terminal. Use --force to skip confirmation.");
1217
+ }
1218
+ const expected = "purge-selected";
1219
+ process.stdout.write(formatBatchPurgeConfirmation(plan));
1220
+ const readline = createInterface({
1221
+ input: process.stdin,
1222
+ output: process.stdout
1223
+ });
1224
+ try {
1225
+ const answer = await readline.question(`Type ${colorize("yellow", expected)} to confirm: `);
1226
+ if (answer.trim() !== expected) {
1227
+ throw new SafetyRefusalError("Confirmation did not match. No local Codex data was modified.");
1228
+ }
1229
+ } finally {
1230
+ readline.close();
1231
+ }
1232
+ }
1175
1233
  async function confirmPurgeOrphans(plan) {
1176
1234
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1177
1235
  throw new SafetyRefusalError("purge-orphans requires an interactive terminal. Use --force to skip confirmation.");
@@ -1277,6 +1335,38 @@ function formatPurgeResult(result) {
1277
1335
  }
1278
1336
  return lines.join("\n");
1279
1337
  }
1338
+ function formatBatchPurgeResult(result) {
1339
+ const sqliteChanges = aggregateBatchSqliteChanges(result);
1340
+ const jsonChanged = result.purgeReports.flatMap((report) => report.json).filter((change) => change.changed).length;
1341
+ const filesDeleted = result.purgeReports.flatMap((report) => report.files).filter((change) => change.deleted).length;
1342
+ const lines = [
1343
+ colorize("green", "Batch purge executed."),
1344
+ "",
1345
+ "Summary:",
1346
+ `- Requested targets: ${result.plan.requestedCount}`,
1347
+ `- Unique targets purged: ${result.plan.plans.length}`,
1348
+ `- Duplicate inputs skipped: ${result.plan.duplicateInputs.length}`,
1349
+ `- SQLite row changes: ${sqliteChanges.reduce((sum, change) => sum + change.rows, 0)}`,
1350
+ `- JSON/JSONL file changes: ${jsonChanged}`,
1351
+ `- Files deleted: ${filesDeleted}`,
1352
+ "",
1353
+ "SQLite changes:",
1354
+ ...formatSqliteRows(sqliteChanges),
1355
+ "",
1356
+ `Verification: ${result.verification.success ? colorize("green", "passed") : colorize("red", "failed")}`
1357
+ ];
1358
+ if (result.verification.remainingReferences.length > 0) {
1359
+ lines.push(
1360
+ "",
1361
+ "Remaining references:",
1362
+ ...result.verification.remainingReferences.map(
1363
+ (reference) => `- ${reference.store}: ${reference.path} (${reference.detail})`
1364
+ )
1365
+ );
1366
+ process.exitCode = 1;
1367
+ }
1368
+ return lines.join("\n");
1369
+ }
1280
1370
  function formatPurgeOrphansPlan(plan) {
1281
1371
  if (!hasPurgeOrphansWork(plan)) {
1282
1372
  return "No orphaned local Codex data found.";
@@ -1330,6 +1420,22 @@ function formatPurgeConfirmation(plan) {
1330
1420
  ""
1331
1421
  ].join("\n");
1332
1422
  }
1423
+ function formatBatchPurgeConfirmation(plan) {
1424
+ return [
1425
+ "About to purge selected local Codex conversations:",
1426
+ "",
1427
+ `Requested targets: ${plan.requestedCount}`,
1428
+ `Unique targets: ${plan.plans.length}`,
1429
+ `Duplicate inputs skipped: ${plan.duplicateInputs.length}`,
1430
+ "",
1431
+ "SQLite row changes:",
1432
+ ...formatSqliteRows(summarizePlannedSqliteRows(plan.plans)),
1433
+ "",
1434
+ ...formatBatchPurgeExamples(plan),
1435
+ colorize("dim", "This cannot be undone."),
1436
+ ""
1437
+ ].join("\n");
1438
+ }
1333
1439
  function formatPurgeOrphansConfirmation(plan) {
1334
1440
  return [
1335
1441
  "About to purge orphaned local Codex data:",
@@ -1373,6 +1479,44 @@ function formatOrphanExamples(plan) {
1373
1479
  }
1374
1480
  return lines;
1375
1481
  }
1482
+ function formatBatchPurgeExamples(plan) {
1483
+ const examples = plan.plans.slice(0, 5).map((item) => {
1484
+ const target = item.target;
1485
+ return `- ${shortId(target.id)} ${displayTitle(target.title)} ${colorize("dim", target.cwd)}`;
1486
+ });
1487
+ if (examples.length === 0) {
1488
+ return [];
1489
+ }
1490
+ const lines = ["Targets:", ...examples, ""];
1491
+ if (plan.plans.length > examples.length) {
1492
+ lines.push(`Showing ${examples.length} of ${plan.plans.length} target(s).`, "");
1493
+ }
1494
+ return lines;
1495
+ }
1496
+ function summarizePlannedSqliteRows(plans) {
1497
+ const rows = /* @__PURE__ */ new Map();
1498
+ for (const plan of plans) {
1499
+ for (const store of plan.stores) {
1500
+ if (store.action !== "delete_rows" && store.store !== "state_db.agent_job_items") {
1501
+ continue;
1502
+ }
1503
+ if (!store.count || store.count <= 0) {
1504
+ continue;
1505
+ }
1506
+ rows.set(store.store, (rows.get(store.store) ?? 0) + store.count);
1507
+ }
1508
+ }
1509
+ return [...rows.entries()].map(([store, count]) => ({ store, rows: count })).sort((a, b) => a.store.localeCompare(b.store));
1510
+ }
1511
+ function aggregateBatchSqliteChanges(result) {
1512
+ const changes = /* @__PURE__ */ new Map();
1513
+ for (const report of result.purgeReports) {
1514
+ for (const change of report.sqlite) {
1515
+ changes.set(change.store, (changes.get(change.store) ?? 0) + change.changedRows);
1516
+ }
1517
+ }
1518
+ return [...changes.entries()].filter(([, rows]) => rows > 0).map(([store, rows]) => ({ store, rows })).sort((a, b) => a.store.localeCompare(b.store));
1519
+ }
1376
1520
  function aggregateOrphanSqliteChanges(result) {
1377
1521
  const changes = /* @__PURE__ */ new Map();
1378
1522
  for (const report of result.purgeReports) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liuyoumi/codex-history",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for listing and safely purging local Codex conversation history",
5
5
  "type": "module",
6
6
  "bin": {