@danieltmn/tamnora 0.9.0 → 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.
- package/CHANGELOG.md +12 -0
- package/harness/packages/core/src/config/app.js +2 -0
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/stt/google.js +91 -0
- package/harness/packages/server/public/app.js +659 -66
- package/harness/packages/server/public/index.html +120 -2
- package/harness/packages/server/public/styles.css +19 -0
- package/harness/packages/server/src/lib/channels/index.js +23 -3
- package/harness/packages/server/src/lib/channels/telegram.js +301 -93
- package/harness/packages/server/src/lib/presence.js +51 -0
- package/harness/packages/server/src/lib/profile.js +63 -0
- package/harness/packages/server/src/lib/prompt.js +7 -2
- package/harness/packages/server/src/lib/transcribe.js +51 -0
- package/harness/packages/server/src/routes/channels.js +2 -0
- package/harness/packages/server/src/routes/presence.js +17 -0
- package/harness/packages/server/src/routes/profile.js +22 -0
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/routes/transcribe.js +115 -0
- package/harness/packages/server/src/routes/tts.js +2 -1
- package/harness/packages/server/src/server.js +11 -0
- package/harness/packages/server/test/profile.test.js +85 -0
- package/harness/packages/server/test/telegram-features.test.js +241 -0
- package/harness/packages/server/test/telegram-voz.test.js +9 -0
- package/harness/packages/server/test/telegram.test.js +37 -2
- package/harness/packages/server/test/transcribe.test.js +215 -0
- package/harness/packages/server/test/tts.test.js +24 -0
- 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
|
-
|
|
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
|
|
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
|
+
});
|