@swarmclawai/swarmclaw 0.7.7 → 0.8.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 (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -0,0 +1,165 @@
1
+ export type ChatArtifactKind = 'image' | 'pdf' | 'markdown' | 'file' | 'site'
2
+
3
+ export interface ChatArtifactItem {
4
+ label: string
5
+ href: string
6
+ kind: ChatArtifactKind
7
+ filename: string
8
+ }
9
+
10
+ export interface ChatArtifactSection {
11
+ title: string
12
+ items: ChatArtifactItem[]
13
+ }
14
+
15
+ export interface ChatArtifactSummary {
16
+ title: string | null
17
+ intro: string[]
18
+ sections: ChatArtifactSection[]
19
+ liveSitesTitle: string | null
20
+ liveSites: ChatArtifactItem[]
21
+ counts: {
22
+ images: number
23
+ pdfs: number
24
+ markdown: number
25
+ files: number
26
+ sites: number
27
+ }
28
+ }
29
+
30
+ const TITLE_RE = /^##+\s+(.+?)\s*$/
31
+ const SECTION_RE = /^###\s+(.+?)\s*$/
32
+ const LINK_BULLET_RE = /^-\s+\[([^\]]+)\]\(([^)]+)\)\s*$/
33
+ const BOLD_URL_BULLET_RE = /^-\s+\*\*([^*]+)\*\*:\s+(https?:\/\/\S+)\s*$/
34
+
35
+ function stripMarkdown(text: string): string {
36
+ return text
37
+ .replace(/[*_`#]/g, '')
38
+ .replace(/\[(.*?)\]\([^)]+\)/g, '$1')
39
+ .trim()
40
+ }
41
+
42
+ function inferArtifactKind(href: string): ChatArtifactKind {
43
+ const normalized = href.trim().toLowerCase()
44
+ if (/^https?:\/\/localhost:\d+/.test(normalized)) return 'site'
45
+ if (/\.(png|jpe?g|gif|webp|svg|avif)(?:[?#]|$)/.test(normalized)) return 'image'
46
+ if (/\.pdf(?:[?#]|$)/.test(normalized)) return 'pdf'
47
+ if (/\.(md|markdown)(?:[?#]|$)/.test(normalized)) return 'markdown'
48
+ return 'file'
49
+ }
50
+
51
+ function buildArtifactItem(label: string, href: string): ChatArtifactItem {
52
+ const filename = href.split('/').pop()?.split('?')[0] || href
53
+ return {
54
+ label: stripMarkdown(label),
55
+ href: href.trim(),
56
+ kind: inferArtifactKind(href),
57
+ filename,
58
+ }
59
+ }
60
+
61
+ function pushSection(sections: ChatArtifactSection[], current: ChatArtifactSection | null) {
62
+ if (!current || current.items.length === 0) return
63
+ sections.push(current)
64
+ }
65
+
66
+ function countItems(sections: ChatArtifactSection[], liveSites: ChatArtifactItem[]) {
67
+ const counts = {
68
+ images: 0,
69
+ pdfs: 0,
70
+ markdown: 0,
71
+ files: 0,
72
+ sites: liveSites.length,
73
+ }
74
+
75
+ for (const item of sections.flatMap((section) => section.items)) {
76
+ if (item.kind === 'image') counts.images += 1
77
+ else if (item.kind === 'pdf') counts.pdfs += 1
78
+ else if (item.kind === 'markdown') counts.markdown += 1
79
+ else if (item.kind === 'site') counts.sites += 1
80
+ else counts.files += 1
81
+ }
82
+
83
+ return counts
84
+ }
85
+
86
+ export function parseChatArtifactSummary(markdown: string): ChatArtifactSummary | null {
87
+ const text = markdown.trim()
88
+ if (!text) return null
89
+
90
+ const lines = text.split(/\r?\n/)
91
+ let title: string | null = null
92
+ let currentSection: ChatArtifactSection | null = null
93
+ const sections: ChatArtifactSection[] = []
94
+ const intro: string[] = []
95
+ const liveSites: ChatArtifactItem[] = []
96
+ let liveSitesTitle: string | null = null
97
+ let seenSection = false
98
+
99
+ for (const rawLine of lines) {
100
+ const line = rawLine.trim()
101
+ if (!line) continue
102
+
103
+ const titleMatch = !seenSection ? line.match(TITLE_RE) : null
104
+ if (titleMatch) {
105
+ title = stripMarkdown(titleMatch[1])
106
+ continue
107
+ }
108
+
109
+ const sectionMatch = line.match(SECTION_RE)
110
+ if (sectionMatch) {
111
+ pushSection(sections, currentSection)
112
+ currentSection = { title: stripMarkdown(sectionMatch[1]), items: [] }
113
+ seenSection = true
114
+ continue
115
+ }
116
+
117
+ const linkMatch = line.match(LINK_BULLET_RE)
118
+ if (linkMatch) {
119
+ const item = buildArtifactItem(linkMatch[1], linkMatch[2])
120
+ if (currentSection) currentSection.items.push(item)
121
+ else if (item.kind === 'site') liveSites.push(item)
122
+ else intro.push(stripMarkdown(line))
123
+ continue
124
+ }
125
+
126
+ const liveSiteMatch = line.match(BOLD_URL_BULLET_RE)
127
+ if (liveSiteMatch) {
128
+ liveSites.push({
129
+ label: stripMarkdown(liveSiteMatch[1]),
130
+ href: liveSiteMatch[2],
131
+ kind: 'site',
132
+ filename: liveSiteMatch[2],
133
+ })
134
+ continue
135
+ }
136
+
137
+ if (!seenSection) {
138
+ intro.push(stripMarkdown(line))
139
+ continue
140
+ }
141
+
142
+ if (currentSection?.items.length === 0 && !liveSites.length) {
143
+ liveSitesTitle = stripMarkdown(line)
144
+ continue
145
+ }
146
+
147
+ if (!liveSites.length) {
148
+ liveSitesTitle = stripMarkdown(line)
149
+ }
150
+ }
151
+
152
+ pushSection(sections, currentSection)
153
+
154
+ const totalSectionItems = sections.reduce((sum, section) => sum + section.items.length, 0)
155
+ if (totalSectionItems < 3 || sections.length === 0) return null
156
+
157
+ return {
158
+ title,
159
+ intro,
160
+ sections,
161
+ liveSitesTitle,
162
+ liveSites,
163
+ counts: countItems(sections, liveSites),
164
+ }
165
+ }
@@ -0,0 +1,91 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { dedupeMessagesForDisplay, formatMessageTimestamp } from './chat-display'
5
+ import type { Message } from '@/types'
6
+
7
+ function baseMessage(overrides: Partial<Message> = {}): Message {
8
+ return {
9
+ role: 'user',
10
+ text: 'hello',
11
+ time: Date.now(),
12
+ ...overrides,
13
+ }
14
+ }
15
+
16
+ test('dedupeMessagesForDisplay removes exact connector duplicates by message id', () => {
17
+ const first = baseMessage({
18
+ source: {
19
+ platform: 'whatsapp',
20
+ connectorId: 'conn-1',
21
+ connectorName: 'WhatsApp',
22
+ messageId: 'wamid-1',
23
+ senderName: 'Alice',
24
+ },
25
+ historyExcluded: true,
26
+ })
27
+ const duplicate = { ...first }
28
+ const unrelated = baseMessage({
29
+ text: 'reply',
30
+ role: 'assistant',
31
+ source: {
32
+ platform: 'whatsapp',
33
+ connectorId: 'conn-1',
34
+ connectorName: 'WhatsApp',
35
+ messageId: 'wamid-2',
36
+ senderName: 'Alice',
37
+ },
38
+ historyExcluded: true,
39
+ })
40
+
41
+ const result = dedupeMessagesForDisplay([first, duplicate, unrelated])
42
+
43
+ assert.equal(result.length, 2)
44
+ assert.equal(result[0].source?.messageId, 'wamid-1')
45
+ assert.equal(result[1].source?.messageId, 'wamid-2')
46
+ })
47
+
48
+ test('dedupeMessagesForDisplay keeps identical connector text from distinct message ids', () => {
49
+ const first = baseMessage({
50
+ source: {
51
+ platform: 'whatsapp',
52
+ connectorId: 'conn-1',
53
+ connectorName: 'WhatsApp',
54
+ messageId: 'wamid-1',
55
+ senderName: 'Alice',
56
+ },
57
+ historyExcluded: true,
58
+ })
59
+ const second = baseMessage({
60
+ source: {
61
+ platform: 'whatsapp',
62
+ connectorId: 'conn-1',
63
+ connectorName: 'WhatsApp',
64
+ messageId: 'wamid-2',
65
+ senderName: 'Alice',
66
+ },
67
+ historyExcluded: true,
68
+ })
69
+
70
+ const result = dedupeMessagesForDisplay([first, second])
71
+
72
+ assert.equal(result.length, 2)
73
+ assert.deepEqual(result.map((message) => message.source?.messageId), ['wamid-1', 'wamid-2'])
74
+ })
75
+
76
+ test('formatMessageTimestamp uses exact time formatting for connector transcript entries', () => {
77
+ const now = new Date()
78
+ now.setHours(14, 5, 0, 0)
79
+ const timestamp = now.getTime()
80
+ const formatted = formatMessageTimestamp({
81
+ time: timestamp,
82
+ source: {
83
+ platform: 'whatsapp',
84
+ connectorId: 'conn-1',
85
+ connectorName: 'WhatsApp',
86
+ },
87
+ })
88
+
89
+ assert.match(formatted, /\d{1,2}:\d{2}/)
90
+ assert.doesNotMatch(formatted, /ago|just now/)
91
+ })
@@ -0,0 +1,58 @@
1
+ import type { Message } from '@/types'
2
+
3
+ function formatClock(ts: number): string {
4
+ return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
5
+ }
6
+
7
+ function formatConnectorTimestamp(ts: number): string {
8
+ const d = new Date(ts)
9
+ const today = new Date()
10
+ if (d.toDateString() === today.toDateString()) return formatClock(ts)
11
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
12
+ }
13
+
14
+ function formatRelativeTimestamp(ts: number): string {
15
+ const now = Date.now()
16
+ const diff = now - ts
17
+ if (diff < 60_000) return 'just now'
18
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
19
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
20
+ const d = new Date(ts)
21
+ const today = new Date()
22
+ if (d.toDateString() === today.toDateString()) return formatClock(ts)
23
+ if (diff < 604_800_000) return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' })
24
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
25
+ }
26
+
27
+ export function formatMessageTimestamp(message: Pick<Message, 'time' | 'source'>): string {
28
+ if (!message.time) return ''
29
+ if (message.source?.connectorId) return formatConnectorTimestamp(message.time)
30
+ return formatRelativeTimestamp(message.time)
31
+ }
32
+
33
+ function buildDisplayDedupKey(message: Message): string | null {
34
+ const source = message.source
35
+ if (source?.connectorId && source.messageId) {
36
+ return [
37
+ message.role,
38
+ source.connectorId,
39
+ source.messageId,
40
+ message.historyExcluded === true ? 'history-excluded' : 'normal',
41
+ ].join('|')
42
+ }
43
+ return null
44
+ }
45
+
46
+ export function dedupeMessagesForDisplay(messages: Message[]): Message[] {
47
+ const seen = new Set<string>()
48
+ const deduped: Message[] = []
49
+ for (const message of messages) {
50
+ const key = buildDisplayDedupKey(message)
51
+ if (key) {
52
+ if (seen.has(key)) continue
53
+ seen.add(key)
54
+ }
55
+ deduped.push(message)
56
+ }
57
+ return deduped
58
+ }
@@ -2,6 +2,7 @@ import { describe, it } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import type { Message } from '@/types'
4
4
  import {
5
+ materializeStreamingAssistantArtifacts,
5
6
  mergeCompletedAssistantMessage,
6
7
  messagesDiffer,
7
8
  pruneStreamingAssistantArtifacts,
@@ -24,7 +25,7 @@ describe('chat-streaming-state', () => {
24
25
  )
25
26
  assert.equal(
26
27
  shouldHidePersistedStreamingAssistantMessage(message, { localStreaming: true, displayText: '' }),
27
- true,
28
+ false,
28
29
  )
29
30
  })
30
31
 
@@ -81,6 +82,51 @@ describe('chat-streaming-state', () => {
81
82
  ])
82
83
  })
83
84
 
85
+ it('materializes stale streaming artifacts into ordinary assistant messages', () => {
86
+ const messages: Message[] = [
87
+ { role: 'user', text: 'hello', time: 1 },
88
+ {
89
+ role: 'assistant',
90
+ text: 'partial result',
91
+ time: 2,
92
+ streaming: true,
93
+ toolEvents: [{ name: 'browser', input: '{"action":"screenshot"}', output: '/api/uploads/wiki.png' }],
94
+ },
95
+ ]
96
+
97
+ const changed = materializeStreamingAssistantArtifacts(messages)
98
+
99
+ assert.equal(changed, true)
100
+ assert.deepEqual(messages, [
101
+ { role: 'user', text: 'hello', time: 1 },
102
+ {
103
+ role: 'assistant',
104
+ text: 'partial result',
105
+ time: 2,
106
+ streaming: false,
107
+ toolEvents: [{ name: 'browser', input: '{"action":"screenshot"}', output: '/api/uploads/wiki.png' }],
108
+ },
109
+ ])
110
+ })
111
+
112
+ it('summarizes tool-only stale streaming artifacts instead of dropping them', () => {
113
+ const messages: Message[] = [
114
+ { role: 'user', text: 'hello', time: 1 },
115
+ {
116
+ role: 'assistant',
117
+ text: '',
118
+ time: 2,
119
+ streaming: true,
120
+ toolEvents: [{ name: 'browser', input: '{"action":"screenshot"}' }],
121
+ },
122
+ ]
123
+
124
+ materializeStreamingAssistantArtifacts(messages)
125
+
126
+ assert.match(messages[1].text, /Started 1 tool call/)
127
+ assert.equal(messages[1].streaming, false)
128
+ })
129
+
84
130
  it('reuses the previous assistant slot when the server already persisted the same final text', () => {
85
131
  const messages: Message[] = [
86
132
  { role: 'user', text: 'hello', time: 1 },
@@ -1,4 +1,5 @@
1
1
  import type { Message } from '@/types'
2
+ import { buildToolEventAssistantSummary } from '@/lib/tool-event-summary'
2
3
 
3
4
  interface StreamingArtifactWindow {
4
5
  minIndex?: number
@@ -26,6 +27,7 @@ export function shouldHidePersistedStreamingAssistantMessage(
26
27
  opts.localStreaming
27
28
  && message.role === 'assistant'
28
29
  && message.streaming === true
30
+ && opts.displayText.trim().length > 0
29
31
  )
30
32
  }
31
33
 
@@ -52,6 +54,46 @@ export function upsertStreamingAssistantArtifact(
52
54
  return true
53
55
  }
54
56
 
57
+ export function materializeStreamingAssistantArtifacts(
58
+ messages: Message[],
59
+ opts: StreamingArtifactWindow = {},
60
+ ): boolean {
61
+ let changed = false
62
+ const nextMessages: Message[] = []
63
+
64
+ for (let index = 0; index < messages.length; index += 1) {
65
+ const message = messages[index]
66
+ if (!isStreamingAssistantMessage(message, index, opts)) {
67
+ nextMessages.push(message)
68
+ continue
69
+ }
70
+
71
+ const trimmedText = typeof message.text === 'string' ? message.text.trim() : ''
72
+ const toolEvents = Array.isArray(message.toolEvents) ? message.toolEvents : []
73
+ const thinking = typeof message.thinking === 'string' ? message.thinking.trim() : ''
74
+ const fallbackText = !trimmedText && toolEvents.length > 0
75
+ ? buildToolEventAssistantSummary(toolEvents, { interrupted: true })
76
+ : ''
77
+ const nextText = trimmedText || fallbackText
78
+
79
+ if (!nextText && !thinking && toolEvents.length === 0) {
80
+ changed = true
81
+ continue
82
+ }
83
+
84
+ nextMessages.push({
85
+ ...message,
86
+ text: nextText,
87
+ streaming: false,
88
+ })
89
+ changed = true
90
+ }
91
+
92
+ if (!changed) return false
93
+ messages.splice(0, messages.length, ...nextMessages)
94
+ return true
95
+ }
96
+
55
97
  export function mergeCompletedAssistantMessage(messages: Message[], assistantMessage: Message): Message[] {
56
98
  let end = messages.length
57
99
  while (end > 0) {
@@ -0,0 +1,10 @@
1
+ export const OLLAMA_CLOUD_MODEL_SUFFIX = ':cloud'
2
+
3
+ export function isOllamaCloudModel(model: string | null | undefined): boolean {
4
+ return typeof model === 'string' && /:cloud$/i.test(model.trim())
5
+ }
6
+
7
+ export function stripOllamaCloudModelSuffix(model: string | null | undefined): string {
8
+ if (typeof model !== 'string') return ''
9
+ return model.trim().replace(/:cloud$/i, '')
10
+ }
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
2
2
  import { test } from 'node:test'
3
3
  import {
4
4
  deriveOpenClawWsUrl,
5
+ isLocalOpenClawEndpoint,
5
6
  normalizeOpenClawEndpoint,
6
7
  normalizeProviderEndpoint,
7
8
  } from './openclaw-endpoint.ts'
@@ -46,3 +47,10 @@ test('normalizeProviderEndpoint only rewrites openclaw provider', () => {
46
47
  null,
47
48
  )
48
49
  })
50
+
51
+ test('isLocalOpenClawEndpoint detects loopback hosts', () => {
52
+ assert.equal(isLocalOpenClawEndpoint('ws://localhost:18789'), true)
53
+ assert.equal(isLocalOpenClawEndpoint('http://127.0.0.1:18789/v1'), true)
54
+ assert.equal(isLocalOpenClawEndpoint('http://[::1]:18789/v1'), true)
55
+ assert.equal(isLocalOpenClawEndpoint('https://openclaw.example.com/v1'), false)
56
+ })
@@ -57,6 +57,12 @@ export function deriveOpenClawWsUrl(input?: string | null): string {
57
57
  return value.endsWith('/') ? value.slice(0, -1) : value
58
58
  }
59
59
 
60
+ export function isLocalOpenClawEndpoint(input?: string | null): boolean {
61
+ const parsed = parseUrl(input || '') || parseUrl(DEFAULT_OPENCLAW_ENDPOINT)!
62
+ const host = parsed.hostname.trim().toLowerCase().replace(/^\[(.*)\]$/, '$1')
63
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0'
64
+ }
65
+
60
66
  export function normalizeProviderEndpoint(provider: string | null | undefined, endpoint: string | null | undefined): string | null {
61
67
  if (typeof endpoint !== 'string') return null
62
68
  const trimmed = endpoint.trim()
@@ -64,4 +70,3 @@ export function normalizeProviderEndpoint(provider: string | null | undefined, e
64
70
  if (provider === 'openclaw') return normalizeOpenClawEndpoint(trimmed)
65
71
  return trimmed.replace(/\/+$/, '')
66
72
  }
67
-
@@ -0,0 +1,46 @@
1
+ const DEFAULT_ALLOWED_ORIGINS = [
2
+ 'https://swarmclaw.ai',
3
+ 'https://www.swarmclaw.ai',
4
+ 'http://localhost:3000',
5
+ 'http://127.0.0.1:3000',
6
+ ]
7
+
8
+ function parseAllowedOrigins(raw: string | undefined): string[] {
9
+ if (!raw) return DEFAULT_ALLOWED_ORIGINS
10
+ const parsed = raw
11
+ .split(',')
12
+ .map((entry) => entry.trim())
13
+ .filter(Boolean)
14
+ return parsed.length > 0 ? parsed : DEFAULT_ALLOWED_ORIGINS
15
+ }
16
+
17
+ function normalizeOrigin(raw: string | null | undefined): string {
18
+ if (!raw) return ''
19
+ try {
20
+ return new URL(raw).origin
21
+ } catch {
22
+ return ''
23
+ }
24
+ }
25
+
26
+ export function resolvePluginInstallCorsOrigin(rawOrigin: string | null | undefined): string | null {
27
+ const origin = normalizeOrigin(rawOrigin)
28
+ if (!origin) return null
29
+ const allowed = parseAllowedOrigins(process.env.SWARMCLAW_PLUGIN_INSTALL_ORIGINS)
30
+ return allowed.includes(origin) ? origin : null
31
+ }
32
+
33
+ export function buildPluginInstallCorsHeaders(origin: string | null): HeadersInit {
34
+ const headers = new Headers()
35
+ headers.set('Vary', 'Origin')
36
+ if (!origin) return headers
37
+ headers.set('Access-Control-Allow-Origin', origin)
38
+ headers.set('Access-Control-Allow-Headers', 'Content-Type, X-Access-Key')
39
+ headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS')
40
+ headers.set('Access-Control-Max-Age', '600')
41
+ return headers
42
+ }
43
+
44
+ export function isPluginInstallCorsPath(pathname: string): boolean {
45
+ return pathname === '/api/plugins/install'
46
+ }
@@ -0,0 +1,43 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import {
5
+ getPluginSourceLabel,
6
+ inferPluginInstallSourceFromUrl,
7
+ inferPluginPublisherSourceFromUrl,
8
+ isMarketplaceInstallSource,
9
+ normalizePluginCatalogSource,
10
+ normalizePluginInstallSource,
11
+ normalizePluginPublisherSource,
12
+ } from './plugin-sources'
13
+
14
+ describe('plugin source helpers', () => {
15
+ it('normalizes publisher, catalog, and install source values', () => {
16
+ assert.equal(normalizePluginPublisherSource('SwarmForge'), 'swarmforge')
17
+ assert.equal(normalizePluginCatalogSource('swarmclaw-site'), 'swarmclaw-site')
18
+ assert.equal(normalizePluginInstallSource('ClawHub'), 'clawhub')
19
+ assert.equal(normalizePluginInstallSource('unknown-source'), undefined)
20
+ })
21
+
22
+ it('infers plugin provenance from known marketplace URLs', () => {
23
+ assert.equal(
24
+ inferPluginPublisherSourceFromUrl('https://raw.githubusercontent.com/swarmclawai/swarmforge/main/tool-logger.js'),
25
+ 'swarmforge',
26
+ )
27
+ assert.equal(
28
+ inferPluginInstallSourceFromUrl('https://clawhub.ai/skills/openclaw-gmail'),
29
+ 'clawhub',
30
+ )
31
+ assert.equal(
32
+ inferPluginPublisherSourceFromUrl('https://swarmclaw.ai/plugins/demo.js'),
33
+ 'swarmclaw',
34
+ )
35
+ })
36
+
37
+ it('labels marketplace sources consistently', () => {
38
+ assert.equal(isMarketplaceInstallSource('swarmclaw-site'), true)
39
+ assert.equal(isMarketplaceInstallSource('manual'), false)
40
+ assert.equal(getPluginSourceLabel('swarmclaw-site'), 'SwarmClaw Site')
41
+ assert.equal(getPluginSourceLabel('swarmforge'), 'SwarmForge')
42
+ })
43
+ })
@@ -0,0 +1,77 @@
1
+ import type {
2
+ PluginCatalogSource,
3
+ PluginInstallSource,
4
+ PluginPublisherSource,
5
+ } from '@/types'
6
+
7
+ const PUBLISHER_SOURCES = ['builtin', 'local', 'manual', 'swarmclaw', 'swarmforge', 'clawhub'] as const
8
+ const CATALOG_SOURCES = ['swarmclaw', 'swarmclaw-site', 'swarmforge', 'clawhub'] as const
9
+ const INSTALL_SOURCES = ['builtin', 'local', 'manual', ...CATALOG_SOURCES] as const
10
+
11
+ const SOURCE_LABELS: Record<PluginInstallSource | PluginPublisherSource, string> = {
12
+ builtin: 'Built-in',
13
+ local: 'Local file',
14
+ manual: 'Manual URL',
15
+ swarmclaw: 'SwarmClaw',
16
+ 'swarmclaw-site': 'SwarmClaw Site',
17
+ swarmforge: 'SwarmForge',
18
+ clawhub: 'ClawHub',
19
+ }
20
+
21
+ export function normalizePluginPublisherSource(raw: unknown): PluginPublisherSource | undefined {
22
+ const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
23
+ if (!value) return undefined
24
+ return (PUBLISHER_SOURCES as readonly string[]).includes(value)
25
+ ? value as PluginPublisherSource
26
+ : undefined
27
+ }
28
+
29
+ export function normalizePluginCatalogSource(raw: unknown): PluginCatalogSource | undefined {
30
+ const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
31
+ if (!value) return undefined
32
+ return (CATALOG_SOURCES as readonly string[]).includes(value)
33
+ ? value as PluginCatalogSource
34
+ : undefined
35
+ }
36
+
37
+ export function normalizePluginInstallSource(raw: unknown): PluginInstallSource | undefined {
38
+ const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
39
+ if (!value) return undefined
40
+ return (INSTALL_SOURCES as readonly string[]).includes(value)
41
+ ? value as PluginInstallSource
42
+ : undefined
43
+ }
44
+
45
+ export function inferPluginPublisherSourceFromUrl(url: string | null | undefined): PluginPublisherSource | undefined {
46
+ const normalized = typeof url === 'string' ? url.trim().toLowerCase() : ''
47
+ if (!normalized) return undefined
48
+ if (normalized.includes('clawhub.ai')) return 'clawhub'
49
+ if (normalized.includes('swarmclaw.ai/')) return 'swarmclaw'
50
+ if (
51
+ normalized.includes('raw.githubusercontent.com/swarmclawai/swarmforge/')
52
+ || normalized.includes('github.com/swarmclawai/swarmforge/')
53
+ || normalized.includes('/swarmclawai/plugins/')
54
+ ) {
55
+ return 'swarmforge'
56
+ }
57
+ return undefined
58
+ }
59
+
60
+ export function inferPluginInstallSourceFromUrl(url: string | null | undefined): PluginInstallSource | undefined {
61
+ const publisherSource = inferPluginPublisherSourceFromUrl(url)
62
+ if (publisherSource === 'swarmclaw' || publisherSource === 'swarmforge' || publisherSource === 'clawhub') {
63
+ return publisherSource
64
+ }
65
+ return undefined
66
+ }
67
+
68
+ export function isMarketplaceInstallSource(source: PluginInstallSource | null | undefined): boolean {
69
+ return source === 'swarmclaw' || source === 'swarmclaw-site' || source === 'swarmforge' || source === 'clawhub'
70
+ }
71
+
72
+ export function getPluginSourceLabel(
73
+ source: PluginInstallSource | PluginPublisherSource | PluginCatalogSource | null | undefined,
74
+ ): string {
75
+ if (!source) return 'Unknown'
76
+ return SOURCE_LABELS[source as keyof typeof SOURCE_LABELS] || source
77
+ }