@danieltmn/tamnora 0.25.1 → 0.26.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 (87) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +13 -25
  3. package/SECURITY.md +3 -4
  4. package/harness/packages/cli/src/cli.js +0 -3
  5. package/harness/packages/cli/src/commands/bridge.js +0 -1
  6. package/harness/packages/cli/src/commands/repl.js +3 -3
  7. package/harness/packages/cli/src/commands/run.js +1 -1
  8. package/harness/packages/cli/src/commands/server.js +1 -1
  9. package/harness/packages/cli/src/commands/tui.js +1 -1
  10. package/harness/packages/cli/src/lib/util.js +2 -2
  11. package/harness/packages/cli/src/tui/app.js +2 -2
  12. package/harness/packages/cli/test/tunnel.test.js +5 -32
  13. package/harness/packages/core/src/agents/index.js +13 -41
  14. package/harness/packages/core/src/config/app.js +0 -4
  15. package/harness/packages/core/src/config/home.js +1 -20
  16. package/harness/packages/core/src/config/index.js +5 -8
  17. package/harness/packages/core/src/extensions/load.js +22 -0
  18. package/harness/packages/core/src/frontmatter.js +32 -0
  19. package/harness/packages/core/src/hooks/index.js +2 -16
  20. package/harness/packages/core/src/index.js +2 -14
  21. package/harness/packages/core/src/plugins/index.js +2 -16
  22. package/harness/packages/core/src/procs/project.js +68 -0
  23. package/harness/packages/core/src/procs/static.js +17 -0
  24. package/harness/packages/core/src/rules/index.js +8 -27
  25. package/harness/packages/core/src/skills/index.js +8 -19
  26. package/harness/packages/core/src/storage/sqlite.js +595 -690
  27. package/harness/packages/core/src/tunnel.js +9 -147
  28. package/harness/packages/core/test/home.test.js +2 -35
  29. package/harness/packages/core/test/procs.test.js +3 -64
  30. package/harness/packages/core/test/storage.test.js +1 -12
  31. package/harness/packages/server/public/app.js +2 -19
  32. package/harness/packages/server/public/appearance.js +2 -56
  33. package/harness/packages/server/public/boot.js +2 -2
  34. package/harness/packages/server/public/cards.js +2 -21
  35. package/harness/packages/server/public/chat.js +0 -16
  36. package/harness/packages/server/public/chatFormat.js +0 -11
  37. package/harness/packages/server/public/chrome.js +2 -5
  38. package/harness/packages/server/public/commands.js +1 -2
  39. package/harness/packages/server/public/explorer.js +7 -10
  40. package/harness/packages/server/public/index.html +1228 -1326
  41. package/harness/packages/server/public/landing/boot.js +2 -3
  42. package/harness/packages/server/public/landing/index.html +3 -367
  43. package/harness/packages/server/public/settings.js +1255 -1322
  44. package/harness/packages/server/public/settingsNav.js +0 -1
  45. package/harness/packages/server/public/styles.css +2341 -2867
  46. package/harness/packages/server/public/system.js +356 -495
  47. package/harness/packages/server/public/voice.js +6 -22
  48. package/harness/packages/server/src/auth.js +4 -15
  49. package/harness/packages/server/src/cli.js +1 -1
  50. package/harness/packages/server/src/lib/agents.js +13 -27
  51. package/harness/packages/server/src/lib/appearance.js +3 -22
  52. package/harness/packages/server/src/lib/channels/telegram/commands.js +1 -1
  53. package/harness/packages/server/src/lib/definitions.js +40 -0
  54. package/harness/packages/server/src/lib/skills.js +14 -21
  55. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  56. package/harness/packages/server/src/lib/workspace.js +9 -11
  57. package/harness/packages/server/src/routes/chat.js +0 -43
  58. package/harness/packages/server/src/routes/preview.js +5 -22
  59. package/harness/packages/server/src/routes/procs.js +2 -4
  60. package/harness/packages/server/src/routes/tts.js +1 -1
  61. package/harness/packages/server/src/server.js +1 -5
  62. package/harness/packages/server/test/appearance-route.test.js +3 -3
  63. package/harness/packages/server/test/appearance.test.js +12 -38
  64. package/harness/packages/server/test/chatFormat.test.js +0 -8
  65. package/harness/packages/server/test/consoleTabs.test.js +4 -4
  66. package/harness/packages/server/test/ops.test.js +8 -56
  67. package/harness/packages/server/test/preview.test.js +2 -42
  68. package/harness/packages/server/test/security.test.js +194 -370
  69. package/harness/packages/server/test/settingsNav.test.js +2 -2
  70. package/harness/packages/server/test/tts.test.js +4 -4
  71. package/harness/tamnora.example.json +1 -1
  72. package/package.json +2 -2
  73. package/harness/packages/cli/src/commands/eval.js +0 -186
  74. package/harness/packages/core/src/eval/dataset.js +0 -47
  75. package/harness/packages/core/src/eval/judge.js +0 -39
  76. package/harness/packages/core/src/eval/report.js +0 -48
  77. package/harness/packages/core/src/eval/runTask.js +0 -62
  78. package/harness/packages/core/src/eval/runner.js +0 -153
  79. package/harness/packages/core/src/eval/scorers.js +0 -217
  80. package/harness/packages/core/src/eval/task.js +0 -59
  81. package/harness/packages/core/src/procs/detect.js +0 -149
  82. package/harness/packages/core/test/eval.test.js +0 -144
  83. package/harness/packages/server/src/lib/advisor.js +0 -198
  84. package/harness/packages/server/src/routes/advisor.js +0 -79
  85. package/harness/packages/server/src/routes/eval.js +0 -100
  86. package/harness/packages/server/test/advisor.test.js +0 -249
  87. package/harness/scripts/example-dataset.jsonl +0 -4
@@ -1,690 +1,595 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
- import { randomUUID } from 'node:crypto';
3
- import { mkdirSync } from 'node:fs';
4
- import path from 'node:path';
5
-
6
- function likePattern(query) {
7
- return `%${String(query).replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
8
- }
9
-
10
- const SCHEMA = `
11
- CREATE TABLE IF NOT EXISTS sessions (
12
- id TEXT PRIMARY KEY,
13
- title TEXT,
14
- provider TEXT,
15
- model TEXT,
16
- cwd TEXT,
17
- created_at INTEGER,
18
- updated_at INTEGER,
19
- meta TEXT
20
- );
21
-
22
- CREATE TABLE IF NOT EXISTS messages (
23
- id TEXT PRIMARY KEY,
24
- session_id TEXT NOT NULL,
25
- role TEXT NOT NULL,
26
- content TEXT,
27
- tool_calls TEXT,
28
- tool_call_id TEXT,
29
- name TEXT,
30
- mode TEXT,
31
- agent_id TEXT,
32
- tokens_in INTEGER,
33
- tokens_out INTEGER,
34
- created_at INTEGER
35
- );
36
- CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
37
-
38
- CREATE TABLE IF NOT EXISTS agents (
39
- id TEXT PRIMARY KEY,
40
- session_id TEXT NOT NULL,
41
- parent_agent_id TEXT,
42
- kind TEXT,
43
- title TEXT,
44
- status TEXT,
45
- model TEXT,
46
- summary TEXT,
47
- created_at INTEGER,
48
- updated_at INTEGER
49
- );
50
- CREATE INDEX IF NOT EXISTS idx_agents_session ON agents(session_id, created_at);
51
-
52
- CREATE TABLE IF NOT EXISTS session_events (
53
- id INTEGER PRIMARY KEY AUTOINCREMENT,
54
- session_id TEXT NOT NULL,
55
- agent_id TEXT,
56
- type TEXT NOT NULL,
57
- data TEXT,
58
- created_at INTEGER
59
- );
60
- CREATE INDEX IF NOT EXISTS idx_session_events ON session_events(session_id, id);
61
-
62
- CREATE TABLE IF NOT EXISTS runs (
63
- id TEXT PRIMARY KEY,
64
- dataset TEXT,
65
- config TEXT,
66
- status TEXT,
67
- created_at INTEGER,
68
- finished_at INTEGER,
69
- summary TEXT
70
- );
71
- CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
72
-
73
- CREATE TABLE IF NOT EXISTS run_results (
74
- id TEXT PRIMARY KEY,
75
- run_id TEXT NOT NULL,
76
- task_id TEXT,
77
- model TEXT,
78
- trial INTEGER,
79
- output TEXT,
80
- score REAL,
81
- passed INTEGER,
82
- trace TEXT,
83
- duration_ms INTEGER,
84
- tokens_in INTEGER,
85
- tokens_out INTEGER,
86
- cost REAL,
87
- artifacts_dir TEXT,
88
- created_at INTEGER
89
- );
90
- CREATE INDEX IF NOT EXISTS idx_results_run ON run_results(run_id);
91
-
92
- CREATE TABLE IF NOT EXISTS mcp_servers (
93
- id TEXT PRIMARY KEY,
94
- name TEXT,
95
- transport TEXT,
96
- command TEXT,
97
- url TEXT,
98
- enabled INTEGER,
99
- config TEXT
100
- );
101
-
102
- CREATE TABLE IF NOT EXISTS providers (
103
- id TEXT PRIMARY KEY,
104
- label TEXT,
105
- base_url TEXT,
106
- api_key TEXT,
107
- models TEXT,
108
- visible_models TEXT,
109
- requires_key INTEGER,
110
- custom INTEGER,
111
- enabled INTEGER,
112
- updated_at INTEGER
113
- );
114
-
115
- CREATE TABLE IF NOT EXISTS settings (
116
- key TEXT PRIMARY KEY,
117
- value TEXT
118
- );
119
-
120
- CREATE TABLE IF NOT EXISTS schedules (
121
- id TEXT PRIMARY KEY,
122
- name TEXT,
123
- type TEXT,
124
- spec TEXT,
125
- payload TEXT,
126
- cwd TEXT,
127
- enabled INTEGER,
128
- last_run INTEGER,
129
- last_status TEXT,
130
- next_run INTEGER,
131
- created_at INTEGER,
132
- updated_at INTEGER
133
- );
134
- `;
135
-
136
- /** Open (creating if needed) a SQLite database with the harness schema. */
137
- export function openDatabase(file) {
138
- if (file && file !== ':memory:') {
139
- mkdirSync(path.dirname(file), { recursive: true });
140
- }
141
- const db = new DatabaseSync(file ?? ':memory:');
142
- db.exec('PRAGMA journal_mode = WAL;');
143
- db.exec(SCHEMA);
144
- migrate(db);
145
- return db;
146
- }
147
-
148
- function migrate(db) {
149
- const messages = db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name);
150
- if (!messages.includes('mode')) db.exec('ALTER TABLE messages ADD COLUMN mode TEXT');
151
- if (!messages.includes('agent_id')) db.exec('ALTER TABLE messages ADD COLUMN agent_id TEXT');
152
-
153
- const providers = db.prepare('PRAGMA table_info(providers)').all().map((c) => c.name);
154
- if (!providers.includes('visible_models')) db.exec('ALTER TABLE providers ADD COLUMN visible_models TEXT');
155
- }
156
-
157
- const now = () => Date.now();
158
- const json = (value) => (value === undefined || value === null ? null : JSON.stringify(value));
159
- const parseJson = (value) => {
160
- if (value === null || value === undefined) return null;
161
- try {
162
- return JSON.parse(value);
163
- } catch {
164
- return value;
165
- }
166
- };
167
-
168
- /** Thin data-access layer over the SQLite database. */
169
- export class Store {
170
- constructor(file) {
171
- this.db = openDatabase(file);
172
- }
173
-
174
- close() {
175
- this.db.close();
176
- }
177
-
178
- // --- Sessions -----------------------------------------------------------
179
-
180
- createSession({ title = 'Nueva sesión', provider, model, cwd, meta } = {}) {
181
- const id = randomUUID();
182
- const ts = now();
183
- this.db
184
- .prepare(
185
- `INSERT INTO sessions (id, title, provider, model, cwd, created_at, updated_at, meta)
186
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
187
- )
188
- .run(id, title, provider ?? null, model ?? null, cwd ?? null, ts, ts, json(meta));
189
- return id;
190
- }
191
-
192
- touchSession(id, { provider, model, title, meta } = {}) {
193
- const session = this.getSession(id);
194
- if (!session) return;
195
- const mergedMeta = meta === undefined ? session.meta : { ...(session.meta ?? {}), ...meta };
196
- this.db
197
- .prepare(`UPDATE sessions SET updated_at = ?, provider = ?, model = ?, title = ?, meta = ? WHERE id = ?`)
198
- .run(
199
- now(),
200
- provider ?? session.provider,
201
- model ?? session.model,
202
- title ?? session.title,
203
- json(mergedMeta),
204
- id
205
- );
206
- }
207
-
208
- clearMessages(sessionId) {
209
- this.db.prepare(`DELETE FROM messages WHERE session_id = ?`).run(sessionId);
210
- }
211
-
212
- /** Borra desde `messageId` (incluido) hasta el final. Devuelve cuantos borro. */
213
- deleteMessagesFrom(sessionId, messageId) {
214
- const row = this.db
215
- .prepare(`SELECT rowid FROM messages WHERE id = ? AND session_id = ?`)
216
- .get(messageId, sessionId);
217
- if (!row) return 0;
218
- const info = this.db.prepare(`DELETE FROM messages WHERE session_id = ? AND rowid >= ?`).run(sessionId, row.rowid);
219
- return info.changes;
220
- }
221
-
222
- sessionTokens(sessionId) {
223
- const row = this.db
224
- .prepare(
225
- `SELECT COALESCE(SUM(tokens_in), 0) AS input_tokens, COALESCE(SUM(tokens_out), 0) AS output_tokens
226
- FROM messages WHERE session_id = ?`
227
- )
228
- .get(sessionId);
229
- return { in: row.input_tokens, out: row.output_tokens };
230
- }
231
-
232
- getSession(id) {
233
- const row = this.db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
234
- if (!row) return null;
235
- return { ...row, meta: parseJson(row.meta) };
236
- }
237
-
238
- listSessions(limit = 20) {
239
- return this.db
240
- .prepare(`SELECT * FROM sessions ORDER BY updated_at DESC LIMIT ?`)
241
- .all(limit)
242
- .map((row) => ({ ...row, meta: parseJson(row.meta) }));
243
- }
244
-
245
- latestSession() {
246
- return this.listSessions(1)[0] ?? null;
247
- }
248
-
249
- searchSessions(query, limit = 20) {
250
- const like = likePattern(query);
251
- return this.db
252
- .prepare(`SELECT * FROM sessions WHERE title LIKE ? ESCAPE '\\' ORDER BY updated_at DESC LIMIT ?`)
253
- .all(like, limit)
254
- .map((row) => ({ ...row, meta: parseJson(row.meta) }));
255
- }
256
-
257
- searchMessages(query, limit = 30) {
258
- const like = likePattern(query);
259
- return this.db
260
- .prepare(
261
- `SELECT m.id, m.session_id, m.role, m.content, m.created_at, s.title, s.cwd
262
- FROM messages m JOIN sessions s ON s.id = m.session_id
263
- WHERE m.content LIKE ? ESCAPE '\\'
264
- ORDER BY m.created_at DESC LIMIT ?`
265
- )
266
- .all(like, limit)
267
- .map((row) => ({
268
- id: row.id,
269
- sessionId: row.session_id,
270
- role: row.role,
271
- content: parseJson(row.content),
272
- title: row.title,
273
- cwd: row.cwd,
274
- }));
275
- }
276
-
277
- deleteSession(id) {
278
- this.db.prepare(`DELETE FROM messages WHERE session_id = ?`).run(id);
279
- this.db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
280
- }
281
-
282
- /** Borra todas las sesiones (y sus mensajes) de una carpeta. No toca archivos. */
283
- deleteSessionsByCwd(cwd) {
284
- const value = cwd === undefined || cwd === '' ? null : cwd;
285
- const messages = this.db
286
- .prepare(`DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE cwd IS ?)`)
287
- .run(value);
288
- const sessions = this.db.prepare(`DELETE FROM sessions WHERE cwd IS ?`).run(value);
289
- return { sessions: sessions.changes, messages: messages.changes };
290
- }
291
-
292
- /** Carpetas de trabajo con cantidad de sesiones (los "proyectos" del sidebar). */
293
- listProjects() {
294
- return this.db
295
- .prepare(
296
- `SELECT cwd, COUNT(*) AS sessions, MAX(updated_at) AS updated_at
297
- FROM sessions GROUP BY cwd ORDER BY updated_at DESC`
298
- )
299
- .all()
300
- .map((row) => ({ cwd: row.cwd, sessions: row.sessions, updatedAt: row.updated_at }));
301
- }
302
-
303
- // --- Messages -----------------------------------------------------------
304
-
305
- addMessage(sessionId, message) {
306
- const id = randomUUID();
307
- this.db
308
- .prepare(
309
- `INSERT INTO messages (id, session_id, role, content, tool_calls, tool_call_id, name, mode, agent_id, tokens_in, tokens_out, created_at)
310
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
311
- )
312
- .run(
313
- id,
314
- sessionId,
315
- message.role,
316
- json(message.content),
317
- json(message.tool_calls),
318
- message.tool_call_id ?? null,
319
- message.name ?? null,
320
- message.mode ?? null,
321
- message.agent_id ?? null,
322
- message.tokens_in ?? null,
323
- message.tokens_out ?? null,
324
- now()
325
- );
326
- return id;
327
- }
328
-
329
- addMessages(sessionId, messages) {
330
- for (const message of messages) this.addMessage(sessionId, message);
331
- }
332
-
333
- getMessages(sessionId) {
334
- return this.db
335
- .prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC`)
336
- .all(sessionId)
337
- .map((row) => {
338
- const message = {
339
- id: row.id,
340
- role: row.role,
341
- content: parseJson(row.content),
342
- };
343
- if (row.tool_calls) message.tool_calls = parseJson(row.tool_calls);
344
- if (row.tool_call_id) message.tool_call_id = row.tool_call_id;
345
- if (row.name) message.name = row.name;
346
- if (row.mode) message.mode = row.mode;
347
- if (row.agent_id) message.agent_id = row.agent_id;
348
- return message;
349
- });
350
- }
351
-
352
- // --- Agents (subagentes) ------------------------------------------------
353
-
354
- createAgent({ sessionId, parentAgentId = null, kind = 'task', title, model } = {}) {
355
- const id = randomUUID();
356
- const ts = now();
357
- this.db
358
- .prepare(
359
- `INSERT INTO agents (id, session_id, parent_agent_id, kind, title, status, model, created_at, updated_at)
360
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
361
- )
362
- .run(id, sessionId, parentAgentId, kind, title ?? null, 'running', model ?? null, ts, ts);
363
- return id;
364
- }
365
-
366
- finishAgent(id, { status = 'done', summary } = {}) {
367
- this.db
368
- .prepare(`UPDATE agents SET status = ?, summary = ?, updated_at = ? WHERE id = ?`)
369
- .run(status, summary ?? null, now(), id);
370
- }
371
-
372
- getAgent(id) {
373
- const row = this.db.prepare(`SELECT * FROM agents WHERE id = ?`).get(id);
374
- return row ? toAgent(row) : null;
375
- }
376
-
377
- listAgents(sessionId) {
378
- return this.db
379
- .prepare(`SELECT * FROM agents WHERE session_id = ? ORDER BY created_at ASC`)
380
- .all(sessionId)
381
- .map(toAgent);
382
- }
383
-
384
- // --- Session events (run log for reconnection) --------------------------
385
-
386
- addSessionEvent(sessionId, { agentId = null, type, data } = {}) {
387
- const info = this.db
388
- .prepare(`INSERT INTO session_events (session_id, agent_id, type, data, created_at) VALUES (?, ?, ?, ?, ?)`)
389
- .run(sessionId, agentId, type, json(data), now());
390
- return Number(info.lastInsertRowid);
391
- }
392
-
393
- updateSessionEvent(id, data) {
394
- this.db.prepare(`UPDATE session_events SET data = ? WHERE id = ?`).run(json(data), id);
395
- }
396
-
397
- getSessionEvents(sessionId, { from = 0, limit = 5000 } = {}) {
398
- return this.db
399
- .prepare(`SELECT * FROM session_events WHERE session_id = ? AND id > ? ORDER BY id ASC LIMIT ?`)
400
- .all(sessionId, from, limit)
401
- .map((row) => ({ id: row.id, agentId: row.agent_id, type: row.type, data: parseJson(row.data), createdAt: row.created_at }));
402
- }
403
-
404
- lastSessionEventId(sessionId) {
405
- const row = this.db
406
- .prepare(`SELECT COALESCE(MAX(id), 0) AS id FROM session_events WHERE session_id = ?`)
407
- .get(sessionId);
408
- return row?.id ?? 0;
409
- }
410
-
411
- clearSessionEvents(sessionId) {
412
- this.db.prepare(`DELETE FROM session_events WHERE session_id = ?`).run(sessionId);
413
- }
414
-
415
- // --- Runs (evaluation) --------------------------------------------------
416
-
417
- createRun({ dataset, config } = {}) {
418
- const id = randomUUID();
419
- this.db
420
- .prepare(`INSERT INTO runs (id, dataset, config, status, created_at) VALUES (?, ?, ?, ?, ?)`)
421
- .run(id, dataset ?? null, json(config), 'running', now());
422
- return id;
423
- }
424
-
425
- finishRun(id, { status = 'done', summary } = {}) {
426
- this.db
427
- .prepare(`UPDATE runs SET status = ?, finished_at = ?, summary = ? WHERE id = ?`)
428
- .run(status, now(), json(summary), id);
429
- }
430
-
431
- getRun(id) {
432
- const row = this.db.prepare(`SELECT * FROM runs WHERE id = ?`).get(id);
433
- if (!row) return null;
434
- return { ...row, config: parseJson(row.config), summary: parseJson(row.summary) };
435
- }
436
-
437
- listRuns(limit = 20) {
438
- return this.db
439
- .prepare(`SELECT * FROM runs ORDER BY created_at DESC LIMIT ?`)
440
- .all(limit)
441
- .map((row) => ({ ...row, config: parseJson(row.config), summary: parseJson(row.summary) }));
442
- }
443
-
444
- addRunResult(result) {
445
- const id = randomUUID();
446
- this.db
447
- .prepare(
448
- `INSERT INTO run_results
449
- (id, run_id, task_id, model, trial, output, score, passed, trace, duration_ms,
450
- tokens_in, tokens_out, cost, artifacts_dir, created_at)
451
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
452
- )
453
- .run(
454
- id,
455
- result.runId,
456
- result.taskId ?? null,
457
- result.model ?? null,
458
- result.trial ?? 0,
459
- result.output ?? null,
460
- result.score ?? null,
461
- result.passed ? 1 : 0,
462
- json(result.trace),
463
- result.durationMs ?? null,
464
- result.tokensIn ?? null,
465
- result.tokensOut ?? null,
466
- result.cost ?? null,
467
- result.artifactsDir ?? null,
468
- now()
469
- );
470
- return id;
471
- }
472
-
473
- getRunResults(runId) {
474
- return this.db
475
- .prepare(`SELECT * FROM run_results WHERE run_id = ? ORDER BY created_at ASC`)
476
- .all(runId)
477
- .map((row) => ({ ...row, trace: parseJson(row.trace) }));
478
- }
479
-
480
- // --- MCP servers --------------------------------------------------------
481
-
482
- listMcpServers() {
483
- return this.db
484
- .prepare(`SELECT * FROM mcp_servers ORDER BY name ASC`)
485
- .all()
486
- .map((row) => ({ ...row, enabled: Boolean(row.enabled), config: parseJson(row.config) }));
487
- }
488
-
489
- saveMcpServer(server) {
490
- this.db
491
- .prepare(
492
- `INSERT INTO mcp_servers (id, name, transport, command, url, enabled, config)
493
- VALUES (?, ?, ?, ?, ?, ?, ?)
494
- ON CONFLICT(id) DO UPDATE SET
495
- name = excluded.name, transport = excluded.transport, command = excluded.command,
496
- url = excluded.url, enabled = excluded.enabled, config = excluded.config`
497
- )
498
- .run(
499
- server.id,
500
- server.name ?? server.id,
501
- server.transport ?? 'http',
502
- server.command ?? null,
503
- server.url ?? null,
504
- server.enabled === false ? 0 : 1,
505
- json(server.config)
506
- );
507
- }
508
-
509
- deleteMcpServer(id) {
510
- this.db.prepare(`DELETE FROM mcp_servers WHERE id = ?`).run(id);
511
- }
512
-
513
- // --- Providers ----------------------------------------------------------
514
-
515
- listProviders() {
516
- return this.db
517
- .prepare(`SELECT * FROM providers ORDER BY id ASC`)
518
- .all()
519
- .map(toProvider);
520
- }
521
-
522
- getProvider(id) {
523
- const row = this.db.prepare(`SELECT * FROM providers WHERE id = ?`).get(id);
524
- return row ? toProvider(row) : null;
525
- }
526
-
527
- saveProvider(provider) {
528
- this.db
529
- .prepare(
530
- `INSERT INTO providers (id, label, base_url, api_key, models, visible_models, requires_key, custom, enabled, updated_at)
531
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
532
- ON CONFLICT(id) DO UPDATE SET
533
- label = excluded.label, base_url = excluded.base_url, api_key = excluded.api_key,
534
- models = excluded.models, visible_models = excluded.visible_models, requires_key = excluded.requires_key,
535
- custom = excluded.custom, enabled = excluded.enabled, updated_at = excluded.updated_at`
536
- )
537
- .run(
538
- provider.id,
539
- provider.label ?? provider.id,
540
- provider.baseUrl ?? null,
541
- provider.apiKey ?? null,
542
- json(provider.models ?? []),
543
- json(provider.visibleModels ?? []),
544
- provider.requiresKey === undefined ? null : provider.requiresKey ? 1 : 0,
545
- provider.custom ? 1 : 0,
546
- provider.enabled === false ? 0 : 1,
547
- now()
548
- );
549
- return this.getProvider(provider.id);
550
- }
551
-
552
- deleteProvider(id) {
553
- this.db.prepare(`DELETE FROM providers WHERE id = ?`).run(id);
554
- }
555
-
556
- // --- Schedules (tareas programadas) ------------------------------------
557
-
558
- listSchedules() {
559
- return this.db
560
- .prepare(`SELECT * FROM schedules ORDER BY created_at ASC`)
561
- .all()
562
- .map(toSchedule);
563
- }
564
-
565
- getSchedule(id) {
566
- const row = this.db.prepare(`SELECT * FROM schedules WHERE id = ?`).get(id);
567
- return row ? toSchedule(row) : null;
568
- }
569
-
570
- saveSchedule(schedule) {
571
- const ts = now();
572
- const existing = this.getSchedule(schedule.id);
573
- this.db
574
- .prepare(
575
- `INSERT INTO schedules (id, name, type, spec, payload, cwd, enabled, last_run, last_status, next_run, created_at, updated_at)
576
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
577
- ON CONFLICT(id) DO UPDATE SET
578
- name = excluded.name, type = excluded.type, spec = excluded.spec, payload = excluded.payload,
579
- cwd = excluded.cwd, enabled = excluded.enabled, next_run = excluded.next_run, updated_at = excluded.updated_at`
580
- )
581
- .run(
582
- schedule.id,
583
- schedule.name ?? schedule.id,
584
- schedule.type ?? 'agent',
585
- schedule.spec ?? '',
586
- json(schedule.payload ?? {}),
587
- schedule.cwd ?? null,
588
- schedule.enabled === false ? 0 : 1,
589
- schedule.lastRun ?? existing?.lastRun ?? null,
590
- schedule.lastStatus ?? existing?.lastStatus ?? null,
591
- schedule.nextRun ?? null,
592
- existing?.createdAt ?? ts,
593
- ts
594
- );
595
- return this.getSchedule(schedule.id);
596
- }
597
-
598
- deleteSchedule(id) {
599
- this.db.prepare(`DELETE FROM schedules WHERE id = ?`).run(id);
600
- }
601
-
602
- setScheduleEnabled(id, enabled) {
603
- this.db.prepare(`UPDATE schedules SET enabled = ?, updated_at = ? WHERE id = ?`).run(enabled ? 1 : 0, now(), id);
604
- }
605
-
606
- markScheduleRun(id, { status, nextRun } = {}) {
607
- this.db
608
- .prepare(`UPDATE schedules SET last_run = ?, last_status = ?, next_run = ?, updated_at = ? WHERE id = ?`)
609
- .run(now(), status ?? null, nextRun ?? null, now(), id);
610
- }
611
-
612
- /** Tareas habilitadas cuyo `next_run` ya venció. */
613
- dueSchedules(at = Date.now()) {
614
- return this.db
615
- .prepare(`SELECT * FROM schedules WHERE enabled = 1 AND next_run IS NOT NULL AND next_run <= ? ORDER BY next_run ASC`)
616
- .all(at)
617
- .map(toSchedule);
618
- }
619
-
620
- // --- Settings (key/value) ----------------------------------------------
621
- getSetting(key, fallback = null) {
622
- const row = this.db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key);
623
- return row ? parseJson(row.value) : fallback;
624
- }
625
-
626
- setSetting(key, value) {
627
- this.db
628
- .prepare(
629
- `INSERT INTO settings (key, value) VALUES (?, ?)
630
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`
631
- )
632
- .run(key, json(value));
633
- return value;
634
- }
635
-
636
- allSettings() {
637
- const out = {};
638
- for (const row of this.db.prepare(`SELECT key, value FROM settings`).all()) {
639
- out[row.key] = parseJson(row.value);
640
- }
641
- return out;
642
- }
643
- }
644
-
645
- function toAgent(row) {
646
- return {
647
- id: row.id,
648
- sessionId: row.session_id,
649
- parentAgentId: row.parent_agent_id ?? null,
650
- kind: row.kind ?? 'task',
651
- title: row.title ?? '',
652
- status: row.status ?? 'running',
653
- model: row.model ?? null,
654
- summary: row.summary ?? null,
655
- createdAt: row.created_at,
656
- updatedAt: row.updated_at,
657
- };
658
- }
659
-
660
- function toSchedule(row) {
661
- return {
662
- id: row.id,
663
- name: row.name ?? row.id,
664
- type: row.type ?? 'agent',
665
- spec: row.spec ?? '',
666
- payload: parseJson(row.payload) ?? {},
667
- cwd: row.cwd ?? null,
668
- enabled: row.enabled === 0 ? false : true,
669
- lastRun: row.last_run ?? null,
670
- lastStatus: row.last_status ?? null,
671
- nextRun: row.next_run ?? null,
672
- createdAt: row.created_at,
673
- updatedAt: row.updated_at,
674
- };
675
- }
676
-
677
- function toProvider(row) {
678
- return {
679
- id: row.id,
680
- label: row.label ?? row.id,
681
- baseUrl: row.base_url ?? '',
682
- apiKey: row.api_key ?? '',
683
- models: parseJson(row.models) ?? [],
684
- visibleModels: parseJson(row.visible_models) ?? [],
685
- requiresKey: row.requires_key === null ? undefined : Boolean(row.requires_key),
686
- custom: Boolean(row.custom),
687
- enabled: row.enabled === 0 ? false : true,
688
- updatedAt: row.updated_at ?? null,
689
- };
690
- }
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdirSync } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ function likePattern(query) {
7
+ return `%${String(query).replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
8
+ }
9
+
10
+ const SCHEMA = `
11
+ CREATE TABLE IF NOT EXISTS sessions (
12
+ id TEXT PRIMARY KEY,
13
+ title TEXT,
14
+ provider TEXT,
15
+ model TEXT,
16
+ cwd TEXT,
17
+ created_at INTEGER,
18
+ updated_at INTEGER,
19
+ meta TEXT
20
+ );
21
+
22
+ CREATE TABLE IF NOT EXISTS messages (
23
+ id TEXT PRIMARY KEY,
24
+ session_id TEXT NOT NULL,
25
+ role TEXT NOT NULL,
26
+ content TEXT,
27
+ tool_calls TEXT,
28
+ tool_call_id TEXT,
29
+ name TEXT,
30
+ mode TEXT,
31
+ agent_id TEXT,
32
+ tokens_in INTEGER,
33
+ tokens_out INTEGER,
34
+ created_at INTEGER
35
+ );
36
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
37
+
38
+ CREATE TABLE IF NOT EXISTS agents (
39
+ id TEXT PRIMARY KEY,
40
+ session_id TEXT NOT NULL,
41
+ parent_agent_id TEXT,
42
+ kind TEXT,
43
+ title TEXT,
44
+ status TEXT,
45
+ model TEXT,
46
+ summary TEXT,
47
+ created_at INTEGER,
48
+ updated_at INTEGER
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_agents_session ON agents(session_id, created_at);
51
+
52
+ CREATE TABLE IF NOT EXISTS session_events (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ session_id TEXT NOT NULL,
55
+ agent_id TEXT,
56
+ type TEXT NOT NULL,
57
+ data TEXT,
58
+ created_at INTEGER
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_session_events ON session_events(session_id, id);
61
+
62
+ CREATE TABLE IF NOT EXISTS mcp_servers (
63
+ id TEXT PRIMARY KEY,
64
+ name TEXT,
65
+ transport TEXT,
66
+ command TEXT,
67
+ url TEXT,
68
+ enabled INTEGER,
69
+ config TEXT
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS providers (
73
+ id TEXT PRIMARY KEY,
74
+ label TEXT,
75
+ base_url TEXT,
76
+ api_key TEXT,
77
+ models TEXT,
78
+ visible_models TEXT,
79
+ requires_key INTEGER,
80
+ custom INTEGER,
81
+ enabled INTEGER,
82
+ updated_at INTEGER
83
+ );
84
+
85
+ CREATE TABLE IF NOT EXISTS settings (
86
+ key TEXT PRIMARY KEY,
87
+ value TEXT
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS schedules (
91
+ id TEXT PRIMARY KEY,
92
+ name TEXT,
93
+ type TEXT,
94
+ spec TEXT,
95
+ payload TEXT,
96
+ cwd TEXT,
97
+ enabled INTEGER,
98
+ last_run INTEGER,
99
+ last_status TEXT,
100
+ next_run INTEGER,
101
+ created_at INTEGER,
102
+ updated_at INTEGER
103
+ );
104
+ `;
105
+
106
+ /** Open (creating if needed) a SQLite database with the harness schema. */
107
+ export function openDatabase(file) {
108
+ if (file && file !== ':memory:') {
109
+ mkdirSync(path.dirname(file), { recursive: true });
110
+ }
111
+ const db = new DatabaseSync(file ?? ':memory:');
112
+ db.exec('PRAGMA journal_mode = WAL;');
113
+ db.exec(SCHEMA);
114
+ migrate(db);
115
+ return db;
116
+ }
117
+
118
+ function migrate(db) {
119
+ const messages = db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name);
120
+ if (!messages.includes('mode')) db.exec('ALTER TABLE messages ADD COLUMN mode TEXT');
121
+ if (!messages.includes('agent_id')) db.exec('ALTER TABLE messages ADD COLUMN agent_id TEXT');
122
+
123
+ const providers = db.prepare('PRAGMA table_info(providers)').all().map((c) => c.name);
124
+ if (!providers.includes('visible_models')) db.exec('ALTER TABLE providers ADD COLUMN visible_models TEXT');
125
+ }
126
+
127
+ const now = () => Date.now();
128
+ const json = (value) => (value === undefined || value === null ? null : JSON.stringify(value));
129
+ const parseJson = (value) => {
130
+ if (value === null || value === undefined) return null;
131
+ try {
132
+ return JSON.parse(value);
133
+ } catch {
134
+ return value;
135
+ }
136
+ };
137
+
138
+ /** Thin data-access layer over the SQLite database. */
139
+ export class Store {
140
+ constructor(file) {
141
+ this.db = openDatabase(file);
142
+ }
143
+
144
+ close() {
145
+ this.db.close();
146
+ }
147
+
148
+ // --- Sessions -----------------------------------------------------------
149
+
150
+ createSession({ title = 'Nueva sesión', provider, model, cwd, meta } = {}) {
151
+ const id = randomUUID();
152
+ const ts = now();
153
+ this.db
154
+ .prepare(
155
+ `INSERT INTO sessions (id, title, provider, model, cwd, created_at, updated_at, meta)
156
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
157
+ )
158
+ .run(id, title, provider ?? null, model ?? null, cwd ?? null, ts, ts, json(meta));
159
+ return id;
160
+ }
161
+
162
+ touchSession(id, { provider, model, title, meta } = {}) {
163
+ const session = this.getSession(id);
164
+ if (!session) return;
165
+ const mergedMeta = meta === undefined ? session.meta : { ...(session.meta ?? {}), ...meta };
166
+ this.db
167
+ .prepare(`UPDATE sessions SET updated_at = ?, provider = ?, model = ?, title = ?, meta = ? WHERE id = ?`)
168
+ .run(
169
+ now(),
170
+ provider ?? session.provider,
171
+ model ?? session.model,
172
+ title ?? session.title,
173
+ json(mergedMeta),
174
+ id
175
+ );
176
+ }
177
+
178
+ clearMessages(sessionId) {
179
+ this.db.prepare(`DELETE FROM messages WHERE session_id = ?`).run(sessionId);
180
+ }
181
+
182
+ /** Borra desde `messageId` (incluido) hasta el final. Devuelve cuantos borro. */
183
+ deleteMessagesFrom(sessionId, messageId) {
184
+ const row = this.db
185
+ .prepare(`SELECT rowid FROM messages WHERE id = ? AND session_id = ?`)
186
+ .get(messageId, sessionId);
187
+ if (!row) return 0;
188
+ const info = this.db.prepare(`DELETE FROM messages WHERE session_id = ? AND rowid >= ?`).run(sessionId, row.rowid);
189
+ return info.changes;
190
+ }
191
+
192
+ sessionTokens(sessionId) {
193
+ const row = this.db
194
+ .prepare(
195
+ `SELECT COALESCE(SUM(tokens_in), 0) AS input_tokens, COALESCE(SUM(tokens_out), 0) AS output_tokens
196
+ FROM messages WHERE session_id = ?`
197
+ )
198
+ .get(sessionId);
199
+ return { in: row.input_tokens, out: row.output_tokens };
200
+ }
201
+
202
+ getSession(id) {
203
+ const row = this.db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
204
+ if (!row) return null;
205
+ return { ...row, meta: parseJson(row.meta) };
206
+ }
207
+
208
+ listSessions(limit = 20) {
209
+ return this.db
210
+ .prepare(`SELECT * FROM sessions ORDER BY updated_at DESC LIMIT ?`)
211
+ .all(limit)
212
+ .map((row) => ({ ...row, meta: parseJson(row.meta) }));
213
+ }
214
+
215
+ latestSession() {
216
+ return this.listSessions(1)[0] ?? null;
217
+ }
218
+
219
+ searchSessions(query, limit = 20) {
220
+ const like = likePattern(query);
221
+ return this.db
222
+ .prepare(`SELECT * FROM sessions WHERE title LIKE ? ESCAPE '\\' ORDER BY updated_at DESC LIMIT ?`)
223
+ .all(like, limit)
224
+ .map((row) => ({ ...row, meta: parseJson(row.meta) }));
225
+ }
226
+
227
+ searchMessages(query, limit = 30) {
228
+ const like = likePattern(query);
229
+ return this.db
230
+ .prepare(
231
+ `SELECT m.id, m.session_id, m.role, m.content, m.created_at, s.title, s.cwd
232
+ FROM messages m JOIN sessions s ON s.id = m.session_id
233
+ WHERE m.content LIKE ? ESCAPE '\\'
234
+ ORDER BY m.created_at DESC LIMIT ?`
235
+ )
236
+ .all(like, limit)
237
+ .map((row) => ({
238
+ id: row.id,
239
+ sessionId: row.session_id,
240
+ role: row.role,
241
+ content: parseJson(row.content),
242
+ title: row.title,
243
+ cwd: row.cwd,
244
+ }));
245
+ }
246
+
247
+ deleteSession(id) {
248
+ this.db.prepare(`DELETE FROM messages WHERE session_id = ?`).run(id);
249
+ this.db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
250
+ }
251
+
252
+ /** Borra todas las sesiones (y sus mensajes) de una carpeta. No toca archivos. */
253
+ deleteSessionsByCwd(cwd) {
254
+ const value = cwd === undefined || cwd === '' ? null : cwd;
255
+ const messages = this.db
256
+ .prepare(`DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE cwd IS ?)`)
257
+ .run(value);
258
+ const sessions = this.db.prepare(`DELETE FROM sessions WHERE cwd IS ?`).run(value);
259
+ return { sessions: sessions.changes, messages: messages.changes };
260
+ }
261
+
262
+ /** Carpetas de trabajo con cantidad de sesiones (los "proyectos" del sidebar). */
263
+ listProjects() {
264
+ return this.db
265
+ .prepare(
266
+ `SELECT cwd, COUNT(*) AS sessions, MAX(updated_at) AS updated_at
267
+ FROM sessions GROUP BY cwd ORDER BY updated_at DESC`
268
+ )
269
+ .all()
270
+ .map((row) => ({ cwd: row.cwd, sessions: row.sessions, updatedAt: row.updated_at }));
271
+ }
272
+
273
+ // --- Messages -----------------------------------------------------------
274
+
275
+ addMessage(sessionId, message) {
276
+ const id = randomUUID();
277
+ this.db
278
+ .prepare(
279
+ `INSERT INTO messages (id, session_id, role, content, tool_calls, tool_call_id, name, mode, agent_id, tokens_in, tokens_out, created_at)
280
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
281
+ )
282
+ .run(
283
+ id,
284
+ sessionId,
285
+ message.role,
286
+ json(message.content),
287
+ json(message.tool_calls),
288
+ message.tool_call_id ?? null,
289
+ message.name ?? null,
290
+ message.mode ?? null,
291
+ message.agent_id ?? null,
292
+ message.tokens_in ?? null,
293
+ message.tokens_out ?? null,
294
+ now()
295
+ );
296
+ return id;
297
+ }
298
+
299
+ addMessages(sessionId, messages) {
300
+ for (const message of messages) this.addMessage(sessionId, message);
301
+ }
302
+
303
+ getMessages(sessionId) {
304
+ return this.db
305
+ .prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC`)
306
+ .all(sessionId)
307
+ .map((row) => {
308
+ const message = {
309
+ id: row.id,
310
+ role: row.role,
311
+ content: parseJson(row.content),
312
+ };
313
+ if (row.tool_calls) message.tool_calls = parseJson(row.tool_calls);
314
+ if (row.tool_call_id) message.tool_call_id = row.tool_call_id;
315
+ if (row.name) message.name = row.name;
316
+ if (row.mode) message.mode = row.mode;
317
+ if (row.agent_id) message.agent_id = row.agent_id;
318
+ return message;
319
+ });
320
+ }
321
+
322
+ // --- Agents (subagentes) ------------------------------------------------
323
+
324
+ createAgent({ sessionId, parentAgentId = null, kind = 'task', title, model } = {}) {
325
+ const id = randomUUID();
326
+ const ts = now();
327
+ this.db
328
+ .prepare(
329
+ `INSERT INTO agents (id, session_id, parent_agent_id, kind, title, status, model, created_at, updated_at)
330
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
331
+ )
332
+ .run(id, sessionId, parentAgentId, kind, title ?? null, 'running', model ?? null, ts, ts);
333
+ return id;
334
+ }
335
+
336
+ finishAgent(id, { status = 'done', summary } = {}) {
337
+ this.db
338
+ .prepare(`UPDATE agents SET status = ?, summary = ?, updated_at = ? WHERE id = ?`)
339
+ .run(status, summary ?? null, now(), id);
340
+ }
341
+
342
+ getAgent(id) {
343
+ const row = this.db.prepare(`SELECT * FROM agents WHERE id = ?`).get(id);
344
+ return row ? toAgent(row) : null;
345
+ }
346
+
347
+ listAgents(sessionId) {
348
+ return this.db
349
+ .prepare(`SELECT * FROM agents WHERE session_id = ? ORDER BY created_at ASC`)
350
+ .all(sessionId)
351
+ .map(toAgent);
352
+ }
353
+
354
+ // --- Session events (run log for reconnection) --------------------------
355
+
356
+ addSessionEvent(sessionId, { agentId = null, type, data } = {}) {
357
+ const info = this.db
358
+ .prepare(`INSERT INTO session_events (session_id, agent_id, type, data, created_at) VALUES (?, ?, ?, ?, ?)`)
359
+ .run(sessionId, agentId, type, json(data), now());
360
+ return Number(info.lastInsertRowid);
361
+ }
362
+
363
+ updateSessionEvent(id, data) {
364
+ this.db.prepare(`UPDATE session_events SET data = ? WHERE id = ?`).run(json(data), id);
365
+ }
366
+
367
+ getSessionEvents(sessionId, { from = 0, limit = 5000 } = {}) {
368
+ return this.db
369
+ .prepare(`SELECT * FROM session_events WHERE session_id = ? AND id > ? ORDER BY id ASC LIMIT ?`)
370
+ .all(sessionId, from, limit)
371
+ .map((row) => ({ id: row.id, agentId: row.agent_id, type: row.type, data: parseJson(row.data), createdAt: row.created_at }));
372
+ }
373
+
374
+ lastSessionEventId(sessionId) {
375
+ const row = this.db
376
+ .prepare(`SELECT COALESCE(MAX(id), 0) AS id FROM session_events WHERE session_id = ?`)
377
+ .get(sessionId);
378
+ return row?.id ?? 0;
379
+ }
380
+
381
+ clearSessionEvents(sessionId) {
382
+ this.db.prepare(`DELETE FROM session_events WHERE session_id = ?`).run(sessionId);
383
+ }
384
+
385
+ // --- MCP servers --------------------------------------------------------
386
+
387
+ listMcpServers() {
388
+ return this.db
389
+ .prepare(`SELECT * FROM mcp_servers ORDER BY name ASC`)
390
+ .all()
391
+ .map((row) => ({ ...row, enabled: Boolean(row.enabled), config: parseJson(row.config) }));
392
+ }
393
+
394
+ saveMcpServer(server) {
395
+ this.db
396
+ .prepare(
397
+ `INSERT INTO mcp_servers (id, name, transport, command, url, enabled, config)
398
+ VALUES (?, ?, ?, ?, ?, ?, ?)
399
+ ON CONFLICT(id) DO UPDATE SET
400
+ name = excluded.name, transport = excluded.transport, command = excluded.command,
401
+ url = excluded.url, enabled = excluded.enabled, config = excluded.config`
402
+ )
403
+ .run(
404
+ server.id,
405
+ server.name ?? server.id,
406
+ server.transport ?? 'http',
407
+ server.command ?? null,
408
+ server.url ?? null,
409
+ server.enabled === false ? 0 : 1,
410
+ json(server.config)
411
+ );
412
+ }
413
+
414
+ deleteMcpServer(id) {
415
+ this.db.prepare(`DELETE FROM mcp_servers WHERE id = ?`).run(id);
416
+ }
417
+
418
+ // --- Providers ----------------------------------------------------------
419
+
420
+ listProviders() {
421
+ return this.db
422
+ .prepare(`SELECT * FROM providers ORDER BY id ASC`)
423
+ .all()
424
+ .map(toProvider);
425
+ }
426
+
427
+ getProvider(id) {
428
+ const row = this.db.prepare(`SELECT * FROM providers WHERE id = ?`).get(id);
429
+ return row ? toProvider(row) : null;
430
+ }
431
+
432
+ saveProvider(provider) {
433
+ this.db
434
+ .prepare(
435
+ `INSERT INTO providers (id, label, base_url, api_key, models, visible_models, requires_key, custom, enabled, updated_at)
436
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
437
+ ON CONFLICT(id) DO UPDATE SET
438
+ label = excluded.label, base_url = excluded.base_url, api_key = excluded.api_key,
439
+ models = excluded.models, visible_models = excluded.visible_models, requires_key = excluded.requires_key,
440
+ custom = excluded.custom, enabled = excluded.enabled, updated_at = excluded.updated_at`
441
+ )
442
+ .run(
443
+ provider.id,
444
+ provider.label ?? provider.id,
445
+ provider.baseUrl ?? null,
446
+ provider.apiKey ?? null,
447
+ json(provider.models ?? []),
448
+ json(provider.visibleModels ?? []),
449
+ provider.requiresKey === undefined ? null : provider.requiresKey ? 1 : 0,
450
+ provider.custom ? 1 : 0,
451
+ provider.enabled === false ? 0 : 1,
452
+ now()
453
+ );
454
+ return this.getProvider(provider.id);
455
+ }
456
+
457
+ deleteProvider(id) {
458
+ this.db.prepare(`DELETE FROM providers WHERE id = ?`).run(id);
459
+ }
460
+
461
+ // --- Schedules (tareas programadas) ------------------------------------
462
+
463
+ listSchedules() {
464
+ return this.db
465
+ .prepare(`SELECT * FROM schedules ORDER BY created_at ASC`)
466
+ .all()
467
+ .map(toSchedule);
468
+ }
469
+
470
+ getSchedule(id) {
471
+ const row = this.db.prepare(`SELECT * FROM schedules WHERE id = ?`).get(id);
472
+ return row ? toSchedule(row) : null;
473
+ }
474
+
475
+ saveSchedule(schedule) {
476
+ const ts = now();
477
+ const existing = this.getSchedule(schedule.id);
478
+ this.db
479
+ .prepare(
480
+ `INSERT INTO schedules (id, name, type, spec, payload, cwd, enabled, last_run, last_status, next_run, created_at, updated_at)
481
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
482
+ ON CONFLICT(id) DO UPDATE SET
483
+ name = excluded.name, type = excluded.type, spec = excluded.spec, payload = excluded.payload,
484
+ cwd = excluded.cwd, enabled = excluded.enabled, next_run = excluded.next_run, updated_at = excluded.updated_at`
485
+ )
486
+ .run(
487
+ schedule.id,
488
+ schedule.name ?? schedule.id,
489
+ schedule.type ?? 'agent',
490
+ schedule.spec ?? '',
491
+ json(schedule.payload ?? {}),
492
+ schedule.cwd ?? null,
493
+ schedule.enabled === false ? 0 : 1,
494
+ schedule.lastRun ?? existing?.lastRun ?? null,
495
+ schedule.lastStatus ?? existing?.lastStatus ?? null,
496
+ schedule.nextRun ?? null,
497
+ existing?.createdAt ?? ts,
498
+ ts
499
+ );
500
+ return this.getSchedule(schedule.id);
501
+ }
502
+
503
+ deleteSchedule(id) {
504
+ this.db.prepare(`DELETE FROM schedules WHERE id = ?`).run(id);
505
+ }
506
+
507
+ setScheduleEnabled(id, enabled) {
508
+ this.db.prepare(`UPDATE schedules SET enabled = ?, updated_at = ? WHERE id = ?`).run(enabled ? 1 : 0, now(), id);
509
+ }
510
+
511
+ markScheduleRun(id, { status, nextRun } = {}) {
512
+ this.db
513
+ .prepare(`UPDATE schedules SET last_run = ?, last_status = ?, next_run = ?, updated_at = ? WHERE id = ?`)
514
+ .run(now(), status ?? null, nextRun ?? null, now(), id);
515
+ }
516
+
517
+ /** Tareas habilitadas cuyo `next_run` ya venció. */
518
+ dueSchedules(at = Date.now()) {
519
+ return this.db
520
+ .prepare(`SELECT * FROM schedules WHERE enabled = 1 AND next_run IS NOT NULL AND next_run <= ? ORDER BY next_run ASC`)
521
+ .all(at)
522
+ .map(toSchedule);
523
+ }
524
+
525
+ // --- Settings (key/value) ----------------------------------------------
526
+ getSetting(key, fallback = null) {
527
+ const row = this.db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key);
528
+ return row ? parseJson(row.value) : fallback;
529
+ }
530
+
531
+ setSetting(key, value) {
532
+ this.db
533
+ .prepare(
534
+ `INSERT INTO settings (key, value) VALUES (?, ?)
535
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
536
+ )
537
+ .run(key, json(value));
538
+ return value;
539
+ }
540
+
541
+ allSettings() {
542
+ const out = {};
543
+ for (const row of this.db.prepare(`SELECT key, value FROM settings`).all()) {
544
+ out[row.key] = parseJson(row.value);
545
+ }
546
+ return out;
547
+ }
548
+ }
549
+
550
+ function toAgent(row) {
551
+ return {
552
+ id: row.id,
553
+ sessionId: row.session_id,
554
+ parentAgentId: row.parent_agent_id ?? null,
555
+ kind: row.kind ?? 'task',
556
+ title: row.title ?? '',
557
+ status: row.status ?? 'running',
558
+ model: row.model ?? null,
559
+ summary: row.summary ?? null,
560
+ createdAt: row.created_at,
561
+ updatedAt: row.updated_at,
562
+ };
563
+ }
564
+
565
+ function toSchedule(row) {
566
+ return {
567
+ id: row.id,
568
+ name: row.name ?? row.id,
569
+ type: row.type ?? 'agent',
570
+ spec: row.spec ?? '',
571
+ payload: parseJson(row.payload) ?? {},
572
+ cwd: row.cwd ?? null,
573
+ enabled: row.enabled === 0 ? false : true,
574
+ lastRun: row.last_run ?? null,
575
+ lastStatus: row.last_status ?? null,
576
+ nextRun: row.next_run ?? null,
577
+ createdAt: row.created_at,
578
+ updatedAt: row.updated_at,
579
+ };
580
+ }
581
+
582
+ function toProvider(row) {
583
+ return {
584
+ id: row.id,
585
+ label: row.label ?? row.id,
586
+ baseUrl: row.base_url ?? '',
587
+ apiKey: row.api_key ?? '',
588
+ models: parseJson(row.models) ?? [],
589
+ visibleModels: parseJson(row.visible_models) ?? [],
590
+ requiresKey: row.requires_key === null ? undefined : Boolean(row.requires_key),
591
+ custom: Boolean(row.custom),
592
+ enabled: row.enabled === 0 ? false : true,
593
+ updatedAt: row.updated_at ?? null,
594
+ };
595
+ }