@swarmclawai/swarmclaw 0.3.1 → 0.4.5

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 (203) hide show
  1. package/README.md +33 -13
  2. package/bin/server-cmd.js +14 -7
  3. package/bin/swarmclaw.js +3 -1
  4. package/bin/update-cmd.js +120 -0
  5. package/next.config.ts +10 -0
  6. package/package.json +4 -1
  7. package/src/app/api/agents/[id]/route.ts +20 -18
  8. package/src/app/api/agents/[id]/thread/route.ts +4 -3
  9. package/src/app/api/agents/route.ts +8 -3
  10. package/src/app/api/auth/route.ts +3 -1
  11. package/src/app/api/claude-skills/route.ts +3 -1
  12. package/src/app/api/clawhub/install/route.ts +2 -2
  13. package/src/app/api/connectors/[id]/route.ts +14 -3
  14. package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
  15. package/src/app/api/connectors/route.ts +12 -4
  16. package/src/app/api/credentials/[id]/route.ts +2 -1
  17. package/src/app/api/credentials/route.ts +5 -3
  18. package/src/app/api/daemon/route.ts +6 -1
  19. package/src/app/api/documents/route.ts +2 -2
  20. package/src/app/api/files/serve/route.ts +8 -0
  21. package/src/app/api/ip/route.ts +3 -1
  22. package/src/app/api/knowledge/[id]/route.ts +5 -4
  23. package/src/app/api/knowledge/upload/route.ts +2 -2
  24. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  25. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  26. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  27. package/src/app/api/mcp-servers/route.ts +5 -3
  28. package/src/app/api/memory/[id]/route.ts +9 -8
  29. package/src/app/api/memory/route.ts +2 -2
  30. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  31. package/src/app/api/openclaw/directory/route.ts +26 -0
  32. package/src/app/api/openclaw/discover/route.ts +61 -0
  33. package/src/app/api/openclaw/sync/route.ts +30 -0
  34. package/src/app/api/orchestrator/graph/route.ts +25 -0
  35. package/src/app/api/orchestrator/run/route.ts +2 -2
  36. package/src/app/api/plugins/marketplace/route.ts +3 -1
  37. package/src/app/api/plugins/route.ts +3 -1
  38. package/src/app/api/projects/[id]/route.ts +55 -0
  39. package/src/app/api/projects/route.ts +27 -0
  40. package/src/app/api/providers/[id]/models/route.ts +2 -1
  41. package/src/app/api/providers/[id]/route.ts +13 -12
  42. package/src/app/api/providers/configs/route.ts +3 -1
  43. package/src/app/api/providers/route.ts +7 -3
  44. package/src/app/api/schedules/[id]/route.ts +16 -15
  45. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  46. package/src/app/api/schedules/route.ts +8 -3
  47. package/src/app/api/secrets/[id]/route.ts +16 -17
  48. package/src/app/api/secrets/route.ts +5 -3
  49. package/src/app/api/sessions/[id]/chat/route.ts +5 -2
  50. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  51. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  52. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  53. package/src/app/api/sessions/[id]/messages/route.ts +2 -1
  54. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  55. package/src/app/api/sessions/[id]/route.ts +2 -1
  56. package/src/app/api/sessions/route.ts +11 -4
  57. package/src/app/api/settings/route.ts +3 -1
  58. package/src/app/api/setup/doctor/route.ts +1 -0
  59. package/src/app/api/setup/openclaw-device/route.ts +3 -1
  60. package/src/app/api/skills/[id]/route.ts +23 -21
  61. package/src/app/api/skills/import/route.ts +2 -2
  62. package/src/app/api/skills/route.ts +5 -3
  63. package/src/app/api/tasks/[id]/approve/route.ts +74 -0
  64. package/src/app/api/tasks/[id]/route.ts +9 -5
  65. package/src/app/api/tasks/route.ts +5 -2
  66. package/src/app/api/tts/stream/route.ts +48 -0
  67. package/src/app/api/upload/route.ts +2 -2
  68. package/src/app/api/uploads/[filename]/route.ts +4 -1
  69. package/src/app/api/usage/route.ts +3 -1
  70. package/src/app/api/version/route.ts +3 -1
  71. package/src/app/api/webhooks/[id]/route.ts +31 -32
  72. package/src/app/api/webhooks/route.ts +5 -3
  73. package/src/app/icon.svg +58 -0
  74. package/src/app/page.tsx +11 -26
  75. package/src/cli/index.js +28 -9
  76. package/src/cli/index.ts +45 -2
  77. package/src/cli/spec.js +2 -8
  78. package/src/components/agents/agent-card.tsx +1 -1
  79. package/src/components/agents/agent-list.tsx +3 -1
  80. package/src/components/agents/agent-sheet.tsx +166 -81
  81. package/src/components/chat/chat-area.tsx +71 -34
  82. package/src/components/chat/chat-header.tsx +141 -29
  83. package/src/components/chat/chat-tool-toggles.tsx +12 -53
  84. package/src/components/chat/message-bubble.tsx +110 -42
  85. package/src/components/chat/tool-call-bubble.tsx +50 -6
  86. package/src/components/chat/tool-request-banner.tsx +1 -9
  87. package/src/components/chat/voice-overlay.tsx +80 -0
  88. package/src/components/connectors/connector-list.tsx +9 -10
  89. package/src/components/connectors/connector-sheet.tsx +55 -36
  90. package/src/components/input/chat-input.tsx +72 -56
  91. package/src/components/knowledge/knowledge-list.tsx +27 -31
  92. package/src/components/layout/app-layout.tsx +133 -90
  93. package/src/components/layout/daemon-indicator.tsx +3 -5
  94. package/src/components/logs/log-list.tsx +5 -9
  95. package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
  96. package/src/components/memory/memory-detail.tsx +1 -1
  97. package/src/components/plugins/plugin-list.tsx +227 -27
  98. package/src/components/projects/project-list.tsx +122 -0
  99. package/src/components/projects/project-sheet.tsx +135 -0
  100. package/src/components/providers/provider-list.tsx +46 -13
  101. package/src/components/providers/provider-sheet.tsx +0 -45
  102. package/src/components/runs/run-list.tsx +6 -15
  103. package/src/components/schedules/schedule-card.tsx +54 -4
  104. package/src/components/schedules/schedule-list.tsx +9 -4
  105. package/src/components/schedules/schedule-sheet.tsx +0 -47
  106. package/src/components/secrets/secrets-list.tsx +20 -2
  107. package/src/components/sessions/new-session-sheet.tsx +14 -15
  108. package/src/components/sessions/session-card.tsx +1 -1
  109. package/src/components/sessions/session-list.tsx +7 -7
  110. package/src/components/shared/connector-platform-icon.tsx +26 -20
  111. package/src/components/shared/model-combobox.tsx +148 -0
  112. package/src/components/shared/settings/section-heartbeat.tsx +8 -40
  113. package/src/components/shared/settings/section-orchestrator.tsx +9 -11
  114. package/src/components/shared/settings/section-web-search.tsx +56 -0
  115. package/src/components/shared/settings/settings-page.tsx +73 -0
  116. package/src/components/skills/skill-list.tsx +262 -35
  117. package/src/components/skills/skill-sheet.tsx +0 -45
  118. package/src/components/tasks/task-board.tsx +3 -6
  119. package/src/components/tasks/task-card.tsx +43 -1
  120. package/src/components/tasks/task-list.tsx +8 -7
  121. package/src/components/tasks/task-sheet.tsx +0 -44
  122. package/src/components/usage/usage-list.tsx +12 -4
  123. package/src/hooks/use-continuous-speech.ts +144 -0
  124. package/src/hooks/use-view-router.ts +52 -0
  125. package/src/hooks/use-voice-conversation.ts +80 -0
  126. package/src/hooks/use-ws.ts +66 -0
  127. package/src/instrumentation.ts +2 -0
  128. package/src/lib/chat.ts +14 -2
  129. package/src/lib/id.ts +6 -0
  130. package/src/lib/projects.ts +13 -0
  131. package/src/lib/provider-sets.ts +5 -0
  132. package/src/lib/providers/anthropic.ts +15 -2
  133. package/src/lib/providers/index.ts +8 -0
  134. package/src/lib/providers/ollama.ts +10 -2
  135. package/src/lib/providers/openai.ts +42 -13
  136. package/src/lib/providers/openclaw.ts +11 -0
  137. package/src/lib/server/api-routes.test.ts +5 -6
  138. package/src/lib/server/build-llm.ts +17 -4
  139. package/src/lib/server/chat-execution.ts +57 -8
  140. package/src/lib/server/collection-helpers.ts +54 -0
  141. package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
  142. package/src/lib/server/connectors/bluebubbles.ts +357 -0
  143. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  144. package/src/lib/server/connectors/googlechat.ts +46 -7
  145. package/src/lib/server/connectors/manager.ts +401 -6
  146. package/src/lib/server/connectors/media.ts +2 -2
  147. package/src/lib/server/connectors/openclaw.ts +64 -0
  148. package/src/lib/server/connectors/pairing.test.ts +99 -0
  149. package/src/lib/server/connectors/pairing.ts +256 -0
  150. package/src/lib/server/connectors/signal.ts +1 -0
  151. package/src/lib/server/connectors/teams.ts +5 -5
  152. package/src/lib/server/connectors/types.ts +10 -0
  153. package/src/lib/server/context-manager.ts +1 -1
  154. package/src/lib/server/daemon-state.ts +3 -0
  155. package/src/lib/server/data-dir.ts +1 -0
  156. package/src/lib/server/execution-log.ts +3 -3
  157. package/src/lib/server/heartbeat-service.ts +67 -3
  158. package/src/lib/server/knowledge-db.test.ts +2 -33
  159. package/src/lib/server/langgraph-checkpoint.ts +274 -0
  160. package/src/lib/server/main-agent-loop.ts +67 -8
  161. package/src/lib/server/memory-db.ts +6 -6
  162. package/src/lib/server/openclaw-approvals.ts +105 -0
  163. package/src/lib/server/openclaw-sync.ts +496 -0
  164. package/src/lib/server/orchestrator-lg.ts +422 -20
  165. package/src/lib/server/orchestrator.ts +29 -9
  166. package/src/lib/server/process-manager.ts +2 -2
  167. package/src/lib/server/queue.ts +39 -13
  168. package/src/lib/server/scheduler.ts +2 -2
  169. package/src/lib/server/session-mailbox.ts +2 -2
  170. package/src/lib/server/session-run-manager.ts +8 -3
  171. package/src/lib/server/session-tools/connector.ts +51 -4
  172. package/src/lib/server/session-tools/crud.ts +3 -3
  173. package/src/lib/server/session-tools/delegate.ts +5 -5
  174. package/src/lib/server/session-tools/file.ts +176 -3
  175. package/src/lib/server/session-tools/index.ts +4 -0
  176. package/src/lib/server/session-tools/memory.ts +2 -2
  177. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  178. package/src/lib/server/session-tools/sandbox.ts +197 -0
  179. package/src/lib/server/session-tools/search-providers.ts +270 -0
  180. package/src/lib/server/session-tools/session-info.ts +2 -2
  181. package/src/lib/server/session-tools/web.ts +47 -66
  182. package/src/lib/server/storage-mcp.test.ts +25 -2
  183. package/src/lib/server/storage.ts +36 -7
  184. package/src/lib/server/stream-agent-chat.ts +106 -22
  185. package/src/lib/server/task-result.test.ts +44 -0
  186. package/src/lib/server/task-result.ts +14 -0
  187. package/src/lib/server/task-validation.test.ts +23 -0
  188. package/src/lib/server/task-validation.ts +5 -3
  189. package/src/lib/server/ws-hub.ts +85 -0
  190. package/src/lib/tool-definitions.ts +44 -0
  191. package/src/lib/tts-stream.ts +130 -0
  192. package/src/lib/upload.ts +7 -1
  193. package/src/lib/view-routes.ts +28 -0
  194. package/src/lib/ws-client.ts +124 -0
  195. package/src/proxy.ts +3 -0
  196. package/src/stores/use-app-store.ts +28 -1
  197. package/src/stores/use-chat-store.ts +42 -14
  198. package/src/types/index.ts +34 -2
  199. package/src/app/api/agents/generate/route.ts +0 -42
  200. package/src/app/api/generate/info/route.ts +0 -12
  201. package/src/app/api/generate/route.ts +0 -106
  202. package/src/app/favicon.ico +0 -0
  203. package/src/components/shared/ai-gen-block.tsx +0 -77
@@ -1,11 +1,14 @@
1
- import crypto from 'crypto'
1
+ import { genId } from '@/lib/id'
2
2
  import { loadTasks, saveTasks, loadQueue, saveQueue, loadAgents, loadSchedules, saveSchedules, loadSessions, saveSessions, loadSettings } from './storage'
3
+ import { notify } from './ws-hub'
4
+ import { WORKSPACE_DIR } from './data-dir'
3
5
  import { createOrchestratorSession, executeOrchestrator } from './orchestrator'
4
6
  import { formatValidationFailure, validateTaskCompletion } from './task-validation'
5
7
  import { ensureTaskCompletionReport } from './task-reports'
6
8
  import { pushMainLoopEventToMainSessions } from './main-agent-loop'
7
9
  import { executeSessionChatTurn } from './chat-execution'
8
10
  import { extractTaskResult, formatResultBody } from './task-result'
11
+ import { getCheckpointSaver } from './langgraph-checkpoint'
9
12
  import type { Agent, BoardTask, Message } from '@/types'
10
13
 
11
14
  let processing = false
@@ -119,7 +122,7 @@ async function executeTaskRun(
119
122
  ): Promise<string> {
120
123
  const prompt = task.description || task.title
121
124
  if (agent?.isOrchestrator) {
122
- return executeOrchestrator(agent, prompt, sessionId)
125
+ return executeOrchestrator(agent, prompt, sessionId, task.id)
123
126
  }
124
127
 
125
128
  const run = await executeSessionChatTurn({
@@ -152,7 +155,11 @@ function notifyMainChatScheduleResult(task: BoardTask): void {
152
155
  const fallbackText = runSession ? latestAssistantText(runSession) : ''
153
156
 
154
157
  // Zod-validated structured extraction: one pass to get summary + all artifacts
155
- const taskResult = extractTaskResult(runSession, task.result || fallbackText || null)
158
+ const taskResult = extractTaskResult(
159
+ runSession,
160
+ task.result || fallbackText || null,
161
+ { sinceTime: typeof task.startedAt === 'number' ? task.startedAt : null },
162
+ )
156
163
  const resultBody = formatResultBody(taskResult)
157
164
 
158
165
  const statusLabel = task.status === 'completed' ? 'completed' : 'failed'
@@ -221,7 +228,11 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
221
228
  const runSessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
222
229
  const runSession = runSessionId ? sessions[runSessionId] : null
223
230
  const fallbackText = runSession ? latestAssistantText(runSession) : ''
224
- const taskResult = extractTaskResult(runSession, task.result || fallbackText || null)
231
+ const taskResult = extractTaskResult(
232
+ runSession,
233
+ task.result || fallbackText || null,
234
+ { sinceTime: typeof task.startedAt === 'number' ? task.startedAt : null },
235
+ )
225
236
  const resultBody = formatResultBody(taskResult)
226
237
 
227
238
  const statusLabel = task.status === 'completed' ? 'completed' : 'failed'
@@ -371,7 +382,7 @@ export function validateCompletedTasksQueue() {
371
382
  task.updatedAt = now
372
383
  if (!task.comments) task.comments = []
373
384
  task.comments.push({
374
- id: crypto.randomBytes(4).toString('hex'),
385
+ id: genId(),
375
386
  author: 'System',
376
387
  text: `Task auto-failed completed-queue validation.\n\n${validation.reasons.map((r) => `- ${r}`).join('\n')}`,
377
388
  createdAt: now,
@@ -389,7 +400,7 @@ export function validateCompletedTasksQueue() {
389
400
  }
390
401
  }
391
402
 
392
- if (tasksDirty) saveTasks(tasks)
403
+ if (tasksDirty) { saveTasks(tasks); notify('tasks') }
393
404
  if (sessionsDirty) saveSessions(sessions)
394
405
  if (demoted > 0) {
395
406
  console.warn(`[queue] Demoted ${demoted} invalid completed task(s) to failed after validation audit`)
@@ -410,7 +421,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
410
421
  task.error = `Retry scheduled after failure: ${reason}`.slice(0, 500)
411
422
  if (!task.comments) task.comments = []
412
423
  task.comments.push({
413
- id: crypto.randomBytes(4).toString('hex'),
424
+ id: genId(),
414
425
  author: 'System',
415
426
  text: `Attempt ${task.attempts}/${task.maxAttempts} failed. Retrying in ${delaySec}s.\n\nReason: ${reason}`,
416
427
  createdAt: now,
@@ -425,7 +436,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
425
436
  task.error = `Dead-lettered after ${task.attempts}/${task.maxAttempts} attempts: ${reason}`.slice(0, 500)
426
437
  if (!task.comments) task.comments = []
427
438
  task.comments.push({
428
- id: crypto.randomBytes(4).toString('hex'),
439
+ id: genId(),
429
440
  author: 'System',
430
441
  text: `Task moved to dead-letter after ${task.attempts}/${task.maxAttempts} attempts.\n\nReason: ${reason}`,
431
442
  createdAt: now,
@@ -510,9 +521,12 @@ export async function processNext() {
510
521
  task.startedAt = Date.now()
511
522
  task.retryScheduledAt = null
512
523
  task.deadLetteredAt = null
524
+ // Clear transient failure fields so validation/error state reflects only this attempt.
525
+ task.error = null
526
+ task.validation = null
513
527
  task.updatedAt = Date.now()
514
528
 
515
- const taskCwd = task.cwd || process.cwd()
529
+ const taskCwd = task.cwd || WORKSPACE_DIR
516
530
  let sessionId = ''
517
531
  const scheduleTask = task as ScheduleTaskMeta
518
532
  const isScheduleTask = scheduleTask.sourceType === 'schedule'
@@ -604,7 +618,11 @@ export async function processNext() {
604
618
  applyTaskPolicyDefaults(t2[taskId])
605
619
  // Structured extraction: Zod-validated result with typed artifacts
606
620
  const runSessions = loadSessions()
607
- const taskResult = extractTaskResult(runSessions[sessionId], result || null)
621
+ const taskResult = extractTaskResult(
622
+ runSessions[sessionId],
623
+ result || null,
624
+ { sinceTime: typeof t2[taskId].startedAt === 'number' ? t2[taskId].startedAt : null },
625
+ )
608
626
  const enrichedResult = formatResultBody(taskResult)
609
627
  t2[taskId].result = enrichedResult.slice(0, 4000) || null
610
628
  t2[taskId].updatedAt = Date.now()
@@ -630,7 +648,7 @@ export async function processNext() {
630
648
  updatedAt: now,
631
649
  }
632
650
  t2[taskId].comments!.push({
633
- id: crypto.randomBytes(4).toString('hex'),
651
+ id: genId(),
634
652
  author: agent.name,
635
653
  agentId: agent.id,
636
654
  text: `Task completed.\n\n${result?.slice(0, 1000) || 'No summary provided.'}`,
@@ -641,7 +659,7 @@ export async function processNext() {
641
659
  const retryState = scheduleRetryOrDeadLetter(t2[taskId], failureReason)
642
660
  t2[taskId].completedAt = retryState === 'dead_lettered' ? null : t2[taskId].completedAt
643
661
  t2[taskId].comments!.push({
644
- id: crypto.randomBytes(4).toString('hex'),
662
+ id: genId(),
645
663
  author: agent.name,
646
664
  agentId: agent.id,
647
665
  text: `Task failed validation and was not marked completed.\n\n${validation.reasons.map((r) => `- ${r}`).join('\n')}`,
@@ -688,6 +706,8 @@ export async function processNext() {
688
706
  }
689
707
 
690
708
  saveTasks(t2)
709
+ notify('tasks')
710
+ notify('runs')
691
711
  disableSessionHeartbeat(t2[taskId].sessionId)
692
712
  }
693
713
  const doneTask = t2[taskId]
@@ -698,6 +718,10 @@ export async function processNext() {
698
718
  })
699
719
  notifyMainChatScheduleResult(doneTask)
700
720
  notifyAgentThreadTaskResult(doneTask)
721
+ // Clean up LangGraph checkpoints for completed tasks
722
+ getCheckpointSaver().deleteThread(taskId).catch((e) =>
723
+ console.warn(`[queue] Failed to clean up checkpoints for task ${taskId}:`, e)
724
+ )
701
725
  console.log(`[queue] Task "${task.title}" completed`)
702
726
  } else {
703
727
  if (doneTask?.status === 'queued') {
@@ -727,7 +751,7 @@ export async function processNext() {
727
751
  const isRepeatError = lastComment?.agentId === agent.id && lastComment?.text.startsWith('Task failed')
728
752
  if (!isRepeatError) {
729
753
  t2[taskId].comments!.push({
730
- id: crypto.randomBytes(4).toString('hex'),
754
+ id: genId(),
731
755
  author: agent.name,
732
756
  agentId: agent.id,
733
757
  text: 'Task failed — see error details above.',
@@ -735,6 +759,8 @@ export async function processNext() {
735
759
  })
736
760
  }
737
761
  saveTasks(t2)
762
+ notify('tasks')
763
+ notify('runs')
738
764
  disableSessionHeartbeat(t2[taskId].sessionId)
739
765
  if (retryState === 'retry') {
740
766
  const qRetry = loadQueue()
@@ -1,4 +1,4 @@
1
- import crypto from 'crypto'
1
+ import { genId } from '@/lib/id'
2
2
  import { loadSchedules, saveSchedules, loadAgents, loadTasks, saveTasks } from './storage'
3
3
  import { enqueueTask } from './queue'
4
4
  import { CronExpressionParser } from 'cron-parser'
@@ -157,7 +157,7 @@ async function tick() {
157
157
  prev.runNumber = schedule.runNumber
158
158
  } else {
159
159
  // Create a new linked task (first run or previous task still in-flight)
160
- taskId = crypto.randomBytes(4).toString('hex')
160
+ taskId = genId()
161
161
  tasks[taskId] = {
162
162
  id: taskId,
163
163
  title: `[Sched] ${schedule.name} (run #${schedule.runNumber})`,
@@ -1,4 +1,4 @@
1
- import crypto from 'crypto'
1
+ import { genId } from '@/lib/id'
2
2
  import { loadSessions, saveSessions } from './storage'
3
3
 
4
4
  export type MailboxStatus = 'new' | 'ack'
@@ -58,7 +58,7 @@ export function sendMailboxEnvelope(input: {
58
58
  ? Math.max(0, Math.min(7 * 24 * 3600, Math.trunc(input.ttlSec)))
59
59
  : null
60
60
  const envelope: MailboxEnvelope = {
61
- id: crypto.randomBytes(6).toString('hex'),
61
+ id: genId(6),
62
62
  type: (input.type || 'message').trim() || 'message',
63
63
  payload: String(input.payload || ''),
64
64
  fromSessionId: input.fromSessionId || null,
@@ -1,4 +1,4 @@
1
- import crypto from 'crypto'
1
+ import { genId } from '@/lib/id'
2
2
  import type { SSEEvent } from '@/types'
3
3
  import { active, loadSessions } from './storage'
4
4
  import { executeSessionChatTurn, type ExecuteChatTurnResult } from './chat-execution'
@@ -31,6 +31,7 @@ interface QueueEntry {
31
31
  message: string
32
32
  imagePath?: string
33
33
  imageUrl?: string
34
+ attachedFiles?: string[]
34
35
  onEvents: Array<(event: SSEEvent) => void>
35
36
  signalController: AbortController
36
37
  maxRuntimeMs?: number
@@ -236,6 +237,7 @@ async function drainExecution(executionKey: string): Promise<void> {
236
237
  message: next.message,
237
238
  imagePath: next.imagePath,
238
239
  imageUrl: next.imageUrl,
240
+ attachedFiles: next.attachedFiles,
239
241
  internal: next.run.internal,
240
242
  source: next.run.source,
241
243
  runId: next.run.id,
@@ -333,6 +335,7 @@ export interface EnqueueSessionRunInput {
333
335
  message: string
334
336
  imagePath?: string
335
337
  imageUrl?: string
338
+ attachedFiles?: string[]
336
339
  internal?: boolean
337
340
  source?: string
338
341
  mode?: SessionQueueMode
@@ -384,7 +387,7 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
384
387
 
385
388
  const running = state.runningByExecution.get(executionKey)
386
389
  const q = queueForExecution(executionKey)
387
- if (mode === 'collect' && !input.imagePath && !input.imageUrl) {
390
+ if (mode === 'collect' && !input.imagePath && !input.imageUrl && !input.attachedFiles?.length) {
388
391
  const nowMs = now()
389
392
  const candidate = q.at(-1)
390
393
  const canCoalesce = !!candidate
@@ -393,6 +396,7 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
393
396
  && candidate.run.source === source
394
397
  && !candidate.imagePath
395
398
  && !candidate.imageUrl
399
+ && !candidate.attachedFiles?.length
396
400
  && (nowMs - candidate.run.queuedAt) <= COLLECT_COALESCE_WINDOW_MS
397
401
 
398
402
  if (candidate && canCoalesce) {
@@ -416,7 +420,7 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
416
420
  }
417
421
  }
418
422
 
419
- const runId = crypto.randomBytes(8).toString('hex')
423
+ const runId = genId(8)
420
424
  const run: SessionRunRecord = {
421
425
  id: runId,
422
426
  sessionId: input.sessionId,
@@ -444,6 +448,7 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
444
448
  message: input.message,
445
449
  imagePath: input.imagePath,
446
450
  imageUrl: input.imageUrl,
451
+ attachedFiles: input.attachedFiles,
447
452
  onEvents: input.onEvent ? [input.onEvent] : [],
448
453
  signalController: new AbortController(),
449
454
  maxRuntimeMs: effectiveMaxRuntimeMs > 0 ? effectiveMaxRuntimeMs : undefined,
@@ -58,6 +58,10 @@ export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInter
58
58
  const outbound = connector.config?.outboundJid?.trim()
59
59
  if (outbound) channelId = outbound
60
60
  }
61
+ if (!channelId) {
62
+ const outbound = connector.config?.outboundTarget?.trim()
63
+ if (outbound) channelId = outbound
64
+ }
61
65
  if (!channelId) {
62
66
  const recentChannelId = getConnectorRecentChannelId(selected.id)
63
67
  if (recentChannelId) channelId = recentChannelId
@@ -67,7 +71,11 @@ export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInter
67
71
  if (allowed.length) channelId = allowed[0]
68
72
  }
69
73
  if (!channelId) {
70
- return `Error: no target recipient configured. Provide "to", or set connector config "outboundJid"/"allowedJids".`
74
+ const allowed = connector.config?.allowFrom?.split(',').map((s: string) => s.trim()).filter(Boolean) || []
75
+ if (allowed.length) channelId = allowed[0]
76
+ }
77
+ if (!channelId) {
78
+ return `Error: no target recipient configured. Provide "to", or set connector config "outboundJid"/"allowedJids"/"outboundTarget"/"allowFrom".`
71
79
  }
72
80
  if (connector.platform === 'whatsapp') {
73
81
  channelId = normalizeWhatsAppTarget(channelId)
@@ -93,6 +101,45 @@ export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInter
93
101
  })
94
102
  }
95
103
 
104
+ if (action === 'message_react' || action === 'message_edit' || action === 'message_pin' || action === 'message_delete') {
105
+ if (!connectorId) return 'Error: connectorId is required for rich messaging actions.'
106
+ const { getRunningInstance } = await import('../connectors/manager')
107
+ const inst = getRunningInstance(connectorId)
108
+ if (!inst) return `Error: connector "${connectorId}" is not running.`
109
+
110
+ const targetChannel = to?.trim() || ''
111
+ const targetMessageId = message?.trim() || ''
112
+ if (!targetMessageId) return 'Error: message parameter (used as messageId) is required for rich messaging actions.'
113
+
114
+ try {
115
+ if (action === 'message_react') {
116
+ if (!inst.sendReaction) return 'Error: this connector does not support reactions.'
117
+ const emoji = caption?.trim() || '👍'
118
+ await inst.sendReaction(targetChannel, targetMessageId, emoji)
119
+ return JSON.stringify({ status: 'reacted', connectorId, messageId: targetMessageId, emoji })
120
+ }
121
+ if (action === 'message_edit') {
122
+ if (!inst.editMessage) return 'Error: this connector does not support message editing.'
123
+ const newText = caption?.trim() || ''
124
+ if (!newText) return 'Error: caption (new text) is required for message_edit.'
125
+ await inst.editMessage(targetChannel, targetMessageId, newText)
126
+ return JSON.stringify({ status: 'edited', connectorId, messageId: targetMessageId })
127
+ }
128
+ if (action === 'message_delete') {
129
+ if (!inst.deleteMessage) return 'Error: this connector does not support message deletion.'
130
+ await inst.deleteMessage(targetChannel, targetMessageId)
131
+ return JSON.stringify({ status: 'deleted', connectorId, messageId: targetMessageId })
132
+ }
133
+ if (action === 'message_pin') {
134
+ if (!inst.pinMessage) return 'Error: this connector does not support message pinning.'
135
+ await inst.pinMessage(targetChannel, targetMessageId)
136
+ return JSON.stringify({ status: 'pinned', connectorId, messageId: targetMessageId })
137
+ }
138
+ } catch (err: any) {
139
+ return `Error: ${err.message || String(err)}`
140
+ }
141
+ }
142
+
96
143
  return 'Unknown action. Use list_running, list_targets, or send.'
97
144
  } catch (err: any) {
98
145
  return `Error: ${err.message || String(err)}`
@@ -100,11 +147,11 @@ export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInter
100
147
  },
101
148
  {
102
149
  name: 'connector_message_tool',
103
- description: 'Send proactive outbound messages through running connectors (for example WhatsApp status updates). Supports listing running connectors/targets and sending text plus optional media (URLs or local file paths).',
150
+ description: 'Send proactive outbound messages and perform rich messaging actions through running connectors. Supports listing running connectors/targets, sending text/media, and rich messaging (react, edit, delete, pin). For rich actions: connectorId + message (as messageId) required; caption carries emoji for react or new text for edit.',
104
151
  schema: z.object({
105
- action: z.enum(['list_running', 'list_targets', 'send']).describe('connector messaging action'),
152
+ action: z.enum(['list_running', 'list_targets', 'send', 'message_react', 'message_edit', 'message_delete', 'message_pin']).describe('connector messaging action'),
106
153
  connectorId: z.string().optional().describe('Optional connector id. Defaults to the first running connector (or first for selected platform).'),
107
- platform: z.string().optional().describe('Optional platform filter (whatsapp, telegram, slack, discord).'),
154
+ platform: z.string().optional().describe('Optional platform filter (whatsapp, telegram, slack, discord, bluebubbles, etc.).'),
108
155
  to: z.string().optional().describe('Target channel id / recipient. For WhatsApp, phone number or full JID.'),
109
156
  message: z.string().optional().describe('Message text to send (required for send action).'),
110
157
  imageUrl: z.string().optional().describe('Optional public image URL to attach/send where platform supports media.'),
@@ -2,7 +2,7 @@ import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
3
  import fs from 'fs'
4
4
  import path from 'path'
5
- import crypto from 'crypto'
5
+ import { genId } from '@/lib/id'
6
6
  import { spawnSync } from 'child_process'
7
7
  import * as cheerio from 'cheerio'
8
8
  import {
@@ -342,7 +342,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
342
342
  })
343
343
  }
344
344
  }
345
- const newId = crypto.randomBytes(4).toString('hex')
345
+ const newId = genId()
346
346
  const entry = {
347
347
  id: newId,
348
348
  ...parsed,
@@ -565,7 +565,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
565
565
  const content = trimDocumentContent(extracted.text)
566
566
  if (!content) return 'Error: extracted document text is empty.'
567
567
 
568
- const docId = crypto.randomBytes(6).toString('hex')
568
+ const docId = genId(6)
569
569
  const now = Date.now()
570
570
  const parsedMetadata = metadata && typeof metadata === 'string'
571
571
  ? (() => {
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
- import crypto from 'crypto'
3
+ import { genId } from '@/lib/id'
4
4
  import { spawn, spawnSync } from 'child_process'
5
5
  import { loadAgents, loadTasks, upsertTask } from '../storage'
6
6
  import { log } from '../logger'
@@ -619,8 +619,8 @@ export function buildDelegateTools(bctx: ToolBuildContext): StructuredToolInterf
619
619
  }
620
620
  }
621
621
 
622
- // delegate_to_agent: requires orchestrator capability to be enabled
623
- if (bctx.activeTools.includes('orchestrator') && ctx?.agentId) {
622
+ // delegate_to_agent: requires "Assign to Other Agents" (platformAssignScope: 'all')
623
+ if (ctx?.platformAssignScope === 'all' && ctx?.agentId) {
624
624
  tools.push(
625
625
  tool(
626
626
  async ({ agentId: targetAgentId, task: taskPrompt, description: taskDesc, startImmediately }) => {
@@ -640,7 +640,7 @@ export function buildDelegateTools(bctx: ToolBuildContext): StructuredToolInterf
640
640
  }
641
641
  if (!target) return `Error: Agent "${targetAgentId}" not found. Use the agent directory in your system prompt to find valid agent IDs.`
642
642
 
643
- const taskId = crypto.randomBytes(4).toString('hex')
643
+ const taskId = genId()
644
644
  const now = Date.now()
645
645
  const newTask = {
646
646
  id: taskId,
@@ -653,7 +653,7 @@ export function buildDelegateTools(bctx: ToolBuildContext): StructuredToolInterf
653
653
  createdAt: now,
654
654
  updatedAt: now,
655
655
  comments: [{
656
- id: crypto.randomBytes(4).toString('hex'),
656
+ id: genId(),
657
657
  author: agents[ctx.agentId!]?.name || 'Agent',
658
658
  agentId: ctx.agentId!,
659
659
  text: `Delegated from ${agents[ctx.agentId!]?.name || ctx.agentId}`,
@@ -44,10 +44,15 @@ export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[
44
44
  if (canWriteFiles) {
45
45
  tools.push(
46
46
  tool(
47
- async ({ filePath, content }) => {
47
+ async ({ filePath, content, encoding }) => {
48
48
  try {
49
49
  const resolved = safePath(bctx.cwd, filePath)
50
50
  fs.mkdirSync(path.dirname(resolved), { recursive: true })
51
+ if (encoding === 'base64') {
52
+ const buf = Buffer.from(content, 'base64')
53
+ fs.writeFileSync(resolved, buf)
54
+ return `File written: ${filePath} (${buf.length} bytes, binary)`
55
+ }
51
56
  fs.writeFileSync(resolved, content, 'utf-8')
52
57
  return `File written: ${filePath} (${content.length} bytes)`
53
58
  } catch (err: any) {
@@ -56,10 +61,11 @@ export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[
56
61
  },
57
62
  {
58
63
  name: 'write_file',
59
- description: 'Write content to a file in the session working directory. Creates directories if needed.',
64
+ description: 'Write content to a file in the session working directory. Creates directories if needed. For PDFs and styled reports, use the create_document tool instead. For other binary files (Excel, images, zip, etc.), set encoding to "base64" and pass base64-encoded content.',
60
65
  schema: z.object({
61
66
  filePath: z.string().describe('Relative path to the file'),
62
- content: z.string().describe('The content to write'),
67
+ content: z.string().describe('The content to write. For binary files, this must be a base64-encoded string.'),
68
+ encoding: z.enum(['utf-8', 'base64']).optional().describe('Encoding of the content. Use "base64" for binary files like PDF, Excel, images, zip archives. Defaults to "utf-8" for plain text.'),
63
69
  }),
64
70
  },
65
71
  ),
@@ -229,6 +235,173 @@ export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[
229
235
  )
230
236
  }
231
237
 
238
+ if (canSendFiles || canWriteFiles) {
239
+ // create_document: markdown → pdf / html / png / jpg
240
+ tools.push(
241
+ tool(
242
+ async ({ content, title, filename, format }) => {
243
+ try {
244
+ const fmt = format || 'pdf'
245
+ const { marked } = await import('marked')
246
+ const html = await marked.parse(content)
247
+ const safeTitle = (title || 'Document').replace(/</g, '&lt;')
248
+ const fullHtml = `<!DOCTYPE html>
249
+ <html><head><meta charset="utf-8"><title>${safeTitle}</title>
250
+ <style>
251
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;color:#1a1a1a;line-height:1.6}
252
+ h1{font-size:28px;border-bottom:2px solid #e5e7eb;padding-bottom:8px}
253
+ h2{font-size:22px;margin-top:32px}
254
+ h3{font-size:18px;margin-top:24px}
255
+ pre{background:#f3f4f6;padding:16px;border-radius:8px;overflow-x:auto;font-size:13px}
256
+ code{background:#f3f4f6;padding:2px 6px;border-radius:4px;font-size:13px}
257
+ pre code{background:none;padding:0}
258
+ table{border-collapse:collapse;width:100%}
259
+ th,td{border:1px solid #d1d5db;padding:8px 12px;text-align:left}
260
+ th{background:#f9fafb;font-weight:600}
261
+ blockquote{border-left:4px solid #d1d5db;margin:16px 0;padding:8px 16px;color:#4b5563}
262
+ img{max-width:100%}
263
+ </style></head><body>${html}</body></html>`
264
+
265
+ const defaultBase = (title || 'document').replace(/[^a-zA-Z0-9_-]/g, '_')
266
+
267
+ if (fmt === 'html') {
268
+ const outName = filename || `${defaultBase}.html`
269
+ const resolved = safePath(bctx.cwd, outName)
270
+ fs.mkdirSync(path.dirname(resolved), { recursive: true })
271
+ fs.writeFileSync(resolved, fullHtml, 'utf-8')
272
+ return `HTML document created: ${outName} (${fullHtml.length} bytes)`
273
+ }
274
+
275
+ const { chromium } = await import('playwright')
276
+ const browser = await chromium.launch({ headless: true })
277
+ try {
278
+ const page = await browser.newPage()
279
+ await page.setContent(fullHtml, { waitUntil: 'networkidle' })
280
+
281
+ if (fmt === 'pdf') {
282
+ const outName = filename || `${defaultBase}.pdf`
283
+ const resolved = safePath(bctx.cwd, outName)
284
+ fs.mkdirSync(path.dirname(resolved), { recursive: true })
285
+ await page.pdf({ path: resolved, format: 'A4', margin: { top: '40px', bottom: '40px', left: '40px', right: '40px' }, printBackground: true })
286
+ return `PDF created: ${outName}`
287
+ }
288
+
289
+ // png or jpg screenshot
290
+ const ext = fmt === 'jpg' ? 'jpeg' : 'png'
291
+ const outName = filename || `${defaultBase}.${fmt}`
292
+ const resolved = safePath(bctx.cwd, outName)
293
+ fs.mkdirSync(path.dirname(resolved), { recursive: true })
294
+ await page.screenshot({ path: resolved, type: ext, fullPage: true })
295
+ const size = fs.statSync(resolved).size
296
+ return `Image created: ${outName} (${(size / 1024).toFixed(1)} KB)`
297
+ } finally {
298
+ await browser.close()
299
+ }
300
+ } catch (err: any) {
301
+ return `Error creating document: ${err.message}`
302
+ }
303
+ },
304
+ {
305
+ name: 'create_document',
306
+ description: 'Create a document from markdown content. Renders markdown with professional styling and outputs as PDF, HTML, or image. Use this instead of write_file for PDFs, reports, styled pages, or document screenshots. After creating, use send_file to deliver it to the user.',
307
+ schema: z.object({
308
+ content: z.string().describe('Markdown content for the document'),
309
+ title: z.string().optional().describe('Document title (shown in header and used for default filename)'),
310
+ filename: z.string().optional().describe('Output filename (defaults to title-based name with appropriate extension)'),
311
+ format: z.enum(['pdf', 'html', 'png', 'jpg']).optional().describe('Output format. "pdf" (default) for print-ready documents, "html" for web pages, "png"/"jpg" for images.'),
312
+ }),
313
+ },
314
+ ),
315
+ )
316
+
317
+ // create_spreadsheet: JSON data → xlsx or csv
318
+ tools.push(
319
+ tool(
320
+ async ({ data, headers, sheetName, filename, format }) => {
321
+ try {
322
+ const fmt = format || 'xlsx'
323
+ let rows: Record<string, unknown>[]
324
+ try {
325
+ rows = JSON.parse(data)
326
+ if (!Array.isArray(rows)) return 'Error: data must be a JSON array of objects'
327
+ } catch {
328
+ return 'Error: data is not valid JSON. Pass a JSON array of objects, e.g. [{"name":"Alice","age":30}]'
329
+ }
330
+
331
+ if (!rows.length) return 'Error: data array is empty'
332
+
333
+ // Resolve column headers: explicit headers, or keys from first row
334
+ const cols = headers?.length
335
+ ? headers
336
+ : Object.keys(rows[0] && typeof rows[0] === 'object' ? rows[0] : {})
337
+ if (!cols.length) return 'Error: could not determine column headers. Pass headers or use objects with keys.'
338
+
339
+ const defaultBase = (sheetName || 'spreadsheet').replace(/[^a-zA-Z0-9_-]/g, '_')
340
+
341
+ if (fmt === 'csv') {
342
+ const escapeCsv = (val: unknown): string => {
343
+ const s = val == null ? '' : String(val)
344
+ return s.includes(',') || s.includes('"') || s.includes('\n')
345
+ ? `"${s.replace(/"/g, '""')}"`
346
+ : s
347
+ }
348
+ const lines = [cols.map(escapeCsv).join(',')]
349
+ for (const row of rows) {
350
+ const r = Array.isArray(row) ? row : cols.map((c) => (row as Record<string, unknown>)[c])
351
+ lines.push(r.map(escapeCsv).join(','))
352
+ }
353
+ const outName = filename || `${defaultBase}.csv`
354
+ const resolved = safePath(bctx.cwd, outName)
355
+ fs.mkdirSync(path.dirname(resolved), { recursive: true })
356
+ fs.writeFileSync(resolved, lines.join('\n'), 'utf-8')
357
+ return `CSV created: ${outName} (${rows.length} rows, ${cols.length} columns)`
358
+ }
359
+
360
+ // xlsx via exceljs
361
+ const ExcelJS = await import('exceljs')
362
+ const workbook = new ExcelJS.default.Workbook()
363
+ const sheet = workbook.addWorksheet(sheetName || 'Sheet1')
364
+
365
+ sheet.columns = cols.map((c) => ({ header: c, key: c, width: Math.max(12, c.length + 4) }))
366
+ // Style header row
367
+ sheet.getRow(1).font = { bold: true }
368
+ sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F4F6' } }
369
+
370
+ for (const row of rows) {
371
+ if (Array.isArray(row)) {
372
+ const obj: Record<string, unknown> = {}
373
+ cols.forEach((c, i) => { obj[c] = row[i] })
374
+ sheet.addRow(obj)
375
+ } else {
376
+ sheet.addRow(row)
377
+ }
378
+ }
379
+
380
+ const outName = filename || `${defaultBase}.xlsx`
381
+ const resolved = safePath(bctx.cwd, outName)
382
+ fs.mkdirSync(path.dirname(resolved), { recursive: true })
383
+ await workbook.xlsx.writeFile(resolved)
384
+ const size = fs.statSync(resolved).size
385
+ return `Excel spreadsheet created: ${outName} (${rows.length} rows, ${cols.length} columns, ${(size / 1024).toFixed(1)} KB)`
386
+ } catch (err: any) {
387
+ return `Error creating spreadsheet: ${err.message}`
388
+ }
389
+ },
390
+ {
391
+ name: 'create_spreadsheet',
392
+ description: 'Create an Excel (.xlsx) or CSV file from structured data. Pass data as a JSON array of objects. Use this for tables, reports, data exports, and any tabular data the user requests. After creating, use send_file to deliver it to the user.',
393
+ schema: z.object({
394
+ data: z.string().describe('JSON array of objects, e.g. [{"name":"Alice","score":95},{"name":"Bob","score":87}]'),
395
+ headers: z.array(z.string()).optional().describe('Column headers in display order. If omitted, keys from the first object are used.'),
396
+ sheetName: z.string().optional().describe('Worksheet name (default "Sheet1")'),
397
+ filename: z.string().optional().describe('Output filename (defaults to sheetName-based name with extension)'),
398
+ format: z.enum(['xlsx', 'csv']).optional().describe('Output format: "xlsx" (default) for Excel, "csv" for plain CSV.'),
399
+ }),
400
+ },
401
+ ),
402
+ )
403
+ }
404
+
232
405
  if (bctx.hasTool('edit_file')) {
233
406
  tools.push(
234
407
  tool(
@@ -14,6 +14,8 @@ import { buildCrudTools } from './crud'
14
14
  import { buildSessionInfoTools } from './session-info'
15
15
  import { buildConnectorTools } from './connector'
16
16
  import { buildContextTools } from './context-mgmt'
17
+ import { buildSandboxTools } from './sandbox'
18
+ import { buildOpenClawNodeTools } from './openclaw-nodes'
17
19
 
18
20
  export type { ToolContext, SessionToolsResult }
19
21
  export { sweepOrphanedBrowsers, cleanupSessionBrowser, getActiveBrowserCount, hasActiveBrowser }
@@ -93,6 +95,8 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
93
95
  ...buildSessionInfoTools(bctx),
94
96
  ...buildConnectorTools(bctx),
95
97
  ...buildContextTools(bctx),
98
+ ...buildSandboxTools(bctx),
99
+ ...buildOpenClawNodeTools(bctx),
96
100
  )
97
101
 
98
102
  // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
3
  import fs from 'fs'
4
- import crypto from 'crypto'
4
+ import { genId } from '@/lib/id'
5
5
  import { getMemoryDb, getMemoryLookupLimits, storeMemoryImageAsset } from '../memory-db'
6
6
  import { loadSettings } from '../storage'
7
7
  import type { ToolBuildContext } from './context'
@@ -81,7 +81,7 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
81
81
  return `Error: image file not found: ${imagePath}`
82
82
  }
83
83
  try {
84
- storedImage = await storeMemoryImageAsset(imagePath, crypto.randomBytes(6).toString('hex'))
84
+ storedImage = await storeMemoryImageAsset(imagePath, genId(6))
85
85
  } catch {
86
86
  return `Error: failed to process image at ${imagePath}`
87
87
  }