@sulala/agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +120 -0
  2. package/bin/sulala.mjs +7 -0
  3. package/dist/agent/loop.d.ts +38 -0
  4. package/dist/agent/loop.d.ts.map +1 -0
  5. package/dist/agent/loop.js +384 -0
  6. package/dist/agent/loop.js.map +1 -0
  7. package/dist/agent/session-queue.d.ts +2 -0
  8. package/dist/agent/session-queue.d.ts.map +1 -0
  9. package/dist/agent/session-queue.js +13 -0
  10. package/dist/agent/session-queue.js.map +1 -0
  11. package/dist/agent/skill-install.d.ts +41 -0
  12. package/dist/agent/skill-install.d.ts.map +1 -0
  13. package/dist/agent/skill-install.js +211 -0
  14. package/dist/agent/skill-install.js.map +1 -0
  15. package/dist/agent/skills-config.d.ts +24 -0
  16. package/dist/agent/skills-config.d.ts.map +1 -0
  17. package/dist/agent/skills-config.js +126 -0
  18. package/dist/agent/skills-config.js.map +1 -0
  19. package/dist/agent/skills-watcher.d.ts +7 -0
  20. package/dist/agent/skills-watcher.d.ts.map +1 -0
  21. package/dist/agent/skills-watcher.js +32 -0
  22. package/dist/agent/skills-watcher.js.map +1 -0
  23. package/dist/agent/skills.d.ts +32 -0
  24. package/dist/agent/skills.d.ts.map +1 -0
  25. package/dist/agent/skills.js +208 -0
  26. package/dist/agent/skills.js.map +1 -0
  27. package/dist/agent/skills.test.d.ts +2 -0
  28. package/dist/agent/skills.test.d.ts.map +1 -0
  29. package/dist/agent/skills.test.js +59 -0
  30. package/dist/agent/skills.test.js.map +1 -0
  31. package/dist/agent/tools.d.ts +8 -0
  32. package/dist/agent/tools.d.ts.map +1 -0
  33. package/dist/agent/tools.js +147 -0
  34. package/dist/agent/tools.js.map +1 -0
  35. package/dist/ai/orchestrator.d.ts +38 -0
  36. package/dist/ai/orchestrator.d.ts.map +1 -0
  37. package/dist/ai/orchestrator.js +360 -0
  38. package/dist/ai/orchestrator.js.map +1 -0
  39. package/dist/ai/orchestrator.test.d.ts +2 -0
  40. package/dist/ai/orchestrator.test.d.ts.map +1 -0
  41. package/dist/ai/orchestrator.test.js +29 -0
  42. package/dist/ai/orchestrator.test.js.map +1 -0
  43. package/dist/cli.d.ts +3 -0
  44. package/dist/cli.d.ts.map +1 -0
  45. package/dist/cli.js +278 -0
  46. package/dist/cli.js.map +1 -0
  47. package/dist/config.d.ts +4 -0
  48. package/dist/config.d.ts.map +1 -0
  49. package/dist/config.js +77 -0
  50. package/dist/config.js.map +1 -0
  51. package/dist/config.test.d.ts +2 -0
  52. package/dist/config.test.d.ts.map +1 -0
  53. package/dist/config.test.js +16 -0
  54. package/dist/config.test.js.map +1 -0
  55. package/dist/db/index.d.ts +42 -0
  56. package/dist/db/index.d.ts.map +1 -0
  57. package/dist/db/index.js +121 -0
  58. package/dist/db/index.js.map +1 -0
  59. package/dist/db/schema.sql +74 -0
  60. package/dist/gateway/server.d.ts +13 -0
  61. package/dist/gateway/server.d.ts.map +1 -0
  62. package/dist/gateway/server.js +566 -0
  63. package/dist/gateway/server.js.map +1 -0
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +78 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/plugins/index.d.ts +52 -0
  69. package/dist/plugins/index.d.ts.map +1 -0
  70. package/dist/plugins/index.js +176 -0
  71. package/dist/plugins/index.js.map +1 -0
  72. package/dist/scheduler/cron.d.ts +8 -0
  73. package/dist/scheduler/cron.d.ts.map +1 -0
  74. package/dist/scheduler/cron.js +31 -0
  75. package/dist/scheduler/cron.js.map +1 -0
  76. package/dist/scheduler/queue.d.ts +13 -0
  77. package/dist/scheduler/queue.d.ts.map +1 -0
  78. package/dist/scheduler/queue.js +75 -0
  79. package/dist/scheduler/queue.js.map +1 -0
  80. package/dist/scheduler/queue.test.d.ts +2 -0
  81. package/dist/scheduler/queue.test.d.ts.map +1 -0
  82. package/dist/scheduler/queue.test.js +41 -0
  83. package/dist/scheduler/queue.test.js.map +1 -0
  84. package/dist/types.d.ts +149 -0
  85. package/dist/types.d.ts.map +1 -0
  86. package/dist/types.js +2 -0
  87. package/dist/types.js.map +1 -0
  88. package/dist/watcher/index.d.ts +15 -0
  89. package/dist/watcher/index.d.ts.map +1 -0
  90. package/dist/watcher/index.js +87 -0
  91. package/dist/watcher/index.js.map +1 -0
  92. package/dist/webhooks.d.ts +2 -0
  93. package/dist/webhooks.d.ts.map +1 -0
  94. package/dist/webhooks.js +38 -0
  95. package/dist/webhooks.js.map +1 -0
  96. package/package.json +62 -0
  97. package/src/db/schema.sql +74 -0
  98. package/src/index.ts +83 -0
@@ -0,0 +1,74 @@
1
+ -- Sulala Agent — SQLite schema
2
+ -- Tasks, Logs, File States, AI Results
3
+
4
+ CREATE TABLE IF NOT EXISTS tasks (
5
+ id TEXT PRIMARY KEY,
6
+ type TEXT NOT NULL,
7
+ payload TEXT,
8
+ status TEXT NOT NULL DEFAULT 'pending',
9
+ scheduled_at INTEGER,
10
+ started_at INTEGER,
11
+ finished_at INTEGER,
12
+ retry_count INTEGER DEFAULT 0,
13
+ max_retries INTEGER DEFAULT 3,
14
+ error TEXT,
15
+ created_at INTEGER NOT NULL,
16
+ updated_at INTEGER NOT NULL
17
+ );
18
+
19
+ CREATE TABLE IF NOT EXISTS logs (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ source TEXT NOT NULL,
22
+ level TEXT NOT NULL,
23
+ message TEXT NOT NULL,
24
+ meta TEXT,
25
+ created_at INTEGER NOT NULL
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS file_states (
29
+ path TEXT PRIMARY KEY,
30
+ mtime_ms INTEGER NOT NULL,
31
+ size INTEGER,
32
+ hash TEXT,
33
+ last_seen INTEGER NOT NULL,
34
+ meta TEXT
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS ai_results (
38
+ id TEXT PRIMARY KEY,
39
+ provider TEXT NOT NULL,
40
+ model TEXT,
41
+ task_id TEXT,
42
+ request_meta TEXT,
43
+ response_meta TEXT,
44
+ created_at INTEGER NOT NULL,
45
+ FOREIGN KEY (task_id) REFERENCES tasks(id)
46
+ );
47
+
48
+ -- Agent runner: sessions and message history
49
+ CREATE TABLE IF NOT EXISTS agent_sessions (
50
+ id TEXT PRIMARY KEY,
51
+ session_key TEXT UNIQUE NOT NULL,
52
+ meta TEXT,
53
+ created_at INTEGER NOT NULL,
54
+ updated_at INTEGER NOT NULL
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS agent_messages (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ session_id TEXT NOT NULL,
60
+ role TEXT NOT NULL,
61
+ content TEXT,
62
+ tool_calls TEXT,
63
+ tool_call_id TEXT,
64
+ name TEXT,
65
+ created_at INTEGER NOT NULL,
66
+ FOREIGN KEY (session_id) REFERENCES agent_sessions(id)
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_agent_messages_session ON agent_messages(session_id);
70
+
71
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
72
+ CREATE INDEX IF NOT EXISTS idx_tasks_scheduled ON tasks(scheduled_at);
73
+ CREATE INDEX IF NOT EXISTS idx_logs_created ON logs(created_at);
74
+ CREATE INDEX IF NOT EXISTS idx_ai_results_created ON ai_results(created_at);
@@ -0,0 +1,13 @@
1
+ import { type Express } from 'express';
2
+ import { createServer } from 'http';
3
+ import { WebSocketServer, type WebSocket } from 'ws';
4
+ export declare function createGateway(appMount?: Express | null): Express;
5
+ export declare function attachWebSocket(server: ReturnType<typeof createServer>, onConnection?: (ws: WebSocket) => void): {
6
+ wss: WebSocketServer;
7
+ broadcast: (data: unknown) => void;
8
+ };
9
+ export declare function startGateway(): {
10
+ app: Express;
11
+ server: ReturnType<typeof createServer>;
12
+ };
13
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/gateway/server.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,KAAK,OAAO,EAA+B,MAAM,SAAS,CAAC;AAC7E,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAIpC,OAAO,EAAE,eAAe,EAAE,KAAK,SAAS,EAAE,MAAM,IAAI,CAAC;AA+CrD,wBAAgB,aAAa,CAAC,QAAQ,GAAE,OAAO,GAAG,IAAW,GAAG,OAAO,CAwetE;AAED,wBAAgB,eAAe,CAC7B,MAAM,EAAE,UAAU,CAAC,OAAO,YAAY,CAAC,EACvC,YAAY,GAAE,CAAC,EAAE,EAAE,SAAS,KAAK,IAAe,GAC/C;IAAE,GAAG,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;CAAE,CAa9D;AAED,wBAAgB,YAAY,IAAI;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,UAAU,CAAC,OAAO,YAAY,CAAC,CAAA;CAAE,CAmBxF"}
@@ -0,0 +1,566 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { WebSocketServer } from 'ws';
7
+ import cors from 'cors';
8
+ import { config } from '../config.js';
9
+ import { initDb, getDb, log, insertTask, getFileStates, updateTaskStatus, setTaskPendingForRetry, getOrCreateAgentSession, getAgentSessionById, getAgentMessages, listAgentSessions, } from '../db/index.js';
10
+ import { runAgentTurn, runAgentTurnStream } from '../agent/loop.js';
11
+ import { listSkills, getAllRequiredBins } from '../agent/skills.js';
12
+ import { getRegistrySkills, getAvailableUpdates, installSkill, uninstallSkill, updateSkillsAll } from '../agent/skill-install.js';
13
+ import { loadSkillsConfig, saveSkillsConfig, getConfigPath, setSkillEnabled } from '../agent/skills-config.js';
14
+ import { withSessionLock } from '../agent/session-queue.js';
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const projectRoot = join(__dirname, '..', '..');
17
+ const dashboardDist = join(projectRoot, 'dashboard', 'dist');
18
+ const registryDir = join(projectRoot, 'registry');
19
+ const rateLimitMap = new Map();
20
+ function rateLimitMiddleware(req, res, next) {
21
+ if (!config.rateLimitMax || config.rateLimitMax <= 0)
22
+ return next();
23
+ const ip = (req.ip || req.socket?.remoteAddress || 'unknown');
24
+ const now = Date.now();
25
+ let entry = rateLimitMap.get(ip);
26
+ if (!entry || now >= entry.resetAt) {
27
+ entry = { count: 0, resetAt: now + config.rateLimitWindowMs };
28
+ rateLimitMap.set(ip, entry);
29
+ }
30
+ entry.count++;
31
+ if (entry.count > config.rateLimitMax) {
32
+ res.status(429).json({ error: 'Too many requests' });
33
+ return;
34
+ }
35
+ next();
36
+ }
37
+ export function createGateway(appMount = null) {
38
+ const app = appMount || express();
39
+ app.use(cors());
40
+ app.use(express.json());
41
+ app.use(rateLimitMiddleware);
42
+ if (config.gatewayApiKey) {
43
+ app.use((req, res, next) => {
44
+ if (req.path === '/health')
45
+ return next();
46
+ const key = req.headers['x-api-key'] || req.query.api_key;
47
+ if (key === config.gatewayApiKey)
48
+ return next();
49
+ res.status(401).json({ error: 'Invalid or missing API key' });
50
+ });
51
+ }
52
+ /** SulalaHub: public skills registry. Set SKILLS_REGISTRY_URL to use this. */
53
+ app.get('/api/sulalahub/registry', (_req, res) => {
54
+ try {
55
+ const baseUrl = process.env.SULALAHUB_BASE_URL || `http://${config.host}:${config.port}`;
56
+ const registryPath = join(registryDir, 'skills-registry.json');
57
+ if (!existsSync(registryPath)) {
58
+ res.json({ skills: [] });
59
+ return;
60
+ }
61
+ const data = JSON.parse(readFileSync(registryPath, 'utf8'));
62
+ const skills = (data.skills || []).map((s) => ({
63
+ ...s,
64
+ url: `${baseUrl}/api/sulalahub/skills/${s.slug}`,
65
+ }));
66
+ res.json({ skills });
67
+ }
68
+ catch (e) {
69
+ log('gateway', 'error', e.message);
70
+ res.status(500).json({ error: e.message });
71
+ }
72
+ });
73
+ app.get('/api/sulalahub/skills/:slug', (req, res) => {
74
+ try {
75
+ const slug = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
76
+ if (!slug || /[^a-z0-9-]/.test(slug)) {
77
+ res.status(400).json({ error: 'Invalid slug' });
78
+ return;
79
+ }
80
+ const path = join(registryDir, `${slug}.md`);
81
+ if (!existsSync(path)) {
82
+ res.status(404).json({ error: `Skill not found: ${slug}` });
83
+ return;
84
+ }
85
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
86
+ res.send(readFileSync(path, 'utf8'));
87
+ }
88
+ catch (e) {
89
+ log('gateway', 'error', e.message);
90
+ res.status(500).json({ error: e.message });
91
+ }
92
+ });
93
+ app.get('/health', (_req, res) => {
94
+ res.json({ status: 'ok', service: 'sulala-gateway' });
95
+ });
96
+ app.get('/api/tasks', (req, res) => {
97
+ try {
98
+ const db = getDb();
99
+ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
100
+ const rows = db.prepare('SELECT * FROM tasks ORDER BY created_at DESC LIMIT ?').all(limit);
101
+ res.json({ tasks: rows });
102
+ }
103
+ catch (e) {
104
+ log('gateway', 'error', e.message, { stack: e.stack });
105
+ res.status(500).json({ error: e.message });
106
+ }
107
+ });
108
+ app.post('/api/tasks', (req, res) => {
109
+ try {
110
+ const { type, payload, scheduled_at } = req.body || {};
111
+ if (!type) {
112
+ res.status(400).json({ error: 'type required' });
113
+ return;
114
+ }
115
+ const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
116
+ insertTask({ id, type, payload: payload ?? null, scheduled_at: scheduled_at ?? null });
117
+ log('gateway', 'info', 'Task enqueued', { id, type });
118
+ res.status(201).json({ id, type, status: 'pending' });
119
+ }
120
+ catch (e) {
121
+ log('gateway', 'error', e.message, { stack: e.stack });
122
+ res.status(500).json({ error: e.message });
123
+ }
124
+ });
125
+ app.get('/api/logs', (req, res) => {
126
+ try {
127
+ const db = getDb();
128
+ const limit = Math.min(parseInt(req.query.limit || '100', 10), 500);
129
+ const rows = db.prepare('SELECT * FROM logs ORDER BY created_at DESC LIMIT ?').all(limit);
130
+ res.json({ logs: rows });
131
+ }
132
+ catch (e) {
133
+ log('gateway', 'error', e.message);
134
+ res.status(500).json({ error: e.message });
135
+ }
136
+ });
137
+ app.get('/api/file-states', (req, res) => {
138
+ try {
139
+ const limit = Math.min(parseInt(req.query.limit || '200', 10), 500);
140
+ const rows = getFileStates(limit);
141
+ res.json({ fileStates: rows });
142
+ }
143
+ catch (e) {
144
+ log('gateway', 'error', e.message);
145
+ res.status(500).json({ error: e.message });
146
+ }
147
+ });
148
+ app.get('/api/config', (_req, res) => {
149
+ try {
150
+ res.json({
151
+ watchFolders: config.watchFolders || [],
152
+ aiProviders: [
153
+ { id: 'openai', label: 'OpenAI', defaultModel: process.env.AI_OPENAI_DEFAULT_MODEL || 'gpt-4o-mini' },
154
+ { id: 'openrouter', label: 'OpenRouter', defaultModel: process.env.AI_OPENROUTER_DEFAULT_MODEL || 'openai/gpt-4o-mini' },
155
+ { id: 'claude', label: 'Claude', defaultModel: process.env.AI_CLAUDE_DEFAULT_MODEL || 'claude-sonnet-4-6' },
156
+ { id: 'gemini', label: 'Gemini', defaultModel: process.env.AI_GEMINI_DEFAULT_MODEL || 'gemini-2.5-flash' },
157
+ { id: 'ollama', label: 'Ollama', defaultModel: process.env.AI_OLLAMA_DEFAULT_MODEL || 'llama3.2' },
158
+ ],
159
+ });
160
+ }
161
+ catch (e) {
162
+ log('gateway', 'error', e.message);
163
+ res.status(500).json({ error: e.message });
164
+ }
165
+ });
166
+ app.get('/api/agent/skills/required-bins', (_req, res) => {
167
+ try {
168
+ const bins = getAllRequiredBins(config);
169
+ res.json({ bins });
170
+ }
171
+ catch (e) {
172
+ log('gateway', 'error', e.message);
173
+ res.status(500).json({ error: e.message });
174
+ }
175
+ });
176
+ app.get('/api/agent/skills', (_req, res) => {
177
+ try {
178
+ const skills = listSkills(config, { includeDisabled: true });
179
+ res.json({ skills });
180
+ }
181
+ catch (e) {
182
+ log('gateway', 'error', e.message);
183
+ res.status(500).json({ error: e.message });
184
+ }
185
+ });
186
+ app.get('/api/agent/skills/config', (_req, res) => {
187
+ try {
188
+ const cfg = loadSkillsConfig();
189
+ res.json({ skills: cfg, configPath: getConfigPath() });
190
+ }
191
+ catch (e) {
192
+ log('gateway', 'error', e.message);
193
+ res.status(500).json({ error: e.message });
194
+ }
195
+ });
196
+ app.put('/api/agent/skills/config', (req, res) => {
197
+ try {
198
+ const { skills } = req.body || {};
199
+ if (skills && typeof skills === 'object')
200
+ saveSkillsConfig(skills);
201
+ res.json({ skills: loadSkillsConfig(), configPath: getConfigPath() });
202
+ }
203
+ catch (e) {
204
+ log('gateway', 'error', e.message);
205
+ res.status(500).json({ error: e.message });
206
+ }
207
+ });
208
+ app.get('/api/agent/skills/updates', async (_req, res) => {
209
+ try {
210
+ const updates = await getAvailableUpdates();
211
+ res.json({ updates });
212
+ }
213
+ catch (e) {
214
+ log('gateway', 'error', e.message);
215
+ res.status(500).json({ error: e.message });
216
+ }
217
+ });
218
+ app.get('/api/agent/skills/registry', async (_req, res) => {
219
+ try {
220
+ const skills = await getRegistrySkills();
221
+ res.json({ skills });
222
+ }
223
+ catch (e) {
224
+ log('gateway', 'error', e.message);
225
+ res.status(500).json({ error: e.message });
226
+ }
227
+ });
228
+ app.post('/api/agent/skills/update', async (_req, res) => {
229
+ try {
230
+ const result = await updateSkillsAll();
231
+ res.json(result);
232
+ }
233
+ catch (e) {
234
+ log('gateway', 'error', e.message);
235
+ res.status(500).json({ error: e.message });
236
+ }
237
+ });
238
+ app.post('/api/agent/skills/uninstall', (req, res) => {
239
+ try {
240
+ const { slug, target } = req.body || {};
241
+ if (!slug || typeof slug !== 'string') {
242
+ res.status(400).json({ error: 'slug required' });
243
+ return;
244
+ }
245
+ const t = target === 'managed' ? 'managed' : 'workspace';
246
+ const result = uninstallSkill(slug, t);
247
+ if (result.success) {
248
+ res.json({ uninstalled: slug, path: result.path, target: t });
249
+ }
250
+ else {
251
+ res.status(400).json({ error: result.error || 'Uninstall failed' });
252
+ }
253
+ }
254
+ catch (e) {
255
+ log('gateway', 'error', e.message);
256
+ res.status(500).json({ error: e.message });
257
+ }
258
+ });
259
+ app.post('/api/agent/skills/install', async (req, res) => {
260
+ try {
261
+ const { slug, target, registryUrl } = req.body || {};
262
+ if (!slug || typeof slug !== 'string') {
263
+ res.status(400).json({ error: 'slug required' });
264
+ return;
265
+ }
266
+ const t = target === 'managed' ? 'managed' : 'workspace';
267
+ const result = await installSkill(slug, t, { registryUrl: typeof registryUrl === 'string' ? registryUrl : undefined });
268
+ if (result.success) {
269
+ setSkillEnabled(slug, true);
270
+ res.status(201).json({ installed: slug, path: result.path, target: t });
271
+ }
272
+ else {
273
+ res.status(400).json({ error: result.error || 'Install failed' });
274
+ }
275
+ }
276
+ catch (e) {
277
+ log('gateway', 'error', e.message);
278
+ res.status(500).json({ error: e.message });
279
+ }
280
+ });
281
+ /** List models for a provider. For openrouter, proxies OpenRouter Models API (id + name). */
282
+ app.get('/api/agent/models', async (req, res) => {
283
+ const provider = req.query.provider || '';
284
+ if (provider !== 'openrouter') {
285
+ res.json({ models: [] });
286
+ return;
287
+ }
288
+ try {
289
+ const key = process.env.OPENROUTER_API_KEY;
290
+ const headers = { Accept: 'application/json' };
291
+ if (key)
292
+ headers['Authorization'] = `Bearer ${key}`;
293
+ const r = await fetch('https://openrouter.ai/api/v1/models', { headers });
294
+ if (!r.ok) {
295
+ res.status(r.status).json({ error: `OpenRouter models: ${r.status}`, models: [] });
296
+ return;
297
+ }
298
+ const data = (await r.json());
299
+ const models = (data.data || []).map((m) => ({ id: m.id, name: m.name || m.id }));
300
+ res.json({ models });
301
+ }
302
+ catch (e) {
303
+ log('gateway', 'error', e.message);
304
+ res.status(500).json({ error: e.message, models: [] });
305
+ }
306
+ });
307
+ app.post('/api/complete', async (req, res) => {
308
+ try {
309
+ const { provider, model, messages, max_tokens } = req.body || {};
310
+ if (!messages || !Array.isArray(messages)) {
311
+ res.status(400).json({ error: 'messages array required' });
312
+ return;
313
+ }
314
+ const { complete } = await import('../ai/orchestrator.js');
315
+ const result = await complete({ provider, model, messages, max_tokens: max_tokens || 1024 });
316
+ res.json(result);
317
+ }
318
+ catch (e) {
319
+ log('gateway', 'error', e.message, { stack: e.stack });
320
+ res.status(500).json({ error: e.message });
321
+ }
322
+ });
323
+ // --- Agent runner (sessions + tool loop) ---
324
+ app.get('/api/agent/sessions', (req, res) => {
325
+ try {
326
+ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
327
+ const sessions = listAgentSessions(limit);
328
+ res.json({ sessions });
329
+ }
330
+ catch (e) {
331
+ log('gateway', 'error', e.message);
332
+ res.status(500).json({ error: e.message });
333
+ }
334
+ });
335
+ app.post('/api/agent/sessions', (req, res) => {
336
+ try {
337
+ const sessionKey = req.body?.session_key || `default_${Date.now()}`;
338
+ const meta = req.body?.meta;
339
+ const session = getOrCreateAgentSession(sessionKey, meta);
340
+ res.status(201).json(session);
341
+ }
342
+ catch (e) {
343
+ log('gateway', 'error', e.message);
344
+ res.status(500).json({ error: e.message });
345
+ }
346
+ });
347
+ app.get('/api/agent/sessions/:id', (req, res) => {
348
+ try {
349
+ const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
350
+ if (!id) {
351
+ res.status(400).json({ error: 'Session id required' });
352
+ return;
353
+ }
354
+ const session = getAgentSessionById(id);
355
+ if (!session) {
356
+ res.status(404).json({ error: 'Session not found' });
357
+ return;
358
+ }
359
+ const rows = getAgentMessages(id);
360
+ const messages = rows.map((r) => {
361
+ const msg = { role: r.role, content: r.content, tool_call_id: r.tool_call_id, name: r.name, created_at: r.created_at };
362
+ if (r.tool_calls) {
363
+ try {
364
+ msg.tool_calls = JSON.parse(r.tool_calls);
365
+ }
366
+ catch {
367
+ msg.tool_calls = [];
368
+ }
369
+ }
370
+ return msg;
371
+ });
372
+ res.json({ ...session, messages });
373
+ }
374
+ catch (e) {
375
+ log('gateway', 'error', e.message);
376
+ res.status(500).json({ error: e.message });
377
+ }
378
+ });
379
+ app.post('/api/agent/sessions/:id/messages', async (req, res) => {
380
+ const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
381
+ if (!id) {
382
+ res.status(400).json({ error: 'Session id required' });
383
+ return;
384
+ }
385
+ const session = getAgentSessionById(id);
386
+ if (!session) {
387
+ res.status(404).json({ error: 'Session not found' });
388
+ return;
389
+ }
390
+ const controller = new AbortController();
391
+ req.on('close', () => controller.abort());
392
+ const { message, system_prompt, provider, model, max_tokens, timeout_ms } = req.body || {};
393
+ const timeoutMs = typeof timeout_ms === 'number' ? timeout_ms : config.agentTimeoutMs || 0;
394
+ try {
395
+ const result = await withSessionLock(id, () => runAgentTurn({
396
+ sessionId: id,
397
+ userMessage: typeof message === 'string' ? message : null,
398
+ systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
399
+ provider: typeof provider === 'string' ? provider : undefined,
400
+ model: typeof model === 'string' ? model : undefined,
401
+ max_tokens: typeof max_tokens === 'number' ? max_tokens : undefined,
402
+ timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
403
+ signal: controller.signal,
404
+ }));
405
+ res.json(result);
406
+ }
407
+ catch (e) {
408
+ const err = e;
409
+ if (err.name === 'AbortError') {
410
+ res.status(499).json({ error: 'Client closed request or run timed out' });
411
+ return;
412
+ }
413
+ log('gateway', 'error', err.message, { stack: err.stack });
414
+ res.status(500).json({ error: err.message });
415
+ }
416
+ });
417
+ app.post('/api/agent/sessions/:id/messages/stream', async (req, res) => {
418
+ const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
419
+ if (!id) {
420
+ res.status(400).json({ error: 'Session id required' });
421
+ return;
422
+ }
423
+ const session = getAgentSessionById(id);
424
+ if (!session) {
425
+ res.status(404).json({ error: 'Session not found' });
426
+ return;
427
+ }
428
+ res.setHeader('Content-Type', 'text/event-stream');
429
+ res.setHeader('Cache-Control', 'no-cache');
430
+ res.setHeader('Connection', 'keep-alive');
431
+ res.flushHeaders?.();
432
+ const send = (event, data) => {
433
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
434
+ res.flush?.();
435
+ };
436
+ const controller = new AbortController();
437
+ req.on('close', () => controller.abort());
438
+ const { message, system_prompt, provider, model, max_tokens, timeout_ms } = req.body || {};
439
+ const timeoutMs = typeof timeout_ms === 'number' ? timeout_ms : config.agentTimeoutMs || 0;
440
+ try {
441
+ await withSessionLock(id, () => runAgentTurnStream({
442
+ sessionId: id,
443
+ userMessage: typeof message === 'string' ? message : null,
444
+ systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
445
+ provider: typeof provider === 'string' ? provider : undefined,
446
+ model: typeof model === 'string' ? model : undefined,
447
+ max_tokens: typeof max_tokens === 'number' ? max_tokens : undefined,
448
+ timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
449
+ signal: controller.signal,
450
+ }, (ev) => {
451
+ if (ev.type === 'assistant')
452
+ send('assistant', { delta: ev.delta });
453
+ else if (ev.type === 'tool_call')
454
+ send('tool_call', { name: ev.name, result: ev.result });
455
+ else if (ev.type === 'done')
456
+ send('done', { finalContent: ev.finalContent, turnCount: ev.turnCount });
457
+ else if (ev.type === 'error')
458
+ send('error', { message: ev.message });
459
+ }));
460
+ }
461
+ catch (e) {
462
+ const err = e;
463
+ if (err.name === 'AbortError')
464
+ send('error', { message: 'Client closed request or run timed out' });
465
+ else {
466
+ log('gateway', 'error', err.message, { stack: err.stack });
467
+ send('error', { message: err.message });
468
+ }
469
+ }
470
+ finally {
471
+ res.end();
472
+ }
473
+ });
474
+ app.patch('/api/tasks/:id', (req, res) => {
475
+ try {
476
+ const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
477
+ if (!id) {
478
+ res.status(400).json({ error: 'Task id required' });
479
+ return;
480
+ }
481
+ const { action } = req.body || {};
482
+ const db = getDb();
483
+ const row = db.prepare('SELECT id, status FROM tasks WHERE id = ?').get(id);
484
+ if (!row) {
485
+ res.status(404).json({ error: 'Task not found' });
486
+ return;
487
+ }
488
+ if (action === 'cancel') {
489
+ if (row.status !== 'pending' && row.status !== 'running') {
490
+ res.status(400).json({ error: 'Task cannot be cancelled' });
491
+ return;
492
+ }
493
+ updateTaskStatus(id, 'cancelled');
494
+ log('gateway', 'info', 'Task cancelled', { id });
495
+ res.json({ id, status: 'cancelled' });
496
+ return;
497
+ }
498
+ if (action === 'retry') {
499
+ if (row.status !== 'failed') {
500
+ res.status(400).json({ error: 'Only failed tasks can be retried' });
501
+ return;
502
+ }
503
+ setTaskPendingForRetry(id);
504
+ const enqueueTaskId = app.locals.enqueueTaskId;
505
+ if (typeof enqueueTaskId === 'function')
506
+ enqueueTaskId(id);
507
+ log('gateway', 'info', 'Task retry enqueued', { id });
508
+ res.json({ id, status: 'pending' });
509
+ return;
510
+ }
511
+ res.status(400).json({ error: 'action required: cancel or retry' });
512
+ }
513
+ catch (e) {
514
+ log('gateway', 'error', e.message);
515
+ res.status(500).json({ error: e.message });
516
+ }
517
+ });
518
+ if (existsSync(dashboardDist)) {
519
+ app.use(express.static(dashboardDist));
520
+ app.get(/^\/(?!api|health|ws)/, (_req, res) => {
521
+ res.sendFile(join(dashboardDist, 'index.html'));
522
+ });
523
+ }
524
+ return app;
525
+ }
526
+ export function attachWebSocket(server, onConnection = () => { }) {
527
+ const wss = new WebSocketServer({ server, path: '/ws' });
528
+ const broadcast = (data) => {
529
+ const msg = typeof data === 'string' ? data : JSON.stringify(data);
530
+ wss.clients.forEach((c) => {
531
+ if (c.readyState === 1)
532
+ c.send(msg);
533
+ });
534
+ };
535
+ wss.on('connection', (ws) => {
536
+ onConnection(ws);
537
+ ws.send(JSON.stringify({ type: 'connected', ts: Date.now() }));
538
+ });
539
+ return { wss, broadcast };
540
+ }
541
+ export function startGateway() {
542
+ initDb(config.dbPath);
543
+ const app = createGateway();
544
+ const server = createServer(app);
545
+ const { broadcast } = attachWebSocket(server, (ws) => {
546
+ ws.on('message', (raw) => {
547
+ try {
548
+ const data = JSON.parse(raw.toString());
549
+ if (data.type === 'ping')
550
+ ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
551
+ }
552
+ catch {
553
+ // ignore
554
+ }
555
+ });
556
+ });
557
+ app.locals.wsBroadcast = broadcast;
558
+ server.listen(config.port, config.host, () => {
559
+ console.log(`Sulala gateway http://${config.host}:${config.port} (WS /ws)`);
560
+ });
561
+ return { app, server };
562
+ }
563
+ if (process.argv[1]?.includes('server')) {
564
+ startGateway();
565
+ }
566
+ //# sourceMappingURL=server.js.map