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 +21 -0
- package/README.md +43 -0
- package/bin/cli.mjs +3 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +200 -0
- package/dist/index.d.mts +17 -0
- package/dist/index.mjs +7 -0
- package/package.json +69 -0
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
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 { };
|
package/dist/index.d.mts
ADDED
|
@@ -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
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
|
+
}
|