ai-chat-cleaner 0.0.1 → 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/README.md +13 -5
- package/dist/cli.mjs +342 -27
- package/dist/index.d.mts +4 -3
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -9,26 +9,34 @@ Clean and remove AI chat with an interactive terminal UI.
|
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
11
|
npx ai-chat-cleaner
|
|
12
|
+
npx ai-chat-cleaner --agent codex
|
|
12
13
|
```
|
|
13
14
|
|
|
14
|
-
<p align='center'>
|
|
15
|
-
<img src='./assets/screenshot.png' alt="screenshot" />
|
|
16
|
-
</p>
|
|
17
|
-
|
|
18
15
|
- Supported agents:
|
|
19
|
-
- `codex`
|
|
16
|
+
- Codex (`codex`)
|
|
17
|
+
- Claude Code (`claude-code`)
|
|
20
18
|
|
|
21
19
|
> [!WARNING]
|
|
22
20
|
> Please restart your AI coding tool after deletion.
|
|
23
21
|
>
|
|
24
22
|
> It is recommended to clean history while Codex is not running, to avoid duplicate writes.
|
|
25
23
|
|
|
24
|
+
<p align='center'>
|
|
25
|
+
<img src='./assets/screenshot.png' alt="screenshot" />
|
|
26
|
+
</p>
|
|
27
|
+
|
|
26
28
|
## Why ?
|
|
27
29
|
|
|
28
30
|
I am not entirely sure why `Codex` and `Claude Code` do not provide a way to delete a specific conversation history, perhaps to preserve context continuity.
|
|
29
31
|
|
|
30
32
|
But in practice, one conversation that goes in the wrong direction can keep affecting later outputs, so I built this tool.
|
|
31
33
|
|
|
34
|
+
## Credit
|
|
35
|
+
|
|
36
|
+
The terminal interaction mode is inspired by [taze](https://github.com/antfu-collective/taze).
|
|
37
|
+
|
|
38
|
+
Claude Code cleanup implementation references [claude-chats-delete](https://github.com/ataleckij/claude-chats-delete).
|
|
39
|
+
|
|
32
40
|
## License
|
|
33
41
|
|
|
34
42
|
[MIT](./LICENSE) License © [jinghaihan](https://github.com/jinghaihan)
|
package/dist/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { cac } from "cac";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import readline from "node:readline";
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import pLimit from "p-limit";
|
|
11
11
|
import { basename, join } from "pathe";
|
|
@@ -129,7 +129,7 @@ function renderGroups(state, groups) {
|
|
|
129
129
|
const totalCount = groups.reduce((acc, group) => acc + group.items.length, 0);
|
|
130
130
|
process.stdout.write(`${c.gray(`${Y("↑↓")} select ${Y("space")} toggle group ${Y("→")} enter group`)}\n`);
|
|
131
131
|
process.stdout.write(`${c.gray(`${Y("enter")} confirm ${Y("esc")} cancel ${Y("a")} toggle all`)}\n\n`);
|
|
132
|
-
process.stdout.write(
|
|
132
|
+
process.stdout.write(`${c.gray("selected")} ${c.red(`${selectedCount}/${totalCount}`)}\n\n`);
|
|
133
133
|
const labelWidth = Math.min(36, Math.max(12, ...groups.map((group) => group.label.length)));
|
|
134
134
|
const countWidth = Math.max(7, ...groups.map((group) => {
|
|
135
135
|
return `${countSelectedInGroup(group, state.selectedKeys)}/${group.items.length}`.length;
|
|
@@ -156,7 +156,7 @@ function renderItems(state, groups) {
|
|
|
156
156
|
const totalCount = groups.reduce((acc, item) => acc + item.items.length, 0);
|
|
157
157
|
process.stdout.write(`${c.gray(`${Y("↑↓")} select ${Y("space")} toggle ${Y("←")} back`)}\n`);
|
|
158
158
|
process.stdout.write(`${c.gray(`${Y("enter")} back ${Y("esc")} back ${Y("a")} toggle group`)}\n\n`);
|
|
159
|
-
process.stdout.write(
|
|
159
|
+
process.stdout.write(`${c.gray("selected")} ${c.red(`${selectedCount}/${totalCount}`)}\n`);
|
|
160
160
|
process.stdout.write("\n");
|
|
161
161
|
process.stdout.write(`${c.green(group.label)} ${c.gray(group.path ? tildifyPath(group.path) : "(unknown cwd)")}\n\n`);
|
|
162
162
|
const { start, end } = getRenderWindow(group.items.length, state.itemIndex, 8);
|
|
@@ -249,12 +249,27 @@ function clearScreen() {
|
|
|
249
249
|
//#region src/utils.ts
|
|
250
250
|
const exec = promisify(execFile);
|
|
251
251
|
async function readJSON(filepath) {
|
|
252
|
-
|
|
253
|
-
return JSON.parse(content);
|
|
252
|
+
return parseJSON(await readFile(filepath, "utf-8"));
|
|
254
253
|
}
|
|
255
254
|
async function writeJSON(filepath, data) {
|
|
256
255
|
await writeFile(filepath, JSON.stringify(data, null, 2), "utf-8");
|
|
257
256
|
}
|
|
257
|
+
function parseJSON(value) {
|
|
258
|
+
try {
|
|
259
|
+
return JSON.parse(value);
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function toUnix(value) {
|
|
265
|
+
return Math.floor(value / 1e3);
|
|
266
|
+
}
|
|
267
|
+
function parseDateToUnix(value) {
|
|
268
|
+
if (!value) return 0;
|
|
269
|
+
const ts = Date.parse(value);
|
|
270
|
+
if (!Number.isFinite(ts)) return 0;
|
|
271
|
+
return Math.floor(ts / 1e3);
|
|
272
|
+
}
|
|
258
273
|
function formatRelativeTime(date) {
|
|
259
274
|
const diff = (/* @__PURE__ */ new Date(date * 1e3)).getTime() - Date.now();
|
|
260
275
|
const seconds = Math.round(diff / 1e3);
|
|
@@ -274,27 +289,296 @@ function formatRelativeTime(date) {
|
|
|
274
289
|
function quoteSqlString(value) {
|
|
275
290
|
return `'${value.replaceAll("'", "''")}'`;
|
|
276
291
|
}
|
|
292
|
+
function normalizeInlineText(value) {
|
|
293
|
+
return value.replace(/\r?\n/g, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
294
|
+
}
|
|
295
|
+
function extractMessageText(content) {
|
|
296
|
+
if (typeof content === "string") return content;
|
|
297
|
+
if (!Array.isArray(content)) return "";
|
|
298
|
+
const texts = [];
|
|
299
|
+
for (const item of content) {
|
|
300
|
+
if (typeof item === "string") {
|
|
301
|
+
texts.push(item);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (!item || typeof item !== "object") continue;
|
|
305
|
+
if (typeof item.text === "string") texts.push(item.text);
|
|
306
|
+
else if (typeof item.content === "string") texts.push(item.content);
|
|
307
|
+
}
|
|
308
|
+
return texts.join(" ");
|
|
309
|
+
}
|
|
310
|
+
function isCommandTitle(title) {
|
|
311
|
+
return /^\/[\w-]+(?:\s+[\w-]+)?$/.test(title);
|
|
312
|
+
}
|
|
313
|
+
function isUUID(value) {
|
|
314
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
315
|
+
}
|
|
277
316
|
|
|
278
317
|
//#endregion
|
|
279
318
|
//#region package.json
|
|
280
319
|
var name = "ai-chat-cleaner";
|
|
281
|
-
var version = "0.0
|
|
320
|
+
var version = "0.1.0";
|
|
282
321
|
|
|
283
322
|
//#endregion
|
|
284
323
|
//#region src/constants.ts
|
|
285
324
|
const NAME = name;
|
|
286
325
|
const VERSION = version;
|
|
287
326
|
const DEFAULT_OPTIONS = {};
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
327
|
+
const AGENTS_CHOICES = ["codex", "claude-code"];
|
|
328
|
+
const AGENTS_CONFIG = {
|
|
329
|
+
"codex": {
|
|
330
|
+
name: "codex",
|
|
331
|
+
path: process.env.CODEX_HOME?.trim() || join(homedir(), ".codex")
|
|
332
|
+
},
|
|
333
|
+
"claude-code": {
|
|
334
|
+
name: "claude-code",
|
|
335
|
+
path: process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude")
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/claude-code/constants.ts
|
|
341
|
+
const ROOT_PATH = AGENTS_CONFIG["claude-code"].path;
|
|
342
|
+
const PROJECTS_PATH = join(ROOT_PATH, "projects");
|
|
343
|
+
const DEBUG_PATH = join(ROOT_PATH, "debug");
|
|
344
|
+
const TODOS_PATH = join(ROOT_PATH, "todos");
|
|
345
|
+
const SESSION_ENV_PATH = join(ROOT_PATH, "session-env");
|
|
346
|
+
const TASKS_PATH = join(ROOT_PATH, "tasks");
|
|
347
|
+
const FILE_HISTORY_PATH = join(ROOT_PATH, "file-history");
|
|
348
|
+
const PLANS_PATH = join(ROOT_PATH, "plans");
|
|
349
|
+
const AGENTS_PATH = join(ROOT_PATH, "agents");
|
|
350
|
+
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region src/claude-code/delete.ts
|
|
353
|
+
async function deleteThreads$1(threads) {
|
|
354
|
+
const limit = pLimit(5);
|
|
355
|
+
await Promise.all(threads.map((thread) => limit(() => deleteThread$1(thread))));
|
|
356
|
+
}
|
|
357
|
+
async function deleteThread$1(thread) {
|
|
358
|
+
await rimraf(thread.path);
|
|
359
|
+
await rimraf(thread.path.replace(/\.jsonl$/i, ""));
|
|
360
|
+
await rimraf(join(DEBUG_PATH, `${thread.id}.txt`));
|
|
361
|
+
await rimraf(join(SESSION_ENV_PATH, thread.id));
|
|
362
|
+
await rimraf(join(TASKS_PATH, thread.id));
|
|
363
|
+
await rimraf(join(FILE_HISTORY_PATH, thread.id));
|
|
364
|
+
if (thread.slug) await rimraf(join(PLANS_PATH, `${thread.slug}.md`));
|
|
365
|
+
const todoFiles = await glob(`${thread.id}*.json`, {
|
|
366
|
+
cwd: TODOS_PATH,
|
|
367
|
+
absolute: true,
|
|
368
|
+
onlyFiles: true
|
|
369
|
+
});
|
|
370
|
+
await Promise.all(todoFiles.map((path) => rimraf(path)));
|
|
371
|
+
const agentIds = await readAgentIds(thread.path);
|
|
372
|
+
await Promise.all(agentIds.map((agentId) => rimraf(join(AGENTS_PATH, agentId, "memory-local.md"))));
|
|
373
|
+
await updateSessionsIndex(thread.project_dir, thread.id);
|
|
374
|
+
}
|
|
375
|
+
async function updateSessionsIndex(projectDir, threadId) {
|
|
376
|
+
const path = join(projectDir, "sessions-index.json");
|
|
377
|
+
const raw = await readFile(path, "utf8").catch(() => "");
|
|
378
|
+
if (!raw) return;
|
|
379
|
+
const data = parseJSON(raw);
|
|
380
|
+
if (!data || !Array.isArray(data.entries)) return;
|
|
381
|
+
const nextEntries = data.entries.filter((entry) => entry?.sessionId !== threadId);
|
|
382
|
+
if (nextEntries.length === data.entries.length) return;
|
|
383
|
+
data.entries = nextEntries;
|
|
384
|
+
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
385
|
+
}
|
|
386
|
+
async function readAgentIds(path) {
|
|
387
|
+
const raw = await readFile(path, "utf8").catch(() => "");
|
|
388
|
+
if (!raw) return [];
|
|
389
|
+
const ids = /* @__PURE__ */ new Set();
|
|
390
|
+
const lines = raw.split("\n");
|
|
391
|
+
for (const line of lines) {
|
|
392
|
+
if (!line) continue;
|
|
393
|
+
const value = parseJSON(line)?.agent_id;
|
|
394
|
+
if (typeof value === "string" && value) ids.add(value);
|
|
395
|
+
}
|
|
396
|
+
return Array.from(ids);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/claude-code/detect.ts
|
|
401
|
+
async function detectClaudeCode(cwd = ROOT_PATH) {
|
|
402
|
+
const projectDirs = await readProjectDirs(join(cwd, "projects"));
|
|
403
|
+
const threads = [];
|
|
404
|
+
for (const projectDir of projectDirs) {
|
|
405
|
+
const sessions = await readSessionsIndex(projectDir);
|
|
406
|
+
const files = await glob("*.jsonl", {
|
|
407
|
+
cwd: projectDir,
|
|
408
|
+
absolute: true,
|
|
409
|
+
onlyFiles: true
|
|
410
|
+
});
|
|
411
|
+
for (const file of files) {
|
|
412
|
+
const id = basename(file, ".jsonl");
|
|
413
|
+
if (id.startsWith("agent-")) continue;
|
|
414
|
+
const info = await stat(file).catch(() => null);
|
|
415
|
+
if (!info) continue;
|
|
416
|
+
const session = sessions.get(id);
|
|
417
|
+
const meta = await readThreadMeta(file);
|
|
418
|
+
const updatedAt = parseDateToUnix(session?.modified) || toUnix(info.mtimeMs);
|
|
419
|
+
const createdAt = parseDateToUnix(session?.created) || toUnix(info.birthtimeMs || info.ctimeMs || info.mtimeMs);
|
|
420
|
+
const cwdPath = session?.projectPath || decodeProjectName(basename(projectDir));
|
|
421
|
+
const title = pickTitle([
|
|
422
|
+
session?.firstPrompt,
|
|
423
|
+
meta.title,
|
|
424
|
+
session?.summary
|
|
425
|
+
]);
|
|
426
|
+
if (!title) continue;
|
|
427
|
+
threads.push({
|
|
428
|
+
id,
|
|
429
|
+
title,
|
|
430
|
+
path: file,
|
|
431
|
+
project_dir: projectDir,
|
|
432
|
+
cwd: cwdPath,
|
|
433
|
+
created_at: createdAt,
|
|
434
|
+
updated_at: updatedAt,
|
|
435
|
+
slug: meta.slug
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
threads.sort((a, b) => b.updated_at - a.updated_at);
|
|
440
|
+
return { threads };
|
|
441
|
+
}
|
|
442
|
+
async function readProjectDirs(projectsPath) {
|
|
443
|
+
return (await readdir(projectsPath, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => join(projectsPath, entry.name));
|
|
444
|
+
}
|
|
445
|
+
async function readSessionsIndex(projectDir) {
|
|
446
|
+
const raw = await readFile(join(projectDir, "sessions-index.json"), "utf8").catch(() => "");
|
|
447
|
+
if (!raw) return /* @__PURE__ */ new Map();
|
|
448
|
+
const data = parseJSON(raw);
|
|
449
|
+
const rows = Array.isArray(data?.entries) ? data.entries : [];
|
|
450
|
+
return new Map(rows.map((entry) => [entry.sessionId, entry]));
|
|
451
|
+
}
|
|
452
|
+
async function readThreadMeta(path) {
|
|
453
|
+
const lines = (await readFile(path, "utf8").catch(() => "")).split("\n").filter(Boolean);
|
|
454
|
+
let userTitle = "";
|
|
455
|
+
let summaryTitle = "";
|
|
456
|
+
let slug = "";
|
|
457
|
+
for (const line of lines) {
|
|
458
|
+
const row = parseJSON(line);
|
|
459
|
+
if (!row) continue;
|
|
460
|
+
if (!slug && typeof row.slug === "string") slug = row.slug;
|
|
461
|
+
if (!summaryTitle && row.type === "summary" && typeof row.summary === "string") summaryTitle = normalizeIfValidTitle(row.summary);
|
|
462
|
+
if (!userTitle && row.type === "user" && row.isMeta !== true) {
|
|
463
|
+
if (!isCommandEvent(row.message?.content)) userTitle = normalizeIfValidTitle(extractMessageText(row.message?.content));
|
|
464
|
+
}
|
|
465
|
+
if (userTitle && summaryTitle && slug) break;
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
title: userTitle || summaryTitle,
|
|
469
|
+
slug: slug || void 0
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
function pickTitle(values) {
|
|
473
|
+
for (const value of values) {
|
|
474
|
+
const normalized = normalizeIfValidTitle(value);
|
|
475
|
+
if (normalized) return normalized;
|
|
476
|
+
}
|
|
477
|
+
return "";
|
|
478
|
+
}
|
|
479
|
+
function normalizeIfValidTitle(value) {
|
|
480
|
+
if (typeof value !== "string") return "";
|
|
481
|
+
const normalized = normalizeInlineText(value);
|
|
482
|
+
if (!normalized) return "";
|
|
483
|
+
if (normalized.toLowerCase() === "no prompt") return "";
|
|
484
|
+
if (isUUID(normalized)) return "";
|
|
485
|
+
if (isCommandTitle(normalized)) return "";
|
|
486
|
+
return normalized;
|
|
487
|
+
}
|
|
488
|
+
function isCommandEvent(content) {
|
|
489
|
+
const source = extractMessageText(content);
|
|
490
|
+
if (!source) return false;
|
|
491
|
+
return source.includes("<command-name>") || source.includes("<command-message>") || source.includes("<local-command-stdout>") || source.includes("<local-command-caveat>");
|
|
492
|
+
}
|
|
493
|
+
function decodeProjectName(projectName) {
|
|
494
|
+
if (!projectName) return "";
|
|
495
|
+
if (projectName.startsWith("-")) return `/${projectName.slice(1).replaceAll("-", "/")}`;
|
|
496
|
+
return projectName.replaceAll("-", "/");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/claude-code/group.ts
|
|
501
|
+
function groupClaudeCodeThreads(threads) {
|
|
502
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
503
|
+
for (const thread of threads) {
|
|
504
|
+
const cwd = thread.cwd || "";
|
|
505
|
+
const id = cwd || "(unknown)";
|
|
506
|
+
const label = cwd ? basename(cwd) : "(unknown)";
|
|
507
|
+
const group = grouped.get(id);
|
|
508
|
+
if (group) {
|
|
509
|
+
group.threads.push(thread);
|
|
510
|
+
group.updatedAt = Math.max(group.updatedAt, thread.updated_at || thread.created_at || 0);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
grouped.set(id, {
|
|
514
|
+
id,
|
|
515
|
+
label,
|
|
516
|
+
cwd,
|
|
517
|
+
threads: [thread],
|
|
518
|
+
updatedAt: thread.updated_at || thread.created_at || 0
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
const groups = Array.from(grouped.values());
|
|
522
|
+
for (const group of groups) group.threads.sort((a, b) => (b.updated_at || b.created_at || 0) - (a.updated_at || a.created_at || 0));
|
|
523
|
+
groups.sort((a, b) => {
|
|
524
|
+
const diff = b.updatedAt - a.updatedAt;
|
|
525
|
+
if (diff !== 0) return diff;
|
|
526
|
+
return a.label.localeCompare(b.label);
|
|
527
|
+
});
|
|
528
|
+
return groups;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/claude-code/index.ts
|
|
533
|
+
async function promptClaudeCode(_options) {
|
|
534
|
+
const spinner = p.spinner();
|
|
535
|
+
spinner.start("detecting claude-code threads...");
|
|
536
|
+
const { threads } = await detectClaudeCode();
|
|
537
|
+
spinner.stop(`detected ${c.yellow`${threads.length}`} threads`);
|
|
538
|
+
if (threads.length === 0) {
|
|
539
|
+
p.outro(c.yellow("no threads found"));
|
|
540
|
+
process.exit(0);
|
|
541
|
+
}
|
|
542
|
+
const resolved = await promptGroupedMultiSelect(formatThreadGroupOptions$1(groupClaudeCodeThreads(threads)));
|
|
543
|
+
if (resolved === null || resolved.length === 0) {
|
|
544
|
+
p.outro(c.red("aborting"));
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
const confirmed = await p.confirm({
|
|
548
|
+
message: `selected ${c.yellow`${resolved.length}`} records, continue?`,
|
|
549
|
+
initialValue: true
|
|
550
|
+
});
|
|
551
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
552
|
+
p.outro(c.red("aborting"));
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
await deleteThreads$1(resolved);
|
|
556
|
+
p.outro(`cleaned ${c.yellow`${resolved.length}`} threads`);
|
|
557
|
+
}
|
|
558
|
+
function formatThreadGroupOptions$1(grouped) {
|
|
559
|
+
return grouped.map((group) => ({
|
|
560
|
+
id: group.id,
|
|
561
|
+
label: group.label,
|
|
562
|
+
path: group.cwd,
|
|
563
|
+
items: group.threads.map((thread) => ({
|
|
564
|
+
id: thread.id,
|
|
565
|
+
label: thread.title,
|
|
566
|
+
hint: formatThreadHint$1(thread),
|
|
567
|
+
value: thread
|
|
568
|
+
}))
|
|
569
|
+
}));
|
|
570
|
+
}
|
|
571
|
+
function formatThreadHint$1(thread) {
|
|
572
|
+
const updatedAt = thread.updated_at || thread.created_at;
|
|
573
|
+
const createdAt = thread.created_at || updatedAt;
|
|
574
|
+
return `updated ${formatRelativeTime(updatedAt)} · created ${formatRelativeTime(createdAt)}`;
|
|
575
|
+
}
|
|
292
576
|
|
|
293
577
|
//#endregion
|
|
294
578
|
//#region src/codex/constants.ts
|
|
295
|
-
const GLOBAL_STATE_PATH = join(
|
|
296
|
-
const HISTORY_FILE_PATH = join(
|
|
297
|
-
const SHELL_SNAPSHOTS_PATH = join(
|
|
579
|
+
const GLOBAL_STATE_PATH = join(AGENTS_CONFIG.codex.path, ".codex-global-state.json");
|
|
580
|
+
const HISTORY_FILE_PATH = join(AGENTS_CONFIG.codex.path, "history.jsonl");
|
|
581
|
+
const SHELL_SNAPSHOTS_PATH = join(AGENTS_CONFIG.codex.path, "shell_snapshots");
|
|
298
582
|
|
|
299
583
|
//#endregion
|
|
300
584
|
//#region src/codex/db.ts
|
|
@@ -304,7 +588,7 @@ async function readSQLite(filepath) {
|
|
|
304
588
|
filepath,
|
|
305
589
|
"SELECT * FROM threads;"
|
|
306
590
|
]);
|
|
307
|
-
return
|
|
591
|
+
return parseJSON(stdout.trim());
|
|
308
592
|
}
|
|
309
593
|
async function writeSQLite(filepath, ids) {
|
|
310
594
|
if (ids.length === 0) return;
|
|
@@ -346,13 +630,13 @@ async function updateGlobalState(threadIds, globalState) {
|
|
|
346
630
|
}
|
|
347
631
|
async function updateHistory(path, ids) {
|
|
348
632
|
const remove = new Set(ids);
|
|
349
|
-
const rows = (await readFile(path, "utf8")).split("\n").filter(Boolean).map((line) =>
|
|
633
|
+
const rows = (await readFile(path, "utf8")).split("\n").filter(Boolean).map((line) => parseJSON(line)).filter((row) => !remove.has(row?.session_id));
|
|
350
634
|
await writeFile(HISTORY_FILE_PATH, rows.length > 0 ? `${rows.map((row) => JSON.stringify(row)).join("\n")}\n` : "", "utf-8");
|
|
351
635
|
}
|
|
352
636
|
|
|
353
637
|
//#endregion
|
|
354
638
|
//#region src/codex/detect.ts
|
|
355
|
-
async function detectCodex(cwd =
|
|
639
|
+
async function detectCodex(cwd = AGENTS_CONFIG.codex.path) {
|
|
356
640
|
const globalState = await readJSON(GLOBAL_STATE_PATH);
|
|
357
641
|
const sqlitePath = await getDatabasePath(cwd);
|
|
358
642
|
const data = sqlitePath ? await readSQLite(sqlitePath) : [];
|
|
@@ -370,7 +654,7 @@ async function detectCodex(cwd = AGENTS.codex.path) {
|
|
|
370
654
|
};
|
|
371
655
|
}
|
|
372
656
|
function normalizeTitle(thread) {
|
|
373
|
-
return thread.title.replace(/\n/g, " ").replace(thread.cwd, "").replace(
|
|
657
|
+
return thread.title.replace(/\n/g, " ").replace(thread.cwd, "").replace(AGENTS_CONFIG.codex.path, "").trim();
|
|
374
658
|
}
|
|
375
659
|
|
|
376
660
|
//#endregion
|
|
@@ -408,18 +692,21 @@ function groupCodexThreads(threads) {
|
|
|
408
692
|
//#endregion
|
|
409
693
|
//#region src/codex/index.ts
|
|
410
694
|
async function promptCodex(_options) {
|
|
695
|
+
const spinner = p.spinner();
|
|
696
|
+
spinner.start("detecting codex threads...");
|
|
411
697
|
const { threads, globalState, sqlitePath } = await detectCodex();
|
|
698
|
+
spinner.stop(`detected ${c.yellow`${threads.length}`} threads`);
|
|
699
|
+
if (threads.length === 0) {
|
|
700
|
+
p.outro(c.yellow("no threads found"));
|
|
701
|
+
process.exit(0);
|
|
702
|
+
}
|
|
412
703
|
const resolved = await promptGroupedMultiSelect(formatThreadGroupOptions(groupCodexThreads(threads)));
|
|
413
|
-
if (resolved === null) {
|
|
704
|
+
if (resolved === null || resolved.length === 0) {
|
|
414
705
|
p.outro(c.red("aborting"));
|
|
415
706
|
process.exit(1);
|
|
416
707
|
}
|
|
417
|
-
if (resolved.length === 0) {
|
|
418
|
-
p.outro(c.yellow("no threads selected"));
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
708
|
const confirmed = await p.confirm({
|
|
422
|
-
message: `
|
|
709
|
+
message: `selected ${c.yellow`${resolved.length}`} records, continue?`,
|
|
423
710
|
initialValue: true
|
|
424
711
|
});
|
|
425
712
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
@@ -461,21 +748,49 @@ function normalizeConfig(options) {
|
|
|
461
748
|
async function resolveConfig(options) {
|
|
462
749
|
const defaults = structuredClone(DEFAULT_OPTIONS);
|
|
463
750
|
options = normalizeConfig(options);
|
|
464
|
-
|
|
751
|
+
const merged = {
|
|
465
752
|
...defaults,
|
|
466
753
|
...options
|
|
467
754
|
};
|
|
755
|
+
const agents = AGENTS_CHOICES.find((agent) => agent === options.agents);
|
|
756
|
+
if (agents) merged.agents = agents;
|
|
757
|
+
else merged.agents = await resolveAgent();
|
|
758
|
+
return merged;
|
|
759
|
+
}
|
|
760
|
+
async function resolveAgent() {
|
|
761
|
+
const selected = await p.select({
|
|
762
|
+
message: "select agent to clean",
|
|
763
|
+
options: AGENTS_CHOICES.map((agent) => ({
|
|
764
|
+
value: agent,
|
|
765
|
+
label: AGENTS_CONFIG[agent].name,
|
|
766
|
+
hint: AGENTS_CONFIG[agent].path
|
|
767
|
+
}))
|
|
768
|
+
});
|
|
769
|
+
if (p.isCancel(selected)) {
|
|
770
|
+
p.outro(c.red("aborting"));
|
|
771
|
+
process.exit(1);
|
|
772
|
+
}
|
|
773
|
+
return selected;
|
|
468
774
|
}
|
|
469
775
|
|
|
470
776
|
//#endregion
|
|
471
777
|
//#region src/cli.ts
|
|
472
778
|
try {
|
|
473
779
|
const cli = cac(NAME);
|
|
474
|
-
cli.command("", "Clean and remove AI chat with an interactive terminal UI").allowUnknownOptions().action(async (options) => {
|
|
780
|
+
cli.command("", "Clean and remove AI chat with an interactive terminal UI").option("--agent, -a <agent>", "Agent to clean").allowUnknownOptions().action(async (options) => {
|
|
475
781
|
p.intro(`${c.yellow`${NAME} `}${c.dim`v${VERSION}`}`);
|
|
476
782
|
const config = await resolveConfig(options);
|
|
477
|
-
|
|
478
|
-
|
|
783
|
+
switch (config.agents) {
|
|
784
|
+
case "codex":
|
|
785
|
+
await promptCodex(config);
|
|
786
|
+
break;
|
|
787
|
+
case "claude-code":
|
|
788
|
+
await promptClaudeCode(config);
|
|
789
|
+
break;
|
|
790
|
+
default:
|
|
791
|
+
p.outro(`unknown agent: ${c.red(config.agents)}`);
|
|
792
|
+
process.exit(1);
|
|
793
|
+
}
|
|
479
794
|
});
|
|
480
795
|
cli.help();
|
|
481
796
|
cli.version(VERSION);
|
package/dist/index.d.mts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
//#region src/constants.d.ts
|
|
2
|
-
declare const AGENTS_CHOICES: readonly ["codex"];
|
|
2
|
+
declare const AGENTS_CHOICES: readonly ["codex", "claude-code"];
|
|
3
3
|
//#endregion
|
|
4
4
|
//#region src/types.d.ts
|
|
5
5
|
interface CommandOptions {
|
|
6
|
-
|
|
6
|
+
agents?: AgentType;
|
|
7
7
|
}
|
|
8
|
+
interface Options extends Required<CommandOptions> {}
|
|
8
9
|
type AgentType = typeof AGENTS_CHOICES[number];
|
|
9
10
|
interface AgentConfig {
|
|
10
11
|
name: string;
|
|
@@ -14,4 +15,4 @@ interface AgentConfig {
|
|
|
14
15
|
//#region src/index.d.ts
|
|
15
16
|
declare function defineConfig(config: Partial<CommandOptions>): Partial<CommandOptions>;
|
|
16
17
|
//#endregion
|
|
17
|
-
export { AgentConfig, AgentType, CommandOptions, defineConfig };
|
|
18
|
+
export { AgentConfig, AgentType, CommandOptions, Options, defineConfig };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-chat-cleaner",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0
|
|
4
|
+
"version": "0.1.0",
|
|
5
5
|
"description": "Clean and remove AI chat with an interactive terminal UI.",
|
|
6
6
|
"author": "jinghaihan",
|
|
7
7
|
"license": "MIT",
|
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/jinghaihan/ai-chat-cleaner/issues"
|
|
15
15
|
},
|
|
16
|
-
"keywords": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"codex",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"conversation",
|
|
20
|
+
"threads",
|
|
21
|
+
"history",
|
|
22
|
+
"cleaner"
|
|
23
|
+
],
|
|
17
24
|
"exports": {
|
|
18
25
|
".": "./dist/index.mjs",
|
|
19
26
|
"./cli": "./dist/cli.mjs",
|