@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.
- package/README.md +45 -44
- package/package.json +1 -1
- package/src/app/api/tts/route.ts +16 -36
- package/src/app/api/tts/stream/route.ts +14 -43
- package/src/app/page.tsx +7 -3
- package/src/components/auth/access-key-gate.tsx +22 -11
- package/src/components/chat/chat-area.tsx +30 -2
- package/src/components/chat/chat-header.tsx +70 -3
- package/src/components/chat/message-bubble.tsx +11 -1
- package/src/components/chat/message-list.tsx +3 -71
- package/src/components/chat/tool-call-bubble.test.ts +28 -0
- package/src/components/chat/tool-call-bubble.tsx +13 -1
- package/src/components/chatrooms/chatroom-input.tsx +6 -5
- package/src/components/connectors/connector-sheet.tsx +16 -1
- package/src/components/input/chat-input.tsx +5 -4
- package/src/components/layout/app-layout.tsx +5 -6
- package/src/components/logs/log-list.tsx +7 -7
- package/src/components/sessions/new-session-sheet.tsx +4 -3
- package/src/hooks/use-media-query.ts +30 -4
- package/src/lib/api-client.ts +6 -18
- package/src/lib/fetch-timeout.ts +17 -0
- package/src/lib/notification-sounds.ts +4 -4
- package/src/lib/safe-storage.ts +42 -0
- package/src/lib/server/chat-execution.ts +74 -3
- package/src/lib/server/connectors/connector-routing.test.ts +118 -1
- package/src/lib/server/connectors/discord.ts +31 -8
- package/src/lib/server/connectors/manager.ts +398 -31
- package/src/lib/server/connectors/media.ts +5 -0
- package/src/lib/server/connectors/telegram.ts +12 -2
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp.ts +28 -2
- package/src/lib/server/elevenlabs.test.ts +60 -0
- package/src/lib/server/elevenlabs.ts +103 -0
- package/src/lib/server/queue.ts +130 -1
- package/src/lib/server/session-tools/connector.ts +540 -94
- package/src/lib/server/session-tools/file.ts +26 -7
- package/src/lib/server/session-tools/web-output.test.ts +29 -0
- package/src/lib/server/session-tools/web-output.ts +16 -0
- package/src/lib/server/session-tools/web.ts +8 -5
- package/src/lib/server/stream-agent-chat.ts +7 -0
- package/src/lib/view-routes.ts +5 -1
- 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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return `[Download ${basename}](/api/uploads/${filename})`
|
|
222
|
-
}
|
|
237
|
+
const output = (IMAGE_EXTS.includes(ext) || VIDEO_EXTS.includes(ext) || AUDIO_EXTS.includes(ext))
|
|
238
|
+
? ``
|
|
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
|
+
'',
|
|
9
|
+
'',
|
|
10
|
+
'Saved to: example_screenshot.png',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const next = dedupeScreenshotMarkdownLines(parts)
|
|
14
|
+
assert.deepEqual(next, [
|
|
15
|
+
'',
|
|
16
|
+
'Saved to: example_screenshot.png',
|
|
17
|
+
])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('keeps single image output untouched', () => {
|
|
21
|
+
const parts = [
|
|
22
|
+
'',
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
310
|
-
if (IMAGE_EXTS.includes(ext) &&
|
|
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.',
|
package/src/lib/view-routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
213
|
-
const
|
|
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)
|
|
219
|
-
else
|
|
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
|
-
|
|
327
|
+
safeStorageRemove('sc_agent')
|
|
328
328
|
return
|
|
329
329
|
}
|
|
330
330
|
set({ currentAgentId: id })
|
|
331
|
-
|
|
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:
|
|
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
|
-
|
|
635
|
+
safeStorageSet('sc_last_read', JSON.stringify(ts))
|
|
638
636
|
},
|
|
639
637
|
|
|
640
638
|
// Notifications
|