@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,177 @@
1
+ // Asesor tipo Jarvis: una capa de comentario sobre el run activo. Mira lo que
2
+ // el agente hizo (pedido, respuesta, herramientas y diffs), lo interpreta con
3
+ // una llamada corta al modelo y decide si vale la pena decir algo. NO lee la
4
+ // respuesta del agente: solo agrega alertas de codigo, explicaciones o consejos.
5
+ //
6
+ // La salida es JSON estricto para poder decidir el silencio: si el modelo no
7
+ // tiene nada que aportar, no habla ni deja tarjeta.
8
+
9
+ import { summarizeToolCall, extractJson } from './core.js';
10
+
11
+ export const DEFAULT_ADVISOR = {
12
+ enabled: false,
13
+ code: true,
14
+ explain: true,
15
+ advice: true,
16
+ notes: '',
17
+ maxWords: 40,
18
+ };
19
+
20
+ const KINDS = new Set(['code', 'explain', 'advice']);
21
+ const MAX_PROMPT_CHARS = 8000;
22
+ const MAX_DIFF_CHARS = 2500;
23
+
24
+ /** Config efectiva: lo guardado en app.json (la casa) sobre los defaults. */
25
+ export function resolveAdvisor(app) {
26
+ const saved = app?.advisor && typeof app.advisor === 'object' ? app.advisor : {};
27
+ const maxWords = Number(saved.maxWords);
28
+ return {
29
+ enabled: saved.enabled === true,
30
+ code: saved.code !== false,
31
+ explain: saved.explain !== false,
32
+ advice: saved.advice !== false,
33
+ notes: typeof saved.notes === 'string' ? saved.notes.slice(0, 2000) : '',
34
+ maxWords: Number.isFinite(maxWords)
35
+ ? Math.min(Math.max(Math.round(maxWords), 10), 120)
36
+ : DEFAULT_ADVISOR.maxWords,
37
+ };
38
+ }
39
+
40
+ /** Gate barato: sin herramientas ni una respuesta larga no hay nada que mirar. */
41
+ export function advisorShouldRun({ toolCalls = 0, answer = '' } = {}) {
42
+ return Number(toolCalls) > 0 || String(answer ?? '').length > 200;
43
+ }
44
+
45
+ function parseArgs(call) {
46
+ try {
47
+ return JSON.parse(call?.function?.arguments ?? call?.arguments ?? '{}') ?? {};
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ /** Tool-calls de los mensajes del turno como `nombre: resumen` (deduplicado, acotado). */
54
+ export function collectToolSummary(messages = [], limit = 40) {
55
+ const out = [];
56
+ const seen = new Set();
57
+ for (const message of messages) {
58
+ if (message.role !== 'assistant') continue;
59
+ for (const call of message.tool_calls ?? []) {
60
+ const name = call.function?.name ?? call.name ?? '';
61
+ if (!name) continue;
62
+ const line = `${name}: ${summarizeToolCall(name, parseArgs(call))}`;
63
+ if (seen.has(line)) continue;
64
+ seen.add(line);
65
+ out.push(line);
66
+ if (out.length >= limit) return out;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** Archivos tocados por write/edit con su diff, tal como lo devolvio la tool. */
73
+ export function collectChangedFiles(messages = []) {
74
+ const calls = new Map();
75
+ for (const message of messages) {
76
+ if (message.role !== 'assistant') continue;
77
+ for (const call of message.tool_calls ?? []) {
78
+ const name = call.function?.name ?? call.name;
79
+ if (name !== 'write' && name !== 'edit') continue;
80
+ const id = call.id ?? call.tool_call_id;
81
+ if (id) calls.set(id, { name, path: parseArgs(call).path ?? '' });
82
+ }
83
+ }
84
+ const files = [];
85
+ for (const message of messages) {
86
+ if (message.role !== 'tool') continue;
87
+ const call = calls.get(message.tool_call_id);
88
+ if (!call) continue;
89
+ const content = typeof message.content === 'string' ? message.content : '';
90
+ files.push({ path: call.path || 'archivo', diff: content.slice(0, MAX_DIFF_CHARS) });
91
+ }
92
+ return files;
93
+ }
94
+
95
+ function focusLines(options) {
96
+ const lines = [];
97
+ if (options.code) lines.push('- alertar de un problema real en el codigo (bug, riesgo, secreto expuesto, inconsistencia)');
98
+ if (options.explain) lines.push('- explicar un proceso o decision que quedo poco clara');
99
+ if (options.advice) lines.push('- dar un consejo concreto y accionable');
100
+ if (!lines.length) lines.push('- aportar algo util que el agente no haya dicho');
101
+ return lines.join('\n');
102
+ }
103
+
104
+ /** System + user para la consulta al asesor (expuestos para test). */
105
+ export function buildAdvisorPrompt({ userInput = '', answer = '', toolSummary = [], changedFiles = [], options = {} } = {}) {
106
+ const maxWords = options.maxWords ?? DEFAULT_ADVISOR.maxWords;
107
+ const system =
108
+ 'Sos el asesor personal del usuario dentro de Tamnora, al estilo de Jarvis.\n' +
109
+ 'Recibis lo que el agente hizo en el ultimo turno: el pedido del usuario, su respuesta\n' +
110
+ 'final, las herramientas que uso y los diffs de los archivos que toco.\n\n' +
111
+ 'Tu trabajo NO es resumir ni repetir la respuesta del agente: el usuario ya la esta\n' +
112
+ 'viendo. Solo aportas algo que el agente no dijo. Enfocate en:\n' +
113
+ `${focusLines(options)}\n\n` +
114
+ 'Hablale al usuario en segunda persona, breve (' +
115
+ `${maxWords} palabras como maximo), en espanol, sin markdown ni listas. Si no hay nada\n` +
116
+ 'que valga la pena decir, no hables.\n' +
117
+ (options.notes ? `\nInstrucciones del usuario: ${options.notes}\n` : '') +
118
+ '\nResponde SOLO con JSON: {"speak": true|false, "kind": "code|explain|advice", "text": "..."}';
119
+
120
+ const parts = [`PEDIDO DEL USUARIO:\n${userInput || '(sin texto)'}`];
121
+ parts.push(`RESPUESTA FINAL DEL AGENTE:\n${answer || '(sin respuesta)'}`);
122
+ if (toolSummary.length) parts.push(`HERRAMIENTAS USADAS:\n${toolSummary.map((t) => `- ${t}`).join('\n')}`);
123
+ if (changedFiles.length) {
124
+ const files = changedFiles.map((f) => `### ${f.path}\n${f.diff}`).join('\n\n');
125
+ parts.push(`ARCHIVOS MODIFICADOS (diffs):\n${files}`);
126
+ }
127
+
128
+ return { system, user: parts.join('\n\n').slice(0, MAX_PROMPT_CHARS) };
129
+ }
130
+
131
+ function truncateWords(text, maxWords) {
132
+ const words = String(text ?? '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
133
+ if (words.length <= maxWords) return words.join(' ');
134
+ return `${words.slice(0, maxWords).join(' ')}…`;
135
+ }
136
+
137
+ /**
138
+ * Consulta unica al modelo (mismo patron que el juez de eval). Devuelve
139
+ * `{ speak, kind, text }` o `null` si no se pudo interpretar. Nunca tira: un
140
+ * asesor roto no debe romper el run.
141
+ */
142
+ export async function consultAdvisor({
143
+ provider,
144
+ config,
145
+ model,
146
+ signal,
147
+ userInput,
148
+ answer,
149
+ messages = [],
150
+ options = {},
151
+ } = {}) {
152
+ const todo = collectToolSummary(messages);
153
+ const changedFiles = collectChangedFiles(messages);
154
+ const { system, user } = buildAdvisorPrompt({ userInput, answer, toolSummary: todo, changedFiles, options });
155
+ const maxWords = options.maxWords ?? DEFAULT_ADVISOR.maxWords;
156
+
157
+ try {
158
+ const result = await provider.streamChat(config, {
159
+ model,
160
+ messages: [
161
+ { role: 'system', content: system },
162
+ { role: 'user', content: user },
163
+ ],
164
+ tools: [],
165
+ signal,
166
+ onEvent: () => {},
167
+ });
168
+ const { value, error } = extractJson(result?.content ?? '');
169
+ if (error || !value || typeof value !== 'object') return null;
170
+ const kind = KINDS.has(value.kind) ? value.kind : 'advice';
171
+ const text = truncateWords(value.text, maxWords);
172
+ if (value.speak !== true || !text) return { speak: false, kind, text: '' };
173
+ return { speak: true, kind, text };
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
@@ -71,7 +71,8 @@ export async function composeAgentMessages({ ctx, cwd, mode, history = [], userM
71
71
  { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
72
72
  ...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
73
73
  ...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
74
- ...history,
74
+ // El asesor no contamina el contexto del modelo: es una capa de comentario.
75
+ ...history.filter((message) => message.name !== 'advisor'),
75
76
  userMessage,
76
77
  ];
77
78
  }
@@ -8,14 +8,14 @@ export const DEFAULT_TTS = {
8
8
  enabled: false,
9
9
  voice: VOZ_POR_DEFECTO,
10
10
  rate: '+0%',
11
- announce: { start: true, tools: true, end: true },
11
+ announce: { start: true, tools: true, end: true, advice: true, answer: false },
12
12
  };
13
13
 
14
14
  /** Merge de defaults + lo guardado (con `announce` aplanado a booleans). */
15
15
  export function resolveTts(app) {
16
16
  const saved = app?.tts && typeof app.tts === 'object' ? app.tts : {};
17
17
  const announce = { ...DEFAULT_TTS.announce, ...(saved.announce ?? {}) };
18
- for (const key of ['start', 'tools', 'end']) announce[key] = announce[key] === true;
18
+ for (const key of ['start', 'tools', 'end', 'advice', 'answer']) announce[key] = announce[key] === true;
19
19
  return {
20
20
  ...DEFAULT_TTS,
21
21
  ...saved,
@@ -0,0 +1,82 @@
1
+ // Chequeo de version nueva contra el registro npm. Cacheado a proposito: el
2
+ // banner de la consola pregunta seguido y no queremos pegarle a npm en cada
3
+ // request. Un fallo de red no rompe: se devuelve `updateAvailable:false`.
4
+
5
+ import { spawn } from 'node:child_process';
6
+ import path from 'node:path';
7
+ import {
8
+ ROOT_PACKAGE_NAME,
9
+ readPackageVersion,
10
+ compareVersions,
11
+ fetchLatestVersion,
12
+ packageRoot,
13
+ homeDir,
14
+ } from './core.js';
15
+
16
+ const CACHE_MS = 10 * 60 * 1000;
17
+
18
+ let cache = { at: 0, latest: '' };
19
+
20
+ /** Limpia la cache (tests). */
21
+ export function clearUpdateCache() {
22
+ cache = { at: 0, latest: '' };
23
+ }
24
+
25
+ /**
26
+ * @returns {{current:string, latest:string, updateAvailable:boolean, checkedAt:string|null, error?:string}}
27
+ */
28
+ export async function checkUpdate({ force = false, current = readPackageVersion(), fetchImpl } = {}) {
29
+ const now = Date.now();
30
+ const fresh = cache.latest && now - cache.at < CACHE_MS;
31
+ if (!force && fresh) {
32
+ return {
33
+ current,
34
+ latest: cache.latest,
35
+ updateAvailable: compareVersions(cache.latest, current) > 0,
36
+ checkedAt: new Date(cache.at).toISOString(),
37
+ };
38
+ }
39
+
40
+ try {
41
+ const latest = await fetchLatestVersion({ pkg: ROOT_PACKAGE_NAME, ...(fetchImpl ? { fetchImpl } : {}) });
42
+ cache = { at: now, latest };
43
+ return {
44
+ current,
45
+ latest,
46
+ updateAvailable: compareVersions(latest, current) > 0,
47
+ checkedAt: new Date(now).toISOString(),
48
+ };
49
+ } catch (error) {
50
+ return {
51
+ current,
52
+ latest: '',
53
+ updateAvailable: false,
54
+ checkedAt: null,
55
+ error: String(error?.message ?? error),
56
+ };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Lanza el script detached que espera la muerte de este server, instala la
62
+ * version nueva y lo vuelve a levantar. Devolver rapido es el punto: el server
63
+ * tiene que soltar los archivos (Windows) antes de que npm los reemplace.
64
+ */
65
+ export function spawnSelfUpdate({ pid = process.pid, port, host, cwd, target, home } = {}) {
66
+ const root = packageRoot();
67
+ if (!root) return { ok: false, error: 'no encontre el paquete del harness' };
68
+ const script = path.join(root, 'harness', 'scripts', 'self-update.mjs');
69
+ const args = [script, '--pid', String(pid)];
70
+ if (port) args.push('--port', String(port));
71
+ if (host) args.push('--host', String(host));
72
+ if (cwd) args.push('--cwd', String(cwd));
73
+ if (target) args.push('--target', String(target));
74
+ args.push('--home', home || homeDir());
75
+ try {
76
+ const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', windowsHide: true });
77
+ child.unref();
78
+ return { ok: true, pid: child.pid };
79
+ } catch (error) {
80
+ return { ok: false, error: String(error?.message ?? error) };
81
+ }
82
+ }
@@ -0,0 +1,76 @@
1
+ // Ajustes del asesor (app.json -> advisor) y una prueba manual que corre una
2
+ // consulta real para ver que devuelve el modelo.
3
+
4
+ import { resolveAdvisor, consultAdvisor } from '../lib/advisor.js';
5
+ import { readApp, writeApp } from '../lib/core.js';
6
+ import { readJson, sendJson, sendError } from '../lib/http.js';
7
+
8
+ export function registerAdvisor(route, ctx) {
9
+ route('GET', '/api/advisor/settings', ({ res }) => {
10
+ sendJson(res, 200, resolveAdvisor(ctx.app));
11
+ });
12
+
13
+ route('PUT', '/api/advisor/settings', async ({ req, res }) => {
14
+ const body = await readJson(req);
15
+ const app = readApp();
16
+ const current = resolveAdvisor(ctx.app);
17
+ const next = { ...current };
18
+ if ('enabled' in body) next.enabled = body.enabled === true;
19
+ if ('code' in body) next.code = body.code !== false;
20
+ if ('explain' in body) next.explain = body.explain !== false;
21
+ if ('advice' in body) next.advice = body.advice !== false;
22
+ if ('notes' in body) next.notes = String(body.notes ?? '').slice(0, 2000);
23
+ if ('maxWords' in body) {
24
+ const value = Number(body.maxWords);
25
+ next.maxWords = Number.isFinite(value)
26
+ ? Math.min(Math.max(Math.round(value), 10), 120)
27
+ : current.maxWords;
28
+ }
29
+ app.advisor = next;
30
+ writeApp(app);
31
+ ctx.app = app;
32
+ sendJson(res, 200, resolveAdvisor(app));
33
+ });
34
+
35
+ route('POST', '/api/advisor/test', async ({ res }) => {
36
+ const options = resolveAdvisor(ctx.app);
37
+ const providerId = ctx.effectiveProviderId();
38
+ const model = ctx.effectiveModel();
39
+ if (!model) return sendError(res, 400, 'Elegí un proveedor y un modelo primero.');
40
+ const { provider, config } = ctx.resolveProvider({
41
+ ...ctx.providerSettings(providerId),
42
+ apiKey: ctx.apiKeyFor(providerId),
43
+ });
44
+ // Turno de ejemplo con una edicion: alcanza para ver si el asesor aporta algo.
45
+ const messages = [
46
+ {
47
+ role: 'assistant',
48
+ content: '',
49
+ tool_calls: [
50
+ {
51
+ id: 'test-1',
52
+ type: 'function',
53
+ function: { name: 'edit', arguments: JSON.stringify({ path: 'src/auth.js' }) },
54
+ },
55
+ ],
56
+ },
57
+ {
58
+ role: 'tool',
59
+ tool_call_id: 'test-1',
60
+ name: 'edit',
61
+ content: 'Editado src/auth.js\n- const token = "secreto123"\n+ const token = process.env.TOKEN',
62
+ },
63
+ ];
64
+ const advice = await consultAdvisor({
65
+ provider,
66
+ config,
67
+ model,
68
+ userInput: 'Revisá el manejo del token de sesión.',
69
+ answer: 'Listo, ajusté el archivo.',
70
+ messages,
71
+ options,
72
+ });
73
+ if (!advice) return sendError(res, 502, 'El asesor no devolvió una respuesta interpretable.');
74
+ sendJson(res, 200, advice);
75
+ });
76
+ }
@@ -17,6 +17,7 @@ import {
17
17
  toolResultPayload,
18
18
  } from '../lib/agentRun.js';
19
19
  import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/announcer.js';
20
+ import { resolveAdvisor, advisorShouldRun, consultAdvisor } from '../lib/advisor.js';
20
21
 
21
22
  /**
22
23
  * Emit streaming partials at most once per `ms` window, keeping only the
@@ -318,6 +319,35 @@ export function registerChat(route, ctx) {
318
319
  durationMs,
319
320
  say: fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }),
320
321
  });
322
+
323
+ // El asesor va despues del `done` pero antes del `finally`, asi el run
324
+ // sigue vivo y el evento llega/persiste. Puede quedarse callado.
325
+ const advisorOptions = resolveAdvisor(ctx.app);
326
+ if (
327
+ result.stopReason !== 'aborted' &&
328
+ advisorOptions.enabled &&
329
+ advisorShouldRun({ toolCalls, answer: result.content })
330
+ ) {
331
+ const advice = await consultAdvisor({
332
+ provider,
333
+ config,
334
+ model,
335
+ signal: ctx.runs.signal(sessionId),
336
+ userInput: input,
337
+ answer: result.content,
338
+ messages: newMessages,
339
+ options: advisorOptions,
340
+ });
341
+ if (advice?.speak) {
342
+ ctx.store.addMessage(sessionId, {
343
+ role: 'assistant',
344
+ name: 'advisor',
345
+ content: advice.text,
346
+ mode: advice.kind,
347
+ });
348
+ ctx.runs.emit(sessionId, 'advice', { text: advice.text, kind: advice.kind, say: advice.text });
349
+ }
350
+ }
321
351
  } catch (error) {
322
352
  partials.flush();
323
353
  if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', { say: fraseFin({ stopReason: 'aborted' }) });
@@ -39,7 +39,7 @@ export function registerTts(route, ctx) {
39
39
  if ('rate' in body) next.rate = sanearRate(body.rate, actual.rate);
40
40
  if (body.announce && typeof body.announce === 'object') {
41
41
  const announce = { ...actual.announce };
42
- for (const key of ['start', 'tools', 'end']) {
42
+ for (const key of ['start', 'tools', 'end', 'advice', 'answer']) {
43
43
  if (key in body.announce) announce[key] = body.announce[key] === true;
44
44
  }
45
45
  next.announce = announce;
@@ -0,0 +1,43 @@
1
+ // Alerta de version nueva y actualizacion con un click desde la consola.
2
+ //
3
+ // `GET /api/update` es solo informacion (cacheada). `POST /api/update` es
4
+ // privilegiado: **solo loopback y sin tunel** (`requireLocal`), igual que
5
+ // shell/git/carpetas. El server responde y se apaga; un script detached espera
6
+ // su muerte, instala la version nueva y lo vuelve a levantar.
7
+
8
+ import { sendJson, sendError } from '../lib/http.js';
9
+ import { requireLocal } from '../lib/workspace.js';
10
+ import { checkUpdate, spawnSelfUpdate } from '../lib/update.js';
11
+
12
+ export function registerUpdate(route, ctx) {
13
+ route('GET', '/api/update', async ({ res, query }) => {
14
+ const force = query.get('force') === '1';
15
+ sendJson(res, 200, await checkUpdate({ force }));
16
+ });
17
+
18
+ route('POST', '/api/update', async ({ req, res }) => {
19
+ if (!requireLocal(req, res, 'Actualizar solo esta permitido en la conexion local.')) return;
20
+
21
+ const info = await checkUpdate({ force: true });
22
+ if (!info.latest) return sendError(res, 502, info.error || 'No pude consultar la ultima version.');
23
+ if (!info.updateAvailable) {
24
+ return sendJson(res, 200, { ok: true, upToDate: true, current: info.current, latest: info.latest });
25
+ }
26
+
27
+ const spawnUpdater = ctx.spawnUpdater ?? spawnSelfUpdate;
28
+ const started = spawnUpdater({
29
+ pid: process.pid,
30
+ port: ctx.serverInfo?.port,
31
+ host: ctx.serverInfo?.host,
32
+ cwd: ctx.cwd,
33
+ target: info.latest,
34
+ });
35
+ if (!started.ok) return sendError(res, 500, started.error);
36
+
37
+ sendJson(res, 202, { ok: true, updating: true, from: info.current, to: info.latest });
38
+ // Soltar los archivos antes de que npm los reemplace.
39
+ setTimeout(() => {
40
+ Promise.resolve(ctx.shutdown?.()).catch(() => process.exit(0));
41
+ }, 800);
42
+ });
43
+ }
@@ -39,6 +39,8 @@ import { registerChannels } from './routes/channels.js';
39
39
  import { registerProfile } from './routes/profile.js';
40
40
  import { registerTts } from './routes/tts.js';
41
41
  import { registerTranscribe } from './routes/transcribe.js';
42
+ import { registerUpdate } from './routes/update.js';
43
+ import { registerAdvisor } from './routes/advisor.js';
42
44
  import { registerProcs } from './routes/procs.js';
43
45
  import { registerShell } from './routes/shell.js';
44
46
  import { registerPreview } from './routes/preview.js';
@@ -186,6 +188,13 @@ export async function createTamnoraServer({
186
188
  mergedMcpServers,
187
189
  procs,
188
190
  procsEnabled,
191
+ // Se completa en `listen()` con el puerto/host reales (util para reiniciar
192
+ // el server tras una actualizacion).
193
+ serverInfo: {
194
+ port: port ?? config.server?.port ?? 4517,
195
+ host: host ?? config.server?.host ?? '127.0.0.1',
196
+ url: '',
197
+ },
189
198
  };
190
199
 
191
200
  ctx.systemPromptFor = createPromptBuilder({
@@ -234,6 +243,8 @@ export async function createTamnoraServer({
234
243
  registerProfile(route, ctx);
235
244
  registerTts(route, ctx);
236
245
  registerTranscribe(route, ctx);
246
+ registerAdvisor(route, ctx);
247
+ registerUpdate(route, ctx);
237
248
  registerProcs(route, ctx);
238
249
  registerShell(route, ctx);
239
250
  const preview = registerPreview(route, ctx);
@@ -320,11 +331,13 @@ export async function createTamnoraServer({
320
331
  options.host ?? host ?? config.server?.host ?? '127.0.0.1',
321
332
  () => {
322
333
  const address = server.address();
323
- resolve({
334
+ const info = {
324
335
  host: address.address,
325
336
  port: address.port,
326
337
  url: `http://${address.address === '::' ? '127.0.0.1' : address.address}:${address.port}`,
327
- });
338
+ };
339
+ ctx.serverInfo = info;
340
+ resolve(info);
328
341
  }
329
342
  );
330
343
  });
@@ -354,5 +367,16 @@ export async function createTamnoraServer({
354
367
  }
355
368
  };
356
369
 
370
+ // Apagado ordenado pedido desde una ruta (actualizacion con un click): cierra
371
+ // el server y termina el proceso para que npm pueda reemplazar los archivos.
372
+ ctx.shutdown = async () => {
373
+ try {
374
+ await close();
375
+ } catch {
376
+ /* ya cerrado */
377
+ }
378
+ process.exit(0);
379
+ };
380
+
357
381
  return { server, config, store, ctx, listen, close, staticDir: staticDir ?? DEFAULT_PUBLIC_DIR };
358
382
  }
@@ -11,6 +11,7 @@ const MIME = {
11
11
  '.mjs': 'text/javascript; charset=utf-8',
12
12
  '.css': 'text/css; charset=utf-8',
13
13
  '.json': 'application/json; charset=utf-8',
14
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
14
15
  '.svg': 'image/svg+xml',
15
16
  '.png': 'image/png',
16
17
  '.jpg': 'image/jpeg',