@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,11 +1,18 @@
1
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
2
- import { resolve, relative, dirname } from 'path';
1
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
2
+ import { resolve, relative, dirname, join } from 'path';
3
3
  import { spawnSync } from 'child_process';
4
4
  import { insertTask, getOrCreateAgentSession } from '../db/index.js';
5
- import { config } from '../config.js';
5
+ import { config, getPortalGatewayBase, getEffectivePortalApiKey } from '../config.js';
6
+ import { getEffectiveStripeSecretKey } from '../channels/stripe.js';
7
+ import { getEffectiveDiscordBotToken } from '../channels/discord.js';
8
+ import { appendMemory, listMemories, getSharedScopeKeyForSession, } from './memory.js';
9
+ import { addWatchPaths } from '../watcher/index.js';
10
+ import { createPendingAction } from './pending-actions.js';
6
11
  import { getAllRequiredBins } from './skills.js';
7
- import { getSkillConfigEnv } from './skills-config.js';
12
+ import { getSkillConfigEnv, getSkillToolPolicy } from './skills-config.js';
13
+ import { redactSecretKeysInSummary } from '../redact.js';
8
14
  import { runAgentTurn } from './loop.js';
15
+ import { getPluginTools } from '../plugins/index.js';
9
16
  const registry = new Map();
10
17
  /** Current agent run depth (0 = top-level). Used to block nested run_agent. */
11
18
  let agentRunDepth = 0;
@@ -21,11 +28,17 @@ export function registerTool(tool) {
21
28
  export function getTool(name) {
22
29
  return registry.get(name);
23
30
  }
24
- /** List tools, optionally filtered by allowlist and profile. */
25
- export function listTools(options) {
26
- let out = [...registry.values()];
27
- const allowlist = options?.allowlist ?? config.agentToolAllowlist;
28
- const profile = options?.profile ?? config.agentToolProfile;
31
+ /** Tool names that perform writes; excluded when a skill has readOnly: true. */
32
+ const WRITE_TOOL_NAMES = new Set([
33
+ 'write_file',
34
+ 'run_command',
35
+ 'register_automation',
36
+ ]);
37
+ /** Policy step: filter tools by allowlist and profile. */
38
+ export function applyToolPolicyPipeline(tools, options) {
39
+ let out = tools;
40
+ const allowlist = options.allowlist ?? config.agentToolAllowlist;
41
+ const profile = options.profile ?? config.agentToolProfile;
29
42
  if (allowlist?.length) {
30
43
  const set = new Set(allowlist.map((n) => n.trim().toLowerCase()));
31
44
  out = out.filter((t) => set.has(t.name.toLowerCase()));
@@ -37,21 +50,84 @@ export function listTools(options) {
37
50
  }
38
51
  return out;
39
52
  }
53
+ /** List tools: core registry + plugin tools, then policy (allowlist + profile). When skillSlug is set, apply that skill's allowedTools and readOnly. */
54
+ export function listTools(options) {
55
+ const core = [...registry.values()];
56
+ const pluginTools = getPluginTools(options?.pluginContext ?? {});
57
+ const nameSet = new Set(core.map((t) => t.name.toLowerCase()));
58
+ const merged = [...core];
59
+ for (const t of pluginTools) {
60
+ if (nameSet.has(t.name.toLowerCase()))
61
+ continue;
62
+ nameSet.add(t.name.toLowerCase());
63
+ merged.push(t);
64
+ }
65
+ let allowlist = options?.allowlist ?? config.agentToolAllowlist;
66
+ if (options?.skillSlug) {
67
+ const policy = getSkillToolPolicy(options.skillSlug);
68
+ if (policy.allowlist !== null)
69
+ allowlist = policy.allowlist;
70
+ let out = applyToolPolicyPipeline(merged, {
71
+ allowlist,
72
+ profile: options?.profile ?? config.agentToolProfile,
73
+ });
74
+ if (policy.readOnly) {
75
+ out = out.filter((t) => !WRITE_TOOL_NAMES.has(t.name));
76
+ }
77
+ return out;
78
+ }
79
+ return applyToolPolicyPipeline(merged, {
80
+ allowlist,
81
+ profile: options?.profile ?? config.agentToolProfile,
82
+ });
83
+ }
40
84
  const PROFILE_TOOLS = {
41
- messaging: new Set(['run_task']),
42
- coding: new Set(['run_task', 'read_file', 'write_file', 'run_command']),
85
+ messaging: new Set([
86
+ 'run_task',
87
+ 'run_command',
88
+ 'list_integrations_connections',
89
+ 'get_connection_token',
90
+ 'bluesky_post',
91
+ 'write_memory',
92
+ 'read_memory',
93
+ ]),
94
+ coding: new Set([
95
+ 'run_task',
96
+ 'read_file',
97
+ 'write_file',
98
+ 'run_command',
99
+ 'list_integrations_connections',
100
+ 'get_connection_token',
101
+ 'bluesky_post',
102
+ 'write_memory',
103
+ 'read_memory',
104
+ ]),
43
105
  minimal: new Set(['run_task']),
44
106
  };
45
- /** Execute a tool; throws if tool is unknown or not allowed by current allowlist/profile. */
46
- export function executeTool(name, args) {
47
- const tool = registry.get(name);
48
- if (!tool)
49
- throw new Error(`Unknown tool: ${name}`);
50
- const allowed = listTools();
51
- if (!allowed.some((t) => t.name === tool.name)) {
52
- throw new Error(`Tool "${name}" is not allowed by current tool allowlist/profile`);
107
+ /** Execute a tool; throws if tool is unknown or not allowed. When execution preview is on and tool is high-risk, returns pending-approval payload instead of running. */
108
+ export function executeTool(name, args, opts) {
109
+ const allowed = listTools({ skillSlug: opts?.skillSlug });
110
+ const tool = allowed.find((t) => t.name === name);
111
+ if (!tool) {
112
+ throw new Error(opts?.skillSlug ? `Tool "${name}" is not allowed for this skill` : `Unknown tool: ${name}`);
53
113
  }
54
- const result = tool.execute(args);
114
+ if (config.agentExecutionPreview &&
115
+ WRITE_TOOL_NAMES.has(name) &&
116
+ !opts?.skipApproval &&
117
+ opts?.sessionId &&
118
+ opts?.toolCallId) {
119
+ const pendingActionId = createPendingAction(opts.sessionId, opts.toolCallId, name, args);
120
+ const message = `This action requires your approval: ${name}. Approve or reject in the dashboard.`;
121
+ return Promise.resolve({ __pendingApproval: true, pendingActionId, message });
122
+ }
123
+ const context = opts?.toolCallId != null || opts?.signal != null || opts?.sessionId != null
124
+ ? {
125
+ toolCallId: opts.toolCallId,
126
+ signal: opts.signal,
127
+ sessionId: opts.sessionId,
128
+ }
129
+ : undefined;
130
+ const result = tool.execute(args, context);
55
131
  return Promise.resolve(result);
56
132
  }
57
133
  /** Built-in: enqueue a task (type + optional payload) */
@@ -108,7 +184,60 @@ export function registerBuiltInTools(enqueueTask) {
108
184
  return { error: `binary "${binary}" is not in ALLOWED_BINARIES (allowed: ${allowed.join(', ')})` };
109
185
  }
110
186
  const rawArgs = Array.isArray(args.args) ? args.args : [];
111
- const argsList = rawArgs.map((a) => String(a).replace(/\0/g, '').trim()).filter((_, i) => i < 50);
187
+ let argsList = rawArgs.map((a) => String(a).replace(/\0/g, '').trim()).filter((_, i) => i < 50);
188
+ // Expand $PORTAL_GATEWAY_URL and $PORTAL_API_KEY in args (no shell in spawnSync, so LLM's $VAR stays literal and curl gets "Couldn't resolve host")
189
+ const portalGatewayBase = getPortalGatewayBase() || process.env.PORTAL_GATEWAY_URL || '';
190
+ const portalApiKey = getEffectivePortalApiKey() || process.env.PORTAL_API_KEY || '';
191
+ if (portalGatewayBase || portalApiKey) {
192
+ argsList = argsList.map((a) => {
193
+ let s = a;
194
+ if (portalGatewayBase && s.includes('$PORTAL_GATEWAY_URL'))
195
+ s = s.replace(/\$PORTAL_GATEWAY_URL/g, portalGatewayBase);
196
+ if (portalApiKey && s.includes('$PORTAL_API_KEY'))
197
+ s = s.replace(/\$PORTAL_API_KEY/g, portalApiKey);
198
+ return s;
199
+ });
200
+ }
201
+ // Resolve portal gateway URLs for curl: .../connections/conn_xxx/use and .../connections/conn_xxx/bsky-request
202
+ if (binary === 'curl' && portalGatewayBase && argsList.length > 0) {
203
+ const argsStr = argsList.join(' ');
204
+ const useMatch = argsStr.match(/\/connections\/(conn_[a-zA-Z0-9_]+)\/use/);
205
+ const bskyMatch = argsStr.match(/\/connections\/(conn_[a-zA-Z0-9_]+)\/bsky-request/);
206
+ const pathMatch = useMatch || bskyMatch;
207
+ const pathSuffix = useMatch ? '/use' : bskyMatch ? '/bsky-request' : '';
208
+ if (pathMatch) {
209
+ const path = `/connections/${pathMatch[1]}${pathSuffix}`;
210
+ const resolved = portalGatewayBase.replace(/\/$/, '') + path;
211
+ const postIdx = argsList.indexOf('POST');
212
+ const urlIdx = postIdx >= 0
213
+ ? postIdx + 1
214
+ : argsList.findIndex((a) => a.includes('/connections/') && (a.includes('/use') || a.includes('/bsky-request')));
215
+ if (urlIdx >= 0 && urlIdx < argsList.length) {
216
+ const urlArg = argsList[urlIdx];
217
+ const isBroken = urlArg.includes('$') ||
218
+ !urlArg.startsWith('http') ||
219
+ (() => {
220
+ try {
221
+ const u = new URL(urlArg);
222
+ return !u.hostname || u.hostname.includes('$');
223
+ }
224
+ catch {
225
+ return true;
226
+ }
227
+ })();
228
+ if (isBroken || urlArg !== resolved) {
229
+ console.log('agent', 'info', 'run_command: resolved portal URL for curl', {
230
+ host: new URL(resolved).hostname,
231
+ path: pathSuffix,
232
+ });
233
+ argsList = [...argsList.slice(0, urlIdx), resolved, ...argsList.slice(urlIdx + 1)];
234
+ if (argsList[urlIdx + 1] === path) {
235
+ argsList = [...argsList.slice(0, urlIdx + 1), ...argsList.slice(urlIdx + 2)];
236
+ }
237
+ }
238
+ }
239
+ }
240
+ }
112
241
  if (binary === 'curl') {
113
242
  const allowedHosts = (process.env.ALLOWED_CURL_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
114
243
  if (allowedHosts.length > 0) {
@@ -129,14 +258,49 @@ export function registerBuiltInTools(enqueueTask) {
129
258
  try {
130
259
  const skillEnv = getSkillConfigEnv();
131
260
  const env = { ...process.env, ...skillEnv };
261
+ if (process.env.DEBUG || process.env.SULALA_LOG_SKILL_ENV) {
262
+ const keys = Object.keys(skillEnv).sort();
263
+ const summary = keys.map((k) => {
264
+ const v = skillEnv[k];
265
+ const len = typeof v === 'string' ? v.length : 0;
266
+ return `${k} (${len > 0 ? `${len} chars` : 'empty'})`;
267
+ });
268
+ console.log('agent', 'info', `run_command skill env: ${redactSecretKeysInSummary(summary).join(', ')}`, { binary, keyCount: keys.length });
269
+ }
132
270
  const result = spawnSync(binary, argsList, { encoding: 'utf8', timeout: 30000, env });
133
271
  if (result.error)
134
272
  return { error: result.error.message };
135
- return {
273
+ // Log token request result so we can confirm gateway returned accessToken (do not log the token)
274
+ if (binary === 'curl' && argsList.length > 0) {
275
+ const argsStr = argsList.join(' ');
276
+ const tokenUseMatch = argsStr.match(/\/connections\/(conn_[a-zA-Z0-9_]+)\/use/);
277
+ if (tokenUseMatch) {
278
+ const stdout = result.stdout ?? '';
279
+ const gotToken = stdout.includes('accessToken') && !stdout.trimStart().startsWith('{"error"');
280
+ console.log('agent', 'info', 'run_command: token request (POST .../connections/<id>/use) result', {
281
+ connection_id: tokenUseMatch[1],
282
+ gotToken,
283
+ exitCode: result.status ?? null,
284
+ });
285
+ }
286
+ }
287
+ const out = {
136
288
  status: result.status ?? null,
137
289
  stdout: result.stdout?.trim() ?? '',
138
290
  stderr: result.stderr?.trim() ?? '',
139
291
  };
292
+ // When agent calls Gmail/Calendar/Google API without token, 401 is returned. Add a hint so the model retries with token-first flow.
293
+ if (binary === 'curl' && argsList.length > 0) {
294
+ const argsStr = argsList.join(' ');
295
+ const stdout = result.stdout ?? '';
296
+ const isGoogleApi = argsStr.includes('gmail.googleapis.com') || argsStr.includes('www.googleapis.com') || argsStr.includes('sheets.googleapis.com');
297
+ const is401 = stdout.includes('401') && (stdout.includes('invalid authentication') || stdout.includes('invalid credentials'));
298
+ if (isGoogleApi && is401) {
299
+ out._hint =
300
+ 'Gmail/Calendar/Google API returned 401. Get an OAuth token first: call get_connection_token(connection_id) with the connection_id from list_integrations_connections, then call the Gmail/API URL again with -H "Authorization: Bearer <accessToken>" using the token from that result.';
301
+ }
302
+ }
303
+ return out;
140
304
  }
141
305
  catch (e) {
142
306
  return { error: e.message };
@@ -144,16 +308,21 @@ export function registerBuiltInTools(enqueueTask) {
144
308
  },
145
309
  });
146
310
  }
147
- if (config.agentWorkspaceRoot) {
148
- const workspaceRoot = resolve(process.cwd(), config.agentWorkspaceRoot);
311
+ if (config.agentWorkspaceRoot || config.workspaceDir) {
312
+ const workspaceRoot = config.agentWorkspaceRoot ? resolve(process.cwd(), config.agentWorkspaceRoot) : null;
313
+ const workspaceDirResolved = config.workspaceDir ? resolve(config.workspaceDir) : null;
314
+ const workspaceSkillsDir = config.skillsWorkspaceDir ? resolve(config.skillsWorkspaceDir) : null;
315
+ const sulalaHomeDir = config.workspaceDir ? resolve(config.workspaceDir, '..') : null;
316
+ const allowedReadRoots = [workspaceRoot, workspaceDirResolved, workspaceSkillsDir, sulalaHomeDir].filter(Boolean);
317
+ const defaultRoot = workspaceRoot ?? workspaceDirResolved ?? process.cwd();
149
318
  registerTool({
150
319
  name: 'read_file',
151
- description: 'Read the contents of a file from the workspace. Path is relative to the workspace root.',
320
+ description: 'Read the contents of a file. Path is relative to the workspace root, or absolute (e.g. user workspace dir for scripts/.env/automations).',
152
321
  profile: 'coding',
153
322
  parameters: {
154
323
  type: 'object',
155
324
  properties: {
156
- path: { type: 'string', description: 'Relative path to the file (e.g. README.md, src/index.ts)' },
325
+ path: { type: 'string', description: 'Path: relative to workspace (e.g. README.md) or absolute (e.g. ~/.sulala/workspace/scripts/foo.sh)' },
157
326
  },
158
327
  required: ['path'],
159
328
  },
@@ -161,30 +330,32 @@ export function registerBuiltInTools(enqueueTask) {
161
330
  const rawPath = args.path || '';
162
331
  if (!rawPath.trim())
163
332
  return { error: 'path is required' };
164
- const requested = resolve(workspaceRoot, rawPath.trim());
165
- const relativePath = relative(workspaceRoot, requested);
166
- if (relativePath.startsWith('..')) {
167
- return { error: 'path must be inside the workspace' };
333
+ const trimmed = rawPath.trim();
334
+ const requested = trimmed.startsWith('/') ? resolve(trimmed) : resolve(defaultRoot, trimmed);
335
+ const insideAllowed = allowedReadRoots.some((root) => requested === root || relative(root, requested).startsWith('..') === false);
336
+ if (!insideAllowed) {
337
+ return { error: 'path must be inside the workspace, workspace dir (scripts/.env), skills dir, or ~/.sulala (read-only)' };
168
338
  }
169
339
  if (!existsSync(requested))
170
- return { error: `file not found: ${rawPath}` };
340
+ return { error: `file not found: ${trimmed}` };
171
341
  try {
172
342
  const content = readFileSync(requested, 'utf8');
173
- return { path: rawPath.trim(), content };
343
+ return { path: trimmed, content };
174
344
  }
175
345
  catch (e) {
176
346
  return { error: e.message };
177
347
  }
178
348
  },
179
349
  });
350
+ const allowedWriteRoots = [workspaceRoot, workspaceDirResolved, workspaceSkillsDir].filter(Boolean);
180
351
  registerTool({
181
352
  name: 'write_file',
182
- description: 'Write content to a file in the workspace. Path is relative to the workspace root. Creates parent directories if needed.',
353
+ description: 'Write content to a file. Use for scripts in workspace/scripts/, credentials in workspace/.env, or skills in workspace/skills/. Path can be relative to workspace root or absolute.',
183
354
  profile: 'coding',
184
355
  parameters: {
185
356
  type: 'object',
186
357
  properties: {
187
- path: { type: 'string', description: 'Relative path to the file (e.g. out.txt, src/foo.js)' },
358
+ path: { type: 'string', description: 'Path: relative to workspace (e.g. out.txt) or absolute (e.g. workspace/scripts/watch_bluesky.sh or workspace/.env)' },
188
359
  content: { type: 'string', description: 'Content to write' },
189
360
  },
190
361
  required: ['path', 'content'],
@@ -196,24 +367,155 @@ export function registerBuiltInTools(enqueueTask) {
196
367
  return { error: 'path is required' };
197
368
  if (content === undefined || content === null)
198
369
  return { error: 'content is required' };
199
- const requested = resolve(workspaceRoot, rawPath.trim());
200
- const relativePath = relative(workspaceRoot, requested);
201
- if (relativePath.startsWith('..')) {
202
- return { error: 'path must be inside the workspace' };
370
+ const trimmed = rawPath.trim();
371
+ const requested = trimmed.startsWith('/') ? resolve(trimmed) : resolve(defaultRoot, trimmed);
372
+ const insideAllowed = allowedWriteRoots.some((root) => requested === root || relative(root, requested).startsWith('..') === false);
373
+ if (!insideAllowed) {
374
+ return { error: 'path must be inside the workspace, workspace dir (scripts/.env), or skills dir' };
203
375
  }
204
376
  try {
205
377
  const dir = dirname(requested);
206
378
  if (!existsSync(dir))
207
379
  mkdirSync(dir, { recursive: true });
208
380
  writeFileSync(requested, typeof content === 'string' ? content : String(content), 'utf8');
209
- return { path: rawPath.trim(), written: true };
381
+ const ext = requested.toLowerCase().slice(requested.lastIndexOf('.'));
382
+ if (['.sh', '.bash', '.py'].includes(ext)) {
383
+ try {
384
+ chmodSync(requested, 0o755);
385
+ }
386
+ catch {
387
+ // ignore chmod errors
388
+ }
389
+ }
390
+ return { path: trimmed, written: true };
391
+ }
392
+ catch (e) {
393
+ return { error: e.message };
394
+ }
395
+ },
396
+ });
397
+ }
398
+ if (config.workspaceDir) {
399
+ registerTool({
400
+ name: 'register_automation',
401
+ description: 'Register a watch-folder automation so the agent runs a script when files are added. Use after creating a script in workspace/scripts/ and (optionally) storing credentials in workspace/.env. Script is run with the file path as first argument and env loaded from workspace/.env. Adds the watch folder to the agent config so file events are emitted.',
402
+ profile: 'coding',
403
+ parameters: {
404
+ type: 'object',
405
+ properties: {
406
+ id: { type: 'string', description: 'Unique id for this automation (e.g. watch_bluesky)' },
407
+ script: { type: 'string', description: 'Path to script under workspace, e.g. scripts/watch_bluesky.sh' },
408
+ watch_folders: {
409
+ type: 'array',
410
+ items: { type: 'string' },
411
+ description: 'Absolute paths of folders to watch (e.g. ["/Users/me/Desktop/bluesky"])',
412
+ },
413
+ filter: { type: 'string', description: 'Optional: "image" to run only for image files (jpg,png,gif,webp); omit for all files' },
414
+ },
415
+ required: ['id', 'script', 'watch_folders'],
416
+ },
417
+ execute: (args) => {
418
+ const id = String(args.id ?? '').trim();
419
+ const script = String(args.script ?? '').trim();
420
+ const watchFolders = Array.isArray(args.watch_folders)
421
+ ? args.watch_folders.map((s) => String(s).trim()).filter(Boolean)
422
+ : [];
423
+ const filter = typeof args.filter === 'string' ? args.filter.trim() : undefined;
424
+ if (!id)
425
+ return { error: 'id is required' };
426
+ if (!script)
427
+ return { error: 'script is required' };
428
+ if (!watchFolders.length)
429
+ return { error: 'watch_folders must be a non-empty array' };
430
+ const workspaceDirResolved = resolve(config.workspaceDir);
431
+ const automationsPath = join(workspaceDirResolved, 'automations.json');
432
+ let automations = {};
433
+ if (existsSync(automationsPath)) {
434
+ try {
435
+ automations = JSON.parse(readFileSync(automationsPath, 'utf8'));
436
+ }
437
+ catch {
438
+ return { error: 'Could not read existing automations.json' };
439
+ }
440
+ }
441
+ const list = Array.isArray(automations.automations) ? automations.automations : [];
442
+ const existing = list.findIndex((a) => a.id === id);
443
+ const entry = { id, script, watch_folders: watchFolders, filter };
444
+ if (existing >= 0)
445
+ list[existing] = entry;
446
+ else
447
+ list.push(entry);
448
+ automations.automations = list;
449
+ try {
450
+ mkdirSync(workspaceDirResolved, { recursive: true });
451
+ writeFileSync(automationsPath, JSON.stringify(automations, null, 2), 'utf8');
210
452
  }
211
453
  catch (e) {
212
454
  return { error: e.message };
213
455
  }
456
+ addWatchPaths(watchFolders);
457
+ return { written: true, automation: entry };
214
458
  },
215
459
  });
216
460
  }
461
+ registerTool({
462
+ name: 'write_memory',
463
+ description: 'Store a durable note the agent can recall later. Use for user preferences, decisions, or facts the user asked to remember. Session memory is for this conversation only; shared memory persists across sessions (e.g. per user).',
464
+ profile: 'full',
465
+ parameters: {
466
+ type: 'object',
467
+ properties: {
468
+ content: { type: 'string', description: 'The fact or note to remember (concise)' },
469
+ scope: {
470
+ type: 'string',
471
+ enum: ['session', 'shared'],
472
+ description: 'session = this conversation only; shared = across sessions for this identity (default: session)',
473
+ },
474
+ },
475
+ required: ['content'],
476
+ },
477
+ execute: (args, context) => {
478
+ const sessionId = context?.sessionId;
479
+ if (!sessionId)
480
+ return { error: 'Memory tools require an active session' };
481
+ const content = typeof args.content === 'string' ? args.content.trim() : '';
482
+ if (!content)
483
+ return { error: 'content is required' };
484
+ const scope = (args.scope === 'shared' ? 'shared' : 'session');
485
+ const scopeKey = scope === 'session' ? sessionId : getSharedScopeKeyForSession(sessionId) ?? sessionId;
486
+ const row = appendMemory(scope, scopeKey, content);
487
+ return { ok: true, id: row.id, scope, message: 'Stored in memory.' };
488
+ },
489
+ });
490
+ registerTool({
491
+ name: 'read_memory',
492
+ description: 'Recall stored memory. Use when the user asks what you remember or to list recent notes. Session = this conversation; shared = across sessions.',
493
+ profile: 'full',
494
+ parameters: {
495
+ type: 'object',
496
+ properties: {
497
+ scope: {
498
+ type: 'string',
499
+ enum: ['session', 'shared'],
500
+ description: 'session = this conversation only; shared = across sessions (default: session)',
501
+ },
502
+ limit: { type: 'number', description: 'Max number of entries to return (default 20)' },
503
+ },
504
+ },
505
+ execute: (args, context) => {
506
+ const sessionId = context?.sessionId;
507
+ if (!sessionId)
508
+ return { error: 'Memory tools require an active session' };
509
+ const scope = (args.scope === 'shared' ? 'shared' : 'session');
510
+ const scopeKey = scope === 'session' ? sessionId : getSharedScopeKeyForSession(sessionId) ?? sessionId;
511
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(args.limit, 100) : 20;
512
+ const entries = listMemories(scope, scopeKey, limit);
513
+ const text = entries.length
514
+ ? entries.map((e) => `[${e.id}] ${e.content}`).join('\n')
515
+ : 'No stored memory for this scope.';
516
+ return { entries, text };
517
+ },
518
+ });
217
519
  registerTool({
218
520
  name: 'run_agent',
219
521
  description: 'Run a sub-agent in a separate session: create or reuse a session by key, send a message, and get the final response. Use for delegated research or a one-off task in isolation. Do not call run_agent from within another run_agent.',
@@ -255,5 +557,356 @@ export function registerBuiltInTools(enqueueTask) {
255
557
  }
256
558
  },
257
559
  });
560
+ function filterByProvider(list, provider) {
561
+ if (!provider)
562
+ return list.map((c) => ({ connection_id: c.connection_id ?? c.id ?? '', provider: c.provider }));
563
+ const p = provider.toLowerCase();
564
+ const filtered = list.filter((c) => (c.provider || '').toLowerCase() === p);
565
+ return filtered.map((c) => ({ connection_id: c.connection_id ?? c.id ?? '', provider: c.provider }));
566
+ }
567
+ /** List OAuth connections so the agent can get connection_id for skills (run_command + curl). Use the exact provider for the integration you need (e.g. calendar, gmail, drive, github, slack). Stripe and Discord are not OAuth; do not use this for them—use stripe_list_customers and discord_* tools instead (they use Settings → Channels). */
568
+ registerTool({
569
+ name: 'list_integrations_connections',
570
+ description: 'List connected OAuth integrations only. Returns connection_id and provider for each. Use provider: "calendar", "gmail", "drive", "docs", "sheets", "slides", "github", "slack", "notion", "linear", "zoom", etc. Do not use for Stripe or Discord—those use API keys from Settings → Channels; use stripe_list_customers and discord_list_guilds / discord_send_message instead. Requires PORTAL_GATEWAY_URL + PORTAL_API_KEY or INTEGRATIONS_URL.',
571
+ profile: 'full',
572
+ parameters: {
573
+ type: 'object',
574
+ properties: {
575
+ provider: {
576
+ type: 'string',
577
+ description: 'Exact provider to filter: "calendar", "gmail", "drive", "docs", "sheets", "slides", "github", "slack", "notion", "linear", "zoom", etc. Optional; omit to list all.',
578
+ },
579
+ },
580
+ required: [],
581
+ },
582
+ execute: async (args) => {
583
+ const portalGatewayBase = getPortalGatewayBase();
584
+ const portalKey = getEffectivePortalApiKey();
585
+ const provider = typeof args.provider === 'string' ? args.provider.trim() : undefined;
586
+ if (portalGatewayBase && portalKey) {
587
+ try {
588
+ const res = await fetch(`${portalGatewayBase}/connections`, {
589
+ headers: { Authorization: `Bearer ${portalKey}` },
590
+ });
591
+ if (!res.ok)
592
+ return { error: `Portal gateway: ${res.status}` };
593
+ const data = (await res.json());
594
+ const list = data.connections || [];
595
+ const filtered = filterByProvider(list, provider);
596
+ console.log('agent', 'info', 'list_integrations_connections: portal gateway', {
597
+ provider: provider ?? '(all)',
598
+ rawCount: list.length,
599
+ filteredCount: filtered.length,
600
+ connections: filtered.map((c) => ({ id: c.connection_id, provider: c.provider })),
601
+ });
602
+ return {
603
+ connections: filtered.map((c) => ({ id: c.connection_id, provider: c.provider })),
604
+ count: filtered.length,
605
+ };
606
+ }
607
+ catch (e) {
608
+ return { error: e.message };
609
+ }
610
+ }
611
+ const base = config.integrationsUrl?.replace(/\/$/, '');
612
+ if (!base)
613
+ return { error: 'Set PORTAL_GATEWAY_URL + PORTAL_API_KEY (from Portal → API Keys) or INTEGRATIONS_URL' };
614
+ const q = provider ? `?provider=${encodeURIComponent(provider)}` : '';
615
+ try {
616
+ const res = await fetch(`${base}/connections${q}`);
617
+ if (!res.ok)
618
+ return { error: `Integrations: ${res.status}` };
619
+ const data = (await res.json());
620
+ const raw = (data.connections || []).map((c) => ({ connection_id: c.id, id: c.id, provider: c.provider }));
621
+ const filtered = filterByProvider(raw, provider);
622
+ console.log('agent', 'info', 'list_integrations_connections: integrations URL', {
623
+ provider: provider ?? '(all)',
624
+ rawCount: raw.length,
625
+ filteredCount: filtered.length,
626
+ connections: filtered.map((c) => ({ id: c.connection_id, provider: c.provider })),
627
+ });
628
+ return {
629
+ connections: filtered.map((c) => ({ id: c.connection_id, provider: c.provider })),
630
+ count: filtered.length,
631
+ };
632
+ }
633
+ catch (e) {
634
+ return { error: e.message };
635
+ }
636
+ },
637
+ });
638
+ /** Get OAuth access token for a connection. Call this before calling Gmail/Calendar/Drive etc. APIs—the agent cannot reliably curl the portal from run_command; use this tool instead, then pass accessToken to run_command (curl) for the provider API. */
639
+ registerTool({
640
+ name: 'get_connection_token',
641
+ description: 'Get an OAuth access token for a connected integration. Call list_integrations_connections first to get connection_id, then call this with that connection_id. Returns accessToken to use in the next run_command (curl) as header: Authorization: Bearer <accessToken>. Required before any Gmail, Calendar, Drive, GitHub, Slack, etc. API call.',
642
+ profile: 'full',
643
+ parameters: {
644
+ type: 'object',
645
+ properties: {
646
+ connection_id: {
647
+ type: 'string',
648
+ description: 'Connection ID from list_integrations_connections (e.g. conn_gmail_..., conn_calendar_...).',
649
+ },
650
+ },
651
+ required: ['connection_id'],
652
+ },
653
+ execute: async (args) => {
654
+ const connectionId = typeof args.connection_id === 'string' ? args.connection_id.trim() : '';
655
+ if (!connectionId)
656
+ return { error: 'connection_id is required' };
657
+ const portalGatewayBase = getPortalGatewayBase();
658
+ const portalKey = getEffectivePortalApiKey();
659
+ if (!portalGatewayBase || !portalKey) {
660
+ return { error: 'Set PORTAL_GATEWAY_URL and PORTAL_API_KEY (from Portal → API Keys)' };
661
+ }
662
+ try {
663
+ const url = `${portalGatewayBase.replace(/\/$/, '')}/connections/${encodeURIComponent(connectionId)}/use`;
664
+ const res = await fetch(url, {
665
+ method: 'POST',
666
+ headers: { Authorization: `Bearer ${portalKey}` },
667
+ });
668
+ if (!res.ok) {
669
+ const text = await res.text();
670
+ return { error: `Portal gateway: ${res.status}`, detail: text.slice(0, 200) };
671
+ }
672
+ const data = (await res.json());
673
+ if (data.error)
674
+ return { error: data.error };
675
+ if (data.useProxy && data.provider === 'bluesky') {
676
+ console.log('agent', 'info', 'get_connection_token: Bluesky proxy', { connection_id: connectionId });
677
+ return {
678
+ useProxy: true,
679
+ connectionId: data.connectionId ?? connectionId,
680
+ blueskyDid: data.blueskyDid,
681
+ message: 'Use the bluesky_post tool with this connection_id and the post text to post. Do not use run_command (curl) for Bluesky.',
682
+ };
683
+ }
684
+ if (!data.accessToken) {
685
+ return { error: 'No accessToken in response' };
686
+ }
687
+ console.log('agent', 'info', 'get_connection_token: got token', { connection_id: connectionId });
688
+ return { accessToken: data.accessToken };
689
+ }
690
+ catch (e) {
691
+ return { error: e.message };
692
+ }
693
+ },
694
+ });
695
+ /** Bluesky: post via Portal OAuth. Use this for posting; run_command (curl) often uses wrong URL/path. */
696
+ registerTool({
697
+ name: 'bluesky_post',
698
+ description: 'Post a message to Bluesky. Call list_integrations_connections with provider "bluesky" to get connection_id, then call this with that connection_id and the post text (max 300 characters).',
699
+ profile: 'full',
700
+ parameters: {
701
+ type: 'object',
702
+ properties: {
703
+ connection_id: { type: 'string', description: 'Bluesky connection ID from list_integrations_connections (e.g. conn_bluesky_...).' },
704
+ text: { type: 'string', description: 'Post text (max 300 characters).' },
705
+ },
706
+ required: ['connection_id', 'text'],
707
+ },
708
+ execute: async (args) => {
709
+ const connectionId = typeof args.connection_id === 'string' ? args.connection_id.trim() : '';
710
+ const text = typeof args.text === 'string' ? args.text.trim() : '';
711
+ if (!connectionId)
712
+ return { error: 'connection_id is required' };
713
+ if (!text)
714
+ return { error: 'text is required' };
715
+ if (text.length > 300)
716
+ return { error: 'Bluesky posts are limited to 300 characters' };
717
+ const portalGatewayBase = getPortalGatewayBase();
718
+ const portalKey = getEffectivePortalApiKey();
719
+ if (!portalGatewayBase || !portalKey)
720
+ return { error: 'Set PORTAL_GATEWAY_URL and PORTAL_API_KEY (from Portal → API Keys)' };
721
+ const base = portalGatewayBase.replace(/\/$/, '');
722
+ try {
723
+ const useRes = await fetch(`${base}/connections/${encodeURIComponent(connectionId)}/use`, {
724
+ method: 'POST',
725
+ headers: { Authorization: `Bearer ${portalKey}` },
726
+ });
727
+ if (!useRes.ok) {
728
+ const t = await useRes.text();
729
+ return { error: `Portal gateway use: ${useRes.status}`, detail: t.slice(0, 200) };
730
+ }
731
+ const useData = (await useRes.json());
732
+ if (useData.error)
733
+ return { error: useData.error };
734
+ if (!useData.useProxy || !useData.blueskyDid) {
735
+ return { error: 'Not a Bluesky connection or missing blueskyDid; reconnect Bluesky in the Portal.' };
736
+ }
737
+ const createdAt = new Date().toISOString().replace(/\.\d{3}Z$/, '.000Z');
738
+ const body = {
739
+ path: '/xrpc/com.atproto.repo.createRecord',
740
+ method: 'POST',
741
+ body: {
742
+ repo: useData.blueskyDid,
743
+ collection: 'app.bsky.feed.post',
744
+ record: { $type: 'app.bsky.feed.post', text, createdAt },
745
+ },
746
+ };
747
+ const bskyRes = await fetch(`${base}/connections/${encodeURIComponent(connectionId)}/bsky-request`, {
748
+ method: 'POST',
749
+ headers: { Authorization: `Bearer ${portalKey}`, 'Content-Type': 'application/json' },
750
+ body: JSON.stringify(body),
751
+ });
752
+ const resText = await bskyRes.text();
753
+ if (!bskyRes.ok)
754
+ return { error: `Bluesky request: ${bskyRes.status}`, detail: resText.slice(0, 300) };
755
+ let parsed;
756
+ try {
757
+ parsed = JSON.parse(resText);
758
+ }
759
+ catch {
760
+ return { error: 'Invalid JSON from Bluesky', detail: resText.slice(0, 200) };
761
+ }
762
+ if (parsed.error)
763
+ return { error: parsed.error };
764
+ if (!parsed.uri)
765
+ return { error: 'Post may have failed; response had no uri', detail: resText.slice(0, 200) };
766
+ return { ok: true, uri: parsed.uri, message: 'Posted to Bluesky successfully.' };
767
+ }
768
+ catch (e) {
769
+ return { error: e.message };
770
+ }
771
+ },
772
+ });
773
+ /** Stripe: list customers using key from Settings → Channels (or STRIPE_SECRET_KEY). Do not use list_integrations_connections for Stripe. */
774
+ registerTool({
775
+ name: 'stripe_list_customers',
776
+ description: 'List Stripe customers. Uses the Stripe secret key from Settings → Channels (Stripe) or STRIPE_SECRET_KEY. Do not use list_integrations_connections for Stripe. Returns customers with id, email, name; or an error if Stripe is not configured.',
777
+ profile: 'full',
778
+ parameters: {
779
+ type: 'object',
780
+ properties: {
781
+ limit: { type: 'number', description: 'Max customers to return (default 10, max 100)' },
782
+ },
783
+ required: [],
784
+ },
785
+ execute: async (args) => {
786
+ const key = getEffectiveStripeSecretKey();
787
+ if (!key?.trim())
788
+ return { error: 'Stripe is not configured. Add a secret key in Settings → Channels (Stripe) or set STRIPE_SECRET_KEY.' };
789
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(Math.floor(args.limit), 100) : 10;
790
+ try {
791
+ const url = `https://api.stripe.com/v1/customers?limit=${limit}`;
792
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
793
+ if (res.status === 401)
794
+ return { error: 'Invalid Stripe secret key (unauthorized)' };
795
+ if (!res.ok) {
796
+ const text = await res.text();
797
+ return { error: `Stripe API: ${res.status}`, detail: text.slice(0, 200) };
798
+ }
799
+ const json = (await res.json());
800
+ const data = json.data ?? [];
801
+ const customers = data.map((c) => ({ id: c.id, email: c.email, name: c.name }));
802
+ return { customers, count: customers.length };
803
+ }
804
+ catch (e) {
805
+ return { error: e.message };
806
+ }
807
+ },
808
+ });
809
+ /** Discord: list guilds (servers) using bot token from Settings → Channels. Do not use list_integrations_connections for Discord. */
810
+ registerTool({
811
+ name: 'discord_list_guilds',
812
+ description: 'List Discord servers (guilds) the bot is in. Uses the bot token from Settings → Channels (Discord) or DISCORD_BOT_TOKEN. Do not use list_integrations_connections for Discord. Returns guilds with id and name; or an error if Discord is not configured.',
813
+ profile: 'full',
814
+ parameters: { type: 'object', properties: {}, required: [] },
815
+ execute: async () => {
816
+ const token = getEffectiveDiscordBotToken();
817
+ if (!token?.trim())
818
+ return { error: 'Discord is not configured. Add a bot token in Settings → Channels (Discord) or set DISCORD_BOT_TOKEN.' };
819
+ try {
820
+ const res = await fetch('https://discord.com/api/v10/users/@me/guilds', {
821
+ headers: { Authorization: `Bot ${token}` },
822
+ });
823
+ if (!res.ok) {
824
+ const text = await res.text();
825
+ return { error: `Discord API: ${res.status}`, detail: text.slice(0, 200) };
826
+ }
827
+ const list = (await res.json());
828
+ return { guilds: list.map((g) => ({ id: g.id, name: g.name })), count: list.length };
829
+ }
830
+ catch (e) {
831
+ return { error: e.message };
832
+ }
833
+ },
834
+ });
835
+ /** Discord: list channels in a guild. */
836
+ registerTool({
837
+ name: 'discord_list_channels',
838
+ description: 'List channels in a Discord server (guild). Uses the bot token from Settings → Channels. Call discord_list_guilds first to get guild_id. Returns channels with id, name, type (0=text, 2=voice, 4=category).',
839
+ profile: 'full',
840
+ parameters: {
841
+ type: 'object',
842
+ properties: {
843
+ guild_id: { type: 'string', description: 'Discord guild (server) ID from discord_list_guilds' },
844
+ },
845
+ required: ['guild_id'],
846
+ },
847
+ execute: async (args) => {
848
+ const token = getEffectiveDiscordBotToken();
849
+ if (!token?.trim())
850
+ return { error: 'Discord is not configured. Add a bot token in Settings → Channels (Discord) or set DISCORD_BOT_TOKEN.' };
851
+ const guildId = typeof args.guild_id === 'string' ? args.guild_id.trim() : '';
852
+ if (!guildId)
853
+ return { error: 'guild_id is required' };
854
+ try {
855
+ const res = await fetch(`https://discord.com/api/v10/guilds/${encodeURIComponent(guildId)}/channels`, {
856
+ headers: { Authorization: `Bot ${token}` },
857
+ });
858
+ if (!res.ok) {
859
+ const text = await res.text();
860
+ return { error: `Discord API: ${res.status}`, detail: text.slice(0, 200) };
861
+ }
862
+ const list = (await res.json());
863
+ return { channels: list.map((c) => ({ id: c.id, name: c.name, type: c.type })), count: list.length };
864
+ }
865
+ catch (e) {
866
+ return { error: e.message };
867
+ }
868
+ },
869
+ });
870
+ /** Discord: send a message to a channel. */
871
+ registerTool({
872
+ name: 'discord_send_message',
873
+ description: 'Send a message to a Discord channel. Uses the bot token from Settings → Channels. Call discord_list_guilds then discord_list_channels to get channel_id. Max content length 2000 characters.',
874
+ profile: 'full',
875
+ parameters: {
876
+ type: 'object',
877
+ properties: {
878
+ channel_id: { type: 'string', description: 'Discord channel ID from discord_list_channels' },
879
+ content: { type: 'string', description: 'Message text (max 2000 chars)' },
880
+ },
881
+ required: ['channel_id', 'content'],
882
+ },
883
+ execute: async (args) => {
884
+ const token = getEffectiveDiscordBotToken();
885
+ if (!token?.trim())
886
+ return { error: 'Discord is not configured. Add a bot token in Settings → Channels (Discord) or set DISCORD_BOT_TOKEN.' };
887
+ const channelId = typeof args.channel_id === 'string' ? args.channel_id.trim() : '';
888
+ let content = typeof args.content === 'string' ? args.content : '';
889
+ if (!channelId)
890
+ return { error: 'channel_id is required' };
891
+ if (content.length > 2000)
892
+ content = content.slice(0, 2000);
893
+ try {
894
+ const res = await fetch(`https://discord.com/api/v10/channels/${encodeURIComponent(channelId)}/messages`, {
895
+ method: 'POST',
896
+ headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json' },
897
+ body: JSON.stringify({ content }),
898
+ });
899
+ if (!res.ok) {
900
+ const text = await res.text();
901
+ return { error: `Discord API: ${res.status}`, detail: text.slice(0, 200) };
902
+ }
903
+ const data = (await res.json());
904
+ return { ok: true, message_id: data.id };
905
+ }
906
+ catch (e) {
907
+ return { error: e.message };
908
+ }
909
+ },
910
+ });
258
911
  }
259
912
  //# sourceMappingURL=tools.js.map