@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.
- package/README.md +15 -2
- package/bin/server-cmd.js +1 -0
- package/package.json +2 -1
- package/src/app/api/canvas/[sessionId]/route.ts +31 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
- package/src/app/api/connectors/[id]/route.ts +1 -0
- package/src/app/api/connectors/route.ts +2 -1
- package/src/app/api/files/open/route.ts +43 -0
- package/src/app/api/search/route.ts +9 -7
- package/src/app/api/sessions/[id]/messages/route.ts +70 -2
- package/src/app/api/sessions/[id]/route.ts +4 -0
- package/src/app/api/tasks/metrics/route.ts +101 -0
- package/src/app/api/tasks/route.ts +17 -2
- package/src/app/api/tts/route.ts +3 -2
- package/src/app/api/tts/stream/route.ts +3 -2
- package/src/app/api/uploads/[filename]/route.ts +19 -34
- package/src/app/api/uploads/route.ts +94 -0
- package/src/app/globals.css +5 -0
- package/src/cli/index.js +16 -1
- package/src/cli/spec.js +26 -0
- package/src/components/agents/agent-card.tsx +3 -3
- package/src/components/agents/agent-chat-list.tsx +29 -6
- package/src/components/agents/agent-sheet.tsx +66 -4
- package/src/components/agents/inspector-panel.tsx +81 -6
- package/src/components/agents/openclaw-skills-panel.tsx +32 -3
- package/src/components/agents/personality-builder.tsx +42 -14
- package/src/components/agents/soul-library-picker.tsx +89 -0
- package/src/components/canvas/canvas-panel.tsx +96 -0
- package/src/components/chat/activity-moment.tsx +8 -4
- package/src/components/chat/chat-area.tsx +46 -22
- package/src/components/chat/chat-header.tsx +455 -286
- package/src/components/chat/chat-preview-panel.tsx +1 -2
- package/src/components/chat/delegation-banner.tsx +371 -0
- package/src/components/chat/file-path-chip.tsx +23 -2
- package/src/components/chat/heartbeat-history-panel.tsx +269 -0
- package/src/components/chat/message-bubble.tsx +315 -25
- package/src/components/chat/message-list.tsx +180 -7
- package/src/components/chat/streaming-bubble.tsx +68 -1
- package/src/components/chat/tool-call-bubble.tsx +45 -3
- package/src/components/chat/transfer-agent-picker.tsx +1 -1
- package/src/components/chatrooms/chatroom-list.tsx +8 -1
- package/src/components/chatrooms/chatroom-message.tsx +8 -3
- package/src/components/chatrooms/chatroom-view.tsx +3 -3
- package/src/components/connectors/connector-list.tsx +168 -90
- package/src/components/connectors/connector-sheet.tsx +68 -16
- package/src/components/home/home-view.tsx +1 -1
- package/src/components/input/chat-input.tsx +28 -2
- package/src/components/layout/app-layout.tsx +19 -2
- package/src/components/projects/project-detail.tsx +1 -1
- package/src/components/schedules/schedule-sheet.tsx +260 -127
- package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
- package/src/components/shared/agent-switch-dialog.tsx +1 -1
- package/src/components/shared/chatroom-picker-list.tsx +61 -0
- package/src/components/shared/connector-platform-icon.tsx +51 -4
- package/src/components/shared/icon-button.tsx +16 -2
- package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
- package/src/components/shared/search-dialog.tsx +17 -10
- package/src/components/shared/settings/section-embedding.tsx +48 -13
- package/src/components/shared/settings/section-orchestrator.tsx +46 -15
- package/src/components/shared/settings/section-storage.tsx +206 -0
- package/src/components/shared/settings/section-user-preferences.tsx +18 -0
- package/src/components/shared/settings/section-voice.tsx +42 -21
- package/src/components/shared/settings/section-web-search.tsx +30 -6
- package/src/components/shared/settings/settings-page.tsx +3 -1
- package/src/components/shared/settings/storage-browser.tsx +259 -0
- package/src/components/tasks/task-card.tsx +14 -1
- package/src/components/tasks/task-sheet.tsx +328 -3
- package/src/components/usage/metrics-dashboard.tsx +90 -6
- package/src/hooks/use-continuous-speech.ts +10 -4
- package/src/hooks/use-voice-conversation.ts +53 -10
- package/src/hooks/use-ws.ts +4 -2
- package/src/lib/providers/anthropic.ts +13 -7
- package/src/lib/providers/index.ts +1 -0
- package/src/lib/providers/openai.ts +13 -7
- package/src/lib/server/chat-execution.ts +51 -11
- package/src/lib/server/chatroom-helpers.ts +146 -0
- package/src/lib/server/connectors/manager.ts +218 -7
- package/src/lib/server/heartbeat-service.ts +8 -1
- package/src/lib/server/main-agent-loop.ts +1 -1
- package/src/lib/server/memory-consolidation.ts +15 -2
- package/src/lib/server/memory-db.ts +134 -6
- package/src/lib/server/mime.ts +51 -0
- package/src/lib/server/openclaw-gateway.ts +2 -2
- package/src/lib/server/orchestrator-lg.ts +2 -0
- package/src/lib/server/orchestrator.ts +5 -2
- package/src/lib/server/playwright-proxy.mjs +2 -3
- package/src/lib/server/prompt-runtime-context.ts +53 -0
- package/src/lib/server/queue.ts +52 -7
- package/src/lib/server/session-tools/canvas.ts +67 -0
- package/src/lib/server/session-tools/connector.ts +83 -9
- package/src/lib/server/session-tools/crud.ts +21 -0
- package/src/lib/server/session-tools/delegate.ts +68 -4
- package/src/lib/server/session-tools/git.ts +71 -0
- package/src/lib/server/session-tools/http.ts +57 -0
- package/src/lib/server/session-tools/index.ts +8 -0
- package/src/lib/server/session-tools/memory.ts +1 -0
- package/src/lib/server/session-tools/search-providers.ts +16 -8
- package/src/lib/server/session-tools/subagent.ts +106 -0
- package/src/lib/server/session-tools/web.ts +115 -4
- package/src/lib/server/stream-agent-chat.ts +32 -10
- package/src/lib/server/task-mention.ts +41 -0
- package/src/lib/sessions.ts +10 -0
- package/src/lib/soul-library.ts +103 -0
- package/src/lib/task-dedupe.ts +26 -0
- package/src/lib/tool-definitions.ts +2 -0
- package/src/lib/tts.ts +2 -2
- package/src/stores/use-app-store.ts +5 -1
- package/src/stores/use-chat-store.ts +65 -2
- 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
|
|
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 
|
|
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: 
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
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) =>
|
|
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
|
|
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,
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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 || '',
|