@swarmclawai/swarmclaw 0.3.1 → 0.4.5

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 (203) hide show
  1. package/README.md +33 -13
  2. package/bin/server-cmd.js +14 -7
  3. package/bin/swarmclaw.js +3 -1
  4. package/bin/update-cmd.js +120 -0
  5. package/next.config.ts +10 -0
  6. package/package.json +4 -1
  7. package/src/app/api/agents/[id]/route.ts +20 -18
  8. package/src/app/api/agents/[id]/thread/route.ts +4 -3
  9. package/src/app/api/agents/route.ts +8 -3
  10. package/src/app/api/auth/route.ts +3 -1
  11. package/src/app/api/claude-skills/route.ts +3 -1
  12. package/src/app/api/clawhub/install/route.ts +2 -2
  13. package/src/app/api/connectors/[id]/route.ts +14 -3
  14. package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
  15. package/src/app/api/connectors/route.ts +12 -4
  16. package/src/app/api/credentials/[id]/route.ts +2 -1
  17. package/src/app/api/credentials/route.ts +5 -3
  18. package/src/app/api/daemon/route.ts +6 -1
  19. package/src/app/api/documents/route.ts +2 -2
  20. package/src/app/api/files/serve/route.ts +8 -0
  21. package/src/app/api/ip/route.ts +3 -1
  22. package/src/app/api/knowledge/[id]/route.ts +5 -4
  23. package/src/app/api/knowledge/upload/route.ts +2 -2
  24. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  25. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  26. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  27. package/src/app/api/mcp-servers/route.ts +5 -3
  28. package/src/app/api/memory/[id]/route.ts +9 -8
  29. package/src/app/api/memory/route.ts +2 -2
  30. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  31. package/src/app/api/openclaw/directory/route.ts +26 -0
  32. package/src/app/api/openclaw/discover/route.ts +61 -0
  33. package/src/app/api/openclaw/sync/route.ts +30 -0
  34. package/src/app/api/orchestrator/graph/route.ts +25 -0
  35. package/src/app/api/orchestrator/run/route.ts +2 -2
  36. package/src/app/api/plugins/marketplace/route.ts +3 -1
  37. package/src/app/api/plugins/route.ts +3 -1
  38. package/src/app/api/projects/[id]/route.ts +55 -0
  39. package/src/app/api/projects/route.ts +27 -0
  40. package/src/app/api/providers/[id]/models/route.ts +2 -1
  41. package/src/app/api/providers/[id]/route.ts +13 -12
  42. package/src/app/api/providers/configs/route.ts +3 -1
  43. package/src/app/api/providers/route.ts +7 -3
  44. package/src/app/api/schedules/[id]/route.ts +16 -15
  45. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  46. package/src/app/api/schedules/route.ts +8 -3
  47. package/src/app/api/secrets/[id]/route.ts +16 -17
  48. package/src/app/api/secrets/route.ts +5 -3
  49. package/src/app/api/sessions/[id]/chat/route.ts +5 -2
  50. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  51. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  52. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  53. package/src/app/api/sessions/[id]/messages/route.ts +2 -1
  54. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  55. package/src/app/api/sessions/[id]/route.ts +2 -1
  56. package/src/app/api/sessions/route.ts +11 -4
  57. package/src/app/api/settings/route.ts +3 -1
  58. package/src/app/api/setup/doctor/route.ts +1 -0
  59. package/src/app/api/setup/openclaw-device/route.ts +3 -1
  60. package/src/app/api/skills/[id]/route.ts +23 -21
  61. package/src/app/api/skills/import/route.ts +2 -2
  62. package/src/app/api/skills/route.ts +5 -3
  63. package/src/app/api/tasks/[id]/approve/route.ts +74 -0
  64. package/src/app/api/tasks/[id]/route.ts +9 -5
  65. package/src/app/api/tasks/route.ts +5 -2
  66. package/src/app/api/tts/stream/route.ts +48 -0
  67. package/src/app/api/upload/route.ts +2 -2
  68. package/src/app/api/uploads/[filename]/route.ts +4 -1
  69. package/src/app/api/usage/route.ts +3 -1
  70. package/src/app/api/version/route.ts +3 -1
  71. package/src/app/api/webhooks/[id]/route.ts +31 -32
  72. package/src/app/api/webhooks/route.ts +5 -3
  73. package/src/app/icon.svg +58 -0
  74. package/src/app/page.tsx +11 -26
  75. package/src/cli/index.js +28 -9
  76. package/src/cli/index.ts +45 -2
  77. package/src/cli/spec.js +2 -8
  78. package/src/components/agents/agent-card.tsx +1 -1
  79. package/src/components/agents/agent-list.tsx +3 -1
  80. package/src/components/agents/agent-sheet.tsx +166 -81
  81. package/src/components/chat/chat-area.tsx +71 -34
  82. package/src/components/chat/chat-header.tsx +141 -29
  83. package/src/components/chat/chat-tool-toggles.tsx +12 -53
  84. package/src/components/chat/message-bubble.tsx +110 -42
  85. package/src/components/chat/tool-call-bubble.tsx +50 -6
  86. package/src/components/chat/tool-request-banner.tsx +1 -9
  87. package/src/components/chat/voice-overlay.tsx +80 -0
  88. package/src/components/connectors/connector-list.tsx +9 -10
  89. package/src/components/connectors/connector-sheet.tsx +55 -36
  90. package/src/components/input/chat-input.tsx +72 -56
  91. package/src/components/knowledge/knowledge-list.tsx +27 -31
  92. package/src/components/layout/app-layout.tsx +133 -90
  93. package/src/components/layout/daemon-indicator.tsx +3 -5
  94. package/src/components/logs/log-list.tsx +5 -9
  95. package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
  96. package/src/components/memory/memory-detail.tsx +1 -1
  97. package/src/components/plugins/plugin-list.tsx +227 -27
  98. package/src/components/projects/project-list.tsx +122 -0
  99. package/src/components/projects/project-sheet.tsx +135 -0
  100. package/src/components/providers/provider-list.tsx +46 -13
  101. package/src/components/providers/provider-sheet.tsx +0 -45
  102. package/src/components/runs/run-list.tsx +6 -15
  103. package/src/components/schedules/schedule-card.tsx +54 -4
  104. package/src/components/schedules/schedule-list.tsx +9 -4
  105. package/src/components/schedules/schedule-sheet.tsx +0 -47
  106. package/src/components/secrets/secrets-list.tsx +20 -2
  107. package/src/components/sessions/new-session-sheet.tsx +14 -15
  108. package/src/components/sessions/session-card.tsx +1 -1
  109. package/src/components/sessions/session-list.tsx +7 -7
  110. package/src/components/shared/connector-platform-icon.tsx +26 -20
  111. package/src/components/shared/model-combobox.tsx +148 -0
  112. package/src/components/shared/settings/section-heartbeat.tsx +8 -40
  113. package/src/components/shared/settings/section-orchestrator.tsx +9 -11
  114. package/src/components/shared/settings/section-web-search.tsx +56 -0
  115. package/src/components/shared/settings/settings-page.tsx +73 -0
  116. package/src/components/skills/skill-list.tsx +262 -35
  117. package/src/components/skills/skill-sheet.tsx +0 -45
  118. package/src/components/tasks/task-board.tsx +3 -6
  119. package/src/components/tasks/task-card.tsx +43 -1
  120. package/src/components/tasks/task-list.tsx +8 -7
  121. package/src/components/tasks/task-sheet.tsx +0 -44
  122. package/src/components/usage/usage-list.tsx +12 -4
  123. package/src/hooks/use-continuous-speech.ts +144 -0
  124. package/src/hooks/use-view-router.ts +52 -0
  125. package/src/hooks/use-voice-conversation.ts +80 -0
  126. package/src/hooks/use-ws.ts +66 -0
  127. package/src/instrumentation.ts +2 -0
  128. package/src/lib/chat.ts +14 -2
  129. package/src/lib/id.ts +6 -0
  130. package/src/lib/projects.ts +13 -0
  131. package/src/lib/provider-sets.ts +5 -0
  132. package/src/lib/providers/anthropic.ts +15 -2
  133. package/src/lib/providers/index.ts +8 -0
  134. package/src/lib/providers/ollama.ts +10 -2
  135. package/src/lib/providers/openai.ts +42 -13
  136. package/src/lib/providers/openclaw.ts +11 -0
  137. package/src/lib/server/api-routes.test.ts +5 -6
  138. package/src/lib/server/build-llm.ts +17 -4
  139. package/src/lib/server/chat-execution.ts +57 -8
  140. package/src/lib/server/collection-helpers.ts +54 -0
  141. package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
  142. package/src/lib/server/connectors/bluebubbles.ts +357 -0
  143. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  144. package/src/lib/server/connectors/googlechat.ts +46 -7
  145. package/src/lib/server/connectors/manager.ts +401 -6
  146. package/src/lib/server/connectors/media.ts +2 -2
  147. package/src/lib/server/connectors/openclaw.ts +64 -0
  148. package/src/lib/server/connectors/pairing.test.ts +99 -0
  149. package/src/lib/server/connectors/pairing.ts +256 -0
  150. package/src/lib/server/connectors/signal.ts +1 -0
  151. package/src/lib/server/connectors/teams.ts +5 -5
  152. package/src/lib/server/connectors/types.ts +10 -0
  153. package/src/lib/server/context-manager.ts +1 -1
  154. package/src/lib/server/daemon-state.ts +3 -0
  155. package/src/lib/server/data-dir.ts +1 -0
  156. package/src/lib/server/execution-log.ts +3 -3
  157. package/src/lib/server/heartbeat-service.ts +67 -3
  158. package/src/lib/server/knowledge-db.test.ts +2 -33
  159. package/src/lib/server/langgraph-checkpoint.ts +274 -0
  160. package/src/lib/server/main-agent-loop.ts +67 -8
  161. package/src/lib/server/memory-db.ts +6 -6
  162. package/src/lib/server/openclaw-approvals.ts +105 -0
  163. package/src/lib/server/openclaw-sync.ts +496 -0
  164. package/src/lib/server/orchestrator-lg.ts +422 -20
  165. package/src/lib/server/orchestrator.ts +29 -9
  166. package/src/lib/server/process-manager.ts +2 -2
  167. package/src/lib/server/queue.ts +39 -13
  168. package/src/lib/server/scheduler.ts +2 -2
  169. package/src/lib/server/session-mailbox.ts +2 -2
  170. package/src/lib/server/session-run-manager.ts +8 -3
  171. package/src/lib/server/session-tools/connector.ts +51 -4
  172. package/src/lib/server/session-tools/crud.ts +3 -3
  173. package/src/lib/server/session-tools/delegate.ts +5 -5
  174. package/src/lib/server/session-tools/file.ts +176 -3
  175. package/src/lib/server/session-tools/index.ts +4 -0
  176. package/src/lib/server/session-tools/memory.ts +2 -2
  177. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  178. package/src/lib/server/session-tools/sandbox.ts +197 -0
  179. package/src/lib/server/session-tools/search-providers.ts +270 -0
  180. package/src/lib/server/session-tools/session-info.ts +2 -2
  181. package/src/lib/server/session-tools/web.ts +47 -66
  182. package/src/lib/server/storage-mcp.test.ts +25 -2
  183. package/src/lib/server/storage.ts +36 -7
  184. package/src/lib/server/stream-agent-chat.ts +106 -22
  185. package/src/lib/server/task-result.test.ts +44 -0
  186. package/src/lib/server/task-result.ts +14 -0
  187. package/src/lib/server/task-validation.test.ts +23 -0
  188. package/src/lib/server/task-validation.ts +5 -3
  189. package/src/lib/server/ws-hub.ts +85 -0
  190. package/src/lib/tool-definitions.ts +44 -0
  191. package/src/lib/tts-stream.ts +130 -0
  192. package/src/lib/upload.ts +7 -1
  193. package/src/lib/view-routes.ts +28 -0
  194. package/src/lib/ws-client.ts +124 -0
  195. package/src/proxy.ts +3 -0
  196. package/src/stores/use-app-store.ts +28 -1
  197. package/src/stores/use-chat-store.ts +42 -14
  198. package/src/types/index.ts +34 -2
  199. package/src/app/api/agents/generate/route.ts +0 -42
  200. package/src/app/api/generate/info/route.ts +0 -12
  201. package/src/app/api/generate/route.ts +0 -106
  202. package/src/app/favicon.ico +0 -0
  203. package/src/components/shared/ai-gen-block.tsx +0 -77
@@ -5,29 +5,9 @@ import path from 'path'
5
5
  import * as cheerio from 'cheerio'
6
6
  import { UPLOAD_DIR } from '../storage'
7
7
  import type { ToolBuildContext } from './context'
8
- import { safePath, truncate, MAX_OUTPUT } from './context'
9
-
10
- // ---------------------------------------------------------------------------
11
- // DuckDuckGo redirect-URL decoder
12
- // ---------------------------------------------------------------------------
13
-
14
- function decodeDuckDuckGoUrl(rawUrl: string): string {
15
- if (!rawUrl) return rawUrl
16
- try {
17
- const url = rawUrl.startsWith('http')
18
- ? new URL(rawUrl)
19
- : new URL(rawUrl, 'https://duckduckgo.com')
20
- const uddg = url.searchParams.get('uddg')
21
- if (uddg) return decodeURIComponent(uddg)
22
- return url.toString()
23
- } catch {
24
- const fromQuery = rawUrl.match(/[?&]uddg=([^&]+)/)?.[1]
25
- if (fromQuery) {
26
- try { return decodeURIComponent(fromQuery) } catch { /* noop */ }
27
- }
28
- return rawUrl
29
- }
30
- }
8
+ import { spawnSync } from 'child_process'
9
+ import { safePath, truncate, MAX_OUTPUT, findBinaryOnPath } from './context'
10
+ import { getSearchProvider } from './search-providers'
31
11
 
32
12
  // ---------------------------------------------------------------------------
33
13
  // Global registry of active browser instances for cleanup sweeps
@@ -86,48 +66,10 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
86
66
  async ({ query, maxResults }) => {
87
67
  try {
88
68
  const limit = Math.min(maxResults || 5, 10)
89
- const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`
90
- const res = await fetch(url, {
91
- headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SwarmClaw/1.0)' },
92
- signal: AbortSignal.timeout(15000),
93
- })
94
- if (!res.ok) {
95
- return `Error searching web: HTTP ${res.status} ${res.statusText}`
96
- }
97
- const html = await res.text()
98
- const $ = cheerio.load(html)
99
- const results: { title: string; url: string; snippet: string }[] = []
100
-
101
- // Primary parser: DuckDuckGo result cards
102
- $('.result').each((_i, el) => {
103
- if (results.length >= limit) return false
104
- const link = $(el).find('a.result__a').first()
105
- const rawHref = link.attr('href') || ''
106
- const title = link.text().replace(/\s+/g, ' ').trim()
107
- if (!rawHref || !title) return
108
- const snippet = $(el).find('.result__snippet').first().text().replace(/\s+/g, ' ').trim()
109
- results.push({
110
- title,
111
- url: decodeDuckDuckGoUrl(rawHref),
112
- snippet,
113
- })
114
- })
115
-
116
- // Fallback parser: any result__a anchors
117
- if (results.length === 0) {
118
- $('a.result__a').each((_i, el) => {
119
- if (results.length >= limit) return false
120
- const rawHref = $(el).attr('href') || ''
121
- const title = $(el).text().replace(/\s+/g, ' ').trim()
122
- if (!rawHref || !title) return
123
- results.push({
124
- title,
125
- url: decodeDuckDuckGoUrl(rawHref),
126
- snippet: '',
127
- })
128
- })
129
- }
130
-
69
+ const { loadSettings } = await import('../storage')
70
+ const settings = loadSettings()
71
+ const provider = await getSearchProvider(settings)
72
+ const results = await provider.search(query, limit)
131
73
  return results.length > 0
132
74
  ? JSON.stringify(results, null, 2)
133
75
  : 'No results found.'
@@ -137,7 +79,7 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
137
79
  },
138
80
  {
139
81
  name: 'web_search',
140
- description: 'Search the web using DuckDuckGo. Returns an array of results with title, url, and snippet.',
82
+ description: 'Search the web. Returns an array of results with title, url, and snippet.',
141
83
  schema: z.object({
142
84
  query: z.string().describe('Search query'),
143
85
  maxResults: z.number().optional().describe('Maximum results to return (default 5, max 10)'),
@@ -404,5 +346,44 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
404
346
  )
405
347
  }
406
348
 
349
+ // ---- openclaw_browser (CLI passthrough) -----------------------------------
350
+
351
+ if (bctx.hasTool('browser') || bctx.hasTool('openclaw_browser')) {
352
+ const openclawPath = findBinaryOnPath('openclaw') || findBinaryOnPath('clawdbot')
353
+ if (openclawPath) {
354
+ tools.push(
355
+ tool(
356
+ async ({ command, args: cmdArgs }) => {
357
+ try {
358
+ const spawnArgs = ['browser', command, '--json']
359
+ if (cmdArgs) spawnArgs.push(...cmdArgs.split(/\s+/).filter(Boolean))
360
+ const result = spawnSync(openclawPath, spawnArgs, {
361
+ encoding: 'utf-8',
362
+ timeout: 60_000,
363
+ maxBuffer: MAX_OUTPUT,
364
+ })
365
+ const stdout = (result.stdout || '').trim()
366
+ const stderr = (result.stderr || '').trim()
367
+ if (result.status !== 0) {
368
+ return `Error (exit ${result.status}): ${stderr || stdout || 'unknown error'}`
369
+ }
370
+ return truncate(stdout || '(no output)', MAX_OUTPUT)
371
+ } catch (err: any) {
372
+ return `Error: ${err.message}`
373
+ }
374
+ },
375
+ {
376
+ name: 'openclaw_browser',
377
+ description: 'Control a browser through the OpenClaw CLI. Requires openclaw/clawdbot CLI on PATH. Passes through to `openclaw browser <command> --json`.',
378
+ schema: z.object({
379
+ command: z.string().describe('Browser command (navigate, screenshot, click, type, evaluate, etc.)'),
380
+ args: z.string().optional().describe('Additional arguments as a space-separated string'),
381
+ }),
382
+ },
383
+ ),
384
+ )
385
+ }
386
+ }
387
+
407
388
  return tools
408
389
  }
@@ -33,8 +33,15 @@ function loadMcpServers(): Record<string, any> {
33
33
  }
34
34
 
35
35
  function saveMcpServers(m: Record<string, any>) {
36
+ const existingRows = db.prepare(`SELECT id FROM ${TABLE}`).all() as { id: string }[]
37
+ const nextIds = new Set(Object.keys(m))
38
+ const toDelete = existingRows.map((r) => r.id).filter((id) => !nextIds.has(id))
36
39
  const upsert = db.prepare(`INSERT OR REPLACE INTO ${TABLE} (id, data) VALUES (?, ?)`)
40
+ const del = db.prepare(`DELETE FROM ${TABLE} WHERE id = ?`)
37
41
  const transaction = db.transaction(() => {
42
+ for (const id of toDelete) {
43
+ del.run(id)
44
+ }
38
45
  for (const [id, val] of Object.entries(m)) {
39
46
  upsert.run(id, JSON.stringify(val))
40
47
  }
@@ -75,8 +82,10 @@ describe('MCP server storage', () => {
75
82
  })
76
83
 
77
84
  it('loadMcpServers returns all saved configs', () => {
78
- // srv-1 already exists from previous test
79
- saveMcpServers({ 'srv-2': { id: 'srv-2', name: 'Second' } })
85
+ saveMcpServers({
86
+ 'srv-1': { id: 'srv-1', name: 'First' },
87
+ 'srv-2': { id: 'srv-2', name: 'Second' },
88
+ })
80
89
 
81
90
  const all = loadMcpServers()
82
91
  assert.ok('srv-1' in all)
@@ -102,6 +111,20 @@ describe('MCP server storage', () => {
102
111
  assert.equal(count, 1)
103
112
  })
104
113
 
114
+ it('saveMcpServers removes records omitted from the next save payload', () => {
115
+ saveMcpServers({
116
+ 'srv-a': { id: 'srv-a', name: 'A' },
117
+ 'srv-b': { id: 'srv-b', name: 'B' },
118
+ })
119
+ saveMcpServers({
120
+ 'srv-b': { id: 'srv-b', name: 'B2' },
121
+ })
122
+
123
+ const all = loadMcpServers()
124
+ assert.equal('srv-a' in all, false)
125
+ assert.equal(all['srv-b'].name, 'B2')
126
+ })
127
+
105
128
  it('deleteMcpServer removes the record', () => {
106
129
  saveMcpServers({ 'srv-d': { id: 'srv-d', name: 'ToDelete' } })
107
130
  deleteMcpServer('srv-d')
@@ -4,11 +4,11 @@ import crypto from 'crypto'
4
4
  import os from 'os'
5
5
  import Database from 'better-sqlite3'
6
6
 
7
- import { DATA_DIR } from './data-dir'
8
- export const UPLOAD_DIR = path.join(os.tmpdir(), 'swarmclaw-uploads')
7
+ import { DATA_DIR, WORKSPACE_DIR } from './data-dir'
8
+ export const UPLOAD_DIR = path.join(DATA_DIR, 'uploads')
9
9
 
10
10
  // Ensure directories exist
11
- for (const dir of [DATA_DIR, UPLOAD_DIR]) {
11
+ for (const dir of [DATA_DIR, UPLOAD_DIR, WORKSPACE_DIR]) {
12
12
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
13
13
  }
14
14
 
@@ -43,6 +43,7 @@ const COLLECTIONS = [
43
43
  'model_overrides',
44
44
  'mcp_servers',
45
45
  'webhook_logs',
46
+ 'projects',
46
47
  ] as const
47
48
 
48
49
  for (const table of COLLECTIONS) {
@@ -65,8 +66,8 @@ function readCollectionRaw(table: string): Map<string, string> {
65
66
  }
66
67
 
67
68
  function getCollectionRawCache(table: string): Map<string, string> {
68
- const cached = collectionCache.get(table)
69
- if (cached) return cached
69
+ // Always reload from SQLite so concurrent Next.js workers/processes
70
+ // observe each other's writes immediately.
70
71
  const loaded = readCollectionRaw(table)
71
72
  collectionCache.set(table, loaded)
72
73
  return loaded
@@ -87,26 +88,43 @@ function loadCollection(table: string): Record<string, any> {
87
88
 
88
89
  function saveCollection(table: string, data: Record<string, any>) {
89
90
  const current = getCollectionRawCache(table)
91
+ const next = new Map<string, string>()
90
92
  const toUpsert: Array<[string, string]> = []
93
+ const toDelete: string[] = []
91
94
 
92
95
  for (const [id, val] of Object.entries(data)) {
93
96
  const serialized = JSON.stringify(val)
94
97
  if (typeof serialized !== 'string') continue
98
+ next.set(id, serialized)
95
99
  if (current.get(id) !== serialized) {
96
100
  toUpsert.push([id, serialized])
97
101
  }
98
- current.set(id, serialized)
99
102
  }
100
103
 
101
- if (!toUpsert.length) return
104
+ for (const id of current.keys()) {
105
+ if (!next.has(id)) toDelete.push(id)
106
+ }
107
+
108
+ if (!toUpsert.length && !toDelete.length) return
102
109
 
103
110
  const transaction = db.transaction(() => {
111
+ if (toDelete.length) {
112
+ const del = db.prepare(`DELETE FROM ${table} WHERE id = ?`)
113
+ for (const id of toDelete) del.run(id)
114
+ }
104
115
  const upsert = db.prepare(`INSERT OR REPLACE INTO ${table} (id, data) VALUES (?, ?)`)
105
116
  for (const [id, serialized] of toUpsert) {
106
117
  upsert.run(id, serialized)
107
118
  }
108
119
  })
109
120
  transaction()
121
+
122
+ for (const id of toDelete) {
123
+ current.delete(id)
124
+ }
125
+ for (const [id, serialized] of next.entries()) {
126
+ current.set(id, serialized)
127
+ }
110
128
  }
111
129
 
112
130
  function deleteCollectionItem(table: string, id: string) {
@@ -559,6 +577,17 @@ export function saveModelOverrides(m: Record<string, string[]>) {
559
577
  saveCollection('model_overrides', m)
560
578
  }
561
579
 
580
+ // --- Projects ---
581
+ export function loadProjects(): Record<string, any> {
582
+ return loadCollection('projects')
583
+ }
584
+
585
+ export function saveProjects(s: Record<string, any>) {
586
+ saveCollection('projects', s)
587
+ }
588
+
589
+ export function deleteProject(id: string) { deleteCollectionItem('projects', id) }
590
+
562
591
  // --- Skills ---
563
592
  export function loadSkills(): Record<string, any> {
564
593
  return loadCollection('skills')
@@ -15,6 +15,7 @@ interface StreamAgentChatOpts {
15
15
  session: Session
16
16
  message: string
17
17
  imagePath?: string
18
+ attachedFiles?: string[]
18
19
  apiKey: string | null
19
20
  systemPrompt?: string
20
21
  write: (data: string) => void
@@ -38,6 +39,7 @@ function buildToolCapabilityLines(enabledTools: string[]): string[] {
38
39
  if (enabledTools.includes('codex_cli')) lines.push('- Codex delegation is available (`delegate_to_codex_cli`) for deep coding/refactor tasks. Resume IDs may be returned via `[delegate_meta]`.')
39
40
  if (enabledTools.includes('opencode_cli')) lines.push('- OpenCode delegation is available (`delegate_to_opencode_cli`) for deep coding/refactor tasks. Resume IDs may be returned via `[delegate_meta]`.')
40
41
  if (enabledTools.includes('memory')) lines.push('- Long-term memory is available (`memory_tool`) to store and recall durable context.')
42
+ if (enabledTools.includes('sandbox')) lines.push('- Sandboxed code execution is available (`sandbox_exec`). Write and run JS/TS (Deno) or Python scripts in an isolated environment. Output includes stdout, stderr, and any files created as downloadable artifacts.')
41
43
  if (enabledTools.includes('manage_agents')) lines.push('- Agent management is available (`manage_agents`) to create or adjust specialist agents.')
42
44
  if (enabledTools.includes('manage_tasks')) lines.push('- Task management is available (`manage_tasks`) to create and track execution plans.')
43
45
  if (enabledTools.includes('manage_schedules')) lines.push('- Schedule management is available (`manage_schedules`) for recurring/ongoing runs.')
@@ -162,15 +164,24 @@ export interface StreamAgentChatResult {
162
164
  }
163
165
 
164
166
  export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<StreamAgentChatResult> {
165
- const { session, message, imagePath, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
167
+ const { session, message, imagePath, attachedFiles, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
166
168
 
167
169
  // fallbackCredentialIds is intentionally accepted for compatibility with caller signatures.
168
170
  void fallbackCredentialIds
171
+
172
+ // Resolve agent's thinking level for provider-native params
173
+ let agentThinkingLevel: 'minimal' | 'low' | 'medium' | 'high' | undefined
174
+ if (session.agentId) {
175
+ const agentsForThinking = loadAgents()
176
+ agentThinkingLevel = agentsForThinking[session.agentId]?.thinkingLevel
177
+ }
178
+
169
179
  const llm = buildChatModel({
170
180
  provider: session.provider,
171
181
  model: session.model,
172
182
  apiKey,
173
183
  apiEndpoint: session.apiEndpoint,
184
+ thinkingLevel: agentThinkingLevel,
174
185
  })
175
186
 
176
187
  // Build stateModifier
@@ -226,6 +237,17 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
226
237
  stateModifierParts.push('You are a capable AI assistant with tool access. Be execution-oriented and outcome-focused.')
227
238
  }
228
239
 
240
+ // Thinking level guidance (applies to all providers via system prompt)
241
+ if (agentThinkingLevel) {
242
+ const thinkingGuidance: Record<string, string> = {
243
+ minimal: 'Be direct and concise. Skip extended analysis.',
244
+ low: 'Keep reasoning brief. Focus on key conclusions.',
245
+ medium: 'Provide moderate depth of analysis and reasoning.',
246
+ high: 'Think deeply and thoroughly. Show detailed reasoning.',
247
+ }
248
+ stateModifierParts.push(`## Reasoning Depth\n${thinkingGuidance[agentThinkingLevel]}`)
249
+ }
250
+
229
251
  if ((session.tools || []).includes('memory') && session.agentId) {
230
252
  try {
231
253
  const memDb = getMemoryDb()
@@ -310,7 +332,6 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
310
332
  const allToolIds = [
311
333
  'shell', 'files', 'edit_file', 'process', 'web_search', 'web_fetch', 'browser', 'memory',
312
334
  'claude_code', 'codex_cli', 'opencode_cli',
313
- 'orchestrator',
314
335
  'manage_agents', 'manage_tasks', 'manage_schedules', 'manage_skills',
315
336
  'manage_documents', 'manage_webhooks', 'manage_connectors', 'manage_sessions', 'manage_secrets',
316
337
  ]
@@ -318,13 +339,9 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
318
339
  const mcpDisabled = agentMcpDisabledTools ?? []
319
340
  const allDisabled = [...disabled, ...mcpDisabled]
320
341
  if (allDisabled.length > 0) {
321
- const delegateNote = disabled.includes('orchestrator')
322
- ? '\n\nIMPORTANT: The `delegate_to_agent` tool requires the `orchestrator` capability to be enabled. You must request access to `orchestrator` before you can delegate work to other agents.'
323
- : ''
324
342
  stateModifierParts.push(
325
343
  `## Disabled Tools\nThe following tools exist but are not enabled for you: ${allDisabled.join(', ')}.\n` +
326
- 'If you need one of these to complete a task, use the `request_tool_access` tool to ask the user for permission.' +
327
- delegateNote,
344
+ 'If you need one of these to complete a task, use the `request_tool_access` tool to ask the user for permission.',
328
345
  )
329
346
  }
330
347
  }
@@ -354,25 +371,82 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
354
371
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
355
372
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
356
373
 
357
- function buildLangChainContent(text: string, filePath?: string): any {
358
- if (!filePath || !fs.existsSync(filePath)) return text
374
+ async function buildContentForFile(filePath: string): Promise<{ type: string; [k: string]: any } | string | null> {
375
+ if (!fs.existsSync(filePath)) {
376
+ console.log(`[stream-agent-chat] FILE NOT FOUND: ${filePath}`)
377
+ return null
378
+ }
379
+ const name = filePath.split('/').pop() || 'file'
359
380
  if (IMAGE_EXTS.test(filePath)) {
360
- const data = fs.readFileSync(filePath).toString('base64')
381
+ const buf = fs.readFileSync(filePath)
382
+ if (buf.length === 0) {
383
+ console.warn(`[stream-agent-chat] Image file is empty: ${filePath}`)
384
+ return `[Attached image: ${name} — file is empty]`
385
+ }
386
+ const data = buf.toString('base64')
361
387
  const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
362
- const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
363
- return [
364
- { type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}` } },
365
- { type: 'text', text },
366
- ]
388
+ // Detect actual MIME from magic bytes (fall back to extension-based)
389
+ let mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
390
+ if (buf[0] === 0xFF && buf[1] === 0xD8) mimeType = 'image/jpeg'
391
+ else if (buf[0] === 0x89 && buf[1] === 0x50) mimeType = 'image/png'
392
+ else if (buf[0] === 0x47 && buf[1] === 0x49) mimeType = 'image/gif'
393
+ else if (buf[0] === 0x52 && buf[1] === 0x49) mimeType = 'image/webp'
394
+ return { type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}`, detail: 'auto' } }
395
+ }
396
+ if (filePath.endsWith('.pdf')) {
397
+ try {
398
+ // @ts-ignore — pdf-parse types
399
+ const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
400
+ const buf = fs.readFileSync(filePath)
401
+ const result = await pdfParse(buf)
402
+ const pdfText = (result.text || '').trim()
403
+ if (!pdfText) return `[Attached PDF: ${name} — no extractable text]`
404
+ // Truncate very large PDFs to avoid token limits
405
+ const maxChars = 100_000
406
+ const truncated = pdfText.length > maxChars ? pdfText.slice(0, maxChars) + '\n\n[... truncated]' : pdfText
407
+ return `[Attached PDF: ${name} (${result.numpages} pages)]\n\n${truncated}`
408
+ } catch {
409
+ return `[Attached PDF: ${name} — could not extract text]`
410
+ }
367
411
  }
368
- if (TEXT_EXTS.test(filePath) || filePath.endsWith('.pdf')) {
412
+ if (TEXT_EXTS.test(filePath)) {
369
413
  try {
370
414
  const fileContent = fs.readFileSync(filePath, 'utf-8')
371
- const name = filePath.split('/').pop() || 'file'
372
- return `[Attached file: ${name}]\n\n${fileContent}\n\n${text}`
373
- } catch { return text }
415
+ return `[Attached file: ${name}]\n\n${fileContent}`
416
+ } catch { return `[Attached file: ${name} — read error]` }
374
417
  }
375
- return `[Attached file: ${filePath.split('/').pop()}]\n\n${text}`
418
+ return `[Attached file: ${name}]`
419
+ }
420
+
421
+ async function buildLangChainContent(text: string, filePath?: string, extraFiles?: string[]): Promise<any> {
422
+ const filePaths: string[] = []
423
+ if (filePath) filePaths.push(filePath)
424
+ if (extraFiles?.length) {
425
+ for (const f of extraFiles) {
426
+ if (f && !filePaths.includes(f)) filePaths.push(f)
427
+ }
428
+ }
429
+ if (!filePaths.length) return text
430
+
431
+ const parts: any[] = []
432
+ const textParts: string[] = []
433
+ for (const fp of filePaths) {
434
+ const content = await buildContentForFile(fp)
435
+ if (!content) continue
436
+ if (typeof content === 'string') {
437
+ textParts.push(content)
438
+ } else {
439
+ parts.push(content)
440
+ }
441
+ }
442
+
443
+ const combinedText = textParts.length
444
+ ? `${textParts.join('\n\n')}\n\n${text}`
445
+ : text
446
+
447
+ if (parts.length === 0) return combinedText
448
+ parts.push({ type: 'text', text: combinedText })
449
+ return parts
376
450
  }
377
451
 
378
452
  // Auto-compaction: prune old history if approaching context window limit
@@ -397,14 +471,15 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
397
471
  const langchainMessages: Array<HumanMessage | AIMessage> = []
398
472
  for (const m of effectiveHistory.slice(-20)) {
399
473
  if (m.role === 'user') {
400
- langchainMessages.push(new HumanMessage({ content: buildLangChainContent(m.text, m.imagePath) }))
474
+ langchainMessages.push(new HumanMessage({ content: await buildLangChainContent(m.text, m.imagePath, m.attachedFiles) }))
401
475
  } else {
402
476
  langchainMessages.push(new AIMessage({ content: m.text }))
403
477
  }
404
478
  }
405
479
 
406
480
  // Add current message
407
- langchainMessages.push(new HumanMessage({ content: buildLangChainContent(message, imagePath) }))
481
+ const currentContent = await buildLangChainContent(message, imagePath, attachedFiles)
482
+ langchainMessages.push(new HumanMessage({ content: currentContent }))
408
483
 
409
484
  let fullText = ''
410
485
  let lastSegment = ''
@@ -557,6 +632,15 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
557
632
  // Plugin hooks: afterAgentComplete
558
633
  await pluginMgr.runHook('afterAgentComplete', { session, response: fullText })
559
634
 
635
+ // OpenClaw auto-sync: push memory if enabled
636
+ try {
637
+ const { loadSyncConfig, pushMemoryToOpenClaw } = await import('./openclaw-sync')
638
+ const syncConfig = loadSyncConfig()
639
+ if (syncConfig.autoSyncMemory) {
640
+ pushMemoryToOpenClaw(session.agentId || undefined)
641
+ }
642
+ } catch { /* OpenClaw sync not available — ignore */ }
643
+
560
644
  // Clean up browser and other session resources
561
645
  await cleanup()
562
646
 
@@ -0,0 +1,44 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { extractTaskResult } from './task-result'
4
+
5
+ describe('extractTaskResult', () => {
6
+ it('limits artifact extraction to messages from the current run window', () => {
7
+ const session = {
8
+ messages: [
9
+ {
10
+ role: 'assistant',
11
+ time: 1_000,
12
+ text: 'old run artifact: /api/uploads/wiki-old.png',
13
+ },
14
+ {
15
+ role: 'assistant',
16
+ time: 2_000,
17
+ text: 'new run artifact: /api/uploads/wiki-new.png',
18
+ },
19
+ ],
20
+ }
21
+
22
+ const result = extractTaskResult(session, 'done', { sinceTime: 1_500 })
23
+ assert.deepEqual(result.artifacts.map((a) => a.url), ['/api/uploads/wiki-new.png'])
24
+ })
25
+
26
+ it('excludes messages without timestamps when sinceTime is provided', () => {
27
+ const session = {
28
+ messages: [
29
+ {
30
+ role: 'assistant',
31
+ text: 'undated artifact: /api/uploads/undated.png',
32
+ },
33
+ {
34
+ role: 'assistant',
35
+ time: 5_000,
36
+ text: 'dated artifact: /api/uploads/dated.png',
37
+ },
38
+ ],
39
+ }
40
+
41
+ const result = extractTaskResult(session, 'done', { sinceTime: 4_000 })
42
+ assert.deepEqual(result.artifacts.map((a) => a.url), ['/api/uploads/dated.png'])
43
+ })
44
+ })
@@ -43,6 +43,7 @@ function classifyArtifact(filename: string): Artifact['type'] {
43
43
  interface MessageLike {
44
44
  role?: string
45
45
  text?: string
46
+ time?: number
46
47
  imageUrl?: string
47
48
  imagePath?: string
48
49
  toolEvents?: Array<{ name?: string; output?: string }>
@@ -52,6 +53,10 @@ interface SessionLike {
52
53
  messages?: MessageLike[]
53
54
  }
54
55
 
56
+ interface ExtractTaskResultOptions {
57
+ sinceTime?: number | null
58
+ }
59
+
55
60
  // ---------------------------------------------------------------------------
56
61
  // Core extraction
57
62
  // ---------------------------------------------------------------------------
@@ -64,9 +69,13 @@ interface SessionLike {
64
69
  export function extractTaskResult(
65
70
  session: SessionLike | null | undefined,
66
71
  rawResultText: string | null | undefined,
72
+ options?: ExtractTaskResultOptions,
67
73
  ): TaskResult {
68
74
  const seen = new Set<string>()
69
75
  const artifacts: Artifact[] = []
76
+ const sinceTime = typeof options?.sinceTime === 'number' && Number.isFinite(options.sinceTime)
77
+ ? options.sinceTime
78
+ : null
70
79
 
71
80
  function addUrl(raw: string) {
72
81
  const url = stripSandbox(raw)
@@ -79,6 +88,11 @@ export function extractTaskResult(
79
88
  // Walk session messages to collect all artifact URLs
80
89
  if (Array.isArray(session?.messages)) {
81
90
  for (const msg of session.messages) {
91
+ if (sinceTime !== null) {
92
+ const msgTime = typeof msg.time === 'number' && Number.isFinite(msg.time) ? msg.time : null
93
+ if (msgTime === null || msgTime < sinceTime) continue
94
+ }
95
+
82
96
  // Explicit image fields
83
97
  if (msg.imageUrl) addUrl(msg.imageUrl)
84
98
  if (msg.imagePath) {
@@ -25,3 +25,26 @@ test('validateTaskCompletion accepts screenshot delivery tasks with upload artif
25
25
 
26
26
  assert.equal(validation.ok, true)
27
27
  })
28
+
29
+ test('validateTaskCompletion accepts concise non-implementation result summaries', () => {
30
+ const validation = validateTaskCompletion({
31
+ title: 'Answer greeting',
32
+ description: 'Respond to a basic hello prompt.',
33
+ result: 'Hello! How can I help you today?',
34
+ error: null,
35
+ } as Partial<BoardTask>)
36
+
37
+ assert.equal(validation.ok, true)
38
+ })
39
+
40
+ test('validateTaskCompletion still enforces stricter minimum for implementation tasks', () => {
41
+ const validation = validateTaskCompletion({
42
+ title: 'Fix retry bug',
43
+ description: 'Implement queue retry fixes and verify.',
44
+ result: 'Patched queue retry bug.',
45
+ error: null,
46
+ } as Partial<BoardTask>)
47
+
48
+ assert.equal(validation.ok, false)
49
+ assert.ok(validation.reasons.some((reason) => reason.includes('Result summary is too short')))
50
+ })
@@ -11,7 +11,8 @@ interface TaskCompletionValidationOptions {
11
11
  report?: TaskReportArtifact | null
12
12
  }
13
13
 
14
- const MIN_RESULT_CHARS = 40
14
+ const MIN_RESULT_CHARS_IMPLEMENTATION = 40
15
+ const MIN_RESULT_CHARS_GENERIC = 20
15
16
 
16
17
  const WEAK_RESULT_PATTERNS: RegExp[] = [
17
18
  /what can i help you with/i,
@@ -45,12 +46,14 @@ export function validateTaskCompletion(
45
46
  const result = normalizeText(task.result)
46
47
  const error = normalizeText(task.error)
47
48
  const report = options.report || null
49
+ const implementationTask = IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description)
48
50
 
49
51
  if (error) reasons.push('Task has a non-empty error field.')
50
52
 
51
53
  if (!result) reasons.push('Result summary is empty.')
52
54
  else {
53
- if (result.length < MIN_RESULT_CHARS) reasons.push(`Result summary is too short (${result.length} chars).`)
55
+ const minChars = implementationTask ? MIN_RESULT_CHARS_IMPLEMENTATION : MIN_RESULT_CHARS_GENERIC
56
+ if (result.length < minChars) reasons.push(`Result summary is too short (${result.length} chars; min ${minChars}).`)
54
57
  if (WEAK_RESULT_PATTERNS.some((rx) => rx.test(result))) {
55
58
  reasons.push('Result contains placeholder/planning language instead of completion evidence.')
56
59
  }
@@ -58,7 +61,6 @@ export function validateTaskCompletion(
58
61
 
59
62
  // If task description/title suggests implementation work, require concrete evidence in
60
63
  // the result summary OR task report.
61
- const implementationTask = IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description)
62
64
  const hasResultEvidence = EXECUTION_EVIDENCE.test(result)
63
65
  const hasReportEvidence = report?.evidence.hasEvidence === true
64
66
  if (implementationTask && !hasResultEvidence && !hasReportEvidence) {