@swarmclawai/swarmclaw 0.4.0 → 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 (144) hide show
  1. package/README.md +13 -2
  2. package/next.config.ts +8 -0
  3. package/package.json +2 -1
  4. package/src/app/api/agents/[id]/route.ts +20 -21
  5. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  6. package/src/app/api/agents/route.ts +3 -2
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/connectors/[id]/route.ts +10 -3
  9. package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
  10. package/src/app/api/connectors/route.ts +6 -3
  11. package/src/app/api/credentials/[id]/route.ts +2 -1
  12. package/src/app/api/credentials/route.ts +2 -2
  13. package/src/app/api/documents/route.ts +2 -2
  14. package/src/app/api/files/serve/route.ts +8 -0
  15. package/src/app/api/knowledge/[id]/route.ts +5 -4
  16. package/src/app/api/knowledge/upload/route.ts +2 -2
  17. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  18. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  19. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  20. package/src/app/api/mcp-servers/route.ts +2 -2
  21. package/src/app/api/memory/[id]/route.ts +9 -8
  22. package/src/app/api/memory/route.ts +2 -2
  23. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  24. package/src/app/api/openclaw/directory/route.ts +26 -0
  25. package/src/app/api/openclaw/discover/route.ts +61 -0
  26. package/src/app/api/openclaw/sync/route.ts +30 -0
  27. package/src/app/api/orchestrator/run/route.ts +2 -2
  28. package/src/app/api/projects/[id]/route.ts +55 -0
  29. package/src/app/api/projects/route.ts +27 -0
  30. package/src/app/api/providers/[id]/models/route.ts +2 -1
  31. package/src/app/api/providers/[id]/route.ts +13 -15
  32. package/src/app/api/providers/route.ts +2 -2
  33. package/src/app/api/schedules/[id]/route.ts +16 -18
  34. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  35. package/src/app/api/schedules/route.ts +2 -2
  36. package/src/app/api/secrets/[id]/route.ts +16 -17
  37. package/src/app/api/secrets/route.ts +2 -2
  38. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  39. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  40. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  41. package/src/app/api/sessions/[id]/messages/route.ts +2 -1
  42. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  43. package/src/app/api/sessions/[id]/route.ts +2 -1
  44. package/src/app/api/sessions/route.ts +2 -2
  45. package/src/app/api/skills/[id]/route.ts +23 -21
  46. package/src/app/api/skills/import/route.ts +2 -2
  47. package/src/app/api/skills/route.ts +2 -2
  48. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  49. package/src/app/api/tasks/[id]/route.ts +6 -5
  50. package/src/app/api/tasks/route.ts +2 -2
  51. package/src/app/api/tts/stream/route.ts +48 -0
  52. package/src/app/api/upload/route.ts +2 -2
  53. package/src/app/api/uploads/[filename]/route.ts +4 -1
  54. package/src/app/api/webhooks/[id]/route.ts +29 -31
  55. package/src/app/api/webhooks/route.ts +2 -2
  56. package/src/app/page.tsx +3 -24
  57. package/src/cli/index.js +28 -0
  58. package/src/cli/index.ts +1 -1
  59. package/src/cli/spec.js +2 -0
  60. package/src/components/agents/agent-list.tsx +3 -1
  61. package/src/components/agents/agent-sheet.tsx +116 -14
  62. package/src/components/chat/chat-area.tsx +27 -4
  63. package/src/components/chat/chat-header.tsx +141 -29
  64. package/src/components/chat/tool-call-bubble.tsx +9 -3
  65. package/src/components/chat/voice-overlay.tsx +80 -0
  66. package/src/components/connectors/connector-list.tsx +6 -2
  67. package/src/components/connectors/connector-sheet.tsx +31 -7
  68. package/src/components/layout/app-layout.tsx +47 -25
  69. package/src/components/projects/project-list.tsx +122 -0
  70. package/src/components/projects/project-sheet.tsx +135 -0
  71. package/src/components/schedules/schedule-list.tsx +3 -1
  72. package/src/components/sessions/new-session-sheet.tsx +6 -6
  73. package/src/components/sessions/session-card.tsx +1 -1
  74. package/src/components/sessions/session-list.tsx +7 -7
  75. package/src/components/shared/connector-platform-icon.tsx +4 -0
  76. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  77. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  78. package/src/components/shared/settings/section-web-search.tsx +56 -0
  79. package/src/components/shared/settings/settings-page.tsx +73 -0
  80. package/src/components/skills/skill-list.tsx +2 -1
  81. package/src/components/tasks/task-list.tsx +5 -2
  82. package/src/hooks/use-continuous-speech.ts +144 -0
  83. package/src/hooks/use-view-router.ts +52 -0
  84. package/src/hooks/use-voice-conversation.ts +80 -0
  85. package/src/lib/id.ts +6 -0
  86. package/src/lib/projects.ts +13 -0
  87. package/src/lib/provider-sets.ts +5 -0
  88. package/src/lib/providers/anthropic.ts +14 -1
  89. package/src/lib/providers/index.ts +6 -0
  90. package/src/lib/providers/ollama.ts +9 -1
  91. package/src/lib/providers/openai.ts +9 -1
  92. package/src/lib/providers/openclaw.ts +11 -0
  93. package/src/lib/server/api-routes.test.ts +5 -6
  94. package/src/lib/server/build-llm.ts +17 -4
  95. package/src/lib/server/chat-execution.ts +38 -4
  96. package/src/lib/server/collection-helpers.ts +54 -0
  97. package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
  98. package/src/lib/server/connectors/bluebubbles.ts +357 -0
  99. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  100. package/src/lib/server/connectors/googlechat.ts +46 -7
  101. package/src/lib/server/connectors/manager.ts +392 -3
  102. package/src/lib/server/connectors/media.ts +2 -2
  103. package/src/lib/server/connectors/openclaw.ts +64 -0
  104. package/src/lib/server/connectors/pairing.test.ts +99 -0
  105. package/src/lib/server/connectors/pairing.ts +256 -0
  106. package/src/lib/server/connectors/signal.ts +1 -0
  107. package/src/lib/server/connectors/teams.ts +5 -5
  108. package/src/lib/server/connectors/types.ts +10 -0
  109. package/src/lib/server/execution-log.ts +3 -3
  110. package/src/lib/server/heartbeat-service.ts +1 -1
  111. package/src/lib/server/knowledge-db.test.ts +2 -33
  112. package/src/lib/server/main-agent-loop.ts +6 -6
  113. package/src/lib/server/memory-db.ts +6 -6
  114. package/src/lib/server/openclaw-approvals.ts +105 -0
  115. package/src/lib/server/openclaw-sync.ts +496 -0
  116. package/src/lib/server/orchestrator-lg.ts +30 -9
  117. package/src/lib/server/orchestrator.ts +4 -4
  118. package/src/lib/server/process-manager.ts +2 -2
  119. package/src/lib/server/queue.ts +22 -10
  120. package/src/lib/server/scheduler.ts +2 -2
  121. package/src/lib/server/session-mailbox.ts +2 -2
  122. package/src/lib/server/session-run-manager.ts +2 -2
  123. package/src/lib/server/session-tools/connector.ts +51 -4
  124. package/src/lib/server/session-tools/crud.ts +3 -3
  125. package/src/lib/server/session-tools/delegate.ts +3 -3
  126. package/src/lib/server/session-tools/file.ts +176 -3
  127. package/src/lib/server/session-tools/index.ts +2 -0
  128. package/src/lib/server/session-tools/memory.ts +2 -2
  129. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  130. package/src/lib/server/session-tools/sandbox.ts +33 -0
  131. package/src/lib/server/session-tools/search-providers.ts +270 -0
  132. package/src/lib/server/session-tools/session-info.ts +2 -2
  133. package/src/lib/server/session-tools/web.ts +47 -66
  134. package/src/lib/server/storage.ts +12 -0
  135. package/src/lib/server/stream-agent-chat.ts +29 -0
  136. package/src/lib/server/task-result.test.ts +44 -0
  137. package/src/lib/server/task-result.ts +14 -0
  138. package/src/lib/tool-definitions.ts +5 -3
  139. package/src/lib/tts-stream.ts +130 -0
  140. package/src/lib/view-routes.ts +28 -0
  141. package/src/proxy.ts +3 -0
  142. package/src/stores/use-app-store.ts +28 -1
  143. package/src/stores/use-chat-store.ts +9 -1
  144. package/src/types/index.ts +27 -2
@@ -1,4 +1,4 @@
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
3
  import { notify } from './ws-hub'
4
4
  import { WORKSPACE_DIR } from './data-dir'
@@ -155,7 +155,11 @@ function notifyMainChatScheduleResult(task: BoardTask): void {
155
155
  const fallbackText = runSession ? latestAssistantText(runSession) : ''
156
156
 
157
157
  // Zod-validated structured extraction: one pass to get summary + all artifacts
158
- 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
+ )
159
163
  const resultBody = formatResultBody(taskResult)
160
164
 
161
165
  const statusLabel = task.status === 'completed' ? 'completed' : 'failed'
@@ -224,7 +228,11 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
224
228
  const runSessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
225
229
  const runSession = runSessionId ? sessions[runSessionId] : null
226
230
  const fallbackText = runSession ? latestAssistantText(runSession) : ''
227
- 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
+ )
228
236
  const resultBody = formatResultBody(taskResult)
229
237
 
230
238
  const statusLabel = task.status === 'completed' ? 'completed' : 'failed'
@@ -374,7 +382,7 @@ export function validateCompletedTasksQueue() {
374
382
  task.updatedAt = now
375
383
  if (!task.comments) task.comments = []
376
384
  task.comments.push({
377
- id: crypto.randomBytes(4).toString('hex'),
385
+ id: genId(),
378
386
  author: 'System',
379
387
  text: `Task auto-failed completed-queue validation.\n\n${validation.reasons.map((r) => `- ${r}`).join('\n')}`,
380
388
  createdAt: now,
@@ -413,7 +421,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
413
421
  task.error = `Retry scheduled after failure: ${reason}`.slice(0, 500)
414
422
  if (!task.comments) task.comments = []
415
423
  task.comments.push({
416
- id: crypto.randomBytes(4).toString('hex'),
424
+ id: genId(),
417
425
  author: 'System',
418
426
  text: `Attempt ${task.attempts}/${task.maxAttempts} failed. Retrying in ${delaySec}s.\n\nReason: ${reason}`,
419
427
  createdAt: now,
@@ -428,7 +436,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
428
436
  task.error = `Dead-lettered after ${task.attempts}/${task.maxAttempts} attempts: ${reason}`.slice(0, 500)
429
437
  if (!task.comments) task.comments = []
430
438
  task.comments.push({
431
- id: crypto.randomBytes(4).toString('hex'),
439
+ id: genId(),
432
440
  author: 'System',
433
441
  text: `Task moved to dead-letter after ${task.attempts}/${task.maxAttempts} attempts.\n\nReason: ${reason}`,
434
442
  createdAt: now,
@@ -610,7 +618,11 @@ export async function processNext() {
610
618
  applyTaskPolicyDefaults(t2[taskId])
611
619
  // Structured extraction: Zod-validated result with typed artifacts
612
620
  const runSessions = loadSessions()
613
- 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
+ )
614
626
  const enrichedResult = formatResultBody(taskResult)
615
627
  t2[taskId].result = enrichedResult.slice(0, 4000) || null
616
628
  t2[taskId].updatedAt = Date.now()
@@ -636,7 +648,7 @@ export async function processNext() {
636
648
  updatedAt: now,
637
649
  }
638
650
  t2[taskId].comments!.push({
639
- id: crypto.randomBytes(4).toString('hex'),
651
+ id: genId(),
640
652
  author: agent.name,
641
653
  agentId: agent.id,
642
654
  text: `Task completed.\n\n${result?.slice(0, 1000) || 'No summary provided.'}`,
@@ -647,7 +659,7 @@ export async function processNext() {
647
659
  const retryState = scheduleRetryOrDeadLetter(t2[taskId], failureReason)
648
660
  t2[taskId].completedAt = retryState === 'dead_lettered' ? null : t2[taskId].completedAt
649
661
  t2[taskId].comments!.push({
650
- id: crypto.randomBytes(4).toString('hex'),
662
+ id: genId(),
651
663
  author: agent.name,
652
664
  agentId: agent.id,
653
665
  text: `Task failed validation and was not marked completed.\n\n${validation.reasons.map((r) => `- ${r}`).join('\n')}`,
@@ -739,7 +751,7 @@ export async function processNext() {
739
751
  const isRepeatError = lastComment?.agentId === agent.id && lastComment?.text.startsWith('Task failed')
740
752
  if (!isRepeatError) {
741
753
  t2[taskId].comments!.push({
742
- id: crypto.randomBytes(4).toString('hex'),
754
+ id: genId(),
743
755
  author: agent.name,
744
756
  agentId: agent.id,
745
757
  text: 'Task failed — see error details above.',
@@ -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'
@@ -420,7 +420,7 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
420
420
  }
421
421
  }
422
422
 
423
- const runId = crypto.randomBytes(8).toString('hex')
423
+ const runId = genId(8)
424
424
  const run: SessionRunRecord = {
425
425
  id: runId,
426
426
  sessionId: input.sessionId,
@@ -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'
@@ -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(
@@ -15,6 +15,7 @@ import { buildSessionInfoTools } from './session-info'
15
15
  import { buildConnectorTools } from './connector'
16
16
  import { buildContextTools } from './context-mgmt'
17
17
  import { buildSandboxTools } from './sandbox'
18
+ import { buildOpenClawNodeTools } from './openclaw-nodes'
18
19
 
19
20
  export type { ToolContext, SessionToolsResult }
20
21
  export { sweepOrphanedBrowsers, cleanupSessionBrowser, getActiveBrowserCount, hasActiveBrowser }
@@ -95,6 +96,7 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
95
96
  ...buildConnectorTools(bctx),
96
97
  ...buildContextTools(bctx),
97
98
  ...buildSandboxTools(bctx),
99
+ ...buildOpenClawNodeTools(bctx),
98
100
  )
99
101
 
100
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
  }
@@ -0,0 +1,112 @@
1
+ import { z } from 'zod'
2
+ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
+ import type { ToolBuildContext } from './context'
4
+
5
+ export function buildOpenClawNodeTools(bctx: ToolBuildContext): StructuredToolInterface[] {
6
+ if (!bctx.hasTool('openclaw_nodes')) return []
7
+
8
+ const tools: StructuredToolInterface[] = []
9
+
10
+ tools.push(
11
+ tool(
12
+ async () => {
13
+ try {
14
+ const { listRunningConnectors } = await import('../connectors/manager')
15
+ const openclawConnectors = listRunningConnectors('openclaw')
16
+ if (!openclawConnectors.length) {
17
+ return JSON.stringify({ error: 'No running OpenClaw connector found.' })
18
+ }
19
+ const { getRunningInstance } = await import('../connectors/manager')
20
+ const inst = getRunningInstance(openclawConnectors[0].id)
21
+ if (!inst) return JSON.stringify({ error: 'OpenClaw connector instance not accessible.' })
22
+
23
+ // Proxy through RPC — use sendMessage as a workaround to invoke RPC
24
+ // We need direct RPC access, so check if the instance exposes it
25
+ // For now, return a helpful message about the integration
26
+ return JSON.stringify({
27
+ status: 'openclaw_nodes_list requires nodes.list RPC support on the gateway',
28
+ connectorId: openclawConnectors[0].id,
29
+ note: 'This feature requires the OpenClaw gateway to support nodes.* RPCs.',
30
+ })
31
+ } catch (err: any) {
32
+ return JSON.stringify({ error: err.message })
33
+ }
34
+ },
35
+ {
36
+ name: 'openclaw_nodes_list',
37
+ description: 'List connected nodes/IoT devices through the OpenClaw gateway. Requires a running OpenClaw connector with nodes.* RPC support.',
38
+ schema: z.object({}),
39
+ },
40
+ ),
41
+ )
42
+
43
+ tools.push(
44
+ tool(
45
+ async ({ nodeId, action, params }) => {
46
+ try {
47
+ const { listRunningConnectors, getRunningInstance } = await import('../connectors/manager')
48
+ const openclawConnectors = listRunningConnectors('openclaw')
49
+ if (!openclawConnectors.length) {
50
+ return JSON.stringify({ error: 'No running OpenClaw connector found.' })
51
+ }
52
+ const inst = getRunningInstance(openclawConnectors[0].id)
53
+ if (!inst) return JSON.stringify({ error: 'OpenClaw connector instance not accessible.' })
54
+
55
+ return JSON.stringify({
56
+ status: 'openclaw_node_invoke requires nodes.invoke RPC support on the gateway',
57
+ nodeId,
58
+ action,
59
+ params: params || null,
60
+ connectorId: openclawConnectors[0].id,
61
+ })
62
+ } catch (err: any) {
63
+ return JSON.stringify({ error: err.message })
64
+ }
65
+ },
66
+ {
67
+ name: 'openclaw_node_invoke',
68
+ description: 'Invoke an action on a connected node/IoT device through the OpenClaw gateway.',
69
+ schema: z.object({
70
+ nodeId: z.string().describe('Target node ID'),
71
+ action: z.string().describe('Action to invoke on the node'),
72
+ params: z.record(z.string(), z.unknown()).optional().describe('Optional parameters for the action'),
73
+ }),
74
+ },
75
+ ),
76
+ )
77
+
78
+ tools.push(
79
+ tool(
80
+ async ({ nodeId, message }) => {
81
+ try {
82
+ const { listRunningConnectors, getRunningInstance } = await import('../connectors/manager')
83
+ const openclawConnectors = listRunningConnectors('openclaw')
84
+ if (!openclawConnectors.length) {
85
+ return JSON.stringify({ error: 'No running OpenClaw connector found.' })
86
+ }
87
+ const inst = getRunningInstance(openclawConnectors[0].id)
88
+ if (!inst) return JSON.stringify({ error: 'OpenClaw connector instance not accessible.' })
89
+
90
+ return JSON.stringify({
91
+ status: 'openclaw_node_notify requires nodes.notify RPC support on the gateway',
92
+ nodeId,
93
+ message,
94
+ connectorId: openclawConnectors[0].id,
95
+ })
96
+ } catch (err: any) {
97
+ return JSON.stringify({ error: err.message })
98
+ }
99
+ },
100
+ {
101
+ name: 'openclaw_node_notify',
102
+ description: 'Send a notification to a connected node/IoT device through the OpenClaw gateway.',
103
+ schema: z.object({
104
+ nodeId: z.string().describe('Target node ID'),
105
+ message: z.string().describe('Notification message'),
106
+ }),
107
+ },
108
+ ),
109
+ )
110
+
111
+ return tools
112
+ }