@danieltmn/tamnora 0.9.1 → 0.9.2

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 (27) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/harness/packages/core/src/config/app.js +2 -0
  3. package/harness/packages/core/src/index.js +1 -0
  4. package/harness/packages/core/src/stt/google.js +91 -0
  5. package/harness/packages/server/public/app.js +652 -9
  6. package/harness/packages/server/public/index.html +120 -1
  7. package/harness/packages/server/public/styles.css +19 -0
  8. package/harness/packages/server/src/lib/channels/index.js +23 -3
  9. package/harness/packages/server/src/lib/channels/telegram.js +301 -93
  10. package/harness/packages/server/src/lib/presence.js +51 -0
  11. package/harness/packages/server/src/lib/profile.js +63 -0
  12. package/harness/packages/server/src/lib/prompt.js +7 -2
  13. package/harness/packages/server/src/lib/transcribe.js +51 -0
  14. package/harness/packages/server/src/routes/channels.js +2 -0
  15. package/harness/packages/server/src/routes/presence.js +17 -0
  16. package/harness/packages/server/src/routes/profile.js +22 -0
  17. package/harness/packages/server/src/routes/providers.js +2 -0
  18. package/harness/packages/server/src/routes/transcribe.js +115 -0
  19. package/harness/packages/server/src/routes/tts.js +2 -1
  20. package/harness/packages/server/src/server.js +11 -0
  21. package/harness/packages/server/test/profile.test.js +85 -0
  22. package/harness/packages/server/test/telegram-features.test.js +241 -0
  23. package/harness/packages/server/test/telegram-voz.test.js +9 -0
  24. package/harness/packages/server/test/telegram.test.js +37 -2
  25. package/harness/packages/server/test/transcribe.test.js +215 -0
  26. package/harness/packages/server/test/tts.test.js +24 -0
  27. package/package.json +1 -1
@@ -0,0 +1,63 @@
1
+ // Perfil del usuario: datos que el agente conoce de la persona (cómo llamarla,
2
+ // idioma, notas y pares libres). Vive en app.json (la casa) como JSON legible y
3
+ // se inyecta en el system prompt de todas las corridas del server.
4
+
5
+ export const DEFAULT_PROFILE = {
6
+ address: '',
7
+ name: '',
8
+ language: '',
9
+ notes: '',
10
+ fields: {},
11
+ };
12
+
13
+ const text = (value) => String(value ?? '').trim();
14
+ const clamp = (value, max) => text(value).slice(0, max);
15
+
16
+ /** Normaliza los pares clave/valor extra (claves y valores no vacíos, acotados). */
17
+ export function sanitizeFields(raw) {
18
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
19
+ const out = {};
20
+ for (const [key, value] of Object.entries(raw)) {
21
+ const name = clamp(key, 60);
22
+ if (!name) continue;
23
+ const val = clamp(value, 500);
24
+ if (val) out[name] = val;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** Merge de defaults + lo guardado en app.json. */
30
+ export function resolveProfile(app) {
31
+ const saved = app?.profile && typeof app.profile === 'object' ? app.profile : {};
32
+ return {
33
+ address: clamp(saved.address, 120),
34
+ name: clamp(saved.name, 120),
35
+ language: clamp(saved.language, 60),
36
+ notes: clamp(saved.notes, 2000),
37
+ fields: sanitizeFields(saved.fields),
38
+ };
39
+ }
40
+
41
+ /** Aplica un body de PUT sobre el perfil actual. */
42
+ export function sanitizeProfile(body, current = DEFAULT_PROFILE) {
43
+ const value = body && typeof body === 'object' ? body : {};
44
+ const next = { ...current };
45
+ if ('address' in value) next.address = clamp(value.address, 120);
46
+ if ('name' in value) next.name = clamp(value.name, 120);
47
+ if ('language' in value) next.language = clamp(value.language, 60);
48
+ if ('notes' in value) next.notes = clamp(value.notes, 2000);
49
+ if ('fields' in value) next.fields = sanitizeFields(value.fields);
50
+ return next;
51
+ }
52
+
53
+ /** Bloque de system prompt con lo que el agente sabe del usuario ('' si vacío). */
54
+ export function profilePrompt(profile) {
55
+ const lines = [];
56
+ if (profile.address) lines.push(`- Dirigite a la persona como: "${profile.address}".`);
57
+ if (profile.name) lines.push(`- Nombre: ${profile.name}.`);
58
+ if (profile.language) lines.push(`- Idioma preferido: ${profile.language}.`);
59
+ if (profile.notes) lines.push(`- Notas: ${profile.notes}`);
60
+ for (const [key, value] of Object.entries(profile.fields)) lines.push(`- ${key}: ${value}`);
61
+ if (!lines.length) return '';
62
+ return `## Sobre el usuario\n${lines.join('\n')}`;
63
+ }
@@ -31,9 +31,10 @@ function workspacePreamble(cwd) {
31
31
  * @param {object} opts
32
32
  * @param {string} opts.cwd - server default workspace
33
33
  * @param {string[]} opts.files - instruction file names (e.g. ['AGENTS.md'])
34
+ * @param {() => string} [opts.profileText] - user profile block, re-read on every call
34
35
  * @param {() => string} [opts.skillsText] - skills block, re-read on every call
35
36
  */
36
- export function createPromptBuilder({ cwd, files, skillsText }) {
37
+ export function createPromptBuilder({ cwd, files, profileText, skillsText }) {
37
38
  const cache = new Map();
38
39
 
39
40
  async function buildFor(workspace) {
@@ -46,7 +47,11 @@ export function createPromptBuilder({ cwd, files, skillsText }) {
46
47
  const key = workspace || cwd;
47
48
  if (!cache.has(key)) cache.set(key, buildFor(key));
48
49
  const base = await cache.get(key);
50
+ const parts = [base];
51
+ const profile = typeof profileText === 'function' ? String(profileText() ?? '').trim() : '';
52
+ if (profile) parts.push(profile);
49
53
  const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
50
- return extra ? `${base}\n\n---\n\n${extra}` : base;
54
+ if (extra) parts.push(extra);
55
+ return parts.join('\n\n---\n\n');
51
56
  };
52
57
  }
@@ -0,0 +1,51 @@
1
+ // Transcripcion de audio a texto, compartida por Telegram (notas de voz) y la
2
+ // consola (microfono en el input). Usa el endpoint OpenAI-compatible
3
+ // `/audio/transcriptions` del proveedor (OpenAI, Groq, ...).
4
+
5
+ export const DEFAULT_TRANSCRIBE_MODEL = 'whisper-1';
6
+
7
+ /** Detalle legible del error del proveedor (OpenAI/Groq devuelven `{error:{message}}`). */
8
+ async function providerError(res) {
9
+ const body = await res.text().catch(() => '');
10
+ try {
11
+ const json = JSON.parse(body);
12
+ return json?.error?.message || json?.error || json?.message || body;
13
+ } catch {
14
+ return body;
15
+ }
16
+ }
17
+
18
+ export async function transcribeAudio(
19
+ ctx,
20
+ { buffer, filename = 'audio.webm', mime = '', providerId, model, language } = {}
21
+ ) {
22
+ const id = providerId || ctx.effectiveProviderId();
23
+ const settings = { ...(ctx.providerSettings(id) ?? {}), apiKey: ctx.apiKeyFor(id) };
24
+ // `providerSettings` no trae el baseUrl de los proveedores builtin (lo resuelve
25
+ // el runtime), asi que resolvemos como lo hace el agente.
26
+ const resolved = ctx.resolveProvider?.(settings)?.config ?? {};
27
+ const baseUrl = String(settings.baseUrl || resolved.baseUrl || '').replace(/\/+$/, '');
28
+ const apiKey = settings.apiKey || resolved.apiKey || '';
29
+ if (!baseUrl) throw new Error(`el proveedor "${id}" no tiene baseUrl para transcribir`);
30
+ const form = new FormData();
31
+ form.append('file', new Blob([buffer], mime ? { type: mime } : undefined), filename);
32
+ form.append('model', model || DEFAULT_TRANSCRIBE_MODEL);
33
+ // Whisper espera ISO-639-1 (`es`, no `es-AR`): sin esto autodetecta y en clips
34
+ // cortos se confunde de idioma.
35
+ const idioma = String(language ?? '').trim().split(/[-_]/)[0].toLowerCase();
36
+ if (/^[a-z]{2}$/.test(idioma)) form.append('language', idioma);
37
+ const res = await fetch(`${baseUrl}/audio/transcriptions`, {
38
+ method: 'POST',
39
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
40
+ body: form,
41
+ });
42
+ if (!res.ok) {
43
+ const detail = String((await providerError(res)) ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
44
+ const hint = res.status === 404 ? ` — "${id}" no expone /audio/transcriptions` : '';
45
+ throw new Error(
46
+ `transcripción falló (HTTP ${res.status})${hint}${detail ? `: ${detail}` : ''}`
47
+ );
48
+ }
49
+ const data = await res.json();
50
+ return String(data.text ?? '').trim();
51
+ }
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { readApp, writeApp } from '../lib/core.js';
5
5
  import { getTelegramBotInfo } from '../lib/channels/telegram.js';
6
+ import { sanitizeNotify } from '../lib/channels/index.js';
6
7
  import { readJson, sendJson } from '../lib/http.js';
7
8
 
8
9
  function sanitizeAllowed(value) {
@@ -31,6 +32,7 @@ export function registerChannels(route, ctx) {
31
32
  if ('allowedChatIds' in body) next.allowedChatIds = sanitizeAllowed(body.allowedChatIds);
32
33
  if ('transcribeModel' in body) next.transcribeModel = String(body.transcribeModel ?? '').trim() || 'whisper-1';
33
34
  if ('transcribeProvider' in body) next.transcribeProvider = String(body.transcribeProvider ?? '').trim();
35
+ if ('notify' in body) next.notify = sanitizeNotify(body.notify);
34
36
 
35
37
  app.telegram = next;
36
38
  writeApp(app);
@@ -0,0 +1,17 @@
1
+ // Reporte de pestañas abiertas de la consola. No hay estado que devolver: el
2
+ // cliente manda su lista (latido) y las que cerró, y el server lo recuerda
3
+ // mientras no venzan.
4
+
5
+ import { readJson, sendJson } from '../lib/http.js';
6
+
7
+ const toIds = (value) =>
8
+ Array.isArray(value) ? value.map((v) => String(v).trim()).filter(Boolean) : [];
9
+
10
+ export function registerPresence(route, ctx) {
11
+ route('POST', '/api/presence', async ({ req, res }) => {
12
+ const body = await readJson(req).catch(() => ({}));
13
+ ctx.presence?.close?.(toIds(body.closed));
14
+ ctx.presence?.touch?.(toIds(body.sessions));
15
+ sendJson(res, 200, { ok: true });
16
+ });
17
+ }
@@ -0,0 +1,22 @@
1
+ // Perfil del usuario (app.json -> profile). El agente lo lee en cada corrida, así
2
+ // que un cambio aplica sin recargar nada.
3
+
4
+ import { readApp, writeApp } from '../lib/core.js';
5
+ import { resolveProfile, sanitizeProfile } from '../lib/profile.js';
6
+ import { readJson, sendJson } from '../lib/http.js';
7
+
8
+ export function registerProfile(route, ctx) {
9
+ route('GET', '/api/profile', ({ res }) => {
10
+ sendJson(res, 200, { profile: resolveProfile(ctx.app) });
11
+ });
12
+
13
+ route('PUT', '/api/profile', async ({ req, res }) => {
14
+ const body = await readJson(req);
15
+ const app = readApp();
16
+ const next = sanitizeProfile(body, resolveProfile(ctx.app));
17
+ app.profile = next;
18
+ writeApp(app);
19
+ ctx.app = app;
20
+ sendJson(res, 200, { profile: next });
21
+ });
22
+ }
@@ -75,6 +75,7 @@ export function registerProviders(route, ctx) {
75
75
  maxRounds: ctx.effectiveMaxRounds(),
76
76
  maxTokens: ctx.effectiveMaxTokens(),
77
77
  maxDurationMs: ctx.effectiveMaxDurationMs(),
78
+ defaultCwd: ctx.store.getSetting('defaultCwd') || '',
78
79
  });
79
80
 
80
81
  route('GET', '/api/settings', ({ res }) => {
@@ -86,6 +87,7 @@ export function registerProviders(route, ctx) {
86
87
  if (body.provider !== undefined) ctx.store.setSetting('activeProvider', String(body.provider) || null);
87
88
  if (body.model !== undefined) ctx.store.setSetting('activeModel', String(body.model) || null);
88
89
  if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
90
+ if (body.defaultCwd !== undefined) ctx.store.setSetting('defaultCwd', String(body.defaultCwd ?? '').trim() || null);
89
91
  for (const key of ['maxRounds', 'maxTokens', 'maxDurationMs']) {
90
92
  if (body[key] === undefined) continue;
91
93
  const value = Number(body[key]);
@@ -0,0 +1,115 @@
1
+ // Transcripcion bajo demanda para la consola (dictado del input) + sus ajustes.
2
+ // El microfono graba, manda el audio como data URL y recibe el texto. Comparte el
3
+ // mismo helper que las notas de voz de Telegram.
4
+ //
5
+ // El proveedor activo no siempre sabe transcribir (p.ej. DeepSeek), por eso hay
6
+ // un proveedor/modelo de transcripcion propio: `app.json` -> `transcribe`, con
7
+ // fallback a lo configurado en Telegram y recien despues al proveedor activo.
8
+
9
+ import { readApp, writeApp, reconocerGoogle } from '../lib/core.js';
10
+ import { transcribeAudio } from '../lib/transcribe.js';
11
+ import { readJson, sendJson, sendError } from '../lib/http.js';
12
+
13
+ const MODES = new Set(['server', 'google', 'browser']);
14
+
15
+ // El tipo puede traer parámetros (`audio/webm;codecs=opus`): el grupo 1 es el
16
+ // MIME (sin coma) y el grupo 2 el base64.
17
+ const DATA_URL = /^data:([^,]+);base64,(.+)$/s;
18
+
19
+ const EXT_POR_MIME = [
20
+ ['wav', 'wav'],
21
+ ['ogg', 'ogg'],
22
+ ['opus', 'ogg'],
23
+ ['mp4', 'm4a'],
24
+ ['m4a', 'm4a'],
25
+ ['mpeg', 'mp3'],
26
+ ['mp3', 'mp3'],
27
+ ['flac', 'flac'],
28
+ ['webm', 'webm'],
29
+ ];
30
+
31
+ /** Extension coherente con el MIME para que el proveedor detecte el formato. */
32
+ function filenameFor(mime) {
33
+ const base = String(mime || '').toLowerCase();
34
+ const found = EXT_POR_MIME.find(([needle]) => base.includes(needle));
35
+ return `audio.${found ? found[1] : 'webm'}`;
36
+ }
37
+
38
+ /** Proveedor/modelo efectivos: ajuste propio -> Telegram -> proveedor activo. */
39
+ export function resolveTranscribe(app = {}, override = {}) {
40
+ const saved = app.transcribe && typeof app.transcribe === 'object' ? app.transcribe : {};
41
+ const telegram = app.telegram && typeof app.telegram === 'object' ? app.telegram : {};
42
+ return {
43
+ provider:
44
+ String(override.provider ?? '').trim() || saved.provider || telegram.transcribeProvider || undefined,
45
+ model: String(override.model ?? '').trim() || saved.model || telegram.transcribeModel || undefined,
46
+ };
47
+ }
48
+
49
+ function settingsView(app) {
50
+ const saved = app.transcribe && typeof app.transcribe === 'object' ? app.transcribe : {};
51
+ const telegram = app.telegram && typeof app.telegram === 'object' ? app.telegram : {};
52
+ return {
53
+ // `server` usa el proveedor de abajo; `google` usa el servicio de voz de
54
+ // Chromium (sin API key, en el server); `browser` usa la transcripción nativa
55
+ // del navegador (Web Speech API), sin pasar por el server.
56
+ mode: MODES.has(saved.mode) ? saved.mode : 'server',
57
+ language: String(saved.language ?? 'es-AR'),
58
+ provider: String(saved.provider ?? ''),
59
+ model: String(saved.model ?? ''),
60
+ fallback: {
61
+ provider: String(telegram.transcribeProvider ?? ''),
62
+ model: String(telegram.transcribeModel ?? ''),
63
+ },
64
+ };
65
+ }
66
+
67
+ export function registerTranscribe(route, ctx) {
68
+ route('GET', '/api/transcribe/settings', ({ res }) => {
69
+ sendJson(res, 200, settingsView(ctx.app));
70
+ });
71
+
72
+ route('PUT', '/api/transcribe/settings', async ({ req, res }) => {
73
+ const body = await readJson(req);
74
+ const app = readApp();
75
+ const next = { ...(app.transcribe ?? {}) };
76
+ if ('mode' in body) next.mode = MODES.has(body.mode) ? body.mode : 'server';
77
+ if ('language' in body) next.language = String(body.language ?? '').trim() || 'es-AR';
78
+ if ('provider' in body) next.provider = String(body.provider ?? '').trim();
79
+ if ('model' in body) next.model = String(body.model ?? '').trim();
80
+ app.transcribe = next;
81
+ writeApp(app);
82
+ ctx.app = app;
83
+ sendJson(res, 200, settingsView(app));
84
+ });
85
+
86
+ route('POST', '/api/transcribe', async ({ req, res }) => {
87
+ const body = await readJson(req, { limit: 25 * 1024 * 1024 });
88
+ const match = DATA_URL.exec(String(body.audio ?? ''));
89
+ if (!match) return sendError(res, 400, 'Falta el audio (data URL base64).');
90
+ const mime = match[1].split(';')[0].trim();
91
+ const buffer = Buffer.from(match[2], 'base64');
92
+ if (!buffer.length) return sendError(res, 400, 'El audio está vacío.');
93
+ const { provider, model } = resolveTranscribe(ctx.app, body);
94
+ const language = String(body.language ?? '').trim() || ctx.app?.transcribe?.language || 'es-AR';
95
+ const mode = String(body.engine ?? '').trim() || (MODES.has(ctx.app?.transcribe?.mode) ? ctx.app.transcribe.mode : 'server');
96
+ try {
97
+ const text =
98
+ mode === 'google'
99
+ ? await reconocerGoogle(buffer, { lang: language })
100
+ : await transcribeAudio(ctx, {
101
+ buffer,
102
+ mime,
103
+ filename: filenameFor(mime),
104
+ providerId: provider,
105
+ model,
106
+ language,
107
+ });
108
+ if (!text) return sendError(res, 422, 'No se pudo transcribir el audio.');
109
+ sendJson(res, 200, { text });
110
+ } catch (error) {
111
+ console.error(`[transcribe] ${error?.message ?? error}`);
112
+ sendError(res, 502, error?.message ?? String(error));
113
+ }
114
+ });
115
+ }
@@ -52,7 +52,8 @@ export function registerTts(route, ctx) {
52
52
  });
53
53
 
54
54
  route('GET', '/api/tts/voices', async ({ res, query }) => {
55
- const filtro = /^[a-zA-Z]{2}-?$/.test(String(query.get('filter') ?? 'es-')) ? query.get('filter') : 'es-';
55
+ const pedido = String(query.get('filter') ?? 'es-').trim() || 'es-';
56
+ const filtro = /^[a-zA-Z]{2}-?$/.test(pedido) ? pedido : 'es-';
56
57
  try {
57
58
  const voces = await catalogoVoces(filtro);
58
59
  sendJson(res, 200, { voices: voces });
@@ -16,8 +16,10 @@ import {
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';
19
+ import { profilePrompt, resolveProfile } from './lib/profile.js';
19
20
  import { skillsPrompt } from './lib/skills.js';
20
21
  import { createApprovalBroker } from './lib/approvals.js';
22
+ import { createPresence } from './lib/presence.js';
21
23
  import { createRunManager } from './lib/runManager.js';
22
24
  import { createProcManager } from './lib/procs.js';
23
25
  import { createChannelsManager } from './lib/channels/index.js';
@@ -34,13 +36,16 @@ import { registerProviders } from './routes/providers.js';
34
36
  import { registerMcp } from './routes/mcp.js';
35
37
  import { registerSkills } from './routes/skills.js';
36
38
  import { registerChannels } from './routes/channels.js';
39
+ import { registerProfile } from './routes/profile.js';
37
40
  import { registerTts } from './routes/tts.js';
41
+ import { registerTranscribe } from './routes/transcribe.js';
38
42
  import { registerProcs } from './routes/procs.js';
39
43
  import { registerShell } from './routes/shell.js';
40
44
  import { registerPreview } from './routes/preview.js';
41
45
  import { registerChat } from './routes/chat.js';
42
46
  import { registerEvents } from './routes/events.js';
43
47
  import { registerState } from './routes/state.js';
48
+ import { registerPresence } from './routes/presence.js';
44
49
  import { registerEval } from './routes/eval.js';
45
50
 
46
51
  const VERSION = readPackageVersion();
@@ -78,6 +83,7 @@ export async function createTamnoraServer({
78
83
  const authEnabled = Boolean(app.user && app.user.password);
79
84
  const store = new Store(dbPath ?? databasePath(cwd));
80
85
  const approvals = createApprovalBroker({ timeoutMs: config.approvalTimeoutMs });
86
+ const presence = createPresence();
81
87
  const runs = createRunManager({ store });
82
88
  const procs = createProcManager({ allow: config.processes?.allow });
83
89
  const procsEnabled = config.processes?.enabled !== false;
@@ -160,6 +166,7 @@ export async function createTamnoraServer({
160
166
  authEnabled,
161
167
  store,
162
168
  approvals,
169
+ presence,
163
170
  runs,
164
171
  isAutoApprove,
165
172
  setAutoApprove,
@@ -184,6 +191,7 @@ export async function createTamnoraServer({
184
191
  ctx.systemPromptFor = createPromptBuilder({
185
192
  cwd,
186
193
  files: config.instructions,
194
+ profileText: () => profilePrompt(resolveProfile(ctx.app)),
187
195
  skillsText: () => skillsPrompt(ctx),
188
196
  });
189
197
  ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
@@ -223,13 +231,16 @@ export async function createTamnoraServer({
223
231
  registerMcp(route, ctx);
224
232
  registerSkills(route, ctx);
225
233
  registerChannels(route, ctx);
234
+ registerProfile(route, ctx);
226
235
  registerTts(route, ctx);
236
+ registerTranscribe(route, ctx);
227
237
  registerProcs(route, ctx);
228
238
  registerShell(route, ctx);
229
239
  const preview = registerPreview(route, ctx);
230
240
  registerChat(route, ctx);
231
241
  registerEvents(route, ctx);
232
242
  registerState(route, ctx);
243
+ registerPresence(route, ctx);
233
244
  registerEval(route, ctx);
234
245
 
235
246
  function matchRoute(method, pathname) {
@@ -0,0 +1,85 @@
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, readApp } from '../../core/src/index.js';
8
+ import { resolveProfile, sanitizeProfile, profilePrompt } from '../src/lib/profile.js';
9
+
10
+ const fakeProvider = {
11
+ id: 'fake',
12
+ async streamChat(_config, { onEvent }) {
13
+ onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
14
+ return { content: 'hola', reasoning: '', toolCalls: [] };
15
+ },
16
+ };
17
+
18
+ async function startServer(overrides = {}) {
19
+ const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-prof-')));
20
+ setHome(cwd);
21
+ const instance = await createTamnoraServer({
22
+ cwd,
23
+ dbPath: path.join(cwd, 'tamnora.db'),
24
+ loadMcp: false,
25
+ resolveProvider: () => ({ provider: fakeProvider, config: {} }),
26
+ ...overrides,
27
+ });
28
+ const info = await instance.listen({ port: 0 });
29
+ return { instance, base: info.url, cwd };
30
+ }
31
+
32
+ test('resolveProfile y sanitizeProfile normalizan los datos', () => {
33
+ assert.deepEqual(resolveProfile({}), { address: '', name: '', language: '', notes: '', fields: {} });
34
+ const next = sanitizeProfile({
35
+ address: ' Amo ',
36
+ name: 'Daniel',
37
+ language: 'es',
38
+ notes: ' corto y al pie ',
39
+ fields: { 'zona horaria': 'CET', vacio: '', ' ': 'x' },
40
+ });
41
+ assert.equal(next.address, 'Amo');
42
+ assert.equal(next.notes, 'corto y al pie');
43
+ assert.deepEqual(next.fields, { 'zona horaria': 'CET' });
44
+ });
45
+
46
+ test('profilePrompt arma el bloque solo con lo que hay', () => {
47
+ assert.equal(profilePrompt(resolveProfile({})), '');
48
+ const block = profilePrompt({ address: 'Señor', name: 'Daniel', language: '', notes: 'Preferí respuestas cortas.', fields: { Empresa: 'Tamnora' } });
49
+ assert.match(block, /## Sobre el usuario/);
50
+ assert.match(block, /como: "Señor"/);
51
+ assert.match(block, /Nombre: Daniel/);
52
+ assert.match(block, /Notas: Preferí respuestas cortas\./);
53
+ assert.match(block, /Empresa: Tamnora/);
54
+ assert.ok(!block.includes('Idioma preferido'));
55
+ });
56
+
57
+ test('el perfil se guarda en app.json y se inyecta en el system prompt', async () => {
58
+ const { instance, base, cwd } = await startServer();
59
+ try {
60
+ const initial = await (await fetch(`${base}/api/profile`)).json();
61
+ assert.equal(initial.profile.address, '');
62
+
63
+ const put = await fetch(`${base}/api/profile`, {
64
+ method: 'PUT',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({ address: 'Compañero', name: 'Daniel', language: 'es', fields: { ciudad: 'Rosario' } }),
67
+ });
68
+ assert.equal(put.status, 200);
69
+ const saved = (await put.json()).profile;
70
+ assert.equal(saved.address, 'Compañero');
71
+
72
+ // Persistido en la casa (app.json), no solo en memoria.
73
+ assert.equal(readApp().profile.address, 'Compañero');
74
+
75
+ const prompt = await instance.ctx.systemPromptFor(cwd);
76
+ assert.match(prompt, /## Sobre el usuario/);
77
+ assert.match(prompt, /como: "Compañero"/);
78
+ assert.match(prompt, /ciudad: Rosario/);
79
+
80
+ const reloaded = await (await fetch(`${base}/api/profile`)).json();
81
+ assert.equal(reloaded.profile.fields.ciudad, 'Rosario');
82
+ } finally {
83
+ await instance.close();
84
+ }
85
+ });