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.
Files changed (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -0
  3. package/bin/mcp.mjs +61 -0
  4. package/bin/swarm.mjs +45 -0
  5. package/dist/agents/aider.d.ts +12 -0
  6. package/dist/agents/aider.js +192 -0
  7. package/dist/agents/aider.js.map +1 -0
  8. package/dist/agents/claude-code.d.ts +9 -0
  9. package/dist/agents/claude-code.js +236 -0
  10. package/dist/agents/claude-code.js.map +1 -0
  11. package/dist/agents/codex.d.ts +14 -0
  12. package/dist/agents/codex.js +203 -0
  13. package/dist/agents/codex.js.map +1 -0
  14. package/dist/agents/direct-llm.d.ts +22 -0
  15. package/dist/agents/direct-llm.js +254 -0
  16. package/dist/agents/direct-llm.js.map +1 -0
  17. package/dist/agents/mock.d.ts +10 -0
  18. package/dist/agents/mock.js +170 -0
  19. package/dist/agents/mock.js.map +1 -0
  20. package/dist/agents/opencode.d.ts +23 -0
  21. package/dist/agents/opencode.js +631 -0
  22. package/dist/agents/opencode.js.map +1 -0
  23. package/dist/agents/provider.d.ts +11 -0
  24. package/dist/agents/provider.js +31 -0
  25. package/dist/agents/provider.js.map +1 -0
  26. package/dist/compression/compressor.d.ts +28 -0
  27. package/dist/compression/compressor.js +268 -0
  28. package/dist/compression/compressor.js.map +1 -0
  29. package/dist/config.d.ts +43 -0
  30. package/dist/config.js +172 -0
  31. package/dist/config.js.map +1 -0
  32. package/dist/contextos/loader.d.ts +46 -0
  33. package/dist/contextos/loader.js +120 -0
  34. package/dist/contextos/loader.js.map +1 -0
  35. package/dist/contextos/selector.d.ts +46 -0
  36. package/dist/contextos/selector.js +80 -0
  37. package/dist/contextos/selector.js.map +1 -0
  38. package/dist/core/action-dispatcher.d.ts +15 -0
  39. package/dist/core/action-dispatcher.js +28 -0
  40. package/dist/core/action-dispatcher.js.map +1 -0
  41. package/dist/core/action-schema.d.ts +72 -0
  42. package/dist/core/action-schema.js +86 -0
  43. package/dist/core/action-schema.js.map +1 -0
  44. package/dist/core/repl-interface.d.ts +48 -0
  45. package/dist/core/repl-interface.js +5 -0
  46. package/dist/core/repl-interface.js.map +1 -0
  47. package/dist/core/repl.d.ts +59 -0
  48. package/dist/core/repl.js +353 -0
  49. package/dist/core/repl.js.map +1 -0
  50. package/dist/core/rlm.d.ts +65 -0
  51. package/dist/core/rlm.js +564 -0
  52. package/dist/core/rlm.js.map +1 -0
  53. package/dist/core/runtime.py +470 -0
  54. package/dist/core/types.d.ts +265 -0
  55. package/dist/core/types.js +19 -0
  56. package/dist/core/types.js.map +1 -0
  57. package/dist/env.d.ts +10 -0
  58. package/dist/env.js +75 -0
  59. package/dist/env.js.map +1 -0
  60. package/dist/hooks/runner.d.ts +43 -0
  61. package/dist/hooks/runner.js +173 -0
  62. package/dist/hooks/runner.js.map +1 -0
  63. package/dist/labs/cli.d.ts +15 -0
  64. package/dist/labs/cli.js +295 -0
  65. package/dist/labs/cli.js.map +1 -0
  66. package/dist/labs/episodic-memory.d.ts +132 -0
  67. package/dist/labs/episodic-memory.js +391 -0
  68. package/dist/labs/episodic-memory.js.map +1 -0
  69. package/dist/labs/interactive-swarm.d.ts +20 -0
  70. package/dist/labs/interactive-swarm.js +1173 -0
  71. package/dist/labs/interactive-swarm.js.map +1 -0
  72. package/dist/labs/interactive.d.ts +10 -0
  73. package/dist/labs/interactive.js +2099 -0
  74. package/dist/labs/interactive.js.map +1 -0
  75. package/dist/labs/ts-analyzer.d.ts +74 -0
  76. package/dist/labs/ts-analyzer.js +379 -0
  77. package/dist/labs/ts-analyzer.js.map +1 -0
  78. package/dist/labs/viewer.d.ts +12 -0
  79. package/dist/labs/viewer.js +1284 -0
  80. package/dist/labs/viewer.js.map +1 -0
  81. package/dist/main.d.ts +15 -0
  82. package/dist/main.js +247 -0
  83. package/dist/main.js.map +1 -0
  84. package/dist/mcp/server.d.ts +16 -0
  85. package/dist/mcp/server.js +192775 -0
  86. package/dist/mcp/server.js.map +1 -0
  87. package/dist/mcp/session.d.ts +117 -0
  88. package/dist/mcp/session.js +450 -0
  89. package/dist/mcp/session.js.map +1 -0
  90. package/dist/mcp/state.d.ts +42 -0
  91. package/dist/mcp/state.js +537 -0
  92. package/dist/mcp/state.js.map +1 -0
  93. package/dist/mcp/tools/contextos.d.ts +17 -0
  94. package/dist/mcp/tools/contextos.js +583 -0
  95. package/dist/mcp/tools/contextos.js.map +1 -0
  96. package/dist/mcp/tools.d.ts +15 -0
  97. package/dist/mcp/tools.js +393 -0
  98. package/dist/mcp/tools.js.map +1 -0
  99. package/dist/orchestration/reviewer-gate.d.ts +43 -0
  100. package/dist/orchestration/reviewer-gate.js +280 -0
  101. package/dist/orchestration/reviewer-gate.js.map +1 -0
  102. package/dist/orchestration/reviewer-prompt.d.ts +18 -0
  103. package/dist/orchestration/reviewer-prompt.js +67 -0
  104. package/dist/orchestration/reviewer-prompt.js.map +1 -0
  105. package/dist/orchestration/verification-runner.d.ts +53 -0
  106. package/dist/orchestration/verification-runner.js +352 -0
  107. package/dist/orchestration/verification-runner.js.map +1 -0
  108. package/dist/plugins/adapter.d.ts +11 -0
  109. package/dist/plugins/adapter.js +11 -0
  110. package/dist/plugins/adapter.js.map +1 -0
  111. package/dist/plugins/core/archive.d.ts +9 -0
  112. package/dist/plugins/core/archive.js +128 -0
  113. package/dist/plugins/core/archive.js.map +1 -0
  114. package/dist/plugins/core/digest.d.ts +11 -0
  115. package/dist/plugins/core/digest.js +70 -0
  116. package/dist/plugins/core/digest.js.map +1 -0
  117. package/dist/plugins/core/grants.d.ts +22 -0
  118. package/dist/plugins/core/grants.js +90 -0
  119. package/dist/plugins/core/grants.js.map +1 -0
  120. package/dist/plugins/core/index.d.ts +10 -0
  121. package/dist/plugins/core/index.js +11 -0
  122. package/dist/plugins/core/index.js.map +1 -0
  123. package/dist/plugins/core/pinning.d.ts +15 -0
  124. package/dist/plugins/core/pinning.js +86 -0
  125. package/dist/plugins/core/pinning.js.map +1 -0
  126. package/dist/plugins/core/types.d.ts +105 -0
  127. package/dist/plugins/core/types.js +22 -0
  128. package/dist/plugins/core/types.js.map +1 -0
  129. package/dist/plugins/core/updater.d.ts +16 -0
  130. package/dist/plugins/core/updater.js +64 -0
  131. package/dist/plugins/core/updater.js.map +1 -0
  132. package/dist/plugins/loader.d.ts +65 -0
  133. package/dist/plugins/loader.js +110 -0
  134. package/dist/plugins/loader.js.map +1 -0
  135. package/dist/prompts/orchestrator.d.ts +6 -0
  136. package/dist/prompts/orchestrator.js +114 -0
  137. package/dist/prompts/orchestrator.js.map +1 -0
  138. package/dist/routing/model-resolver.d.ts +27 -0
  139. package/dist/routing/model-resolver.js +265 -0
  140. package/dist/routing/model-resolver.js.map +1 -0
  141. package/dist/routing/model-router.d.ts +130 -0
  142. package/dist/routing/model-router.js +515 -0
  143. package/dist/routing/model-router.js.map +1 -0
  144. package/dist/runtime/attestations.cjs +260 -0
  145. package/dist/runtime/reviewer-pipeline.cjs +319 -0
  146. package/dist/runtime/sandbox.cjs +468 -0
  147. package/dist/runtime/state-machine.cjs +885 -0
  148. package/dist/runtime/thread-store.cjs +175 -0
  149. package/dist/runtime/transactional-git.cjs +315 -0
  150. package/dist/runtime/verification-pipeline.cjs +435 -0
  151. package/dist/sandbox/execution.d.ts +165 -0
  152. package/dist/sandbox/execution.js +497 -0
  153. package/dist/sandbox/execution.js.map +1 -0
  154. package/dist/security/file-policy.d.ts +22 -0
  155. package/dist/security/file-policy.js +53 -0
  156. package/dist/security/file-policy.js.map +1 -0
  157. package/dist/security/repository-boundary.d.ts +21 -0
  158. package/dist/security/repository-boundary.js +119 -0
  159. package/dist/security/repository-boundary.js.map +1 -0
  160. package/dist/security/secret-filter.d.ts +36 -0
  161. package/dist/security/secret-filter.js +149 -0
  162. package/dist/security/secret-filter.js.map +1 -0
  163. package/dist/swarm.d.ts +14 -0
  164. package/dist/swarm.js +624 -0
  165. package/dist/swarm.js.map +1 -0
  166. package/dist/threads/cache.d.ts +64 -0
  167. package/dist/threads/cache.js +241 -0
  168. package/dist/threads/cache.js.map +1 -0
  169. package/dist/threads/manager.d.ts +94 -0
  170. package/dist/threads/manager.js +914 -0
  171. package/dist/threads/manager.js.map +1 -0
  172. package/dist/ui/banner.d.ts +14 -0
  173. package/dist/ui/banner.js +42 -0
  174. package/dist/ui/banner.js.map +1 -0
  175. package/dist/ui/dashboard.d.ts +33 -0
  176. package/dist/ui/dashboard.js +135 -0
  177. package/dist/ui/dashboard.js.map +1 -0
  178. package/dist/ui/index.d.ts +11 -0
  179. package/dist/ui/index.js +12 -0
  180. package/dist/ui/index.js.map +1 -0
  181. package/dist/ui/log.d.ts +39 -0
  182. package/dist/ui/log.js +126 -0
  183. package/dist/ui/log.js.map +1 -0
  184. package/dist/ui/onboarding.d.ts +14 -0
  185. package/dist/ui/onboarding.js +755 -0
  186. package/dist/ui/onboarding.js.map +1 -0
  187. package/dist/ui/run-log.d.ts +50 -0
  188. package/dist/ui/run-log.js +60 -0
  189. package/dist/ui/run-log.js.map +1 -0
  190. package/dist/ui/spinner.d.ts +41 -0
  191. package/dist/ui/spinner.js +172 -0
  192. package/dist/ui/spinner.js.map +1 -0
  193. package/dist/ui/streaming-feed.d.ts +72 -0
  194. package/dist/ui/streaming-feed.js +386 -0
  195. package/dist/ui/streaming-feed.js.map +1 -0
  196. package/dist/ui/summary.d.ts +18 -0
  197. package/dist/ui/summary.js +113 -0
  198. package/dist/ui/summary.js.map +1 -0
  199. package/dist/ui/text-input.d.ts +19 -0
  200. package/dist/ui/text-input.js +266 -0
  201. package/dist/ui/text-input.js.map +1 -0
  202. package/dist/ui/theme.d.ts +63 -0
  203. package/dist/ui/theme.js +97 -0
  204. package/dist/ui/theme.js.map +1 -0
  205. package/dist/utils/command-exists.d.ts +11 -0
  206. package/dist/utils/command-exists.js +41 -0
  207. package/dist/utils/command-exists.js.map +1 -0
  208. package/dist/utils/process-security.d.ts +19 -0
  209. package/dist/utils/process-security.js +90 -0
  210. package/dist/utils/process-security.js.map +1 -0
  211. package/dist/utils/rolling-buffer.d.ts +16 -0
  212. package/dist/utils/rolling-buffer.js +30 -0
  213. package/dist/utils/rolling-buffer.js.map +1 -0
  214. package/dist/utils/safe-spawn.d.ts +13 -0
  215. package/dist/utils/safe-spawn.js +49 -0
  216. package/dist/utils/safe-spawn.js.map +1 -0
  217. package/dist/worktree/manager.d.ts +69 -0
  218. package/dist/worktree/manager.js +593 -0
  219. package/dist/worktree/manager.js.map +1 -0
  220. package/dist/worktree/merge.d.ts +58 -0
  221. package/dist/worktree/merge.js +389 -0
  222. package/dist/worktree/merge.js.map +1 -0
  223. 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