@swarmclawai/swarmclaw 0.7.2 → 0.7.3

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 (197) hide show
  1. package/README.md +81 -22
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +36 -7
  5. package/src/app/api/agents/route.ts +12 -1
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  9. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  10. package/src/app/api/chats/[id]/main-loop/route.ts +7 -88
  11. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  12. package/src/app/api/chats/[id]/route.ts +18 -0
  13. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  14. package/src/app/api/chats/route.ts +16 -0
  15. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  16. package/src/app/api/connectors/doctor/route.ts +13 -0
  17. package/src/app/api/files/open/route.ts +16 -14
  18. package/src/app/api/memory/maintenance/route.ts +11 -1
  19. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  20. package/src/app/api/openclaw/skills/route.ts +11 -3
  21. package/src/app/api/plugins/dependencies/route.ts +24 -0
  22. package/src/app/api/plugins/install/route.ts +15 -92
  23. package/src/app/api/plugins/route.ts +3 -26
  24. package/src/app/api/plugins/settings/route.ts +17 -12
  25. package/src/app/api/plugins/ui/route.ts +1 -0
  26. package/src/app/api/settings/route.ts +49 -7
  27. package/src/app/api/tasks/[id]/route.ts +15 -6
  28. package/src/app/api/tasks/bulk/route.ts +2 -2
  29. package/src/app/api/tasks/route.ts +9 -4
  30. package/src/app/api/webhooks/[id]/route.ts +8 -1
  31. package/src/app/page.tsx +9 -2
  32. package/src/cli/index.js +4 -0
  33. package/src/cli/index.ts +3 -10
  34. package/src/components/agents/agent-card.tsx +15 -12
  35. package/src/components/agents/agent-chat-list.tsx +101 -1
  36. package/src/components/agents/agent-list.tsx +46 -9
  37. package/src/components/agents/agent-sheet.tsx +207 -16
  38. package/src/components/agents/inspector-panel.tsx +108 -48
  39. package/src/components/auth/access-key-gate.tsx +36 -97
  40. package/src/components/chat/chat-area.tsx +29 -13
  41. package/src/components/chat/chat-card.tsx +4 -20
  42. package/src/components/chat/chat-header.tsx +255 -353
  43. package/src/components/chat/chat-list.tsx +7 -9
  44. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  45. package/src/components/chat/message-list.tsx +3 -1
  46. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  47. package/src/components/connectors/connector-list.tsx +265 -127
  48. package/src/components/connectors/connector-sheet.tsx +217 -0
  49. package/src/components/home/home-view.tsx +128 -4
  50. package/src/components/layout/app-layout.tsx +383 -194
  51. package/src/components/layout/mobile-header.tsx +26 -8
  52. package/src/components/plugins/plugin-list.tsx +15 -3
  53. package/src/components/plugins/plugin-sheet.tsx +118 -9
  54. package/src/components/projects/project-detail.tsx +183 -0
  55. package/src/components/shared/agent-picker-list.tsx +2 -2
  56. package/src/components/shared/command-palette.tsx +111 -24
  57. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  58. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  59. package/src/components/shared/settings/section-heartbeat.tsx +77 -0
  60. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  61. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  62. package/src/components/shared/settings/section-secrets.tsx +6 -6
  63. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  64. package/src/components/shared/settings/section-voice.tsx +5 -1
  65. package/src/components/shared/settings/section-web-search.tsx +10 -2
  66. package/src/components/shared/settings/settings-page.tsx +245 -46
  67. package/src/components/tasks/approvals-panel.tsx +205 -18
  68. package/src/components/tasks/task-board.tsx +242 -46
  69. package/src/components/usage/metrics-dashboard.tsx +74 -1
  70. package/src/components/wallets/wallet-panel.tsx +17 -5
  71. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  72. package/src/lib/auth.ts +17 -0
  73. package/src/lib/chat-streaming-state.test.ts +108 -0
  74. package/src/lib/chat-streaming-state.ts +108 -0
  75. package/src/lib/openclaw-agent-id.test.ts +14 -0
  76. package/src/lib/openclaw-agent-id.ts +31 -0
  77. package/src/lib/server/agent-assignment.test.ts +112 -0
  78. package/src/lib/server/agent-assignment.ts +169 -0
  79. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  80. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  81. package/src/lib/server/approvals.ts +483 -75
  82. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  83. package/src/lib/server/browser-state.test.ts +118 -0
  84. package/src/lib/server/browser-state.ts +123 -0
  85. package/src/lib/server/build-llm.test.ts +36 -0
  86. package/src/lib/server/build-llm.ts +11 -4
  87. package/src/lib/server/builtin-plugins.ts +34 -0
  88. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  89. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  90. package/src/lib/server/chat-execution.ts +250 -61
  91. package/src/lib/server/chatroom-health.test.ts +26 -0
  92. package/src/lib/server/chatroom-health.ts +2 -3
  93. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  94. package/src/lib/server/chatroom-helpers.ts +45 -5
  95. package/src/lib/server/connectors/discord.ts +175 -11
  96. package/src/lib/server/connectors/doctor.test.ts +80 -0
  97. package/src/lib/server/connectors/doctor.ts +116 -0
  98. package/src/lib/server/connectors/manager.ts +946 -110
  99. package/src/lib/server/connectors/policy.test.ts +222 -0
  100. package/src/lib/server/connectors/policy.ts +452 -0
  101. package/src/lib/server/connectors/slack.ts +188 -9
  102. package/src/lib/server/connectors/telegram.ts +65 -15
  103. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  104. package/src/lib/server/connectors/thread-context.ts +72 -0
  105. package/src/lib/server/connectors/types.ts +41 -11
  106. package/src/lib/server/daemon-state.ts +59 -1
  107. package/src/lib/server/data-dir.ts +13 -0
  108. package/src/lib/server/delegation-jobs.test.ts +140 -0
  109. package/src/lib/server/delegation-jobs.ts +248 -0
  110. package/src/lib/server/document-utils.test.ts +47 -0
  111. package/src/lib/server/document-utils.ts +397 -0
  112. package/src/lib/server/heartbeat-service.ts +13 -39
  113. package/src/lib/server/heartbeat-source.test.ts +22 -0
  114. package/src/lib/server/heartbeat-source.ts +7 -0
  115. package/src/lib/server/identity-continuity.test.ts +77 -0
  116. package/src/lib/server/identity-continuity.ts +127 -0
  117. package/src/lib/server/mailbox-utils.ts +347 -0
  118. package/src/lib/server/main-agent-loop.ts +27 -967
  119. package/src/lib/server/memory-db.ts +4 -6
  120. package/src/lib/server/memory-tiers.ts +40 -0
  121. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  122. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  123. package/src/lib/server/openclaw-exec-config.ts +5 -6
  124. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  125. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  126. package/src/lib/server/openclaw-sync.ts +3 -2
  127. package/src/lib/server/orchestrator-lg.ts +17 -6
  128. package/src/lib/server/orchestrator.ts +2 -2
  129. package/src/lib/server/playwright-proxy.mjs +27 -3
  130. package/src/lib/server/plugins.test.ts +207 -0
  131. package/src/lib/server/plugins.ts +822 -69
  132. package/src/lib/server/provider-health.ts +33 -3
  133. package/src/lib/server/queue.ts +3 -20
  134. package/src/lib/server/scheduler.ts +2 -0
  135. package/src/lib/server/session-archive-memory.test.ts +85 -0
  136. package/src/lib/server/session-archive-memory.ts +230 -0
  137. package/src/lib/server/session-mailbox.ts +8 -18
  138. package/src/lib/server/session-reset-policy.test.ts +99 -0
  139. package/src/lib/server/session-reset-policy.ts +311 -0
  140. package/src/lib/server/session-run-manager.ts +33 -80
  141. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  142. package/src/lib/server/session-tools/calendar.ts +2 -12
  143. package/src/lib/server/session-tools/connector.ts +109 -8
  144. package/src/lib/server/session-tools/context.ts +14 -2
  145. package/src/lib/server/session-tools/crawl.ts +447 -0
  146. package/src/lib/server/session-tools/crud.ts +70 -32
  147. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  148. package/src/lib/server/session-tools/delegate.ts +406 -20
  149. package/src/lib/server/session-tools/discovery.ts +22 -4
  150. package/src/lib/server/session-tools/document.ts +283 -0
  151. package/src/lib/server/session-tools/email.ts +1 -3
  152. package/src/lib/server/session-tools/extract.ts +137 -0
  153. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  154. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  155. package/src/lib/server/session-tools/file.ts +237 -24
  156. package/src/lib/server/session-tools/human-loop.ts +227 -0
  157. package/src/lib/server/session-tools/image-gen.ts +1 -3
  158. package/src/lib/server/session-tools/index.ts +56 -1
  159. package/src/lib/server/session-tools/mailbox.ts +276 -0
  160. package/src/lib/server/session-tools/memory.ts +35 -3
  161. package/src/lib/server/session-tools/monitor.ts +150 -7
  162. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  163. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  164. package/src/lib/server/session-tools/platform.ts +142 -4
  165. package/src/lib/server/session-tools/plugin-creator.ts +86 -23
  166. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  167. package/src/lib/server/session-tools/replicate.ts +1 -3
  168. package/src/lib/server/session-tools/schedule.ts +20 -10
  169. package/src/lib/server/session-tools/session-info.ts +36 -3
  170. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  171. package/src/lib/server/session-tools/subagent.ts +193 -27
  172. package/src/lib/server/session-tools/table.ts +587 -0
  173. package/src/lib/server/session-tools/wallet.ts +13 -10
  174. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  175. package/src/lib/server/session-tools/web.ts +896 -100
  176. package/src/lib/server/storage.ts +226 -7
  177. package/src/lib/server/stream-agent-chat.ts +46 -21
  178. package/src/lib/server/structured-extract.test.ts +72 -0
  179. package/src/lib/server/structured-extract.ts +373 -0
  180. package/src/lib/server/task-mention.test.ts +16 -2
  181. package/src/lib/server/task-mention.ts +61 -10
  182. package/src/lib/server/tool-aliases.ts +44 -7
  183. package/src/lib/server/tool-capability-policy.ts +6 -0
  184. package/src/lib/server/tool-retry.ts +2 -0
  185. package/src/lib/server/watch-jobs.test.ts +173 -0
  186. package/src/lib/server/watch-jobs.ts +532 -0
  187. package/src/lib/server/ws-hub.ts +5 -3
  188. package/src/lib/validation/schemas.test.ts +26 -0
  189. package/src/lib/validation/schemas.ts +7 -0
  190. package/src/lib/ws-client.ts +14 -12
  191. package/src/proxy.ts +5 -5
  192. package/src/stores/use-app-store.ts +0 -6
  193. package/src/stores/use-chat-store.ts +31 -2
  194. package/src/types/index.ts +287 -44
  195. package/src/components/chat/new-chat-sheet.tsx +0 -253
  196. package/src/lib/server/main-session.ts +0 -17
  197. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs'
2
2
  import os from 'os'
3
+ import path from 'path'
3
4
  import {
4
5
  loadSessions,
5
6
  saveSessions,
@@ -38,7 +39,12 @@ import {
38
39
  } from './llm-response-cache'
39
40
  import type { Message, MessageToolEvent, SSEEvent, UsageRecord } from '@/types'
40
41
  import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
42
+ import { isHeartbeatSource, isInternalHeartbeatRun } from './heartbeat-source'
41
43
  import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
44
+ import { buildIdentityContinuityContext, refreshSessionIdentityState } from './identity-continuity'
45
+ import { syncSessionArchiveMemory } from './session-archive-memory'
46
+ import { evaluateSessionFreshness, resetSessionRuntime, resolveSessionResetPolicy } from './session-reset-policy'
47
+ import { pruneStreamingAssistantArtifacts, upsertStreamingAssistantArtifact } from '@/lib/chat-streaming-state'
42
48
  type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
43
49
 
44
50
  /** Slice history from the most recent context-clear marker forward */
@@ -92,6 +98,10 @@ export interface ExecuteChatTurnResult {
92
98
  estimatedCost?: number
93
99
  }
94
100
 
101
+ export function shouldApplySessionFreshnessReset(source: string): boolean {
102
+ return source !== 'eval'
103
+ }
104
+
95
105
  function extractEventJson(line: string): SSEEvent | null {
96
106
  if (!line.startsWith('data: ')) return null
97
107
  try {
@@ -101,8 +111,17 @@ function extractEventJson(line: string): SSEEvent | null {
101
111
  }
102
112
  }
103
113
 
104
- function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
114
+ export function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
105
115
  if (ev.t === 'tool_call') {
116
+ const previous = bag[bag.length - 1]
117
+ if (
118
+ previous
119
+ && previous.name === (ev.toolName || 'unknown')
120
+ && previous.input === (ev.toolInput || '')
121
+ && !previous.output
122
+ ) {
123
+ return
124
+ }
106
125
  bag.push({
107
126
  name: ev.toolName || 'unknown',
108
127
  input: ev.toolInput || '',
@@ -125,6 +144,96 @@ function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
125
144
  }
126
145
  }
127
146
 
147
+ export function dedupeConsecutiveToolEvents(events: MessageToolEvent[]): MessageToolEvent[] {
148
+ const sameEvent = (left: MessageToolEvent, right: MessageToolEvent): boolean => (
149
+ left.name === right.name
150
+ && left.input === right.input
151
+ && (left.output || '') === (right.output || '')
152
+ && (left.error === true) === (right.error === true)
153
+ )
154
+ const sameBlock = (startA: number, startB: number, size: number): boolean => {
155
+ for (let offset = 0; offset < size; offset += 1) {
156
+ if (!sameEvent(events[startA + offset], events[startB + offset])) return false
157
+ }
158
+ return true
159
+ }
160
+
161
+ const deduped: MessageToolEvent[] = []
162
+ for (let index = 0; index < events.length;) {
163
+ const remaining = events.length - index
164
+ let collapsed = false
165
+ for (let blockSize = Math.floor(remaining / 2); blockSize >= 1; blockSize -= 1) {
166
+ if (!sameBlock(index, index + blockSize, blockSize)) continue
167
+ for (let offset = 0; offset < blockSize; offset += 1) deduped.push(events[index + offset])
168
+ const blockStart = index
169
+ index += blockSize
170
+ while (index + blockSize <= events.length && sameBlock(blockStart, index, blockSize)) {
171
+ index += blockSize
172
+ }
173
+ collapsed = true
174
+ break
175
+ }
176
+ if (collapsed) continue
177
+ deduped.push(events[index])
178
+ index += 1
179
+ }
180
+ return deduped
181
+ }
182
+
183
+ function extractDelegateResponse(outputText: string): string | null {
184
+ try {
185
+ const parsed = JSON.parse(outputText) as Record<string, unknown>
186
+ if (typeof parsed.response === 'string' && parsed.response.trim()) return parsed.response.trim()
187
+ if (typeof parsed.result === 'string' && parsed.result.trim()) return parsed.result.trim()
188
+ return null
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ function normalizeWorkspaceSandboxLinks(text: string, cwd: string): string {
195
+ return text.replace(/sandbox:\/workspace\/([^\s)"'\]`]+)/g, (raw, relativePath: string) => {
196
+ const normalized = String(relativePath || '').replace(/^\/+/, '')
197
+ if (!normalized) return raw
198
+ const resolvedCwd = path.resolve(cwd)
199
+ const resolved = path.resolve(resolvedCwd, normalized)
200
+ if (!resolved.startsWith(resolvedCwd)) return raw
201
+ if (!fs.existsSync(resolved)) return raw
202
+ return `/api/files/serve?path=${encodeURIComponent(resolved)}`
203
+ })
204
+ }
205
+
206
+ function normalizeAbsoluteFileMarkdownLinks(text: string): string {
207
+ return text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (raw, label: string, target: string) => {
208
+ if (!path.isAbsolute(target)) return raw
209
+ const resolved = path.resolve(target)
210
+ if (!fs.existsSync(resolved)) return raw
211
+ return `[${label}](/api/files/serve?path=${encodeURIComponent(resolved)})`
212
+ })
213
+ }
214
+
215
+ export function normalizeAssistantArtifactLinks(text: string, cwd: string): string {
216
+ const uploadsNormalized = text.replace(/sandbox:\/api\/uploads\//g, '/api/uploads/')
217
+ const workspaceNormalized = normalizeWorkspaceSandboxLinks(uploadsNormalized, cwd)
218
+ return normalizeAbsoluteFileMarkdownLinks(workspaceNormalized)
219
+ }
220
+
221
+ function extractHeartbeatStatus(text: string): { goal?: string; status?: string; summary?: string; nextAction?: string } | null {
222
+ const match = text.match(/\[AGENT_HEARTBEAT_META\]\s*(\{[^\n]*\})/i)
223
+ if (!match) return null
224
+ try {
225
+ const meta = JSON.parse(match[1]) as Record<string, unknown>
226
+ const payload: { goal?: string; status?: string; summary?: string; nextAction?: string } = {}
227
+ if (typeof meta.goal === 'string' && meta.goal.trim()) payload.goal = meta.goal.trim()
228
+ if (typeof meta.status === 'string' && meta.status.trim()) payload.status = meta.status.trim()
229
+ if (typeof meta.summary === 'string' && meta.summary.trim()) payload.summary = meta.summary.trim()
230
+ if (typeof meta.next_action === 'string' && meta.next_action.trim()) payload.nextAction = meta.next_action.trim()
231
+ return Object.keys(payload).length > 0 ? payload : null
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+
128
237
  function shouldReplaceRecentAssistantMessage(params: {
129
238
  previous: Message | null | undefined
130
239
  nextToolEvents: MessageToolEvent[]
@@ -140,7 +249,11 @@ function shouldReplaceRecentAssistantMessage(params: {
140
249
  return prevTools === 0
141
250
  }
142
251
 
143
- function requestedToolNamesFromMessage(message: string): string[] {
252
+ export function pruneSuppressedHeartbeatStreamMessage(messages: Message[]): boolean {
253
+ return pruneStreamingAssistantArtifacts(messages)
254
+ }
255
+
256
+ export function requestedToolNamesFromMessage(message: string): string[] {
144
257
  const lower = message.toLowerCase()
145
258
  const candidates = [
146
259
  'delegate_to_claude_code',
@@ -179,15 +292,24 @@ function requestedToolNamesFromMessage(message: string): string[] {
179
292
  'sandbox_list_runtimes',
180
293
  'git',
181
294
  'canvas',
182
- 'delegate',
183
295
  'schedule_wake',
184
296
  'spawn_subagent',
297
+ 'mailbox',
298
+ 'ask_human',
299
+ 'document',
300
+ 'extract',
301
+ 'table',
302
+ 'crawl',
185
303
  'context_status',
186
304
  'context_summarize',
187
305
  'openclaw_nodes',
188
306
  'openclaw_workspace',
189
307
  ]
190
- return candidates.filter((name) => lower.includes(name.toLowerCase()))
308
+ const requested = candidates.filter((name) => lower.includes(name.toLowerCase()))
309
+ if (/(^|[\s(])`delegate`([\s).,!?]|$)|\bdelegate tool\b|\buse delegate\b/.test(lower)) {
310
+ requested.push('delegate')
311
+ }
312
+ return Array.from(new Set(requested))
191
313
  }
192
314
 
193
315
  function parseKeyValueArgs(raw: string): Record<string, string> {
@@ -398,10 +520,16 @@ function syncSessionFromAgent(sessionId: string): void {
398
520
  if (!agent) return
399
521
 
400
522
  let changed = false
401
- if (agent.provider && agent.provider !== session.provider) { session.provider = agent.provider; changed = true }
402
- if (agent.model !== undefined && agent.model !== session.model) { session.model = agent.model; changed = true }
403
- if (agent.credentialId !== undefined && agent.credentialId !== session.credentialId) { session.credentialId = agent.credentialId ?? null; changed = true }
404
- if (agent.apiEndpoint !== undefined) {
523
+ if (!session.provider && agent.provider) { session.provider = agent.provider; changed = true }
524
+ if ((session.model === undefined || session.model === null || session.model === '') && agent.model !== undefined) {
525
+ session.model = agent.model
526
+ changed = true
527
+ }
528
+ if (session.credentialId === undefined && agent.credentialId !== undefined) {
529
+ session.credentialId = agent.credentialId ?? null
530
+ changed = true
531
+ }
532
+ if ((session.apiEndpoint === undefined || session.apiEndpoint === null) && agent.apiEndpoint !== undefined) {
405
533
  const normalized = normalizeProviderEndpoint(agent.provider, agent.apiEndpoint ?? null)
406
534
  if (normalized !== session.apiEndpoint) { session.apiEndpoint = normalized; changed = true }
407
535
  }
@@ -409,6 +537,11 @@ function syncSessionFromAgent(sessionId: string): void {
409
537
  session.plugins = Array.isArray(agent.plugins) ? [...agent.plugins] : []
410
538
  changed = true
411
539
  }
540
+ const isShortcutChat = session.shortcutForAgentId === agent.id || agent.threadSessionId === sessionId
541
+ if (isShortcutChat) {
542
+ if (session.shortcutForAgentId !== agent.id) { session.shortcutForAgentId = agent.id; changed = true }
543
+ if (session.name !== agent.name) { session.name = agent.name; changed = true }
544
+ }
412
545
 
413
546
  if (changed) {
414
547
  sessions[sessionId] = session
@@ -435,6 +568,8 @@ function buildAgentSystemPrompt(session: Session): string | undefined {
435
568
  if (agent.description) identityLines.push(`Description: ${agent.description}`)
436
569
  identityLines.push('I should always embody this identity. I am not just an "Assistant" — I am a persistent companion with my own soul and presence.')
437
570
  parts.push(identityLines.join('\n'))
571
+ const continuityBlock = buildIdentityContinuityContext(session, agent)
572
+ if (continuityBlock) parts.push(continuityBlock)
438
573
 
439
574
  // 2. Runtime & Capabilities (OpenClaw Style)
440
575
  const runtimeLines = [
@@ -553,20 +688,50 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
553
688
  const sessions = loadSessions()
554
689
  const session = sessions[sessionId]
555
690
  if (!session) throw new Error(`Session not found: ${sessionId}`)
691
+ session.messages = Array.isArray(session.messages) ? session.messages : []
692
+ const runStartedAt = Date.now()
693
+ const runMessageStartIndex = session.messages.length
556
694
 
557
695
  const appSettings = loadSettings()
696
+ const agentForSession = session.agentId ? loadAgents()[session.agentId] : null
558
697
  const toolPolicy = resolveSessionToolPolicy(session.plugins, appSettings)
559
- const isHeartbeatRun = internal && source === 'heartbeat'
560
- const isAutoRunNoHistory = isHeartbeatRun || (internal && source === 'main-loop-followup')
561
- const heartbeatStatus = session.mainLoopState?.status || 'idle'
562
- const mainLoopIdle = session.id.startsWith('agent-thread-')
563
- && (heartbeatStatus === 'ok' || heartbeatStatus === 'idle')
564
- && !(session.mainLoopState?.pendingEvents?.length > 0)
565
- const heartbeatStatusOnly = isHeartbeatRun && mainLoopIdle
698
+ const isHeartbeatRun = isInternalHeartbeatRun(internal, source)
699
+ const isAutoRunNoHistory = isHeartbeatRun
700
+ const heartbeatStatusOnly = false
701
+ if (shouldApplySessionFreshnessReset(source)) {
702
+ const freshness = evaluateSessionFreshness({
703
+ session,
704
+ policy: resolveSessionResetPolicy({
705
+ session,
706
+ agent: agentForSession,
707
+ settings: appSettings,
708
+ }),
709
+ })
710
+ if (!freshness.fresh) {
711
+ try { syncSessionArchiveMemory(session, { agent: agentForSession }) } catch { /* archive sync is best-effort */ }
712
+ resetSessionRuntime(session, freshness.reason || 'session_reset')
713
+ onEvent?.({ t: 'status', text: JSON.stringify({ sessionReset: freshness.reason || 'session_reset' }) })
714
+ sessions[sessionId] = session
715
+ saveSessions(sessions)
716
+ }
717
+ }
566
718
  const pluginsForRun = heartbeatStatusOnly ? [] : toolPolicy.enabledPlugins
567
719
  let sessionForRun = pluginsForRun === session.plugins
568
720
  ? session
569
721
  : { ...session, plugins: pluginsForRun }
722
+ let effectiveMessage = message
723
+
724
+ if (pluginsForRun.length > 0) {
725
+ try {
726
+ effectiveMessage = await getPluginManager().transformText(
727
+ 'transformInboundMessage',
728
+ { session: sessionForRun, text: message },
729
+ { enabledIds: pluginsForRun },
730
+ )
731
+ } catch {
732
+ effectiveMessage = message
733
+ }
734
+ }
570
735
 
571
736
  // Apply model override for heartbeat runs (cheaper model)
572
737
  if (isHeartbeatRun && input.modelOverride) {
@@ -662,7 +827,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
662
827
  internal,
663
828
  provider: session.provider,
664
829
  model: session.model,
665
- messagePreview: message.slice(0, 200),
830
+ messagePreview: effectiveMessage.slice(0, 200),
666
831
  hasImage: !!(imagePath || imageUrl),
667
832
  },
668
833
  })
@@ -679,7 +844,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
679
844
 
680
845
  if (!internal) {
681
846
  const linkAnalysis = await runLinkUnderstanding(message)
682
- session.messages.push({
847
+ const nextUserMessage: Message = {
683
848
  role: 'user',
684
849
  text: message,
685
850
  time: Date.now(),
@@ -687,7 +852,8 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
687
852
  imageUrl: imageUrl || undefined,
688
853
  attachedFiles: attachedFiles?.length ? attachedFiles : undefined,
689
854
  replyToId: input.replyToId || undefined,
690
- })
855
+ }
856
+ session.messages.push(nextUserMessage)
691
857
  if (linkAnalysis.length > 0) {
692
858
  session.messages.push({
693
859
  role: 'assistant',
@@ -698,6 +864,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
698
864
  }
699
865
  session.lastActiveAt = Date.now()
700
866
  saveSessions(sessions)
867
+ try {
868
+ await getPluginManager().runHook('onMessage', { session, message: nextUserMessage }, { enabledIds: pluginsForRun })
869
+ } catch { /* onMessage hooks are non-critical */ }
701
870
  }
702
871
 
703
872
  const systemPrompt = buildAgentSystemPrompt(session)
@@ -746,19 +915,18 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
746
915
  const fresh = loadSessions()
747
916
  const current = fresh[sessionId]
748
917
  if (!current) return
918
+ current.messages = Array.isArray(current.messages) ? current.messages : []
749
919
  const partialMsg: Message = {
750
920
  role: 'assistant',
751
921
  text: streamingPartialText,
752
922
  time: Date.now(),
753
923
  streaming: true,
754
- toolEvents: toolEvents.length ? [...toolEvents] : undefined,
755
- }
756
- const lastMsg = current.messages.at(-1)
757
- if (lastMsg?.streaming) {
758
- current.messages[current.messages.length - 1] = partialMsg
759
- } else {
760
- current.messages.push(partialMsg)
924
+ toolEvents: toolEvents.length ? dedupeConsecutiveToolEvents([...toolEvents]) : undefined,
761
925
  }
926
+ upsertStreamingAssistantArtifact(current.messages, partialMsg, {
927
+ minIndex: runMessageStartIndex,
928
+ minTime: runStartedAt,
929
+ })
762
930
  fresh[sessionId] = current
763
931
  saveSessions(fresh)
764
932
  notify(`messages:${sessionId}`)
@@ -812,7 +980,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
812
980
  if (hasPlugins) {
813
981
  fullResponse = (await streamAgentChat({
814
982
  session: sessionForRun,
815
- message: message,
983
+ message: effectiveMessage,
816
984
  imagePath,
817
985
  attachedFiles,
818
986
  apiKey,
@@ -830,7 +998,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
830
998
  model: sessionForRun.model,
831
999
  apiEndpoint: sessionForRun.apiEndpoint || '',
832
1000
  systemPrompt,
833
- message: message,
1001
+ message: effectiveMessage,
834
1002
  imagePath,
835
1003
  imageUrl,
836
1004
  attachedFiles,
@@ -858,7 +1026,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
858
1026
  } else {
859
1027
  fullResponse = await provider.handler.streamChat({
860
1028
  session: sessionForRun,
861
- message: message,
1029
+ message: effectiveMessage,
862
1030
  imagePath,
863
1031
  apiKey,
864
1032
  systemPrompt,
@@ -1019,10 +1187,16 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1019
1187
  const toolOutput = await selectedTool.invoke(translated.args)
1020
1188
  const outputText = typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput)
1021
1189
  emit({ t: 'tool_result', toolName, toolOutput: outputText })
1022
- // Don't overwrite fullResponse with raw tool output — it's already captured
1023
- // in toolEvents. Only set a brief notice when the LLM produced no text,
1024
- // so the message bubble isn't empty.
1025
- if (!fullResponse.trim() && outputText?.trim()) {
1190
+ const delegateResponse = (
1191
+ toolName === 'delegate'
1192
+ || toolName.startsWith('delegate_to_')
1193
+ ) ? extractDelegateResponse(outputText) : null
1194
+ if (delegateResponse) {
1195
+ fullResponse = delegateResponse
1196
+ } else if (!fullResponse.trim() && outputText?.trim()) {
1197
+ // Don't overwrite fullResponse with raw tool output — it's already captured
1198
+ // in toolEvents. Only set a brief notice when the LLM produced no text,
1199
+ // so the message bubble isn't empty.
1026
1200
  const label = toolName.replace(/_/g, ' ')
1027
1201
  fullResponse = `Used **${label}** — see tool output above for details.`
1028
1202
  }
@@ -1075,7 +1249,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1075
1249
  const delegationOrder = rankDelegatesByHealth(baseDelegationOrder as DelegateTool[])
1076
1250
  .filter((tool) => enabledDelegateTools.includes(tool))
1077
1251
  for (const delegateTool of delegationOrder) {
1078
- const invoked = await invokeSessionTool(delegateTool, { task: message.trim() }, 'Auto-delegation failed')
1252
+ const invoked = await invokeSessionTool(delegateTool, { task: effectiveMessage.trim() }, 'Auto-delegation failed')
1079
1253
  if (invoked) break
1080
1254
  }
1081
1255
  }
@@ -1095,7 +1269,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1095
1269
  for (const delegateTool of fallbackOrder) {
1096
1270
  const invoked = await invokeSessionTool(
1097
1271
  delegateTool,
1098
- { task: message.trim() },
1272
+ { task: effectiveMessage.trim() },
1099
1273
  `Provider failover via ${delegateTool} failed`,
1100
1274
  )
1101
1275
  if (invoked) {
@@ -1113,7 +1287,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1113
1287
  if (canAutoRouteWithTools && routingDecision?.intent === 'browsing' && routingDecision.primaryUrl && hasToolEnabled(sessionForRun, 'browser')) {
1114
1288
  await invokeSessionTool(
1115
1289
  'browser',
1116
- { action: 'navigate', url: routingDecision.primaryUrl },
1290
+ { action: 'read_page', url: routingDecision.primaryUrl },
1117
1291
  'Auto browser routing failed',
1118
1292
  )
1119
1293
  }
@@ -1123,7 +1297,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1123
1297
  if (routeUrl && hasToolEnabled(sessionForRun, 'web_fetch')) {
1124
1298
  await invokeSessionTool('web_fetch', { url: routeUrl }, 'Auto web_fetch routing failed')
1125
1299
  } else if (hasToolEnabled(sessionForRun, 'web_search')) {
1126
- await invokeSessionTool('web_search', { query: message.trim(), maxResults: 5 }, 'Auto web_search routing failed')
1300
+ await invokeSessionTool('web_search', { query: effectiveMessage.trim(), maxResults: 5 }, 'Auto web_search routing failed')
1127
1301
  }
1128
1302
  }
1129
1303
 
@@ -1158,27 +1332,23 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1158
1332
  errorMessage = streamErrors[streamErrors.length - 1]
1159
1333
  }
1160
1334
 
1161
- const finalText = (fullResponse || '').trim() || (!internal && errorMessage ? `Error: ${errorMessage}` : '')
1335
+ let finalText = (fullResponse || '').trim() || (!internal && errorMessage ? `Error: ${errorMessage}` : '')
1336
+ if (pluginsForRun.length > 0 && finalText && !isHeartbeatRun) {
1337
+ try {
1338
+ finalText = await getPluginManager().transformText(
1339
+ 'transformOutboundMessage',
1340
+ { session: sessionForRun, text: finalText },
1341
+ { enabledIds: pluginsForRun },
1342
+ )
1343
+ } catch { /* outbound transforms are non-critical */ }
1344
+ }
1345
+ finalText = normalizeAssistantArtifactLinks(finalText, session.cwd)
1162
1346
  const textForPersistence = stripMainLoopMetaForPersistence(finalText)
1347
+ const persistedToolEvents = dedupeConsecutiveToolEvents(toolEvents)
1163
1348
 
1164
- // Emit status SSE event from [MAIN_LOOP_META] if present
1165
- if (internal && finalText) {
1166
- const metaMatch = finalText.match(/\[MAIN_LOOP_META\]\s*(\{[^\n]*\})/i)
1167
- if (metaMatch) {
1168
- try {
1169
- const meta = JSON.parse(metaMatch[1])
1170
- const statusPayload: Record<string, string | undefined> = {}
1171
- if (meta.goal) statusPayload.goal = String(meta.goal)
1172
- if (meta.status) statusPayload.status = String(meta.status)
1173
- if (meta.summary) statusPayload.summary = String(meta.summary)
1174
- if (meta.next_action) statusPayload.nextAction = String(meta.next_action)
1175
- if (Object.keys(statusPayload).length > 0) {
1176
- emit({ t: 'status', text: JSON.stringify(statusPayload) })
1177
- }
1178
- } catch {
1179
- // ignore malformed meta JSON
1180
- }
1181
- }
1349
+ if (isHeartbeatRun && finalText) {
1350
+ const heartbeatStatus = extractHeartbeatStatus(finalText)
1351
+ if (heartbeatStatus) emit({ t: 'status', text: JSON.stringify(heartbeatStatus) })
1182
1352
  }
1183
1353
 
1184
1354
  // HEARTBEAT_OK suppression
@@ -1214,7 +1384,13 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1214
1384
  const fresh = loadSessions()
1215
1385
  const current = fresh[sessionId]
1216
1386
  if (current) {
1387
+ current.messages = Array.isArray(current.messages) ? current.messages : []
1388
+ const currentAgent = current.agentId ? loadAgents()[current.agentId] : null
1217
1389
  let changed = false
1390
+ changed = pruneStreamingAssistantArtifacts(current.messages, {
1391
+ minIndex: runMessageStartIndex,
1392
+ minTime: runStartedAt,
1393
+ }) || changed
1218
1394
  const persistField = (key: string, value: unknown) => {
1219
1395
  const normalized = normalizeResumeId(value)
1220
1396
  if ((current as Record<string, unknown>)[key] !== normalized) {
@@ -1246,7 +1422,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1246
1422
  }
1247
1423
 
1248
1424
  if (shouldPersistAssistant) {
1249
- const persistedKind = internal && source === 'heartbeat' ? 'heartbeat' : 'chat'
1425
+ const persistedKind = isHeartbeatRun ? 'heartbeat' : 'chat'
1250
1426
  const persistedText = heartbeatClassification === 'strip'
1251
1427
  ? textForPersistence.replace(/HEARTBEAT_OK/gi, '').trim()
1252
1428
  : textForPersistence
@@ -1256,13 +1432,13 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1256
1432
  text: persistedText,
1257
1433
  time: nowTs,
1258
1434
  thinking: thinkingText || undefined,
1259
- toolEvents: toolEvents.length ? toolEvents : undefined,
1435
+ toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
1260
1436
  kind: persistedKind,
1261
1437
  }
1262
1438
  const previous = current.messages.at(-1)
1263
1439
  if (previous?.streaming || shouldReplaceRecentAssistantMessage({
1264
1440
  previous,
1265
- nextToolEvents: toolEvents,
1441
+ nextToolEvents: persistedToolEvents,
1266
1442
  nextKind: persistedKind,
1267
1443
  now: nowTs,
1268
1444
  })) {
@@ -1275,6 +1451,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1275
1451
  current.lastHeartbeatSentAt = nowTs
1276
1452
  }
1277
1453
  changed = true
1454
+ try {
1455
+ await getPluginManager().runHook('onMessage', { session: current, message: nextAssistantMessage }, { enabledIds: pluginsForRun })
1456
+ } catch { /* onMessage hooks are non-critical */ }
1278
1457
 
1279
1458
  // Conversation tone detection
1280
1459
  if (!internal) {
@@ -1329,6 +1508,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1329
1508
  }
1330
1509
  }
1331
1510
  }
1511
+ if (isHeartbeatRun && heartbeatClassification === 'suppress') {
1512
+ changed = pruneSuppressedHeartbeatStreamMessage(current.messages) || changed
1513
+ }
1332
1514
 
1333
1515
  // Fire afterChatTurn hook for all enabled plugins (memory auto-save, logging, etc.)
1334
1516
  try {
@@ -1338,13 +1520,20 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1338
1520
  response: textForPersistence,
1339
1521
  source,
1340
1522
  internal,
1341
- })
1523
+ }, { enabledIds: pluginsForRun })
1342
1524
  } catch { /* afterChatTurn hooks are non-critical */ }
1343
1525
 
1344
1526
  // Don't extend idle timeout for heartbeat runs — only user-initiated activity counts
1345
- if (source !== 'heartbeat' && source !== 'heartbeat-wake' && source !== 'main-loop-followup') {
1527
+ if (!isHeartbeatSource(source)) {
1346
1528
  current.lastActiveAt = Date.now()
1347
1529
  }
1530
+
1531
+ refreshSessionIdentityState(current, currentAgent)
1532
+ changed = true
1533
+ try {
1534
+ const archiveSync = syncSessionArchiveMemory(current, { agent: currentAgent })
1535
+ if (archiveSync.stored) changed = true
1536
+ } catch { /* archive sync is best-effort */ }
1348
1537
  fresh[sessionId] = current
1349
1538
  saveSessions(fresh)
1350
1539
  notify(`messages:${sessionId}`)
@@ -1355,7 +1544,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1355
1544
  sessionId,
1356
1545
  text: finalText,
1357
1546
  persisted: shouldPersistAssistant,
1358
- toolEvents,
1547
+ toolEvents: persistedToolEvents,
1359
1548
  error: errorMessage,
1360
1549
  inputTokens: accumulatedUsage.inputTokens || undefined,
1361
1550
  outputTokens: accumulatedUsage.outputTokens || undefined,
@@ -0,0 +1,26 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import type { Agent } from '@/types'
4
+ import { filterHealthyChatroomAgents } from './chatroom-health'
5
+
6
+ describe('filterHealthyChatroomAgents', () => {
7
+ it('treats providers with default endpoints as healthy without explicit agent endpoints', () => {
8
+ const now = Date.now()
9
+ const agents: Record<string, Agent> = {
10
+ agent_writer: {
11
+ id: 'agent_writer',
12
+ name: 'Writer',
13
+ description: '',
14
+ systemPrompt: '',
15
+ provider: 'ollama',
16
+ model: 'glm-5:cloud',
17
+ createdAt: now,
18
+ updatedAt: now,
19
+ },
20
+ }
21
+
22
+ const result = filterHealthyChatroomAgents(['agent_writer'], agents)
23
+ assert.deepEqual(result.healthyAgentIds, ['agent_writer'])
24
+ assert.deepEqual(result.skipped, [])
25
+ })
26
+ })
@@ -1,6 +1,6 @@
1
1
  import { getProvider } from '@/lib/providers'
2
2
  import type { Agent } from '@/types'
3
- import { resolveApiKey } from './chatroom-helpers'
3
+ import { resolveAgentApiEndpoint, resolveApiKey } from './chatroom-helpers'
4
4
  import { isProviderCoolingDown } from './provider-health'
5
5
 
6
6
  export interface ChatroomAgentHealthSkip {
@@ -47,7 +47,7 @@ export function filterHealthyChatroomAgents(
47
47
  skipped.push({ agentId, reason: 'missing_api_credentials' })
48
48
  continue
49
49
  }
50
- if (providerInfo.requiresEndpoint && !agent.apiEndpoint) {
50
+ if (providerInfo.requiresEndpoint && !resolveAgentApiEndpoint(agent)) {
51
51
  skipped.push({ agentId, reason: 'missing_api_endpoint' })
52
52
  continue
53
53
  }
@@ -57,4 +57,3 @@ export function filterHealthyChatroomAgents(
57
57
 
58
58
  return { healthyAgentIds, skipped }
59
59
  }
60
-