@liuyoumi/codex-history 0.1.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 +27 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/README.zh-CN.md +173 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1244 -0
- package/package.json +56 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { createInterface } from "readline/promises";
|
|
7
|
+
|
|
8
|
+
// src/core/schema.ts
|
|
9
|
+
import { mkdirSync, readFileSync } from "fs";
|
|
10
|
+
import { dirname } from "path";
|
|
11
|
+
|
|
12
|
+
// src/core/paths.ts
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import path from "path";
|
|
16
|
+
function expandHome(input) {
|
|
17
|
+
if (input === "~") {
|
|
18
|
+
return homedir();
|
|
19
|
+
}
|
|
20
|
+
if (input.startsWith("~/")) {
|
|
21
|
+
return path.join(homedir(), input.slice(2));
|
|
22
|
+
}
|
|
23
|
+
return input;
|
|
24
|
+
}
|
|
25
|
+
function resolvePaths(codexHomeInput, toolHomeInput) {
|
|
26
|
+
const codexHome = path.resolve(expandHome(codexHomeInput ?? "~/.codex"));
|
|
27
|
+
const toolHome = path.resolve(expandHome(toolHomeInput ?? process.env.CODEX_HISTORY_HOME ?? "~/.codex-history"));
|
|
28
|
+
return {
|
|
29
|
+
codexHome,
|
|
30
|
+
toolHome,
|
|
31
|
+
backupHome: path.join(toolHome, "backups"),
|
|
32
|
+
stateDb: path.join(codexHome, "state_5.sqlite"),
|
|
33
|
+
logsDb: path.join(codexHome, "logs_2.sqlite"),
|
|
34
|
+
goalsDb: path.join(codexHome, "goals_1.sqlite"),
|
|
35
|
+
sessionIndex: path.join(codexHome, "session_index.jsonl"),
|
|
36
|
+
globalState: path.join(codexHome, ".codex-global-state.json"),
|
|
37
|
+
globalStateBackup: path.join(codexHome, ".codex-global-state.json.bak"),
|
|
38
|
+
shellSnapshotsDir: path.join(codexHome, "shell_snapshots")
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function fileStatus(filePath) {
|
|
42
|
+
return existsSync(filePath) ? "present" : "missing";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/stores/sqlite.ts
|
|
46
|
+
import Database from "better-sqlite3";
|
|
47
|
+
import { existsSync as existsSync2 } from "fs";
|
|
48
|
+
function openReadonlyDatabase(filePath) {
|
|
49
|
+
return new Database(filePath, {
|
|
50
|
+
readonly: true,
|
|
51
|
+
fileMustExist: true
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function openWritableDatabase(filePath) {
|
|
55
|
+
return new Database(filePath, {
|
|
56
|
+
fileMustExist: true
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function sqliteFileExists(filePath) {
|
|
60
|
+
return existsSync2(filePath);
|
|
61
|
+
}
|
|
62
|
+
function tableExists(db, tableName) {
|
|
63
|
+
const row = db.prepare("select 1 as ok from sqlite_master where type = 'table' and name = ?").get(tableName);
|
|
64
|
+
return row?.ok === 1;
|
|
65
|
+
}
|
|
66
|
+
function tableColumns(db, tableName) {
|
|
67
|
+
const rows = db.prepare(`pragma table_info(${quoteIdentifier(tableName)})`).all();
|
|
68
|
+
return rows.map((row) => row.name);
|
|
69
|
+
}
|
|
70
|
+
function countWhereThreadId(db, tableName, columnName, threadId) {
|
|
71
|
+
if (!tableExists(db, tableName)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const columns = tableColumns(db, tableName);
|
|
75
|
+
if (!columns.includes(columnName)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const row = db.prepare(
|
|
79
|
+
`select count(*) as count from ${quoteIdentifier(tableName)} where ${quoteIdentifier(
|
|
80
|
+
columnName
|
|
81
|
+
)} = ?`
|
|
82
|
+
).get(threadId);
|
|
83
|
+
return row.count;
|
|
84
|
+
}
|
|
85
|
+
function quoteIdentifier(value) {
|
|
86
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
87
|
+
}
|
|
88
|
+
function checkpointWal(filePath) {
|
|
89
|
+
if (!sqliteFileExists(filePath)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const db = openWritableDatabase(filePath);
|
|
93
|
+
try {
|
|
94
|
+
db.pragma("wal_checkpoint(TRUNCATE)");
|
|
95
|
+
} finally {
|
|
96
|
+
db.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/core/schema.ts
|
|
101
|
+
var REQUIRED_THREAD_COLUMNS = [
|
|
102
|
+
"id",
|
|
103
|
+
"title",
|
|
104
|
+
"rollout_path",
|
|
105
|
+
"created_at",
|
|
106
|
+
"updated_at",
|
|
107
|
+
"cwd",
|
|
108
|
+
"archived",
|
|
109
|
+
"first_user_message",
|
|
110
|
+
"preview"
|
|
111
|
+
];
|
|
112
|
+
function runDoctor(paths) {
|
|
113
|
+
const checks = [];
|
|
114
|
+
checks.push({
|
|
115
|
+
name: "codex_home",
|
|
116
|
+
status: fileStatus(paths.codexHome) === "present" ? "ok" : "error",
|
|
117
|
+
detail: paths.codexHome
|
|
118
|
+
});
|
|
119
|
+
checks.push(checkStateDatabase(paths.stateDb));
|
|
120
|
+
checks.push(checkOptionalSqlite(paths.logsDb, "logs_db"));
|
|
121
|
+
checks.push(checkOptionalSqlite(paths.goalsDb, "goals_db"));
|
|
122
|
+
checks.push(checkJsonFile(paths.globalState, "global_state"));
|
|
123
|
+
checks.push(checkJsonFile(paths.globalStateBackup, "global_state_backup"));
|
|
124
|
+
checks.push(checkJsonlFile(paths.sessionIndex, "session_index"));
|
|
125
|
+
checks.push(checkBackupHome(paths.backupHome));
|
|
126
|
+
return {
|
|
127
|
+
supported: !checks.some((check) => check.status === "error"),
|
|
128
|
+
codexHome: paths.codexHome,
|
|
129
|
+
checks
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function validateSupportedDataModel(paths, options = {}) {
|
|
133
|
+
const report = runDoctor(paths);
|
|
134
|
+
const errors = report.checks.filter(
|
|
135
|
+
(check) => check.status === "error" && (options.requireBackupHome || check.name !== "backup_home")
|
|
136
|
+
);
|
|
137
|
+
if (errors.length > 0) {
|
|
138
|
+
const details = errors.map((check) => `${check.name}: ${check.detail}`).join("; ");
|
|
139
|
+
throw new Error(`Unsupported Codex data model: ${details}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function checkStateDatabase(filePath) {
|
|
143
|
+
if (!sqliteFileExists(filePath)) {
|
|
144
|
+
return {
|
|
145
|
+
name: "state_db",
|
|
146
|
+
status: "error",
|
|
147
|
+
detail: `${filePath} is missing`
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const db = openReadonlyDatabase(filePath);
|
|
152
|
+
try {
|
|
153
|
+
if (!tableExists(db, "threads")) {
|
|
154
|
+
return {
|
|
155
|
+
name: "state_db",
|
|
156
|
+
status: "error",
|
|
157
|
+
detail: "missing threads table"
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const columns = tableColumns(db, "threads");
|
|
161
|
+
const missingColumns = REQUIRED_THREAD_COLUMNS.filter((column) => !columns.includes(column));
|
|
162
|
+
if (missingColumns.length > 0) {
|
|
163
|
+
return {
|
|
164
|
+
name: "state_db",
|
|
165
|
+
status: "error",
|
|
166
|
+
detail: `missing threads columns: ${missingColumns.join(", ")}`
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
name: "state_db",
|
|
171
|
+
status: "ok",
|
|
172
|
+
detail: filePath
|
|
173
|
+
};
|
|
174
|
+
} finally {
|
|
175
|
+
db.close();
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return {
|
|
179
|
+
name: "state_db",
|
|
180
|
+
status: "error",
|
|
181
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function checkOptionalSqlite(filePath, name) {
|
|
186
|
+
if (!sqliteFileExists(filePath)) {
|
|
187
|
+
return {
|
|
188
|
+
name,
|
|
189
|
+
status: "warning",
|
|
190
|
+
detail: `${filePath} is missing`
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const db = openReadonlyDatabase(filePath);
|
|
195
|
+
db.close();
|
|
196
|
+
return {
|
|
197
|
+
name,
|
|
198
|
+
status: "ok",
|
|
199
|
+
detail: filePath
|
|
200
|
+
};
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return {
|
|
203
|
+
name,
|
|
204
|
+
status: "error",
|
|
205
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function checkJsonFile(filePath, name) {
|
|
210
|
+
if (fileStatus(filePath) === "missing") {
|
|
211
|
+
return {
|
|
212
|
+
name,
|
|
213
|
+
status: "warning",
|
|
214
|
+
detail: `${filePath} is missing`
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
JSON.parse(readFileSync(filePath, "utf8"));
|
|
219
|
+
return {
|
|
220
|
+
name,
|
|
221
|
+
status: "ok",
|
|
222
|
+
detail: filePath
|
|
223
|
+
};
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return {
|
|
226
|
+
name,
|
|
227
|
+
status: "error",
|
|
228
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function checkJsonlFile(filePath, name) {
|
|
233
|
+
if (fileStatus(filePath) === "missing") {
|
|
234
|
+
return {
|
|
235
|
+
name,
|
|
236
|
+
status: "warning",
|
|
237
|
+
detail: `${filePath} is missing`
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const lines = readFileSync(filePath, "utf8").split("\n").filter((line) => line.trim().length > 0);
|
|
242
|
+
for (const line of lines) {
|
|
243
|
+
JSON.parse(line);
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
name,
|
|
247
|
+
status: "ok",
|
|
248
|
+
detail: `${filePath} (${lines.length} entries)`
|
|
249
|
+
};
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return {
|
|
252
|
+
name,
|
|
253
|
+
status: "error",
|
|
254
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function checkBackupHome(backupHome) {
|
|
259
|
+
try {
|
|
260
|
+
mkdirSync(dirname(backupHome), { recursive: true });
|
|
261
|
+
mkdirSync(backupHome, { recursive: true });
|
|
262
|
+
return {
|
|
263
|
+
name: "backup_home",
|
|
264
|
+
status: "ok",
|
|
265
|
+
detail: backupHome
|
|
266
|
+
};
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return {
|
|
269
|
+
name: "backup_home",
|
|
270
|
+
status: "error",
|
|
271
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/commands/doctor.ts
|
|
277
|
+
function doctorCommand(paths) {
|
|
278
|
+
return runDoctor(paths);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/stores/session-index.ts
|
|
282
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
283
|
+
function readSessionIndex(filePath) {
|
|
284
|
+
const entries = /* @__PURE__ */ new Map();
|
|
285
|
+
if (!existsSync3(filePath)) {
|
|
286
|
+
return entries;
|
|
287
|
+
}
|
|
288
|
+
const lines = readFileSync2(filePath, "utf8").split("\n").filter((line) => line.trim().length > 0);
|
|
289
|
+
for (const line of lines) {
|
|
290
|
+
const raw = JSON.parse(line);
|
|
291
|
+
if (typeof raw.id !== "string" || typeof raw.thread_name !== "string") {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
entries.set(raw.id, {
|
|
295
|
+
id: raw.id,
|
|
296
|
+
threadName: raw.thread_name,
|
|
297
|
+
updatedAt: typeof raw.updated_at === "string" ? raw.updated_at : void 0
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return entries;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/core/threads.ts
|
|
304
|
+
function listThreads(paths, options = {}) {
|
|
305
|
+
const limit = options.limit;
|
|
306
|
+
const where = [];
|
|
307
|
+
const params = [];
|
|
308
|
+
if (!options.all) {
|
|
309
|
+
where.push(`${quoteIdentifier("archived")} = ?`);
|
|
310
|
+
params.push(options.archived ? 1 : 0);
|
|
311
|
+
} else if (options.archived) {
|
|
312
|
+
where.push(`${quoteIdentifier("archived")} = ?`);
|
|
313
|
+
params.push(1);
|
|
314
|
+
}
|
|
315
|
+
if (options.cwd) {
|
|
316
|
+
where.push(`${quoteIdentifier("cwd")} = ?`);
|
|
317
|
+
params.push(options.cwd);
|
|
318
|
+
}
|
|
319
|
+
const whereClause = where.length > 0 ? `where ${where.join(" and ")}` : "";
|
|
320
|
+
const hasLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0;
|
|
321
|
+
const shouldFilterAfterLoad = Boolean(options.grep);
|
|
322
|
+
const limitClause = hasLimit && !shouldFilterAfterLoad ? "limit ?" : "";
|
|
323
|
+
if (limitClause) {
|
|
324
|
+
params.push(limit);
|
|
325
|
+
}
|
|
326
|
+
const sessionIndex = readSessionIndex(paths.sessionIndex);
|
|
327
|
+
const db = openReadonlyDatabase(paths.stateDb);
|
|
328
|
+
try {
|
|
329
|
+
const rows = db.prepare(
|
|
330
|
+
`select id, title, rollout_path, created_at, updated_at, created_at_ms, updated_at_ms, cwd, archived, first_user_message, preview
|
|
331
|
+
from threads
|
|
332
|
+
${whereClause}
|
|
333
|
+
order by coalesce(updated_at_ms, updated_at * 1000) desc, id desc
|
|
334
|
+
${limitClause}`
|
|
335
|
+
).all(...params);
|
|
336
|
+
const threads = rows.map((row) => mapThreadRow(row, sessionIndex));
|
|
337
|
+
const filteredThreads = options.grep ? filterThreads(threads, options.grep) : threads;
|
|
338
|
+
return hasLimit && shouldFilterAfterLoad ? filteredThreads.slice(0, limit) : filteredThreads;
|
|
339
|
+
} finally {
|
|
340
|
+
db.close();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function filterThreads(threads, keyword) {
|
|
344
|
+
const normalizedKeyword = keyword.toLocaleLowerCase();
|
|
345
|
+
return threads.filter((thread) => {
|
|
346
|
+
const haystack = [
|
|
347
|
+
thread.id,
|
|
348
|
+
thread.title,
|
|
349
|
+
thread.cwd
|
|
350
|
+
].join("\n").toLocaleLowerCase();
|
|
351
|
+
return haystack.includes(normalizedKeyword);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
function getThreadsByIdPrefix(paths, idPrefix) {
|
|
355
|
+
const sessionIndex = readSessionIndex(paths.sessionIndex);
|
|
356
|
+
const db = openReadonlyDatabase(paths.stateDb);
|
|
357
|
+
try {
|
|
358
|
+
const rows = db.prepare(
|
|
359
|
+
`select id, title, rollout_path, created_at, updated_at, created_at_ms, updated_at_ms, cwd, archived, first_user_message, preview
|
|
360
|
+
from threads
|
|
361
|
+
where id like ?
|
|
362
|
+
order by coalesce(updated_at_ms, updated_at * 1000) desc, id desc`
|
|
363
|
+
).all(`${escapeLike(idPrefix)}%`);
|
|
364
|
+
return rows.map((row) => mapThreadRow(row, sessionIndex));
|
|
365
|
+
} finally {
|
|
366
|
+
db.close();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function mapThreadRow(row, sessionIndex) {
|
|
370
|
+
const sourceTitle = row.title;
|
|
371
|
+
const displayTitle2 = sessionIndex.get(row.id)?.threadName || sourceTitle;
|
|
372
|
+
return {
|
|
373
|
+
id: row.id,
|
|
374
|
+
title: displayTitle2,
|
|
375
|
+
sourceTitle,
|
|
376
|
+
rolloutPath: row.rollout_path,
|
|
377
|
+
createdAtMs: row.created_at_ms ?? row.created_at * 1e3,
|
|
378
|
+
updatedAtMs: row.updated_at_ms ?? row.updated_at * 1e3,
|
|
379
|
+
cwd: row.cwd,
|
|
380
|
+
archived: row.archived === 1,
|
|
381
|
+
firstUserMessage: row.first_user_message,
|
|
382
|
+
preview: row.preview
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function escapeLike(value) {
|
|
386
|
+
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/commands/list.ts
|
|
390
|
+
function listCommand(paths, options = {}) {
|
|
391
|
+
validateSupportedDataModel(paths);
|
|
392
|
+
return listThreads(paths, options);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/core/executor.ts
|
|
396
|
+
import { existsSync as existsSync9 } from "fs";
|
|
397
|
+
|
|
398
|
+
// src/safety/active-thread.ts
|
|
399
|
+
import { existsSync as existsSync4, statSync } from "fs";
|
|
400
|
+
|
|
401
|
+
// src/core/errors.ts
|
|
402
|
+
var CodexHistoryError = class extends Error {
|
|
403
|
+
constructor(message, exitCode = 1) {
|
|
404
|
+
super(message);
|
|
405
|
+
this.exitCode = exitCode;
|
|
406
|
+
this.name = "CodexHistoryError";
|
|
407
|
+
}
|
|
408
|
+
exitCode;
|
|
409
|
+
};
|
|
410
|
+
var UsageError = class extends CodexHistoryError {
|
|
411
|
+
constructor(message) {
|
|
412
|
+
super(message, 2);
|
|
413
|
+
this.name = "UsageError";
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
var SafetyRefusalError = class extends CodexHistoryError {
|
|
417
|
+
constructor(message) {
|
|
418
|
+
super(message, 3);
|
|
419
|
+
this.name = "SafetyRefusalError";
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// src/safety/active-thread.ts
|
|
424
|
+
function assertThreadIsNotActive(target) {
|
|
425
|
+
const checks = [];
|
|
426
|
+
const currentThreadId = process.env.CODEX_THREAD_ID;
|
|
427
|
+
if (currentThreadId && currentThreadId === target.id) {
|
|
428
|
+
throw new SafetyRefusalError(`Refusing to purge active Codex thread: ${target.id}`);
|
|
429
|
+
}
|
|
430
|
+
checks.push({
|
|
431
|
+
status: "ok",
|
|
432
|
+
detail: "CODEX_THREAD_ID does not match target thread"
|
|
433
|
+
});
|
|
434
|
+
if (existsSync4(target.rolloutPath)) {
|
|
435
|
+
const before = statSync(target.rolloutPath).mtimeMs;
|
|
436
|
+
const after = statSync(target.rolloutPath).mtimeMs;
|
|
437
|
+
if (before !== after) {
|
|
438
|
+
throw new SafetyRefusalError(`Refusing to purge a changing rollout file: ${target.rolloutPath}`);
|
|
439
|
+
}
|
|
440
|
+
checks.push({
|
|
441
|
+
status: "ok",
|
|
442
|
+
detail: "rollout file does not appear to be changing"
|
|
443
|
+
});
|
|
444
|
+
} else {
|
|
445
|
+
checks.push({
|
|
446
|
+
status: "warning",
|
|
447
|
+
detail: `rollout file is missing: ${target.rolloutPath}`
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return checks;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/safety/backup.ts
|
|
454
|
+
import { copyFileSync, existsSync as existsSync5, mkdirSync as mkdirSync2, statSync as statSync2, writeFileSync } from "fs";
|
|
455
|
+
import path2 from "path";
|
|
456
|
+
function createBackup(paths, plan) {
|
|
457
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "-");
|
|
458
|
+
const backupDir = path2.join(paths.backupHome, `${timestamp}-${plan.target.id}`);
|
|
459
|
+
mkdirSync2(backupDir, { recursive: true });
|
|
460
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
461
|
+
candidates.add(paths.stateDb);
|
|
462
|
+
addIfPresent(candidates, `${paths.stateDb}-wal`);
|
|
463
|
+
addIfPresent(candidates, `${paths.stateDb}-shm`);
|
|
464
|
+
addIfPresent(candidates, paths.logsDb);
|
|
465
|
+
addIfPresent(candidates, `${paths.logsDb}-wal`);
|
|
466
|
+
addIfPresent(candidates, `${paths.logsDb}-shm`);
|
|
467
|
+
addIfPresent(candidates, paths.goalsDb);
|
|
468
|
+
addIfPresent(candidates, `${paths.goalsDb}-wal`);
|
|
469
|
+
addIfPresent(candidates, `${paths.goalsDb}-shm`);
|
|
470
|
+
addIfPresent(candidates, paths.sessionIndex);
|
|
471
|
+
addIfPresent(candidates, paths.globalState);
|
|
472
|
+
addIfPresent(candidates, paths.globalStateBackup);
|
|
473
|
+
addIfPresent(candidates, plan.target.rolloutPath);
|
|
474
|
+
for (const store of plan.stores) {
|
|
475
|
+
if (store.store === "shell_snapshot") {
|
|
476
|
+
addIfPresent(candidates, store.path);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const entries = [];
|
|
480
|
+
for (const originalPath of candidates) {
|
|
481
|
+
if (!existsSync5(originalPath)) {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const backupPath = uniqueBackupPath(backupDir, originalPath);
|
|
485
|
+
mkdirSync2(path2.dirname(backupPath), { recursive: true });
|
|
486
|
+
copyFileSync(originalPath, backupPath);
|
|
487
|
+
const stats = statSync2(originalPath);
|
|
488
|
+
entries.push({
|
|
489
|
+
originalPath,
|
|
490
|
+
backupPath,
|
|
491
|
+
size: stats.size,
|
|
492
|
+
mtimeMs: stats.mtimeMs
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
const manifest = {
|
|
496
|
+
threadId: plan.target.id,
|
|
497
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
498
|
+
backupDir,
|
|
499
|
+
entries
|
|
500
|
+
};
|
|
501
|
+
writeFileSync(path2.join(backupDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
502
|
+
return manifest;
|
|
503
|
+
}
|
|
504
|
+
function addIfPresent(paths, filePath) {
|
|
505
|
+
if (existsSync5(filePath)) {
|
|
506
|
+
paths.add(filePath);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function uniqueBackupPath(backupDir, originalPath) {
|
|
510
|
+
const parsed = path2.parse(originalPath);
|
|
511
|
+
const safeBase = `${parsed.name}${parsed.ext}`.replaceAll(/[^a-zA-Z0-9._-]/g, "_");
|
|
512
|
+
const hash = Buffer.from(originalPath).toString("base64url").slice(0, 12);
|
|
513
|
+
return path2.join(backupDir, `${hash}-${safeBase}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/safety/verify.ts
|
|
517
|
+
import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
|
|
518
|
+
function verifyPurge(paths, plan) {
|
|
519
|
+
const remainingReferences = [];
|
|
520
|
+
const threadId = plan.target.id;
|
|
521
|
+
checkSqlite(paths.stateDb, "state_db", threadId, remainingReferences);
|
|
522
|
+
checkSqlite(paths.logsDb, "logs_db", threadId, remainingReferences);
|
|
523
|
+
checkSqlite(paths.goalsDb, "goals_db", threadId, remainingReferences);
|
|
524
|
+
checkTextFile(paths.sessionIndex, "session_index", threadId, remainingReferences);
|
|
525
|
+
checkTextFile(paths.globalState, "global_state", threadId, remainingReferences);
|
|
526
|
+
checkTextFile(paths.globalStateBackup, "global_state_backup", threadId, remainingReferences);
|
|
527
|
+
if (existsSync6(plan.target.rolloutPath)) {
|
|
528
|
+
remainingReferences.push({
|
|
529
|
+
store: "rollout_jsonl",
|
|
530
|
+
path: plan.target.rolloutPath,
|
|
531
|
+
detail: "rollout file still exists"
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
for (const store of plan.stores) {
|
|
535
|
+
if (store.store === "shell_snapshot" && existsSync6(store.path)) {
|
|
536
|
+
remainingReferences.push({
|
|
537
|
+
store: "shell_snapshot",
|
|
538
|
+
path: store.path,
|
|
539
|
+
detail: "shell snapshot still exists"
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
success: remainingReferences.length === 0,
|
|
545
|
+
remainingReferences
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
function checkSqlite(filePath, store, threadId, remainingReferences) {
|
|
549
|
+
if (!existsSync6(filePath)) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const db = openReadonlyDatabase(filePath);
|
|
553
|
+
try {
|
|
554
|
+
const tables = db.prepare("select name from sqlite_master where type = 'table'").all();
|
|
555
|
+
for (const table of tables) {
|
|
556
|
+
if (!tableExists(db, table.name)) {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const columns = tableColumns(db, table.name);
|
|
560
|
+
for (const column of columns) {
|
|
561
|
+
if (!column.endsWith("thread_id") && column !== "id") {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const row = db.prepare(
|
|
565
|
+
`select count(*) as count from ${quoteIdentifier(table.name)} where ${quoteIdentifier(column)} = ?`
|
|
566
|
+
).get(threadId);
|
|
567
|
+
if (row.count > 0) {
|
|
568
|
+
remainingReferences.push({
|
|
569
|
+
store: `${store}.${table.name}`,
|
|
570
|
+
path: filePath,
|
|
571
|
+
detail: `${row.count} row(s) still reference ${column}`
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
} finally {
|
|
577
|
+
db.close();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function checkTextFile(filePath, store, threadId, remainingReferences) {
|
|
581
|
+
if (!existsSync6(filePath)) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const content = readFileSync3(filePath, "utf8");
|
|
585
|
+
if (content.includes(threadId)) {
|
|
586
|
+
remainingReferences.push({
|
|
587
|
+
store,
|
|
588
|
+
path: filePath,
|
|
589
|
+
detail: "text file still contains thread id"
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/stores/files.ts
|
|
595
|
+
import { existsSync as existsSync7, unlinkSync } from "fs";
|
|
596
|
+
function deleteFileIfExists(filePath) {
|
|
597
|
+
if (!existsSync7(filePath)) {
|
|
598
|
+
return {
|
|
599
|
+
path: filePath,
|
|
600
|
+
deleted: false
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
unlinkSync(filePath);
|
|
604
|
+
return {
|
|
605
|
+
path: filePath,
|
|
606
|
+
deleted: true
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/stores/json-state.ts
|
|
611
|
+
import { existsSync as existsSync8, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
612
|
+
function removeThreadFromSessionIndex(filePath, threadId) {
|
|
613
|
+
if (!existsSync8(filePath)) {
|
|
614
|
+
return { path: filePath, changed: false };
|
|
615
|
+
}
|
|
616
|
+
const original = readFileSync4(filePath, "utf8");
|
|
617
|
+
const lines = original.split("\n");
|
|
618
|
+
const kept = lines.filter((line) => {
|
|
619
|
+
if (line.trim().length === 0) {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
const parsed = JSON.parse(line);
|
|
623
|
+
return parsed.id !== threadId;
|
|
624
|
+
});
|
|
625
|
+
const next = kept.length > 0 ? `${kept.join("\n")}
|
|
626
|
+
` : "";
|
|
627
|
+
const changed = next !== original;
|
|
628
|
+
if (changed) {
|
|
629
|
+
writeFileSync2(filePath, next);
|
|
630
|
+
}
|
|
631
|
+
return { path: filePath, changed };
|
|
632
|
+
}
|
|
633
|
+
function removeThreadFromGlobalState(filePath, threadId) {
|
|
634
|
+
if (!existsSync8(filePath)) {
|
|
635
|
+
return { path: filePath, changed: false };
|
|
636
|
+
}
|
|
637
|
+
const original = readFileSync4(filePath, "utf8");
|
|
638
|
+
const parsed = JSON.parse(original);
|
|
639
|
+
const nextValue = removeThreadReferences(parsed, threadId);
|
|
640
|
+
const next = JSON.stringify(nextValue.value, null, 2) + "\n";
|
|
641
|
+
if (nextValue.changed) {
|
|
642
|
+
writeFileSync2(filePath, next);
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
path: filePath,
|
|
646
|
+
changed: nextValue.changed
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function removeThreadReferences(value, threadId) {
|
|
650
|
+
if (Array.isArray(value)) {
|
|
651
|
+
let changed = false;
|
|
652
|
+
const next = [];
|
|
653
|
+
for (const item of value) {
|
|
654
|
+
if (item === threadId) {
|
|
655
|
+
changed = true;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const result = removeThreadReferences(item, threadId);
|
|
659
|
+
changed ||= result.changed;
|
|
660
|
+
next.push(result.value);
|
|
661
|
+
}
|
|
662
|
+
return { value: next, changed };
|
|
663
|
+
}
|
|
664
|
+
if (value && typeof value === "object") {
|
|
665
|
+
let changed = false;
|
|
666
|
+
const next = {};
|
|
667
|
+
for (const [key, child] of Object.entries(value)) {
|
|
668
|
+
if (key === threadId) {
|
|
669
|
+
changed = true;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const result = removeThreadReferences(child, threadId);
|
|
673
|
+
changed ||= result.changed;
|
|
674
|
+
next[key] = result.value;
|
|
675
|
+
}
|
|
676
|
+
return { value: next, changed };
|
|
677
|
+
}
|
|
678
|
+
return { value, changed: false };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/core/executor.ts
|
|
682
|
+
function executePurge(paths, plan) {
|
|
683
|
+
const activeThreadChecks = assertThreadIsNotActive(plan.target);
|
|
684
|
+
const backup = createBackup(paths, plan);
|
|
685
|
+
const sqlite = purgeSqlite(paths, plan.target.id);
|
|
686
|
+
const json = [
|
|
687
|
+
removeThreadFromSessionIndex(paths.sessionIndex, plan.target.id),
|
|
688
|
+
removeThreadFromGlobalState(paths.globalState, plan.target.id),
|
|
689
|
+
removeThreadFromGlobalState(paths.globalStateBackup, plan.target.id)
|
|
690
|
+
];
|
|
691
|
+
const files = purgeFiles(plan);
|
|
692
|
+
checkpointWal(paths.stateDb);
|
|
693
|
+
checkpointWal(paths.logsDb);
|
|
694
|
+
checkpointWal(paths.goalsDb);
|
|
695
|
+
const verification = verifyPurge(paths, plan);
|
|
696
|
+
return {
|
|
697
|
+
mode: "executed",
|
|
698
|
+
plan,
|
|
699
|
+
activeThreadChecks,
|
|
700
|
+
backup,
|
|
701
|
+
sqlite,
|
|
702
|
+
json,
|
|
703
|
+
files,
|
|
704
|
+
verification
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function purgeSqlite(paths, threadId) {
|
|
708
|
+
const results = [];
|
|
709
|
+
if (existsSync9(paths.stateDb)) {
|
|
710
|
+
const db = openWritableDatabase(paths.stateDb);
|
|
711
|
+
try {
|
|
712
|
+
const transaction = db.transaction(() => {
|
|
713
|
+
results.push(deleteRows(db, "thread_dynamic_tools", "thread_id", threadId, "state_db.thread_dynamic_tools"));
|
|
714
|
+
results.push(deleteRows(db, "stage1_outputs", "thread_id", threadId, "state_db.stage1_outputs"));
|
|
715
|
+
if (tableExists(db, "thread_spawn_edges")) {
|
|
716
|
+
const info = db.prepare("delete from thread_spawn_edges where parent_thread_id = ? or child_thread_id = ?").run(threadId, threadId);
|
|
717
|
+
results.push({ store: "state_db.thread_spawn_edges", changedRows: Number(info.changes) });
|
|
718
|
+
}
|
|
719
|
+
if (tableExists(db, "agent_job_items")) {
|
|
720
|
+
const info = db.prepare("update agent_job_items set assigned_thread_id = null where assigned_thread_id = ?").run(threadId);
|
|
721
|
+
results.push({ store: "state_db.agent_job_items", changedRows: Number(info.changes) });
|
|
722
|
+
}
|
|
723
|
+
results.push(deleteRows(db, "threads", "id", threadId, "state_db.threads"));
|
|
724
|
+
});
|
|
725
|
+
transaction();
|
|
726
|
+
} finally {
|
|
727
|
+
db.close();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (existsSync9(paths.logsDb)) {
|
|
731
|
+
const db = openWritableDatabase(paths.logsDb);
|
|
732
|
+
try {
|
|
733
|
+
const transaction = db.transaction(() => {
|
|
734
|
+
results.push(deleteRows(db, "logs", "thread_id", threadId, "logs_db.logs"));
|
|
735
|
+
});
|
|
736
|
+
transaction();
|
|
737
|
+
} finally {
|
|
738
|
+
db.close();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (existsSync9(paths.goalsDb)) {
|
|
742
|
+
const db = openWritableDatabase(paths.goalsDb);
|
|
743
|
+
try {
|
|
744
|
+
const transaction = db.transaction(() => {
|
|
745
|
+
results.push(deleteRows(db, "thread_goals", "thread_id", threadId, "goals_db.thread_goals"));
|
|
746
|
+
});
|
|
747
|
+
transaction();
|
|
748
|
+
} finally {
|
|
749
|
+
db.close();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return results;
|
|
753
|
+
}
|
|
754
|
+
function deleteRows(db, tableName, columnName, threadId, store) {
|
|
755
|
+
if (!tableExists(db, tableName)) {
|
|
756
|
+
return { store, changedRows: 0 };
|
|
757
|
+
}
|
|
758
|
+
const info = db.prepare(`delete from "${tableName}" where "${columnName}" = ?`).run(threadId);
|
|
759
|
+
return {
|
|
760
|
+
store,
|
|
761
|
+
changedRows: Number(info.changes)
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
function purgeFiles(plan) {
|
|
765
|
+
const results = [];
|
|
766
|
+
results.push(deleteFileIfExists(plan.target.rolloutPath));
|
|
767
|
+
for (const store of plan.stores) {
|
|
768
|
+
if (store.store === "shell_snapshot") {
|
|
769
|
+
results.push(deleteFileIfExists(store.path));
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return results;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// src/core/planner.ts
|
|
776
|
+
import { existsSync as existsSync10, readdirSync, statSync as statSync3 } from "fs";
|
|
777
|
+
import path3 from "path";
|
|
778
|
+
function resolvePurgeTarget(paths, threadId) {
|
|
779
|
+
if (!threadId.trim()) {
|
|
780
|
+
throw new UsageError("Provide a thread id or unique short id prefix.");
|
|
781
|
+
}
|
|
782
|
+
const matches = getThreadsByIdPrefix(paths, threadId);
|
|
783
|
+
if (matches.length === 0) {
|
|
784
|
+
throw new UsageError(`No Codex thread found for id: ${threadId}`);
|
|
785
|
+
}
|
|
786
|
+
if (matches.length > 1) {
|
|
787
|
+
throw new SafetyRefusalError(`ID prefix matched ${matches.length} threads. Use a longer id prefix or full id.`);
|
|
788
|
+
}
|
|
789
|
+
return matches[0];
|
|
790
|
+
}
|
|
791
|
+
function buildPurgePlan(paths, target) {
|
|
792
|
+
const stores = [];
|
|
793
|
+
const warnings = [];
|
|
794
|
+
stores.push(...planStateDb(paths.stateDb, target.id));
|
|
795
|
+
stores.push(...planLogsDb(paths.logsDb, target.id));
|
|
796
|
+
stores.push(...planGoalsDb(paths.goalsDb, target.id));
|
|
797
|
+
stores.push(planRewrite(paths.sessionIndex, "session_index", "remove matching JSONL entry"));
|
|
798
|
+
stores.push(planRewrite(paths.globalState, "global_state", "remove known thread references"));
|
|
799
|
+
stores.push(planRewrite(paths.globalStateBackup, "global_state_backup", "remove known thread references"));
|
|
800
|
+
stores.push(planDeleteFile(target.rolloutPath, "rollout_jsonl"));
|
|
801
|
+
for (const snapshot of findShellSnapshots(paths.shellSnapshotsDir, target.id)) {
|
|
802
|
+
stores.push(planDeleteFile(snapshot, "shell_snapshot"));
|
|
803
|
+
}
|
|
804
|
+
if (!existsSync10(target.rolloutPath)) {
|
|
805
|
+
warnings.push(`rollout file is missing: ${target.rolloutPath}`);
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
mode: "planned",
|
|
809
|
+
target,
|
|
810
|
+
stores,
|
|
811
|
+
warnings
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
function planStateDb(filePath, threadId) {
|
|
815
|
+
if (!existsSync10(filePath)) {
|
|
816
|
+
return [missingStore("state_db", filePath)];
|
|
817
|
+
}
|
|
818
|
+
const db = openReadonlyDatabase(filePath);
|
|
819
|
+
try {
|
|
820
|
+
const plans = [];
|
|
821
|
+
plans.push(countPlan("state_db.threads", filePath, 1, "delete target thread row"));
|
|
822
|
+
plans.push(countTable(db, filePath, "thread_dynamic_tools", "thread_id", threadId));
|
|
823
|
+
plans.push(countTable(db, filePath, "stage1_outputs", "thread_id", threadId));
|
|
824
|
+
if (tableExists(db, "thread_spawn_edges")) {
|
|
825
|
+
const row = db.prepare(
|
|
826
|
+
"select count(*) as count from thread_spawn_edges where parent_thread_id = ? or child_thread_id = ?"
|
|
827
|
+
).get(threadId, threadId);
|
|
828
|
+
plans.push(countPlan("state_db.thread_spawn_edges", filePath, row.count, "delete parent/child edges"));
|
|
829
|
+
}
|
|
830
|
+
if (tableExists(db, "agent_job_items")) {
|
|
831
|
+
const row = db.prepare("select count(*) as count from agent_job_items where assigned_thread_id = ?").get(threadId);
|
|
832
|
+
plans.push(
|
|
833
|
+
countPlan(
|
|
834
|
+
"state_db.agent_job_items",
|
|
835
|
+
filePath,
|
|
836
|
+
row.count,
|
|
837
|
+
"inspect assigned thread references before purge execution",
|
|
838
|
+
"inspect"
|
|
839
|
+
)
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
return plans;
|
|
843
|
+
} finally {
|
|
844
|
+
db.close();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
function planLogsDb(filePath, threadId) {
|
|
848
|
+
if (!existsSync10(filePath)) {
|
|
849
|
+
return [missingStore("logs_db", filePath)];
|
|
850
|
+
}
|
|
851
|
+
const db = openReadonlyDatabase(filePath);
|
|
852
|
+
try {
|
|
853
|
+
return [countTable(db, filePath, "logs", "thread_id", threadId, "logs_db.logs")];
|
|
854
|
+
} finally {
|
|
855
|
+
db.close();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
function planGoalsDb(filePath, threadId) {
|
|
859
|
+
if (!existsSync10(filePath)) {
|
|
860
|
+
return [missingStore("goals_db", filePath)];
|
|
861
|
+
}
|
|
862
|
+
const db = openReadonlyDatabase(filePath);
|
|
863
|
+
try {
|
|
864
|
+
return [countTable(db, filePath, "thread_goals", "thread_id", threadId, "goals_db.thread_goals")];
|
|
865
|
+
} finally {
|
|
866
|
+
db.close();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
function countTable(db, filePath, tableName, columnName, threadId, storeName) {
|
|
870
|
+
const count = countWhereThreadId(db, tableName, columnName, threadId);
|
|
871
|
+
if (count === null) {
|
|
872
|
+
return {
|
|
873
|
+
store: storeName ?? `state_db.${tableName}`,
|
|
874
|
+
path: filePath,
|
|
875
|
+
action: "inspect",
|
|
876
|
+
detail: `table or column unavailable: ${tableName}.${columnName}`,
|
|
877
|
+
exists: existsSync10(filePath)
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
return countPlan(storeName ?? `state_db.${tableName}`, filePath, count, "delete matching rows");
|
|
881
|
+
}
|
|
882
|
+
function countPlan(store, filePath, count, detail, action = "delete_rows") {
|
|
883
|
+
return {
|
|
884
|
+
store,
|
|
885
|
+
path: filePath,
|
|
886
|
+
action,
|
|
887
|
+
detail,
|
|
888
|
+
count,
|
|
889
|
+
exists: true
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
function planRewrite(filePath, store, detail) {
|
|
893
|
+
return {
|
|
894
|
+
store,
|
|
895
|
+
path: filePath,
|
|
896
|
+
action: "rewrite",
|
|
897
|
+
detail,
|
|
898
|
+
exists: existsSync10(filePath)
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function planDeleteFile(filePath, store) {
|
|
902
|
+
return {
|
|
903
|
+
store,
|
|
904
|
+
path: filePath,
|
|
905
|
+
action: "delete_file",
|
|
906
|
+
detail: "delete file",
|
|
907
|
+
exists: existsSync10(filePath),
|
|
908
|
+
count: existsSync10(filePath) ? statSync3(filePath).size : 0
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
function missingStore(store, filePath) {
|
|
912
|
+
return {
|
|
913
|
+
store,
|
|
914
|
+
path: filePath,
|
|
915
|
+
action: "inspect",
|
|
916
|
+
detail: "store is missing",
|
|
917
|
+
exists: false
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
function findShellSnapshots(shellSnapshotsDir, threadId) {
|
|
921
|
+
if (!existsSync10(shellSnapshotsDir)) {
|
|
922
|
+
return [];
|
|
923
|
+
}
|
|
924
|
+
return readdirSync(shellSnapshotsDir).filter((entry) => entry.startsWith(`${threadId}.`) && entry.endsWith(".sh")).map((entry) => path3.join(shellSnapshotsDir, entry));
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// src/commands/purge.ts
|
|
928
|
+
function planPurgeCommand(paths, threadId) {
|
|
929
|
+
validateSupportedDataModel(paths);
|
|
930
|
+
const target = resolvePurgeTarget(paths, threadId);
|
|
931
|
+
return buildPurgePlan(paths, target);
|
|
932
|
+
}
|
|
933
|
+
function executePurgePlanCommand(paths, plan) {
|
|
934
|
+
validateSupportedDataModel(paths, { requireBackupHome: true });
|
|
935
|
+
return executePurge(paths, plan);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/core/output.ts
|
|
939
|
+
function printOutput(value, mode) {
|
|
940
|
+
if (mode === "json") {
|
|
941
|
+
console.log(JSON.stringify(value, null, 2));
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (typeof value === "string") {
|
|
945
|
+
if (value.length === 0) {
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
console.log(value);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
console.log(JSON.stringify(value, null, 2));
|
|
952
|
+
}
|
|
953
|
+
function formatDate(valueMs) {
|
|
954
|
+
if (valueMs === null || Number.isNaN(valueMs)) {
|
|
955
|
+
return "-";
|
|
956
|
+
}
|
|
957
|
+
return new Date(valueMs).toISOString();
|
|
958
|
+
}
|
|
959
|
+
function shortId(id) {
|
|
960
|
+
return id.length <= 8 ? id : id.slice(0, 8);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// src/cli.ts
|
|
964
|
+
var TITLE_MAX_LENGTH = 80;
|
|
965
|
+
var program = new Command();
|
|
966
|
+
program.name("codex-history").description("Inspect and safely purge local Codex conversation history.").version("0.1.0").option("--codex-home <path>", "Path to Codex home directory", "~/.codex").option("--json", "Print machine-readable JSON output");
|
|
967
|
+
program.command("doctor").description("Check whether the local Codex data model is supported.").action(
|
|
968
|
+
() => runCommand(() => {
|
|
969
|
+
const report = doctorCommand(currentPaths());
|
|
970
|
+
if (!report.supported) {
|
|
971
|
+
process.exitCode = 1;
|
|
972
|
+
}
|
|
973
|
+
return formatDoctor(report);
|
|
974
|
+
})
|
|
975
|
+
);
|
|
976
|
+
program.command("list").description("List local Codex conversations.").option("--limit <number>", "Maximum rows to show", parseInteger).option("--all", "Include archived and non-archived threads").option("--archived", "Show archived threads").option("--cwd <path>", "Filter by exact working directory").option("--grep <keyword>", "Filter by title, id, or cwd keyword").option("--pretty <format>", "Output format: oneline, medium, full", parsePretty, "oneline").action(
|
|
977
|
+
(options) => runCommand(
|
|
978
|
+
() => formatThreads(
|
|
979
|
+
listCommand(currentPaths(), {
|
|
980
|
+
limit: options.limit,
|
|
981
|
+
all: options.all,
|
|
982
|
+
archived: options.archived,
|
|
983
|
+
cwd: options.cwd,
|
|
984
|
+
grep: options.grep
|
|
985
|
+
}),
|
|
986
|
+
options.pretty,
|
|
987
|
+
shouldUsePager(options)
|
|
988
|
+
)
|
|
989
|
+
)
|
|
990
|
+
);
|
|
991
|
+
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(
|
|
992
|
+
(threadId, options) => runCommand(async () => {
|
|
993
|
+
const paths = currentPaths();
|
|
994
|
+
const plan = planPurgeCommand(paths, threadId);
|
|
995
|
+
const force = Boolean(options.force);
|
|
996
|
+
if (currentOutputModeIsJson() && !force) {
|
|
997
|
+
throw new UsageError("JSON purge output requires --force because interactive confirmation is text-only.");
|
|
998
|
+
}
|
|
999
|
+
if (!force) {
|
|
1000
|
+
await confirmPurge(plan);
|
|
1001
|
+
}
|
|
1002
|
+
return formatPurgeResult(executePurgePlanCommand(paths, plan));
|
|
1003
|
+
})
|
|
1004
|
+
);
|
|
1005
|
+
await program.parseAsync();
|
|
1006
|
+
function currentOutputMode() {
|
|
1007
|
+
return program.opts().json ? "json" : "text";
|
|
1008
|
+
}
|
|
1009
|
+
function currentPaths() {
|
|
1010
|
+
return resolvePaths(program.opts().codexHome);
|
|
1011
|
+
}
|
|
1012
|
+
async function runCommand(produce) {
|
|
1013
|
+
try {
|
|
1014
|
+
printOutput(await produce(), currentOutputMode());
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
const exitCode = error instanceof CodexHistoryError ? error.exitCode : 1;
|
|
1017
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1018
|
+
if (currentOutputMode() === "json") {
|
|
1019
|
+
console.error(JSON.stringify({ error: message, exitCode }, null, 2));
|
|
1020
|
+
} else {
|
|
1021
|
+
console.error(`${colorizeError("red", "Error:")} ${message}`);
|
|
1022
|
+
}
|
|
1023
|
+
process.exitCode = exitCode;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
function parseInteger(value) {
|
|
1027
|
+
const parsed = Number.parseInt(value, 10);
|
|
1028
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
1029
|
+
throw new Error(`Expected positive integer, got: ${value}`);
|
|
1030
|
+
}
|
|
1031
|
+
return parsed;
|
|
1032
|
+
}
|
|
1033
|
+
function parsePretty(value) {
|
|
1034
|
+
if (value === "oneline" || value === "medium" || value === "full") {
|
|
1035
|
+
return value;
|
|
1036
|
+
}
|
|
1037
|
+
throw new Error(`Expected pretty format oneline, medium, or full; got: ${value}`);
|
|
1038
|
+
}
|
|
1039
|
+
function shouldUsePager(options) {
|
|
1040
|
+
return Boolean(options.limit === void 0 && process.stdout.isTTY && !currentOutputModeIsJson());
|
|
1041
|
+
}
|
|
1042
|
+
function currentOutputModeIsJson() {
|
|
1043
|
+
return currentOutputMode() === "json";
|
|
1044
|
+
}
|
|
1045
|
+
async function confirmPurge(plan) {
|
|
1046
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1047
|
+
throw new SafetyRefusalError("Purge requires an interactive terminal. Use --force to skip confirmation.");
|
|
1048
|
+
}
|
|
1049
|
+
const expected = shortId(plan.target.id);
|
|
1050
|
+
process.stdout.write(formatPurgeConfirmation(plan));
|
|
1051
|
+
const readline = createInterface({
|
|
1052
|
+
input: process.stdin,
|
|
1053
|
+
output: process.stdout
|
|
1054
|
+
});
|
|
1055
|
+
try {
|
|
1056
|
+
const answer = await readline.question(`Type ${colorize("yellow", expected)} to confirm: `);
|
|
1057
|
+
if (answer.trim() !== expected) {
|
|
1058
|
+
throw new SafetyRefusalError("Confirmation did not match. No local Codex data was modified.");
|
|
1059
|
+
}
|
|
1060
|
+
} finally {
|
|
1061
|
+
readline.close();
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
function formatDoctor(report) {
|
|
1065
|
+
if (currentOutputMode() === "json") {
|
|
1066
|
+
return report;
|
|
1067
|
+
}
|
|
1068
|
+
const lines = [
|
|
1069
|
+
`${colorize("dim", "Codex home:")} ${colorize("dim", report.codexHome)}`,
|
|
1070
|
+
`${colorize("dim", "Supported:")} ${report.supported ? colorize("green", "yes") : colorize("red", "no")}`,
|
|
1071
|
+
"",
|
|
1072
|
+
...report.checks.map(
|
|
1073
|
+
(check) => `${formatCheckStatus(check.status)} ${check.name}: ${check.detail}`
|
|
1074
|
+
)
|
|
1075
|
+
];
|
|
1076
|
+
return lines.join("\n");
|
|
1077
|
+
}
|
|
1078
|
+
function formatThreads(threads, pretty = "oneline", usePager = false) {
|
|
1079
|
+
if (currentOutputMode() === "json") {
|
|
1080
|
+
return { count: threads.length, threads: threads.map(toPublicThread) };
|
|
1081
|
+
}
|
|
1082
|
+
if (threads.length === 0) {
|
|
1083
|
+
return "No Codex conversations found.";
|
|
1084
|
+
}
|
|
1085
|
+
const text = threads.map((thread) => formatThread(thread, pretty)).join(pretty === "oneline" ? "\n" : "\n\n");
|
|
1086
|
+
return usePager ? pageText(text) : text;
|
|
1087
|
+
}
|
|
1088
|
+
function formatThread(thread, pretty) {
|
|
1089
|
+
const header = `${colorize("yellow", shortId(thread.id))} ${displayTitle(thread.title)}`;
|
|
1090
|
+
if (pretty === "oneline") {
|
|
1091
|
+
return header;
|
|
1092
|
+
}
|
|
1093
|
+
const mediumLines = [
|
|
1094
|
+
header,
|
|
1095
|
+
` ${colorize("dim", "id:")} ${colorize("yellow", thread.id)}`,
|
|
1096
|
+
` ${colorize("dim", "updated:")} ${formatDate(thread.updatedAtMs)}`,
|
|
1097
|
+
` ${colorize("dim", "cwd:")} ${colorize("dim", thread.cwd)}`
|
|
1098
|
+
];
|
|
1099
|
+
if (pretty === "medium") {
|
|
1100
|
+
return mediumLines.join("\n");
|
|
1101
|
+
}
|
|
1102
|
+
return [
|
|
1103
|
+
...mediumLines,
|
|
1104
|
+
` ${colorize("dim", "created:")} ${formatDate(thread.createdAtMs)}`,
|
|
1105
|
+
` ${colorize("dim", "archived:")} ${thread.archived}`,
|
|
1106
|
+
` ${colorize("dim", "rollout:")} ${colorize("dim", thread.rolloutPath)}`
|
|
1107
|
+
].join("\n");
|
|
1108
|
+
}
|
|
1109
|
+
function pageText(text) {
|
|
1110
|
+
const pagerCommand = process.env.PAGER || "less";
|
|
1111
|
+
const [pager, ...configuredArgs] = pagerCommand.split(/\s+/).filter(Boolean);
|
|
1112
|
+
const pagerArgs = configuredArgs.length === 0 && pager?.endsWith("less") ? ["-FRX"] : configuredArgs;
|
|
1113
|
+
if (!pager) {
|
|
1114
|
+
return text;
|
|
1115
|
+
}
|
|
1116
|
+
const result = spawnSync(pager, pagerArgs, {
|
|
1117
|
+
input: text,
|
|
1118
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
1119
|
+
encoding: "utf8"
|
|
1120
|
+
});
|
|
1121
|
+
if (result.error) {
|
|
1122
|
+
return text;
|
|
1123
|
+
}
|
|
1124
|
+
return "";
|
|
1125
|
+
}
|
|
1126
|
+
function formatPurgeResult(result) {
|
|
1127
|
+
if (currentOutputMode() === "json") {
|
|
1128
|
+
return sanitizePurgeResult(result);
|
|
1129
|
+
}
|
|
1130
|
+
const lines = [
|
|
1131
|
+
colorize("green", "Purge executed."),
|
|
1132
|
+
"",
|
|
1133
|
+
`Target: ${displayTitle(result.plan.target.title)}`,
|
|
1134
|
+
`Thread id: ${result.plan.target.id}`,
|
|
1135
|
+
`Backup: ${colorize("dim", result.backup.backupDir)}`,
|
|
1136
|
+
"",
|
|
1137
|
+
"SQLite changes:",
|
|
1138
|
+
...result.sqlite.map((change) => `- ${change.store}: ${change.changedRows} row(s)`),
|
|
1139
|
+
"",
|
|
1140
|
+
"JSON changes:",
|
|
1141
|
+
...result.json.map((change) => `- ${change.changed ? "changed" : "unchanged"}: ${colorize("dim", change.path)}`),
|
|
1142
|
+
"",
|
|
1143
|
+
"File changes:",
|
|
1144
|
+
...result.files.map((change) => `- ${change.deleted ? "deleted" : "missing"}: ${colorize("dim", change.path)}`),
|
|
1145
|
+
"",
|
|
1146
|
+
`Verification: ${result.verification.success ? colorize("green", "passed") : colorize("red", "failed")}`
|
|
1147
|
+
];
|
|
1148
|
+
if (result.verification.remainingReferences.length > 0) {
|
|
1149
|
+
lines.push(
|
|
1150
|
+
"",
|
|
1151
|
+
"Remaining references:",
|
|
1152
|
+
...result.verification.remainingReferences.map(
|
|
1153
|
+
(reference) => `- ${reference.store}: ${reference.path} (${reference.detail})`
|
|
1154
|
+
)
|
|
1155
|
+
);
|
|
1156
|
+
process.exitCode = 1;
|
|
1157
|
+
}
|
|
1158
|
+
return lines.join("\n");
|
|
1159
|
+
}
|
|
1160
|
+
function formatPurgeConfirmation(plan) {
|
|
1161
|
+
return [
|
|
1162
|
+
"About to purge this local Codex conversation:",
|
|
1163
|
+
"",
|
|
1164
|
+
`title: ${displayTitle(plan.target.title)}`,
|
|
1165
|
+
`id: ${plan.target.id}`,
|
|
1166
|
+
`cwd: ${colorize("dim", plan.target.cwd)}`,
|
|
1167
|
+
`updated: ${formatDate(plan.target.updatedAtMs)}`,
|
|
1168
|
+
"",
|
|
1169
|
+
colorize("dim", "A backup will be created before deletion."),
|
|
1170
|
+
""
|
|
1171
|
+
].join("\n");
|
|
1172
|
+
}
|
|
1173
|
+
function formatCheckStatus(status) {
|
|
1174
|
+
const label = status.toUpperCase().padEnd(7);
|
|
1175
|
+
if (status === "ok") {
|
|
1176
|
+
return colorize("green", label);
|
|
1177
|
+
}
|
|
1178
|
+
if (status === "warning") {
|
|
1179
|
+
return colorize("yellow", label);
|
|
1180
|
+
}
|
|
1181
|
+
if (status === "error") {
|
|
1182
|
+
return colorize("red", label);
|
|
1183
|
+
}
|
|
1184
|
+
return label;
|
|
1185
|
+
}
|
|
1186
|
+
function colorize(color, value) {
|
|
1187
|
+
return applyColor(color, value, colorsEnabled());
|
|
1188
|
+
}
|
|
1189
|
+
function colorizeError(color, value) {
|
|
1190
|
+
return applyColor(color, value, errorColorsEnabled());
|
|
1191
|
+
}
|
|
1192
|
+
function applyColor(color, value, enabled) {
|
|
1193
|
+
if (!enabled) {
|
|
1194
|
+
return value;
|
|
1195
|
+
}
|
|
1196
|
+
const codes = {
|
|
1197
|
+
dim: [2, 22],
|
|
1198
|
+
green: [32, 39],
|
|
1199
|
+
red: [31, 39],
|
|
1200
|
+
yellow: [33, 39]
|
|
1201
|
+
};
|
|
1202
|
+
const [open, close] = codes[color];
|
|
1203
|
+
return `\x1B[${open}m${value}\x1B[${close}m`;
|
|
1204
|
+
}
|
|
1205
|
+
function colorsEnabled() {
|
|
1206
|
+
return streamColorsEnabled(process.stdout);
|
|
1207
|
+
}
|
|
1208
|
+
function errorColorsEnabled() {
|
|
1209
|
+
return streamColorsEnabled(process.stderr);
|
|
1210
|
+
}
|
|
1211
|
+
function streamColorsEnabled(stream) {
|
|
1212
|
+
return !currentOutputModeIsJson() && !("NO_COLOR" in process.env) && Boolean(stream.isTTY);
|
|
1213
|
+
}
|
|
1214
|
+
function displayTitle(title) {
|
|
1215
|
+
const normalized = title.trim().replaceAll(/\s+/g, " ");
|
|
1216
|
+
if (!normalized) {
|
|
1217
|
+
return "(untitled)";
|
|
1218
|
+
}
|
|
1219
|
+
if (normalized.length <= TITLE_MAX_LENGTH) {
|
|
1220
|
+
return normalized;
|
|
1221
|
+
}
|
|
1222
|
+
return `${normalized.slice(0, TITLE_MAX_LENGTH - 3)}...`;
|
|
1223
|
+
}
|
|
1224
|
+
function toPublicThread(thread) {
|
|
1225
|
+
return {
|
|
1226
|
+
id: thread.id,
|
|
1227
|
+
title: displayTitle(thread.title),
|
|
1228
|
+
titleTruncated: thread.title.trim().replaceAll(/\s+/g, " ").length > TITLE_MAX_LENGTH,
|
|
1229
|
+
rolloutPath: thread.rolloutPath,
|
|
1230
|
+
createdAtMs: thread.createdAtMs,
|
|
1231
|
+
updatedAtMs: thread.updatedAtMs,
|
|
1232
|
+
cwd: thread.cwd,
|
|
1233
|
+
archived: thread.archived
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
function sanitizePurgeResult(result) {
|
|
1237
|
+
return {
|
|
1238
|
+
...result,
|
|
1239
|
+
plan: {
|
|
1240
|
+
...result.plan,
|
|
1241
|
+
target: toPublicThread(result.plan.target)
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
}
|