@swarmclawai/swarmclaw 0.6.0 → 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 (109) hide show
  1. package/README.md +15 -2
  2. package/bin/server-cmd.js +1 -0
  3. package/package.json +2 -1
  4. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
  6. package/src/app/api/connectors/[id]/route.ts +1 -0
  7. package/src/app/api/connectors/route.ts +2 -1
  8. package/src/app/api/files/open/route.ts +43 -0
  9. package/src/app/api/search/route.ts +9 -7
  10. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  11. package/src/app/api/sessions/[id]/route.ts +4 -0
  12. package/src/app/api/tasks/metrics/route.ts +101 -0
  13. package/src/app/api/tasks/route.ts +17 -2
  14. package/src/app/api/tts/route.ts +3 -2
  15. package/src/app/api/tts/stream/route.ts +3 -2
  16. package/src/app/api/uploads/[filename]/route.ts +19 -34
  17. package/src/app/api/uploads/route.ts +94 -0
  18. package/src/app/globals.css +5 -0
  19. package/src/cli/index.js +16 -1
  20. package/src/cli/spec.js +26 -0
  21. package/src/components/agents/agent-card.tsx +3 -3
  22. package/src/components/agents/agent-chat-list.tsx +29 -6
  23. package/src/components/agents/agent-sheet.tsx +66 -4
  24. package/src/components/agents/inspector-panel.tsx +81 -6
  25. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  26. package/src/components/agents/personality-builder.tsx +42 -14
  27. package/src/components/agents/soul-library-picker.tsx +89 -0
  28. package/src/components/canvas/canvas-panel.tsx +96 -0
  29. package/src/components/chat/activity-moment.tsx +8 -4
  30. package/src/components/chat/chat-area.tsx +46 -22
  31. package/src/components/chat/chat-header.tsx +455 -286
  32. package/src/components/chat/chat-preview-panel.tsx +1 -2
  33. package/src/components/chat/delegation-banner.tsx +371 -0
  34. package/src/components/chat/file-path-chip.tsx +23 -2
  35. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  36. package/src/components/chat/message-bubble.tsx +315 -25
  37. package/src/components/chat/message-list.tsx +180 -7
  38. package/src/components/chat/streaming-bubble.tsx +68 -1
  39. package/src/components/chat/tool-call-bubble.tsx +45 -3
  40. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  41. package/src/components/chatrooms/chatroom-list.tsx +8 -1
  42. package/src/components/chatrooms/chatroom-message.tsx +8 -3
  43. package/src/components/chatrooms/chatroom-view.tsx +3 -3
  44. package/src/components/connectors/connector-list.tsx +168 -90
  45. package/src/components/connectors/connector-sheet.tsx +68 -16
  46. package/src/components/home/home-view.tsx +1 -1
  47. package/src/components/input/chat-input.tsx +28 -2
  48. package/src/components/layout/app-layout.tsx +19 -2
  49. package/src/components/projects/project-detail.tsx +1 -1
  50. package/src/components/schedules/schedule-sheet.tsx +260 -127
  51. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  52. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  53. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  54. package/src/components/shared/connector-platform-icon.tsx +51 -4
  55. package/src/components/shared/icon-button.tsx +16 -2
  56. package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
  57. package/src/components/shared/search-dialog.tsx +17 -10
  58. package/src/components/shared/settings/section-embedding.tsx +48 -13
  59. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  60. package/src/components/shared/settings/section-storage.tsx +206 -0
  61. package/src/components/shared/settings/section-user-preferences.tsx +18 -0
  62. package/src/components/shared/settings/section-voice.tsx +42 -21
  63. package/src/components/shared/settings/section-web-search.tsx +30 -6
  64. package/src/components/shared/settings/settings-page.tsx +3 -1
  65. package/src/components/shared/settings/storage-browser.tsx +259 -0
  66. package/src/components/tasks/task-card.tsx +14 -1
  67. package/src/components/tasks/task-sheet.tsx +328 -3
  68. package/src/components/usage/metrics-dashboard.tsx +90 -6
  69. package/src/hooks/use-continuous-speech.ts +10 -4
  70. package/src/hooks/use-voice-conversation.ts +53 -10
  71. package/src/hooks/use-ws.ts +4 -2
  72. package/src/lib/providers/anthropic.ts +13 -7
  73. package/src/lib/providers/index.ts +1 -0
  74. package/src/lib/providers/openai.ts +13 -7
  75. package/src/lib/server/chat-execution.ts +51 -11
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/manager.ts +218 -7
  78. package/src/lib/server/heartbeat-service.ts +8 -1
  79. package/src/lib/server/main-agent-loop.ts +1 -1
  80. package/src/lib/server/memory-consolidation.ts +15 -2
  81. package/src/lib/server/memory-db.ts +134 -6
  82. package/src/lib/server/mime.ts +51 -0
  83. package/src/lib/server/openclaw-gateway.ts +2 -2
  84. package/src/lib/server/orchestrator-lg.ts +2 -0
  85. package/src/lib/server/orchestrator.ts +5 -2
  86. package/src/lib/server/playwright-proxy.mjs +2 -3
  87. package/src/lib/server/prompt-runtime-context.ts +53 -0
  88. package/src/lib/server/queue.ts +52 -7
  89. package/src/lib/server/session-tools/canvas.ts +67 -0
  90. package/src/lib/server/session-tools/connector.ts +83 -9
  91. package/src/lib/server/session-tools/crud.ts +21 -0
  92. package/src/lib/server/session-tools/delegate.ts +68 -4
  93. package/src/lib/server/session-tools/git.ts +71 -0
  94. package/src/lib/server/session-tools/http.ts +57 -0
  95. package/src/lib/server/session-tools/index.ts +8 -0
  96. package/src/lib/server/session-tools/memory.ts +1 -0
  97. package/src/lib/server/session-tools/search-providers.ts +16 -8
  98. package/src/lib/server/session-tools/subagent.ts +106 -0
  99. package/src/lib/server/session-tools/web.ts +115 -4
  100. package/src/lib/server/stream-agent-chat.ts +32 -10
  101. package/src/lib/server/task-mention.ts +41 -0
  102. package/src/lib/sessions.ts +10 -0
  103. package/src/lib/soul-library.ts +103 -0
  104. package/src/lib/task-dedupe.ts +26 -0
  105. package/src/lib/tool-definitions.ts +2 -0
  106. package/src/lib/tts.ts +2 -2
  107. package/src/stores/use-app-store.ts +5 -1
  108. package/src/stores/use-chat-store.ts +65 -2
  109. package/src/types/index.ts +32 -2
@@ -2,14 +2,27 @@ import { genId } from '@/lib/id'
2
2
  import {
3
3
  loadConnectors, saveConnectors, loadSessions, saveSessions,
4
4
  loadAgents, loadCredentials, decryptKey, loadSettings, loadSkills,
5
+ loadChatrooms, saveChatrooms,
5
6
  } from '../storage'
6
7
  import { WORKSPACE_DIR } from '../data-dir'
8
+ import { UPLOAD_DIR } from '../storage'
9
+ import fs from 'fs'
10
+ import path from 'path'
7
11
  import { streamAgentChat } from '../stream-agent-chat'
8
12
  import { notify } from '../ws-hub'
9
13
  import { logExecution } from '../execution-log'
10
14
  import { enqueueSystemEvent } from '../system-events'
11
15
  import { requestHeartbeatNow } from '../heartbeat-wake'
12
- import type { Connector } from '@/types'
16
+ import { buildCurrentDateTimePromptContext } from '../prompt-runtime-context'
17
+ import {
18
+ parseMentions,
19
+ buildChatroomSystemPrompt,
20
+ buildSyntheticSession,
21
+ buildAgentSystemPromptForChatroom,
22
+ buildHistoryForAgent,
23
+ resolveApiKey as resolveApiKeyHelper,
24
+ } from '../chatroom-helpers'
25
+ import type { Connector, MessageSource, Chatroom, ChatroomMessage } from '@/types'
13
26
  import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
14
27
  import {
15
28
  addAllowedSender,
@@ -23,6 +36,30 @@ import {
23
36
  type PairingPolicy,
24
37
  } from './pairing'
25
38
 
39
+ /**
40
+ * Extract embedded media references from agent response text.
41
+ * Parses markdown image/link patterns like ![alt](/api/uploads/filename)
42
+ * and resolves them to actual file paths on disk.
43
+ */
44
+ function extractEmbeddedMedia(text: string): { cleanText: string; files: Array<{ path: string; alt: string }> } {
45
+ const files: Array<{ path: string; alt: string }> = []
46
+ // Match markdown images: ![alt](/api/uploads/filename)
47
+ const imgRegex = /!\[([^\]]*)\]\(\/api\/uploads\/([^)]+)\)/g
48
+ let match: RegExpExecArray | null
49
+ while ((match = imgRegex.exec(text)) !== null) {
50
+ const [, alt, filename] = match
51
+ const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '')
52
+ const filePath = path.join(UPLOAD_DIR, safeName)
53
+ if (fs.existsSync(filePath)) {
54
+ files.push({ path: filePath, alt: alt || '' })
55
+ }
56
+ }
57
+ if (files.length === 0) return { cleanText: text, files }
58
+ // Strip the image markdown from text — the files will be sent as separate media
59
+ const cleanText = text.replace(imgRegex, '').replace(/\n{3,}/g, '\n\n').trim()
60
+ return { cleanText, files }
61
+ }
62
+
26
63
  /** Sentinel value agents return when no outbound reply should be sent */
27
64
  export const NO_MESSAGE_SENTINEL = 'NO_MESSAGE'
28
65
 
@@ -421,6 +458,132 @@ async function handleConnectorCommand(params: {
421
458
  return 'Unknown command.'
422
459
  }
423
460
 
461
+ /** Route an inbound message to a chatroom — process mentioned agents and return concatenated responses */
462
+ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage): Promise<string> {
463
+ const chatroomId = connector.chatroomId
464
+ if (!chatroomId) return '[Error] No chatroom configured.'
465
+
466
+ const chatrooms = loadChatrooms()
467
+ const chatroom = chatrooms[chatroomId] as Chatroom | undefined
468
+ if (!chatroom) return '[Error] Chatroom not found.'
469
+
470
+ const agents = loadAgents()
471
+ const source: MessageSource = {
472
+ platform: connector.platform,
473
+ connectorId: connector.id,
474
+ connectorName: connector.name,
475
+ senderName: msg.senderName,
476
+ }
477
+
478
+ // Parse mentions from the message text
479
+ let mentions = parseMentions(msg.text || '', agents, chatroom.agentIds)
480
+ // Auto-address: if enabled and no explicit mentions, address all agents
481
+ if (chatroom.autoAddress && mentions.length === 0) {
482
+ mentions = [...chatroom.agentIds]
483
+ }
484
+
485
+ // Create and persist the user message in the chatroom
486
+ const userMessage: ChatroomMessage = {
487
+ id: genId(),
488
+ senderId: 'user',
489
+ senderName: msg.senderName || 'User',
490
+ role: 'user',
491
+ text: msg.text || '',
492
+ mentions,
493
+ reactions: [],
494
+ time: Date.now(),
495
+ source,
496
+ }
497
+ chatroom.messages.push(userMessage)
498
+ chatroom.updatedAt = Date.now()
499
+ chatrooms[chatroomId] = chatroom
500
+ saveChatrooms(chatrooms)
501
+ notify('chatrooms')
502
+ notify(`chatroom:${chatroomId}`)
503
+
504
+ // Process mentioned agents sequentially and collect responses
505
+ const responses: string[] = []
506
+ for (const agentId of mentions) {
507
+ const agent = agents[agentId]
508
+ if (!agent) continue
509
+
510
+ const apiKey = resolveApiKeyHelper(agent.credentialId)
511
+ const freshChatrooms = loadChatrooms()
512
+ const freshChatroom = freshChatrooms[chatroomId] as Chatroom
513
+
514
+ const syntheticSession = buildSyntheticSession(agent, chatroomId)
515
+ const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
516
+ const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
517
+ const fullSystemPrompt = [agentSystemPrompt, chatroomContext].filter(Boolean).join('\n\n')
518
+ const history = buildHistoryForAgent(freshChatroom, agent.id)
519
+
520
+ try {
521
+ const result = await streamAgentChat({
522
+ session: syntheticSession,
523
+ message: msg.text || '',
524
+ apiKey,
525
+ systemPrompt: fullSystemPrompt,
526
+ write: () => {},
527
+ history,
528
+ })
529
+
530
+ const responseText = result.finalResponse || result.fullText
531
+ if (responseText.trim() && !isNoMessage(responseText)) {
532
+ // Persist agent response to chatroom
533
+ const agentSource: MessageSource = {
534
+ platform: connector.platform,
535
+ connectorId: connector.id,
536
+ connectorName: connector.name,
537
+ }
538
+ const agentMessage: ChatroomMessage = {
539
+ id: genId(),
540
+ senderId: agent.id,
541
+ senderName: agent.name,
542
+ role: 'assistant',
543
+ text: responseText,
544
+ mentions: parseMentions(responseText, agents, freshChatroom.agentIds),
545
+ reactions: [],
546
+ time: Date.now(),
547
+ source: agentSource,
548
+ }
549
+ const latestChatrooms = loadChatrooms()
550
+ const latestChatroom = latestChatrooms[chatroomId] as Chatroom
551
+ latestChatroom.messages.push(agentMessage)
552
+ latestChatroom.updatedAt = Date.now()
553
+ latestChatrooms[chatroomId] = latestChatroom
554
+ saveChatrooms(latestChatrooms)
555
+ notify(`chatroom:${chatroomId}`)
556
+
557
+ responses.push(`[${agent.name}] ${responseText}`)
558
+ }
559
+ } catch (err: unknown) {
560
+ const errMsg = err instanceof Error ? err.message : String(err)
561
+ console.error(`[connector] Chatroom agent ${agent.name} error:`, errMsg)
562
+ }
563
+ }
564
+
565
+ if (responses.length === 0) return NO_MESSAGE_SENTINEL
566
+
567
+ const joined = responses.join('\n\n')
568
+ // Extract embedded media from agent responses and send them via connector
569
+ const extracted = extractEmbeddedMedia(joined)
570
+ if (extracted.files.length > 0) {
571
+ const inst = running.get(connector.id)
572
+ if (inst?.sendMessage) {
573
+ for (const file of extracted.files) {
574
+ try {
575
+ await inst.sendMessage(msg.channelId, '', { mediaPath: file.path, caption: file.alt || undefined })
576
+ console.log(`[connector] Sent chatroom media to ${msg.platform}: ${path.basename(file.path)}`)
577
+ } catch (err: unknown) {
578
+ console.error(`[connector] Failed to send chatroom media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
579
+ }
580
+ }
581
+ }
582
+ return extracted.cleanText || '(no response)'
583
+ }
584
+ return joined
585
+ }
586
+
424
587
  /** Route an inbound message through the assigned agent and return the response */
425
588
  async function routeMessage(connector: Connector, msg: InboundMessage): Promise<string> {
426
589
  if (msg?.channelId) {
@@ -428,8 +591,14 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
428
591
  }
429
592
  lastInboundTimeByConnector.set(connector.id, Date.now())
430
593
 
594
+ // Route to chatroom if configured
595
+ if (connector.chatroomId) {
596
+ return routeMessageToChatroom(connector, msg)
597
+ }
598
+
431
599
  const agents = loadAgents()
432
600
  const effectiveAgentId = msg.agentIdOverride || connector.agentId
601
+ if (!effectiveAgentId) return '[Error] Connector has no agent configured.'
433
602
  const agent = agents[effectiveAgentId]
434
603
  if (!agent) return '[Error] Connector agent not found.'
435
604
 
@@ -472,11 +641,16 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
472
641
  }
473
642
  }
474
643
 
475
- // Find or create a session keyed by platform + channel
644
+ // Find a session for this connector message.
645
+ // Prefer the agent's thread session (visible in the agent chat UI) so connector
646
+ // messages appear inline alongside web UI messages.
647
+ // Fall back to a connector-keyed session if the agent has no thread session.
476
648
  const sessionKey = `connector:${connector.id}:${msg.channelId}`
477
649
  const sessions = loadSessions()
478
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
479
- let session = Object.values(sessions).find((s: any) => s.name === sessionKey)
650
+ let session = (agent.threadSessionId && sessions[agent.threadSessionId])
651
+ ? sessions[agent.threadSessionId]
652
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
653
+ : Object.values(sessions).find((s: any) => s.name === sessionKey)
480
654
  if (!session) {
481
655
  const id = genId()
482
656
  session = {
@@ -564,6 +738,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
564
738
  const settings = loadSettings()
565
739
  const promptParts: string[] = []
566
740
  if (settings.userPrompt) promptParts.push(settings.userPrompt)
741
+ promptParts.push(buildCurrentDateTimePromptContext())
567
742
  if (agent.soul) promptParts.push(agent.soul)
568
743
  if (agent.systemPrompt) promptParts.push(agent.systemPrompt)
569
744
  if (agent.skillIds?.length) {
@@ -594,12 +769,22 @@ The test: would a thoughtful friend feel compelled to type something back? If no
594
769
  const firstImageUrl = msg.imageUrl || (firstImage?.url) || undefined
595
770
  const firstImagePath = firstImage?.localPath || undefined
596
771
  const inboundText = formatInboundUserText(msg)
772
+ // Store the raw user text for display (source.senderName handles attribution).
773
+ // The formatted text with [SenderName] prefix is only used for LLM history context.
774
+ const rawText = (msg.text || '').trim()
775
+ const messageSource: MessageSource = {
776
+ platform: connector.platform,
777
+ connectorId: connector.id,
778
+ connectorName: connector.name,
779
+ senderName: msg.senderName,
780
+ }
597
781
  session.messages.push({
598
782
  role: 'user',
599
- text: inboundText,
783
+ text: rawText || inboundText,
600
784
  time: Date.now(),
601
785
  imageUrl: firstImageUrl,
602
786
  imagePath: firstImagePath,
787
+ source: messageSource,
603
788
  })
604
789
  session.lastActiveAt = Date.now()
605
790
  const s1 = loadSessions()
@@ -679,9 +864,14 @@ The test: would a thoughtful friend feel compelled to type something back? If no
679
864
  },
680
865
  })
681
866
 
682
- // Save assistant response to session
867
+ // Save assistant response to session (full text with image markdown for web UI rendering)
868
+ const assistantSource: MessageSource = {
869
+ platform: connector.platform,
870
+ connectorId: connector.id,
871
+ connectorName: connector.name,
872
+ }
683
873
  if (fullText.trim()) {
684
- session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now() })
874
+ session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now(), source: assistantSource })
685
875
  session.lastActiveAt = Date.now()
686
876
  const s2 = loadSessions()
687
877
  s2[session.id] = session
@@ -689,6 +879,24 @@ The test: would a thoughtful friend feel compelled to type something back? If no
689
879
  notify(`messages:${session.id}`)
690
880
  }
691
881
 
882
+ // Extract embedded media (screenshots, uploaded files) and send them as separate
883
+ // media messages via the connector, then return the cleaned text
884
+ const extracted = extractEmbeddedMedia(fullText)
885
+ if (extracted.files.length > 0) {
886
+ const inst = running.get(connector.id)
887
+ if (inst?.sendMessage) {
888
+ for (const file of extracted.files) {
889
+ try {
890
+ await inst.sendMessage(msg.channelId, '', { mediaPath: file.path, caption: file.alt || undefined })
891
+ console.log(`[connector] Sent media to ${msg.platform}: ${path.basename(file.path)}`)
892
+ } catch (err: unknown) {
893
+ console.error(`[connector] Failed to send media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
894
+ }
895
+ }
896
+ }
897
+ return extracted.cleanText || '(no response)'
898
+ }
899
+
692
900
  return fullText || '(no response)'
693
901
  }
694
902
 
@@ -873,6 +1081,7 @@ export function listRunningConnectors(platform?: string): Array<{
873
1081
  id: string
874
1082
  name: string
875
1083
  platform: string
1084
+ agentId: string | null
876
1085
  supportsSend: boolean
877
1086
  configuredTargets: string[]
878
1087
  recentChannelId: string | null
@@ -882,6 +1091,7 @@ export function listRunningConnectors(platform?: string): Array<{
882
1091
  id: string
883
1092
  name: string
884
1093
  platform: string
1094
+ agentId: string | null
885
1095
  supportsSend: boolean
886
1096
  configuredTargets: string[]
887
1097
  recentChannelId: string | null
@@ -907,6 +1117,7 @@ export function listRunningConnectors(platform?: string): Array<{
907
1117
  id,
908
1118
  name: connector.name,
909
1119
  platform: connector.platform,
1120
+ agentId: connector.agentId || null,
910
1121
  supportsSend: typeof instance.sendMessage === 'function',
911
1122
  configuredTargets: Array.from(new Set(configuredTargets)),
912
1123
  recentChannelId: lastInboundChannelByConnector.get(id) || null,
@@ -163,7 +163,13 @@ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: str
163
163
  const goalSummary = systemPrompt.slice(0, 500)
164
164
  const recentMessages = (session.messages || []).slice(-5)
165
165
  const recentContext = recentMessages
166
- .map((m: any) => `[${m.role}]: ${(m.text || '').slice(0, 200)}`)
166
+ .map((m: any) => {
167
+ const text = (m.text || '').slice(0, 200)
168
+ const tools = Array.isArray(m.toolEvents) && m.toolEvents.length > 0
169
+ ? ` [tools used: ${m.toolEvents.map((t: { name: string }) => t.name).join(', ')}]`
170
+ : ''
171
+ return `[${m.role}]: ${text}${tools}`
172
+ })
167
173
  .join('\n')
168
174
 
169
175
  // Don't inject effectively-empty HEARTBEAT.md content
@@ -187,6 +193,7 @@ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: str
187
193
  'You are running an autonomous heartbeat tick. Review your goal and recent context.',
188
194
  'If there is meaningful work to do toward your goal, use your tools and take action.',
189
195
  'If nothing needs attention right now, reply exactly HEARTBEAT_OK.',
196
+ 'IMPORTANT: Do NOT repeat actions you already performed in recent context. If you already searched for something or completed a task (shown above), report your findings or reply HEARTBEAT_OK — do not search or act again unless there is a NEW reason to do so.',
190
197
  'Do not ask clarifying questions. Take the most reasonable next action.',
191
198
  '',
192
199
  'To update your goal or plan, include this line in your response:',
@@ -728,7 +728,7 @@ export function stripMainLoopMetaForPersistence(text: string, internal: boolean)
728
728
  if (!text) return ''
729
729
  return text
730
730
  .split('\n')
731
- .filter((line) => !line.includes('[MAIN_LOOP_META]') && !line.includes('[MAIN_LOOP_PLAN]') && !line.includes('[MAIN_LOOP_REVIEW]'))
731
+ .filter((line) => !line.includes('[MAIN_LOOP_META]') && !line.includes('[MAIN_LOOP_PLAN]') && !line.includes('[MAIN_LOOP_REVIEW]') && !line.includes('[AGENT_HEARTBEAT_META]'))
732
732
  .join('\n')
733
733
  .trim()
734
734
  }
@@ -38,10 +38,14 @@ export async function runDailyConsolidation(): Promise<{
38
38
 
39
39
  if (candidates.length < 5) continue
40
40
 
41
+ // Sort by reinforcement count descending so most-reinforced memories are prioritized in digest
42
+ candidates.sort((a, b) => (b.reinforcementCount || 0) - (a.reinforcementCount || 0))
43
+
41
44
  // Build summarization prompt
42
45
  const memoryLines = candidates.slice(0, 30).map((m) => {
46
+ const rc = m.reinforcementCount || 0
43
47
  const content = (m.content || '').slice(0, 300)
44
- return `- [${m.category}] ${m.title}: ${content}`
48
+ return `- [${m.category}]${rc > 0 ? ` (reinforced x${rc})` : ''} ${m.title}: ${content}`
45
49
  })
46
50
 
47
51
  const prompt = [
@@ -65,7 +69,8 @@ export async function runDailyConsolidation(): Promise<{
65
69
 
66
70
  if (!digestContent.trim()) continue
67
71
 
68
- const linkedMemoryIds = candidates.slice(0, 10).map((m) => m.id)
72
+ const digestCandidates = candidates.slice(0, 30)
73
+ const linkedMemoryIds = digestCandidates.slice(0, 10).map((m) => m.id)
69
74
  memDb.add({
70
75
  agentId,
71
76
  sessionId: null,
@@ -74,6 +79,14 @@ export async function runDailyConsolidation(): Promise<{
74
79
  content: digestContent.trim(),
75
80
  linkedMemoryIds,
76
81
  })
82
+
83
+ // Reset reinforcement counts on entries folded into the digest to prevent double-counting
84
+ for (const m of digestCandidates) {
85
+ if (m.reinforcementCount && m.reinforcementCount > 0) {
86
+ memDb.update(m.id, { reinforcementCount: 0 })
87
+ }
88
+ }
89
+
77
90
  digestsCreated++
78
91
  } catch (err: unknown) {
79
92
  errors.push(`Agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`)
@@ -1,6 +1,7 @@
1
1
  import Database from 'better-sqlite3'
2
2
  import path from 'path'
3
3
  import fs from 'fs'
4
+ import { createHash } from 'crypto'
4
5
  import { genId } from '@/lib/id'
5
6
  import type { MemoryEntry, FileReference, MemoryImage, MemoryReference } from '@/types'
6
7
  import { getEmbedding, cosineSimilarity, serializeEmbedding, deserializeEmbedding } from './embeddings'
@@ -32,6 +33,11 @@ export const MEMORY_FTS_STOP_WORDS = new Set([
32
33
  'you', 'your',
33
34
  ])
34
35
 
36
+ function computeContentHash(category: string, content: string): string {
37
+ const normalized = `${category}|${content.toLowerCase().trim()}`
38
+ return createHash('sha256').update(normalized).digest('hex').slice(0, 16)
39
+ }
40
+
35
41
  function shouldSkipSearchQuery(input: string): boolean {
36
42
  const text = String(input || '').toLowerCase().trim()
37
43
  if (!text) return true
@@ -357,6 +363,10 @@ function initDb() {
357
363
  'image TEXT',
358
364
  'pinned INTEGER DEFAULT 0',
359
365
  'sharedWith TEXT',
366
+ 'accessCount INTEGER DEFAULT 0',
367
+ 'lastAccessedAt INTEGER DEFAULT 0',
368
+ 'contentHash TEXT',
369
+ 'reinforcementCount INTEGER DEFAULT 0',
360
370
  ]) {
361
371
  try { db.exec(`ALTER TABLE memories ADD COLUMN ${col}`) } catch { /* already exists */ }
362
372
  }
@@ -364,6 +374,9 @@ function initDb() {
364
374
  // Partial index for fast pinned-memory lookups
365
375
  db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_pinned ON memories(agentId, updatedAt DESC) WHERE pinned = 1`)
366
376
 
377
+ // Index for content hash dedup lookups
378
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_content_hash ON memories(contentHash) WHERE contentHash IS NOT NULL`)
379
+
367
380
  // FTS5 virtual table for full-text search
368
381
  db.exec(`
369
382
  CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
@@ -447,6 +460,24 @@ function initDb() {
447
460
  })
448
461
  migrateLegacyRows()
449
462
 
463
+ // Backfill contentHash for existing rows that don't have one yet
464
+ const unhashed = (db.prepare(`SELECT COUNT(*) as cnt FROM memories WHERE contentHash IS NULL`).get() as { cnt: number }).cnt
465
+ if (unhashed > 0) {
466
+ const backfillRows = db.prepare(`SELECT id, category, content FROM memories WHERE contentHash IS NULL`).all() as Array<{ id: string; category: string; content: string }>
467
+ const backfillStmt = db.prepare(`UPDATE memories SET contentHash = ? WHERE id = ?`)
468
+ const BATCH = 500
469
+ for (let i = 0; i < backfillRows.length; i += BATCH) {
470
+ const batch = backfillRows.slice(i, i + BATCH)
471
+ const tx = db.transaction(() => {
472
+ for (const r of batch) {
473
+ backfillStmt.run(computeContentHash(r.category, r.content), r.id)
474
+ }
475
+ })
476
+ tx()
477
+ }
478
+ console.log(`[memory-db] Backfilled contentHash for ${backfillRows.length} memory row(s)`)
479
+ }
480
+
450
481
  // Fresh installs now start with an empty memory graph.
451
482
  // Durable memories are created only from actual user/agent interactions.
452
483
 
@@ -454,9 +485,9 @@ function initDb() {
454
485
  insert: db.prepare(`
455
486
  INSERT INTO memories (
456
487
  id, agentId, sessionId, category, title, content, metadata, embedding,
457
- "references", filePaths, image, imagePath, linkedMemoryIds, pinned, sharedWith, createdAt, updatedAt
488
+ "references", filePaths, image, imagePath, linkedMemoryIds, pinned, sharedWith, contentHash, createdAt, updatedAt
458
489
  )
459
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
490
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
460
491
  `),
461
492
  update: db.prepare(`
462
493
  UPDATE memories
@@ -511,6 +542,24 @@ function initDb() {
511
542
  ORDER BY updatedAt DESC
512
543
  LIMIT 1
513
544
  `),
545
+ findByContentHash: db.prepare(`
546
+ SELECT * FROM memories
547
+ WHERE contentHash = ? AND agentId = ?
548
+ ORDER BY updatedAt DESC
549
+ LIMIT 1
550
+ `),
551
+ findByContentHashShared: db.prepare(`
552
+ SELECT * FROM memories
553
+ WHERE contentHash = ? AND agentId IS NULL
554
+ ORDER BY updatedAt DESC
555
+ LIMIT 1
556
+ `),
557
+ reinforceMemory: db.prepare(`
558
+ UPDATE memories SET reinforcementCount = reinforcementCount + 1, updatedAt = ? WHERE id = ?
559
+ `),
560
+ bumpAccessCount: db.prepare(`
561
+ UPDATE memories SET accessCount = accessCount + 1, lastAccessedAt = ? WHERE id = ?
562
+ `),
514
563
  }
515
564
 
516
565
  function rowToEntry(row: Record<string, unknown>): MemoryEntry {
@@ -535,6 +584,10 @@ function initDb() {
535
584
  linkedMemoryIds: linkedMemoryIds.length ? linkedMemoryIds : undefined,
536
585
  pinned: row.pinned === 1,
537
586
  sharedWith: parseJsonSafe<string[]>(row.sharedWith, []).length ? parseJsonSafe<string[]>(row.sharedWith, []) : undefined,
587
+ accessCount: typeof row.accessCount === 'number' ? row.accessCount : 0,
588
+ lastAccessedAt: typeof row.lastAccessedAt === 'number' ? row.lastAccessedAt : 0,
589
+ contentHash: typeof row.contentHash === 'string' ? row.contentHash : undefined,
590
+ reinforcementCount: typeof row.reinforcementCount === 'number' ? row.reinforcementCount : 0,
538
591
  createdAt: typeof row.createdAt === 'number' ? row.createdAt : Date.now(),
539
592
  updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : Date.now(),
540
593
  }
@@ -574,6 +627,17 @@ function initDb() {
574
627
  const category = data.category || 'note'
575
628
  const title = data.title || 'Untitled'
576
629
  const content = data.content || ''
630
+ const contentHash = computeContentHash(category, content)
631
+
632
+ // Content-hash dedup: if same content already exists for this agent, reinforce instead of duplicating
633
+ const agentId = data.agentId || null
634
+ const existingByHash = agentId
635
+ ? stmts.findByContentHash.get(contentHash, agentId) as Record<string, unknown> | undefined
636
+ : stmts.findByContentHashShared.get(contentHash) as Record<string, unknown> | undefined
637
+ if (existingByHash) {
638
+ stmts.reinforceMemory.run(now, existingByHash.id)
639
+ return rowToEntry({ ...existingByHash, reinforcementCount: ((existingByHash.reinforcementCount as number) || 0) + 1, updatedAt: now })
640
+ }
577
641
 
578
642
  // Guard against exact duplicate memory spam for the same session/category.
579
643
  if (sessionId) {
@@ -583,7 +647,7 @@ function initDb() {
583
647
  const pinned = data.pinned ? 1 : 0
584
648
  const sharedWith = Array.isArray(data.sharedWith) && data.sharedWith.length ? JSON.stringify(data.sharedWith) : null
585
649
  stmts.insert.run(
586
- id, data.agentId || null, sessionId,
650
+ id, agentId, sessionId,
587
651
  category, title, content,
588
652
  data.metadata ? JSON.stringify(data.metadata) : null,
589
653
  null, // embedding computed async
@@ -594,6 +658,7 @@ function initDb() {
594
658
  linkedMemoryIds.length ? JSON.stringify(linkedMemoryIds) : null,
595
659
  pinned,
596
660
  sharedWith,
661
+ contentHash,
597
662
  now, now,
598
663
  )
599
664
  // Compute embedding in background (fire-and-forget)
@@ -623,6 +688,10 @@ function initDb() {
623
688
  image,
624
689
  imagePath: image?.path || null,
625
690
  linkedMemoryIds,
691
+ accessCount: 0,
692
+ lastAccessedAt: 0,
693
+ contentHash,
694
+ reinforcementCount: 0,
626
695
  createdAt: now,
627
696
  updatedAt: now,
628
697
  }
@@ -699,6 +768,10 @@ function initDb() {
699
768
  get(id: string): MemoryEntry | null {
700
769
  const row = stmts.getById.get(id) as Record<string, unknown> | undefined
701
770
  if (!row) return null
771
+ // Bump access count (non-blocking)
772
+ setTimeout(() => {
773
+ try { stmts.bumpAccessCount.run(Date.now(), id) } catch { /* best-effort */ }
774
+ }, 0)
702
775
  return rowToEntry(row)
703
776
  },
704
777
 
@@ -791,6 +864,7 @@ function initDb() {
791
864
  : []
792
865
 
793
866
  // Attempt vector search (synchronous — uses cached embedding if available)
867
+ const vectorSimilarityScores = new Map<string, number>()
794
868
  let vectorResults: MemoryEntry[] = []
795
869
  try {
796
870
  const queryEmbedding = getEmbeddingSync(query)
@@ -809,13 +883,17 @@ function initDb() {
809
883
  .sort((a, b) => b.score - a.score)
810
884
  .slice(0, 20)
811
885
 
812
- vectorResults = scored.map((s) => rowToEntry(s.row))
886
+ vectorResults = scored.map((s) => {
887
+ const entry = rowToEntry(s.row)
888
+ vectorSimilarityScores.set(entry.id, s.score)
889
+ return entry
890
+ })
813
891
  }
814
892
  } catch {
815
893
  // Vector search unavailable, use FTS only
816
894
  }
817
895
 
818
- // Merge: deduplicate by id, FTS results first then vector-only
896
+ // Merge: deduplicate by id
819
897
  const seen = new Set<string>()
820
898
  const merged: MemoryEntry[] = []
821
899
  for (const entry of [...ftsResults, ...vectorResults]) {
@@ -824,7 +902,34 @@ function initDb() {
824
902
  merged.push(entry)
825
903
  }
826
904
  }
827
- const out = merged.slice(0, MAX_MERGED_RESULTS)
905
+
906
+ // Apply salience scoring: similarity * recencyDecay * reinforcement * pinnedBoost
907
+ const now = Date.now()
908
+ const HALF_LIFE_DAYS = 30
909
+ const salienceScored = merged.map((entry) => {
910
+ const similarity = vectorSimilarityScores.get(entry.id) ?? 0.5
911
+ const daysSinceAccess = (now - (entry.lastAccessedAt || entry.updatedAt)) / 86_400_000
912
+ const recencyDecay = Math.exp(-0.693 * daysSinceAccess / HALF_LIFE_DAYS)
913
+ const reinforcement = Math.log((entry.reinforcementCount || 0) + 1) + 1
914
+ const pinnedBoost = entry.pinned ? 1.5 : 1.0
915
+ const salience = similarity * recencyDecay * reinforcement * pinnedBoost
916
+ return { entry, salience }
917
+ })
918
+ salienceScored.sort((a, b) => b.salience - a.salience)
919
+
920
+ const out = salienceScored.slice(0, MAX_MERGED_RESULTS).map((s) => s.entry)
921
+
922
+ // Bump access counts for returned results (non-blocking)
923
+ if (out.length) {
924
+ const returnedIds = out.map((e) => e.id)
925
+ setTimeout(() => {
926
+ try {
927
+ const ts = Date.now()
928
+ for (const mid of returnedIds) stmts.bumpAccessCount.run(ts, mid)
929
+ } catch { /* best-effort */ }
930
+ }, 0)
931
+ }
932
+
828
933
  const elapsed = Date.now() - startedAt
829
934
  if (elapsed > 1200) {
830
935
  console.warn(
@@ -965,9 +1070,32 @@ function initDb() {
965
1070
  const pruneWorking = options.pruneWorking !== false
966
1071
  const cutoff = Date.now() - Math.max(1, Math.min(24 * 365, Math.trunc(options.ttlHours || 24))) * 3600_000
967
1072
 
1073
+ // Hash-based dedup: group by contentHash + agentId, keep the one with highest reinforcementCount
1074
+ if (dedupe && toDelete.size < deleteBudget) {
1075
+ const hashGroups = new Map<string, MemoryEntry[]>()
1076
+ for (const row of rows) {
1077
+ if (!row.contentHash || toDelete.has(row.id)) continue
1078
+ const groupKey = `${row.agentId || ''}|${row.contentHash}`
1079
+ const group = hashGroups.get(groupKey)
1080
+ if (group) group.push(row)
1081
+ else hashGroups.set(groupKey, [row])
1082
+ }
1083
+ for (const group of hashGroups.values()) {
1084
+ if (group.length <= 1) continue
1085
+ group.sort((a, b) => (b.reinforcementCount || 0) - (a.reinforcementCount || 0))
1086
+ for (let i = 1; i < group.length; i++) {
1087
+ toDelete.add(group[i].id)
1088
+ if (toDelete.size >= deleteBudget) break
1089
+ }
1090
+ if (toDelete.size >= deleteBudget) break
1091
+ }
1092
+ }
1093
+
1094
+ // Exact string-match dedup (legacy fallback for rows without contentHash)
968
1095
  if (dedupe) {
969
1096
  const seen = new Set<string>()
970
1097
  for (const row of rows) {
1098
+ if (toDelete.has(row.id)) continue
971
1099
  const key = [
972
1100
  row.agentId || '',
973
1101
  row.sessionId || '',