@swarmclawai/swarmclaw 0.4.0 → 0.5.0

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 (209) hide show
  1. package/README.md +21 -4
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +13 -0
  4. package/package.json +3 -1
  5. package/src/app/api/agents/[id]/route.ts +39 -22
  6. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  7. package/src/app/api/agents/route.ts +3 -2
  8. package/src/app/api/agents/trash/route.ts +44 -0
  9. package/src/app/api/clawhub/install/route.ts +2 -2
  10. package/src/app/api/connectors/[id]/route.ts +17 -7
  11. package/src/app/api/connectors/[id]/webhook/route.ts +103 -0
  12. package/src/app/api/connectors/route.ts +6 -3
  13. package/src/app/api/credentials/[id]/route.ts +2 -1
  14. package/src/app/api/credentials/route.ts +2 -2
  15. package/src/app/api/documents/route.ts +2 -2
  16. package/src/app/api/files/serve/route.ts +8 -0
  17. package/src/app/api/knowledge/[id]/route.ts +5 -4
  18. package/src/app/api/knowledge/upload/route.ts +2 -2
  19. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  20. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  21. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  22. package/src/app/api/mcp-servers/route.ts +2 -2
  23. package/src/app/api/memory/[id]/route.ts +9 -8
  24. package/src/app/api/memory/route.ts +2 -2
  25. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  26. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  27. package/src/app/api/openclaw/approvals/route.ts +46 -0
  28. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  29. package/src/app/api/openclaw/cron/route.ts +52 -0
  30. package/src/app/api/openclaw/directory/route.ts +27 -0
  31. package/src/app/api/openclaw/discover/route.ts +62 -0
  32. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  33. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  34. package/src/app/api/openclaw/gateway/route.ts +72 -0
  35. package/src/app/api/openclaw/history/route.ts +109 -0
  36. package/src/app/api/openclaw/media/route.ts +53 -0
  37. package/src/app/api/openclaw/models/route.ts +12 -0
  38. package/src/app/api/openclaw/permissions/route.ts +39 -0
  39. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  40. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  41. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  42. package/src/app/api/openclaw/skills/route.ts +82 -0
  43. package/src/app/api/openclaw/sync/route.ts +31 -0
  44. package/src/app/api/orchestrator/run/route.ts +2 -2
  45. package/src/app/api/projects/[id]/route.ts +55 -0
  46. package/src/app/api/projects/route.ts +27 -0
  47. package/src/app/api/providers/[id]/models/route.ts +2 -1
  48. package/src/app/api/providers/[id]/route.ts +13 -15
  49. package/src/app/api/providers/route.ts +2 -2
  50. package/src/app/api/schedules/[id]/route.ts +16 -18
  51. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  52. package/src/app/api/schedules/route.ts +2 -2
  53. package/src/app/api/secrets/[id]/route.ts +16 -17
  54. package/src/app/api/secrets/route.ts +2 -2
  55. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  56. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  57. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  58. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  59. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  60. package/src/app/api/sessions/[id]/messages/route.ts +20 -2
  61. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  62. package/src/app/api/sessions/[id]/route.ts +14 -4
  63. package/src/app/api/sessions/route.ts +8 -4
  64. package/src/app/api/skills/[id]/route.ts +23 -21
  65. package/src/app/api/skills/import/route.ts +2 -2
  66. package/src/app/api/skills/route.ts +2 -2
  67. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  68. package/src/app/api/tasks/[id]/route.ts +6 -5
  69. package/src/app/api/tasks/route.ts +2 -2
  70. package/src/app/api/tts/stream/route.ts +48 -0
  71. package/src/app/api/upload/route.ts +2 -2
  72. package/src/app/api/uploads/[filename]/route.ts +4 -1
  73. package/src/app/api/webhooks/[id]/route.ts +29 -31
  74. package/src/app/api/webhooks/route.ts +2 -2
  75. package/src/app/globals.css +14 -0
  76. package/src/app/layout.tsx +5 -20
  77. package/src/app/page.tsx +3 -24
  78. package/src/cli/index.js +60 -0
  79. package/src/cli/index.ts +1 -1
  80. package/src/cli/spec.js +42 -0
  81. package/src/components/agents/agent-avatar.tsx +45 -0
  82. package/src/components/agents/agent-card.tsx +19 -5
  83. package/src/components/agents/agent-chat-list.tsx +31 -24
  84. package/src/components/agents/agent-files-editor.tsx +185 -0
  85. package/src/components/agents/agent-list.tsx +84 -3
  86. package/src/components/agents/agent-sheet.tsx +147 -14
  87. package/src/components/agents/cron-job-form.tsx +137 -0
  88. package/src/components/agents/exec-config-panel.tsx +147 -0
  89. package/src/components/agents/inspector-panel.tsx +310 -0
  90. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  91. package/src/components/agents/permission-preset-selector.tsx +79 -0
  92. package/src/components/agents/personality-builder.tsx +111 -0
  93. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  94. package/src/components/agents/skill-install-dialog.tsx +102 -0
  95. package/src/components/agents/trash-list.tsx +109 -0
  96. package/src/components/chat/chat-area.tsx +41 -6
  97. package/src/components/chat/chat-header.tsx +305 -29
  98. package/src/components/chat/chat-preview-panel.tsx +113 -0
  99. package/src/components/chat/exec-approval-card.tsx +89 -0
  100. package/src/components/chat/message-bubble.tsx +218 -36
  101. package/src/components/chat/message-list.tsx +135 -31
  102. package/src/components/chat/streaming-bubble.tsx +59 -10
  103. package/src/components/chat/suggestions-bar.tsx +74 -0
  104. package/src/components/chat/thinking-indicator.tsx +20 -6
  105. package/src/components/chat/tool-call-bubble.tsx +98 -19
  106. package/src/components/chat/tool-request-banner.tsx +20 -2
  107. package/src/components/chat/trace-block.tsx +103 -0
  108. package/src/components/chat/voice-overlay.tsx +80 -0
  109. package/src/components/connectors/connector-list.tsx +6 -2
  110. package/src/components/connectors/connector-sheet.tsx +31 -7
  111. package/src/components/layout/app-layout.tsx +47 -25
  112. package/src/components/projects/project-list.tsx +123 -0
  113. package/src/components/projects/project-sheet.tsx +135 -0
  114. package/src/components/schedules/schedule-list.tsx +3 -1
  115. package/src/components/sessions/new-session-sheet.tsx +6 -6
  116. package/src/components/sessions/session-card.tsx +1 -1
  117. package/src/components/sessions/session-list.tsx +7 -7
  118. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  119. package/src/components/shared/avatar.tsx +13 -2
  120. package/src/components/shared/connector-platform-icon.tsx +4 -0
  121. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  122. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  123. package/src/components/shared/settings/section-web-search.tsx +56 -0
  124. package/src/components/shared/settings/settings-page.tsx +74 -0
  125. package/src/components/skills/skill-list.tsx +2 -1
  126. package/src/components/tasks/task-board.tsx +1 -1
  127. package/src/components/tasks/task-list.tsx +5 -2
  128. package/src/components/tasks/task-sheet.tsx +12 -12
  129. package/src/hooks/use-continuous-speech.ts +181 -0
  130. package/src/hooks/use-openclaw-gateway.ts +63 -0
  131. package/src/hooks/use-view-router.ts +52 -0
  132. package/src/hooks/use-voice-conversation.ts +80 -0
  133. package/src/lib/id.ts +6 -0
  134. package/src/lib/notification-sounds.ts +58 -0
  135. package/src/lib/personality-parser.ts +97 -0
  136. package/src/lib/projects.ts +13 -0
  137. package/src/lib/provider-sets.ts +5 -0
  138. package/src/lib/providers/anthropic.ts +14 -1
  139. package/src/lib/providers/index.ts +6 -0
  140. package/src/lib/providers/ollama.ts +9 -1
  141. package/src/lib/providers/openai.ts +9 -1
  142. package/src/lib/providers/openclaw.ts +28 -2
  143. package/src/lib/runtime-loop.ts +2 -2
  144. package/src/lib/server/api-routes.test.ts +5 -6
  145. package/src/lib/server/build-llm.ts +17 -4
  146. package/src/lib/server/chat-execution.ts +82 -6
  147. package/src/lib/server/collection-helpers.ts +54 -0
  148. package/src/lib/server/connectors/bluebubbles.test.ts +217 -0
  149. package/src/lib/server/connectors/bluebubbles.ts +360 -0
  150. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  151. package/src/lib/server/connectors/googlechat.ts +51 -8
  152. package/src/lib/server/connectors/manager.ts +424 -13
  153. package/src/lib/server/connectors/media.ts +2 -2
  154. package/src/lib/server/connectors/openclaw.ts +65 -0
  155. package/src/lib/server/connectors/pairing.test.ts +99 -0
  156. package/src/lib/server/connectors/pairing.ts +256 -0
  157. package/src/lib/server/connectors/signal.ts +1 -0
  158. package/src/lib/server/connectors/teams.ts +5 -5
  159. package/src/lib/server/connectors/types.ts +10 -0
  160. package/src/lib/server/daemon-state.ts +11 -0
  161. package/src/lib/server/execution-log.ts +3 -3
  162. package/src/lib/server/heartbeat-service.ts +1 -1
  163. package/src/lib/server/knowledge-db.test.ts +2 -33
  164. package/src/lib/server/main-agent-loop.ts +8 -9
  165. package/src/lib/server/main-session.ts +21 -0
  166. package/src/lib/server/memory-db.ts +6 -6
  167. package/src/lib/server/openclaw-approvals.ts +105 -0
  168. package/src/lib/server/openclaw-config-sync.ts +107 -0
  169. package/src/lib/server/openclaw-exec-config.ts +52 -0
  170. package/src/lib/server/openclaw-gateway.ts +291 -0
  171. package/src/lib/server/openclaw-history-merge.ts +36 -0
  172. package/src/lib/server/openclaw-models.ts +56 -0
  173. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  174. package/src/lib/server/openclaw-sync.ts +497 -0
  175. package/src/lib/server/orchestrator-lg.ts +30 -9
  176. package/src/lib/server/orchestrator.ts +4 -4
  177. package/src/lib/server/process-manager.ts +2 -2
  178. package/src/lib/server/queue.ts +24 -11
  179. package/src/lib/server/scheduler.ts +2 -2
  180. package/src/lib/server/session-mailbox.ts +2 -2
  181. package/src/lib/server/session-run-manager.ts +2 -2
  182. package/src/lib/server/session-tools/connector.ts +53 -6
  183. package/src/lib/server/session-tools/crud.ts +3 -3
  184. package/src/lib/server/session-tools/delegate.ts +22 -6
  185. package/src/lib/server/session-tools/file.ts +192 -19
  186. package/src/lib/server/session-tools/index.ts +4 -2
  187. package/src/lib/server/session-tools/memory.ts +2 -2
  188. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  189. package/src/lib/server/session-tools/sandbox.ts +33 -0
  190. package/src/lib/server/session-tools/search-providers.ts +277 -0
  191. package/src/lib/server/session-tools/session-info.ts +2 -2
  192. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  193. package/src/lib/server/session-tools/shell.ts +1 -1
  194. package/src/lib/server/session-tools/web.ts +53 -72
  195. package/src/lib/server/storage.ts +74 -11
  196. package/src/lib/server/stream-agent-chat.ts +53 -4
  197. package/src/lib/server/suggestions.ts +20 -0
  198. package/src/lib/server/task-result.test.ts +44 -0
  199. package/src/lib/server/task-result.ts +14 -0
  200. package/src/lib/server/ws-hub.ts +14 -0
  201. package/src/lib/tool-definitions.ts +5 -3
  202. package/src/lib/tts-stream.ts +130 -0
  203. package/src/lib/view-routes.ts +28 -0
  204. package/src/proxy.ts +3 -0
  205. package/src/stores/use-app-store.ts +80 -1
  206. package/src/stores/use-approval-store.ts +78 -0
  207. package/src/stores/use-chat-store.ts +162 -6
  208. package/src/types/index.ts +154 -3
  209. package/tsconfig.json +13 -4
@@ -63,6 +63,18 @@ function tryLoadIdentityFile(filePath: string): DeviceIdentity | null {
63
63
  }
64
64
 
65
65
  function loadOrCreateDeviceIdentity(): DeviceIdentity {
66
+ // 0. Check shared device token for cross-synced identity
67
+ try {
68
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
69
+ const { getSharedDeviceToken } = require('../server/openclaw-sync')
70
+ const sharedToken = getSharedDeviceToken()
71
+ if (sharedToken) {
72
+ // Shared token exists — the connector has already paired.
73
+ // Still need the keypair, so continue to identity resolution below.
74
+ // The token will be used during WS connect.
75
+ }
76
+ } catch { /* openclaw-sync not available */ }
77
+
66
78
  // 1. Prefer the openclaw CLI's identity — it's likely already paired with the gateway
67
79
  const cliIdentityPath = path.join(resolveCliStateDir(), 'identity', 'device.json')
68
80
  const cliIdentity = tryLoadIdentityFile(cliIdentityPath)
@@ -333,8 +345,22 @@ export function streamOpenClawChat({ session, message, imagePath, apiKey, write,
333
345
  for (const p of payloads) {
334
346
  const text = typeof p.text === 'string' ? p.text.trimEnd() : ''
335
347
  if (text) {
336
- fullResponse += text
337
- write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
348
+ // Detect [[trace]], [[tool]], [[tool-result]], [[meta]] prefixes
349
+ const traceMatch = text.match(/^\[\[(thinking|tool|tool-result|trace|meta)\]\]/)
350
+ if (traceMatch) {
351
+ const traceType = traceMatch[1]
352
+ const traceContent = text.slice(traceMatch[0].length)
353
+ if (traceType === 'meta') {
354
+ write(`data: ${JSON.stringify({ t: 'md', text: traceContent })}\n\n`)
355
+ } else {
356
+ // Include as text (client-side will parse trace markers)
357
+ fullResponse += text
358
+ write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
359
+ }
360
+ } else {
361
+ fullResponse += text
362
+ write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
363
+ }
338
364
  }
339
365
  }
340
366
  if (!fullResponse && msg.payload?.summary) {
@@ -11,5 +11,5 @@ export const DEFAULT_ONGOING_LOOP_MAX_RUNTIME_MINUTES = 60
11
11
 
12
12
  // Tool/process timeouts
13
13
  export const DEFAULT_SHELL_COMMAND_TIMEOUT_SEC = 30
14
- export const DEFAULT_CLAUDE_CODE_TIMEOUT_SEC = 120
15
- export const DEFAULT_CLI_PROCESS_TIMEOUT_SEC = 300
14
+ export const DEFAULT_CLAUDE_CODE_TIMEOUT_SEC = 1800
15
+ export const DEFAULT_CLI_PROCESS_TIMEOUT_SEC = 1800
@@ -346,17 +346,16 @@ describe('MCP Server API contract', () => {
346
346
  assert.match(src, /export\s+async\s+function\s+DELETE/)
347
347
  })
348
348
 
349
- it('MCP POST route assigns an id via crypto.randomBytes', () => {
349
+ it('MCP POST route assigns an id via genId helper', () => {
350
350
  const src = readRoute('mcp-servers', 'route.ts')
351
- assert.match(src, /crypto\.randomBytes/)
351
+ assert.match(src, /const\s+id\s*=\s*genId\(/)
352
352
  })
353
353
 
354
- it('MCP PUT route preserves id and sets updatedAt', () => {
354
+ it('MCP PUT route preserves id and sets updatedAt via mutateItem', () => {
355
355
  const src = readRoute('mcp-servers', '[id]', 'route.ts')
356
+ assert.match(src, /mutateItem\(/)
356
357
  assert.match(src, /updatedAt:\s*Date\.now\(\)/)
357
- // Verify id is pinned (spread then override)
358
- assert.match(src, /\.\.\.servers\[id\]/)
359
- assert.match(src, /\bid\b,/)
358
+ assert.match(src, /\.\.\.server,\s*\.\.\.body,\s*id,/)
360
359
  })
361
360
  })
362
361
  })
@@ -3,10 +3,10 @@ import { ChatOpenAI } from '@langchain/openai'
3
3
  import { loadCredentials, decryptKey, loadAgents, loadSettings } from './storage'
4
4
  import { getProviderList } from '../providers'
5
5
  import { normalizeOpenClawEndpoint } from '../openclaw-endpoint'
6
+ import { NON_LANGGRAPH_PROVIDER_IDS } from '../provider-sets'
6
7
 
7
8
  const OLLAMA_CLOUD_URL = 'https://ollama.com/v1'
8
9
  const OLLAMA_LOCAL_URL = 'http://localhost:11434/v1'
9
- const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli'])
10
10
 
11
11
  /**
12
12
  * Build a LangChain chat model from provider config.
@@ -18,8 +18,9 @@ export function buildChatModel(opts: {
18
18
  model: string
19
19
  apiKey: string | null
20
20
  apiEndpoint?: string | null
21
+ thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high'
21
22
  }) {
22
- const { provider, model, apiKey, apiEndpoint } = opts
23
+ const { provider, model, apiKey, apiEndpoint, thinkingLevel } = opts
23
24
  const providers = getProviderList()
24
25
  const providerInfo = providers.find((p) => p.id === provider)
25
26
  const endpointRaw = apiEndpoint || providerInfo?.defaultEndpoint || null
@@ -28,11 +29,18 @@ export function buildChatModel(opts: {
28
29
  : endpointRaw
29
30
 
30
31
  if (provider === 'anthropic') {
31
- return new ChatAnthropic({
32
+ const anthropicOpts: Record<string, unknown> = {
32
33
  model: model || 'claude-sonnet-4-6',
33
34
  anthropicApiKey: apiKey || undefined,
34
35
  maxTokens: 8192,
35
- })
36
+ }
37
+ if (thinkingLevel) {
38
+ const budgetMap = { minimal: 1024, low: 4096, medium: 8192, high: 16384 }
39
+ anthropicOpts.thinking = { type: 'enabled', budget_tokens: budgetMap[thinkingLevel] }
40
+ // Extended thinking requires higher maxTokens (budget + output)
41
+ anthropicOpts.maxTokens = budgetMap[thinkingLevel] + 8192
42
+ }
43
+ return new ChatAnthropic(anthropicOpts as ConstructorParameters<typeof ChatAnthropic>[0])
36
44
  }
37
45
 
38
46
  if (provider === 'ollama') {
@@ -48,6 +56,11 @@ export function buildChatModel(opts: {
48
56
 
49
57
  // All other providers — OpenAI-compatible with their registered endpoint
50
58
  const config: any = { model: model || 'gpt-4o', apiKey: apiKey || undefined }
59
+ // Map thinking level to reasoning_effort for OpenAI o-series models
60
+ if (thinkingLevel && provider === 'openai' && /^o\d/.test(model || '')) {
61
+ const effortMap = { minimal: 'low', low: 'low', medium: 'medium', high: 'high' }
62
+ config.modelKwargs = { reasoning_effort: effortMap[thinkingLevel] }
63
+ }
51
64
  if (endpoint) {
52
65
  config.configuration = { baseURL: endpoint }
53
66
  // OpenClaw endpoints behind Hostinger's proxy use express.json() middleware
@@ -9,9 +9,11 @@ import {
9
9
  loadSkills,
10
10
  loadSettings,
11
11
  loadUsage,
12
+ appendUsage,
12
13
  active,
13
14
  } from './storage'
14
15
  import { getProvider } from '@/lib/providers'
16
+ import { estimateCost } from './cost'
15
17
  import { log } from './logger'
16
18
  import { logExecution } from './execution-log'
17
19
  import { streamAgentChat } from './stream-agent-chat'
@@ -22,10 +24,9 @@ import { getMemoryDb } from './memory-db'
22
24
  import { routeTaskIntent } from './capability-router'
23
25
  import { notify } from './ws-hub'
24
26
  import { resolveConcreteToolPolicyBlock, resolveSessionToolPolicy } from './tool-capability-policy'
25
- import type { MessageToolEvent, SSEEvent } from '@/types'
27
+ import type { MessageToolEvent, SSEEvent, UsageRecord } from '@/types'
26
28
  import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
27
-
28
- const CLI_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli'])
29
+ import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
29
30
  type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli'
30
31
 
31
32
  interface SessionWithTools {
@@ -342,13 +343,27 @@ function resolveApiKeyForSession(session: SessionWithCredentials, provider: Prov
342
343
 
343
344
  function classifyHeartbeatResponse(text: string, ackMaxChars: number): 'suppress' | 'strip' | 'keep' {
344
345
  const trimmed = text.trim()
345
- if (trimmed === 'HEARTBEAT_OK') return 'suppress'
346
- const stripped = trimmed.replace(/HEARTBEAT_OK/gi, '').trim()
346
+ if (trimmed === 'HEARTBEAT_OK' || trimmed === 'NO_MESSAGE') return 'suppress'
347
+ const stripped = trimmed.replace(/HEARTBEAT_OK/gi, '').replace(/NO_MESSAGE/gi, '').trim()
347
348
  if (!stripped) return 'suppress'
348
349
  if (stripped.length <= ackMaxChars) return 'suppress'
349
350
  return stripped.length < trimmed.length ? 'strip' : 'keep'
350
351
  }
351
352
 
353
+ function estimateConversationTone(text: string): string {
354
+ const t = text || ''
355
+ // Technical: code blocks, function signatures, technical terms
356
+ if (/```/.test(t) || /\b(function|const|let|var|import|export|class|interface|async|await|return)\b/.test(t)) return 'technical'
357
+ if (/\b(error|bug|debug|stack trace|exception|null|undefined|TypeError)\b/i.test(t)) return 'technical'
358
+ // Empathetic: emotional/supportive language
359
+ if (/\b(understand|feel|sorry|empathize|appreciate|grateful|tough|difficult|challenging)\b/i.test(t)) return 'empathetic'
360
+ // Formal: academic/business language
361
+ if (/\b(furthermore|regarding|consequently|therefore|henceforth|pursuant|accordingly|notwithstanding)\b/i.test(t)) return 'formal'
362
+ // Casual: contractions, exclamations, informal language
363
+ if (/\b(gonna|wanna|gotta|yeah|hey|awesome|cool|lol|btw|tbh)\b/i.test(t) || /!{2,}/.test(t)) return 'casual'
364
+ return 'neutral'
365
+ }
366
+
352
367
  const AUTO_MEMORY_MIN_INTERVAL_MS = 45 * 60 * 1000
353
368
 
354
369
  function normalizeMemoryText(value: string): string {
@@ -572,8 +587,12 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
572
587
  kill: () => abortController.abort(),
573
588
  })
574
589
 
590
+ // Capture provider-reported usage for the direct (non-tools) path.
591
+ // Uses a mutable object because TS can't track callback mutations on plain variables.
592
+ const directUsage = { inputTokens: 0, outputTokens: 0, received: false }
593
+ const hasTools = !!sessionForRun.tools?.length && !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
594
+
575
595
  try {
576
- const hasTools = !!sessionForRun.tools?.length && !CLI_PROVIDER_IDS.has(providerType)
577
596
  // Heartbeat runs are self-contained — skip conversation history to avoid
578
597
  // blowing past the context window on long-lived sessions.
579
598
  const heartbeatHistory = isAutoRunNoHistory ? [] : undefined
@@ -601,6 +620,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
601
620
  write: (raw: string) => parseAndEmit(raw),
602
621
  active,
603
622
  loadHistory: isAutoRunNoHistory ? () => [] : getSessionMessages,
623
+ onUsage: (u) => { directUsage.inputTokens = u.inputTokens; directUsage.outputTokens = u.outputTokens; directUsage.received = true },
604
624
  })
605
625
  } catch (err: any) {
606
626
  errorMessage = err?.message || String(err)
@@ -622,6 +642,34 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
622
642
  markProviderSuccess(providerType)
623
643
  }
624
644
 
645
+ // Record usage for the direct (non-tools) streamChat path.
646
+ // streamAgentChat already calls appendUsage internally for the tools path.
647
+ if (!hasTools && fullResponse && !errorMessage) {
648
+ const inputTokens = directUsage.received ? directUsage.inputTokens : Math.ceil(message.length / 4)
649
+ const outputTokens = directUsage.received ? directUsage.outputTokens : Math.ceil(fullResponse.length / 4)
650
+ const totalTokens = inputTokens + outputTokens
651
+ if (totalTokens > 0) {
652
+ const cost = estimateCost(sessionForRun.model, inputTokens, outputTokens)
653
+ const history = getSessionMessages(sessionId)
654
+ const usageRecord: UsageRecord = {
655
+ sessionId,
656
+ messageIndex: history.length,
657
+ model: sessionForRun.model,
658
+ provider: providerType,
659
+ inputTokens,
660
+ outputTokens,
661
+ totalTokens,
662
+ estimatedCost: cost,
663
+ timestamp: Date.now(),
664
+ }
665
+ appendUsage(sessionId, usageRecord)
666
+ emit({
667
+ t: 'md',
668
+ text: JSON.stringify({ usage: { inputTokens, outputTokens, totalTokens, estimatedCost: cost } }),
669
+ })
670
+ }
671
+ }
672
+
625
673
  const requestedToolNames = (!internal && source === 'chat')
626
674
  ? requestedToolNamesFromMessage(message)
627
675
  : []
@@ -782,6 +830,26 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
782
830
  const finalText = (fullResponse || '').trim() || (!internal && errorMessage ? `Error: ${errorMessage}` : '')
783
831
  const textForPersistence = stripMainLoopMetaForPersistence(finalText, internal)
784
832
 
833
+ // Emit status SSE event from [MAIN_LOOP_META] if present
834
+ if (internal && finalText) {
835
+ const metaMatch = finalText.match(/\[MAIN_LOOP_META\]\s*(\{[^\n]*\})/i)
836
+ if (metaMatch) {
837
+ try {
838
+ const meta = JSON.parse(metaMatch[1])
839
+ const statusPayload: Record<string, string | undefined> = {}
840
+ if (meta.goal) statusPayload.goal = String(meta.goal)
841
+ if (meta.status) statusPayload.status = String(meta.status)
842
+ if (meta.summary) statusPayload.summary = String(meta.summary)
843
+ if (meta.next_action) statusPayload.nextAction = String(meta.next_action)
844
+ if (Object.keys(statusPayload).length > 0) {
845
+ emit({ t: 'status', text: JSON.stringify(statusPayload) })
846
+ }
847
+ } catch {
848
+ // ignore malformed meta JSON
849
+ }
850
+ }
851
+ }
852
+
785
853
  // HEARTBEAT_OK suppression
786
854
  const heartbeatConfig = input.heartbeatConfig
787
855
  let heartbeatClassification: 'suppress' | 'strip' | 'keep' | null = null
@@ -841,6 +909,14 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
841
909
  })
842
910
  changed = true
843
911
 
912
+ // Conversation tone detection
913
+ if (!internal) {
914
+ const tone = estimateConversationTone(persistedText)
915
+ if (tone !== current.conversationTone) {
916
+ current.conversationTone = tone
917
+ }
918
+ }
919
+
844
920
  // Target routing for non-suppressed heartbeat alerts
845
921
  if (isHeartbeatRun && heartbeatConfig?.target && heartbeatConfig.target !== 'none' && heartbeatConfig.showAlerts !== false) {
846
922
  try {
@@ -0,0 +1,54 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { notify } from './ws-hub'
3
+
4
+ export interface CollectionOps<T> {
5
+ load: () => Record<string, T>
6
+ save: (data: Record<string, T>) => void
7
+ deleteFn?: (id: string) => void
8
+ topic?: string
9
+ }
10
+
11
+ /**
12
+ * Load → 404 check → mutate → save → notify.
13
+ * `fn` receives the item and the full collection, returns the updated item.
14
+ */
15
+ export function mutateItem<T>(
16
+ ops: CollectionOps<T>,
17
+ id: string,
18
+ fn: (item: T, all: Record<string, T>) => T,
19
+ ): T | null {
20
+ const all = ops.load()
21
+ if (!all[id]) return null
22
+ all[id] = fn(all[id], all)
23
+ ops.save(all)
24
+ if (ops.topic) notify(ops.topic)
25
+ return all[id]
26
+ }
27
+
28
+ /**
29
+ * Load → 404 check → delete → notify.
30
+ * Uses `ops.deleteFn` if provided, otherwise inline `delete` + `save`.
31
+ */
32
+ export function deleteItem<T>(
33
+ ops: CollectionOps<T>,
34
+ id: string,
35
+ ): boolean {
36
+ const all = ops.load()
37
+ if (!all[id]) return false
38
+ if (ops.deleteFn) {
39
+ ops.deleteFn(id)
40
+ } else {
41
+ delete all[id]
42
+ ops.save(all)
43
+ }
44
+ if (ops.topic) notify(ops.topic)
45
+ return true
46
+ }
47
+
48
+ export function notFound(msg = 'Not found') {
49
+ return NextResponse.json({ error: msg }, { status: 404 })
50
+ }
51
+
52
+ export function badRequest(msg: string) {
53
+ return NextResponse.json({ error: msg }, { status: 400 })
54
+ }
@@ -0,0 +1,217 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import bluebubbles from './bluebubbles.ts'
4
+ import type { Connector } from '@/types'
5
+ import type { InboundMessage } from './types'
6
+
7
+ type FetchCall = {
8
+ url: string
9
+ init?: RequestInit
10
+ }
11
+
12
+ type MockResponse = {
13
+ ok: boolean
14
+ status: number
15
+ json: () => Promise<unknown>
16
+ text: () => Promise<string>
17
+ }
18
+
19
+ function jsonResponse(status: number, body: unknown): MockResponse {
20
+ return {
21
+ ok: status >= 200 && status < 300,
22
+ status,
23
+ json: async () => body,
24
+ text: async () => JSON.stringify(body),
25
+ }
26
+ }
27
+
28
+ function textResponse(status: number, text: string): MockResponse {
29
+ return {
30
+ ok: status >= 200 && status < 300,
31
+ status,
32
+ json: async () => {
33
+ throw new Error('not json')
34
+ },
35
+ text: async () => text,
36
+ }
37
+ }
38
+
39
+ const originalFetch = globalThis.fetch
40
+
41
+ test.afterEach(() => {
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ ;(globalThis as any).fetch = originalFetch
44
+ })
45
+
46
+ test('bluebubbles connector processes inbound webhook payloads and sends replies', async () => {
47
+ const calls: FetchCall[] = []
48
+ const queue: MockResponse[] = [
49
+ jsonResponse(200, { ok: true }), // ping
50
+ jsonResponse(200, { data: { guid: 'msg-1' } }), // send reply
51
+ ]
52
+
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ ;(globalThis as any).fetch = async (url: string, init?: RequestInit) => {
55
+ calls.push({ url: String(url), init })
56
+ const next = queue.shift()
57
+ assert.ok(next, 'unexpected fetch call')
58
+ return next
59
+ }
60
+
61
+ const received: InboundMessage[] = []
62
+ const connector = {
63
+ id: 'bb-1',
64
+ name: 'BlueBubbles Test',
65
+ platform: 'bluebubbles',
66
+ agentId: 'agent-1',
67
+ credentialId: null,
68
+ config: {
69
+ serverUrl: 'http://127.0.0.1:1234',
70
+ },
71
+ isEnabled: true,
72
+ status: 'running',
73
+ createdAt: Date.now(),
74
+ updatedAt: Date.now(),
75
+ } as unknown as Connector
76
+
77
+ const instance = await bluebubbles.start(connector, 'pw-test', async (msg) => {
78
+ received.push(msg)
79
+ return 'pong'
80
+ })
81
+
82
+ try {
83
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
+ const handler = (globalThis as any).__swarmclaw_bluebubbles_handler_bb_1__
85
+ assert.equal(typeof handler, 'undefined', 'sanity: wrong handler key should be undefined')
86
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
+ const validHandler = (globalThis as any)[`__swarmclaw_bluebubbles_handler_${connector.id}__`]
88
+ assert.equal(typeof validHandler, 'function')
89
+
90
+ await validHandler({
91
+ type: 'new-message',
92
+ data: {
93
+ guid: 'm-123',
94
+ text: 'hello there',
95
+ isFromMe: false,
96
+ isGroup: false,
97
+ handle: { address: '+15551234567', displayName: 'Alice' },
98
+ chatGuid: 'iMessage;-;+15551234567',
99
+ },
100
+ })
101
+
102
+ assert.equal(received.length, 1)
103
+ assert.equal(received[0].text, 'hello there')
104
+ assert.equal(received[0].senderId, '+15551234567')
105
+ assert.equal(received[0].channelId, 'iMessage;-;+15551234567')
106
+
107
+ assert.equal(calls.length, 2)
108
+ assert.ok(calls[0].url.includes('/api/v1/ping'))
109
+ assert.ok(calls[1].url.includes('/api/v1/message/text'))
110
+
111
+ const body = JSON.parse(String(calls[1].init?.body || '{}'))
112
+ assert.equal(body.chatGuid, 'iMessage;-;+15551234567')
113
+ assert.equal(body.message, 'pong')
114
+ } finally {
115
+ await instance.stop()
116
+ }
117
+ })
118
+
119
+ test('bluebubbles connector supports array-wrapped webhook payload and NO_MESSAGE suppression', async () => {
120
+ const calls: FetchCall[] = []
121
+ const queue: MockResponse[] = [
122
+ jsonResponse(200, { ok: true }), // ping
123
+ ]
124
+
125
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
126
+ ;(globalThis as any).fetch = async (url: string, init?: RequestInit) => {
127
+ calls.push({ url: String(url), init })
128
+ const next = queue.shift()
129
+ assert.ok(next, 'unexpected fetch call')
130
+ return next
131
+ }
132
+
133
+ const connector = {
134
+ id: 'bb-2',
135
+ name: 'BlueBubbles Test 2',
136
+ platform: 'bluebubbles',
137
+ agentId: 'agent-2',
138
+ credentialId: null,
139
+ config: {
140
+ serverUrl: 'http://127.0.0.1:1234',
141
+ },
142
+ isEnabled: true,
143
+ status: 'running',
144
+ createdAt: Date.now(),
145
+ updatedAt: Date.now(),
146
+ } as unknown as Connector
147
+
148
+ const instance = await bluebubbles.start(connector, 'pw-test', async () => 'NO_MESSAGE')
149
+
150
+ try {
151
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
152
+ const handler = (globalThis as any)[`__swarmclaw_bluebubbles_handler_${connector.id}__`]
153
+ assert.equal(typeof handler, 'function')
154
+
155
+ await handler({
156
+ type: 'new-message',
157
+ data: [
158
+ {
159
+ guid: 'm-124',
160
+ text: '',
161
+ isFromMe: false,
162
+ handle: { address: 'test@example.com', displayName: 'Taylor' },
163
+ chatGuid: 'iMessage;-;test@example.com',
164
+ attachments: [{ mimeType: 'image/png', transferName: 'a.png', totalBytes: 128 }],
165
+ },
166
+ ],
167
+ })
168
+
169
+ assert.equal(calls.length, 1, 'should not call send endpoint when NO_MESSAGE is returned')
170
+ } finally {
171
+ await instance.stop()
172
+ }
173
+ })
174
+
175
+ test('bluebubbles sendMessage posts to message/text endpoint', async () => {
176
+ const calls: FetchCall[] = []
177
+ const queue: MockResponse[] = [
178
+ jsonResponse(200, { ok: true }), // ping
179
+ textResponse(200, ''), // send
180
+ ]
181
+
182
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
183
+ ;(globalThis as any).fetch = async (url: string, init?: RequestInit) => {
184
+ calls.push({ url: String(url), init })
185
+ const next = queue.shift()
186
+ assert.ok(next, 'unexpected fetch call')
187
+ return next
188
+ }
189
+
190
+ const connector = {
191
+ id: 'bb-3',
192
+ name: 'BlueBubbles Test 3',
193
+ platform: 'bluebubbles',
194
+ agentId: 'agent-3',
195
+ credentialId: null,
196
+ config: {
197
+ serverUrl: 'http://127.0.0.1:1234',
198
+ },
199
+ isEnabled: true,
200
+ status: 'running',
201
+ createdAt: Date.now(),
202
+ updatedAt: Date.now(),
203
+ } as unknown as Connector
204
+
205
+ const instance = await bluebubbles.start(connector, 'pw-test', async () => 'ok')
206
+
207
+ try {
208
+ await instance.sendMessage?.('iMessage;-;+15550001111', 'hello outbound')
209
+ assert.equal(calls.length, 2)
210
+ assert.ok(calls[1].url.includes('/api/v1/message/text'))
211
+ const body = JSON.parse(String(calls[1].init?.body || '{}'))
212
+ assert.equal(body.chatGuid, 'iMessage;-;+15550001111')
213
+ assert.equal(body.message, 'hello outbound')
214
+ } finally {
215
+ await instance.stop()
216
+ }
217
+ })