residoo 0.1.0 → 0.2.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.
Files changed (50) hide show
  1. package/README.md +225 -46
  2. package/SECURITY.md +29 -22
  3. package/package.json +1 -1
  4. package/src/cli.js +82 -16
  5. package/src/integrity.js +669 -0
  6. package/src/patterns.js +78 -5
  7. package/src/report.js +74 -7
  8. package/src/sources/agent-configs.js +308 -0
  9. package/src/sources/aider.js +361 -0
  10. package/src/sources/amazon-q.js +199 -0
  11. package/src/sources/antigravity-cli.js +155 -0
  12. package/src/sources/cline.js +208 -0
  13. package/src/sources/codebuff.js +295 -0
  14. package/src/sources/codex-cli.js +258 -0
  15. package/src/sources/cody.js +325 -0
  16. package/src/sources/continue.js +408 -0
  17. package/src/sources/copilot-chat.js +272 -0
  18. package/src/sources/copilot-cli.js +300 -0
  19. package/src/sources/crush.js +364 -0
  20. package/src/sources/cursor.js +374 -0
  21. package/src/sources/devin-cli.js +241 -0
  22. package/src/sources/factory-droid.js +153 -0
  23. package/src/sources/fx.js +136 -0
  24. package/src/sources/gemini-cli.js +242 -0
  25. package/src/sources/goose.js +366 -0
  26. package/src/sources/grok-cli.js +267 -0
  27. package/src/sources/hermes.js +282 -0
  28. package/src/sources/index.js +172 -8
  29. package/src/sources/jetbrains-ai-assistant.js +343 -0
  30. package/src/sources/jetbrains-junie.js +292 -0
  31. package/src/sources/kilo-code.js +430 -0
  32. package/src/sources/kimi-code.js +147 -0
  33. package/src/sources/kiro-cli.js +393 -0
  34. package/src/sources/kiro-ide.js +230 -0
  35. package/src/sources/llm.js +328 -0
  36. package/src/sources/mentat.js +143 -0
  37. package/src/sources/open-interpreter.js +224 -0
  38. package/src/sources/openclaw.js +218 -0
  39. package/src/sources/opencode.js +379 -0
  40. package/src/sources/openhands.js +181 -0
  41. package/src/sources/pearai.js +151 -0
  42. package/src/sources/pi-agent.js +130 -0
  43. package/src/sources/qodo-gen.js +189 -0
  44. package/src/sources/qwen-code.js +244 -0
  45. package/src/sources/roo-code.js +239 -0
  46. package/src/sources/trae.js +294 -0
  47. package/src/sources/void.js +273 -0
  48. package/src/sources/warp.js +395 -0
  49. package/src/sources/windsurf.js +256 -0
  50. package/src/sources/zed.js +374 -0
@@ -0,0 +1,343 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const { createInterface } = require("readline/promises");
5
+ const path = require("path");
6
+ const os = require("os");
7
+
8
+ /**
9
+ * JetBrains AI Assistant — the AI chat/completion plugin built into
10
+ * IntelliJ-platform IDEs (distinct from Junie, JetBrains' separate agentic
11
+ * tool covered by jetbrains-junie.js in this same directory).
12
+ *
13
+ * VERIFICATION STATUS (read this before trusting anything below): two
14
+ * genuinely different storage locations are read here, each backed by
15
+ * independent corroboration, plus one piece of GENUINE on-disk
16
+ * verification:
17
+ *
18
+ * 1. `<config dir>/JetBrains/<product><version>/workspace/*.xml` — the AI
19
+ * Chat panel's own history. Each file is standard JetBrains "workspace"
20
+ * state XML, containing (among a project's ordinary editor/tool-window
21
+ * state) a `<component name="ChatSessionStateTemp">` block with
22
+ * `SerializedChat` entries — title, `chatModelId`, a UID, and a list of
23
+ * `SerializedChatMessage` (author/displayContent/internalContent).
24
+ *
25
+ * GENUINE VERIFICATION: this exact path SHAPE is real and was
26
+ * confirmed directly on the machine this source was built on —
27
+ * `~/Library/Application Support/JetBrains/PyCharmCE2023.1/workspace/
28
+ * 2QYoJ9UvZwAbMvy50zVXfkUNIlG.xml` exists, is a real workspace XML
29
+ * file with a cryptic (non-project-derived) filename, exactly as
30
+ * described below. What it does NOT contain is an actual
31
+ * `ChatSessionStateTemp` component (confirmed by grepping it) — that
32
+ * PyCharm CE install is a 2023.1-vintage install last touched mid-2023
33
+ * that never had AI Assistant chat used in it, so the XML *schema*
34
+ * inside the marker (SerializedChat/SerializedChatMessage field names)
35
+ * is corroborated by sources below rather than confirmed against real
36
+ * chat content on this machine.
37
+ *
38
+ * Corroborating sources for the schema: multiple independent YouTrack
39
+ * threads against JetBrains' own real LLM project — "AI Losing Chats"
40
+ * (intellij-support.jetbrains.com community post), LLM-3605, LLM-12257,
41
+ * LLM-19268, LLM-26509, LLM-25178 — describe the same
42
+ * `ChatSessionStateTemp` component name and the same "one workspace XML
43
+ * per project, cryptically named" behavior independently of the tool
44
+ * below. And github.com/sfinktah/junie-export — a real, actively
45
+ * maintained ~2200-line Python tool (its actual source was read for
46
+ * this file, not just its README) whose entire job is parsing exactly
47
+ * this XML shape via `xml.etree.ElementTree`, field by field
48
+ * (`SerializedChatTitle`, `chatModelId`, `uid`, `statisticInformation`,
49
+ * `messages/list/SerializedChatMessage` with
50
+ * `author`/`displayContent`/`internalContent`) — matching the schema
51
+ * used below exactly.
52
+ *
53
+ * 2. `<config dir>/JetBrains/<product><version>/aia-task-history/*.events`
54
+ * — AI Assistant's own agent-mode task history (what junie-export
55
+ * recovers assistant content from when a `chatModelId` starts with
56
+ * `agent_` and the workspace XML's own message body is empty). Each
57
+ * `.events` file is newline-delimited, each line base64-encoded JSON,
58
+ * optionally prefixed with one literal `AUI_EVENTS_V1` header line —
59
+ * confirmed directly from junie-export's own decode loop
60
+ * (`base64.b64decode(line)`, `data[0] == b"AUI_EVENTS_V1"`). The
61
+ * decoded records carry real, compiled-in JetBrains class names —
62
+ * `com.intellij.ml.llm.chat.shared.ChatSessionUserPromptEvent`,
63
+ * `ChatSessionMessageBlockEvent`, and
64
+ * `com.intellij.ml.llm.aui.events.api.{Terminal,AgentThought,Tool,
65
+ * ViewFiles,FileChanges,Result}BlockUpdatedEvent` — the same kind of
66
+ * hard-to-fabricate signal as Junie's `matterhorn` class names (see
67
+ * jetbrains-junie.js).
68
+ *
69
+ * Both locations sit under the JetBrains "config" directory (not the
70
+ * "system"/cache directory Junie uses) — see JetBrains' own official docs,
71
+ * "Directories used by the IDE to store settings, caches, plugins and logs"
72
+ * (jetbrains.com/help/idea/...), for that config/cache split and the
73
+ * `<product><version>` per-install folder convention CONFIG_BASE_DIRS below
74
+ * follows; the macOS root of that convention
75
+ * (`~/Library/Application Support/JetBrains/<product><version>`) is exactly
76
+ * what the on-disk verification above confirms is real.
77
+ *
78
+ * What this source has NOT been checked against: real AI Assistant chat
79
+ * content, or a real `aia-task-history` directory, on the machine it was
80
+ * built on — neither exists there (confirmed by search), for the same
81
+ * "PyCharm install predates real usage of this feature" reason given in
82
+ * jetbrains-junie.js. Treat findings accordingly until confirmed against a
83
+ * real install with real AI Assistant history.
84
+ */
85
+
86
+ /**
87
+ * The two base roots this source walks, per OS. Each base root is expected
88
+ * to contain zero or more `<product><version>` directories (e.g.
89
+ * `PyCharm2024.3`), each of which may in turn contain a `workspace/` and/or
90
+ * an `aia-task-history/` subdirectory.
91
+ *
92
+ * macOS carries a second, legacy root: pre-2020 JetBrains IDEs kept their
93
+ * per-product config directly under `~/Library/Preferences/<product><version>`
94
+ * rather than under a shared "JetBrains" umbrella folder (this predates the
95
+ * unified directory layout JetBrains switched to across all OSes in the
96
+ * 2020.1 release cycle) — corroborated by junie-export's own workspace glob
97
+ * list, which includes exactly this path. It is included here because it is
98
+ * cheap: `~/Library/Preferences` is one well-known, bounded directory to
99
+ * list, not a wide filesystem walk.
100
+ *
101
+ * Deliberately NOT included: the equivalent pre-2020 Linux layout
102
+ * (`~/.<product><version>/config/workspace`, a bare dot-directory directly
103
+ * under $HOME rather than under `~/.config/JetBrains`), also referenced by
104
+ * junie-export. Finding it requires enumerating every hidden entry in
105
+ * $HOME to test each one for a nested `config/workspace`, which is a much
106
+ * broader and slower walk than every other root here for a layout no
107
+ * install after 2020 uses — six-plus years stale as of this writing. This
108
+ * is a deliberate, documented scope limit, not an oversight.
109
+ */
110
+ function configBaseDirs() {
111
+ const home = os.homedir();
112
+ if (process.platform === "win32") {
113
+ const roaming = process.env.APPDATA || path.join(home, "AppData", "Roaming");
114
+ return [path.join(roaming, "JetBrains")];
115
+ }
116
+ if (process.platform === "darwin") {
117
+ return [
118
+ path.join(home, "Library", "Application Support", "JetBrains"),
119
+ path.join(home, "Library", "Preferences"), // pre-2020 legacy layout
120
+ ];
121
+ }
122
+ // Linux and other XDG-following unix platforms.
123
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
124
+ return [path.join(configHome, "JetBrains")];
125
+ }
126
+
127
+ // Same bounds as claude-code.js and jetbrains-junie.js, same caveat as
128
+ // jetbrains-junie.js's MAX_BYTES: not backed by an observed real large file
129
+ // for this source, since no real AI Assistant data was available to test
130
+ // against — a generous, deliberately-reused backstop, not a measured limit.
131
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
132
+ const READ_TIMEOUT_MS = 60_000;
133
+
134
+ // The one literal, non-base64 header line junie-export's own decoder checks
135
+ // for at the start of an `.events` file — see EVENT_FILE FORMAT note above.
136
+ const EVENTS_HEADER = "AUI_EVENTS_V1";
137
+
138
+ function id() { return "jetbrains-ai-assistant"; }
139
+ function label() { return "JetBrains AI Assistant"; }
140
+
141
+ /**
142
+ * Cheap and honest on purpose: only the modern, primary root per OS is
143
+ * checked (not the macOS Preferences legacy root, which is universally
144
+ * present on every macOS machine regardless of whether JetBrains is
145
+ * installed at all — checking it here would make this source falsely
146
+ * report "available" for users with no JetBrains presence whatsoever).
147
+ * files() still walks the legacy root when it's present; the tradeoff this
148
+ * accepts is the reverse edge case — a machine with ONLY a pre-2020 install
149
+ * and no modern one — reporting unavailable. That is an intentional,
150
+ * documented limitation, not an oversight.
151
+ */
152
+ function available() {
153
+ const primary = configBaseDirs()[0];
154
+ try { return fs.statSync(primary).isDirectory(); } catch { return false; }
155
+ }
156
+
157
+ /** Same lstat-vs-follow reasoning as claude-code.js's isKindFollowingSymlink. */
158
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
159
+ if (checkFn(dirent)) return true;
160
+ if (!dirent.isSymbolicLink()) return false;
161
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
162
+ }
163
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
164
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
165
+
166
+ /** See jetbrains-junie.js's tryReaddir for the "not found vs broken" rationale. */
167
+ function tryReaddir(dir) {
168
+ try { return { ok: true, entries: fs.readdirSync(dir, { withFileTypes: true }) }; }
169
+ catch (err) { return { ok: false, enoent: Boolean(err && err.code === "ENOENT") }; }
170
+ }
171
+
172
+ function* statLeaf(filePath, dirent) {
173
+ if (!isFileFollowingSymlink(filePath, dirent)) {
174
+ if (dirent.isSymbolicLink()) yield { file: filePath, broken: true };
175
+ return;
176
+ }
177
+ let stat;
178
+ try { stat = fs.statSync(filePath); }
179
+ catch { yield { file: filePath, broken: true }; return; }
180
+ yield { file: filePath, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
181
+ }
182
+
183
+ function* leafFiles(dir, matchName) {
184
+ const listing = tryReaddir(dir);
185
+ if (!listing.ok) {
186
+ if (!listing.enoent) yield { file: dir, broken: true };
187
+ return;
188
+ }
189
+ for (const entry of listing.entries) {
190
+ if (!matchName(entry.name)) continue;
191
+ yield* statLeaf(path.join(dir, entry.name), entry);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * List one base root and yield `{ broken: false, path }` for every real
197
+ * `<product><version>` install directory found under it, or
198
+ * `{ broken: true, path }` for an entry that looked like it should resolve
199
+ * (a symlink) and didn't. A base root that simply doesn't exist (e.g. no
200
+ * legacy Preferences-based JetBrains subdirectories on this machine) yields
201
+ * nothing, silently — see tryReaddir.
202
+ */
203
+ function* eachIdeDir(baseDir) {
204
+ const listing = tryReaddir(baseDir);
205
+ if (!listing.ok) {
206
+ if (!listing.enoent) yield { broken: true, path: baseDir };
207
+ return;
208
+ }
209
+ for (const entry of listing.entries) {
210
+ const dir = path.join(baseDir, entry.name);
211
+ if (isDirFollowingSymlink(dir, entry)) { yield { broken: false, path: dir }; continue; }
212
+ if (entry.isSymbolicLink()) yield { broken: true, path: dir };
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Yield `{ file, mtimeMs, sizeBytes, broken }` for every AI Assistant
218
+ * transcript-bearing file found: `workspace/*.xml` and
219
+ * `aia-task-history/*.events` under every `<product><version>` install
220
+ * directory under every base root from configBaseDirs().
221
+ *
222
+ * Every `workspace/*.xml` file is yielded, not only ones already confirmed
223
+ * to contain a `ChatSessionStateTemp` component — deliberately, mirroring
224
+ * cursor.js's own choice not to filter `ItemTable`/`cursorDiskKV` rows by
225
+ * key name (see that file's docstring). Peeking file content to decide
226
+ * relevance would also break the established files()/readLines() division
227
+ * of labour both reference sources use, where files() is a pure stat walk
228
+ * and only readLines() ever opens content. The cost is scanning some
229
+ * workspace XML from projects that never used AI Assistant at all — for
230
+ * every JetBrains user, that file exists whether or not AI features were
231
+ * ever touched — but these files are ordinary IDE state, not bulk data, so
232
+ * that cost is small and bounded by MAX_BYTES like everything else here.
233
+ */
234
+ function* files() {
235
+ for (const baseDir of configBaseDirs()) {
236
+ for (const ide of eachIdeDir(baseDir)) {
237
+ if (ide.broken) { yield { file: ide.path, broken: true }; continue; }
238
+ yield* leafFiles(path.join(ide.path, "workspace"), (name) => name.endsWith(".xml"));
239
+ yield* leafFiles(path.join(ide.path, "aia-task-history"), (name) => name.endsWith(".events"));
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Plain-text read identical in shape to claude-code.js's readLines() — used
246
+ * for `workspace/*.xml`, which needs no decoding: it's already UTF-8 text on
247
+ * disk, and scan.js matches raw text regardless of the XML structure it came
248
+ * from, the same way it matches raw JSON/JSONL text from the other sources.
249
+ */
250
+ async function readPlainTextFile(file) {
251
+ let stat;
252
+ try { stat = fs.statSync(file); }
253
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
254
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
255
+
256
+ const lines = [];
257
+ let bytesRead = 0;
258
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
259
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
260
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
261
+
262
+ try {
263
+ for await (const line of rl) {
264
+ lines.push(line);
265
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1;
266
+ }
267
+ return { lines, status: "complete", bytesRead };
268
+ } catch {
269
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
270
+ } finally {
271
+ clearTimeout(timer);
272
+ rl.close();
273
+ stream.destroy();
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Read one `.events` file: newline-delimited, each line base64-encoded
279
+ * JSON, with an optional literal `AUI_EVENTS_V1` header as the very first
280
+ * line (checked verbatim, not base64-decoded — matches junie-export's own
281
+ * `data[0] == b"AUI_EVENTS_V1"` check exactly).
282
+ *
283
+ * This decoding step is not optional the way it might look — it is the
284
+ * whole reason this half of the source has any value. A secret embedded in
285
+ * an agent's tool output or terminal block is, on disk, base64 text; run
286
+ * residoo's plain-text regexes against that base64 directly and every one
287
+ * of them fails to match (base64 systematically destroys the literal
288
+ * substrings — "sk-ant-...", "AKIA...", etc. — those regexes look for).
289
+ * Skipping this step would mean silently scanning nothing here while still
290
+ * reporting the file as scanned: exactly the false "all clear" this
291
+ * project's own rule 5 exists to prevent. This mirrors, in spirit, exactly
292
+ * what cursor.js's valueToText() does for its BLOB-vs-TEXT SQLite columns:
293
+ * turn whatever the real storage encoding is back into the actual text a
294
+ * regex can match, and do it losslessly rather than round-tripping through
295
+ * JSON.parse/stringify (so no escaping/quoting/control-character byte the
296
+ * regexes depend on is altered by the decode).
297
+ *
298
+ * `Buffer.from(line, "base64")` never throws on malformed input — invalid
299
+ * characters are simply ignored per Node's own documented behavior — so
300
+ * there is no failure mode here to branch on beyond the same stream-level
301
+ * timeout/partial-read handling every other readLines() in this project
302
+ * uses.
303
+ */
304
+ async function readEventsFile(file) {
305
+ let stat;
306
+ try { stat = fs.statSync(file); }
307
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
308
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
309
+
310
+ const lines = [];
311
+ let bytesRead = 0;
312
+ let isFirstLine = true;
313
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
314
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
315
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
316
+
317
+ try {
318
+ for await (const line of rl) {
319
+ if (isFirstLine) {
320
+ isFirstLine = false;
321
+ if (line === EVENTS_HEADER) continue;
322
+ }
323
+ if (line.length === 0) continue;
324
+ const decoded = Buffer.from(line, "base64").toString("utf-8");
325
+ lines.push(decoded);
326
+ bytesRead += Buffer.byteLength(decoded, "utf-8");
327
+ }
328
+ return { lines, status: "complete", bytesRead };
329
+ } catch {
330
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
331
+ } finally {
332
+ clearTimeout(timer);
333
+ rl.close();
334
+ stream.destroy();
335
+ }
336
+ }
337
+
338
+ async function readLines(file) {
339
+ if (file.endsWith(".events")) return readEventsFile(file);
340
+ return readPlainTextFile(file);
341
+ }
342
+
343
+ module.exports = { id, label, available, files, readLines };
@@ -0,0 +1,292 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const { createInterface } = require("readline/promises");
5
+ const path = require("path");
6
+ const os = require("os");
7
+
8
+ /**
9
+ * JetBrains Junie session history (Junie = JetBrains' own agentic coding
10
+ * tool, bundled/installable into IntelliJ-platform IDEs: IntelliJ IDEA,
11
+ * PyCharm, WebStorm, GoLand, etc.).
12
+ *
13
+ * VERIFICATION STATUS (read this before trusting anything below): the path
14
+ * and schema here are corroborated by:
15
+ *
16
+ * 1. junie-explorer (github.com/dmeehan1968/junie-explorer) — a real,
17
+ * actively developed, ~370-file TypeScript/Bun web app whose entire
18
+ * purpose is reading these exact files to render a UI over them. Its
19
+ * path-discovery code (src/jetbrains.ts) and Zod schemas (src/schema.ts,
20
+ * src/schema/*.ts) were read directly (not just its README) for this
21
+ * source. Its schema encodes real, compiled-in JetBrains class names —
22
+ * e.g. `com.intellij.ml.llm.matterhorn.llm.MatterhornChatMessage` and
23
+ * `com.intellij.ml.llm.matterhorn.ArtifactReasoning.{Success,Failure}`
24
+ * — strong evidence this isn't a guess: those exact dotted package
25
+ * names aren't the kind of thing an outside author invents, they're
26
+ * read off Junie's own bytecode/plugin.xml by someone who actually
27
+ * inspected a real install. "Matterhorn" is Junie's internal codename,
28
+ * which is also why the on-disk directory is `matterhorn/.matterhorn`,
29
+ * not anything with "junie" in it.
30
+ * 2. JetBrains' own official docs, "Directories used by the IDE to store
31
+ * settings, caches, plugins and logs" (jetbrains.com/help/idea/...),
32
+ * for the per-OS "system"/cache directory convention
33
+ * (`<product><version>` folder under a platform-specific root) that
34
+ * CACHE_ROOT below follows.
35
+ * 3. Multiple YouTrack issues against the real JUNIE project (JUNIE-606
36
+ * "loses chat history after moving the project location", JUNIE-924
37
+ * "Recovering Junie history", JUNIE-498 "loses all history when
38
+ * updating the IDE") independently corroborate that Junie's history is
39
+ * local, keyed by project, and lives outside the IDE's own settings
40
+ * sync — consistent with a per-project cache directory rather than
41
+ * e.g. a cloud-synced or single-file store.
42
+ *
43
+ * What this source has NOT been checked against: a real Junie install with
44
+ * real session history on the machine it was built on. PyCharm (both the
45
+ * paid and Community editions) IS installed there, confirmed via
46
+ * `~/Library/Application Support/JetBrains/PyCharm2023.1` and
47
+ * `PyCharmCE2023.1` actually existing on disk — which independently
48
+ * confirms the sibling `<product><version>`-per-install naming convention
49
+ * this file relies on for CACHE_ROOT is real, not guessed. But that install
50
+ * is a `2023.1`-vintage install last touched mid-2023, years before Junie
51
+ * existed as a product, and a filesystem search of the whole home directory
52
+ * for `matterhorn`, `Junie`, or `AIAssistant` turned up nothing — so there
53
+ * is no real Junie history on this machine to verify the JSON/JSONL schema
54
+ * against. Treat findings from this source accordingly until someone with
55
+ * an actual Junie session confirms it against real data.
56
+ */
57
+
58
+ /**
59
+ * Junie's own data sits under each IDE's "system" (cache) directory, one
60
+ * `<product><version>` folder per install — e.g. `PyCharm2024.3`,
61
+ * `IntelliJIdea2025.1` — exactly mirroring claude-code.js's ROOT and
62
+ * cursor.js's cursorUserDir(): a single well-known platform root, walked at
63
+ * scan time rather than hard-coded per product/version.
64
+ */
65
+ function cacheRoot() {
66
+ const home = os.homedir();
67
+ if (process.platform === "darwin") {
68
+ return path.join(home, "Library", "Caches", "JetBrains");
69
+ }
70
+ if (process.platform === "win32") {
71
+ const local = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
72
+ return path.join(local, "JetBrains");
73
+ }
74
+ // Linux and other XDG-following unix platforms.
75
+ const cacheHome = process.env.XDG_CACHE_HOME || path.join(home, ".cache");
76
+ return path.join(cacheHome, "JetBrains");
77
+ }
78
+
79
+ const CACHE_ROOT = cacheRoot();
80
+
81
+ // Same bounds as claude-code.js, for the same reasons (see its docstring).
82
+ // Unlike claude-code.js's MAX_BYTES, this is NOT backed by an observed real
83
+ // large file for this source specifically — no real Junie data was
84
+ // available to test against, per the verification note above — it is only
85
+ // a generous, deliberately-reused backstop.
86
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
87
+ const READ_TIMEOUT_MS = 60_000;
88
+
89
+ function id() { return "jetbrains-junie"; }
90
+ function label() { return "JetBrains Junie"; }
91
+
92
+ function available() {
93
+ try { return fs.statSync(CACHE_ROOT).isDirectory(); } catch { return false; }
94
+ }
95
+
96
+ /**
97
+ * Same lstat-vs-follow reasoning as claude-code.js's isKindFollowingSymlink
98
+ * (Dirent reflects the entry itself, not what a symlink resolves to).
99
+ * Duplicated rather than imported — see cursor.js's docstring on
100
+ * isDirFollowingSymlink for why each source stays self-contained.
101
+ */
102
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
103
+ if (checkFn(dirent)) return true;
104
+ if (!dirent.isSymbolicLink()) return false;
105
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
106
+ }
107
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
108
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
109
+
110
+ /**
111
+ * List a directory, distinguishing "doesn't exist" from every other
112
+ * failure. Reused at every directory boundary below because Junie's real
113
+ * layout is several levels deep (cache root -> IDE install -> project ->
114
+ * matterhorn/.matterhorn -> issues/events -> optionally one more level) and
115
+ * each of those levels needs the same "not found is normal, anything else
116
+ * is broken" judgment call that claude-code.js and cursor.js each make once.
117
+ * A missing `projects` directory under one IDE install, for instance, just
118
+ * means that IDE install predates Junie or never had it open on a project —
119
+ * not a reportable failure. An unreadable directory that DOES exist (e.g.
120
+ * permissions) is a real, surfaceable failure.
121
+ */
122
+ function tryReaddir(dir) {
123
+ try { return { ok: true, entries: fs.readdirSync(dir, { withFileTypes: true }) }; }
124
+ catch (err) { return { ok: false, enoent: Boolean(err && err.code === "ENOENT") }; }
125
+ }
126
+
127
+ /**
128
+ * Yield `{ file, mtimeMs, sizeBytes, broken }` for one leaf file, following
129
+ * a symlink to see what it really is first — same convention as
130
+ * claude-code.js's files() loop over `.jsonl` entries.
131
+ */
132
+ function* statLeaf(filePath, dirent) {
133
+ if (!isFileFollowingSymlink(filePath, dirent)) {
134
+ if (dirent.isSymbolicLink()) yield { file: filePath, broken: true };
135
+ return;
136
+ }
137
+ let stat;
138
+ try { stat = fs.statSync(filePath); }
139
+ catch { yield { file: filePath, broken: true }; return; }
140
+ yield { file: filePath, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
141
+ }
142
+
143
+ /** List `dir` and yield statLeaf() for every entry whose name passes `matchName`. */
144
+ function* leafFiles(dir, matchName) {
145
+ const listing = tryReaddir(dir);
146
+ if (!listing.ok) {
147
+ if (!listing.enoent) yield { file: dir, broken: true };
148
+ return;
149
+ }
150
+ for (const entry of listing.entries) {
151
+ if (!matchName(entry.name)) continue;
152
+ yield* statLeaf(path.join(dir, entry.name), entry);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Walk one project's `matterhorn/.matterhorn` directory:
158
+ *
159
+ * matterhorn/.matterhorn/
160
+ * events/
161
+ * <uuid>-events.jsonl <- already line-delimited JSON, read as-is
162
+ * issues/
163
+ * chain-<issueId>.json <- one JSON object per file
164
+ * chain-<issueId>/
165
+ * task-<index>.json <- one JSON object per file, one level deeper
166
+ *
167
+ * `events/*.jsonl` holds the actual LLM request/response and tool-call
168
+ * event stream for Junie's newer "AIA task" flow; `issues/` holds the older
169
+ * "chain" flow's per-issue and per-task state (context.description, the
170
+ * unified diff `patch` applied, prior agent observations, etc. — see
171
+ * junie-explorer's schema.ts for the full shape). Both are read the exact
172
+ * same way by readLines() below: no transform is needed for either, since
173
+ * both are already plain text on disk (unlike cursor.js's SQLite rows, there
174
+ * is no encoding layer here to peel back).
175
+ *
176
+ * Subdirectories under `issues/` are not filtered by name (e.g. requiring a
177
+ * `chain-` prefix) — they are Junie's own internal storage, not user
178
+ * content, so listing them broadly costs nothing and stays resilient to
179
+ * exact naming drift across versions, the same reasoning cursor.js gives for
180
+ * not filtering `cursorDiskKV` by key name.
181
+ */
182
+ function* walkMatterhorn(matterhornDir) {
183
+ yield* leafFiles(path.join(matterhornDir, "events"), (name) => name.endsWith("-events.jsonl"));
184
+
185
+ const issuesDir = path.join(matterhornDir, "issues");
186
+ const issuesListing = tryReaddir(issuesDir);
187
+ if (!issuesListing.ok) {
188
+ if (!issuesListing.enoent) yield { file: issuesDir, broken: true };
189
+ return;
190
+ }
191
+ for (const entry of issuesListing.entries) {
192
+ const entryPath = path.join(issuesDir, entry.name);
193
+ if (isFileFollowingSymlink(entryPath, entry)) {
194
+ if (entry.name.endsWith(".json")) yield* statLeaf(entryPath, entry);
195
+ continue;
196
+ }
197
+ if (isDirFollowingSymlink(entryPath, entry)) {
198
+ yield* leafFiles(entryPath, (name) => name.endsWith(".json"));
199
+ continue;
200
+ }
201
+ if (entry.isSymbolicLink()) yield { file: entryPath, broken: true };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Yield `{ file, mtimeMs, sizeBytes, broken }` for every Junie transcript
207
+ * file found across every IDE install and every project under
208
+ * CACHE_ROOT/<product><version>/projects/<project name>/matterhorn/.matterhorn.
209
+ *
210
+ * Mirrors claude-code.js's files(): a missing `projects` directory under one
211
+ * IDE install, or a missing `matterhorn/.matterhorn` under one project, is
212
+ * the ordinary case (that install or that project never used Junie) and is
213
+ * silently skipped, not reported broken — only entries that looked
214
+ * resolvable and weren't (chiefly dangling symlinks) or directories that
215
+ * exist but couldn't be listed are surfaced.
216
+ */
217
+ function* files() {
218
+ const top = tryReaddir(CACHE_ROOT);
219
+ if (!top.ok) {
220
+ if (!top.enoent) yield { file: CACHE_ROOT, broken: true };
221
+ return;
222
+ }
223
+
224
+ for (const ideEntry of top.entries) {
225
+ const ideDir = path.join(CACHE_ROOT, ideEntry.name);
226
+ if (!isDirFollowingSymlink(ideDir, ideEntry)) {
227
+ if (ideEntry.isSymbolicLink()) yield { file: ideDir, broken: true };
228
+ continue;
229
+ }
230
+
231
+ const projectsDir = path.join(ideDir, "projects");
232
+ const projectsListing = tryReaddir(projectsDir);
233
+ if (!projectsListing.ok) {
234
+ if (!projectsListing.enoent) yield { file: projectsDir, broken: true };
235
+ continue;
236
+ }
237
+
238
+ for (const projEntry of projectsListing.entries) {
239
+ const projectDir = path.join(projectsDir, projEntry.name);
240
+ if (!isDirFollowingSymlink(projectDir, projEntry)) {
241
+ if (projEntry.isSymbolicLink()) yield { file: projectDir, broken: true };
242
+ continue;
243
+ }
244
+ yield* walkMatterhorn(path.join(projectDir, "matterhorn", ".matterhorn"));
245
+ }
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Read one transcript file (either an `events/*.jsonl` or an
251
+ * `issues/**\/*.json` file) as an array of raw text lines.
252
+ *
253
+ * Identical in shape and reasoning to claude-code.js's readLines(): both
254
+ * file kinds here are plain UTF-8 text on disk (pretty-printed or minified
255
+ * JSON, or true JSONL), so the same streamed, size-capped, timed-out,
256
+ * partial-read-preserving read applies unchanged — see that file's
257
+ * docstring for the full rationale (ERR_STRING_TOO_LONG avoidance, the
258
+ * TOCTOU re-stat, why a hung read needs a destroy()-based timeout). This is
259
+ * deliberately not copy-pasted-and-modified; it just IS the same read
260
+ * strategy, because the underlying storage shape is the same (a plain text
261
+ * file), unlike cursor.js's SQLite source which genuinely needs different
262
+ * machinery.
263
+ */
264
+ async function readLines(file) {
265
+ let stat;
266
+ try { stat = fs.statSync(file); }
267
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
268
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
269
+
270
+ const lines = [];
271
+ let bytesRead = 0;
272
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
273
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
274
+
275
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
276
+
277
+ try {
278
+ for await (const line of rl) {
279
+ lines.push(line);
280
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1;
281
+ }
282
+ return { lines, status: "complete", bytesRead };
283
+ } catch {
284
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
285
+ } finally {
286
+ clearTimeout(timer);
287
+ rl.close();
288
+ stream.destroy();
289
+ }
290
+ }
291
+
292
+ module.exports = { id, label, available, files, readLines };