@danieltmn/tamnora 0.1.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 +48 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/SECURITY.md +31 -0
- package/harness/packages/cli/bin/tamnora.mjs +11 -0
- package/harness/packages/cli/package.json +10 -0
- package/harness/packages/cli/src/cli.js +90 -0
- package/harness/packages/cli/src/commands/autostart.js +121 -0
- package/harness/packages/cli/src/commands/bridge.js +274 -0
- package/harness/packages/cli/src/commands/doctor.js +73 -0
- package/harness/packages/cli/src/commands/eval.js +183 -0
- package/harness/packages/cli/src/commands/init.js +31 -0
- package/harness/packages/cli/src/commands/mcp.js +101 -0
- package/harness/packages/cli/src/commands/repl.js +172 -0
- package/harness/packages/cli/src/commands/run.js +165 -0
- package/harness/packages/cli/src/commands/serve.js +1 -0
- package/harness/packages/cli/src/commands/server.js +345 -0
- package/harness/packages/cli/src/commands/tui.js +110 -0
- package/harness/packages/cli/src/commands/update.js +76 -0
- package/harness/packages/cli/src/commands/user.js +135 -0
- package/harness/packages/cli/src/lib/core.js +2 -0
- package/harness/packages/cli/src/lib/daemon.js +153 -0
- package/harness/packages/cli/src/lib/qr.js +203 -0
- package/harness/packages/cli/src/lib/runtime.js +110 -0
- package/harness/packages/cli/src/lib/tunnel.js +113 -0
- package/harness/packages/cli/src/lib/util.js +24 -0
- package/harness/packages/cli/src/tui/app.js +302 -0
- package/harness/packages/cli/src/tui/keys.js +78 -0
- package/harness/packages/cli/src/tui/layout.js +130 -0
- package/harness/packages/cli/src/tui/screen.js +13 -0
- package/harness/packages/cli/src/ui/color.js +15 -0
- package/harness/packages/cli/test/release.test.js +34 -0
- package/harness/packages/cli/test/tui.test.js +99 -0
- package/harness/packages/cli/test/tunnel.test.js +20 -0
- package/harness/packages/core/package.json +14 -0
- package/harness/packages/core/src/agent/context.js +66 -0
- package/harness/packages/core/src/agent/modes.js +26 -0
- package/harness/packages/core/src/agent/runAgent.js +257 -0
- package/harness/packages/core/src/config/app.js +49 -0
- package/harness/packages/core/src/config/home.js +98 -0
- package/harness/packages/core/src/config/identity.js +41 -0
- package/harness/packages/core/src/config/index.js +69 -0
- package/harness/packages/core/src/config/providers.js +24 -0
- package/harness/packages/core/src/env.js +19 -0
- package/harness/packages/core/src/eval/dataset.js +47 -0
- package/harness/packages/core/src/eval/judge.js +39 -0
- package/harness/packages/core/src/eval/report.js +48 -0
- package/harness/packages/core/src/eval/runTask.js +71 -0
- package/harness/packages/core/src/eval/runner.js +150 -0
- package/harness/packages/core/src/eval/scorers.js +217 -0
- package/harness/packages/core/src/eval/task.js +59 -0
- package/harness/packages/core/src/index.js +72 -0
- package/harness/packages/core/src/mcp/common.js +82 -0
- package/harness/packages/core/src/mcp/http.js +149 -0
- package/harness/packages/core/src/mcp/index.js +167 -0
- package/harness/packages/core/src/mcp/stdio.js +218 -0
- package/harness/packages/core/src/providers/index.js +119 -0
- package/harness/packages/core/src/providers/ollama.js +76 -0
- package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
- package/harness/packages/core/src/providers/openrouter.js +57 -0
- package/harness/packages/core/src/providers/presets.js +19 -0
- package/harness/packages/core/src/providers/resilience.js +74 -0
- package/harness/packages/core/src/providers/stream.js +200 -0
- package/harness/packages/core/src/providers/utils.js +66 -0
- package/harness/packages/core/src/storage/sqlite.js +453 -0
- package/harness/packages/core/src/tools/diff.js +90 -0
- package/harness/packages/core/src/tools/executors.js +277 -0
- package/harness/packages/core/src/tools/glob.js +67 -0
- package/harness/packages/core/src/tools/index.js +28 -0
- package/harness/packages/core/src/tools/paths.js +29 -0
- package/harness/packages/core/src/tools/permissions.js +69 -0
- package/harness/packages/core/src/tools/registry.js +44 -0
- package/harness/packages/core/src/tools/schema.js +177 -0
- package/harness/packages/core/test/agent.test.js +237 -0
- package/harness/packages/core/test/config.test.js +33 -0
- package/harness/packages/core/test/context.test.js +44 -0
- package/harness/packages/core/test/diff.test.js +20 -0
- package/harness/packages/core/test/eval.test.js +144 -0
- package/harness/packages/core/test/git-tools.test.js +44 -0
- package/harness/packages/core/test/home.test.js +62 -0
- package/harness/packages/core/test/mcp.test.js +158 -0
- package/harness/packages/core/test/providers.test.js +80 -0
- package/harness/packages/core/test/resilience.test.js +61 -0
- package/harness/packages/core/test/storage.test.js +55 -0
- package/harness/packages/core/test/stream.test.js +91 -0
- package/harness/packages/core/test/tools.test.js +96 -0
- package/harness/packages/server/bin/tamnora-server.mjs +11 -0
- package/harness/packages/server/package.json +10 -0
- package/harness/packages/server/public/app.js +2188 -0
- package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
- package/harness/packages/server/public/index.html +368 -0
- package/harness/packages/server/public/landing/index.html +523 -0
- package/harness/packages/server/public/landing/landing.js +89 -0
- package/harness/packages/server/public/markdown.js +174 -0
- package/harness/packages/server/public/styles.css +923 -0
- package/harness/packages/server/src/auth.js +146 -0
- package/harness/packages/server/src/cli.js +61 -0
- package/harness/packages/server/src/index.js +1 -0
- package/harness/packages/server/src/lib/approvals.js +51 -0
- package/harness/packages/server/src/lib/core.js +2 -0
- package/harness/packages/server/src/lib/http.js +74 -0
- package/harness/packages/server/src/lib/modelCache.js +32 -0
- package/harness/packages/server/src/lib/prompt.js +48 -0
- package/harness/packages/server/src/lib/workspace.js +74 -0
- package/harness/packages/server/src/routes/auth.js +56 -0
- package/harness/packages/server/src/routes/chat.js +222 -0
- package/harness/packages/server/src/routes/eval.js +100 -0
- package/harness/packages/server/src/routes/files.js +117 -0
- package/harness/packages/server/src/routes/folders.js +80 -0
- package/harness/packages/server/src/routes/mcp.js +50 -0
- package/harness/packages/server/src/routes/models.js +26 -0
- package/harness/packages/server/src/routes/providers.js +135 -0
- package/harness/packages/server/src/routes/sessions.js +157 -0
- package/harness/packages/server/src/server.js +249 -0
- package/harness/packages/server/src/static.js +67 -0
- package/harness/packages/server/test/markdown.test.js +36 -0
- package/harness/packages/server/test/modelCache.test.js +72 -0
- package/harness/packages/server/test/server.test.js +809 -0
- package/harness/scripts/example-dataset.jsonl +4 -0
- package/harness/scripts/mcp-echo-server.mjs +108 -0
- package/harness/scripts/mock-provider.mjs +134 -0
- package/harness/tamnora.example.json +18 -0
- package/package.json +70 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { runAgent, createTools, resolveMode, MAX_TOOL_ROUNDS } from '../lib/core.js';
|
|
3
|
+
import { openSse, readJson, sendJson, sendError } from '../lib/http.js';
|
|
4
|
+
import { workspaceForRequest, resolveExistingDir } from '../lib/workspace.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Emit streaming partials at most once per `ms` window, keeping only the
|
|
8
|
+
* latest payload per event (openbridge-style throttling: the payload carries
|
|
9
|
+
* the cumulative `full`, so last-wins is lossless).
|
|
10
|
+
*/
|
|
11
|
+
function createPartialThrottle(ms, emit) {
|
|
12
|
+
const pending = new Map();
|
|
13
|
+
let timer = null;
|
|
14
|
+
|
|
15
|
+
const drain = () => {
|
|
16
|
+
if (timer) {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
timer = null;
|
|
19
|
+
}
|
|
20
|
+
const entries = [...pending.entries()];
|
|
21
|
+
pending.clear();
|
|
22
|
+
for (const [name, payload] of entries) emit(name, payload);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
push(name, payload) {
|
|
27
|
+
pending.set(name, payload);
|
|
28
|
+
if (timer) return;
|
|
29
|
+
timer = setTimeout(drain, ms);
|
|
30
|
+
timer.unref?.();
|
|
31
|
+
},
|
|
32
|
+
flush: drain,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerChat(route, ctx) {
|
|
37
|
+
const askMeta = new Map();
|
|
38
|
+
|
|
39
|
+
route('POST', '/api/permissions/:askId', async ({ req, res, params }) => {
|
|
40
|
+
const body = await readJson(req);
|
|
41
|
+
const meta = askMeta.get(params.askId);
|
|
42
|
+
const allow = Boolean(body.allow);
|
|
43
|
+
if (!ctx.approvals.decide(params.askId, allow)) {
|
|
44
|
+
return sendError(res, 404, 'Solicitud de permiso expirada o inexistente.');
|
|
45
|
+
}
|
|
46
|
+
askMeta.delete(params.askId);
|
|
47
|
+
const scope = body.scope === 'session' ? 'session' : 'once';
|
|
48
|
+
if (allow && scope === 'session' && meta?.sessionId) ctx.setAutoApprove(meta.sessionId, true);
|
|
49
|
+
sendJson(res, 200, { ok: true, allow, scope });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
route('POST', '/api/chat', async ({ req, res }) => {
|
|
53
|
+
const body = await readJson(req);
|
|
54
|
+
const input = String(body.input ?? '').trim();
|
|
55
|
+
const images = (Array.isArray(body.images) ? body.images : []).filter(
|
|
56
|
+
(url) => typeof url === 'string' && url.startsWith('data:image/')
|
|
57
|
+
);
|
|
58
|
+
if (!input && !images.length) return sendError(res, 400, 'Falta "input".');
|
|
59
|
+
|
|
60
|
+
const providerId = body.provider || ctx.effectiveProviderId();
|
|
61
|
+
const model = body.model || ctx.effectiveModel();
|
|
62
|
+
if (!model) return sendError(res, 400, 'Falta "model" (en el cuerpo, en Ajustes o en tamnora.json).');
|
|
63
|
+
|
|
64
|
+
let sessionId = body.sessionId;
|
|
65
|
+
let history = [];
|
|
66
|
+
let sessionCwd;
|
|
67
|
+
if (sessionId) {
|
|
68
|
+
const existing = ctx.store.getSession(sessionId);
|
|
69
|
+
if (!existing) return sendError(res, 404, 'Sesión no encontrada.');
|
|
70
|
+
history = ctx.store.getMessages(sessionId);
|
|
71
|
+
try {
|
|
72
|
+
sessionCwd = resolveExistingDir(existing.cwd || ctx.cwd);
|
|
73
|
+
} catch {
|
|
74
|
+
sessionCwd = ctx.cwd;
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
try {
|
|
78
|
+
sessionCwd = workspaceForRequest({ req, raw: body.cwd, fallback: ctx.cwd });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return sendError(res, 400, error.message);
|
|
81
|
+
}
|
|
82
|
+
sessionId = ctx.store.createSession({
|
|
83
|
+
title: (input || 'Imagen').slice(0, 60),
|
|
84
|
+
provider: providerId,
|
|
85
|
+
model,
|
|
86
|
+
cwd: sessionCwd,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const mode = resolveMode(body.mode ?? ctx.store.getSetting('activeMode'));
|
|
91
|
+
|
|
92
|
+
const sse = openSse(req, res);
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const autoApprove = body.yes ?? ctx.yes;
|
|
95
|
+
const asked = new Set();
|
|
96
|
+
req.on('close', () => {
|
|
97
|
+
controller.abort();
|
|
98
|
+
ctx.approvals.cancel(asked);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
sse.event('start', { sessionId, provider: providerId, model, mode: mode.id, cwd: sessionCwd });
|
|
102
|
+
sse.event('user', { content: input });
|
|
103
|
+
|
|
104
|
+
let mcp = null;
|
|
105
|
+
try {
|
|
106
|
+
mcp = await ctx.getMcp();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
sse.event('warning', { message: `MCP: ${error?.message ?? error}` });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const enabled = Array.isArray(body.tools) && body.tools.length ? body.tools : mode.tools ?? undefined;
|
|
112
|
+
const onAsk = autoApprove
|
|
113
|
+
? undefined
|
|
114
|
+
: async (name, args) => {
|
|
115
|
+
if (ctx.isAutoApprove(sessionId)) return true;
|
|
116
|
+
const askId = randomUUID();
|
|
117
|
+
asked.add(askId);
|
|
118
|
+
askMeta.set(askId, { sessionId });
|
|
119
|
+
sse.event('permission', { askId, sessionId, name, args });
|
|
120
|
+
const allow = await ctx.approvals.ask(askId);
|
|
121
|
+
asked.delete(askId);
|
|
122
|
+
askMeta.delete(askId);
|
|
123
|
+
sse.event('permission_resolved', { askId, allow });
|
|
124
|
+
return allow;
|
|
125
|
+
};
|
|
126
|
+
const { definitions, execute } = createTools({
|
|
127
|
+
root: sessionCwd,
|
|
128
|
+
enabled,
|
|
129
|
+
permissions: ctx.config.permissions,
|
|
130
|
+
yes: autoApprove,
|
|
131
|
+
onAsk,
|
|
132
|
+
mcp,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const { provider, config } = ctx.resolveProvider({
|
|
136
|
+
...ctx.providerSettings(providerId),
|
|
137
|
+
apiKey: ctx.apiKeyFor(providerId),
|
|
138
|
+
baseUrl: body.baseUrl || ctx.providerSettings(providerId).baseUrl,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const userMessage = images.length
|
|
142
|
+
? {
|
|
143
|
+
role: 'user',
|
|
144
|
+
content: [
|
|
145
|
+
{ type: 'text', text: input },
|
|
146
|
+
...images.map((url) => ({ type: 'image_url', image_url: { url } })),
|
|
147
|
+
],
|
|
148
|
+
}
|
|
149
|
+
: { role: 'user', content: input };
|
|
150
|
+
|
|
151
|
+
const sessionRecord = ctx.store.getSession(sessionId);
|
|
152
|
+
const goal = sessionRecord?.meta?.goal;
|
|
153
|
+
const summary = sessionRecord?.meta?.summary;
|
|
154
|
+
const basePrompt = await ctx.systemPromptFor(sessionCwd);
|
|
155
|
+
const messages = [
|
|
156
|
+
{ role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
|
|
157
|
+
...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
|
|
158
|
+
...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
|
|
159
|
+
...history,
|
|
160
|
+
userMessage,
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const partials = createPartialThrottle(300, (name, payload) => sse.event(name, payload));
|
|
165
|
+
const result = await runAgent({
|
|
166
|
+
provider,
|
|
167
|
+
config,
|
|
168
|
+
model,
|
|
169
|
+
messages,
|
|
170
|
+
tools: definitions,
|
|
171
|
+
execute,
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
maxRounds: Number(body.maxRounds) || ctx.config.maxRounds || MAX_TOOL_ROUNDS,
|
|
174
|
+
stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
|
|
175
|
+
contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
|
|
176
|
+
onEvent(event) {
|
|
177
|
+
if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
|
|
178
|
+
else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });
|
|
179
|
+
else if (event.type === 'stall') {
|
|
180
|
+
partials.flush();
|
|
181
|
+
sse.event('stall', { seconds: event.seconds });
|
|
182
|
+
} else if (event.type === 'retry') {
|
|
183
|
+
partials.flush();
|
|
184
|
+
sse.event('retry', { attempt: event.attempt, delayMs: event.delayMs, message: event.message });
|
|
185
|
+
} else if (event.type === 'trim') {
|
|
186
|
+
partials.flush();
|
|
187
|
+
sse.event('trim', { trimmed: event.trimmed, dropped: event.dropped, tokens: event.tokens });
|
|
188
|
+
} else if (event.type === 'tool_round') {
|
|
189
|
+
partials.flush();
|
|
190
|
+
sse.event('tools', { toolCalls: event.toolCalls });
|
|
191
|
+
} else if (event.type === 'tool_call') {
|
|
192
|
+
partials.flush();
|
|
193
|
+
sse.event('tool_call', { name: event.name, args: event.args });
|
|
194
|
+
} else if (event.type === 'tool_result') {
|
|
195
|
+
partials.flush();
|
|
196
|
+
sse.event('tool_result', { name: event.name, result: String(event.result).slice(0, 8000) });
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
partials.flush();
|
|
201
|
+
|
|
202
|
+
const newMessages = result.messages.slice(1 + history.length);
|
|
203
|
+
ctx.store.addMessages(sessionId, newMessages);
|
|
204
|
+
const current = ctx.store.getSession(sessionId);
|
|
205
|
+
const title =
|
|
206
|
+
current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
|
|
207
|
+
ctx.store.touchSession(sessionId, { provider: providerId, model, title });
|
|
208
|
+
sse.event('done', {
|
|
209
|
+
sessionId,
|
|
210
|
+
content: result.content,
|
|
211
|
+
rounds: result.rounds,
|
|
212
|
+
stopReason: result.stopReason,
|
|
213
|
+
tokens: result.usage ?? null,
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error?.name === 'AbortError') sse.event('aborted', {});
|
|
217
|
+
else sse.event('error', { message: error?.message ?? String(error) });
|
|
218
|
+
} finally {
|
|
219
|
+
sse.close();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadDataset, runDataset, summarize, createJudge } from '../lib/core.js';
|
|
3
|
+
import { openSse, readJson, sendError, sendJson } from '../lib/http.js';
|
|
4
|
+
|
|
5
|
+
function toNumber(value, fallback) {
|
|
6
|
+
const n = Number(value);
|
|
7
|
+
return Number.isFinite(n) ? n : fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function registerEval(route, ctx) {
|
|
11
|
+
route('POST', '/api/eval', async ({ req, res }) => {
|
|
12
|
+
const body = await readJson(req);
|
|
13
|
+
|
|
14
|
+
let tasks = Array.isArray(body.tasks) ? body.tasks : [];
|
|
15
|
+
if (!tasks.length && body.dataset) {
|
|
16
|
+
try {
|
|
17
|
+
tasks = await loadDataset(path.resolve(ctx.cwd, String(body.dataset)));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return sendError(res, 400, `No se pudo leer el dataset: ${error?.message ?? error}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (!tasks.length) return sendError(res, 400, 'Faltan tareas ("tasks" o "dataset").');
|
|
23
|
+
|
|
24
|
+
const models = (Array.isArray(body.models) ? body.models : [body.model])
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map(String);
|
|
27
|
+
if (!models.length) return sendError(res, 400, 'Falta "models".');
|
|
28
|
+
|
|
29
|
+
const providerId = body.provider || ctx.effectiveProviderId();
|
|
30
|
+
const trials = toNumber(body.trials, 1);
|
|
31
|
+
const concurrency = toNumber(body.concurrency, 1);
|
|
32
|
+
const baseUrl = body.baseUrl || ctx.providerSettings(providerId).baseUrl;
|
|
33
|
+
const apiKey = ctx.apiKeyFor(providerId);
|
|
34
|
+
|
|
35
|
+
const enabledTools = Array.isArray(body.tools) && body.tools.length ? body.tools : ctx.config.tools;
|
|
36
|
+
|
|
37
|
+
const judge = body.judgeModel
|
|
38
|
+
? createJudge({ providerId, model: body.judgeModel, baseUrl, apiKey })
|
|
39
|
+
: undefined;
|
|
40
|
+
|
|
41
|
+
const sse = openSse(req, res);
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
req.on('close', () => controller.abort());
|
|
44
|
+
|
|
45
|
+
const runId = ctx.store.createRun({
|
|
46
|
+
dataset: body.name || body.dataset || 'inline',
|
|
47
|
+
config: { providerId, models, trials, concurrency, tools: enabledTools ?? 'all' },
|
|
48
|
+
});
|
|
49
|
+
sse.event('run', { runId, total: tasks.length * models.length * trials });
|
|
50
|
+
|
|
51
|
+
const startedAt = Date.now();
|
|
52
|
+
try {
|
|
53
|
+
const results = await runDataset({
|
|
54
|
+
tasks,
|
|
55
|
+
models,
|
|
56
|
+
providerId,
|
|
57
|
+
baseUrl,
|
|
58
|
+
apiKey,
|
|
59
|
+
systemPrompt: ctx.systemPrompt,
|
|
60
|
+
rootDir: ctx.cwd,
|
|
61
|
+
trials,
|
|
62
|
+
concurrency,
|
|
63
|
+
enabledTools: Array.isArray(enabledTools) && enabledTools.length ? enabledTools : undefined,
|
|
64
|
+
permissions: ctx.config.permissions,
|
|
65
|
+
yes: body.yes ?? ctx.yes,
|
|
66
|
+
judge,
|
|
67
|
+
store: ctx.store,
|
|
68
|
+
runId,
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
onProgress: (entry) => sse.event('result', entry),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const summary = summarize(results);
|
|
74
|
+
summary.durationMs = Date.now() - startedAt;
|
|
75
|
+
const status =
|
|
76
|
+
summary.errors && summary.passed === 0
|
|
77
|
+
? 'failed'
|
|
78
|
+
: summary.errors
|
|
79
|
+
? 'done_with_errors'
|
|
80
|
+
: 'done';
|
|
81
|
+
ctx.store.finishRun(runId, { status, summary });
|
|
82
|
+
sse.event('summary', { runId, summary });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error?.name === 'AbortError') sse.event('aborted', {});
|
|
85
|
+
else sse.event('error', { message: error?.message ?? String(error) });
|
|
86
|
+
} finally {
|
|
87
|
+
sse.close();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
route('GET', '/api/runs', ({ res }) => {
|
|
92
|
+
sendJson(res, 200, { runs: ctx.store.listRuns(50) });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
route('GET', '/api/runs/:id', ({ res, params }) => {
|
|
96
|
+
const run = ctx.store.getRun(params.id);
|
|
97
|
+
if (!run) return sendError(res, 404, 'Run no encontrado.');
|
|
98
|
+
sendJson(res, 200, { run, results: ctx.store.getRunResults(params.id) });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { sendJson, sendError } from '../lib/http.js';
|
|
5
|
+
import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
|
|
6
|
+
|
|
7
|
+
const MAX_PREVIEW = 200 * 1024;
|
|
8
|
+
const IGNORED = new Set(['node_modules', '.git', '.tamnora', '.playwright-mcp', 'dist', '.cache']);
|
|
9
|
+
|
|
10
|
+
function isInside(root, target) {
|
|
11
|
+
const rel = path.relative(root, target);
|
|
12
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Resolve `?root` + `?path` into a confined filesystem target, or send an error. */
|
|
16
|
+
function resolveTarget(req, res, query, fallback) {
|
|
17
|
+
if (!isLoopback(req.socket.localAddress)) {
|
|
18
|
+
sendError(res, 403, 'Explorar archivos solo está disponible en conexiones locales.');
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
let root;
|
|
22
|
+
try {
|
|
23
|
+
root = resolveExistingDir(query.get('root') || fallback);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
sendError(res, 400, error.message);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const rel = String(query.get('path') ?? '').replace(/^[/\\]+/, '');
|
|
29
|
+
const target = path.resolve(root, rel);
|
|
30
|
+
if (!isInside(root, target)) {
|
|
31
|
+
sendError(res, 400, 'La ruta queda fuera del workspace.');
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return { root, target };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const toPosix = (value) => value.split(path.sep).join('/');
|
|
38
|
+
|
|
39
|
+
export function registerFiles(route, ctx) {
|
|
40
|
+
route('GET', '/api/files', ({ req, res, query }) => {
|
|
41
|
+
const resolved = resolveTarget(req, res, query, ctx.cwd);
|
|
42
|
+
if (!resolved) return;
|
|
43
|
+
|
|
44
|
+
let stat;
|
|
45
|
+
try {
|
|
46
|
+
stat = fs.statSync(resolved.target);
|
|
47
|
+
} catch {
|
|
48
|
+
return sendError(res, 404, 'La carpeta no existe.');
|
|
49
|
+
}
|
|
50
|
+
if (!stat.isDirectory()) return sendError(res, 400, 'No es una carpeta.');
|
|
51
|
+
|
|
52
|
+
let dirents;
|
|
53
|
+
try {
|
|
54
|
+
dirents = fs.readdirSync(resolved.target, { withFileTypes: true });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return sendError(res, 500, error.message);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const entries = [];
|
|
60
|
+
for (const dirent of dirents) {
|
|
61
|
+
if (IGNORED.has(dirent.name)) continue;
|
|
62
|
+
const full = path.join(resolved.target, dirent.name);
|
|
63
|
+
let isDir = dirent.isDirectory();
|
|
64
|
+
if (!isDir && dirent.isSymbolicLink()) {
|
|
65
|
+
try {
|
|
66
|
+
isDir = fs.statSync(full).isDirectory();
|
|
67
|
+
} catch {
|
|
68
|
+
isDir = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let size = 0;
|
|
72
|
+
if (!isDir) {
|
|
73
|
+
try {
|
|
74
|
+
size = fs.statSync(full).size;
|
|
75
|
+
} catch {
|
|
76
|
+
size = 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
entries.push({ name: dirent.name, type: isDir ? 'dir' : 'file', size });
|
|
80
|
+
}
|
|
81
|
+
entries.sort((a, b) =>
|
|
82
|
+
a.type === b.type ? a.name.localeCompare(b.name, 'es') : a.type === 'dir' ? -1 : 1
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
sendJson(res, 200, { root: resolved.root, path: toPosix(path.relative(resolved.root, resolved.target)), entries });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
route('GET', '/api/files/content', async ({ req, res, query }) => {
|
|
89
|
+
const resolved = resolveTarget(req, res, query, ctx.cwd);
|
|
90
|
+
if (!resolved) return;
|
|
91
|
+
|
|
92
|
+
let stat;
|
|
93
|
+
try {
|
|
94
|
+
stat = await fsp.stat(resolved.target);
|
|
95
|
+
} catch {
|
|
96
|
+
return sendError(res, 404, 'El archivo no existe.');
|
|
97
|
+
}
|
|
98
|
+
if (!stat.isFile()) return sendError(res, 400, 'No es un archivo.');
|
|
99
|
+
if (stat.size > MAX_PREVIEW) {
|
|
100
|
+
return sendError(res, 413, `Archivo demasiado grande (${stat.size} bytes; máximo ${MAX_PREVIEW}).`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let buffer;
|
|
104
|
+
try {
|
|
105
|
+
buffer = await fsp.readFile(resolved.target);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return sendError(res, 500, error.message);
|
|
108
|
+
}
|
|
109
|
+
if (buffer.includes(0)) return sendError(res, 415, 'Vista previa no disponible para archivos binarios.');
|
|
110
|
+
|
|
111
|
+
sendJson(res, 200, {
|
|
112
|
+
path: toPosix(path.relative(resolved.root, resolved.target)),
|
|
113
|
+
size: stat.size,
|
|
114
|
+
content: buffer.toString('utf8'),
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
4
|
+
import { isLoopback, rootDirs, listSubdirs, resolveExistingDir } from '../lib/workspace.js';
|
|
5
|
+
|
|
6
|
+
const INVALID_NAME = /[\\/:*?"<>|]/;
|
|
7
|
+
|
|
8
|
+
function validFolderName(name) {
|
|
9
|
+
if (!name || name === '.' || name === '..' || INVALID_NAME.test(name)) return false;
|
|
10
|
+
return [...name].every((char) => char.charCodeAt(0) >= 32);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function registerFolders(route) {
|
|
14
|
+
route('GET', '/api/folders', ({ req, res, query }) => {
|
|
15
|
+
if (!isLoopback(req.socket.localAddress)) {
|
|
16
|
+
return sendError(res, 403, 'El navegador de carpetas solo está disponible en conexiones locales.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const raw = String(query.get('path') ?? '').trim();
|
|
20
|
+
const atRoot = !raw || raw === '/' || /^[A-Za-z]:?$/.test(raw);
|
|
21
|
+
if (atRoot) {
|
|
22
|
+
return sendJson(res, 200, {
|
|
23
|
+
path: null,
|
|
24
|
+
parent: null,
|
|
25
|
+
dirs: rootDirs().map((drive) => ({ name: drive, path: drive })),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let dir;
|
|
30
|
+
try {
|
|
31
|
+
dir = resolveExistingDir(raw);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return sendError(res, 400, error.message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let dirs;
|
|
37
|
+
try {
|
|
38
|
+
dirs = listSubdirs(dir);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return sendError(res, 400, `No se pudo leer la carpeta: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const parent = path.dirname(dir);
|
|
44
|
+
sendJson(res, 200, {
|
|
45
|
+
path: dir,
|
|
46
|
+
parent: parent === dir ? null : parent,
|
|
47
|
+
dirs,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
route('POST', '/api/folders', async ({ req, res }) => {
|
|
52
|
+
if (!isLoopback(req.socket.localAddress)) {
|
|
53
|
+
return sendError(res, 403, 'Crear carpetas solo está disponible en conexiones locales.');
|
|
54
|
+
}
|
|
55
|
+
const body = await readJson(req);
|
|
56
|
+
const name = String(body.name ?? '').trim();
|
|
57
|
+
if (!validFolderName(name)) {
|
|
58
|
+
return sendError(res, 400, 'Nombre de carpeta inválido.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!String(body.parent ?? '').trim()) {
|
|
62
|
+
return sendError(res, 400, 'Elegí la carpeta contenedora primero.');
|
|
63
|
+
}
|
|
64
|
+
let parent;
|
|
65
|
+
try {
|
|
66
|
+
parent = resolveExistingDir(body.parent);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return sendError(res, 400, error.message);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const target = path.join(parent, name);
|
|
72
|
+
try {
|
|
73
|
+
await mkdir(target);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code === 'EEXIST') return sendError(res, 409, `Ya existe una carpeta "${name}" acá.`);
|
|
76
|
+
return sendError(res, 400, `No se pudo crear la carpeta: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
sendJson(res, 201, { path: target });
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { buildMcpRegistry, mcpTransport } from '../lib/core.js';
|
|
2
|
+
import { sendJson } from '../lib/http.js';
|
|
3
|
+
|
|
4
|
+
function safeTransport(server) {
|
|
5
|
+
try {
|
|
6
|
+
return mcpTransport(server);
|
|
7
|
+
} catch {
|
|
8
|
+
return '?';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerMcp(route, ctx) {
|
|
13
|
+
route('GET', '/api/mcp', async ({ res, query }) => {
|
|
14
|
+
const servers = (ctx.config.mcp ?? []).map((server) => ({
|
|
15
|
+
id: server.id,
|
|
16
|
+
name: server.name,
|
|
17
|
+
transport: safeTransport(server),
|
|
18
|
+
enabled: server.enabled !== false,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
if (query.get('connect')) {
|
|
22
|
+
const registry = await buildMcpRegistry(ctx.config.mcp ?? []);
|
|
23
|
+
try {
|
|
24
|
+
for (const server of servers) {
|
|
25
|
+
const session = registry.sessions.find((s) => s.server.id === server.id);
|
|
26
|
+
const failure = registry.errors.find((e) => e.server.id === server.id);
|
|
27
|
+
server.ok = !failure;
|
|
28
|
+
server.error = failure?.error;
|
|
29
|
+
server.tools = (session?.tools ?? []).map((t) => ({
|
|
30
|
+
name: t.name,
|
|
31
|
+
description: t.description,
|
|
32
|
+
}));
|
|
33
|
+
server.resources = (session?.resources ?? []).map((r) => ({
|
|
34
|
+
uri: r.uri,
|
|
35
|
+
name: r.name,
|
|
36
|
+
mimeType: r.mimeType,
|
|
37
|
+
}));
|
|
38
|
+
server.prompts = (session?.prompts ?? []).map((p) => ({
|
|
39
|
+
name: p.name,
|
|
40
|
+
description: p.description,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
registry.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
sendJson(res, 200, { servers });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { listProviderModels } from '../lib/core.js';
|
|
2
|
+
import { sendJson } from '../lib/http.js';
|
|
3
|
+
import { createModelCache } from '../lib/modelCache.js';
|
|
4
|
+
|
|
5
|
+
export function registerModels(route, ctx) {
|
|
6
|
+
const modelCache = createModelCache();
|
|
7
|
+
|
|
8
|
+
route('GET', '/api/models', async ({ res, query }) => {
|
|
9
|
+
const providerId = query.get('provider') || ctx.effectiveProviderId();
|
|
10
|
+
const settings = { ...ctx.providerSettings(providerId), apiKey: ctx.apiKeyFor(providerId) };
|
|
11
|
+
if (query.get('baseUrl')) settings.baseUrl = query.get('baseUrl');
|
|
12
|
+
const key = [providerId, settings.baseUrl ?? '', (settings.models ?? []).join(',')].join('|');
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const { models, cached, stale } = await modelCache.get(key, () => listProviderModels(settings));
|
|
16
|
+
sendJson(res, 200, {
|
|
17
|
+
provider: providerId,
|
|
18
|
+
models,
|
|
19
|
+
cached: Boolean(cached),
|
|
20
|
+
...(stale ? { stale } : {}),
|
|
21
|
+
});
|
|
22
|
+
} catch (error) {
|
|
23
|
+
sendJson(res, 200, { provider: providerId, models: [], error: error?.message ?? String(error) });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|