@sulala/agent 0.1.6 → 0.1.7

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