@danieltmn/tamnora 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +25 -10
  3. package/harness/packages/cli/src/commands/autostart.js +5 -1
  4. package/harness/packages/cli/src/commands/doctor.js +14 -0
  5. package/harness/packages/cli/src/commands/update.js +91 -21
  6. package/harness/packages/core/src/index.js +7 -1
  7. package/harness/packages/core/src/version.js +81 -7
  8. package/harness/packages/core/test/version.test.js +34 -0
  9. package/harness/packages/server/public/app.js +337 -24
  10. package/harness/packages/server/public/favicon.ico +0 -0
  11. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  12. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  13. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  14. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  15. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  16. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  17. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  18. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  19. package/harness/packages/server/public/icons/logo.png +0 -0
  20. package/harness/packages/server/public/icons/tamnora.ico +0 -0
  21. package/harness/packages/server/public/index.html +119 -30
  22. package/harness/packages/server/public/landing/index.html +8 -4
  23. package/harness/packages/server/public/logo.png +0 -0
  24. package/harness/packages/server/public/manifest.webmanifest +20 -0
  25. package/harness/packages/server/public/markdown.js +54 -0
  26. package/harness/packages/server/public/styles.css +1775 -1633
  27. package/harness/packages/server/src/lib/advisor.js +177 -0
  28. package/harness/packages/server/src/lib/agentRun.js +2 -1
  29. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  30. package/harness/packages/server/src/lib/update.js +82 -0
  31. package/harness/packages/server/src/routes/advisor.js +76 -0
  32. package/harness/packages/server/src/routes/chat.js +30 -0
  33. package/harness/packages/server/src/routes/tts.js +1 -1
  34. package/harness/packages/server/src/routes/update.js +43 -0
  35. package/harness/packages/server/src/server.js +26 -2
  36. package/harness/packages/server/src/static.js +1 -0
  37. package/harness/packages/server/test/advisor.test.js +205 -0
  38. package/harness/packages/server/test/markdown.test.js +43 -1
  39. package/harness/packages/server/test/update.test.js +94 -0
  40. package/harness/scripts/self-update.mjs +123 -0
  41. package/package.json +1 -1
@@ -0,0 +1,205 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ resolveAdvisor,
8
+ advisorShouldRun,
9
+ collectChangedFiles,
10
+ collectToolSummary,
11
+ buildAdvisorPrompt,
12
+ consultAdvisor,
13
+ } from '../src/lib/advisor.js';
14
+ import { createTamnoraServer } from '../src/index.js';
15
+ import { setHome } from '../../core/src/index.js';
16
+
17
+ const editMessages = () => [
18
+ {
19
+ role: 'assistant',
20
+ content: '',
21
+ tool_calls: [
22
+ { id: 'c1', function: { name: 'edit', arguments: JSON.stringify({ path: 'src/a.js' }) } },
23
+ ],
24
+ },
25
+ { role: 'tool', tool_call_id: 'c1', name: 'edit', content: '- const token = "secreto"\n+ const token = process.env.TOKEN' },
26
+ ];
27
+
28
+ test('resolveAdvisor mezcla defaults y sanitiza', () => {
29
+ assert.equal(resolveAdvisor({}).enabled, false);
30
+ const saved = resolveAdvisor({ advisor: { enabled: true, notes: 'x', maxWords: 999 } });
31
+ assert.equal(saved.enabled, true);
32
+ assert.equal(saved.code, true);
33
+ assert.equal(saved.maxWords, 120);
34
+ const off = resolveAdvisor({ advisor: { code: false, explain: false, advice: false, maxWords: 5 } });
35
+ assert.equal(off.maxWords, 10);
36
+ assert.equal(off.code, false);
37
+ });
38
+
39
+ test('advisorShouldRun exige tools o respuesta larga', () => {
40
+ assert.equal(advisorShouldRun({ toolCalls: 0, answer: 'corto' }), false);
41
+ assert.equal(advisorShouldRun({ toolCalls: 1, answer: '' }), true);
42
+ assert.equal(advisorShouldRun({ toolCalls: 0, answer: 'x'.repeat(201) }), true);
43
+ });
44
+
45
+ test('collectChangedFiles arma path + diff de write/edit', () => {
46
+ const files = collectChangedFiles(editMessages());
47
+ assert.equal(files.length, 1);
48
+ assert.equal(files[0].path, 'src/a.js');
49
+ assert.match(files[0].diff, /secreto/);
50
+ });
51
+
52
+ test('collectToolSummary lista las tools del turno', () => {
53
+ const summary = collectToolSummary(editMessages());
54
+ assert.equal(summary.length, 1);
55
+ assert.match(summary[0], /^edit:/);
56
+ });
57
+
58
+ test('buildAdvisorPrompt respeta los toggles y las notas', () => {
59
+ const { system, user } = buildAdvisorPrompt({
60
+ userInput: 'hola',
61
+ answer: 'listo',
62
+ toolSummary: ['edit: src/a.js'],
63
+ changedFiles: [{ path: 'src/a.js', diff: '- x\n+ y' }],
64
+ options: { code: false, explain: true, advice: true, notes: 'avisame de secretos', maxWords: 20 },
65
+ });
66
+ assert.doesNotMatch(system, /problema real en el codigo/);
67
+ assert.match(system, /explicar un proceso/);
68
+ assert.match(system, /avisame de secretos/);
69
+ assert.match(user, /PEDIDO DEL USUARIO/);
70
+ assert.match(user, /src\/a\.js/);
71
+ });
72
+
73
+ const fakeProvider = (content) => ({ streamChat: async () => ({ content }) });
74
+
75
+ test('consultAdvisor interpreta speak:true', async () => {
76
+ const advice = await consultAdvisor({
77
+ provider: fakeProvider('{"speak":true,"kind":"code","text":"Ojo con el token en src/a.js."}'),
78
+ config: {},
79
+ model: 'x',
80
+ messages: editMessages(),
81
+ options: { code: true, explain: true, advice: true, maxWords: 40 },
82
+ });
83
+ assert.equal(advice.speak, true);
84
+ assert.equal(advice.kind, 'code');
85
+ assert.match(advice.text, /token/);
86
+ });
87
+
88
+ test('consultAdvisor respeta speak:false, JSON invalido y errores', async () => {
89
+ const off = await consultAdvisor({
90
+ provider: fakeProvider('{"speak":false,"text":""}'),
91
+ config: {},
92
+ model: 'x',
93
+ messages: [],
94
+ options: {},
95
+ });
96
+ assert.equal(off.speak, false);
97
+ assert.equal(
98
+ await consultAdvisor({ provider: fakeProvider('no json'), config: {}, model: 'x', messages: [], options: {} }),
99
+ null
100
+ );
101
+ const boom = {
102
+ streamChat: async () => {
103
+ throw new Error('boom');
104
+ },
105
+ };
106
+ assert.equal(await consultAdvisor({ provider: boom, config: {}, model: 'x', messages: [], options: {} }), null);
107
+ });
108
+
109
+ test('consultAdvisor recorta a maxWords', async () => {
110
+ const long = 'palabra '.repeat(50).trim();
111
+ const advice = await consultAdvisor({
112
+ provider: fakeProvider(JSON.stringify({ speak: true, kind: 'advice', text: long })),
113
+ config: {},
114
+ model: 'x',
115
+ messages: [],
116
+ options: { maxWords: 10, code: true, explain: true, advice: true },
117
+ });
118
+ assert.ok(advice.text.split(' ').length <= 11);
119
+ assert.match(advice.text, /…$/);
120
+ });
121
+
122
+ test('GET/PUT /api/advisor/settings y POST /api/advisor/test con proveedor inyectado', async () => {
123
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-advisor-'));
124
+ setHome(cwd);
125
+ let reply = '{"speak":true,"kind":"code","text":"Cuidado con el secreto."}';
126
+ const provider = { streamChat: async () => ({ content: reply }) };
127
+ const instance = await createTamnoraServer({
128
+ cwd,
129
+ dbPath: path.join(cwd, 'tamnora.db'),
130
+ loadMcp: false,
131
+ resolveProvider: () => ({ provider, config: {} }),
132
+ });
133
+ instance.ctx.store.setSetting('activeProvider', 'fake');
134
+ instance.ctx.store.setSetting('activeModel', 'test-model');
135
+ const info = await instance.listen({ port: 0 });
136
+ const base = info.url;
137
+ try {
138
+ const initial = await (await fetch(`${base}/api/advisor/settings`)).json();
139
+ assert.equal(initial.enabled, false);
140
+
141
+ const put = await fetch(`${base}/api/advisor/settings`, {
142
+ method: 'PUT',
143
+ headers: { 'Content-Type': 'application/json' },
144
+ body: JSON.stringify({ enabled: true, code: false, maxWords: 500 }),
145
+ });
146
+ const saved = await put.json();
147
+ assert.equal(saved.enabled, true);
148
+ assert.equal(saved.code, false);
149
+ assert.equal(saved.maxWords, 120);
150
+
151
+ const on = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
152
+ assert.equal(on.speak, true);
153
+ assert.match(on.text, /secreto/);
154
+
155
+ reply = '{"speak":false,"text":""}';
156
+ const off = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
157
+ assert.equal(off.speak, false);
158
+ } finally {
159
+ await instance.close();
160
+ }
161
+ });
162
+
163
+ test('el asesor emite el evento advice al cerrar el turno', async () => {
164
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-advice-'));
165
+ setHome(cwd);
166
+ let calls = 0;
167
+ const provider = {
168
+ id: 'fake',
169
+ streamChat: async (_config, { onEvent } = {}) => {
170
+ calls += 1;
171
+ if (calls === 1) {
172
+ const text = `Listo. ${'x'.repeat(220)}`;
173
+ onEvent?.({ type: 'text', delta: text, full: text });
174
+ return { content: text, reasoning: '', toolCalls: [] };
175
+ }
176
+ return { content: '{"speak":true,"kind":"code","text":"Cuidado con el token."}', reasoning: '', toolCalls: [] };
177
+ },
178
+ };
179
+ const instance = await createTamnoraServer({
180
+ cwd,
181
+ dbPath: path.join(cwd, 'tamnora.db'),
182
+ loadMcp: false,
183
+ resolveProvider: () => ({ provider, config: {} }),
184
+ });
185
+ instance.ctx.app = {
186
+ ...instance.ctx.app,
187
+ advisor: { enabled: true, code: true, explain: true, advice: true, notes: '', maxWords: 40 },
188
+ };
189
+ instance.ctx.store.setSetting('activeProvider', 'fake');
190
+ instance.ctx.store.setSetting('activeModel', 'test-model');
191
+ const info = await instance.listen({ port: 0 });
192
+ try {
193
+ const res = await fetch(`${info.url}/api/chat`, {
194
+ method: 'POST',
195
+ headers: { 'Content-Type': 'application/json' },
196
+ body: JSON.stringify({ input: 'quién soy', model: 'test-model', provider: 'fake' }),
197
+ });
198
+ const body = await res.text();
199
+ assert.equal(calls, 2);
200
+ assert.match(body, /event: advice/);
201
+ assert.match(body, /Cuidado con el token/);
202
+ } finally {
203
+ await instance.close();
204
+ }
205
+ });
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { renderMarkdown, highlight } from '../public/markdown.js';
3
+ import { renderMarkdown, highlight, speechText } from '../public/markdown.js';
4
4
 
5
5
  test('escapes raw HTML from model output', () => {
6
6
  const html = renderMarkdown('<script>alert(1)</script>');
@@ -67,3 +67,45 @@ test('a lone pipe line without a delimiter row stays a paragraph', () => {
67
67
  test('highlight escapes code content', () => {
68
68
  assert.equal(highlight('a < b', 'bash'), 'a &lt; b');
69
69
  });
70
+
71
+ test('speechText saca bloques de codigo y tablas', () => {
72
+ const md = [
73
+ 'Te dejo el resumen.',
74
+ '',
75
+ '```js',
76
+ 'const x = 1;',
77
+ '```',
78
+ '',
79
+ 'Y una tabla:',
80
+ '',
81
+ '| a | b |',
82
+ '| - | - |',
83
+ '| 1 | 2 |',
84
+ '',
85
+ 'Fin.',
86
+ ].join('\n');
87
+ const spoken = speechText(md);
88
+ assert.doesNotMatch(spoken, /const x/);
89
+ assert.doesNotMatch(spoken, /1 \| 2/);
90
+ assert.match(spoken, /Te dejo el resumen\./);
91
+ assert.match(spoken, /Fin\./);
92
+ });
93
+
94
+ test('speechText limpia marcas de markdown y aplasta espacios', () => {
95
+ const spoken = speechText('# Título\n\n**Nombre:** Daniel\n\n- uno\n- dos');
96
+ assert.equal(spoken, 'Título Nombre: Daniel uno dos');
97
+ });
98
+
99
+ test('speechText mantiene el texto de inline code', () => {
100
+ assert.equal(speechText('Corré `npm test` y listo.'), 'Corré npm test y listo.');
101
+ });
102
+
103
+ test('speechText recorta en un limite con maxChars', () => {
104
+ const spoken = speechText(`${'hola '.repeat(40)}`, { maxChars: 50 });
105
+ assert.ok(spoken.length <= 51);
106
+ assert.match(spoken, /…$/);
107
+ });
108
+
109
+ test('speechText devuelve vacio si solo hay codigo', () => {
110
+ assert.equal(speechText('```\nsolo codigo\n```'), '');
111
+ });
@@ -0,0 +1,94 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { createTamnoraServer } from '../src/index.js';
7
+ import { setHome } from '../../core/src/index.js';
8
+ import { clearUpdateCache } from '../src/lib/update.js';
9
+
10
+ async function startServer() {
11
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-update-'));
12
+ setHome(cwd);
13
+ const instance = await createTamnoraServer({
14
+ cwd,
15
+ dbPath: path.join(cwd, 'tamnora.db'),
16
+ loadMcp: false,
17
+ resolveProvider: () => ({ provider: { id: 'fake' }, config: {} }),
18
+ });
19
+ const info = await instance.listen({ port: 0 });
20
+ return { instance, base: info.url };
21
+ }
22
+
23
+ /** `fetch` que solo intercepta el registro npm; el resto va al server real. */
24
+ function fakeRegistry(version, original) {
25
+ return async (url, opts) => {
26
+ if (String(url).includes('registry.npmjs.org')) {
27
+ return { ok: true, json: async () => ({ 'dist-tags': { latest: version } }) };
28
+ }
29
+ return original(url, opts);
30
+ };
31
+ }
32
+
33
+ test('GET /api/update informa cuando hay version nueva', async () => {
34
+ clearUpdateCache();
35
+ const { instance, base } = await startServer();
36
+ const original = global.fetch;
37
+ global.fetch = fakeRegistry('99.0.0', original);
38
+ try {
39
+ const data = await (await fetch(`${base}/api/update`)).json();
40
+ assert.equal(data.latest, '99.0.0');
41
+ assert.equal(data.updateAvailable, true);
42
+ assert.match(data.current, /^\d+\.\d+\.\d+/);
43
+ } finally {
44
+ global.fetch = original;
45
+ await instance.close();
46
+ }
47
+ });
48
+
49
+ test('GET /api/update no rompe si no hay red', async () => {
50
+ clearUpdateCache();
51
+ const { instance, base } = await startServer();
52
+ const original = global.fetch;
53
+ global.fetch = async (url, opts) => {
54
+ if (String(url).includes('registry.npmjs.org')) throw new Error('offline');
55
+ return original(url, opts);
56
+ };
57
+ try {
58
+ const data = await (await fetch(`${base}/api/update`)).json();
59
+ assert.equal(data.updateAvailable, false);
60
+ assert.equal(data.latest, '');
61
+ assert.ok(data.error);
62
+ } finally {
63
+ global.fetch = original;
64
+ await instance.close();
65
+ }
66
+ });
67
+
68
+ test('POST /api/update dispara el actualizador y responde 202', async () => {
69
+ clearUpdateCache();
70
+ const { instance, base } = await startServer();
71
+ const original = global.fetch;
72
+ global.fetch = fakeRegistry('99.0.0', original);
73
+ let called = null;
74
+ instance.ctx.spawnUpdater = (options) => {
75
+ called = options;
76
+ return { ok: true, pid: 1234 };
77
+ };
78
+ instance.ctx.shutdown = async () => {};
79
+ try {
80
+ const res = await fetch(`${base}/api/update`, { method: 'POST' });
81
+ assert.equal(res.status, 202);
82
+ const data = await res.json();
83
+ assert.equal(data.updating, true);
84
+ assert.match(data.from, /^\d+\.\d+\.\d+/);
85
+ assert.equal(data.to, '99.0.0');
86
+ assert.ok(called);
87
+ assert.equal(called.target, '99.0.0');
88
+ assert.equal(called.pid, process.pid);
89
+ assert.equal(typeof called.cwd, 'string');
90
+ } finally {
91
+ global.fetch = original;
92
+ await instance.close();
93
+ }
94
+ });
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ // Auto-actualizacion disparada desde el server (POST /api/update).
3
+ //
4
+ // Corre detached: espera a que el server viejo muera (en Windows eso libera los
5
+ // archivos que npm necesita reemplazar), instala la ultima version y vuelve a
6
+ // levantar el server con el codigo nuevo. Todo se loguea en el log del server
7
+ // para poder diagnosticar desde Ajustes o `tamnora server --logs`.
8
+
9
+ import { spawn, spawnSync } from 'node:child_process';
10
+ import { appendFileSync, existsSync } from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ const PACKAGE = '@danieltmn/tamnora';
14
+ const IS_WIN = process.platform === 'win32';
15
+ const NPM = IS_WIN ? 'npm.cmd' : 'npm';
16
+
17
+ function arg(name) {
18
+ const i = process.argv.indexOf(`--${name}`);
19
+ return i >= 0 ? process.argv[i + 1] : '';
20
+ }
21
+
22
+ const pid = Number(arg('pid')) || 0;
23
+ const port = arg('port');
24
+ const host = arg('host');
25
+ const cwd = arg('cwd');
26
+ const target = arg('target') || 'latest';
27
+ const home = arg('home') || process.env.TAMNORA_HOME || '';
28
+
29
+ function defaultHome() {
30
+ if (process.env.APPDATA) return path.join(process.env.APPDATA, 'tamnora');
31
+ if (process.platform === 'darwin') {
32
+ return path.join(process.env.HOME || '', 'Library', 'Application Support', 'tamnora');
33
+ }
34
+ return path.join(process.env.HOME || process.env.USERPROFILE || '', '.config', 'tamnora');
35
+ }
36
+
37
+ function log(line) {
38
+ const text = `[${new Date().toISOString()}] self-update: ${line}`;
39
+ try {
40
+ appendFileSync(path.join(home || defaultHome(), 'logs', 'server.log'), `${text}\n`);
41
+ } catch {
42
+ /* sin log seguimos: la instalacion es lo importante */
43
+ }
44
+ try {
45
+ process.stdout.write(`${text}\n`);
46
+ } catch {
47
+ /* stdout cerrado */
48
+ }
49
+ }
50
+
51
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
52
+
53
+ function alive(p) {
54
+ if (!p) return false;
55
+ try {
56
+ process.kill(p, 0);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ async function waitDead(p, ms = 15000) {
64
+ const start = Date.now();
65
+ while (alive(p)) {
66
+ if (Date.now() - start > ms) {
67
+ try {
68
+ process.kill(p);
69
+ } catch {
70
+ /* ya murio */
71
+ }
72
+ await sleep(1500);
73
+ return !alive(p);
74
+ }
75
+ await sleep(300);
76
+ }
77
+ return true;
78
+ }
79
+
80
+ function runNpm(args) {
81
+ return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN, timeout: 180000 });
82
+ }
83
+
84
+ async function main() {
85
+ log(`esperando que muera el server (pid ${pid})`);
86
+ await waitDead(pid);
87
+ await sleep(800);
88
+
89
+ log(`instalando ${PACKAGE}@${target}`);
90
+ const install = runNpm(['i', '-g', `${PACKAGE}@${target}`]);
91
+ if (install.status !== 0) {
92
+ log(`FALLO la instalacion: ${(install.stderr || install.stdout || '').trim()}`);
93
+ log('Podes reintentar a mano: npm i -g ' + `${PACKAGE}@${target}`);
94
+ return;
95
+ }
96
+
97
+ const rootOut = runNpm(['root', '-g']).stdout?.trim();
98
+ const pkgDir = rootOut ? path.join(rootOut, ...PACKAGE.split('/')) : '';
99
+ const bin = pkgDir ? path.join(pkgDir, 'harness', 'packages', 'cli', 'bin', 'tamnora.mjs') : '';
100
+ if (!bin || !existsSync(bin)) {
101
+ log(`instale pero no encontre el binario nuevo en ${pkgDir || '(npm root -g vacio)'}`);
102
+ log('Levantalo a mano: tamnora server --start');
103
+ return;
104
+ }
105
+
106
+ const args = [bin, 'server', '--stream', '--no-open'];
107
+ if (port) args.push('--port', port);
108
+ if (host) args.push('--host', host);
109
+ if (cwd) args.push('--dir', cwd);
110
+ log(`reiniciando: ${args.join(' ')}`);
111
+ const env = { ...process.env, TAMNORA_DETACHED: '1' };
112
+ if (home) env.TAMNORA_HOME = home;
113
+ const child = spawn(process.execPath, args, {
114
+ detached: true,
115
+ stdio: 'ignore',
116
+ windowsHide: true,
117
+ env,
118
+ });
119
+ child.unref();
120
+ log(`server reiniciado (pid ${child.pid}).`);
121
+ }
122
+
123
+ main().catch((error) => log(`error: ${error?.stack ?? error}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
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",