@danieltmn/tamnora 0.1.0 → 0.2.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 (51) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/harness/packages/cli/src/cli.js +2 -0
  3. package/harness/packages/cli/src/commands/eval.js +3 -0
  4. package/harness/packages/cli/src/commands/repl.js +2 -0
  5. package/harness/packages/cli/src/commands/run.js +2 -1
  6. package/harness/packages/cli/src/commands/tui.js +18 -11
  7. package/harness/packages/cli/src/lib/runtime.js +3 -1
  8. package/harness/packages/cli/src/lib/tunnel.js +2 -113
  9. package/harness/packages/cli/src/lib/util.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +45 -4
  11. package/harness/packages/cli/src/tui/keys.js +3 -0
  12. package/harness/packages/cli/test/tui.test.js +5 -0
  13. package/harness/packages/core/src/agent/modes.js +1 -1
  14. package/harness/packages/core/src/config/index.js +3 -0
  15. package/harness/packages/core/src/console.js +18 -0
  16. package/harness/packages/core/src/eval/judge.js +3 -3
  17. package/harness/packages/core/src/eval/runner.js +3 -0
  18. package/harness/packages/core/src/index.js +4 -0
  19. package/harness/packages/core/src/procs/detect.js +69 -0
  20. package/harness/packages/core/src/providers/ollama.js +3 -3
  21. package/harness/packages/core/src/providers/openaiCompatible.js +3 -3
  22. package/harness/packages/core/src/providers/openrouter.js +2 -2
  23. package/harness/packages/core/src/providers/resilience.js +1 -1
  24. package/harness/packages/core/src/providers/utils.js +43 -0
  25. package/harness/packages/core/src/skills/index.js +72 -0
  26. package/harness/packages/core/src/storage/sqlite.js +21 -0
  27. package/harness/packages/core/src/tunnel.js +113 -0
  28. package/harness/packages/core/test/config.test.js +15 -0
  29. package/harness/packages/core/test/procs.test.js +34 -0
  30. package/harness/packages/core/test/providers.test.js +20 -0
  31. package/harness/packages/core/test/resilience.test.js +1 -0
  32. package/harness/packages/core/test/skills.test.js +49 -0
  33. package/harness/packages/server/public/app.js +440 -6
  34. package/harness/packages/server/public/index.html +124 -6
  35. package/harness/packages/server/public/styles.css +43 -1
  36. package/harness/packages/server/src/cli.js +2 -0
  37. package/harness/packages/server/src/lib/agentTools.js +159 -0
  38. package/harness/packages/server/src/lib/procs.js +125 -0
  39. package/harness/packages/server/src/lib/prompt.js +6 -3
  40. package/harness/packages/server/src/lib/skills.js +69 -0
  41. package/harness/packages/server/src/lib/telegram.js +267 -0
  42. package/harness/packages/server/src/routes/chat.js +9 -1
  43. package/harness/packages/server/src/routes/mcp.js +71 -8
  44. package/harness/packages/server/src/routes/preview.js +58 -0
  45. package/harness/packages/server/src/routes/procs.js +47 -0
  46. package/harness/packages/server/src/routes/sessions.js +10 -0
  47. package/harness/packages/server/src/routes/skills.js +61 -0
  48. package/harness/packages/server/src/server.js +79 -6
  49. package/harness/packages/server/test/server.test.js +227 -0
  50. package/harness/tamnora.example.json +7 -1
  51. package/package.json +1 -1
@@ -16,7 +16,11 @@ import {
16
16
  } from './lib/core.js';
17
17
  import { sendJson, sendError } from './lib/http.js';
18
18
  import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
19
+ import { skillsPrompt } from './lib/skills.js';
19
20
  import { createApprovalBroker } from './lib/approvals.js';
21
+ import { createProcManager } from './lib/procs.js';
22
+ import { startTelegramBot } from './lib/telegram.js';
23
+ import { createAgentTools } from './lib/agentTools.js';
20
24
  import { createStaticHandler } from './static.js';
21
25
  import { requireLogin, requireCsrf } from './auth.js';
22
26
  import { registerAuth } from './routes/auth.js';
@@ -26,6 +30,9 @@ import { registerFiles } from './routes/files.js';
26
30
  import { registerModels } from './routes/models.js';
27
31
  import { registerProviders } from './routes/providers.js';
28
32
  import { registerMcp } from './routes/mcp.js';
33
+ import { registerSkills } from './routes/skills.js';
34
+ import { registerProcs } from './routes/procs.js';
35
+ import { registerPreview } from './routes/preview.js';
29
36
  import { registerChat } from './routes/chat.js';
30
37
  import { registerEval } from './routes/eval.js';
31
38
 
@@ -67,26 +74,60 @@ export async function createTamnoraServer({
67
74
  resolveProvider,
68
75
  yes = false,
69
76
  loadMcp = true,
77
+ telegram = true,
70
78
  dbPath,
71
79
  } = {}) {
72
80
  const config = await loadConfig(cwd);
73
81
  const app = readApp();
74
82
  const authEnabled = Boolean(app.user && app.user.password);
75
83
  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
84
  const approvals = createApprovalBroker({ timeoutMs: config.approvalTimeoutMs });
85
+ const procs = createProcManager({ allow: config.processes?.allow });
86
+ const procsEnabled = config.processes?.enabled !== false;
79
87
  const autoApproved = new Set();
80
88
  const isAutoApprove = (sessionId) => autoApproved.has(sessionId);
81
89
  const setAutoApprove = (sessionId, value) => (value ? autoApproved.add(sessionId) : autoApproved.delete(sessionId));
82
90
 
91
+ // MCP servers come from tamnora.json plus the ones saved from the console;
92
+ // the store wins on id collisions.
93
+ const mergedMcpServers = () => {
94
+ const fromConfig = Array.isArray(config.mcp) ? config.mcp : [];
95
+ const fromStore = store.listMcpServers().map((row) => ({
96
+ id: row.id,
97
+ name: row.name ?? row.id,
98
+ type: row.transport ?? 'stdio',
99
+ command: row.command ?? undefined,
100
+ args: Array.isArray(row.config?.args) ? row.config.args : [],
101
+ url: row.url ?? undefined,
102
+ env: row.config?.env ?? undefined,
103
+ enabled: row.enabled !== false,
104
+ source: 'store',
105
+ }));
106
+ const byId = new Map();
107
+ for (const server of fromConfig) if (server?.id) byId.set(server.id, { ...server, source: 'config' });
108
+ for (const server of fromStore) byId.set(server.id, server);
109
+ return [...byId.values()];
110
+ };
111
+
83
112
  let mcpPromise = null;
84
113
  const getMcp = async () => {
85
- if (!loadMcp || !(config.mcp?.length)) return null;
86
- if (!mcpPromise) mcpPromise = buildMcpRegistry(config.mcp);
114
+ if (!loadMcp) return null;
115
+ const servers = mergedMcpServers().filter((s) => s.enabled !== false);
116
+ if (!servers.length) return null;
117
+ if (!mcpPromise) mcpPromise = buildMcpRegistry(servers);
87
118
  return mcpPromise;
88
119
  };
89
120
 
121
+ const reloadMcp = async () => {
122
+ const registry = mcpPromise ? await mcpPromise.catch(() => null) : null;
123
+ try {
124
+ registry?.close?.();
125
+ } catch {
126
+ // ignore MCP shutdown errors
127
+ }
128
+ mcpPromise = null;
129
+ };
130
+
90
131
  const customProviderDefs = () =>
91
132
  store
92
133
  .listProviders()
@@ -111,8 +152,6 @@ export async function createTamnoraServer({
111
152
  app,
112
153
  authEnabled,
113
154
  store,
114
- systemPrompt,
115
- systemPromptFor,
116
155
  approvals,
117
156
  isAutoApprove,
118
157
  setAutoApprove,
@@ -125,8 +164,35 @@ export async function createTamnoraServer({
125
164
  apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
126
165
  resolveProvider: resolveProvider ?? ((settings) => resolveProviderRuntime(settings)),
127
166
  getMcp,
167
+ reloadMcp,
168
+ mergedMcpServers,
169
+ procs,
170
+ procsEnabled,
128
171
  };
129
172
 
173
+ ctx.systemPromptFor = createPromptBuilder({
174
+ cwd,
175
+ files: config.instructions,
176
+ skillsText: () => skillsPrompt(ctx),
177
+ });
178
+ ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
179
+ ctx.agentTools = createAgentTools(ctx);
180
+
181
+ // Bot de Telegram (long polling) si hay token: config, app.json o entorno.
182
+ const tgConfig = { ...(app.telegram ?? {}), ...(config.telegram ?? {}) };
183
+ const tgToken = tgConfig.token || process.env.TAMNORA_TELEGRAM_TOKEN || '';
184
+ const rawAllow = tgConfig.allowedChatIds ?? process.env.TAMNORA_TELEGRAM_CHAT_IDS ?? [];
185
+ const tgAllow = Array.isArray(rawAllow) ? rawAllow : String(rawAllow).split(/[,\s]+/).filter(Boolean);
186
+ const telegramBot =
187
+ telegram && tgToken
188
+ ? startTelegramBot(ctx, {
189
+ token: tgToken,
190
+ allowedChatIds: tgAllow,
191
+ log: (message) => process.stderr.write(`${message}\n`),
192
+ })
193
+ : null;
194
+ ctx.telegram = Boolean(telegramBot);
195
+
130
196
  const routes = [];
131
197
  const route = (method, pattern, handler) =>
132
198
  routes.push({ method, segments: pattern.split('/').filter(Boolean), handler });
@@ -139,6 +205,7 @@ export async function createTamnoraServer({
139
205
  home: os.homedir(),
140
206
  tamnoraHome: homeDir(),
141
207
  version: VERSION,
208
+ telegram: ctx.telegram,
142
209
  });
143
210
  });
144
211
 
@@ -149,6 +216,9 @@ export async function createTamnoraServer({
149
216
  registerModels(route, ctx);
150
217
  registerProviders(route, ctx);
151
218
  registerMcp(route, ctx);
219
+ registerSkills(route, ctx);
220
+ registerProcs(route, ctx);
221
+ const preview = registerPreview(route, ctx);
152
222
  registerChat(route, ctx);
153
223
  registerEval(route, ctx);
154
224
 
@@ -231,6 +301,9 @@ export async function createTamnoraServer({
231
301
 
232
302
  const close = async () => {
233
303
  approvals.cancelAll();
304
+ telegramBot?.stop();
305
+ preview.close();
306
+ procs.close();
234
307
  try {
235
308
  const registry = mcpPromise ? await mcpPromise : null;
236
309
  registry?.close?.();
@@ -78,6 +78,44 @@ test('creates, lists, reads and deletes sessions', async () => {
78
78
  }
79
79
  });
80
80
 
81
+ test('lists and deletes sessions grouped by project (cwd)', async () => {
82
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proj-'));
83
+ const dirA = path.join(cwd, 'a');
84
+ const dirB = path.join(cwd, 'b');
85
+ await mkdir(dirA);
86
+ await mkdir(dirB);
87
+ const { instance, base } = await startServer({ cwd });
88
+ const create = (body) =>
89
+ fetch(`${base}/api/sessions`, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify(body),
93
+ });
94
+ try {
95
+ await create({ title: 'a1', model: 'm', cwd: dirA });
96
+ await create({ title: 'a2', model: 'm', cwd: dirA });
97
+ await create({ title: 'b1', model: 'm', cwd: dirB });
98
+
99
+ const projects = await (await fetch(`${base}/api/projects`)).json();
100
+ assert.equal(projects.projects.find((p) => p.cwd === dirA).sessions, 2);
101
+ assert.equal(projects.projects.find((p) => p.cwd === dirB).sessions, 1);
102
+
103
+ const removed = await (
104
+ await fetch(`${base}/api/projects?cwd=${encodeURIComponent(dirA)}`, { method: 'DELETE' })
105
+ ).json();
106
+ assert.equal(removed.sessions, 2);
107
+
108
+ const list = await (await fetch(`${base}/api/sessions`)).json();
109
+ assert.equal(list.sessions.length, 1);
110
+ assert.equal(list.sessions[0].cwd, dirB);
111
+
112
+ const missing = await fetch(`${base}/api/projects`, { method: 'DELETE' });
113
+ assert.equal(missing.status, 400);
114
+ } finally {
115
+ await instance.close();
116
+ }
117
+ });
118
+
81
119
  test('streams a chat turn over SSE and persists the messages', async () => {
82
120
  const { instance, base } = await startServer();
83
121
  try {
@@ -103,6 +141,195 @@ test('streams a chat turn over SSE and persists the messages', async () => {
103
141
  }
104
142
  });
105
143
 
144
+ test('manages MCP servers from the console', async () => {
145
+ const { instance, base } = await startServer();
146
+ const json = { 'Content-Type': 'application/json' };
147
+ try {
148
+ let data = await (await fetch(`${base}/api/mcp`)).json();
149
+ assert.deepEqual(data.servers, []);
150
+
151
+ let res = await fetch(`${base}/api/mcp/servers/echo`, {
152
+ method: 'PUT',
153
+ headers: json,
154
+ body: JSON.stringify({ name: 'Echo', transport: 'stdio', command: 'node', args: ['a.mjs'], enabled: true }),
155
+ });
156
+ assert.equal(res.status, 200);
157
+ data = await res.json();
158
+ const echo = data.servers.find((s) => s.id === 'echo');
159
+ assert.equal(echo.source, 'store');
160
+ assert.deepEqual(echo.args, ['a.mjs']);
161
+
162
+ const bad = await fetch(`${base}/api/mcp/servers/bad`, {
163
+ method: 'PUT',
164
+ headers: json,
165
+ body: JSON.stringify({ transport: 'http' }),
166
+ });
167
+ assert.equal(bad.status, 400);
168
+
169
+ res = await fetch(`${base}/api/mcp/servers/echo`, { method: 'DELETE' });
170
+ assert.equal(res.status, 200);
171
+ data = await res.json();
172
+ assert.equal(data.servers.length, 0);
173
+ } finally {
174
+ await instance.close();
175
+ }
176
+ });
177
+
178
+ test('manages skills from the console', async () => {
179
+ const { instance, base } = await startServer();
180
+ const json = { 'Content-Type': 'application/json' };
181
+ const put = (id, body) => fetch(`${base}/api/skills/${id}`, { method: 'PUT', headers: json, body: JSON.stringify(body) });
182
+ try {
183
+ let res = await put('demo', { name: 'Demo', description: 'para pruebas', body: 'Hacé X.' });
184
+ assert.equal(res.status, 200);
185
+ let data = await res.json();
186
+ const demo = data.skills.find((s) => s.id === 'demo');
187
+ assert.equal(demo.enabled, true);
188
+ assert.equal(demo.removable, true);
189
+
190
+ const detail = await (await fetch(`${base}/api/skills/demo`)).json();
191
+ assert.match(detail.skill.body, /Hacé X/);
192
+
193
+ await fetch(`${base}/api/skills/demo/toggle`, { method: 'POST' });
194
+ data = await (await fetch(`${base}/api/skills`)).json();
195
+ assert.equal(data.skills.find((s) => s.id === 'demo').enabled, false);
196
+
197
+ res = await fetch(`${base}/api/skills/demo`, { method: 'DELETE' });
198
+ assert.equal(res.status, 200);
199
+ data = await (await fetch(`${base}/api/skills`)).json();
200
+ assert.equal(data.skills.length, 0);
201
+
202
+ res = await put('vacia', { body: ' ' });
203
+ assert.equal(res.status, 400);
204
+ } finally {
205
+ await instance.close();
206
+ }
207
+ });
208
+
209
+ test('injects only enabled skills into the system prompt', async () => {
210
+ let captured = '';
211
+ const captureProvider = {
212
+ id: 'capture',
213
+ async streamChat(_config, { messages }) {
214
+ captured = messages?.[0]?.content ?? '';
215
+ return { content: 'ok', reasoning: '', toolCalls: [] };
216
+ },
217
+ };
218
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider: captureProvider, config: {} }) });
219
+ const json = { 'Content-Type': 'application/json' };
220
+ const chat = (sessionId) =>
221
+ fetch(`${base}/api/chat`, { method: 'POST', headers: json, body: JSON.stringify({ sessionId, model: 'm', input: 'hola' }) });
222
+ const newSession = async () => {
223
+ const created = await (
224
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
225
+ ).json();
226
+ return created.session.id;
227
+ };
228
+ try {
229
+ await fetch(`${base}/api/skills/demo`, {
230
+ method: 'PUT',
231
+ headers: json,
232
+ body: JSON.stringify({ name: 'Demo', body: 'Regla secreta de la skill.' }),
233
+ });
234
+
235
+ await (await chat(await newSession())).text();
236
+ assert.match(captured, /Regla secreta de la skill/);
237
+
238
+ await fetch(`${base}/api/skills/demo/toggle`, { method: 'POST' });
239
+ await (await chat(await newSession())).text();
240
+ assert.doesNotMatch(captured, /Regla secreta de la skill/);
241
+ } finally {
242
+ await instance.close();
243
+ }
244
+ });
245
+
246
+ test('detects, runs and stops dev processes', async () => {
247
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
248
+ await writeFile(path.join(cwd, 'package.json'), JSON.stringify({ scripts: { dev: 'node server.js' } }));
249
+ const { instance, base } = await startServer({ cwd });
250
+ const json = { 'Content-Type': 'application/json' };
251
+ try {
252
+ const detected = await (await fetch(`${base}/api/procs/detect?cwd=${encodeURIComponent(cwd)}`)).json();
253
+ assert.ok(detected.chips.some((c) => c.command === 'npm' && c.args.includes('dev')));
254
+
255
+ const blocked = await fetch(`${base}/api/procs/start`, {
256
+ method: 'POST',
257
+ headers: json,
258
+ body: JSON.stringify({ cwd, command: 'curl' }),
259
+ });
260
+ assert.equal(blocked.status, 400);
261
+
262
+ const started = await (
263
+ await fetch(`${base}/api/procs/start`, {
264
+ method: 'POST',
265
+ headers: json,
266
+ body: JSON.stringify({ cwd, command: 'node', args: ['-e', 'console.log(42)'] }),
267
+ })
268
+ ).json();
269
+ assert.ok(started.proc.id);
270
+ await new Promise((resolve) => setTimeout(resolve, 500));
271
+
272
+ const logs = await (await fetch(`${base}/api/procs/${started.proc.id}/logs`)).json();
273
+ assert.match(logs.logs.map((l) => l.text).join(''), /42/);
274
+
275
+ const stopped = await fetch(`${base}/api/procs/${started.proc.id}/stop`, { method: 'POST' });
276
+ assert.equal(stopped.status, 200);
277
+
278
+ const badTunnel = await fetch(`${base}/api/preview/tunnel`, {
279
+ method: 'POST',
280
+ headers: json,
281
+ body: JSON.stringify({ port: 0 }),
282
+ });
283
+ assert.equal(badTunnel.status, 400);
284
+ } finally {
285
+ await instance.close();
286
+ }
287
+ });
288
+
289
+ test('the agent can install a skill from chat (with approval)', async () => {
290
+ const calls = { n: 0 };
291
+ const toolProvider = {
292
+ id: 'tooly',
293
+ async streamChat() {
294
+ calls.n += 1;
295
+ if (calls.n === 1) {
296
+ return {
297
+ content: '',
298
+ reasoning: '',
299
+ toolCalls: [
300
+ {
301
+ id: 't1',
302
+ function: {
303
+ name: 'install_skill',
304
+ arguments: JSON.stringify({ id: 'demo-skill', content: '---\nname: Demo\n---\nCuerpo de prueba.' }),
305
+ },
306
+ },
307
+ ],
308
+ };
309
+ }
310
+ return { content: 'listo', reasoning: '', toolCalls: [] };
311
+ },
312
+ };
313
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider: toolProvider, config: {} }) });
314
+ const json = { 'Content-Type': 'application/json' };
315
+ try {
316
+ const created = await (
317
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
318
+ ).json();
319
+ const res = await fetch(`${base}/api/chat`, {
320
+ method: 'POST',
321
+ headers: json,
322
+ body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'instalá una skill', yes: true }),
323
+ });
324
+ await res.text();
325
+
326
+ const skills = await (await fetch(`${base}/api/skills`)).json();
327
+ assert.ok(skills.skills.some((s) => s.id === 'demo-skill'));
328
+ } finally {
329
+ await instance.close();
330
+ }
331
+ });
332
+
106
333
  test('rejects chat without input or model', async () => {
107
334
  const { instance, base } = await startServer();
108
335
  try {
@@ -14,5 +14,11 @@
14
14
  "args": ["harness/scripts/mcp-echo-server.mjs"],
15
15
  "enabled": true
16
16
  }
17
- ]
17
+ ],
18
+ "processes": {
19
+ "enabled": true,
20
+ "allow": ["npm", "node", "npx", "pnpm", "yarn", "bun", "php", "python", "composer"]
21
+ },
22
+ "preview": { "provider": "tunnelmole" },
23
+ "telegram": { "token": "", "allowedChatIds": [] }
18
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",