@danieltmn/tamnora 0.11.3 → 0.12.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 +24 -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 +15 -2
- package/harness/packages/core/src/agent/scope.js +15 -0
- package/harness/packages/core/src/config/index.js +4 -0
- package/harness/packages/core/src/index.js +10 -1
- 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/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/providers.test.js +66 -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 +20 -4
- package/harness/packages/server/public/index.html +4 -1
- package/harness/packages/server/src/lib/agentRun.js +7 -7
- package/harness/packages/server/src/lib/agentTools.js +18 -3
- package/harness/packages/server/src/lib/approvals.js +7 -0
- package/harness/packages/server/src/lib/channels/telegram.js +9 -1
- package/harness/packages/server/src/lib/compact.js +77 -0
- package/harness/packages/server/src/lib/prompt.js +4 -1
- 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 +21 -2
- 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 +8 -2
- package/harness/packages/server/test/scope.test.js +68 -0
- package/harness/packages/server/test/server.test.js +130 -49
- package/package.json +1 -1
|
@@ -46,6 +46,7 @@ const HELP = [
|
|
|
46
46
|
'/model [id] — cambia el modelo (sin id: lista con botones)',
|
|
47
47
|
'/provider [id] — cambia el proveedor (sin id: lista)',
|
|
48
48
|
'/mode [plan|build] — cambia el modo (sin arg: panel)',
|
|
49
|
+
'/estricto [on|off] — el agente hace solo lo pedido (sin arg: alterna)',
|
|
49
50
|
'/stop — cancela la respuesta en curso',
|
|
50
51
|
'/help — esta ayuda',
|
|
51
52
|
'',
|
|
@@ -508,6 +509,7 @@ export function startTelegramChannel(
|
|
|
508
509
|
maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
|
|
509
510
|
maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
|
|
510
511
|
maxDurationMs: ctx.effectiveMaxDurationMs?.() ?? 0,
|
|
512
|
+
maxOutputTokens: ctx.effectiveMaxOutputTokens?.() ?? ctx.config.maxOutputTokens ?? 0,
|
|
511
513
|
onEvent(event) {
|
|
512
514
|
if (event.type === 'text') {
|
|
513
515
|
full = event.full;
|
|
@@ -659,7 +661,7 @@ export function startTelegramChannel(
|
|
|
659
661
|
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
660
662
|
return send(
|
|
661
663
|
chatId,
|
|
662
|
-
`proyecto: ${session ? projectLabel(session) : '—'}\nproveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
|
|
664
|
+
`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
665
|
);
|
|
664
666
|
}
|
|
665
667
|
if (cmd === 'resumen' || cmd === 'resume') {
|
|
@@ -698,6 +700,12 @@ export function startTelegramChannel(
|
|
|
698
700
|
controllers.get(String(chatId))?.abort();
|
|
699
701
|
return send(chatId, 'Cancelando…');
|
|
700
702
|
}
|
|
703
|
+
if (cmd === 'estricto' || cmd === 'strict') {
|
|
704
|
+
const current = ctx.store.getSetting('strictScope') === true;
|
|
705
|
+
const value = arg ? /^(on|si|sí|1|true)$/i.test(arg) : !current;
|
|
706
|
+
ctx.store.setSetting('strictScope', value);
|
|
707
|
+
return send(chatId, `solo lo pedido: ${value ? 'activado' : 'desactivado'}`);
|
|
708
|
+
}
|
|
701
709
|
return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
|
|
702
710
|
}
|
|
703
711
|
|
|
@@ -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
|
+
}
|
|
@@ -33,8 +33,9 @@ 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
|
|
36
37
|
*/
|
|
37
|
-
export function createPromptBuilder({ cwd, files, profileText, skillsText }) {
|
|
38
|
+
export function createPromptBuilder({ cwd, files, profileText, skillsText, scopeText }) {
|
|
38
39
|
const cache = new Map();
|
|
39
40
|
|
|
40
41
|
async function buildFor(workspace) {
|
|
@@ -52,6 +53,8 @@ export function createPromptBuilder({ cwd, files, profileText, skillsText }) {
|
|
|
52
53
|
if (profile) parts.push(profile);
|
|
53
54
|
const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
|
|
54
55
|
if (extra) parts.push(extra);
|
|
56
|
+
const scope = typeof scopeText === 'function' ? String(scopeText() ?? '').trim() : '';
|
|
57
|
+
if (scope) parts.push(scope);
|
|
55
58
|
return parts.join('\n\n---\n\n');
|
|
56
59
|
};
|
|
57
60
|
}
|
|
@@ -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,7 @@ 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';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Emit streaming partials at most once per `ms` window, keeping only the
|
|
@@ -75,6 +76,9 @@ export function registerChat(route, ctx) {
|
|
|
75
76
|
route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
|
|
76
77
|
if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
|
|
77
78
|
ctx.runs.cancel(params.id);
|
|
79
|
+
// Un run esperando una aprobacion quedaria colgado: la denegamos para que
|
|
80
|
+
// el bucle del agente pueda desenrollarse y termine.
|
|
81
|
+
ctx.approvals.cancelForSession(params.id);
|
|
78
82
|
sendJson(res, 200, { ok: true, stopped: true });
|
|
79
83
|
});
|
|
80
84
|
|
|
@@ -200,6 +204,7 @@ export function registerChat(route, ctx) {
|
|
|
200
204
|
yes: autoApprove,
|
|
201
205
|
onAsk,
|
|
202
206
|
mcp,
|
|
207
|
+
signal: ctx.runs.signal(sessionId),
|
|
203
208
|
});
|
|
204
209
|
// En modo plan (solo lectura) no exponemos las tools de administración.
|
|
205
210
|
const exposeAdmin = !mode.tools;
|
|
@@ -225,7 +230,19 @@ export function registerChat(route, ctx) {
|
|
|
225
230
|
|
|
226
231
|
const userMessage = buildUserMessage(input, images);
|
|
227
232
|
|
|
228
|
-
|
|
233
|
+
let sessionRecord = ctx.store.getSession(sessionId);
|
|
234
|
+
const contextTokens = Number(body.contextTokens) || ctx.config.contextTokens || 0;
|
|
235
|
+
// Auto-compactación: si el historial vigente ya no entra en el contexto,
|
|
236
|
+
// resumimos lo viejo y seguimos con el resumen + lo reciente. No borra nada.
|
|
237
|
+
try {
|
|
238
|
+
const compacted = await maybeAutoCompact(ctx, sessionRecord, history, { contextTokens });
|
|
239
|
+
if (compacted) {
|
|
240
|
+
ctx.runs.emit(sessionId, 'compacted', { summarized: compacted.summarized, contextTokens });
|
|
241
|
+
sessionRecord = ctx.store.getSession(sessionId);
|
|
242
|
+
}
|
|
243
|
+
} catch (error) {
|
|
244
|
+
ctx.runs.emit(sessionId, 'warning', { message: `auto-compact: ${error?.message ?? error}` });
|
|
245
|
+
}
|
|
229
246
|
const messages = await composeAgentMessages({
|
|
230
247
|
ctx,
|
|
231
248
|
cwd: sessionCwd,
|
|
@@ -255,8 +272,10 @@ export function registerChat(route, ctx) {
|
|
|
255
272
|
maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
|
|
256
273
|
maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
|
|
257
274
|
maxDurationMs: Number(body.maxDurationMs) || ctx.effectiveMaxDurationMs?.() || 0,
|
|
275
|
+
maxOutputTokens: Number(body.maxOutputTokens) || ctx.effectiveMaxOutputTokens?.() || 0,
|
|
276
|
+
promptCaching: ctx.config.promptCaching === true,
|
|
258
277
|
stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
|
|
259
|
-
contextTokens
|
|
278
|
+
contextTokens,
|
|
260
279
|
onEvent(event) {
|
|
261
280
|
if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
|
|
262
281
|
else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });
|
|
@@ -75,7 +75,9 @@ export function registerProviders(route, ctx) {
|
|
|
75
75
|
maxRounds: ctx.effectiveMaxRounds(),
|
|
76
76
|
maxTokens: ctx.effectiveMaxTokens(),
|
|
77
77
|
maxDurationMs: ctx.effectiveMaxDurationMs(),
|
|
78
|
+
maxOutputTokens: ctx.effectiveMaxOutputTokens(),
|
|
78
79
|
defaultCwd: ctx.store.getSetting('defaultCwd') || '',
|
|
80
|
+
strictScope: ctx.effectiveStrictScope(),
|
|
79
81
|
});
|
|
80
82
|
|
|
81
83
|
route('GET', '/api/settings', ({ res }) => {
|
|
@@ -88,7 +90,8 @@ export function registerProviders(route, ctx) {
|
|
|
88
90
|
if (body.model !== undefined) ctx.store.setSetting('activeModel', String(body.model) || null);
|
|
89
91
|
if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
|
|
90
92
|
if (body.defaultCwd !== undefined) ctx.store.setSetting('defaultCwd', String(body.defaultCwd ?? '').trim() || null);
|
|
91
|
-
|
|
93
|
+
if (body.strictScope !== undefined) ctx.store.setSetting('strictScope', Boolean(body.strictScope));
|
|
94
|
+
for (const key of ['maxRounds', 'maxTokens', 'maxDurationMs', 'maxOutputTokens']) {
|
|
92
95
|
if (body[key] === undefined) continue;
|
|
93
96
|
const value = Number(body[key]);
|
|
94
97
|
ctx.store.setSetting(key, Number.isFinite(value) && value > 0 ? Math.round(value) : 0);
|
|
@@ -1,53 +1,7 @@
|
|
|
1
|
-
import { runAgent } from '../lib/core.js';
|
|
2
1
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
2
|
import { workspaceForRequest } from '../lib/workspace.js';
|
|
4
3
|
import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
5
|
-
|
|
6
|
-
const COMPACT_INSTRUCTION =
|
|
7
|
-
'Resumí la conversación en un texto conciso que preserve decisiones tomadas, archivos tocados, hallazgos y el estado actual, para poder continuar el trabajo sin el historial completo.';
|
|
8
|
-
|
|
9
|
-
const SUMMARY_INSTRUCTION =
|
|
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
|
-
|
|
12
|
-
function sessionTranscript(messages) {
|
|
13
|
-
return messages
|
|
14
|
-
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
15
|
-
.join('\n');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Corre un resumen no destructivo de la sesión (no toca el historial).
|
|
20
|
-
* Devuelve `{ summary }` o `{ error, status }`.
|
|
21
|
-
*/
|
|
22
|
-
async function summarizeSession(ctx, session, instruction) {
|
|
23
|
-
const messages = ctx.store.getMessages(session.id);
|
|
24
|
-
if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
|
|
25
|
-
const providerId = session.provider || ctx.effectiveProviderId();
|
|
26
|
-
const model = session.model || ctx.effectiveModel();
|
|
27
|
-
if (!model) return { error: 'Falta el modelo para resumir.', status: 400 };
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
const { provider, config } = ctx.resolveProvider({
|
|
31
|
-
...ctx.providerSettings(providerId),
|
|
32
|
-
apiKey: ctx.apiKeyFor(providerId),
|
|
33
|
-
});
|
|
34
|
-
const result = await runAgent({
|
|
35
|
-
provider,
|
|
36
|
-
config,
|
|
37
|
-
model,
|
|
38
|
-
messages: [
|
|
39
|
-
{ role: 'system', content: instruction },
|
|
40
|
-
{ role: 'user', content: sessionTranscript(messages).slice(0, 60000) },
|
|
41
|
-
],
|
|
42
|
-
tools: [],
|
|
43
|
-
});
|
|
44
|
-
const summary = result.content?.trim();
|
|
45
|
-
if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
|
|
46
|
-
return { summary };
|
|
47
|
-
} catch (error) {
|
|
48
|
-
return { error: `No se pudo resumir: ${error?.message ?? error}`, status: 502 };
|
|
49
|
-
}
|
|
50
|
-
}
|
|
4
|
+
import { summarizeSession, COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION } from '../lib/compact.js';
|
|
51
5
|
|
|
52
6
|
export function registerSessions(route, ctx) {
|
|
53
7
|
route('GET', '/api/sessions', ({ res }) => {
|
|
@@ -147,7 +101,7 @@ export function registerSessions(route, ctx) {
|
|
|
147
101
|
const session = ctx.store.getSession(params.id);
|
|
148
102
|
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
149
103
|
ctx.store.clearMessages(params.id);
|
|
150
|
-
ctx.store.touchSession(params.id, { meta: { summary: undefined } });
|
|
104
|
+
ctx.store.touchSession(params.id, { meta: { summary: undefined, summaryCursor: undefined } });
|
|
151
105
|
sendJson(res, 200, { ok: true });
|
|
152
106
|
});
|
|
153
107
|
|
|
@@ -159,7 +113,7 @@ export function registerSessions(route, ctx) {
|
|
|
159
113
|
if (!fromId) return sendError(res, 400, 'Falta "fromId".');
|
|
160
114
|
const removed = ctx.store.deleteMessagesFrom(params.id, fromId);
|
|
161
115
|
if (!removed) return sendError(res, 404, 'Mensaje no encontrado.');
|
|
162
|
-
ctx.store.touchSession(params.id, {});
|
|
116
|
+
ctx.store.touchSession(params.id, { meta: { summary: undefined, summaryCursor: undefined } });
|
|
163
117
|
sendJson(res, 200, { ok: true, removed });
|
|
164
118
|
});
|
|
165
119
|
|
|
@@ -169,7 +123,7 @@ export function registerSessions(route, ctx) {
|
|
|
169
123
|
const out = await summarizeSession(ctx, session, COMPACT_INSTRUCTION);
|
|
170
124
|
if (out.error) return sendError(res, out.status, out.error);
|
|
171
125
|
ctx.store.clearMessages(params.id);
|
|
172
|
-
ctx.store.touchSession(params.id, { meta: { summary: out.summary } });
|
|
126
|
+
ctx.store.touchSession(params.id, { meta: { summary: out.summary, summaryCursor: null } });
|
|
173
127
|
sendJson(res, 200, { summary: out.summary, messages: 0 });
|
|
174
128
|
});
|
|
175
129
|
|
|
@@ -13,11 +13,12 @@ import {
|
|
|
13
13
|
composeProviderSettings,
|
|
14
14
|
buildMcpRegistry,
|
|
15
15
|
readPackageVersion,
|
|
16
|
+
scopeDirective,
|
|
16
17
|
} from './lib/core.js';
|
|
17
18
|
import { sendJson, sendError } from './lib/http.js';
|
|
18
19
|
import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
|
|
19
20
|
import { profilePrompt, resolveProfile } from './lib/profile.js';
|
|
20
|
-
import {
|
|
21
|
+
import { skillsBlock } from './lib/skills.js';
|
|
21
22
|
import { createApprovalBroker } from './lib/approvals.js';
|
|
22
23
|
import { createPresence } from './lib/presence.js';
|
|
23
24
|
import { createRunManager } from './lib/runManager.js';
|
|
@@ -160,6 +161,8 @@ export async function createTamnoraServer({
|
|
|
160
161
|
const effectiveMaxRounds = () => settingNumber('maxRounds', config.maxRounds);
|
|
161
162
|
const effectiveMaxTokens = () => settingNumber('maxTokens', config.maxTokens);
|
|
162
163
|
const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
|
|
164
|
+
const effectiveMaxOutputTokens = () => settingNumber('maxOutputTokens', config.maxOutputTokens);
|
|
165
|
+
const effectiveStrictScope = () => store.getSetting('strictScope') === true;
|
|
163
166
|
|
|
164
167
|
const ctx = {
|
|
165
168
|
cwd,
|
|
@@ -179,6 +182,8 @@ export async function createTamnoraServer({
|
|
|
179
182
|
effectiveMaxRounds,
|
|
180
183
|
effectiveMaxTokens,
|
|
181
184
|
effectiveMaxDurationMs,
|
|
185
|
+
effectiveMaxOutputTokens,
|
|
186
|
+
effectiveStrictScope,
|
|
182
187
|
providerSettings,
|
|
183
188
|
customProviderDefs,
|
|
184
189
|
apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
|
|
@@ -201,7 +206,8 @@ export async function createTamnoraServer({
|
|
|
201
206
|
cwd,
|
|
202
207
|
files: config.instructions,
|
|
203
208
|
profileText: () => profilePrompt(resolveProfile(ctx.app)),
|
|
204
|
-
skillsText: () =>
|
|
209
|
+
skillsText: () => skillsBlock(ctx),
|
|
210
|
+
scopeText: () => scopeDirective(ctx.effectiveStrictScope()),
|
|
205
211
|
});
|
|
206
212
|
ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
|
|
207
213
|
ctx.agentTools = createAgentTools(ctx);
|
|
@@ -0,0 +1,68 @@
|
|
|
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, scopeDirective, STRICT_SCOPE_PROMPT } from '../../core/src/index.js';
|
|
8
|
+
|
|
9
|
+
const fakeProvider = {
|
|
10
|
+
id: 'fake',
|
|
11
|
+
async streamChat(_config, { onEvent }) {
|
|
12
|
+
onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
|
|
13
|
+
return { content: 'hola', reasoning: '', toolCalls: [] };
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function startServer(overrides = {}) {
|
|
18
|
+
const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-scope-')));
|
|
19
|
+
setHome(cwd);
|
|
20
|
+
const instance = await createTamnoraServer({
|
|
21
|
+
cwd,
|
|
22
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
23
|
+
loadMcp: false,
|
|
24
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
25
|
+
...overrides,
|
|
26
|
+
});
|
|
27
|
+
const info = await instance.listen({ port: 0 });
|
|
28
|
+
return { instance, base: info.url, cwd };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('scopeDirective devuelve el bloque solo cuando esta activo', () => {
|
|
32
|
+
assert.equal(scopeDirective(false), '');
|
|
33
|
+
assert.equal(scopeDirective(true), STRICT_SCOPE_PROMPT);
|
|
34
|
+
assert.match(STRICT_SCOPE_PROMPT, /EXACTAMENTE/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('el toggle estricto se persiste y se inyecta en el system prompt', async () => {
|
|
38
|
+
const { instance, base, cwd } = await startServer();
|
|
39
|
+
const put = (body) =>
|
|
40
|
+
fetch(`${base}/api/settings`, {
|
|
41
|
+
method: 'PUT',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
const initial = await (await fetch(`${base}/api/settings`)).json();
|
|
47
|
+
assert.equal(initial.settings.strictScope, false);
|
|
48
|
+
|
|
49
|
+
let prompt = await instance.ctx.systemPromptFor(cwd);
|
|
50
|
+
assert.ok(!prompt.includes(STRICT_SCOPE_PROMPT), 'sin el toggle el prompt no trae la directiva');
|
|
51
|
+
|
|
52
|
+
const saved = await put({ strictScope: true });
|
|
53
|
+
assert.equal(saved.status, 200);
|
|
54
|
+
assert.equal((await saved.json()).settings.strictScope, true);
|
|
55
|
+
|
|
56
|
+
prompt = await instance.ctx.systemPromptFor(cwd);
|
|
57
|
+
assert.ok(prompt.includes(STRICT_SCOPE_PROMPT), 'con el toggle on la directiva entra al prompt');
|
|
58
|
+
|
|
59
|
+
const reloaded = await (await fetch(`${base}/api/settings`)).json();
|
|
60
|
+
assert.equal(reloaded.settings.strictScope, true);
|
|
61
|
+
|
|
62
|
+
await put({ strictScope: false });
|
|
63
|
+
prompt = await instance.ctx.systemPromptFor(cwd);
|
|
64
|
+
assert.ok(!prompt.includes(STRICT_SCOPE_PROMPT), 'apagarlo la saca del prompt');
|
|
65
|
+
} finally {
|
|
66
|
+
await instance.close();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
@@ -1016,41 +1016,119 @@ test('manages skills from the console', async () => {
|
|
|
1016
1016
|
}
|
|
1017
1017
|
});
|
|
1018
1018
|
|
|
1019
|
-
test('
|
|
1020
|
-
let captured = '';
|
|
1021
|
-
const captureProvider = {
|
|
1022
|
-
id: 'capture',
|
|
1023
|
-
async streamChat(_config, { messages }) {
|
|
1024
|
-
captured = messages?.[0]?.content ?? '';
|
|
1025
|
-
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
1026
|
-
},
|
|
1027
|
-
};
|
|
1028
|
-
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: captureProvider, config: {} }) });
|
|
1029
|
-
const json = { 'Content-Type': 'application/json' };
|
|
1030
|
-
const chat = (sessionId) =>
|
|
1031
|
-
fetch(`${base}/api/chat`, { method: 'POST', headers: json, body: JSON.stringify({ sessionId, model: 'm', input: 'hola' }) });
|
|
1032
|
-
const newSession = async () => {
|
|
1033
|
-
const created = await (
|
|
1034
|
-
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
|
|
1035
|
-
).json();
|
|
1036
|
-
return created.session.id;
|
|
1037
|
-
};
|
|
1038
|
-
try {
|
|
1039
|
-
await fetch(`${base}/api/skills/demo`, {
|
|
1040
|
-
method: 'PUT',
|
|
1041
|
-
headers: json,
|
|
1042
|
-
body: JSON.stringify({ name: 'Demo', body: 'Regla secreta de la skill.' }),
|
|
1043
|
-
});
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
await
|
|
1053
|
-
|
|
1019
|
+
test('skills: indice lazy por defecto (sin body)', async () => {
|
|
1020
|
+
let captured = '';
|
|
1021
|
+
const captureProvider = {
|
|
1022
|
+
id: 'capture',
|
|
1023
|
+
async streamChat(_config, { messages }) {
|
|
1024
|
+
captured = messages?.[0]?.content ?? '';
|
|
1025
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
1026
|
+
},
|
|
1027
|
+
};
|
|
1028
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: captureProvider, config: {} }) });
|
|
1029
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1030
|
+
const chat = (sessionId) =>
|
|
1031
|
+
fetch(`${base}/api/chat`, { method: 'POST', headers: json, body: JSON.stringify({ sessionId, model: 'm', input: 'hola' }) });
|
|
1032
|
+
const newSession = async () => {
|
|
1033
|
+
const created = await (
|
|
1034
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
|
|
1035
|
+
).json();
|
|
1036
|
+
return created.session.id;
|
|
1037
|
+
};
|
|
1038
|
+
try {
|
|
1039
|
+
await fetch(`${base}/api/skills/demo`, {
|
|
1040
|
+
method: 'PUT',
|
|
1041
|
+
headers: json,
|
|
1042
|
+
body: JSON.stringify({ name: 'Demo', body: 'Regla secreta de la skill.' }),
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
// Lazy: se inyecta el indice (id), no el cuerpo.
|
|
1046
|
+
await (await chat(await newSession())).text();
|
|
1047
|
+
assert.match(captured, /demo/);
|
|
1048
|
+
assert.doesNotMatch(captured, /Regla secreta de la skill/);
|
|
1049
|
+
|
|
1050
|
+
// Deshabilitada: no aparece ni en el indice.
|
|
1051
|
+
await fetch(`${base}/api/skills/demo/toggle`, { method: 'POST' });
|
|
1052
|
+
await (await chat(await newSession())).text();
|
|
1053
|
+
assert.doesNotMatch(captured, /demo/);
|
|
1054
|
+
} finally {
|
|
1055
|
+
await instance.close();
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
test('skills: modo eager inyecta el body en el system prompt', async () => {
|
|
1060
|
+
let captured = '';
|
|
1061
|
+
const captureProvider = {
|
|
1062
|
+
id: 'capture',
|
|
1063
|
+
async streamChat(_config, { messages }) {
|
|
1064
|
+
captured = messages?.[0]?.content ?? '';
|
|
1065
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
1066
|
+
},
|
|
1067
|
+
};
|
|
1068
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-srv-'));
|
|
1069
|
+
await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ skills: { mode: 'eager' } }));
|
|
1070
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider: captureProvider, config: {} }) });
|
|
1071
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1072
|
+
try {
|
|
1073
|
+
await fetch(`${base}/api/skills/demo`, {
|
|
1074
|
+
method: 'PUT',
|
|
1075
|
+
headers: json,
|
|
1076
|
+
body: JSON.stringify({ name: 'Demo', body: 'Regla secreta de la skill.' }),
|
|
1077
|
+
});
|
|
1078
|
+
const created = await (
|
|
1079
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
|
|
1080
|
+
).json();
|
|
1081
|
+
await fetch(`${base}/api/chat`, {
|
|
1082
|
+
method: 'POST',
|
|
1083
|
+
headers: json,
|
|
1084
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'hola' }),
|
|
1085
|
+
}).then((r) => r.text());
|
|
1086
|
+
assert.match(captured, /Regla secreta de la skill/);
|
|
1087
|
+
} finally {
|
|
1088
|
+
await instance.close();
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
test('auto-compact resume el historial cuando el contexto se pasa', async () => {
|
|
1093
|
+
let captured = '';
|
|
1094
|
+
const provider = {
|
|
1095
|
+
id: 'capture',
|
|
1096
|
+
async streamChat(_config, { messages }) {
|
|
1097
|
+
captured = (messages ?? [])
|
|
1098
|
+
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
|
1099
|
+
.join('\n');
|
|
1100
|
+
return { content: 'RESUMEN OK', reasoning: '', toolCalls: [] };
|
|
1101
|
+
},
|
|
1102
|
+
};
|
|
1103
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1104
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
|
|
1105
|
+
try {
|
|
1106
|
+
const created = await (
|
|
1107
|
+
await fetch(`${base}/api/sessions`, {
|
|
1108
|
+
method: 'POST',
|
|
1109
|
+
headers: json,
|
|
1110
|
+
body: JSON.stringify({ title: 't', model: 'm' }),
|
|
1111
|
+
})
|
|
1112
|
+
).json();
|
|
1113
|
+
const id = created.session.id;
|
|
1114
|
+
const big = 'x'.repeat(4000);
|
|
1115
|
+
instance.store.addMessages(
|
|
1116
|
+
id,
|
|
1117
|
+
Array.from({ length: 12 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: `m${i} ${big}` }))
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
await fetch(`${base}/api/chat`, {
|
|
1121
|
+
method: 'POST',
|
|
1122
|
+
headers: json,
|
|
1123
|
+
body: JSON.stringify({ sessionId: id, model: 'm', input: 'hola', contextTokens: 1000 }),
|
|
1124
|
+
}).then((r) => r.text());
|
|
1125
|
+
|
|
1126
|
+
const meta = instance.store.getSession(id).meta;
|
|
1127
|
+
assert.ok(meta.summaryCursor, 'debe guardar el cursor de compactación');
|
|
1128
|
+
assert.match(captured, /Resumen de la conversación anterior/);
|
|
1129
|
+
} finally {
|
|
1130
|
+
await instance.close();
|
|
1131
|
+
}
|
|
1054
1132
|
});
|
|
1055
1133
|
|
|
1056
1134
|
test('detects, runs and stops dev processes', async () => {
|
|
@@ -1792,20 +1870,23 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
|
|
|
1792
1870
|
body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
|
|
1793
1871
|
})
|
|
1794
1872
|
).text();
|
|
1795
|
-
assert.deepEqual(captured.tools, [
|
|
1796
|
-
'console_errors',
|
|
1797
|
-
'fetch_url',
|
|
1798
|
-
'git_diff',
|
|
1799
|
-
'git_log',
|
|
1800
|
-
'git_status',
|
|
1801
|
-
'glob',
|
|
1802
|
-
'grep',
|
|
1803
|
-
'list',
|
|
1804
|
-
'
|
|
1805
|
-
'
|
|
1806
|
-
'
|
|
1807
|
-
'
|
|
1808
|
-
'
|
|
1873
|
+
assert.deepEqual(captured.tools, [
|
|
1874
|
+
'console_errors',
|
|
1875
|
+
'fetch_url',
|
|
1876
|
+
'git_diff',
|
|
1877
|
+
'git_log',
|
|
1878
|
+
'git_status',
|
|
1879
|
+
'glob',
|
|
1880
|
+
'grep',
|
|
1881
|
+
'list',
|
|
1882
|
+
'list_mcp',
|
|
1883
|
+
'list_skills',
|
|
1884
|
+
'preview_url',
|
|
1885
|
+
'read',
|
|
1886
|
+
'screenshot',
|
|
1887
|
+
'skill',
|
|
1888
|
+
'speak',
|
|
1889
|
+
'task',
|
|
1809
1890
|
]);
|
|
1810
1891
|
assert.match(captured.system, /modo PLAN/);
|
|
1811
1892
|
} finally {
|
package/package.json
CHANGED