codex-cleaner 0.0.1 → 0.0.3

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/dist/cleaner.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import Database from "better-sqlite3";
2
+ import { DatabaseSync } from "node:sqlite";
3
3
  import type { BlockingProcess, CleanerOptions, CompactWhere } from "./types.js";
4
4
  type CodexSpawnCommand = {
5
5
  args: string[];
@@ -30,22 +30,23 @@ export declare function compactWhere(args: {
30
30
  protectRecent: boolean;
31
31
  protectedIds: Set<string>;
32
32
  }): CompactWhere;
33
- export declare function collectCompactCandidateStats(db: Database.Database, args: {
33
+ export declare function collectCompactCandidateStats(db: DatabaseSync, args: {
34
34
  archivedOnly: boolean;
35
35
  cutoffMs: number;
36
36
  maxChars: number;
37
37
  protectRecent: boolean;
38
38
  protectedIds: Set<string>;
39
39
  }): Record<string, unknown>;
40
- export declare function collectStaleArchiveCandidateStats(db: Database.Database, codexHome: string, args: {
40
+ export declare function collectStaleArchiveCandidateStats(db: DatabaseSync, codexHome: string, args: {
41
41
  cutoffMs: number;
42
42
  protectedIds: Set<string>;
43
43
  statRollouts?: boolean;
44
44
  }): Record<string, unknown>;
45
- export declare function collectLogCleanupStats(db: Database.Database, options: CleanerOptions): Record<string, unknown>;
45
+ export declare function collectLogCleanupStats(db: DatabaseSync, options: CleanerOptions): Record<string, unknown>;
46
46
  export declare function collectTuiLogCleanupStats(logPath: string, keepMib: number): Record<string, unknown>;
47
- export declare function collectOrphanRolloutArchiveStats(db: Database.Database, codexHome: string, args: {
47
+ export declare function collectOrphanRolloutArchiveStats(db: DatabaseSync, codexHome: string, args: {
48
48
  cutoffMs: number;
49
+ protectedIds: Set<string>;
49
50
  }): Record<string, unknown>;
50
51
  export declare function nextBackupPath(dbPath: string, backupDir: string, now?: Date): string;
51
52
  export declare function nextFileBackupPath(filePath: string, backupDir: string, now?: Date): string;
package/dist/cleaner.js CHANGED
@@ -2,8 +2,8 @@ import { execFile, spawn, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { backup as sqliteBackup, DatabaseSync } from "node:sqlite";
5
6
  import { promisify } from "node:util";
6
- import Database from "better-sqlite3";
7
7
  const execFileAsync = promisify(execFile);
8
8
  const THREAD_COLUMNS_TO_CAP = ["title", "preview", "first_user_message"];
9
9
  const APP_SERVER_REQUEST_TIMEOUT_MS = 60_000;
@@ -373,7 +373,10 @@ export function buildScanReport(options) {
373
373
  });
374
374
  }
375
375
  if (options.archiveOrphanRollouts) {
376
- report.orphanRolloutArchiveCandidates = collectOrphanRolloutArchiveStats(db, codexHome, { cutoffMs });
376
+ report.orphanRolloutArchiveCandidates = collectOrphanRolloutArchiveStats(db, codexHome, {
377
+ cutoffMs,
378
+ protectedIds,
379
+ });
377
380
  }
378
381
  report.recentSample = queryAll(db, `
379
382
  SELECT id, archived, updated_at, updated_at_ms, source, agent_role,
@@ -438,7 +441,7 @@ export async function compactMetadata(options) {
438
441
  throw new Error("--apply requires --confirm-lossy-metadata");
439
442
  }
440
443
  if (options.apply && Number(before.rows) > 0) {
441
- backupPath = await backupSqliteDatabase(stateDb, resolveBackupDir(options, codexHome));
444
+ backupPath = await backupOpenSqliteDatabase(db, stateDb, resolveBackupDir(options, codexHome));
442
445
  const where = compactWhere({
443
446
  archivedOnly: options.archivedOnly,
444
447
  cutoffMs,
@@ -447,9 +450,8 @@ export async function compactMetadata(options) {
447
450
  protectedIds,
448
451
  });
449
452
  const assignments = THREAD_COLUMNS_TO_CAP.map((column) => `${column} = CASE WHEN length(${column}) > @maxChars THEN substr(${column}, 1, @maxChars) ELSE ${column} END`).join(", ");
450
- const update = db.prepare(`UPDATE threads SET ${assignments} WHERE ${where.sql}`);
451
- const tx = db.transaction(() => update.run(where.params));
452
- changedRows = Number(tx().changes);
453
+ const update = prepareStatement(db, `UPDATE threads SET ${assignments} WHERE ${where.sql}`);
454
+ changedRows = Number(runTransaction(db, () => update.run(where.params).changes));
453
455
  }
454
456
  const after = collectCompactCandidateStats(db, {
455
457
  archivedOnly: options.archivedOnly,
@@ -592,10 +594,11 @@ export function archiveOrphanRollouts(options) {
592
594
  const codexHome = resolveCodexHome(options);
593
595
  const stateDb = path.join(codexHome, "state_5.sqlite");
594
596
  const cutoffMs = recentCutoffMs(options.keepRecentDays);
597
+ const protectedIds = allProtectedIds(loadThreadProtection(codexHome, loadGlobalState(codexHome)));
595
598
  const beforeDb = openReadonlyDb(stateDb);
596
599
  let beforePlan;
597
600
  try {
598
- beforePlan = buildOrphanRolloutPlan(beforeDb, codexHome, { cutoffMs });
601
+ beforePlan = buildOrphanRolloutPlan(beforeDb, codexHome, { cutoffMs, protectedIds });
599
602
  }
600
603
  finally {
601
604
  beforeDb.close();
@@ -614,6 +617,7 @@ export function archiveOrphanRollouts(options) {
614
617
  codexHome,
615
618
  policy: {
616
619
  keepRecentDays: options.keepRecentDays,
620
+ protectedThreads: protectedIds.size,
617
621
  recentCutoffMs: cutoffMs,
618
622
  recentCutoffUtc: millisToIso(cutoffMs),
619
623
  },
@@ -645,6 +649,7 @@ export function archiveOrphanRollouts(options) {
645
649
  codexHome,
646
650
  policy: {
647
651
  keepRecentDays: options.keepRecentDays,
652
+ protectedThreads: protectedIds.size,
648
653
  recentCutoffMs: cutoffMs,
649
654
  recentCutoffUtc: millisToIso(cutoffMs),
650
655
  },
@@ -657,7 +662,7 @@ export function archiveOrphanRollouts(options) {
657
662
  const afterDb = openReadonlyDb(stateDb);
658
663
  let afterPlan;
659
664
  try {
660
- afterPlan = buildOrphanRolloutPlan(afterDb, codexHome, { cutoffMs });
665
+ afterPlan = buildOrphanRolloutPlan(afterDb, codexHome, { cutoffMs, protectedIds });
661
666
  }
662
667
  finally {
663
668
  afterDb.close();
@@ -669,6 +674,7 @@ export function archiveOrphanRollouts(options) {
669
674
  generatedAt: new Date().toISOString(),
670
675
  policy: {
671
676
  keepRecentDays: options.keepRecentDays,
677
+ protectedThreads: protectedIds.size,
672
678
  recentCutoffMs: cutoffMs,
673
679
  recentCutoffUtc: millisToIso(cutoffMs),
674
680
  },
@@ -749,23 +755,20 @@ export async function cleanLogs(options) {
749
755
  const hasRowChanges = Number(before.cap_rows) > 0 || Number(before.delete_rows) > 0;
750
756
  const shouldVacuum = Number(beforeSpace.freelist_count) > 0 || hasRowChanges;
751
757
  if (options.apply && shouldVacuum) {
752
- backupPath = await backupSqliteDatabase(logsDb, resolveBackupDir(options, codexHome));
758
+ backupPath = await backupOpenSqliteDatabase(db, logsDb, resolveBackupDir(options, codexHome));
753
759
  const cutoffSeconds = logCutoffSeconds(options.keepLogDays);
754
760
  if (hasRowChanges) {
755
- const tx = db.transaction(() => {
756
- deletedRows = Number(db.prepare("DELETE FROM logs WHERE ts < @cutoffSeconds").run({ cutoffSeconds }).changes);
757
- cappedRows = Number(db
758
- .prepare(`
761
+ runTransaction(db, () => {
762
+ deletedRows = Number(prepareStatement(db, "DELETE FROM logs WHERE ts < @cutoffSeconds").run({ cutoffSeconds }).changes);
763
+ cappedRows = Number(prepareStatement(db, `
759
764
  UPDATE logs
760
765
  SET estimated_bytes = max(0, estimated_bytes - (length(feedback_log_body) - @maxLogBodyChars)),
761
766
  feedback_log_body = substr(feedback_log_body, 1, @maxLogBodyChars)
762
767
  WHERE ts >= @cutoffSeconds
763
768
  AND feedback_log_body IS NOT NULL
764
769
  AND length(feedback_log_body) > @maxLogBodyChars
765
- `)
766
- .run({ cutoffSeconds, maxLogBodyChars: options.maxLogBodyChars }).changes);
770
+ `).run({ cutoffSeconds, maxLogBodyChars: options.maxLogBodyChars }).changes);
767
771
  });
768
- tx();
769
772
  }
770
773
  db.exec("VACUUM");
771
774
  queryAll(db, "PRAGMA wal_checkpoint(TRUNCATE)");
@@ -912,9 +915,7 @@ export async function scheduleBackupPrune(options) {
912
915
  };
913
916
  }
914
917
  export function compactWhere(args) {
915
- const clauses = [
916
- `(${THREAD_COLUMNS_TO_CAP.map((column) => `length(${column}) > @maxChars`).join(" OR ")})`,
917
- ];
918
+ const clauses = [`(${THREAD_COLUMNS_TO_CAP.map((column) => `length(${column}) > @maxChars`).join(" OR ")})`];
918
919
  const params = {
919
920
  cutoffMs: args.cutoffMs,
920
921
  maxChars: args.maxChars,
@@ -1185,6 +1186,10 @@ function buildOrphanRolloutPlan(db, codexHome, args) {
1185
1186
  skipped.push({ ...move, reason: "recent" });
1186
1187
  continue;
1187
1188
  }
1189
+ if (threadId && args.protectedIds.has(threadId)) {
1190
+ skipped.push({ ...move, reason: "protected" });
1191
+ continue;
1192
+ }
1188
1193
  if (move.indexed) {
1189
1194
  skipped.push({ ...move, reason: "session-indexed" });
1190
1195
  continue;
@@ -1216,6 +1221,7 @@ function buildOrphanRolloutPlan(db, codexHome, args) {
1216
1221
  unindexed_size_mib: fileMoveListSizeMib(unindexed),
1217
1222
  skipped_files: skipped.length,
1218
1223
  skipped_recent_files: skipped.filter((move) => move.reason === "recent").length,
1224
+ skipped_protected_files: skipped.filter((move) => move.reason === "protected").length,
1219
1225
  skipped_session_indexed_files: skipped.filter((move) => move.reason === "session-indexed").length,
1220
1226
  skipped_destination_exists_files: skipped.filter((move) => move.reason === "destination-exists").length,
1221
1227
  oldest_candidate_modified_utc: millisToIso(minNumberOrNull(modifiedValues)),
@@ -1368,15 +1374,18 @@ function backupFileStats(files) {
1368
1374
  };
1369
1375
  }
1370
1376
  async function backupSqliteDatabase(dbPath, backupDir) {
1371
- fs.mkdirSync(backupDir, { recursive: true });
1372
- const backupPath = nextBackupPath(dbPath, backupDir);
1373
1377
  const db = openWritableDb(dbPath);
1374
1378
  try {
1375
- await db.backup(backupPath);
1379
+ return await backupOpenSqliteDatabase(db, dbPath, backupDir);
1376
1380
  }
1377
1381
  finally {
1378
1382
  db.close();
1379
1383
  }
1384
+ }
1385
+ async function backupOpenSqliteDatabase(db, dbPath, backupDir) {
1386
+ fs.mkdirSync(backupDir, { recursive: true });
1387
+ const backupPath = nextBackupPath(dbPath, backupDir);
1388
+ await sqliteBackup(db, backupPath);
1380
1389
  return backupPath;
1381
1390
  }
1382
1391
  function backupRegularFile(filePath, backupDir) {
@@ -1393,7 +1402,7 @@ async function vacuumSqliteDatabase(args) {
1393
1402
  const beforeSpace = collectSqliteSpaceStats(db);
1394
1403
  if (args.apply && Number(beforeSpace.freelist_count) > 0) {
1395
1404
  if (args.backupBeforeVacuum) {
1396
- backupPath = await backupSqliteDatabase(args.dbPath, args.backupDir);
1405
+ backupPath = await backupOpenSqliteDatabase(db, args.dbPath, args.backupDir);
1397
1406
  }
1398
1407
  db.exec("VACUUM");
1399
1408
  queryAll(db, "PRAGMA wal_checkpoint(TRUNCATE)");
@@ -1424,10 +1433,7 @@ function nextOrphanRolloutManifestPath(backupDir, now = new Date()) {
1424
1433
  return nextTimestampedPath(backupDir, "orphan-rollouts", ".manifest.bak", now);
1425
1434
  }
1426
1435
  function nextTimestampedPath(backupDir, name, suffix, now) {
1427
- const stamp = now
1428
- .toISOString()
1429
- .replace(/[-:]/g, "")
1430
- .replace(".", "_");
1436
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(".", "_");
1431
1437
  const base = `${name}.${stamp}`;
1432
1438
  let candidate = path.join(backupDir, `${base}${suffix}`);
1433
1439
  let collision = 2;
@@ -1604,21 +1610,38 @@ function timestampForName(date) {
1604
1610
  function openReadonlyDb(file) {
1605
1611
  if (!fs.existsSync(file))
1606
1612
  throw new Error(`SQLite database not found: ${file}`);
1607
- return new Database(file, { fileMustExist: true, readonly: true });
1613
+ return new DatabaseSync(file, { readOnly: true });
1608
1614
  }
1609
1615
  function openWritableDb(file) {
1610
1616
  if (!fs.existsSync(file))
1611
1617
  throw new Error(`SQLite database not found: ${file}`);
1612
- const db = new Database(file, { fileMustExist: true });
1613
- db.pragma("busy_timeout = 5000");
1614
- db.pragma("foreign_keys = ON");
1618
+ const db = new DatabaseSync(file);
1619
+ db.exec("PRAGMA busy_timeout = 5000");
1620
+ db.exec("PRAGMA foreign_keys = ON");
1615
1621
  return db;
1616
1622
  }
1617
1623
  function queryAll(db, sql, params) {
1618
- return db.prepare(sql).all(params ?? {});
1624
+ return prepareStatement(db, sql).all(params ?? {});
1619
1625
  }
1620
1626
  function queryOne(db, sql, params) {
1621
- return db.prepare(sql).get(params ?? {}) ?? {};
1627
+ return prepareStatement(db, sql).get(params ?? {}) ?? {};
1628
+ }
1629
+ function prepareStatement(db, sql) {
1630
+ const statement = db.prepare(sql);
1631
+ statement.setAllowUnknownNamedParameters(true);
1632
+ return statement;
1633
+ }
1634
+ function runTransaction(db, fn) {
1635
+ db.exec("BEGIN IMMEDIATE");
1636
+ try {
1637
+ const result = fn();
1638
+ db.exec("COMMIT");
1639
+ return result;
1640
+ }
1641
+ catch (error) {
1642
+ db.exec("ROLLBACK");
1643
+ throw error;
1644
+ }
1622
1645
  }
1623
1646
  function tryQueryAll(db, sql) {
1624
1647
  try {
@@ -2227,6 +2250,7 @@ function printOrphanRolloutArchiveCandidate(title, value) {
2227
2250
  "unindexed_files",
2228
2251
  "empty_dir_candidates",
2229
2252
  "skipped_recent_files",
2253
+ "skipped_protected_files",
2230
2254
  "skipped_session_indexed_files",
2231
2255
  "skipped_destination_exists_files",
2232
2256
  "oldest_candidate_modified_utc",
package/dist/cli.js CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
2
3
  import { parseArgs } from "node:util";
3
- import { buildScanReport, archiveOrphanRollouts, checkpointWal, cleanCodex, compactMetadata, emitReport, pruneBackups, requireStoppedOrReadonlyAllowed, scanBackups, scheduleBackupPrune, } from "./cleaner.js";
4
- import { runWizard } from "./wizard.js";
5
4
  const USAGE = `
6
5
  codex-cleaner [command] [options]
7
6
 
@@ -131,7 +130,12 @@ async function main(argv = process.argv.slice(2)) {
131
130
  pruneLogs: Boolean(parsed.values["prune-logs"]),
132
131
  pruneTuiLog: Boolean(parsed.values["prune-tui-log"]),
133
132
  };
133
+ const reexecCode = reexecWithSqliteWarningDisabled(argv);
134
+ if (reexecCode != null)
135
+ return reexecCode;
136
+ const { archiveOrphanRollouts, buildScanReport, checkpointWal, cleanCodex, compactMetadata, emitReport, pruneBackups, requireStoppedOrReadonlyAllowed, scanBackups, scheduleBackupPrune, } = await import("./cleaner.js");
134
137
  if (!command) {
138
+ const { runWizard } = await import("./wizard.js");
135
139
  return runWizard(options);
136
140
  }
137
141
  if (command !== "backups") {
@@ -178,6 +182,24 @@ function isMutating(command, options) {
178
182
  function allowsRunningFileOnlyMutation(command, options) {
179
183
  return command === "archive-orphan-rollouts" && options.apply && options.allowRunningOrphanRolloutArchive;
180
184
  }
185
+ function reexecWithSqliteWarningDisabled(argv) {
186
+ const alreadyDisabled = process.env.NODE_NO_WARNINGS === "1" ||
187
+ process.execArgv.includes("--no-warnings") ||
188
+ process.execArgv.includes("--disable-warning=ExperimentalWarning");
189
+ if (alreadyDisabled)
190
+ return null;
191
+ if (!process.allowedNodeEnvironmentFlags.has("--disable-warning=ExperimentalWarning"))
192
+ return null;
193
+ if (!process.argv[1])
194
+ return null;
195
+ const result = spawnSync(process.execPath, [...process.execArgv, "--disable-warning=ExperimentalWarning", process.argv[1], ...argv], {
196
+ stdio: "inherit",
197
+ windowsHide: true,
198
+ });
199
+ if (result.error)
200
+ throw result.error;
201
+ return result.status ?? 1;
202
+ }
181
203
  function parsePositiveInt(raw, name) {
182
204
  const value = Number(raw);
183
205
  if (!Number.isInteger(value) || value <= 0) {
package/dist/wizard.js CHANGED
@@ -252,6 +252,9 @@ function printDryRunSummary(report) {
252
252
  if (Number(orphanRollouts.skipped_recent_files) > 0) {
253
253
  console.log(pc.yellow(` Skipped ${String(orphanRollouts.skipped_recent_files)} orphan rollouts inside the recent window.`));
254
254
  }
255
+ if (Number(orphanRollouts.skipped_protected_files) > 0) {
256
+ console.log(pc.yellow(` Skipped ${String(orphanRollouts.skipped_protected_files)} protected orphan rollouts.`));
257
+ }
255
258
  if (Number(orphanRollouts.skipped_session_indexed_files) > 0) {
256
259
  console.log(pc.yellow(` Skipped ${String(orphanRollouts.skipped_session_indexed_files)} DB-orphaned rollouts still present in session_index.jsonl.`));
257
260
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "codex-cleaner",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Guarded cleanup and compaction utility for local Codex state.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "packageManager": "npm@11.11.0",
7
+ "packageManager": "npm@11.15.0",
8
8
  "bin": {
9
9
  "codex-cleaner": "dist/cli.js"
10
10
  },
@@ -13,7 +13,7 @@
13
13
  "README.md"
14
14
  ],
15
15
  "engines": {
16
- "node": ">=22.12"
16
+ "node": ">=22.16"
17
17
  },
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
@@ -21,16 +21,14 @@
21
21
  "format": "prettier --check .",
22
22
  "lint": "eslint .",
23
23
  "prepack": "npm run build",
24
- "test": "vitest run"
24
+ "test": "node scripts/run-vitest.js"
25
25
  },
26
26
  "dependencies": {
27
27
  "@inquirer/prompts": "^8.4.3",
28
- "better-sqlite3": "^12.10.0",
29
28
  "picocolors": "^1.1.1"
30
29
  },
31
30
  "devDependencies": {
32
31
  "@eslint/js": "^10.0.1",
33
- "@types/better-sqlite3": "^7.6.13",
34
32
  "@types/node": "^25.9.1",
35
33
  "eslint": "^10.4.0",
36
34
  "globals": "^17.6.0",