@swarmclawai/swarmclaw 0.7.2 → 0.7.4

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 (274) hide show
  1. package/README.md +116 -50
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +43 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +39 -8
  12. package/src/app/api/agents/route.ts +35 -2
  13. package/src/app/api/auth/route.ts +77 -8
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  19. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  20. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  21. package/src/app/api/chats/[id]/route.ts +30 -0
  22. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  23. package/src/app/api/chats/heartbeat/route.ts +2 -1
  24. package/src/app/api/chats/route.ts +23 -1
  25. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  26. package/src/app/api/connectors/doctor/route.ts +13 -0
  27. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  28. package/src/app/api/external-agents/[id]/route.ts +31 -0
  29. package/src/app/api/external-agents/register/route.ts +3 -0
  30. package/src/app/api/external-agents/route.ts +66 -0
  31. package/src/app/api/files/open/route.ts +16 -14
  32. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  33. package/src/app/api/gateways/[id]/route.ts +79 -0
  34. package/src/app/api/gateways/route.ts +57 -0
  35. package/src/app/api/memory/maintenance/route.ts +11 -1
  36. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  37. package/src/app/api/openclaw/gateway/route.ts +10 -7
  38. package/src/app/api/openclaw/skills/route.ts +12 -4
  39. package/src/app/api/plugins/dependencies/route.ts +24 -0
  40. package/src/app/api/plugins/install/route.ts +15 -92
  41. package/src/app/api/plugins/route.ts +3 -26
  42. package/src/app/api/plugins/settings/route.ts +17 -12
  43. package/src/app/api/plugins/ui/route.ts +1 -0
  44. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  45. package/src/app/api/schedules/[id]/route.ts +38 -9
  46. package/src/app/api/schedules/route.ts +51 -28
  47. package/src/app/api/settings/route.ts +55 -17
  48. package/src/app/api/setup/doctor/route.ts +6 -4
  49. package/src/app/api/tasks/[id]/route.ts +16 -6
  50. package/src/app/api/tasks/bulk/route.ts +3 -3
  51. package/src/app/api/tasks/route.ts +9 -4
  52. package/src/app/api/webhooks/[id]/route.ts +8 -1
  53. package/src/app/page.tsx +135 -17
  54. package/src/cli/binary.test.js +142 -0
  55. package/src/cli/index.js +38 -11
  56. package/src/cli/index.test.js +195 -0
  57. package/src/cli/index.ts +21 -12
  58. package/src/cli/server-cmd.test.js +59 -0
  59. package/src/cli/spec.js +20 -2
  60. package/src/components/agents/agent-card.tsx +15 -12
  61. package/src/components/agents/agent-chat-list.tsx +101 -1
  62. package/src/components/agents/agent-list.tsx +46 -9
  63. package/src/components/agents/agent-sheet.tsx +456 -23
  64. package/src/components/agents/inspector-panel.tsx +110 -49
  65. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  66. package/src/components/auth/access-key-gate.tsx +36 -97
  67. package/src/components/auth/setup-wizard.tsx +970 -275
  68. package/src/components/chat/chat-area.tsx +70 -27
  69. package/src/components/chat/chat-card.tsx +6 -21
  70. package/src/components/chat/chat-header.tsx +263 -366
  71. package/src/components/chat/chat-list.tsx +62 -26
  72. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  73. package/src/components/chat/message-list.tsx +145 -19
  74. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  75. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  76. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  77. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  78. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  79. package/src/components/chatrooms/chatroom-view.tsx +422 -209
  80. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  81. package/src/components/connectors/connector-list.tsx +265 -127
  82. package/src/components/connectors/connector-sheet.tsx +217 -0
  83. package/src/components/gateways/gateway-sheet.tsx +567 -0
  84. package/src/components/home/home-view.tsx +128 -4
  85. package/src/components/input/chat-input.tsx +135 -86
  86. package/src/components/layout/app-layout.tsx +385 -194
  87. package/src/components/layout/mobile-header.tsx +26 -8
  88. package/src/components/memory/memory-browser.tsx +71 -6
  89. package/src/components/memory/memory-card.tsx +18 -0
  90. package/src/components/memory/memory-detail.tsx +58 -31
  91. package/src/components/memory/memory-sheet.tsx +32 -4
  92. package/src/components/plugins/plugin-list.tsx +15 -3
  93. package/src/components/plugins/plugin-sheet.tsx +118 -9
  94. package/src/components/projects/project-detail.tsx +189 -1
  95. package/src/components/providers/provider-list.tsx +158 -2
  96. package/src/components/providers/provider-sheet.tsx +81 -70
  97. package/src/components/shared/agent-picker-list.tsx +2 -2
  98. package/src/components/shared/bottom-sheet.tsx +31 -15
  99. package/src/components/shared/command-palette.tsx +111 -24
  100. package/src/components/shared/confirm-dialog.tsx +45 -30
  101. package/src/components/shared/model-combobox.tsx +90 -8
  102. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  103. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  104. package/src/components/shared/settings/section-heartbeat.tsx +88 -6
  105. package/src/components/shared/settings/section-orchestrator.tsx +6 -3
  106. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  107. package/src/components/shared/settings/section-secrets.tsx +6 -6
  108. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  109. package/src/components/shared/settings/section-voice.tsx +5 -1
  110. package/src/components/shared/settings/section-web-search.tsx +10 -2
  111. package/src/components/shared/settings/settings-page.tsx +248 -47
  112. package/src/components/tasks/approvals-panel.tsx +211 -18
  113. package/src/components/tasks/task-board.tsx +242 -46
  114. package/src/components/ui/dialog.tsx +2 -2
  115. package/src/components/usage/metrics-dashboard.tsx +74 -1
  116. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  117. package/src/components/wallets/wallet-panel.tsx +17 -5
  118. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  119. package/src/lib/auth.ts +17 -0
  120. package/src/lib/chat-streaming-state.test.ts +108 -0
  121. package/src/lib/chat-streaming-state.ts +108 -0
  122. package/src/lib/heartbeat-defaults.ts +48 -0
  123. package/src/lib/memory-presentation.ts +59 -0
  124. package/src/lib/openclaw-agent-id.test.ts +14 -0
  125. package/src/lib/openclaw-agent-id.ts +31 -0
  126. package/src/lib/provider-model-discovery-client.ts +29 -0
  127. package/src/lib/providers/index.ts +12 -5
  128. package/src/lib/runtime-loop.ts +105 -3
  129. package/src/lib/safe-storage.ts +6 -1
  130. package/src/lib/server/agent-assignment.test.ts +112 -0
  131. package/src/lib/server/agent-assignment.ts +169 -0
  132. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  133. package/src/lib/server/agent-runtime-config.ts +277 -0
  134. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  135. package/src/lib/server/approvals-auto-approve.test.ts +264 -0
  136. package/src/lib/server/approvals.ts +483 -75
  137. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  138. package/src/lib/server/browser-state.test.ts +118 -0
  139. package/src/lib/server/browser-state.ts +123 -0
  140. package/src/lib/server/build-llm.test.ts +44 -0
  141. package/src/lib/server/build-llm.ts +11 -4
  142. package/src/lib/server/builtin-plugins.ts +34 -0
  143. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  144. package/src/lib/server/chat-execution-tool-events.test.ts +219 -0
  145. package/src/lib/server/chat-execution.ts +402 -125
  146. package/src/lib/server/chatroom-health.test.ts +26 -0
  147. package/src/lib/server/chatroom-health.ts +2 -3
  148. package/src/lib/server/chatroom-helpers.test.ts +74 -2
  149. package/src/lib/server/chatroom-helpers.ts +144 -11
  150. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  151. package/src/lib/server/connectors/discord.ts +175 -11
  152. package/src/lib/server/connectors/doctor.test.ts +80 -0
  153. package/src/lib/server/connectors/doctor.ts +116 -0
  154. package/src/lib/server/connectors/manager.ts +994 -130
  155. package/src/lib/server/connectors/policy.test.ts +222 -0
  156. package/src/lib/server/connectors/policy.ts +452 -0
  157. package/src/lib/server/connectors/slack.ts +189 -10
  158. package/src/lib/server/connectors/telegram.ts +65 -15
  159. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  160. package/src/lib/server/connectors/thread-context.ts +72 -0
  161. package/src/lib/server/connectors/types.ts +41 -11
  162. package/src/lib/server/daemon-state.ts +62 -3
  163. package/src/lib/server/data-dir.ts +13 -0
  164. package/src/lib/server/delegation-jobs.test.ts +140 -0
  165. package/src/lib/server/delegation-jobs.ts +248 -0
  166. package/src/lib/server/document-utils.test.ts +47 -0
  167. package/src/lib/server/document-utils.ts +397 -0
  168. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  169. package/src/lib/server/eval/agent-regression.ts +1742 -0
  170. package/src/lib/server/eval/runner.ts +11 -1
  171. package/src/lib/server/eval/store.ts +2 -1
  172. package/src/lib/server/heartbeat-service.ts +23 -43
  173. package/src/lib/server/heartbeat-source.test.ts +22 -0
  174. package/src/lib/server/heartbeat-source.ts +7 -0
  175. package/src/lib/server/identity-continuity.test.ts +77 -0
  176. package/src/lib/server/identity-continuity.ts +127 -0
  177. package/src/lib/server/mailbox-utils.ts +347 -0
  178. package/src/lib/server/main-agent-loop.ts +31 -964
  179. package/src/lib/server/memory-db.ts +4 -6
  180. package/src/lib/server/memory-tiers.ts +40 -0
  181. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  182. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  183. package/src/lib/server/openclaw-exec-config.ts +6 -5
  184. package/src/lib/server/openclaw-gateway.ts +123 -36
  185. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  186. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  187. package/src/lib/server/openclaw-sync.ts +3 -2
  188. package/src/lib/server/orchestrator-lg.ts +18 -8
  189. package/src/lib/server/orchestrator.ts +5 -4
  190. package/src/lib/server/playwright-proxy.mjs +27 -3
  191. package/src/lib/server/plugins.test.ts +215 -0
  192. package/src/lib/server/plugins.ts +832 -69
  193. package/src/lib/server/provider-health.ts +33 -3
  194. package/src/lib/server/provider-model-discovery.ts +481 -0
  195. package/src/lib/server/queue.ts +4 -21
  196. package/src/lib/server/runtime-settings.test.ts +119 -0
  197. package/src/lib/server/runtime-settings.ts +12 -92
  198. package/src/lib/server/schedule-normalization.ts +187 -0
  199. package/src/lib/server/scheduler.ts +2 -0
  200. package/src/lib/server/session-archive-memory.test.ts +85 -0
  201. package/src/lib/server/session-archive-memory.ts +230 -0
  202. package/src/lib/server/session-mailbox.ts +8 -18
  203. package/src/lib/server/session-reset-policy.test.ts +99 -0
  204. package/src/lib/server/session-reset-policy.ts +311 -0
  205. package/src/lib/server/session-run-manager.ts +33 -80
  206. package/src/lib/server/session-tools/autonomy-tools.test.ts +128 -0
  207. package/src/lib/server/session-tools/calendar.ts +2 -12
  208. package/src/lib/server/session-tools/connector.ts +109 -8
  209. package/src/lib/server/session-tools/context.ts +14 -2
  210. package/src/lib/server/session-tools/crawl.ts +447 -0
  211. package/src/lib/server/session-tools/crud.ts +96 -34
  212. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  213. package/src/lib/server/session-tools/delegate.ts +406 -20
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  215. package/src/lib/server/session-tools/discovery.ts +40 -12
  216. package/src/lib/server/session-tools/document.ts +283 -0
  217. package/src/lib/server/session-tools/email.ts +1 -3
  218. package/src/lib/server/session-tools/extract.ts +137 -0
  219. package/src/lib/server/session-tools/file-normalize.test.ts +98 -0
  220. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  221. package/src/lib/server/session-tools/file.ts +243 -24
  222. package/src/lib/server/session-tools/http.ts +9 -3
  223. package/src/lib/server/session-tools/human-loop.ts +227 -0
  224. package/src/lib/server/session-tools/image-gen.ts +1 -3
  225. package/src/lib/server/session-tools/index.ts +87 -2
  226. package/src/lib/server/session-tools/mailbox.ts +276 -0
  227. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  228. package/src/lib/server/session-tools/memory.ts +35 -3
  229. package/src/lib/server/session-tools/monitor.ts +162 -12
  230. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  231. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  232. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  233. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  234. package/src/lib/server/session-tools/platform.ts +142 -4
  235. package/src/lib/server/session-tools/plugin-creator.ts +95 -25
  236. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  237. package/src/lib/server/session-tools/replicate.ts +1 -3
  238. package/src/lib/server/session-tools/sandbox.ts +51 -92
  239. package/src/lib/server/session-tools/schedule.ts +20 -10
  240. package/src/lib/server/session-tools/session-info.ts +58 -4
  241. package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
  242. package/src/lib/server/session-tools/shell.ts +2 -2
  243. package/src/lib/server/session-tools/subagent.ts +195 -27
  244. package/src/lib/server/session-tools/table.ts +587 -0
  245. package/src/lib/server/session-tools/wallet.ts +13 -10
  246. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  247. package/src/lib/server/session-tools/web.ts +947 -108
  248. package/src/lib/server/storage.ts +255 -10
  249. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  250. package/src/lib/server/stream-agent-chat.ts +185 -25
  251. package/src/lib/server/structured-extract.test.ts +72 -0
  252. package/src/lib/server/structured-extract.ts +373 -0
  253. package/src/lib/server/task-mention.test.ts +16 -2
  254. package/src/lib/server/task-mention.ts +61 -11
  255. package/src/lib/server/tool-aliases.ts +80 -12
  256. package/src/lib/server/tool-capability-policy.ts +7 -1
  257. package/src/lib/server/tool-retry.ts +2 -0
  258. package/src/lib/server/watch-jobs.test.ts +173 -0
  259. package/src/lib/server/watch-jobs.ts +532 -0
  260. package/src/lib/server/ws-hub.ts +5 -3
  261. package/src/lib/setup-defaults.ts +352 -11
  262. package/src/lib/tool-definitions.ts +3 -4
  263. package/src/lib/validation/schemas.test.ts +26 -0
  264. package/src/lib/validation/schemas.ts +62 -1
  265. package/src/lib/ws-client.ts +14 -12
  266. package/src/proxy.ts +5 -5
  267. package/src/stores/use-app-store.ts +43 -7
  268. package/src/stores/use-chat-store.ts +31 -2
  269. package/src/stores/use-chatroom-store.ts +153 -26
  270. package/src/types/index.ts +470 -44
  271. package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
  272. package/src/components/chat/new-chat-sheet.tsx +0 -253
  273. package/src/lib/server/main-session.ts +0 -17
  274. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -0,0 +1,587 @@
1
+ import path from 'path'
2
+ import { z } from 'zod'
3
+ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
4
+ import type { Plugin, PluginHooks } from '@/types'
5
+ import { getPluginManager } from '../plugins'
6
+ import {
7
+ loadTabularFile,
8
+ normalizeInlineRows,
9
+ writeStructuredTable,
10
+ type StructuredTable,
11
+ } from '../document-utils'
12
+ import type { ToolBuildContext } from './context'
13
+ import { safePath } from './context'
14
+ import { normalizeToolInputArgs } from './normalize-tool-args'
15
+
16
+ interface TableCondition {
17
+ column: string
18
+ op: string
19
+ value?: unknown
20
+ }
21
+
22
+ interface SortSpec {
23
+ column: string
24
+ direction: 'asc' | 'desc'
25
+ }
26
+
27
+ interface GroupMetric {
28
+ op: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'values'
29
+ column?: string
30
+ as?: string
31
+ }
32
+
33
+ function parseJsonValue<T>(value: unknown): T | null {
34
+ if (value === undefined || value === null) return null
35
+ if (typeof value === 'string') {
36
+ const trimmed = value.trim()
37
+ if (!trimmed) return null
38
+ try {
39
+ return JSON.parse(trimmed) as T
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+ return value as T
45
+ }
46
+
47
+ function resolveTablePath(cwd: string, value: unknown): string {
48
+ if (typeof value !== 'string' || !value.trim()) throw new Error('filePath is required.')
49
+ return path.isAbsolute(value) ? path.resolve(value) : safePath(cwd, value)
50
+ }
51
+
52
+ async function loadPrimaryTable(normalized: Record<string, unknown>, cwd: string): Promise<StructuredTable> {
53
+ if (normalized.rows !== undefined) {
54
+ const parsed = parseJsonValue<unknown[]>(normalized.rows) ?? normalized.rows
55
+ return normalizeInlineRows(parsed)
56
+ }
57
+ const filePath = normalized.filePath ?? normalized.path
58
+ return loadTabularFile(resolveTablePath(cwd, filePath), {
59
+ sheetName: typeof normalized.sheetName === 'string' ? normalized.sheetName : undefined,
60
+ })
61
+ }
62
+
63
+ async function loadJoinTable(
64
+ normalized: Record<string, unknown>,
65
+ cwd: string,
66
+ side: 'left' | 'right',
67
+ ): Promise<StructuredTable> {
68
+ const rowsKey = side === 'left' ? 'leftRows' : 'rightRows'
69
+ const fileKey = side === 'left' ? 'leftFilePath' : 'rightFilePath'
70
+ const sheetKey = side === 'left' ? 'leftSheetName' : 'rightSheetName'
71
+ const rowSource = normalized[rowsKey] !== undefined
72
+ ? normalized[rowsKey]
73
+ : side === 'left'
74
+ ? normalized.rows
75
+ : undefined
76
+ if (rowSource !== undefined) {
77
+ const parsed = parseJsonValue<unknown[]>(rowSource) ?? rowSource
78
+ return normalizeInlineRows(parsed)
79
+ }
80
+ const fileSource = normalized[fileKey] !== undefined
81
+ ? normalized[fileKey]
82
+ : side === 'left'
83
+ ? normalized.filePath ?? normalized.path
84
+ : undefined
85
+ return loadTabularFile(resolveTablePath(cwd, fileSource), {
86
+ sheetName: typeof normalized[sheetKey] === 'string'
87
+ ? normalized[sheetKey] as string
88
+ : side === 'left' && typeof normalized.sheetName === 'string'
89
+ ? normalized.sheetName
90
+ : undefined,
91
+ })
92
+ }
93
+
94
+ function previewTable(table: StructuredTable, sample = 50) {
95
+ return {
96
+ name: table.name,
97
+ headers: table.headers,
98
+ rowCount: table.rowCount,
99
+ rows: table.rows.slice(0, sample),
100
+ truncated: table.rowCount > sample,
101
+ }
102
+ }
103
+
104
+ function scalarToString(value: unknown): string {
105
+ if (value === null || value === undefined) return ''
106
+ if (typeof value === 'string') return value
107
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
108
+ return JSON.stringify(value)
109
+ }
110
+
111
+ function numericValue(value: unknown): number | null {
112
+ if (typeof value === 'number' && Number.isFinite(value)) return value
113
+ if (typeof value === 'string' && value.trim()) {
114
+ const parsed = Number(value)
115
+ if (Number.isFinite(parsed)) return parsed
116
+ }
117
+ return null
118
+ }
119
+
120
+ function compareValues(left: unknown, right: unknown): number {
121
+ const leftNumber = numericValue(left)
122
+ const rightNumber = numericValue(right)
123
+ if (leftNumber !== null && rightNumber !== null) return leftNumber - rightNumber
124
+ return scalarToString(left).localeCompare(scalarToString(right), undefined, { numeric: true, sensitivity: 'base' })
125
+ }
126
+
127
+ function normalizeConditions(normalized: Record<string, unknown>): TableCondition[] {
128
+ const where = parseJsonValue<TableCondition[]>(normalized.where) ?? (Array.isArray(normalized.where) ? normalized.where as TableCondition[] : [])
129
+ if (where.length > 0) {
130
+ return where
131
+ .filter((entry) => entry && typeof entry === 'object')
132
+ .map((entry) => ({
133
+ column: typeof entry.column === 'string' ? entry.column : '',
134
+ op: typeof entry.op === 'string' ? entry.op.toLowerCase() : 'eq',
135
+ value: entry.value,
136
+ }))
137
+ .filter((entry) => entry.column)
138
+ }
139
+
140
+ if (normalized.filters && typeof normalized.filters === 'object' && !Array.isArray(normalized.filters)) {
141
+ return Object.entries(normalized.filters as Record<string, unknown>).map(([column, value]) => ({
142
+ column,
143
+ op: 'eq',
144
+ value,
145
+ }))
146
+ }
147
+
148
+ if (typeof normalized.column === 'string' && normalized.column.trim()) {
149
+ if (normalized.greaterThan !== undefined) return [{ column: normalized.column, op: 'gt', value: normalized.greaterThan }]
150
+ if (normalized.greaterThanOrEqual !== undefined) return [{ column: normalized.column, op: 'gte', value: normalized.greaterThanOrEqual }]
151
+ if (normalized.lessThan !== undefined) return [{ column: normalized.column, op: 'lt', value: normalized.lessThan }]
152
+ if (normalized.lessThanOrEqual !== undefined) return [{ column: normalized.column, op: 'lte', value: normalized.lessThanOrEqual }]
153
+ if (normalized.contains !== undefined) return [{ column: normalized.column, op: 'contains', value: normalized.contains }]
154
+ if (normalized.equals !== undefined) return [{ column: normalized.column, op: 'eq', value: normalized.equals }]
155
+ }
156
+
157
+ return []
158
+ }
159
+
160
+ function rowMatchesConditions(row: Record<string, unknown>, conditions: TableCondition[]): boolean {
161
+ return conditions.every((condition) => {
162
+ const actual = row[condition.column]
163
+ const actualText = scalarToString(actual).toLowerCase()
164
+ const expectedText = scalarToString(condition.value).toLowerCase()
165
+
166
+ switch (condition.op) {
167
+ case 'eq':
168
+ return compareValues(actual, condition.value) === 0
169
+ case 'neq':
170
+ return compareValues(actual, condition.value) !== 0
171
+ case 'gt':
172
+ return compareValues(actual, condition.value) > 0
173
+ case 'gte':
174
+ return compareValues(actual, condition.value) >= 0
175
+ case 'lt':
176
+ return compareValues(actual, condition.value) < 0
177
+ case 'lte':
178
+ return compareValues(actual, condition.value) <= 0
179
+ case 'contains':
180
+ return actualText.includes(expectedText)
181
+ case 'regex':
182
+ if (typeof condition.value !== 'string' || !condition.value.trim()) return false
183
+ try {
184
+ return new RegExp(condition.value, 'i').test(actualText)
185
+ } catch {
186
+ return false
187
+ }
188
+ case 'in': {
189
+ const values = Array.isArray(condition.value) ? condition.value : [condition.value]
190
+ return values.some((entry) => compareValues(actual, entry) === 0)
191
+ }
192
+ case 'exists':
193
+ return actual !== null && actual !== undefined && scalarToString(actual).trim().length > 0
194
+ case 'not_empty':
195
+ return scalarToString(actual).trim().length > 0
196
+ default:
197
+ return compareValues(actual, condition.value) === 0
198
+ }
199
+ })
200
+ }
201
+
202
+ function normalizeSortSpecs(normalized: Record<string, unknown>): SortSpec[] {
203
+ const sort = parseJsonValue<SortSpec[]>(normalized.sort) ?? (Array.isArray(normalized.sort) ? normalized.sort as SortSpec[] : [])
204
+ if (sort.length > 0) {
205
+ return sort
206
+ .map((entry) => ({
207
+ column: typeof entry.column === 'string' ? entry.column : '',
208
+ direction: (entry.direction === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc',
209
+ }))
210
+ .filter((entry) => entry.column)
211
+ }
212
+ if (typeof normalized.sortBy === 'string' && normalized.sortBy.trim()) {
213
+ return [{
214
+ column: normalized.sortBy,
215
+ direction: (normalized.direction === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc',
216
+ }]
217
+ }
218
+ return []
219
+ }
220
+
221
+ function applySort(table: StructuredTable, specs: SortSpec[]): StructuredTable {
222
+ if (specs.length === 0) return table
223
+ const rows = [...table.rows].sort((left, right) => {
224
+ for (const spec of specs) {
225
+ const result = compareValues(left[spec.column], right[spec.column])
226
+ if (result !== 0) return spec.direction === 'desc' ? -result : result
227
+ }
228
+ return 0
229
+ })
230
+ return { ...table, rows, rowCount: rows.length }
231
+ }
232
+
233
+ function normalizeGroupMetrics(normalized: Record<string, unknown>): GroupMetric[] {
234
+ const metrics = parseJsonValue<GroupMetric[]>(normalized.metrics) ?? (Array.isArray(normalized.metrics) ? normalized.metrics as GroupMetric[] : [])
235
+ if (metrics.length > 0) {
236
+ return metrics.map((metric) => ({
237
+ op: ['count', 'sum', 'avg', 'min', 'max', 'values'].includes(metric.op) ? metric.op : 'count',
238
+ column: typeof metric.column === 'string' ? metric.column : undefined,
239
+ as: typeof metric.as === 'string' ? metric.as : undefined,
240
+ }))
241
+ }
242
+ return [{ op: 'count', as: 'count' }]
243
+ }
244
+
245
+ function groupTable(table: StructuredTable, by: string[], metrics: GroupMetric[]): StructuredTable {
246
+ const groups = new Map<string, Record<string, unknown>[]>()
247
+ for (const row of table.rows) {
248
+ const key = JSON.stringify(by.map((column) => row[column] ?? null))
249
+ const current = groups.get(key) || []
250
+ current.push(row)
251
+ groups.set(key, current)
252
+ }
253
+
254
+ const outRows = Array.from(groups.entries()).map(([key, rows]) => {
255
+ const groupValues = JSON.parse(key) as unknown[]
256
+ const next: Record<string, unknown> = {}
257
+ by.forEach((column, index) => {
258
+ next[column] = groupValues[index] ?? null
259
+ })
260
+ for (const metric of metrics) {
261
+ const name = metric.as || (metric.column ? `${metric.op}_${metric.column}` : metric.op)
262
+ const values = metric.column ? rows.map((row) => row[metric.column!]) : []
263
+ const numeric = values.map((value) => numericValue(value)).filter((value): value is number => value !== null)
264
+ switch (metric.op) {
265
+ case 'count':
266
+ next[name] = rows.length
267
+ break
268
+ case 'sum':
269
+ next[name] = numeric.reduce((total, value) => total + value, 0)
270
+ break
271
+ case 'avg':
272
+ next[name] = numeric.length ? numeric.reduce((total, value) => total + value, 0) / numeric.length : null
273
+ break
274
+ case 'min':
275
+ next[name] = numeric.length ? Math.min(...numeric) : null
276
+ break
277
+ case 'max':
278
+ next[name] = numeric.length ? Math.max(...numeric) : null
279
+ break
280
+ case 'values':
281
+ next[name] = Array.from(new Set(values.map((value) => scalarToString(value)).filter(Boolean)))
282
+ break
283
+ }
284
+ }
285
+ return next
286
+ })
287
+
288
+ const headers = Array.from(new Set([...by, ...metrics.map((metric) => metric.as || (metric.column ? `${metric.op}_${metric.column}` : metric.op))]))
289
+ return {
290
+ name: `${table.name || 'table'}_grouped`,
291
+ headers,
292
+ rows: outRows,
293
+ rowCount: outRows.length,
294
+ }
295
+ }
296
+
297
+ function dedupeTable(table: StructuredTable, keys: string[], keep: 'first' | 'last'): StructuredTable {
298
+ const seen = new Map<string, Record<string, unknown>>()
299
+ for (const row of table.rows) {
300
+ const key = JSON.stringify(keys.map((column) => row[column] ?? null))
301
+ if (keep === 'last' || !seen.has(key)) seen.set(key, row)
302
+ }
303
+ const rows = Array.from(seen.values())
304
+ return { ...table, rows, rowCount: rows.length }
305
+ }
306
+
307
+ function joinTables(
308
+ left: StructuredTable,
309
+ right: StructuredTable,
310
+ keys: string[],
311
+ joinType: 'inner' | 'left',
312
+ rightPrefix = 'right_',
313
+ ): StructuredTable {
314
+ const rightGroups = new Map<string, Record<string, unknown>[]>()
315
+ for (const row of right.rows) {
316
+ const key = JSON.stringify(keys.map((column) => row[column] ?? null))
317
+ const current = rightGroups.get(key) || []
318
+ current.push(row)
319
+ rightGroups.set(key, current)
320
+ }
321
+
322
+ const rightHeaders = right.headers.map((header) => (keys.includes(header) ? null : left.headers.includes(header) ? `${rightPrefix}${header}` : header)).filter((header): header is string => !!header)
323
+ const rows: Array<Record<string, unknown>> = []
324
+
325
+ for (const leftRow of left.rows) {
326
+ const key = JSON.stringify(keys.map((column) => leftRow[column] ?? null))
327
+ const matches = rightGroups.get(key) || []
328
+ if (matches.length === 0) {
329
+ if (joinType === 'left') rows.push({ ...leftRow })
330
+ continue
331
+ }
332
+ for (const rightRow of matches) {
333
+ const merged: Record<string, unknown> = { ...leftRow }
334
+ for (const header of right.headers) {
335
+ if (keys.includes(header)) continue
336
+ const target = left.headers.includes(header) ? `${rightPrefix}${header}` : header
337
+ merged[target] = rightRow[header]
338
+ }
339
+ rows.push(merged)
340
+ }
341
+ }
342
+
343
+ return {
344
+ name: `${left.name || 'left'}_joined_${right.name || 'right'}`,
345
+ headers: Array.from(new Set([...left.headers, ...rightHeaders])),
346
+ rows,
347
+ rowCount: rows.length,
348
+ }
349
+ }
350
+
351
+ function pivotTable(
352
+ table: StructuredTable,
353
+ indexColumns: string[],
354
+ columnField: string,
355
+ valueField: string,
356
+ aggregate: 'count' | 'sum' | 'first',
357
+ ): StructuredTable {
358
+ const columnValues = Array.from(new Set(table.rows.map((row) => scalarToString(row[columnField])).filter(Boolean)))
359
+ const grouped = new Map<string, Record<string, unknown>[]>()
360
+ for (const row of table.rows) {
361
+ const key = JSON.stringify(indexColumns.map((column) => row[column] ?? null))
362
+ const current = grouped.get(key) || []
363
+ current.push(row)
364
+ grouped.set(key, current)
365
+ }
366
+
367
+ const rows = Array.from(grouped.entries()).map(([key, groupRows]) => {
368
+ const base: Record<string, unknown> = {}
369
+ const indexValues = JSON.parse(key) as unknown[]
370
+ indexColumns.forEach((column, index) => {
371
+ base[column] = indexValues[index] ?? null
372
+ })
373
+ for (const columnValue of columnValues) {
374
+ const matches = groupRows.filter((row) => scalarToString(row[columnField]) === columnValue)
375
+ if (aggregate === 'count') {
376
+ base[columnValue] = matches.length
377
+ } else if (aggregate === 'sum') {
378
+ base[columnValue] = matches
379
+ .map((row) => numericValue(row[valueField]))
380
+ .filter((value): value is number => value !== null)
381
+ .reduce((total, value) => total + value, 0)
382
+ } else {
383
+ base[columnValue] = matches[0]?.[valueField] ?? null
384
+ }
385
+ }
386
+ return base
387
+ })
388
+
389
+ return {
390
+ name: `${table.name || 'table'}_pivot`,
391
+ headers: [...indexColumns, ...columnValues],
392
+ rows,
393
+ rowCount: rows.length,
394
+ }
395
+ }
396
+
397
+ async function maybePersistOutput(normalized: Record<string, unknown>, cwd: string, table: StructuredTable) {
398
+ const outputPath = typeof normalized.outputPath === 'string'
399
+ ? normalized.outputPath
400
+ : typeof normalized.saveTo === 'string'
401
+ ? normalized.saveTo
402
+ : typeof normalized.outputFilePath === 'string'
403
+ ? normalized.outputFilePath
404
+ : null
405
+ if (!outputPath) return null
406
+ const resolved = path.isAbsolute(outputPath) ? path.resolve(outputPath) : safePath(cwd, outputPath)
407
+ return writeStructuredTable(resolved, table)
408
+ }
409
+
410
+ async function executeTableAction(args: Record<string, unknown>, bctx: { cwd: string }) {
411
+ const normalized = normalizeToolInputArgs(args)
412
+ const action = String(normalized.action || 'read').trim().toLowerCase()
413
+
414
+ try {
415
+ if (action === 'status') {
416
+ return JSON.stringify({
417
+ supports: ['read', 'load_csv', 'load_xlsx', 'summarize', 'filter', 'sort', 'group', 'pivot', 'dedupe', 'join', 'write'],
418
+ })
419
+ }
420
+
421
+ if (action === 'join') {
422
+ const left = await loadJoinTable(normalized, bctx.cwd, 'left')
423
+ const right = await loadJoinTable(normalized, bctx.cwd, 'right')
424
+ const keys = Array.isArray(normalized.on)
425
+ ? normalized.on.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
426
+ : typeof normalized.on === 'string'
427
+ ? [normalized.on]
428
+ : []
429
+ if (keys.length === 0) return 'Error: on is required for join.'
430
+ const joined = joinTables(
431
+ left,
432
+ right,
433
+ keys,
434
+ normalized.joinType === 'left' ? 'left' : 'inner',
435
+ typeof normalized.rightPrefix === 'string' && normalized.rightPrefix.trim() ? normalized.rightPrefix : 'right_',
436
+ )
437
+ const persisted = await maybePersistOutput(normalized, bctx.cwd, joined)
438
+ return JSON.stringify({ action, ...previewTable(joined), output: persisted })
439
+ }
440
+
441
+ let table = await loadPrimaryTable(normalized, bctx.cwd)
442
+
443
+ if (action === 'read' || action === 'load_csv' || action === 'load_xlsx') {
444
+ return JSON.stringify({ action: 'read', ...previewTable(table) })
445
+ }
446
+
447
+ if (action === 'summarize') {
448
+ const nonEmptyCounts = Object.fromEntries(table.headers.map((header) => [
449
+ header,
450
+ table.rows.filter((row) => scalarToString(row[header]).trim().length > 0).length,
451
+ ]))
452
+ return JSON.stringify({
453
+ name: table.name,
454
+ headers: table.headers,
455
+ rowCount: table.rowCount,
456
+ nonEmptyCounts,
457
+ sample: table.rows.slice(0, 10),
458
+ })
459
+ }
460
+
461
+ if (action === 'filter') {
462
+ const conditions = normalizeConditions(normalized)
463
+ if (conditions.length === 0) return 'Error: where or filters is required for filter.'
464
+ const rows = table.rows.filter((row) => rowMatchesConditions(row, conditions))
465
+ table = { ...table, rows, rowCount: rows.length }
466
+ } else if (action === 'sort') {
467
+ table = applySort(table, normalizeSortSpecs(normalized))
468
+ } else if (action === 'group') {
469
+ const by = Array.isArray(normalized.by)
470
+ ? normalized.by.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
471
+ : typeof normalized.by === 'string'
472
+ ? [normalized.by]
473
+ : []
474
+ if (by.length === 0) return 'Error: by is required for group.'
475
+ table = groupTable(table, by, normalizeGroupMetrics(normalized))
476
+ } else if (action === 'pivot') {
477
+ const indexColumns = Array.isArray(normalized.index)
478
+ ? normalized.index.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
479
+ : typeof normalized.index === 'string'
480
+ ? [normalized.index]
481
+ : []
482
+ const columnField = typeof normalized.columns === 'string' ? normalized.columns : typeof normalized.column === 'string' ? normalized.column : ''
483
+ const valueField = typeof normalized.value === 'string' ? normalized.value : ''
484
+ if (indexColumns.length === 0 || !columnField || !valueField) {
485
+ return 'Error: index, columns, and value are required for pivot.'
486
+ }
487
+ const aggregate = normalized.aggregate === 'sum' || normalized.aggregate === 'count' ? normalized.aggregate : 'first'
488
+ table = pivotTable(table, indexColumns, columnField, valueField, aggregate)
489
+ } else if (action === 'dedupe') {
490
+ const keys = Array.isArray(normalized.on)
491
+ ? normalized.on.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
492
+ : typeof normalized.on === 'string'
493
+ ? [normalized.on]
494
+ : Array.isArray(normalized.columns)
495
+ ? normalized.columns.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
496
+ : table.headers
497
+ table = dedupeTable(table, keys, normalized.keep === 'last' ? 'last' : 'first')
498
+ } else if (action === 'write') {
499
+ const persisted = await maybePersistOutput(normalized, bctx.cwd, table)
500
+ if (!persisted) return 'Error: outputPath or saveTo is required for write.'
501
+ return JSON.stringify({ action: 'write', output: persisted, ...previewTable(table) })
502
+ } else {
503
+ return `Error: Unknown action "${action}".`
504
+ }
505
+
506
+ const persisted = await maybePersistOutput(normalized, bctx.cwd, table)
507
+ return JSON.stringify({ action, ...previewTable(table), output: persisted })
508
+ } catch (err: unknown) {
509
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
510
+ }
511
+ }
512
+
513
+ const TablePlugin: Plugin = {
514
+ name: 'Table',
515
+ enabledByDefault: false,
516
+ description: 'Load, transform, join, pivot, and export CSV/XLSX/JSON tables without dropping to shell scripts.',
517
+ hooks: {
518
+ getCapabilityDescription: () =>
519
+ 'I can load and transform tabular data with `table`, including filtering, sorting, grouping, pivoting, deduping, joining, summarizing, and exporting results.',
520
+ } as PluginHooks,
521
+ tools: [
522
+ {
523
+ name: 'table',
524
+ description: 'Tabular data tool. Actions: read, load_csv, load_xlsx, summarize, filter, sort, group, pivot, dedupe, join, write, status.',
525
+ parameters: {
526
+ type: 'object',
527
+ properties: {
528
+ action: {
529
+ type: 'string',
530
+ enum: ['read', 'load_csv', 'load_xlsx', 'summarize', 'filter', 'sort', 'group', 'pivot', 'dedupe', 'join', 'write', 'status'],
531
+ },
532
+ filePath: { type: 'string' },
533
+ rows: {},
534
+ where: {},
535
+ filters: {},
536
+ sort: {},
537
+ sortBy: { type: 'string' },
538
+ direction: { type: 'string', enum: ['asc', 'desc'] },
539
+ by: {},
540
+ metrics: {},
541
+ index: {},
542
+ columns: { type: 'string' },
543
+ value: { type: 'string' },
544
+ aggregate: { type: 'string', enum: ['first', 'count', 'sum'] },
545
+ on: {},
546
+ keep: { type: 'string', enum: ['first', 'last'] },
547
+ leftFilePath: { type: 'string' },
548
+ rightFilePath: { type: 'string' },
549
+ leftRows: {},
550
+ rightRows: {},
551
+ joinType: { type: 'string', enum: ['inner', 'left'] },
552
+ rightPrefix: { type: 'string' },
553
+ outputPath: { type: 'string' },
554
+ outputFilePath: { type: 'string' },
555
+ saveTo: { type: 'string' },
556
+ greaterThan: {},
557
+ greaterThanOrEqual: {},
558
+ lessThan: {},
559
+ lessThanOrEqual: {},
560
+ equals: {},
561
+ contains: {},
562
+ sheetName: { type: 'string' },
563
+ leftSheetName: { type: 'string' },
564
+ rightSheetName: { type: 'string' },
565
+ },
566
+ required: ['action'],
567
+ },
568
+ execute: async (args, context) => executeTableAction(args, { cwd: context.session.cwd || process.cwd() }),
569
+ },
570
+ ],
571
+ }
572
+
573
+ getPluginManager().registerBuiltin('table', TablePlugin)
574
+
575
+ export function buildTableTools(bctx: ToolBuildContext): StructuredToolInterface[] {
576
+ if (!bctx.hasPlugin('table')) return []
577
+ return [
578
+ tool(
579
+ async (args) => executeTableAction(args, { cwd: bctx.cwd }),
580
+ {
581
+ name: 'table',
582
+ description: TablePlugin.tools![0].description,
583
+ schema: z.object({}).passthrough(),
584
+ },
585
+ ),
586
+ ]
587
+ }
@@ -63,21 +63,24 @@ async function executeWalletAction(args: any, context: { agentId?: string | null
63
63
  if (!amountSol || amountSol <= 0) return JSON.stringify({ error: 'amountSol must be positive' })
64
64
 
65
65
  if (normalized.approved !== true) {
66
- const { requestApproval } = await import('../approvals')
67
- requestApproval({
66
+ const { requestApprovalMaybeAutoApprove } = await import('../approvals')
67
+ const approval = await requestApprovalMaybeAutoApprove({
68
68
  category: 'wallet_transfer',
69
69
  title: `Send ${amountSol} SOL`,
70
70
  description: `Transfer to ${toAddress}. Memo: ${memo || 'none'}`,
71
71
  data: { toAddress, amountSol, memo },
72
72
  agentId: context.agentId,
73
73
  })
74
- return JSON.stringify({
75
- type: 'plugin_wallet_transfer_request',
76
- amountSol,
77
- toAddress,
78
- memo,
79
- message: `I'm requesting to send ${amountSol} SOL to ${toAddress}. Please approve this transaction.`
80
- })
74
+ if (approval.status !== 'approved') {
75
+ return JSON.stringify({
76
+ type: 'plugin_wallet_transfer_request',
77
+ amountSol,
78
+ toAddress,
79
+ memo,
80
+ message: `I'm requesting to send ${amountSol} SOL to ${toAddress}. Please approve this transaction.`
81
+ })
82
+ }
83
+ normalized.approved = true
81
84
  }
82
85
 
83
86
  const { isValidSolanaAddress, solToLamports, lamportsToSol } = await import('../solana')
@@ -175,7 +178,7 @@ const WalletPlugin: Plugin = {
175
178
  headerWidgets: [
176
179
  {
177
180
  id: 'wallet-status',
178
- label: '💎 Wallet Active'
181
+ label: 'Wallet'
179
182
  }
180
183
  ]
181
184
  },
@@ -0,0 +1,39 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { buildBrowserConnectionOptions, buildBrowserStdioServerParams, sanitizePlaywrightMcpEnv } from './web'
5
+
6
+ describe('browser tool connection config', () => {
7
+ it('does not opt into Playwright shared browser contexts', () => {
8
+ const config = buildBrowserConnectionOptions('/tmp/swarmclaw-browser-profile')
9
+
10
+ assert.equal(config.sharedBrowserContext, false)
11
+ assert.equal(config.browser.userDataDir, '/tmp/swarmclaw-browser-profile')
12
+ assert.deepEqual(config.browser.contextOptions.viewport, { width: 1440, height: 900 })
13
+ })
14
+
15
+ it('spawns a dedicated stdio MCP server with an isolated profile directory', () => {
16
+ const params = buildBrowserStdioServerParams('/tmp/swarmclaw-browser-profile')
17
+
18
+ assert.equal(params.command, process.execPath)
19
+ assert.equal(params.args.includes('--headless'), true)
20
+ assert.equal(params.args.includes('--shared-browser-context'), false)
21
+ assert.equal(params.args.includes('/tmp/swarmclaw-browser-profile'), true)
22
+ assert.equal(params.env.PLAYWRIGHT_MCP_USER_DATA_DIR, '/tmp/swarmclaw-browser-profile')
23
+ assert.equal(params.env.PLAYWRIGHT_MCP_OUTPUT_MODE, 'file')
24
+ })
25
+
26
+ it('strips host Playwright MCP env overrides before applying the local browser config', () => {
27
+ const env = sanitizePlaywrightMcpEnv({
28
+ PLAYWRIGHT_MCP_CONFIG: '/tmp/evil-config.json',
29
+ PLAYWRIGHT_MCP_SHARED_BROWSER_CONTEXT: '1',
30
+ PLAYWRIGHT_MCP_TIMEOUT_ACTION: '999999',
31
+ OTHER_ENV: 'keep-me',
32
+ })
33
+
34
+ assert.equal(env.PLAYWRIGHT_MCP_CONFIG, undefined)
35
+ assert.equal(env.PLAYWRIGHT_MCP_SHARED_BROWSER_CONTEXT, undefined)
36
+ assert.equal(env.PLAYWRIGHT_MCP_TIMEOUT_ACTION, undefined)
37
+ assert.equal(env.OTHER_ENV, 'keep-me')
38
+ })
39
+ })