@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 +42 -3
- package/README.md +11 -2
- package/harness/packages/cli/src/cli.js +2 -10
- package/harness/packages/cli/src/commands/update.js +20 -16
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/storage/sqlite.js +11 -2
- package/harness/packages/core/src/tools/executors.js +95 -7
- package/harness/packages/core/src/tools/schema.js +2 -1
- package/harness/packages/core/src/version.js +26 -0
- package/harness/packages/core/test/tools.test.js +53 -0
- package/harness/packages/server/public/app.js +29 -3
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/public/styles.css +15 -4
- package/harness/packages/server/src/lib/agentTools.js +1 -1
- package/harness/packages/server/src/lib/prompt.js +2 -1
- package/harness/packages/server/src/routes/chat.js +3 -1
- package/harness/packages/server/src/server.js +2 -13
- package/harness/tamnora.example.json +8 -0
- package/package.json +1 -1
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.
|
|
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
|
-
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
14
|
-
|
|
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 '
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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 & 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.
|
|
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.
|
|
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
|
|
369
|
-
display:
|
|
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
|
|
377
|
-
.msg
|
|
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
|
|
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
|
|
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 =
|
|
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