@swarmclawai/swarmclaw 0.6.7 → 0.6.8

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 (73) hide show
  1. package/README.md +24 -6
  2. package/package.json +1 -1
  3. package/src/app/api/agents/route.ts +1 -0
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -0
  5. package/src/app/api/eval/run/route.ts +37 -0
  6. package/src/app/api/eval/scenarios/route.ts +24 -0
  7. package/src/app/api/eval/suite/route.ts +29 -0
  8. package/src/app/api/memory/graph/route.ts +46 -0
  9. package/src/app/api/sessions/[id]/checkpoints/route.ts +31 -0
  10. package/src/app/api/sessions/[id]/restore/route.ts +36 -0
  11. package/src/app/api/souls/[id]/route.ts +65 -0
  12. package/src/app/api/souls/route.ts +70 -0
  13. package/src/app/api/tasks/[id]/route.ts +5 -0
  14. package/src/app/api/tasks/route.ts +2 -0
  15. package/src/app/api/usage/route.ts +9 -2
  16. package/src/cli/index.js +24 -0
  17. package/src/components/agents/agent-sheet.tsx +27 -6
  18. package/src/components/agents/soul-library-picker.tsx +84 -13
  19. package/src/components/chat/activity-moment.tsx +2 -0
  20. package/src/components/chat/checkpoint-timeline.tsx +112 -0
  21. package/src/components/chat/message-list.tsx +19 -3
  22. package/src/components/chat/session-debug-panel.tsx +106 -84
  23. package/src/components/chat/task-approval-card.tsx +78 -0
  24. package/src/components/chat/tool-call-bubble.tsx +3 -0
  25. package/src/components/connectors/connector-sheet.tsx +8 -1
  26. package/src/components/home/home-view.tsx +39 -15
  27. package/src/components/layout/app-layout.tsx +18 -2
  28. package/src/components/memory/memory-browser.tsx +73 -45
  29. package/src/components/memory/memory-graph-view.tsx +203 -0
  30. package/src/components/plugins/plugin-list.tsx +1 -1
  31. package/src/components/schedules/schedule-sheet.tsx +9 -2
  32. package/src/components/shared/hint-tip.tsx +31 -0
  33. package/src/components/shared/settings/section-runtime-loop.tsx +5 -4
  34. package/src/components/tasks/approvals-panel.tsx +120 -0
  35. package/src/components/usage/metrics-dashboard.tsx +25 -3
  36. package/src/lib/server/chat-execution.ts +96 -12
  37. package/src/lib/server/chatroom-helpers.ts +63 -5
  38. package/src/lib/server/chatroom-orchestration.ts +74 -0
  39. package/src/lib/server/context-manager.ts +132 -50
  40. package/src/lib/server/daemon-state.ts +70 -1
  41. package/src/lib/server/eval/runner.ts +126 -0
  42. package/src/lib/server/eval/scenarios.ts +218 -0
  43. package/src/lib/server/eval/scorer.ts +96 -0
  44. package/src/lib/server/eval/store.ts +37 -0
  45. package/src/lib/server/eval/types.ts +48 -0
  46. package/src/lib/server/execution-log.ts +12 -8
  47. package/src/lib/server/guardian.ts +34 -0
  48. package/src/lib/server/heartbeat-service.ts +53 -1
  49. package/src/lib/server/langgraph-checkpoint.ts +10 -0
  50. package/src/lib/server/link-understanding.ts +55 -0
  51. package/src/lib/server/main-agent-loop.ts +114 -15
  52. package/src/lib/server/memory-db.ts +18 -7
  53. package/src/lib/server/mmr.ts +73 -0
  54. package/src/lib/server/orchestrator-lg.ts +3 -0
  55. package/src/lib/server/plugins.ts +44 -22
  56. package/src/lib/server/query-expansion.ts +57 -0
  57. package/src/lib/server/queue.ts +27 -0
  58. package/src/lib/server/session-run-manager.ts +21 -1
  59. package/src/lib/server/session-tools/http.ts +19 -9
  60. package/src/lib/server/session-tools/index.ts +34 -0
  61. package/src/lib/server/session-tools/memory.ts +39 -11
  62. package/src/lib/server/session-tools/schedule.ts +43 -0
  63. package/src/lib/server/session-tools/web.ts +35 -11
  64. package/src/lib/server/storage.ts +12 -0
  65. package/src/lib/server/stream-agent-chat.ts +57 -8
  66. package/src/lib/server/tool-capability-policy.ts +1 -0
  67. package/src/lib/server/tool-retry.ts +62 -0
  68. package/src/lib/server/transcript-repair.ts +72 -0
  69. package/src/lib/setup-defaults.ts +1 -0
  70. package/src/lib/tool-definitions.ts +1 -0
  71. package/src/lib/validation/schemas.ts +1 -0
  72. package/src/lib/view-routes.ts +1 -0
  73. package/src/types/index.ts +34 -3
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs'
2
+ import os from 'os'
2
3
  import {
3
4
  loadSessions,
4
5
  saveSessions,
@@ -17,6 +18,7 @@ import { estimateCost, checkBudget } from './cost'
17
18
  import { log } from './logger'
18
19
  import { logExecution } from './execution-log'
19
20
  import { streamAgentChat } from './stream-agent-chat'
21
+ import { runLinkUnderstanding } from './link-understanding'
20
22
  import { buildSessionTools } from './session-tools'
21
23
  import { stripMainLoopMetaForPersistence } from './main-agent-loop'
22
24
  import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
@@ -74,6 +76,9 @@ export interface ExecuteChatTurnResult {
74
76
  persisted: boolean
75
77
  toolEvents: MessageToolEvent[]
76
78
  error?: string
79
+ inputTokens?: number
80
+ outputTokens?: number
81
+ estimatedCost?: number
77
82
  }
78
83
 
79
84
  function extractEventJson(line: string): SSEEvent | null {
@@ -389,18 +394,39 @@ function buildAgentSystemPrompt(session: any): string | undefined {
389
394
  if (!session.agentId) return undefined
390
395
  const agents = loadAgents()
391
396
  const agent = agents[session.agentId]
397
+ if (!agent) return undefined
392
398
 
393
399
  const settings = loadSettings()
394
400
  const parts: string[] = []
395
- // Identity block — agent needs to know who it is
396
- const identityLines = [`## My Identity`, `My name is ${agent.name}.`]
397
- if (agent.description) identityLines.push(agent.description)
398
- identityLines.push('I should always refer to myself by this name. I am not "Assistant" — I have my own name and identity.')
399
- parts.push(identityLines.join(' '))
400
- if (settings.userPrompt) parts.push(settings.userPrompt)
401
+
402
+ // 1. Identity & Persona (Grounded OpenClaw Style)
403
+ const identityLines = [`## My Identity`]
404
+ identityLines.push(`Name: ${agent.name}`)
405
+ if (agent.emoji) identityLines.push(`Emoji: ${agent.emoji}`)
406
+ if (agent.creature) identityLines.push(`Creature: ${agent.creature}`)
407
+ if (agent.vibe) identityLines.push(`Vibe: ${agent.vibe}`)
408
+ if (agent.theme) identityLines.push(`Theme: ${agent.theme}`)
409
+ if (agent.description) identityLines.push(`Description: ${agent.description}`)
410
+ 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.')
411
+ parts.push(identityLines.join('\n'))
412
+
413
+ // 2. Runtime & Capabilities (OpenClaw Style)
414
+ const runtimeLines = [
415
+ '## Runtime',
416
+ `os=${process.platform} | host=${os.hostname()} | agent=${agent.id} | provider=${agent.provider} | model=${agent.model}`,
417
+ `capabilities=tools,heartbeats,autonomous_loop,multi_agent_chat`,
418
+ ]
419
+ parts.push(runtimeLines.join('\n'))
420
+
421
+ // 3. User & DateTime Context
422
+ if (settings.userPrompt) parts.push(`## User Instructions\n${settings.userPrompt}`)
401
423
  parts.push(buildCurrentDateTimePromptContext())
402
- if (agent.soul) parts.push(agent.soul)
403
- if (agent.systemPrompt) parts.push(agent.systemPrompt)
424
+
425
+ // 4. Soul & Core Instructions
426
+ if (agent.soul) parts.push(`## Soul\n${agent.soul}`)
427
+ if (agent.systemPrompt) parts.push(`## System Prompt\n${agent.systemPrompt}`)
428
+
429
+ // 5. Skills (SwarmClaw Core)
404
430
  if (agent.skillIds?.length) {
405
431
  const allSkills = loadSkills()
406
432
  for (const skillId of agent.skillIds) {
@@ -408,7 +434,22 @@ function buildAgentSystemPrompt(session: any): string | undefined {
408
434
  if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
409
435
  }
410
436
  }
411
- if (!parts.length) return undefined
437
+
438
+ // 6. Thinking & Output Format (OpenClaw Style)
439
+ const thinkingHint = [
440
+ '## Output Format',
441
+ 'If your model supports internal reasoning/thinking, put all internal analysis inside <think>...</think> tags.',
442
+ 'Your final response to the user should be clear and concise.',
443
+ 'When you have nothing to say, respond with ONLY: NO_MESSAGE',
444
+ ]
445
+ parts.push(thinkingHint.join('\n'))
446
+
447
+ // 7. Heartbeat Guidance
448
+ parts.push([
449
+ '## Heartbeats',
450
+ 'You run on an autonomous heartbeat. If you receive a heartbeat poll and nothing needs attention, reply exactly: HEARTBEAT_OK',
451
+ ].join('\n'))
452
+
412
453
  return parts.join('\n\n')
413
454
  }
414
455
 
@@ -676,6 +717,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
676
717
  const apiKey = resolveApiKeyForSession(session, provider)
677
718
 
678
719
  if (!internal) {
720
+ const linkAnalysis = await runLinkUnderstanding(message)
679
721
  session.messages.push({
680
722
  role: 'user',
681
723
  text: message,
@@ -685,6 +727,14 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
685
727
  attachedFiles: attachedFiles?.length ? attachedFiles : undefined,
686
728
  replyToId: input.replyToId || undefined,
687
729
  })
730
+ if (linkAnalysis.length > 0) {
731
+ session.messages.push({
732
+ role: 'assistant',
733
+ kind: 'system',
734
+ text: `[Automated Link Analysis]\n${linkAnalysis.join('\n\n')}`,
735
+ time: Date.now(),
736
+ })
737
+ }
688
738
  session.lastActiveAt = Date.now()
689
739
  saveSessions(sessions)
690
740
  }
@@ -692,6 +742,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
692
742
  const systemPrompt = buildAgentSystemPrompt(session)
693
743
  const toolEvents: MessageToolEvent[] = []
694
744
  const streamErrors: string[] = []
745
+ const accumulatedUsage = { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }
695
746
 
696
747
  let thinkingText = ''
697
748
  const emit = (ev: SSEEvent) => {
@@ -705,6 +756,17 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
705
756
  if (ev.t === 'thinking' && ev.text) {
706
757
  thinkingText += ev.text
707
758
  }
759
+ if (ev.t === 'md' && ev.text) {
760
+ try {
761
+ const mdPayload = JSON.parse(ev.text) as Record<string, unknown>
762
+ const usage = mdPayload.usage as { inputTokens?: number; outputTokens?: number; estimatedCost?: number } | undefined
763
+ if (usage) {
764
+ if (typeof usage.inputTokens === 'number') accumulatedUsage.inputTokens += usage.inputTokens
765
+ if (typeof usage.outputTokens === 'number') accumulatedUsage.outputTokens += usage.outputTokens
766
+ if (typeof usage.estimatedCost === 'number') accumulatedUsage.estimatedCost += usage.estimatedCost
767
+ }
768
+ } catch { /* ignore non-JSON md events */ }
769
+ }
708
770
  collectToolEvent(ev, toolEvents)
709
771
  onEvent?.(ev)
710
772
  }
@@ -738,6 +800,8 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
738
800
  const directUsage = { inputTokens: 0, outputTokens: 0, received: false }
739
801
  const hasTools = !!sessionForRun.tools?.length && !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
740
802
 
803
+ let durationMs = 0
804
+ const startTs = Date.now()
741
805
  try {
742
806
  // Heartbeat runs get a small tail of recent messages so the agent can see
743
807
  // prior findings and avoid repeating the same searches. Full history is
@@ -747,7 +811,6 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
747
811
  : undefined
748
812
 
749
813
  console.log(`[chat-execution] provider=${providerType}, hasTools=${hasTools}, imagePath=${imagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, tools=${(sessionForRun.tools || []).length}`)
750
-
751
814
  fullResponse = hasTools
752
815
  ? (await streamAgentChat({
753
816
  session: sessionForRun,
@@ -772,8 +835,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
772
835
  onUsage: (u) => { directUsage.inputTokens = u.inputTokens; directUsage.outputTokens = u.outputTokens; directUsage.received = true },
773
836
  signal: abortController.signal,
774
837
  })
775
- } catch (err: any) {
776
- errorMessage = err?.message || String(err)
838
+ durationMs = Date.now() - startTs
839
+ } catch (err: unknown) {
840
+ errorMessage = err instanceof Error ? err.message : String(err)
777
841
  const failureText = errorMessage || 'Run failed.'
778
842
  markProviderFailure(providerType, failureText)
779
843
  emit({ t: 'err', text: failureText })
@@ -811,6 +875,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
811
875
  totalTokens,
812
876
  estimatedCost: cost,
813
877
  timestamp: Date.now(),
878
+ durationMs,
814
879
  }
815
880
  appendUsage(sessionId, usageRecord)
816
881
  emit({
@@ -1024,6 +1089,18 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1024
1089
  let heartbeatClassification: 'suppress' | 'strip' | 'keep' | null = null
1025
1090
  if (isHeartbeatRun && textForPersistence.length > 0) {
1026
1091
  heartbeatClassification = classifyHeartbeatResponse(textForPersistence, heartbeatConfig?.ackMaxChars ?? 300, toolEvents.length > 0)
1092
+
1093
+ // Deduplication logic from OpenClaw (nagging prevention)
1094
+ // If the model repeats itself exactly within 24h, suppress the heartbeat alert.
1095
+ if (heartbeatClassification !== 'suppress' && !toolEvents.length) {
1096
+ const prevText = session.lastHeartbeatText || ''
1097
+ const prevSentAt = session.lastHeartbeatSentAt || 0
1098
+ const isDuplicate = prevText.trim() === textForPersistence.trim()
1099
+ && (Date.now() - prevSentAt) < 24 * 60 * 60 * 1000
1100
+ if (isDuplicate) {
1101
+ heartbeatClassification = 'suppress'
1102
+ }
1103
+ }
1027
1104
  }
1028
1105
 
1029
1106
  // Emit WS notification for every heartbeat completion so UI can show pulse
@@ -1094,6 +1171,10 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1094
1171
  } else {
1095
1172
  current.messages.push(nextAssistantMessage)
1096
1173
  }
1174
+ if (isHeartbeatRun) {
1175
+ current.lastHeartbeatText = persistedText
1176
+ current.lastHeartbeatSentAt = nowTs
1177
+ }
1097
1178
  changed = true
1098
1179
 
1099
1180
  // Conversation tone detection
@@ -1184,5 +1265,8 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1184
1265
  persisted: shouldPersistAssistant,
1185
1266
  toolEvents,
1186
1267
  error: errorMessage,
1268
+ inputTokens: accumulatedUsage.inputTokens || undefined,
1269
+ outputTokens: accumulatedUsage.outputTokens || undefined,
1270
+ estimatedCost: accumulatedUsage.estimatedCost || undefined,
1187
1271
  }
1188
1272
  }
@@ -1,3 +1,4 @@
1
+ import os from 'os'
1
2
  import { loadSettings, loadSkills, loadCredentials, decryptKey } from './storage'
2
3
  import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
3
4
  import { genId } from '@/lib/id'
@@ -49,11 +50,15 @@ function truncateText(text: string, max: number): string {
49
50
  return `${compact.slice(0, Math.max(0, max - 3))}...`
50
51
  }
51
52
 
53
+ import { isImplicitlyMentioned } from './chatroom-orchestration'
54
+
52
55
  /** Parse @mentions from message text, returns matching agentIds */
53
56
  export function parseMentions(text: string, agents: Record<string, Agent>, memberIds: string[]): string[] {
54
57
  if (/@all\b/i.test(text)) return [...memberIds]
55
58
  const mentionPattern = /(?:^|[\s(])@([a-zA-Z0-9._-]+)/g
56
59
  const mentioned: string[] = []
60
+
61
+ // 1. Explicit @mentions
57
62
  let match: RegExpExecArray | null
58
63
  while ((match = mentionPattern.exec(text)) !== null) {
59
64
  const token = normalizeMentionToken(match[1] || '')
@@ -67,6 +72,18 @@ export function parseMentions(text: string, agents: Record<string, Agent>, membe
67
72
  }
68
73
  }
69
74
  }
75
+
76
+ // 2. Implicit mentions (OpenClaw Style - Reading the room)
77
+ // Only if no explicit mentions found yet
78
+ if (mentioned.length === 0) {
79
+ for (const id of memberIds) {
80
+ const agent = agents[id]
81
+ if (agent && isImplicitlyMentioned(text, agent)) {
82
+ mentioned.push(id)
83
+ }
84
+ }
85
+ }
86
+
70
87
  return mentioned
71
88
  }
72
89
 
@@ -144,6 +161,8 @@ export function buildChatroomSystemPrompt(chatroom: Chatroom, agents: Record<str
144
161
  '- **Handle greetings like a human.** For "hello", "how are you", or light check-ins, give a normal conversational reply instead of tool/process commentary.',
145
162
  '- **Keep responses short** unless depth is needed. A few sentences is usually enough. This is a chat, not an essay.',
146
163
  '- **@mention teammates** only when you genuinely need their specific expertise. Don\'t tag people just to be polite.',
164
+ '- **Use Reactions**: To acknowledge a message, agree with a plan, or signal progress without sending a full text reply, use this format at the end of your message: [REACTION]{"emoji": "👍", "to": "message_id"}.',
165
+ '- **Implicit Mentions**: If someone uses your name, creature, or vibe in a message but doesn\'t @tag you, they are still "reading the room" and you may respond if it\'s relevant to you.',
147
166
  '- **Don\'t narrate your capabilities** unless asked. Just demonstrate them by doing things.',
148
167
  '- **Read the room.** Look at recent messages to understand context. Don\'t repeat what others already said.',
149
168
  '',
@@ -173,14 +192,39 @@ export function buildSyntheticSession(agent: Agent, chatroomId: string): Session
173
192
  }
174
193
  }
175
194
 
176
- /** Build agent's system prompt including skills */
195
+ /** Build agent's system prompt including skills and identity context */
177
196
  export function buildAgentSystemPromptForChatroom(agent: Agent): string {
178
197
  const settings = loadSettings()
179
198
  const parts: string[] = []
180
- if (settings.userPrompt) parts.push(settings.userPrompt)
199
+
200
+ // 1. Identity & Persona (Grounded OpenClaw Style)
201
+ const identityLines = [`## My Identity`]
202
+ identityLines.push(`Name: ${agent.name}`)
203
+ if (agent.emoji) identityLines.push(`Emoji: ${agent.emoji}`)
204
+ if (agent.creature) identityLines.push(`Creature: ${agent.creature}`)
205
+ if (agent.vibe) identityLines.push(`Vibe: ${agent.vibe}`)
206
+ if (agent.theme) identityLines.push(`Theme: ${agent.theme}`)
207
+ if (agent.description) identityLines.push(`Description: ${agent.description}`)
208
+ identityLines.push('I should always embody this identity in the group chat. I am a persistent companion with my own soul and presence.')
209
+ parts.push(identityLines.join('\n'))
210
+
211
+ // 2. Runtime & Capabilities (OpenClaw Style)
212
+ const runtimeLines = [
213
+ '## Runtime',
214
+ `os=${process.platform} | host=${os.hostname()} | agent=${agent.id} | provider=${agent.provider} | model=${agent.model}`,
215
+ `capabilities=tools,multi_agent_chatroom,collaborative_reasoning`,
216
+ ]
217
+ parts.push(runtimeLines.join('\n'))
218
+
219
+ // 3. User & DateTime Context
220
+ if (settings.userPrompt) parts.push(`## User Instructions\n${settings.userPrompt}`)
181
221
  parts.push(buildCurrentDateTimePromptContext())
182
- if (agent.soul) parts.push(agent.soul)
183
- if (agent.systemPrompt) parts.push(agent.systemPrompt)
222
+
223
+ // 4. Soul & Core Instructions
224
+ if (agent.soul) parts.push(`## Soul\n${agent.soul}`)
225
+ if (agent.systemPrompt) parts.push(`## System Prompt\n${agent.systemPrompt}`)
226
+
227
+ // 5. Skills (SwarmClaw Core)
184
228
  if (agent.skillIds?.length) {
185
229
  const allSkills = loadSkills()
186
230
  for (const skillId of agent.skillIds) {
@@ -188,6 +232,16 @@ export function buildAgentSystemPromptForChatroom(agent: Agent): string {
188
232
  if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
189
233
  }
190
234
  }
235
+
236
+ // 6. Thinking & Output Format (OpenClaw Style)
237
+ const thinkingHint = [
238
+ '## Output Format',
239
+ 'If your model supports internal reasoning/thinking, put all internal analysis inside <think>...</think> tags.',
240
+ 'Your final response to the chatroom should be clear and concise.',
241
+ 'When you have nothing to say, respond with ONLY: NO_MESSAGE',
242
+ ]
243
+ parts.push(thinkingHint.join('\n'))
244
+
191
245
  return parts.join('\n\n')
192
246
  }
193
247
 
@@ -196,7 +250,11 @@ export function buildHistoryForAgent(chatroom: Chatroom, agentId: string, imageP
196
250
  const recentMessages = chatroom.messages.slice(-24)
197
251
  const includeAttachmentsFrom = Math.max(0, recentMessages.length - 6)
198
252
  const history = recentMessages.map((m, idx) => {
199
- let msgText = `[${m.senderName}]: ${m.text}`
253
+ let msgText = `[${m.senderName}] (id: ${m.id}): ${m.text}`
254
+ if (m.reactions?.length) {
255
+ const reactionSummary = m.reactions.map(r => `${r.emoji} by ${r.reactorId}`).join(', ')
256
+ msgText += `\n[Reactions: ${reactionSummary}]`
257
+ }
200
258
  const includeAttachments = idx >= includeAttachmentsFrom
201
259
  if (includeAttachments && m.attachedFiles?.length) {
202
260
  const names = m.attachedFiles.map((f) => f.split('/').pop()).join(', ')
@@ -0,0 +1,74 @@
1
+ import type { Chatroom, Agent } from '@/types'
2
+ import { loadChatrooms, saveChatrooms } from './storage'
3
+ import { notify } from './ws-hub'
4
+
5
+ /**
6
+ * Normalizes text for comparison (lowercase, alphanumeric only)
7
+ */
8
+ function normalizeForMatch(text: string): string {
9
+ return text.toLowerCase().replace(/[^a-z0-9]/g, '')
10
+ }
11
+
12
+ /**
13
+ * Determines if an agent was implicitly mentioned in a message.
14
+ * Matches against name, creature, and vibe.
15
+ */
16
+ export function isImplicitlyMentioned(text: string, agent: Agent): boolean {
17
+ const normText = normalizeForMatch(text)
18
+ const normName = normalizeForMatch(agent.name)
19
+ const normCreature = agent.creature ? normalizeForMatch(agent.creature) : null
20
+ const normVibe = agent.vibe ? normalizeForMatch(agent.vibe) : null
21
+
22
+ if (normText.includes(normName)) return true
23
+ if (normCreature && normText.includes(normCreature)) return true
24
+
25
+ // Vibe match: only if the vibe is a distinct single word like "skeptic" or "helper"
26
+ if (normVibe && normVibe.length > 3 && normVibe.split(' ').length === 1) {
27
+ if (normText.includes(normVibe)) return true
28
+ }
29
+
30
+ return false
31
+ }
32
+
33
+ /**
34
+ * Adds an "ack" reaction to a chatroom message on behalf of an agent.
35
+ * Useful for acknowledging tasks or agreeing with teammates.
36
+ */
37
+ export function addAgentReaction(chatroomId: string, messageId: string, agentId: string, emoji: string) {
38
+ const chatrooms = loadChatrooms()
39
+ const chatroom = chatrooms[chatroomId] as Chatroom | undefined
40
+ if (!chatroom) return
41
+
42
+ const message = chatroom.messages.find(m => m.id === messageId)
43
+ if (!message) return
44
+
45
+ // Prevent duplicate reactions from the same agent
46
+ if (message.reactions.some(r => r.reactorId === agentId && r.emoji === emoji)) return
47
+
48
+ message.reactions.push({
49
+ emoji,
50
+ reactorId: agentId,
51
+ time: Date.now()
52
+ })
53
+
54
+ chatrooms[chatroomId] = chatroom
55
+ saveChatrooms(chatrooms)
56
+ notify(`chatroom:${chatroomId}`)
57
+ }
58
+
59
+ /**
60
+ * Parses [REACTION] tokens from agent output and applies them.
61
+ * Format: [REACTION]{"emoji": "👍", "to": "msg_id"}
62
+ */
63
+ export function applyAgentReactionsFromText(text: string, chatroomId: string, agentId: string) {
64
+ const reactionRegex = /\[REACTION\]\s*(\{.*?\})/g
65
+ let match
66
+ while ((match = reactionRegex.exec(text)) !== null) {
67
+ try {
68
+ const data = JSON.parse(match[1])
69
+ if (data.emoji && data.to) {
70
+ addAgentReaction(chatroomId, data.to, agentId, data.emoji)
71
+ }
72
+ } catch { /* ignore invalid JSON */ }
73
+ }
74
+ }
@@ -1,14 +1,25 @@
1
1
  import type { Message } from '@/types'
2
2
  import { getMemoryDb } from './memory-db'
3
3
 
4
+ import { repairTranscriptConsistency } from './transcript-repair'
5
+
4
6
  // --- LLM compaction constants ---
5
7
 
6
- const COMPACTION_CHUNK_BUDGET_RATIO = 0.4 // 40% of context per summarization chunk
7
- const COMPACTION_SAFETY_MARGIN = 1.2 // 20% buffer for token underestimation
8
- const COMPACTION_OVERHEAD_TOKENS = 4096 // reserved for summarization prompt + response
8
+ const BASE_CHUNK_RATIO = 0.4
9
+ const MIN_CHUNK_RATIO = 0.15
10
+ const COMPACTION_SAFETY_MARGIN = 1.2
11
+ const COMPACTION_OVERHEAD_TOKENS = 4096
9
12
  const MAX_TOOL_FAILURES = 8
10
13
  const MAX_FAILURE_CHARS = 240
11
14
 
15
+ const MERGE_SUMMARIES_INSTRUCTIONS =
16
+ 'Merge these partial summaries into a single cohesive summary. Preserve decisions,' +
17
+ ' TODOs, open questions, and any constraints.'
18
+
19
+ const IDENTIFIER_PRESERVATION_INSTRUCTIONS =
20
+ 'Preserve all opaque identifiers exactly as written (no shortening or reconstruction), ' +
21
+ 'including UUIDs, hashes, IDs, tokens, API keys, hostnames, IPs, ports, URLs, and file names.'
22
+
12
23
  /** Callback that sends a prompt to an LLM and returns response text */
13
24
  export type LLMSummarizer = (prompt: string) => Promise<string>
14
25
 
@@ -132,6 +143,44 @@ export function getContextStatus(
132
143
  }
133
144
  }
134
145
 
146
+ // --- Context degradation warnings ---
147
+
148
+ /** Returns a warning string when context usage exceeds thresholds, or null if within safe bounds. */
149
+ export function getContextDegradationWarning(
150
+ messages: Message[],
151
+ systemPromptTokens: number,
152
+ provider: string,
153
+ model: string,
154
+ ): string | null {
155
+ const status = getContextStatus(messages, systemPromptTokens, provider, model)
156
+ const pct = status.percentUsed
157
+ const remaining = status.contextWindow - status.estimatedTokens
158
+ const estTurnsLeft = Math.max(0, Math.floor(remaining / 2000))
159
+
160
+ if (pct >= 85) {
161
+ return [
162
+ `[CONTEXT_WARNING] Context window is ${pct}% full (${status.estimatedTokens.toLocaleString()} / ${status.contextWindow.toLocaleString()} tokens).`,
163
+ `Estimated remaining capacity: ~${estTurnsLeft} turns.`,
164
+ 'CRITICAL: Save essential state to memory immediately. Summarize key findings, decisions, and next steps.',
165
+ 'Consider completing the current subtask and storing a checkpoint before context is exhausted.',
166
+ ].join(' ')
167
+ }
168
+ if (pct >= 70) {
169
+ return [
170
+ `[CONTEXT_WARNING] Context window is ${pct}% full.`,
171
+ `Estimated remaining capacity: ~${estTurnsLeft} turns.`,
172
+ 'Recommended: Store important progress notes to memory. Prioritize completing high-value subtasks.',
173
+ ].join(' ')
174
+ }
175
+ if (pct >= 60) {
176
+ return [
177
+ `[CONTEXT_WARNING] Context window is ${pct}% full (~${estTurnsLeft} turns remaining).`,
178
+ 'Consider saving intermediate state to memory for continuity.',
179
+ ].join(' ')
180
+ }
181
+ return null
182
+ }
183
+
135
184
  // --- Memory consolidation ---
136
185
 
137
186
  /** Extract important facts from old messages before pruning */
@@ -240,6 +289,54 @@ export function splitMessagesByTokenBudget(messages: Message[], budgetPerChunk:
240
289
  return chunks
241
290
  }
242
291
 
292
+ /** Compute adaptive chunk ratio based on average message size. */
293
+ export function computeAdaptiveChunkRatio(messages: Message[], contextWindow: number): number {
294
+ if (messages.length === 0) return BASE_CHUNK_RATIO
295
+ const totalTokens = estimateMessagesTokens(messages)
296
+ const avgTokens = totalTokens / messages.length
297
+ const safeAvgTokens = avgTokens * COMPACTION_SAFETY_MARGIN
298
+ const avgRatio = safeAvgTokens / contextWindow
299
+
300
+ if (avgRatio > 0.1) {
301
+ const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO)
302
+ return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction)
303
+ }
304
+ return BASE_CHUNK_RATIO
305
+ }
306
+
307
+ /** Summarize in hierarchical stages if context is very large */
308
+ export async function summarizeInStages(opts: {
309
+ messages: Message[]
310
+ contextWindow: number
311
+ summarize: LLMSummarizer
312
+ maxChunkTokens: number
313
+ }): Promise<string> {
314
+ const { messages, summarize, maxChunkTokens } = opts
315
+ const totalTokens = estimateMessagesTokens(messages)
316
+
317
+ if (totalTokens <= maxChunkTokens || messages.length < 4) {
318
+ return summarize(buildSummarizationPrompt(messages))
319
+ }
320
+
321
+ const chunks = splitMessagesByTokenBudget(messages, maxChunkTokens)
322
+ if (chunks.length <= 1) {
323
+ return summarize(buildSummarizationPrompt(messages))
324
+ }
325
+
326
+ const partialSummaries: string[] = []
327
+ for (const chunk of chunks) {
328
+ try {
329
+ const partial = await summarize(buildSummarizationPrompt(chunk))
330
+ if (partial?.trim()) partialSummaries.push(partial.trim())
331
+ } catch { /* skip failed chunk */ }
332
+ }
333
+
334
+ if (partialSummaries.length === 0) return 'Summary unavailable.'
335
+ if (partialSummaries.length === 1) return partialSummaries[0]
336
+
337
+ return summarize(buildMergePrompt(partialSummaries))
338
+ }
339
+
243
340
  /** Build an OpenClaw-aligned summarization prompt for a batch of messages */
244
341
  function buildSummarizationPrompt(messages: Message[]): string {
245
342
  const transcript = messages.map((m) => {
@@ -258,13 +355,13 @@ function buildSummarizationPrompt(messages: Message[]): string {
258
355
  'Summarize the following conversation transcript into structured notes.',
259
356
  '',
260
357
  'Rules:',
261
- '- Preserve all decisions, TODOs, open questions, and constraints',
262
- '- Preserve all opaque identifiers exactly as they appear (UUIDs, hashes, IDs, URLs, file paths, API keys, variable names)',
263
- '- Note errors encountered and their resolutions',
264
- '- Keep technical details needed to continue work (versions, configs, commands)',
265
- '- Aim for 20-40% of original length',
266
- '- Use structured notes with bullet points, not narrative prose',
267
- '- Group by topic/theme when possible',
358
+ '- Preserve all decisions, TODOs, open questions, and any constraints.',
359
+ `- ${IDENTIFIER_PRESERVATION_INSTRUCTIONS}`,
360
+ '- Note errors encountered and their resolutions.',
361
+ '- Keep technical details needed to continue work (versions, configs, commands).',
362
+ '- Aim for 20-40% of original length.',
363
+ '- Use structured notes with bullet points, not narrative prose.',
364
+ '- Group by topic/theme when possible.',
268
365
  '',
269
366
  '---TRANSCRIPT---',
270
367
  transcript,
@@ -280,11 +377,12 @@ function buildMergePrompt(partialSummaries: string[]): string {
280
377
  'Merge the following partial conversation summaries into a single cohesive summary.',
281
378
  '',
282
379
  'Rules:',
283
- '- Remove redundancy across parts while preserving all important details',
284
- '- Preserve all opaque identifiers exactly (UUIDs, hashes, IDs, URLs, file paths)',
285
- '- Keep decisions, TODOs, open questions, constraints, and error resolutions',
286
- '- Use structured notes with bullet points',
287
- '- The result should be shorter than the combined input',
380
+ '- Remove redundancy across parts while preserving all important details.',
381
+ `- ${MERGE_SUMMARIES_INSTRUCTIONS}`,
382
+ `- ${IDENTIFIER_PRESERVATION_INSTRUCTIONS}`,
383
+ '- Keep decisions, TODOs, open questions, constraints, and error resolutions.',
384
+ '- Use structured notes with bullet points.',
385
+ '- The result should be shorter than the combined input.',
288
386
  '',
289
387
  numbered,
290
388
  ].join('\n')
@@ -324,62 +422,46 @@ export async function llmCompact(opts: {
324
422
  return { messages, prunedCount: 0, memoriesStored: 0, summaryAdded: false }
325
423
  }
326
424
 
327
- const oldMessages = messages.slice(0, -keepLastN)
328
- const recentMessages = messages.slice(-keepLastN)
425
+ const repaired = repairTranscriptConsistency(messages)
426
+ const oldMessages = repaired.slice(0, -keepLastN)
427
+ const recentMessages = repaired.slice(-keepLastN)
329
428
 
330
- // 1. Consolidate important info to memory (existing regex extraction)
429
+ // 1. Consolidate important info to memory
331
430
  const memoriesStored = consolidateToMemory(oldMessages, agentId, sessionId)
332
431
 
333
- // 2. Extract metadata from old messages
432
+ // 2. Extract metadata
334
433
  const toolFailures = extractToolFailures(oldMessages)
335
434
  const fileOps = extractFileOperations(oldMessages)
336
435
 
337
- // 3. Compute chunk budget
436
+ // 3. Compute adaptive budget
338
437
  const contextWindow = getContextWindowSize(provider, model)
339
- const chunkBudget = Math.floor((contextWindow / COMPACTION_SAFETY_MARGIN) * COMPACTION_CHUNK_BUDGET_RATIO) - COMPACTION_OVERHEAD_TOKENS
438
+ const ratio = computeAdaptiveChunkRatio(oldMessages, contextWindow)
439
+ const chunkBudget = Math.floor((contextWindow / COMPACTION_SAFETY_MARGIN) * ratio) - COMPACTION_OVERHEAD_TOKENS
340
440
 
341
- // 4. Split old messages into chunks
342
- const chunks = splitMessagesByTokenBudget(oldMessages, Math.max(chunkBudget, 2000))
343
-
344
- // 5. Summarize chunks (progressive fallback on failure)
441
+ // 4. Hierarchical summarization
345
442
  let finalSummary: string | null = null
346
443
  try {
347
- if (chunks.length === 1) {
348
- finalSummary = await summarize(buildSummarizationPrompt(chunks[0]))
349
- } else {
350
- // Multi-chunk: summarize each, then merge
351
- const partialSummaries: string[] = []
352
- for (const chunk of chunks) {
353
- try {
354
- const partial = await summarize(buildSummarizationPrompt(chunk))
355
- if (partial?.trim()) partialSummaries.push(partial.trim())
356
- } catch {
357
- // Skip failed chunks — progressive fallback
358
- }
359
- }
360
- if (partialSummaries.length === 0) {
361
- finalSummary = null // all chunks failed
362
- } else if (partialSummaries.length === 1) {
363
- finalSummary = partialSummaries[0]
364
- } else {
365
- finalSummary = await summarize(buildMergePrompt(partialSummaries))
366
- }
367
- }
444
+ finalSummary = await summarizeInStages({
445
+ messages: oldMessages,
446
+ contextWindow,
447
+ summarize,
448
+ maxChunkTokens: Math.max(chunkBudget, 2000),
449
+ })
368
450
  } catch {
369
451
  finalSummary = null
370
452
  }
371
453
 
372
- // 6. Fall back to sliding window if LLM summarization failed entirely
454
+ // 5. Fall back to sliding window if LLM summarization failed entirely
373
455
  if (!finalSummary?.trim()) {
374
456
  return {
375
- messages: slidingWindowCompact(messages, keepLastN),
457
+ messages: slidingWindowCompact(repaired, keepLastN),
376
458
  prunedCount: oldMessages.length,
377
459
  memoriesStored,
378
460
  summaryAdded: false,
379
461
  }
380
462
  }
381
463
 
382
- // 7. Append metadata sections
464
+ // 6. Append metadata sections
383
465
  const metaSections: string[] = [finalSummary.trim()]
384
466
 
385
467
  if (toolFailures.length > 0) {
@@ -392,7 +474,7 @@ export async function llmCompact(opts: {
392
474
  metaSections.push('\n## File Operations\n' + parts.join('\n'))
393
475
  }
394
476
 
395
- // 8. Build context summary message
477
+ // 7. Build context summary message
396
478
  const summaryMessage: Message = {
397
479
  role: 'assistant',
398
480
  text: `[Context Summary]\n${metaSections.join('\n')}`,