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