ai-chat-cleaner 0.0.0-alpha.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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jing Haihan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # ai-chat-cleaner
2
+
3
+ [![npm version][npm-version-src]][npm-version-href]
4
+ [![bundle][bundle-src]][bundle-href]
5
+ [![JSDocs][jsdocs-src]][jsdocs-href]
6
+ [![License][license-src]][license-href]
7
+
8
+ Clean and remove AI chat with an interactive terminal UI.
9
+
10
+ ```sh
11
+ npx ai-chat-cleaner
12
+ ```
13
+
14
+ - Supported agents:
15
+ - `codex`
16
+
17
+ > [!WARNING]
18
+ > Please restart your AI coding tool after deletion.
19
+ >
20
+ > It is recommended to clean history while Codex is not running, to avoid duplicate writes.
21
+
22
+ ## Why ?
23
+
24
+ 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.
25
+
26
+ But in practice, one conversation that goes in the wrong direction can keep affecting later outputs, so I built this tool.
27
+
28
+ ## License
29
+
30
+ [MIT](./LICENSE) License © [jinghaihan](https://github.com/jinghaihan)
31
+
32
+ <!-- Badges -->
33
+
34
+ [npm-version-src]: https://img.shields.io/npm/v/ai-chat-cleaner?style=flat&colorA=080f12&colorB=1fa669
35
+ [npm-version-href]: https://npmjs.com/package/ai-chat-cleaner
36
+ [npm-downloads-src]: https://img.shields.io/npm/dm/ai-chat-cleaner?style=flat&colorA=080f12&colorB=1fa669
37
+ [npm-downloads-href]: https://npmjs.com/package/ai-chat-cleaner
38
+ [bundle-src]: https://img.shields.io/bundlephobia/minzip/ai-chat-cleaner?style=flat&colorA=080f12&colorB=1fa669&label=minzip
39
+ [bundle-href]: https://bundlephobia.com/result?p=ai-chat-cleaner
40
+ [license-src]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat&colorA=080f12&colorB=1fa669
41
+ [license-href]: https://github.com/jinghaihan/ai-chat-cleaner/LICENSE
42
+ [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669
43
+ [jsdocs-href]: https://www.jsdocs.io/package/ai-chat-cleaner
package/bin/cli.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+ import '../dist/cli.mjs'
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ export { };
package/dist/cli.mjs ADDED
@@ -0,0 +1,200 @@
1
+ import process from "node:process";
2
+ import * as p from "@clack/prompts";
3
+ import c from "ansis";
4
+ import { cac } from "cac";
5
+ import { execFile } from "node:child_process";
6
+ import { readFile, writeFile } from "node:fs/promises";
7
+ import { promisify } from "node:util";
8
+ import pLimit from "p-limit";
9
+ import { join } from "pathe";
10
+ import { rimraf } from "rimraf";
11
+ import { homedir } from "node:os";
12
+ import { glob } from "tinyglobby";
13
+
14
+ //#region src/utils.ts
15
+ const exec = promisify(execFile);
16
+ async function readJSON(filepath) {
17
+ const content = await readFile(filepath, "utf-8");
18
+ return JSON.parse(content);
19
+ }
20
+ async function writeJSON(filepath, data) {
21
+ await writeFile(filepath, JSON.stringify(data, null, 2), "utf-8");
22
+ }
23
+ function formatRelativeTime(date) {
24
+ const diff = (/* @__PURE__ */ new Date(date * 1e3)).getTime() - Date.now();
25
+ const seconds = Math.round(diff / 1e3);
26
+ const minutes = Math.round(seconds / 60);
27
+ const hours = Math.round(minutes / 60);
28
+ const days = Math.round(hours / 24);
29
+ const months = Math.round(days / 30);
30
+ const years = Math.round(days / 365);
31
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
32
+ if (Math.abs(seconds) < 60) return rtf.format(seconds, "second");
33
+ if (Math.abs(minutes) < 60) return rtf.format(minutes, "minute");
34
+ if (Math.abs(hours) < 24) return rtf.format(hours, "hour");
35
+ if (Math.abs(days) < 30) return rtf.format(days, "day");
36
+ if (Math.abs(months) < 12) return rtf.format(months, "month");
37
+ return rtf.format(years, "year");
38
+ }
39
+ function quoteSqlString(value) {
40
+ return `'${value.replaceAll("'", "''")}'`;
41
+ }
42
+
43
+ //#endregion
44
+ //#region package.json
45
+ var name = "ai-chat-cleaner";
46
+ var version = "0.0.0-alpha.0";
47
+
48
+ //#endregion
49
+ //#region src/constants.ts
50
+ const NAME = name;
51
+ const VERSION = version;
52
+ const DEFAULT_OPTIONS = {};
53
+ const AGENTS = { codex: {
54
+ name: "codex",
55
+ path: join(homedir(), ".codex")
56
+ } };
57
+
58
+ //#endregion
59
+ //#region src/codex/constants.ts
60
+ const GLOBAL_STATE_PATH = join(AGENTS.codex.path, ".codex-global-state.json");
61
+ const HISTORY_FILE_PATH = join(AGENTS.codex.path, "history.jsonl");
62
+ const SHELL_SNAPSHOTS_PATH = join(AGENTS.codex.path, "shell_snapshots");
63
+
64
+ //#endregion
65
+ //#region src/codex/db.ts
66
+ async function readSQLite(filepath) {
67
+ const { stdout } = await exec("sqlite3", [
68
+ "-json",
69
+ filepath,
70
+ "SELECT * FROM threads;"
71
+ ]);
72
+ return JSON.parse(stdout.trim());
73
+ }
74
+ async function writeSQLite(filepath, ids) {
75
+ if (ids.length === 0) return;
76
+ await exec("sqlite3", [filepath, `DELETE FROM threads WHERE id IN (${ids.map(quoteSqlString).join(", ")});`]);
77
+ }
78
+ async function getDatabasePath(cwd) {
79
+ return (await glob("state_*.sqlite", {
80
+ cwd,
81
+ absolute: true,
82
+ onlyFiles: true
83
+ })).filter((file) => /state_\d+\.sqlite$/i.test(file)).sort((a, b) => extractVersion(b) - extractVersion(a))[0] || null;
84
+ }
85
+ function extractVersion(path) {
86
+ const matched = path.match(/state_(\d+)\.sqlite$/i);
87
+ if (!matched) return 0;
88
+ const version = Number.parseInt(matched[1], 10);
89
+ return Number.isFinite(version) ? version : 0;
90
+ }
91
+
92
+ //#endregion
93
+ //#region src/codex/delete.ts
94
+ async function deleteThread(thread) {
95
+ const id = thread.id;
96
+ await rimraf(thread.rollout_path);
97
+ await rimraf(join(SHELL_SNAPSHOTS_PATH, `${id}.sh`));
98
+ }
99
+ async function deleteThreads({ threads, globalState, sqlitePath }) {
100
+ const threadIds = new Set(threads.map((thread) => thread.id));
101
+ const limit = pLimit(5);
102
+ await Promise.all(threads.map((thread) => limit(() => deleteThread(thread))));
103
+ updateGlobalState(threadIds, globalState);
104
+ await updateHistory(HISTORY_FILE_PATH, Array.from(threadIds));
105
+ if (sqlitePath) await writeSQLite(sqlitePath, Array.from(threadIds));
106
+ }
107
+ async function updateGlobalState(threadIds, globalState) {
108
+ for (const id of threadIds) delete globalState["thread-titles"].titles[id];
109
+ globalState["thread-titles"].order = globalState["thread-titles"].order.filter((id) => !threadIds.has(id));
110
+ await writeJSON(GLOBAL_STATE_PATH, globalState);
111
+ }
112
+ async function updateHistory(path, ids) {
113
+ const remove = new Set(ids);
114
+ const rows = (await readFile(path, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line)).filter((row) => !remove.has(row?.session_id));
115
+ await writeFile(HISTORY_FILE_PATH, rows.length > 0 ? `${rows.map((row) => JSON.stringify(row)).join("\n")}\n` : "", "utf-8");
116
+ }
117
+
118
+ //#endregion
119
+ //#region src/codex/detect.ts
120
+ async function detectCodex(cwd = AGENTS.codex.path) {
121
+ const globalState = await readJSON(GLOBAL_STATE_PATH);
122
+ const sqlitePath = await getDatabasePath(cwd);
123
+ const data = sqlitePath ? await readSQLite(sqlitePath) : [];
124
+ const titles = globalState["thread-titles"].titles;
125
+ return {
126
+ threads: data.filter((i) => i.title || i.id in titles).sort((a, b) => a.updated_at > b.updated_at ? -1 : 1).map((thread) => {
127
+ const title = titles[thread.id] || normalizeTitle(thread);
128
+ return {
129
+ ...thread,
130
+ title
131
+ };
132
+ }),
133
+ globalState,
134
+ sqlitePath
135
+ };
136
+ }
137
+ function normalizeTitle(thread) {
138
+ return thread.title.replace(/\n/g, " ").replace(thread.cwd, "").replace(AGENTS.codex.path, "").trim();
139
+ }
140
+
141
+ //#endregion
142
+ //#region src/codex/index.ts
143
+ async function promptCodex(_options) {
144
+ const { threads, globalState, sqlitePath } = await detectCodex();
145
+ const resolved = await p.multiselect({
146
+ message: `found ${c.yellow`${threads.length}`} threads`,
147
+ options: threads.map((thread) => ({
148
+ label: thread.title,
149
+ hint: formatRelativeTime(thread.updated_at || thread.created_at),
150
+ value: thread
151
+ }))
152
+ });
153
+ if (p.isCancel(resolved)) {
154
+ p.outro(c.red("aborting"));
155
+ process.exit(1);
156
+ return;
157
+ }
158
+ await deleteThreads({
159
+ threads: resolved,
160
+ globalState,
161
+ sqlitePath
162
+ });
163
+ p.outro(`cleaned ${c.yellow`${resolved.length}`} threads`);
164
+ }
165
+
166
+ //#endregion
167
+ //#region src/config.ts
168
+ function normalizeConfig(options) {
169
+ if ("default" in options) options = options.default;
170
+ return options;
171
+ }
172
+ async function resolveConfig(options) {
173
+ const defaults = structuredClone(DEFAULT_OPTIONS);
174
+ options = normalizeConfig(options);
175
+ return {
176
+ ...defaults,
177
+ ...options
178
+ };
179
+ }
180
+
181
+ //#endregion
182
+ //#region src/cli.ts
183
+ try {
184
+ const cli = cac(NAME);
185
+ cli.command("", "Clean and remove AI chat with an interactive terminal UI").allowUnknownOptions().action(async (options) => {
186
+ p.intro(`${c.yellow`${NAME} `}${c.dim`v${VERSION}`}`);
187
+ const config = await resolveConfig(options);
188
+ p.log.info(`start detecting ${c.yellow`Codex`} threads...`);
189
+ await promptCodex(config);
190
+ });
191
+ cli.help();
192
+ cli.version(VERSION);
193
+ cli.parse();
194
+ } catch (error) {
195
+ console.error(error);
196
+ process.exit(1);
197
+ }
198
+
199
+ //#endregion
200
+ export { };
@@ -0,0 +1,17 @@
1
+ //#region src/constants.d.ts
2
+ declare const AGENTS_CHOICES: readonly ["codex"];
3
+ //#endregion
4
+ //#region src/types.d.ts
5
+ interface CommandOptions {
6
+ cwd?: string;
7
+ }
8
+ type AgentType = typeof AGENTS_CHOICES[number];
9
+ interface AgentConfig {
10
+ name: string;
11
+ path: string;
12
+ }
13
+ //#endregion
14
+ //#region src/index.d.ts
15
+ declare function defineConfig(config: Partial<CommandOptions>): Partial<CommandOptions>;
16
+ //#endregion
17
+ export { AgentConfig, AgentType, CommandOptions, defineConfig };
package/dist/index.mjs ADDED
@@ -0,0 +1,7 @@
1
+ //#region src/index.ts
2
+ function defineConfig(config) {
3
+ return config;
4
+ }
5
+
6
+ //#endregion
7
+ export { defineConfig };
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "ai-chat-cleaner",
3
+ "type": "module",
4
+ "version": "0.0.0-alpha.0",
5
+ "description": "Clean and remove AI chat with an interactive terminal UI.",
6
+ "author": "jinghaihan",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/jinghaihan/ai-chat-cleaner#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/jinghaihan/ai-chat-cleaner.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/jinghaihan/ai-chat-cleaner/issues"
15
+ },
16
+ "keywords": [],
17
+ "exports": {
18
+ ".": "./dist/index.mjs",
19
+ "./cli": "./dist/cli.mjs",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "types": "./dist/index.d.mts",
23
+ "bin": {
24
+ "ai-chat-cleaner": "./bin/cli.mjs"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "dist"
29
+ ],
30
+ "dependencies": {
31
+ "@clack/prompts": "^1.0.1",
32
+ "ansis": "^4.2.0",
33
+ "cac": "^7.0.0",
34
+ "p-limit": "^7.3.0",
35
+ "pathe": "^2.0.3",
36
+ "rimraf": "^6.1.3",
37
+ "tinyglobby": "^0.2.15"
38
+ },
39
+ "devDependencies": {
40
+ "tsdown": "^0.20.3",
41
+ "bumpp": "^10.4.1",
42
+ "pncat": "^0.10.4",
43
+ "simple-git-hooks": "^2.13.1",
44
+ "taze": "^19.9.2",
45
+ "tsx": "^4.21.0",
46
+ "typescript": "^5.9.3",
47
+ "@octohash/eslint-config": "^0.2.4",
48
+ "eslint": "^10.0.2",
49
+ "lint-staged": "^16.3.0",
50
+ "vitest": "^4.0.18",
51
+ "@types/node": "^25.3.2"
52
+ },
53
+ "simple-git-hooks": {
54
+ "pre-commit": "pnpm lint-staged"
55
+ },
56
+ "lint-staged": {
57
+ "*": "eslint --fix"
58
+ },
59
+ "scripts": {
60
+ "start": "tsx ./src/cli.ts",
61
+ "build": "tsdown",
62
+ "deps": "taze major -I",
63
+ "lint": "eslint",
64
+ "typecheck": "tsc --noEmit",
65
+ "test": "vitest",
66
+ "release": "bumpp",
67
+ "bootstrap": "pnpm install"
68
+ }
69
+ }