@krmxd/onegpt 0.0.0-beta

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/src/cli.js ADDED
@@ -0,0 +1,1111 @@
1
+ "use strict";
2
+
3
+ const readline = require("readline");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const { getConfig } = require("./config");
8
+ const { getSharedProvider } = require("./ollama");
9
+ const { ToolRegistry } = require("./tools");
10
+ const { Agent } = require("./agent");
11
+ const Platform = require("./platform");
12
+ const catalog = require("./catalog");
13
+ const web = require("./web");
14
+
15
+ const VERSION = "1.1.0";
16
+
17
+ class Command {
18
+ constructor(name, handler, description, usage, category, ai, aliases) {
19
+ this.name = name;
20
+ this.handler = handler; // (args: string[]) => void|Promise<void>
21
+ this.description = description;
22
+ this.usage = usage || name;
23
+ this.category = category || "general";
24
+ this.ai = !!ai; // safe for the AI to execute via its ogpt_command tool
25
+ this.aliases = aliases || [];
26
+ }
27
+ }
28
+
29
+ class CLI {
30
+ constructor(options = {}) {
31
+ this.cfg = options.config || getConfig();
32
+ this.tools = options.tools || new ToolRegistry();
33
+ this.agent = new Agent({ config: this.cfg, tools: this.tools });
34
+ this.host = (this.cfg.get("ollama.host", "http://127.0.0.1:11434") || "").replace(/localhost/g, "127.0.0.1");
35
+ this.running = true;
36
+ this._capture = null; // when set, output is captured for the AI
37
+ this._warmupBusy = false;
38
+ this._chatActive = false; // suppress background prints while a reply streams
39
+ this._replStarted = false; // suppress background prints once the REPL takes input
40
+ this._registerCommands();
41
+ // Let the AI execute internal commands through its own tool
42
+ this.tools.commandDispatcher = (cmd) => this.runAiCommand(cmd);
43
+ }
44
+
45
+ get provider() {
46
+ return getSharedProvider(this.host);
47
+ }
48
+
49
+ // ------------------------------------------------------------------ output
50
+
51
+ _emit(msg) {
52
+ if (this._capture !== null) {
53
+ this._capture.push(String(msg).replace(/\x1b\[[0-9;]*m/g, ""));
54
+ return;
55
+ }
56
+ process.stdout.write(msg + "\n");
57
+ }
58
+
59
+ print(msg) {
60
+ this._emit(msg ?? "");
61
+ }
62
+
63
+ printColor(color, msg) {
64
+ const colors = { red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m", magenta: "\x1b[35m", dim: "\x1b[2m", bold: "\x1b[1m" };
65
+ if (this._capture !== null) {
66
+ this._capture.push(String(msg));
67
+ return;
68
+ }
69
+ const c = colors[color] || "";
70
+ process.stdout.write(`${c}${msg}\x1b[0m\n`);
71
+ }
72
+
73
+ error(msg) { this.printColor("red", `✗ ${msg}`); }
74
+ success(msg) { this.printColor("green", `✓ ${msg}`); }
75
+ info(msg) { this.printColor("cyan", `ℹ ${msg}`); }
76
+ warn(msg) { this.printColor("yellow", `⚠ ${msg}`); }
77
+
78
+ _raw(msg) {
79
+ if (this._capture !== null) {
80
+ this._capture.push(msg);
81
+ return;
82
+ }
83
+ process.stdout.write(msg);
84
+ }
85
+
86
+ _table(title, columns, rows) {
87
+ if (this._capture !== null) {
88
+ const widths = columns.map((c) => c.length);
89
+ for (const row of rows) {
90
+ row.forEach((cell, i) => { widths[i] = Math.max(widths[i], String(cell ?? "").length); });
91
+ }
92
+ const lines = [" " + columns.map((c, i) => String(c).padEnd(widths[i])).join(" ")];
93
+ lines.push(" " + widths.map((w) => "-".repeat(w)).join(" "));
94
+ for (const row of rows) {
95
+ lines.push(" " + row.map((cell, i) => String(cell ?? "").padEnd(widths[i])).join(" "));
96
+ }
97
+ this._capture.push(title + "\n" + lines.join("\n"));
98
+ return;
99
+ }
100
+ // columnar layout with ANSI-aware padding
101
+ const strip = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "");
102
+ const widths = columns.map((c) => strip(c).length);
103
+ for (const row of rows) {
104
+ row.forEach((cell, i) => { widths[i] = Math.max(widths[i], strip(cell).length); });
105
+ }
106
+ const line = (cells, padChar) =>
107
+ " " + cells.map((cell, i) => {
108
+ const s = String(cell);
109
+ return s + padChar.repeat(widths[i] - strip(s).length);
110
+ }).join(" ");
111
+ this.print("");
112
+ this.printColor("bold", ` ${title}`);
113
+ this.print(line(columns, " "));
114
+ this.print(line(widths.map((w) => "─".repeat(w)), "─").replace(/─/g, "─"));
115
+ for (const row of rows) this.print(line(row, " "));
116
+ this.print("");
117
+ }
118
+
119
+ // ------------------------------------------------------------------ splash & prompt
120
+
121
+ splash() {
122
+ const model = this.cfg.displayName();
123
+ const tier = Platform.recommendTier();
124
+ const cols = process.stdout.columns || 80;
125
+ const rows = process.stdout.rows || 24;
126
+ const tty = process.stdout.isTTY === true;
127
+ const C = "\x1b[36m", B = "\x1b[1m", D = "\x1b[2m", W = "\x1b[37m", R = "\x1b[0m";
128
+
129
+ // Fresh start: wipe whatever was on screen, then draw the banner centered.
130
+ if (tty) process.stdout.write("\x1b[2J\x1b[H");
131
+
132
+ const center = (s) => {
133
+ const pad = Math.max(0, Math.floor((cols - s.length) / 2));
134
+ return " ".repeat(pad) + s;
135
+ };
136
+ const art = [
137
+ " ___ ____ _____ _ _ ______ __",
138
+ " / _ \\/ __ \\| ____| \\ | | _ \\ \\ / /",
139
+ "| | | | | | _| | \\| | | | \\ V /",
140
+ "| |_| |__| | |___| |\\ | |_| || |",
141
+ " \\___/ \\____/|_____|_| \\_|____/ |_|",
142
+ ];
143
+ if (tty) {
144
+ const lines = [""];
145
+ for (const l of art) lines.push(`${B}${C}${center(l)}${R}`);
146
+ lines.push("");
147
+ lines.push(`${B}${W}${center(`AI coding assistant by KareemXD · v${VERSION}`)}${R}`);
148
+ lines.push(`${B}${C}${center(`Active model: ${model} · ${Platform.ramGB()}GB RAM · tier ${tier}`)}${R}`);
149
+ lines.push(`${D}${center("Type /help for commands · /quit to exit")}${R}`);
150
+ lines.push("", "");
151
+ const vpad = Math.max(0, Math.floor((rows - lines.length) / 2) - 1);
152
+ process.stdout.write("\n".repeat(vpad) + lines.join("\n") + "\n\n");
153
+ } else {
154
+ const out = [...art, "", center(`OGPT v${VERSION} · model: ${model} · ${Platform.ramGB()}GB RAM · tier ${tier}`), center("Type /help for commands, /quit to exit"), ""];
155
+ process.stdout.write(out.join("\n") + "\n");
156
+ }
157
+ }
158
+
159
+ prompt() {
160
+ const model = this.cfg.displayName();
161
+ return `\x1b[1;36m${model}\x1b[0m \x1b[36m❯\x1b[0m `;
162
+ }
163
+
164
+ // ------------------------------------------------------------------ chat rendering
165
+
166
+ _thinkingStart() {
167
+ if (process.stdout.isTTY) {
168
+ process.stdout.write("\x1b[2m● thinking...\x1b[0m");
169
+ }
170
+ }
171
+
172
+ _thinkingClear() {
173
+ if (process.stdout.isTTY) process.stdout.write("\r\x1b[K");
174
+ }
175
+
176
+ _timingFooter() {
177
+ if (!this.cfg.get("ui.timing", true)) return;
178
+ const s = this.agent.lastRun || {};
179
+ const parts = [];
180
+ if (s.tokens) parts.push(`${s.tokens} tok`);
181
+ if (s.tps) parts.push(`${s.tps} tok/s`);
182
+ if (s.ttft !== undefined) parts.push(`first token ${Number(s.ttft).toFixed(2)}s`);
183
+ if (s.duration) parts.push(`total ${Number(s.duration).toFixed(1)}s`);
184
+ let cwd = process.cwd();
185
+ try {
186
+ const home = require("os").homedir();
187
+ if (cwd.startsWith(home)) cwd = "~" + cwd.slice(home.length);
188
+ } catch {}
189
+ parts.push(cwd);
190
+ const dim = process.stdout.isTTY ? "\x1b[2m" : "";
191
+ const rst = process.stdout.isTTY ? "\x1b[0m" : "";
192
+ this._raw(`${dim}· ${this.cfg.displayName()} · ${parts.join(" · ")}${rst}\n`);
193
+ }
194
+
195
+ async _chat(userInput) {
196
+ const stream = this.cfg.get("ui.stream", true);
197
+ const collected = [];
198
+ this._chatActive = true;
199
+ try {
200
+ if (stream) {
201
+ this._raw("\n");
202
+ this._thinkingStart();
203
+ let thinkingShown = process.stdout.isTTY === true;
204
+ for await (const chunk of this.agent.runStream(userInput)) {
205
+ if (chunk) {
206
+ if (thinkingShown) { this._thinkingClear(); thinkingShown = false; }
207
+ collected.push(chunk);
208
+ this._raw(chunk);
209
+ }
210
+ }
211
+ // Only clear the indicator - never touch the response text.
212
+ if (thinkingShown) { this._thinkingClear(); thinkingShown = false; }
213
+ this._raw("\n");
214
+ web.recordRun(this.agent.lastRun);
215
+ this._timingFooter();
216
+ } else {
217
+ const response = await this.agent.run(userInput);
218
+ web.recordRun(this.agent.lastRun);
219
+ this.print(response);
220
+ }
221
+ } catch (e) {
222
+ this._raw("\n");
223
+ this.error(`Error: ${e.message}`);
224
+ } finally {
225
+ this._chatActive = false;
226
+ }
227
+ }
228
+
229
+ // ------------------------------------------------------------------ command registry
230
+
231
+ _registerCommands() {
232
+ const reg = (cmd) => { this.commands[cmd.name] = cmd; };
233
+ this.commands = {};
234
+
235
+ reg(new Command("/help", (a) => this.cmdHelp(a), "Show commands (use /help <cmd> for details)", "/help [command]", "general", true, ["/?", "/h"]));
236
+ reg(new Command("/quit", () => { this.running = false; this.print("Goodbye!"); }, "Exit OGPT", "/quit", "general", false, ["/exit", "/q"]));
237
+ reg(new Command("/clear", () => { this.agent.clear(); this.success("Conversation cleared."); }, "Clear conversation history", "/clear", "general"));
238
+ reg(new Command("/cls", () => { process.stdout.write("\x1b[2J\x1b[H"); }, "Clear the terminal screen", "/cls", "general"));
239
+ reg(new Command("/multi", () => this.cmdMulti(), "Enter multi-line input (empty line to finish)", "/multi", "general"));
240
+
241
+ reg(new Command("/status", () => this.cmdStatus(), "Show current status", "/status", "model", true));
242
+ reg(new Command("/model", (a) => this.cmdModel(a), "Switch model by oGPT name or id", "/model <oGPT-name|id>", "model", true));
243
+ reg(new Command("/models", () => this.cmdModels(), "List installed models", "/models", "model", true));
244
+ reg(new Command("/catalog", () => this.cmdCatalog(), "Show curated oGPT model catalog", "/catalog", "model", true));
245
+ reg(new Command("/pull", (a) => this.cmdPull(a), "Download a model (with progress)", "/pull <oGPT-name|id>", "model"));
246
+ reg(new Command("/ps", () => this.cmdPs(), "Show models loaded in memory", "/ps", "model", true));
247
+ reg(new Command("/warmup", (a) => this.cmdWarmup(a), "Preload the active model into memory", "/warmup", "model", true));
248
+
249
+ reg(new Command("/save", () => this.cmdSave(), "Save the current session", "/save", "session", true));
250
+ reg(new Command("/load", (a) => this.cmdLoad(a), "Load a saved session by id", "/load <id>", "session"));
251
+ reg(new Command("/sessions", () => this.cmdSessions(), "List saved sessions", "/sessions", "session"));
252
+ reg(new Command("/session", (a) => this.cmdSession(a), "Manage sessions", "/session new|save|list|load|delete <id>", "session"));
253
+ reg(new Command("/new", (a) => this.cmdNew(a), "Archive current chat and start a fresh one", "/new [title]", "general"));
254
+ reg(new Command("/fresh", (a) => this.cmdNew(a), "Alias of /new - start a fresh chat", "/fresh [title]", "general"));
255
+ reg(new Command("/reset", () => this.cmdReset(), "Reset conversation, memory and sessions", "/reset", "general"));
256
+ reg(new Command("/dash", async () => {
257
+ const url = await web.startDashboard(this).catch(() => null);
258
+ if (url) this.print(`Token dashboard running at ${url}`);
259
+ else this.printColor("dim", "Dashboard unavailable - set web.enabled=true in config");
260
+ }, "Show (or start) the token dashboard URL", "/dash"));
261
+ reg(new Command("/deny", () => {
262
+ this.tools._perm.auto_approve = false;
263
+ this.tools._perm.allowed.clear();
264
+ this.success("Auto-approve disabled.");
265
+ }, "Disable auto-approve", "/deny", "tools"));
266
+ reg(new Command("/provider", (a) => this.cmdProvider(a), "List or switch providers", "/provider [name]", "model"));
267
+ reg(new Command("/context", (a) => this.cmdContext(a), "Show the current conversation messages", "/context [n]", "general", true));
268
+ reg(new Command("/verbose", () => {
269
+ const cur = !!this.cfg.get("agent.verbose", false);
270
+ this.cfg.set("agent.verbose", !cur);
271
+ this.success(`Verbose: ${!cur ? "ON" : "OFF"}`);
272
+ }, "Toggle verbose mode", "/verbose", "settings"));
273
+ reg(new Command("/panel", () => {
274
+ const cur = !!this.cfg.get("ui.panel", true);
275
+ this.cfg.set("ui.panel", !cur);
276
+ this.success(`Panel UI: ${!cur ? "ON" : "OFF"} (restart applies)`);
277
+ }, "Toggle the chat panel UI (restart applies)", "/panel", "settings"));
278
+ reg(new Command("/index", () => this.cmdIndex(), "Show project structure", "/index", "project", true));
279
+ reg(new Command("/skills", () => this.info("No skills installed. Drop folders into ~/.config/ogpt/skills."),
280
+ "List available skills", "/skills", "system", true));
281
+ reg(new Command("/hooks", () => this.info("No hooks registered."),
282
+ "List registered hooks", "/hooks", "system", true));
283
+ reg(new Command("/plugins", () => this.info("No plugins loaded."),
284
+ "List plugins", "/plugins", "system", true));
285
+ reg(new Command("/mcp", () => this.info("No MCP servers configured."),
286
+ "List MCP servers", "/mcp", "system", true));
287
+
288
+ reg(new Command("/tools", () => this.cmdTools(), "List available tools", "/tools", "tools", true));
289
+ reg(new Command("/approve", (a) => { this.print(this.tools.toggleApprove(a[0] || "")); }, "Auto-approve a specific tool", "/approve <tool>", "tools"));
290
+ reg(new Command("/auto", () => { this.print(this.tools.toggleApprove()); }, "Toggle auto-approve for all tools", "/auto", "tools"));
291
+
292
+ reg(new Command("/history", (a) => this.cmdHistory(a), "Show conversation history", "/history [n]", "general", true));
293
+ reg(new Command("/compact", (a) => this.cmdCompact(a), "Compact conversation into a summary", "/compact [keep-n]", "general"));
294
+ reg(new Command("/undo", () => this.cmdUndo(), "Remove the last exchange from context", "/undo", "general"));
295
+ reg(new Command("/retry", () => this.cmdRetry(), "Regenerate the last reply", "/retry", "general"));
296
+
297
+ reg(new Command("/memory", (a) => this.cmdMemory(a), "Project memory: show/add/search/clear", "/memory [add <text>|search <q>|clear]", "project", true));
298
+ reg(new Command("/task", (a) => this.cmdTask(a), "Tasks: list/new/update", "/task list|new <desc>|update <id> <status>", "project", true));
299
+
300
+ reg(new Command("/config", () => this.cmdConfig(), "Show configuration", "/config", "system"));
301
+ reg(new Command("/tier", () => this.cmdTier(), "Show recommended model tier", "/tier", "system", true));
302
+ reg(new Command("/stats", () => this.cmdStats(), "Show usage statistics", "/stats", "system", true));
303
+
304
+ reg(new Command("/stream", () => this.cmdToggle("ui.stream", "Streaming"), "Toggle streaming responses", "/stream", "settings"));
305
+ reg(new Command("/timing", () => this.cmdToggle("ui.timing", "Timing footer"), "Toggle perf timing footer", "/timing", "settings"));
306
+ reg(new Command("/preview", () => this.cmdToggle("ui.preview", "Code preview"), "Toggle code preview on writes", "/preview", "settings"));
307
+ reg(new Command("/alias", (a) => this.cmdAlias(a), "Create command shortcuts", "/alias [name </command>|remove name]", "settings"));
308
+
309
+ // Quick prompt templates
310
+ reg(new Command("/ask", this._mkPromptCmd("ask"), "Quick question - answer only, no tools", "/ask <question>", "prompts"));
311
+ reg(new Command("/fix", this._mkPromptCmd("fix"), "Fix code or an error you paste", "/fix <code|error>", "prompts"));
312
+ reg(new Command("/explain", this._mkPromptCmd("explain"), "Explain code or a concept", "/explain <topic|code>", "prompts"));
313
+ reg(new Command("/test", this._mkPromptCmd("test"), "Write tests for given code", "/test <code|file>", "prompts"));
314
+ reg(new Command("/review", () => this.cmdReview(), "Review uncommitted changes (git diff)", "/review", "prompts"));
315
+ reg(new Command("/optimize", this._mkPromptCmd("optimize"), "Optimize given code", "/optimize <code>", "prompts"));
316
+ reg(new Command("/doc", this._mkPromptCmd("doc"), "Document given code", "/doc <code>", "prompts"));
317
+ }
318
+
319
+ _mkPromptCmd(kind) {
320
+ const templates = {
321
+ ask: "Answer the following question directly and concisely. Do not use any tools.\n\n{}",
322
+ fix: "Fix the following code or error. Explain the root cause briefly, then provide the corrected code.\n\n{}",
323
+ explain: "Explain the following in clear detail, step by step where useful:\n\n{}",
324
+ test: "Write comprehensive unit tests for the following code. Include edge cases.\n\n{}",
325
+ optimize: "Optimize the following code for performance and readability. Show the improved version and summarize the changes.\n\n{}",
326
+ doc: "Add clear documentation (docstrings/comments) to the following code. Return the fully documented code.\n\n{}",
327
+ };
328
+ const template = templates[kind];
329
+ return async (args) => {
330
+ const text = args.join(" ").trim();
331
+ if (!text) { this.error(`Usage: /${kind} <text>`); return; }
332
+ await this._chat(template.replace("{}", text));
333
+ };
334
+ }
335
+
336
+ _resolveAlias(line) {
337
+ const spaceIdx = line.indexOf(" ");
338
+ const cmd = (spaceIdx === -1 ? line : line.slice(0, spaceIdx)).toLowerCase();
339
+ const rest = spaceIdx === -1 ? "" : line.slice(spaceIdx + 1);
340
+ const alias = (this.cfg.get("aliases", {}) || {})[cmd];
341
+ if (alias) return `${alias} ${rest}`.trim();
342
+ return line;
343
+ }
344
+
345
+ _findCommand(name) {
346
+ const cmd = this.commands[name.toLowerCase()];
347
+ if (cmd) return cmd;
348
+ for (const c of Object.values(this.commands)) {
349
+ if (c.aliases.includes(name.toLowerCase())) return c;
350
+ }
351
+ return null;
352
+ }
353
+
354
+ async processCommand(input) {
355
+ if (!input.startsWith("/")) return false;
356
+ input = this._resolveAlias(input);
357
+ const parts = input.split(/\s+/);
358
+ const cmd = parts[0].toLowerCase();
359
+ const args = parts.slice(1);
360
+
361
+ const command = this._findCommand(cmd);
362
+ if (!command) {
363
+ this.error(`Unknown command: ${cmd}. Type /help for available commands.`);
364
+ return true;
365
+ }
366
+ try {
367
+ await command.handler(args);
368
+ } catch (e) {
369
+ this.error(`Command error: ${e.message}`);
370
+ }
371
+ return true;
372
+ }
373
+
374
+ // ------------------------------------------------------------------ AI command bridge
375
+
376
+ runAiCommand(line) {
377
+ line = String(line || "").trim();
378
+ if (!line.startsWith("/")) line = "/" + line;
379
+ const parts = line.split(/\s+/);
380
+ const command = this._findCommand(parts[0]);
381
+ if (!command) return `Unknown command: ${parts[0]}. Type /help for available commands.`;
382
+ if (!command.ai) return `Command ${command.name} is not available to the AI.`;
383
+ this._capture = [];
384
+ try {
385
+ const result = command.handler(parts.slice(1));
386
+ if (result && typeof result.then === "function") {
387
+ // async handlers need sync dispatch - not supported for AI
388
+ return `Command ${command.name} could not be completed synchronously.`;
389
+ }
390
+ return this._capture.join("\n").trim() || "OK";
391
+ } catch (e) {
392
+ return `Error: ${e.message}`;
393
+ } finally {
394
+ this._capture = null;
395
+ }
396
+ }
397
+
398
+ // ------------------------------------------------------------------ general commands
399
+
400
+ cmdHelp(args) {
401
+ if (args && args.length) {
402
+ const target = this._resolveAlias(args[0]);
403
+ const command = this._findCommand(target);
404
+ if (!command) { this.error(`Unknown command: ${args[0]}`); return; }
405
+ const lines = [
406
+ `## ${command.name}${command.ai ? " 🤖" : ""}`,
407
+ "",
408
+ command.description,
409
+ "",
410
+ `**Usage:** \`${command.usage}\``,
411
+ ];
412
+ if (command.aliases.length) lines.push(`**Aliases:** ${command.aliases.join(", ")}`);
413
+ this.print(lines.join("\n"));
414
+ return;
415
+ }
416
+ const titles = {
417
+ general: "General", model: "Models & Providers", session: "Sessions",
418
+ tools: "Tools & Permissions", project: "Memory & Tasks", system: "System",
419
+ settings: "Settings", prompts: "Quick Prompts",
420
+ };
421
+ const cats = {};
422
+ for (const cmd of Object.values(this.commands).sort((a, b) => a.name.localeCompare(b.name))) {
423
+ (cats[cmd.category] = cats[cmd.category] || []).push(cmd);
424
+ }
425
+ const lines = [" OGPT Commands (🤖 = also usable by the AI)", ""];
426
+ for (const [cat, cmds] of Object.entries(cats)) {
427
+ lines.push(` ${titles[cat] || cat}:`);
428
+ for (const cmd of cmds) {
429
+ const mark = cmd.ai ? " 🤖" : "";
430
+ lines.push(` ${cmd.usage.padEnd(42)}${D()} ${cmd.description}${mark}${R()}`);
431
+ }
432
+ lines.push("");
433
+ }
434
+ this.print(lines.join("\n"));
435
+ }
436
+
437
+ cmdMulti() {
438
+ return new Promise((resolve) => {
439
+ this.info("Multi-line mode - finish with an empty line:");
440
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
441
+ const lines = [];
442
+ rl.on("line", (line) => {
443
+ if (!line.trim()) { rl.close(); return; }
444
+ lines.push(line);
445
+ });
446
+ rl.on("close", async () => {
447
+ if (lines.length) await this._chat(lines.join("\n"));
448
+ resolve();
449
+ });
450
+ });
451
+ }
452
+
453
+ cmdHistory(args) {
454
+ const n = args && args[0] && !isNaN(args[0]) ? parseInt(args[0]) : 20;
455
+ const msgs = this.agent.history.filter((m) => m.role !== "tool");
456
+ if (!msgs.length) { this.print("No conversation history."); return; }
457
+ msgs.slice(-n).forEach((m, i) => {
458
+ const icon = m.role === "user" ? "🧑" : "🤖";
459
+ const content = m.content.length > 120 ? m.content.slice(0, 120) + "..." : m.content.replace(/\n/g, " ");
460
+ this.print(`${i + 1}. ${icon} ${m.role}: ${content}`);
461
+ });
462
+ }
463
+
464
+ cmdUndo() {
465
+ if (this.agent.undo()) this.success("Removed last exchange from context.");
466
+ else this.print("Nothing to undo.");
467
+ }
468
+
469
+ async cmdRetry() {
470
+ const last = this.agent.retry();
471
+ if (last === null) { this.print("Nothing to retry."); return; }
472
+ this.info(`Retrying: ${last.slice(0, 80)}`);
473
+ await this._chat(last);
474
+ }
475
+
476
+ cmdCompact(args) {
477
+ const keep = args[0] && !isNaN(args[0]) ? parseInt(args[0]) : 4;
478
+ const removed = this.agent.compact(keep);
479
+ if (removed) this.success(`Compacted ${removed} messages. ${keep} recent kept.`);
480
+ else this.print("Nothing to compact.");
481
+ }
482
+
483
+ // ------------------------------------------------------------------ model & provider commands
484
+
485
+ async cmdStatus() {
486
+ const ram = Platform.ramGB();
487
+ const tier = Platform.recommendTier();
488
+ const threads = Platform.recommendThreads();
489
+ const stats = this.agent.stats();
490
+ const model = this.cfg.activeModel();
491
+ const ogpt = this.cfg.displayName(model);
492
+ let loaded = "none";
493
+ try {
494
+ const running = await this.provider.runningModels();
495
+ if (running.length) loaded = running.map((m) => m.name).join(", ");
496
+ } catch {}
497
+ const keepAlive = this.cfg.get("ollama.keep_alive", "30m");
498
+ const numCtx = this.cfg.get("ollama.num_ctx", 8192);
499
+ this.print(`
500
+ Status:
501
+ Model: ${ogpt} (${model})
502
+ Loaded: ${loaded}
503
+ RAM: ${ram}GB (tier: ${tier}, threads: ${threads})
504
+ Perf: keep_alive=${keepAlive}, ctx=${numCtx}
505
+ Messages: ${stats.messages}
506
+ Tokens: ${stats.totalTokens}
507
+ Auto-approve: ${this.tools._autoApprove ? "ON" : "OFF"}
508
+ `);
509
+ }
510
+
511
+ switchModel(name) {
512
+ const resolved = this.cfg.resolveModel(name);
513
+ if (catalog.isOgptName(name) && !catalog.resolveModel(name)) {
514
+ this.error(`Unknown oGPT model: ${name}. See /catalog for valid names.`);
515
+ return;
516
+ }
517
+ this.cfg.set("active_model", resolved);
518
+ this.success(`Switched to model: ${this.cfg.displayName(resolved)} (${resolved})`);
519
+ }
520
+
521
+ async cmdModel(args) {
522
+ if (!args || !args.length) {
523
+ await this.cmdModels();
524
+ this.info("Switch with /model <oGPT-name> - e.g. /model oGPT-2a");
525
+ return;
526
+ }
527
+ this.switchModel(args[0]);
528
+ }
529
+
530
+ async cmdModels() {
531
+ const models = await this.provider.listModels(true);
532
+ if (!models.length) {
533
+ this.print("No models found. Pull one with: /pull <oGPT-name>");
534
+ return;
535
+ }
536
+ const rows = models.map((m) => [
537
+ m.id,
538
+ this.cfg.displayName(m.id),
539
+ m.size ? formatSize(m.size) : "-",
540
+ m.id === this.cfg.activeModel() ? "←" : "",
541
+ ]);
542
+ this._table("Installed Models", ["Model ID", "oGPT Name", "Size", ""], rows);
543
+ }
544
+
545
+ cmdCatalog() {
546
+ const rows = catalog.entries().map(([id, e]) => [
547
+ e.ogpt,
548
+ id,
549
+ e.provider || "local",
550
+ e.size || "-",
551
+ e.ram_gb ? `${e.ram_gb}GB` : "-",
552
+ e.desc,
553
+ id === this.cfg.activeModel() ? "←" : "",
554
+ ]);
555
+ this._table(
556
+ "oGPT Model Catalog",
557
+ ["oGPT", "Model ID", "Provider", "Size", "Min RAM", "Description", ""],
558
+ rows
559
+ );
560
+ this.info("Install with /pull <oGPT-name> · switch with /model <oGPT-name>");
561
+ }
562
+
563
+ async cmdPull(args) {
564
+ if (!args || !args.length) { this.error("Usage: /pull <oGPT-name|model-id>"); return; }
565
+ const model = this.cfg.resolveModel(args[0]);
566
+ let lastPct = -1;
567
+ for await (const prog of this.provider.pull(model)) {
568
+ if (prog.error) { this._raw("\n"); this.error(`Pull failed: ${prog.error}`); return; }
569
+ const total = prog.total || 0;
570
+ const done = prog.completed || 0;
571
+ if (total && done) {
572
+ const pct = Math.floor((done * 100) / total);
573
+ if (pct !== lastPct) {
574
+ const bar = "█".repeat(Math.floor(pct / 5)) + "░".repeat(20 - Math.floor(pct / 5));
575
+ this._raw(`\r [pull ${model}] ${bar} ${pct}% (${(done / 1e6).toFixed(0)}/${(total / 1e6).toFixed(0)} MB)`);
576
+ lastPct = pct;
577
+ }
578
+ }
579
+ }
580
+ this._raw("\r" + " ".repeat(72) + "\r");
581
+ this.success(`Pulled ${model} (${this.cfg.displayName(model)})`);
582
+ }
583
+
584
+ async cmdPs() {
585
+ let models;
586
+ try {
587
+ models = await this.provider.runningModels();
588
+ } catch (e) {
589
+ this.error(`Local engine unavailable: ${e.message}`);
590
+ return;
591
+ }
592
+ if (!models.length) { this.print("No models loaded in memory."); return; }
593
+ const rows = models.map((m) => [
594
+ m.name || "?",
595
+ this.cfg.displayName(m.name || ""),
596
+ formatSize(m.size || 0),
597
+ m.expires_at ? new Date(m.expires_at).toLocaleTimeString() : "-",
598
+ ]);
599
+ this._table("Loaded Models", ["Model", "oGPT Name", "Size", "Expires"], rows);
600
+ }
601
+
602
+ async cmdWarmup(args) {
603
+ const model = args && args.length ? this.cfg.resolveModel(args[0]) : this.cfg.activeModel();
604
+ this.info(`Warming up ${this.cfg.displayName(model)}...`);
605
+ const t0 = Date.now();
606
+ const ok = await this.provider.warmup(model);
607
+ if (ok) this.success(`${this.cfg.displayName(model)} loaded in ${((Date.now() - t0) / 1000).toFixed(1)}s - first message will be instant`);
608
+ else this.error("Warmup failed - is the local engine running?");
609
+ }
610
+
611
+ // ------------------------------------------------------------------ session commands
612
+
613
+ cmdSave() {
614
+ const sid = this.saveSession();
615
+ if (sid) this.success(`Saved session: ${sid}`);
616
+ else this.warn("Session persistence not configured in this build.");
617
+ }
618
+
619
+ cmdNew(args) {
620
+ // Archive the current chat and start a completely fresh one.
621
+ const title = (args || []).join(" ").trim();
622
+ const had = this.agent.history.some((m) => String(m.content || "").trim());
623
+ if (had && this.cfg.get("session.auto_save", true)) {
624
+ const sid = this.saveSession();
625
+ if (sid) this.success(`Archived chat as session ${sid}`);
626
+ } else if (had) {
627
+ this.warn("Previous chat discarded (auto-save is off)");
628
+ }
629
+ this.agent.clear();
630
+ this._sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
631
+ this.success(`New chat ready - session ${this._sessionId}${title ? ` "${title}"` : ""}`);
632
+ }
633
+
634
+ cmdReset() {
635
+ this.agent.clear();
636
+ try { this.cmdMemory(["clear"]); } catch {}
637
+ this._sessionId = null;
638
+ this.success("Everything reset.");
639
+ }
640
+
641
+ cmdProvider(args) {
642
+ if (!args || !args.length) {
643
+ this._table("Providers", ["Name", "Status", "Models"], [
644
+ ["Ollama", "Available", "local"],
645
+ ]);
646
+ return;
647
+ }
648
+ const name = args[0].toLowerCase();
649
+ if (name === "ollama") this.success("Already using Ollama.");
650
+ else this.error(`Unknown provider '${args[0]}' - this build runs on Ollama only.`);
651
+ }
652
+
653
+ cmdContext(args) {
654
+ const n = args && /^\d+$/.test(args[0]) ? parseInt(args[0], 10) : 20;
655
+ const msgs = this.agent.history.filter((m) => m.role !== "tool");
656
+ if (!msgs.length) { this.info("No conversation history."); return; }
657
+ for (const m of msgs.slice(-n)) {
658
+ const who = m.role === "user" ? "you" : m.role === "assistant" ? this.cfg.displayName() : m.role;
659
+ this.printColor(m.role === "user" ? "cyan" : "magenta", `\n${who}:`);
660
+ this.print(String(m.content || "").slice(0, 2000));
661
+ }
662
+ }
663
+
664
+ cmdIndex() {
665
+ const lines = [];
666
+ const IGNORE = new Set(["node_modules", ".git", "__pycache__", ".venv", "venv", ".ogpt"]);
667
+ const walk = (dir, prefix, depth) => {
668
+ if (depth > 3 || lines.length > 220) return;
669
+ let entries;
670
+ try {
671
+ entries = fs.readdirSync(dir, { withFileTypes: true })
672
+ .filter((e) => !IGNORE.has(e.name) && !e.name.startsWith("."));
673
+ } catch { return; }
674
+ entries.sort((a, b) => (b.isDirectory() - a.isDirectory()) || a.name.localeCompare(b.name));
675
+ entries.slice(0, 40).forEach((e, i) => {
676
+ const last = i === Math.min(entries.length, 40) - 1;
677
+ lines.push(`${prefix}${last ? "`-- " : "|-- "}${e.name}${e.isDirectory() ? "/" : ""}`);
678
+ if (e.isDirectory()) walk(path.join(dir, e.name), prefix + (last ? " " : "| "), depth + 1);
679
+ });
680
+ };
681
+ lines.push(`${path.basename(process.cwd())}/`);
682
+ walk(process.cwd(), "", 0);
683
+ this.print(`## Project Structure\n\`\`\`\n${lines.join("\n")}\n\`\`\``);
684
+ }
685
+
686
+ cmdSession(args) {
687
+ const sub = (args && args[0]) || "list";
688
+ if (sub === "list") return this.cmdSessions();
689
+ if (sub === "save") return this.cmdSave();
690
+ if (sub === "new" || sub === "fresh") return this.cmdNew(args.slice(1));
691
+ if (sub === "load") return args[1] ? this.cmdLoad([args[1]]) : this.error("Usage: /session load <id>");
692
+ if (sub === "delete") {
693
+ if (!args[1]) return this.error("Usage: /session delete <id>");
694
+ try {
695
+ const dir = this._sessionsDir();
696
+ fs.unlinkSync(path.join(dir, `${args[1]}.json`));
697
+ try { fs.unlinkSync(path.join(dir, `${args[1]}.meta.json`)); } catch {}
698
+ this.success(`Deleted session: ${args[1]}`);
699
+ } catch { this.error(`Session not found: ${args[1]}`); }
700
+ return;
701
+ }
702
+ this.error("Usage: /session new|save|list|load|delete <id>");
703
+ }
704
+
705
+ saveSession() {
706
+ // Sessions use the SAME on-disk format as the Python build so both
707
+ // versions share one history: <id>.json holds the message array and
708
+ // <id>.meta.json holds { id, title, created, updated, msgs }.
709
+ try {
710
+ const dir = this._sessionsDir();
711
+ if (!this._sessionId) this._sessionId = this._newSessionId();
712
+ const data = this.agent.history.map((m) => ({ role: m.role, content: m.content }));
713
+ fs.writeFileSync(path.join(dir, `${this._sessionId}.json`),
714
+ JSON.stringify(data, null, 2));
715
+ let meta = {};
716
+ const metaPath = path.join(dir, `${this._sessionId}.meta.json`);
717
+ try { meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")); } catch {}
718
+ if (!meta.id) {
719
+ meta = { id: this._sessionId,
720
+ title: `Session ${new Date().toISOString().slice(0, 16).replace("T", " ")}`,
721
+ created: Date.now() / 1000, ...meta };
722
+ }
723
+ meta.updated = Date.now() / 1000;
724
+ meta.msgs = data.length;
725
+ fs.writeFileSync(metaPath, JSON.stringify(meta));
726
+ return this._sessionId;
727
+ } catch {
728
+ return null;
729
+ }
730
+ }
731
+
732
+ _sessionsDir() {
733
+ const dir = path.join(os.homedir(), ".config", "ogpt", "sessions");
734
+ fs.mkdirSync(dir, { recursive: true });
735
+ return dir;
736
+ }
737
+
738
+ _newSessionId() {
739
+ return require("crypto").randomBytes(6).toString("hex").replace(/^(.{4})/, "$1-");
740
+ }
741
+
742
+ loadSession(sid) {
743
+ const p = path.join(this._sessionsDir(), `${sid}.json`);
744
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
745
+ this.agent.history = (Array.isArray(data) ? data : []).map((d) => ({
746
+ role: d.role || "user", content: d.content || "", ts: Date.now() / 1000 }));
747
+ this._sessionId = sid;
748
+ return this.agent.history.length;
749
+ }
750
+
751
+ cmdLoad(args) {
752
+ if (!args || !args.length) { this.error("Usage: /load <session-id>"); return; }
753
+ try {
754
+ const n = this.loadSession(args[0]);
755
+ this.success(`Loaded session: ${args[0]} (${n} messages)`);
756
+ } catch {
757
+ this.error(`Session not found: ${args[0]} (see /sessions)`);
758
+ }
759
+ }
760
+
761
+ cmdSessions() {
762
+ let rows = [];
763
+ try {
764
+ const dir = this._sessionsDir();
765
+ rows = fs.readdirSync(dir).filter((f) => f.endsWith(".meta.json"))
766
+ .map((f) => {
767
+ try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")); }
768
+ catch { return null; }
769
+ })
770
+ .filter(Boolean)
771
+ .sort((a, b) => (b.updated || 0) - (a.updated || 0))
772
+ .map((m) => [m.id || "?", m.title || "", String(m.msgs ?? 0),
773
+ new Date((m.updated || 0) * 1000).toLocaleString()]);
774
+ } catch {}
775
+ if (!rows.length) { this.print("No sessions found."); return; }
776
+ this._table("Sessions", ["ID", "Title", "Messages", "Updated"], rows);
777
+ }
778
+
779
+ // ------------------------------------------------------------------ tools commands
780
+
781
+ cmdTools() {
782
+ const rows = this.tools.listTools().map((t) => [
783
+ t.name,
784
+ t.description,
785
+ t.dangerous ? "⚠" : "",
786
+ ]);
787
+ this._table("Available Tools", ["Name", "Description", "Risky"], rows);
788
+ }
789
+
790
+ // ------------------------------------------------------------------ memory & tasks
791
+
792
+ cmdMemory(args) {
793
+ const sub = args && args.length ? args[0] : "";
794
+ if (!sub) {
795
+ this.print("Memory is stored per-project. Use /memory add <text> or /memory search <query>.");
796
+ return;
797
+ }
798
+ if (sub === "add") {
799
+ const text = args.slice(1).join(" ").trim();
800
+ if (!text) { this.error("Usage: /memory add <text>"); return; }
801
+ this.memoryAdd(text);
802
+ this.success(`Added note: ${text}`);
803
+ return;
804
+ }
805
+ if (sub === "search") {
806
+ const q = args.slice(1).join(" ").trim();
807
+ const results = this.memorySearch(q);
808
+ if (results.length) results.forEach((r) => this.print(` ${r}`));
809
+ else this.print(`No results for: ${q}`);
810
+ return;
811
+ }
812
+ if (sub === "clear") {
813
+ this.memoryClear();
814
+ this.success("Memory cleared.");
815
+ return;
816
+ }
817
+ this.error(`Unknown memory command: ${sub}`);
818
+ }
819
+
820
+ memoryFile() {
821
+ const path = require("path");
822
+ const os = require("os");
823
+ return path.join(process.cwd(), ".ogpt-memory.json");
824
+ }
825
+
826
+ memoryRead() {
827
+ try { return JSON.parse(require("fs").readFileSync(this.memoryFile(), "utf-8")); } catch { return []; }
828
+ }
829
+
830
+ memoryAdd(text) {
831
+ const notes = this.memoryRead();
832
+ notes.push({ text, ts: Date.now() });
833
+ require("fs").writeFileSync(this.memoryFile(), JSON.stringify(notes, null, 2));
834
+ }
835
+
836
+ memorySearch(q) {
837
+ return this.memoryRead()
838
+ .filter((n) => n.text.toLowerCase().includes(q.toLowerCase()))
839
+ .map((n) => `${new Date(n.ts).toLocaleString()}: ${n.text}`);
840
+ }
841
+
842
+ memoryClear() {
843
+ try { require("fs").unlinkSync(this.memoryFile()); } catch {}
844
+ }
845
+
846
+ cmdTask(args) {
847
+ const sub = args && args.length ? args[0] : "list";
848
+ if (sub === "list") {
849
+ const tasks = this.tasksRead();
850
+ if (!tasks.length) { this.print("No tasks."); return; }
851
+ this._table("Tasks", ["ID", "Description", "Status"], tasks.map((t) => [t.id, t.description, t.status]));
852
+ return;
853
+ }
854
+ if (sub === "new") {
855
+ const desc = args.slice(1).join(" ").trim();
856
+ if (!desc) { this.error("Usage: /task new <description>"); return; }
857
+ const tasks = this.tasksRead();
858
+ const task = { id: String(tasks.length + 1).padStart(3, "0"), description: desc, status: "pending", ts: Date.now() };
859
+ tasks.push(task);
860
+ this.tasksWrite(tasks);
861
+ this.success(`Created task: ${task.id}`);
862
+ return;
863
+ }
864
+ if (sub === "update") {
865
+ if (args.length < 3) { this.error("Usage: /task update <id> <status>"); return; }
866
+ const tasks = this.tasksRead();
867
+ const t = tasks.find((x) => x.id === args[1]);
868
+ if (!t) { this.error(`Task not found: ${args[1]}`); return; }
869
+ t.status = args[2];
870
+ this.tasksWrite(tasks);
871
+ this.success(`Updated task: ${t.id}`);
872
+ return;
873
+ }
874
+ this.error(`Unknown task command: ${sub}`);
875
+ }
876
+
877
+ tasksFile() { return require("path").join(process.cwd(), ".ogpt-tasks.json"); }
878
+ tasksRead() { try { return JSON.parse(require("fs").readFileSync(this.tasksFile(), "utf-8")); } catch { return []; } }
879
+ tasksWrite(tasks) { require("fs").writeFileSync(this.tasksFile(), JSON.stringify(tasks, null, 2)); }
880
+
881
+ // ------------------------------------------------------------------ system commands
882
+
883
+ cmdConfig() {
884
+ const scrub = (obj) => {
885
+ if (Array.isArray(obj)) return obj.map(scrub);
886
+ if (obj && typeof obj === "object") {
887
+ const out = {};
888
+ for (const [k, v] of Object.entries(obj)) {
889
+ out[k === "ollama" ? "local" : k] = scrub(v);
890
+ }
891
+ return out;
892
+ }
893
+ if (typeof obj === "string") return obj.replace(/ollama/g, "local").replace(/Ollama/g, "Local");
894
+ return obj;
895
+ };
896
+ const safe = JSON.parse(JSON.stringify(this.cfg.data()));
897
+ for (const p of Object.values(safe.providers || {})) delete p.api_key;
898
+ this.print(JSON.stringify(scrub(safe), null, 2));
899
+ }
900
+
901
+ cmdTier() {
902
+ const routing = this.cfg.get("model_routing", {});
903
+ this.print(`
904
+ Recommended tier: ${Platform.recommendTier()}
905
+ RAM: ${Platform.ramGB()}GB
906
+ Models:
907
+ fast: ${routing.fast || "qwen2.5-coder:1.5b"} (${catalog.displayName(routing.fast || "qwen2.5-coder:1.5b")})
908
+ balanced: ${routing.balanced || "qwen2.5-coder:7b"} (${catalog.displayName(routing.balanced || "qwen2.5-coder:7b")})
909
+ pro: ${routing.pro || "qwen2.5-coder:32b"} (${catalog.displayName(routing.pro || "qwen2.5-coder:32b")})
910
+ `);
911
+ }
912
+
913
+ cmdStats() {
914
+ const s = this.agent.stats();
915
+ this.print(`
916
+ Stats:
917
+ Messages: ${s.messages}
918
+ Total tokens: ${s.totalTokens}
919
+ Prompt: ${s.promptTokens}
920
+ Completion: ${s.completionTokens}
921
+ `);
922
+ }
923
+
924
+ // ------------------------------------------------------------------ settings commands
925
+
926
+ cmdToggle(key, label) {
927
+ const current = this.cfg.get(key, true);
928
+ this.cfg.set(key, !current);
929
+ this.success(`${label}: ${!current ? "ON" : "OFF"}`);
930
+ }
931
+
932
+ cmdAlias(args) {
933
+ const aliases = this.cfg.get("aliases", {}) || {};
934
+ if (!args || !args.length) {
935
+ const names = Object.keys(aliases);
936
+ if (!names.length) { this.info("No aliases. Create one: /alias big /model oGPT-3a"); return; }
937
+ for (const name of names.sort()) this.print(` ${name} → ${aliases[name]}`);
938
+ return;
939
+ }
940
+ if (args[0] === "remove" && args.length > 1) {
941
+ const name = args[1].toLowerCase();
942
+ if (name in aliases) {
943
+ delete aliases[name];
944
+ this.cfg.set("aliases", aliases);
945
+ this.success(`Removed alias: ${name}`);
946
+ } else this.error(`No such alias: ${name}`);
947
+ return;
948
+ }
949
+ if (args.length < 2) { this.error("Usage: /alias <name> </command>"); return; }
950
+ const name = args[0].toLowerCase();
951
+ const target = args.slice(1).join(" ");
952
+ if (!target.startsWith("/")) { this.error("Alias target must be a command starting with /"); return; }
953
+ aliases[name] = target;
954
+ this.cfg.set("aliases", aliases);
955
+ this.success(`Alias created: ${name} → ${target}`);
956
+ }
957
+
958
+ // ------------------------------------------------------------------ review (git diff prompt)
959
+
960
+ async cmdReview() {
961
+ const { execSync } = require("child_process");
962
+ let diff = "";
963
+ try {
964
+ diff = execSync("git diff HEAD", { encoding: "utf-8", timeout: 10000, maxBuffer: 1024 * 1024 * 4 });
965
+ } catch (e) {
966
+ this.error(`git diff failed: ${e.message.split("\n")[0]}`);
967
+ return;
968
+ }
969
+ if (!diff.trim()) { this.print("No uncommitted changes found."); return; }
970
+ await this._chat(
971
+ "Review the following uncommitted changes. Comment on correctness, bugs, security issues, and style. Be specific and concise.\n\n```diff\n" +
972
+ diff.slice(0, 8000) +
973
+ "\n```"
974
+ );
975
+ }
976
+
977
+ // ------------------------------------------------------------------ local engine bootstrap
978
+
979
+ async ensureOllama() {
980
+ const available = await this.provider.isAvailable();
981
+ if (!available) {
982
+ this.warn("Local engine not found or not running.");
983
+ this.printColor("dim", "Please install the local engine, then start it and try again.");
984
+ return false;
985
+ }
986
+
987
+ const ram = Platform.ramGB();
988
+ const tier = Platform.recommendTier();
989
+ const currentModel = this.cfg.activeModel();
990
+
991
+ const { recommendForRam } = catalog;
992
+ const fit = recommendForRam(ram);
993
+ const installed = new Set((await this.provider.listModels(true)).map((m) => m.id));
994
+
995
+ let target;
996
+ if (installed.has(currentModel)) target = currentModel;
997
+ else if (fit && !installed.has(fit)) target = fit; // worth downloading: matches RAM
998
+ else target = await this.provider.bestAvailableModel(tier, currentModel);
999
+
1000
+ if (target !== currentModel) {
1001
+ this.cfg.set("active_model", target);
1002
+ this.printColor("cyan", `Auto-selected model: ${this.cfg.displayName(target)} (tier: ${tier}, RAM: ${ram}GB)`);
1003
+ }
1004
+
1005
+ if (!installed.has(target)) {
1006
+ this.info(`Pulling ${this.cfg.displayName(target)} (${target})...`);
1007
+ try {
1008
+ await this.cmdPull([target]);
1009
+ } catch {
1010
+ this.warn(`Failed to pull ${target}.`);
1011
+ return true;
1012
+ }
1013
+ }
1014
+
1015
+ // Warm up in the background so the first message is instant
1016
+ if (this.cfg.get("ollama.warmup", true)) this.startWarmup(target);
1017
+ return true;
1018
+ }
1019
+
1020
+ startWarmup(model) {
1021
+ if (this._warmupBusy) return;
1022
+ this._warmupBusy = true;
1023
+ const ogptName = this.cfg.displayName(model);
1024
+ const t0 = Date.now();
1025
+ if (!this._chatActive && !this._replStarted) this.info(`[warmup] preloading ${ogptName}...`);
1026
+ this.provider
1027
+ .warmup(model)
1028
+ .then((ok) => {
1029
+ if (this._chatActive || this._replStarted) return; // never print over chat or prompt
1030
+ if (ok) this.success(`[warmup] ${ogptName} ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
1031
+ else this.warn("[warmup] skipped (server busy or unreachable)");
1032
+ })
1033
+ .catch(() => {})
1034
+ .finally(() => { this._warmupBusy = false; });
1035
+ }
1036
+
1037
+ // ------------------------------------------------------------------ main loops
1038
+
1039
+ async runInteractive() {
1040
+ this.splash();
1041
+ await this.ensureOllama();
1042
+
1043
+ const dashUrl = await web.startDashboard(this).catch(() => null);
1044
+ if (dashUrl) this.printColor("dim", `Token dashboard: ${dashUrl} (/dash to reopen)`);
1045
+
1046
+ const rl = readline.createInterface({
1047
+ input: process.stdin,
1048
+ output: process.stdout,
1049
+ terminal: process.stdin.isTTY === true,
1050
+ historySize: 1000,
1051
+ });
1052
+
1053
+ // Route tool-permission questions through this readline loop: pause the
1054
+ // main prompt while asking, so answers can never be swallowed by the
1055
+ // idle input reader (that was mangling y/n into bogus denials).
1056
+ this.tools._perm.ui_hook = (toolName, desc) => new Promise((resolve) => {
1057
+ rl.pause();
1058
+ process.stdout.write(`\n\x1b[33m Permission:\x1b[0m ${toolName} -> ${String(desc).slice(0, 80)}\n`);
1059
+ const q = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
1060
+ q.question(" Allow? (y/n/a=always): ", (ans) => {
1061
+ try { q.close(); } catch {}
1062
+ resolve(String(ans || "").trim().toLowerCase());
1063
+ });
1064
+ q.on("close", () => { try { rl.resume(); } catch {} });
1065
+ });
1066
+
1067
+ const ask = () =>
1068
+ new Promise((resolve) => {
1069
+ if (!this.running) return resolve(undefined);
1070
+ this._replStarted = true;
1071
+ rl.question(this.prompt(), (ans) => resolve(ans));
1072
+ });
1073
+
1074
+ while (this.running) {
1075
+ let answer;
1076
+ try {
1077
+ answer = await ask();
1078
+ } catch {
1079
+ break;
1080
+ }
1081
+ if (answer === undefined) break;
1082
+ const input = answer.trim();
1083
+ if (!input) continue;
1084
+
1085
+ if (await this.processCommand(input)) continue;
1086
+ await this._chat(input);
1087
+ }
1088
+
1089
+ rl.close();
1090
+ this.provider.close();
1091
+ }
1092
+
1093
+ async runSingle(promptText) {
1094
+ await this.ensureOllama();
1095
+ await this._chat(promptText);
1096
+ this.provider.close();
1097
+ }
1098
+ }
1099
+
1100
+ // ANSI helpers used in help rendering
1101
+ function D() { return process.stdout.isTTY ? "\x1b[2m" : ""; }
1102
+ function R() { return process.stdout.isTTY ? "\x1b[0m" : ""; }
1103
+
1104
+ function formatSize(bytes) {
1105
+ if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + " GB";
1106
+ if (bytes >= 1e6) return (bytes / 1e6).toFixed(0) + " MB";
1107
+ if (bytes >= 1e3) return (bytes / 1e3).toFixed(0) + " KB";
1108
+ return bytes + " B";
1109
+ }
1110
+
1111
+ module.exports = { CLI };