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,224 @@
|
|
|
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
|
+
* Open Interpreter (the classic Python "natural language interface for
|
|
10
|
+
* computers" CLI, `pip install open-interpreter`, entry point `interpreter`).
|
|
11
|
+
*
|
|
12
|
+
* VERIFICATION STATUS: NOT installed on the machine this adapter was built on
|
|
13
|
+
* (checked: no `interpreter`/`open-interpreter` on PATH, no `open-interpreter`
|
|
14
|
+
* in `pip3 list`, no conversations directory under any of the paths below).
|
|
15
|
+
* Ships anyway per CONTRIBUTING.md rule 3, on unusually strong grounds for an
|
|
16
|
+
* "unverified" source: not a description of the tool, but the tool's own
|
|
17
|
+
* source code, read directly, plus a real, on-this-machine execution of the
|
|
18
|
+
* exact path-resolution call the tool itself makes at import time (only the
|
|
19
|
+
* `interpreter.chat()` LLM round-trip itself was not exercised, since that
|
|
20
|
+
* needs a model API key this research had no reason to acquire):
|
|
21
|
+
*
|
|
22
|
+
* - `interpreter/core/core.py` (fetched from the real GitHub history, tag
|
|
23
|
+
* v0.4.2 — see the naming note below) confirms conversation logging is ON
|
|
24
|
+
* by default (`conversation_history=True` in `OpenInterpreter.__init__`)
|
|
25
|
+
* and shows the exact write path: after every `chat()` turn, the full
|
|
26
|
+
* `self.messages` list is re-serialized whole with `json.dump(self.messages, f)`
|
|
27
|
+
* to `<conversation_history_path>/<conversation_filename>`, where the
|
|
28
|
+
* filename is `<first-25-chars-of-first-message-slug>__<Month_DD_YYYY_HH-MM-SS>.json`
|
|
29
|
+
* and `conversation_history_path` defaults to `get_storage_path("conversations")`.
|
|
30
|
+
* - `interpreter/terminal_interface/utils/local_storage_path.py` confirms
|
|
31
|
+
* `get_storage_path(sub)` is `os.path.join(platformdirs.user_config_dir("open-interpreter"), sub)`
|
|
32
|
+
* — i.e. a single call into the third-party `platformdirs` library, not a
|
|
33
|
+
* hand-rolled per-OS branch.
|
|
34
|
+
* - `interpreter/terminal_interface/conversation_navigator.py` and
|
|
35
|
+
* `.../utils/get_conversations.py` both independently confirm the
|
|
36
|
+
* directory is listed non-recursively for `*.json` files — no subfolders,
|
|
37
|
+
* no other extension.
|
|
38
|
+
* - `platformdirs`' OWN source (tox-dev/platformdirs, macos.py/windows.py/
|
|
39
|
+
* unix.py + api.py, fetched and read directly) confirms, for a call with
|
|
40
|
+
* only an appname (no appauthor) exactly like this one:
|
|
41
|
+
* * macOS: `user_config_dir` == `user_data_dir` == `~/Library/Application
|
|
42
|
+
* Support/<appname>` (macOS doesn't split these the way XDG does).
|
|
43
|
+
* * Linux/XDG: `$XDG_CONFIG_HOME/<appname>` or `~/.config/<appname>`.
|
|
44
|
+
* * Windows: `user_config_dir` == `user_data_dir`; `appauthor` defaults
|
|
45
|
+
* to `appname` when omitted (confirmed from platformdirs' own
|
|
46
|
+
* `api.py` docstring), giving `%LOCALAPPDATA%\<appname>\<appname>`.
|
|
47
|
+
* - REAL execution, on this machine, of the literal call `core.py` makes:
|
|
48
|
+
* a throwaway venv with only `platformdirs` installed (a zero-dependency,
|
|
49
|
+
* pure-path-math package — not the multi-hundred-MB `open-interpreter`
|
|
50
|
+
* package itself, which was not worth installing just to import one
|
|
51
|
+
* function) ran `platformdirs.user_config_dir("open-interpreter")` and
|
|
52
|
+
* printed `/Users/Dan/Library/Application Support/open-interpreter` —
|
|
53
|
+
* exactly matching the macOS formula derived from source above. The venv
|
|
54
|
+
* was deleted after use; nothing from it is installed anymore.
|
|
55
|
+
* - PyPI's `open-interpreter` project page confirms the latest release is
|
|
56
|
+
* 0.4.3 (2024-10-26) — i.e. this scheme, introduced in v0.3.0 (see below),
|
|
57
|
+
* is still what today's `pip install open-interpreter` ships.
|
|
58
|
+
*
|
|
59
|
+
* NAMING TRAP, resolved during this research: the GitHub org this project
|
|
60
|
+
* lived under (`openinterpreter`) was reused in 2026 for an unrelated, brand
|
|
61
|
+
* new Rust rewrite ("a coding agent for open models like Kimi K3", built on
|
|
62
|
+
* OpenAI's open-sourced Codex CLI — its repo tree is full of `codex-rs/*`
|
|
63
|
+
* paths). That is NOT this tool. This adapter targets the classic Python
|
|
64
|
+
* package the task actually asked for ("Python-based local-execution agent"),
|
|
65
|
+
* reached via the org's still-intact git tag history (tag `v0.4.2`, the last
|
|
66
|
+
* one cut before the rewrite) rather than the `main` branch, which now holds
|
|
67
|
+
* the unrelated project.
|
|
68
|
+
*
|
|
69
|
+
* VERSION DRIFT, checked and deliberately scoped around: fetching
|
|
70
|
+
* `local_storage_path.py` at old tags shows v0.1.x/v0.2.x used a DIFFERENT
|
|
71
|
+
* library and app name — `appdirs.user_config_dir("Open Interpreter")`
|
|
72
|
+
* (capitalized, space, no hyphen) — before v0.3.0 switched to the
|
|
73
|
+
* `platformdirs` + `"open-interpreter"` scheme this adapter targets as
|
|
74
|
+
* current. That legacy scheme was itself verified the same way: real
|
|
75
|
+
* execution on this machine (`appdirs.user_config_dir("Open Interpreter")`
|
|
76
|
+
* printed `/Users/Dan/Library/Application Support/Open Interpreter` — note
|
|
77
|
+
* the differing capitalization from the current path) plus reading appdirs'
|
|
78
|
+
* own source for its Windows/Linux formulas. Both schemes read `conversations/`
|
|
79
|
+
* for `*.json` files the same way in both eras (confirmed directly from
|
|
80
|
+
* source at old and new tags alike), so this source scans both directories
|
|
81
|
+
* rather than picking one — the same "format moved, so read every location
|
|
82
|
+
* it has lived" choice cursor.js and gemini-cli.js make for their own
|
|
83
|
+
* cross-version drift.
|
|
84
|
+
*/
|
|
85
|
+
function platformdirsConfigDir(appname) {
|
|
86
|
+
const home = os.homedir();
|
|
87
|
+
if (process.platform === "darwin") {
|
|
88
|
+
return path.join(home, "Library", "Application Support", appname);
|
|
89
|
+
}
|
|
90
|
+
if (process.platform === "win32") {
|
|
91
|
+
// appauthor is never passed by Open Interpreter's own call, so
|
|
92
|
+
// platformdirs falls back to using appname as the author too (confirmed
|
|
93
|
+
// from platformdirs' api.py docstring) — hence the doubled segment.
|
|
94
|
+
const base = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
|
95
|
+
return path.join(base, appname, appname);
|
|
96
|
+
}
|
|
97
|
+
// Linux and other XDG-following unix platforms.
|
|
98
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
99
|
+
return path.join(configHome, appname);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const CURRENT_CONVERSATIONS_DIR = path.join(platformdirsConfigDir("open-interpreter"), "conversations");
|
|
103
|
+
const LEGACY_CONVERSATIONS_DIR = path.join(platformdirsConfigDir("Open Interpreter"), "conversations");
|
|
104
|
+
|
|
105
|
+
// Bounds for readLines() — same shape as claude-code.js's, but the number is
|
|
106
|
+
// NOT backed by a real large Open Interpreter conversation file this tool
|
|
107
|
+
// was tested against (no install available). A single conversation file here
|
|
108
|
+
// is a whole-session JSON re-dump, not an append-only log, so it is expected
|
|
109
|
+
// to be far smaller than a multi-session JSONL transcript in practice — this
|
|
110
|
+
// is a generous, untested backstop against a pathological file, not evidence
|
|
111
|
+
// of what real files look like.
|
|
112
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
|
|
113
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
114
|
+
|
|
115
|
+
function id() { return "open-interpreter"; }
|
|
116
|
+
function label() { return "Open Interpreter"; }
|
|
117
|
+
|
|
118
|
+
function available() {
|
|
119
|
+
return dirExists(CURRENT_CONVERSATIONS_DIR) || dirExists(LEGACY_CONVERSATIONS_DIR);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function dirExists(p) {
|
|
123
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Same defensive symlink-following pattern as claude-code.js — see that
|
|
128
|
+
* file's docstring for the full reasoning. Duplicated rather than imported:
|
|
129
|
+
* each source here is meant to be a small, self-contained file a reviewer
|
|
130
|
+
* can audit on its own (see CONTRIBUTING.md).
|
|
131
|
+
*/
|
|
132
|
+
function isFileFollowingSymlink(fullPath, dirent) {
|
|
133
|
+
if (dirent.isFile()) return true;
|
|
134
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
135
|
+
try { return fs.statSync(fullPath).isFile(); } catch { return false; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every `*.json` file directly
|
|
140
|
+
* inside one conversations directory — flat, non-recursive, matching
|
|
141
|
+
* `get_conversations.py`'s own `os.listdir(...)` + `.endswith(".json")`
|
|
142
|
+
* exactly (no subfolder nesting exists in this format at either the current
|
|
143
|
+
* or legacy path).
|
|
144
|
+
*/
|
|
145
|
+
function* walkConversationsDir(dir) {
|
|
146
|
+
let entries;
|
|
147
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
148
|
+
catch { return; } // directory doesn't exist — not broken, just nothing here yet
|
|
149
|
+
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
if (!e.name.endsWith(".json")) continue;
|
|
152
|
+
const file = path.join(dir, e.name);
|
|
153
|
+
if (!isFileFollowingSymlink(file, e)) {
|
|
154
|
+
if (e.isSymbolicLink()) yield { file, broken: true };
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
let stat;
|
|
158
|
+
try { stat = fs.statSync(file); } catch { yield { file, broken: true }; continue; }
|
|
159
|
+
yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Yields every `*.json` conversation file under both the current
|
|
165
|
+
* (platformdirs) and legacy (appdirs) conversations directories — see the
|
|
166
|
+
* module docstring for why both are read. When both directories happen to
|
|
167
|
+
* resolve to the exact same path (impossible here since the app names differ
|
|
168
|
+
* in case/hyphenation on every supported OS, but guarded anyway in case a
|
|
169
|
+
* future platformdirs/appdirs release ever changes that), the second walk is
|
|
170
|
+
* skipped rather than double-yielding every file in it.
|
|
171
|
+
*/
|
|
172
|
+
function* files() {
|
|
173
|
+
yield* walkConversationsDir(CURRENT_CONVERSATIONS_DIR);
|
|
174
|
+
if (LEGACY_CONVERSATIONS_DIR !== CURRENT_CONVERSATIONS_DIR) {
|
|
175
|
+
yield* walkConversationsDir(LEGACY_CONVERSATIONS_DIR);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read one conversation file as an array of raw text lines. Identical
|
|
181
|
+
* streaming strategy to claude-code.js's readLines (a JSON file on disk is
|
|
182
|
+
* still line-delimited text as far as a byte stream is concerned) — see that
|
|
183
|
+
* file's docstring for the full reasoning behind streaming + a hard timeout.
|
|
184
|
+
*
|
|
185
|
+
* In practice, `json.dump(self.messages, f)` is called with no `indent=`, so
|
|
186
|
+
* the whole file is one single physical line (Python's json module escapes
|
|
187
|
+
* embedded newlines inside string values as `\n`, it doesn't emit them raw)
|
|
188
|
+
* — that's fine: the pattern-matcher works on raw text regardless of how
|
|
189
|
+
* many physical lines it's split across, exactly per the adapter contract
|
|
190
|
+
* cursor.js's docstring already spells out for a whole-document-per-row case.
|
|
191
|
+
*/
|
|
192
|
+
async function readLines(file) {
|
|
193
|
+
let stat;
|
|
194
|
+
try { stat = fs.statSync(file); }
|
|
195
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
196
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
197
|
+
|
|
198
|
+
const lines = [];
|
|
199
|
+
let bytesRead = 0;
|
|
200
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
201
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
202
|
+
|
|
203
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
for await (const line of rl) {
|
|
207
|
+
lines.push(line);
|
|
208
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
|
|
209
|
+
}
|
|
210
|
+
return { lines, status: "complete", bytesRead };
|
|
211
|
+
} catch {
|
|
212
|
+
// Whatever WAS read before the failure is real content and may contain a
|
|
213
|
+
// real secret — discarding it because the file didn't finish cleanly
|
|
214
|
+
// would be a silent false negative, which is worse than an honest
|
|
215
|
+
// "partial" label.
|
|
216
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
217
|
+
} finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
rl.close();
|
|
220
|
+
stream.destroy();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { id, label, available, files, readLines };
|
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
* OpenClaw (openclaw/openclaw, "your own personal AI assistant... the
|
|
10
|
+
* lobster way") session transcripts. OpenClaw is a multi-channel agent
|
|
11
|
+
* gateway (Discord/Slack/Telegram/iMessage/etc.) that can launch and drive
|
|
12
|
+
* other coding agents (Claude Code, Codex, OpenCode, Kiro CLI, Pi) as
|
|
13
|
+
* subprocesses — those tools' own transcripts are already covered by this
|
|
14
|
+
* project's other sources; this source covers OpenClaw's OWN session/chat
|
|
15
|
+
* logs (the conversation the user has with OpenClaw itself, across whatever
|
|
16
|
+
* channel it's reached from).
|
|
17
|
+
*
|
|
18
|
+
* VERIFICATION STATUS: NOT checked against a real install — no `~/.openclaw`
|
|
19
|
+
* (or any of the legacy names below) exists on the machine this adapter was
|
|
20
|
+
* built on. Ships per CONTRIBUTING.md rule 3 on two independent
|
|
21
|
+
* corroborating sources, WITH THE SAME INTEGRITY CAVEAT documented at length
|
|
22
|
+
* in hermes.js's module docstring — summarized here, read that file for the
|
|
23
|
+
* full reasoning: `gh api` showed openclaw/openclaw at 388,546 GitHub stars
|
|
24
|
+
* for a repo created 2025-11-24 (more stars in under a year than
|
|
25
|
+
* facebook/react has after 13 years, sanity-checked against real repos in
|
|
26
|
+
* the same research pass), and independent search turned up a real fake-
|
|
27
|
+
* GitHub-stars research paper, a Hacker News thread about a plagiarism
|
|
28
|
+
* dispute between Hermes Agent and OpenClaw specifically, and a GitHub topic
|
|
29
|
+
* literally describing "Two zero-human AI companies battle for GitHub stars
|
|
30
|
+
* using Hermes Agent + Paperclip." That doesn't make the format below wrong,
|
|
31
|
+
* but it does mean OpenClaw's own docs and Hermes' own docs may not be
|
|
32
|
+
* independent of each other (Hermes' docs describe importing OpenClaw's
|
|
33
|
+
* config directory directly) — treat this as
|
|
34
|
+
* multi-source-corroborated-but-unverified, not stronger, and see this
|
|
35
|
+
* adapter's PR description/report for the full writeup.
|
|
36
|
+
*
|
|
37
|
+
* The two sources:
|
|
38
|
+
*
|
|
39
|
+
* 1. OpenClaw's own docs (docs.openclaw.ai/cli, /gateway/configuration):
|
|
40
|
+
* default state directory `~/.openclaw`, overridable via
|
|
41
|
+
* `OPENCLAW_STATE_DIR`; `--profile <name>` isolates state under
|
|
42
|
+
* `~/.openclaw-<name>`; `--dev` uses `~/.openclaw-dev`.
|
|
43
|
+
* 2. ccusage (github.com/ccusage/ccusage — see hermes.js for why this
|
|
44
|
+
* project's own star count and code were trusted) ships a tested Rust
|
|
45
|
+
* adapter for OpenClaw (rust/adapters/openclaw/src/{paths,parser}.rs),
|
|
46
|
+
* fetched and read directly. It confirms: an `OPENCLAW_DIR` env var
|
|
47
|
+
* (comma-separated list — ccusage's own name, not confirmed identical
|
|
48
|
+
* to OPENCLAW_STATE_DIR above, so this source checks both rather than
|
|
49
|
+
* betting on one), and — absent either — four candidate home-relative
|
|
50
|
+
* directories: `~/.openclaw`, `~/.clawdbot`, `~/.moltbot`, `~/.moldbot`
|
|
51
|
+
* (evidently earlier names across OpenClaw's own rebrand history).
|
|
52
|
+
* Under whichever directory exists, it recursively collects files whose
|
|
53
|
+
* name contains `.jsonl` as an exact suffix, or as
|
|
54
|
+
* `.jsonl.deleted.<...>` / `.jsonl.reset.<...>` (OpenClaw's own naming
|
|
55
|
+
* for archived/rotated session files) — confirmed by that adapter's own
|
|
56
|
+
* unit test asserting exactly these three shapes. Each line is plain
|
|
57
|
+
* JSONL: a `{"type":"model_change",...}` or
|
|
58
|
+
* `{"type":"custom","customType":"model-snapshot",...}` tracking
|
|
59
|
+
* record, or a `{"type":"message","message":{"role":...,...}}` record —
|
|
60
|
+
* confirmed directly from that adapter's own parser and fixture data.
|
|
61
|
+
*
|
|
62
|
+
* One deliberate departure from ccusage's own behaviour: its directory walk
|
|
63
|
+
* explicitly `continue`s past any symlink it meets (`file_type.is_symlink()
|
|
64
|
+
* => continue`) — reasonable for a usage-stats tool, wrong for a security
|
|
65
|
+
* scanner. This source instead follows claude-code.js's own house
|
|
66
|
+
* convention: follow a symlink if it resolves, report it `broken: true` if
|
|
67
|
+
* it doesn't, never silently skip it either way (CONTRIBUTING.md rule 5).
|
|
68
|
+
*/
|
|
69
|
+
const OPENCLAW_STATE_DIR_ENV = "OPENCLAW_STATE_DIR"; // OpenClaw's own official docs name
|
|
70
|
+
const OPENCLAW_DIR_ENV = "OPENCLAW_DIR"; // ccusage's name — kept as a second, unconfirmed alias
|
|
71
|
+
const LEGACY_HOME_NAMES = [".openclaw", ".clawdbot", ".moltbot", ".moldbot"];
|
|
72
|
+
|
|
73
|
+
function id() { return "openclaw"; }
|
|
74
|
+
function label() { return "OpenClaw"; }
|
|
75
|
+
|
|
76
|
+
function envRoots() {
|
|
77
|
+
for (const envVar of [OPENCLAW_STATE_DIR_ENV, OPENCLAW_DIR_ENV]) {
|
|
78
|
+
const val = process.env[envVar];
|
|
79
|
+
if (val && val.trim() !== "") {
|
|
80
|
+
return val.split(",").map((s) => s.trim()).filter((s) => s !== "").map((p) => path.resolve(p));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve every OpenClaw state root that actually exists on disk: either
|
|
88
|
+
* both env-var overrides (checked in envRoots()), or every legacy/renamed
|
|
89
|
+
* default directory under $HOME that's present, PLUS any `~/.openclaw-*`
|
|
90
|
+
* sibling (covers `--profile <name>` and `--dev`, per source 1 above —
|
|
91
|
+
* ccusage's own code doesn't do this, since it wasn't documented from
|
|
92
|
+
* ccusage's side, only OpenClaw's own docs).
|
|
93
|
+
*/
|
|
94
|
+
function stateRoots() {
|
|
95
|
+
const env = envRoots();
|
|
96
|
+
if (env) return env.filter((p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
|
|
97
|
+
|
|
98
|
+
const home = os.homedir();
|
|
99
|
+
const roots = [];
|
|
100
|
+
for (const name of LEGACY_HOME_NAMES) {
|
|
101
|
+
const dir = path.join(home, name);
|
|
102
|
+
try { if (fs.statSync(dir).isDirectory()) roots.push(dir); } catch { /* not present */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let homeEntries;
|
|
106
|
+
try { homeEntries = fs.readdirSync(home, { withFileTypes: true }); }
|
|
107
|
+
catch { homeEntries = []; }
|
|
108
|
+
for (const e of homeEntries) {
|
|
109
|
+
if (!e.name.startsWith(".openclaw-")) continue;
|
|
110
|
+
const dir = path.join(home, e.name);
|
|
111
|
+
if (roots.includes(dir)) continue;
|
|
112
|
+
let isDir = e.isDirectory();
|
|
113
|
+
if (!isDir && e.isSymbolicLink()) {
|
|
114
|
+
try { isDir = fs.statSync(dir).isDirectory(); } catch { isDir = false; }
|
|
115
|
+
}
|
|
116
|
+
if (isDir) roots.push(dir);
|
|
117
|
+
}
|
|
118
|
+
return roots;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function available() {
|
|
122
|
+
return stateRoots().length > 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
126
|
+
if (checkFn(dirent)) return true;
|
|
127
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
128
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
129
|
+
}
|
|
130
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
131
|
+
const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* True for `*.jsonl`, `*.jsonl.deleted.<...>`, `*.jsonl.reset.<...>` —
|
|
135
|
+
* mirrors ccusage's own `is_openclaw_session_file` (including its own unit
|
|
136
|
+
* test's exact examples) byte for byte.
|
|
137
|
+
*/
|
|
138
|
+
function isOpenClawSessionFile(name) {
|
|
139
|
+
const index = name.indexOf(".jsonl");
|
|
140
|
+
if (index === -1) return false;
|
|
141
|
+
const suffix = name.slice(index);
|
|
142
|
+
return suffix === ".jsonl" || suffix.startsWith(".jsonl.deleted.") || suffix.startsWith(".jsonl.reset.");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Recursively yield { file, mtimeMs, sizeBytes, broken } for every session
|
|
147
|
+
* file under one root. Depth-first, symlink-following-with-broken-reporting
|
|
148
|
+
* (see module docstring for why this deliberately differs from ccusage's
|
|
149
|
+
* own skip-on-symlink walk).
|
|
150
|
+
*/
|
|
151
|
+
function* walk(dir) {
|
|
152
|
+
let entries;
|
|
153
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
154
|
+
catch { yield { file: dir, broken: true }; return; }
|
|
155
|
+
|
|
156
|
+
for (const e of entries) {
|
|
157
|
+
const full = path.join(dir, e.name);
|
|
158
|
+
if (e.isDirectory() || (e.isSymbolicLink() && isDirFollowingSymlink(full, e))) {
|
|
159
|
+
yield* walk(full);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (!isOpenClawSessionFile(e.name)) continue;
|
|
163
|
+
if (!e.isFile()) {
|
|
164
|
+
const resolved = isFileFollowingSymlink(full, e);
|
|
165
|
+
if (!resolved) {
|
|
166
|
+
if (e.isSymbolicLink()) yield { file: full, broken: true };
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
let stat;
|
|
171
|
+
try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
|
|
172
|
+
yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function* files() {
|
|
177
|
+
for (const root of stateRoots()) yield* walk(root);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — same untested-against-a-real-huge-file backstop
|
|
181
|
+
// claude-code.js uses, for the same stated reason.
|
|
182
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read one session file as an array of raw text lines — identical
|
|
186
|
+
* streamed/bounded/timed-out shape to claude-code.js's own readLines(), for
|
|
187
|
+
* the same reasons documented there (JSONL, so this needs no JSON-array
|
|
188
|
+
* reconstruction the way codebuff.js's chat-messages.json does).
|
|
189
|
+
*/
|
|
190
|
+
async function readLines(file) {
|
|
191
|
+
let stat;
|
|
192
|
+
try { stat = fs.statSync(file); }
|
|
193
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
194
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
195
|
+
|
|
196
|
+
const lines = [];
|
|
197
|
+
let bytesRead = 0;
|
|
198
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
199
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
200
|
+
|
|
201
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
for await (const line of rl) {
|
|
205
|
+
lines.push(line);
|
|
206
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1;
|
|
207
|
+
}
|
|
208
|
+
return { lines, status: "complete", bytesRead };
|
|
209
|
+
} catch {
|
|
210
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
211
|
+
} finally {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
rl.close();
|
|
214
|
+
stream.destroy();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { id, label, available, files, readLines };
|