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.
- package/README.md +225 -46
- package/SECURITY.md +29 -22
- package/package.json +1 -1
- package/src/cli.js +82 -16
- package/src/integrity.js +669 -0
- package/src/patterns.js +78 -5
- package/src/report.js +74 -7
- 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/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,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 };
|