@newsails/veil-cli 1.0.1

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 (199) hide show
  1. package/.veil/agents/analyst/AGENT.md +21 -0
  2. package/.veil/agents/analyst/agent.json +23 -0
  3. package/.veil/agents/assistant/AGENT.md +15 -0
  4. package/.veil/agents/assistant/agent.json +19 -0
  5. package/.veil/agents/coder/AGENT.md +18 -0
  6. package/.veil/agents/coder/agent.json +19 -0
  7. package/.veil/agents/hello/AGENT.md +5 -0
  8. package/.veil/agents/hello/agent.json +13 -0
  9. package/.veil/agents/writer/AGENT.md +12 -0
  10. package/.veil/agents/writer/agent.json +17 -0
  11. package/.veil/memory/MEMORY.md +343 -0
  12. package/.veil/memory/agents/analyst/MEMORY.md +55 -0
  13. package/.veil/memory/agents/hello/MEMORY.md +12 -0
  14. package/.veil/runtime.pid +1 -0
  15. package/.veil/settings.json +10 -0
  16. package/.veil-studio/studio.db +0 -0
  17. package/.veil-studio/studio.db-shm +0 -0
  18. package/.veil-studio/studio.db-wal +0 -0
  19. package/PLAN/01-vision.md +26 -0
  20. package/PLAN/02-tech-stack.md +94 -0
  21. package/PLAN/03-agents.md +232 -0
  22. package/PLAN/04-runtime.md +171 -0
  23. package/PLAN/05-tools.md +211 -0
  24. package/PLAN/06-communication.md +243 -0
  25. package/PLAN/07-storage.md +218 -0
  26. package/PLAN/08-api-cli.md +153 -0
  27. package/PLAN/09-permissions.md +108 -0
  28. package/PLAN/10-ably.md +105 -0
  29. package/PLAN/11-file-formats.md +442 -0
  30. package/PLAN/12-folder-structure.md +205 -0
  31. package/PLAN/13-operations.md +212 -0
  32. package/PLAN/README.md +23 -0
  33. package/README.md +128 -0
  34. package/REPORT.md +174 -0
  35. package/TODO.md +45 -0
  36. package/ai-tests/FRONTEND_PROMPT.md +220 -0
  37. package/ai-tests/Research & Planning.md +814 -0
  38. package/ai-tests/prompt-001-basic-api.md +230 -0
  39. package/ai-tests/prompt-002-basic-flows.md +230 -0
  40. package/ai-tests/prompt-003-agent-behaviors.md +220 -0
  41. package/api/middleware.js +60 -0
  42. package/api/routes/agents.js +193 -0
  43. package/api/routes/chat.js +93 -0
  44. package/api/routes/completions.js +122 -0
  45. package/api/routes/daemons.js +80 -0
  46. package/api/routes/memory.js +169 -0
  47. package/api/routes/models.js +40 -0
  48. package/api/routes/remote-methods.js +74 -0
  49. package/api/routes/sessions.js +208 -0
  50. package/api/routes/settings.js +108 -0
  51. package/api/routes/system.js +50 -0
  52. package/api/routes/tasks.js +270 -0
  53. package/api/server.js +120 -0
  54. package/cli/formatter.js +70 -0
  55. package/cli/index.js +443 -0
  56. package/cli/parser.js +113 -0
  57. package/config/config.json +10 -0
  58. package/config/models.json +6826 -0
  59. package/core/agent.js +329 -0
  60. package/core/cancel.js +38 -0
  61. package/core/compaction.js +176 -0
  62. package/core/events.js +13 -0
  63. package/core/loop.js +564 -0
  64. package/core/memory.js +51 -0
  65. package/core/prompt.js +185 -0
  66. package/core/queue.js +96 -0
  67. package/core/registry.js +291 -0
  68. package/core/remote-methods.js +124 -0
  69. package/core/router.js +386 -0
  70. package/core/running-sessions.js +18 -0
  71. package/docs/api/01-system.md +84 -0
  72. package/docs/api/02-agents.md +374 -0
  73. package/docs/api/03-chat.md +269 -0
  74. package/docs/api/04-tasks.md +470 -0
  75. package/docs/api/05-sessions.md +444 -0
  76. package/docs/api/06-daemons.md +142 -0
  77. package/docs/api/07-memory.md +186 -0
  78. package/docs/api/08-settings.md +133 -0
  79. package/docs/api/09-models.md +119 -0
  80. package/docs/api/09-websocket.md +350 -0
  81. package/docs/api/10-completions.md +134 -0
  82. package/docs/api/README.md +116 -0
  83. package/docs/guide/01-quickstart.md +220 -0
  84. package/docs/guide/02-folder-structure.md +185 -0
  85. package/docs/guide/03-configuration.md +252 -0
  86. package/docs/guide/04-agents.md +267 -0
  87. package/docs/guide/05-cli.md +290 -0
  88. package/docs/guide/06-tools.md +643 -0
  89. package/docs/guide/07-permissions.md +236 -0
  90. package/docs/guide/08-memory.md +139 -0
  91. package/docs/guide/09-multi-agent.md +271 -0
  92. package/docs/guide/10-daemons.md +226 -0
  93. package/docs/guide/README.md +53 -0
  94. package/docs/index.html +623 -0
  95. package/examples/README.md +151 -0
  96. package/examples/agents/assistant/AGENT.md +31 -0
  97. package/examples/agents/assistant/SOUL.md +9 -0
  98. package/examples/agents/assistant/agent.json +74 -0
  99. package/examples/agents/hello/AGENT.md +15 -0
  100. package/examples/agents/hello/agent.json +14 -0
  101. package/examples/agents/monitor/AGENT.md +51 -0
  102. package/examples/agents/monitor/agent.json +33 -0
  103. package/examples/agents/monitor/heartbeats/monitor.md +24 -0
  104. package/examples/agents/orchestrator/AGENT.md +70 -0
  105. package/examples/agents/orchestrator/agent.json +30 -0
  106. package/examples/agents/researcher/AGENT.md +52 -0
  107. package/examples/agents/researcher/agent.json +49 -0
  108. package/examples/agents/researcher/skills/web-research.md +28 -0
  109. package/examples/skills/code-review.md +72 -0
  110. package/examples/skills/summarise.md +59 -0
  111. package/examples/skills/web-research.md +42 -0
  112. package/examples/tools/word-count/index.js +27 -0
  113. package/examples/tools/word-count/tool.json +18 -0
  114. package/infrastructure/database.js +563 -0
  115. package/infrastructure/scheduler.js +122 -0
  116. package/llm/client.js +206 -0
  117. package/migrations/001-initial.sql +121 -0
  118. package/migrations/002-debuggability.sql +13 -0
  119. package/migrations/003-drop-orphaned-columns.sql +72 -0
  120. package/migrations/004-session-message-token-fields.sql +78 -0
  121. package/migrations/005-session-thinking.sql +5 -0
  122. package/package.json +30 -0
  123. package/schemas/agent.json +143 -0
  124. package/schemas/settings.json +111 -0
  125. package/scripts/fetch-models.js +93 -0
  126. package/session-debug-scenario.md +248 -0
  127. package/settings/fields.js +52 -0
  128. package/system-prompts/base-core.md +7 -0
  129. package/system-prompts/environment.md +13 -0
  130. package/system-prompts/reminders/anti-drift.md +6 -0
  131. package/system-prompts/reminders/stall-recovery.md +10 -0
  132. package/system-prompts/safety-rules.md +25 -0
  133. package/system-prompts/task-heuristics.md +27 -0
  134. package/test/client.js +71 -0
  135. package/test/integration/01-health.test.js +25 -0
  136. package/test/integration/02-agents.test.js +80 -0
  137. package/test/integration/03-chat-hello.test.js +48 -0
  138. package/test/integration/04-chat-multiturn.test.js +61 -0
  139. package/test/integration/05-chat-writer.test.js +48 -0
  140. package/test/integration/06-task-basic.test.js +68 -0
  141. package/test/integration/07-task-tools.test.js +74 -0
  142. package/test/integration/08-task-code-analysis.test.js +69 -0
  143. package/test/integration/09-memory-analyst.test.js +63 -0
  144. package/test/integration/10-task-advanced.test.js +85 -0
  145. package/test/integration/11-sessions-advanced.test.js +84 -0
  146. package/test/integration/12-assistant-chat-tools.test.js +75 -0
  147. package/test/integration/13-edge-cases.test.js +99 -0
  148. package/test/integration/14-cancel.test.js +62 -0
  149. package/test/integration/15-debug.test.js +106 -0
  150. package/test/integration/16-memory-api.test.js +83 -0
  151. package/test/integration/17-settings-api.test.js +41 -0
  152. package/test/integration/18-tool-search-activation.test.js +119 -0
  153. package/test/results/.gitkeep +0 -0
  154. package/test/runner.js +206 -0
  155. package/test/smoke.js +216 -0
  156. package/tools/agent_message.js +85 -0
  157. package/tools/agent_send.js +80 -0
  158. package/tools/agent_spawn.js +44 -0
  159. package/tools/bash.js +49 -0
  160. package/tools/edit_file.js +41 -0
  161. package/tools/glob.js +64 -0
  162. package/tools/grep.js +82 -0
  163. package/tools/list_dir.js +63 -0
  164. package/tools/log_write.js +31 -0
  165. package/tools/memory_read.js +38 -0
  166. package/tools/memory_search.js +65 -0
  167. package/tools/memory_write.js +42 -0
  168. package/tools/read_file.js +48 -0
  169. package/tools/sleep.js +22 -0
  170. package/tools/task_create.js +41 -0
  171. package/tools/task_respond.js +37 -0
  172. package/tools/task_spawn.js +64 -0
  173. package/tools/task_status.js +39 -0
  174. package/tools/task_subscribe.js +37 -0
  175. package/tools/todo_read.js +26 -0
  176. package/tools/todo_write.js +38 -0
  177. package/tools/tool_activate.js +24 -0
  178. package/tools/tool_search.js +24 -0
  179. package/tools/web_fetch.js +50 -0
  180. package/tools/web_search.js +52 -0
  181. package/tools/write_file.js +28 -0
  182. package/ui/api.js +190 -0
  183. package/ui/app.js +281 -0
  184. package/ui/index.html +382 -0
  185. package/ui/views/agents.js +377 -0
  186. package/ui/views/chat.js +610 -0
  187. package/ui/views/connection.js +96 -0
  188. package/ui/views/daemons.js +129 -0
  189. package/ui/views/feed.js +194 -0
  190. package/ui/views/memory.js +263 -0
  191. package/ui/views/models.js +146 -0
  192. package/ui/views/sessions.js +314 -0
  193. package/ui/views/settings.js +142 -0
  194. package/ui/views/tasks.js +415 -0
  195. package/utils/context.js +49 -0
  196. package/utils/id.js +16 -0
  197. package/utils/models.js +88 -0
  198. package/utils/paths.js +213 -0
  199. package/utils/settings.js +172 -0
@@ -0,0 +1,563 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const paths = require('../utils/paths');
6
+ const { generateId } = require('../utils/id');
7
+ const { getContextLimit } = require('../utils/models');
8
+
9
+ let _db = null;
10
+ const CURRENT_SCHEMA_VERSION = 1;
11
+
12
+ /**
13
+ * Open (or return cached) the SQLite database.
14
+ * Runs migrations on every call (idempotent IF NOT EXISTS).
15
+ * @returns {import('better-sqlite3').Database}
16
+ */
17
+ function getDb() {
18
+ if (_db) return _db;
19
+
20
+ const dbPath = paths.getDbPath();
21
+ const dbDir = path.dirname(dbPath);
22
+ if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
23
+
24
+ const Database = require('better-sqlite3');
25
+ _db = new Database(dbPath);
26
+
27
+ _db.pragma('journal_mode = WAL');
28
+ _db.pragma('synchronous = NORMAL');
29
+ _db.pragma('foreign_keys = ON');
30
+
31
+ runMigrations(_db);
32
+ return _db;
33
+ }
34
+
35
+ /**
36
+ * Run all migrations. SQL uses IF NOT EXISTS so this is always safe.
37
+ * @param {import('better-sqlite3').Database} db
38
+ */
39
+ function runMigrations(db) {
40
+ // Bootstrap: create schema_version before we can check it
41
+ db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
42
+ version INTEGER PRIMARY KEY,
43
+ applied_at TEXT NOT NULL
44
+ )`);
45
+
46
+ const migrationsDir = path.join(__dirname, '..', 'migrations');
47
+ const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
48
+
49
+ for (const file of files) {
50
+ const versionMatch = file.match(/^(\d+)/);
51
+ if (!versionMatch) continue;
52
+ const version = parseInt(versionMatch[1], 10);
53
+
54
+ const alreadyApplied = db.prepare(
55
+ 'SELECT version FROM schema_version WHERE version = ?'
56
+ ).get(version);
57
+
58
+ if (!alreadyApplied) {
59
+ const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
60
+ try {
61
+ db.transaction(() => {
62
+ db.exec(sql);
63
+ db.prepare('INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (?, ?)').run(
64
+ version, new Date().toISOString()
65
+ );
66
+ })();
67
+ } catch (err) {
68
+ // Table-rename migrations (003, 004) can fail on existing DBs whose schema is
69
+ // already partially updated. ensureColumn() below is the authoritative patcher
70
+ // and will add every required column regardless, so we log and continue.
71
+ console.warn(`[db] Migration ${file} skipped (schema already partially applied): ${err.message}`);
72
+ }
73
+ }
74
+ }
75
+
76
+ // Ensure ALL required columns exist (idempotent schema patching for legacy DBs)
77
+ // Sessions table
78
+ ensureColumn(db, 'sessions', 'instance_folder', 'TEXT');
79
+ ensureColumn(db, 'sessions', 'title', 'TEXT');
80
+ ensureColumn(db, 'sessions', 'message_count', 'INTEGER NOT NULL DEFAULT 0');
81
+ ensureColumn(db, 'sessions', 'total_input_tokens', 'INTEGER NOT NULL DEFAULT 0');
82
+ ensureColumn(db, 'sessions', 'total_output_tokens', 'INTEGER NOT NULL DEFAULT 0');
83
+ ensureColumn(db, 'sessions', 'total_cache_tokens', 'INTEGER NOT NULL DEFAULT 0');
84
+ ensureColumn(db, 'sessions', 'context_size', 'INTEGER NOT NULL DEFAULT 0');
85
+ ensureColumn(db, 'sessions', 'context_size_limit', 'INTEGER');
86
+ ensureColumn(db, 'sessions', 'cost', 'REAL NOT NULL DEFAULT 0');
87
+ ensureColumn(db, 'sessions', 'compaction_count', 'INTEGER NOT NULL DEFAULT 0');
88
+ ensureColumn(db, 'sessions', 'model_thinking', 'TEXT');
89
+
90
+ // Tasks table
91
+ ensureColumn(db, 'tasks', 'tags', 'TEXT');
92
+ ensureColumn(db, 'tasks', 'token_budget', 'INTEGER');
93
+ ensureColumn(db, 'tasks', 'max_iterations', 'INTEGER');
94
+ ensureColumn(db, 'tasks', 'max_duration_seconds', 'INTEGER');
95
+ ensureColumn(db, 'tasks', 'instance_folder', 'TEXT');
96
+ ensureColumn(db, 'tasks', 'iterations', 'INTEGER NOT NULL DEFAULT 0');
97
+ ensureColumn(db, 'tasks', 'token_input', 'INTEGER NOT NULL DEFAULT 0');
98
+ ensureColumn(db, 'tasks', 'token_output', 'INTEGER NOT NULL DEFAULT 0');
99
+ ensureColumn(db, 'tasks', 'token_cache', 'INTEGER NOT NULL DEFAULT 0');
100
+ ensureColumn(db, 'tasks', 'started_at', 'TEXT');
101
+ ensureColumn(db, 'tasks', 'finished_at', 'TEXT');
102
+
103
+ // Messages table
104
+ ensureColumn(db, 'messages', 'tool_calls', 'TEXT');
105
+ ensureColumn(db, 'messages', 'tool_call_id', 'TEXT');
106
+ ensureColumn(db, 'messages', 'model_key', 'TEXT');
107
+ ensureColumn(db, 'messages', 'input_tokens', 'INTEGER');
108
+ ensureColumn(db, 'messages', 'output_tokens', 'INTEGER');
109
+ ensureColumn(db, 'messages', 'cache_tokens', 'INTEGER');
110
+ ensureColumn(db, 'messages', 'cost', 'REAL');
111
+
112
+ // Agent messages table
113
+ ensureColumn(db, 'agent_messages', 'skill', 'TEXT');
114
+ ensureColumn(db, 'agent_messages', 'correlation_id', 'TEXT');
115
+ ensureColumn(db, 'agent_messages', 'response', 'TEXT');
116
+ ensureColumn(db, 'agent_messages', 'delivered_at', 'TEXT');
117
+ }
118
+
119
+ /**
120
+ * Add a column if it doesn't exist (SQLite doesn't support IF NOT EXISTS for ALTER TABLE).
121
+ * @param {import('better-sqlite3').Database} db
122
+ * @param {string} table
123
+ * @param {string} column
124
+ * @param {string} type
125
+ */
126
+ function ensureColumn(db, table, column, type) {
127
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all();
128
+ if (!columns.some(c => c.name === column)) {
129
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Close the database (for graceful shutdown).
135
+ */
136
+ function closeDb() {
137
+ if (_db) {
138
+ _db.close();
139
+ _db = null;
140
+ }
141
+ }
142
+
143
+ // ── Session queries ──────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * @param {{ agentName: string, mode: string, instanceFolder: string, model: string }} opts
147
+ * @returns {string} sessionId
148
+ */
149
+ function createSession({ agentName, mode, instanceFolder, model, modelThinking }) {
150
+ const db = getDb();
151
+ const id = generateId('sess_');
152
+ const now = new Date().toISOString();
153
+ const contextSizeLimit = model ? (getContextLimit(model) || null) : null;
154
+ db.prepare(`
155
+ INSERT INTO sessions (id, agent_name, mode, instance_folder, status, model, model_thinking, context_size_limit, created_at, updated_at)
156
+ VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
157
+ `).run(id, agentName, mode, instanceFolder || null, model || null,
158
+ modelThinking ? JSON.stringify(modelThinking) : null, contextSizeLimit, now, now);
159
+ return id;
160
+ }
161
+
162
+ /**
163
+ * @param {string} sessionId
164
+ * @returns {Object|null}
165
+ */
166
+ function getSession(sessionId) {
167
+ return getDb().prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) || null;
168
+ }
169
+
170
+ /**
171
+ * @param {{ instanceFolder?: string, agentName?: string, status?: string, limit?: number, cursor?: string }} opts
172
+ * @returns {Object[]}
173
+ */
174
+ function listSessions({ instanceFolder, agentName, status, limit = 20, cursor } = {}) {
175
+ const db = getDb();
176
+ let query = 'SELECT * FROM sessions WHERE 1=1';
177
+ const params = [];
178
+ if (instanceFolder) { query += ' AND instance_folder = ?'; params.push(instanceFolder); }
179
+ if (agentName) { query += ' AND agent_name = ?'; params.push(agentName); }
180
+ if (status) { query += ' AND status = ?'; params.push(status); }
181
+ if (cursor) { query += ' AND id < ?'; params.push(cursor); }
182
+ query += ' ORDER BY created_at DESC LIMIT ?';
183
+ params.push(limit);
184
+ return db.prepare(query).all(...params);
185
+ }
186
+
187
+ /**
188
+ * @param {string} sessionId
189
+ * @param {Object} updates - Partial session fields to update
190
+ */
191
+ function updateSession(sessionId, updates) {
192
+ const db = getDb();
193
+ const fields = Object.keys(updates).map(k => `${camelToSnake(k)} = ?`).join(', ');
194
+ const values = Object.values(updates);
195
+ db.prepare(`UPDATE sessions SET ${fields}, updated_at = ? WHERE id = ?`)
196
+ .run(...values, new Date().toISOString(), sessionId);
197
+ }
198
+
199
+ /**
200
+ * @param {string} sessionId
201
+ */
202
+ function closeSession(sessionId) {
203
+ updateSession(sessionId, { status: 'closed' });
204
+ }
205
+
206
+ /**
207
+ * Hard delete a session and all its messages/todos (via CASCADE).
208
+ * @param {string} sessionId
209
+ */
210
+ function deleteSession(sessionId) {
211
+ getDb().prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
212
+ }
213
+
214
+ /**
215
+ * Reset a session — clear all messages and token counts, keep session metadata.
216
+ * @param {string} sessionId
217
+ */
218
+ function resetSession(sessionId) {
219
+ const db = getDb();
220
+ db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
221
+ db.prepare(`
222
+ UPDATE sessions
223
+ SET message_count = 0, total_input_tokens = 0, total_output_tokens = 0, total_cache_tokens = 0,
224
+ context_size = 0, cost = 0, updated_at = ?
225
+ WHERE id = ?
226
+ `).run(new Date().toISOString(), sessionId);
227
+ }
228
+
229
+ // ── Message queries ──────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * @param {{ sessionId: string, role: string, content?: string, toolCalls?: any[], toolCallId?: string, modelKey?: string, inputTokens?: number, outputTokens?: number, cacheTokens?: number, cost?: number }} msg
233
+ */
234
+ function addMessage({ sessionId, role, content, toolCalls, toolCallId, modelKey, inputTokens, outputTokens, cacheTokens, cost }) {
235
+ const db = getDb();
236
+ const now = new Date().toISOString();
237
+ const { lastInsertRowid } = db.prepare(`
238
+ INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id, model_key, input_tokens, output_tokens, cache_tokens, cost, created_at)
239
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
240
+ `).run(
241
+ sessionId, role,
242
+ content || null,
243
+ toolCalls ? JSON.stringify(toolCalls) : null,
244
+ toolCallId || null,
245
+ modelKey || null,
246
+ inputTokens || null,
247
+ outputTokens || null,
248
+ cacheTokens || null,
249
+ cost || null,
250
+ now
251
+ );
252
+ db.prepare('UPDATE sessions SET message_count = message_count + 1, updated_at = ? WHERE id = ?')
253
+ .run(now, sessionId);
254
+ return lastInsertRowid;
255
+ }
256
+
257
+ /**
258
+ * @param {string} sessionId
259
+ * @param {{ limit?: number, offset?: number }} opts
260
+ * @returns {Object[]}
261
+ */
262
+ function getMessages(sessionId, { limit = 1000, offset = 0 } = {}) {
263
+ const rows = getDb().prepare(
264
+ 'SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC LIMIT ? OFFSET ?'
265
+ ).all(sessionId, limit, offset);
266
+ return rows.map(r => ({
267
+ ...r,
268
+ tool_calls: r.tool_calls ? JSON.parse(r.tool_calls) : null,
269
+ }));
270
+ }
271
+
272
+ // ── Task queries ─────────────────────────────────────────────────────────────
273
+
274
+ /**
275
+ * @param {{ agentName: string, input: string, priority?: string, tags?: string[], maxIterations?: number, maxDurationSeconds?: number, parentTaskId?: string, instanceFolder?: string, sessionId?: string }} opts
276
+ * @returns {string} taskId
277
+ */
278
+ function createTask({ agentName, input, priority = 'normal', tags, maxIterations, maxDurationSeconds, tokenBudget, parentTaskId, instanceFolder, sessionId }) {
279
+ const db = getDb();
280
+ const id = generateId('task_');
281
+ const now = new Date().toISOString();
282
+ db.prepare(`
283
+ INSERT INTO tasks (id, agent_name, status, priority, parent_task_id, session_id, input, tags, max_iterations, max_duration_seconds, token_budget, instance_folder, created_at, updated_at)
284
+ VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
285
+ `).run(
286
+ id, agentName, priority, parentTaskId || null, sessionId || null,
287
+ typeof input === 'string' ? input : JSON.stringify(input),
288
+ tags ? JSON.stringify(tags) : null,
289
+ maxIterations || null, maxDurationSeconds || null, tokenBudget || null,
290
+ instanceFolder || null, now, now
291
+ );
292
+ return id;
293
+ }
294
+
295
+ /**
296
+ * @param {string} taskId
297
+ * @returns {Object|null}
298
+ */
299
+ function getTask(taskId) {
300
+ const row = getDb().prepare(`
301
+ SELECT t.*, (SELECT COUNT(*) FROM task_events WHERE task_id = t.id) AS event_count
302
+ FROM tasks t WHERE t.id = ?
303
+ `).get(taskId);
304
+ if (!row) return null;
305
+ return parseTaskRow(row);
306
+ }
307
+
308
+ /**
309
+ * @param {{ instanceFolder?: string, agentName?: string, status?: string, priority?: string, tags?: string, limit?: number, cursor?: string }} opts
310
+ * @returns {Object[]}
311
+ */
312
+ function listTasks({ instanceFolder, agentName, status, priority, limit = 20, cursor } = {}) {
313
+ const db = getDb();
314
+ let query = 'SELECT * FROM tasks WHERE 1=1';
315
+ const params = [];
316
+ if (instanceFolder) { query += ' AND instance_folder = ?'; params.push(instanceFolder); }
317
+ if (agentName) { query += ' AND agent_name = ?'; params.push(agentName); }
318
+ if (status) { query += ' AND status = ?'; params.push(status); }
319
+ if (priority) { query += ' AND priority = ?'; params.push(priority); }
320
+ if (cursor) { query += ' AND id < ?'; params.push(cursor); }
321
+ query += ' ORDER BY created_at DESC LIMIT ?';
322
+ params.push(limit);
323
+ return db.prepare(query).all(...params).map(parseTaskRow);
324
+ }
325
+
326
+ /**
327
+ * @param {string} taskId
328
+ * @param {Object} updates
329
+ */
330
+ function updateTask(taskId, updates) {
331
+ const db = getDb();
332
+ const snakeUpdates = {};
333
+ for (const [k, v] of Object.entries(updates)) {
334
+ snakeUpdates[camelToSnake(k)] = v;
335
+ }
336
+ const fields = Object.keys(snakeUpdates).map(k => `${k} = ?`).join(', ');
337
+ const values = Object.values(snakeUpdates);
338
+ db.prepare(`UPDATE tasks SET ${fields}, updated_at = ? WHERE id = ?`)
339
+ .run(...values, new Date().toISOString(), taskId);
340
+ }
341
+
342
+ /**
343
+ * Hard-delete a task and all its associated rows (events, context, subscriptions via CASCADE).
344
+ * @param {string} taskId
345
+ */
346
+ function deleteTask(taskId) {
347
+ getDb().prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
348
+ }
349
+
350
+ // ── Task context (LLM context snapshots for debuggability) ──────────────────────────────────────
351
+
352
+ /**
353
+ * Save a snapshot of the LLM context (messages + tools) for a task.
354
+ * Upserts — always overwrites with the latest snapshot.
355
+ * @param {string} taskId
356
+ * @param {{ messages: Object[], tools: string[], iteration: number }} opts
357
+ */
358
+ function saveTaskContext(taskId, { messages, tools, iteration }) {
359
+ getDb().prepare(`
360
+ INSERT INTO task_context (task_id, messages, tools, iteration, updated_at)
361
+ VALUES (?, ?, ?, ?, ?)
362
+ ON CONFLICT(task_id) DO UPDATE
363
+ SET messages = excluded.messages, tools = excluded.tools, iteration = excluded.iteration, updated_at = excluded.updated_at
364
+ `).run(taskId, JSON.stringify(messages), JSON.stringify(tools || []), iteration, new Date().toISOString());
365
+ }
366
+
367
+ /**
368
+ * Get the most recent LLM context snapshot for a task.
369
+ * @param {string} taskId
370
+ * @returns {Object|null}
371
+ */
372
+ function getTaskContext(taskId) {
373
+ const row = getDb().prepare('SELECT * FROM task_context WHERE task_id = ?').get(taskId);
374
+ if (!row) return null;
375
+ return {
376
+ messages: JSON.parse(row.messages),
377
+ tools: JSON.parse(row.tools || '[]'),
378
+ iteration: row.iteration,
379
+ updatedAt: row.updated_at,
380
+ };
381
+ }
382
+
383
+ // ── Task events ──────────────────────────────────────────────────────────────────
384
+
385
+ /**
386
+ * @param {{ taskId: string, type: string, data?: Object }} opts
387
+ */
388
+ function addTaskEvent({ taskId, type, data }) {
389
+ getDb().prepare(
390
+ 'INSERT INTO task_events (task_id, type, data, created_at) VALUES (?, ?, ?, ?)'
391
+ ).run(taskId, type, data ? JSON.stringify(data) : null, new Date().toISOString());
392
+ }
393
+
394
+ /**
395
+ * @param {string} taskId
396
+ * @returns {Object[]}
397
+ */
398
+ function getTaskEvents(taskId, { since = 0, limit = 500 } = {}) {
399
+ return getDb().prepare(
400
+ 'SELECT * FROM task_events WHERE task_id = ? AND id > ? ORDER BY id ASC LIMIT ?'
401
+ ).all(taskId, since, limit)
402
+ .map(r => ({ ...r, data: r.data ? JSON.parse(r.data) : null }));
403
+ }
404
+
405
+ // ── Todos ────────────────────────────────────────────────────────────────────
406
+
407
+ /**
408
+ * @param {string} sessionId
409
+ * @param {Object[]} items
410
+ */
411
+ function saveTodos(sessionId, items) {
412
+ const db = getDb();
413
+ const now = new Date().toISOString();
414
+ db.prepare(`
415
+ INSERT INTO todos (session_id, items, updated_at) VALUES (?, ?, ?)
416
+ ON CONFLICT(session_id) DO UPDATE SET items = excluded.items, updated_at = excluded.updated_at
417
+ `).run(sessionId, JSON.stringify(items), now);
418
+ }
419
+
420
+ /**
421
+ * @param {string} sessionId
422
+ * @returns {Object[]}
423
+ */
424
+ function getTodos(sessionId) {
425
+ const row = getDb().prepare('SELECT items FROM todos WHERE session_id = ?').get(sessionId);
426
+ return row ? JSON.parse(row.items) : [];
427
+ }
428
+
429
+ // ── Agent messages ────────────────────────────────────────────────────────────
430
+
431
+ /**
432
+ * @param {{ targetAgent: string, targetSessionId?: string, fromAgent: string, content: string, followup?: boolean, skill?: string, correlationId?: string }} opts
433
+ * @returns {string} messageId
434
+ */
435
+ function enqueueAgentMessage({ targetAgent, targetSessionId, fromAgent, content, followup = false, skill, correlationId }) {
436
+ const id = generateId('amsg_');
437
+ const now = new Date().toISOString();
438
+ getDb().prepare(`
439
+ INSERT INTO agent_messages (id, target_agent, target_session_id, from_agent, content, followup, skill, status, correlation_id, created_at)
440
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
441
+ `).run(id, targetAgent, targetSessionId || null, fromAgent, content, followup ? 1 : 0, skill || null, correlationId || null, now);
442
+ return id;
443
+ }
444
+
445
+ /**
446
+ * @param {string} targetAgent
447
+ * @returns {Object[]}
448
+ */
449
+ function getPendingAgentMessages(targetAgent) {
450
+ return getDb().prepare(
451
+ "SELECT * FROM agent_messages WHERE target_agent = ? AND status = 'pending' ORDER BY id ASC"
452
+ ).all(targetAgent);
453
+ }
454
+
455
+ /**
456
+ * @param {string} messageId
457
+ * @param {string} [response]
458
+ */
459
+ function markAgentMessageDelivered(messageId, response) {
460
+ getDb().prepare(
461
+ 'UPDATE agent_messages SET status = ?, response = ?, delivered_at = ? WHERE id = ?'
462
+ ).run('delivered', response || null, new Date().toISOString(), messageId);
463
+ }
464
+
465
+ // ── Task subscriptions ───────────────────────────────────────────────────────
466
+
467
+ /**
468
+ * @param {{ taskId: string, subscriberSessionId: string, subscriberAgent: string }} opts
469
+ */
470
+ function createTaskSubscription({ taskId, subscriberSessionId, subscriberAgent }) {
471
+ getDb().prepare(`
472
+ INSERT INTO task_subscriptions (task_id, subscriber_session_id, subscriber_agent, status, created_at)
473
+ VALUES (?, ?, ?, 'pending', ?)
474
+ `).run(taskId, subscriberSessionId, subscriberAgent, new Date().toISOString());
475
+ }
476
+
477
+ /**
478
+ * @param {string} taskId
479
+ * @returns {Object[]}
480
+ */
481
+ function getPendingSubscriptions(taskId) {
482
+ return getDb().prepare(
483
+ "SELECT * FROM task_subscriptions WHERE task_id = ? AND status = 'pending'"
484
+ ).all(taskId);
485
+ }
486
+
487
+ /**
488
+ * @param {number} subscriptionId
489
+ */
490
+ function markSubscriptionDelivered(subscriptionId) {
491
+ getDb().prepare(
492
+ 'UPDATE task_subscriptions SET status = ?, delivered_at = ? WHERE id = ?'
493
+ ).run('delivered', new Date().toISOString(), subscriptionId);
494
+ }
495
+
496
+ // ── Helpers ──────────────────────────────────────────────────────────────────
497
+
498
+ /**
499
+ * Convert camelCase to snake_case.
500
+ * @param {string} str
501
+ * @returns {string}
502
+ */
503
+ function camelToSnake(str) {
504
+ return str.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`);
505
+ }
506
+
507
+ /**
508
+ * Parse a task row from the DB, JSON-decoding fields.
509
+ * @param {Object} row
510
+ * @returns {Object}
511
+ */
512
+ function parseTaskRow(row) {
513
+ return {
514
+ ...row,
515
+ tags: row.tags ? JSON.parse(row.tags) : [],
516
+ input: row.input ? tryJson(row.input) : null,
517
+ output: row.output || null,
518
+ error: row.error ? tryJson(row.error) : null,
519
+ tokenBudget: row.token_budget ?? null,
520
+ eventCount: row.event_count ?? undefined,
521
+ };
522
+ }
523
+
524
+ function tryJson(str) {
525
+ try { return JSON.parse(str); } catch { return str; }
526
+ }
527
+
528
+ /**
529
+ * Mark pending/processing tasks as failed (used on startup recovery).
530
+ * @param {string} instanceFolder
531
+ */
532
+ function recoverStaleTasks(instanceFolder) {
533
+ const db = getDb();
534
+ const now = new Date().toISOString();
535
+ db.prepare(`
536
+ UPDATE tasks SET status = 'failed', error = 'Recovered: server was restarted', finished_at = ?, updated_at = ?
537
+ WHERE status IN ('processing') AND instance_folder = ?
538
+ `).run(now, now, instanceFolder);
539
+ }
540
+
541
+ module.exports = {
542
+ getDb,
543
+ closeDb,
544
+ runMigrations,
545
+ // Sessions
546
+ createSession, getSession, listSessions, updateSession, closeSession, deleteSession, resetSession,
547
+ // Messages
548
+ addMessage, getMessages,
549
+ // Tasks
550
+ createTask, getTask, listTasks, updateTask, deleteTask,
551
+ // Task context
552
+ saveTaskContext, getTaskContext,
553
+ // Task events
554
+ addTaskEvent, getTaskEvents,
555
+ // Todos
556
+ saveTodos, getTodos,
557
+ // Agent messages
558
+ enqueueAgentMessage, getPendingAgentMessages, markAgentMessageDelivered,
559
+ // Subscriptions
560
+ createTaskSubscription, getPendingSubscriptions, markSubscriptionDelivered,
561
+ // Startup
562
+ recoverStaleTasks,
563
+ };
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ const cron = require('node-cron');
4
+
5
+ /**
6
+ * Daemon scheduler — manages cron jobs for daemon-mode agents.
7
+ * State is in-memory, rebuilt from agent.json on restart.
8
+ */
9
+
10
+ /**
11
+ * Create a new scheduler instance.
12
+ * @returns {Object} Scheduler API
13
+ */
14
+ function createScheduler() {
15
+ const _jobs = new Map(); // agentName → { task: CronTask, lastRun: Date|null, status: string }
16
+
17
+ /**
18
+ * Start a daemon cron job for an agent.
19
+ * @param {{ agentName: string, agent: Object, cwd: string, settings: Object }} opts
20
+ */
21
+ function startDaemon({ agentName, agent, cwd, settings }) {
22
+ if (_jobs.has(agentName)) {
23
+ stopDaemon(agentName);
24
+ }
25
+
26
+ const cronExpr = agent.modes?.daemon?.cron;
27
+ if (!cronExpr) {
28
+ console.warn(`[scheduler] Agent "${agentName}" has no daemon cron schedule`);
29
+ return;
30
+ }
31
+
32
+ if (!cron.validate(cronExpr)) {
33
+ console.error(`[scheduler] Invalid cron schedule for "${agentName}": ${cronExpr}`);
34
+ return;
35
+ }
36
+
37
+ const conflictPolicy = agent.modes?.daemon?.conflictPolicy || 'skip';
38
+ let isRunning = false;
39
+
40
+ const task = cron.schedule(cronExpr, async () => {
41
+ if (isRunning) {
42
+ if (conflictPolicy === 'skip') {
43
+ console.log(`[scheduler] Daemon "${agentName}" tick skipped (still running)`);
44
+ return;
45
+ }
46
+ if (conflictPolicy === 'queue') {
47
+ // Wait for current run to finish — handled by cron itself
48
+ return;
49
+ }
50
+ // 'restart' — cancel current and start new (not easily doable with node-cron, just skip)
51
+ return;
52
+ }
53
+
54
+ isRunning = true;
55
+ const state = _jobs.get(agentName);
56
+ if (state) {
57
+ state.lastRun = new Date();
58
+ state.status = 'running';
59
+ }
60
+
61
+ try {
62
+ const { runDaemonTick } = require('../core/router');
63
+ await runDaemonTick({ agentName, cwd, settings });
64
+ if (state) state.status = 'idle';
65
+ } catch (err) {
66
+ console.error(`[scheduler] Daemon "${agentName}" tick error: ${err.message}`);
67
+ if (state) state.status = 'error';
68
+ } finally {
69
+ isRunning = false;
70
+ }
71
+ });
72
+
73
+ _jobs.set(agentName, {
74
+ task,
75
+ cron: cronExpr,
76
+ lastRun: null,
77
+ status: 'idle',
78
+ agentName,
79
+ });
80
+
81
+ console.log(`[scheduler] Daemon "${agentName}" started (cron: ${cronExpr})`);
82
+ }
83
+
84
+ /**
85
+ * Stop a daemon cron job.
86
+ * @param {string} agentName
87
+ */
88
+ function stopDaemon(agentName) {
89
+ const job = _jobs.get(agentName);
90
+ if (job) {
91
+ job.task.stop();
92
+ _jobs.delete(agentName);
93
+ console.log(`[scheduler] Daemon "${agentName}" stopped`);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Stop all daemon cron jobs.
99
+ */
100
+ function stopAll() {
101
+ for (const agentName of _jobs.keys()) {
102
+ stopDaemon(agentName);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * List all active daemons.
108
+ * @returns {Object[]}
109
+ */
110
+ function listDaemons() {
111
+ return Array.from(_jobs.values()).map(j => ({
112
+ agentName: j.agentName,
113
+ cron: j.cron,
114
+ lastRun: j.lastRun ? j.lastRun.toISOString() : null,
115
+ status: j.status,
116
+ }));
117
+ }
118
+
119
+ return { startDaemon, stopDaemon, stopAll, listDaemons };
120
+ }
121
+
122
+ module.exports = { createScheduler };