@danieltmn/tamnora 0.18.0 → 0.20.0
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 +31 -0
- package/README.md +6 -3
- package/harness/packages/cli/src/commands/tui.js +2 -2
- package/harness/packages/cli/src/tui/app.js +2 -1
- package/harness/packages/core/src/agent/compact.js +6 -1
- package/harness/packages/core/src/agent/modes.js +29 -0
- package/harness/packages/core/src/providers/openaiCompatible.js +30 -17
- package/harness/packages/core/src/providers/stream.js +81 -7
- package/harness/packages/core/src/tools/executors.js +2 -2
- package/harness/packages/core/src/tools/registry.js +21 -1
- package/harness/packages/core/src/tools/schema.js +1 -0
- package/harness/packages/core/test/compact.test.js +4 -2
- package/harness/packages/core/test/providers.test.js +29 -0
- package/harness/packages/core/test/stream.test.js +46 -0
- package/harness/packages/core/test/tools.test.js +30 -0
- package/harness/packages/server/public/app.js +433 -53
- package/harness/packages/server/public/index.html +27 -8
- package/harness/packages/server/public/styles.css +51 -1
- package/harness/packages/server/src/lib/advisor.js +14 -13
- package/harness/packages/server/src/lib/channels/telegram.js +8 -3
- package/harness/packages/server/src/lib/compact.js +26 -1
- package/harness/packages/server/src/lib/scheduler.js +1 -0
- package/harness/packages/server/src/lib/sessionView.js +1 -0
- package/harness/packages/server/src/lib/tasks.js +11 -1
- package/harness/packages/server/src/lib/ttsSettings.js +11 -0
- package/harness/packages/server/src/routes/chat.js +20 -26
- package/harness/packages/server/src/routes/sessions.js +20 -11
- package/harness/packages/server/src/routes/tts.js +4 -2
- package/harness/packages/server/test/advisor.test.js +17 -13
- package/harness/packages/server/test/server.test.js +154 -51
- package/harness/packages/server/test/tts.test.js +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.20.0] - 2026-10-14
|
|
9
|
+
|
|
10
|
+
### Agregado
|
|
11
|
+
|
|
12
|
+
- **Modo Voz** (`voice`): como Plan (solo lectura) pero con `write`/`edit`
|
|
13
|
+
**limitados a `.md` y `.json`**. La restricción por extensión se aplica en el
|
|
14
|
+
motor de tools (`writeExtensions`), aunque el permiso del usuario sea `allow`, y
|
|
15
|
+
se propaga a la consola, Telegram, subagentes, scheduler y la CLI/TUI.
|
|
16
|
+
- **Manos libres** en la consola: loop conversacional
|
|
17
|
+
(escuchar → enviar → esperar la voz → volver a escuchar), con corte por silencio,
|
|
18
|
+
lectura forzada de la respuesta final y **auto-activación en modo Voz**.
|
|
19
|
+
- **Asesor de un solo mensaje** (estilo Jarvis): el resumen breve y la
|
|
20
|
+
alerta/consejo viajan en un mismo texto, con **una** tarjeta y **una** locución.
|
|
21
|
+
- **Tipo de agente por sesión**: cada sesión guarda su proveedor/modelo/modo y la
|
|
22
|
+
consola aplica y persiste ese tipo al abrirla.
|
|
23
|
+
|
|
24
|
+
### Corregido
|
|
25
|
+
|
|
26
|
+
- **Razonamiento y tokens con MiniMax/DeepSeek**: el pensamiento en línea
|
|
27
|
+
(`<think>`) y `reasoning_content` se separan del texto, y el streaming pide
|
|
28
|
+
`usage` (`stream_options`/`include_usage`) para que la consola muestre tokens y
|
|
29
|
+
contexto.
|
|
30
|
+
- **Compactar** ya no pierde el historial previo al resumen.
|
|
31
|
+
|
|
32
|
+
## [0.19.0] - 2026-10-12
|
|
33
|
+
|
|
34
|
+
### Cambios
|
|
35
|
+
|
|
36
|
+
- fix(consola): permiso y URL de preview legibles en movil
|
|
37
|
+
- feat(voz): velocidad y tono como rangos numericos
|
|
38
|
+
|
|
8
39
|
## [0.18.0] - 2026-10-12
|
|
9
40
|
|
|
10
41
|
### Agregado
|
package/README.md
CHANGED
|
@@ -12,10 +12,12 @@ con TUI, consola web, tools locales, MCP y evaluacion de modelos.
|
|
|
12
12
|
saca una captura PNG o lista los errores de consola) y `preview_url`.
|
|
13
13
|
- **Voz**: el agente narra su progreso, puede **leer la respuesta final** (sin
|
|
14
14
|
bloques de codigo ni tablas) y mandar audio bajo pedido (`speak`,
|
|
15
|
-
`/audioresumen`).
|
|
15
|
+
`/audioresumen`). El **modo Voz** habilita **manos libres**: hablás y responde en
|
|
16
|
+
audio, como una conversacion (y solo escribe notas `.md`/`.json`).
|
|
16
17
|
- **Asesor**: una capa de comentario sobre el run que interpreta lo que hizo el
|
|
17
|
-
agente y,
|
|
18
|
-
|
|
18
|
+
agente y, en **un solo mensaje** estilo Jarvis, resume lo hecho y —si aporta—
|
|
19
|
+
avisa de un problema en el codigo, explica un proceso o da un consejo (tarjeta en
|
|
20
|
+
el chat y voz opcional). No lee la respuesta.
|
|
19
21
|
- **Actualizar desde la consola**: la consola avisa cuando hay una version nueva
|
|
20
22
|
y la instala con un click (el server se reinicia solo).
|
|
21
23
|
- **Memoria y reglas**: memoria durable por proyecto (`memory`) y reglas por path
|
|
@@ -55,6 +57,7 @@ tamnora serve # consola web
|
|
|
55
57
|
```bash
|
|
56
58
|
tamnora # TUI full-screen (REPL si no hay TTY)
|
|
57
59
|
tamnora tui --mode plan # modo plan: solo lectura, sin escritura ni bash
|
|
60
|
+
tamnora tui --mode voice # modo voz: solo lectura + notas .md/.json
|
|
58
61
|
```
|
|
59
62
|
|
|
60
63
|
Atajos: `enter` enviar · `↑/↓` historial · `←/→` cursor · `home/end` inicio/fin ·
|
|
@@ -77,7 +77,7 @@ export async function tuiCommand(argv) {
|
|
|
77
77
|
);
|
|
78
78
|
const modeContext = (id) => {
|
|
79
79
|
const m = resolveMode(id);
|
|
80
|
-
return { label: m.label, enabled: enabledFor(m), systemPrompt: promptFor(m) };
|
|
80
|
+
return { label: m.label, enabled: enabledFor(m), systemPrompt: promptFor(m), writeExtensions: m.writeExtensions };
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
const sessionId = store.createSession({ title: 'Consola', provider: providerId, model, cwd });
|
|
@@ -127,7 +127,7 @@ Consola full-screen (estilo opencode/codex). Atajos:
|
|
|
127
127
|
Opciones:
|
|
128
128
|
--provider <id> Proveedor (por defecto: el activo)
|
|
129
129
|
--model <id> Modelo (por defecto: el activo)
|
|
130
|
-
--mode <id> Modo de agente: agent | plan
|
|
130
|
+
--mode <id> Modo de agente: agent | plan | voice
|
|
131
131
|
--dir <path> Directorio del proyecto
|
|
132
132
|
--max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
|
|
133
133
|
--tools <lista> Tools habilitadas (coma) --no-tools
|
|
@@ -86,7 +86,8 @@ export function runTui({
|
|
|
86
86
|
draw();
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
const buildTools = () =>
|
|
89
|
+
const buildTools = () =>
|
|
90
|
+
createTools({ ...toolOptions, enabled: modeEnabled, writeExtensions: activeMode.writeExtensions, onAsk });
|
|
90
91
|
let toolset = buildTools();
|
|
91
92
|
|
|
92
93
|
function setMode(id) {
|
|
@@ -9,13 +9,18 @@ export const COMPACT_INSTRUCTION =
|
|
|
9
9
|
export const SUMMARY_INSTRUCTION =
|
|
10
10
|
'Resumí la conversación para la persona: qué se pidió, qué se hizo, decisiones tomadas, archivos o comandos relevantes y el estado actual. Sé claro y conciso, en español, sin markdown pesado.';
|
|
11
11
|
|
|
12
|
+
// Mensajes que existen para el usuario pero nunca entran al contexto del modelo:
|
|
13
|
+
// el asesor (capa de comentario) y la marca de compactación (el resumen ya viaja
|
|
14
|
+
// aparte como `meta.summary`).
|
|
15
|
+
const NON_CONTEXT_NAMES = new Set(['advisor', 'compact']);
|
|
16
|
+
|
|
12
17
|
/**
|
|
13
18
|
* Historial vigente: sin los mensajes del asesor ni lo ya resumido (anterior al
|
|
14
19
|
* cursor de compactación). Si el cursor no está (historial truncado), devuelve
|
|
15
20
|
* todo para no perder contexto.
|
|
16
21
|
*/
|
|
17
22
|
export function liveHistory(history = [], cursor = null) {
|
|
18
|
-
const filtered = history.filter((message) => message.name
|
|
23
|
+
const filtered = history.filter((message) => !NON_CONTEXT_NAMES.has(message.name));
|
|
19
24
|
if (!cursor) return filtered;
|
|
20
25
|
const idx = filtered.findIndex((message) => message.id === cursor);
|
|
21
26
|
return idx === -1 ? filtered : filtered.slice(idx + 1);
|
|
@@ -33,6 +33,35 @@ export const MODES = {
|
|
|
33
33
|
systemPrompt:
|
|
34
34
|
'Estás en modo PLAN: no modifiques archivos ni ejecutes comandos. Investigá el proyecto con las herramientas de solo lectura y respondé con un plan concreto y accionable.',
|
|
35
35
|
},
|
|
36
|
+
voice: {
|
|
37
|
+
id: 'voice',
|
|
38
|
+
label: 'Voz',
|
|
39
|
+
description: 'Conversación por voz y notas: como Plan, pero solo crea/edita .md y .json.',
|
|
40
|
+
tools: [
|
|
41
|
+
'read',
|
|
42
|
+
'list',
|
|
43
|
+
'glob',
|
|
44
|
+
'grep',
|
|
45
|
+
'fetch_url',
|
|
46
|
+
'speak',
|
|
47
|
+
'screenshot',
|
|
48
|
+
'console_errors',
|
|
49
|
+
'preview_url',
|
|
50
|
+
'ask_user',
|
|
51
|
+
'offer_choices',
|
|
52
|
+
'show_plan',
|
|
53
|
+
'git_status',
|
|
54
|
+
'git_diff',
|
|
55
|
+
'git_log',
|
|
56
|
+
'write',
|
|
57
|
+
'edit',
|
|
58
|
+
],
|
|
59
|
+
writeExtensions: ['.md', '.json'],
|
|
60
|
+
systemPrompt:
|
|
61
|
+
'Estás en modo VOZ: una conversación hablada con el usuario, breve y natural (tus respuestas se leen en voz alta). ' +
|
|
62
|
+
'No ejecutes comandos. Podés investigar el proyecto con las herramientas de solo lectura y SOLO podés crear o editar archivos .md y .json ' +
|
|
63
|
+
'(notas, ideas, listas, datos). Nunca toques otro tipo de archivo; si el usuario pide modificar código, decíselo y ofrecele hacerlo en modo Build.',
|
|
64
|
+
},
|
|
36
65
|
};
|
|
37
66
|
|
|
38
67
|
export const MODE_IDS = Object.keys(MODES);
|
|
@@ -23,24 +23,37 @@ export const openaiCompatibleProvider = {
|
|
|
23
23
|
},
|
|
24
24
|
|
|
25
25
|
async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
const buildBody = () => {
|
|
27
|
+
const body = { model, messages, stream: true };
|
|
28
|
+
if (tools?.length) body.tools = tools;
|
|
29
|
+
if (maxOutputTokens > 0) {
|
|
30
|
+
// OpenAI o-series exige `max_completion_tokens`; el resto usa `max_tokens`.
|
|
31
|
+
const field = this?.maxTokensField ?? 'max_tokens';
|
|
32
|
+
body[field] = maxOutputTokens;
|
|
33
|
+
}
|
|
34
|
+
if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
|
|
35
|
+
return body;
|
|
36
|
+
};
|
|
37
|
+
const run = (body) =>
|
|
38
|
+
requestStream({
|
|
39
|
+
url: joinUrl(baseUrl, '/chat/completions'),
|
|
40
|
+
headers: authHeaders({ apiKey, headers }),
|
|
41
|
+
providerLabel: 'OpenAI-compatible',
|
|
42
|
+
model,
|
|
43
|
+
body,
|
|
44
|
+
signal,
|
|
45
|
+
onEvent,
|
|
46
|
+
});
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
48
|
+
// La mayoria de los proveedores (OpenAI, MiniMax, DeepSeek, Groq...) solo
|
|
49
|
+
// reportan `usage` en streaming si se pide explicitamente.
|
|
50
|
+
try {
|
|
51
|
+
return await run({ ...buildBody(), stream_options: { include_usage: true } });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
// Servidores OpenAI-compatibles que no conocen `stream_options`.
|
|
54
|
+
if (!/stream_options/i.test(error?.message ?? '')) throw error;
|
|
55
|
+
return run(buildBody());
|
|
56
|
+
}
|
|
44
57
|
},
|
|
45
58
|
};
|
|
46
59
|
|
|
@@ -89,6 +89,18 @@ export async function readOpenAISSE(response, { onEvent, signal } = {}) {
|
|
|
89
89
|
const toolCalls = [];
|
|
90
90
|
let serverError = null;
|
|
91
91
|
let usage = null;
|
|
92
|
+
// Algunos modelos abren y cierran el stream antes de mandar `usage`; guardamos
|
|
93
|
+
// el ultimo que hayamos visto (puede llegar en un frame sin `choices`).
|
|
94
|
+
const think = createThinkSplitter({
|
|
95
|
+
onText(piece) {
|
|
96
|
+
content += piece;
|
|
97
|
+
onEvent?.({ type: 'text', delta: piece, full: content });
|
|
98
|
+
},
|
|
99
|
+
onReasoning(piece) {
|
|
100
|
+
reasoning += piece;
|
|
101
|
+
onEvent?.({ type: 'reasoning', delta: piece, full: reasoning });
|
|
102
|
+
},
|
|
103
|
+
});
|
|
92
104
|
|
|
93
105
|
while (true) {
|
|
94
106
|
const { done, value } = await readChunk(reader, signal);
|
|
@@ -114,14 +126,14 @@ export async function readOpenAISSE(response, { onEvent, signal } = {}) {
|
|
|
114
126
|
const delta = parsed.choices?.[0]?.delta;
|
|
115
127
|
if (!delta) continue;
|
|
116
128
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
reasoning
|
|
123
|
-
onEvent?.({ type: 'reasoning', delta: delta.reasoning, full: reasoning });
|
|
129
|
+
// DeepSeek y MiniMax (con `reasoning_split`) mandan el pensamiento en
|
|
130
|
+
// `reasoning_content`; OpenRouter/OpenAI usan `reasoning`.
|
|
131
|
+
const reasoningDelta = delta.reasoning ?? delta.reasoning_content;
|
|
132
|
+
if (reasoningDelta) {
|
|
133
|
+
reasoning += reasoningDelta;
|
|
134
|
+
onEvent?.({ type: 'reasoning', delta: reasoningDelta, full: reasoning });
|
|
124
135
|
}
|
|
136
|
+
if (delta.content) think.feed(delta.content);
|
|
125
137
|
if (delta.tool_calls) {
|
|
126
138
|
for (const tc of delta.tool_calls) {
|
|
127
139
|
const idx = tc.index ?? 0;
|
|
@@ -142,9 +154,71 @@ export async function readOpenAISSE(response, { onEvent, signal } = {}) {
|
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
if (serverError) throw new Error(serverError);
|
|
157
|
+
think.flush();
|
|
145
158
|
return finalize({ content, reasoning, toolCalls, usage });
|
|
146
159
|
}
|
|
147
160
|
|
|
161
|
+
const THINK_OPEN = '<think>';
|
|
162
|
+
const THINK_CLOSE = '</think>';
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* MiniMax (sin `reasoning_split`) y otros modelos sin un campo de razonamiento
|
|
166
|
+
* mandan el pensamiento dentro del propio `content`, envuelto en
|
|
167
|
+
* `<think>…</think>`. Este separador incremental lo mueve a `reasoning`,
|
|
168
|
+
* tolerando que la etiqueta venga partida entre fragmentos del stream.
|
|
169
|
+
*/
|
|
170
|
+
function createThinkSplitter({ onText, onReasoning }) {
|
|
171
|
+
let mode = 'start';
|
|
172
|
+
let pending = '';
|
|
173
|
+
const emitText = (text) => text && onText(text);
|
|
174
|
+
const emitReasoning = (text) => text && onReasoning(text);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
feed(chunk) {
|
|
178
|
+
if (!chunk) return;
|
|
179
|
+
pending += chunk;
|
|
180
|
+
for (;;) {
|
|
181
|
+
if (mode === 'content') {
|
|
182
|
+
emitText(pending);
|
|
183
|
+
pending = '';
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (mode === 'start') {
|
|
187
|
+
if (pending.startsWith(THINK_OPEN)) {
|
|
188
|
+
pending = pending.slice(THINK_OPEN.length);
|
|
189
|
+
mode = 'thinking';
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// Mientras sea un prefijo de la etiqueta, puede ser el inicio: esperar.
|
|
193
|
+
if (THINK_OPEN.startsWith(pending)) return;
|
|
194
|
+
mode = 'content';
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const idx = pending.indexOf(THINK_CLOSE);
|
|
198
|
+
if (idx !== -1) {
|
|
199
|
+
emitReasoning(pending.slice(0, idx));
|
|
200
|
+
pending = pending.slice(idx + THINK_CLOSE.length);
|
|
201
|
+
mode = 'content';
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// Conservamos el posible cierre partido al final.
|
|
205
|
+
const keep = THINK_CLOSE.length - 1;
|
|
206
|
+
if (pending.length > keep) {
|
|
207
|
+
emitReasoning(pending.slice(0, pending.length - keep));
|
|
208
|
+
pending = pending.slice(-keep);
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
flush() {
|
|
214
|
+
if (!pending) return;
|
|
215
|
+
if (mode === 'thinking') emitReasoning(pending);
|
|
216
|
+
else emitText(pending);
|
|
217
|
+
pending = '';
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
148
222
|
/**
|
|
149
223
|
* Parse Ollama's native NDJSON stream (`/api/chat`).
|
|
150
224
|
* @returns {Promise<{content:string, reasoning:string, toolCalls:object[], usage:object|null}>}
|
|
@@ -422,11 +422,11 @@ const MAX_SPEAK_TEXT = 1200;
|
|
|
422
422
|
* son del cliente (la consola lo reproduce y Telegram lo manda como nota de
|
|
423
423
|
* voz), asi que aca solo se normaliza el pedido y se acota el largo.
|
|
424
424
|
*/
|
|
425
|
-
export function speakTool(_root, { texto, voice, rate } = {}) {
|
|
425
|
+
export function speakTool(_root, { texto, voice, rate, pitch } = {}) {
|
|
426
426
|
const value = String(texto ?? '').replace(/\s+/g, ' ').trim();
|
|
427
427
|
if (!value) return 'Error: falta "texto".';
|
|
428
428
|
const recortado = value.length > MAX_SPEAK_TEXT ? `${value.slice(0, MAX_SPEAK_TEXT - 1).trimEnd()}…` : value;
|
|
429
|
-
const opciones = [voice, rate].filter(Boolean).join(', ');
|
|
429
|
+
const opciones = [voice, rate, pitch].filter(Boolean).join(', ');
|
|
430
430
|
return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
|
|
431
431
|
}
|
|
432
432
|
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { TOOL_DEFINITIONS, TOOL_NAMES } from './schema.js';
|
|
2
3
|
import { EXECUTORS } from './executors.js';
|
|
3
4
|
import { evaluatePermission } from './permissions.js';
|
|
4
5
|
|
|
6
|
+
const WRITE_TOOLS = new Set(['write', 'edit']);
|
|
7
|
+
|
|
8
|
+
/** Extension (en minusculas) de un path, o '' si no tiene. */
|
|
9
|
+
function extensionOf(p) {
|
|
10
|
+
return path.extname(String(p ?? '')).toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
/**
|
|
6
14
|
* Build the local tool registry.
|
|
7
15
|
*
|
|
@@ -9,16 +17,25 @@ import { evaluatePermission } from './permissions.js';
|
|
|
9
17
|
* @param {string} opts.root - workspace root (all paths are confined here)
|
|
10
18
|
* @param {string[]} [opts.enabled] - tool names to expose (default: all)
|
|
11
19
|
* @param {object} [opts.permissions] - policy per tool ('allow'|'ask'|'deny' or pattern map)
|
|
20
|
+
* @param {string[]} [opts.writeExtensions] - si se define, `write`/`edit` solo aceptan estas extensiones (p.ej. ['.md', '.json'])
|
|
12
21
|
* @param {(name:string, args:object)=>Promise<boolean>} [opts.onAsk] - interactive approval
|
|
13
22
|
* @param {(payload:{title:string, questions:object[]})=>Promise<{answers:object,cancelled?:boolean}>} [opts.onQuestion] - interactive form
|
|
14
23
|
* @param {boolean} [opts.yes] - auto-approve 'ask' (non-interactive)
|
|
15
24
|
* @param {AbortSignal} [opts.signal] - cancela las tools de larga duracion (bash, git)
|
|
16
25
|
*/
|
|
17
|
-
export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, yes = false, signal } = {}) {
|
|
26
|
+
export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, yes = false, signal, writeExtensions } = {}) {
|
|
18
27
|
const names = new Set(enabled?.length ? enabled : TOOL_NAMES);
|
|
19
28
|
const definitions = TOOL_DEFINITIONS.filter((d) => names.has(d.function.name));
|
|
29
|
+
const allowedWrite = Array.isArray(writeExtensions) && writeExtensions.length
|
|
30
|
+
? new Set(writeExtensions.map((ext) => String(ext).toLowerCase()))
|
|
31
|
+
: null;
|
|
32
|
+
|
|
33
|
+
// Restriccion por extension (modo Voz): solo write/edit, y solo si el modo la define.
|
|
34
|
+
const writeAllowed = (name, args = {}) =>
|
|
35
|
+
!allowedWrite || !WRITE_TOOLS.has(name) || allowedWrite.has(extensionOf(args?.path));
|
|
20
36
|
|
|
21
37
|
async function authorize(name, args = {}) {
|
|
38
|
+
if (!writeAllowed(name, args)) return 'deny';
|
|
22
39
|
let decision = evaluatePermission(name, args, permissions);
|
|
23
40
|
if (decision === 'ask') {
|
|
24
41
|
if (onAsk) decision = (await onAsk(name, args)) ? 'allow' : 'deny';
|
|
@@ -31,6 +48,9 @@ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQ
|
|
|
31
48
|
if (!names.has(name) || !(name in EXECUTORS)) {
|
|
32
49
|
return `Error: herramienta desconocida "${name}".`;
|
|
33
50
|
}
|
|
51
|
+
if (!writeAllowed(name, args)) {
|
|
52
|
+
return `Error: en este modo solo se permite crear o editar ${[...allowedWrite].join(' / ')}.`;
|
|
53
|
+
}
|
|
34
54
|
|
|
35
55
|
const decision = await authorize(name, args);
|
|
36
56
|
if (decision === 'deny') {
|
|
@@ -145,6 +145,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
145
145
|
texto: { type: 'string', description: 'Text to speak (long text is trimmed)' },
|
|
146
146
|
voice: { type: 'string', description: 'Optional voice id, e.g. "es-AR-ElenaNeural"' },
|
|
147
147
|
rate: { type: 'string', description: 'Optional speed, e.g. "+10%"' },
|
|
148
|
+
pitch: { type: 'string', description: 'Optional tone, e.g. "-10Hz"' },
|
|
148
149
|
},
|
|
149
150
|
required: ['texto'],
|
|
150
151
|
},
|
|
@@ -2,15 +2,17 @@ import { test } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { liveHistory, planCompaction } from '../src/agent/compact.js';
|
|
4
4
|
|
|
5
|
-
test('liveHistory saca al asesor y recorta hasta el cursor', () => {
|
|
5
|
+
test('liveHistory saca al asesor y la marca de compactación, y recorta hasta el cursor', () => {
|
|
6
6
|
const history = [
|
|
7
7
|
{ id: 'a', role: 'user', content: '1' },
|
|
8
8
|
{ id: 'b', role: 'assistant', name: 'advisor', content: 'charla' },
|
|
9
9
|
{ id: 'c', role: 'assistant', content: '2' },
|
|
10
10
|
{ id: 'd', role: 'user', content: '3' },
|
|
11
11
|
{ id: 'e', role: 'assistant', name: 'advisor', mode: 'summary', content: 'resumen de lo hecho' },
|
|
12
|
+
{ id: 'f', role: 'assistant', name: 'compact', mode: 'compact', content: 'resumen de contexto' },
|
|
12
13
|
];
|
|
13
|
-
// El consejo
|
|
14
|
+
// El consejo, el resumen del asesor y la marca de compactación nunca entran
|
|
15
|
+
// al contexto del modelo.
|
|
14
16
|
assert.deepEqual(liveHistory(history).map((m) => m.id), ['a', 'c', 'd']);
|
|
15
17
|
assert.deepEqual(liveHistory(history, 'a').map((m) => m.id), ['c', 'd']);
|
|
16
18
|
// Cursor inexistente: no perdemos contexto, devolvemos todo (sin asesor).
|
|
@@ -132,6 +132,35 @@ test('openaiCompatible manda max_tokens y respeta maxTokensField', async () => {
|
|
|
132
132
|
assert.equal(bodies[2].max_tokens, undefined);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
test('openaiCompatible pide usage en streaming y cae si el server no lo soporta', async () => {
|
|
136
|
+
const bodies = [];
|
|
137
|
+
const original = globalThis.fetch;
|
|
138
|
+
const sse = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n';
|
|
139
|
+
globalThis.fetch = async (_url, init) => {
|
|
140
|
+
const body = JSON.parse(init.body);
|
|
141
|
+
bodies.push(body);
|
|
142
|
+
if (body.stream_options) {
|
|
143
|
+
return new Response(
|
|
144
|
+
JSON.stringify({ error: { message: 'Unrecognized request argument supplied: stream_options' } }),
|
|
145
|
+
{ status: 400, headers: { 'content-type': 'application/json' } }
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
|
149
|
+
};
|
|
150
|
+
const cfg = { baseUrl: 'https://x/v1', apiKey: 'k' };
|
|
151
|
+
try {
|
|
152
|
+
const res = await openaiCompatibleProvider.streamChat(cfg, {
|
|
153
|
+
model: 'm',
|
|
154
|
+
messages: [{ role: 'user', content: 'u' }],
|
|
155
|
+
});
|
|
156
|
+
assert.equal(res.content, 'hi');
|
|
157
|
+
} finally {
|
|
158
|
+
globalThis.fetch = original;
|
|
159
|
+
}
|
|
160
|
+
assert.deepEqual(bodies[0].stream_options, { include_usage: true });
|
|
161
|
+
assert.equal(bodies[1].stream_options, undefined);
|
|
162
|
+
});
|
|
163
|
+
|
|
135
164
|
test('applyPromptCache: anthropic marca el system, openai agrega la clave', () => {
|
|
136
165
|
const anthropic = { messages: [{ role: 'system', content: 'sys' }, { role: 'user', content: 'u' }] };
|
|
137
166
|
applyPromptCache(anthropic, { mode: 'anthropic' });
|
|
@@ -21,6 +21,52 @@ test('readOpenAISSE parses content, reasoning and tool calls', async () => {
|
|
|
21
21
|
assert.equal(res.toolCalls[0].function.arguments, '{"a":1}');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
test('readOpenAISSE separa el pensamiento en línea (<think>) del contenido', async () => {
|
|
25
|
+
const sse = [
|
|
26
|
+
'data: {"choices":[{"delta":{"content":"<thi"}}]}',
|
|
27
|
+
'data: {"choices":[{"delta":{"content":"nk>pienso"}}]}',
|
|
28
|
+
'data: {"choices":[{"delta":{"content":" mucho</thi"}}]}',
|
|
29
|
+
'data: {"choices":[{"delta":{"content":"nk>Hola"}}]}',
|
|
30
|
+
'data: [DONE]',
|
|
31
|
+
'',
|
|
32
|
+
].join('\n');
|
|
33
|
+
|
|
34
|
+
const events = [];
|
|
35
|
+
const res = await readOpenAISSE(new Response(sse), { onEvent: (event) => events.push(event) });
|
|
36
|
+
assert.equal(res.reasoning, 'pienso mucho');
|
|
37
|
+
assert.equal(res.content, 'Hola');
|
|
38
|
+
assert.ok(events.some((event) => event.type === 'reasoning' && event.full.includes('pienso')));
|
|
39
|
+
assert.ok(events.some((event) => event.type === 'text' && event.full === 'Hola'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('readOpenAISSE parsea reasoning_content (DeepSeek/MiniMax)', async () => {
|
|
43
|
+
const sse = [
|
|
44
|
+
'data: {"choices":[{"delta":{"reasoning_content":"pienso "}}]}',
|
|
45
|
+
'data: {"choices":[{"delta":{"reasoning_content":"esto"}}]}',
|
|
46
|
+
'data: {"choices":[{"delta":{"content":"Hola"}}]}',
|
|
47
|
+
'data: {"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}',
|
|
48
|
+
'data: [DONE]',
|
|
49
|
+
'',
|
|
50
|
+
].join('\n');
|
|
51
|
+
|
|
52
|
+
const res = await readOpenAISSE(new Response(sse));
|
|
53
|
+
assert.equal(res.reasoning, 'pienso esto');
|
|
54
|
+
assert.equal(res.content, 'Hola');
|
|
55
|
+
assert.deepEqual(res.usage, { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('readOpenAISSE no toca el contenido sin etiquetas <think>', async () => {
|
|
59
|
+
const sse = [
|
|
60
|
+
'data: {"choices":[{"delta":{"content":"<p>hola</p>"}}]}',
|
|
61
|
+
'data: {"choices":[{"delta":{"content":" mundo"}}]}',
|
|
62
|
+
'data: [DONE]',
|
|
63
|
+
'',
|
|
64
|
+
].join('\n');
|
|
65
|
+
const res = await readOpenAISSE(new Response(sse));
|
|
66
|
+
assert.equal(res.content, '<p>hola</p> mundo');
|
|
67
|
+
assert.equal(res.reasoning, '');
|
|
68
|
+
});
|
|
69
|
+
|
|
24
70
|
test('readNDJSON parses Ollama messages and tool calls', async () => {
|
|
25
71
|
const ndjson = [
|
|
26
72
|
'{"message":{"content":"Hola "},"done":false}',
|
|
@@ -91,6 +91,36 @@ test('edit refuses ambiguous replacements without replaceAll', async () => {
|
|
|
91
91
|
);
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
test('writeExtensions limita write/edit por extension', async () => {
|
|
95
|
+
const root = await makeWorkspace();
|
|
96
|
+
const reg = createToolRegistry({
|
|
97
|
+
root,
|
|
98
|
+
permissions: { write: 'allow', edit: 'allow' },
|
|
99
|
+
writeExtensions: ['.md', '.json'],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.match(await reg.execute('write', { path: 'nota.md', content: '# hola' }), /Creado|Escrito/);
|
|
103
|
+
assert.match(await reg.execute('write', { path: 'datos.json', content: '{}' }), /Creado|Escrito/);
|
|
104
|
+
assert.match(await reg.execute('write', { path: 'src/x.txt', content: 'nope' }), /solo se permite crear o editar/);
|
|
105
|
+
assert.match(await reg.execute('write', { path: 'sin_extension', content: 'nope' }), /solo se permite crear o editar/);
|
|
106
|
+
|
|
107
|
+
assert.match(
|
|
108
|
+
await reg.execute('edit', { path: 'datos.json', oldString: '{}', newString: '{"a":1}' }),
|
|
109
|
+
/Editado/
|
|
110
|
+
);
|
|
111
|
+
assert.match(
|
|
112
|
+
await reg.execute('edit', { path: 'nota.txt', oldString: 'x', newString: 'y' }),
|
|
113
|
+
/solo se permite crear o editar/
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Leer sigue permitido aunque la extension no este en la lista.
|
|
117
|
+
assert.match(await reg.execute('read', { path: 'src/a.js' }), /const a = 1;/);
|
|
118
|
+
|
|
119
|
+
// Sin writeExtensions no hay restriccion (modo agent intacto).
|
|
120
|
+
const open = createToolRegistry({ root, permissions: { write: 'allow' } });
|
|
121
|
+
assert.match(await open.execute('write', { path: 'src/libre.txt', content: 'ok' }), /Creado|Escrito/);
|
|
122
|
+
});
|
|
123
|
+
|
|
94
124
|
test('permission "ask" is denied by default and allowed with yes', async () => {
|
|
95
125
|
const root = await makeWorkspace();
|
|
96
126
|
const denied = createToolRegistry({ root });
|