@danieltmn/tamnora 0.11.3 → 0.13.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 +39 -0
- package/README.md +7 -1
- package/harness/packages/cli/src/commands/repl.js +18 -5
- package/harness/packages/cli/src/commands/run.js +11 -2
- package/harness/packages/cli/src/commands/tui.js +13 -3
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +22 -0
- package/harness/packages/core/src/agent/compact.js +38 -0
- package/harness/packages/core/src/agent/context.js +13 -8
- package/harness/packages/core/src/agent/runAgent.js +35 -4
- package/harness/packages/core/src/agent/scope.js +15 -0
- package/harness/packages/core/src/config/index.js +6 -0
- package/harness/packages/core/src/index.js +36 -1
- package/harness/packages/core/src/memory/index.js +220 -0
- package/harness/packages/core/src/providers/cache.js +29 -0
- package/harness/packages/core/src/providers/ollama.js +3 -2
- package/harness/packages/core/src/providers/openaiCompatible.js +9 -2
- package/harness/packages/core/src/providers/openrouter.js +6 -1
- package/harness/packages/core/src/providers/presets.js +7 -4
- package/harness/packages/core/src/providers/stream.js +45 -4
- package/harness/packages/core/src/rules/index.js +151 -0
- package/harness/packages/core/src/skills/index.js +21 -0
- package/harness/packages/core/src/tools/executors.js +21 -13
- package/harness/packages/core/src/tools/index.js +2 -2
- package/harness/packages/core/src/tools/registry.js +3 -2
- package/harness/packages/core/test/agent.test.js +38 -0
- package/harness/packages/core/test/compact.test.js +33 -0
- package/harness/packages/core/test/context.test.js +14 -0
- package/harness/packages/core/test/memory.test.js +96 -0
- package/harness/packages/core/test/providers.test.js +66 -0
- package/harness/packages/core/test/rules.test.js +64 -0
- package/harness/packages/core/test/skills.test.js +10 -1
- package/harness/packages/core/test/stream.test.js +20 -0
- package/harness/packages/core/test/tools.test.js +18 -0
- package/harness/packages/server/public/app.js +116 -5
- package/harness/packages/server/public/index.html +49 -1
- package/harness/packages/server/src/lib/agentRun.js +8 -8
- package/harness/packages/server/src/lib/agentTools.js +78 -8
- package/harness/packages/server/src/lib/approvals.js +7 -0
- package/harness/packages/server/src/lib/channels/telegram.js +12 -1
- package/harness/packages/server/src/lib/compact.js +77 -0
- package/harness/packages/server/src/lib/prompt.js +17 -3
- package/harness/packages/server/src/lib/rules.js +43 -0
- package/harness/packages/server/src/lib/skills.js +12 -1
- package/harness/packages/server/src/lib/tasks.js +2 -1
- package/harness/packages/server/src/routes/chat.js +24 -2
- package/harness/packages/server/src/routes/memory.js +66 -0
- package/harness/packages/server/src/routes/providers.js +4 -1
- package/harness/packages/server/src/routes/sessions.js +4 -50
- package/harness/packages/server/src/server.js +12 -2
- package/harness/packages/server/test/scope.test.js +68 -0
- package/harness/packages/server/test/server.test.js +291 -49
- package/harness/tamnora.example.json +6 -0
- package/package.json +1 -1
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
</head>
|
|
38
38
|
<body>
|
|
39
39
|
<header class="topbar">
|
|
40
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.
|
|
40
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.13.0</span></div>
|
|
41
41
|
<nav class="tabs">
|
|
42
42
|
<button class="tab active" data-tab="chat">chat</button>
|
|
43
43
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -461,6 +461,7 @@
|
|
|
461
461
|
<button type="button" class="ajustes-tab" data-panel="sesiones" role="tab">Sesiones</button>
|
|
462
462
|
<button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
|
|
463
463
|
<button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
|
|
464
|
+
<button type="button" class="ajustes-tab" data-panel="memoria" role="tab">Memoria</button>
|
|
464
465
|
<button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
|
|
465
466
|
<button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
|
|
466
467
|
<button type="button" class="ajustes-tab" data-panel="asesor" role="tab">Asesor</button>
|
|
@@ -486,6 +487,9 @@
|
|
|
486
487
|
<label class="check" title="aprobar automáticamente tools con permiso ask">
|
|
487
488
|
<input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
|
|
488
489
|
</label>
|
|
490
|
+
<label class="check" title="el agente hace exactamente lo pedido, sin mejoras ni cambios fuera del pedido">
|
|
491
|
+
<input id="strict-scope" type="checkbox" /> Hacer solo lo pedido (estricto)
|
|
492
|
+
</label>
|
|
489
493
|
</div>
|
|
490
494
|
</div>
|
|
491
495
|
<div class="row">
|
|
@@ -580,6 +584,25 @@
|
|
|
580
584
|
<ul id="skill-list" class="providers"></ul>
|
|
581
585
|
</div>
|
|
582
586
|
|
|
587
|
+
<div class="panel ajustes-panel" data-panel="memoria">
|
|
588
|
+
<div class="row between">
|
|
589
|
+
<h2>Memoria</h2>
|
|
590
|
+
<button id="memory-new" class="btn">+ tema</button>
|
|
591
|
+
</div>
|
|
592
|
+
<p class="muted verify-line">
|
|
593
|
+
Lo que el proyecto recuerda entre sesiones (<code>.tamnora/memory/</code>). El agente la mantiene con la
|
|
594
|
+
herramienta <code>memory</code>; acá la podés revisar y editar.
|
|
595
|
+
</p>
|
|
596
|
+
<label class="field"><span>índice (MEMORY.md)</span>
|
|
597
|
+
<textarea id="memory-index" rows="6" spellcheck="false" placeholder="# Memoria del proyecto"></textarea>
|
|
598
|
+
</label>
|
|
599
|
+
<div class="row between settings-actions">
|
|
600
|
+
<span id="memory-status" class="summary"></span>
|
|
601
|
+
<button id="memory-save-index" class="btn" type="button">guardar índice</button>
|
|
602
|
+
</div>
|
|
603
|
+
<ul id="memory-list" class="providers"></ul>
|
|
604
|
+
</div>
|
|
605
|
+
|
|
583
606
|
<div class="panel ajustes-panel" data-panel="telegram">
|
|
584
607
|
<div class="row between">
|
|
585
608
|
<h2>Telegram</h2>
|
|
@@ -953,6 +976,31 @@
|
|
|
953
976
|
</form>
|
|
954
977
|
</dialog>
|
|
955
978
|
|
|
979
|
+
<dialog id="memory-modal" class="modal">
|
|
980
|
+
<form id="memory-form" class="modal-card">
|
|
981
|
+
<div class="row between">
|
|
982
|
+
<h3 id="memory-title">nuevo tema</h3>
|
|
983
|
+
<button type="button" id="memory-close" class="btn icon" title="cerrar" aria-label="cerrar">
|
|
984
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
985
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
986
|
+
</svg>
|
|
987
|
+
</button>
|
|
988
|
+
</div>
|
|
989
|
+
<div class="row">
|
|
990
|
+
<label class="field"><span>tema (slug)</span><input id="mem-name" spellcheck="false" placeholder="arquitectura" /></label>
|
|
991
|
+
<label class="field grow"><span>título</span><input id="mem-title" spellcheck="false" placeholder="Arquitectura" /></label>
|
|
992
|
+
</div>
|
|
993
|
+
<label class="field"><span>contenido (markdown)</span>
|
|
994
|
+
<textarea id="mem-body" rows="10" spellcheck="false" placeholder="Qué recordar del proyecto…"></textarea>
|
|
995
|
+
</label>
|
|
996
|
+
<div class="row between settings-actions">
|
|
997
|
+
<button type="button" id="mem-cancel" class="btn">cancelar</button>
|
|
998
|
+
<button type="submit" class="btn primary">guardar</button>
|
|
999
|
+
</div>
|
|
1000
|
+
<div id="mem-status" class="summary"></div>
|
|
1001
|
+
</form>
|
|
1002
|
+
</dialog>
|
|
1003
|
+
|
|
956
1004
|
<dialog id="palette-modal" class="modal palette-modal">
|
|
957
1005
|
<div class="palette">
|
|
958
1006
|
<div class="palette-row">
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// subagentes). La creacion de tools y el onEvent de cada canal siguen siendo
|
|
3
3
|
// propios (los canales de salida son distintos); lo combinable vive aca.
|
|
4
4
|
|
|
5
|
-
import { summarizeToolCall, previewToolResult } from './core.js';
|
|
5
|
+
import { summarizeToolCall, previewToolResult, liveHistory } from './core.js';
|
|
6
6
|
|
|
7
7
|
const FAILED_RE = /^Error|^Permiso denegado/i;
|
|
8
8
|
|
|
@@ -21,16 +21,15 @@ export function toolCallPayload(event) {
|
|
|
21
21
|
* `approve` es el callback de aprobacion de las tools de administraación.
|
|
22
22
|
*/
|
|
23
23
|
export function combineToolset({ local, admin = null, tasks = null, exposeAdmin = false, approve = null }) {
|
|
24
|
+
const adminAllowed = (name) => Boolean(admin?.handles(name)) && (exposeAdmin || admin.readOnly?.has(name));
|
|
24
25
|
const definitions = [
|
|
25
26
|
...local.definitions,
|
|
26
|
-
...(
|
|
27
|
+
...(admin ? admin.definitions.filter((d) => adminAllowed(d.function.name)) : []),
|
|
27
28
|
...(tasks ? tasks.definitions : []),
|
|
28
29
|
];
|
|
29
|
-
const execute = (name, args, context) => {
|
|
30
|
+
const execute = (name, args, context = {}) => {
|
|
30
31
|
if (tasks?.handles(name)) return tasks.execute(name, args);
|
|
31
|
-
if (
|
|
32
|
-
return admin.execute(name, args, approve ? { approve } : undefined);
|
|
33
|
-
}
|
|
32
|
+
if (adminAllowed(name)) return admin.execute(name, args, { approve, ...context });
|
|
34
33
|
return local.execute(name, args, context);
|
|
35
34
|
};
|
|
36
35
|
return { definitions, execute };
|
|
@@ -67,12 +66,13 @@ export async function composeAgentMessages({ ctx, cwd, mode, history = [], userM
|
|
|
67
66
|
const basePrompt = await ctx.systemPromptFor(cwd);
|
|
68
67
|
const goal = sessionRecord?.meta?.goal;
|
|
69
68
|
const summary = sessionRecord?.meta?.summary;
|
|
69
|
+
// Historial vigente: sin el asesor y sin lo ya resumido (si hay cursor).
|
|
70
|
+
const live = liveHistory(history, sessionRecord?.meta?.summaryCursor ?? null);
|
|
70
71
|
return [
|
|
71
72
|
{ role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
|
|
72
73
|
...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
|
|
73
74
|
...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
|
|
74
|
-
|
|
75
|
-
...history.filter((message) => message.name !== 'advisor'),
|
|
75
|
+
...live,
|
|
76
76
|
userMessage,
|
|
77
77
|
];
|
|
78
78
|
}
|
|
@@ -12,8 +12,14 @@ import {
|
|
|
12
12
|
registryServerToConfig,
|
|
13
13
|
normalizeMcpConfig,
|
|
14
14
|
homeDir,
|
|
15
|
+
memorySlug,
|
|
16
|
+
listMemoryTopics,
|
|
17
|
+
readMemoryIndex,
|
|
18
|
+
readMemoryTopic,
|
|
19
|
+
writeMemory,
|
|
20
|
+
searchMemory,
|
|
15
21
|
} from './core.js';
|
|
16
|
-
import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled } from './skills.js';
|
|
22
|
+
import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled, findSkill } from './skills.js';
|
|
17
23
|
|
|
18
24
|
function sanitizeId(value) {
|
|
19
25
|
return String(value ?? '')
|
|
@@ -51,6 +57,12 @@ const DEFINITIONS = [
|
|
|
51
57
|
),
|
|
52
58
|
definition('remove_mcp', 'Elimina un servidor MCP guardado desde la consola.', { id: { type: 'string' } }, ['id']),
|
|
53
59
|
definition('list_skills', 'Lista las skills instaladas y si están habilitadas.', {}),
|
|
60
|
+
definition(
|
|
61
|
+
'skill',
|
|
62
|
+
'Carga el contenido completo de una skill por su id. Usala cuando una skill del índice aplique a la tarea.',
|
|
63
|
+
{ name: { type: 'string', description: 'id de la skill (ver el índice de skills)' } },
|
|
64
|
+
['name']
|
|
65
|
+
),
|
|
54
66
|
definition(
|
|
55
67
|
'install_skill',
|
|
56
68
|
'Crea o actualiza una skill en la casa. Pasá "content" (markdown, opcionalmente con frontmatter) o "url" para descargarla.',
|
|
@@ -65,6 +77,18 @@ const DEFINITIONS = [
|
|
|
65
77
|
},
|
|
66
78
|
[]
|
|
67
79
|
),
|
|
80
|
+
definition(
|
|
81
|
+
'memory',
|
|
82
|
+
'Memoria durable del proyecto, persiste entre sesiones. Acciones: list (temas), read (índice o un tema), search (busca) y write (crea/actualiza un tema). Guardá ahí lo que valga la pena recordar del proyecto.',
|
|
83
|
+
{
|
|
84
|
+
action: { type: 'string', enum: ['list', 'read', 'search', 'write'], description: 'Acción a ejecutar' },
|
|
85
|
+
topic: { type: 'string', description: 'Tema (slug) para read/write' },
|
|
86
|
+
query: { type: 'string', description: 'Texto a buscar (action=search)' },
|
|
87
|
+
title: { type: 'string', description: 'Título del tema (action=write)' },
|
|
88
|
+
content: { type: 'string', description: 'Contenido markdown (action=write)' },
|
|
89
|
+
},
|
|
90
|
+
['action']
|
|
91
|
+
),
|
|
68
92
|
];
|
|
69
93
|
|
|
70
94
|
async function fetchText(url) {
|
|
@@ -84,24 +108,26 @@ async function fetchText(url) {
|
|
|
84
108
|
}
|
|
85
109
|
}
|
|
86
110
|
|
|
87
|
-
function decisionFor(ctx, name) {
|
|
111
|
+
function decisionFor(ctx, name, args) {
|
|
88
112
|
const perms = ctx.config.permissions ?? {};
|
|
89
113
|
const value = perms[name];
|
|
90
114
|
if (value === 'allow' || value === 'ask' || value === 'deny') return value;
|
|
91
|
-
return
|
|
115
|
+
if (name === 'memory') return String(args?.action ?? 'read').toLowerCase() === 'write' ? 'ask' : 'allow';
|
|
116
|
+
return name.startsWith('list_') || name === 'skill' ? 'allow' : 'ask';
|
|
92
117
|
}
|
|
93
118
|
|
|
94
119
|
/**
|
|
95
|
-
* Crea las tools de administracion. `execute(name, args, { approve })`,
|
|
96
|
-
* `approve(name, args)` resuelve true/false para los permisos "ask"
|
|
120
|
+
* Crea las tools de administracion. `execute(name, args, { approve, cwd })`,
|
|
121
|
+
* donde `approve(name, args)` resuelve true/false para los permisos "ask" y
|
|
122
|
+
* `cwd` es la carpeta de la sesion (para la memoria del proyecto).
|
|
97
123
|
*/
|
|
98
124
|
export function createAgentTools(ctx) {
|
|
99
125
|
function handles(name) {
|
|
100
126
|
return DEFINITIONS.some((d) => d.function.name === name);
|
|
101
127
|
}
|
|
102
128
|
|
|
103
|
-
async function execute(name, args = {}, { approve } = {}) {
|
|
104
|
-
const decision = decisionFor(ctx, name);
|
|
129
|
+
async function execute(name, args = {}, { approve, cwd } = {}) {
|
|
130
|
+
const decision = decisionFor(ctx, name, args);
|
|
105
131
|
if (decision === 'deny') return `Permiso denegado para "${name}".`;
|
|
106
132
|
if (decision === 'ask') {
|
|
107
133
|
const ok = approve ? await approve(name, args) : false;
|
|
@@ -197,6 +223,13 @@ export function createAgentTools(ctx) {
|
|
|
197
223
|
case 'list_skills':
|
|
198
224
|
return JSON.stringify(skillsView(ctx).map(({ id, name, enabled, description }) => ({ id, name, enabled, description })), null, 2);
|
|
199
225
|
|
|
226
|
+
case 'skill': {
|
|
227
|
+
const id = String(args.name ?? args.id ?? '').trim();
|
|
228
|
+
const skill = findSkill(ctx, id);
|
|
229
|
+
if (!skill) return `No existe la skill "${id}".`;
|
|
230
|
+
return `# ${skill.name || skill.id}\n\n${skill.body}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
200
233
|
case 'install_skill': {
|
|
201
234
|
const ref = parseSkillRef(args.ref ?? args.url ?? '');
|
|
202
235
|
if (ref && (args.ref || (args.url && !/\.md(\?|$)/i.test(String(args.url))))) {
|
|
@@ -235,10 +268,47 @@ export function createAgentTools(ctx) {
|
|
|
235
268
|
return `Skill "${id}" instalada.`;
|
|
236
269
|
}
|
|
237
270
|
|
|
271
|
+
case 'memory': {
|
|
272
|
+
const root = cwd ?? ctx.cwd;
|
|
273
|
+
const action = String(args.action ?? 'read').toLowerCase();
|
|
274
|
+
if (action === 'list') {
|
|
275
|
+
const topics = listMemoryTopics(root);
|
|
276
|
+
if (!topics.length) return 'No hay memoria guardada todavía para este proyecto.';
|
|
277
|
+
return JSON.stringify(topics.map(({ name, title, bytes }) => ({ name, title, bytes })), null, 2);
|
|
278
|
+
}
|
|
279
|
+
if (action === 'search') {
|
|
280
|
+
const query = String(args.query ?? '').trim();
|
|
281
|
+
if (!query) return 'Pasá "query" para buscar en la memoria.';
|
|
282
|
+
const hits = searchMemory(root, query);
|
|
283
|
+
if (!hits.length) return `Sin resultados para "${query}".`;
|
|
284
|
+
return hits.map((h) => `### ${h.title} (${h.name})\n${h.snippet}`).join('\n\n');
|
|
285
|
+
}
|
|
286
|
+
if (action === 'write') {
|
|
287
|
+
const content = String(args.content ?? '').trim();
|
|
288
|
+
if (!content) return 'Pasá "content" para guardar en memoria.';
|
|
289
|
+
try {
|
|
290
|
+
const saved = await writeMemory(root, { topic: args.topic ?? args.title, title: args.title, content });
|
|
291
|
+
return `Guardado en memoria: ${saved.name} (${saved.title}).`;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return `No se pudo guardar la memoria: ${error.message}`;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const topic = String(args.topic ?? '').trim();
|
|
297
|
+
if (topic) {
|
|
298
|
+
const name = topic.endsWith('.md') ? topic : `${memorySlug(topic)}.md`;
|
|
299
|
+
const read = readMemoryTopic(root, name);
|
|
300
|
+
if (!read) return `No hay memoria del tema "${topic}".`;
|
|
301
|
+
return read.body;
|
|
302
|
+
}
|
|
303
|
+
return readMemoryIndex(root).trim() || 'No hay índice de memoria todavía.';
|
|
304
|
+
}
|
|
305
|
+
|
|
238
306
|
default:
|
|
239
307
|
return `Herramienta desconocida: ${name}`;
|
|
240
308
|
}
|
|
241
309
|
}
|
|
242
310
|
|
|
243
|
-
|
|
311
|
+
// Tools de admin que no modifican nada (lístar/leer): disponibles en todo modo.
|
|
312
|
+
const readOnly = new Set(['list_mcp', 'list_skills', 'skill']);
|
|
313
|
+
return { definitions: DEFINITIONS, handles, execute, readOnly };
|
|
244
314
|
}
|
|
@@ -48,6 +48,13 @@ export function createApprovalBroker({ timeoutMs = 300000 } = {}) {
|
|
|
48
48
|
for (const id of ids) settle(id, false);
|
|
49
49
|
},
|
|
50
50
|
|
|
51
|
+
/** Deny every pending request of a session (used when the user stops the run). */
|
|
52
|
+
cancelForSession(sessionId) {
|
|
53
|
+
for (const [id, entry] of pending) {
|
|
54
|
+
if (entry.sessionId === sessionId) settle(id, false);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
51
58
|
/** Deny everything still pending (used on shutdown). */
|
|
52
59
|
cancelAll() {
|
|
53
60
|
for (const id of [...pending.keys()]) settle(id, false);
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
toolCallPayload,
|
|
26
26
|
} from '../agentRun.js';
|
|
27
27
|
import { mp3AOgg } from '../ffmpeg.js';
|
|
28
|
+
import { createRulesInjector } from '../rules.js';
|
|
28
29
|
import { resolveTts } from '../ttsSettings.js';
|
|
29
30
|
import { transcribeAudio } from '../transcribe.js';
|
|
30
31
|
|
|
@@ -46,6 +47,7 @@ const HELP = [
|
|
|
46
47
|
'/model [id] — cambia el modelo (sin id: lista con botones)',
|
|
47
48
|
'/provider [id] — cambia el proveedor (sin id: lista)',
|
|
48
49
|
'/mode [plan|build] — cambia el modo (sin arg: panel)',
|
|
50
|
+
'/estricto [on|off] — el agente hace solo lo pedido (sin arg: alterna)',
|
|
49
51
|
'/stop — cancela la respuesta en curso',
|
|
50
52
|
'/help — esta ayuda',
|
|
51
53
|
'',
|
|
@@ -504,10 +506,13 @@ export function startTelegramChannel(
|
|
|
504
506
|
messages,
|
|
505
507
|
tools: definitions,
|
|
506
508
|
execute,
|
|
509
|
+
toolContext: { cwd, sessionId },
|
|
510
|
+
onLifecycle: createRulesInjector({ cwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
|
|
507
511
|
signal: controller.signal,
|
|
508
512
|
maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
|
|
509
513
|
maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
|
|
510
514
|
maxDurationMs: ctx.effectiveMaxDurationMs?.() ?? 0,
|
|
515
|
+
maxOutputTokens: ctx.effectiveMaxOutputTokens?.() ?? ctx.config.maxOutputTokens ?? 0,
|
|
511
516
|
onEvent(event) {
|
|
512
517
|
if (event.type === 'text') {
|
|
513
518
|
full = event.full;
|
|
@@ -659,7 +664,7 @@ export function startTelegramChannel(
|
|
|
659
664
|
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
660
665
|
return send(
|
|
661
666
|
chatId,
|
|
662
|
-
`proyecto: ${session ? projectLabel(session) : '—'}\nproveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
|
|
667
|
+
`proyecto: ${session ? projectLabel(session) : '—'}\nproveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsolo lo pedido: ${ctx.store.getSetting('strictScope') === true ? 'sí' : 'no'}\nsesión: ${session?.title || '—'}`
|
|
663
668
|
);
|
|
664
669
|
}
|
|
665
670
|
if (cmd === 'resumen' || cmd === 'resume') {
|
|
@@ -698,6 +703,12 @@ export function startTelegramChannel(
|
|
|
698
703
|
controllers.get(String(chatId))?.abort();
|
|
699
704
|
return send(chatId, 'Cancelando…');
|
|
700
705
|
}
|
|
706
|
+
if (cmd === 'estricto' || cmd === 'strict') {
|
|
707
|
+
const current = ctx.store.getSetting('strictScope') === true;
|
|
708
|
+
const value = arg ? /^(on|si|sí|1|true)$/i.test(arg) : !current;
|
|
709
|
+
ctx.store.setSetting('strictScope', value);
|
|
710
|
+
return send(chatId, `solo lo pedido: ${value ? 'activado' : 'desactivado'}`);
|
|
711
|
+
}
|
|
701
712
|
return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
|
|
702
713
|
}
|
|
703
714
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Compactación de contexto: resumen no destructivo, compactación manual
|
|
2
|
+
// (/compact) y auto-compactación antes de armar el prompt de un turno.
|
|
3
|
+
|
|
4
|
+
import { runAgent, liveHistory, planCompaction, COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION } from './core.js';
|
|
5
|
+
|
|
6
|
+
export { COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION };
|
|
7
|
+
|
|
8
|
+
function transcript(messages) {
|
|
9
|
+
return messages
|
|
10
|
+
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
11
|
+
.join('\n');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function providerFor(ctx, session) {
|
|
15
|
+
const providerId = session.provider || ctx.effectiveProviderId();
|
|
16
|
+
const model = session.model || ctx.effectiveModel();
|
|
17
|
+
if (!model) return { error: 'Falta el modelo para resumir.', status: 400 };
|
|
18
|
+
const { provider, config } = ctx.resolveProvider({
|
|
19
|
+
...ctx.providerSettings(providerId),
|
|
20
|
+
apiKey: ctx.apiKeyFor(providerId),
|
|
21
|
+
});
|
|
22
|
+
return { provider, config, model };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Corre una consulta de resumen sobre `messages` (opcionalmente con el resumen previo). */
|
|
26
|
+
async function summarize(ctx, session, messages, instruction, previous) {
|
|
27
|
+
const resolved = providerFor(ctx, session);
|
|
28
|
+
if (resolved.error) return null;
|
|
29
|
+
const parts = [];
|
|
30
|
+
if (previous) parts.push(`RESUMEN PREVIO:\n${previous}`);
|
|
31
|
+
parts.push(`CONVERSACIÓN:\n${transcript(messages).slice(0, 60000)}`);
|
|
32
|
+
try {
|
|
33
|
+
const result = await runAgent({
|
|
34
|
+
provider: resolved.provider,
|
|
35
|
+
config: resolved.config,
|
|
36
|
+
model: resolved.model,
|
|
37
|
+
messages: [
|
|
38
|
+
{ role: 'system', content: instruction },
|
|
39
|
+
{ role: 'user', content: parts.join('\n\n') },
|
|
40
|
+
],
|
|
41
|
+
tools: [],
|
|
42
|
+
});
|
|
43
|
+
return result.content?.trim() || null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resumen no destructivo de toda la sesión (no toca el historial). */
|
|
50
|
+
export async function summarizeSession(ctx, session, instruction) {
|
|
51
|
+
const messages = ctx.store.getMessages(session.id);
|
|
52
|
+
if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
|
|
53
|
+
const resolved = providerFor(ctx, session);
|
|
54
|
+
if (resolved.error) return resolved;
|
|
55
|
+
const summary = await summarize(ctx, session, messages, instruction);
|
|
56
|
+
if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
|
|
57
|
+
return { summary };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Auto-compactación: si el historial vigente supera el umbral del contexto,
|
|
62
|
+
* resume lo viejo y lo reemplaza por `meta.summary` + `meta.summaryCursor`.
|
|
63
|
+
* Devuelve `{ summary, cursor, summarized }` o `null` si no hace falta.
|
|
64
|
+
*/
|
|
65
|
+
export async function maybeAutoCompact(ctx, session, history, { contextTokens } = {}) {
|
|
66
|
+
const cfg = ctx.config.compact ?? {};
|
|
67
|
+
if (cfg.enabled === false) return null;
|
|
68
|
+
const threshold = Number(cfg.threshold) > 0 && Number(cfg.threshold) < 1 ? Number(cfg.threshold) : 0.75;
|
|
69
|
+
const keepLast = Number.isFinite(Number(cfg.keepLast)) ? Number(cfg.keepLast) : 8;
|
|
70
|
+
const live = liveHistory(history, session.meta?.summaryCursor ?? null);
|
|
71
|
+
const plan = planCompaction(live, { contextTokens, threshold, keepLast });
|
|
72
|
+
if (!plan) return null;
|
|
73
|
+
const summary = await summarize(ctx, session, plan.toSummarize, COMPACT_INSTRUCTION, session.meta?.summary);
|
|
74
|
+
if (!summary) return null;
|
|
75
|
+
ctx.store.touchSession(session.id, { meta: { summary, summaryCursor: plan.cursor } });
|
|
76
|
+
return { summary, cursor: plan.cursor, summarized: plan.toSummarize.length };
|
|
77
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadInstructions } from './core.js';
|
|
1
|
+
import { loadInstructions, loadRules, buildStaticRulesBlock, memoryPrompt } from './core.js';
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_SYSTEM_PROMPT =
|
|
4
4
|
'Eres Tamnora, un asistente útil. Responde en el idioma del usuario y usa las herramientas cuando aporten valor.';
|
|
@@ -33,14 +33,22 @@ function workspacePreamble(cwd) {
|
|
|
33
33
|
* @param {string[]} opts.files - instruction file names (e.g. ['AGENTS.md'])
|
|
34
34
|
* @param {() => string} [opts.profileText] - user profile block, re-read on every call
|
|
35
35
|
* @param {() => string} [opts.skillsText] - skills block, re-read on every call
|
|
36
|
+
* @param {() => string} [opts.scopeText] - strict-scope block, re-read on every call
|
|
37
|
+
* @param {string[]} [opts.ruleFiles] - glob patterns of project rule files
|
|
38
|
+
* @param {boolean} [opts.memory] - inject the project memory block
|
|
36
39
|
*/
|
|
37
|
-
export function createPromptBuilder({ cwd, files, profileText, skillsText }) {
|
|
40
|
+
export function createPromptBuilder({ cwd, files, profileText, skillsText, scopeText, ruleFiles = [], memory = false }) {
|
|
38
41
|
const cache = new Map();
|
|
39
42
|
|
|
40
43
|
async function buildFor(workspace) {
|
|
41
44
|
const instructions = (await loadInstructions(workspace, files)).trim();
|
|
42
45
|
const base = workspacePreamble(workspace);
|
|
43
|
-
|
|
46
|
+
let text = instructions ? `${base}\n\n---\n\n${instructions}` : base;
|
|
47
|
+
if (ruleFiles.length) {
|
|
48
|
+
const block = buildStaticRulesBlock(await loadRules(workspace, ruleFiles).catch(() => []));
|
|
49
|
+
if (block) text = `${text}\n\n---\n\n${block}`;
|
|
50
|
+
}
|
|
51
|
+
return text;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
return async function systemPromptFor(workspace = cwd) {
|
|
@@ -52,6 +60,12 @@ export function createPromptBuilder({ cwd, files, profileText, skillsText }) {
|
|
|
52
60
|
if (profile) parts.push(profile);
|
|
53
61
|
const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
|
|
54
62
|
if (extra) parts.push(extra);
|
|
63
|
+
if (memory) {
|
|
64
|
+
const mem = memoryPrompt(key);
|
|
65
|
+
if (mem) parts.push(mem);
|
|
66
|
+
}
|
|
67
|
+
const scope = typeof scopeText === 'function' ? String(scopeText() ?? '').trim() : '';
|
|
68
|
+
if (scope) parts.push(scope);
|
|
55
69
|
return parts.join('\n\n---\n\n');
|
|
56
70
|
};
|
|
57
71
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Reglas de proyecto por path inyectadas en runtime. Se engancha al seam de
|
|
2
|
+
// ciclo de vida del agente: cuando una tool toca un archivo que matchea el
|
|
3
|
+
// `paths:` de una regla, se agrega al contexto de las proximas rondas (efimera,
|
|
4
|
+
// no se persiste). Dedup: una instancia por run, cada regla una vez.
|
|
5
|
+
|
|
6
|
+
import { loadRules, rulesForPath, buildPathRulesBlock } from './core.js';
|
|
7
|
+
|
|
8
|
+
const PATH_TOOLS = new Set(['read', 'write', 'edit', 'list']);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Crea un handler `onLifecycle(event, payload)` para las reglas por path.
|
|
12
|
+
* Devuelve `null` si no hay patrones configurados.
|
|
13
|
+
*/
|
|
14
|
+
export function createRulesInjector({ cwd, patterns = [] }) {
|
|
15
|
+
if (!patterns.length) return null;
|
|
16
|
+
let cached = null;
|
|
17
|
+
let loading = null;
|
|
18
|
+
const injected = new Set();
|
|
19
|
+
|
|
20
|
+
async function all() {
|
|
21
|
+
if (cached) return cached;
|
|
22
|
+
if (!loading) {
|
|
23
|
+
loading = loadRules(cwd, patterns)
|
|
24
|
+
.catch(() => [])
|
|
25
|
+
.then((rules) => {
|
|
26
|
+
cached = rules;
|
|
27
|
+
return rules;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return loading;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return async function onLifecycle(event) {
|
|
34
|
+
if (event?.type !== 'tool.after' || !PATH_TOOLS.has(event.name)) return undefined;
|
|
35
|
+
const rel = event.args?.path ?? event.args?.file;
|
|
36
|
+
if (!rel || typeof rel !== 'string') return undefined;
|
|
37
|
+
const rules = await all();
|
|
38
|
+
const matches = rulesForPath(rules, rel).filter((rule) => !injected.has(rule.id));
|
|
39
|
+
if (!matches.length) return undefined;
|
|
40
|
+
for (const rule of matches) injected.add(rule.id);
|
|
41
|
+
return { inject: buildPathRulesBlock(matches, rel) };
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { homeDir, loadSkills, mergeSkills, buildSkillsPrompt } from './core.js';
|
|
3
|
+
import { homeDir, loadSkills, mergeSkills, buildSkillsPrompt, buildSkillsIndex } from './core.js';
|
|
4
4
|
|
|
5
5
|
export const SKILL_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
|
|
6
6
|
|
|
@@ -46,6 +46,17 @@ export function skillsPrompt(ctx) {
|
|
|
46
46
|
return buildSkillsPrompt(enabledSkills(ctx));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** Índice compacto de skills (para carga on-demand con la tool `skill`). */
|
|
50
|
+
export function skillsIndexPrompt(ctx) {
|
|
51
|
+
return buildSkillsIndex(enabledSkills(ctx));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Bloque de skills según `config.skills.mode`: `lazy` (default) o `eager`. */
|
|
55
|
+
export function skillsBlock(ctx) {
|
|
56
|
+
const eager = ctx.config?.skills?.mode === 'eager';
|
|
57
|
+
return eager ? skillsPrompt(ctx) : skillsIndexPrompt(ctx);
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
export async function writeSkill(id, { name, description, when, body }) {
|
|
50
61
|
const dir = path.join(homeDir(), 'skills', id);
|
|
51
62
|
await mkdir(dir, { recursive: true });
|
|
@@ -67,7 +67,7 @@ export function createSubagentTool(ctx, options) {
|
|
|
67
67
|
Array.isArray(args.tools) && args.tools.length
|
|
68
68
|
? args.tools.filter((tool) => !exclude.includes(tool))
|
|
69
69
|
: mode.tools ?? undefined;
|
|
70
|
-
const local = createTools({ root: cwd, enabled, permissions, yes: false, mcp });
|
|
70
|
+
const local = createTools({ root: cwd, enabled, permissions, yes: false, mcp, signal });
|
|
71
71
|
const basePrompt = await ctx.systemPromptFor(cwd);
|
|
72
72
|
const messages = [
|
|
73
73
|
{ role: 'system', content: `${basePrompt}\n\n${SUBAGENT_PROMPT}\n\nTarea: ${description}` },
|
|
@@ -88,6 +88,7 @@ export function createSubagentTool(ctx, options) {
|
|
|
88
88
|
execute: local.execute,
|
|
89
89
|
signal,
|
|
90
90
|
maxRounds: ctx.config.agents?.maxRounds ?? 6,
|
|
91
|
+
maxOutputTokens: ctx.config.agents?.maxOutputTokens ?? ctx.config.maxOutputTokens ?? 0,
|
|
91
92
|
onEvent(event) {
|
|
92
93
|
if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
|
|
93
94
|
else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
} from '../lib/agentRun.js';
|
|
19
19
|
import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/announcer.js';
|
|
20
20
|
import { resolveAdvisor, advisorShouldRun, consultAdvisor } from '../lib/advisor.js';
|
|
21
|
+
import { maybeAutoCompact } from '../lib/compact.js';
|
|
22
|
+
import { createRulesInjector } from '../lib/rules.js';
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Emit streaming partials at most once per `ms` window, keeping only the
|
|
@@ -75,6 +77,9 @@ export function registerChat(route, ctx) {
|
|
|
75
77
|
route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
|
|
76
78
|
if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
|
|
77
79
|
ctx.runs.cancel(params.id);
|
|
80
|
+
// Un run esperando una aprobacion quedaria colgado: la denegamos para que
|
|
81
|
+
// el bucle del agente pueda desenrollarse y termine.
|
|
82
|
+
ctx.approvals.cancelForSession(params.id);
|
|
78
83
|
sendJson(res, 200, { ok: true, stopped: true });
|
|
79
84
|
});
|
|
80
85
|
|
|
@@ -200,6 +205,7 @@ export function registerChat(route, ctx) {
|
|
|
200
205
|
yes: autoApprove,
|
|
201
206
|
onAsk,
|
|
202
207
|
mcp,
|
|
208
|
+
signal: ctx.runs.signal(sessionId),
|
|
203
209
|
});
|
|
204
210
|
// En modo plan (solo lectura) no exponemos las tools de administración.
|
|
205
211
|
const exposeAdmin = !mode.tools;
|
|
@@ -225,7 +231,19 @@ export function registerChat(route, ctx) {
|
|
|
225
231
|
|
|
226
232
|
const userMessage = buildUserMessage(input, images);
|
|
227
233
|
|
|
228
|
-
|
|
234
|
+
let sessionRecord = ctx.store.getSession(sessionId);
|
|
235
|
+
const contextTokens = Number(body.contextTokens) || ctx.config.contextTokens || 0;
|
|
236
|
+
// Auto-compactación: si el historial vigente ya no entra en el contexto,
|
|
237
|
+
// resumimos lo viejo y seguimos con el resumen + lo reciente. No borra nada.
|
|
238
|
+
try {
|
|
239
|
+
const compacted = await maybeAutoCompact(ctx, sessionRecord, history, { contextTokens });
|
|
240
|
+
if (compacted) {
|
|
241
|
+
ctx.runs.emit(sessionId, 'compacted', { summarized: compacted.summarized, contextTokens });
|
|
242
|
+
sessionRecord = ctx.store.getSession(sessionId);
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
ctx.runs.emit(sessionId, 'warning', { message: `auto-compact: ${error?.message ?? error}` });
|
|
246
|
+
}
|
|
229
247
|
const messages = await composeAgentMessages({
|
|
230
248
|
ctx,
|
|
231
249
|
cwd: sessionCwd,
|
|
@@ -251,12 +269,16 @@ export function registerChat(route, ctx) {
|
|
|
251
269
|
messages,
|
|
252
270
|
tools: definitions,
|
|
253
271
|
execute,
|
|
272
|
+
toolContext: { cwd: sessionCwd, sessionId },
|
|
273
|
+
onLifecycle: createRulesInjector({ cwd: sessionCwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
|
|
254
274
|
signal: ctx.runs.signal(sessionId),
|
|
255
275
|
maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
|
|
256
276
|
maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
|
|
257
277
|
maxDurationMs: Number(body.maxDurationMs) || ctx.effectiveMaxDurationMs?.() || 0,
|
|
278
|
+
maxOutputTokens: Number(body.maxOutputTokens) || ctx.effectiveMaxOutputTokens?.() || 0,
|
|
279
|
+
promptCaching: ctx.config.promptCaching === true,
|
|
258
280
|
stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
|
|
259
|
-
contextTokens
|
|
281
|
+
contextTokens,
|
|
260
282
|
onEvent(event) {
|
|
261
283
|
if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
|
|
262
284
|
else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });
|