@danieltmn/tamnora 0.13.1 → 0.15.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 +38 -0
- package/harness/packages/core/src/config/index.js +2 -0
- package/harness/packages/core/src/hooks/index.js +42 -0
- package/harness/packages/core/src/index.js +3 -0
- package/harness/packages/core/src/plugins/index.js +36 -0
- package/harness/packages/core/src/rules/index.js +9 -63
- package/harness/packages/core/src/schedule/cron.js +124 -0
- package/harness/packages/core/src/storage/sqlite.js +96 -1
- package/harness/packages/core/src/tools/glob.js +64 -2
- package/harness/packages/core/test/cron.test.js +46 -0
- package/harness/packages/core/test/hooks.test.js +29 -0
- package/harness/packages/core/test/plugins.test.js +25 -0
- package/harness/packages/server/public/app.js +193 -3
- package/harness/packages/server/public/index.html +76 -2
- package/harness/packages/server/public/landing/index.html +5 -3
- package/harness/packages/server/public/styles.css +17 -10
- package/harness/packages/server/src/lib/advisor.js +4 -0
- package/harness/packages/server/src/lib/agentRun.js +24 -1
- package/harness/packages/server/src/lib/channels/telegram.js +7 -1
- package/harness/packages/server/src/lib/checkpoints.js +106 -0
- package/harness/packages/server/src/lib/compact.js +4 -0
- package/harness/packages/server/src/lib/hooks.js +45 -0
- package/harness/packages/server/src/lib/plugins.js +76 -0
- package/harness/packages/server/src/lib/scheduler.js +190 -0
- package/harness/packages/server/src/routes/advisor.js +4 -2
- package/harness/packages/server/src/routes/chat.js +32 -7
- package/harness/packages/server/src/routes/schedules.js +124 -0
- package/harness/packages/server/src/routes/sessions.js +18 -2
- package/harness/packages/server/src/server.js +24 -0
- package/harness/packages/server/test/server.test.js +201 -0
- package/harness/tamnora.example.json +2 -0
- package/package.json +1 -1
|
@@ -52,8 +52,10 @@ export async function summarizeSession(ctx, session, instruction) {
|
|
|
52
52
|
if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
|
|
53
53
|
const resolved = providerFor(ctx, session);
|
|
54
54
|
if (resolved.error) return resolved;
|
|
55
|
+
ctx.hooks?.dispatch({ type: 'session.compacting', sessionId: session.id, messages: messages.length });
|
|
55
56
|
const summary = await summarize(ctx, session, messages, instruction);
|
|
56
57
|
if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
|
|
58
|
+
ctx.hooks?.dispatch({ type: 'session.compacted', sessionId: session.id, summary });
|
|
57
59
|
return { summary };
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -70,8 +72,10 @@ export async function maybeAutoCompact(ctx, session, history, { contextTokens }
|
|
|
70
72
|
const live = liveHistory(history, session.meta?.summaryCursor ?? null);
|
|
71
73
|
const plan = planCompaction(live, { contextTokens, threshold, keepLast });
|
|
72
74
|
if (!plan) return null;
|
|
75
|
+
ctx.hooks?.dispatch({ type: 'session.compacting', sessionId: session.id, auto: true, messages: plan.toSummarize.length });
|
|
73
76
|
const summary = await summarize(ctx, session, plan.toSummarize, COMPACT_INSTRUCTION, session.meta?.summary);
|
|
74
77
|
if (!summary) return null;
|
|
75
78
|
ctx.store.touchSession(session.id, { meta: { summary, summaryCursor: plan.cursor } });
|
|
79
|
+
ctx.hooks?.dispatch({ type: 'session.compacted', sessionId: session.id, auto: true, summary });
|
|
76
80
|
return { summary, cursor: plan.cursor, summarized: plan.toSummarize.length };
|
|
77
81
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Runner de hooks: carga los modulos (`core/src/hooks`) y despacha eventos a
|
|
2
|
+
// sus handlers de forma aislada (un hook que tira no rompe el run) y con los
|
|
3
|
+
// datos redactados. Es opt-in: sin archivos de hooks, no se ejecuta codigo.
|
|
4
|
+
|
|
5
|
+
import { loadHooks, redactDeep } from './core.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Crea el runner. `dispatch(event)` llama a los handlers suscritos a
|
|
9
|
+
* `event.type` (y a los wildcard `*`); no devuelve valor (observadores).
|
|
10
|
+
*/
|
|
11
|
+
export async function createHookRunner(ctx, patterns = []) {
|
|
12
|
+
let hooks = [];
|
|
13
|
+
try {
|
|
14
|
+
hooks = await loadHooks(ctx.cwd, patterns);
|
|
15
|
+
} catch {
|
|
16
|
+
hooks = [];
|
|
17
|
+
}
|
|
18
|
+
const handlers = new Map();
|
|
19
|
+
for (const hook of hooks) {
|
|
20
|
+
for (const [event, fn] of Object.entries(hook.events)) {
|
|
21
|
+
if (typeof fn !== 'function') continue;
|
|
22
|
+
const list = handlers.get(event) ?? [];
|
|
23
|
+
list.push(fn);
|
|
24
|
+
handlers.set(event, list);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function dispatch(event) {
|
|
29
|
+
const list = [...(handlers.get(event.type) ?? []), ...(handlers.get('*') ?? [])];
|
|
30
|
+
if (!list.length) return;
|
|
31
|
+
const safe = redactDeep(event);
|
|
32
|
+
for (const fn of list) {
|
|
33
|
+
Promise.resolve()
|
|
34
|
+
.then(() => fn(safe, ctx))
|
|
35
|
+
.catch(() => {
|
|
36
|
+
// un hook caido no debe frenar el run
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const view = () =>
|
|
42
|
+
hooks.map((hook) => ({ id: hook.id, path: hook.path, events: Object.keys(hook.events).filter((e) => e !== '*') }));
|
|
43
|
+
|
|
44
|
+
return { dispatch, count: hooks.length, list: view };
|
|
45
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Plugins: agregan herramientas propias al agente (`.tamnora/plugins/*.mjs` o
|
|
2
|
+
// las rutas de `config.plugins`). Corren codigo del usuario, asi que pasan por
|
|
3
|
+
// permisos (default `ask`).
|
|
4
|
+
|
|
5
|
+
import { loadPlugins } from './core.js';
|
|
6
|
+
|
|
7
|
+
function normalizeName(value) {
|
|
8
|
+
return String(value ?? '')
|
|
9
|
+
.replace(/[^a-z0-9_-]+/gi, '_')
|
|
10
|
+
.replace(/^_+|_+$/g, '')
|
|
11
|
+
.slice(0, 64);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Crea el toolset de plugins. `execute(name, args, { approve })`. */
|
|
15
|
+
export async function createPlugins(ctx, patterns = []) {
|
|
16
|
+
let plugins = [];
|
|
17
|
+
try {
|
|
18
|
+
plugins = await loadPlugins(ctx.cwd, patterns);
|
|
19
|
+
} catch {
|
|
20
|
+
plugins = [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tools = [];
|
|
24
|
+
const used = new Set();
|
|
25
|
+
for (const plugin of plugins) {
|
|
26
|
+
for (const tool of plugin.tools) {
|
|
27
|
+
const name = `plugin__${normalizeName(plugin.id)}__${normalizeName(tool.name)}`;
|
|
28
|
+
if (!name || used.has(name)) continue;
|
|
29
|
+
used.add(name);
|
|
30
|
+
tools.push({
|
|
31
|
+
name,
|
|
32
|
+
source: tool.name,
|
|
33
|
+
plugin: plugin.id,
|
|
34
|
+
description: String(tool.description || tool.name),
|
|
35
|
+
parameters: tool.parameters && typeof tool.parameters === 'object' ? tool.parameters : {},
|
|
36
|
+
required: Array.isArray(tool.required) ? tool.required : [],
|
|
37
|
+
handler: tool.handler,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
43
|
+
const definitions = tools.map((tool) => ({
|
|
44
|
+
type: 'function',
|
|
45
|
+
function: {
|
|
46
|
+
name: tool.name,
|
|
47
|
+
description: tool.description,
|
|
48
|
+
parameters: { type: 'object', properties: tool.parameters, required: tool.required },
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
async function execute(name, args = {}, { approve } = {}) {
|
|
53
|
+
const tool = byName.get(name);
|
|
54
|
+
if (!tool) return `Herramienta desconocida: ${name}`;
|
|
55
|
+
const decision = (ctx.config.permissions ?? {})[tool.name] ?? 'ask';
|
|
56
|
+
if (decision === 'deny') return `Permiso denegado para "${name}".`;
|
|
57
|
+
if (decision === 'ask') {
|
|
58
|
+
const ok = approve ? await approve(name, args) : false;
|
|
59
|
+
if (!ok) return `Permiso denegado para "${name}".`;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const out = await tool.handler(args, ctx);
|
|
63
|
+
return typeof out === 'string' ? out : JSON.stringify(out ?? null, null, 2);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return `Error en ${name}: ${error?.message ?? error}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
definitions,
|
|
71
|
+
handles: (name) => byName.has(name),
|
|
72
|
+
execute,
|
|
73
|
+
count: tools.length,
|
|
74
|
+
list: () => tools.map(({ name, source, plugin, description }) => ({ name, source, plugin, description })),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Tareas programadas: un tick por minuto revisa `schedules` y dispara las que
|
|
2
|
+
// vencieron. `shell` usa la allowlist de procesos; `agent` corre un turno
|
|
3
|
+
// headless en una sesion (reusa runManager, asi que los canales avisan solos).
|
|
4
|
+
|
|
5
|
+
import { runAgent, createTools, resolveMode, parseScheduleSpec, nextRun, MAX_TOOL_ROUNDS } from './core.js';
|
|
6
|
+
import {
|
|
7
|
+
combineToolset,
|
|
8
|
+
composeAgentMessages,
|
|
9
|
+
buildUserMessage,
|
|
10
|
+
resolveProviderFor,
|
|
11
|
+
toolCallPayload,
|
|
12
|
+
toolResultPayload,
|
|
13
|
+
} from './agentRun.js';
|
|
14
|
+
import { createSubagentTool } from './tasks.js';
|
|
15
|
+
import { createRulesInjector } from './rules.js';
|
|
16
|
+
|
|
17
|
+
function scheduleSessionId(ctx, schedule) {
|
|
18
|
+
const payload = schedule.payload ?? {};
|
|
19
|
+
if (payload.sessionId) return payload.sessionId;
|
|
20
|
+
return ctx.store.getSetting(`scheduleSession:${schedule.id}`) ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function runAgentSchedule(ctx, schedule) {
|
|
24
|
+
const payload = schedule.payload ?? {};
|
|
25
|
+
const cwd = schedule.cwd || ctx.cwd;
|
|
26
|
+
const providerId = payload.provider || ctx.effectiveProviderId();
|
|
27
|
+
const model = payload.model || ctx.effectiveModel();
|
|
28
|
+
if (!model) throw new Error('Falta el modelo para la tarea.');
|
|
29
|
+
const mode = resolveMode(payload.mode ?? 'agent');
|
|
30
|
+
const prompt = String(payload.prompt ?? '').trim() || `Ejecutá la tarea programada "${schedule.name}".`;
|
|
31
|
+
|
|
32
|
+
let sessionId = scheduleSessionId(ctx, schedule);
|
|
33
|
+
if (!sessionId || !ctx.store.getSession(sessionId)) {
|
|
34
|
+
sessionId = ctx.store.createSession({ title: `Tarea: ${schedule.name}`, provider: providerId, model, cwd });
|
|
35
|
+
ctx.store.setSetting(`scheduleSession:${schedule.id}`, sessionId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const run = ctx.runs.start(sessionId);
|
|
39
|
+
if (!run) throw new Error('La sesión de la tarea ya está corriendo.');
|
|
40
|
+
|
|
41
|
+
let mcp = null;
|
|
42
|
+
try {
|
|
43
|
+
mcp = await ctx.getMcp();
|
|
44
|
+
} catch {
|
|
45
|
+
mcp = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const signal = ctx.runs.signal(sessionId);
|
|
49
|
+
const sessionRecord = ctx.store.getSession(sessionId);
|
|
50
|
+
const history = ctx.store.getMessages(sessionId);
|
|
51
|
+
const userMessage = buildUserMessage(prompt);
|
|
52
|
+
const messages = await composeAgentMessages({ ctx, cwd, mode, history, userMessage, sessionRecord });
|
|
53
|
+
ctx.store.addMessage(sessionId, userMessage);
|
|
54
|
+
const prefixLength = messages.length;
|
|
55
|
+
|
|
56
|
+
const local = createTools({ root: cwd, enabled: mode.tools ?? undefined, permissions: ctx.config.permissions, yes: true, mcp, signal });
|
|
57
|
+
const exposeAdmin = !mode.tools;
|
|
58
|
+
const admin = ctx.agentTools;
|
|
59
|
+
const emit = (type, data, agentId = null) => ctx.runs.emit(sessionId, type, data, { agentId });
|
|
60
|
+
const tasks = createSubagentTool(ctx, {
|
|
61
|
+
sessionId,
|
|
62
|
+
cwd,
|
|
63
|
+
providerId,
|
|
64
|
+
model,
|
|
65
|
+
permissions: ctx.config.permissions,
|
|
66
|
+
mcp,
|
|
67
|
+
signal,
|
|
68
|
+
exclude: admin.definitions.map((d) => d.function.name),
|
|
69
|
+
emit,
|
|
70
|
+
});
|
|
71
|
+
const { definitions, execute } = combineToolset({
|
|
72
|
+
local,
|
|
73
|
+
admin,
|
|
74
|
+
tasks,
|
|
75
|
+
plugins: exposeAdmin ? ctx.plugins : null,
|
|
76
|
+
exposeAdmin,
|
|
77
|
+
approve: async () => true,
|
|
78
|
+
});
|
|
79
|
+
const { provider, config } = resolveProviderFor(ctx, providerId);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const result = await runAgent({
|
|
83
|
+
provider,
|
|
84
|
+
config,
|
|
85
|
+
model,
|
|
86
|
+
messages,
|
|
87
|
+
tools: definitions,
|
|
88
|
+
execute,
|
|
89
|
+
toolContext: { cwd, sessionId },
|
|
90
|
+
onLifecycle: createRulesInjector({ cwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
|
|
91
|
+
signal,
|
|
92
|
+
maxRounds: ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
|
|
93
|
+
maxTokens: ctx.effectiveMaxTokens?.() || 0,
|
|
94
|
+
maxDurationMs: ctx.effectiveMaxDurationMs?.() || 0,
|
|
95
|
+
maxOutputTokens: ctx.effectiveMaxOutputTokens?.() || 0,
|
|
96
|
+
onEvent(event) {
|
|
97
|
+
if (event.type === 'text') emit('text', { full: event.full });
|
|
98
|
+
else if (event.type === 'reasoning') emit('reasoning', { full: event.full });
|
|
99
|
+
else if (event.type === 'tool_call') emit('tool_call', toolCallPayload(event));
|
|
100
|
+
else if (event.type === 'tool_result') emit('tool_result', { id: event.id, name: event.name, ...toolResultPayload(event) });
|
|
101
|
+
else if (event.type === 'usage') emit('usage', { tokens: event.usage });
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
ctx.store.addMessages(sessionId, result.messages.slice(prefixLength));
|
|
105
|
+
ctx.runs.emit(sessionId, 'done', {
|
|
106
|
+
sessionId,
|
|
107
|
+
content: result.content,
|
|
108
|
+
rounds: result.rounds,
|
|
109
|
+
stopReason: result.stopReason,
|
|
110
|
+
wrappedUp: result.wrappedUp === true,
|
|
111
|
+
tokens: result.usage ?? null,
|
|
112
|
+
schedule: schedule.id,
|
|
113
|
+
});
|
|
114
|
+
return { ok: true, sessionId };
|
|
115
|
+
} finally {
|
|
116
|
+
ctx.runs.finish(sessionId);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function runShellSchedule(ctx, schedule) {
|
|
121
|
+
const payload = schedule.payload ?? {};
|
|
122
|
+
if (!ctx.procsEnabled) throw new Error('Los procesos están deshabilitados.');
|
|
123
|
+
const cwd = schedule.cwd || ctx.cwd;
|
|
124
|
+
const proc = ctx.procs.start({ cwd, command: payload.command, args: payload.args ?? [], port: payload.port });
|
|
125
|
+
return { ok: true, procId: proc.id };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function executeSchedule(ctx, schedule) {
|
|
129
|
+
if (schedule.type === 'shell') return runShellSchedule(ctx, schedule);
|
|
130
|
+
return runAgentSchedule(ctx, schedule);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Crea el scheduler. `tick()` revisa y dispara lo vencido; `start()` lo corre
|
|
135
|
+
* cada `intervalMs` (default 30 s). `runNow(id)` dispara una tarea a mano.
|
|
136
|
+
*/
|
|
137
|
+
export function createScheduler(ctx, { intervalMs = 30000, log = () => {} } = {}) {
|
|
138
|
+
let timer = null;
|
|
139
|
+
let ticking = false;
|
|
140
|
+
|
|
141
|
+
function plan(schedule, from = Date.now()) {
|
|
142
|
+
const parsed = parseScheduleSpec(schedule.spec);
|
|
143
|
+
return parsed ? nextRun(parsed, from) : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function fire(schedule) {
|
|
147
|
+
const next = plan(schedule);
|
|
148
|
+
ctx.store.markScheduleRun(schedule.id, { status: 'running', nextRun: next });
|
|
149
|
+
try {
|
|
150
|
+
const result = await executeSchedule(ctx, schedule);
|
|
151
|
+
ctx.store.markScheduleRun(schedule.id, { status: 'ok', nextRun: next });
|
|
152
|
+
log(`tarea "${schedule.id}" ok`);
|
|
153
|
+
return result;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const message = String(error?.message ?? error);
|
|
156
|
+
ctx.store.markScheduleRun(schedule.id, { status: `error: ${message}`.slice(0, 140), nextRun: next });
|
|
157
|
+
log(`tarea "${schedule.id}" falló: ${message}`);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function tick() {
|
|
163
|
+
if (ticking) return;
|
|
164
|
+
ticking = true;
|
|
165
|
+
try {
|
|
166
|
+
for (const schedule of ctx.store.dueSchedules()) {
|
|
167
|
+
try {
|
|
168
|
+
await fire(schedule);
|
|
169
|
+
} catch {
|
|
170
|
+
// seguir con las siguientes
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
ticking = false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function start() {
|
|
179
|
+
if (timer) return;
|
|
180
|
+
timer = setInterval(() => tick().catch(() => {}), intervalMs);
|
|
181
|
+
timer.unref?.();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function stop() {
|
|
185
|
+
if (timer) clearInterval(timer);
|
|
186
|
+
timer = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { start, stop, tick, fire, runNow: (id) => fire(ctx.store.getSchedule(id)) };
|
|
190
|
+
}
|
|
@@ -20,6 +20,8 @@ export function registerAdvisor(route, ctx) {
|
|
|
20
20
|
if ('explain' in body) next.explain = body.explain !== false;
|
|
21
21
|
if ('advice' in body) next.advice = body.advice !== false;
|
|
22
22
|
if ('notes' in body) next.notes = String(body.notes ?? '').slice(0, 2000);
|
|
23
|
+
if ('provider' in body) next.provider = String(body.provider ?? '').trim().slice(0, 60);
|
|
24
|
+
if ('model' in body) next.model = String(body.model ?? '').trim().slice(0, 120);
|
|
23
25
|
if ('maxWords' in body) {
|
|
24
26
|
const value = Number(body.maxWords);
|
|
25
27
|
next.maxWords = Number.isFinite(value)
|
|
@@ -34,8 +36,8 @@ export function registerAdvisor(route, ctx) {
|
|
|
34
36
|
|
|
35
37
|
route('POST', '/api/advisor/test', async ({ res }) => {
|
|
36
38
|
const options = resolveAdvisor(ctx.app);
|
|
37
|
-
const providerId = ctx.effectiveProviderId();
|
|
38
|
-
const model = ctx.effectiveModel();
|
|
39
|
+
const providerId = options.provider || ctx.effectiveProviderId();
|
|
40
|
+
const model = options.model || ctx.effectiveModel();
|
|
39
41
|
if (!model) return sendError(res, 400, 'Elegí un proveedor y un modelo primero.');
|
|
40
42
|
const { provider, config } = ctx.resolveProvider({
|
|
41
43
|
...ctx.providerSettings(providerId),
|
|
@@ -10,6 +10,7 @@ import { workspaceForRequest, resolveExistingDir } from '../lib/workspace.js';
|
|
|
10
10
|
import { createSubagentTool } from '../lib/tasks.js';
|
|
11
11
|
import {
|
|
12
12
|
combineToolset,
|
|
13
|
+
combineLifecycle,
|
|
13
14
|
composeAgentMessages,
|
|
14
15
|
buildUserMessage,
|
|
15
16
|
resolveProviderFor,
|
|
@@ -20,6 +21,7 @@ import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/ann
|
|
|
20
21
|
import { resolveAdvisor, advisorShouldRun, consultAdvisor } from '../lib/advisor.js';
|
|
21
22
|
import { maybeAutoCompact } from '../lib/compact.js';
|
|
22
23
|
import { createRulesInjector } from '../lib/rules.js';
|
|
24
|
+
import { createCheckpointer } from '../lib/checkpoints.js';
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Emit streaming partials at most once per `ms` window, keeping only the
|
|
@@ -219,11 +221,18 @@ export function registerChat(route, ctx) {
|
|
|
219
221
|
permissions: ctx.config.permissions,
|
|
220
222
|
mcp,
|
|
221
223
|
signal: ctx.runs.signal(sessionId),
|
|
222
|
-
exclude: admin.definitions.map((d) => d.function.name),
|
|
224
|
+
exclude: [...admin.definitions.map((d) => d.function.name), ...(ctx.plugins?.list?.().map((t) => t.name) ?? [])],
|
|
223
225
|
emit: emitAgent,
|
|
224
226
|
});
|
|
225
227
|
const approve = autoApprove ? async () => true : (name, args) => onAsk(name, args);
|
|
226
|
-
const { definitions, execute } = combineToolset({
|
|
228
|
+
const { definitions, execute } = combineToolset({
|
|
229
|
+
local,
|
|
230
|
+
admin,
|
|
231
|
+
tasks,
|
|
232
|
+
plugins: exposeAdmin ? ctx.plugins : null,
|
|
233
|
+
exposeAdmin,
|
|
234
|
+
approve,
|
|
235
|
+
});
|
|
227
236
|
|
|
228
237
|
const { provider, config } = resolveProviderFor(ctx, providerId, {
|
|
229
238
|
baseUrl: body.baseUrl || ctx.providerSettings(providerId).baseUrl,
|
|
@@ -254,8 +263,13 @@ export function registerChat(route, ctx) {
|
|
|
254
263
|
});
|
|
255
264
|
// Persistimos el mensaje del usuario ya: asi sobrevive a un corte y la
|
|
256
265
|
// reconexion puede reconstruir el turno desde el historial.
|
|
257
|
-
ctx.store.addMessage(sessionId, userMessage);
|
|
266
|
+
const userMessageId = ctx.store.addMessage(sessionId, userMessage);
|
|
258
267
|
const prefixLength = messages.length;
|
|
268
|
+
const onLifecycle = combineLifecycle([
|
|
269
|
+
createRulesInjector({ cwd: sessionCwd, patterns: ctx.config.rules ?? [] }),
|
|
270
|
+
createCheckpointer(ctx, { sessionId, cwd: sessionCwd, turnId: userMessageId }),
|
|
271
|
+
ctx.hooks ? (event) => ctx.hooks.dispatch(event) : null,
|
|
272
|
+
]);
|
|
259
273
|
|
|
260
274
|
(async () => {
|
|
261
275
|
const startedAt = Date.now();
|
|
@@ -270,7 +284,7 @@ export function registerChat(route, ctx) {
|
|
|
270
284
|
tools: definitions,
|
|
271
285
|
execute,
|
|
272
286
|
toolContext: { cwd: sessionCwd, sessionId },
|
|
273
|
-
onLifecycle:
|
|
287
|
+
onLifecycle: onLifecycle ?? undefined,
|
|
274
288
|
signal: ctx.runs.signal(sessionId),
|
|
275
289
|
maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
|
|
276
290
|
maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
|
|
@@ -350,10 +364,20 @@ export function registerChat(route, ctx) {
|
|
|
350
364
|
advisorOptions.enabled &&
|
|
351
365
|
advisorShouldRun({ toolCalls, answer: result.content })
|
|
352
366
|
) {
|
|
367
|
+
// El asesor puede tener proveedor/modelo propios; si no, usa los activos.
|
|
368
|
+
let advisorProvider = provider;
|
|
369
|
+
let advisorConfig = config;
|
|
370
|
+
let advisorModel = model;
|
|
371
|
+
if (advisorOptions.provider || advisorOptions.model) {
|
|
372
|
+
const resolved = resolveProviderFor(ctx, advisorOptions.provider || providerId);
|
|
373
|
+
advisorProvider = resolved.provider;
|
|
374
|
+
advisorConfig = resolved.config;
|
|
375
|
+
advisorModel = advisorOptions.model || model;
|
|
376
|
+
}
|
|
353
377
|
const advice = await consultAdvisor({
|
|
354
|
-
provider,
|
|
355
|
-
config,
|
|
356
|
-
model,
|
|
378
|
+
provider: advisorProvider,
|
|
379
|
+
config: advisorConfig,
|
|
380
|
+
model: advisorModel,
|
|
357
381
|
signal: ctx.runs.signal(sessionId),
|
|
358
382
|
userInput: input,
|
|
359
383
|
answer: result.content,
|
|
@@ -368,6 +392,7 @@ export function registerChat(route, ctx) {
|
|
|
368
392
|
mode: advice.kind,
|
|
369
393
|
});
|
|
370
394
|
ctx.runs.emit(sessionId, 'advice', { text: advice.text, kind: advice.kind, say: advice.text });
|
|
395
|
+
ctx.hooks?.dispatch({ type: 'advice', sessionId, text: advice.text, kind: advice.kind });
|
|
371
396
|
}
|
|
372
397
|
}
|
|
373
398
|
} catch (error) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Tareas programadas: CRUD + "correr ahora". El tick vive en lib/scheduler.js.
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { parseScheduleSpec, nextRunFor } from '../lib/core.js';
|
|
5
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
6
|
+
|
|
7
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
|
|
8
|
+
|
|
9
|
+
function sanitizeId(value) {
|
|
10
|
+
return String(value ?? '')
|
|
11
|
+
.trim()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
14
|
+
.replace(/^-+|-+$/g, '')
|
|
15
|
+
.slice(0, 40);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildPayload(body, type) {
|
|
19
|
+
if (type === 'shell') {
|
|
20
|
+
const args = Array.isArray(body.args)
|
|
21
|
+
? body.args.map(String)
|
|
22
|
+
: String(body.args ?? '').trim()
|
|
23
|
+
? String(body.args).match(/(?:[^\s"]+|"[^"]*")+/g).map((s) => s.replace(/^"|"$/g, ''))
|
|
24
|
+
: [];
|
|
25
|
+
return { command: String(body.command ?? '').trim(), args, port: body.port ?? null };
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
prompt: String(body.prompt ?? '').trim(),
|
|
29
|
+
mode: body.mode === 'plan' ? 'plan' : 'agent',
|
|
30
|
+
model: body.model || undefined,
|
|
31
|
+
provider: body.provider || undefined,
|
|
32
|
+
sessionId: body.sessionId || undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validate(type, payload) {
|
|
37
|
+
if (type === 'shell' && !payload.command) return 'Una tarea shell necesita "command".';
|
|
38
|
+
if (type === 'agent' && !payload.prompt) return 'Una tarea agent necesita "prompt".';
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function registerSchedules(route, ctx) {
|
|
43
|
+
route('GET', '/api/schedules', ({ res }) => {
|
|
44
|
+
sendJson(res, 200, { schedules: ctx.store.listSchedules() });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
route('POST', '/api/schedules', async ({ req, res }) => {
|
|
48
|
+
const body = await readJson(req);
|
|
49
|
+
const spec = String(body.spec ?? '').trim();
|
|
50
|
+
if (!parseScheduleSpec(spec)) {
|
|
51
|
+
return sendError(res, 400, 'Especificación inválida. Ej.: "every 30 m", "daily 09:00", "0 6 * * 1".');
|
|
52
|
+
}
|
|
53
|
+
const id = sanitizeId(body.id || body.name) || randomUUID().slice(0, 8);
|
|
54
|
+
if (!ID_PATTERN.test(id)) return sendError(res, 400, 'Id de tarea inválido.');
|
|
55
|
+
if (ctx.store.getSchedule(id)) return sendError(res, 409, `Ya existe una tarea con id "${id}".`);
|
|
56
|
+
const type = body.type === 'shell' ? 'shell' : 'agent';
|
|
57
|
+
const payload = buildPayload(body, type);
|
|
58
|
+
const problem = validate(type, payload);
|
|
59
|
+
if (problem) return sendError(res, 400, problem);
|
|
60
|
+
const schedule = ctx.store.saveSchedule({
|
|
61
|
+
id,
|
|
62
|
+
name: String(body.name ?? id).trim() || id,
|
|
63
|
+
type,
|
|
64
|
+
spec,
|
|
65
|
+
cwd: body.cwd || ctx.cwd,
|
|
66
|
+
enabled: body.enabled !== false,
|
|
67
|
+
payload,
|
|
68
|
+
nextRun: nextRunFor(spec),
|
|
69
|
+
});
|
|
70
|
+
sendJson(res, 201, { schedule });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
route('PUT', '/api/schedules/:id', async ({ req, res, params }) => {
|
|
74
|
+
const current = ctx.store.getSchedule(params.id);
|
|
75
|
+
if (!current) return sendError(res, 404, 'Tarea no encontrada.');
|
|
76
|
+
const body = await readJson(req);
|
|
77
|
+
const spec = body.spec === undefined ? current.spec : String(body.spec).trim();
|
|
78
|
+
if (!parseScheduleSpec(spec)) return sendError(res, 400, 'Especificación inválida.');
|
|
79
|
+
const type = body.type === 'shell' || body.type === 'agent' ? body.type : current.type;
|
|
80
|
+
const merged = { ...current.payload, ...buildPayload(body, type) };
|
|
81
|
+
const problem = validate(type, merged);
|
|
82
|
+
if (problem) return sendError(res, 400, problem);
|
|
83
|
+
const schedule = ctx.store.saveSchedule({
|
|
84
|
+
id: current.id,
|
|
85
|
+
name: body.name ?? current.name,
|
|
86
|
+
type,
|
|
87
|
+
spec,
|
|
88
|
+
cwd: body.cwd ?? current.cwd,
|
|
89
|
+
enabled: body.enabled === undefined ? current.enabled : body.enabled !== false,
|
|
90
|
+
payload: merged,
|
|
91
|
+
nextRun: nextRunFor(spec),
|
|
92
|
+
});
|
|
93
|
+
sendJson(res, 200, { schedule });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
route('POST', '/api/schedules/:id/toggle', ({ res, params }) => {
|
|
97
|
+
const current = ctx.store.getSchedule(params.id);
|
|
98
|
+
if (!current) return sendError(res, 404, 'Tarea no encontrada.');
|
|
99
|
+
const enabled = !current.enabled;
|
|
100
|
+
ctx.store.setScheduleEnabled(current.id, enabled);
|
|
101
|
+
const nextRun = enabled ? nextRunFor(current.spec) : null;
|
|
102
|
+
ctx.store.markScheduleRun(current.id, { status: current.lastStatus, nextRun });
|
|
103
|
+
sendJson(res, 200, { id: current.id, enabled, schedule: ctx.store.getSchedule(current.id) });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
route('DELETE', '/api/schedules/:id', ({ res, params }) => {
|
|
107
|
+
const current = ctx.store.getSchedule(params.id);
|
|
108
|
+
if (!current) return sendError(res, 404, 'Tarea no encontrada.');
|
|
109
|
+
ctx.store.deleteSchedule(params.id);
|
|
110
|
+
sendJson(res, 200, { schedules: ctx.store.listSchedules() });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
route('POST', '/api/schedules/:id/run', async ({ res, params }) => {
|
|
114
|
+
const current = ctx.store.getSchedule(params.id);
|
|
115
|
+
if (!current) return sendError(res, 404, 'Tarea no encontrada.');
|
|
116
|
+
if (!ctx.scheduler) return sendError(res, 503, 'El planificador no está activo.');
|
|
117
|
+
try {
|
|
118
|
+
const result = await ctx.scheduler.runNow(params.id);
|
|
119
|
+
sendJson(res, 200, { ok: true, result, schedule: ctx.store.getSchedule(params.id) });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
sendError(res, 500, `La tarea falló: ${error?.message ?? error}`);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -2,6 +2,7 @@ import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
|
2
2
|
import { workspaceForRequest } from '../lib/workspace.js';
|
|
3
3
|
import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
4
4
|
import { summarizeSession, COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION } from '../lib/compact.js';
|
|
5
|
+
import { rewindSession, checkpointTurns } from '../lib/checkpoints.js';
|
|
5
6
|
|
|
6
7
|
export function registerSessions(route, ctx) {
|
|
7
8
|
route('GET', '/api/sessions', ({ res }) => {
|
|
@@ -117,8 +118,23 @@ export function registerSessions(route, ctx) {
|
|
|
117
118
|
sendJson(res, 200, { ok: true, removed });
|
|
118
119
|
});
|
|
119
120
|
|
|
120
|
-
route('
|
|
121
|
-
|
|
121
|
+
route('GET', '/api/sessions/:id/checkpoints', async ({ res, params }) => {
|
|
122
|
+
if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
|
|
123
|
+
sendJson(res, 200, { turns: await checkpointTurns(params.id) });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
route('POST', '/api/sessions/:id/rewind', async ({ req, res, params }) => {
|
|
127
|
+
if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
|
|
128
|
+
if (ctx.runs?.isActive(params.id)) return sendError(res, 409, 'Hay un run en curso en esta sesión.');
|
|
129
|
+
const body = await readJson(req);
|
|
130
|
+
const turn = String(body.turn || '');
|
|
131
|
+
if (!turn) return sendError(res, 400, 'Falta "turn" (id del mensaje del turno).');
|
|
132
|
+
const result = await rewindSession(ctx, params.id, turn);
|
|
133
|
+
ctx.store.touchSession(params.id, { meta: { summary: undefined, summaryCursor: undefined } });
|
|
134
|
+
sendJson(res, 200, { ok: true, ...result });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
route('POST', '/api/sessions/:id/compact', async ({ res, params }) => { const session = ctx.store.getSession(params.id);
|
|
122
138
|
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
123
139
|
const out = await summarizeSession(ctx, session, COMPACT_INSTRUCTION);
|
|
124
140
|
if (out.error) return sendError(res, out.status, out.error);
|
|
@@ -51,6 +51,10 @@ import { registerEvents } from './routes/events.js';
|
|
|
51
51
|
import { registerState } from './routes/state.js';
|
|
52
52
|
import { registerPresence } from './routes/presence.js';
|
|
53
53
|
import { registerEval } from './routes/eval.js';
|
|
54
|
+
import { registerSchedules } from './routes/schedules.js';
|
|
55
|
+
import { createScheduler } from './lib/scheduler.js';
|
|
56
|
+
import { createHookRunner } from './lib/hooks.js';
|
|
57
|
+
import { createPlugins } from './lib/plugins.js';
|
|
54
58
|
|
|
55
59
|
const VERSION = readPackageVersion();
|
|
56
60
|
|
|
@@ -215,6 +219,12 @@ export async function createTamnoraServer({
|
|
|
215
219
|
ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
|
|
216
220
|
ctx.agentTools = createAgentTools(ctx);
|
|
217
221
|
|
|
222
|
+
// Hooks locales (opt-in: solo si hay archivos). Observan el ciclo de vida.
|
|
223
|
+
ctx.hooks = await createHookRunner(ctx, config.hooks ?? []);
|
|
224
|
+
|
|
225
|
+
// Plugins locales: tools propias del usuario (`.tamnora/plugins/*.mjs`).
|
|
226
|
+
ctx.plugins = await createPlugins(ctx, config.plugins ?? []);
|
|
227
|
+
|
|
218
228
|
// Canales de mensajeria (Telegram via long polling) si hay token.
|
|
219
229
|
const channels = createChannelsManager(ctx, {
|
|
220
230
|
enabled: telegram,
|
|
@@ -223,6 +233,9 @@ export async function createTamnoraServer({
|
|
|
223
233
|
channels.load();
|
|
224
234
|
ctx.channels = channels;
|
|
225
235
|
|
|
236
|
+
// Planificador de tareas (tick cada 30 s). Arranca cuando el server escucha.
|
|
237
|
+
ctx.scheduler = createScheduler(ctx, { log: (message) => process.stderr.write(`${message}\n`) });
|
|
238
|
+
|
|
226
239
|
const routes = [];
|
|
227
240
|
const route = (method, pattern, handler) =>
|
|
228
241
|
routes.push({ method, segments: pattern.split('/').filter(Boolean), handler });
|
|
@@ -263,6 +276,15 @@ export async function createTamnoraServer({
|
|
|
263
276
|
registerState(route, ctx);
|
|
264
277
|
registerPresence(route, ctx);
|
|
265
278
|
registerEval(route, ctx);
|
|
279
|
+
registerSchedules(route, ctx);
|
|
280
|
+
|
|
281
|
+
route('GET', '/api/hooks', ({ res }) => {
|
|
282
|
+
sendJson(res, 200, { hooks: ctx.hooks?.list?.() ?? [] });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
route('GET', '/api/plugins', ({ res }) => {
|
|
286
|
+
sendJson(res, 200, { plugins: ctx.plugins?.list?.() ?? [] });
|
|
287
|
+
});
|
|
266
288
|
|
|
267
289
|
function matchRoute(method, pathname) {
|
|
268
290
|
const parts = pathname.split('/').filter(Boolean);
|
|
@@ -347,6 +369,7 @@ export async function createTamnoraServer({
|
|
|
347
369
|
url: `http://${address.address === '::' ? '127.0.0.1' : address.address}:${address.port}`,
|
|
348
370
|
};
|
|
349
371
|
ctx.serverInfo = info;
|
|
372
|
+
ctx.scheduler?.start();
|
|
350
373
|
resolve(info);
|
|
351
374
|
}
|
|
352
375
|
);
|
|
@@ -355,6 +378,7 @@ export async function createTamnoraServer({
|
|
|
355
378
|
const close = async () => {
|
|
356
379
|
approvals.cancelAll();
|
|
357
380
|
runs.cancelAll();
|
|
381
|
+
ctx.scheduler?.stop();
|
|
358
382
|
channels.stop();
|
|
359
383
|
preview.close();
|
|
360
384
|
procs.close();
|