residoo 0.1.0 → 0.3.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 +334 -46
- package/SECURITY.md +29 -22
- package/package.json +1 -1
- package/src/cli.js +249 -17
- package/src/integrity.js +689 -0
- package/src/patterns.js +78 -5
- package/src/report.js +188 -8
- package/src/rotation.js +834 -0
- package/src/sources/agent-configs.js +308 -0
- package/src/sources/aider.js +361 -0
- package/src/sources/amazon-q.js +199 -0
- package/src/sources/antigravity-cli.js +155 -0
- package/src/sources/cline.js +208 -0
- package/src/sources/codebuff.js +295 -0
- package/src/sources/codex-cli.js +258 -0
- package/src/sources/cody.js +325 -0
- package/src/sources/continue.js +408 -0
- package/src/sources/copilot-chat.js +272 -0
- package/src/sources/copilot-cli.js +300 -0
- package/src/sources/crush.js +364 -0
- package/src/sources/cursor.js +374 -0
- package/src/sources/devin-cli.js +241 -0
- package/src/sources/factory-droid.js +153 -0
- package/src/sources/fx.js +136 -0
- package/src/sources/gemini-cli.js +242 -0
- package/src/sources/goose.js +366 -0
- package/src/sources/grok-cli.js +267 -0
- package/src/sources/hermes.js +282 -0
- package/src/sources/index.js +172 -8
- package/src/sources/jetbrains-ai-assistant.js +343 -0
- package/src/sources/jetbrains-junie.js +292 -0
- package/src/sources/kilo-code.js +430 -0
- package/src/sources/kimi-code.js +147 -0
- package/src/sources/kiro-cli.js +393 -0
- package/src/sources/kiro-ide.js +230 -0
- package/src/sources/llm.js +328 -0
- package/src/sources/mentat.js +143 -0
- package/src/sources/open-interpreter.js +224 -0
- package/src/sources/openclaw.js +218 -0
- package/src/sources/opencode.js +379 -0
- package/src/sources/openhands.js +181 -0
- package/src/sources/pearai.js +151 -0
- package/src/sources/pi-agent.js +130 -0
- package/src/sources/project-artifacts.js +355 -0
- package/src/sources/qodo-gen.js +189 -0
- package/src/sources/qwen-code.js +244 -0
- package/src/sources/roo-code.js +239 -0
- package/src/sources/trae.js +294 -0
- package/src/sources/void.js +273 -0
- package/src/sources/warp.js +395 -0
- package/src/sources/windsurf.js +256 -0
- package/src/sources/zed.js +374 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Codebuff (codebuff.com, formerly named "Manicode" — the on-disk config
|
|
9
|
+
* directory still uses the old name) local chat history.
|
|
10
|
+
*
|
|
11
|
+
* VERIFICATION STATUS: NOT checked against a real Codebuff install — neither
|
|
12
|
+
* `codebuff` nor `manicode` is on PATH, and no `~/.config/manicode*`
|
|
13
|
+
* directory exists on the machine this adapter was built on (checked PATH,
|
|
14
|
+
* npm -g, mdfind, the paths below directly). Ships anyway per
|
|
15
|
+
* CONTRIBUTING.md rule 3, on the strength of three independent, current,
|
|
16
|
+
* mutually-corroborating sources:
|
|
17
|
+
*
|
|
18
|
+
* 1. Codebuff's own official docs (codebuff.com/docs/advanced), which
|
|
19
|
+
* document the `CODEBUFF_DATA_DIR` override and the fact that history is
|
|
20
|
+
* scoped per "channel" (production/dev/staging).
|
|
21
|
+
* 2. A real, independent, third-party tool's own written documentation of
|
|
22
|
+
* the format: CodexBar (github.com/steipete/CodexBar,
|
|
23
|
+
* docs/codebuff.md), which states conversation history "is stored
|
|
24
|
+
* locally at ~/.config/manicode/projects/<project-name>/chats" and that
|
|
25
|
+
* `~/.config/manicode/credentials.json` is written after `codebuff
|
|
26
|
+
* login` — the "formerly manicode" naming and directory layout agree
|
|
27
|
+
* exactly with source 3 below.
|
|
28
|
+
* 3. The strongest source: ccusage (github.com/ccusage/ccusage, a real,
|
|
29
|
+
* independently maintained, actively developed CLI usage tracker with
|
|
30
|
+
* 18k+ real GitHub stars — sanity-checked directly, not assumed) ships
|
|
31
|
+
* its OWN tested Rust adapter for Codebuff
|
|
32
|
+
* (rust/adapters/codebuff/src/{paths,parser,loader}.rs), fetched and
|
|
33
|
+
* read directly, not summarized secondhand. Its unit tests embed a real
|
|
34
|
+
* fixture file at
|
|
35
|
+
* `projects/project-a/chats/2026-01-02T03-04-05.000Z/chat-messages.json`
|
|
36
|
+
* containing actual message-object shapes
|
|
37
|
+
* (`{"role":"user","text":"hello"}`,
|
|
38
|
+
* `{"id":"...","role":"assistant","timestamp":"...","metadata":{"model":"...","usage":{...}},"credits":1.25}`)
|
|
39
|
+
* — this is a real, working, unrelated tool's reverse-engineered
|
|
40
|
+
* understanding of the exact same file this source reads, the same
|
|
41
|
+
* evidentiary bar cursor.js's own docstring cites approvingly for its
|
|
42
|
+
* own two corroborating community tools.
|
|
43
|
+
*
|
|
44
|
+
* Directory layout (agreed by all three sources): one root per "channel" —
|
|
45
|
+
* `~/.config/manicode` (production), `~/.config/manicode-dev`,
|
|
46
|
+
* `~/.config/manicode-staging` — each containing
|
|
47
|
+
* `projects/<project>/chats/<chatId>/chat-messages.json`, overridable via
|
|
48
|
+
* the `CODEBUFF_DATA_DIR` env var (a comma-separated list of channel roots;
|
|
49
|
+
* source 1 and source 3 agree on both the env var name and its comma-list
|
|
50
|
+
* shape). `chat-messages.json` is a JSON ARRAY of message objects — not
|
|
51
|
+
* line-delimited — so, per the adapter-contract note about non-line-
|
|
52
|
+
* delimited storage, each message object becomes one scanned "line" (see
|
|
53
|
+
* extractTopLevelJsonObjects() below).
|
|
54
|
+
*
|
|
55
|
+
* Deliberately NOT scanned: `credentials.json` (Codebuff's own CLI auth
|
|
56
|
+
* token for the logged-in account) — it is not a session transcript, and
|
|
57
|
+
* every other source in this project scans transcripts only, not each
|
|
58
|
+
* tool's own credential store (cursor.js, for instance, does not read
|
|
59
|
+
* Cursor's OS keychain entries either).
|
|
60
|
+
*/
|
|
61
|
+
const CODEBUFF_DATA_DIR_ENV = "CODEBUFF_DATA_DIR";
|
|
62
|
+
const CHANNELS = ["manicode", "manicode-dev", "manicode-staging"];
|
|
63
|
+
|
|
64
|
+
function id() { return "codebuff"; }
|
|
65
|
+
function label() { return "Codebuff"; }
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the "projects" root for every configured channel, deduped.
|
|
69
|
+
*
|
|
70
|
+
* Mirrors ccusage's own `codebuff_project_roots()` exactly: when
|
|
71
|
+
* CODEBUFF_DATA_DIR is set, each comma-separated entry is used as-is if its
|
|
72
|
+
* basename is already "projects", otherwise "projects" is appended — this
|
|
73
|
+
* lets a user point the env var either at a channel root or directly at its
|
|
74
|
+
* projects subdirectory, matching Codebuff's own documented flexibility.
|
|
75
|
+
*/
|
|
76
|
+
function codebuffProjectRoots() {
|
|
77
|
+
const envVal = process.env[CODEBUFF_DATA_DIR_ENV];
|
|
78
|
+
let roots;
|
|
79
|
+
if (envVal && envVal.trim() !== "") {
|
|
80
|
+
roots = envVal
|
|
81
|
+
.split(",")
|
|
82
|
+
.map((s) => s.trim())
|
|
83
|
+
.filter((s) => s !== "")
|
|
84
|
+
.map((p) => path.resolve(p));
|
|
85
|
+
} else {
|
|
86
|
+
const home = os.homedir();
|
|
87
|
+
roots = CHANNELS.map((channel) => path.join(home, ".config", channel));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const projectRoots = [];
|
|
92
|
+
for (const root of roots) {
|
|
93
|
+
const projectRoot = path.basename(root) === "projects" ? root : path.join(root, "projects");
|
|
94
|
+
let isDir = false;
|
|
95
|
+
try { isDir = fs.statSync(projectRoot).isDirectory(); } catch { isDir = false; }
|
|
96
|
+
if (isDir && !seen.has(projectRoot)) {
|
|
97
|
+
seen.add(projectRoot);
|
|
98
|
+
projectRoots.push(projectRoot);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return projectRoots;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function available() {
|
|
105
|
+
return codebuffProjectRoots().length > 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Same defensive symlink-following pattern as claude-code.js's
|
|
110
|
+
* isDirFollowingSymlink/isFileFollowingSymlink — duplicated rather than
|
|
111
|
+
* imported per this project's one-small-self-contained-file-per-source
|
|
112
|
+
* convention (see cursor.js's own docstring for why).
|
|
113
|
+
*/
|
|
114
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
115
|
+
if (checkFn(dirent)) return true;
|
|
116
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
117
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve one candidate `chat-messages.json` path into zero or one files()
|
|
123
|
+
* entries — same lstat-first, follow-if-symlink shape as cursor.js's
|
|
124
|
+
* statIfPresent, and for the same reason: this path is constructed (joined
|
|
125
|
+
* onto an already-resolved chat directory), not discovered via a Dirent, so
|
|
126
|
+
* there is no Dirent to reuse the isKindFollowingSymlink check against. A
|
|
127
|
+
* chat directory with no chat-messages.json yet (a brand new, still-empty
|
|
128
|
+
* chat) is normal and NOT broken; only a symlink that fails to resolve is.
|
|
129
|
+
*/
|
|
130
|
+
function* statFileIfPresent(file) {
|
|
131
|
+
let lst;
|
|
132
|
+
try { lst = fs.lstatSync(file); }
|
|
133
|
+
catch { return; }
|
|
134
|
+
|
|
135
|
+
if (lst.isSymbolicLink()) {
|
|
136
|
+
try {
|
|
137
|
+
const st = fs.statSync(file);
|
|
138
|
+
if (!st.isFile()) { yield { file, broken: true }; return; }
|
|
139
|
+
yield { file, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
|
|
140
|
+
} catch {
|
|
141
|
+
yield { file, broken: true };
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!lst.isFile()) return;
|
|
147
|
+
yield { file, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every chat-messages.json
|
|
152
|
+
* found under every configured channel's projects root:
|
|
153
|
+
* <projectsRoot>/<project>/chats/<chatId>/chat-messages.json.
|
|
154
|
+
*/
|
|
155
|
+
function* files() {
|
|
156
|
+
for (const projectsRoot of codebuffProjectRoots()) {
|
|
157
|
+
let projectEntries;
|
|
158
|
+
try { projectEntries = fs.readdirSync(projectsRoot, { withFileTypes: true }); }
|
|
159
|
+
catch { continue; }
|
|
160
|
+
|
|
161
|
+
for (const proj of projectEntries) {
|
|
162
|
+
const projDir = path.join(projectsRoot, proj.name);
|
|
163
|
+
if (!isDirFollowingSymlink(projDir, proj)) {
|
|
164
|
+
if (proj.isSymbolicLink()) yield { file: projDir, broken: true };
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const chatsDir = path.join(projDir, "chats");
|
|
169
|
+
let chatEntries;
|
|
170
|
+
try { chatEntries = fs.readdirSync(chatsDir, { withFileTypes: true }); }
|
|
171
|
+
catch { continue; } // no "chats" subdir yet — normal for a project with no chat history
|
|
172
|
+
|
|
173
|
+
for (const chat of chatEntries) {
|
|
174
|
+
const chatDir = path.join(chatsDir, chat.name);
|
|
175
|
+
if (!isDirFollowingSymlink(chatDir, chat)) {
|
|
176
|
+
if (chat.isSymbolicLink()) yield { file: chatDir, broken: true };
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
yield* statFileIfPresent(path.join(chatDir, "chat-messages.json"));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A single chat's message history has not been observed anywhere in this
|
|
186
|
+
// source's research to approach this size — like cursor.js's MAX_DB_BYTES,
|
|
187
|
+
// this is a generous, untested-against-a-real-huge-file backstop against a
|
|
188
|
+
// corrupted or pathological file, not an empirically derived ceiling.
|
|
189
|
+
const MAX_BYTES = 512 * 1024 * 1024;
|
|
190
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read the whole file as text, bounded by a wall-clock deadline the same
|
|
194
|
+
* way claude-code.js bounds its line-by-line read: no timeout exists
|
|
195
|
+
* natively anywhere in Node's stream stack, so a symlink retargeted onto
|
|
196
|
+
* something with no natural EOF (e.g. a FIFO with no writer) would otherwise
|
|
197
|
+
* hang forever with no 'error' or 'end' ever firing. Destroying the stream
|
|
198
|
+
* is what actually unblocks that.
|
|
199
|
+
*
|
|
200
|
+
* Resolves with whatever text WAS accumulated even when the read errors or
|
|
201
|
+
* times out partway — the caller (readLines) still extracts every complete
|
|
202
|
+
* top-level JSON object out of that partial text rather than discarding it,
|
|
203
|
+
* the same "partial read is still real content" principle claude-code.js's
|
|
204
|
+
* and cursor.js's own docstrings insist on.
|
|
205
|
+
*/
|
|
206
|
+
function readWholeFileBounded(file) {
|
|
207
|
+
return new Promise((resolve) => {
|
|
208
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
209
|
+
let text = "";
|
|
210
|
+
let bytesRead = 0;
|
|
211
|
+
let errored = false;
|
|
212
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
213
|
+
stream.on("data", (chunk) => {
|
|
214
|
+
text += chunk;
|
|
215
|
+
bytesRead += Buffer.byteLength(chunk, "utf-8");
|
|
216
|
+
});
|
|
217
|
+
stream.on("end", () => {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
resolve({ text, bytesRead, complete: true });
|
|
220
|
+
});
|
|
221
|
+
stream.on("error", () => {
|
|
222
|
+
if (errored) return; // 'error' can fire once for the destroy() and once natively
|
|
223
|
+
errored = true;
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
resolve({ text, bytesRead, complete: false });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Extract every complete top-level `{...}` object out of arbitrary JSON
|
|
232
|
+
* text, tracking brace depth and string/escape state, WITHOUT requiring the
|
|
233
|
+
* surrounding `[ ... ]` array to be syntactically complete.
|
|
234
|
+
*
|
|
235
|
+
* This is what lets a partial read (timeout, mid-write file, I/O error)
|
|
236
|
+
* still surface every message that was fully written before the cutoff,
|
|
237
|
+
* instead of the whole file being discarded because JSON.parse on truncated
|
|
238
|
+
* input throws. It is also what lets a fully-successful read skip a real
|
|
239
|
+
* JSON.parse of the whole array entirely: each returned substring is used
|
|
240
|
+
* verbatim as one scanned "line", the same "don't re-serialize, keep the
|
|
241
|
+
* exact bytes the regexes depend on" reasoning cursor.js's valueToText()
|
|
242
|
+
* docstring gives for not round-tripping through JSON.parse/stringify.
|
|
243
|
+
*/
|
|
244
|
+
function extractTopLevelJsonObjects(text) {
|
|
245
|
+
const objects = [];
|
|
246
|
+
let depth = 0;
|
|
247
|
+
let start = -1;
|
|
248
|
+
let inString = false;
|
|
249
|
+
let escape = false;
|
|
250
|
+
for (let i = 0; i < text.length; i++) {
|
|
251
|
+
const ch = text[i];
|
|
252
|
+
if (inString) {
|
|
253
|
+
if (escape) escape = false;
|
|
254
|
+
else if (ch === "\\") escape = true;
|
|
255
|
+
else if (ch === '"') inString = false;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (ch === '"') { inString = true; continue; }
|
|
259
|
+
if (ch === "{") {
|
|
260
|
+
if (depth === 0) start = i;
|
|
261
|
+
depth++;
|
|
262
|
+
} else if (ch === "}") {
|
|
263
|
+
if (depth > 0) depth--;
|
|
264
|
+
if (depth === 0 && start !== -1) {
|
|
265
|
+
objects.push(text.slice(start, i + 1));
|
|
266
|
+
start = -1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return objects;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Read one chat-messages.json as an array of raw text "lines", one per
|
|
275
|
+
* top-level message object. Returns { lines, status, bytesRead } with the
|
|
276
|
+
* same status vocabulary as claude-code.js/cursor.js: "complete", "partial",
|
|
277
|
+
* "too-large", "failed".
|
|
278
|
+
*/
|
|
279
|
+
async function readLines(file) {
|
|
280
|
+
let stat;
|
|
281
|
+
try { stat = fs.statSync(file); }
|
|
282
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
283
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
284
|
+
|
|
285
|
+
const { text, bytesRead, complete } = await readWholeFileBounded(file);
|
|
286
|
+
const lines = extractTopLevelJsonObjects(text);
|
|
287
|
+
|
|
288
|
+
if (complete) return { lines, status: "complete", bytesRead };
|
|
289
|
+
// Errored or timed out partway: whatever complete objects WERE recovered
|
|
290
|
+
// are real content and may contain a real secret — never discard them
|
|
291
|
+
// just because the tail of the file didn't finish cleanly.
|
|
292
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = { id, label, available, files, readLines };
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const { createInterface } = require("readline/promises");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OpenAI's Codex CLI (the `codex` coding-agent binary — see
|
|
10
|
+
* https://github.com/openai/codex — NOT the retired "Codex" language model
|
|
11
|
+
* from 2021). Also referred to below as "Codex CLI" throughout to keep that
|
|
12
|
+
* distinction unambiguous.
|
|
13
|
+
*
|
|
14
|
+
* VERIFICATION STATUS: this source was NOT checked against a real Codex CLI
|
|
15
|
+
* install — `codex` is not installed on the machine this adapter was built
|
|
16
|
+
* on (checked: no `codex` on PATH, no `~/.codex`, no Homebrew/npm-global
|
|
17
|
+
* install, mdfind turned up nothing for a local CLI install — the only
|
|
18
|
+
* "codex" hits on this machine were the ChatGPT desktop app's own unrelated
|
|
19
|
+
* `com.openai.chat` local cache for its cloud-hosted "Codex" task feature,
|
|
20
|
+
* which is a different product with no local session transcripts of its
|
|
21
|
+
* own to scan; it runs in a remote container, not on this machine). Per
|
|
22
|
+
* CONTRIBUTING.md this ships anyway because it clears that bar a different
|
|
23
|
+
* way: multiple independent, credible, and largely recent sources agree
|
|
24
|
+
* with each other on the exact path and schema below, including official
|
|
25
|
+
* OpenAI documentation, the tool's own GitHub issue tracker describing this
|
|
26
|
+
* exact file layout as a live bug surface, and more than one third-party
|
|
27
|
+
* tool that reads these same files for a living. Specifically:
|
|
28
|
+
*
|
|
29
|
+
* - Official docs (developers.openai.com/codex/environment-variables,
|
|
30
|
+
* redirects to learn.chatgpt.com/docs/config-file/environment-variables):
|
|
31
|
+
* CODEX_HOME "sets the root directory for Codex state, including config,
|
|
32
|
+
* auth, logs, sessions, skills, and standalone package metadata,"
|
|
33
|
+
* defaulting to `~/.codex`.
|
|
34
|
+
* - openai/codex GitHub issue #21660 ("rollout: session JSONL files are
|
|
35
|
+
* created world-readable (mode 0644) on Unix") and issue #20864
|
|
36
|
+
* ("Codex Desktop App becomes laggy because it scans all
|
|
37
|
+
* `~/.codex/sessions` rollout files...") — both filed against the real
|
|
38
|
+
* tool, both independently naming this exact directory.
|
|
39
|
+
* - openai/codex GitHub Discussion #24042, a real, working, open-source
|
|
40
|
+
* native macOS viewer built specifically to read `~/.codex/sessions/
|
|
41
|
+
* *.jsonl`, and community tools codex-trace (PixelPaw-Labs) and
|
|
42
|
+
* codex-history-list (shinshin86) doing the same — the kind of "a
|
|
43
|
+
* maintained tool reads the same files" corroboration CONTRIBUTING.md
|
|
44
|
+
* calls out explicitly.
|
|
45
|
+
* - openai/codex GitHub issue #17000 ("Auto-archive and zstd-compress
|
|
46
|
+
* inactive local rollout files...") independently confirms the
|
|
47
|
+
* `rollout-*.jsonl` naming and the date-partitioned directory shape by
|
|
48
|
+
* proposing changes to it. NOTE: this issue also shows the zstd
|
|
49
|
+
* compression feature it proposes is NOT yet shipped as of this
|
|
50
|
+
* research — a compression detail that appeared in one AI-generated
|
|
51
|
+
* summary was traced back to this still-open proposal, not a shipped
|
|
52
|
+
* behavior, so no zstd decompression is assumed live here. It's still
|
|
53
|
+
* handled defensively below (see ZSTD_RE) in case that changes.
|
|
54
|
+
* - Independent write-ups (a dev.to reverse-engineering post showing real
|
|
55
|
+
* rollout JSONL line shapes, prismmd.app and betelgeuse.work blog posts,
|
|
56
|
+
* and a Codex-Knowledge-Base article on session archiving) all agree on
|
|
57
|
+
* the same `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`
|
|
58
|
+
* shape and on a parallel `~/.codex/archived_sessions/` tree used when a
|
|
59
|
+
* session is archived (the rollout JSONL is moved, not transformed).
|
|
60
|
+
* - Multiple of the above also describe a flat `~/.codex/history.jsonl` —
|
|
61
|
+
* one line per user turn, holding just the raw text the user typed for
|
|
62
|
+
* that turn (not the full conversation) — which is real, secret-scanning
|
|
63
|
+
* -relevant content (a pasted key or token lands here) distinct from the
|
|
64
|
+
* per-session rollout files, so it's read too.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately NOT read: `~/.codex/session_index.jsonl`. Independent
|
|
67
|
+
* sources agree it is a lightweight metadata cache only (id, timestamp,
|
|
68
|
+
* cwd, model, status) that explicitly does NOT duplicate rollout content —
|
|
69
|
+
* scanning it would add file-walk cost with no realistic chance of a
|
|
70
|
+
* secret-bearing line. Also not read: anything under `~/.codex` that isn't
|
|
71
|
+
* one of the three content locations above — chiefly `config.toml`,
|
|
72
|
+
* `auth.json`, and `log/`, which are Codex's own config/credential/log
|
|
73
|
+
* files, not session transcript content, mirroring how claude-code.js and
|
|
74
|
+
* cursor.js each stay scoped to actual conversation data rather than a
|
|
75
|
+
* tool's entire state directory.
|
|
76
|
+
*
|
|
77
|
+
* If you have Codex CLI installed, the most useful thing you can do is run
|
|
78
|
+
* `residoo scan` and confirm `sourcesScanned`/`filesScanned` look right for
|
|
79
|
+
* what you know is actually on disk under `~/.codex`, then report back
|
|
80
|
+
* either way — see CONTRIBUTING.md.
|
|
81
|
+
*/
|
|
82
|
+
function codexHome() {
|
|
83
|
+
// Honoring CODEX_HOME (rather than hardcoding ~/.codex) mirrors how
|
|
84
|
+
// cursor.js honors XDG_CONFIG_HOME — the tool's own documented override,
|
|
85
|
+
// not a guess, and the official docs above are explicit that sessions,
|
|
86
|
+
// not just config, live under this root.
|
|
87
|
+
if (process.env.CODEX_HOME) return process.env.CODEX_HOME;
|
|
88
|
+
return path.join(os.homedir(), ".codex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ROOT = codexHome();
|
|
92
|
+
const SESSIONS_DIR = path.join(ROOT, "sessions");
|
|
93
|
+
const ARCHIVED_SESSIONS_DIR = path.join(ROOT, "archived_sessions");
|
|
94
|
+
const HISTORY_FILE = path.join(ROOT, "history.jsonl");
|
|
95
|
+
|
|
96
|
+
// A rollout file this large has not been reported anywhere in this source's
|
|
97
|
+
// research; kept identical to claude-code.js's bound (same underlying
|
|
98
|
+
// concern — V8's whole-string ceiling doesn't apply here since this source
|
|
99
|
+
// also streams line-by-line, but a shared, already-reasoned-about number
|
|
100
|
+
// beats inventing a new one with no evidence behind it).
|
|
101
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
|
|
102
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
103
|
+
|
|
104
|
+
// Symlink-loop safety for the recursive sessions/archived_sessions walk.
|
|
105
|
+
// claude-code.js never needed a depth bound — its walk is exactly two
|
|
106
|
+
// levels (project dir, then files in it). Walking an arbitrary
|
|
107
|
+
// YYYY/MM/DD(/...)? tree that may itself contain a symlink is a genuinely
|
|
108
|
+
// new risk this source introduces, so it gets a guard claude-code.js didn't
|
|
109
|
+
// need. 12 gives generous headroom over the documented 3-level date
|
|
110
|
+
// partitioning while still bounding a pathological symlink cycle.
|
|
111
|
+
const MAX_WALK_DEPTH = 12;
|
|
112
|
+
|
|
113
|
+
// See the ZSTD note in the module docstring: not confirmed shipped, but
|
|
114
|
+
// handled honestly rather than assumed absent forever. A zero-dependency
|
|
115
|
+
// project has no built-in Zstandard decoder available across the supported
|
|
116
|
+
// Node range (>=18), so a matching file is surfaced as a normal, resolvable
|
|
117
|
+
// file entry (not "broken" — it's not unresolvable, just undecodable by
|
|
118
|
+
// this tool) and readLines() reports it "failed" rather than silently
|
|
119
|
+
// omitting it from the walk.
|
|
120
|
+
const ZSTD_RE = /\.zst$/i;
|
|
121
|
+
const JSONL_RE = /\.jsonl$/i;
|
|
122
|
+
|
|
123
|
+
function id() { return "codex-cli"; }
|
|
124
|
+
function label() { return "Codex CLI"; }
|
|
125
|
+
|
|
126
|
+
function available() {
|
|
127
|
+
try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Same lstat-vs-stat symlink-following pattern as claude-code.js's
|
|
132
|
+
* isKindFollowingSymlink — duplicated rather than imported, matching this
|
|
133
|
+
* project's convention (cursor.js's docstring on the same duplication:
|
|
134
|
+
* "each source in this project is meant to be a small, self-contained file
|
|
135
|
+
* a reviewer can audit on its own").
|
|
136
|
+
*/
|
|
137
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
138
|
+
if (checkFn(dirent)) return true;
|
|
139
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
140
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
141
|
+
}
|
|
142
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
143
|
+
const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Recursively walk one directory (sessions/ or archived_sessions/) yielding
|
|
147
|
+
* { file, mtimeMs, sizeBytes, broken } for every `*.jsonl` (scanned) and
|
|
148
|
+
* `*.zst` (surfaced, see ZSTD_RE above) file found at any depth, following
|
|
149
|
+
* symlinks the same way claude-code.js's files() does for project dirs and
|
|
150
|
+
* jsonl files, and reporting a dangling symlink as broken rather than
|
|
151
|
+
* skipping it silently. Any other file extension under this tree is out of
|
|
152
|
+
* scope, same as claude-code.js ignoring non-`.jsonl` entries.
|
|
153
|
+
*/
|
|
154
|
+
function* walkSessionDir(dir, depth) {
|
|
155
|
+
if (depth > MAX_WALK_DEPTH) return;
|
|
156
|
+
let entries;
|
|
157
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
158
|
+
catch { return; } // dir vanished or unreadable mid-walk — not reportable, nothing was ever yielded for it
|
|
159
|
+
|
|
160
|
+
for (const e of entries) {
|
|
161
|
+
const full = path.join(dir, e.name);
|
|
162
|
+
|
|
163
|
+
if (!e.isFile() && !e.isDirectory()) {
|
|
164
|
+
// Symlink (or other special entry) — resolve to find out which kind.
|
|
165
|
+
if (isDirFollowingSymlink(full, e)) { yield* walkSessionDir(full, depth + 1); continue; }
|
|
166
|
+
if (isFileFollowingSymlink(full, e)) {
|
|
167
|
+
if (!JSONL_RE.test(e.name) && !ZSTD_RE.test(e.name)) continue;
|
|
168
|
+
let stat;
|
|
169
|
+
try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
|
|
170
|
+
yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// Didn't resolve to either — a dangling symlink is the plausible
|
|
174
|
+
// real-world case (see claude-code.js's identical reasoning).
|
|
175
|
+
if (e.isSymbolicLink()) yield { file: full, broken: true };
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (e.isDirectory()) { yield* walkSessionDir(full, depth + 1); continue; }
|
|
180
|
+
|
|
181
|
+
if (!JSONL_RE.test(e.name) && !ZSTD_RE.test(e.name)) continue;
|
|
182
|
+
let stat;
|
|
183
|
+
try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
|
|
184
|
+
yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every rollout file under
|
|
190
|
+
* sessions/ and archived_sessions/, plus the single flat history.jsonl —
|
|
191
|
+
* see the module docstring for why each of these three (and only these
|
|
192
|
+
* three) locations is read.
|
|
193
|
+
*/
|
|
194
|
+
function* files() {
|
|
195
|
+
yield* walkSessionDir(SESSIONS_DIR, 0);
|
|
196
|
+
yield* walkSessionDir(ARCHIVED_SESSIONS_DIR, 0);
|
|
197
|
+
|
|
198
|
+
// history.jsonl is a single fixed-name file, not a directory to walk —
|
|
199
|
+
// same lstat-first, follow-if-symlink handling as cursor.js's
|
|
200
|
+
// statIfPresent, and a path that simply doesn't exist (most installs,
|
|
201
|
+
// depending on version/config) is normal, not broken.
|
|
202
|
+
let lst;
|
|
203
|
+
try { lst = fs.lstatSync(HISTORY_FILE); }
|
|
204
|
+
catch { return; }
|
|
205
|
+
|
|
206
|
+
if (lst.isSymbolicLink()) {
|
|
207
|
+
try {
|
|
208
|
+
const st = fs.statSync(HISTORY_FILE);
|
|
209
|
+
if (!st.isFile()) { yield { file: HISTORY_FILE, broken: true }; return; }
|
|
210
|
+
yield { file: HISTORY_FILE, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
|
|
211
|
+
} catch {
|
|
212
|
+
yield { file: HISTORY_FILE, broken: true };
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!lst.isFile()) return;
|
|
217
|
+
yield { file: HISTORY_FILE, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Read one file as an array of raw text lines. Identical streaming strategy
|
|
222
|
+
* to claude-code.js's readLines (see that file's docstring for the full
|
|
223
|
+
* reasoning on why streaming + a read timeout + honest partial-read
|
|
224
|
+
* handling all matter) — duplicated rather than shared, per this project's
|
|
225
|
+
* one-file-per-source convention. The one addition is the zstd short-circuit
|
|
226
|
+
* at the top; see ZSTD_RE above.
|
|
227
|
+
*/
|
|
228
|
+
async function readLines(file) {
|
|
229
|
+
if (ZSTD_RE.test(file)) return { lines: [], status: "failed", bytesRead: 0 };
|
|
230
|
+
|
|
231
|
+
let stat;
|
|
232
|
+
try { stat = fs.statSync(file); }
|
|
233
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
234
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
235
|
+
|
|
236
|
+
const lines = [];
|
|
237
|
+
let bytesRead = 0;
|
|
238
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
239
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
240
|
+
|
|
241
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
for await (const line of rl) {
|
|
245
|
+
lines.push(line);
|
|
246
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1;
|
|
247
|
+
}
|
|
248
|
+
return { lines, status: "complete", bytesRead };
|
|
249
|
+
} catch {
|
|
250
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
251
|
+
} finally {
|
|
252
|
+
clearTimeout(timer);
|
|
253
|
+
rl.close();
|
|
254
|
+
stream.destroy();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
module.exports = { id, label, available, files, readLines };
|