@swarmclawai/swarmclaw 0.5.3 → 0.6.2

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 (224) hide show
  1. package/README.md +53 -9
  2. package/bin/server-cmd.js +1 -0
  3. package/bin/swarmclaw.js +76 -16
  4. package/next.config.ts +11 -1
  5. package/package.json +5 -2
  6. package/scripts/postinstall.mjs +18 -0
  7. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  8. package/src/app/api/chatrooms/[id]/chat/route.ts +284 -0
  9. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  10. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  11. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  12. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  13. package/src/app/api/chatrooms/route.ts +50 -0
  14. package/src/app/api/connectors/[id]/route.ts +1 -0
  15. package/src/app/api/connectors/route.ts +2 -1
  16. package/src/app/api/credentials/route.ts +2 -3
  17. package/src/app/api/files/open/route.ts +43 -0
  18. package/src/app/api/knowledge/[id]/route.ts +13 -2
  19. package/src/app/api/knowledge/route.ts +8 -1
  20. package/src/app/api/memory/route.ts +8 -0
  21. package/src/app/api/notifications/route.ts +4 -0
  22. package/src/app/api/orchestrator/run/route.ts +1 -1
  23. package/src/app/api/plugins/install/route.ts +2 -2
  24. package/src/app/api/search/route.ts +53 -1
  25. package/src/app/api/sessions/[id]/chat/route.ts +2 -0
  26. package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
  27. package/src/app/api/sessions/[id]/fork/route.ts +1 -1
  28. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  29. package/src/app/api/sessions/[id]/route.ts +4 -0
  30. package/src/app/api/sessions/route.ts +3 -3
  31. package/src/app/api/settings/route.ts +9 -0
  32. package/src/app/api/setup/check-provider/route.ts +3 -16
  33. package/src/app/api/skills/[id]/route.ts +6 -0
  34. package/src/app/api/skills/route.ts +6 -0
  35. package/src/app/api/tasks/[id]/route.ts +12 -0
  36. package/src/app/api/tasks/bulk/route.ts +100 -0
  37. package/src/app/api/tasks/metrics/route.ts +101 -0
  38. package/src/app/api/tasks/route.ts +18 -2
  39. package/src/app/api/tts/route.ts +3 -2
  40. package/src/app/api/tts/stream/route.ts +3 -2
  41. package/src/app/api/uploads/[filename]/route.ts +19 -34
  42. package/src/app/api/uploads/route.ts +94 -0
  43. package/src/app/api/webhooks/[id]/route.ts +15 -1
  44. package/src/app/globals.css +63 -15
  45. package/src/app/page.tsx +142 -13
  46. package/src/cli/index.js +40 -1
  47. package/src/cli/index.test.js +30 -0
  48. package/src/cli/spec.js +42 -0
  49. package/src/components/agents/agent-avatar.tsx +57 -10
  50. package/src/components/agents/agent-card.tsx +50 -17
  51. package/src/components/agents/agent-chat-list.tsx +148 -12
  52. package/src/components/agents/agent-list.tsx +50 -19
  53. package/src/components/agents/agent-sheet.tsx +120 -65
  54. package/src/components/agents/inspector-panel.tsx +81 -6
  55. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  56. package/src/components/agents/personality-builder.tsx +42 -14
  57. package/src/components/agents/soul-library-picker.tsx +89 -0
  58. package/src/components/auth/access-key-gate.tsx +10 -3
  59. package/src/components/auth/setup-wizard.tsx +2 -2
  60. package/src/components/auth/user-picker.tsx +31 -3
  61. package/src/components/canvas/canvas-panel.tsx +96 -0
  62. package/src/components/chat/activity-moment.tsx +173 -0
  63. package/src/components/chat/chat-area.tsx +46 -22
  64. package/src/components/chat/chat-header.tsx +457 -286
  65. package/src/components/chat/chat-preview-panel.tsx +1 -2
  66. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  67. package/src/components/chat/delegation-banner.tsx +371 -0
  68. package/src/components/chat/file-path-chip.tsx +146 -0
  69. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  70. package/src/components/chat/markdown-utils.ts +9 -0
  71. package/src/components/chat/message-bubble.tsx +356 -315
  72. package/src/components/chat/message-list.tsx +230 -8
  73. package/src/components/chat/streaming-bubble.tsx +104 -47
  74. package/src/components/chat/suggestions-bar.tsx +1 -1
  75. package/src/components/chat/thinking-indicator.tsx +72 -10
  76. package/src/components/chat/tool-call-bubble.tsx +111 -73
  77. package/src/components/chat/tool-request-banner.tsx +31 -7
  78. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  79. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  80. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  81. package/src/components/chatrooms/chatroom-list.tsx +130 -0
  82. package/src/components/chatrooms/chatroom-message.tsx +432 -0
  83. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  84. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  85. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  86. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  87. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  88. package/src/components/connectors/connector-list.tsx +168 -90
  89. package/src/components/connectors/connector-sheet.tsx +95 -56
  90. package/src/components/home/home-view.tsx +501 -0
  91. package/src/components/input/chat-input.tsx +107 -43
  92. package/src/components/knowledge/knowledge-list.tsx +31 -1
  93. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  94. package/src/components/layout/app-layout.tsx +194 -97
  95. package/src/components/layout/update-banner.tsx +2 -2
  96. package/src/components/logs/log-list.tsx +2 -2
  97. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  98. package/src/components/memory/memory-agent-list.tsx +143 -0
  99. package/src/components/memory/memory-browser.tsx +205 -0
  100. package/src/components/memory/memory-card.tsx +34 -7
  101. package/src/components/memory/memory-detail.tsx +359 -120
  102. package/src/components/memory/memory-sheet.tsx +157 -23
  103. package/src/components/plugins/plugin-list.tsx +1 -1
  104. package/src/components/plugins/plugin-sheet.tsx +1 -1
  105. package/src/components/projects/project-detail.tsx +509 -0
  106. package/src/components/projects/project-list.tsx +195 -59
  107. package/src/components/providers/provider-list.tsx +2 -2
  108. package/src/components/providers/provider-sheet.tsx +3 -3
  109. package/src/components/schedules/schedule-card.tsx +1 -1
  110. package/src/components/schedules/schedule-list.tsx +1 -1
  111. package/src/components/schedules/schedule-sheet.tsx +259 -126
  112. package/src/components/secrets/secret-sheet.tsx +47 -24
  113. package/src/components/secrets/secrets-list.tsx +18 -8
  114. package/src/components/sessions/new-session-sheet.tsx +33 -65
  115. package/src/components/sessions/session-card.tsx +45 -14
  116. package/src/components/sessions/session-list.tsx +35 -18
  117. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  118. package/src/components/shared/agent-picker-list.tsx +90 -0
  119. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  120. package/src/components/shared/attachment-chip.tsx +165 -0
  121. package/src/components/shared/avatar.tsx +10 -1
  122. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  123. package/src/components/shared/check-icon.tsx +12 -0
  124. package/src/components/shared/confirm-dialog.tsx +1 -1
  125. package/src/components/shared/connector-platform-icon.tsx +51 -4
  126. package/src/components/shared/empty-state.tsx +32 -0
  127. package/src/components/shared/file-preview.tsx +34 -0
  128. package/src/components/shared/form-styles.ts +2 -0
  129. package/src/components/shared/icon-button.tsx +16 -2
  130. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  131. package/src/components/shared/notification-center.tsx +44 -6
  132. package/src/components/shared/profile-sheet.tsx +115 -0
  133. package/src/components/shared/reply-quote.tsx +26 -0
  134. package/src/components/shared/search-dialog.tsx +31 -15
  135. package/src/components/shared/section-label.tsx +12 -0
  136. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  137. package/src/components/shared/settings/section-embedding.tsx +48 -13
  138. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  139. package/src/components/shared/settings/section-providers.tsx +1 -1
  140. package/src/components/shared/settings/section-secrets.tsx +1 -1
  141. package/src/components/shared/settings/section-storage.tsx +206 -0
  142. package/src/components/shared/settings/section-theme.tsx +95 -0
  143. package/src/components/shared/settings/section-user-preferences.tsx +57 -0
  144. package/src/components/shared/settings/section-voice.tsx +42 -21
  145. package/src/components/shared/settings/section-web-search.tsx +30 -6
  146. package/src/components/shared/settings/settings-page.tsx +182 -27
  147. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  148. package/src/components/shared/settings/storage-browser.tsx +259 -0
  149. package/src/components/shared/sheet-footer.tsx +33 -0
  150. package/src/components/skills/skill-list.tsx +61 -30
  151. package/src/components/skills/skill-sheet.tsx +81 -2
  152. package/src/components/tasks/task-board.tsx +448 -26
  153. package/src/components/tasks/task-card.tsx +59 -9
  154. package/src/components/tasks/task-column.tsx +62 -3
  155. package/src/components/tasks/task-list.tsx +12 -4
  156. package/src/components/tasks/task-sheet.tsx +416 -74
  157. package/src/components/ui/hover-card.tsx +52 -0
  158. package/src/components/usage/metrics-dashboard.tsx +90 -6
  159. package/src/components/usage/usage-list.tsx +1 -1
  160. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  161. package/src/hooks/use-continuous-speech.ts +10 -4
  162. package/src/hooks/use-view-router.ts +69 -19
  163. package/src/hooks/use-voice-conversation.ts +53 -10
  164. package/src/hooks/use-ws.ts +4 -2
  165. package/src/instrumentation.ts +15 -1
  166. package/src/lib/chat.ts +2 -0
  167. package/src/lib/memory.ts +3 -0
  168. package/src/lib/providers/anthropic.ts +13 -7
  169. package/src/lib/providers/index.ts +1 -0
  170. package/src/lib/providers/openai.ts +13 -7
  171. package/src/lib/server/chat-execution.ts +75 -15
  172. package/src/lib/server/chatroom-helpers.ts +146 -0
  173. package/src/lib/server/connectors/manager.ts +229 -7
  174. package/src/lib/server/context-manager.ts +225 -13
  175. package/src/lib/server/create-notification.ts +14 -2
  176. package/src/lib/server/daemon-state.ts +157 -10
  177. package/src/lib/server/execution-log.ts +1 -0
  178. package/src/lib/server/heartbeat-service.ts +48 -6
  179. package/src/lib/server/heartbeat-wake.ts +110 -0
  180. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  181. package/src/lib/server/main-agent-loop.ts +1 -1
  182. package/src/lib/server/memory-consolidation.ts +105 -0
  183. package/src/lib/server/memory-db.ts +183 -10
  184. package/src/lib/server/mime.ts +51 -0
  185. package/src/lib/server/openclaw-gateway.ts +9 -1
  186. package/src/lib/server/orchestrator-lg.ts +2 -0
  187. package/src/lib/server/orchestrator.ts +5 -2
  188. package/src/lib/server/playwright-proxy.mjs +2 -3
  189. package/src/lib/server/prompt-runtime-context.ts +53 -0
  190. package/src/lib/server/provider-health.ts +125 -0
  191. package/src/lib/server/queue.ts +56 -10
  192. package/src/lib/server/scheduler.ts +8 -0
  193. package/src/lib/server/session-run-manager.ts +4 -0
  194. package/src/lib/server/session-tools/canvas.ts +67 -0
  195. package/src/lib/server/session-tools/chatroom.ts +136 -0
  196. package/src/lib/server/session-tools/connector.ts +83 -9
  197. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  198. package/src/lib/server/session-tools/crud.ts +21 -0
  199. package/src/lib/server/session-tools/delegate.ts +68 -4
  200. package/src/lib/server/session-tools/git.ts +71 -0
  201. package/src/lib/server/session-tools/http.ts +57 -0
  202. package/src/lib/server/session-tools/index.ts +10 -0
  203. package/src/lib/server/session-tools/memory.ts +7 -1
  204. package/src/lib/server/session-tools/search-providers.ts +16 -8
  205. package/src/lib/server/session-tools/subagent.ts +106 -0
  206. package/src/lib/server/session-tools/web.ts +115 -4
  207. package/src/lib/server/storage.ts +53 -29
  208. package/src/lib/server/stream-agent-chat.ts +185 -57
  209. package/src/lib/server/system-events.ts +49 -0
  210. package/src/lib/server/task-mention.ts +41 -0
  211. package/src/lib/server/ws-hub.ts +11 -0
  212. package/src/lib/sessions.ts +10 -0
  213. package/src/lib/soul-library.ts +103 -0
  214. package/src/lib/soul-suggestions.ts +109 -0
  215. package/src/lib/task-dedupe.ts +26 -0
  216. package/src/lib/tasks.ts +4 -1
  217. package/src/lib/tool-definitions.ts +2 -0
  218. package/src/lib/tts.ts +2 -2
  219. package/src/lib/view-routes.ts +36 -1
  220. package/src/lib/ws-client.ts +14 -4
  221. package/src/stores/use-app-store.ts +41 -3
  222. package/src/stores/use-chat-store.ts +113 -5
  223. package/src/stores/use-chatroom-store.ts +276 -0
  224. package/src/types/index.ts +88 -4
@@ -9,6 +9,58 @@ import { spawnSync } from 'child_process'
9
9
  import { safePath, truncate, MAX_OUTPUT, findBinaryOnPath } from './context'
10
10
  import { getSearchProvider } from './search-providers'
11
11
 
12
+ // ---------------------------------------------------------------------------
13
+ // Search result compression — summarize verbose results before injecting into context
14
+ // ---------------------------------------------------------------------------
15
+
16
+ async function compressSearchResults(
17
+ results: Array<{ title?: string; url?: string; snippet?: string }>,
18
+ query: string,
19
+ bctx: ToolBuildContext,
20
+ ): Promise<string | null> {
21
+ const session = bctx.resolveCurrentSession?.()
22
+ if (!session?.provider || !session?.model) return null
23
+
24
+ const { getProvider } = await import('@/lib/providers')
25
+ const { loadCredentials, decryptKey } = await import('../storage')
26
+ const providerEntry = getProvider(session.provider)
27
+ if (!providerEntry?.handler?.streamChat) return null
28
+
29
+ // Resolve API key
30
+ let apiKey: string | undefined
31
+ if (session.credentialId) {
32
+ const creds = loadCredentials()
33
+ const cred = creds[session.credentialId]
34
+ if (cred) apiKey = decryptKey(cred)
35
+ }
36
+
37
+ const systemPrompt = 'You are a search result summarizer. Condense search results into a concise reference. Keep key facts, URLs, and data points. Remove filler and redundancy. Output plain text, not JSON.'
38
+ const message = `Query: "${query}"\n\nResults:\n${JSON.stringify(results, null, 1)}\n\nSummarize these results concisely.`
39
+
40
+ let compressed = ''
41
+ await providerEntry.handler.streamChat({
42
+ session: { ...session, messages: [] },
43
+ message,
44
+ apiKey,
45
+ systemPrompt,
46
+ write: (raw: string) => {
47
+ // Extract text data from SSE lines
48
+ const lines = raw.split('\n').filter(Boolean)
49
+ for (const line of lines) {
50
+ if (!line.startsWith('data: ')) continue
51
+ try {
52
+ const ev = JSON.parse(line.slice(6))
53
+ if (ev.t === 'd' && ev.text) compressed += ev.text
54
+ } catch { /* skip */ }
55
+ }
56
+ },
57
+ active: new Map(),
58
+ loadHistory: () => [],
59
+ })
60
+
61
+ return compressed.trim() || null
62
+ }
63
+
12
64
  // ---------------------------------------------------------------------------
13
65
  // Global registry of active browser instances for cleanup sweeps
14
66
  // ---------------------------------------------------------------------------
@@ -70,9 +122,18 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
70
122
  const settings = loadSettings()
71
123
  const provider = await getSearchProvider(settings)
72
124
  const results = await provider.search(query, limit)
73
- return results.length > 0
74
- ? JSON.stringify(results, null, 2)
75
- : 'No results found.'
125
+ if (results.length === 0) return 'No results found.'
126
+ const raw = JSON.stringify(results, null, 2)
127
+ // Compress search results if they exceed 2000 chars
128
+ if (raw.length > 2000) {
129
+ try {
130
+ const compressed = await compressSearchResults(results, query, bctx)
131
+ if (compressed) return compressed
132
+ } catch {
133
+ // Compression failed — fall through to raw results
134
+ }
135
+ }
136
+ return raw
76
137
  } catch (err: unknown) {
77
138
  return `Error searching web: ${err instanceof Error ? err.message : String(err)}`
78
139
  }
@@ -284,6 +345,49 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
284
345
  return JSON.stringify(result)
285
346
  }
286
347
 
348
+ // Best-effort cookie/consent banner dismissal after navigation
349
+ const dismissCookieBanners = async (
350
+ mcpCall: (toolName: string, args: Record<string, unknown>) => Promise<string>,
351
+ ) => {
352
+ // Wait briefly for consent overlays to appear
353
+ await new Promise((r) => setTimeout(r, 1500))
354
+ const js = `
355
+ (() => {
356
+ const sel = [
357
+ // Common "Reject" / "Reject all" / "Decline" buttons
358
+ 'button[id*="reject" i]', 'button[class*="reject" i]',
359
+ 'a[id*="reject" i]', 'a[class*="reject" i]',
360
+ '[data-testid*="reject" i]', '[data-action="reject"]',
361
+ // OneTrust
362
+ '#onetrust-reject-all-handler',
363
+ // Cookiebot
364
+ '#CybotCookiebotDialogBodyButtonDecline',
365
+ // Didomi
366
+ '#didomi-notice-disagree-button',
367
+ // Quantcast / IAB TCF
368
+ '.qc-cmp2-summary-buttons button:first-child',
369
+ 'button.sp_choice_type_12',
370
+ // Generic patterns
371
+ 'button[aria-label*="reject" i]', 'button[aria-label*="decline" i]',
372
+ 'button[aria-label*="deny" i]', 'button[aria-label*="refuse" i]',
373
+ ];
374
+ for (const s of sel) {
375
+ const el = document.querySelector(s);
376
+ if (el && el.offsetParent !== null) { el.click(); return 'dismissed:' + s; }
377
+ }
378
+ // Fallback: find buttons by visible text
379
+ const btns = [...document.querySelectorAll('button, a[role="button"], [class*="cookie"] button, [class*="consent"] button, [id*="cookie"] button')];
380
+ const rejectRe = /^(reject|reject all|decline|deny|refuse|no,? thanks|only necessary|necessary only)$/i;
381
+ for (const b of btns) {
382
+ const txt = (b.textContent || '').trim();
383
+ if (rejectRe.test(txt) && b.offsetParent !== null) { b.click(); return 'dismissed:text=' + txt; }
384
+ }
385
+ return 'none';
386
+ })()
387
+ `
388
+ await mcpCall('browser_evaluate', { expression: js })
389
+ }
390
+
287
391
  // Action-to-MCP tool mapping
288
392
  const MCP_TOOL_MAP: Record<string, string> = {
289
393
  navigate: 'browser_navigate',
@@ -315,7 +419,14 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
315
419
  const saveTo = typeof params.saveTo === 'string' && params.saveTo.trim()
316
420
  ? params.saveTo.trim()
317
421
  : undefined
318
- return await callMcpTool(mcpTool, args, { saveTo })
422
+ const result = await callMcpTool(mcpTool, args, { saveTo })
423
+
424
+ // After navigation, attempt to dismiss cookie consent banners
425
+ if (action === 'navigate') {
426
+ try { await dismissCookieBanners(callMcpTool) } catch { /* best-effort */ }
427
+ }
428
+
429
+ return result
319
430
  } catch (err: unknown) {
320
431
  return `Error: ${err instanceof Error ? err.message : String(err)}`
321
432
  }
@@ -20,6 +20,7 @@ const DB_PATH = IS_BUILD_BOOTSTRAP ? ':memory:' : path.join(DATA_DIR, 'swarmclaw
20
20
  const db = new Database(DB_PATH)
21
21
  if (!IS_BUILD_BOOTSTRAP) {
22
22
  db.pragma('journal_mode = WAL')
23
+ db.pragma('busy_timeout = 5000')
23
24
  }
24
25
  db.pragma('foreign_keys = ON')
25
26
 
@@ -52,6 +53,7 @@ const COLLECTIONS = [
52
53
  'activity',
53
54
  'webhook_retry_queue',
54
55
  'notifications',
56
+ 'chatrooms',
55
57
  ] as const
56
58
 
57
59
  for (const table of COLLECTIONS) {
@@ -284,35 +286,37 @@ if (!IS_BUILD_BOOTSTRAP) {
284
286
  description: 'A general-purpose AI assistant',
285
287
  provider: 'claude-cli',
286
288
  model: '',
287
- systemPrompt: `You are the default SwarmClaw assistant. SwarmClaw is a self-hosted AI agent orchestration dashboard.
288
-
289
- Help users get started with the platform:
290
- - **Agents**: Create specialized AI agents (Agents tab → "+"). Each agent has a provider, model, system prompt, and optional tools (shell, files, web search, browser). Use "Generate with AI" to scaffold agents from a description.
291
- - **Orchestrators**: Toggle "Orchestrator" when creating an agent to let it delegate tasks to other agents. Orchestrators coordinate multi-agent workflows automatically.
292
- - **Providers**: Configure LLM backends in Settings → Providers. Built-in providers: Claude Code CLI, OpenAI Codex CLI, OpenCode CLI, Anthropic, OpenAI, Google Gemini, DeepSeek, Groq, Together AI, Mistral AI, xAI (Grok), Fireworks AI, Ollama (local or cloud), and OpenClaw. You can also add custom OpenAI-compatible endpoints.
293
- - **Tasks**: Use the Task Board to create, assign, and track work items. Agents can be assigned to tasks and will execute them autonomously.
294
- - **Schedules**: Set up cron-based schedules to run agents or tasks on a recurring basis (Schedules tab).
295
- - **Skills**: Create reusable skill files (markdown instructions) in the Skills tab and attach them to agents to specialize their behavior.
296
- - **Connectors**: Bridge agents to Discord, Slack, Telegram, or WhatsApp so they can respond in chat platforms.
297
- - **Secrets**: Store API keys securely in the encrypted vault (Settings → Secrets).
298
-
299
- ## Platform Tools
300
-
301
- You have access to platform management tools. Here's how to use them:
302
-
303
- - **manage_agents**: List, create, update, or delete agents. Use action "list" to see all agents, "create" with a JSON data payload to add new ones.
304
- - **manage_tasks**: Create and manage task board items. Set "agentId" to assign a task to an agent, "status" to track progress (backlog → queued → running → completed/failed). Use action "create" with data like \`{"title": "...", "description": "...", "agentId": "...", "status": "backlog"}\`.
305
- - **manage_schedules**: Create recurring or one-time scheduled jobs. Set "scheduleType" to "cron", "interval", or "once". Provide "taskPrompt" for what the agent should do and "agentId" for who runs it.
306
- - **manage_skills**: List, create, or update reusable skill definitions that can be attached to agents.
307
- - **manage_documents**: Upload/index/search long-lived docs (PDFs, markdown, notes) for retrieval.
308
- - **manage_webhooks**: Register external webhook endpoints that trigger agent runs.
309
- - **manage_connectors**: Manage chat platform bridges (Discord, Slack, Telegram, WhatsApp).
310
- - **manage_sessions**: Session-level operations. Use \`sessions_tool\` to list sessions, send inter-session messages, spawn new agent sessions, and inspect status/history.
311
- - **manage_secrets**: Store and retrieve encrypted service tokens/API credentials for durable reuse.
312
- - **memory_tool**: Store and retrieve long-term memories. Use "store" to save knowledge, "search" to find relevant memories.
313
-
314
- Be concise and helpful. When users ask how to do something, guide them to the specific UI location and explain the steps.`,
315
- soul: '',
289
+ systemPrompt: `You are the SwarmClaw assistant. SwarmClaw is a self-hosted AI agent orchestration dashboard.
290
+
291
+ ## Platform
292
+
293
+ - **Agents** Create specialized AI agents (Agents tab → "+") with a provider, model, system prompt, and tools. "Generate with AI" scaffolds agents from a description. Toggle "Orchestrator" to let an agent delegate work to others.
294
+ - **Providers** Configure LLM backends in Settings → Providers: Claude Code CLI, OpenAI Codex CLI, OpenCode CLI, Anthropic, OpenAI, Google Gemini, DeepSeek, Groq, Together AI, Mistral AI, xAI (Grok), Fireworks AI, Ollama, OpenClaw, or custom OpenAI-compatible endpoints.
295
+ - **Tasks** The Task Board tracks work items. Assign agents and they'll execute autonomously.
296
+ - **Schedules** Cron-based recurring jobs that run agents or tasks automatically.
297
+ - **Skills** Reusable markdown instruction files you attach to agents to specialize them.
298
+ - **Connectors** Bridge agents to Discord, Slack, Telegram, or WhatsApp.
299
+ - **Secrets** Encrypted vault for API keys (Settings → Secrets).
300
+
301
+ ## Tools
302
+
303
+ Use your platform management tools proactively:
304
+
305
+ - **manage_agents**: List, create, update, or delete agents.
306
+ - **manage_tasks**: Create and manage task board items. Set status (backlog → queued → running → completed/failed) and assign agents.
307
+ - **manage_schedules**: Create recurring or one-time scheduled jobs with cron expressions or intervals.
308
+ - **manage_skills**: Manage reusable skill definitions.
309
+ - **manage_documents**: Upload, index, and search long-lived documents.
310
+ - **manage_webhooks**: Register webhook endpoints that trigger agent runs.
311
+ - **manage_connectors**: Manage chat platform bridges.
312
+ - **manage_sessions**: List chats, send inter-chat messages, spawn new agent chats.
313
+ - **manage_secrets**: Store and retrieve encrypted credentials.
314
+ - **memory_tool**: Store and retrieve long-term knowledge.`,
315
+ soul: `You're a knowledgeable, friendly guide who's genuinely enthusiastic about helping people build agent workflows. You adapt your tone to match the conversation — casual when exploring, precise when debugging, encouraging when learning.
316
+
317
+ You have opinions about good agent design. You suggest creative approaches, warn about common pitfalls, and get excited when someone gets something cool working. You're not a manual — you're a collaborator.
318
+
319
+ Be concise but not curt. Warmth doesn't require verbosity. When someone asks "how do I...?", give them the direct steps. Offer to do things rather than just explaining — if someone wants an agent created, create it. Use your tools when actions speak louder than words. If you don't know something, say so honestly.`,
316
320
  isOrchestrator: false,
317
321
  tools: defaultStarterTools,
318
322
  heartbeatEnabled: true,
@@ -691,6 +695,15 @@ export function saveConnectors(c: Record<string, any>) {
691
695
  saveCollection('connectors', c)
692
696
  }
693
697
 
698
+ // --- Chatrooms ---
699
+ export function loadChatrooms(): Record<string, any> {
700
+ return loadCollection('chatrooms')
701
+ }
702
+
703
+ export function saveChatrooms(c: Record<string, any>) {
704
+ saveCollection('chatrooms', c)
705
+ }
706
+
694
707
  // --- Documents ---
695
708
  export function loadDocuments(): Record<string, any> {
696
709
  return loadCollection('documents')
@@ -789,6 +802,17 @@ export function deleteNotification(id: string) {
789
802
  deleteCollectionItem('notifications', id)
790
803
  }
791
804
 
805
+ export function hasUnreadNotificationWithKey(dedupKey: string): boolean {
806
+ const raw = getCollectionRawCache('notifications')
807
+ for (const json of raw.values()) {
808
+ try {
809
+ const n = JSON.parse(json) as Record<string, unknown>
810
+ if (n.dedupKey === dedupKey && n.read !== true) return true
811
+ } catch { /* skip malformed */ }
812
+ }
813
+ return false
814
+ }
815
+
792
816
  export function markNotificationRead(id: string) {
793
817
  const raw = getCollectionRawCache('notifications')
794
818
  const json = raw.get(id)
@@ -9,9 +9,24 @@ import { getPluginManager } from './plugins'
9
9
  import { loadRuntimeSettings, getAgentLoopRecursionLimit } from './runtime-settings'
10
10
  import { getMemoryDb } from './memory-db'
11
11
  import { logExecution } from './execution-log'
12
+ import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
12
13
  import type { Session, Message, UsageRecord } from '@/types'
13
14
  import { extractSuggestions } from './suggestions'
14
15
 
16
+ /** Extract a breadcrumb title from notable tool completions (task/schedule/agent creation). */
17
+ function extractBreadcrumbTitle(toolName: string, input: unknown, output: string | undefined): string | null {
18
+ if (!input || typeof input !== 'object') return null
19
+ const inp = input as Record<string, unknown>
20
+ const action = typeof inp.action === 'string' ? inp.action : ''
21
+ if (toolName === 'manage_tasks') {
22
+ if (action === 'create') return `Created task: ${inp.title || 'Untitled'}`
23
+ if (output && /status.*completed|completed.*successfully/i.test(output)) return `Completed task: ${inp.title || inp.taskId || 'unknown'}`
24
+ }
25
+ if (toolName === 'manage_schedules' && action === 'create') return `Created schedule: ${inp.name || 'Untitled'}`
26
+ if (toolName === 'manage_agents' && action === 'create') return `Created agent: ${inp.name || 'Untitled'}`
27
+ return null
28
+ }
29
+
15
30
  interface StreamAgentChatOpts {
16
31
  session: Session
17
32
  message: string
@@ -25,7 +40,7 @@ interface StreamAgentChatOpts {
25
40
  signal?: AbortSignal
26
41
  }
27
42
 
28
- function buildToolCapabilityLines(enabledTools: string[]): string[] {
43
+ function buildToolCapabilityLines(enabledTools: string[], opts?: { platformAssignScope?: 'self' | 'all' }): string[] {
29
44
  const lines: string[] = []
30
45
  if (enabledTools.includes('shell')) lines.push('- Shell execution is available (`execute_command`). Use it for running servers, installing deps, running scripts, git commands, build/test steps, and any single or chained shell commands. Supports background mode for long-running processes like dev servers.')
31
46
  if (enabledTools.includes('process')) lines.push('- Process control is available (`process_tool`) for long-running commands (poll/log/write/kill).')
@@ -52,9 +67,12 @@ function buildToolCapabilityLines(enabledTools: string[]): string[] {
52
67
  // Context tools are available to any session with tools (not just manage_sessions)
53
68
  if (enabledTools.length > 0) {
54
69
  lines.push('- Context management is available (`context_status`, `context_summarize`). Use `context_status` to check token usage and `context_summarize` to compact conversation history when approaching limits.')
55
- lines.push('- Agent delegation is available (`delegate_to_agent`). Use it to assign tasks to other agents based on their capabilities.')
70
+ if (opts?.platformAssignScope === 'all') {
71
+ lines.push('- Agent delegation is available (`delegate_to_agent`). Use it to assign tasks to other agents based on their capabilities.')
72
+ }
56
73
  }
57
74
  if (enabledTools.includes('manage_secrets')) lines.push('- Secret management is available (`manage_secrets`) for durable encrypted credentials and API tokens.')
75
+ if (enabledTools.includes('manage_chatrooms')) lines.push('- Chatroom management is available (`manage_chatrooms`) for multi-agent collaborative chatrooms with @mention-based interactions.')
58
76
  return lines
59
77
  }
60
78
 
@@ -63,9 +81,10 @@ function buildAgenticExecutionPolicy(opts: {
63
81
  loopMode: 'bounded' | 'ongoing'
64
82
  heartbeatPrompt: string
65
83
  heartbeatIntervalSec: number
84
+ platformAssignScope?: 'self' | 'all'
66
85
  }) {
67
86
  const hasTooling = opts.enabledTools.length > 0
68
- const toolLines = buildToolCapabilityLines(opts.enabledTools)
87
+ const toolLines = buildToolCapabilityLines(opts.enabledTools, { platformAssignScope: opts.platformAssignScope })
69
88
  const delegationOrder = [
70
89
  opts.enabledTools.includes('claude_code') ? '`delegate_to_claude_code`' : null,
71
90
  opts.enabledTools.includes('codex_cli') ? '`delegate_to_codex_cli`' : null,
@@ -209,6 +228,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
209
228
  stateModifierParts.push(systemPrompt!.trim())
210
229
  } else {
211
230
  if (settings.userPrompt) stateModifierParts.push(settings.userPrompt)
231
+ stateModifierParts.push(buildCurrentDateTimePromptContext())
212
232
  }
213
233
 
214
234
  // Load agent context when a full prompt was not already composed by the route layer.
@@ -260,57 +280,92 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
260
280
  .map((h) => h.text),
261
281
  ].join('\n')
262
282
 
263
- const relevantLookup = memDb.searchWithLinked(memoryQuerySeed, session.agentId, 1, 10, 14)
264
- const relevant = relevantLookup.entries.slice(0, 6)
265
- const recent = memDb.list(session.agentId, 12).slice(0, 6)
266
-
267
283
  const seen = new Set<string>()
268
- const formatMemoryLine = (m: any) => {
284
+ const formatMemoryLine = (m: { category?: string; title?: string; content?: string; pinned?: boolean }) => {
269
285
  const category = String(m.category || 'note')
270
286
  const title = String(m.title || 'Untitled').replace(/\s+/g, ' ').trim()
271
287
  const snippet = String(m.content || '').replace(/\s+/g, ' ').trim().slice(0, 220)
272
- return `- [${category}] ${title}: ${snippet}`
288
+ const pin = m.pinned ? ' [pinned]' : ''
289
+ return `- [${category}]${pin} ${title}: ${snippet}`
273
290
  }
274
291
 
292
+ // Pinned memories always appear first
293
+ const pinned = memDb.listPinned(session.agentId, 5)
294
+ const pinnedLines = pinned
295
+ .filter((m) => { if (!m?.id || seen.has(m.id)) return false; seen.add(m.id); return true })
296
+ .map(formatMemoryLine)
297
+
298
+ // Reduce relevant slice by pinned count to keep total context bounded
299
+ const relevantSlice = Math.max(2, 6 - pinnedLines.length)
300
+ const relevantLookup = memDb.searchWithLinked(memoryQuerySeed, session.agentId, 1, 10, 14)
301
+ const relevant = relevantLookup.entries.slice(0, relevantSlice)
302
+ const recent = memDb.list(session.agentId, 12).slice(0, 6)
303
+
275
304
  const relevantLines = relevant
276
- .filter((m) => {
277
- if (!m?.id || seen.has(m.id)) return false
278
- seen.add(m.id)
279
- return true
280
- })
305
+ .filter((m) => { if (!m?.id || seen.has(m.id)) return false; seen.add(m.id); return true })
281
306
  .map(formatMemoryLine)
282
307
 
283
308
  const recentLines = recent
284
- .filter((m) => {
285
- if (!m?.id || seen.has(m.id)) return false
286
- seen.add(m.id)
287
- return true
288
- })
309
+ .filter((m) => { if (!m?.id || seen.has(m.id)) return false; seen.add(m.id); return true })
289
310
  .map(formatMemoryLine)
290
311
 
291
312
  const memorySections: string[] = []
313
+ if (pinnedLines.length) {
314
+ memorySections.push(
315
+ ['## Pinned Memories', 'Always-loaded memories marked as important.', ...pinnedLines].join('\n'),
316
+ )
317
+ }
292
318
  if (relevantLines.length) {
293
319
  memorySections.push(
294
- [
295
- '## Relevant Memory Hits',
296
- 'These memories were retrieved by relevance for the current objective.',
297
- ...relevantLines,
298
- ].join('\n'),
320
+ ['## Relevant Memory Hits', 'These memories were retrieved by relevance for the current objective.', ...relevantLines].join('\n'),
299
321
  )
300
322
  }
301
323
  if (recentLines.length) {
302
324
  memorySections.push(
303
- [
304
- '## Recent Memory Notes',
305
- 'Recent durable notes that may still apply.',
306
- ...recentLines,
307
- ].join('\n'),
325
+ ['## Recent Memory Notes', 'Recent durable notes that may still apply.', ...recentLines].join('\n'),
308
326
  )
309
327
  }
310
328
 
311
329
  if (memorySections.length) {
312
330
  stateModifierParts.push(memorySections.join('\n\n'))
313
331
  }
332
+
333
+ // Memory Policy — always injected when memory tool is available
334
+ stateModifierParts.push([
335
+ '## Memory Policy',
336
+ 'You have long-term memory. Use it proactively — do not wait to be asked.',
337
+ '',
338
+ '**Store memories for:**',
339
+ '- User preferences, corrections, or explicit "remember this" requests',
340
+ '- Key decisions or outcomes from complex tasks',
341
+ '- Discovered facts about projects, codebases, or environments',
342
+ '- Errors encountered and their solutions',
343
+ '- Relationship context (who is who, team dynamics)',
344
+ '- Important configuration details or environment specifics',
345
+ '',
346
+ '**Do NOT store:**',
347
+ '- Trivial acknowledgments or small talk',
348
+ '- Temporary in-progress work (use category "working" for ephemeral notes)',
349
+ '- Information already in your system prompt',
350
+ '- Exact duplicates of memories you already have',
351
+ '',
352
+ '**Best practices:**',
353
+ '- Use descriptive titles ("User prefers dark mode" not "Note 1")',
354
+ '- Use categories: preference, fact, learning, project, identity, decision',
355
+ '- Search memory before storing to avoid duplicates',
356
+ '- When correcting old knowledge, update or delete the old memory',
357
+ ].join('\n'))
358
+
359
+ // Pre-compaction memory flush: nudge agent to persist learnings when conversation is long
360
+ const msgCount = history.filter(m => m.role === 'user' || m.role === 'assistant').length
361
+ if (msgCount > 20) {
362
+ stateModifierParts.push([
363
+ '## Memory Flush Reminder',
364
+ 'This conversation is getting long. Before context is trimmed, store any important',
365
+ 'learnings, decisions, or facts as memories now. Only store what is significant and durable —',
366
+ 'skip trivial details. If nothing needs storing, continue normally.',
367
+ ].join('\n'))
368
+ }
314
369
  } catch {
315
370
  // If memory context fails to load, continue without blocking the run.
316
371
  }
@@ -347,15 +402,17 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
347
402
  }
348
403
  }
349
404
 
350
- stateModifierParts.push(
351
- [
352
- '## Follow-up Suggestions',
353
- 'At the end of every response, include a <suggestions> block with exactly 3 short',
354
- 'follow-up prompts the user might want to send next, as a JSON array. Keep each under 60 chars.',
355
- 'Make them contextual to what you just said. Example:',
356
- '<suggestions>["Set up a Discord connector", "Create a research agent", "Show the task board"]</suggestions>',
357
- ].join('\n'),
358
- )
405
+ if (settings.suggestionsEnabled !== false) {
406
+ stateModifierParts.push(
407
+ [
408
+ '## Follow-up Suggestions',
409
+ 'At the end of every response, include a <suggestions> block with exactly 3 short',
410
+ 'follow-up prompts the user might want to send next, as a JSON array. Keep each under 60 chars.',
411
+ 'Make them contextual to what you just said. Example:',
412
+ '<suggestions>["Set up a Discord connector", "Create a research agent", "Show the task board"]</suggestions>',
413
+ ].join('\n'),
414
+ )
415
+ }
359
416
 
360
417
  stateModifierParts.push(
361
418
  buildAgenticExecutionPolicy({
@@ -363,6 +420,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
363
420
  loopMode: runtime.loopMode,
364
421
  heartbeatPrompt,
365
422
  heartbeatIntervalSec,
423
+ platformAssignScope: agentPlatformAssignScope,
366
424
  }),
367
425
  )
368
426
 
@@ -463,24 +521,49 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
463
521
  // Auto-compaction: prune old history if approaching context window limit
464
522
  let effectiveHistory = history
465
523
  try {
466
- const { shouldAutoCompact, consolidateToMemory, slidingWindowCompact, estimateTokens } = await import('./context-manager')
524
+ const { shouldAutoCompact, llmCompact, estimateTokens } = await import('./context-manager')
467
525
  const systemPromptTokens = estimateTokens(stateModifier)
468
526
  if (shouldAutoCompact(history, systemPromptTokens, session.provider, session.model)) {
469
- // Consolidate important old messages to memory before pruning
470
- const oldMessages = history.slice(0, -10)
471
- if (oldMessages.length > 0 && session.agentId) {
472
- consolidateToMemory(oldMessages, session.agentId, session.id)
527
+ const summarize = async (prompt: string): Promise<string> => {
528
+ const response = await llm.invoke([new HumanMessage(prompt)])
529
+ if (typeof response.content === 'string') return response.content
530
+ if (Array.isArray(response.content)) {
531
+ return response.content
532
+ .map((b: Record<string, unknown>) => (typeof b.text === 'string' ? b.text : ''))
533
+ .join('')
534
+ }
535
+ return ''
473
536
  }
474
- // Keep last 10 messages via sliding window
475
- effectiveHistory = slidingWindowCompact(history, 10)
476
- console.log(`[stream-agent-chat] Auto-compacted session ${session.id}: ${history.length} → ${effectiveHistory.length} messages`)
537
+ const result = await llmCompact({
538
+ messages: history,
539
+ provider: session.provider,
540
+ model: session.model,
541
+ agentId: session.agentId || null,
542
+ sessionId: session.id,
543
+ summarize,
544
+ })
545
+ effectiveHistory = result.messages
546
+ console.log(
547
+ `[stream-agent-chat] Auto-compacted ${session.id}: ${history.length} → ${effectiveHistory.length} msgs` +
548
+ (result.summaryAdded ? ' (LLM summary)' : ' (sliding window fallback)'),
549
+ )
477
550
  }
478
551
  } catch {
479
- // If context manager fails, continue with full history
552
+ // Context manager failure continue with full history
480
553
  }
481
554
 
555
+ // Apply context-clear boundary: slice from most recent context-clear marker
556
+ let contextStart = 0
557
+ for (let i = effectiveHistory.length - 1; i >= 0; i--) {
558
+ if (effectiveHistory[i].kind === 'context-clear') {
559
+ contextStart = i + 1
560
+ break
561
+ }
562
+ }
563
+ const postClearHistory = effectiveHistory.slice(contextStart)
564
+
482
565
  const langchainMessages: Array<HumanMessage | AIMessage> = []
483
- for (const m of effectiveHistory.slice(-20)) {
566
+ for (const m of postClearHistory.slice(-20)) {
484
567
  if (m.role === 'user') {
485
568
  langchainMessages.push(new HumanMessage({ content: await buildLangChainContent(m.text, m.imagePath, m.attachedFiles) }))
486
569
  } else {
@@ -497,6 +580,8 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
497
580
  let hasToolCalls = false
498
581
  let totalInputTokens = 0
499
582
  let totalOutputTokens = 0
583
+ let lastToolInput: unknown = null
584
+ let accumulatedThinking = ''
500
585
 
501
586
  // Plugin hooks: beforeAgentStart
502
587
  const pluginMgr = getPluginManager()
@@ -529,15 +614,29 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
529
614
  const chunk = event.data?.chunk
530
615
  if (chunk?.content) {
531
616
  // content can be string or array of content blocks
532
- const text = typeof chunk.content === 'string'
533
- ? chunk.content
534
- : Array.isArray(chunk.content)
535
- ? chunk.content.map((c: any) => c.text || '').join('')
536
- : ''
537
- if (text) {
538
- fullText += text
539
- lastSegment += text
540
- write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
617
+ if (Array.isArray(chunk.content)) {
618
+ for (const block of chunk.content) {
619
+ // Anthropic extended thinking blocks
620
+ if (block.type === 'thinking' && block.thinking) {
621
+ accumulatedThinking += block.thinking
622
+ write(`data: ${JSON.stringify({ t: 'thinking', text: block.thinking })}\n\n`)
623
+ // OpenClaw [[thinking]] prefix convention
624
+ } else if (typeof block.text === 'string' && block.text.startsWith('[[thinking]]')) {
625
+ accumulatedThinking += block.text.slice(12)
626
+ write(`data: ${JSON.stringify({ t: 'thinking', text: block.text.slice(12) })}\n\n`)
627
+ } else if (block.text) {
628
+ fullText += block.text
629
+ lastSegment += block.text
630
+ write(`data: ${JSON.stringify({ t: 'd', text: block.text })}\n\n`)
631
+ }
632
+ }
633
+ } else {
634
+ const text = typeof chunk.content === 'string' ? chunk.content : ''
635
+ if (text) {
636
+ fullText += text
637
+ lastSegment += text
638
+ write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
639
+ }
541
640
  }
542
641
  }
543
642
  } else if (kind === 'on_llm_end') {
@@ -554,6 +653,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
554
653
  lastSegment = ''
555
654
  const toolName = event.name || 'unknown'
556
655
  const input = event.data?.input
656
+ lastToolInput = input
557
657
  // Plugin hooks: beforeToolExec
558
658
  await pluginMgr.runHook('beforeToolExec', { toolName, input })
559
659
  const inputStr = typeof input === 'string' ? input : JSON.stringify(input)
@@ -576,6 +676,23 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
576
676
  : JSON.stringify(output)
577
677
  // Plugin hooks: afterToolExec
578
678
  await pluginMgr.runHook('afterToolExec', { toolName, input: null, output: outputStr })
679
+ // Event-driven memory breadcrumbs
680
+ if (session.agentId && (session.tools || []).includes('memory')) {
681
+ try {
682
+ const breadcrumbTitle = extractBreadcrumbTitle(toolName, lastToolInput, outputStr)
683
+ if (breadcrumbTitle) {
684
+ const memDb = getMemoryDb()
685
+ memDb.add({
686
+ agentId: session.agentId,
687
+ sessionId: session.id,
688
+ category: 'breadcrumb',
689
+ title: breadcrumbTitle,
690
+ content: '',
691
+ })
692
+ }
693
+ } catch { /* breadcrumbs are best-effort */ }
694
+ }
695
+ lastToolInput = null
579
696
  logExecution(session.id, 'tool_result', `${toolName} returned`, {
580
697
  agentId: session.agentId,
581
698
  detail: { toolName, output: outputStr?.slice(0, 4000), error: /^(Error:|error:)/i.test((outputStr || '').trim()) || undefined },
@@ -617,6 +734,12 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
617
734
  if (signal) signal.removeEventListener('abort', abortFromSignal)
618
735
  }
619
736
 
737
+ // Skip post-stream work if the client disconnected mid-stream
738
+ if (signal?.aborted) {
739
+ await cleanup()
740
+ return { fullText, finalResponse: fullText }
741
+ }
742
+
620
743
  // Extract LLM-generated suggestions from the response and strip the tag
621
744
  const extracted = extractSuggestions(fullText)
622
745
  fullText = extracted.clean
@@ -624,6 +747,11 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
624
747
  write(`data: ${JSON.stringify({ t: 'md', text: JSON.stringify({ suggestions: extracted.suggestions }) })}\n\n`)
625
748
  }
626
749
 
750
+ // Emit full thinking text as metadata so the client can persist it
751
+ if (accumulatedThinking) {
752
+ write(`data: ${JSON.stringify({ t: 'md', text: JSON.stringify({ thinking: accumulatedThinking }) })}\n\n`)
753
+ }
754
+
627
755
  // Track cost
628
756
  const totalTokens = totalInputTokens + totalOutputTokens
629
757
  if (totalTokens > 0) {