@swarmclawai/swarmclaw 0.6.0 → 0.6.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 (118) hide show
  1. package/README.md +56 -42
  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 +16 -35
  15. package/src/app/api/tts/stream/route.ts +14 -42
  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 +76 -24
  31. package/src/components/chat/chat-header.tsx +522 -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 +113 -8
  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 +84 -17
  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 +125 -14
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/connector-routing.test.ts +118 -1
  78. package/src/lib/server/connectors/discord.ts +31 -8
  79. package/src/lib/server/connectors/manager.ts +594 -16
  80. package/src/lib/server/connectors/media.ts +5 -0
  81. package/src/lib/server/connectors/telegram.ts +12 -2
  82. package/src/lib/server/connectors/types.ts +2 -0
  83. package/src/lib/server/connectors/whatsapp.ts +28 -2
  84. package/src/lib/server/elevenlabs.test.ts +60 -0
  85. package/src/lib/server/elevenlabs.ts +103 -0
  86. package/src/lib/server/heartbeat-service.ts +8 -1
  87. package/src/lib/server/main-agent-loop.ts +1 -1
  88. package/src/lib/server/memory-consolidation.ts +15 -2
  89. package/src/lib/server/memory-db.ts +134 -6
  90. package/src/lib/server/mime.ts +51 -0
  91. package/src/lib/server/openclaw-gateway.ts +2 -2
  92. package/src/lib/server/orchestrator-lg.ts +2 -0
  93. package/src/lib/server/orchestrator.ts +5 -2
  94. package/src/lib/server/playwright-proxy.mjs +2 -3
  95. package/src/lib/server/prompt-runtime-context.ts +53 -0
  96. package/src/lib/server/queue.ts +182 -8
  97. package/src/lib/server/session-tools/canvas.ts +67 -0
  98. package/src/lib/server/session-tools/connector.ts +583 -63
  99. package/src/lib/server/session-tools/crud.ts +21 -0
  100. package/src/lib/server/session-tools/delegate.ts +68 -4
  101. package/src/lib/server/session-tools/file.ts +26 -7
  102. package/src/lib/server/session-tools/git.ts +71 -0
  103. package/src/lib/server/session-tools/http.ts +57 -0
  104. package/src/lib/server/session-tools/index.ts +8 -0
  105. package/src/lib/server/session-tools/memory.ts +1 -0
  106. package/src/lib/server/session-tools/search-providers.ts +16 -8
  107. package/src/lib/server/session-tools/subagent.ts +106 -0
  108. package/src/lib/server/session-tools/web.ts +118 -8
  109. package/src/lib/server/stream-agent-chat.ts +39 -10
  110. package/src/lib/server/task-mention.ts +41 -0
  111. package/src/lib/sessions.ts +10 -0
  112. package/src/lib/soul-library.ts +103 -0
  113. package/src/lib/task-dedupe.ts +26 -0
  114. package/src/lib/tool-definitions.ts +2 -0
  115. package/src/lib/tts.ts +2 -2
  116. package/src/stores/use-app-store.ts +5 -1
  117. package/src/stores/use-chat-store.ts +65 -2
  118. package/src/types/index.ts +32 -2
@@ -74,6 +74,11 @@ export function isImageMime(mime: string): boolean {
74
74
  return mime.startsWith('image/')
75
75
  }
76
76
 
77
+ /** Check if a MIME type is audio */
78
+ export function isAudioMime(mime: string): boolean {
79
+ return mime.startsWith('audio/')
80
+ }
81
+
77
82
  export function inferInboundMediaType(mimeType?: string, fileName?: string, fallback: InboundMediaType = 'file'): InboundMediaType {
78
83
  const probe = `${mimeType || ''} ${fileName || ''}`.toLowerCase()
79
84
  if (probe.includes('image')) return 'image'
@@ -3,7 +3,7 @@ import fs from 'fs'
3
3
  import path from 'path'
4
4
  import type { Connector } from '@/types'
5
5
  import type { PlatformConnector, ConnectorInstance, InboundMessage, InboundMediaType } from './types'
6
- import { downloadInboundMediaToUpload, inferInboundMediaType, mimeFromPath, isImageMime } from './media'
6
+ import { downloadInboundMediaToUpload, inferInboundMediaType, mimeFromPath, isImageMime, isAudioMime } from './media'
7
7
  import { isNoMessage } from './manager'
8
8
 
9
9
  const telegram: PlatformConnector = {
@@ -181,6 +181,11 @@ const telegram: PlatformConnector = {
181
181
  if (isImageMime(mime)) {
182
182
  const msg = await bot.api.sendPhoto(chatId, inputFile, { caption })
183
183
  return { messageId: String(msg.message_id) }
184
+ } else if (isAudioMime(mime)) {
185
+ const msg = options?.ptt
186
+ ? await bot.api.sendVoice(chatId, inputFile, { caption })
187
+ : await bot.api.sendAudio(chatId, inputFile, { caption })
188
+ return { messageId: String(msg.message_id) }
184
189
  } else {
185
190
  const msg = await bot.api.sendDocument(chatId, inputFile, { caption })
186
191
  return { messageId: String(msg.message_id) }
@@ -193,7 +198,12 @@ const telegram: PlatformConnector = {
193
198
  }
194
199
  // URL-based file
195
200
  if (options?.fileUrl) {
196
- const msg = await bot.api.sendDocument(chatId, options.fileUrl, { caption })
201
+ const mime = options.mimeType || ''
202
+ const msg = isAudioMime(mime)
203
+ ? options?.ptt
204
+ ? await bot.api.sendVoice(chatId, options.fileUrl, { caption })
205
+ : await bot.api.sendAudio(chatId, options.fileUrl, { caption })
206
+ : await bot.api.sendDocument(chatId, options.fileUrl, { caption })
197
207
  return { messageId: String(msg.message_id) }
198
208
  }
199
209
  // Text only
@@ -44,6 +44,8 @@ export interface ConnectorInstance {
44
44
  mimeType?: string
45
45
  fileName?: string
46
46
  caption?: string
47
+ /** Send audio as a WhatsApp voice note (push-to-talk) */
48
+ ptt?: boolean
47
49
  },
48
50
  ) => Promise<{ messageId?: string } | void>
49
51
  /** Current QR code data URL (WhatsApp only, null when paired) */
@@ -10,12 +10,13 @@ import path from 'path'
10
10
  import fs from 'fs'
11
11
  import type { Connector } from '@/types'
12
12
  import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
13
- import { saveInboundMediaBuffer, mimeFromPath, isImageMime } from './media'
13
+ import { saveInboundMediaBuffer, mimeFromPath, isImageMime, isAudioMime } from './media'
14
14
  import { isNoMessage } from './manager'
15
15
 
16
16
  import { DATA_DIR } from '../data-dir'
17
17
 
18
18
  const AUTH_DIR = path.join(DATA_DIR, 'whatsapp-auth')
19
+ const INBOUND_DEDUPE_TTL_MS = 2 * 60 * 1000
19
20
 
20
21
  /** Normalize a phone number for JID matching — strip leading 0 or + */
21
22
  function normalizeNumber(num: string): string {
@@ -57,6 +58,7 @@ const whatsapp: PlatformConnector = {
57
58
  let sock: ReturnType<typeof makeWASocket> | null = null
58
59
  let stopped = false
59
60
  let socketGen = 0 // Track socket generation to ignore stale events
61
+ const seenInboundMessageIds = new Map<string, number>()
60
62
 
61
63
  const instance: ConnectorInstance = {
62
64
  connector,
@@ -74,7 +76,15 @@ const whatsapp: PlatformConnector = {
74
76
  const fName = options.fileName || path.basename(options.mediaPath)
75
77
  let sent
76
78
  if (isImageMime(mime)) {
77
- sent = await sock.sendMessage(channelId, { image: buf, caption, mimetype: mime })
79
+ try {
80
+ sent = await sock.sendMessage(channelId, { image: buf, caption, mimetype: mime })
81
+ } catch (err: unknown) {
82
+ const errMsg = err instanceof Error ? err.message : String(err)
83
+ console.warn(`[whatsapp] Image send failed (${errMsg}); retrying as document: ${fName}`)
84
+ sent = await sock.sendMessage(channelId, { document: buf, fileName: fName, mimetype: mime, caption })
85
+ }
86
+ } else if (isAudioMime(mime)) {
87
+ sent = await sock.sendMessage(channelId, { audio: buf, mimetype: mime, ptt: options.ptt !== false })
78
88
  } else {
79
89
  sent = await sock.sendMessage(channelId, { document: buf, fileName: fName, mimetype: mime, caption })
80
90
  }
@@ -228,6 +238,22 @@ const whatsapp: PlatformConnector = {
228
238
 
229
239
  if (msg.key.remoteJid === 'status@broadcast') continue
230
240
 
241
+ const msgId = msg.key.id || ''
242
+ if (msgId) {
243
+ const now = Date.now()
244
+ const seenAt = seenInboundMessageIds.get(msgId)
245
+ if (typeof seenAt === 'number' && now - seenAt <= INBOUND_DEDUPE_TTL_MS) {
246
+ console.log(`[whatsapp] Skipping duplicate inbound message id: ${msgId}`)
247
+ continue
248
+ }
249
+ seenInboundMessageIds.set(msgId, now)
250
+ if (seenInboundMessageIds.size > 5000) {
251
+ for (const [id, ts] of seenInboundMessageIds.entries()) {
252
+ if (now - ts > INBOUND_DEDUPE_TTL_MS) seenInboundMessageIds.delete(id)
253
+ }
254
+ }
255
+ }
256
+
231
257
  // Skip messages sent by the bot itself (tracked by ID to prevent infinite loops)
232
258
  if (msg.key.id && sentMessageIds.has(msg.key.id)) {
233
259
  console.log(`[whatsapp] Skipping own bot reply: ${msg.key.id}`)
@@ -0,0 +1,60 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { requestElevenLabsMp3Stream, synthesizeElevenLabsMp3 } from './elevenlabs'
4
+
5
+ describe('elevenlabs helpers', () => {
6
+ it('synthesizeElevenLabsMp3 posts TTS request and returns audio bytes', async () => {
7
+ const originalFetch = global.fetch
8
+ const originalKey = process.env.ELEVENLABS_API_KEY
9
+ const originalVoice = process.env.ELEVENLABS_VOICE
10
+ process.env.ELEVENLABS_API_KEY = 'test-key'
11
+ process.env.ELEVENLABS_VOICE = 'voice-123'
12
+
13
+ let called = false
14
+ global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
15
+ called = true
16
+ assert.equal(String(input), 'https://api.elevenlabs.io/v1/text-to-speech/voice-123')
17
+ assert.equal(init?.method, 'POST')
18
+ assert.equal((init?.headers as Record<string, string>)['xi-api-key'], 'test-key')
19
+ return new Response(Buffer.from('abc'), { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
20
+ }) as typeof fetch
21
+
22
+ try {
23
+ const out = await synthesizeElevenLabsMp3({ text: 'hello world' })
24
+ assert.ok(called)
25
+ assert.equal(out.toString('utf8'), 'abc')
26
+ } finally {
27
+ global.fetch = originalFetch
28
+ if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
29
+ else process.env.ELEVENLABS_API_KEY = originalKey
30
+ if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
31
+ else process.env.ELEVENLABS_VOICE = originalVoice
32
+ }
33
+ })
34
+
35
+ it('requestElevenLabsMp3Stream calls streaming endpoint', async () => {
36
+ const originalFetch = global.fetch
37
+ const originalKey = process.env.ELEVENLABS_API_KEY
38
+ const originalVoice = process.env.ELEVENLABS_VOICE
39
+ process.env.ELEVENLABS_API_KEY = 'test-key'
40
+ process.env.ELEVENLABS_VOICE = 'voice-xyz'
41
+
42
+ global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
43
+ assert.equal(String(input), 'https://api.elevenlabs.io/v1/text-to-speech/voice-xyz/stream')
44
+ assert.equal(init?.method, 'POST')
45
+ return new Response('stream', { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
46
+ }) as typeof fetch
47
+
48
+ try {
49
+ const res = await requestElevenLabsMp3Stream({ text: 'streaming text' })
50
+ assert.equal(res.status, 200)
51
+ assert.equal(await res.text(), 'stream')
52
+ } finally {
53
+ global.fetch = originalFetch
54
+ if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
55
+ else process.env.ELEVENLABS_API_KEY = originalKey
56
+ if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
57
+ else process.env.ELEVENLABS_VOICE = originalVoice
58
+ }
59
+ })
60
+ })
@@ -0,0 +1,103 @@
1
+ import { loadSettings } from './storage'
2
+
3
+ const DEFAULT_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'
4
+ const DEFAULT_MODEL_ID = 'eleven_multilingual_v2'
5
+
6
+ function getErrorMessage(err: unknown): string {
7
+ if (err instanceof Error && err.message) return err.message
8
+ return String(err)
9
+ }
10
+
11
+ export function resolveElevenLabsConfig(voiceId?: string | null): {
12
+ apiKey: string
13
+ voiceId: string
14
+ } {
15
+ const settings = loadSettings()
16
+ const apiKey = String(settings.elevenLabsApiKey || process.env.ELEVENLABS_API_KEY || '').trim()
17
+ if (!apiKey) {
18
+ throw new Error('No ElevenLabs API key. Set one in Settings > Voice.')
19
+ }
20
+
21
+ const resolvedVoiceId = String(
22
+ voiceId
23
+ || settings.elevenLabsVoiceId
24
+ || process.env.ELEVENLABS_VOICE
25
+ || DEFAULT_VOICE_ID,
26
+ ).trim()
27
+
28
+ return { apiKey, voiceId: resolvedVoiceId || DEFAULT_VOICE_ID }
29
+ }
30
+
31
+ export async function synthesizeElevenLabsMp3(params: {
32
+ text: string
33
+ voiceId?: string | null
34
+ stability?: number
35
+ similarityBoost?: number
36
+ }): Promise<Buffer> {
37
+ const text = params.text.trim()
38
+ if (!text) throw new Error('No text provided for ElevenLabs synthesis.')
39
+
40
+ const { apiKey, voiceId } = resolveElevenLabsConfig(params.voiceId)
41
+ const stability = Number.isFinite(params.stability) ? Math.max(0, Math.min(1, Number(params.stability))) : 0.5
42
+ const similarityBoost = Number.isFinite(params.similarityBoost) ? Math.max(0, Math.min(1, Number(params.similarityBoost))) : 0.75
43
+
44
+ const apiRes = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'xi-api-key': apiKey,
48
+ 'Content-Type': 'application/json',
49
+ Accept: 'audio/mpeg',
50
+ },
51
+ body: JSON.stringify({
52
+ text,
53
+ model_id: DEFAULT_MODEL_ID,
54
+ voice_settings: {
55
+ stability,
56
+ similarity_boost: similarityBoost,
57
+ },
58
+ }),
59
+ })
60
+
61
+ if (!apiRes.ok) {
62
+ const errBody = await apiRes.text().catch(() => '')
63
+ throw new Error(errBody || `ElevenLabs request failed (${apiRes.status})`)
64
+ }
65
+
66
+ const audioBuffer = await apiRes.arrayBuffer()
67
+ return Buffer.from(audioBuffer)
68
+ }
69
+
70
+ export async function requestElevenLabsMp3Stream(params: {
71
+ text: string
72
+ voiceId?: string | null
73
+ }): Promise<Response> {
74
+ const text = params.text.trim()
75
+ if (!text) throw new Error('No text provided for ElevenLabs stream.')
76
+
77
+ const { apiKey, voiceId } = resolveElevenLabsConfig(params.voiceId)
78
+ const apiRes = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream`, {
79
+ method: 'POST',
80
+ headers: {
81
+ 'xi-api-key': apiKey,
82
+ 'Content-Type': 'application/json',
83
+ Accept: 'audio/mpeg',
84
+ },
85
+ body: JSON.stringify({
86
+ text: text.slice(0, 2000),
87
+ model_id: DEFAULT_MODEL_ID,
88
+ voice_settings: { stability: 0.5, similarity_boost: 0.75 },
89
+ output_format: 'mp3_22050_32',
90
+ }),
91
+ })
92
+
93
+ if (!apiRes.ok) {
94
+ const errBody = await apiRes.text().catch(() => '')
95
+ throw new Error(errBody || `ElevenLabs streaming request failed (${apiRes.status})`)
96
+ }
97
+
98
+ return apiRes
99
+ }
100
+
101
+ export function explainElevenLabsError(err: unknown): string {
102
+ return getErrorMessage(err)
103
+ }
@@ -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 || '',
@@ -0,0 +1,51 @@
1
+ export const MIME_TYPES: Record<string, string> = {
2
+ '.png': 'image/png',
3
+ '.jpg': 'image/jpeg',
4
+ '.jpeg': 'image/jpeg',
5
+ '.gif': 'image/gif',
6
+ '.webp': 'image/webp',
7
+ '.svg': 'image/svg+xml',
8
+ '.bmp': 'image/bmp',
9
+ '.ico': 'image/x-icon',
10
+ '.mp4': 'video/mp4',
11
+ '.webm': 'video/webm',
12
+ '.mov': 'video/quicktime',
13
+ '.avi': 'video/x-msvideo',
14
+ '.mkv': 'video/x-matroska',
15
+ '.pdf': 'application/pdf',
16
+ '.json': 'application/json',
17
+ '.csv': 'text/csv',
18
+ '.txt': 'text/plain',
19
+ '.html': 'text/html',
20
+ '.xml': 'application/xml',
21
+ '.zip': 'application/zip',
22
+ '.tar': 'application/x-tar',
23
+ '.gz': 'application/gzip',
24
+ '.doc': 'application/msword',
25
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
26
+ '.xls': 'application/vnd.ms-excel',
27
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
28
+ '.ppt': 'application/vnd.ms-powerpoint',
29
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
30
+ '.mp3': 'audio/mpeg',
31
+ '.wav': 'audio/wav',
32
+ '.ogg': 'audio/ogg',
33
+ }
34
+
35
+ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
36
+ const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv'])
37
+ const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg'])
38
+ const DOCUMENT_EXTS = new Set(['.pdf', '.json', '.csv', '.txt', '.html', '.xml', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'])
39
+ const ARCHIVE_EXTS = new Set(['.zip', '.tar', '.gz'])
40
+
41
+ export type FileCategory = 'image' | 'video' | 'audio' | 'document' | 'archive' | 'other'
42
+
43
+ export function getFileCategory(ext: string): FileCategory {
44
+ const lower = ext.toLowerCase()
45
+ if (IMAGE_EXTS.has(lower)) return 'image'
46
+ if (VIDEO_EXTS.has(lower)) return 'video'
47
+ if (AUDIO_EXTS.has(lower)) return 'audio'
48
+ if (DOCUMENT_EXTS.has(lower)) return 'document'
49
+ if (ARCHIVE_EXTS.has(lower)) return 'archive'
50
+ return 'other'
51
+ }