@swarmclawai/swarmclaw 0.6.2 → 0.6.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 (42) hide show
  1. package/README.md +45 -44
  2. package/package.json +1 -1
  3. package/src/app/api/tts/route.ts +16 -36
  4. package/src/app/api/tts/stream/route.ts +14 -43
  5. package/src/app/page.tsx +7 -3
  6. package/src/components/auth/access-key-gate.tsx +22 -11
  7. package/src/components/chat/chat-area.tsx +30 -2
  8. package/src/components/chat/chat-header.tsx +70 -3
  9. package/src/components/chat/message-bubble.tsx +11 -1
  10. package/src/components/chat/message-list.tsx +3 -71
  11. package/src/components/chat/tool-call-bubble.test.ts +28 -0
  12. package/src/components/chat/tool-call-bubble.tsx +13 -1
  13. package/src/components/chatrooms/chatroom-input.tsx +6 -5
  14. package/src/components/connectors/connector-sheet.tsx +16 -1
  15. package/src/components/input/chat-input.tsx +5 -4
  16. package/src/components/layout/app-layout.tsx +5 -6
  17. package/src/components/logs/log-list.tsx +7 -7
  18. package/src/components/sessions/new-session-sheet.tsx +4 -3
  19. package/src/hooks/use-media-query.ts +30 -4
  20. package/src/lib/api-client.ts +6 -18
  21. package/src/lib/fetch-timeout.ts +17 -0
  22. package/src/lib/notification-sounds.ts +4 -4
  23. package/src/lib/safe-storage.ts +42 -0
  24. package/src/lib/server/chat-execution.ts +74 -3
  25. package/src/lib/server/connectors/connector-routing.test.ts +118 -1
  26. package/src/lib/server/connectors/discord.ts +31 -8
  27. package/src/lib/server/connectors/manager.ts +398 -31
  28. package/src/lib/server/connectors/media.ts +5 -0
  29. package/src/lib/server/connectors/telegram.ts +12 -2
  30. package/src/lib/server/connectors/types.ts +2 -0
  31. package/src/lib/server/connectors/whatsapp.ts +28 -2
  32. package/src/lib/server/elevenlabs.test.ts +60 -0
  33. package/src/lib/server/elevenlabs.ts +103 -0
  34. package/src/lib/server/queue.ts +130 -1
  35. package/src/lib/server/session-tools/connector.ts +540 -94
  36. package/src/lib/server/session-tools/file.ts +26 -7
  37. package/src/lib/server/session-tools/web-output.test.ts +29 -0
  38. package/src/lib/server/session-tools/web-output.ts +16 -0
  39. package/src/lib/server/session-tools/web.ts +8 -5
  40. package/src/lib/server/stream-agent-chat.ts +7 -0
  41. package/src/lib/view-routes.ts +5 -1
  42. package/src/stores/use-app-store.ts +9 -11
@@ -6,6 +6,17 @@ import { UPLOAD_DIR } from '../storage'
6
6
  import type { ToolBuildContext } from './context'
7
7
  import { safePath, truncate, listDirRecursive, MAX_OUTPUT, MAX_FILE } from './context'
8
8
 
9
+ const SEND_FILE_DEDUPE_TTL_MS = 30_000
10
+ const recentSendFileResults = new Map<string, { at: number; output: string; uploadPath: string }>()
11
+
12
+ function pruneRecentSendFileCache(now: number): void {
13
+ for (const [key, entry] of recentSendFileResults.entries()) {
14
+ if (now - entry.at > SEND_FILE_DEDUPE_TTL_MS || !fs.existsSync(entry.uploadPath)) {
15
+ recentSendFileResults.delete(key)
16
+ }
17
+ }
18
+ }
19
+
9
20
  export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[] {
10
21
  const tools: StructuredToolInterface[] = []
11
22
 
@@ -197,6 +208,8 @@ export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[
197
208
  tool(
198
209
  async ({ filePath: rawPath }) => {
199
210
  try {
211
+ const now = Date.now()
212
+ pruneRecentSendFileCache(now)
200
213
  // Resolve relative to cwd, but also allow absolute paths
201
214
  const resolved = path.isAbsolute(rawPath) ? rawPath : path.resolve(bctx.cwd, rawPath)
202
215
  if (!fs.existsSync(resolved)) return `Error: file not found: ${rawPath}`
@@ -204,6 +217,13 @@ export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[
204
217
  if (stat.isDirectory()) return `Error: cannot send a directory. Send individual files instead.`
205
218
  if (stat.size > 100 * 1024 * 1024) return `Error: file too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Max 100MB.`
206
219
 
220
+ const sessionId = bctx.ctx?.sessionId || 'no-session'
221
+ const dedupeKey = `${sessionId}|${resolved}`
222
+ const cached = recentSendFileResults.get(dedupeKey)
223
+ if (cached && now - cached.at <= SEND_FILE_DEDUPE_TTL_MS && fs.existsSync(cached.uploadPath)) {
224
+ return cached.output
225
+ }
226
+
207
227
  const ext = path.extname(resolved).slice(1).toLowerCase()
208
228
  const basename = path.basename(resolved)
209
229
  const filename = `${Date.now()}-${basename}`
@@ -212,14 +232,13 @@ export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[
212
232
 
213
233
  const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']
214
234
  const VIDEO_EXTS = ['mp4', 'webm', 'mov', 'avi', 'mkv']
235
+ const AUDIO_EXTS = ['mp3', 'ogg', 'wav', 'aac', 'm4a', 'opus']
215
236
 
216
- if (IMAGE_EXTS.includes(ext)) {
217
- return `![${basename}](/api/uploads/${filename})`
218
- } else if (VIDEO_EXTS.includes(ext)) {
219
- return `![${basename}](/api/uploads/${filename})`
220
- } else {
221
- return `[Download ${basename}](/api/uploads/${filename})`
222
- }
237
+ const output = (IMAGE_EXTS.includes(ext) || VIDEO_EXTS.includes(ext) || AUDIO_EXTS.includes(ext))
238
+ ? `![${basename}](/api/uploads/${filename})`
239
+ : `[Download ${basename}](/api/uploads/${filename})`
240
+ recentSendFileResults.set(dedupeKey, { at: now, output, uploadPath: dest })
241
+ return output
223
242
  } catch (err: unknown) {
224
243
  return `Error sending file: ${err instanceof Error ? err.message : String(err)}`
225
244
  }
@@ -0,0 +1,29 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { dedupeScreenshotMarkdownLines } from './web-output'
4
+
5
+ describe('dedupeScreenshotMarkdownLines', () => {
6
+ it('prefers screenshot-* image when both browser-* and screenshot-* variants are present', () => {
7
+ const parts = [
8
+ '![Screenshot](/api/uploads/browser-1772498741525.png)',
9
+ '![Screenshot](/api/uploads/screenshot-1772498741526.png)',
10
+ 'Saved to: example_screenshot.png',
11
+ ]
12
+
13
+ const next = dedupeScreenshotMarkdownLines(parts)
14
+ assert.deepEqual(next, [
15
+ '![Screenshot](/api/uploads/screenshot-1772498741526.png)',
16
+ 'Saved to: example_screenshot.png',
17
+ ])
18
+ })
19
+
20
+ it('keeps single image output untouched', () => {
21
+ const parts = [
22
+ '![Screenshot](/api/uploads/browser-1772498741525.png)',
23
+ 'Saved to: example_screenshot.png',
24
+ ]
25
+
26
+ const next = dedupeScreenshotMarkdownLines(parts)
27
+ assert.deepEqual(next, parts)
28
+ })
29
+ })
@@ -0,0 +1,16 @@
1
+ export function dedupeScreenshotMarkdownLines(parts: string[]): string[] {
2
+ const imageLineRe = /^!\[[^\]]*]\(\/api\/uploads\/([^)]+)\)$/
3
+ const imageLines = parts
4
+ .map((line, index) => ({ line: line.trim(), index }))
5
+ .map((entry) => {
6
+ const match = entry.line.match(imageLineRe)
7
+ return match ? { ...entry, filename: match[1] } : null
8
+ })
9
+ .filter((entry): entry is { line: string; index: number; filename: string } => !!entry)
10
+
11
+ if (imageLines.length <= 1) return parts
12
+
13
+ const preferred = imageLines.find((entry) => !entry.filename.startsWith('browser-')) || imageLines[0]
14
+ const keepIndex = preferred.index
15
+ return parts.filter((_, index) => !imageLines.some((entry) => entry.index === index) || index === keepIndex)
16
+ }
@@ -8,6 +8,7 @@ import type { ToolBuildContext } from './context'
8
8
  import { spawnSync } from 'child_process'
9
9
  import { safePath, truncate, MAX_OUTPUT, findBinaryOnPath } from './context'
10
10
  import { getSearchProvider } from './search-providers'
11
+ import { dedupeScreenshotMarkdownLines } from './web-output'
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // Search result compression — summarize verbose results before injecting into context
@@ -277,11 +278,11 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
277
278
  }
278
279
 
279
280
  if (Array.isArray(content)) {
280
- const parts: string[] = []
281
- let hasBinaryImage = false
281
+ let parts: string[] = []
282
+ const isScreenshotTool = toolName === 'browser_take_screenshot'
283
+ const contentHasBinaryImage = content.some((c) => c.type === 'image' && !!c.data)
282
284
  for (const c of content) {
283
285
  if (c.type === 'image' && c.data) {
284
- hasBinaryImage = true
285
286
  const imageBuffer = Buffer.from(c.data, 'base64')
286
287
  const filename = `screenshot-${Date.now()}.png`
287
288
  const filepath = path.join(UPLOAD_DIR, filename)
@@ -306,8 +307,8 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
306
307
  if (fs.existsSync(srcPath)) {
307
308
  const ext = path.extname(srcPath).slice(1).toLowerCase()
308
309
  const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp']
309
- // Skip file-path images if we already have a binary image (avoids duplicates)
310
- if (IMAGE_EXTS.includes(ext) && hasBinaryImage) {
310
+ // Skip file-path images whenever MCP already returned image binary payloads.
311
+ if (IMAGE_EXTS.includes(ext) && contentHasBinaryImage) {
311
312
  parts.push(isError ? text : cleanPlaywrightOutput(text))
312
313
  } else {
313
314
  const filename = `browser-${Date.now()}.${ext}`
@@ -335,6 +336,8 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
335
336
  }
336
337
  }
337
338
  }
339
+ if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
340
+
338
341
  if (savedPaths.length > 0) {
339
342
  const unique = Array.from(new Set(savedPaths))
340
343
  const rendered = unique.map((p) => path.relative(cwd, p) || '.').join(', ')
@@ -127,6 +127,12 @@ function buildAgenticExecutionPolicy(opts: {
127
127
  opts.enabledTools.includes('manage_connectors')
128
128
  ? 'If the user wants proactive outreach (e.g., WhatsApp updates), configure connectors and pair with schedules/tasks to deliver status updates.'
129
129
  : '',
130
+ opts.enabledTools.includes('manage_connectors')
131
+ ? 'Autonomous outreach is allowed for significant events (completed/failed tasks, blockers, deadlines, meaningful reminders from memory). Avoid casual or repetitive check-ins.'
132
+ : '',
133
+ opts.enabledTools.includes('manage_connectors')
134
+ ? 'When you proactively message through connectors, keep it concise and purposeful, and avoid sending duplicate updates about the same event.'
135
+ : '',
130
136
  opts.enabledTools.includes('manage_sessions')
131
137
  ? 'When coordinating platform work, inspect existing sessions and avoid duplicating active efforts.'
132
138
  : '',
@@ -164,6 +170,7 @@ function buildAgenticExecutionPolicy(opts: {
164
170
  'The test: if you saw this message from a friend, would you feel compelled to type something back? If not, NO_MESSAGE.',
165
171
  'Ask for confirmation only for high-risk or irreversible actions. For normal low-risk research/build steps, proceed autonomously.',
166
172
  'Default behavior is execution, not interrogation: do not ask exploratory clarification questions when a safe next action exists.',
173
+ 'Do not end every response with a question. Use declarative completion statements by default, and only ask a question when a concrete missing detail blocks the next action.',
167
174
  'Do not pause for a "continue" confirmation after the user has already asked you to execute a goal. Keep moving until blocked by permissions, missing credentials, or hard tool failures.',
168
175
  'Never repeat one-time side effects that are already complete (for example creating the same schedule/task again). Verify state first, then either continue execution or reply HEARTBEAT_OK.',
169
176
  'For main-loop tick messages that begin with "SWARM_MAIN_MISSION_TICK" or "SWARM_MAIN_AUTO_FOLLOWUP", follow that response contract exactly and include one valid [MAIN_LOOP_META] JSON line when you are not returning HEARTBEAT_OK.',
@@ -49,7 +49,11 @@ export function parsePath(pathname: string): { view: AppView; id: string | null
49
49
  if (pathname.startsWith(path + '/')) {
50
50
  const rest = pathname.slice(path.length + 1)
51
51
  if (rest && !rest.includes('/') && VIEWS_WITH_ID.has(view)) {
52
- return { view, id: decodeURIComponent(rest) }
52
+ try {
53
+ return { view, id: decodeURIComponent(rest) }
54
+ } catch {
55
+ return null
56
+ }
53
57
  }
54
58
  }
55
59
  }
@@ -7,6 +7,7 @@ import { fetchAgents } from '../lib/agents'
7
7
  import { fetchSchedules } from '../lib/schedules'
8
8
  import { fetchTasks } from '../lib/tasks'
9
9
  import { api } from '../lib/api-client'
10
+ import { safeStorageGet, safeStorageGetJson, safeStorageRemove, safeStorageSet } from '../lib/safe-storage'
10
11
 
11
12
  interface AppState {
12
13
  currentUser: string | null
@@ -209,14 +210,13 @@ export const useAppStore = create<AppState>((set, get) => ({
209
210
  currentUser: null,
210
211
  _hydrated: false,
211
212
  hydrate: () => {
212
- if (typeof window === 'undefined') return
213
- const user = localStorage.getItem('sc_user')
214
- const savedAgentId = localStorage.getItem('sc_agent')
213
+ const user = safeStorageGet('sc_user')
214
+ const savedAgentId = safeStorageGet('sc_agent')
215
215
  set({ currentUser: user, currentAgentId: savedAgentId, _hydrated: true })
216
216
  },
217
217
  setUser: (user) => {
218
- if (user) localStorage.setItem('sc_user', user)
219
- else localStorage.removeItem('sc_user')
218
+ if (user) safeStorageSet('sc_user', user)
219
+ else safeStorageRemove('sc_user')
220
220
  set({ currentUser: user })
221
221
  },
222
222
 
@@ -324,11 +324,11 @@ export const useAppStore = create<AppState>((set, get) => ({
324
324
  setCurrentAgent: async (id) => {
325
325
  if (!id) {
326
326
  set({ currentAgentId: null })
327
- if (typeof window !== 'undefined') localStorage.removeItem('sc_agent')
327
+ safeStorageRemove('sc_agent')
328
328
  return
329
329
  }
330
330
  set({ currentAgentId: id })
331
- if (typeof window !== 'undefined') localStorage.setItem('sc_agent', id)
331
+ safeStorageSet('sc_agent', id)
332
332
  try {
333
333
  const user = get().currentUser || 'default'
334
334
  const session = await api<Session>('POST', `/agents/${id}/thread`, { user })
@@ -628,13 +628,11 @@ export const useAppStore = create<AppState>((set, get) => ({
628
628
  },
629
629
 
630
630
  // Unread tracking
631
- lastReadTimestamps: typeof window !== 'undefined'
632
- ? (() => { try { return JSON.parse(localStorage.getItem('sc_last_read') || '{}') } catch { return {} } })()
633
- : {},
631
+ lastReadTimestamps: safeStorageGetJson<Record<string, number>>('sc_last_read', {}),
634
632
  markChatRead: (id) => {
635
633
  const ts = { ...get().lastReadTimestamps, [id]: Date.now() }
636
634
  set({ lastReadTimestamps: ts })
637
- try { localStorage.setItem('sc_last_read', JSON.stringify(ts)) } catch { /* ignore */ }
635
+ safeStorageSet('sc_last_read', JSON.stringify(ts))
638
636
  },
639
637
 
640
638
  // Notifications