@danieltmn/tamnora 0.1.0 → 0.2.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 (51) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/harness/packages/cli/src/cli.js +2 -0
  3. package/harness/packages/cli/src/commands/eval.js +3 -0
  4. package/harness/packages/cli/src/commands/repl.js +2 -0
  5. package/harness/packages/cli/src/commands/run.js +2 -1
  6. package/harness/packages/cli/src/commands/tui.js +18 -11
  7. package/harness/packages/cli/src/lib/runtime.js +3 -1
  8. package/harness/packages/cli/src/lib/tunnel.js +2 -113
  9. package/harness/packages/cli/src/lib/util.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +45 -4
  11. package/harness/packages/cli/src/tui/keys.js +3 -0
  12. package/harness/packages/cli/test/tui.test.js +5 -0
  13. package/harness/packages/core/src/agent/modes.js +1 -1
  14. package/harness/packages/core/src/config/index.js +3 -0
  15. package/harness/packages/core/src/console.js +18 -0
  16. package/harness/packages/core/src/eval/judge.js +3 -3
  17. package/harness/packages/core/src/eval/runner.js +3 -0
  18. package/harness/packages/core/src/index.js +4 -0
  19. package/harness/packages/core/src/procs/detect.js +69 -0
  20. package/harness/packages/core/src/providers/ollama.js +3 -3
  21. package/harness/packages/core/src/providers/openaiCompatible.js +3 -3
  22. package/harness/packages/core/src/providers/openrouter.js +2 -2
  23. package/harness/packages/core/src/providers/resilience.js +1 -1
  24. package/harness/packages/core/src/providers/utils.js +43 -0
  25. package/harness/packages/core/src/skills/index.js +72 -0
  26. package/harness/packages/core/src/storage/sqlite.js +21 -0
  27. package/harness/packages/core/src/tunnel.js +113 -0
  28. package/harness/packages/core/test/config.test.js +15 -0
  29. package/harness/packages/core/test/procs.test.js +34 -0
  30. package/harness/packages/core/test/providers.test.js +20 -0
  31. package/harness/packages/core/test/resilience.test.js +1 -0
  32. package/harness/packages/core/test/skills.test.js +49 -0
  33. package/harness/packages/server/public/app.js +440 -6
  34. package/harness/packages/server/public/index.html +124 -6
  35. package/harness/packages/server/public/styles.css +43 -1
  36. package/harness/packages/server/src/cli.js +2 -0
  37. package/harness/packages/server/src/lib/agentTools.js +159 -0
  38. package/harness/packages/server/src/lib/procs.js +125 -0
  39. package/harness/packages/server/src/lib/prompt.js +6 -3
  40. package/harness/packages/server/src/lib/skills.js +69 -0
  41. package/harness/packages/server/src/lib/telegram.js +267 -0
  42. package/harness/packages/server/src/routes/chat.js +9 -1
  43. package/harness/packages/server/src/routes/mcp.js +71 -8
  44. package/harness/packages/server/src/routes/preview.js +58 -0
  45. package/harness/packages/server/src/routes/procs.js +47 -0
  46. package/harness/packages/server/src/routes/sessions.js +10 -0
  47. package/harness/packages/server/src/routes/skills.js +61 -0
  48. package/harness/packages/server/src/server.js +79 -6
  49. package/harness/packages/server/test/server.test.js +227 -0
  50. package/harness/tamnora.example.json +7 -1
  51. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,53 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.2.0] - 2026-10-02
9
+
10
+ ### Agregado
11
+
12
+ - Consola: eliminar sesiones (menu por sesion) y proyectos (borra las sesiones
13
+ de esa carpeta, nunca los archivos). `GET/DELETE /api/projects` y
14
+ `deleteSessionsByCwd`/`listProjects` en el Store.
15
+ - Atajo `Ctrl+.` para alternar **Plan / Build** en la consola y la TUI (el modo
16
+ `agent` pasa a mostrarse como "Build"); en la TUI tambien `/modo`.
17
+ - Apartado **ajustes** en la consola: alta/baja/edicion de **servidores MCP**
18
+ (se combinan con `mcp[]` de tamnora.json y se recargan al guardar) y de
19
+ **skills** (`<casa>/skills/<slug>/SKILL.md`), con habilitar/deshabilitar. Las
20
+ skills habilitadas se inyectan al system prompt.
21
+ - Tools de agente para el chat: `list_mcp`, `install_mcp`, `remove_mcp`,
22
+ `list_skills` e `install_skill` (contenido o URL). Instalar/eliminar piden
23
+ aprobacion (permiso `ask`); en modo plan no se exponen.
24
+ - **Preview** de la app en desarrollo y **procs**: deteccion de comandos de dev
25
+ (`package.json`, composer/artisan, PHP), arranque/parada de procesos con lista
26
+ blanca (`processes.allow`) y su log. Preview local (`127.0.0.1:<puerto>`) o
27
+ remoto con un **tunel** aparte (`POST /api/preview/tunnel`).
28
+ - **Telegram**: bot por long polling (`telegram.token`/`allowedChatIds` o
29
+ `TAMNORA_TELEGRAM_TOKEN`) que mapea cada chat a una sesion y reenvia al agente
30
+ con streaming; comandos `/new`, `/status`, `/model`, `/provider`, `/mode`,
31
+ `/stop`, `/help`.
32
+ - `startTunnel`/`parseTunnelmoleUrls` movidos al core (los usa el server y el CLI).
33
+
34
+ ### Arreglado
35
+
36
+ - La consola en Windows fuerza UTF-8 (`chcp 65001`) al arrancar, para que no se
37
+ pierdan acentos ni la ñ. Nuevo `document.md` con la referencia completa.
38
+ - Los proveedores custom ya se resuelven bien en la CLI, la TUI y la evaluacion:
39
+ antes `runTurn`/`judge`/`runDataset` no pasaban `customProviders`, el adapter
40
+ caia al de OpenRouter e ignoraba el `baseUrl` (un provider propio terminaba
41
+ pegandole a `openrouter.ai`).
42
+ - Los fallos de red al llamar a un proveedor muestran
43
+ `No se pudo conectar con <host>...` en vez del crudo `fetch failed` (el error
44
+ conserva el `code` para seguir siendo reintentable).
45
+
46
+ ### Pendiente
47
+
48
+ - Publicar el fix de proveedores custom como patch (`npm run release -- patch`)
49
+ para que `npm i -g @danieltmn/tamnora` lo incluya; la 0.1.0 publicada todavia
50
+ tiene el bug.
51
+ - Confirmar el proveedor Xiaomi (`mimo-v2.6-flash`): en la prueba respondio
52
+ `429 Too Many Requests` (cuota/rate limit del endpoint), no del harness.
53
+ - Cerrar la revision del sitio en `website/` (contenido ya alineado al harness).
54
+
8
55
  ## [0.1.0] - 2026-10-01
9
56
 
10
57
  ### Agregado
@@ -14,6 +14,7 @@ import { updateCommand } from './commands/update.js';
14
14
  import { autostartCommand } from './commands/autostart.js';
15
15
  import { doctorCommand } from './commands/doctor.js';
16
16
  import { tuiCommand } from './commands/tui.js';
17
+ import { ensureUtf8Console } from './lib/util.js';
17
18
  import { color } from './ui/color.js';
18
19
 
19
20
  const COMMANDS = {
@@ -43,6 +44,7 @@ function readVersion() {
43
44
  }
44
45
 
45
46
  export async function runCli(argv) {
47
+ ensureUtf8Console();
46
48
  const [command, ...rest] = argv;
47
49
 
48
50
  if (command === '-h' || command === '--help' || command === 'help') {
@@ -62,6 +62,7 @@ export async function evalCommand(argv) {
62
62
  const providerId = resolved.providerId;
63
63
  const baseUrl = resolved.baseUrl;
64
64
  const apiKey = resolved.apiKey;
65
+ const customProviders = resolved.customProviders;
65
66
  const models = (values.model?.length ? values.model : [resolved.model]).filter(Boolean);
66
67
  if (!models.length) {
67
68
  console.error(color.red('Falta el modelo (usá --model, Ajustes o "model" en tamnora.json).'));
@@ -74,6 +75,7 @@ export async function evalCommand(argv) {
74
75
  model: values['judge-model'] || config.judgeModel,
75
76
  baseUrl,
76
77
  apiKey,
78
+ customProviders,
77
79
  });
78
80
 
79
81
  const enabledTools = values['no-tools']
@@ -104,6 +106,7 @@ export async function evalCommand(argv) {
104
106
  providerId,
105
107
  baseUrl,
106
108
  apiKey,
109
+ customProviders,
107
110
  systemPrompt,
108
111
  rootDir: cwd,
109
112
  trials,
@@ -34,6 +34,7 @@ export async function replCommand(argv) {
34
34
  let model = resolved.model;
35
35
  const baseUrl = resolved.baseUrl;
36
36
  const apiKey = resolved.apiKey;
37
+ const customProviders = resolved.customProviders;
37
38
  if (!model) {
38
39
  console.error(color.red('Falta el modelo (usá --model, Ajustes o "model" en tamnora.json).'));
39
40
  store.close();
@@ -140,6 +141,7 @@ export async function replCommand(argv) {
140
141
  model,
141
142
  baseUrl,
142
143
  apiKey,
144
+ customProviders,
143
145
  systemPrompt,
144
146
  history,
145
147
  input: text,
@@ -36,7 +36,7 @@ export async function runCommand(argv) {
36
36
  const config = await loadConfig(cwd);
37
37
  const instructions = await loadInstructions(cwd, config.instructions);
38
38
  const store = new Store(databasePath(cwd));
39
- const { providerId, model, baseUrl, apiKey } = resolveCliProvider(store, config, {
39
+ const { providerId, model, baseUrl, apiKey, customProviders } = resolveCliProvider(store, config, {
40
40
  provider: values.provider,
41
41
  model: values.model,
42
42
  });
@@ -101,6 +101,7 @@ export async function runCommand(argv) {
101
101
  model,
102
102
  baseUrl,
103
103
  apiKey,
104
+ customProviders,
104
105
  systemPrompt: buildSystemPrompt(instructions, values.system),
105
106
  history,
106
107
  input: prompt,
@@ -28,7 +28,7 @@ export async function tuiCommand(argv) {
28
28
  const cwd = values.dir ? path.resolve(values.dir) : process.cwd();
29
29
  const config = await loadConfig(cwd);
30
30
  const store = new Store(databasePath(cwd));
31
- const { providerId, model, baseUrl, apiKey } = resolveCliProvider(store, config, {
31
+ const { providerId, model, baseUrl, apiKey, customProviders } = resolveCliProvider(store, config, {
32
32
  provider: values.provider,
33
33
  model: values.model,
34
34
  });
@@ -63,11 +63,14 @@ export async function tuiCommand(argv) {
63
63
  : undefined;
64
64
 
65
65
  const mode = resolveMode(values.mode || store.getSetting('activeMode') || 'agent');
66
- const enabled = mode.tools
67
- ? baseEnabled
68
- ? baseEnabled.filter((tool) => mode.tools.includes(tool))
69
- : mode.tools
70
- : baseEnabled;
66
+ const enabledFor = (m) =>
67
+ m.tools ? (baseEnabled ? baseEnabled.filter((tool) => m.tools.includes(tool)) : m.tools) : baseEnabled;
68
+ const promptFor = (m) =>
69
+ buildSystemPrompt(instructions, [values.system, m.systemPrompt].filter(Boolean).join('\n\n'));
70
+ const modeContext = (id) => {
71
+ const m = resolveMode(id);
72
+ return { label: m.label, enabled: enabledFor(m), systemPrompt: promptFor(m) };
73
+ };
71
74
 
72
75
  const sessionId = store.createSession({ title: 'Consola', provider: providerId, model, cwd });
73
76
 
@@ -76,11 +79,15 @@ export async function tuiCommand(argv) {
76
79
  model,
77
80
  baseUrl,
78
81
  apiKey,
79
- systemPrompt: buildSystemPrompt(instructions, [values.system, mode.systemPrompt].filter(Boolean).join('\n\n')),
82
+ customProviders,
83
+ modeId: mode.id,
84
+ modeContext,
85
+ onModeChange: (id) => store.setSetting('activeMode', id),
86
+ systemPrompt: promptFor(mode),
80
87
  maxRounds: num(values['max-steps'], config.maxRounds),
81
88
  toolOptions: {
82
89
  root: cwd,
83
- enabled,
90
+ enabled: enabledFor(mode),
84
91
  permissions: config.permissions,
85
92
  mcp,
86
93
  yes: Boolean(values.yes),
@@ -95,9 +102,9 @@ function printHelp() {
95
102
 
96
103
  Consola full-screen (estilo opencode/codex). Atajos:
97
104
  enter enviar ↑ / ↓ historial
98
- ← → mover cursor home/end inicio / fin
99
- pgup/pgdn scroll ctrl+l limpiar
100
- ctrl+c cancelar / salir /salir /limpiar
105
+ ← → mover cursor home/end inicio / fin
106
+ pgup/pgdn scroll ctrl+l limpiar
107
+ ctrl+. alternar Plan / Build /modo /salir /limpiar
101
108
 
102
109
  Opciones:
103
110
  --provider <id> Proveedor (por defecto: el activo)
@@ -22,6 +22,7 @@ export function resolveCliProvider(store, config, { provider, model } = {}) {
22
22
  model: activeModel,
23
23
  baseUrl: settings.baseUrl,
24
24
  apiKey: resolveApiKey(settings.apiKey, process.env),
25
+ customProviders,
25
26
  settings,
26
27
  };
27
28
  }
@@ -46,6 +47,7 @@ export async function runTurn({
46
47
  model,
47
48
  baseUrl,
48
49
  apiKey,
50
+ customProviders = [],
49
51
  systemPrompt,
50
52
  history = [],
51
53
  input,
@@ -57,7 +59,7 @@ export async function runTurn({
57
59
  toolContext = {},
58
60
  onEvent,
59
61
  }) {
60
- const { provider, config } = resolveProviderRuntime({ providerId, apiKey, baseUrl });
62
+ const { provider, config } = resolveProviderRuntime({ providerId, apiKey, baseUrl, customProviders });
61
63
 
62
64
  const messages = [
63
65
  { role: 'system', content: systemPrompt },
@@ -1,113 +1,2 @@
1
- import { spawn } from 'node:child_process';
2
-
3
- /**
4
- * Tuneles publicos. v1 soporta tunnelmole (gratis, sin cuenta; URL aleatoria)
5
- * via `npx --yes tunnelmole <puerto>`.
6
- */
7
-
8
- function killTree(child) {
9
- if (!child || child.killed) return;
10
- const pid = child.pid;
11
- try {
12
- if (process.platform === 'win32' && pid) {
13
- spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
14
- } else {
15
- if (pid) {
16
- try {
17
- process.kill(-pid, 'SIGTERM');
18
- } catch {
19
- /* sin grupo */
20
- }
21
- }
22
- try {
23
- child.kill('SIGTERM');
24
- } catch {
25
- /* ya murio */
26
- }
27
- }
28
- } catch {
29
- /* ya murio */
30
- }
31
- }
32
-
33
- export function parseTunnelmoleUrls(text) {
34
- const urls = [...new Set(String(text).match(/https?:\/\/[a-z0-9-]+\.tunnelmole\.net/gi) || [])];
35
- return {
36
- https: urls.find((u) => u.startsWith('https')) || '',
37
- http: urls.find((u) => u.startsWith('http:')) || '',
38
- };
39
- }
40
-
41
- function spawnTunnel(bin, args, opts = {}) {
42
- const log = opts.log || (() => {});
43
- const parse = opts.parse || (() => ({ https: '', http: '' }));
44
- const timeoutMs = opts.timeoutMs || 90000;
45
- const label = opts.label || bin;
46
- return new Promise((resolve, reject) => {
47
- let child;
48
- try {
49
- child = spawn(bin, args, {
50
- stdio: ['ignore', 'pipe', 'pipe'],
51
- windowsHide: true,
52
- shell: process.platform === 'win32',
53
- detached: process.platform !== 'win32',
54
- });
55
- } catch (err) {
56
- reject(new Error(`no se pudo iniciar ${label}: ${err.message}`));
57
- return;
58
- }
59
- let buf = '';
60
- let settled = false;
61
- const timer = setTimeout(() => {
62
- if (settled) return;
63
- settled = true;
64
- killTree(child);
65
- reject(new Error(`${label} no devolvio URL (${Math.round(timeoutMs / 1000)} s).`));
66
- }, timeoutMs);
67
- const feed = (data) => {
68
- buf += String(data);
69
- if (opts.verbose) process.stdout.write(String(data));
70
- const urls = parse(buf);
71
- const url = urls.https || urls.http;
72
- if (url) {
73
- if (settled) return;
74
- settled = true;
75
- clearTimeout(timer);
76
- log(`${label}: ${url} -> localhost:${opts.port}`);
77
- resolve({ url, provider: opts.provider, pid: child.pid, stop: () => killTree(child) });
78
- }
79
- };
80
- child.stdout.on('data', feed);
81
- child.stderr.on('data', feed);
82
- child.on('error', (err) => {
83
- if (settled) return;
84
- settled = true;
85
- clearTimeout(timer);
86
- reject(new Error(`${label}: ${err.message}`));
87
- });
88
- child.on('exit', (code) => {
89
- if (settled) return;
90
- settled = true;
91
- clearTimeout(timer);
92
- reject(new Error(`${label} termino (codigo ${code})${buf ? `: ${buf.slice(0, 200).trim()}` : ''}`));
93
- });
94
- });
95
- }
96
-
97
- export function startTunnelmole(port, opts = {}) {
98
- return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
99
- ...opts,
100
- port,
101
- provider: 'tunnelmole',
102
- label: 'tunnelmole',
103
- parse: parseTunnelmoleUrls,
104
- });
105
- }
106
-
107
- /** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
108
- export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
109
- const p = String(provider || 'tunnelmole').toLowerCase();
110
- if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
111
- if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
112
- throw new Error(`proveedor de tunel no soportado: ${provider}`);
113
- }
1
+ // Movido al core para que tambien lo use el server; se mantiene el import.
2
+ export * from '../../../core/src/tunnel.js';
@@ -22,3 +22,5 @@ export function num(value, fallback) {
22
22
  const n = Number(value);
23
23
  return Number.isFinite(n) ? n : fallback;
24
24
  }
25
+
26
+ export { ensureUtf8Console } from '../../../core/src/index.js';
@@ -12,12 +12,24 @@ export function runTui({
12
12
  model,
13
13
  baseUrl,
14
14
  apiKey,
15
+ customProviders = [],
16
+ modeId = 'agent',
17
+ modeContext,
18
+ onModeChange,
15
19
  systemPrompt,
16
20
  maxRounds,
17
21
  toolOptions,
18
22
  store,
19
23
  sessionId,
20
24
  }) {
25
+ let mode = modeId;
26
+ const readMode = (id) =>
27
+ modeContext
28
+ ? modeContext(id)
29
+ : { systemPrompt, enabled: toolOptions.enabled, label: id };
30
+ let activeMode = readMode(mode);
31
+ let modePrompt = activeMode.systemPrompt ?? systemPrompt;
32
+ let modeEnabled = activeMode.enabled;
21
33
  const state = {
22
34
  header: { provider: providerId, model, cwd: toolOptions.root },
23
35
  messages: [],
@@ -59,7 +71,23 @@ export function runTui({
59
71
  draw();
60
72
  });
61
73
 
62
- const { definitions, execute } = createTools({ ...toolOptions, onAsk });
74
+ const buildTools = () => createTools({ ...toolOptions, enabled: modeEnabled, onAsk });
75
+ let toolset = buildTools();
76
+
77
+ function setMode(id) {
78
+ mode = id;
79
+ activeMode = readMode(id);
80
+ modePrompt = activeMode.systemPrompt ?? systemPrompt;
81
+ modeEnabled = activeMode.enabled;
82
+ toolset = buildTools();
83
+ onModeChange?.(id);
84
+ }
85
+
86
+ function toggleMode() {
87
+ setMode(mode === 'plan' ? 'agent' : 'plan');
88
+ addMessage('assistant', `Modo ${activeMode.label ?? mode}${mode === 'plan' ? ' (solo lectura)' : ''}.`);
89
+ draw();
90
+ }
63
91
 
64
92
  function draw() {
65
93
  if (closed) return;
@@ -104,6 +132,15 @@ export function runTui({
104
132
  const text = state.input.trim();
105
133
  if (!text) return;
106
134
  if (text === '/salir' || text === '/exit' || text === '/quit') return shutdown(0);
135
+ if (text === '/modo' || text === '/mode') return toggleMode();
136
+ const modeArg = text.match(/^\/(?:modo|mode)\s+(plan|agent|build)$/i);
137
+ if (modeArg) {
138
+ const id = modeArg[1].toLowerCase() === 'build' ? 'agent' : modeArg[1].toLowerCase();
139
+ setMode(id);
140
+ addMessage('assistant', `Modo ${activeMode.label ?? id}.`);
141
+ draw();
142
+ return;
143
+ }
107
144
  if (text === '/limpiar' || text === '/clear') {
108
145
  state.input = '';
109
146
  state.messages = [];
@@ -128,13 +165,14 @@ export function runTui({
128
165
  model,
129
166
  baseUrl,
130
167
  apiKey,
131
- systemPrompt,
168
+ customProviders,
169
+ systemPrompt: modePrompt,
132
170
  history: conv,
133
171
  input: text,
134
172
  maxRounds,
135
173
  signal: controller.signal,
136
- tools: definitions,
137
- execute,
174
+ tools: toolset.definitions,
175
+ execute: toolset.execute,
138
176
  onEvent(event) {
139
177
  if (event.type === 'text') {
140
178
  sawText = true;
@@ -243,6 +281,9 @@ export function runTui({
243
281
  case 'clear':
244
282
  state.messages = [];
245
283
  break;
284
+ case 'toggle-mode':
285
+ toggleMode();
286
+ return;
246
287
  case 'enter':
247
288
  submit();
248
289
  return;
@@ -18,6 +18,9 @@ const ESCAPE_ACTIONS = {
18
18
  '\x1b[3~': 'delete',
19
19
  '\x1b[5~': 'pageup',
20
20
  '\x1b[6~': 'pagedown',
21
+ // Ctrl+. (Kitty keyboard protocol / xterm modifyOtherKeys)
22
+ '\x1b[46;5u': 'toggle-mode',
23
+ '\x1b[27;5;46~': 'toggle-mode',
21
24
  };
22
25
 
23
26
  const FINAL_BYTE = /[@-~]/;
@@ -30,6 +30,11 @@ test('parseKeys handles editing and navigation keys', () => {
30
30
  assert.deepEqual(parseKeys('\x05'), [{ type: 'end' }]);
31
31
  });
32
32
 
33
+ test('parseKeys maps Ctrl+. (Plan/Build) from kitty and xterm sequences', () => {
34
+ assert.deepEqual(parseKeys('\x1b[46;5u'), [{ type: 'toggle-mode' }]);
35
+ assert.deepEqual(parseKeys('\x1b[27;5;46~'), [{ type: 'toggle-mode' }]);
36
+ });
37
+
33
38
  test('parseKeys reads bracketed paste as a single action', () => {
34
39
  assert.deepEqual(parseKeys('\x1b[200~hola mundo\x1b[201~'), [{ type: 'paste', value: 'hola mundo' }]);
35
40
  assert.deepEqual(parseKeys('\x1b[200~a\nb\x1b[201~'), [{ type: 'paste', value: 'a\nb' }]);
@@ -4,7 +4,7 @@
4
4
  export const MODES = {
5
5
  agent: {
6
6
  id: 'agent',
7
- label: 'Agent',
7
+ label: 'Build',
8
8
  description: 'Lee, escribe y ejecuta comandos en el workspace.',
9
9
  tools: null,
10
10
  systemPrompt: '',
@@ -12,6 +12,9 @@ export const DEFAULT_CONFIG = {
12
12
  permissions: {},
13
13
  mcp: [],
14
14
  server: { host: '127.0.0.1', port: 4517 },
15
+ processes: { enabled: true, allow: [] },
16
+ preview: { provider: 'tunnelmole' },
17
+ telegram: { token: '', allowedChatIds: [] },
15
18
  };
16
19
 
17
20
  /** Directory (relative to the project) where harness state lived historically. */
@@ -0,0 +1,18 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ let utf8Ready = false;
4
+
5
+ /**
6
+ * En Windows la consola suele arrancar en cp850/cp437 y los acentos y la ñ se
7
+ * pierden. Cambia el code page a UTF-8 (65001) una sola vez, solo en TTY.
8
+ */
9
+ export function ensureUtf8Console() {
10
+ if (utf8Ready || process.platform !== 'win32') return;
11
+ utf8Ready = true;
12
+ if (!process.stdout.isTTY) return;
13
+ try {
14
+ spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true });
15
+ } catch {
16
+ /* no pasa nada si no esta disponible */
17
+ }
18
+ }
@@ -6,11 +6,11 @@ import { extractJson } from './scorers.js';
6
6
  * The judge model receives the criteria and the candidate output and must
7
7
  * reply with JSON: { "passed": boolean, "reason": string }.
8
8
  *
9
- * @param {{providerId?:string, model?:string, baseUrl?:string, apiKey?:string}} opts
9
+ * @param {{providerId?:string, model?:string, baseUrl?:string, apiKey?:string, customProviders?:object[]}} opts
10
10
  */
11
- export function createJudge({ providerId, model, baseUrl, apiKey } = {}) {
11
+ export function createJudge({ providerId, model, baseUrl, apiKey, customProviders = [] } = {}) {
12
12
  if (!model) return null;
13
- const { provider, config } = resolveProviderRuntime({ providerId, apiKey, baseUrl });
13
+ const { provider, config } = resolveProviderRuntime({ providerId, apiKey, baseUrl, customProviders });
14
14
 
15
15
  return async ({ criteria, output, input }) => {
16
16
  const prompt =
@@ -53,6 +53,7 @@ async function pool(items, limit, worker) {
53
53
  * @param {string} opts.providerId
54
54
  * @param {string} [opts.baseUrl]
55
55
  * @param {string} [opts.apiKey]
56
+ * @param {object[]} [opts.customProviders]
56
57
  * @param {string} [opts.systemPrompt]
57
58
  * @param {string} [opts.rootDir] - base dir for task fixtures
58
59
  * @param {number} [opts.trials]
@@ -72,6 +73,7 @@ export async function runDataset({
72
73
  providerId,
73
74
  baseUrl,
74
75
  apiKey,
76
+ customProviders = [],
75
77
  systemPrompt,
76
78
  rootDir = process.cwd(),
77
79
  trials = 1,
@@ -107,6 +109,7 @@ export async function runDataset({
107
109
  model,
108
110
  baseUrl: task.baseUrl ?? baseUrl,
109
111
  apiKey: task.apiKey ?? apiKey,
112
+ customProviders: task.customProviders ?? customProviders,
110
113
  systemPrompt: task.systemPrompt || systemPrompt,
111
114
  tools: tools.definitions.length ? tools.definitions : undefined,
112
115
  },
@@ -1,4 +1,5 @@
1
1
  export { getEnv, envValue } from './env.js';
2
+ export { ensureUtf8Console } from './console.js';
2
3
  export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
3
4
  export { estimateTokens, trimContext } from './agent/context.js';
4
5
  export { diffLines } from './tools/diff.js';
@@ -70,3 +71,6 @@ export { summarize } from './eval/report.js';
70
71
  export { runScorers, validateSchema, extractJson, SCORERS } from './eval/scorers.js';
71
72
  export { createJudge } from './eval/judge.js';
72
73
  export { createTaskWorkspace, normalizeExpect } from './eval/task.js';
74
+ export { parseSkillMarkdown, loadSkills, mergeSkills, buildSkillsPrompt } from './skills/index.js';
75
+ export { parseTunnelmoleUrls, startTunnelmole, startTunnel } from './tunnel.js';
76
+ export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from './procs/detect.js';
@@ -0,0 +1,69 @@
1
+ // Deteccion de como correr el proyecto en desarrollo (dev server) a partir de
2
+ // package.json, composer/artisan y entrypoints PHP.
3
+
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ export const DEFAULT_PROCESS_ALLOW = [
8
+ 'npm',
9
+ 'node',
10
+ 'npx',
11
+ 'pnpm',
12
+ 'yarn',
13
+ 'bun',
14
+ 'bunx',
15
+ 'php',
16
+ 'python',
17
+ 'python3',
18
+ 'composer',
19
+ ];
20
+
21
+ const SCRIPT_ORDER = ['dev', 'start', 'serve', 'preview'];
22
+
23
+ function readJson(file) {
24
+ try {
25
+ return JSON.parse(readFileSync(file, 'utf8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** Package manager por `packageManager` o por lockfile (default npm). */
32
+ export function packageManager(dir) {
33
+ const pkg = readJson(path.join(dir, 'package.json'));
34
+ if (pkg?.packageManager) return String(pkg.packageManager).split('@')[0];
35
+ if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
36
+ if (existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
37
+ if (existsSync(path.join(dir, 'bun.lockb')) || existsSync(path.join(dir, 'bun.lock'))) return 'bun';
38
+ return 'npm';
39
+ }
40
+
41
+ /** Devuelve chips `{ label, command, args, cwd }` para arrancar el proyecto. */
42
+ export function detectProcs(dir) {
43
+ const chips = [];
44
+ const pkg = readJson(path.join(dir, 'package.json'));
45
+ if (pkg?.scripts && typeof pkg.scripts === 'object') {
46
+ const pm = packageManager(dir);
47
+ const names = SCRIPT_ORDER.filter((name) => pkg.scripts[name]);
48
+ if (!names.length) {
49
+ const first = Object.keys(pkg.scripts)[0];
50
+ if (first) names.push(first);
51
+ }
52
+ for (const name of names) chips.push({ label: `${name} (${pm})`, command: pm, args: ['run', name], cwd: dir });
53
+ }
54
+
55
+ if (existsSync(path.join(dir, 'artisan'))) {
56
+ chips.push({ label: 'artisan serve', command: 'php', args: ['artisan', 'serve'], cwd: dir });
57
+ } else if (readJson(path.join(dir, 'composer.json'))?.scripts?.dev) {
58
+ chips.push({ label: 'composer dev', command: 'composer', args: ['run', 'dev'], cwd: dir });
59
+ }
60
+
61
+ for (const rel of ['public/index.php', 'backend/public/index.php', 'index.php']) {
62
+ if (!existsSync(path.join(dir, rel))) continue;
63
+ const docroot = path.dirname(rel) === '.' ? '.' : path.dirname(rel);
64
+ chips.push({ label: `php -S (${rel})`, command: 'php', args: ['-S', '127.0.0.1:8000', '-t', docroot], cwd: dir });
65
+ break;
66
+ }
67
+
68
+ return chips;
69
+ }
@@ -1,5 +1,5 @@
1
1
  import { readNDJSON } from './stream.js';
2
- import { joinUrl, providerError, sortModels } from './utils.js';
2
+ import { joinUrl, providerError, providerFetch, sortModels } from './utils.js';
3
3
 
4
4
  const OLLAMA_BASE_URL = 'http://localhost:11434';
5
5
 
@@ -46,7 +46,7 @@ export const ollamaProvider = {
46
46
  requiresKey: false,
47
47
 
48
48
  async listModels({ baseUrl }) {
49
- const res = await fetch(joinUrl(baseUrl, '/api/tags'));
49
+ const res = await providerFetch(joinUrl(baseUrl, '/api/tags'));
50
50
  if (!res.ok) throw new Error(`No se pudo conectar con Ollama (HTTP ${res.status})`);
51
51
  const data = await res.json();
52
52
  return sortModels((data.models ?? []).map(toOllamaModel).filter((m) => m.id));
@@ -56,7 +56,7 @@ export const ollamaProvider = {
56
56
  const body = { model, messages: toOllamaMessages(messages), stream: true };
57
57
  if (tools?.length) body.tools = tools;
58
58
 
59
- const res = await fetch(joinUrl(baseUrl, '/api/chat'), {
59
+ const res = await providerFetch(joinUrl(baseUrl, '/api/chat'), {
60
60
  method: 'POST',
61
61
  signal,
62
62
  headers: { 'Content-Type': 'application/json' },
@@ -1,5 +1,5 @@
1
1
  import { readOpenAISSE } from './stream.js';
2
- import { joinUrl, authHeaders, providerError, normalizeModel, sortModels } from './utils.js';
2
+ import { joinUrl, authHeaders, providerError, providerFetch, normalizeModel, sortModels } from './utils.js';
3
3
 
4
4
  /**
5
5
  * Generic adapter for any OpenAI-compatible chat completions API
@@ -12,7 +12,7 @@ export const openaiCompatibleProvider = {
12
12
  requiresKey: true,
13
13
 
14
14
  async listModels({ baseUrl, apiKey, headers }) {
15
- const res = await fetch(joinUrl(baseUrl, '/models'), {
15
+ const res = await providerFetch(joinUrl(baseUrl, '/models'), {
16
16
  headers: authHeaders({ apiKey, headers }),
17
17
  });
18
18
  if (!res.ok) throw new Error(`No se pudo listar modelos (HTTP ${res.status})`);
@@ -38,7 +38,7 @@ export const openaiCompatibleProvider = {
38
38
  };
39
39
 
40
40
  export async function requestStream({ url, headers, providerLabel, model, body, signal, onEvent }) {
41
- const res = await fetch(url, {
41
+ const res = await providerFetch(url, {
42
42
  method: 'POST',
43
43
  signal,
44
44
  headers: { ...headers, 'Content-Type': 'application/json' },