@danieltmn/tamnora 0.3.1 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -5,12 +5,44 @@ 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.3.1] - 2026-10-02
8
+ ## [0.4.1] - 2026-10-02
9
+
10
+ ### Corregido
11
+
12
+ - `tamnora update` fallaba en Windows con "No se pudo consultar npm": lanzaba
13
+ `npm.cmd` con `spawnSync` sin `shell`, que da `EINVAL`. Ahora usa shell en Windows.
14
+
15
+ ## [0.4.0] - 2026-10-02
9
16
 
10
17
  ### Agregado
11
18
 
12
- - Consola (ajustes): campo `env` (lineas `KEY=valor`) en el modal de MCP, para
13
- cargar las variables de los servidores importados del registro.
19
+ - La consola muestra el modo activo (`Plan`/`Build`) como badge junto al nombre del
20
+ agente en cada respuesta del chat; alternarlo con `Ctrl+.` ya no inserta un
21
+ mensaje en el flujo. El modo queda guardado por respuesta.
22
+ - `fetch_url` convierte HTML a texto legible (título + cuerpo, sin scripts ni
23
+ estilos), avisa cuando el sitio exige JavaScript o está detrás de Cloudflare y
24
+ no vuelca cuerpos binarios; `raw` mantiene el comportamiento anterior.
25
+ - El system prompt del server y `AGENTS.md` listan `fetch_url` y las tools de git,
26
+ para que el agente sepa que puede abrir URLs. `install_mcp` documenta el MCP de
27
+ navegador (Playwright) para webs con JavaScript, con preset en
28
+ `harness/tamnora.example.json`.
29
+
30
+ ### Corregido
31
+
32
+ - La consola web muestra la versión real en el header (antes `v0.0.0` fijo): se lee
33
+ de `/api/health` y el release actualiza el HTML.
34
+ - `tamnora update` reportaba siempre `0.0.0` por una ruta relativa mal calculada, así
35
+ que nunca detectaba "ya estás al día"; ahora resuelve la versión subiendo hasta el
36
+ `package.json` raíz y verifica la versión realmente instalada.
37
+
38
+ ### Cambiado
39
+
40
+ - Website: se quitó el formulario de login (no tiene sentido en la web pública) y
41
+ el header dejó de mostrar "Ingresar". En su lugar, el hero muestra una tarjeta de
42
+ inicio rápido con los comandos de instalación. La consola demo ya no simula sesión.
43
+ - Documentación actualizada: README y las páginas del website reflejan las 12 tools
44
+ locales (incl. `fetch_url` y las de Git), el listado completo de comandos, el MCP
45
+ de navegador para webs con JavaScript/Cloudflare y `tamnora update`/`autostart`.
14
46
 
15
47
  ### Pendiente
16
48
 
@@ -19,6 +51,13 @@ y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
19
51
  - Telegram: audio y voz (transcribir notas de voz y responder con audio).
20
52
  - Plugins: cargador de plugins locales (fase 2 del apartado ajustes).
21
53
 
54
+ ## [0.3.1] - 2026-10-02
55
+
56
+ ### Agregado
57
+
58
+ - Consola (ajustes): campo `env` (lineas `KEY=valor`) en el modal de MCP, para
59
+ cargar las variables de los servidores importados del registro.
60
+
22
61
  ## [0.3.0] - 2026-10-02
23
62
 
24
63
  ### 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,26 @@
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
- const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
6
+ const IS_WIN = process.platform === 'win32';
7
+ const NPM = IS_WIN ? 'npm.cmd' : 'npm';
9
8
  const OPTIONS = { yes: { type: 'boolean', short: 'y' }, check: { type: 'boolean' }, tag: { type: 'string' }, help: { type: 'boolean', short: 'h' } };
10
9
 
11
- function currentVersion() {
10
+ function npm(args) {
11
+ return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN });
12
+ }
13
+
14
+ function installedVersion() {
15
+ const out = npm(['ls', '-g', PACKAGE, '--json', '--depth=0']);
12
16
  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';
17
+ const data = JSON.parse(out.stdout);
18
+ return data?.dependencies?.[PACKAGE]?.version || '';
17
19
  } catch {
18
- return '0.0.0';
20
+ return '';
19
21
  }
20
22
  }
21
23
 
22
- function npm(args) {
23
- return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true });
24
- }
25
-
26
24
  export async function updateCommand(argv) {
27
25
  const { values } = parse(argv, OPTIONS);
28
26
  if (values.help) {
@@ -32,7 +30,7 @@ export async function updateCommand(argv) {
32
30
 
33
31
  const tag = typeof values.tag === 'string' && values.tag ? values.tag : 'latest';
34
32
  const spec = `${PACKAGE}@${tag}`;
35
- const version = currentVersion();
33
+ const version = readPackageVersion();
36
34
  console.log(`Version actual: ${version}`);
37
35
 
38
36
  const view = npm(['view', spec, 'version', '--json']);
@@ -66,11 +64,17 @@ export async function updateCommand(argv) {
66
64
  const result = spawnSync(NPM, ['i', '-g', spec], {
67
65
  stdio: 'inherit',
68
66
  windowsHide: true,
67
+ shell: IS_WIN,
69
68
  });
70
69
  if (result.status !== 0) {
71
70
  console.error(`La actualizacion fallo. Proba a mano: npm i -g ${spec}`);
72
71
  return 1;
73
72
  }
74
- console.log('Listo. Reinicia el server: tamnora server --reload');
73
+ const installed = installedVersion() || readPackageVersion();
74
+ console.log(`Listo. Instalado: v${installed} (pediste ${latest}).`);
75
+ if (installed && installed !== latest) {
76
+ console.log('Ojo: la version instalada no coincide con la publicada. Cerra procesos que usen la carpeta y reintenta.');
77
+ }
78
+ console.log('Reinicia el server: tamnora server --reload');
75
79
  return 0;
76
80
  }
@@ -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();
@@ -2674,6 +2696,10 @@ async function boot() {
2674
2696
  setCwd(health.cwd);
2675
2697
  }
2676
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
+ }
2677
2703
  await refreshProviders();
2678
2704
  await loadModes();
2679
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.1</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>
@@ -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.1",
3
+ "version": "0.4.1",
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",