@danieltmn/tamnora 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,52 @@ 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.4.0] - 2026-10-02
9
+
10
+ ### Agregado
11
+
12
+ - La consola muestra el modo activo (`Plan`/`Build`) como badge junto al nombre del
13
+ agente en cada respuesta del chat; alternarlo con `Ctrl+.` ya no inserta un
14
+ mensaje en el flujo. El modo queda guardado por respuesta.
15
+ - `fetch_url` convierte HTML a texto legible (título + cuerpo, sin scripts ni
16
+ estilos), avisa cuando el sitio exige JavaScript o está detrás de Cloudflare y
17
+ no vuelca cuerpos binarios; `raw` mantiene el comportamiento anterior.
18
+ - El system prompt del server y `AGENTS.md` listan `fetch_url` y las tools de git,
19
+ para que el agente sepa que puede abrir URLs. `install_mcp` documenta el MCP de
20
+ navegador (Playwright) para webs con JavaScript, con preset en
21
+ `harness/tamnora.example.json`.
22
+
23
+ ### Corregido
24
+
25
+ - La consola web muestra la versión real en el header (antes `v0.0.0` fijo): se lee
26
+ de `/api/health` y el release actualiza el HTML.
27
+ - `tamnora update` reportaba siempre `0.0.0` por una ruta relativa mal calculada, así
28
+ que nunca detectaba "ya estás al día"; ahora resuelve la versión subiendo hasta el
29
+ `package.json` raíz y verifica la versión realmente instalada.
30
+
31
+ ### Cambiado
32
+
33
+ - Website: se quitó el formulario de login (no tiene sentido en la web pública) y
34
+ el header dejó de mostrar "Ingresar". En su lugar, el hero muestra una tarjeta de
35
+ inicio rápido con los comandos de instalación. La consola demo ya no simula sesión.
36
+ - Documentación actualizada: README y las páginas del website reflejan las 12 tools
37
+ locales (incl. `fetch_url` y las de Git), el listado completo de comandos, el MCP
38
+ de navegador para webs con JavaScript/Cloudflare y `tamnora update`/`autostart`.
39
+
40
+ ### Pendiente
41
+
42
+ - Confirmar el proveedor Xiaomi (`mimo-v2.6-flash`): respondio `429 Too Many
43
+ Requests` (cuota/rate limit del endpoint), no del harness.
44
+ - Telegram: audio y voz (transcribir notas de voz y responder con audio).
45
+ - Plugins: cargador de plugins locales (fase 2 del apartado ajustes).
46
+
47
+ ## [0.3.1] - 2026-10-02
48
+
49
+ ### Agregado
50
+
51
+ - Consola (ajustes): campo `env` (lineas `KEY=valor`) en el modal de MCP, para
52
+ cargar las variables de los servidores importados del registro.
53
+
8
54
  ## [0.3.0] - 2026-10-02
9
55
 
10
56
  ### Agregado
@@ -23,13 +69,6 @@ y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
23
69
  - Consola (ajustes): barras para importar skills y MCP, con estado y aviso de
24
70
  variables de entorno requeridas.
25
71
 
26
- ### Pendiente
27
-
28
- - Confirmar el proveedor Xiaomi (`mimo-v2.6-flash`): respondio `429 Too Many
29
- Requests` (cuota/rate limit del endpoint), no del harness.
30
- - Telegram: audio y voz (transcribir notas de voz y responder con audio).
31
- - Plugins: cargador de plugins locales (fase 2 del apartado ajustes).
32
-
33
72
  ## [0.2.0] - 2026-10-02
34
73
 
35
74
  ### Agregado
package/README.md CHANGED
@@ -5,8 +5,8 @@ con TUI, consola web, tools locales, MCP y evaluacion de modelos.
5
5
 
6
6
  - **Sin dependencias**: solo modulos de Node (`node:sqlite`, `node:crypto`, ...).
7
7
  - **Local**: corre en tu maquina y guarda el estado en tu casa local.
8
- - **Multi-proveedor**: OpenRouter, OpenAI-compatible (DeepSeek, Groq, Mistral,
9
- xAI, Together, Fireworks, Cerebras, Perplexity) y Ollama.
8
+ - **Multi-proveedor**: OpenRouter, OpenAI, proveedores compatibles (DeepSeek,
9
+ Groq, Mistral, xAI, Together, Fireworks, Cerebras, Perplexity) y Ollama.
10
10
 
11
11
  ## Instalacion
12
12
 
@@ -97,6 +97,15 @@ stdio. Las tools se exponen como `mcp__<servidor>__<tool>` y, si el servidor los
97
97
  anuncia, tambien sus **recursos** y **prompts** (`list_resources`,
98
98
  `read_resource`, `list_prompts`, `get_prompt`).
99
99
 
100
+ ## Tools
101
+
102
+ Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
103
+ `edit`, `bash`, `fetch_url`) y de Git (`git_status`, `git_diff`, `git_log`,
104
+ `git_commit`), con permisos `allow`/`ask`/`deny` por tool o por patron.
105
+ `fetch_url` lee URLs (HTML a texto) y avisa cuando un sitio exige JavaScript o
106
+ esta detras de Cloudflare; para esos casos, registra un MCP de navegador (por
107
+ ejemplo Playwright).
108
+
100
109
  ## Documentacion
101
110
 
102
111
  - [`CHANGELOG.md`](CHANGELOG.md) — historial de versiones.
@@ -1,6 +1,3 @@
1
- import { readFileSync } from 'node:fs';
2
- import { fileURLToPath } from 'node:url';
3
- import path from 'node:path';
4
1
  import { runCommand } from './commands/run.js';
5
2
  import { replCommand } from './commands/repl.js';
6
3
  import { evalCommand } from './commands/eval.js';
@@ -15,6 +12,7 @@ import { autostartCommand } from './commands/autostart.js';
15
12
  import { doctorCommand } from './commands/doctor.js';
16
13
  import { tuiCommand } from './commands/tui.js';
17
14
  import { ensureUtf8Console } from './lib/util.js';
15
+ import { readPackageVersion } from './lib/core.js';
18
16
  import { color } from './ui/color.js';
19
17
 
20
18
  const COMMANDS = {
@@ -34,13 +32,7 @@ const COMMANDS = {
34
32
  };
35
33
 
36
34
  function readVersion() {
37
- try {
38
- const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../../package.json');
39
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
40
- return pkg.version || '0.0.0';
41
- } catch {
42
- return '0.0.0';
43
- }
35
+ return readPackageVersion();
44
36
  }
45
37
 
46
38
  export async function runCli(argv) {
@@ -1,28 +1,25 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
3
- import { fileURLToPath } from 'node:url';
4
- import path from 'node:path';
2
+ import { readPackageVersion } from '../lib/core.js';
5
3
  import { parse } from '../lib/util.js';
6
4
 
7
5
  const PACKAGE = '@danieltmn/tamnora';
8
6
  const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
9
7
  const OPTIONS = { yes: { type: 'boolean', short: 'y' }, check: { type: 'boolean' }, tag: { type: 'string' }, help: { type: 'boolean', short: 'h' } };
10
8
 
11
- function currentVersion() {
9
+ function npm(args) {
10
+ return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true });
11
+ }
12
+
13
+ function installedVersion() {
14
+ const out = npm(['ls', '-g', PACKAGE, '--json', '--depth=0']);
12
15
  try {
13
- const pkg = JSON.parse(
14
- readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../../package.json'), 'utf8')
15
- );
16
- return pkg.version || '0.0.0';
16
+ const data = JSON.parse(out.stdout);
17
+ return data?.dependencies?.[PACKAGE]?.version || '';
17
18
  } catch {
18
- return '0.0.0';
19
+ return '';
19
20
  }
20
21
  }
21
22
 
22
- function npm(args) {
23
- return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true });
24
- }
25
-
26
23
  export async function updateCommand(argv) {
27
24
  const { values } = parse(argv, OPTIONS);
28
25
  if (values.help) {
@@ -32,7 +29,7 @@ export async function updateCommand(argv) {
32
29
 
33
30
  const tag = typeof values.tag === 'string' && values.tag ? values.tag : 'latest';
34
31
  const spec = `${PACKAGE}@${tag}`;
35
- const version = currentVersion();
32
+ const version = readPackageVersion();
36
33
  console.log(`Version actual: ${version}`);
37
34
 
38
35
  const view = npm(['view', spec, 'version', '--json']);
@@ -71,6 +68,11 @@ export async function updateCommand(argv) {
71
68
  console.error(`La actualizacion fallo. Proba a mano: npm i -g ${spec}`);
72
69
  return 1;
73
70
  }
74
- console.log('Listo. Reinicia el server: tamnora server --reload');
71
+ const installed = installedVersion() || readPackageVersion();
72
+ console.log(`Listo. Instalado: v${installed} (pediste ${latest}).`);
73
+ if (installed && installed !== latest) {
74
+ console.log('Ojo: la version instalada no coincide con la publicada. Cerra procesos que usen la carpeta y reintenta.');
75
+ }
76
+ console.log('Reinicia el server: tamnora server --reload');
75
77
  return 0;
76
78
  }
@@ -81,3 +81,4 @@ export {
81
81
  } from './mcp/registry.js';
82
82
  export { parseTunnelmoleUrls, startTunnelmole, startTunnel } from './tunnel.js';
83
83
  export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from './procs/detect.js';
84
+ export { readPackageVersion } from './version.js';
@@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS messages (
27
27
  tool_calls TEXT,
28
28
  tool_call_id TEXT,
29
29
  name TEXT,
30
+ mode TEXT,
30
31
  tokens_in INTEGER,
31
32
  tokens_out INTEGER,
32
33
  created_at INTEGER
@@ -99,9 +100,15 @@ export function openDatabase(file) {
99
100
  const db = new DatabaseSync(file ?? ':memory:');
100
101
  db.exec('PRAGMA journal_mode = WAL;');
101
102
  db.exec(SCHEMA);
103
+ migrate(db);
102
104
  return db;
103
105
  }
104
106
 
107
+ function migrate(db) {
108
+ const columns = db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name);
109
+ if (!columns.includes('mode')) db.exec('ALTER TABLE messages ADD COLUMN mode TEXT');
110
+ }
111
+
105
112
  const now = () => Date.now();
106
113
  const json = (value) => (value === undefined || value === null ? null : JSON.stringify(value));
107
114
  const parseJson = (value) => {
@@ -254,8 +261,8 @@ export class Store {
254
261
  const id = randomUUID();
255
262
  this.db
256
263
  .prepare(
257
- `INSERT INTO messages (id, session_id, role, content, tool_calls, tool_call_id, name, tokens_in, tokens_out, created_at)
258
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
264
+ `INSERT INTO messages (id, session_id, role, content, tool_calls, tool_call_id, name, mode, tokens_in, tokens_out, created_at)
265
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
259
266
  )
260
267
  .run(
261
268
  id,
@@ -265,6 +272,7 @@ export class Store {
265
272
  json(message.tool_calls),
266
273
  message.tool_call_id ?? null,
267
274
  message.name ?? null,
275
+ message.mode ?? null,
268
276
  message.tokens_in ?? null,
269
277
  message.tokens_out ?? null,
270
278
  now()
@@ -289,6 +297,7 @@ export class Store {
289
297
  if (row.tool_calls) message.tool_calls = parseJson(row.tool_calls);
290
298
  if (row.tool_call_id) message.tool_call_id = row.tool_call_id;
291
299
  if (row.name) message.name = row.name;
300
+ if (row.mode) message.mode = row.mode;
292
301
  return message;
293
302
  });
294
303
  }
@@ -14,6 +14,24 @@ const MAX_BASH_OUTPUT = 100_000;
14
14
  const FETCH_TIMEOUT_MS = 20000;
15
15
  const MAX_FETCH_CHARS = 200_000;
16
16
 
17
+ const NAMED_ENTITIES = {
18
+ amp: '&',
19
+ lt: '<',
20
+ gt: '>',
21
+ quot: '"',
22
+ apos: "'",
23
+ nbsp: ' ',
24
+ hellip: '\u2026',
25
+ mdash: '\u2014',
26
+ ndash: '\u2013',
27
+ laquo: '\u00ab',
28
+ raquo: '\u00bb',
29
+ middot: '\u00b7',
30
+ copy: '\u00a9',
31
+ reg: '\u00ae',
32
+ trade: '\u2122',
33
+ };
34
+
17
35
  function isBlockedHost(hostname) {
18
36
  const h = String(hostname ?? '')
19
37
  .toLowerCase()
@@ -31,7 +49,66 @@ function isBlockedHost(hostname) {
31
49
  return false;
32
50
  }
33
51
 
34
- export async function fetchUrlTool(_root, { url, maxLength } = {}) {
52
+ function isTextualType(type) {
53
+ const t = String(type || '').toLowerCase();
54
+ if (!t) return true;
55
+ return (
56
+ /^text\//.test(t) ||
57
+ /(?:json|xml|xhtml|javascript|ecmascript|yaml|x-yaml|x-sh|urlencoded)/.test(t)
58
+ );
59
+ }
60
+
61
+ function isHtmlType(type) {
62
+ return /text\/html|application\/xhtml/i.test(String(type || ''));
63
+ }
64
+
65
+ function decodeEntities(text) {
66
+ return String(text).replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, ent) => {
67
+ if (ent[0] === '#') {
68
+ const code = /^x/i.test(ent.slice(1)) ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10);
69
+ if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return match;
70
+ try {
71
+ return String.fromCodePoint(code);
72
+ } catch {
73
+ return match;
74
+ }
75
+ }
76
+ const key = ent.toLowerCase();
77
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, key) ? NAMED_ENTITIES[key] : match;
78
+ });
79
+ }
80
+
81
+ function htmlToText(html) {
82
+ const source = String(html);
83
+ const titleMatch = source.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
84
+ const title = titleMatch ? decodeEntities(titleMatch[1]).replace(/\s+/g, ' ').trim() : '';
85
+ const body = decodeEntities(
86
+ source
87
+ .replace(/<!--[\s\S]*?-->/g, ' ')
88
+ .replace(/<(script|style|noscript|template|svg|head)[\s\S]*?<\/\1>/gi, ' ')
89
+ .replace(/<(?:br|\/p|\/div|\/li|\/tr|\/h[1-6]|\/section|\/article)\s*\/?>/gi, '\n')
90
+ .replace(/<[^>]+>/g, ' ')
91
+ )
92
+ .replace(/[ \t\f\v]+/g, ' ')
93
+ .replace(/\s*\n\s*/g, '\n')
94
+ .replace(/\n{3,}/g, '\n\n')
95
+ .trim();
96
+ return { title, body };
97
+ }
98
+
99
+ function isChallenge(status, headers, body) {
100
+ if (/<title>\s*just a moment/i.test(body)) return true;
101
+ if (/cf-challenge|challenge-platform|__cf_chl|turnstile/i.test(body)) return true;
102
+ return status >= 403 && /cloudflare/i.test(headers.get('server') || '');
103
+ }
104
+
105
+ function formatBody(head, text, limit) {
106
+ const body = text.slice(0, limit);
107
+ const truncated = text.length > limit ? `\n[... truncado ${text.length - limit} caracteres]` : '';
108
+ return `${head}\n\n${body}${truncated}`;
109
+ }
110
+
111
+ export async function fetchUrlTool(_root, { url, maxLength, raw } = {}) {
35
112
  let target;
36
113
  try {
37
114
  target = new URL(String(url ?? ''));
@@ -48,16 +125,27 @@ export async function fetchUrlTool(_root, { url, maxLength } = {}) {
48
125
  redirect: 'follow',
49
126
  signal: controller.signal,
50
127
  headers: {
51
- 'User-Agent': 'tamnora-harness (+https://github.com/tamnora/tamnora-harness)',
52
- Accept: '*/*',
128
+ 'User-Agent': 'Mozilla/5.0 (compatible; tamnora-harness; +https://github.com/tamnora/tamnora-harness)',
129
+ Accept: 'text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.5',
130
+ 'Accept-Language': 'es,en;q=0.8',
53
131
  },
54
132
  });
55
133
  const type = res.headers.get('content-type') || '';
56
134
  const limit = Math.min(Math.max(Number(maxLength) || 20000, 500), MAX_FETCH_CHARS);
57
- const text = await res.text();
58
- const body = text.slice(0, limit);
59
- const truncated = text.length > limit ? `\n[... truncado ${text.length - limit} caracteres]` : '';
60
- return `${res.status} ${res.statusText || ''} · ${type || 'sin content-type'} · ${target.toString()}\n\n${body}${truncated}`;
135
+ const full = await res.text();
136
+ const head = `${res.status} ${res.statusText || ''} · ${type || 'sin content-type'} · ${target.toString()}`;
137
+
138
+ if (!isTextualType(type)) {
139
+ return `${head}\n\n[contenido no textual (${type || 'desconocido'}); no se muestra el cuerpo. Usá raw sólo si sabés lo que hacés.]`;
140
+ }
141
+ if (!raw && isChallenge(res.status, res.headers, full)) {
142
+ return `${head}\n\n[sitio con challenge de Cloudflare o página que exige JavaScript; fetch_url no puede leerlo. Probá un servidor MCP de navegador (Playwright).]`;
143
+ }
144
+ if (!raw && isHtmlType(type)) {
145
+ const { title, body } = htmlToText(full);
146
+ return formatBody(head, (title ? `# ${title}\n\n` : '') + body, limit);
147
+ }
148
+ return formatBody(head, full, limit);
61
149
  } catch (err) {
62
150
  if (err?.name === 'AbortError') return `Error: timeout (${FETCH_TIMEOUT_MS / 1000}s) al pedir ${target}.`;
63
151
  return `Error de red al pedir ${target}: ${err?.message ?? err}`;
@@ -121,12 +121,13 @@ export const TOOL_DEFINITIONS = [
121
121
  function: {
122
122
  name: 'fetch_url',
123
123
  description:
124
- 'Fetch a URL over http(s) and return its text content (status, content-type and body). Use it to read docs, APIs or raw files.',
124
+ 'Fetch a URL over http(s) and return its content (status, content-type and body). HTML is converted to readable text. Blocks private hosts and warns about Cloudflare/JS-only pages. Use raw to get the original body.',
125
125
  parameters: {
126
126
  type: 'object',
127
127
  properties: {
128
128
  url: { type: 'string', description: 'Absolute http(s) URL' },
129
129
  maxLength: { type: 'integer', description: 'Max characters of the body (default 20000)' },
130
+ raw: { type: 'boolean', description: 'Return the raw body without HTML-to-text conversion' },
130
131
  },
131
132
  required: ['url'],
132
133
  },
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+
5
+ const ROOT_PACKAGE_NAME = '@danieltmn/tamnora';
6
+
7
+ /**
8
+ * Resolve the published package version by walking up from `startUrl` until a
9
+ * package.json named `@danieltmn/tamnora` is found. Robust to file depth so
10
+ * callers don't have to count `../` levels.
11
+ */
12
+ export function readPackageVersion(startUrl = import.meta.url) {
13
+ let dir = path.dirname(fileURLToPath(startUrl));
14
+ for (let i = 0; i < 10; i++) {
15
+ try {
16
+ const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
17
+ if (pkg.name === ROOT_PACKAGE_NAME && pkg.version) return pkg.version;
18
+ } catch {
19
+ /* no hay package.json acá: seguir subiendo */
20
+ }
21
+ const parent = path.dirname(dir);
22
+ if (parent === dir) break;
23
+ dir = parent;
24
+ }
25
+ return '0.0.0';
26
+ }
@@ -84,6 +84,59 @@ test('fetchUrlTool rejects invalid URLs and private hosts', async () => {
84
84
  assert.match(await fetchUrlTool(root, { url: 'http://169.254.169.254/' }), /host bloqueado/);
85
85
  });
86
86
 
87
+ async function withFetch(stub, fn) {
88
+ const original = globalThis.fetch;
89
+ globalThis.fetch = stub;
90
+ try {
91
+ return await fn();
92
+ } finally {
93
+ globalThis.fetch = original;
94
+ }
95
+ }
96
+
97
+ test('fetchUrlTool converts HTML to readable text', async () => {
98
+ const html =
99
+ '<html><head><title>Hola &amp; chau</title><style>x{}</style></head>' +
100
+ '<body><nav>menu</nav><h1>Titulo</h1><p>Parrafo<br>dos</p><script>evil()</script></body></html>';
101
+ const out = await withFetch(
102
+ async () => new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } }),
103
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
104
+ );
105
+ assert.match(out, /# Hola & chau/);
106
+ assert.match(out, /Titulo/);
107
+ assert.match(out, /Parrafo/);
108
+ assert.doesNotMatch(out, /evil\(\)/);
109
+ assert.doesNotMatch(out, /<p>/);
110
+ });
111
+
112
+ test('fetchUrlTool flags Cloudflare/JS challenges', async () => {
113
+ const body = '<html><head><title>Just a moment...</title></head><body>challenge-platform</body></html>';
114
+ const out = await withFetch(
115
+ async () => new Response(body, { status: 403, headers: { 'content-type': 'text/html', server: 'cloudflare' } }),
116
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
117
+ );
118
+ assert.match(out, /Cloudflare/);
119
+ assert.doesNotMatch(out, /challenge-platform/);
120
+ });
121
+
122
+ test('fetchUrlTool does not dump non-text bodies', async () => {
123
+ const out = await withFetch(
124
+ async () => new Response('%PDF-1.4 binary', { status: 200, headers: { 'content-type': 'application/pdf' } }),
125
+ () => fetchUrlTool(null, { url: 'https://example.com/x.pdf' })
126
+ );
127
+ assert.match(out, /no textual/);
128
+ assert.doesNotMatch(out, /%PDF/);
129
+ });
130
+
131
+ test('fetchUrlTool raw returns the original body', async () => {
132
+ const html = '<html><body><p>hola</p></body></html>';
133
+ const out = await withFetch(
134
+ async () => new Response(html, { status: 200, headers: { 'content-type': 'text/html' } }),
135
+ () => fetchUrlTool(null, { url: 'https://example.com/', raw: true })
136
+ );
137
+ assert.match(out, /<p>hola<\/p>/);
138
+ });
139
+
87
140
  test('createTools combines local and MCP tool routing', async () => {
88
141
  const root = await makeWorkspace();
89
142
  const mcp = {
@@ -388,7 +388,6 @@ async function setMode(id, { persist = true } = {}) {
388
388
  async function toggleMode() {
389
389
  const next = modeSelect.value === 'plan' ? 'agent' : 'plan';
390
390
  await setMode(next);
391
- notify(`Modo **${modeLabel(next)}**${next === 'plan' ? ' (solo lectura)' : ''}`);
392
391
  }
393
392
 
394
393
  modeSelect.addEventListener('change', () => setMode(modeSelect.value));
@@ -674,21 +673,39 @@ const scrollMessages = (pane = activePane) => {
674
673
  function addMessage(role, text = '', pane = activePane) {
675
674
  pane.querySelector('.empty')?.remove();
676
675
  const wrap = el('div', `msg ${role}`);
676
+ const label = el('div', 'msg-label');
677
+ let modeEl = null;
678
+ if (role === 'user') {
679
+ label.textContent = '❯ vos';
680
+ } else {
681
+ label.appendChild(el('span', 'msg-name', 'tamnora'));
682
+ modeEl = el('span', 'msg-mode');
683
+ modeEl.hidden = true;
684
+ label.appendChild(modeEl);
685
+ }
677
686
  const bubble = el('div', 'bubble');
678
687
  const textEl = el('div', 'text');
679
688
  const tools = el('div', 'tools');
680
689
  const activity = el('div', 'activity');
681
690
  activity.hidden = true;
682
691
  bubble.append(textEl, tools, activity);
683
- wrap.appendChild(bubble);
692
+ wrap.append(label, bubble);
684
693
  pane.appendChild(wrap);
685
- const message = { role, wrap, bubble, textEl, tools, activity, reasoningEl: null, id: null };
694
+ const message = { role, wrap, bubble, label, modeEl, textEl, tools, activity, reasoningEl: null, id: null };
686
695
  addMessageActions(message);
687
696
  if (text) setText(message, text);
688
697
  scrollMessages(pane);
689
698
  return message;
690
699
  }
691
700
 
701
+ function setMessageMode(message, modeId) {
702
+ if (!message?.modeEl) return;
703
+ const mode = modeId === 'plan' ? 'plan' : 'build';
704
+ message.modeEl.textContent = mode === 'plan' ? 'Plan' : 'Build';
705
+ message.modeEl.dataset.mode = mode;
706
+ message.modeEl.hidden = false;
707
+ }
708
+
692
709
  function addMessageActions(message) {
693
710
  const actions = el('div', 'msg-actions');
694
711
  actions.appendChild(button('copiar', () => copyMessage(message), 'msg-act'));
@@ -919,6 +936,7 @@ function renderStored(message, pane) {
919
936
  appendImages(bubble, userImages(message.content));
920
937
  } else if (message.role === 'assistant') {
921
938
  const bubble = addMessage('assistant', typeof message.content === 'string' ? message.content : '', pane);
939
+ if (message.mode) setMessageMode(bubble, message.mode);
922
940
  if (message.id) {
923
941
  bubble.id = message.id;
924
942
  bubble.wrap.dataset.messageId = message.id;
@@ -1256,10 +1274,12 @@ async function sendMessage() {
1256
1274
  let sawText = false;
1257
1275
  let lastTool = null;
1258
1276
  let startNewOnText = false;
1277
+ let turnMode = modeSelect.value || 'agent';
1259
1278
 
1260
1279
  const flowTarget = () => {
1261
1280
  if (startNewOnText) {
1262
1281
  active = addMessage('assistant', '', pane);
1282
+ setMessageMode(active, turnMode);
1263
1283
  startNewOnText = false;
1264
1284
  setActivity(active, 'thinking');
1265
1285
  }
@@ -1285,6 +1305,8 @@ async function sendMessage() {
1285
1305
  (event, data) => {
1286
1306
  if (event === 'start') {
1287
1307
  if (data.cwd) setCwd(data.cwd);
1308
+ if (data.mode) turnMode = data.mode;
1309
+ setMessageMode(active, turnMode);
1288
1310
  } else if (event === 'text') {
1289
1311
  sawText = true;
1290
1312
  const target = flowTarget();
@@ -1981,6 +2003,9 @@ function openMcpForm(server = null) {
1981
2003
  $('#mf-command').value = server?.command ?? '';
1982
2004
  $('#mf-args').value = (server?.args ?? []).join(' ');
1983
2005
  $('#mf-url').value = server?.url ?? '';
2006
+ $('#mf-env').value = Object.entries(server?.env ?? {})
2007
+ .map(([key, value]) => `${key}=${value ?? ''}`)
2008
+ .join('\n');
1984
2009
  $('#mf-enabled').checked = server ? server.enabled !== false : true;
1985
2010
  syncMcpFormFields();
1986
2011
  setStatus('#mf-status', '');
@@ -2001,6 +2026,7 @@ async function toggleMcp(server) {
2001
2026
  command: server.command,
2002
2027
  args: server.args,
2003
2028
  url: server.url,
2029
+ env: server.env,
2004
2030
  enabled: !server.enabled,
2005
2031
  },
2006
2032
  }).catch((error) => notify(`No se pudo actualizar MCP: ${error.message}`, 'notice err'));
@@ -2016,6 +2042,18 @@ async function removeMcp(id) {
2016
2042
  refreshMcp();
2017
2043
  }
2018
2044
 
2045
+ function parseEnv(text) {
2046
+ const out = {};
2047
+ for (const line of String(text ?? '').split('\n')) {
2048
+ const trimmed = line.trim();
2049
+ if (!trimmed || trimmed.startsWith('#')) continue;
2050
+ const eq = trimmed.indexOf('=');
2051
+ if (eq === -1) continue;
2052
+ out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1);
2053
+ }
2054
+ return out;
2055
+ }
2056
+
2019
2057
  async function submitMcp(event) {
2020
2058
  event.preventDefault();
2021
2059
  const id = $('#mf-id').value.trim();
@@ -2026,6 +2064,7 @@ async function submitMcp(event) {
2026
2064
  command: $('#mf-command').value.trim(),
2027
2065
  args: $('#mf-args').value.trim(),
2028
2066
  url: $('#mf-url').value.trim(),
2067
+ env: parseEnv($('#mf-env').value),
2029
2068
  enabled: $('#mf-enabled').checked,
2030
2069
  };
2031
2070
  try {
@@ -2657,6 +2696,10 @@ async function boot() {
2657
2696
  setCwd(health.cwd);
2658
2697
  }
2659
2698
  if (health?.home) state.home = health.home;
2699
+ if (health?.version) {
2700
+ const ver = document.querySelector('.brand .ver');
2701
+ if (ver) ver.textContent = `v${health.version}`;
2702
+ }
2660
2703
  await refreshProviders();
2661
2704
  await loadModes();
2662
2705
  await loadModels();
@@ -33,7 +33,7 @@
33
33
  </head>
34
34
  <body>
35
35
  <header class="topbar">
36
- <div class="brand"><span class="logo">T</span> tamnora <span class="ver">v0.0.0</span></div>
36
+ <div class="brand"><span class="logo">T</span> tamnora <span class="ver">v0.4.0</span></div>
37
37
  <nav class="tabs">
38
38
  <button class="tab active" data-tab="chat">chat</button>
39
39
  <button class="tab" data-tab="providers">proveedores</button>
@@ -441,6 +441,9 @@
441
441
  <div class="row">
442
442
  <label class="field grow" id="mf-args-field"><span>args (separados por espacio)</span><input id="mf-args" spellcheck="false" placeholder="harness/scripts/mcp-echo-server.mjs" /></label>
443
443
  </div>
444
+ <label class="field"><span>env (KEY=valor por línea)</span>
445
+ <textarea id="mf-env" rows="3" spellcheck="false" placeholder="API_KEY=..."></textarea>
446
+ </label>
444
447
  <label class="check"><input id="mf-enabled" type="checkbox" checked /> habilitado</label>
445
448
  <div class="row between settings-actions">
446
449
  <button type="button" id="mf-cancel" class="btn">cancelar</button>
@@ -365,16 +365,27 @@ body.resizing * { cursor: col-resize !important; }
365
365
 
366
366
  /* transcript style: role label, no bubbles */
367
367
  .msg { min-width: 0; }
368
- .msg::before {
369
- display: block;
368
+ .msg-label {
369
+ display: flex;
370
+ align-items: center;
371
+ gap: 6px;
370
372
  font: 500 10px var(--mono);
371
373
  letter-spacing: 0.14em;
372
374
  text-transform: uppercase;
373
375
  color: var(--muted);
374
376
  margin-bottom: 5px;
375
377
  }
376
- .msg.user::before { content: '❯ vos'; color: var(--mine); }
377
- .msg.assistant::before { content: 'tamnora'; }
378
+ .msg.user .msg-label { color: var(--mine); }
379
+ .msg-mode {
380
+ font: 500 9px var(--mono);
381
+ line-height: 1.5;
382
+ letter-spacing: 0.08em;
383
+ color: var(--accent);
384
+ border: 1px solid var(--border);
385
+ border-radius: 999px;
386
+ padding: 0 6px;
387
+ }
388
+ .msg-mode[data-mode='plan'] { color: var(--warn); }
378
389
  .bubble {
379
390
  max-width: 900px;
380
391
  min-width: 0;
@@ -33,7 +33,7 @@ const DEFINITIONS = [
33
33
  definition('list_mcp', 'Lista los servidores MCP configurados (de tamnora.json y de la base).', {}),
34
34
  definition(
35
35
  'install_mcp',
36
- 'Registra un servidor MCP. stdio: pasá command (y args). http: pasá url.',
36
+ 'Registra un servidor MCP. stdio: pasá command (y args). http: pasá url. Para webs con JavaScript/Cloudflare (que fetch_url no lee) instalá un MCP de navegador, ej. command "npx" args ["-y","@playwright/mcp@latest"].',
37
37
  {
38
38
  id: { type: 'string', description: 'Id corto, ej. "echo"' },
39
39
  name: { type: 'string' },
@@ -10,7 +10,8 @@ function workspacePreamble(cwd) {
10
10
  `Carpeta de trabajo: ${cwd}`,
11
11
  '',
12
12
  'Reglas:',
13
- '- Tus herramientas (read, list, glob, grep, write, edit) usan rutas relativas a esa carpeta; bash se ejecuta ahí.',
13
+ '- Tus herramientas locales son read, list, glob, grep, write, edit, bash, fetch_url y git_status/git_diff/git_log/git_commit. Las de archivos usan rutas relativas a la carpeta de trabajo; bash se ejecuta ahí.',
14
+ '- Con fetch_url podés abrir una URL http(s) y leer su contenido (docs, APIs, archivos remotos): no digas que no podés acceder a la web.',
14
15
  '- No podés salir de la carpeta de trabajo.',
15
16
  '- Antes de afirmar algo sobre el proyecto, inspeccionalo con las herramientas (list, glob, grep, read). No inventes archivos ni contenido.',
16
17
  '- write, edit y bash pueden requerir aprobación del usuario; si una llamada se deniega, explicá qué querías hacer en vez de reintentar en bucle.',
@@ -207,7 +207,9 @@ export function registerChat(route, ctx) {
207
207
  });
208
208
  partials.flush();
209
209
 
210
- const newMessages = result.messages.slice(1 + history.length);
210
+ const newMessages = result.messages
211
+ .slice(1 + history.length)
212
+ .map((message) => (message.role === 'assistant' ? { ...message, mode: mode.id } : message));
211
213
  ctx.store.addMessages(sessionId, newMessages);
212
214
  const current = ctx.store.getSession(sessionId);
213
215
  const title =
@@ -1,7 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { readFileSync } from 'node:fs';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import {
7
6
  loadConfig,
@@ -13,6 +12,7 @@ import {
13
12
  resolveApiKey,
14
13
  composeProviderSettings,
15
14
  buildMcpRegistry,
15
+ readPackageVersion,
16
16
  } from './lib/core.js';
17
17
  import { sendJson, sendError } from './lib/http.js';
18
18
  import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
@@ -36,18 +36,7 @@ import { registerPreview } from './routes/preview.js';
36
36
  import { registerChat } from './routes/chat.js';
37
37
  import { registerEval } from './routes/eval.js';
38
38
 
39
- const VERSION = readVersion();
40
-
41
- function readVersion() {
42
- try {
43
- const pkg = JSON.parse(
44
- readFileSync(fileURLToPath(new URL('../../../../package.json', import.meta.url)), 'utf8')
45
- );
46
- return pkg.version || '0.0.0';
47
- } catch {
48
- return '0.0.0';
49
- }
50
- }
39
+ const VERSION = readPackageVersion();
51
40
 
52
41
  const DEFAULT_PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
53
42
 
@@ -13,6 +13,14 @@
13
13
  "command": "node",
14
14
  "args": ["harness/scripts/mcp-echo-server.mjs"],
15
15
  "enabled": true
16
+ },
17
+ {
18
+ "id": "playwright",
19
+ "name": "Playwright (navegador headless, para webs con JS)",
20
+ "type": "stdio",
21
+ "command": "npx",
22
+ "args": ["-y", "@playwright/mcp@latest"],
23
+ "enabled": false
16
24
  }
17
25
  ],
18
26
  "processes": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",