swarm-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +384 -0
- package/bin/swarm.mjs +45 -0
- package/dist/agents/aider.d.ts +12 -0
- package/dist/agents/aider.js +182 -0
- package/dist/agents/claude-code.d.ts +9 -0
- package/dist/agents/claude-code.js +216 -0
- package/dist/agents/codex.d.ts +14 -0
- package/dist/agents/codex.js +193 -0
- package/dist/agents/direct-llm.d.ts +9 -0
- package/dist/agents/direct-llm.js +78 -0
- package/dist/agents/mock.d.ts +9 -0
- package/dist/agents/mock.js +77 -0
- package/dist/agents/opencode.d.ts +23 -0
- package/dist/agents/opencode.js +571 -0
- package/dist/agents/provider.d.ts +11 -0
- package/dist/agents/provider.js +31 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +285 -0
- package/dist/compression/compressor.d.ts +28 -0
- package/dist/compression/compressor.js +265 -0
- package/dist/config.d.ts +42 -0
- package/dist/config.js +170 -0
- package/dist/core/repl.d.ts +69 -0
- package/dist/core/repl.js +336 -0
- package/dist/core/rlm.d.ts +63 -0
- package/dist/core/rlm.js +409 -0
- package/dist/core/runtime.py +335 -0
- package/dist/core/types.d.ts +131 -0
- package/dist/core/types.js +19 -0
- package/dist/env.d.ts +10 -0
- package/dist/env.js +75 -0
- package/dist/interactive-swarm.d.ts +20 -0
- package/dist/interactive-swarm.js +1041 -0
- package/dist/interactive.d.ts +10 -0
- package/dist/interactive.js +1765 -0
- package/dist/main.d.ts +15 -0
- package/dist/main.js +242 -0
- package/dist/mcp/server.d.ts +15 -0
- package/dist/mcp/server.js +72 -0
- package/dist/mcp/session.d.ts +73 -0
- package/dist/mcp/session.js +184 -0
- package/dist/mcp/tools.d.ts +15 -0
- package/dist/mcp/tools.js +377 -0
- package/dist/memory/episodic.d.ts +132 -0
- package/dist/memory/episodic.js +390 -0
- package/dist/prompts/orchestrator.d.ts +5 -0
- package/dist/prompts/orchestrator.js +191 -0
- package/dist/routing/model-router.d.ts +130 -0
- package/dist/routing/model-router.js +515 -0
- package/dist/swarm.d.ts +14 -0
- package/dist/swarm.js +557 -0
- package/dist/threads/cache.d.ts +58 -0
- package/dist/threads/cache.js +198 -0
- package/dist/threads/manager.d.ts +85 -0
- package/dist/threads/manager.js +659 -0
- package/dist/ui/banner.d.ts +14 -0
- package/dist/ui/banner.js +42 -0
- package/dist/ui/dashboard.d.ts +33 -0
- package/dist/ui/dashboard.js +151 -0
- package/dist/ui/index.d.ts +10 -0
- package/dist/ui/index.js +11 -0
- package/dist/ui/log.d.ts +39 -0
- package/dist/ui/log.js +126 -0
- package/dist/ui/onboarding.d.ts +14 -0
- package/dist/ui/onboarding.js +518 -0
- package/dist/ui/spinner.d.ts +25 -0
- package/dist/ui/spinner.js +113 -0
- package/dist/ui/summary.d.ts +18 -0
- package/dist/ui/summary.js +113 -0
- package/dist/ui/theme.d.ts +63 -0
- package/dist/ui/theme.js +97 -0
- package/dist/viewer.d.ts +12 -0
- package/dist/viewer.js +1284 -0
- package/dist/worktree/manager.d.ts +45 -0
- package/dist/worktree/manager.js +266 -0
- package/dist/worktree/merge.d.ts +28 -0
- package/dist/worktree/merge.js +138 -0
- package/package.json +69 -0
|
@@ -0,0 +1,1765 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* RLM Interactive — Production-quality interactive terminal REPL.
|
|
4
|
+
*
|
|
5
|
+
* Launch with `rlm` and get a persistent session where you can:
|
|
6
|
+
* - Set context (file/URL/paste)
|
|
7
|
+
* - Type queries and watch the RLM loop run with smooth, real-time output
|
|
8
|
+
* - Browse previous trajectories
|
|
9
|
+
*/
|
|
10
|
+
import "./env.js";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { stdin, stdout } from "node:process";
|
|
15
|
+
import * as readline from "node:readline";
|
|
16
|
+
// Global error handlers — prevent raw stack traces from leaking to terminal
|
|
17
|
+
process.on("uncaughtException", (err) => {
|
|
18
|
+
console.error(`\n \x1b[31mUnexpected error: ${err.message}\x1b[0m\n`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
|
21
|
+
process.on("unhandledRejection", (err) => {
|
|
22
|
+
console.error(`\n \x1b[31mUnexpected error: ${err?.message || err}\x1b[0m\n`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
});
|
|
25
|
+
const { getModels, getProviders } = await import("@mariozechner/pi-ai");
|
|
26
|
+
const { PythonRepl } = await import("./core/repl.js");
|
|
27
|
+
const { runRlmLoop } = await import("./core/rlm.js");
|
|
28
|
+
const { loadConfig } = await import("./config.js");
|
|
29
|
+
const config = loadConfig();
|
|
30
|
+
// ── ANSI helpers ────────────────────────────────────────────────────────────
|
|
31
|
+
const c = {
|
|
32
|
+
reset: "\x1b[0m",
|
|
33
|
+
bold: "\x1b[1m",
|
|
34
|
+
dim: "\x1b[2m",
|
|
35
|
+
italic: "\x1b[3m",
|
|
36
|
+
underline: "\x1b[4m",
|
|
37
|
+
red: "\x1b[31m",
|
|
38
|
+
green: "\x1b[32m",
|
|
39
|
+
yellow: "\x1b[33m",
|
|
40
|
+
blue: "\x1b[34m",
|
|
41
|
+
magenta: "\x1b[35m",
|
|
42
|
+
cyan: "\x1b[36m",
|
|
43
|
+
white: "\x1b[37m",
|
|
44
|
+
gray: "\x1b[90m",
|
|
45
|
+
clearLine: "\x1b[2K\r",
|
|
46
|
+
};
|
|
47
|
+
// ── Spinner ─────────────────────────────────────────────────────────────────
|
|
48
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
49
|
+
class Spinner {
|
|
50
|
+
interval = null;
|
|
51
|
+
frameIndex = 0;
|
|
52
|
+
message = "";
|
|
53
|
+
startTime = Date.now();
|
|
54
|
+
start(message) {
|
|
55
|
+
this.stop();
|
|
56
|
+
this.message = message;
|
|
57
|
+
this.startTime = Date.now();
|
|
58
|
+
this.frameIndex = 0;
|
|
59
|
+
this.render();
|
|
60
|
+
this.interval = setInterval(() => this.render(), 80);
|
|
61
|
+
}
|
|
62
|
+
update(message) {
|
|
63
|
+
this.message = message;
|
|
64
|
+
}
|
|
65
|
+
stop() {
|
|
66
|
+
if (this.interval) {
|
|
67
|
+
clearInterval(this.interval);
|
|
68
|
+
this.interval = null;
|
|
69
|
+
process.stdout.write(c.clearLine);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
render() {
|
|
73
|
+
const frame = SPINNER_FRAMES[this.frameIndex % SPINNER_FRAMES.length];
|
|
74
|
+
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
|
|
75
|
+
process.stdout.write(`${c.clearLine} ${c.cyan}${frame}${c.reset} ${this.message} ${c.dim}${elapsed}s${c.reset}`);
|
|
76
|
+
this.frameIndex++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
80
|
+
const DEFAULT_MODEL = process.env.RLM_MODEL || "claude-sonnet-4-6";
|
|
81
|
+
const RLM_HOME = path.join(os.homedir(), ".rlm");
|
|
82
|
+
const TRAJ_DIR = path.join(RLM_HOME, "trajectories");
|
|
83
|
+
let _W = Math.min(process.stdout.columns || 80, 100);
|
|
84
|
+
// ── Session state ───────────────────────────────────────────────────────────
|
|
85
|
+
let currentModelId = DEFAULT_MODEL;
|
|
86
|
+
let currentModel;
|
|
87
|
+
let currentProviderName = "";
|
|
88
|
+
let contextText = "";
|
|
89
|
+
let contextSource = "";
|
|
90
|
+
let queryCount = 0;
|
|
91
|
+
let isRunning = false;
|
|
92
|
+
// Exposed so the readline SIGINT handler can abort the running query
|
|
93
|
+
let activeAc = null;
|
|
94
|
+
let activeRepl = null;
|
|
95
|
+
let activeSpinner = null;
|
|
96
|
+
// ── Resolve model ───────────────────────────────────────────────────────────
|
|
97
|
+
function resolveModel(modelId) {
|
|
98
|
+
const result = resolveModelWithProvider(modelId);
|
|
99
|
+
return result?.model;
|
|
100
|
+
}
|
|
101
|
+
// Provider → env var mapping for well-known providers
|
|
102
|
+
const PROVIDER_KEYS = {
|
|
103
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
104
|
+
openai: "OPENAI_API_KEY",
|
|
105
|
+
google: "GEMINI_API_KEY",
|
|
106
|
+
};
|
|
107
|
+
// User-facing provider list for setup & /provider command
|
|
108
|
+
const SETUP_PROVIDERS = [
|
|
109
|
+
{ name: "Anthropic", label: "Claude", env: "ANTHROPIC_API_KEY", piProvider: "anthropic" },
|
|
110
|
+
{ name: "OpenAI", label: "GPT", env: "OPENAI_API_KEY", piProvider: "openai" },
|
|
111
|
+
{ name: "Google", label: "Gemini", env: "GEMINI_API_KEY", piProvider: "google" },
|
|
112
|
+
];
|
|
113
|
+
function providerEnvKey(provider) {
|
|
114
|
+
return PROVIDER_KEYS[provider] || `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`;
|
|
115
|
+
}
|
|
116
|
+
function detectProvider() {
|
|
117
|
+
for (const provider of Object.keys(PROVIDER_KEYS)) {
|
|
118
|
+
if (process.env[PROVIDER_KEYS[provider]])
|
|
119
|
+
return provider;
|
|
120
|
+
}
|
|
121
|
+
return "unknown";
|
|
122
|
+
}
|
|
123
|
+
function hasAnyApiKey() {
|
|
124
|
+
return detectProvider() !== "unknown";
|
|
125
|
+
}
|
|
126
|
+
/** Returns the pi-ai provider name + model for a given model ID, searching all providers.
|
|
127
|
+
* Prioritises SETUP_PROVIDERS (with API key) so e.g. "gpt-4o" resolves to "openai" not "azure-openai-responses". */
|
|
128
|
+
function resolveModelWithProvider(modelId) {
|
|
129
|
+
const knownNames = new Set(SETUP_PROVIDERS.map((p) => p.piProvider));
|
|
130
|
+
let firstMatch;
|
|
131
|
+
// First pass: well-known providers that have an API key set (best match)
|
|
132
|
+
for (const provider of getProviders()) {
|
|
133
|
+
if (!knownNames.has(provider))
|
|
134
|
+
continue;
|
|
135
|
+
for (const m of getModels(provider)) {
|
|
136
|
+
if (m.id === modelId) {
|
|
137
|
+
if (process.env[providerEnvKey(provider)]) {
|
|
138
|
+
return { model: m, provider };
|
|
139
|
+
}
|
|
140
|
+
if (!firstMatch)
|
|
141
|
+
firstMatch = { model: m, provider };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Second pass: well-known providers without key (user may enter it later)
|
|
146
|
+
if (firstMatch)
|
|
147
|
+
return firstMatch;
|
|
148
|
+
// Third pass: all remaining providers
|
|
149
|
+
for (const provider of getProviders()) {
|
|
150
|
+
if (knownNames.has(provider))
|
|
151
|
+
continue;
|
|
152
|
+
for (const m of getModels(provider)) {
|
|
153
|
+
if (m.id === modelId)
|
|
154
|
+
return { model: m, provider };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
/** Sensible default model per provider. */
|
|
160
|
+
const PROVIDER_DEFAULT_MODELS = {
|
|
161
|
+
anthropic: "claude-sonnet-4-6",
|
|
162
|
+
openai: "gpt-4o",
|
|
163
|
+
google: "gemini-2.5-flash",
|
|
164
|
+
};
|
|
165
|
+
/** Returns the recommended default model for a provider. */
|
|
166
|
+
function getDefaultModelForProvider(provider) {
|
|
167
|
+
const preferred = PROVIDER_DEFAULT_MODELS[provider];
|
|
168
|
+
if (preferred) {
|
|
169
|
+
const model = resolveModel(preferred);
|
|
170
|
+
if (model)
|
|
171
|
+
return preferred;
|
|
172
|
+
}
|
|
173
|
+
// Fallback: first non-excluded model
|
|
174
|
+
const models = getModelsForProvider(provider);
|
|
175
|
+
return models.length > 0 ? models[0].id : undefined;
|
|
176
|
+
}
|
|
177
|
+
/** Wrap rl.question with ESC-to-cancel. Returns user input, empty string, or null on ESC.
|
|
178
|
+
* When secret=true, suppresses character echo (for API keys). */
|
|
179
|
+
function questionWithEsc(rlInstance, promptText, opts) {
|
|
180
|
+
return new Promise((resolve) => {
|
|
181
|
+
let escaped = false;
|
|
182
|
+
const rlAny = rlInstance;
|
|
183
|
+
let savedWrite;
|
|
184
|
+
if (opts?.secret) {
|
|
185
|
+
// Suppress readline's echo — write prompt ourselves, hide typed chars
|
|
186
|
+
savedWrite = rlAny._writeToOutput;
|
|
187
|
+
rlAny._writeToOutput = () => { };
|
|
188
|
+
process.stdout.write(promptText);
|
|
189
|
+
}
|
|
190
|
+
const onKeypress = (_str, key) => {
|
|
191
|
+
if (key?.name === "escape" && !escaped) {
|
|
192
|
+
escaped = true;
|
|
193
|
+
stdin.removeListener("keypress", onKeypress);
|
|
194
|
+
if (savedWrite)
|
|
195
|
+
rlAny._writeToOutput = savedWrite;
|
|
196
|
+
process.stdout.write("\r\x1b[2K");
|
|
197
|
+
rlInstance.write("\n");
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
stdin.on("keypress", onKeypress);
|
|
201
|
+
rlInstance.question(opts?.secret ? "" : promptText, (answer) => {
|
|
202
|
+
stdin.removeListener("keypress", onKeypress);
|
|
203
|
+
if (savedWrite) {
|
|
204
|
+
rlAny._writeToOutput = savedWrite;
|
|
205
|
+
process.stdout.write("\n");
|
|
206
|
+
}
|
|
207
|
+
resolve(escaped ? null : answer.trim());
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/** Prompt user for a provider's API key (only if not already set).
|
|
212
|
+
* Returns true (got key / already set), false (empty input), or null (ESC pressed). */
|
|
213
|
+
async function promptForProviderKey(rlInstance, providerInfo) {
|
|
214
|
+
if (process.env[providerInfo.env])
|
|
215
|
+
return true;
|
|
216
|
+
const rawKey = await questionWithEsc(rlInstance, ` ${c.cyan}${providerInfo.env}:${c.reset} `, { secret: true });
|
|
217
|
+
if (rawKey === null)
|
|
218
|
+
return null; // ESC
|
|
219
|
+
if (!rawKey)
|
|
220
|
+
return false; // empty
|
|
221
|
+
// Sanitize: strip newlines, control chars, whitespace
|
|
222
|
+
const key = rawKey.replace(/[\r\n\x00-\x1f]/g, "").trim();
|
|
223
|
+
if (!key)
|
|
224
|
+
return false;
|
|
225
|
+
process.env[providerInfo.env] = key;
|
|
226
|
+
// Save to ~/.rlm/credentials (persistent across sessions, replaces existing key)
|
|
227
|
+
const credPath = path.join(RLM_HOME, "credentials");
|
|
228
|
+
try {
|
|
229
|
+
if (!fs.existsSync(RLM_HOME))
|
|
230
|
+
fs.mkdirSync(RLM_HOME, { recursive: true });
|
|
231
|
+
// Remove existing entry for this key to avoid duplicates
|
|
232
|
+
if (fs.existsSync(credPath)) {
|
|
233
|
+
const existing = fs.readFileSync(credPath, "utf-8");
|
|
234
|
+
const filtered = existing
|
|
235
|
+
.split("\n")
|
|
236
|
+
.filter((l) => {
|
|
237
|
+
const t = l.trim();
|
|
238
|
+
if (t.startsWith("export "))
|
|
239
|
+
return !t.slice(7).startsWith(`${providerInfo.env}=`);
|
|
240
|
+
return !t.startsWith(`${providerInfo.env}=`);
|
|
241
|
+
})
|
|
242
|
+
.join("\n");
|
|
243
|
+
fs.writeFileSync(credPath, filtered.endsWith("\n") ? filtered : `${filtered}\n`);
|
|
244
|
+
}
|
|
245
|
+
fs.appendFileSync(credPath, `${providerInfo.env}=${key}\n`);
|
|
246
|
+
// Restrict permissions (owner-only read/write)
|
|
247
|
+
try {
|
|
248
|
+
fs.chmodSync(credPath, 0o600);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* Windows etc. */
|
|
252
|
+
}
|
|
253
|
+
console.log(`\n ${c.green}✓${c.reset} ${providerInfo.name} key saved to ${c.dim}~/.rlm/credentials${c.reset}`);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
console.log(`\n ${c.yellow}!${c.reset} Could not save key. Add manually:`);
|
|
257
|
+
console.log(` ${c.yellow}export ${providerInfo.env}=<your-key>${c.reset}`);
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
/** Persist the user's model choice to ~/.rlm/credentials so it survives restarts. */
|
|
262
|
+
function saveModelPreference(modelId) {
|
|
263
|
+
const credPath = path.join(RLM_HOME, "credentials");
|
|
264
|
+
try {
|
|
265
|
+
if (!fs.existsSync(RLM_HOME))
|
|
266
|
+
fs.mkdirSync(RLM_HOME, { recursive: true });
|
|
267
|
+
// Remove existing RLM_MODEL entry
|
|
268
|
+
if (fs.existsSync(credPath)) {
|
|
269
|
+
const existing = fs.readFileSync(credPath, "utf-8");
|
|
270
|
+
const filtered = existing
|
|
271
|
+
.split("\n")
|
|
272
|
+
.filter((l) => {
|
|
273
|
+
const t = l.trim();
|
|
274
|
+
if (t.startsWith("export "))
|
|
275
|
+
return !t.slice(7).startsWith("RLM_MODEL=");
|
|
276
|
+
return !t.startsWith("RLM_MODEL=");
|
|
277
|
+
})
|
|
278
|
+
.join("\n");
|
|
279
|
+
fs.writeFileSync(credPath, filtered.endsWith("\n") ? filtered : `${filtered}\n`);
|
|
280
|
+
}
|
|
281
|
+
fs.appendFileSync(credPath, `RLM_MODEL=${modelId}\n`);
|
|
282
|
+
try {
|
|
283
|
+
fs.chmodSync(credPath, 0o600);
|
|
284
|
+
}
|
|
285
|
+
catch { }
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
/* best-effort */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Find the SETUP_PROVIDERS entry that owns a given pi-ai provider name. */
|
|
292
|
+
function findSetupProvider(piProvider) {
|
|
293
|
+
return SETUP_PROVIDERS.find((p) => p.piProvider === piProvider);
|
|
294
|
+
}
|
|
295
|
+
// ── Paste detection ─────────────────────────────────────────────────────────
|
|
296
|
+
function isMultiLineInput(input) {
|
|
297
|
+
return input.includes("\n");
|
|
298
|
+
}
|
|
299
|
+
function handleMultiLineAsContext(input) {
|
|
300
|
+
const lines = input.split("\n");
|
|
301
|
+
if (lines.length > 3) {
|
|
302
|
+
const sizeKB = (input.length / 1024).toFixed(1);
|
|
303
|
+
console.log(` ${c.green}✓${c.reset} Pasted ${c.bold}${lines.length} lines${c.reset} ${c.dim}(${sizeKB}KB)${c.reset}`);
|
|
304
|
+
return { context: input, query: "" };
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
// ── Banner ──────────────────────────────────────────────────────────────────
|
|
309
|
+
function printBanner() {
|
|
310
|
+
console.log(`
|
|
311
|
+
${c.cyan}${c.bold}
|
|
312
|
+
██████╗ ██╗ ███╗ ███╗
|
|
313
|
+
██╔══██╗██║ ████╗ ████║
|
|
314
|
+
██████╔╝██║ ██╔████╔██║
|
|
315
|
+
██╔══██╗██║ ██║╚██╔╝██║
|
|
316
|
+
██║ ██║███████╗██║ ╚═╝ ██║
|
|
317
|
+
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝
|
|
318
|
+
${c.reset}
|
|
319
|
+
${c.dim} Recursive Language Models — arXiv:2512.24601${c.reset}
|
|
320
|
+
`);
|
|
321
|
+
}
|
|
322
|
+
// ── Status line ─────────────────────────────────────────────────────────────
|
|
323
|
+
function printStatusLine() {
|
|
324
|
+
const provider = currentProviderName || detectProvider();
|
|
325
|
+
const modelShort = currentModelId.length > 35 ? `${currentModelId.slice(0, 32)}...` : currentModelId;
|
|
326
|
+
const ctx = contextText
|
|
327
|
+
? `${c.green}●${c.reset} ${(contextText.length / 1024).toFixed(1)}KB${contextSource ? ` ${c.dim}(${contextSource})${c.reset}` : ""}`
|
|
328
|
+
: `${c.dim}○${c.reset}`;
|
|
329
|
+
console.log(` ${c.dim}${modelShort}${c.reset} ${c.dim}(${provider})${c.reset} ${ctx} ${c.dim}Q:${queryCount}${c.reset}`);
|
|
330
|
+
}
|
|
331
|
+
// ── Directory tree ──────────────────────────────────────────────────────────
|
|
332
|
+
/** Generate a concise directory tree string (like `tree -L 2`). */
|
|
333
|
+
function generateDirTree(dir, prefix = "", depth = 0, maxDepth = 2) {
|
|
334
|
+
if (depth > maxDepth)
|
|
335
|
+
return "";
|
|
336
|
+
let entries;
|
|
337
|
+
try {
|
|
338
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return "";
|
|
342
|
+
}
|
|
343
|
+
// Filter and sort: dirs first, skip hidden/ignored
|
|
344
|
+
const filtered = entries
|
|
345
|
+
.filter((e) => {
|
|
346
|
+
if (e.name.startsWith(".") && e.name !== ".env")
|
|
347
|
+
return false;
|
|
348
|
+
if (e.isDirectory() && SKIP_DIRS.has(e.name))
|
|
349
|
+
return false;
|
|
350
|
+
if (e.isSymbolicLink())
|
|
351
|
+
return false;
|
|
352
|
+
if (e.isFile() && isBinaryFile(path.join(dir, e.name)))
|
|
353
|
+
return false;
|
|
354
|
+
return true;
|
|
355
|
+
})
|
|
356
|
+
.sort((a, b) => {
|
|
357
|
+
if (a.isDirectory() !== b.isDirectory())
|
|
358
|
+
return a.isDirectory() ? -1 : 1;
|
|
359
|
+
return a.name.localeCompare(b.name);
|
|
360
|
+
});
|
|
361
|
+
// Cap entries per level to keep it concise
|
|
362
|
+
const MAX_PER_LEVEL = 25;
|
|
363
|
+
const shown = filtered.slice(0, MAX_PER_LEVEL);
|
|
364
|
+
const omitted = filtered.length - shown.length;
|
|
365
|
+
const lines = [];
|
|
366
|
+
for (let i = 0; i < shown.length; i++) {
|
|
367
|
+
const entry = shown[i];
|
|
368
|
+
const isLast = i === shown.length - 1 && omitted === 0;
|
|
369
|
+
const connector = isLast ? "└── " : "├── ";
|
|
370
|
+
const childPrefix = isLast ? " " : "│ ";
|
|
371
|
+
const suffix = entry.isDirectory() ? "/" : "";
|
|
372
|
+
lines.push(`${prefix}${connector}${entry.name}${suffix}`);
|
|
373
|
+
if (entry.isDirectory()) {
|
|
374
|
+
const sub = generateDirTree(path.join(dir, entry.name), prefix + childPrefix, depth + 1, maxDepth);
|
|
375
|
+
if (sub)
|
|
376
|
+
lines.push(sub);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (omitted > 0)
|
|
380
|
+
lines.push(`${prefix}└── ... ${omitted} more`);
|
|
381
|
+
return lines.join("\n");
|
|
382
|
+
}
|
|
383
|
+
/** Build a cwd context string with directory tree. */
|
|
384
|
+
function buildCwdContext() {
|
|
385
|
+
const cwd = process.cwd();
|
|
386
|
+
const tree = generateDirTree(cwd);
|
|
387
|
+
const parts = [`Working directory: ${cwd}\n`];
|
|
388
|
+
if (tree)
|
|
389
|
+
parts.push(`File tree:\n${tree}`);
|
|
390
|
+
return parts.join("\n");
|
|
391
|
+
}
|
|
392
|
+
// ── Welcome ─────────────────────────────────────────────────────────────────
|
|
393
|
+
function printWelcome() {
|
|
394
|
+
console.clear();
|
|
395
|
+
printBanner();
|
|
396
|
+
const cwdShort = process.cwd().replace(os.homedir(), "~");
|
|
397
|
+
console.log(` ${c.dim}${cwdShort}${c.reset}`);
|
|
398
|
+
printStatusLine();
|
|
399
|
+
console.log(` ${c.dim}max ${config.max_iterations} iters · ${config.max_sub_queries} sub-queries · /help${c.reset}\n`);
|
|
400
|
+
}
|
|
401
|
+
// ── Help ────────────────────────────────────────────────────────────────────
|
|
402
|
+
function printCommandHelp() {
|
|
403
|
+
console.log(`
|
|
404
|
+
${c.bold}Loading Context${c.reset}
|
|
405
|
+
${c.cyan}/file${c.reset} <path> Load a single file
|
|
406
|
+
${c.cyan}/file${c.reset} <p1> <p2> ... Load multiple files
|
|
407
|
+
${c.cyan}/file${c.reset} <dir>/ Load all files in a directory (recursive)
|
|
408
|
+
${c.cyan}/file${c.reset} src/**/*.ts Load files matching a glob pattern
|
|
409
|
+
${c.cyan}/url${c.reset} <url> Fetch URL as context
|
|
410
|
+
${c.cyan}/paste${c.reset} Multi-line paste mode (type EOF to finish)
|
|
411
|
+
${c.cyan}/context${c.reset} Show loaded context info + file list
|
|
412
|
+
${c.cyan}/clear-context${c.reset} Unload context
|
|
413
|
+
|
|
414
|
+
${c.bold}@ Shorthand${c.reset} ${c.dim}(inline file loading)${c.reset}
|
|
415
|
+
${c.cyan}@file.ts${c.reset} <query> Load file and ask in one shot
|
|
416
|
+
${c.cyan}@a.ts @b.ts${c.reset} <query> Load multiple files + query
|
|
417
|
+
${c.cyan}@src/${c.reset} <query> Load directory + query
|
|
418
|
+
${c.cyan}@src/**/*.ts${c.reset} <query> Load glob + query
|
|
419
|
+
|
|
420
|
+
${c.bold}Model & Provider${c.reset}
|
|
421
|
+
${c.cyan}/model${c.reset} List models for current provider
|
|
422
|
+
${c.cyan}/model${c.reset} <#|id> Switch model by number or ID
|
|
423
|
+
${c.cyan}/provider${c.reset} Switch provider (Anthropic, OpenAI, Google)
|
|
424
|
+
${c.cyan}/key${c.reset} Update an API key
|
|
425
|
+
|
|
426
|
+
${c.bold}Tools${c.reset}
|
|
427
|
+
${c.cyan}/trajectories${c.reset} List saved runs
|
|
428
|
+
|
|
429
|
+
${c.bold}General${c.reset}
|
|
430
|
+
${c.cyan}/clear${c.reset} Clear screen
|
|
431
|
+
${c.cyan}/help${c.reset} Show this help
|
|
432
|
+
${c.cyan}/quit${c.reset} Exit
|
|
433
|
+
|
|
434
|
+
${c.bold}Tips${c.reset}
|
|
435
|
+
${c.dim}•${c.reset} Just type a question — no context needed for general queries
|
|
436
|
+
${c.dim}•${c.reset} Paste a URL directly to fetch it as context
|
|
437
|
+
${c.dim}•${c.reset} Paste 4+ lines of text to set it as context
|
|
438
|
+
${c.dim}•${c.reset} ${c.bold}Ctrl+C${c.reset} stops a running query, ${c.bold}Ctrl+C twice${c.reset} exits
|
|
439
|
+
${c.dim}•${c.reset} Directories skip node_modules, .git, dist, binaries, etc.
|
|
440
|
+
${c.dim}•${c.reset} Limits: ${MAX_FILES} files max, ${MAX_TOTAL_BYTES / 1024 / 1024}MB total
|
|
441
|
+
`);
|
|
442
|
+
}
|
|
443
|
+
// ── Slash command handlers ──────────────────────────────────────────────────
|
|
444
|
+
/** Check if a token looks like a file path (vs. plain query text). */
|
|
445
|
+
function looksLikePath(token) {
|
|
446
|
+
if (token.includes("/") || token.includes("\\"))
|
|
447
|
+
return true;
|
|
448
|
+
if (token.includes("*") || token.includes("?"))
|
|
449
|
+
return true;
|
|
450
|
+
if (token.startsWith("~"))
|
|
451
|
+
return true;
|
|
452
|
+
if (token.startsWith("."))
|
|
453
|
+
return true;
|
|
454
|
+
if (/\.\w{1,6}$/.test(token))
|
|
455
|
+
return true; // has file extension
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
async function handleFile(arg) {
|
|
459
|
+
if (!arg) {
|
|
460
|
+
console.log(` ${c.red}Usage: /file <path> [query]${c.reset}`);
|
|
461
|
+
console.log(` ${c.dim}Examples: /file src/main.ts | /file src/ | /file src/**/*.ts${c.reset}`);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const tokens = arg.split(/\s+/).filter(Boolean);
|
|
465
|
+
// Separate paths from query text
|
|
466
|
+
const pathTokens = [];
|
|
467
|
+
const queryTokens = [];
|
|
468
|
+
let pastPaths = false;
|
|
469
|
+
for (const t of tokens) {
|
|
470
|
+
if (!pastPaths && looksLikePath(t)) {
|
|
471
|
+
pathTokens.push(t);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
pastPaths = true;
|
|
475
|
+
queryTokens.push(t);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (pathTokens.length === 0) {
|
|
479
|
+
console.log(` ${c.red}No file path found in: ${arg}${c.reset}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const filePaths = resolveFileArgs(pathTokens);
|
|
483
|
+
if (filePaths.length === 0) {
|
|
484
|
+
console.log(` ${c.red}No files found.${c.reset}`);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (filePaths.length === 1) {
|
|
488
|
+
try {
|
|
489
|
+
contextText = fs.readFileSync(filePaths[0], "utf-8");
|
|
490
|
+
contextSource = path.relative(process.cwd(), filePaths[0]) || filePaths[0];
|
|
491
|
+
const lines = contextText.split("\n").length;
|
|
492
|
+
console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from ${c.underline}${contextSource}${c.reset}`);
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
const { text, count, totalBytes } = loadMultipleFiles(filePaths);
|
|
500
|
+
contextText = text;
|
|
501
|
+
contextSource = `${count} files`;
|
|
502
|
+
console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${count}${c.reset} files (${(totalBytes / 1024).toFixed(1)}KB total)`);
|
|
503
|
+
// Show file list
|
|
504
|
+
for (const fp of filePaths.slice(0, 20)) {
|
|
505
|
+
console.log(` ${c.dim}•${c.reset} ${path.relative(process.cwd(), fp)}`);
|
|
506
|
+
}
|
|
507
|
+
if (filePaths.length > 20) {
|
|
508
|
+
console.log(` ${c.dim}... and ${filePaths.length - 20} more${c.reset}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Return query text if provided after paths
|
|
512
|
+
if (queryTokens.length > 0)
|
|
513
|
+
return queryTokens.join(" ");
|
|
514
|
+
}
|
|
515
|
+
async function handleUrl(arg) {
|
|
516
|
+
if (!arg) {
|
|
517
|
+
console.log(` ${c.red}Usage: /url <url>${c.reset}`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
console.log(` ${c.dim}Fetching ${arg}...${c.reset}`);
|
|
521
|
+
try {
|
|
522
|
+
contextText = await safeFetch(arg);
|
|
523
|
+
contextSource = arg;
|
|
524
|
+
const lines = contextText.split("\n").length;
|
|
525
|
+
console.log(` ${c.green}✓${c.reset} Fetched ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines)`);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
console.log(` ${c.red}Failed: ${err.message}${c.reset}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function handlePaste(rl) {
|
|
532
|
+
return new Promise((resolve) => {
|
|
533
|
+
console.log(` ${c.dim}Paste your context below. Type ${c.bold}EOF${c.reset}${c.dim} on an empty line to finish.${c.reset}`);
|
|
534
|
+
const lines = [];
|
|
535
|
+
const onLine = (line) => {
|
|
536
|
+
if (line.trim() === "EOF") {
|
|
537
|
+
rl.removeListener("line", onLine);
|
|
538
|
+
contextText = lines.join("\n");
|
|
539
|
+
contextSource = "(pasted)";
|
|
540
|
+
console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.length} lines) from paste`);
|
|
541
|
+
resolve();
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
lines.push(line);
|
|
545
|
+
};
|
|
546
|
+
rl.on("line", onLine);
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
function handleContext() {
|
|
550
|
+
if (!contextText) {
|
|
551
|
+
console.log(` ${c.dim}No context loaded. Use /file, /url, @file, or /paste.${c.reset}`);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const lines = contextText.split("\n").length;
|
|
555
|
+
const sizeKB = (contextText.length / 1024).toFixed(1);
|
|
556
|
+
console.log(` ${c.bold}Context:${c.reset} ${contextText.length.toLocaleString()} chars (${sizeKB}KB), ${lines.toLocaleString()} lines`);
|
|
557
|
+
console.log(` ${c.bold}Source:${c.reset} ${contextSource}`);
|
|
558
|
+
// For multi-file context, extract and display individual file paths
|
|
559
|
+
const fileSeparators = contextText.match(/^=== .+ ===$/gm);
|
|
560
|
+
if (fileSeparators && fileSeparators.length > 1) {
|
|
561
|
+
console.log(` ${c.bold}Files:${c.reset} ${fileSeparators.length}`);
|
|
562
|
+
for (const sep of fileSeparators.slice(0, 20)) {
|
|
563
|
+
const name = sep.replace(/^=== /, "").replace(/ ===$/, "");
|
|
564
|
+
console.log(` ${c.dim}•${c.reset} ${name}`);
|
|
565
|
+
}
|
|
566
|
+
if (fileSeparators.length > 20) {
|
|
567
|
+
console.log(` ${c.dim}... and ${fileSeparators.length - 20} more${c.reset}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
console.log();
|
|
572
|
+
const preview = contextText.slice(0, 500);
|
|
573
|
+
const previewLines = preview.split("\n").slice(0, 8);
|
|
574
|
+
for (const l of previewLines) {
|
|
575
|
+
console.log(` ${c.dim}│${c.reset} ${l}`);
|
|
576
|
+
}
|
|
577
|
+
if (contextText.length > 500) {
|
|
578
|
+
console.log(` ${c.dim}│ ...${c.reset}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function handleTrajectories() {
|
|
583
|
+
if (!fs.existsSync(TRAJ_DIR)) {
|
|
584
|
+
console.log(` ${c.dim}No trajectories yet.${c.reset}`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const files = fs
|
|
588
|
+
.readdirSync(TRAJ_DIR)
|
|
589
|
+
.filter((f) => f.endsWith(".json"))
|
|
590
|
+
.sort()
|
|
591
|
+
.reverse();
|
|
592
|
+
if (files.length === 0) {
|
|
593
|
+
console.log(` ${c.dim}No trajectories yet.${c.reset}`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
console.log(`\n ${c.bold}Saved trajectories:${c.reset}\n`);
|
|
597
|
+
for (const f of files.slice(0, 15)) {
|
|
598
|
+
const stat = fs.statSync(path.join(TRAJ_DIR, f));
|
|
599
|
+
const size = (stat.size / 1024).toFixed(1);
|
|
600
|
+
console.log(` ${c.dim}•${c.reset} ${f} ${c.dim}(${size}K)${c.reset}`);
|
|
601
|
+
}
|
|
602
|
+
if (files.length > 15) {
|
|
603
|
+
console.log(` ${c.dim}... and ${files.length - 15} more${c.reset}`);
|
|
604
|
+
}
|
|
605
|
+
console.log();
|
|
606
|
+
}
|
|
607
|
+
// ── Display helpers ─────────────────────────────────────────────────────────
|
|
608
|
+
let BOX_W = Math.min(process.stdout.columns || 80, 96) - 4;
|
|
609
|
+
let MAX_CONTENT_W = BOX_W - 4;
|
|
610
|
+
// Update dimensions on terminal resize
|
|
611
|
+
process.stdout.on("resize", () => {
|
|
612
|
+
_W = Math.min(process.stdout.columns || 80, 100);
|
|
613
|
+
BOX_W = Math.min(process.stdout.columns || 80, 96) - 4;
|
|
614
|
+
MAX_CONTENT_W = BOX_W - 4;
|
|
615
|
+
});
|
|
616
|
+
/** Strip ANSI escape codes to get visible string length. */
|
|
617
|
+
function stripAnsi(str) {
|
|
618
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
619
|
+
}
|
|
620
|
+
/** Wrap a raw text line into chunks that fit within maxWidth. */
|
|
621
|
+
function wrapText(text, maxWidth) {
|
|
622
|
+
if (text.length <= maxWidth)
|
|
623
|
+
return [text];
|
|
624
|
+
const result = [];
|
|
625
|
+
for (let i = 0; i < text.length; i += maxWidth) {
|
|
626
|
+
result.push(text.slice(i, i + maxWidth));
|
|
627
|
+
}
|
|
628
|
+
return result;
|
|
629
|
+
}
|
|
630
|
+
// ── Box-drawing helpers ─────────────────────────────────────────────────────
|
|
631
|
+
function boxTop(label, color = c.dim) {
|
|
632
|
+
const visLen = stripAnsi(label).length;
|
|
633
|
+
const right = Math.max(0, BOX_W - visLen - 5);
|
|
634
|
+
return ` ${color}╭─ ${c.reset}${label}${color} ${"─".repeat(right)}╮${c.reset}`;
|
|
635
|
+
}
|
|
636
|
+
function boxLine(text, color = c.dim) {
|
|
637
|
+
return ` ${color}│${c.reset} ${text}`;
|
|
638
|
+
}
|
|
639
|
+
function boxBottom(color = c.dim) {
|
|
640
|
+
return ` ${color}╰${"─".repeat(BOX_W - 2)}╯${c.reset}`;
|
|
641
|
+
}
|
|
642
|
+
function stepRule() {
|
|
643
|
+
console.log(` ${c.dim}${"─".repeat(BOX_W - 2)}${c.reset}`);
|
|
644
|
+
}
|
|
645
|
+
// ── Display functions ───────────────────────────────────────────────────────
|
|
646
|
+
function displayCode(code) {
|
|
647
|
+
const lines = code.split("\n");
|
|
648
|
+
const lineNumWidth = String(lines.length).length;
|
|
649
|
+
const codeMaxW = MAX_CONTENT_W - lineNumWidth - 1;
|
|
650
|
+
console.log(boxTop("Code", c.cyan));
|
|
651
|
+
for (let i = 0; i < lines.length; i++) {
|
|
652
|
+
const wrapped = wrapText(lines[i], codeMaxW);
|
|
653
|
+
for (let j = 0; j < wrapped.length; j++) {
|
|
654
|
+
const prefix = j === 0 ? `${c.dim}${String(i + 1).padStart(lineNumWidth)}${c.reset}` : " ".repeat(lineNumWidth);
|
|
655
|
+
console.log(boxLine(`${prefix} ${c.cyan}${wrapped[j]}${c.reset}`, c.cyan));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
console.log(boxBottom(c.cyan));
|
|
659
|
+
}
|
|
660
|
+
function displayOutput(output) {
|
|
661
|
+
const lines = output.split("\n").filter((l) => l.trim() !== "");
|
|
662
|
+
if (lines.length === 0)
|
|
663
|
+
return;
|
|
664
|
+
console.log(boxTop("Output", c.green));
|
|
665
|
+
for (const line of lines) {
|
|
666
|
+
for (const chunk of wrapText(line, MAX_CONTENT_W)) {
|
|
667
|
+
console.log(boxLine(`${c.green}${chunk}${c.reset}`, c.green));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
console.log(boxBottom(c.green));
|
|
671
|
+
}
|
|
672
|
+
function displayError(stderr) {
|
|
673
|
+
const lines = stderr.split("\n").filter((l) => l.trim() !== "");
|
|
674
|
+
if (lines.length === 0)
|
|
675
|
+
return;
|
|
676
|
+
console.log(boxTop("Error", c.red));
|
|
677
|
+
for (const line of lines) {
|
|
678
|
+
for (const chunk of wrapText(line, MAX_CONTENT_W)) {
|
|
679
|
+
console.log(boxLine(`${c.red}${chunk}${c.reset}`, c.red));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
console.log(boxBottom(c.red));
|
|
683
|
+
}
|
|
684
|
+
function showErrorMsg(msg) {
|
|
685
|
+
const lines = msg.split(/\n/).filter((l) => l.trim());
|
|
686
|
+
console.log(boxTop("Error", c.red));
|
|
687
|
+
for (const line of lines) {
|
|
688
|
+
for (const chunk of wrapText(line, MAX_CONTENT_W)) {
|
|
689
|
+
console.log(boxLine(chunk, c.red));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
console.log(boxBottom(c.red));
|
|
693
|
+
}
|
|
694
|
+
function formatSize(chars) {
|
|
695
|
+
return chars >= 1000 ? `${(chars / 1000).toFixed(1)}K` : `${chars}`;
|
|
696
|
+
}
|
|
697
|
+
function displaySubQueryStart(info) {
|
|
698
|
+
console.log(boxTop(`${c.magenta}Sub-query #${info.index}${c.reset} ${c.dim}${formatSize(info.contextLength)} chars`, c.magenta));
|
|
699
|
+
const instrLines = info.instruction.split("\n").filter((l) => l.trim());
|
|
700
|
+
for (const line of instrLines) {
|
|
701
|
+
for (const chunk of wrapText(line, MAX_CONTENT_W)) {
|
|
702
|
+
console.log(boxLine(`${c.dim}${chunk}${c.reset}`, c.magenta));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function displaySubQueryResult(info) {
|
|
707
|
+
const elapsed = (info.elapsedMs / 1000).toFixed(1);
|
|
708
|
+
const resultLines = info.resultPreview.split("\n");
|
|
709
|
+
console.log(boxLine("", c.magenta));
|
|
710
|
+
console.log(boxLine(`${c.green}Response:${c.reset}`, c.magenta));
|
|
711
|
+
for (const line of resultLines) {
|
|
712
|
+
for (const chunk of wrapText(line, MAX_CONTENT_W)) {
|
|
713
|
+
console.log(boxLine(`${c.green}${chunk}${c.reset}`, c.magenta));
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
console.log(boxBottom(c.magenta));
|
|
717
|
+
console.log(` ${c.dim}${elapsed}s · ${formatSize(info.resultLength)} received${c.reset}`);
|
|
718
|
+
}
|
|
719
|
+
// ── Available models list ────────────────────────────────────────────────────
|
|
720
|
+
/** Filter out deprecated, retired, and non-chat models (Feb 2026). */
|
|
721
|
+
const EXCLUDED_MODEL_PATTERNS = [
|
|
722
|
+
// ── Anthropic retired / old gen ──
|
|
723
|
+
/^claude-3-/, // all claude 3.x retired (haiku, sonnet, opus, 3-5-*, 3-7-*)
|
|
724
|
+
// ── OpenAI legacy / specialized ──
|
|
725
|
+
/^gpt-4$/, // superseded by gpt-4.1
|
|
726
|
+
/^gpt-4-turbo/, // superseded by gpt-4.1
|
|
727
|
+
/^gpt-4o-2024-/, // dated snapshots
|
|
728
|
+
/-chat-latest$/, // chat variants (use base model)
|
|
729
|
+
/^codex-/, // code-only
|
|
730
|
+
/-codex/, // all codex variants
|
|
731
|
+
// ── Google retired / deprecated ──
|
|
732
|
+
/^gemini-1\.5-/, // all 1.5 retired
|
|
733
|
+
/^gemini-3-pro-preview$/, // deprecated, shuts down Mar 9, 2026
|
|
734
|
+
/^gemini-live-/, // real-time streaming, not standard chat
|
|
735
|
+
// ── Dated snapshots / previews ──
|
|
736
|
+
/preview-\d{2}-\d{2}$/, // e.g. preview-04-17
|
|
737
|
+
/preview-\d{2}-\d{4}$/, // e.g. preview-09-2025
|
|
738
|
+
/^labs-/,
|
|
739
|
+
/-customtools$/,
|
|
740
|
+
/deep-research$/,
|
|
741
|
+
];
|
|
742
|
+
function isModelExcluded(modelId) {
|
|
743
|
+
return EXCLUDED_MODEL_PATTERNS.some((p) => p.test(modelId));
|
|
744
|
+
}
|
|
745
|
+
/** Collect models from providers that have an API key set. */
|
|
746
|
+
function _getAvailableModels() {
|
|
747
|
+
const items = [];
|
|
748
|
+
for (const provider of getProviders()) {
|
|
749
|
+
if (!process.env[providerEnvKey(provider)])
|
|
750
|
+
continue;
|
|
751
|
+
for (const m of getModels(provider)) {
|
|
752
|
+
if (!isModelExcluded(m.id))
|
|
753
|
+
items.push({ id: m.id, provider });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return items;
|
|
757
|
+
}
|
|
758
|
+
/** Get models for a specific provider (matching by pi-ai provider name or SETUP_PROVIDERS piProvider). */
|
|
759
|
+
function getModelsForProvider(providerName) {
|
|
760
|
+
const items = [];
|
|
761
|
+
for (const provider of getProviders()) {
|
|
762
|
+
if (provider !== providerName)
|
|
763
|
+
continue;
|
|
764
|
+
for (const m of getModels(provider)) {
|
|
765
|
+
if (!isModelExcluded(m.id))
|
|
766
|
+
items.push({ id: m.id, provider });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return items;
|
|
770
|
+
}
|
|
771
|
+
// ── Truncate helper ─────────────────────────────────────────────────────────
|
|
772
|
+
function _truncateStr(text, max) {
|
|
773
|
+
return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
|
|
774
|
+
}
|
|
775
|
+
// ── Multi-file context loading ──────────────────────────────────────────────
|
|
776
|
+
const MAX_FILES = 100;
|
|
777
|
+
const MAX_TOTAL_BYTES = 10 * 1024 * 1024; // 10MB
|
|
778
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
779
|
+
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; // 50MB
|
|
780
|
+
/** Fetch a URL with timeout and size limits. */
|
|
781
|
+
async function safeFetch(url) {
|
|
782
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
783
|
+
if (!resp.ok)
|
|
784
|
+
throw new Error(`${resp.status} ${resp.statusText}`);
|
|
785
|
+
const contentLength = resp.headers.get("content-length");
|
|
786
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
|
|
787
|
+
throw new Error(`Response too large (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB)`);
|
|
788
|
+
}
|
|
789
|
+
const text = await resp.text();
|
|
790
|
+
if (text.length > MAX_RESPONSE_BYTES) {
|
|
791
|
+
throw new Error(`Response too large (${(text.length / 1024 / 1024).toFixed(1)}MB)`);
|
|
792
|
+
}
|
|
793
|
+
return text;
|
|
794
|
+
}
|
|
795
|
+
const BINARY_EXTENSIONS = new Set([
|
|
796
|
+
".png",
|
|
797
|
+
".jpg",
|
|
798
|
+
".jpeg",
|
|
799
|
+
".gif",
|
|
800
|
+
".bmp",
|
|
801
|
+
".ico",
|
|
802
|
+
".webp",
|
|
803
|
+
".svg",
|
|
804
|
+
".mp3",
|
|
805
|
+
".mp4",
|
|
806
|
+
".wav",
|
|
807
|
+
".ogg",
|
|
808
|
+
".flac",
|
|
809
|
+
".avi",
|
|
810
|
+
".mov",
|
|
811
|
+
".mkv",
|
|
812
|
+
".zip",
|
|
813
|
+
".gz",
|
|
814
|
+
".tar",
|
|
815
|
+
".bz2",
|
|
816
|
+
".7z",
|
|
817
|
+
".rar",
|
|
818
|
+
".xz",
|
|
819
|
+
".exe",
|
|
820
|
+
".dll",
|
|
821
|
+
".so",
|
|
822
|
+
".dylib",
|
|
823
|
+
".bin",
|
|
824
|
+
".o",
|
|
825
|
+
".a",
|
|
826
|
+
".woff",
|
|
827
|
+
".woff2",
|
|
828
|
+
".ttf",
|
|
829
|
+
".otf",
|
|
830
|
+
".eot",
|
|
831
|
+
".pdf",
|
|
832
|
+
".doc",
|
|
833
|
+
".docx",
|
|
834
|
+
".xls",
|
|
835
|
+
".xlsx",
|
|
836
|
+
".ppt",
|
|
837
|
+
".pptx",
|
|
838
|
+
".pyc",
|
|
839
|
+
".pyo",
|
|
840
|
+
".class",
|
|
841
|
+
".jar",
|
|
842
|
+
".db",
|
|
843
|
+
".sqlite",
|
|
844
|
+
".sqlite3",
|
|
845
|
+
".DS_Store",
|
|
846
|
+
]);
|
|
847
|
+
const SKIP_DIRS = new Set([
|
|
848
|
+
"node_modules",
|
|
849
|
+
".git",
|
|
850
|
+
"dist",
|
|
851
|
+
"build",
|
|
852
|
+
"__pycache__",
|
|
853
|
+
".venv",
|
|
854
|
+
"venv",
|
|
855
|
+
".next",
|
|
856
|
+
".nuxt",
|
|
857
|
+
"coverage",
|
|
858
|
+
".cache",
|
|
859
|
+
".tsc-output",
|
|
860
|
+
".svelte-kit",
|
|
861
|
+
"target",
|
|
862
|
+
"out",
|
|
863
|
+
]);
|
|
864
|
+
function isBinaryFile(filePath) {
|
|
865
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
866
|
+
if (BINARY_EXTENSIONS.has(ext))
|
|
867
|
+
return true;
|
|
868
|
+
// Quick null-byte check on first 512 bytes
|
|
869
|
+
let fd;
|
|
870
|
+
try {
|
|
871
|
+
fd = fs.openSync(filePath, "r");
|
|
872
|
+
const buf = Buffer.alloc(512);
|
|
873
|
+
const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
|
|
874
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
875
|
+
if (buf[i] === 0)
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
/* unreadable → skip */ return true;
|
|
881
|
+
}
|
|
882
|
+
finally {
|
|
883
|
+
if (fd !== undefined)
|
|
884
|
+
try {
|
|
885
|
+
fs.closeSync(fd);
|
|
886
|
+
}
|
|
887
|
+
catch { }
|
|
888
|
+
}
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
const MAX_DIR_DEPTH = 30;
|
|
892
|
+
function walkDir(dir, depth = 0) {
|
|
893
|
+
if (depth > MAX_DIR_DEPTH)
|
|
894
|
+
return [];
|
|
895
|
+
const results = [];
|
|
896
|
+
let entries;
|
|
897
|
+
try {
|
|
898
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
return results;
|
|
902
|
+
}
|
|
903
|
+
for (const entry of entries) {
|
|
904
|
+
if (results.length >= MAX_FILES)
|
|
905
|
+
break;
|
|
906
|
+
if (entry.name.startsWith(".") && entry.name !== ".env")
|
|
907
|
+
continue;
|
|
908
|
+
if (entry.isSymbolicLink())
|
|
909
|
+
continue;
|
|
910
|
+
const full = path.join(dir, entry.name);
|
|
911
|
+
if (entry.isDirectory()) {
|
|
912
|
+
if (SKIP_DIRS.has(entry.name))
|
|
913
|
+
continue;
|
|
914
|
+
const sub = walkDir(full, depth + 1);
|
|
915
|
+
const remaining = MAX_FILES - results.length;
|
|
916
|
+
results.push(...sub.slice(0, remaining));
|
|
917
|
+
}
|
|
918
|
+
else if (entry.isFile()) {
|
|
919
|
+
if (!isBinaryFile(full))
|
|
920
|
+
results.push(full);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return results;
|
|
924
|
+
}
|
|
925
|
+
/** Normalize path separators to forward slash for consistent matching. */
|
|
926
|
+
function toForwardSlash(p) {
|
|
927
|
+
return p.replace(/\\/g, "/");
|
|
928
|
+
}
|
|
929
|
+
function simpleGlobMatch(pattern, filePath, _braceDepth = 0) {
|
|
930
|
+
// Normalize both to forward slashes for cross-platform matching
|
|
931
|
+
pattern = toForwardSlash(pattern);
|
|
932
|
+
filePath = toForwardSlash(filePath);
|
|
933
|
+
// Expand {a,b,c} braces into alternatives (with depth limit)
|
|
934
|
+
const braceMatch = pattern.match(/\{([^}]+)\}/);
|
|
935
|
+
if (braceMatch && _braceDepth < 5) {
|
|
936
|
+
const alternatives = braceMatch[1].split(",").slice(0, 50);
|
|
937
|
+
return alternatives.some((alt) => simpleGlobMatch(pattern.replace(braceMatch[0], alt.trim()), filePath, _braceDepth + 1));
|
|
938
|
+
}
|
|
939
|
+
// Convert glob to regex
|
|
940
|
+
let regex = "^";
|
|
941
|
+
let i = 0;
|
|
942
|
+
while (i < pattern.length) {
|
|
943
|
+
const ch = pattern[i];
|
|
944
|
+
if (ch === "*" && pattern[i + 1] === "*") {
|
|
945
|
+
// ** matches any path segment(s)
|
|
946
|
+
regex += ".*";
|
|
947
|
+
i += 2;
|
|
948
|
+
if (pattern[i] === "/")
|
|
949
|
+
i++; // skip trailing slash after **
|
|
950
|
+
}
|
|
951
|
+
else if (ch === "*") {
|
|
952
|
+
regex += "[^/]*";
|
|
953
|
+
i++;
|
|
954
|
+
}
|
|
955
|
+
else if (ch === "?") {
|
|
956
|
+
regex += "[^/]";
|
|
957
|
+
i++;
|
|
958
|
+
}
|
|
959
|
+
else if (".+^$|()[]\\".includes(ch)) {
|
|
960
|
+
regex += `\\${ch}`;
|
|
961
|
+
i++;
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
regex += ch;
|
|
965
|
+
i++;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
regex += "$";
|
|
969
|
+
return new RegExp(regex).test(filePath);
|
|
970
|
+
}
|
|
971
|
+
/** Expand ~ to home directory (shell doesn't do this for us). */
|
|
972
|
+
function expandTilde(p) {
|
|
973
|
+
if (p === "~")
|
|
974
|
+
return os.homedir();
|
|
975
|
+
if (p.startsWith("~/") || p.startsWith("~\\"))
|
|
976
|
+
return path.join(os.homedir(), p.slice(2));
|
|
977
|
+
return p;
|
|
978
|
+
}
|
|
979
|
+
function resolveFileArgs(args) {
|
|
980
|
+
const files = [];
|
|
981
|
+
for (const rawArg of args) {
|
|
982
|
+
const arg = expandTilde(rawArg);
|
|
983
|
+
const resolved = path.resolve(arg);
|
|
984
|
+
// Glob pattern (contains * or ?)
|
|
985
|
+
if (arg.includes("*") || arg.includes("?")) {
|
|
986
|
+
// Find the base directory (portion before the first glob char)
|
|
987
|
+
const normalized = toForwardSlash(arg);
|
|
988
|
+
const firstGlob = normalized.search(/[*?{]/);
|
|
989
|
+
const baseDir = firstGlob > 0
|
|
990
|
+
? path.resolve(normalized.slice(0, normalized.lastIndexOf("/", firstGlob) + 1) || ".")
|
|
991
|
+
: process.cwd();
|
|
992
|
+
const allFiles = walkDir(baseDir);
|
|
993
|
+
for (const f of allFiles) {
|
|
994
|
+
const rel = path.relative(process.cwd(), f);
|
|
995
|
+
if (simpleGlobMatch(arg, rel) || simpleGlobMatch(arg, f)) {
|
|
996
|
+
files.push(f);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
// Directory
|
|
1002
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
1003
|
+
files.push(...walkDir(resolved));
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
// Regular file
|
|
1007
|
+
if (fs.existsSync(resolved)) {
|
|
1008
|
+
if (!isBinaryFile(resolved))
|
|
1009
|
+
files.push(resolved);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
console.log(` ${c.yellow}⚠${c.reset} Not found: ${arg}`);
|
|
1013
|
+
}
|
|
1014
|
+
return [...new Set(files)]; // deduplicate
|
|
1015
|
+
}
|
|
1016
|
+
function loadMultipleFiles(filePaths) {
|
|
1017
|
+
if (filePaths.length > MAX_FILES) {
|
|
1018
|
+
console.log(` ${c.yellow}⚠${c.reset} Too many files (${filePaths.length}). Limit is ${MAX_FILES}.`);
|
|
1019
|
+
filePaths = filePaths.slice(0, MAX_FILES);
|
|
1020
|
+
}
|
|
1021
|
+
const parts = [];
|
|
1022
|
+
let totalBytes = 0;
|
|
1023
|
+
for (const fp of filePaths) {
|
|
1024
|
+
try {
|
|
1025
|
+
const content = fs.readFileSync(fp, "utf-8");
|
|
1026
|
+
if (totalBytes + content.length > MAX_TOTAL_BYTES) {
|
|
1027
|
+
console.log(` ${c.yellow}⚠${c.reset} Size limit reached (${(MAX_TOTAL_BYTES / 1024 / 1024).toFixed(0)}MB). Loaded ${parts.length} of ${filePaths.length} files.`);
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
const rel = path.relative(process.cwd(), fp);
|
|
1031
|
+
parts.push(`=== ${rel} ===\n${content}`);
|
|
1032
|
+
totalBytes += content.length;
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
/* skip unreadable */
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return { text: parts.join("\n\n"), count: parts.length, totalBytes };
|
|
1039
|
+
}
|
|
1040
|
+
// ── Run RLM query ───────────────────────────────────────────────────────────
|
|
1041
|
+
async function runQuery(query) {
|
|
1042
|
+
const effectiveContext = contextText || query;
|
|
1043
|
+
if (!currentModel) {
|
|
1044
|
+
const resolved = resolveModelWithProvider(currentModelId);
|
|
1045
|
+
if (resolved) {
|
|
1046
|
+
currentModel = resolved.model;
|
|
1047
|
+
currentProviderName = resolved.provider;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
// Safety: verify the current provider still has an API key — re-resolve if not
|
|
1051
|
+
if (currentModel && currentProviderName) {
|
|
1052
|
+
const key = providerEnvKey(currentProviderName);
|
|
1053
|
+
if (!process.env[key]) {
|
|
1054
|
+
const resolved = resolveModelWithProvider(currentModelId);
|
|
1055
|
+
if (resolved && process.env[providerEnvKey(resolved.provider)]) {
|
|
1056
|
+
currentModel = resolved.model;
|
|
1057
|
+
currentProviderName = resolved.provider;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
if (!currentModel) {
|
|
1062
|
+
console.log(`\n ${c.red}Model "${currentModelId}" not found.${c.reset}`);
|
|
1063
|
+
console.log(` ${c.dim}Check RLM_MODEL in your .env file.${c.reset}\n`);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
isRunning = true;
|
|
1067
|
+
queryCount++;
|
|
1068
|
+
const startTime = Date.now();
|
|
1069
|
+
const spinner = new Spinner();
|
|
1070
|
+
// ── RLM mode ─────────────────────────────────────────────────────────
|
|
1071
|
+
let _subQueryCount = 0;
|
|
1072
|
+
console.log(`\n ${c.dim}${currentModelId} · ${(effectiveContext.length / 1024).toFixed(1)}KB context${c.reset}`);
|
|
1073
|
+
// Trajectory bookkeeping
|
|
1074
|
+
const trajectory = {
|
|
1075
|
+
model: currentModelId,
|
|
1076
|
+
query,
|
|
1077
|
+
contextLength: effectiveContext.length,
|
|
1078
|
+
contextLines: effectiveContext.split("\n").length,
|
|
1079
|
+
startTime: new Date().toISOString(),
|
|
1080
|
+
iterations: [],
|
|
1081
|
+
result: null,
|
|
1082
|
+
totalElapsedMs: 0,
|
|
1083
|
+
};
|
|
1084
|
+
let currentStep = null;
|
|
1085
|
+
let iterStart = Date.now();
|
|
1086
|
+
const repl = new PythonRepl();
|
|
1087
|
+
const ac = new AbortController();
|
|
1088
|
+
// Expose to the readline SIGINT handler
|
|
1089
|
+
activeAc = ac;
|
|
1090
|
+
activeRepl = repl;
|
|
1091
|
+
activeSpinner = spinner;
|
|
1092
|
+
try {
|
|
1093
|
+
await repl.start(ac.signal);
|
|
1094
|
+
const result = await runRlmLoop({
|
|
1095
|
+
context: effectiveContext,
|
|
1096
|
+
query,
|
|
1097
|
+
model: currentModel,
|
|
1098
|
+
repl,
|
|
1099
|
+
signal: ac.signal,
|
|
1100
|
+
onProgress: (info) => {
|
|
1101
|
+
if (info.phase === "generating_code") {
|
|
1102
|
+
iterStart = Date.now();
|
|
1103
|
+
currentStep = {
|
|
1104
|
+
iteration: info.iteration,
|
|
1105
|
+
code: null,
|
|
1106
|
+
stdout: "",
|
|
1107
|
+
stderr: "",
|
|
1108
|
+
subQueries: [],
|
|
1109
|
+
hasFinal: false,
|
|
1110
|
+
elapsedMs: 0,
|
|
1111
|
+
userMessage: info.userMessage || "",
|
|
1112
|
+
rawResponse: "",
|
|
1113
|
+
systemPrompt: info.systemPrompt,
|
|
1114
|
+
};
|
|
1115
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
1116
|
+
console.log(`\n ${c.bold}Step ${info.iteration}${c.reset}${c.dim}/${info.maxIterations} ${elapsed}s elapsed${c.reset}`);
|
|
1117
|
+
stepRule();
|
|
1118
|
+
spinner.start("Generating code");
|
|
1119
|
+
}
|
|
1120
|
+
if (info.phase === "executing" && info.code) {
|
|
1121
|
+
spinner.stop();
|
|
1122
|
+
if (currentStep) {
|
|
1123
|
+
currentStep.code = info.code;
|
|
1124
|
+
currentStep.rawResponse = info.rawResponse || "";
|
|
1125
|
+
}
|
|
1126
|
+
displayCode(info.code);
|
|
1127
|
+
spinner.start("Executing");
|
|
1128
|
+
}
|
|
1129
|
+
if (info.phase === "checking_final") {
|
|
1130
|
+
spinner.stop();
|
|
1131
|
+
if (currentStep) {
|
|
1132
|
+
currentStep.stdout = info.stdout || "";
|
|
1133
|
+
currentStep.stderr = info.stderr || "";
|
|
1134
|
+
currentStep.elapsedMs = Date.now() - iterStart;
|
|
1135
|
+
trajectory.iterations.push({ ...currentStep });
|
|
1136
|
+
}
|
|
1137
|
+
if (info.stdout) {
|
|
1138
|
+
displayOutput(info.stdout);
|
|
1139
|
+
}
|
|
1140
|
+
if (info.stderr) {
|
|
1141
|
+
displayError(info.stderr);
|
|
1142
|
+
}
|
|
1143
|
+
const iterElapsed = ((Date.now() - iterStart) / 1000).toFixed(1);
|
|
1144
|
+
const sqLabel = info.subQueries > 0 ? ` · ${info.subQueries} sub-queries` : "";
|
|
1145
|
+
console.log(`\n ${c.dim}${iterElapsed}s${sqLabel}${c.reset}`);
|
|
1146
|
+
}
|
|
1147
|
+
},
|
|
1148
|
+
onSubQueryStart: (info) => {
|
|
1149
|
+
spinner.stop();
|
|
1150
|
+
displaySubQueryStart(info);
|
|
1151
|
+
spinner.start("");
|
|
1152
|
+
},
|
|
1153
|
+
onSubQuery: (info) => {
|
|
1154
|
+
_subQueryCount++;
|
|
1155
|
+
if (currentStep) {
|
|
1156
|
+
currentStep.subQueries.push(info);
|
|
1157
|
+
}
|
|
1158
|
+
spinner.stop();
|
|
1159
|
+
displaySubQueryResult(info);
|
|
1160
|
+
spinner.start("Executing");
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
trajectory.result = result;
|
|
1164
|
+
trajectory.totalElapsedMs = Date.now() - startTime;
|
|
1165
|
+
if (result.completed && trajectory.iterations.length > 0) {
|
|
1166
|
+
trajectory.iterations[trajectory.iterations.length - 1].hasFinal = true;
|
|
1167
|
+
}
|
|
1168
|
+
// Final answer
|
|
1169
|
+
const totalSec = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
1170
|
+
const stats = `${result.iterations} step${result.iterations !== 1 ? "s" : ""} · ${result.totalSubQueries} sub-quer${result.totalSubQueries !== 1 ? "ies" : "y"} · ${totalSec}s`;
|
|
1171
|
+
const answerLines = result.answer.split("\n");
|
|
1172
|
+
console.log();
|
|
1173
|
+
console.log(boxTop(`${c.green}✔ Result${c.reset} ${c.dim}${stats}`, c.green));
|
|
1174
|
+
for (const line of answerLines) {
|
|
1175
|
+
for (const chunk of wrapText(line, MAX_CONTENT_W)) {
|
|
1176
|
+
console.log(boxLine(chunk, c.green));
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
console.log(boxBottom(c.green));
|
|
1180
|
+
console.log();
|
|
1181
|
+
// Save trajectory
|
|
1182
|
+
try {
|
|
1183
|
+
if (!fs.existsSync(TRAJ_DIR))
|
|
1184
|
+
fs.mkdirSync(TRAJ_DIR, { recursive: true });
|
|
1185
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1186
|
+
const trajFile = `trajectory-${ts}.json`;
|
|
1187
|
+
fs.writeFileSync(path.join(TRAJ_DIR, trajFile), JSON.stringify(trajectory, null, 2), "utf-8");
|
|
1188
|
+
console.log(` ${c.dim}Saved: ~/.rlm/trajectories/${trajFile}${c.reset}\n`);
|
|
1189
|
+
}
|
|
1190
|
+
catch {
|
|
1191
|
+
console.log(` ${c.yellow}Could not save trajectory.${c.reset}\n`);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
catch (err) {
|
|
1195
|
+
spinner.stop();
|
|
1196
|
+
const msg = err?.message || String(err);
|
|
1197
|
+
// Suppress expected abort/shutdown errors
|
|
1198
|
+
if (err?.name !== "AbortError" &&
|
|
1199
|
+
!msg.includes("Aborted") &&
|
|
1200
|
+
!msg.includes("not running") &&
|
|
1201
|
+
!msg.includes("REPL subprocess") &&
|
|
1202
|
+
!msg.includes("REPL shut down")) {
|
|
1203
|
+
showErrorMsg(msg);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
finally {
|
|
1207
|
+
spinner.stop();
|
|
1208
|
+
activeAc = null;
|
|
1209
|
+
activeRepl = null;
|
|
1210
|
+
activeSpinner = null;
|
|
1211
|
+
try {
|
|
1212
|
+
repl.shutdown();
|
|
1213
|
+
}
|
|
1214
|
+
catch {
|
|
1215
|
+
/* already dead */
|
|
1216
|
+
}
|
|
1217
|
+
isRunning = false;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
// ── @file shorthand and auto-detect file paths ─────────────────────────────
|
|
1221
|
+
function expandAtFiles(input) {
|
|
1222
|
+
// Extract all @tokens from input
|
|
1223
|
+
const tokens = [];
|
|
1224
|
+
const remaining = [];
|
|
1225
|
+
for (const part of input.split(/\s+/)) {
|
|
1226
|
+
if (part.startsWith("@") && part.length > 1) {
|
|
1227
|
+
tokens.push(expandTilde(part.slice(1)));
|
|
1228
|
+
}
|
|
1229
|
+
else {
|
|
1230
|
+
remaining.push(part);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (tokens.length === 0)
|
|
1234
|
+
return input;
|
|
1235
|
+
const filePaths = resolveFileArgs(tokens);
|
|
1236
|
+
if (filePaths.length === 0) {
|
|
1237
|
+
console.log(` ${c.red}No files found for: ${tokens.join(", ")}${c.reset}`);
|
|
1238
|
+
return "";
|
|
1239
|
+
}
|
|
1240
|
+
if (filePaths.length === 1) {
|
|
1241
|
+
// Single file — simple load
|
|
1242
|
+
try {
|
|
1243
|
+
contextText = fs.readFileSync(filePaths[0], "utf-8");
|
|
1244
|
+
contextSource = path.relative(process.cwd(), filePaths[0]) || filePaths[0];
|
|
1245
|
+
const lines = contextText.split("\n").length;
|
|
1246
|
+
console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${contextSource}${c.reset}`);
|
|
1247
|
+
}
|
|
1248
|
+
catch (err) {
|
|
1249
|
+
console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
|
|
1250
|
+
return "";
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
else {
|
|
1254
|
+
// Multiple files — concatenate with separators
|
|
1255
|
+
const { text, count, totalBytes } = loadMultipleFiles(filePaths);
|
|
1256
|
+
contextText = text;
|
|
1257
|
+
contextSource = `${count} files`;
|
|
1258
|
+
console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${count}${c.reset} files (${(totalBytes / 1024).toFixed(1)}KB total)`);
|
|
1259
|
+
}
|
|
1260
|
+
return remaining.join(" ");
|
|
1261
|
+
}
|
|
1262
|
+
// ── Auto-detect URLs ────────────────────────────────────────────────────────
|
|
1263
|
+
async function detectAndLoadUrl(input) {
|
|
1264
|
+
const urlMatch = input.match(/^https?:\/\/\S+$/);
|
|
1265
|
+
if (urlMatch) {
|
|
1266
|
+
const url = urlMatch[0];
|
|
1267
|
+
console.log(` ${c.dim}Fetching ${url}...${c.reset}`);
|
|
1268
|
+
try {
|
|
1269
|
+
contextText = await safeFetch(url);
|
|
1270
|
+
contextSource = url;
|
|
1271
|
+
const lines = contextText.split("\n").length;
|
|
1272
|
+
console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines)`);
|
|
1273
|
+
return true;
|
|
1274
|
+
}
|
|
1275
|
+
catch (err) {
|
|
1276
|
+
console.log(` ${c.red}Failed: ${err.message}${c.reset}`);
|
|
1277
|
+
return false;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
// ── Main interactive loop ───────────────────────────────────────────────────
|
|
1283
|
+
async function interactive() {
|
|
1284
|
+
// Validate env
|
|
1285
|
+
if (!hasAnyApiKey()) {
|
|
1286
|
+
printBanner();
|
|
1287
|
+
console.log(` ${c.bold}Welcome! Let's get you set up.${c.reset}\n`);
|
|
1288
|
+
const setupRl = readline.createInterface({ input: stdin, output: stdout, terminal: true });
|
|
1289
|
+
let setupDone = false;
|
|
1290
|
+
while (!setupDone) {
|
|
1291
|
+
console.log(` ${c.bold}Select your provider:${c.reset}\n`);
|
|
1292
|
+
for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
|
|
1293
|
+
console.log(` ${c.dim}${i + 1}${c.reset} ${SETUP_PROVIDERS[i].name} ${c.dim}(${SETUP_PROVIDERS[i].label})${c.reset}`);
|
|
1294
|
+
}
|
|
1295
|
+
console.log();
|
|
1296
|
+
const choice = await questionWithEsc(setupRl, ` ${c.cyan}Provider [1-${SETUP_PROVIDERS.length}]:${c.reset} `);
|
|
1297
|
+
if (choice === null) {
|
|
1298
|
+
// ESC at provider selection → exit
|
|
1299
|
+
console.log(`\n ${c.dim}Exiting.${c.reset}\n`);
|
|
1300
|
+
setupRl.close();
|
|
1301
|
+
process.exit(0);
|
|
1302
|
+
}
|
|
1303
|
+
const idx = parseInt(choice, 10) - 1;
|
|
1304
|
+
if (Number.isNaN(idx) || idx < 0 || idx >= SETUP_PROVIDERS.length) {
|
|
1305
|
+
console.log(`\n ${c.dim}Invalid choice.${c.reset}\n`);
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
const provider = SETUP_PROVIDERS[idx];
|
|
1309
|
+
const gotKey = await promptForProviderKey(setupRl, provider);
|
|
1310
|
+
if (gotKey === null) {
|
|
1311
|
+
// ESC at key entry → back to provider selection
|
|
1312
|
+
console.log();
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
if (!gotKey) {
|
|
1316
|
+
console.log(`\n ${c.dim}No key provided. Exiting.${c.reset}\n`);
|
|
1317
|
+
setupRl.close();
|
|
1318
|
+
process.exit(0);
|
|
1319
|
+
}
|
|
1320
|
+
// Auto-select default model for chosen provider
|
|
1321
|
+
currentProviderName = provider.piProvider;
|
|
1322
|
+
const defaultModel = getDefaultModelForProvider(provider.piProvider);
|
|
1323
|
+
if (defaultModel) {
|
|
1324
|
+
currentModelId = defaultModel;
|
|
1325
|
+
saveModelPreference(currentModelId);
|
|
1326
|
+
console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset}`);
|
|
1327
|
+
}
|
|
1328
|
+
console.log();
|
|
1329
|
+
setupDone = true;
|
|
1330
|
+
}
|
|
1331
|
+
setupRl.close();
|
|
1332
|
+
}
|
|
1333
|
+
// Resolve model — ensure the resolved provider actually has an API key
|
|
1334
|
+
const initialResolved = resolveModelWithProvider(currentModelId);
|
|
1335
|
+
if (initialResolved) {
|
|
1336
|
+
const resolvedKey = providerEnvKey(initialResolved.provider);
|
|
1337
|
+
if (process.env[resolvedKey]) {
|
|
1338
|
+
// Provider has a key — use it
|
|
1339
|
+
currentModel = initialResolved.model;
|
|
1340
|
+
currentProviderName = initialResolved.provider;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
// If default model's provider has no key, fall back to a provider that does
|
|
1344
|
+
if (!currentModel) {
|
|
1345
|
+
const activeProvider = detectProvider();
|
|
1346
|
+
if (activeProvider !== "unknown") {
|
|
1347
|
+
const fallbackModel = getDefaultModelForProvider(activeProvider);
|
|
1348
|
+
if (fallbackModel) {
|
|
1349
|
+
const fallbackResolved = resolveModelWithProvider(fallbackModel);
|
|
1350
|
+
if (fallbackResolved) {
|
|
1351
|
+
currentModelId = fallbackModel;
|
|
1352
|
+
currentModel = fallbackResolved.model;
|
|
1353
|
+
currentProviderName = fallbackResolved.provider;
|
|
1354
|
+
const label = findSetupProvider(activeProvider)?.name || activeProvider;
|
|
1355
|
+
console.log(` ${c.dim}Using ${label} (${currentModelId})${c.reset}`);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
if (!currentModel) {
|
|
1361
|
+
console.log(`\n ${c.red}Model "${currentModelId}" not found.${c.reset}`);
|
|
1362
|
+
console.log(` Check ${c.bold}RLM_MODEL${c.reset} in your .env file.\n`);
|
|
1363
|
+
process.exit(1);
|
|
1364
|
+
}
|
|
1365
|
+
// Auto-load cwd context so the LLM knows the project structure
|
|
1366
|
+
contextText = buildCwdContext();
|
|
1367
|
+
contextSource = path.basename(process.cwd());
|
|
1368
|
+
printWelcome();
|
|
1369
|
+
const rl = readline.createInterface({
|
|
1370
|
+
input: stdin,
|
|
1371
|
+
output: stdout,
|
|
1372
|
+
prompt: `${c.cyan}>${c.reset} `,
|
|
1373
|
+
terminal: true,
|
|
1374
|
+
});
|
|
1375
|
+
// Color slash commands cyan as the user types
|
|
1376
|
+
const rlAny = rl;
|
|
1377
|
+
const promptStr = rl.getPrompt();
|
|
1378
|
+
rlAny._writeToOutput = (str) => {
|
|
1379
|
+
if (!rlAny.line?.startsWith("/")) {
|
|
1380
|
+
rlAny.output.write(str);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (str.startsWith(promptStr)) {
|
|
1384
|
+
rlAny.output.write(promptStr + c.cyan + str.slice(promptStr.length) + c.reset);
|
|
1385
|
+
}
|
|
1386
|
+
else {
|
|
1387
|
+
rlAny.output.write(c.cyan + str + c.reset);
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
rl.prompt();
|
|
1391
|
+
rl.on("line", async (rawLine) => {
|
|
1392
|
+
try {
|
|
1393
|
+
if (isRunning)
|
|
1394
|
+
return; // ignore input while a query is active
|
|
1395
|
+
const line = rawLine.trim();
|
|
1396
|
+
// URL auto-detect
|
|
1397
|
+
if (line.startsWith("http://") || line.startsWith("https://")) {
|
|
1398
|
+
const loaded = await detectAndLoadUrl(line);
|
|
1399
|
+
if (loaded) {
|
|
1400
|
+
printStatusLine();
|
|
1401
|
+
console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
|
|
1402
|
+
rl.prompt();
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
// Multi-line paste detect
|
|
1407
|
+
if (isMultiLineInput(rawLine)) {
|
|
1408
|
+
const result = handleMultiLineAsContext(rawLine);
|
|
1409
|
+
if (result) {
|
|
1410
|
+
contextText = result.context;
|
|
1411
|
+
contextSource = "(pasted)";
|
|
1412
|
+
printStatusLine();
|
|
1413
|
+
console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
|
|
1414
|
+
rl.prompt();
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (!line) {
|
|
1419
|
+
rl.prompt();
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
// Slash commands
|
|
1423
|
+
if (line.startsWith("/")) {
|
|
1424
|
+
const [cmd, ...rest] = line.slice(1).split(/\s+/);
|
|
1425
|
+
const arg = rest.join(" ");
|
|
1426
|
+
switch (cmd) {
|
|
1427
|
+
case "help":
|
|
1428
|
+
case "h":
|
|
1429
|
+
printCommandHelp();
|
|
1430
|
+
break;
|
|
1431
|
+
case "file":
|
|
1432
|
+
case "f": {
|
|
1433
|
+
const fileQuery = await handleFile(arg);
|
|
1434
|
+
if (fileQuery && contextText) {
|
|
1435
|
+
await runQuery(fileQuery);
|
|
1436
|
+
printStatusLine();
|
|
1437
|
+
}
|
|
1438
|
+
break;
|
|
1439
|
+
}
|
|
1440
|
+
case "url":
|
|
1441
|
+
case "u":
|
|
1442
|
+
await handleUrl(arg);
|
|
1443
|
+
break;
|
|
1444
|
+
case "paste":
|
|
1445
|
+
case "p":
|
|
1446
|
+
await handlePaste(rl);
|
|
1447
|
+
break;
|
|
1448
|
+
case "context":
|
|
1449
|
+
case "ctx":
|
|
1450
|
+
handleContext();
|
|
1451
|
+
break;
|
|
1452
|
+
case "clear-context":
|
|
1453
|
+
case "cc":
|
|
1454
|
+
contextText = "";
|
|
1455
|
+
contextSource = "";
|
|
1456
|
+
console.log(` ${c.green}✓${c.reset} Context cleared.`);
|
|
1457
|
+
break;
|
|
1458
|
+
case "model":
|
|
1459
|
+
case "m": {
|
|
1460
|
+
const curProvider = currentProviderName || detectProvider();
|
|
1461
|
+
if (arg) {
|
|
1462
|
+
// Accept a number (from current provider list) or a model ID
|
|
1463
|
+
const curModels = getModelsForProvider(curProvider);
|
|
1464
|
+
let pick;
|
|
1465
|
+
if (/^\d+$/.test(arg)) {
|
|
1466
|
+
pick = curModels[parseInt(arg, 10) - 1]?.id;
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
pick = arg;
|
|
1470
|
+
}
|
|
1471
|
+
if (!pick) {
|
|
1472
|
+
console.log(` ${c.red}Invalid selection.${c.reset} Use ${c.cyan}/model${c.reset} to list available models.`);
|
|
1473
|
+
break;
|
|
1474
|
+
}
|
|
1475
|
+
// Check if this model belongs to a different provider
|
|
1476
|
+
const resolved = resolveModelWithProvider(pick);
|
|
1477
|
+
if (!resolved) {
|
|
1478
|
+
console.log(` ${c.red}Model "${arg}" not found.${c.reset} Use ${c.cyan}/model${c.reset} to list available models.`);
|
|
1479
|
+
break;
|
|
1480
|
+
}
|
|
1481
|
+
if (resolved.provider !== curProvider) {
|
|
1482
|
+
// Cross-provider switch
|
|
1483
|
+
const setupInfo = findSetupProvider(resolved.provider);
|
|
1484
|
+
const envVar = setupInfo?.env || providerEnvKey(resolved.provider);
|
|
1485
|
+
const provName = setupInfo?.name || resolved.provider;
|
|
1486
|
+
if (!process.env[envVar]) {
|
|
1487
|
+
console.log(` ${c.yellow}That model requires ${provName}.${c.reset}`);
|
|
1488
|
+
const gotKey = await promptForProviderKey(rl, { name: provName, env: envVar });
|
|
1489
|
+
if (!gotKey) {
|
|
1490
|
+
console.log(` ${c.dim}Cancelled.${c.reset}`);
|
|
1491
|
+
break;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
currentModelId = pick;
|
|
1496
|
+
currentModel = resolved.model;
|
|
1497
|
+
currentProviderName = resolved.provider;
|
|
1498
|
+
saveModelPreference(currentModelId);
|
|
1499
|
+
console.log(` ${c.green}✓${c.reset} Switched to ${c.bold}${currentModelId}${c.reset}`);
|
|
1500
|
+
console.log();
|
|
1501
|
+
printStatusLine();
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
// List models for current provider
|
|
1505
|
+
const models = getModelsForProvider(curProvider);
|
|
1506
|
+
const provLabel = findSetupProvider(curProvider)?.name || curProvider;
|
|
1507
|
+
console.log(`\n ${c.bold}Current model:${c.reset} ${c.cyan}${currentModelId}${c.reset} ${c.dim}(${provLabel})${c.reset}\n`);
|
|
1508
|
+
const pad = String(models.length).length;
|
|
1509
|
+
for (let i = 0; i < models.length; i++) {
|
|
1510
|
+
const m = models[i];
|
|
1511
|
+
const num = String(i + 1).padStart(pad);
|
|
1512
|
+
const dot = m.id === currentModelId ? `${c.green}●${c.reset}` : ` `;
|
|
1513
|
+
const label = m.id === currentModelId ? `${c.cyan}${m.id}${c.reset}` : `${c.dim}${m.id}${c.reset}`;
|
|
1514
|
+
console.log(` ${c.dim}${num}${c.reset} ${dot} ${label}`);
|
|
1515
|
+
}
|
|
1516
|
+
console.log(`\n ${c.dim}${models.length} models · scroll up to see full list.${c.reset}`);
|
|
1517
|
+
console.log(` ${c.dim}Type${c.reset} ${c.cyan}/model <number>${c.reset} ${c.dim}or${c.reset} ${c.cyan}/model <id>${c.reset} ${c.dim}to switch.${c.reset}`);
|
|
1518
|
+
console.log(` ${c.dim}Type${c.reset} ${c.cyan}/provider${c.reset} ${c.dim}to switch provider.${c.reset}`);
|
|
1519
|
+
}
|
|
1520
|
+
break;
|
|
1521
|
+
}
|
|
1522
|
+
case "provider":
|
|
1523
|
+
case "prov": {
|
|
1524
|
+
const curProvider = currentProviderName || detectProvider();
|
|
1525
|
+
const curLabel = findSetupProvider(curProvider)?.name || curProvider;
|
|
1526
|
+
console.log(`\n ${c.bold}Current provider:${c.reset} ${c.cyan}${curLabel}${c.reset}\n`);
|
|
1527
|
+
for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
|
|
1528
|
+
const p = SETUP_PROVIDERS[i];
|
|
1529
|
+
const isCurrent = p.piProvider === curProvider;
|
|
1530
|
+
const dot = isCurrent ? `${c.green}●${c.reset}` : ` `;
|
|
1531
|
+
const label = isCurrent
|
|
1532
|
+
? `${c.cyan}${p.name}${c.reset} ${c.dim}(${p.label})${c.reset}`
|
|
1533
|
+
: `${p.name} ${c.dim}(${p.label})${c.reset}`;
|
|
1534
|
+
console.log(` ${c.dim}${i + 1}${c.reset} ${dot} ${label}`);
|
|
1535
|
+
}
|
|
1536
|
+
console.log();
|
|
1537
|
+
const provChoice = await questionWithEsc(rl, ` ${c.cyan}Provider [1-${SETUP_PROVIDERS.length}]:${c.reset} ${c.dim}(ESC to cancel)${c.reset} `);
|
|
1538
|
+
if (provChoice === null)
|
|
1539
|
+
break; // ESC
|
|
1540
|
+
const idx = parseInt(provChoice, 10) - 1;
|
|
1541
|
+
if (Number.isNaN(idx) || idx < 0 || idx >= SETUP_PROVIDERS.length) {
|
|
1542
|
+
console.log(` ${c.dim}Cancelled.${c.reset}`);
|
|
1543
|
+
break;
|
|
1544
|
+
}
|
|
1545
|
+
const chosen = SETUP_PROVIDERS[idx];
|
|
1546
|
+
const gotKey = await promptForProviderKey(rl, chosen);
|
|
1547
|
+
if (!gotKey) {
|
|
1548
|
+
// null (ESC) or false (empty) → cancel
|
|
1549
|
+
break;
|
|
1550
|
+
}
|
|
1551
|
+
// Auto-select first model from new provider
|
|
1552
|
+
const defaultModel = getDefaultModelForProvider(chosen.piProvider);
|
|
1553
|
+
if (defaultModel) {
|
|
1554
|
+
currentModelId = defaultModel;
|
|
1555
|
+
const provResolved = resolveModelWithProvider(currentModelId);
|
|
1556
|
+
currentModel = provResolved?.model;
|
|
1557
|
+
currentProviderName = provResolved?.provider || chosen.piProvider;
|
|
1558
|
+
saveModelPreference(currentModelId);
|
|
1559
|
+
console.log(` ${c.green}✓${c.reset} ${chosen.name} · ${c.bold}${currentModelId}${c.reset}`);
|
|
1560
|
+
printStatusLine();
|
|
1561
|
+
}
|
|
1562
|
+
else {
|
|
1563
|
+
console.log(` ${c.red}No models available for ${chosen.name}.${c.reset}`);
|
|
1564
|
+
}
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
case "key": {
|
|
1568
|
+
// Update API key for a provider
|
|
1569
|
+
const _curProvider = currentProviderName || detectProvider();
|
|
1570
|
+
console.log();
|
|
1571
|
+
for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
|
|
1572
|
+
const p = SETUP_PROVIDERS[i];
|
|
1573
|
+
const hasKey = process.env[p.env] ? `${c.green}✓${c.reset}` : `${c.dim}○${c.reset}`;
|
|
1574
|
+
console.log(` ${c.dim}${i + 1}${c.reset} ${hasKey} ${p.name} ${c.dim}(${p.label})${c.reset}`);
|
|
1575
|
+
}
|
|
1576
|
+
console.log();
|
|
1577
|
+
const keyChoice = await questionWithEsc(rl, ` ${c.cyan}Update key for [1-${SETUP_PROVIDERS.length}]:${c.reset} ${c.dim}(ESC to cancel)${c.reset} `);
|
|
1578
|
+
if (keyChoice === null || !keyChoice)
|
|
1579
|
+
break;
|
|
1580
|
+
const keyIdx = parseInt(keyChoice, 10) - 1;
|
|
1581
|
+
if (Number.isNaN(keyIdx) || keyIdx < 0 || keyIdx >= SETUP_PROVIDERS.length) {
|
|
1582
|
+
console.log(` ${c.dim}Cancelled.${c.reset}`);
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
const keyProvider = SETUP_PROVIDERS[keyIdx];
|
|
1586
|
+
const newKey = await questionWithEsc(rl, ` ${c.cyan}${keyProvider.env}:${c.reset} `, { secret: true });
|
|
1587
|
+
if (newKey === null || !newKey)
|
|
1588
|
+
break;
|
|
1589
|
+
const sanitized = newKey.replace(/[\r\n\x00-\x1f]/g, "").trim();
|
|
1590
|
+
if (!sanitized)
|
|
1591
|
+
break;
|
|
1592
|
+
process.env[keyProvider.env] = sanitized;
|
|
1593
|
+
const credPath = path.join(RLM_HOME, "credentials");
|
|
1594
|
+
try {
|
|
1595
|
+
if (!fs.existsSync(RLM_HOME))
|
|
1596
|
+
fs.mkdirSync(RLM_HOME, { recursive: true });
|
|
1597
|
+
if (fs.existsSync(credPath)) {
|
|
1598
|
+
const content = fs.readFileSync(credPath, "utf-8");
|
|
1599
|
+
const filtered = content
|
|
1600
|
+
.split("\n")
|
|
1601
|
+
.filter((l) => {
|
|
1602
|
+
const t = l.trim();
|
|
1603
|
+
if (t.startsWith("export "))
|
|
1604
|
+
return !t.slice(7).startsWith(`${keyProvider.env}=`);
|
|
1605
|
+
return !t.startsWith(`${keyProvider.env}=`);
|
|
1606
|
+
})
|
|
1607
|
+
.join("\n");
|
|
1608
|
+
fs.writeFileSync(credPath, filtered.endsWith("\n") ? filtered : `${filtered}\n`);
|
|
1609
|
+
}
|
|
1610
|
+
fs.appendFileSync(credPath, `${keyProvider.env}=${sanitized}\n`);
|
|
1611
|
+
try {
|
|
1612
|
+
fs.chmodSync(credPath, 0o600);
|
|
1613
|
+
}
|
|
1614
|
+
catch { }
|
|
1615
|
+
console.log(` ${c.green}✓${c.reset} ${keyProvider.name} key updated`);
|
|
1616
|
+
}
|
|
1617
|
+
catch {
|
|
1618
|
+
console.log(` ${c.yellow}!${c.reset} Could not save key.`);
|
|
1619
|
+
}
|
|
1620
|
+
break;
|
|
1621
|
+
}
|
|
1622
|
+
case "trajectories":
|
|
1623
|
+
case "traj":
|
|
1624
|
+
handleTrajectories();
|
|
1625
|
+
break;
|
|
1626
|
+
case "clear":
|
|
1627
|
+
printWelcome();
|
|
1628
|
+
break;
|
|
1629
|
+
case "quit":
|
|
1630
|
+
case "q":
|
|
1631
|
+
case "exit":
|
|
1632
|
+
console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
|
|
1633
|
+
process.exit(0);
|
|
1634
|
+
break;
|
|
1635
|
+
default:
|
|
1636
|
+
console.log(` ${c.red}Unknown command: /${cmd}${c.reset}. Type ${c.cyan}/help${c.reset} for commands.`);
|
|
1637
|
+
}
|
|
1638
|
+
rl.prompt();
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
// @file shorthand
|
|
1642
|
+
let query = expandAtFiles(line);
|
|
1643
|
+
if (!query && line.startsWith("@")) {
|
|
1644
|
+
rl.prompt();
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (!query)
|
|
1648
|
+
query = line;
|
|
1649
|
+
// Inline URL detection — extract URL from query, fetch as context
|
|
1650
|
+
if (!contextText) {
|
|
1651
|
+
const urlInline = query.match(/(https?:\/\/\S+)/);
|
|
1652
|
+
if (urlInline) {
|
|
1653
|
+
const url = urlInline[1];
|
|
1654
|
+
const queryWithoutUrl = query.replace(url, "").trim();
|
|
1655
|
+
console.log(` ${c.dim}Fetching ${url}...${c.reset}`);
|
|
1656
|
+
try {
|
|
1657
|
+
contextText = await safeFetch(url);
|
|
1658
|
+
contextSource = url;
|
|
1659
|
+
const lines = contextText.split("\n").length;
|
|
1660
|
+
console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from URL`);
|
|
1661
|
+
if (queryWithoutUrl) {
|
|
1662
|
+
query = queryWithoutUrl;
|
|
1663
|
+
}
|
|
1664
|
+
else {
|
|
1665
|
+
// URL only, no query — prompt for one
|
|
1666
|
+
printStatusLine();
|
|
1667
|
+
console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
|
|
1668
|
+
rl.prompt();
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
catch (err) {
|
|
1673
|
+
console.log(` ${c.red}Failed to fetch URL: ${err.message}${c.reset}`);
|
|
1674
|
+
console.log(` ${c.dim}Running query as-is...${c.reset}`);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
// Auto-detect bare file/directory paths (tilde, absolute, relative)
|
|
1679
|
+
if (!contextText) {
|
|
1680
|
+
const tokens = query.split(/\s+/);
|
|
1681
|
+
const pathTokens = [];
|
|
1682
|
+
for (const t of tokens) {
|
|
1683
|
+
if (looksLikePath(t))
|
|
1684
|
+
pathTokens.push(t);
|
|
1685
|
+
else
|
|
1686
|
+
break;
|
|
1687
|
+
}
|
|
1688
|
+
if (pathTokens.length > 0) {
|
|
1689
|
+
const existing = pathTokens.filter((t) => {
|
|
1690
|
+
const p = path.resolve(expandTilde(t));
|
|
1691
|
+
return fs.existsSync(p);
|
|
1692
|
+
});
|
|
1693
|
+
if (existing.length > 0) {
|
|
1694
|
+
const filePaths = resolveFileArgs(existing);
|
|
1695
|
+
if (filePaths.length === 1) {
|
|
1696
|
+
try {
|
|
1697
|
+
contextText = fs.readFileSync(filePaths[0], "utf-8");
|
|
1698
|
+
contextSource = path.relative(process.cwd(), filePaths[0]) || filePaths[0];
|
|
1699
|
+
const lines = contextText.split("\n").length;
|
|
1700
|
+
console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${contextSource}${c.reset}`);
|
|
1701
|
+
}
|
|
1702
|
+
catch (err) {
|
|
1703
|
+
console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
else if (filePaths.length > 1) {
|
|
1707
|
+
const { text, count, totalBytes } = loadMultipleFiles(filePaths);
|
|
1708
|
+
contextText = text;
|
|
1709
|
+
contextSource = `${count} files`;
|
|
1710
|
+
console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${count}${c.reset} files (${(totalBytes / 1024).toFixed(1)}KB total)`);
|
|
1711
|
+
}
|
|
1712
|
+
if (contextText) {
|
|
1713
|
+
query = tokens.slice(pathTokens.length).join(" ") || query;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
// Run query
|
|
1719
|
+
await runQuery(query);
|
|
1720
|
+
printStatusLine();
|
|
1721
|
+
rl.prompt();
|
|
1722
|
+
}
|
|
1723
|
+
catch (err) {
|
|
1724
|
+
showErrorMsg(String(err?.message || err));
|
|
1725
|
+
rl.prompt();
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
// Ctrl+C: abort running query, or double-tap to exit
|
|
1729
|
+
let lastSigint = 0;
|
|
1730
|
+
rl.on("SIGINT", () => {
|
|
1731
|
+
if (isRunning && activeAc) {
|
|
1732
|
+
activeSpinner?.stop();
|
|
1733
|
+
console.log(`\n ${c.red}Stopped${c.reset}`);
|
|
1734
|
+
activeAc.abort();
|
|
1735
|
+
try {
|
|
1736
|
+
activeRepl?.shutdown();
|
|
1737
|
+
}
|
|
1738
|
+
catch {
|
|
1739
|
+
/* ok */
|
|
1740
|
+
}
|
|
1741
|
+
isRunning = false;
|
|
1742
|
+
lastSigint = 0;
|
|
1743
|
+
}
|
|
1744
|
+
else {
|
|
1745
|
+
const now = Date.now();
|
|
1746
|
+
if (now - lastSigint < 1000) {
|
|
1747
|
+
// Double Ctrl+C — exit
|
|
1748
|
+
console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
|
|
1749
|
+
process.exit(0);
|
|
1750
|
+
}
|
|
1751
|
+
lastSigint = now;
|
|
1752
|
+
console.log(`\n ${c.dim}Press Ctrl+C again to exit${c.reset}`);
|
|
1753
|
+
rl.prompt();
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
rl.on("close", () => {
|
|
1757
|
+
console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
|
|
1758
|
+
process.exit(0);
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
interactive().catch((err) => {
|
|
1762
|
+
console.error(`${c.red}Fatal error:${c.reset}`, err);
|
|
1763
|
+
process.exit(1);
|
|
1764
|
+
});
|
|
1765
|
+
//# sourceMappingURL=interactive.js.map
|