helix-assistant-cli 0.2.14 → 0.6.1

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.
@@ -12,12 +12,15 @@ exports.SLASH_COMMANDS = [
12
12
  { command: '/skill', description: 'Listar o inspeccionar skills registradas', usage: '/skill [nombre]' },
13
13
  { command: '/mcp', description: 'Listar servidores MCP y sus herramientas', usage: '/mcp [defaults]' },
14
14
  { command: '/create-mcp', description: 'Registrar y configurar un nuevo servidor MCP' },
15
- { command: '/browser', description: 'Ver estado del navegador web o abrir URLs', usage: '/browser [open <url>]' },
15
+ { command: '/browser', description: 'Ver estado, abrir URLs o alternar la vista ASCII', usage: '/browser [open <url>|live [on|off]]' },
16
+ { command: '/subagents', description: 'Ver el panel ASCII o chatear con un subagente', usage: '/subagents [list|chat <id> <mensaje>]' },
16
17
  { command: '/config-apikey', description: 'Configurador interactivo de API Keys (OpenAI, Anthropic, Gemini, etc.)' },
17
18
  { command: '/show-apikey', description: 'Ver estado y validez de las API Keys configuradas' },
18
19
  { command: '/microphone', description: 'Ver o alternar estado del micrófono', usage: '/microphone [enable|disable]' },
19
20
  { command: '/tasks', description: 'Ver tareas activas y recientes del agente' },
21
+ { command: '/model', description: 'Ver o cambiar el modelo activo (free/local/cloud)', usage: '/model [list|set <id>|effort <low|medium|high>]' },
20
22
  { command: '/models', description: 'Listar modelos disponibles de proveedores configurados' },
23
+ { command: '/memory', description: 'Ver estadísticas del harness de aprendizaje adaptativo', usage: '/memory [stats|rules|reset]' },
21
24
  { command: '/status', description: 'Mostrar resumen del estado del sistema' },
22
25
  { command: '/providers', description: 'Ver proveedores configurados y salud de sus claves' },
23
26
  { command: '/capabilities', description: 'Ver capacidades activas: sandbox, subagentes, voz y browser' },
@@ -107,7 +110,19 @@ class Autocompleter {
107
110
  const hits = options.filter((o) => o.toLowerCase().startsWith(trimmed.toLowerCase()));
108
111
  return [hits.length ? hits : options, line];
109
112
  }
110
- // 3. Browser sub-arguments
113
+ // 3. Model sub-arguments
114
+ if (trimmed.startsWith('/model ')) {
115
+ const options = ['/model list', '/model set ', '/model effort low', '/model effort medium', '/model effort high'];
116
+ const hits = options.filter((o) => o.toLowerCase().startsWith(trimmed.toLowerCase()));
117
+ return [hits.length ? hits : options, line];
118
+ }
119
+ // 3b. Memory sub-arguments
120
+ if (trimmed.startsWith('/memory ')) {
121
+ const options = ['/memory stats', '/memory rules', '/memory reset'];
122
+ const hits = options.filter((o) => o.toLowerCase().startsWith(trimmed.toLowerCase()));
123
+ return [hits.length ? hits : options, line];
124
+ }
125
+ // 4. Browser sub-arguments
111
126
  if (trimmed.startsWith('/browser ')) {
112
127
  const options = ['/browser open https://'];
113
128
  return [options, line];
@@ -0,0 +1,2 @@
1
+ export declare function grayscaleToAscii(pixels: number[][], width?: number): string;
2
+ export declare function renderJpegAsAscii(base64: string, width?: number): string;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.grayscaleToAscii = grayscaleToAscii;
7
+ exports.renderJpegAsAscii = renderJpegAsAscii;
8
+ const jpeg_js_1 = __importDefault(require("jpeg-js"));
9
+ const ASCII_RAMP = ' .:-=+*#%@';
10
+ const MAX_FRAME_WIDTH = 76;
11
+ const MAX_FRAME_HEIGHT = 34;
12
+ const MAX_JPEG_BYTES = 560_000;
13
+ function grayscaleToAscii(pixels, width = MAX_FRAME_WIDTH) {
14
+ if (!pixels.length || !pixels[0]?.length) {
15
+ throw new Error('Browser frame has no pixels to render.');
16
+ }
17
+ const boundedWidth = Math.max(1, Math.min(MAX_FRAME_WIDTH, Math.floor(width)));
18
+ const sourceHeight = pixels.length;
19
+ const sourceWidth = pixels[0].length;
20
+ const outputHeight = Math.max(1, Math.min(MAX_FRAME_HEIGHT, Math.round((sourceHeight / sourceWidth) * boundedWidth * 0.5)));
21
+ const lines = [];
22
+ for (let y = 0; y < outputHeight; y += 1) {
23
+ const sourceY = Math.min(sourceHeight - 1, Math.floor((y / outputHeight) * sourceHeight));
24
+ let line = '';
25
+ for (let x = 0; x < boundedWidth; x += 1) {
26
+ const sourceX = Math.min(sourceWidth - 1, Math.floor((x / boundedWidth) * sourceWidth));
27
+ const value = Math.max(0, Math.min(255, pixels[sourceY][sourceX]));
28
+ const index = Math.round(((255 - value) / 255) * (ASCII_RAMP.length - 1));
29
+ line += ASCII_RAMP[index];
30
+ }
31
+ lines.push(line);
32
+ }
33
+ return lines.join('\n');
34
+ }
35
+ function renderJpegAsAscii(base64, width = MAX_FRAME_WIDTH) {
36
+ if (typeof base64 !== 'string'
37
+ || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)
38
+ || base64.length > Math.ceil(MAX_JPEG_BYTES * 4 / 3)) {
39
+ throw new Error('Browser frame is not a valid, bounded Base64 JPEG.');
40
+ }
41
+ const bytes = Buffer.from(base64, 'base64');
42
+ if (bytes.length === 0 || bytes.length > MAX_JPEG_BYTES) {
43
+ throw new Error('Browser frame exceeds the supported JPEG size.');
44
+ }
45
+ const image = jpeg_js_1.default.decode(bytes, {
46
+ useTArray: true,
47
+ formatAsRGBA: true,
48
+ maxResolutionInMP: 4,
49
+ maxMemoryUsageInMB: 128,
50
+ });
51
+ const pixels = [];
52
+ const outputWidth = Math.min(MAX_FRAME_WIDTH, image.width);
53
+ const outputHeight = Math.max(1, Math.min(MAX_FRAME_HEIGHT, Math.round((image.height / image.width) * outputWidth * 0.5)));
54
+ for (let y = 0; y < outputHeight; y += 1) {
55
+ const row = [];
56
+ const sourceY = Math.min(image.height - 1, Math.floor((y / outputHeight) * image.height));
57
+ for (let x = 0; x < outputWidth; x += 1) {
58
+ const sourceX = Math.min(image.width - 1, Math.floor((x / outputWidth) * image.width));
59
+ const offset = (sourceY * image.width + sourceX) * 4;
60
+ row.push(image.data[offset] * 0.299
61
+ + image.data[offset + 1] * 0.587
62
+ + image.data[offset + 2] * 0.114);
63
+ }
64
+ pixels.push(row);
65
+ }
66
+ return grayscaleToAscii(pixels, Math.min(width, outputWidth));
67
+ }
package/dist/ui/repl.d.ts CHANGED
@@ -17,6 +17,7 @@ export declare class HelixRepl {
17
17
  private activeTasksCount;
18
18
  private isProcessingPrompt;
19
19
  private plain;
20
+ private browserLiveViewEnabled;
20
21
  constructor(options: ReplOptions);
21
22
  start(): Promise<void>;
22
23
  private _syncSettings;
package/dist/ui/repl.js CHANGED
@@ -16,8 +16,12 @@ const skills_1 = require("../commands/skills");
16
16
  const mcp_1 = require("../commands/mcp");
17
17
  const mic_1 = require("../commands/mic");
18
18
  const browser_1 = require("../commands/browser");
19
+ const subagents_1 = require("../commands/subagents");
19
20
  const markdown_1 = require("./markdown");
20
21
  const diagnostics_1 = require("../commands/diagnostics");
22
+ const browser_ascii_1 = require("./browser-ascii");
23
+ const model_1 = require("../commands/model");
24
+ const memory_1 = require("../commands/memory");
21
25
  class HelixRepl {
22
26
  options;
23
27
  rl = null;
@@ -32,6 +36,7 @@ class HelixRepl {
32
36
  activeTasksCount = 0;
33
37
  isProcessingPrompt = false;
34
38
  plain = false;
39
+ browserLiveViewEnabled = false;
35
40
  constructor(options) {
36
41
  this.options = options;
37
42
  this.conversationId = `cli_conv_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
@@ -174,6 +179,23 @@ class HelixRepl {
174
179
  this.activeTaskId = task.task_id || null;
175
180
  }
176
181
  });
182
+ this.client.on('browser_frame', (payload) => {
183
+ if (!this.browserLiveViewEnabled
184
+ || !payload?.conversation_id?.startsWith('cli_')
185
+ || payload.mime_type !== 'image/jpeg'
186
+ || typeof payload.image_base64 !== 'string') {
187
+ return;
188
+ }
189
+ try {
190
+ process.stdout.write('\r\x1b[K');
191
+ console.log(`\n${(0, colors_1.CYAN_BOLD)(' HELIX LIVE BROWSER')}`);
192
+ console.log((0, browser_ascii_1.renderJpegAsAscii)(payload.image_base64));
193
+ this._promptUser();
194
+ }
195
+ catch (error) {
196
+ console.log((0, colors_1.RED)(` No se pudo representar el cuadro del navegador: ${error.message}`));
197
+ }
198
+ });
177
199
  }
178
200
  async _handleConfirmationDialog(req) {
179
201
  console.log(`\n${(0, colors_1.YELLOW_BOLD)('⚠️ CONFIRMACIÓN DE ACCIÓN REQUERIDA')}`);
@@ -343,15 +365,40 @@ class HelixRepl {
343
365
  this.voiceEnabled = newState;
344
366
  break;
345
367
  }
346
- case '/browser':
368
+ case '/browser': {
369
+ const browserArgs = args.trim().split(/\s+/);
370
+ if (browserArgs[0]?.toLowerCase() === 'live') {
371
+ const requestedState = browserArgs[1]?.toLowerCase();
372
+ if (requestedState && !['on', 'off'].includes(requestedState)) {
373
+ console.log((0, colors_1.YELLOW)(' Uso: /browser live [on|off]\n'));
374
+ break;
375
+ }
376
+ this.browserLiveViewEnabled = requestedState
377
+ ? requestedState === 'on'
378
+ : !this.browserLiveViewEnabled;
379
+ console.log(this.browserLiveViewEnabled
380
+ ? (0, colors_1.GREEN)(' Vista ASCII del navegador activada.\n')
381
+ : (0, colors_1.YELLOW)(' Vista ASCII del navegador desactivada.\n'));
382
+ break;
383
+ }
347
384
  await (0, browser_1.handleBrowserCommand)(this.client, args);
348
385
  break;
386
+ }
387
+ case '/subagents':
388
+ await (0, subagents_1.handleSubagentsCommand)(this.client, args);
389
+ break;
349
390
  case '/tasks':
350
391
  await this._handleListTasks();
351
392
  break;
393
+ case '/model':
394
+ await (0, model_1.handleModelCommand)(this.client, args);
395
+ break;
352
396
  case '/models':
353
397
  await this._handleListModels();
354
398
  break;
399
+ case '/memory':
400
+ await (0, memory_1.handleMemoryCommand)(this.client, args);
401
+ break;
355
402
  case '/status':
356
403
  await this._handleStatus();
357
404
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helix-assistant-cli",
3
- "version": "0.2.14",
3
+ "version": "0.6.1",
4
4
  "description": "HELIX - Interactive Terminal CLI for Windows AI Desktop Agent",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -21,10 +21,12 @@
21
21
  "chalk": "^4.1.2",
22
22
  "commander": "^12.0.0",
23
23
  "dotenv": "^16.4.5",
24
+ "jpeg-js": "^0.4.4",
24
25
  "prompts": "^2.4.2",
25
26
  "ws": "^8.16.0"
26
27
  },
27
28
  "devDependencies": {
29
+ "@types/jpeg-js": "^0.3.0",
28
30
  "@types/node": "^20.11.0",
29
31
  "@types/prompts": "^2.4.9",
30
32
  "@types/ws": "^8.5.10",
@@ -14,7 +14,7 @@ export async function handleBrowserCommand(
14
14
  console.log(CYAN(`\n Solicitando al agente abrir en navegador: ${targetUrl}...`));
15
15
  client.sendUserText(
16
16
  `Por favor abre la siguiente URL en el navegador: ${targetUrl}`,
17
- `browser_${Date.now()}`
17
+ `cli_browser_${Date.now()}`
18
18
  );
19
19
  console.log(GREEN(` ✔ Comando enviado al agente.\n`));
20
20
  return;
@@ -32,7 +32,8 @@ export async function handleBrowserCommand(
32
32
  console.log(` Dominios: ${settings.block_untrusted_domains ? YELLOW('Filtrado Estricto') : GRAY('Abierto')}`);
33
33
  console.log(`\n ${DIM('Comandos disponibles:')}`);
34
34
  console.log(` ${CYAN('/browser')} ${DIM('- Ver estado de navegación')}`);
35
- console.log(` ${CYAN('/browser open <url>')} ${DIM('- Abrir URL con el agente')}\n`);
35
+ console.log(` ${CYAN('/browser open <url>')} ${DIM('- Abrir URL con el agente')}`);
36
+ console.log(` ${CYAN('/browser live [on|off]')} ${DIM('- Alternar la vista ASCII de capturas')}\n`);
36
37
  } catch (err) {
37
38
  console.log(RED(` Error al consultar estado del navegador: ${(err as Error).message}\n`));
38
39
  }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * /memory — CLI command for inspecting the Adaptive Learning Harness.
3
+ *
4
+ * Subcommands:
5
+ * /memory — show quick stats summary
6
+ * /memory stats — detailed episode + rule counts
7
+ * /memory rules — list top learned rules
8
+ * /memory reset — run maintenance / pruning cycle
9
+ */
10
+ import chalk from 'chalk';
11
+ import type { AgentClient } from '../client/agent-client';
12
+ import { GREEN, YELLOW, RED, GRAY, DIM, BOLD, ORANGE_BOLD, CYAN } from '../ui/colors';
13
+
14
+ // ── Types ─────────────────────────────────────────────────────────────────────
15
+
16
+ interface LearnedRule {
17
+ category: string;
18
+ rule: string;
19
+ confidence: number;
20
+ uses: number;
21
+ }
22
+
23
+ interface LearningStats {
24
+ episode_count: number;
25
+ rule_count: number;
26
+ correction_count: number;
27
+ skill_count: number;
28
+ top_rules: LearnedRule[];
29
+ last_episode_at?: string;
30
+ user_md_exists: boolean;
31
+ }
32
+
33
+ // ── Request helper ────────────────────────────────────────────────────────────
34
+
35
+ type RequestableClient = AgentClient & {
36
+ request: <T>(type: string, payload: unknown) => Promise<T>;
37
+ };
38
+
39
+ async function fetchLearningStats(client: AgentClient): Promise<LearningStats | null> {
40
+ try {
41
+ return await (client as RequestableClient).request<LearningStats>('GET_LEARNING_STATS', {});
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ // ── Renderers ─────────────────────────────────────────────────────────────────
48
+
49
+ function renderStats(stats: LearningStats): void {
50
+ console.log('');
51
+ console.log(` ${chalk.hex('#FF8C00').bold('⬡ Sistema de Aprendizaje Adaptativo')}`);
52
+ console.log(` ${chalk.gray('─'.repeat(48))}`);
53
+
54
+ const ep = stats.episode_count;
55
+ const ru = stats.rule_count;
56
+ const co = stats.correction_count;
57
+ const sk = stats.skill_count;
58
+
59
+ console.log(` ${chalk.gray('Episodios registrados')} ${chalk.white.bold(ep.toString())}`);
60
+ console.log(` ${chalk.gray('Correcciones del usuario')} ${co > 0 ? chalk.yellow.bold(co.toString()) : chalk.white(co.toString())}`);
61
+ console.log(` ${chalk.gray('Reglas aprendidas')} ${chalk.cyan.bold(ru.toString())}`);
62
+ console.log(` ${chalk.gray('Skills auto-generadas')} ${chalk.magenta.bold(sk.toString())}`);
63
+
64
+ if (stats.last_episode_at) {
65
+ const d = new Date(stats.last_episode_at);
66
+ console.log(` ${chalk.gray('Último episodio')} ${DIM(d.toLocaleString())}`);
67
+ }
68
+
69
+ const userMdStatus = stats.user_md_exists
70
+ ? chalk.green('✔ USER.md activo')
71
+ : chalk.gray('○ Sin perfil generado aún');
72
+ console.log(` ${chalk.gray('Perfil feedforward')} ${userMdStatus}`);
73
+ console.log('');
74
+ }
75
+
76
+ function renderRules(stats: LearningStats): void {
77
+ if (!stats.top_rules || stats.top_rules.length === 0) {
78
+ console.log(GRAY(' No hay reglas aprendidas todavía. Interactúa con HELIX para que aprenda tus preferencias.\n'));
79
+ return;
80
+ }
81
+
82
+ console.log('');
83
+ console.log(` ${chalk.hex('#FF8C00').bold(`✦ Top ${stats.top_rules.length} Reglas Aprendidas`)}`);
84
+ console.log(` ${chalk.gray('─'.repeat(56))}`);
85
+
86
+ for (const rule of stats.top_rules) {
87
+ const conf = Math.round(rule.confidence * 100);
88
+ const bar = '█'.repeat(Math.min(10, Math.floor(conf / 10)));
89
+ const confColor = conf >= 80 ? chalk.green : conf >= 50 ? chalk.yellow : chalk.gray;
90
+ console.log(` ${chalk.cyan(`[${rule.category}]`).padEnd(24)} ${chalk.white(rule.rule)}`);
91
+ console.log(` ${DIM('Confianza:')} ${confColor(`${conf}%`)} ${chalk.gray(bar)} ${DIM(`(${rule.uses} usos)`)}`);
92
+ console.log('');
93
+ }
94
+ }
95
+
96
+ // ── Command handlers ──────────────────────────────────────────────────────────
97
+
98
+ export async function handleMemoryStats(client: AgentClient): Promise<void> {
99
+ const stats = await fetchLearningStats(client);
100
+
101
+ if (!stats) {
102
+ console.log(YELLOW(' ⚠ El harness de aprendizaje no está disponible (agente desconectado o versión antigua).\n'));
103
+ return;
104
+ }
105
+
106
+ renderStats(stats);
107
+ if (stats.top_rules?.length) {
108
+ console.log(DIM(' Usa /memory rules para ver las reglas detalladas.'));
109
+ console.log('');
110
+ }
111
+ }
112
+
113
+ export async function handleMemoryRules(client: AgentClient): Promise<void> {
114
+ const stats = await fetchLearningStats(client);
115
+
116
+ if (!stats) {
117
+ console.log(YELLOW(' ⚠ El harness de aprendizaje no está disponible.\n'));
118
+ return;
119
+ }
120
+
121
+ renderStats(stats);
122
+ renderRules(stats);
123
+ }
124
+
125
+ export async function handleMemoryReset(client: AgentClient): Promise<void> {
126
+ try {
127
+ const result = await (client as RequestableClient).request<{ pruned_rules_count: number; pruned_skills: string[] }>(
128
+ 'RUN_LEARNING_MAINTENANCE',
129
+ {},
130
+ );
131
+
132
+ console.log('');
133
+ console.log(GREEN(' ✔ Mantenimiento del harness completado.'));
134
+ console.log(` ${chalk.gray('Reglas podadas:')} ${chalk.white.bold(String(result.pruned_rules_count ?? 0))}`);
135
+ const pruned = result.pruned_skills ?? [];
136
+ if (pruned.length) {
137
+ console.log(` ${chalk.gray('Skills podadas:')} ${chalk.white(pruned.join(', '))}`);
138
+ } else {
139
+ console.log(` ${chalk.gray('Skills podadas:')} ${DIM('ninguna')}`);
140
+ }
141
+ console.log('');
142
+ } catch (err) {
143
+ console.log(RED(` ✖ Error durante mantenimiento: ${(err as Error).message}\n`));
144
+ }
145
+ }
146
+
147
+ /** Top-level dispatcher for /memory <args> */
148
+ export async function handleMemoryCommand(client: AgentClient, args: string): Promise<void> {
149
+ const sub = args.trim().split(/\s+/)[0]?.toLowerCase() || '';
150
+
151
+ switch (sub) {
152
+ case '':
153
+ case 'stats':
154
+ await handleMemoryStats(client);
155
+ break;
156
+ case 'rules':
157
+ await handleMemoryRules(client);
158
+ break;
159
+ case 'reset':
160
+ case 'maintenance':
161
+ case 'prune':
162
+ await handleMemoryReset(client);
163
+ break;
164
+ default:
165
+ console.log(YELLOW(` Subcomando desconocido: "${sub}". Opciones: stats, rules, reset\n`));
166
+ }
167
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * /model — CLI command for viewing and switching the active AI model.
3
+ *
4
+ * Subcommands:
5
+ * /model — show current model + provider
6
+ * /model list — list all model tiers (Free, Local Ollama, Cloud)
7
+ * /model set <id> — switch to a model by id
8
+ * /model effort <low|medium|high> — set effort/quality level
9
+ */
10
+ import chalk from 'chalk';
11
+ import type { AgentClient } from '../client/agent-client';
12
+ import { CYAN, CYAN_BOLD, GREEN, YELLOW, GRAY, DIM, BOLD, ORANGE_BOLD, RED } from '../ui/colors';
13
+
14
+ // ── Model catalog (mirrors ModelSelectorDropdown.tsx) ──────────────────────────
15
+ export interface ModelEntry {
16
+ id: string;
17
+ label: string;
18
+ tier: 'free' | 'local' | 'cloud';
19
+ provider: string;
20
+ badge?: string;
21
+ }
22
+
23
+ export const FREE_MODELS: ModelEntry[] = [
24
+ {
25
+ id: 'meta-llama/llama-3.2-3b-instruct:free',
26
+ label: 'Llama 3.2 3B Instruct',
27
+ tier: 'free',
28
+ provider: 'free',
29
+ badge: '0-cost',
30
+ },
31
+ {
32
+ id: 'qwen/qwen-2.5-coder-32b-instruct:free',
33
+ label: 'Qwen 2.5 Coder 32B',
34
+ tier: 'free',
35
+ provider: 'free',
36
+ badge: '0-cost',
37
+ },
38
+ {
39
+ id: 'deepseek/deepseek-r1:free',
40
+ label: 'DeepSeek R1',
41
+ tier: 'free',
42
+ provider: 'free',
43
+ badge: '0-cost · razonamiento',
44
+ },
45
+ {
46
+ id: 'google/gemini-flash-1.5:free',
47
+ label: 'Gemini Flash 1.5',
48
+ tier: 'free',
49
+ provider: 'free',
50
+ badge: '0-cost · rápido',
51
+ },
52
+ ];
53
+
54
+ export const LOCAL_MODELS: ModelEntry[] = [
55
+ { id: 'ollama/llama3', label: 'Llama 3 (local)', tier: 'local', provider: 'ollama' },
56
+ { id: 'ollama/mistral', label: 'Mistral 7B (local)', tier: 'local', provider: 'ollama' },
57
+ { id: 'ollama/deepseek-coder', label: 'DeepSeek Coder (local)', tier: 'local', provider: 'ollama' },
58
+ { id: 'ollama/phi3', label: 'Phi-3 Mini (local)', tier: 'local', provider: 'ollama' },
59
+ { id: 'ollama/qwen2.5-coder', label: 'Qwen 2.5 Coder (local)', tier: 'local', provider: 'ollama' },
60
+ ];
61
+
62
+ export const CLOUD_MODELS: ModelEntry[] = [
63
+ { id: 'gpt-4o', label: 'GPT-4o', tier: 'cloud', provider: 'openai' },
64
+ { id: 'gpt-4o-mini', label: 'GPT-4o Mini', tier: 'cloud', provider: 'openai' },
65
+ { id: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet', tier: 'cloud', provider: 'anthropic' },
66
+ { id: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku', tier: 'cloud', provider: 'anthropic' },
67
+ { id: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro', tier: 'cloud', provider: 'gemini' },
68
+ { id: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash', tier: 'cloud', provider: 'gemini' },
69
+ ];
70
+
71
+ export const ALL_MODELS: ModelEntry[] = [...FREE_MODELS, ...LOCAL_MODELS, ...CLOUD_MODELS];
72
+
73
+ // ── Helpers ───────────────────────────────────────────────────────────────────
74
+
75
+ function providerBadge(entry: ModelEntry): string {
76
+ if (entry.tier === 'free') return chalk.green.bold('[0-COST]');
77
+ if (entry.tier === 'local') return chalk.cyan.bold('[LOCAL]');
78
+ return chalk.yellow.bold('[CLOUD]');
79
+ }
80
+
81
+ function findModel(id: string): ModelEntry | undefined {
82
+ return ALL_MODELS.find((m) => m.id === id || m.id.startsWith(id));
83
+ }
84
+
85
+ // ── Command handlers ──────────────────────────────────────────────────────────
86
+
87
+ /** /model — show current model */
88
+ export async function handleModelStatus(client: AgentClient): Promise<void> {
89
+ try {
90
+ const settings = await client.getSettings();
91
+ const modelId: string = (settings as Record<string, string>).default_model || '(none)';
92
+ const provider: string = (settings as Record<string, string>).preferred_provider || '(none)';
93
+ const entry = findModel(modelId);
94
+
95
+ console.log('');
96
+ console.log(` ${chalk.hex('#FF8C00').bold('⬡ Modelo Activo')}`);
97
+ console.log(` ${chalk.gray('─'.repeat(44))}`);
98
+ console.log(` ${chalk.gray('ID')} ${chalk.white.bold(modelId)}`);
99
+ console.log(` ${chalk.gray('Proveedor')} ${chalk.white(provider)}`);
100
+ if (entry) {
101
+ console.log(` ${chalk.gray('Nombre')} ${entry.label}`);
102
+ console.log(` ${chalk.gray('Nivel')} ${providerBadge(entry)} ${entry.badge ? DIM(`(${entry.badge})`) : ''}`);
103
+ }
104
+ const speed: string = (settings as Record<string, string>).speed_preference || 'balanced';
105
+ const quality: string = (settings as Record<string, string>).quality_preference || 'balanced';
106
+ console.log(` ${chalk.gray('Velocidad')} ${chalk.cyan(speed)}`);
107
+ console.log(` ${chalk.gray('Calidad')} ${chalk.cyan(quality)}`);
108
+ console.log('');
109
+ console.log(DIM(' Usa /model list para ver opciones, /model set <id> para cambiar.'));
110
+ console.log('');
111
+ } catch (err) {
112
+ console.log(RED(` Error al obtener modelo: ${(err as Error).message}\n`));
113
+ }
114
+ }
115
+
116
+ /** /model list — print the full model catalog */
117
+ export function handleModelList(): void {
118
+ const printSection = (title: string, models: ModelEntry[]): void => {
119
+ console.log(`\n ${chalk.hex('#FF8C00').bold(title)}`);
120
+ console.log(` ${chalk.gray('─'.repeat(60))}`);
121
+ for (const m of models) {
122
+ const badge = providerBadge(m);
123
+ const extra = m.badge ? DIM(` · ${m.badge}`) : '';
124
+ console.log(` ${badge} ${chalk.white.bold(m.label.padEnd(32))} ${GRAY(m.id)}${extra}`);
125
+ }
126
+ };
127
+
128
+ console.log('');
129
+ console.log(` ${BOLD('🧠 Catálogo de Modelos HELIX')}`);
130
+ printSection('✦ Nivel 1 — Sin costo (OpenRouter Free)', FREE_MODELS);
131
+ printSection('✦ Nivel 2 — Local Ollama (sin internet)', LOCAL_MODELS);
132
+ printSection('✦ Nivel 3 — Nube con API Key propia', CLOUD_MODELS);
133
+ console.log('');
134
+ console.log(DIM(' Usa: /model set <model-id> para activar un modelo'));
135
+ console.log(DIM(' Usa: /model effort <low|medium|high> para ajustar esfuerzo'));
136
+ console.log('');
137
+ }
138
+
139
+ /** /model set <id> — switch the active model */
140
+ export async function handleModelSet(client: AgentClient, modelId: string): Promise<void> {
141
+ if (!modelId) {
142
+ console.log(YELLOW(' Uso: /model set <model-id>\n'));
143
+ return;
144
+ }
145
+
146
+ const entry = findModel(modelId);
147
+ const provider = entry?.provider || (modelId.startsWith('ollama/') ? 'ollama' : 'openai');
148
+
149
+ try {
150
+ await client.saveSettings({
151
+ default_model: entry?.id ?? modelId,
152
+ preferred_provider: provider,
153
+ } as Parameters<typeof client.saveSettings>[0]);
154
+
155
+ const badge = entry ? providerBadge(entry) : '';
156
+ console.log('');
157
+ console.log(GREEN(' ✔ Modelo actualizado correctamente.'));
158
+ console.log(` ${chalk.gray('Modelo:')} ${chalk.white.bold(entry?.id ?? modelId)} ${badge}`);
159
+ console.log(` ${chalk.gray('Proveedor:')} ${chalk.white(provider)}`);
160
+ console.log('');
161
+ } catch (err) {
162
+ console.log(RED(` ✖ Error al cambiar modelo: ${(err as Error).message}\n`));
163
+ }
164
+ }
165
+
166
+ /** /model effort <low|medium|high> — set effort/quality level */
167
+ export async function handleModelEffort(client: AgentClient, level: string): Promise<void> {
168
+ const valid = ['low', 'medium', 'high'];
169
+ const normalized = level?.toLowerCase();
170
+ if (!normalized || !valid.includes(normalized)) {
171
+ console.log(YELLOW(' Uso: /model effort <low|medium|high>\n'));
172
+ return;
173
+ }
174
+
175
+ const speedMap: Record<string, string> = { low: 'fast', medium: 'balanced', high: 'balanced' };
176
+ const qualityMap: Record<string, string> = { low: 'fast', medium: 'balanced', high: 'best' };
177
+
178
+ try {
179
+ await client.saveSettings({
180
+ speed_preference: speedMap[normalized],
181
+ quality_preference: qualityMap[normalized],
182
+ } as Parameters<typeof client.saveSettings>[0]);
183
+
184
+ console.log('');
185
+ console.log(GREEN(` ✔ Nivel de esfuerzo → ${chalk.white.bold(normalized.toUpperCase())}`));
186
+ console.log(DIM(` Velocidad: ${speedMap[normalized]} | Calidad: ${qualityMap[normalized]}`));
187
+ console.log('');
188
+ } catch (err) {
189
+ console.log(RED(` ✖ Error al ajustar esfuerzo: ${(err as Error).message}\n`));
190
+ }
191
+ }
192
+
193
+ /** /model free-list — list free models fetched from bridge (live) */
194
+ export async function handleFreeModelList(client: AgentClient): Promise<void> {
195
+ try {
196
+ const result = await (client as AgentClient & { request: <T>(type: string, payload: unknown) => Promise<T> })
197
+ .request<{ models: Array<{ id: string; label: string; provider: string }> }>('LIST_FREE_MODELS', {});
198
+
199
+ if (!result?.models?.length) {
200
+ // Fallback to static catalog
201
+ handleModelList();
202
+ return;
203
+ }
204
+
205
+ console.log(`\n ${BOLD('✦ Modelos gratuitos disponibles (live):')} \n`);
206
+ for (const m of result.models) {
207
+ console.log(` ${GREEN('[0-COST]')} ${chalk.white.bold(m.label)} ${GRAY(m.id)}`);
208
+ }
209
+ console.log('');
210
+ } catch {
211
+ // Agent not connected or handler not present — fall back to static
212
+ handleModelList();
213
+ }
214
+ }
215
+
216
+ /** Top-level dispatcher for /model <args> */
217
+ export async function handleModelCommand(client: AgentClient, args: string): Promise<void> {
218
+ const parts = args.trim().split(/\s+/);
219
+ const sub = parts[0]?.toLowerCase() || '';
220
+
221
+ switch (sub) {
222
+ case '':
223
+ await handleModelStatus(client);
224
+ break;
225
+ case 'list':
226
+ handleModelList();
227
+ break;
228
+ case 'set':
229
+ await handleModelSet(client, parts.slice(1).join(' '));
230
+ break;
231
+ case 'effort':
232
+ await handleModelEffort(client, parts[1] || '');
233
+ break;
234
+ default:
235
+ console.log(YELLOW(` Subcomando desconocido: "${sub}". Opciones: list, set, effort\n`));
236
+ }
237
+ }