@sulala/agent 0.1.6 → 0.1.8

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 (173) hide show
  1. package/README.md +42 -27
  2. package/context/airtable.md +35 -0
  3. package/context/asana.md +37 -0
  4. package/context/bluesky.md +26 -91
  5. package/context/calendar.md +63 -0
  6. package/context/country-info.md +13 -0
  7. package/context/create-skill.md +128 -0
  8. package/context/discord.md +30 -0
  9. package/context/docs.md +29 -0
  10. package/context/drive.md +49 -0
  11. package/context/dropbox.md +39 -0
  12. package/context/facebook.md +47 -0
  13. package/context/fetch-form-api.md +16 -0
  14. package/context/figma.md +30 -0
  15. package/context/github.md +58 -0
  16. package/context/gmail.md +52 -0
  17. package/context/google.md +28 -0
  18. package/context/hellohub.md +29 -0
  19. package/context/jira.md +46 -0
  20. package/context/linear.md +40 -0
  21. package/context/notion.md +45 -0
  22. package/context/portal-integrations.md +42 -0
  23. package/context/post-to-x.md +50 -0
  24. package/context/sheets.md +47 -0
  25. package/context/slack.md +48 -0
  26. package/context/slides.md +35 -0
  27. package/context/stripe.md +38 -0
  28. package/context/tes.md +7 -0
  29. package/context/test.md +7 -0
  30. package/context/zoom.md +28 -0
  31. package/dist/agent/google/calendar.d.ts +2 -0
  32. package/dist/agent/google/calendar.d.ts.map +1 -0
  33. package/dist/agent/google/calendar.js +119 -0
  34. package/dist/agent/google/calendar.js.map +1 -0
  35. package/dist/agent/google/drive.d.ts +2 -0
  36. package/dist/agent/google/drive.d.ts.map +1 -0
  37. package/dist/agent/google/drive.js +51 -0
  38. package/dist/agent/google/drive.js.map +1 -0
  39. package/dist/agent/google/get-token.d.ts +7 -0
  40. package/dist/agent/google/get-token.d.ts.map +1 -0
  41. package/dist/agent/google/get-token.js +37 -0
  42. package/dist/agent/google/get-token.js.map +1 -0
  43. package/dist/agent/google/gmail.d.ts +2 -0
  44. package/dist/agent/google/gmail.d.ts.map +1 -0
  45. package/dist/agent/google/gmail.js +138 -0
  46. package/dist/agent/google/gmail.js.map +1 -0
  47. package/dist/agent/google/index.d.ts +2 -0
  48. package/dist/agent/google/index.d.ts.map +1 -0
  49. package/dist/agent/google/index.js +13 -0
  50. package/dist/agent/google/index.js.map +1 -0
  51. package/dist/agent/loop.d.ts +8 -0
  52. package/dist/agent/loop.d.ts.map +1 -1
  53. package/dist/agent/loop.js +226 -40
  54. package/dist/agent/loop.js.map +1 -1
  55. package/dist/agent/memory.d.ts +21 -0
  56. package/dist/agent/memory.d.ts.map +1 -0
  57. package/dist/agent/memory.js +33 -0
  58. package/dist/agent/memory.js.map +1 -0
  59. package/dist/agent/pending-actions.d.ts +21 -0
  60. package/dist/agent/pending-actions.d.ts.map +1 -0
  61. package/dist/agent/pending-actions.js +65 -0
  62. package/dist/agent/pending-actions.js.map +1 -0
  63. package/dist/agent/pi-runner.d.ts +27 -0
  64. package/dist/agent/pi-runner.d.ts.map +1 -0
  65. package/dist/agent/pi-runner.js +300 -0
  66. package/dist/agent/pi-runner.js.map +1 -0
  67. package/dist/agent/skill-generate.d.ts +63 -0
  68. package/dist/agent/skill-generate.d.ts.map +1 -0
  69. package/dist/agent/skill-generate.js +128 -0
  70. package/dist/agent/skill-generate.js.map +1 -0
  71. package/dist/agent/skill-install.d.ts.map +1 -1
  72. package/dist/agent/skill-install.js +80 -31
  73. package/dist/agent/skill-install.js.map +1 -1
  74. package/dist/agent/skill-templates.d.ts +17 -0
  75. package/dist/agent/skill-templates.d.ts.map +1 -0
  76. package/dist/agent/skill-templates.js +26 -0
  77. package/dist/agent/skill-templates.js.map +1 -0
  78. package/dist/agent/skills-config.d.ts +24 -2
  79. package/dist/agent/skills-config.d.ts.map +1 -1
  80. package/dist/agent/skills-config.js +108 -9
  81. package/dist/agent/skills-config.js.map +1 -1
  82. package/dist/agent/skills-watcher.js +1 -1
  83. package/dist/agent/skills.d.ts +9 -3
  84. package/dist/agent/skills.d.ts.map +1 -1
  85. package/dist/agent/skills.js +104 -9
  86. package/dist/agent/skills.js.map +1 -1
  87. package/dist/agent/tools.d.ts +25 -3
  88. package/dist/agent/tools.d.ts.map +1 -1
  89. package/dist/agent/tools.integrations.test.d.ts +2 -0
  90. package/dist/agent/tools.integrations.test.d.ts.map +1 -0
  91. package/dist/agent/tools.integrations.test.js +269 -0
  92. package/dist/agent/tools.integrations.test.js.map +1 -0
  93. package/dist/agent/tools.js +692 -39
  94. package/dist/agent/tools.js.map +1 -1
  95. package/dist/ai/orchestrator.d.ts +6 -1
  96. package/dist/ai/orchestrator.d.ts.map +1 -1
  97. package/dist/ai/orchestrator.js +499 -212
  98. package/dist/ai/orchestrator.js.map +1 -1
  99. package/dist/ai/pricing.d.ts +6 -0
  100. package/dist/ai/pricing.d.ts.map +1 -0
  101. package/dist/ai/pricing.js +39 -0
  102. package/dist/ai/pricing.js.map +1 -0
  103. package/dist/channels/discord.d.ts +15 -0
  104. package/dist/channels/discord.d.ts.map +1 -0
  105. package/dist/channels/discord.js +55 -0
  106. package/dist/channels/discord.js.map +1 -0
  107. package/dist/channels/stripe.d.ts +15 -0
  108. package/dist/channels/stripe.d.ts.map +1 -0
  109. package/dist/channels/stripe.js +58 -0
  110. package/dist/channels/stripe.js.map +1 -0
  111. package/dist/channels/telegram.d.ts +60 -0
  112. package/dist/channels/telegram.d.ts.map +1 -0
  113. package/dist/channels/telegram.js +562 -0
  114. package/dist/channels/telegram.js.map +1 -0
  115. package/dist/cli.js +69 -11
  116. package/dist/cli.js.map +1 -1
  117. package/dist/config.d.ts +14 -0
  118. package/dist/config.d.ts.map +1 -1
  119. package/dist/config.js +91 -2
  120. package/dist/config.js.map +1 -1
  121. package/dist/db/index.d.ts +83 -0
  122. package/dist/db/index.d.ts.map +1 -1
  123. package/dist/db/index.js +174 -2
  124. package/dist/db/index.js.map +1 -1
  125. package/dist/db/schema.sql +35 -0
  126. package/dist/gateway/server.d.ts.map +1 -1
  127. package/dist/gateway/server.js +1224 -29
  128. package/dist/gateway/server.js.map +1 -1
  129. package/dist/index.js +149 -6
  130. package/dist/index.js.map +1 -1
  131. package/dist/ollama-setup.d.ts +27 -0
  132. package/dist/ollama-setup.d.ts.map +1 -0
  133. package/dist/ollama-setup.js +191 -0
  134. package/dist/ollama-setup.js.map +1 -0
  135. package/dist/onboard-env.d.ts +1 -1
  136. package/dist/onboard-env.d.ts.map +1 -1
  137. package/dist/onboard-env.js +3 -0
  138. package/dist/onboard-env.js.map +1 -1
  139. package/dist/onboard.d.ts +3 -1
  140. package/dist/onboard.d.ts.map +1 -1
  141. package/dist/onboard.js +9 -4
  142. package/dist/onboard.js.map +1 -1
  143. package/dist/plugins/index.d.ts +10 -0
  144. package/dist/plugins/index.d.ts.map +1 -1
  145. package/dist/plugins/index.js +32 -0
  146. package/dist/plugins/index.js.map +1 -1
  147. package/dist/redact.d.ts +15 -0
  148. package/dist/redact.d.ts.map +1 -0
  149. package/dist/redact.js +56 -0
  150. package/dist/redact.js.map +1 -0
  151. package/dist/scheduler/cron.d.ts +21 -0
  152. package/dist/scheduler/cron.d.ts.map +1 -1
  153. package/dist/scheduler/cron.js +60 -0
  154. package/dist/scheduler/cron.js.map +1 -1
  155. package/dist/system-capabilities.d.ts +11 -0
  156. package/dist/system-capabilities.d.ts.map +1 -0
  157. package/dist/system-capabilities.js +109 -0
  158. package/dist/system-capabilities.js.map +1 -0
  159. package/dist/types.d.ts +62 -3
  160. package/dist/types.d.ts.map +1 -1
  161. package/dist/watcher/index.d.ts +2 -0
  162. package/dist/watcher/index.d.ts.map +1 -1
  163. package/dist/watcher/index.js +31 -1
  164. package/dist/watcher/index.js.map +1 -1
  165. package/dist/workspace-automations.d.ts +16 -0
  166. package/dist/workspace-automations.d.ts.map +1 -0
  167. package/dist/workspace-automations.js +133 -0
  168. package/dist/workspace-automations.js.map +1 -0
  169. package/package.json +19 -3
  170. package/registry/bluesky.md +12 -89
  171. package/registry/skills-registry.json +6 -0
  172. package/src/db/schema.sql +35 -0
  173. package/src/index.ts +159 -6
@@ -1,18 +1,32 @@
1
1
  import express from 'express';
2
2
  import { createServer } from 'http';
3
- import { existsSync, readFileSync } from 'fs';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
4
4
  import { join, dirname } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
+ import { randomBytes } from 'crypto';
6
7
  import { WebSocketServer } from 'ws';
7
8
  import cors from 'cors';
8
- import { config, getSulalaEnvPath } from '../config.js';
9
+ import { config, getSulalaEnvPath, getPortalGatewayBase, getEffectivePortalApiKey } from '../config.js';
9
10
  import { readOnboardEnvKeys, writeOnboardEnvKeys, ONBOARD_ENV_WHITELIST, } from '../onboard-env.js';
10
- import { initDb, getDb, log, insertTask, getFileStates, updateTaskStatus, setTaskPendingForRetry, getOrCreateAgentSession, getAgentSessionById, getAgentMessages, listAgentSessions, } from '../db/index.js';
11
+ import { initDb, getDb, log, insertTask, getFileStates, updateTaskStatus, setTaskPendingForRetry, getOrCreateAgentSession, getAgentSessionById, getAgentMessages, appendAgentMessage, updateAgentMessageToolResult, listAgentMemories, listAgentMemoryScopeKeys, listAgentSessions, listScheduledJobs, getScheduledJob, insertScheduledJob, updateScheduledJob, deleteScheduledJob, getTasksForJob, getChannelConfig, setChannelConfig, } from '../db/index.js';
12
+ import { scheduleCronById, unscheduleJob } from '../scheduler/cron.js';
13
+ import { getTelegramChannelState, setTelegramChannelConfig } from '../channels/telegram.js';
14
+ import { getDiscordChannelState, setDiscordChannelConfig } from '../channels/discord.js';
15
+ import { getStripeChannelState, setStripeChannelConfig } from '../channels/stripe.js';
16
+ import { reloadProviders } from '../ai/orchestrator.js';
11
17
  import { runAgentTurn, runAgentTurnStream } from '../agent/loop.js';
18
+ import { runAgentTurnWithPi, isPiAvailable } from '../agent/pi-runner.js';
19
+ import { listTools, executeTool } from '../agent/tools.js';
12
20
  import { listSkills, getAllRequiredBins } from '../agent/skills.js';
13
21
  import { getRegistrySkills, getAvailableUpdates, installSkill, uninstallSkill, updateSkillsAll } from '../agent/skill-install.js';
14
- import { loadSkillsConfig, saveSkillsConfig, getConfigPath, setSkillEnabled } from '../agent/skills-config.js';
22
+ import { getTemplates } from '../agent/skill-templates.js';
23
+ import { generateSkillSpec, writeGeneratedSkill, WIZARD_APPS, WIZARD_TRIGGERS } from '../agent/skill-generate.js';
24
+ import { loadSkillsConfig, saveSkillsConfig, getConfigPath, setSkillEnabled, removeSkillEntry, migrateConfigNameKeysToSlug, getOnboardingComplete, setOnboardingComplete } from '../agent/skills-config.js';
25
+ import { redactSkillsConfig } from '../redact.js';
15
26
  import { withSessionLock } from '../agent/session-queue.js';
27
+ import { listPendingActions, getPendingAction, getPendingActionForReplay, setPendingActionApproved, setPendingActionRejected, sanitizeArgsForDisplay, } from '../agent/pending-actions.js';
28
+ import { isOllamaReachable, startOllamaServeForApi, runOllamaInstall, setPullProgressCallback, pullOllamaModel } from '../ollama-setup.js';
29
+ import { getSystemCapabilities } from '../system-capabilities.js';
16
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
31
  const projectRoot = join(__dirname, '..', '..');
18
32
  const dashboardDist = join(projectRoot, 'dashboard', 'dist');
@@ -21,6 +35,25 @@ const rateLimitMap = new Map();
21
35
  function rateLimitMiddleware(req, res, next) {
22
36
  if (!config.rateLimitMax || config.rateLimitMax <= 0)
23
37
  return next();
38
+ if (req.path === '/health')
39
+ return next();
40
+ // Don't rate-limit read-only or bootstrap endpoints the dashboard calls often
41
+ if (req.path.startsWith('/api/onboard'))
42
+ return next();
43
+ if (req.path === '/api/agent/pending-actions' && req.method === 'GET')
44
+ return next();
45
+ if (req.path === '/api/config' && req.method === 'GET')
46
+ return next();
47
+ if (req.path === '/api/integrations/connections' && req.method === 'GET')
48
+ return next();
49
+ if (req.path === '/api/integrations/connect' && req.method === 'POST')
50
+ return next();
51
+ if (req.path.startsWith('/api/integrations/connections/') && req.method === 'DELETE')
52
+ return next();
53
+ if (req.path === '/api/oauth/connect-url' && req.method === 'GET')
54
+ return next();
55
+ if (req.path === '/api/oauth/callback' && req.method === 'GET')
56
+ return next();
24
57
  const ip = (req.ip || req.socket?.remoteAddress || 'unknown');
25
58
  const now = Date.now();
26
59
  let entry = rateLimitMap.get(ip);
@@ -40,11 +73,18 @@ export function createGateway(appMount = null) {
40
73
  app.use(cors());
41
74
  app.use(express.json());
42
75
  app.use(rateLimitMiddleware);
76
+ try {
77
+ const skills = listSkills(config, { includeDisabled: true });
78
+ migrateConfigNameKeysToSlug(skills.map((s) => ({ name: s.name, slug: s.slug ?? s.name })));
79
+ }
80
+ catch {
81
+ /* migration best-effort */
82
+ }
43
83
  if (config.gatewayApiKey) {
44
84
  app.use((req, res, next) => {
45
85
  if (req.path === '/health')
46
86
  return next();
47
- if (req.path === '/onboard' || req.path.startsWith('/api/onboard'))
87
+ if (req.path === '/onboard' || req.path.startsWith('/api/onboard') || req.path.startsWith('/api/ollama'))
48
88
  return next();
49
89
  const key = req.headers['x-api-key'] || req.query.api_key;
50
90
  if (key === config.gatewayApiKey)
@@ -93,9 +133,48 @@ export function createGateway(appMount = null) {
93
133
  res.status(500).json({ error: e.message });
94
134
  }
95
135
  });
136
+ app.get('/api/system/capabilities', (_req, res) => {
137
+ try {
138
+ const caps = getSystemCapabilities();
139
+ res.json(caps);
140
+ }
141
+ catch (e) {
142
+ log('gateway', 'error', e.message);
143
+ res.status(500).json({
144
+ storageFreeBytes: null,
145
+ isCloudOrContainer: false,
146
+ cloudReason: '',
147
+ ollamaSuitable: true,
148
+ ollamaSuitableReason: 'Check unavailable',
149
+ });
150
+ }
151
+ });
96
152
  app.get('/health', (_req, res) => {
97
153
  res.json({ status: 'ok', service: 'sulala-gateway' });
98
154
  });
155
+ /** Onboard: check if onboarding is complete (first-launch detection). */
156
+ app.get('/api/onboard/status', (_req, res) => {
157
+ try {
158
+ const complete = getOnboardingComplete();
159
+ res.json({ complete });
160
+ }
161
+ catch (e) {
162
+ log('gateway', 'error', e.message);
163
+ res.status(500).json({ complete: false, error: e.message });
164
+ }
165
+ });
166
+ /** Onboard: mark onboarding as complete. */
167
+ app.put('/api/onboard/complete', async (_req, res) => {
168
+ try {
169
+ setOnboardingComplete(true);
170
+ await reloadProviders();
171
+ res.json({ ok: true, complete: true });
172
+ }
173
+ catch (e) {
174
+ log('gateway', 'error', e.message);
175
+ res.status(500).json({ error: e.message });
176
+ }
177
+ });
99
178
  /** Onboard: which API keys are set (values never returned). */
100
179
  app.get('/api/onboard/env', (_req, res) => {
101
180
  try {
@@ -108,8 +187,8 @@ export function createGateway(appMount = null) {
108
187
  res.status(500).json({ error: e.message });
109
188
  }
110
189
  });
111
- /** Onboard: save API keys to ~/.sulala/.env (whitelist only). */
112
- app.put('/api/onboard/env', (req, res) => {
190
+ /** Onboard: save API keys to ~/.sulala/.env (whitelist only). Reload AI providers so new keys apply without restart. */
191
+ app.put('/api/onboard/env', async (req, res) => {
113
192
  try {
114
193
  const body = req.body;
115
194
  if (!body || typeof body !== 'object') {
@@ -117,6 +196,7 @@ export function createGateway(appMount = null) {
117
196
  return;
118
197
  }
119
198
  writeOnboardEnvKeys(body);
199
+ await reloadProviders();
120
200
  res.json({ ok: true, keys: readOnboardEnvKeys() });
121
201
  }
122
202
  catch (e) {
@@ -124,6 +204,76 @@ export function createGateway(appMount = null) {
124
204
  res.status(500).json({ error: e.message });
125
205
  }
126
206
  });
207
+ /** Ollama status and setup (for onboard and dashboard). */
208
+ app.get('/api/ollama/status', async (_req, res) => {
209
+ try {
210
+ const running = await isOllamaReachable();
211
+ const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
212
+ res.json({ running, baseUrl });
213
+ }
214
+ catch (e) {
215
+ log('gateway', 'error', e.message);
216
+ res.status(500).json({ running: false, error: e.message });
217
+ }
218
+ });
219
+ app.post('/api/ollama/start', async (_req, res) => {
220
+ try {
221
+ const result = await startOllamaServeForApi();
222
+ res.json(result);
223
+ }
224
+ catch (e) {
225
+ log('gateway', 'error', e.message);
226
+ res.status(500).json({ started: false, error: e.message });
227
+ }
228
+ });
229
+ app.post('/api/ollama/install', (_req, res) => {
230
+ try {
231
+ runOllamaInstall();
232
+ res.json({ ok: true, message: 'Install started. Check your terminal or install from https://ollama.com' });
233
+ }
234
+ catch (e) {
235
+ log('gateway', 'error', e.message);
236
+ res.status(500).json({ error: e.message });
237
+ }
238
+ });
239
+ app.locals.ollamaPullState = { inProgress: false, model: '', lastLine: '', percent: 0 };
240
+ app.get('/api/ollama/pull-status', (_req, res) => {
241
+ res.json(app.locals.ollamaPullState ?? { inProgress: false, model: '', lastLine: '', percent: 0 });
242
+ });
243
+ setPullProgressCallback((model, line, percent) => {
244
+ const state = { inProgress: percent >= 0 && percent < 100, model, lastLine: line, percent };
245
+ app.locals.ollamaPullState = state;
246
+ const b = app.locals.wsBroadcast;
247
+ if (b)
248
+ b({ type: 'ollama_pull_progress', data: state });
249
+ });
250
+ app.post('/api/ollama/pull', (req, res) => {
251
+ try {
252
+ const { model } = req.body || {};
253
+ if (!model || typeof model !== 'string' || !model.trim()) {
254
+ res.status(400).json({ error: 'model required' });
255
+ return;
256
+ }
257
+ pullOllamaModel(model.trim());
258
+ res.json({ ok: true, model: model.trim(), message: 'Pull started. Check pull-status for progress.' });
259
+ }
260
+ catch (e) {
261
+ log('gateway', 'error', e.message);
262
+ res.status(500).json({ error: e.message });
263
+ }
264
+ });
265
+ /** Recommended Ollama models for onboarding (size, RAM, CPU/GPU). */
266
+ const RECOMMENDED_OLLAMA_MODELS = [
267
+ { id: 'llama3.2', name: 'Llama 3.2', size: '~2GB', ram: '8GB', cpu: 'Any', gpu: 'Optional', description: 'Good balance of speed and quality' },
268
+ { id: 'llama3.2:1b', name: 'Llama 3.2 1B', size: '~1.3GB', ram: '4GB', cpu: 'Any', gpu: 'None', description: 'Fast, low resource' },
269
+ { id: 'mistral', name: 'Mistral 7B', size: '~4.1GB', ram: '8GB', cpu: '4+ cores', gpu: 'Recommended', description: 'Strong general model' },
270
+ { id: 'codellama', name: 'Code Llama', size: '~3.8GB', ram: '8GB', cpu: '4+ cores', gpu: 'Recommended', description: 'Optimized for code' },
271
+ { id: 'phi3', name: 'Phi-3 Mini', size: '~2.3GB', ram: '8GB', cpu: 'Any', gpu: 'Optional', description: 'Efficient small model' },
272
+ { id: 'gemma2:2b', name: 'Gemma 2 2B', size: '~1.6GB', ram: '4GB', cpu: 'Any', gpu: 'None', description: 'Lightweight, capable' },
273
+ ];
274
+ app.get('/api/onboard/recommended-models', (_req, res) => {
275
+ res.json({ models: RECOMMENDED_OLLAMA_MODELS });
276
+ });
127
277
  /** Onboard: minimal HTML page to add API keys (no dashboard required). */
128
278
  app.get('/onboard', (_req, res) => {
129
279
  const keys = ONBOARD_ENV_WHITELIST.map((k) => `<div class="row"><label for="${k}">${k}</label><input type="password" id="${k}" name="${k}" placeholder="optional" autocomplete="off" /></div>`).join('\n');
@@ -137,22 +287,37 @@ export function createGateway(appMount = null) {
137
287
  * { box-sizing: border-box; }
138
288
  body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
139
289
  h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
290
+ h2 { font-size: 1rem; margin: 1.5rem 0 0.5rem; color: #374151; }
140
291
  p { color: #666; font-size: 0.875rem; margin-bottom: 1.25rem; }
141
292
  .row { margin-bottom: 0.75rem; }
142
293
  label { display: block; font-size: 0.75rem; font-weight: 600; margin-bottom: 0.25rem; color: #374151; }
143
294
  input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.875rem; }
144
295
  input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
145
- button { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; cursor: pointer; margin-top: 0.5rem; }
296
+ button { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; cursor: pointer; margin-top: 0.5rem; margin-right: 0.5rem; }
146
297
  button:hover { background: #1d4ed8; }
298
+ button.secondary { background: #6b7280; }
299
+ button.secondary:hover { background: #4b5563; }
147
300
  .msg { margin-top: 1rem; font-size: 0.875rem; }
148
301
  .msg.success { color: #059669; }
149
302
  .msg.err { color: #dc2626; }
303
+ .ollama-ok { color: #059669; font-size: 0.875rem; }
150
304
  a { color: #2563eb; }
305
+ #ollama-section { margin-bottom: 1rem; padding: 1rem; background: #f9fafb; border-radius: 8px; }
151
306
  </style>
152
307
  </head>
153
308
  <body>
154
- <h1>Sulala — API keys</h1>
155
- <p>Add at least one AI provider key. Keys are saved to <code>~/.sulala/.env</code>. Restart the agent for changes to apply.</p>
309
+ <div id="ollama-section">
310
+ <h2>Ollama (default AI)</h2>
311
+ <p id="ollama-status">Checking…</p>
312
+ <div id="ollama-actions" style="display: none;">
313
+ <button type="button" id="ollama-install">Install Ollama</button>
314
+ <button type="button" id="ollama-start">Start Ollama</button>
315
+ <button type="button" id="ollama-check" class="secondary">Check again</button>
316
+ <p style="margin-top: 0.75rem; font-size: 0.8rem;">Or install from <a href="https://ollama.com" target="_blank" rel="noopener">ollama.com</a> or run in terminal: <code>ollama serve</code></p>
317
+ </div>
318
+ </div>
319
+ <h1>API keys (optional)</h1>
320
+ <p>Ollama works with no key. Add keys below to use OpenAI, Claude, Gemini, or OpenRouter. Saved to <code>~/.sulala/.env</code>. Restart the agent for changes.</p>
156
321
  <form id="f">
157
322
  ${keys}
158
323
  <button type="submit">Save</button>
@@ -162,6 +327,47 @@ export function createGateway(appMount = null) {
162
327
  <script>
163
328
  const form = document.getElementById('f');
164
329
  const msg = document.getElementById('msg');
330
+ const ollamaStatus = document.getElementById('ollama-status');
331
+ const ollamaActions = document.getElementById('ollama-actions');
332
+ async function checkOllama() {
333
+ ollamaStatus.textContent = 'Checking…';
334
+ ollamaActions.style.display = 'none';
335
+ try {
336
+ const r = await fetch('/api/ollama/status');
337
+ const d = await r.json();
338
+ if (d.running) {
339
+ ollamaStatus.textContent = 'Ollama is running. You can use Chat.';
340
+ ollamaStatus.className = 'ollama-ok';
341
+ } else {
342
+ ollamaStatus.textContent = 'Ollama is not running.';
343
+ ollamaStatus.className = '';
344
+ ollamaActions.style.display = 'block';
345
+ }
346
+ } catch (e) {
347
+ ollamaStatus.textContent = 'Could not check Ollama.';
348
+ ollamaActions.style.display = 'block';
349
+ }
350
+ }
351
+ document.getElementById('ollama-install').onclick = async () => {
352
+ ollamaStatus.textContent = 'Starting install… (see terminal or install from ollama.com)';
353
+ try {
354
+ await fetch('/api/ollama/install', { method: 'POST' });
355
+ ollamaStatus.textContent = 'Install started. After it finishes, run: ollama serve';
356
+ ollamaActions.style.display = 'block';
357
+ } catch (e) { ollamaStatus.textContent = e.message; }
358
+ };
359
+ document.getElementById('ollama-start').onclick = async () => {
360
+ ollamaStatus.textContent = 'Starting Ollama…';
361
+ try {
362
+ const r = await fetch('/api/ollama/start', { method: 'POST' });
363
+ const d = await r.json();
364
+ if (d.started) ollamaStatus.textContent = 'Ollama starting… Check again in a few seconds.';
365
+ else ollamaStatus.textContent = 'Ollama not found. Install it first (button above or ollama.com).';
366
+ ollamaActions.style.display = 'block';
367
+ } catch (e) { ollamaStatus.textContent = e.message; }
368
+ };
369
+ document.getElementById('ollama-check').onclick = checkOllama;
370
+ checkOllama();
165
371
  fetch('/api/onboard/env').then(r => r.json()).then(d => {
166
372
  for (const [key, status] of Object.entries(d.keys || {})) {
167
373
  const el = document.getElementById(key);
@@ -241,10 +447,377 @@ export function createGateway(appMount = null) {
241
447
  res.status(500).json({ error: e.message });
242
448
  }
243
449
  });
450
+ app.get('/api/channels/telegram', (_req, res) => {
451
+ try {
452
+ const state = getTelegramChannelState();
453
+ res.json(state);
454
+ }
455
+ catch (e) {
456
+ log('gateway', 'error', e.message);
457
+ res.status(500).json({ error: e.message });
458
+ }
459
+ });
460
+ app.put('/api/channels/telegram', async (req, res) => {
461
+ try {
462
+ const { enabled, botToken, dmPolicy, allowFrom, notificationChatId, defaultProvider, defaultModel } = req.body || {};
463
+ const result = await setTelegramChannelConfig({
464
+ enabled: typeof enabled === 'boolean' ? enabled : undefined,
465
+ botToken: botToken !== undefined ? botToken : undefined,
466
+ dmPolicy: typeof dmPolicy === 'string' ? dmPolicy : undefined,
467
+ allowFrom: Array.isArray(allowFrom) ? allowFrom : undefined,
468
+ notificationChatId: notificationChatId !== undefined ? (typeof notificationChatId === 'number' ? notificationChatId : parseInt(String(notificationChatId), 10)) : undefined,
469
+ defaultProvider: defaultProvider !== undefined ? (typeof defaultProvider === 'string' ? defaultProvider : null) : undefined,
470
+ defaultModel: defaultModel !== undefined ? (typeof defaultModel === 'string' ? defaultModel : null) : undefined,
471
+ });
472
+ if (!result.ok) {
473
+ res.status(400).json({ error: result.error || 'Failed to save' });
474
+ return;
475
+ }
476
+ const state = getTelegramChannelState();
477
+ res.json(state);
478
+ }
479
+ catch (e) {
480
+ log('gateway', 'error', e.message);
481
+ res.status(500).json({ error: e.message });
482
+ }
483
+ });
484
+ app.get('/api/channels/discord', (_req, res) => {
485
+ try {
486
+ const state = getDiscordChannelState();
487
+ res.json(state);
488
+ }
489
+ catch (e) {
490
+ log('gateway', 'error', e.message);
491
+ res.status(500).json({ error: e.message });
492
+ }
493
+ });
494
+ app.put('/api/channels/discord', async (req, res) => {
495
+ try {
496
+ const { botToken } = req.body || {};
497
+ const result = await setDiscordChannelConfig({
498
+ botToken: botToken !== undefined ? (typeof botToken === 'string' ? botToken : null) : undefined,
499
+ });
500
+ if (!result.ok) {
501
+ res.status(400).json({ error: result.error || 'Failed to save' });
502
+ return;
503
+ }
504
+ const state = getDiscordChannelState();
505
+ res.json(state);
506
+ }
507
+ catch (e) {
508
+ log('gateway', 'error', e.message);
509
+ res.status(500).json({ error: e.message });
510
+ }
511
+ });
512
+ app.get('/api/channels/stripe', (_req, res) => {
513
+ try {
514
+ const state = getStripeChannelState();
515
+ res.json(state);
516
+ }
517
+ catch (e) {
518
+ log('gateway', 'error', e.message);
519
+ res.status(500).json({ error: e.message });
520
+ }
521
+ });
522
+ app.put('/api/channels/stripe', async (req, res) => {
523
+ try {
524
+ const { secretKey } = req.body || {};
525
+ const result = await setStripeChannelConfig({
526
+ secretKey: secretKey !== undefined ? (typeof secretKey === 'string' ? secretKey : null) : undefined,
527
+ });
528
+ if (!result.ok) {
529
+ res.status(400).json({ error: result.error || 'Failed to save' });
530
+ return;
531
+ }
532
+ const state = getStripeChannelState();
533
+ res.json(state);
534
+ }
535
+ catch (e) {
536
+ log('gateway', 'error', e.message);
537
+ res.status(500).json({ error: e.message });
538
+ }
539
+ });
540
+ /** Returns connected integration provider ids (from Portal). When Portal is not configured, returns []. */
541
+ async function getConnectedIntegrationIds() {
542
+ const base = getPortalGatewayBase();
543
+ const key = getEffectivePortalApiKey();
544
+ if (!base || !key)
545
+ return [];
546
+ try {
547
+ const portalRes = await fetch(`${base}/connections`, {
548
+ headers: { Authorization: `Bearer ${key}` },
549
+ });
550
+ if (!portalRes.ok)
551
+ return [];
552
+ const data = (await portalRes.json());
553
+ const raw = data.connections ?? [];
554
+ const ids = new Set();
555
+ for (const c of raw) {
556
+ const p = (c.provider ?? '').trim();
557
+ if (p)
558
+ ids.add(p);
559
+ }
560
+ return Array.from(ids);
561
+ }
562
+ catch {
563
+ return [];
564
+ }
565
+ }
566
+ /** GET /api/integrations/connections — When Portal is configured, proxy list from Portal (dashboard can show connections like direct mode). */
567
+ app.get('/api/integrations/connections', async (_req, res) => {
568
+ try {
569
+ const base = getPortalGatewayBase();
570
+ const key = getEffectivePortalApiKey();
571
+ if (!base || !key) {
572
+ res.status(404).json({ error: 'Portal not configured', connections: [] });
573
+ return;
574
+ }
575
+ const portalRes = await fetch(`${base}/connections`, {
576
+ headers: { Authorization: `Bearer ${key}` },
577
+ });
578
+ if (!portalRes.ok) {
579
+ const text = await portalRes.text();
580
+ let err = `Portal: ${portalRes.status}`;
581
+ try {
582
+ const j = JSON.parse(text);
583
+ if (j.error)
584
+ err = j.error;
585
+ }
586
+ catch {
587
+ if (text)
588
+ err = text.slice(0, 200);
589
+ }
590
+ res.status(portalRes.status >= 500 ? 502 : portalRes.status).json({ error: err, connections: [] });
591
+ return;
592
+ }
593
+ const data = (await portalRes.json());
594
+ const raw = data.connections ?? [];
595
+ const connections = raw.map((c) => {
596
+ const created = c.created_at;
597
+ const createdAt = typeof created === 'number' ? created : created ? new Date(created).getTime() : 0;
598
+ return {
599
+ id: c.connection_id ?? c.id ?? '',
600
+ provider: c.provider ?? '',
601
+ scopes: [],
602
+ createdAt,
603
+ updatedAt: undefined,
604
+ };
605
+ });
606
+ res.json({ connections });
607
+ }
608
+ catch (e) {
609
+ log('gateway', 'error', e.message);
610
+ res.status(500).json({ error: e.message, connections: [] });
611
+ }
612
+ });
613
+ /** POST /api/integrations/connect — When Portal configured, start OAuth via Portal. Body: { provider, redirect_success }. Returns { authUrl, connectionId }. */
614
+ app.post('/api/integrations/connect', async (req, res) => {
615
+ try {
616
+ const base = getPortalGatewayBase();
617
+ const key = getEffectivePortalApiKey();
618
+ if (!base || !key) {
619
+ res.status(404).json({ error: 'Portal not configured' });
620
+ return;
621
+ }
622
+ const body = req.body;
623
+ const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
624
+ if (!provider) {
625
+ res.status(400).json({ error: 'provider required' });
626
+ return;
627
+ }
628
+ const portalRes = await fetch(`${base}/connect`, {
629
+ method: 'POST',
630
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
631
+ body: JSON.stringify({ provider, redirect_success: body.redirect_success || undefined }),
632
+ });
633
+ const text = await portalRes.text();
634
+ if (!portalRes.ok) {
635
+ let err = `Portal: ${portalRes.status}`;
636
+ try {
637
+ const j = JSON.parse(text);
638
+ if (j.error)
639
+ err = j.error;
640
+ }
641
+ catch {
642
+ if (text)
643
+ err = text.slice(0, 200);
644
+ }
645
+ res.status(portalRes.status >= 500 ? 502 : portalRes.status).json({ error: err });
646
+ return;
647
+ }
648
+ const data = JSON.parse(text);
649
+ if (!data.authUrl) {
650
+ res.status(502).json({ error: 'No authUrl from Portal' });
651
+ return;
652
+ }
653
+ res.json({ authUrl: data.authUrl, connectionId: data.connectionId ?? null });
654
+ }
655
+ catch (e) {
656
+ log('gateway', 'error', e.message);
657
+ res.status(500).json({ error: e.message });
658
+ }
659
+ });
660
+ /** DELETE /api/integrations/connections/:id — When Portal configured, disconnect via Portal. */
661
+ app.delete('/api/integrations/connections/:id', async (req, res) => {
662
+ try {
663
+ const base = getPortalGatewayBase();
664
+ const key = getEffectivePortalApiKey();
665
+ if (!base || !key) {
666
+ res.status(404).json({ error: 'Portal not configured' });
667
+ return;
668
+ }
669
+ const rawId = req.params?.id;
670
+ const id = Array.isArray(rawId) ? rawId[0] : rawId;
671
+ if (!id || typeof id !== 'string') {
672
+ res.status(400).json({ error: 'connection id required' });
673
+ return;
674
+ }
675
+ const portalRes = await fetch(`${base}/connections/${encodeURIComponent(id)}`, {
676
+ method: 'DELETE',
677
+ headers: { Authorization: `Bearer ${key}` },
678
+ });
679
+ if (!portalRes.ok) {
680
+ const text = await portalRes.text();
681
+ let err = `Portal: ${portalRes.status}`;
682
+ try {
683
+ const j = JSON.parse(text);
684
+ if (j.error)
685
+ err = j.error;
686
+ }
687
+ catch {
688
+ if (text)
689
+ err = text.slice(0, 200);
690
+ }
691
+ res.status(portalRes.status >= 500 ? 502 : portalRes.status).json({ error: err });
692
+ return;
693
+ }
694
+ res.json({ ok: true });
695
+ }
696
+ catch (e) {
697
+ log('gateway', 'error', e.message);
698
+ res.status(500).json({ error: e.message });
699
+ }
700
+ });
701
+ /** GET /api/oauth/connect-url — Build Portal "Connect with Sulala" URL for dashboard. Requires PORTAL_GATEWAY_URL, PORTAL_OAUTH_CLIENT_ID; redirect_uri = this gateway base + /api/oauth/callback. Optional return_to encoded in state for callback redirect. */
702
+ app.get('/api/oauth/connect-url', (req, res) => {
703
+ try {
704
+ const portalGateway = getPortalGatewayBase();
705
+ const portalBase = portalGateway ? portalGateway.replace(/\/api\/gateway$/i, '') : '';
706
+ const clientId = (process.env.PORTAL_OAUTH_CLIENT_ID || '').trim();
707
+ if (!portalBase || !clientId) {
708
+ res.status(503).json({
709
+ error: 'OAuth not configured',
710
+ hint: 'Set PORTAL_GATEWAY_URL and PORTAL_OAUTH_CLIENT_ID (and PORTAL_OAUTH_CLIENT_SECRET for callback). Register redirect_uri in the Portal.',
711
+ });
712
+ return;
713
+ }
714
+ const proto = req.headers['x-forwarded-proto'] || (req.secure ? 'https' : 'http');
715
+ const host = req.headers['x-forwarded-host'] || req.headers.host || '';
716
+ const publicBase = (process.env.PUBLIC_URL || process.env.GATEWAY_PUBLIC_URL || '').trim() || `${proto}://${host}`;
717
+ const callbackUrl = `${publicBase.replace(/\/$/, '')}/api/oauth/callback`;
718
+ const return_to = req.query.return_to?.trim() || undefined;
719
+ const statePayload = JSON.stringify({ r: randomBytes(12).toString('base64url'), return_to });
720
+ const state = Buffer.from(statePayload, 'utf8').toString('base64url');
721
+ const url = `${portalBase}/connect?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`;
722
+ res.json({ url });
723
+ }
724
+ catch (e) {
725
+ log('gateway', 'error', e.message);
726
+ res.status(500).json({ error: e.message });
727
+ }
728
+ });
729
+ function parseOAuthReturnTo(req) {
730
+ try {
731
+ const stateRaw = req.query.state || '';
732
+ const statePayload = JSON.parse(Buffer.from(stateRaw, 'base64url').toString('utf8'));
733
+ if (statePayload && typeof statePayload.return_to === 'string' && statePayload.return_to.trim()) {
734
+ return statePayload.return_to.trim();
735
+ }
736
+ }
737
+ catch {
738
+ /* state may be legacy random string */
739
+ }
740
+ return undefined;
741
+ }
742
+ /** GET /api/oauth/callback — Portal redirects here with code & state. Exchange for access token, save as PORTAL_API_KEY, redirect to dashboard. */
743
+ app.get('/api/oauth/callback', async (req, res) => {
744
+ const dashboardOrigin = (req.headers['x-forwarded-proto'] ? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}` : null) || (req.headers.origin || `${req.protocol}://${req.get('host')}`);
745
+ const return_to = parseOAuthReturnTo(req);
746
+ const returnFragment = return_to ? `&return_to=${encodeURIComponent(return_to)}` : '';
747
+ try {
748
+ const code = req.query.code || '';
749
+ const portalGateway = getPortalGatewayBase();
750
+ const portalBase = portalGateway ? portalGateway.replace(/\/api\/gateway$/i, '') : '';
751
+ const clientId = (process.env.PORTAL_OAUTH_CLIENT_ID || '').trim();
752
+ const clientSecret = (process.env.PORTAL_OAUTH_CLIENT_SECRET || '').trim();
753
+ if (!code || !portalBase || !clientId || !clientSecret) {
754
+ res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=missing_config${returnFragment}`);
755
+ return;
756
+ }
757
+ const proto = req.headers['x-forwarded-proto'] || (req.secure ? 'https' : 'http');
758
+ const host = req.headers['x-forwarded-host'] || req.headers.host || '';
759
+ const publicBase = (process.env.PUBLIC_URL || process.env.GATEWAY_PUBLIC_URL || '').trim() || `${proto}://${host}`;
760
+ const redirectUri = `${publicBase.replace(/\/$/, '')}/api/oauth/callback`;
761
+ const tokenRes = await fetch(`${portalBase}/api/oauth/token`, {
762
+ method: 'POST',
763
+ headers: { 'Content-Type': 'application/json' },
764
+ body: JSON.stringify({
765
+ grant_type: 'authorization_code',
766
+ code,
767
+ client_id: clientId,
768
+ client_secret: clientSecret,
769
+ redirect_uri: redirectUri,
770
+ }),
771
+ });
772
+ const tokenText = await tokenRes.text();
773
+ if (!tokenRes.ok) {
774
+ log('gateway', 'error', `OAuth token exchange failed: ${tokenRes.status} ${tokenText}`);
775
+ res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=exchange_failed${returnFragment}`);
776
+ return;
777
+ }
778
+ let tokenData;
779
+ try {
780
+ tokenData = JSON.parse(tokenText);
781
+ }
782
+ catch {
783
+ res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=invalid_response${returnFragment}`);
784
+ return;
785
+ }
786
+ const accessToken = tokenData.access_token?.trim();
787
+ if (!accessToken) {
788
+ res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=no_token${returnFragment}`);
789
+ return;
790
+ }
791
+ writeOnboardEnvKeys({ PORTAL_API_KEY: accessToken });
792
+ res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=success${returnFragment}`);
793
+ }
794
+ catch (e) {
795
+ log('gateway', 'error', e.message);
796
+ const dashboardOrigin = (req.headers.origin || (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host'] ? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}` : null) || `${req.protocol}://${req.get('host')}`);
797
+ const return_to = parseOAuthReturnTo(req);
798
+ const returnFragment = return_to ? `&return_to=${encodeURIComponent(return_to)}` : '';
799
+ res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=server_error${returnFragment}`);
800
+ }
801
+ });
244
802
  app.get('/api/config', (_req, res) => {
245
803
  try {
804
+ const portalUrl = getPortalGatewayBase();
805
+ const portalKey = getEffectivePortalApiKey();
806
+ const portalSet = !!(portalUrl && portalKey);
807
+ const directSet = !!config.integrationsUrl?.trim();
808
+ const integrationsMode = portalSet ? 'portal' : directSet ? 'direct' : null;
809
+ const portalOAuthClientId = (process.env.PORTAL_OAUTH_CLIENT_ID || '').trim();
246
810
  res.json({
247
811
  watchFolders: config.watchFolders || [],
812
+ agentUsePi: config.agentUsePi,
813
+ piAvailable: isPiAvailable(),
814
+ /** When set, dashboard can use this for Integrations page instead of VITE_INTEGRATIONS_URL. */
815
+ integrationsUrl: config.integrationsUrl || null,
816
+ /** 'portal' = agent uses Portal for connections; 'direct' = agent uses INTEGRATIONS_URL; null = neither. */
817
+ integrationsMode,
818
+ portalGatewayUrl: portalUrl || config.portalGatewayUrl || null,
819
+ /** When set, dashboard can show "Connect with Sulala (OAuth)" and use /api/oauth/connect-url. */
820
+ portalOAuthConnectAvailable: !!(portalUrl && portalOAuthClientId),
248
821
  aiProviders: [
249
822
  { id: 'openai', label: 'OpenAI', defaultModel: process.env.AI_OPENAI_DEFAULT_MODEL || 'gpt-4o-mini' },
250
823
  { id: 'openrouter', label: 'OpenRouter', defaultModel: process.env.AI_OPENROUTER_DEFAULT_MODEL || 'openai/gpt-4o-mini' },
@@ -259,6 +832,51 @@ export function createGateway(appMount = null) {
259
832
  res.status(500).json({ error: e.message });
260
833
  }
261
834
  });
835
+ const JOB_DEFAULT_KEY = 'job_default';
836
+ app.get('/api/settings/job-default', (_req, res) => {
837
+ try {
838
+ const raw = getChannelConfig(JOB_DEFAULT_KEY);
839
+ if (!raw?.trim()) {
840
+ res.json({ defaultProvider: null, defaultModel: null });
841
+ return;
842
+ }
843
+ const o = JSON.parse(raw);
844
+ const defaultProvider = typeof o.defaultProvider === 'string' ? o.defaultProvider.trim() || null : null;
845
+ const defaultModel = typeof o.defaultModel === 'string' ? o.defaultModel.trim() || null : null;
846
+ res.json({ defaultProvider, defaultModel });
847
+ }
848
+ catch (e) {
849
+ log('gateway', 'error', e.message);
850
+ res.status(500).json({ error: e.message });
851
+ }
852
+ });
853
+ app.put('/api/settings/job-default', (req, res) => {
854
+ try {
855
+ const { defaultProvider, defaultModel } = req.body || {};
856
+ const provider = defaultProvider !== undefined ? (typeof defaultProvider === 'string' ? defaultProvider.trim() || null : null) : undefined;
857
+ const model = defaultModel !== undefined ? (typeof defaultModel === 'string' ? defaultModel.trim() || null : null) : undefined;
858
+ const current = getChannelConfig(JOB_DEFAULT_KEY);
859
+ let next = {};
860
+ if (current?.trim()) {
861
+ try {
862
+ next = JSON.parse(current);
863
+ }
864
+ catch {
865
+ // ignore
866
+ }
867
+ }
868
+ if (provider !== undefined)
869
+ next.defaultProvider = provider;
870
+ if (model !== undefined)
871
+ next.defaultModel = model;
872
+ setChannelConfig(JOB_DEFAULT_KEY, JSON.stringify(next));
873
+ res.json({ defaultProvider: next.defaultProvider ?? null, defaultModel: next.defaultModel ?? null });
874
+ }
875
+ catch (e) {
876
+ log('gateway', 'error', e.message);
877
+ res.status(500).json({ error: e.message });
878
+ }
879
+ });
262
880
  app.get('/api/agent/skills/required-bins', (_req, res) => {
263
881
  try {
264
882
  const bins = getAllRequiredBins(config);
@@ -282,7 +900,7 @@ export function createGateway(appMount = null) {
282
900
  app.get('/api/agent/skills/config', (_req, res) => {
283
901
  try {
284
902
  const cfg = loadSkillsConfig();
285
- res.json({ skills: cfg, configPath: getConfigPath() });
903
+ res.json({ skills: redactSkillsConfig(cfg), configPath: getConfigPath() });
286
904
  }
287
905
  catch (e) {
288
906
  log('gateway', 'error', e.message);
@@ -294,7 +912,22 @@ export function createGateway(appMount = null) {
294
912
  const { skills } = req.body || {};
295
913
  if (skills && typeof skills === 'object')
296
914
  saveSkillsConfig(skills);
297
- res.json({ skills: loadSkillsConfig(), configPath: getConfigPath() });
915
+ res.json({ skills: redactSkillsConfig(loadSkillsConfig()), configPath: getConfigPath() });
916
+ }
917
+ catch (e) {
918
+ log('gateway', 'error', e.message);
919
+ res.status(500).json({ error: e.message });
920
+ }
921
+ });
922
+ app.post('/api/agent/skills/config/remove', (req, res) => {
923
+ try {
924
+ const { slug } = req.body || {};
925
+ if (!slug || typeof slug !== 'string') {
926
+ res.status(400).json({ error: 'slug required' });
927
+ return;
928
+ }
929
+ removeSkillEntry(slug);
930
+ res.json({ removed: slug, skills: redactSkillsConfig(loadSkillsConfig()), configPath: getConfigPath() });
298
931
  }
299
932
  catch (e) {
300
933
  log('gateway', 'error', e.message);
@@ -311,8 +944,17 @@ export function createGateway(appMount = null) {
311
944
  res.status(500).json({ error: e.message });
312
945
  }
313
946
  });
314
- app.get('/api/agent/skills/registry', async (_req, res) => {
947
+ app.get('/api/agent/skills/registry', async (req, res) => {
315
948
  try {
949
+ const url = req.query.url?.trim();
950
+ if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
951
+ const resp = await fetch(url);
952
+ if (!resp.ok)
953
+ throw new Error(`Registry fetch failed: ${resp.status}`);
954
+ const data = (await resp.json());
955
+ res.json({ skills: data.skills ?? [] });
956
+ return;
957
+ }
316
958
  const skills = await getRegistrySkills();
317
959
  res.json({ skills });
318
960
  }
@@ -374,9 +1016,150 @@ export function createGateway(appMount = null) {
374
1016
  res.status(500).json({ error: e.message });
375
1017
  }
376
1018
  });
377
- /** List models for a provider. For openrouter, proxies OpenRouter Models API (id + name). */
1019
+ /** Skill Wizard: generate spec and optionally write skill to workspace. */
1020
+ app.get('/api/agent/skills/wizard-apps', (_req, res) => {
1021
+ res.json({ apps: WIZARD_APPS, triggers: WIZARD_TRIGGERS });
1022
+ });
1023
+ app.get('/api/agent/skills/templates', async (_req, res) => {
1024
+ try {
1025
+ const registrySkills = await getRegistrySkills();
1026
+ const templates = getTemplates(registrySkills);
1027
+ res.json({ templates });
1028
+ }
1029
+ catch (e) {
1030
+ log('gateway', 'error', e.message);
1031
+ res.status(500).json({ error: e.message });
1032
+ }
1033
+ });
1034
+ app.post('/api/agent/skills/generate', (req, res) => {
1035
+ try {
1036
+ const { goal = '', app: appId = 'other', trigger: triggerId = 'manual', write } = req.body || {};
1037
+ const spec = generateSkillSpec(typeof goal === 'string' ? goal : '', typeof appId === 'string' ? appId : 'other', typeof triggerId === 'string' ? triggerId : 'manual');
1038
+ if (write) {
1039
+ const { path: filePath, slug } = writeGeneratedSkill(config, spec);
1040
+ res.status(201).json({ spec, path: filePath, slug, written: true });
1041
+ }
1042
+ else {
1043
+ res.json({ spec, written: false });
1044
+ }
1045
+ }
1046
+ catch (e) {
1047
+ log('gateway', 'error', e.message);
1048
+ res.status(500).json({ error: e.message });
1049
+ }
1050
+ });
1051
+ /** Execution preview: list pending tool actions awaiting approval. */
1052
+ app.get('/api/agent/pending-actions', (req, res) => {
1053
+ try {
1054
+ const sessionId = typeof req.query.session_id === 'string' ? req.query.session_id : undefined;
1055
+ const list = listPendingActions(sessionId).map((a) => ({
1056
+ ...a,
1057
+ args: sanitizeArgsForDisplay(a.args),
1058
+ }));
1059
+ res.json({ pendingActions: list });
1060
+ }
1061
+ catch (e) {
1062
+ log('gateway', 'error', e.message);
1063
+ res.status(500).json({ error: e.message });
1064
+ }
1065
+ });
1066
+ app.post('/api/agent/pending-actions/:id/approve', async (req, res) => {
1067
+ const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
1068
+ if (!id) {
1069
+ res.status(400).json({ error: 'Pending action id required' });
1070
+ return;
1071
+ }
1072
+ try {
1073
+ const pending = getPendingActionForReplay(id);
1074
+ if (!pending) {
1075
+ res.status(404).json({ error: 'Pending action not found or already handled' });
1076
+ return;
1077
+ }
1078
+ let result;
1079
+ try {
1080
+ result = await executeTool(pending.toolName, pending.args, {
1081
+ sessionId: pending.sessionId,
1082
+ toolCallId: pending.toolCallId,
1083
+ skipApproval: true,
1084
+ });
1085
+ }
1086
+ catch (err) {
1087
+ result = { error: err instanceof Error ? err.message : String(err) };
1088
+ }
1089
+ const resultContent = typeof result === 'string' ? result : JSON.stringify(result);
1090
+ const updated = updateAgentMessageToolResult(pending.sessionId, pending.toolCallId, resultContent);
1091
+ if (!updated) {
1092
+ appendAgentMessage({
1093
+ session_id: pending.sessionId,
1094
+ role: 'tool',
1095
+ tool_call_id: pending.toolCallId,
1096
+ name: pending.toolName,
1097
+ content: resultContent,
1098
+ });
1099
+ }
1100
+ setPendingActionApproved(id, result);
1101
+ res.json({ ok: true, result });
1102
+ }
1103
+ catch (e) {
1104
+ log('gateway', 'error', e.message);
1105
+ res.status(500).json({ error: e.message });
1106
+ }
1107
+ });
1108
+ app.post('/api/agent/pending-actions/:id/reject', (req, res) => {
1109
+ const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
1110
+ if (!id) {
1111
+ res.status(400).json({ error: 'Pending action id required' });
1112
+ return;
1113
+ }
1114
+ try {
1115
+ const pending = getPendingAction(id);
1116
+ if (!pending || pending.status !== 'pending') {
1117
+ res.status(404).json({ error: 'Pending action not found or already handled' });
1118
+ return;
1119
+ }
1120
+ setPendingActionRejected(id);
1121
+ const rejectedContent = JSON.stringify({ error: 'User rejected this action.' });
1122
+ const updated = updateAgentMessageToolResult(pending.sessionId, pending.toolCallId, rejectedContent);
1123
+ if (!updated) {
1124
+ appendAgentMessage({
1125
+ session_id: pending.sessionId,
1126
+ role: 'tool',
1127
+ tool_call_id: pending.toolCallId,
1128
+ name: pending.toolName,
1129
+ content: rejectedContent,
1130
+ });
1131
+ }
1132
+ res.json({ ok: true });
1133
+ }
1134
+ catch (e) {
1135
+ log('gateway', 'error', e.message);
1136
+ res.status(500).json({ error: e.message });
1137
+ }
1138
+ });
1139
+ /** List models for a provider. Ollama: local /api/tags; OpenRouter: proxies OpenRouter API. */
378
1140
  app.get('/api/agent/models', async (req, res) => {
379
1141
  const provider = req.query.provider || '';
1142
+ if (provider === 'ollama') {
1143
+ try {
1144
+ const base = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
1145
+ const r = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(5000) });
1146
+ if (!r.ok) {
1147
+ res.json({ models: [] });
1148
+ return;
1149
+ }
1150
+ const data = (await r.json());
1151
+ const toolsWhitelist = /^(qwen3|qwen3\.5|qwen3-vl|qwen3-next|deepseek-r1|deepseek-v3|gpt-oss|glm-4\.7-flash|nemotron-3-nano|magistral|llama3|hermes3|mistral|gemma2|codellama|solar|qwen2|wizardlm2|neural-chat|starling-lm)(:|$)/i;
1152
+ const models = (data.models || [])
1153
+ .filter((m) => toolsWhitelist.test(m.name))
1154
+ .filter((m) => !/:1b$|:1\.5b$|:0\.6b$|:2b$/i.test(m.name))
1155
+ .map((m) => ({ id: m.name, name: m.name }));
1156
+ res.json({ models });
1157
+ }
1158
+ catch {
1159
+ res.json({ models: [] });
1160
+ }
1161
+ return;
1162
+ }
380
1163
  if (provider !== 'openrouter') {
381
1164
  res.json({ models: [] });
382
1165
  return;
@@ -416,6 +1199,33 @@ export function createGateway(appMount = null) {
416
1199
  res.status(500).json({ error: e.message });
417
1200
  }
418
1201
  });
1202
+ /** POST /api/tools/invoke — direct tool execution. Same policy as agent run. */
1203
+ app.post('/api/tools/invoke', async (req, res) => {
1204
+ try {
1205
+ const body = (req.body || {});
1206
+ const toolName = typeof body.tool === 'string' ? body.tool.trim() : '';
1207
+ if (!toolName) {
1208
+ res.status(400).json({ error: 'body.tool (string) required' });
1209
+ return;
1210
+ }
1211
+ const args = body.args != null && typeof body.args === 'object' && !Array.isArray(body.args)
1212
+ ? body.args
1213
+ : {};
1214
+ const allowed = listTools();
1215
+ const tool = allowed.find((t) => t.name === toolName);
1216
+ if (!tool) {
1217
+ res.status(404).json({ error: `Tool "${toolName}" not found or not allowed` });
1218
+ return;
1219
+ }
1220
+ const result = await executeTool(toolName, args);
1221
+ res.json({ ok: true, result });
1222
+ }
1223
+ catch (e) {
1224
+ const msg = e instanceof Error ? e.message : String(e);
1225
+ log('gateway', 'error', msg, { stack: e instanceof Error ? e.stack : undefined });
1226
+ res.status(500).json({ error: msg });
1227
+ }
1228
+ });
419
1229
  // --- Agent runner (sessions + tool loop) ---
420
1230
  app.get('/api/agent/sessions', (req, res) => {
421
1231
  try {
@@ -428,6 +1238,42 @@ export function createGateway(appMount = null) {
428
1238
  res.status(500).json({ error: e.message });
429
1239
  }
430
1240
  });
1241
+ /** GET /api/agent/memory/scope-keys — distinct scope_key per scope (for Settings > Memory dropdowns). */
1242
+ app.get('/api/agent/memory/scope-keys', (_req, res) => {
1243
+ try {
1244
+ const keys = listAgentMemoryScopeKeys();
1245
+ res.json(keys);
1246
+ }
1247
+ catch (e) {
1248
+ log('gateway', 'error', e.message);
1249
+ res.status(500).json({ error: e.message });
1250
+ }
1251
+ });
1252
+ /** GET /api/agent/memory — list memory entries. Query: scope (session|shared), scope_key, limit (default 100). */
1253
+ app.get('/api/agent/memory', (req, res) => {
1254
+ try {
1255
+ const scope = req.query.scope === 'shared' ? 'shared' : 'session';
1256
+ const scopeKey = typeof req.query.scope_key === 'string' ? req.query.scope_key.trim() : '';
1257
+ if (!scopeKey) {
1258
+ res.status(400).json({ error: 'scope_key is required' });
1259
+ return;
1260
+ }
1261
+ const limit = Math.min(parseInt(req.query.limit || '100', 10), 500);
1262
+ const rows = listAgentMemories(scope, scopeKey, limit);
1263
+ const entries = rows.map((r) => ({
1264
+ id: r.id,
1265
+ scope: r.scope,
1266
+ scope_key: r.scope_key,
1267
+ content: r.content,
1268
+ created_at: r.created_at,
1269
+ }));
1270
+ res.json({ entries });
1271
+ }
1272
+ catch (e) {
1273
+ log('gateway', 'error', e.message);
1274
+ res.status(500).json({ error: e.message });
1275
+ }
1276
+ });
431
1277
  app.post('/api/agent/sessions', (req, res) => {
432
1278
  try {
433
1279
  const sessionKey = req.body?.session_key || `default_${Date.now()}`;
@@ -463,6 +1309,17 @@ export function createGateway(appMount = null) {
463
1309
  msg.tool_calls = [];
464
1310
  }
465
1311
  }
1312
+ if (r.usage) {
1313
+ try {
1314
+ msg.usage = JSON.parse(r.usage);
1315
+ }
1316
+ catch {
1317
+ // ignore
1318
+ }
1319
+ }
1320
+ const costUsd = r.cost_usd;
1321
+ if (costUsd != null)
1322
+ msg.cost_usd = costUsd;
466
1323
  return msg;
467
1324
  });
468
1325
  res.json({ ...session, messages });
@@ -485,19 +1342,55 @@ export function createGateway(appMount = null) {
485
1342
  }
486
1343
  const controller = new AbortController();
487
1344
  req.on('close', () => controller.abort());
488
- const { message, system_prompt, provider, model, max_tokens, timeout_ms } = req.body || {};
1345
+ const { message, system_prompt, provider, model, max_tokens, timeout_ms, use_pi, required_integrations, attachment_urls } = req.body || {};
1346
+ const urls = Array.isArray(attachment_urls) ? attachment_urls.filter((u) => typeof u === 'string' && u.trim()) : [];
1347
+ const userMessageText = typeof message === 'string' ? message : '';
1348
+ const fullUserMessage = urls.length > 0 ? `${userMessageText}\n\n[Attached media (use these URLs when posting images, e.g. Facebook photo post): ${urls.join(', ')}]` : userMessageText;
1349
+ const required = Array.isArray(required_integrations)
1350
+ ? required_integrations.filter((k) => typeof k === 'string' && k.trim())
1351
+ : [];
1352
+ if (required.length > 0) {
1353
+ const connected = await getConnectedIntegrationIds();
1354
+ const missing = required.filter((k) => !connected.includes(k));
1355
+ if (missing.length > 0) {
1356
+ res.status(422).json({
1357
+ type: 'missing_integrations',
1358
+ error: 'Missing required integrations',
1359
+ missing,
1360
+ });
1361
+ return;
1362
+ }
1363
+ }
489
1364
  const timeoutMs = typeof timeout_ms === 'number' ? timeout_ms : config.agentTimeoutMs || 0;
1365
+ const usePi = config.agentUsePi || use_pi === true || use_pi === '1';
1366
+ if (usePi && !isPiAvailable()) {
1367
+ res.status(503).json({
1368
+ error: 'Pi runner requested but not available. Install optional deps: npm install @mariozechner/pi-agent-core @mariozechner/pi-ai @mariozechner/pi-coding-agent',
1369
+ });
1370
+ return;
1371
+ }
490
1372
  try {
491
- const result = await withSessionLock(id, () => runAgentTurn({
492
- sessionId: id,
493
- userMessage: typeof message === 'string' ? message : null,
494
- systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
495
- provider: typeof provider === 'string' ? provider : undefined,
496
- model: typeof model === 'string' ? model : undefined,
497
- max_tokens: typeof max_tokens === 'number' ? max_tokens : undefined,
498
- timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
499
- signal: controller.signal,
500
- }));
1373
+ const result = await withSessionLock(id, () => usePi
1374
+ ? runAgentTurnWithPi({
1375
+ sessionId: id,
1376
+ userMessage: fullUserMessage || null,
1377
+ systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
1378
+ provider: typeof provider === 'string' ? provider : undefined,
1379
+ model: typeof model === 'string' ? model : undefined,
1380
+ max_tokens: typeof max_tokens === 'number' ? max_tokens : undefined,
1381
+ timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
1382
+ signal: controller.signal,
1383
+ })
1384
+ : runAgentTurn({
1385
+ sessionId: id,
1386
+ userMessage: fullUserMessage || null,
1387
+ systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
1388
+ provider: typeof provider === 'string' ? provider : undefined,
1389
+ model: typeof model === 'string' ? model : undefined,
1390
+ max_tokens: typeof max_tokens === 'number' ? max_tokens : undefined,
1391
+ timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
1392
+ signal: controller.signal,
1393
+ }));
501
1394
  res.json(result);
502
1395
  }
503
1396
  catch (e) {
@@ -506,6 +1399,7 @@ export function createGateway(appMount = null) {
506
1399
  res.status(499).json({ error: 'Client closed request or run timed out' });
507
1400
  return;
508
1401
  }
1402
+ console.error('[gateway] POST /api/agent/sessions/:id/messages error:', err.message, err.stack);
509
1403
  log('gateway', 'error', err.message, { stack: err.stack });
510
1404
  res.status(500).json({ error: err.message });
511
1405
  }
@@ -521,6 +1415,21 @@ export function createGateway(appMount = null) {
521
1415
  res.status(404).json({ error: 'Session not found' });
522
1416
  return;
523
1417
  }
1418
+ const bodyRequired = Array.isArray(req.body?.required_integrations)
1419
+ ? (req.body.required_integrations.filter((k) => typeof k === 'string' && k.trim()))
1420
+ : [];
1421
+ if (bodyRequired.length > 0) {
1422
+ const connected = await getConnectedIntegrationIds();
1423
+ const missing = bodyRequired.filter((k) => !connected.includes(k));
1424
+ if (missing.length > 0) {
1425
+ res.status(422).json({
1426
+ type: 'missing_integrations',
1427
+ error: 'Missing required integrations',
1428
+ missing,
1429
+ });
1430
+ return;
1431
+ }
1432
+ }
524
1433
  res.setHeader('Content-Type', 'text/event-stream');
525
1434
  res.setHeader('Cache-Control', 'no-cache');
526
1435
  res.setHeader('Connection', 'keep-alive');
@@ -538,12 +1447,17 @@ export function createGateway(appMount = null) {
538
1447
  send('start', { runId });
539
1448
  const controller = new AbortController();
540
1449
  req.on('close', () => controller.abort());
541
- const { message, system_prompt, provider, model, max_tokens, timeout_ms } = req.body || {};
1450
+ const { message, continue: continueAfterApproval, system_prompt, provider, model, max_tokens, timeout_ms, attachment_urls: streamAttachmentUrls } = req.body || {};
542
1451
  const timeoutMs = typeof timeout_ms === 'number' ? timeout_ms : config.agentTimeoutMs || 0;
1452
+ const isContinue = continueAfterApproval === true || continueAfterApproval === 'true';
1453
+ const streamUrls = Array.isArray(streamAttachmentUrls) ? streamAttachmentUrls.filter((u) => typeof u === 'string' && u.trim()) : [];
1454
+ const streamMsg = typeof message === 'string' ? message : '';
1455
+ const streamFullMessage = streamUrls.length > 0 ? `${streamMsg}\n\n[Attached media (use these URLs when posting images, e.g. Facebook photo post): ${streamUrls.join(', ')}]` : streamMsg;
1456
+ const userMessage = isContinue ? null : (streamFullMessage || null);
543
1457
  try {
544
- await withSessionLock(id, () => runAgentTurnStream({
1458
+ const runResult = await withSessionLock(id, () => runAgentTurnStream({
545
1459
  sessionId: id,
546
- userMessage: typeof message === 'string' ? message : null,
1460
+ userMessage,
547
1461
  systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
548
1462
  provider: typeof provider === 'string' ? provider : undefined,
549
1463
  model: typeof model === 'string' ? model : undefined,
@@ -553,19 +1467,25 @@ export function createGateway(appMount = null) {
553
1467
  }, (ev) => {
554
1468
  if (ev.type === 'assistant')
555
1469
  send('assistant', { delta: ev.delta });
1470
+ else if (ev.type === 'thinking')
1471
+ send('thinking', { delta: ev.delta });
556
1472
  else if (ev.type === 'tool_call')
557
1473
  send('tool_call', { name: ev.name, result: ev.result });
558
1474
  else if (ev.type === 'done')
559
- send('done', { finalContent: ev.finalContent, turnCount: ev.turnCount });
1475
+ send('done', { finalContent: ev.finalContent, turnCount: ev.turnCount, usage: ev.usage });
560
1476
  else if (ev.type === 'error')
561
1477
  send('error', { message: ev.message });
562
1478
  }));
1479
+ if (runResult.pendingActionId) {
1480
+ send('pending_approval', { pendingActionId: runResult.pendingActionId });
1481
+ }
563
1482
  }
564
1483
  catch (e) {
565
1484
  const err = e;
566
1485
  if (err.name === 'AbortError')
567
1486
  send('error', { message: 'Client closed request or run timed out' });
568
1487
  else {
1488
+ console.error('[gateway] POST /api/agent/sessions/:id/messages/stream error:', err.message, err.stack);
569
1489
  log('gateway', 'error', err.message, { stack: err.stack });
570
1490
  send('error', { message: err.message });
571
1491
  }
@@ -610,6 +1530,8 @@ export function createGateway(appMount = null) {
610
1530
  }, (ev) => {
611
1531
  if (ev.type === 'assistant')
612
1532
  sendWs('assistant', { delta: ev.delta });
1533
+ else if (ev.type === 'thinking')
1534
+ sendWs('thinking', { delta: ev.delta });
613
1535
  else if (ev.type === 'tool_call')
614
1536
  sendWs('tool_call', { name: ev.name, result: ev.result });
615
1537
  else if (ev.type === 'done')
@@ -666,6 +1588,279 @@ export function createGateway(appMount = null) {
666
1588
  res.status(500).json({ error: e.message });
667
1589
  }
668
1590
  });
1591
+ // Scheduled jobs (cron → task type)
1592
+ app.get('/api/schedules', (_req, res) => {
1593
+ try {
1594
+ const jobs = listScheduledJobs(false);
1595
+ res.json({ schedules: jobs });
1596
+ }
1597
+ catch (e) {
1598
+ log('gateway', 'error', e.message);
1599
+ res.status(500).json({ error: e.message });
1600
+ }
1601
+ });
1602
+ app.post('/api/schedules', (req, res) => {
1603
+ try {
1604
+ const { name, description, cron_expression, task_type, payload, prompt, delivery, provider, model } = req.body || {};
1605
+ if (!cron_expression?.trim()) {
1606
+ res.status(400).json({ error: 'cron_expression required' });
1607
+ return;
1608
+ }
1609
+ const isAgentJob = typeof prompt === 'string' && prompt.trim().length > 0;
1610
+ const effectiveType = isAgentJob ? 'agent_job' : (task_type?.trim() || 'agent_job');
1611
+ const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
1612
+ const payloadStr = !isAgentJob && payload != null ? JSON.stringify(payload) : null;
1613
+ const deliveryStr = isAgentJob && delivery != null ? JSON.stringify(delivery) : null;
1614
+ const providerStr = typeof provider === 'string' ? provider.trim() || null : null;
1615
+ const modelStr = typeof model === 'string' ? model.trim() || null : null;
1616
+ insertScheduledJob({
1617
+ id,
1618
+ name: name?.trim() ?? '',
1619
+ description: description?.trim() ?? '',
1620
+ cron_expression: cron_expression.trim(),
1621
+ task_type: effectiveType,
1622
+ payload: payloadStr,
1623
+ prompt: isAgentJob ? prompt.trim() : null,
1624
+ delivery: deliveryStr,
1625
+ provider: providerStr,
1626
+ model: modelStr,
1627
+ enabled: 1,
1628
+ });
1629
+ try {
1630
+ if (isAgentJob) {
1631
+ const deliveryList = Array.isArray(delivery) ? delivery : (delivery ? [delivery] : []);
1632
+ const agentPayload = {
1633
+ jobId: id,
1634
+ name: name?.trim() || id,
1635
+ prompt: prompt.trim(),
1636
+ delivery: deliveryList,
1637
+ };
1638
+ if (providerStr)
1639
+ agentPayload.provider = providerStr;
1640
+ if (modelStr)
1641
+ agentPayload.model = modelStr;
1642
+ scheduleCronById(id, cron_expression.trim(), 'agent_job', agentPayload);
1643
+ }
1644
+ else {
1645
+ scheduleCronById(id, cron_expression.trim(), effectiveType, payload ?? null);
1646
+ }
1647
+ }
1648
+ catch (e) {
1649
+ deleteScheduledJob(id);
1650
+ throw e;
1651
+ }
1652
+ const row = getScheduledJob(id);
1653
+ log('gateway', 'info', 'Schedule created', { id, task_type: effectiveType });
1654
+ res.status(201).json(row);
1655
+ }
1656
+ catch (e) {
1657
+ log('gateway', 'error', e.message);
1658
+ res.status(500).json({ error: e.message });
1659
+ }
1660
+ });
1661
+ app.patch('/api/schedules/:id', (req, res) => {
1662
+ try {
1663
+ const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
1664
+ if (!id) {
1665
+ res.status(400).json({ error: 'Schedule id required' });
1666
+ return;
1667
+ }
1668
+ const row = getScheduledJob(id);
1669
+ if (!row) {
1670
+ res.status(404).json({ error: 'Schedule not found' });
1671
+ return;
1672
+ }
1673
+ const { name, description, cron_expression, task_type, payload, prompt, delivery, provider, model, enabled } = req.body || {};
1674
+ const updates = {};
1675
+ if (typeof name === 'string')
1676
+ updates.name = name;
1677
+ if (typeof description === 'string')
1678
+ updates.description = description;
1679
+ if (typeof cron_expression === 'string')
1680
+ updates.cron_expression = cron_expression;
1681
+ if (typeof task_type === 'string')
1682
+ updates.task_type = task_type;
1683
+ if (payload !== undefined)
1684
+ updates.payload = payload == null ? null : JSON.stringify(payload);
1685
+ if (prompt !== undefined)
1686
+ updates.prompt = prompt == null ? null : String(prompt).trim() || null;
1687
+ if (delivery !== undefined)
1688
+ updates.delivery = delivery == null ? null : JSON.stringify(delivery);
1689
+ if (provider !== undefined)
1690
+ updates.provider = provider == null ? null : (typeof provider === 'string' ? provider.trim() || null : null);
1691
+ if (model !== undefined)
1692
+ updates.model = model == null ? null : (typeof model === 'string' ? model.trim() || null : null);
1693
+ if (enabled !== undefined)
1694
+ updates.enabled = enabled ? 1 : 0;
1695
+ updateScheduledJob(id, updates);
1696
+ unscheduleJob(id);
1697
+ const updated = getScheduledJob(id);
1698
+ if (updated.enabled) {
1699
+ if (updated.prompt?.trim()) {
1700
+ const deliveryList = updated.delivery?.trim() ? JSON.parse(updated.delivery) : [];
1701
+ const agentPayload = {
1702
+ jobId: id,
1703
+ name: updated.name || id,
1704
+ prompt: updated.prompt,
1705
+ delivery: deliveryList,
1706
+ };
1707
+ const prov = updated.provider;
1708
+ const mod = updated.model;
1709
+ if (prov?.trim())
1710
+ agentPayload.provider = prov.trim();
1711
+ if (mod?.trim())
1712
+ agentPayload.model = mod.trim();
1713
+ scheduleCronById(id, updated.cron_expression, 'agent_job', agentPayload);
1714
+ }
1715
+ else {
1716
+ const payloadVal = updated.payload ? JSON.parse(updated.payload) : null;
1717
+ scheduleCronById(id, updated.cron_expression, updated.task_type, payloadVal);
1718
+ }
1719
+ }
1720
+ log('gateway', 'info', 'Schedule updated', { id });
1721
+ res.json(updated);
1722
+ }
1723
+ catch (e) {
1724
+ log('gateway', 'error', e.message);
1725
+ res.status(500).json({ error: e.message });
1726
+ }
1727
+ });
1728
+ app.delete('/api/schedules/:id', (req, res) => {
1729
+ try {
1730
+ const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
1731
+ if (!id) {
1732
+ res.status(400).json({ error: 'Schedule id required' });
1733
+ return;
1734
+ }
1735
+ const row = getScheduledJob(id);
1736
+ if (!row) {
1737
+ res.status(404).json({ error: 'Schedule not found' });
1738
+ return;
1739
+ }
1740
+ unscheduleJob(id);
1741
+ deleteScheduledJob(id);
1742
+ log('gateway', 'info', 'Schedule deleted', { id });
1743
+ res.status(204).send();
1744
+ }
1745
+ catch (e) {
1746
+ log('gateway', 'error', e.message);
1747
+ res.status(500).json({ error: e.message });
1748
+ }
1749
+ });
1750
+ /** Run a scheduled job immediately (test run). */
1751
+ app.post('/api/schedules/:id/run', (req, res) => {
1752
+ try {
1753
+ const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
1754
+ if (!id) {
1755
+ res.status(400).json({ error: 'Schedule id required' });
1756
+ return;
1757
+ }
1758
+ const row = getScheduledJob(id);
1759
+ if (!row) {
1760
+ res.status(404).json({ error: 'Schedule not found' });
1761
+ return;
1762
+ }
1763
+ let payload;
1764
+ let taskType;
1765
+ if (row.prompt?.trim()) {
1766
+ const deliveryList = row.delivery?.trim() ? JSON.parse(row.delivery) : [];
1767
+ const r = row;
1768
+ const agentPayload = { jobId: id, name: row.name || id, prompt: row.prompt, delivery: deliveryList };
1769
+ if (r.provider?.trim())
1770
+ agentPayload.provider = r.provider.trim();
1771
+ if (r.model?.trim())
1772
+ agentPayload.model = r.model.trim();
1773
+ payload = agentPayload;
1774
+ taskType = 'agent_job';
1775
+ }
1776
+ else {
1777
+ payload = row.payload ? JSON.parse(row.payload) : null;
1778
+ taskType = row.task_type;
1779
+ }
1780
+ const taskId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
1781
+ insertTask({ id: taskId, type: taskType, payload, scheduled_at: Date.now() });
1782
+ const enqueueTaskId = app.locals.enqueueTaskId;
1783
+ if (typeof enqueueTaskId === 'function')
1784
+ enqueueTaskId(taskId);
1785
+ log('gateway', 'info', 'Schedule test run enqueued', { scheduleId: id, taskId });
1786
+ res.status(201).json({ id: taskId, type: taskType, status: 'pending' });
1787
+ }
1788
+ catch (e) {
1789
+ log('gateway', 'error', e.message);
1790
+ res.status(500).json({ error: e.message });
1791
+ }
1792
+ });
1793
+ /** Get run history for a scheduled job. */
1794
+ app.get('/api/schedules/:id/runs', (req, res) => {
1795
+ try {
1796
+ const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
1797
+ if (!id) {
1798
+ res.status(400).json({ error: 'Schedule id required' });
1799
+ return;
1800
+ }
1801
+ const row = getScheduledJob(id);
1802
+ if (!row) {
1803
+ res.status(404).json({ error: 'Schedule not found' });
1804
+ return;
1805
+ }
1806
+ const limit = Math.min(parseInt(req.query.limit || '30', 10), 100);
1807
+ const runs = getTasksForJob(id, limit);
1808
+ res.json({ runs });
1809
+ }
1810
+ catch (e) {
1811
+ log('gateway', 'error', e.message);
1812
+ res.status(500).json({ error: e.message });
1813
+ }
1814
+ });
1815
+ /** Chat attachments: upload image/file, get a URL the agent can use (e.g. for Facebook photo posts). */
1816
+ const uploadsDir = join(projectRoot, 'uploads');
1817
+ const ALLOWED_EXT = /\.(jpg|jpeg|png|gif|webp)$/i;
1818
+ app.post('/api/upload', express.json({ limit: '10mb' }), (req, res) => {
1819
+ try {
1820
+ const { filename, data } = req.body || {};
1821
+ if (typeof data !== 'string') {
1822
+ res.status(400).json({ error: 'Missing or invalid body: { filename, data (base64) }' });
1823
+ return;
1824
+ }
1825
+ const base = typeof filename === 'string' ? filename.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80) : 'file';
1826
+ const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')) : '';
1827
+ const safeExt = ALLOWED_EXT.test(ext) ? ext : '.jpg';
1828
+ const name = `${randomBytes(8).toString('hex')}${safeExt}`;
1829
+ if (!existsSync(uploadsDir))
1830
+ mkdirSync(uploadsDir, { recursive: true });
1831
+ const path = join(uploadsDir, name);
1832
+ const buf = Buffer.from(data, 'base64');
1833
+ if (buf.length > 8 * 1024 * 1024) {
1834
+ res.status(400).json({ error: 'File too large (max 8MB)' });
1835
+ return;
1836
+ }
1837
+ writeFileSync(path, buf);
1838
+ const host = req.get('host') || `${config.host}:${config.port}`;
1839
+ const protocol = req.get('x-forwarded-proto') || req.protocol || 'http';
1840
+ const url = `${protocol}://${host}/api/uploads/${name}`;
1841
+ res.json({ url, name });
1842
+ }
1843
+ catch (e) {
1844
+ log('gateway', 'error', e.message);
1845
+ res.status(500).json({ error: e.message });
1846
+ }
1847
+ });
1848
+ app.get('/api/uploads/:name', (req, res) => {
1849
+ const name = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name;
1850
+ if (!name || !/^[a-f0-9]{16}\.(jpg|jpeg|png|gif|webp)$/i.test(name)) {
1851
+ res.status(400).json({ error: 'Invalid upload name' });
1852
+ return;
1853
+ }
1854
+ const path = join(uploadsDir, name);
1855
+ if (!existsSync(path)) {
1856
+ res.status(404).json({ error: 'Not found' });
1857
+ return;
1858
+ }
1859
+ res.sendFile(path, { maxAge: 86400 * 7 }, (err) => {
1860
+ if (err)
1861
+ res.status(500).json({ error: err.message });
1862
+ });
1863
+ });
669
1864
  if (existsSync(dashboardDist)) {
670
1865
  app.use(express.static(dashboardDist));
671
1866
  app.get(/^\/(?!api|health|ws)/, (_req, res) => {