@sulala/agent 0.1.13 → 0.1.15

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 (102) hide show
  1. package/README.md +3 -3
  2. package/dashboard/dist/assets/index-DegBJNv6.css +1 -0
  3. package/dashboard/dist/assets/index-pVHpAj3h.js +83 -0
  4. package/dashboard/dist/index.html +2 -2
  5. package/dist/agent/loop.d.ts.map +1 -1
  6. package/dist/agent/loop.js +21 -6
  7. package/dist/agent/loop.js.map +1 -1
  8. package/dist/agent/skill-generate.d.ts +1 -1
  9. package/dist/agent/skill-generate.d.ts.map +1 -1
  10. package/dist/agent/skill-generate.js +10 -3
  11. package/dist/agent/skill-generate.js.map +1 -1
  12. package/dist/agent/skill-install.d.ts +12 -1
  13. package/dist/agent/skill-install.d.ts.map +1 -1
  14. package/dist/agent/skill-install.js +130 -15
  15. package/dist/agent/skill-install.js.map +1 -1
  16. package/dist/agent/skills.d.ts +4 -3
  17. package/dist/agent/skills.d.ts.map +1 -1
  18. package/dist/agent/skills.js +53 -25
  19. package/dist/agent/skills.js.map +1 -1
  20. package/dist/agent/tool/spec-loader.d.ts +7 -0
  21. package/dist/agent/tool/spec-loader.d.ts.map +1 -0
  22. package/dist/agent/tool/spec-loader.js +540 -0
  23. package/dist/agent/tool/spec-loader.js.map +1 -0
  24. package/dist/agent/tools.d.ts.map +1 -1
  25. package/dist/agent/tools.integrations.test.js +4 -5
  26. package/dist/agent/tools.integrations.test.js.map +1 -1
  27. package/dist/agent/tools.js +144 -367
  28. package/dist/agent/tools.js.map +1 -1
  29. package/dist/ai/orchestrator.d.ts.map +1 -1
  30. package/dist/ai/orchestrator.js +82 -17
  31. package/dist/ai/orchestrator.js.map +1 -1
  32. package/dist/cli.d.ts +4 -1
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +25 -9
  35. package/dist/cli.js.map +1 -1
  36. package/dist/config.d.ts.map +1 -1
  37. package/dist/config.js +20 -5
  38. package/dist/config.js.map +1 -1
  39. package/dist/db/index.d.ts +14 -7
  40. package/dist/db/index.d.ts.map +1 -1
  41. package/dist/db/index.js +108 -30
  42. package/dist/db/index.js.map +1 -1
  43. package/dist/gateway/server.d.ts.map +1 -1
  44. package/dist/gateway/server.js +141 -15
  45. package/dist/gateway/server.js.map +1 -1
  46. package/dist/index.js +1 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/onboard-env.d.ts +1 -1
  49. package/dist/onboard-env.d.ts.map +1 -1
  50. package/dist/onboard-env.js +2 -0
  51. package/dist/onboard-env.js.map +1 -1
  52. package/dist/types.d.ts +5 -3
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/watcher/index.d.ts.map +1 -1
  55. package/dist/watcher/index.js +1 -2
  56. package/dist/watcher/index.js.map +1 -1
  57. package/package.json +4 -5
  58. package/src/index.ts +1 -1
  59. package/context/00-rules.md +0 -1
  60. package/context/airtable.md +0 -35
  61. package/context/apple-notes.md +0 -99
  62. package/context/asana.md +0 -37
  63. package/context/bluesky.md +0 -46
  64. package/context/calendar.md +0 -63
  65. package/context/country-info.md +0 -13
  66. package/context/create-skill.md +0 -128
  67. package/context/discord.md +0 -30
  68. package/context/docs.md +0 -29
  69. package/context/drive.md +0 -49
  70. package/context/dropbox.md +0 -39
  71. package/context/facebook.md +0 -47
  72. package/context/fetch-form-api.md +0 -16
  73. package/context/figma.md +0 -30
  74. package/context/files.md +0 -30
  75. package/context/git.md +0 -37
  76. package/context/github.md +0 -58
  77. package/context/gmail.md +0 -52
  78. package/context/google.md +0 -28
  79. package/context/hellohub.md +0 -29
  80. package/context/jira.md +0 -46
  81. package/context/linear.md +0 -40
  82. package/context/news.md +0 -64
  83. package/context/notion.md +0 -45
  84. package/context/portal-integrations.md +0 -42
  85. package/context/post-to-x.md +0 -50
  86. package/context/sheets.md +0 -47
  87. package/context/slack.md +0 -48
  88. package/context/slides.md +0 -35
  89. package/context/stripe.md +0 -38
  90. package/context/tes.md +0 -7
  91. package/context/test.md +0 -7
  92. package/context/weather.md +0 -32
  93. package/context/zoom.md +0 -28
  94. package/dashboard/dist/assets/index-BTx-9jCj.css +0 -1
  95. package/dashboard/dist/assets/index-B_QGQ8c_.js +0 -83
  96. package/registry/apple-notes.md +0 -99
  97. package/registry/bluesky.md +0 -34
  98. package/registry/files.md +0 -30
  99. package/registry/git.md +0 -37
  100. package/registry/news.md +0 -64
  101. package/registry/skills-registry.json +0 -46
  102. package/registry/weather.md +0 -32
@@ -3,12 +3,10 @@ import { resolve, relative, dirname, join } from 'path';
3
3
  import { spawnSync } from 'child_process';
4
4
  import { insertTask, getOrCreateAgentSession } from '../db/index.js';
5
5
  import { config, getPortalGatewayBase, getEffectivePortalApiKey } from '../config.js';
6
- import { getEffectiveStripeSecretKey } from '../channels/stripe.js';
7
- import { getEffectiveDiscordBotToken } from '../channels/discord.js';
6
+ import { registerSpecTools } from './tool/spec-loader.js';
8
7
  import { appendMemory, listMemories, getSharedScopeKeyForSession, } from './memory.js';
9
8
  import { addWatchPaths } from '../watcher/index.js';
10
9
  import { createPendingAction } from './pending-actions.js';
11
- import { getAllRequiredBins } from './skills.js';
12
10
  import { getSkillConfigEnv, getSkillToolPolicy } from './skills-config.js';
13
11
  import { redactSecretKeysInSummary } from '../redact.js';
14
12
  import { runAgentTurn } from './loop.js';
@@ -134,11 +132,11 @@ export function executeTool(name, args, opts) {
134
132
  export function registerBuiltInTools(enqueueTask) {
135
133
  registerTool({
136
134
  name: 'run_task',
137
- description: 'Enqueue a background task by type and optional payload. Use for scheduling work (e.g. heartbeat, file_event, or custom types).',
135
+ description: 'Enqueue a background task by type and optional payload. Use ONLY for system/scheduled task types (e.g. heartbeat, file_event, agent_job). Do NOT use for user-requested immediate results: for read/summarize email, list calendar, create invoice, or similar, use the integration tools (list_integrations_connections, get_connection_token, run_command, stripe_*) and return the result in this turn.',
138
136
  parameters: {
139
137
  type: 'object',
140
138
  properties: {
141
- type: { type: 'string', description: 'Task type (e.g. heartbeat, file_event)' },
139
+ type: { type: 'string', description: 'Task type (e.g. heartbeat, file_event, agent_job)' },
142
140
  payload: { type: 'object', description: 'Optional JSON payload' },
143
141
  },
144
142
  required: ['type'],
@@ -153,167 +151,157 @@ export function registerBuiltInTools(enqueueTask) {
153
151
  return { taskId, type, status: 'enqueued' };
154
152
  },
155
153
  });
156
- if (process.env.ALLOW_SHELL_TOOL === '1') {
157
- const envBins = (process.env.ALLOWED_BINARIES || '')
158
- .split(',')
159
- .map((b) => b.trim().toLowerCase())
160
- .filter(Boolean);
161
- const skillBins = getAllRequiredBins(config);
162
- const allowed = [...new Set([...envBins, ...skillBins])];
163
- registerTool({
164
- name: 'run_command',
165
- description: 'Run a single command (binary + args). Only binaries in the ALLOWED_BINARIES list are permitted. Use for skills that document CLI usage (e.g. memo for Apple Notes): read the skill doc, then run the commands it describes.',
166
- profile: 'coding',
167
- parameters: {
168
- type: 'object',
169
- properties: {
170
- binary: { type: 'string', description: 'Executable name (e.g. memo, git). Must be in ALLOWED_BINARIES.' },
171
- args: {
172
- type: 'array',
173
- items: { type: 'string' },
174
- description: 'Arguments (e.g. ["notes", "-a", "buy saiko"] for memo).',
175
- },
154
+ registerTool({
155
+ name: 'run_command',
156
+ description: 'Run a single command (binary + args). Use for skills that document CLI usage (e.g. memo for Apple Notes, osascript): read the skill doc, then run the commands it describes.',
157
+ profile: 'coding',
158
+ parameters: {
159
+ type: 'object',
160
+ properties: {
161
+ binary: { type: 'string', description: 'Executable name (e.g. osascript, memo, git, curl).' },
162
+ args: {
163
+ type: 'array',
164
+ items: { type: 'string' },
165
+ description: 'Arguments (e.g. ["notes", "-a", "buy saiko"] for memo).',
176
166
  },
177
- required: ['binary', 'args'],
178
167
  },
179
- execute: (args) => {
180
- const binary = String(args.binary || '').trim().toLowerCase();
181
- if (!binary)
182
- return { error: 'binary is required' };
183
- if (allowed.length > 0 && !allowed.includes(binary)) {
184
- return { error: `binary "${binary}" is not in ALLOWED_BINARIES (allowed: ${allowed.join(', ')})` };
185
- }
186
- const rawArgs = Array.isArray(args.args) ? args.args : [];
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)];
168
+ required: ['binary', 'args'],
169
+ },
170
+ execute: (args) => {
171
+ const binary = String(args.binary || '').trim();
172
+ if (!binary)
173
+ return { error: 'binary is required' };
174
+ const rawArgs = Array.isArray(args.args) ? args.args : [];
175
+ let argsList = rawArgs.map((a) => String(a).replace(/\0/g, '').trim()).filter((_, i) => i < 50);
176
+ // 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")
177
+ const portalGatewayBase = getPortalGatewayBase() || process.env.PORTAL_GATEWAY_URL || '';
178
+ const portalApiKey = getEffectivePortalApiKey() || process.env.PORTAL_API_KEY || '';
179
+ if (portalGatewayBase || portalApiKey) {
180
+ argsList = argsList.map((a) => {
181
+ let s = a;
182
+ if (portalGatewayBase && s.includes('$PORTAL_GATEWAY_URL'))
183
+ s = s.replace(/\$PORTAL_GATEWAY_URL/g, portalGatewayBase);
184
+ if (portalApiKey && s.includes('$PORTAL_API_KEY'))
185
+ s = s.replace(/\$PORTAL_API_KEY/g, portalApiKey);
186
+ return s;
187
+ });
188
+ }
189
+ // Resolve portal gateway URLs for curl: .../connections/conn_xxx/use and .../connections/conn_xxx/bsky-request
190
+ if (binary === 'curl' && portalGatewayBase && argsList.length > 0) {
191
+ const argsStr = argsList.join(' ');
192
+ const useMatch = argsStr.match(/\/connections\/(conn_[a-zA-Z0-9_]+)\/use/);
193
+ const bskyMatch = argsStr.match(/\/connections\/(conn_[a-zA-Z0-9_]+)\/bsky-request/);
194
+ const pathMatch = useMatch || bskyMatch;
195
+ const pathSuffix = useMatch ? '/use' : bskyMatch ? '/bsky-request' : '';
196
+ if (pathMatch) {
197
+ const path = `/connections/${pathMatch[1]}${pathSuffix}`;
198
+ const resolved = portalGatewayBase.replace(/\/$/, '') + path;
199
+ const postIdx = argsList.indexOf('POST');
200
+ const urlIdx = postIdx >= 0
201
+ ? postIdx + 1
202
+ : argsList.findIndex((a) => a.includes('/connections/') && (a.includes('/use') || a.includes('/bsky-request')));
203
+ if (urlIdx >= 0 && urlIdx < argsList.length) {
204
+ const urlArg = argsList[urlIdx];
205
+ const isBroken = urlArg.includes('$') ||
206
+ !urlArg.startsWith('http') ||
207
+ (() => {
208
+ try {
209
+ const u = new URL(urlArg);
210
+ return !u.hostname || u.hostname.includes('$');
236
211
  }
212
+ catch {
213
+ return true;
214
+ }
215
+ })();
216
+ if (isBroken || urlArg !== resolved) {
217
+ console.log('agent', 'info', 'run_command: resolved portal URL for curl', {
218
+ host: new URL(resolved).hostname,
219
+ path: pathSuffix,
220
+ });
221
+ argsList = [...argsList.slice(0, urlIdx), resolved, ...argsList.slice(urlIdx + 1)];
222
+ if (argsList[urlIdx + 1] === path) {
223
+ argsList = [...argsList.slice(0, urlIdx + 1), ...argsList.slice(urlIdx + 2)];
237
224
  }
238
225
  }
239
226
  }
240
227
  }
241
- if (binary === 'curl') {
242
- const allowedHosts = (process.env.ALLOWED_CURL_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
243
- if (allowedHosts.length > 0) {
244
- const urls = argsList.join(' ').match(/https?:\/\/[^/\s"'<>]+/g) || [];
245
- for (const u of urls) {
246
- try {
247
- const host = new URL(u).hostname.toLowerCase();
248
- const allowed = allowedHosts.some((h) => host === h || host.endsWith('.' + h));
249
- if (!allowed)
250
- return { error: `curl URL host not allowed: ${host} (ALLOWED_CURL_HOSTS)` };
251
- }
252
- catch {
253
- // skip malformed URL
254
- }
228
+ }
229
+ if (binary === 'curl') {
230
+ const allowedHosts = (process.env.ALLOWED_CURL_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
231
+ if (allowedHosts.length > 0) {
232
+ const urls = argsList.join(' ').match(/https?:\/\/[^/\s"'<>]+/g) || [];
233
+ for (const u of urls) {
234
+ try {
235
+ const host = new URL(u).hostname.toLowerCase();
236
+ const allowed = allowedHosts.some((h) => host === h || host.endsWith('.' + h));
237
+ if (!allowed)
238
+ return { error: `curl URL host not allowed: ${host} (ALLOWED_CURL_HOSTS)` };
255
239
  }
256
- }
257
- }
258
- try {
259
- const skillEnv = getSkillConfigEnv();
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
- }
270
- const result = spawnSync(binary, argsList, { encoding: 'utf8', timeout: 30000, env });
271
- if (result.error)
272
- return { error: result.error.message };
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
- });
240
+ catch {
241
+ // skip malformed URL
285
242
  }
286
243
  }
287
- const out = {
288
- status: result.status ?? null,
289
- stdout: result.stdout?.trim() ?? '',
290
- stderr: result.stderr?.trim() ?? '',
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(' ');
244
+ }
245
+ }
246
+ try {
247
+ const skillEnv = getSkillConfigEnv();
248
+ const env = { ...process.env, ...skillEnv };
249
+ if (process.env.DEBUG || process.env.SULALA_LOG_SKILL_ENV) {
250
+ const keys = Object.keys(skillEnv).sort();
251
+ const summary = keys.map((k) => {
252
+ const v = skillEnv[k];
253
+ const len = typeof v === 'string' ? v.length : 0;
254
+ return `${k} (${len > 0 ? `${len} chars` : 'empty'})`;
255
+ });
256
+ console.log('agent', 'info', `run_command skill env: ${redactSecretKeysInSummary(summary).join(', ')}`, { binary, keyCount: keys.length });
257
+ }
258
+ const result = spawnSync(binary, argsList, { encoding: 'utf8', timeout: 30000, env });
259
+ if (result.error)
260
+ return { error: result.error.message };
261
+ // Log token request result so we can confirm gateway returned accessToken (do not log the token)
262
+ if (binary === 'curl' && argsList.length > 0) {
263
+ const argsStr = argsList.join(' ');
264
+ const tokenUseMatch = argsStr.match(/\/connections\/(conn_[a-zA-Z0-9_]+)\/use/);
265
+ if (tokenUseMatch) {
295
266
  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
- }
267
+ const gotToken = stdout.includes('accessToken') && !stdout.trimStart().startsWith('{"error"');
268
+ console.log('agent', 'info', 'run_command: token request (POST .../connections/<id>/use) result', {
269
+ connection_id: tokenUseMatch[1],
270
+ gotToken,
271
+ exitCode: result.status ?? null,
272
+ });
302
273
  }
303
- return out;
304
274
  }
305
- catch (e) {
306
- return { error: e.message };
275
+ const out = {
276
+ status: result.status ?? null,
277
+ stdout: result.stdout?.trim() ?? '',
278
+ stderr: result.stderr?.trim() ?? '',
279
+ };
280
+ // When agent calls Gmail/Calendar/Google API without token, 401 is returned. Add a hint so the model retries with token-first flow.
281
+ if (binary === 'curl' && argsList.length > 0) {
282
+ const argsStr = argsList.join(' ');
283
+ const stdout = result.stdout ?? '';
284
+ const isGoogleApi = argsStr.includes('gmail.googleapis.com') || argsStr.includes('www.googleapis.com') || argsStr.includes('sheets.googleapis.com');
285
+ const is401 = stdout.includes('401') && (stdout.includes('invalid authentication') || stdout.includes('invalid credentials'));
286
+ if (isGoogleApi && is401) {
287
+ out._hint =
288
+ '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.';
289
+ }
307
290
  }
308
- },
309
- });
310
- }
291
+ return out;
292
+ }
293
+ catch (e) {
294
+ return { error: e.message };
295
+ }
296
+ },
297
+ });
311
298
  if (config.agentWorkspaceRoot || config.workspaceDir) {
312
299
  const workspaceRoot = config.agentWorkspaceRoot ? resolve(process.cwd(), config.agentWorkspaceRoot) : null;
313
300
  const workspaceDirResolved = config.workspaceDir ? resolve(config.workspaceDir) : null;
314
301
  const workspaceSkillsDir = config.skillsWorkspaceDir ? resolve(config.skillsWorkspaceDir) : null;
302
+ const workspaceSkillsMyDir = config.skillsWorkspaceMyDir ? resolve(config.skillsWorkspaceMyDir) : null;
315
303
  const sulalaHomeDir = config.workspaceDir ? resolve(config.workspaceDir, '..') : null;
316
- const allowedReadRoots = [workspaceRoot, workspaceDirResolved, workspaceSkillsDir, sulalaHomeDir].filter(Boolean);
304
+ const allowedReadRoots = [workspaceRoot, workspaceDirResolved, workspaceSkillsDir, workspaceSkillsMyDir, sulalaHomeDir].filter(Boolean);
317
305
  const defaultRoot = workspaceRoot ?? workspaceDirResolved ?? process.cwd();
318
306
  registerTool({
319
307
  name: 'read_file',
@@ -347,10 +335,10 @@ export function registerBuiltInTools(enqueueTask) {
347
335
  }
348
336
  },
349
337
  });
350
- const allowedWriteRoots = [workspaceRoot, workspaceDirResolved, workspaceSkillsDir].filter(Boolean);
338
+ const allowedWriteRoots = [workspaceRoot, workspaceDirResolved, workspaceSkillsDir, workspaceSkillsMyDir].filter(Boolean);
351
339
  registerTool({
352
340
  name: 'write_file',
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.',
341
+ description: 'Write content to a file. Use for scripts in workspace/scripts/, credentials in workspace/.env, or skills you create in workspace/skills/my/<slug>/README.md. Path can be relative to workspace root or absolute.',
354
342
  profile: 'coding',
355
343
  parameters: {
356
344
  type: 'object',
@@ -564,10 +552,10 @@ export function registerBuiltInTools(enqueueTask) {
564
552
  const filtered = list.filter((c) => (c.provider || '').toLowerCase() === p);
565
553
  return filtered.map((c) => ({ connection_id: c.connection_id ?? c.id ?? '', provider: c.provider }));
566
554
  }
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). */
555
+ /** List OAuth connections only (calendar, gmail, drive, github, slack, etc.). Not for Stripe or Discord—use stripe_list_customers / discord_* instead. */
568
556
  registerTool({
569
557
  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.',
558
+ description: 'OAuth integrations only (calendar, gmail, drive, docs, sheets, slides, github, slack, notion, linear, zoom). Returns connection_id and provider. Do NOT use for Stripe or Discord—for Stripe customers use stripe_list_customers; for Discord use discord_list_guilds / discord_send_message. Requires PORTAL_GATEWAY_URL + PORTAL_API_KEY or INTEGRATIONS_URL.',
571
559
  profile: 'full',
572
560
  parameters: {
573
561
  type: 'object',
@@ -692,221 +680,10 @@ export function registerBuiltInTools(enqueueTask) {
692
680
  }
693
681
  },
694
682
  });
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
- });
683
+ // Register YAML spec tools; first-wins so ~/.sulala/workspace/skills (e.g. stripe) overrides ./context
684
+ registerSpecTools((tool) => {
685
+ if (!registry.has(tool.name))
686
+ registerTool(tool);
687
+ }, config);
911
688
  }
912
689
  //# sourceMappingURL=tools.js.map