@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/SECURITY.md +31 -0
  5. package/harness/packages/cli/bin/tamnora.mjs +11 -0
  6. package/harness/packages/cli/package.json +10 -0
  7. package/harness/packages/cli/src/cli.js +90 -0
  8. package/harness/packages/cli/src/commands/autostart.js +121 -0
  9. package/harness/packages/cli/src/commands/bridge.js +274 -0
  10. package/harness/packages/cli/src/commands/doctor.js +73 -0
  11. package/harness/packages/cli/src/commands/eval.js +183 -0
  12. package/harness/packages/cli/src/commands/init.js +31 -0
  13. package/harness/packages/cli/src/commands/mcp.js +101 -0
  14. package/harness/packages/cli/src/commands/repl.js +172 -0
  15. package/harness/packages/cli/src/commands/run.js +165 -0
  16. package/harness/packages/cli/src/commands/serve.js +1 -0
  17. package/harness/packages/cli/src/commands/server.js +345 -0
  18. package/harness/packages/cli/src/commands/tui.js +110 -0
  19. package/harness/packages/cli/src/commands/update.js +76 -0
  20. package/harness/packages/cli/src/commands/user.js +135 -0
  21. package/harness/packages/cli/src/lib/core.js +2 -0
  22. package/harness/packages/cli/src/lib/daemon.js +153 -0
  23. package/harness/packages/cli/src/lib/qr.js +203 -0
  24. package/harness/packages/cli/src/lib/runtime.js +110 -0
  25. package/harness/packages/cli/src/lib/tunnel.js +113 -0
  26. package/harness/packages/cli/src/lib/util.js +24 -0
  27. package/harness/packages/cli/src/tui/app.js +302 -0
  28. package/harness/packages/cli/src/tui/keys.js +78 -0
  29. package/harness/packages/cli/src/tui/layout.js +130 -0
  30. package/harness/packages/cli/src/tui/screen.js +13 -0
  31. package/harness/packages/cli/src/ui/color.js +15 -0
  32. package/harness/packages/cli/test/release.test.js +34 -0
  33. package/harness/packages/cli/test/tui.test.js +99 -0
  34. package/harness/packages/cli/test/tunnel.test.js +20 -0
  35. package/harness/packages/core/package.json +14 -0
  36. package/harness/packages/core/src/agent/context.js +66 -0
  37. package/harness/packages/core/src/agent/modes.js +26 -0
  38. package/harness/packages/core/src/agent/runAgent.js +257 -0
  39. package/harness/packages/core/src/config/app.js +49 -0
  40. package/harness/packages/core/src/config/home.js +98 -0
  41. package/harness/packages/core/src/config/identity.js +41 -0
  42. package/harness/packages/core/src/config/index.js +69 -0
  43. package/harness/packages/core/src/config/providers.js +24 -0
  44. package/harness/packages/core/src/env.js +19 -0
  45. package/harness/packages/core/src/eval/dataset.js +47 -0
  46. package/harness/packages/core/src/eval/judge.js +39 -0
  47. package/harness/packages/core/src/eval/report.js +48 -0
  48. package/harness/packages/core/src/eval/runTask.js +71 -0
  49. package/harness/packages/core/src/eval/runner.js +150 -0
  50. package/harness/packages/core/src/eval/scorers.js +217 -0
  51. package/harness/packages/core/src/eval/task.js +59 -0
  52. package/harness/packages/core/src/index.js +72 -0
  53. package/harness/packages/core/src/mcp/common.js +82 -0
  54. package/harness/packages/core/src/mcp/http.js +149 -0
  55. package/harness/packages/core/src/mcp/index.js +167 -0
  56. package/harness/packages/core/src/mcp/stdio.js +218 -0
  57. package/harness/packages/core/src/providers/index.js +119 -0
  58. package/harness/packages/core/src/providers/ollama.js +76 -0
  59. package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
  60. package/harness/packages/core/src/providers/openrouter.js +57 -0
  61. package/harness/packages/core/src/providers/presets.js +19 -0
  62. package/harness/packages/core/src/providers/resilience.js +74 -0
  63. package/harness/packages/core/src/providers/stream.js +200 -0
  64. package/harness/packages/core/src/providers/utils.js +66 -0
  65. package/harness/packages/core/src/storage/sqlite.js +453 -0
  66. package/harness/packages/core/src/tools/diff.js +90 -0
  67. package/harness/packages/core/src/tools/executors.js +277 -0
  68. package/harness/packages/core/src/tools/glob.js +67 -0
  69. package/harness/packages/core/src/tools/index.js +28 -0
  70. package/harness/packages/core/src/tools/paths.js +29 -0
  71. package/harness/packages/core/src/tools/permissions.js +69 -0
  72. package/harness/packages/core/src/tools/registry.js +44 -0
  73. package/harness/packages/core/src/tools/schema.js +177 -0
  74. package/harness/packages/core/test/agent.test.js +237 -0
  75. package/harness/packages/core/test/config.test.js +33 -0
  76. package/harness/packages/core/test/context.test.js +44 -0
  77. package/harness/packages/core/test/diff.test.js +20 -0
  78. package/harness/packages/core/test/eval.test.js +144 -0
  79. package/harness/packages/core/test/git-tools.test.js +44 -0
  80. package/harness/packages/core/test/home.test.js +62 -0
  81. package/harness/packages/core/test/mcp.test.js +158 -0
  82. package/harness/packages/core/test/providers.test.js +80 -0
  83. package/harness/packages/core/test/resilience.test.js +61 -0
  84. package/harness/packages/core/test/storage.test.js +55 -0
  85. package/harness/packages/core/test/stream.test.js +91 -0
  86. package/harness/packages/core/test/tools.test.js +96 -0
  87. package/harness/packages/server/bin/tamnora-server.mjs +11 -0
  88. package/harness/packages/server/package.json +10 -0
  89. package/harness/packages/server/public/app.js +2188 -0
  90. package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
  91. package/harness/packages/server/public/index.html +368 -0
  92. package/harness/packages/server/public/landing/index.html +523 -0
  93. package/harness/packages/server/public/landing/landing.js +89 -0
  94. package/harness/packages/server/public/markdown.js +174 -0
  95. package/harness/packages/server/public/styles.css +923 -0
  96. package/harness/packages/server/src/auth.js +146 -0
  97. package/harness/packages/server/src/cli.js +61 -0
  98. package/harness/packages/server/src/index.js +1 -0
  99. package/harness/packages/server/src/lib/approvals.js +51 -0
  100. package/harness/packages/server/src/lib/core.js +2 -0
  101. package/harness/packages/server/src/lib/http.js +74 -0
  102. package/harness/packages/server/src/lib/modelCache.js +32 -0
  103. package/harness/packages/server/src/lib/prompt.js +48 -0
  104. package/harness/packages/server/src/lib/workspace.js +74 -0
  105. package/harness/packages/server/src/routes/auth.js +56 -0
  106. package/harness/packages/server/src/routes/chat.js +222 -0
  107. package/harness/packages/server/src/routes/eval.js +100 -0
  108. package/harness/packages/server/src/routes/files.js +117 -0
  109. package/harness/packages/server/src/routes/folders.js +80 -0
  110. package/harness/packages/server/src/routes/mcp.js +50 -0
  111. package/harness/packages/server/src/routes/models.js +26 -0
  112. package/harness/packages/server/src/routes/providers.js +135 -0
  113. package/harness/packages/server/src/routes/sessions.js +157 -0
  114. package/harness/packages/server/src/server.js +249 -0
  115. package/harness/packages/server/src/static.js +67 -0
  116. package/harness/packages/server/test/markdown.test.js +36 -0
  117. package/harness/packages/server/test/modelCache.test.js +72 -0
  118. package/harness/packages/server/test/server.test.js +809 -0
  119. package/harness/scripts/example-dataset.jsonl +4 -0
  120. package/harness/scripts/mcp-echo-server.mjs +108 -0
  121. package/harness/scripts/mock-provider.mjs +134 -0
  122. package/harness/tamnora.example.json +18 -0
  123. package/package.json +70 -0
@@ -0,0 +1,135 @@
1
+ import { MODES, MODE_IDS, resolveMode, PROVIDER_META, testProviderConnection } from '../lib/core.js';
2
+ import { readJson, sendJson, sendError } from '../lib/http.js';
3
+
4
+ const ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
5
+
6
+ function hint(apiKey) {
7
+ if (!apiKey) return '';
8
+ if (apiKey.startsWith('env:')) return apiKey;
9
+ return apiKey.length <= 4 ? '••••' : `••••${apiKey.slice(-4)}`;
10
+ }
11
+
12
+ /** Merge built-in metadata with stored overrides into the provider view. */
13
+ export function providerList(ctx) {
14
+ const stored = new Map(ctx.store.listProviders().map((p) => [p.id, p]));
15
+ const items = [];
16
+
17
+ for (const meta of PROVIDER_META) {
18
+ const row = stored.get(meta.id);
19
+ items.push({
20
+ id: meta.id,
21
+ label: row?.label || meta.label,
22
+ baseUrl: row?.baseUrl || '',
23
+ defaultBaseUrl: meta.defaultBaseUrl,
24
+ requiresKey: row?.requiresKey ?? meta.requiresKey,
25
+ custom: false,
26
+ builtin: true,
27
+ stored: Boolean(row),
28
+ hasKey: Boolean(row?.apiKey),
29
+ apiKeyHint: hint(row?.apiKey),
30
+ models: row?.models ?? [],
31
+ });
32
+ }
33
+
34
+ for (const row of stored.values()) {
35
+ if (row.custom) {
36
+ items.push({
37
+ id: row.id,
38
+ label: row.label,
39
+ baseUrl: row.baseUrl,
40
+ defaultBaseUrl: '',
41
+ requiresKey: row.requiresKey ?? true,
42
+ custom: true,
43
+ builtin: false,
44
+ stored: true,
45
+ hasKey: Boolean(row.apiKey),
46
+ apiKeyHint: hint(row.apiKey),
47
+ models: row.models,
48
+ });
49
+ }
50
+ }
51
+
52
+ return {
53
+ providers: items,
54
+ active: { provider: ctx.effectiveProviderId(), model: ctx.effectiveModel() },
55
+ };
56
+ }
57
+
58
+ export function registerProviders(route, ctx) {
59
+ route('GET', '/api/providers', ({ res }) => sendJson(res, 200, providerList(ctx)));
60
+
61
+ route('GET', '/api/modes', ({ res }) => {
62
+ sendJson(res, 200, {
63
+ modes: MODE_IDS.map((id) => ({
64
+ id,
65
+ label: MODES[id].label,
66
+ description: MODES[id].description,
67
+ })),
68
+ });
69
+ });
70
+
71
+ route('GET', '/api/settings', ({ res }) => {
72
+ sendJson(res, 200, {
73
+ settings: {
74
+ provider: ctx.effectiveProviderId(),
75
+ model: ctx.effectiveModel(),
76
+ mode: ctx.effectiveMode(),
77
+ maxRounds: ctx.config.maxRounds,
78
+ },
79
+ });
80
+ });
81
+
82
+ route('PUT', '/api/settings', async ({ req, res }) => {
83
+ const body = await readJson(req);
84
+ if (body.provider !== undefined) ctx.store.setSetting('activeProvider', String(body.provider) || null);
85
+ if (body.model !== undefined) ctx.store.setSetting('activeModel', String(body.model) || null);
86
+ if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
87
+ sendJson(res, 200, {
88
+ settings: {
89
+ provider: ctx.effectiveProviderId(),
90
+ model: ctx.effectiveModel(),
91
+ mode: ctx.effectiveMode(),
92
+ },
93
+ });
94
+ });
95
+
96
+ route('PUT', '/api/providers/:id', async ({ req, res, params }) => {
97
+ const id = String(params.id);
98
+ if (!ID_PATTERN.test(id)) return sendError(res, 400, 'Id de proveedor inválido.');
99
+
100
+ const body = await readJson(req);
101
+ const existing = ctx.store.getProvider(id) ?? {};
102
+ const builtin = PROVIDER_META.find((p) => p.id === id);
103
+
104
+ const record = {
105
+ id,
106
+ label: body.label ?? existing.label ?? builtin?.label ?? id,
107
+ baseUrl: body.baseUrl ?? existing.baseUrl ?? '',
108
+ apiKey: 'apiKey' in body ? String(body.apiKey ?? '') : existing.apiKey ?? '',
109
+ models: Array.isArray(body.models)
110
+ ? body.models.map((m) => String(m).trim()).filter(Boolean)
111
+ : existing.models ?? [],
112
+ requiresKey: body.requiresKey ?? existing.requiresKey ?? builtin?.requiresKey,
113
+ custom: builtin ? Boolean(body.custom ?? existing.custom ?? false) : true,
114
+ enabled: body.enabled ?? existing.enabled ?? true,
115
+ };
116
+
117
+ ctx.store.saveProvider(record);
118
+ sendJson(res, 200, providerList(ctx));
119
+ });
120
+
121
+ route('DELETE', '/api/providers/:id', ({ res, params }) => {
122
+ const id = String(params.id);
123
+ if (!ctx.store.getProvider(id)) return sendError(res, 404, 'El proveedor no tiene configuración guardada.');
124
+ ctx.store.deleteProvider(id);
125
+ sendJson(res, 200, providerList(ctx));
126
+ });
127
+
128
+ route('POST', '/api/providers/:id/test', async ({ res, params }) => {
129
+ const id = String(params.id);
130
+ const settings = ctx.providerSettings(id);
131
+ const apiKey = ctx.apiKeyFor(id);
132
+ const result = await testProviderConnection({ ...settings, apiKey });
133
+ sendJson(res, 200, { provider: id, ...result });
134
+ });
135
+ }
@@ -0,0 +1,157 @@
1
+ import { runAgent } from '../lib/core.js';
2
+ import { readJson, sendJson, sendError } from '../lib/http.js';
3
+ import { workspaceForRequest } from '../lib/workspace.js';
4
+
5
+ function view(session) {
6
+ return {
7
+ id: session.id,
8
+ title: session.title,
9
+ provider: session.provider,
10
+ model: session.model,
11
+ cwd: session.cwd,
12
+ goal: session.meta?.goal ?? null,
13
+ hasSummary: Boolean(session.meta?.summary),
14
+ createdAt: session.created_at,
15
+ updatedAt: session.updated_at,
16
+ };
17
+ }
18
+
19
+ const COMPACT_INSTRUCTION =
20
+ '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.';
21
+
22
+ export function registerSessions(route, ctx) {
23
+ route('GET', '/api/sessions', ({ res }) => {
24
+ sendJson(res, 200, { sessions: ctx.store.listSessions(50).map(view) });
25
+ });
26
+
27
+ route('GET', '/api/search', ({ res, query }) => {
28
+ const q = String(query.get('q') || '').trim();
29
+ if (q.length < 2) return sendJson(res, 200, { sessions: [], messages: [] });
30
+ const limit = Math.min(Math.max(Number(query.get('limit')) || 20, 1), 50);
31
+ const snippet = (content) => {
32
+ const text =
33
+ typeof content === 'string'
34
+ ? content
35
+ : Array.isArray(content)
36
+ ? content.map((part) => part?.text ?? '').join(' ')
37
+ : JSON.stringify(content ?? '');
38
+ return text.replace(/\s+/g, ' ').trim().slice(0, 200);
39
+ };
40
+ sendJson(res, 200, {
41
+ sessions: ctx.store.searchSessions(q, limit).map(view),
42
+ messages: ctx.store.searchMessages(q, limit).map((m) => ({
43
+ id: m.id,
44
+ sessionId: m.sessionId,
45
+ role: m.role,
46
+ title: m.title,
47
+ cwd: m.cwd,
48
+ snippet: snippet(m.content),
49
+ })),
50
+ });
51
+ });
52
+
53
+ route('POST', '/api/sessions', async ({ req, res }) => {
54
+ const body = await readJson(req);
55
+ let cwd;
56
+ try {
57
+ cwd = workspaceForRequest({ req, raw: body.cwd, fallback: ctx.cwd });
58
+ } catch (error) {
59
+ return sendError(res, 400, error.message);
60
+ }
61
+ const meta = body.goal ? { goal: String(body.goal) } : undefined;
62
+ const id = ctx.store.createSession({
63
+ title: String(body.title || 'Nueva sesión').slice(0, 80),
64
+ provider: body.provider || ctx.config.provider,
65
+ model: body.model || ctx.config.model,
66
+ cwd,
67
+ meta,
68
+ });
69
+ sendJson(res, 201, { session: view(ctx.store.getSession(id)) });
70
+ });
71
+
72
+ route('GET', '/api/sessions/:id', ({ res, params }) => {
73
+ const session = ctx.store.getSession(params.id);
74
+ if (!session) return sendError(res, 404, 'Sesión no encontrada.');
75
+ sendJson(res, 200, {
76
+ session: view(session),
77
+ tokens: ctx.store.sessionTokens(params.id),
78
+ messages: ctx.store.getMessages(params.id),
79
+ });
80
+ });
81
+
82
+ route('PUT', '/api/sessions/:id', async ({ req, res, params }) => {
83
+ const session = ctx.store.getSession(params.id);
84
+ if (!session) return sendError(res, 404, 'Sesión no encontrada.');
85
+ const body = await readJson(req);
86
+ const patch = {};
87
+ if ('title' in body) patch.title = String(body.title || '').slice(0, 80) || session.title;
88
+ if ('goal' in body) patch.meta = { goal: body.goal ? String(body.goal) : undefined };
89
+ ctx.store.touchSession(params.id, patch);
90
+ sendJson(res, 200, { session: view(ctx.store.getSession(params.id)) });
91
+ });
92
+
93
+ route('DELETE', '/api/sessions/:id/messages', ({ res, params }) => {
94
+ const session = ctx.store.getSession(params.id);
95
+ if (!session) return sendError(res, 404, 'Sesión no encontrada.');
96
+ ctx.store.clearMessages(params.id);
97
+ ctx.store.touchSession(params.id, { meta: { summary: undefined } });
98
+ sendJson(res, 200, { ok: true });
99
+ });
100
+
101
+ route('POST', '/api/sessions/:id/truncate', async ({ req, res, params }) => {
102
+ const session = ctx.store.getSession(params.id);
103
+ if (!session) return sendError(res, 404, 'Sesión no encontrada.');
104
+ const body = await readJson(req);
105
+ const fromId = String(body.fromId || '');
106
+ if (!fromId) return sendError(res, 400, 'Falta "fromId".');
107
+ const removed = ctx.store.deleteMessagesFrom(params.id, fromId);
108
+ if (!removed) return sendError(res, 404, 'Mensaje no encontrado.');
109
+ ctx.store.touchSession(params.id, {});
110
+ sendJson(res, 200, { ok: true, removed });
111
+ });
112
+
113
+ route('POST', '/api/sessions/:id/compact', async ({ res, params }) => {
114
+ const session = ctx.store.getSession(params.id);
115
+ if (!session) return sendError(res, 404, 'Sesión no encontrada.');
116
+ const messages = ctx.store.getMessages(params.id);
117
+ if (!messages.length) return sendError(res, 400, 'La sesión no tiene mensajes para compactar.');
118
+
119
+ const providerId = session.provider || ctx.effectiveProviderId();
120
+ const model = session.model || ctx.effectiveModel();
121
+ if (!model) return sendError(res, 400, 'Falta el modelo para compactar.');
122
+
123
+ const transcript = messages
124
+ .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
125
+ .join('\n');
126
+
127
+ try {
128
+ const { provider, config } = ctx.resolveProvider({
129
+ ...ctx.providerSettings(providerId),
130
+ apiKey: ctx.apiKeyFor(providerId),
131
+ });
132
+ const result = await runAgent({
133
+ provider,
134
+ config,
135
+ model,
136
+ messages: [
137
+ { role: 'system', content: COMPACT_INSTRUCTION },
138
+ { role: 'user', content: transcript.slice(0, 60000) },
139
+ ],
140
+ tools: [],
141
+ });
142
+ const summary = result.content?.trim();
143
+ if (!summary) return sendError(res, 502, 'El modelo no devolvió un resumen.');
144
+ ctx.store.clearMessages(params.id);
145
+ ctx.store.touchSession(params.id, { meta: { summary } });
146
+ sendJson(res, 200, { summary, messages: 0 });
147
+ } catch (error) {
148
+ sendError(res, 502, `No se pudo compactar: ${error?.message ?? error}`);
149
+ }
150
+ });
151
+
152
+ route('DELETE', '/api/sessions/:id', ({ res, params }) => {
153
+ if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
154
+ ctx.store.deleteSession(params.id);
155
+ sendJson(res, 200, { ok: true });
156
+ });
157
+ }
@@ -0,0 +1,249 @@
1
+ import http from 'node:http';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ loadConfig,
8
+ Store,
9
+ databasePath,
10
+ homeDir,
11
+ readApp,
12
+ resolveProviderRuntime,
13
+ resolveApiKey,
14
+ composeProviderSettings,
15
+ buildMcpRegistry,
16
+ } from './lib/core.js';
17
+ import { sendJson, sendError } from './lib/http.js';
18
+ import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
19
+ import { createApprovalBroker } from './lib/approvals.js';
20
+ import { createStaticHandler } from './static.js';
21
+ import { requireLogin, requireCsrf } from './auth.js';
22
+ import { registerAuth } from './routes/auth.js';
23
+ import { registerSessions } from './routes/sessions.js';
24
+ import { registerFolders } from './routes/folders.js';
25
+ import { registerFiles } from './routes/files.js';
26
+ import { registerModels } from './routes/models.js';
27
+ import { registerProviders } from './routes/providers.js';
28
+ import { registerMcp } from './routes/mcp.js';
29
+ import { registerChat } from './routes/chat.js';
30
+ import { registerEval } from './routes/eval.js';
31
+
32
+ const VERSION = readVersion();
33
+
34
+ function readVersion() {
35
+ try {
36
+ const pkg = JSON.parse(
37
+ readFileSync(fileURLToPath(new URL('../../../../package.json', import.meta.url)), 'utf8')
38
+ );
39
+ return pkg.version || '0.0.0';
40
+ } catch {
41
+ return '0.0.0';
42
+ }
43
+ }
44
+
45
+ const DEFAULT_PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
46
+
47
+ const AUTH_OPEN = new Set(['/api/health', '/api/auth/login', '/api/auth/me']);
48
+
49
+ /**
50
+ * Create the harness HTTP server (REST + SSE) bound to a project directory.
51
+ * Does not start listening; call `listen()`.
52
+ *
53
+ * @param {object} [opts]
54
+ * @param {string} [opts.cwd] - project dir (tamnora.json / AGENTS.md)
55
+ * @param {string} [opts.host]
56
+ * @param {number} [opts.port]
57
+ * @param {string} [opts.staticDir] - override the console directory
58
+ * @param {Function} [opts.resolveProvider] - injection point (tests)
59
+ * @param {boolean} [opts.yes] - auto-approve tools with permission "ask"
60
+ * @param {boolean} [opts.loadMcp] - connect configured MCP servers (default true)
61
+ */
62
+ export async function createTamnoraServer({
63
+ cwd = process.cwd(),
64
+ host,
65
+ port,
66
+ staticDir,
67
+ resolveProvider,
68
+ yes = false,
69
+ loadMcp = true,
70
+ dbPath,
71
+ } = {}) {
72
+ const config = await loadConfig(cwd);
73
+ const app = readApp();
74
+ const authEnabled = Boolean(app.user && app.user.password);
75
+ const store = new Store(dbPath ?? databasePath(cwd));
76
+ const systemPromptFor = createPromptBuilder({ cwd, files: config.instructions });
77
+ const systemPrompt = (await systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
78
+ const approvals = createApprovalBroker({ timeoutMs: config.approvalTimeoutMs });
79
+ const autoApproved = new Set();
80
+ const isAutoApprove = (sessionId) => autoApproved.has(sessionId);
81
+ const setAutoApprove = (sessionId, value) => (value ? autoApproved.add(sessionId) : autoApproved.delete(sessionId));
82
+
83
+ let mcpPromise = null;
84
+ const getMcp = async () => {
85
+ if (!loadMcp || !(config.mcp?.length)) return null;
86
+ if (!mcpPromise) mcpPromise = buildMcpRegistry(config.mcp);
87
+ return mcpPromise;
88
+ };
89
+
90
+ const customProviderDefs = () =>
91
+ store
92
+ .listProviders()
93
+ .filter((p) => p.custom)
94
+ .map((p) => ({ id: p.id, label: p.label, baseUrl: p.baseUrl, models: p.models }));
95
+
96
+ const providerSettings = (id) =>
97
+ composeProviderSettings({
98
+ providerId: id,
99
+ stored: store.getProvider(id) ?? {},
100
+ config,
101
+ customProviders: customProviderDefs(),
102
+ });
103
+
104
+ const effectiveProviderId = () => store.getSetting('activeProvider') || config.provider;
105
+ const effectiveModel = () => store.getSetting('activeModel') || config.model;
106
+ const effectiveMode = () => store.getSetting('activeMode') || 'agent';
107
+
108
+ const ctx = {
109
+ cwd,
110
+ config,
111
+ app,
112
+ authEnabled,
113
+ store,
114
+ systemPrompt,
115
+ systemPromptFor,
116
+ approvals,
117
+ isAutoApprove,
118
+ setAutoApprove,
119
+ yes,
120
+ effectiveProviderId,
121
+ effectiveModel,
122
+ effectiveMode,
123
+ providerSettings,
124
+ customProviderDefs,
125
+ apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
126
+ resolveProvider: resolveProvider ?? ((settings) => resolveProviderRuntime(settings)),
127
+ getMcp,
128
+ };
129
+
130
+ const routes = [];
131
+ const route = (method, pattern, handler) =>
132
+ routes.push({ method, segments: pattern.split('/').filter(Boolean), handler });
133
+
134
+ route('GET', '/api/health', ({ res }) => {
135
+ sendJson(res, 200, {
136
+ ok: true,
137
+ name: 'tamnora',
138
+ cwd,
139
+ home: os.homedir(),
140
+ tamnoraHome: homeDir(),
141
+ version: VERSION,
142
+ });
143
+ });
144
+
145
+ registerAuth(route, ctx);
146
+ registerSessions(route, ctx);
147
+ registerFolders(route, ctx);
148
+ registerFiles(route, ctx);
149
+ registerModels(route, ctx);
150
+ registerProviders(route, ctx);
151
+ registerMcp(route, ctx);
152
+ registerChat(route, ctx);
153
+ registerEval(route, ctx);
154
+
155
+ function matchRoute(method, pathname) {
156
+ const parts = pathname.split('/').filter(Boolean);
157
+ for (const r of routes) {
158
+ if (r.method !== method || r.segments.length !== parts.length) continue;
159
+ const params = {};
160
+ let ok = true;
161
+ for (let i = 0; i < r.segments.length; i++) {
162
+ const segment = r.segments[i];
163
+ if (segment.startsWith(':')) params[segment.slice(1)] = decodeURIComponent(parts[i]);
164
+ else if (segment !== parts[i]) {
165
+ ok = false;
166
+ break;
167
+ }
168
+ }
169
+ if (ok) return { handler: r.handler, params };
170
+ }
171
+ return null;
172
+ }
173
+
174
+ const serveStatic = createStaticHandler(staticDir ?? DEFAULT_PUBLIC_DIR);
175
+
176
+ const server = http.createServer(async (req, res) => {
177
+ const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
178
+
179
+ if (url.pathname.startsWith('/api/')) {
180
+ const matched = matchRoute(req.method, url.pathname);
181
+ if (!matched) return sendError(res, 404, `Ruta no encontrada: ${req.method} ${url.pathname}`);
182
+ if (authEnabled && !AUTH_OPEN.has(url.pathname)) {
183
+ if (!requireLogin(app, req, res)) return;
184
+ if (req.method !== 'GET' && req.method !== 'HEAD' && !requireCsrf(app, req, res)) return;
185
+ }
186
+ try {
187
+ await matched.handler({ req, res, params: matched.params, query: url.searchParams });
188
+ } catch (error) {
189
+ if (res.headersSent) {
190
+ try {
191
+ res.end();
192
+ } catch {
193
+ // already closed
194
+ }
195
+ } else {
196
+ sendError(res, 500, error?.message ?? String(error));
197
+ }
198
+ }
199
+ return;
200
+ }
201
+
202
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
203
+ res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
204
+ res.end('405');
205
+ return;
206
+ }
207
+ try {
208
+ await serveStatic(req, res, url.pathname);
209
+ } catch (error) {
210
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
211
+ res.end(String(error?.message ?? error));
212
+ }
213
+ });
214
+
215
+ const listen = (options = {}) =>
216
+ new Promise((resolve, reject) => {
217
+ server.once('error', reject);
218
+ server.listen(
219
+ options.port ?? port ?? config.server?.port ?? 4517,
220
+ options.host ?? host ?? config.server?.host ?? '127.0.0.1',
221
+ () => {
222
+ const address = server.address();
223
+ resolve({
224
+ host: address.address,
225
+ port: address.port,
226
+ url: `http://${address.address === '::' ? '127.0.0.1' : address.address}:${address.port}`,
227
+ });
228
+ }
229
+ );
230
+ });
231
+
232
+ const close = async () => {
233
+ approvals.cancelAll();
234
+ try {
235
+ const registry = mcpPromise ? await mcpPromise : null;
236
+ registry?.close?.();
237
+ } catch {
238
+ // ignore MCP shutdown errors
239
+ }
240
+ try {
241
+ store.close();
242
+ } catch {
243
+ // already closed
244
+ }
245
+ await new Promise((resolve) => server.close(() => resolve()));
246
+ };
247
+
248
+ return { server, config, store, ctx, listen, close, staticDir: staticDir ?? DEFAULT_PUBLIC_DIR };
249
+ }
@@ -0,0 +1,67 @@
1
+ // Static file server for the built-in console (and any --static dir).
2
+ // Guards against path traversal and falls back to index.html.
3
+
4
+ import { createReadStream } from 'node:fs';
5
+ import { stat } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ const MIME = {
9
+ '.html': 'text/html; charset=utf-8',
10
+ '.js': 'text/javascript; charset=utf-8',
11
+ '.mjs': 'text/javascript; charset=utf-8',
12
+ '.css': 'text/css; charset=utf-8',
13
+ '.json': 'application/json; charset=utf-8',
14
+ '.svg': 'image/svg+xml',
15
+ '.png': 'image/png',
16
+ '.jpg': 'image/jpeg',
17
+ '.webp': 'image/webp',
18
+ '.ico': 'image/x-icon',
19
+ '.txt': 'text/plain; charset=utf-8',
20
+ '.woff2': 'font/woff2',
21
+ };
22
+
23
+ /** Build a `(req, res, pathname) => Promise<void>` static handler. */
24
+ export function createStaticHandler(rootDir) {
25
+ const root = path.resolve(rootDir);
26
+
27
+ return async function serveStatic(req, res, pathname) {
28
+ const relative = decodeURIComponent(pathname).replace(/^\/+/, '');
29
+ let file = path.resolve(root, relative || 'index.html');
30
+
31
+ if (file !== root && !file.startsWith(root + path.sep)) {
32
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
33
+ res.end('403');
34
+ return;
35
+ }
36
+
37
+ let info = await stat(file).catch(() => null);
38
+ if (info?.isDirectory()) {
39
+ file = path.join(file, 'index.html');
40
+ info = await stat(file).catch(() => null);
41
+ }
42
+ if (!info?.isFile()) {
43
+ const index = path.join(root, 'index.html');
44
+ const indexInfo = await stat(index).catch(() => null);
45
+ if (indexInfo?.isFile()) {
46
+ file = index;
47
+ info = indexInfo;
48
+ }
49
+ }
50
+ if (!info?.isFile()) {
51
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
52
+ res.end('404');
53
+ return;
54
+ }
55
+
56
+ res.writeHead(200, {
57
+ 'Content-Type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
58
+ 'Content-Length': info.size,
59
+ 'Cache-Control': 'no-cache',
60
+ });
61
+ if (req.method === 'HEAD') {
62
+ res.end();
63
+ return;
64
+ }
65
+ createReadStream(file).pipe(res);
66
+ };
67
+ }
@@ -0,0 +1,36 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { renderMarkdown, highlight } from '../public/markdown.js';
4
+
5
+ test('escapes raw HTML from model output', () => {
6
+ const html = renderMarkdown('<script>alert(1)</script>');
7
+ assert.ok(!html.includes('<script>'));
8
+ assert.match(html, /&lt;script&gt;/);
9
+ });
10
+
11
+ test('renders headings, bold, inline code and lists', () => {
12
+ const html = renderMarkdown('# Título\n\n**negrita** y `code`');
13
+ assert.match(html, /<h1>Título<\/h1>/);
14
+ assert.match(html, /<strong>negrita<\/strong>/);
15
+ assert.match(html, /<code>code<\/code>/);
16
+
17
+ const list = renderMarkdown('- uno\n- dos');
18
+ assert.equal(list, '<ul><li>uno</li><li>dos</li></ul>');
19
+ });
20
+
21
+ test('renders fenced code blocks and highlights keywords/strings', () => {
22
+ const html = renderMarkdown('```js\nconst x = "hola"; // nota\n```');
23
+ assert.match(html, /class="code-block"/);
24
+ assert.match(html, /tok-keyword">const</);
25
+ assert.match(html, /tok-string">&quot;hola&quot;</);
26
+ assert.match(html, /tok-comment">\/\/ nota</);
27
+ });
28
+
29
+ test('only allows http(s) links', () => {
30
+ assert.match(renderMarkdown('[x](https://a.com)'), /<a href="https:\/\/a\.com"/);
31
+ assert.ok(!renderMarkdown('[x](javascript:alert(1))').includes('<a '));
32
+ });
33
+
34
+ test('highlight escapes code content', () => {
35
+ assert.equal(highlight('a < b', 'bash'), 'a &lt; b');
36
+ });