@swarmclawai/swarmclaw 0.6.7 → 0.7.0
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 +82 -39
- package/next.config.ts +31 -6
- package/package.json +3 -2
- package/src/app/api/agents/[id]/thread/route.ts +1 -0
- package/src/app/api/agents/route.ts +19 -5
- package/src/app/api/approvals/route.ts +22 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +4 -0
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/eval/run/route.ts +37 -0
- package/src/app/api/eval/scenarios/route.ts +24 -0
- package/src/app/api/eval/suite/route.ts +29 -0
- package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
- package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
- package/src/app/api/memory/graph/route.ts +46 -0
- package/src/app/api/memory/route.ts +36 -5
- package/src/app/api/notifications/route.ts +3 -0
- package/src/app/api/plugins/install/route.ts +57 -5
- package/src/app/api/plugins/marketplace/route.ts +73 -22
- package/src/app/api/plugins/route.ts +61 -1
- package/src/app/api/plugins/ui/route.ts +34 -0
- package/src/app/api/sessions/[id]/checkpoints/route.ts +31 -0
- package/src/app/api/sessions/[id]/restore/route.ts +36 -0
- package/src/app/api/settings/route.ts +62 -0
- package/src/app/api/setup/doctor/route.ts +22 -5
- package/src/app/api/souls/[id]/route.ts +65 -0
- package/src/app/api/souls/route.ts +70 -0
- package/src/app/api/tasks/[id]/approve/route.ts +4 -3
- package/src/app/api/tasks/[id]/route.ts +16 -3
- package/src/app/api/tasks/route.ts +10 -2
- package/src/app/api/usage/route.ts +9 -2
- package/src/app/globals.css +27 -0
- package/src/app/page.tsx +10 -5
- package/src/cli/index.js +37 -0
- package/src/components/activity/activity-feed.tsx +9 -2
- package/src/components/agents/agent-avatar.tsx +5 -1
- package/src/components/agents/agent-card.tsx +55 -9
- package/src/components/agents/agent-sheet.tsx +112 -34
- package/src/components/agents/inspector-panel.tsx +1 -1
- package/src/components/agents/soul-library-picker.tsx +84 -13
- package/src/components/auth/access-key-gate.tsx +63 -54
- package/src/components/auth/user-picker.tsx +37 -32
- package/src/components/chat/activity-moment.tsx +2 -0
- package/src/components/chat/chat-area.tsx +11 -0
- package/src/components/chat/chat-header.tsx +69 -25
- package/src/components/chat/chat-tool-toggles.tsx +2 -2
- package/src/components/chat/checkpoint-timeline.tsx +112 -0
- package/src/components/chat/code-block.tsx +3 -1
- package/src/components/chat/exec-approval-card.tsx +8 -1
- package/src/components/chat/message-bubble.tsx +164 -4
- package/src/components/chat/message-list.tsx +46 -4
- package/src/components/chat/session-approval-card.tsx +80 -0
- package/src/components/chat/session-debug-panel.tsx +106 -84
- package/src/components/chat/streaming-bubble.tsx +6 -5
- package/src/components/chat/task-approval-card.tsx +78 -0
- package/src/components/chat/thinking-indicator.tsx +48 -12
- package/src/components/chat/tool-call-bubble.tsx +3 -0
- package/src/components/chat/tool-request-banner.tsx +39 -20
- package/src/components/chatrooms/chatroom-list.tsx +11 -4
- package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
- package/src/components/connectors/connector-list.tsx +33 -11
- package/src/components/connectors/connector-sheet.tsx +37 -7
- package/src/components/home/home-view.tsx +54 -24
- package/src/components/input/chat-input.tsx +22 -1
- package/src/components/knowledge/knowledge-list.tsx +17 -18
- package/src/components/knowledge/knowledge-sheet.tsx +9 -5
- package/src/components/layout/app-layout.tsx +87 -19
- package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
- package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
- package/src/components/memory/memory-browser.tsx +73 -45
- package/src/components/memory/memory-graph-view.tsx +203 -0
- package/src/components/memory/memory-list.tsx +20 -13
- package/src/components/plugins/plugin-list.tsx +214 -60
- package/src/components/plugins/plugin-sheet.tsx +119 -24
- package/src/components/projects/project-list.tsx +17 -9
- package/src/components/providers/provider-list.tsx +21 -6
- package/src/components/providers/provider-sheet.tsx +42 -25
- package/src/components/runs/run-list.tsx +17 -13
- package/src/components/schedules/schedule-card.tsx +10 -3
- package/src/components/schedules/schedule-list.tsx +2 -2
- package/src/components/schedules/schedule-sheet.tsx +28 -9
- package/src/components/secrets/secret-sheet.tsx +7 -2
- package/src/components/secrets/secrets-list.tsx +18 -5
- package/src/components/sessions/new-session-sheet.tsx +183 -376
- package/src/components/sessions/session-card.tsx +10 -2
- package/src/components/settings/gateway-connection-panel.tsx +9 -8
- package/src/components/shared/command-palette.tsx +13 -5
- package/src/components/shared/empty-state.tsx +20 -8
- package/src/components/shared/hint-tip.tsx +31 -0
- package/src/components/shared/notification-center.tsx +134 -86
- package/src/components/shared/profile-sheet.tsx +4 -0
- package/src/components/shared/settings/plugin-manager.tsx +360 -135
- package/src/components/shared/settings/section-capability-policy.tsx +3 -3
- package/src/components/shared/settings/section-runtime-loop.tsx +149 -4
- package/src/components/skills/clawhub-browser.tsx +1 -0
- package/src/components/skills/skill-list.tsx +31 -12
- package/src/components/skills/skill-sheet.tsx +20 -7
- package/src/components/tasks/approvals-panel.tsx +224 -0
- package/src/components/tasks/task-board.tsx +20 -12
- package/src/components/tasks/task-card.tsx +21 -7
- package/src/components/tasks/task-column.tsx +4 -3
- package/src/components/tasks/task-list.tsx +1 -1
- package/src/components/tasks/task-sheet.tsx +130 -1
- package/src/components/ui/dialog.tsx +1 -0
- package/src/components/ui/sheet.tsx +1 -0
- package/src/components/usage/metrics-dashboard.tsx +72 -48
- package/src/components/wallets/wallet-panel.tsx +65 -41
- package/src/components/wallets/wallet-section.tsx +9 -3
- package/src/components/webhooks/webhook-list.tsx +21 -12
- package/src/components/webhooks/webhook-sheet.tsx +13 -3
- package/src/lib/approval-display.test.ts +45 -0
- package/src/lib/approval-display.ts +62 -0
- package/src/lib/clipboard.ts +38 -0
- package/src/lib/memory.ts +8 -0
- package/src/lib/providers/claude-cli.ts +5 -3
- package/src/lib/providers/index.ts +67 -21
- package/src/lib/runtime-loop.ts +3 -2
- package/src/lib/server/approvals.ts +150 -0
- package/src/lib/server/chat-execution.ts +319 -74
- package/src/lib/server/chatroom-helpers.ts +63 -5
- package/src/lib/server/chatroom-orchestration.ts +74 -0
- package/src/lib/server/clawhub-client.ts +82 -6
- package/src/lib/server/connectors/manager.ts +27 -1
- package/src/lib/server/context-manager.ts +132 -50
- package/src/lib/server/cost.test.ts +73 -0
- package/src/lib/server/cost.ts +165 -34
- package/src/lib/server/daemon-state.ts +112 -1
- package/src/lib/server/data-dir.ts +18 -1
- package/src/lib/server/eval/runner.ts +126 -0
- package/src/lib/server/eval/scenarios.ts +218 -0
- package/src/lib/server/eval/scorer.ts +96 -0
- package/src/lib/server/eval/store.ts +37 -0
- package/src/lib/server/eval/types.ts +48 -0
- package/src/lib/server/execution-log.ts +12 -8
- package/src/lib/server/guardian.ts +34 -0
- package/src/lib/server/heartbeat-service.ts +53 -1
- package/src/lib/server/integrity-monitor.ts +208 -0
- package/src/lib/server/langgraph-checkpoint.ts +10 -0
- package/src/lib/server/link-understanding.ts +55 -0
- package/src/lib/server/llm-response-cache.test.ts +102 -0
- package/src/lib/server/llm-response-cache.ts +227 -0
- package/src/lib/server/main-agent-loop.ts +115 -16
- package/src/lib/server/main-session.ts +6 -3
- package/src/lib/server/mcp-conformance.test.ts +18 -0
- package/src/lib/server/mcp-conformance.ts +233 -0
- package/src/lib/server/memory-db.ts +193 -19
- package/src/lib/server/memory-retrieval.test.ts +56 -0
- package/src/lib/server/mmr.ts +73 -0
- package/src/lib/server/orchestrator-lg.ts +7 -1
- package/src/lib/server/orchestrator.ts +4 -3
- package/src/lib/server/plugins.ts +662 -132
- package/src/lib/server/process-manager.ts +18 -0
- package/src/lib/server/query-expansion.ts +57 -0
- package/src/lib/server/queue.ts +280 -11
- package/src/lib/server/runtime-settings.ts +9 -0
- package/src/lib/server/session-run-manager.test.ts +23 -0
- package/src/lib/server/session-run-manager.ts +32 -2
- package/src/lib/server/session-tools/canvas.ts +85 -50
- package/src/lib/server/session-tools/chatroom.ts +130 -127
- package/src/lib/server/session-tools/connector.ts +233 -454
- package/src/lib/server/session-tools/context-mgmt.ts +87 -105
- package/src/lib/server/session-tools/crud.ts +84 -7
- package/src/lib/server/session-tools/delegate.ts +351 -752
- package/src/lib/server/session-tools/discovery.ts +198 -0
- package/src/lib/server/session-tools/edit_file.ts +82 -0
- package/src/lib/server/session-tools/file-send.test.ts +39 -0
- package/src/lib/server/session-tools/file.ts +257 -425
- package/src/lib/server/session-tools/git.ts +87 -47
- package/src/lib/server/session-tools/http.ts +95 -33
- package/src/lib/server/session-tools/index.ts +217 -138
- package/src/lib/server/session-tools/memory.ts +154 -239
- package/src/lib/server/session-tools/monitor.ts +126 -0
- package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
- package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
- package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
- package/src/lib/server/session-tools/platform.ts +86 -0
- package/src/lib/server/session-tools/plugin-creator.ts +239 -0
- package/src/lib/server/session-tools/sample-ui.ts +97 -0
- package/src/lib/server/session-tools/sandbox.ts +175 -148
- package/src/lib/server/session-tools/schedule.ts +78 -0
- package/src/lib/server/session-tools/session-info.ts +104 -410
- package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
- package/src/lib/server/session-tools/shell.ts +171 -143
- package/src/lib/server/session-tools/subagent.ts +77 -77
- package/src/lib/server/session-tools/wallet.ts +182 -106
- package/src/lib/server/session-tools/web.ts +181 -327
- package/src/lib/server/storage.ts +36 -0
- package/src/lib/server/stream-agent-chat.ts +348 -242
- package/src/lib/server/task-quality-gate.test.ts +44 -0
- package/src/lib/server/task-quality-gate.ts +67 -0
- package/src/lib/server/task-validation.test.ts +78 -0
- package/src/lib/server/task-validation.ts +67 -2
- package/src/lib/server/tool-aliases.ts +68 -0
- package/src/lib/server/tool-capability-policy.ts +24 -5
- package/src/lib/server/tool-retry.ts +62 -0
- package/src/lib/server/transcript-repair.ts +72 -0
- package/src/lib/setup-defaults.ts +1 -0
- package/src/lib/tasks.ts +7 -1
- package/src/lib/tool-definitions.ts +24 -23
- package/src/lib/validation/schemas.ts +13 -0
- package/src/lib/view-routes.ts +2 -23
- package/src/stores/use-app-store.ts +23 -1
- package/src/types/index.ts +155 -10
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os from 'os'
|
|
1
2
|
import { loadSettings, loadSkills, loadCredentials, decryptKey } from './storage'
|
|
2
3
|
import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
|
|
3
4
|
import { genId } from '@/lib/id'
|
|
@@ -49,11 +50,15 @@ function truncateText(text: string, max: number): string {
|
|
|
49
50
|
return `${compact.slice(0, Math.max(0, max - 3))}...`
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
import { isImplicitlyMentioned } from './chatroom-orchestration'
|
|
54
|
+
|
|
52
55
|
/** Parse @mentions from message text, returns matching agentIds */
|
|
53
56
|
export function parseMentions(text: string, agents: Record<string, Agent>, memberIds: string[]): string[] {
|
|
54
57
|
if (/@all\b/i.test(text)) return [...memberIds]
|
|
55
58
|
const mentionPattern = /(?:^|[\s(])@([a-zA-Z0-9._-]+)/g
|
|
56
59
|
const mentioned: string[] = []
|
|
60
|
+
|
|
61
|
+
// 1. Explicit @mentions
|
|
57
62
|
let match: RegExpExecArray | null
|
|
58
63
|
while ((match = mentionPattern.exec(text)) !== null) {
|
|
59
64
|
const token = normalizeMentionToken(match[1] || '')
|
|
@@ -67,6 +72,18 @@ export function parseMentions(text: string, agents: Record<string, Agent>, membe
|
|
|
67
72
|
}
|
|
68
73
|
}
|
|
69
74
|
}
|
|
75
|
+
|
|
76
|
+
// 2. Implicit mentions (OpenClaw Style - Reading the room)
|
|
77
|
+
// Only if no explicit mentions found yet
|
|
78
|
+
if (mentioned.length === 0) {
|
|
79
|
+
for (const id of memberIds) {
|
|
80
|
+
const agent = agents[id]
|
|
81
|
+
if (agent && isImplicitlyMentioned(text, agent)) {
|
|
82
|
+
mentioned.push(id)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
70
87
|
return mentioned
|
|
71
88
|
}
|
|
72
89
|
|
|
@@ -144,6 +161,8 @@ export function buildChatroomSystemPrompt(chatroom: Chatroom, agents: Record<str
|
|
|
144
161
|
'- **Handle greetings like a human.** For "hello", "how are you", or light check-ins, give a normal conversational reply instead of tool/process commentary.',
|
|
145
162
|
'- **Keep responses short** unless depth is needed. A few sentences is usually enough. This is a chat, not an essay.',
|
|
146
163
|
'- **@mention teammates** only when you genuinely need their specific expertise. Don\'t tag people just to be polite.',
|
|
164
|
+
'- **Use Reactions**: To acknowledge a message, agree with a plan, or signal progress without sending a full text reply, use this format at the end of your message: [REACTION]{"emoji": "👍", "to": "message_id"}.',
|
|
165
|
+
'- **Implicit Mentions**: If someone uses your name, creature, or vibe in a message but doesn\'t @tag you, they are still "reading the room" and you may respond if it\'s relevant to you.',
|
|
147
166
|
'- **Don\'t narrate your capabilities** unless asked. Just demonstrate them by doing things.',
|
|
148
167
|
'- **Read the room.** Look at recent messages to understand context. Don\'t repeat what others already said.',
|
|
149
168
|
'',
|
|
@@ -173,14 +192,39 @@ export function buildSyntheticSession(agent: Agent, chatroomId: string): Session
|
|
|
173
192
|
}
|
|
174
193
|
}
|
|
175
194
|
|
|
176
|
-
/** Build agent's system prompt including skills */
|
|
195
|
+
/** Build agent's system prompt including skills and identity context */
|
|
177
196
|
export function buildAgentSystemPromptForChatroom(agent: Agent): string {
|
|
178
197
|
const settings = loadSettings()
|
|
179
198
|
const parts: string[] = []
|
|
180
|
-
|
|
199
|
+
|
|
200
|
+
// 1. Identity & Persona (Grounded OpenClaw Style)
|
|
201
|
+
const identityLines = [`## My Identity`]
|
|
202
|
+
identityLines.push(`Name: ${agent.name}`)
|
|
203
|
+
if (agent.emoji) identityLines.push(`Emoji: ${agent.emoji}`)
|
|
204
|
+
if (agent.creature) identityLines.push(`Creature: ${agent.creature}`)
|
|
205
|
+
if (agent.vibe) identityLines.push(`Vibe: ${agent.vibe}`)
|
|
206
|
+
if (agent.theme) identityLines.push(`Theme: ${agent.theme}`)
|
|
207
|
+
if (agent.description) identityLines.push(`Description: ${agent.description}`)
|
|
208
|
+
identityLines.push('I should always embody this identity in the group chat. I am a persistent companion with my own soul and presence.')
|
|
209
|
+
parts.push(identityLines.join('\n'))
|
|
210
|
+
|
|
211
|
+
// 2. Runtime & Capabilities (OpenClaw Style)
|
|
212
|
+
const runtimeLines = [
|
|
213
|
+
'## Runtime',
|
|
214
|
+
`os=${process.platform} | host=${os.hostname()} | agent=${agent.id} | provider=${agent.provider} | model=${agent.model}`,
|
|
215
|
+
`capabilities=tools,multi_agent_chatroom,collaborative_reasoning`,
|
|
216
|
+
]
|
|
217
|
+
parts.push(runtimeLines.join('\n'))
|
|
218
|
+
|
|
219
|
+
// 3. User & DateTime Context
|
|
220
|
+
if (settings.userPrompt) parts.push(`## User Instructions\n${settings.userPrompt}`)
|
|
181
221
|
parts.push(buildCurrentDateTimePromptContext())
|
|
182
|
-
|
|
183
|
-
|
|
222
|
+
|
|
223
|
+
// 4. Soul & Core Instructions
|
|
224
|
+
if (agent.soul) parts.push(`## Soul\n${agent.soul}`)
|
|
225
|
+
if (agent.systemPrompt) parts.push(`## System Prompt\n${agent.systemPrompt}`)
|
|
226
|
+
|
|
227
|
+
// 5. Skills (SwarmClaw Core)
|
|
184
228
|
if (agent.skillIds?.length) {
|
|
185
229
|
const allSkills = loadSkills()
|
|
186
230
|
for (const skillId of agent.skillIds) {
|
|
@@ -188,6 +232,16 @@ export function buildAgentSystemPromptForChatroom(agent: Agent): string {
|
|
|
188
232
|
if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
|
|
189
233
|
}
|
|
190
234
|
}
|
|
235
|
+
|
|
236
|
+
// 6. Thinking & Output Format (OpenClaw Style)
|
|
237
|
+
const thinkingHint = [
|
|
238
|
+
'## Output Format',
|
|
239
|
+
'If your model supports internal reasoning/thinking, put all internal analysis inside <think>...</think> tags.',
|
|
240
|
+
'Your final response to the chatroom should be clear and concise.',
|
|
241
|
+
'When you have nothing to say, respond with ONLY: NO_MESSAGE',
|
|
242
|
+
]
|
|
243
|
+
parts.push(thinkingHint.join('\n'))
|
|
244
|
+
|
|
191
245
|
return parts.join('\n\n')
|
|
192
246
|
}
|
|
193
247
|
|
|
@@ -196,7 +250,11 @@ export function buildHistoryForAgent(chatroom: Chatroom, agentId: string, imageP
|
|
|
196
250
|
const recentMessages = chatroom.messages.slice(-24)
|
|
197
251
|
const includeAttachmentsFrom = Math.max(0, recentMessages.length - 6)
|
|
198
252
|
const history = recentMessages.map((m, idx) => {
|
|
199
|
-
let msgText = `[${m.senderName}]: ${m.text}`
|
|
253
|
+
let msgText = `[${m.senderName}] (id: ${m.id}): ${m.text}`
|
|
254
|
+
if (m.reactions?.length) {
|
|
255
|
+
const reactionSummary = m.reactions.map(r => `${r.emoji} by ${r.reactorId}`).join(', ')
|
|
256
|
+
msgText += `\n[Reactions: ${reactionSummary}]`
|
|
257
|
+
}
|
|
200
258
|
const includeAttachments = idx >= includeAttachmentsFrom
|
|
201
259
|
if (includeAttachments && m.attachedFiles?.length) {
|
|
202
260
|
const names = m.attachedFiles.map((f) => f.split('/').pop()).join(', ')
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Chatroom, Agent } from '@/types'
|
|
2
|
+
import { loadChatrooms, saveChatrooms } from './storage'
|
|
3
|
+
import { notify } from './ws-hub'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes text for comparison (lowercase, alphanumeric only)
|
|
7
|
+
*/
|
|
8
|
+
function normalizeForMatch(text: string): string {
|
|
9
|
+
return text.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Determines if an agent was implicitly mentioned in a message.
|
|
14
|
+
* Matches against name, creature, and vibe.
|
|
15
|
+
*/
|
|
16
|
+
export function isImplicitlyMentioned(text: string, agent: Agent): boolean {
|
|
17
|
+
const normText = normalizeForMatch(text)
|
|
18
|
+
const normName = normalizeForMatch(agent.name)
|
|
19
|
+
const normCreature = agent.creature ? normalizeForMatch(agent.creature) : null
|
|
20
|
+
const normVibe = agent.vibe ? normalizeForMatch(agent.vibe) : null
|
|
21
|
+
|
|
22
|
+
if (normText.includes(normName)) return true
|
|
23
|
+
if (normCreature && normText.includes(normCreature)) return true
|
|
24
|
+
|
|
25
|
+
// Vibe match: only if the vibe is a distinct single word like "skeptic" or "helper"
|
|
26
|
+
if (normVibe && normVibe.length > 3 && normVibe.split(' ').length === 1) {
|
|
27
|
+
if (normText.includes(normVibe)) return true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Adds an "ack" reaction to a chatroom message on behalf of an agent.
|
|
35
|
+
* Useful for acknowledging tasks or agreeing with teammates.
|
|
36
|
+
*/
|
|
37
|
+
export function addAgentReaction(chatroomId: string, messageId: string, agentId: string, emoji: string) {
|
|
38
|
+
const chatrooms = loadChatrooms()
|
|
39
|
+
const chatroom = chatrooms[chatroomId] as Chatroom | undefined
|
|
40
|
+
if (!chatroom) return
|
|
41
|
+
|
|
42
|
+
const message = chatroom.messages.find(m => m.id === messageId)
|
|
43
|
+
if (!message) return
|
|
44
|
+
|
|
45
|
+
// Prevent duplicate reactions from the same agent
|
|
46
|
+
if (message.reactions.some(r => r.reactorId === agentId && r.emoji === emoji)) return
|
|
47
|
+
|
|
48
|
+
message.reactions.push({
|
|
49
|
+
emoji,
|
|
50
|
+
reactorId: agentId,
|
|
51
|
+
time: Date.now()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
chatrooms[chatroomId] = chatroom
|
|
55
|
+
saveChatrooms(chatrooms)
|
|
56
|
+
notify(`chatroom:${chatroomId}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parses [REACTION] tokens from agent output and applies them.
|
|
61
|
+
* Format: [REACTION]{"emoji": "👍", "to": "msg_id"}
|
|
62
|
+
*/
|
|
63
|
+
export function applyAgentReactionsFromText(text: string, chatroomId: string, agentId: string) {
|
|
64
|
+
const reactionRegex = /\[REACTION\]\s*(\{.*?\})/g
|
|
65
|
+
let match
|
|
66
|
+
while ((match = reactionRegex.exec(text)) !== null) {
|
|
67
|
+
try {
|
|
68
|
+
const data = JSON.parse(match[1])
|
|
69
|
+
if (data.emoji && data.to) {
|
|
70
|
+
addAgentReaction(chatroomId, data.to, agentId, data.emoji)
|
|
71
|
+
}
|
|
72
|
+
} catch { /* ignore invalid JSON */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -4,23 +4,99 @@ export interface ClawHubSearchResult {
|
|
|
4
4
|
skills: ClawHubSkill[]
|
|
5
5
|
total: number
|
|
6
6
|
page: number
|
|
7
|
+
nextCursor?: string | null
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
const CLAWHUB_BASE_URL = process.env.CLAWHUB_API_URL || 'https://clawhub.
|
|
10
|
+
const CLAWHUB_BASE_URL = process.env.CLAWHUB_API_URL || 'https://clawhub.ai/api/v1'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Raw shape returned by the ClawHub `/skills` endpoint.
|
|
14
|
+
* Fields are mapped to our internal `ClawHubSkill` type.
|
|
15
|
+
*/
|
|
16
|
+
interface ClawHubRawItem {
|
|
17
|
+
slug: string
|
|
18
|
+
displayName?: string
|
|
19
|
+
name?: string
|
|
20
|
+
summary?: string
|
|
21
|
+
description?: string
|
|
22
|
+
author?: string | { name?: string }
|
|
23
|
+
tags?: Record<string, string> | string[]
|
|
24
|
+
stats?: { downloads?: number; installsAllTime?: number; stars?: number }
|
|
25
|
+
latestVersion?: { version?: string; changelog?: string }
|
|
26
|
+
metadata?: Record<string, unknown> | null
|
|
27
|
+
url?: string
|
|
28
|
+
createdAt?: number
|
|
29
|
+
updatedAt?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mapRawToSkill(raw: ClawHubRawItem): ClawHubSkill {
|
|
33
|
+
const name = raw.displayName || raw.name || raw.slug
|
|
34
|
+
const description = raw.summary || raw.description || ''
|
|
35
|
+
const author = typeof raw.author === 'string'
|
|
36
|
+
? raw.author
|
|
37
|
+
: raw.author?.name || 'community'
|
|
38
|
+
const tags = Array.isArray(raw.tags)
|
|
39
|
+
? raw.tags
|
|
40
|
+
: raw.tags ? Object.keys(raw.tags) : []
|
|
41
|
+
const downloads = raw.stats?.installsAllTime ?? raw.stats?.downloads ?? 0
|
|
42
|
+
const version = raw.latestVersion?.version || '1.0.0'
|
|
43
|
+
return {
|
|
44
|
+
id: raw.slug,
|
|
45
|
+
name,
|
|
46
|
+
description,
|
|
47
|
+
author,
|
|
48
|
+
tags,
|
|
49
|
+
downloads,
|
|
50
|
+
url: raw.url || `https://clawhub.ai/skills/${raw.slug}`,
|
|
51
|
+
version,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
10
54
|
|
|
11
55
|
export async function searchClawHub(query: string, page = 1, limit = 20): Promise<ClawHubSearchResult> {
|
|
12
56
|
try {
|
|
13
|
-
const
|
|
14
|
-
|
|
57
|
+
const params = new URLSearchParams({ limit: String(limit) })
|
|
58
|
+
if (query) params.set('q', query)
|
|
59
|
+
if (page > 1) params.set('page', String(page))
|
|
60
|
+
|
|
61
|
+
const url = `${CLAWHUB_BASE_URL}/skills?${params}`
|
|
62
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
|
15
63
|
if (!res.ok) throw new Error(`ClawHub responded with ${res.status}`)
|
|
16
|
-
|
|
17
|
-
|
|
64
|
+
|
|
65
|
+
const data = await res.json() as { items?: ClawHubRawItem[]; skills?: ClawHubRawItem[]; nextCursor?: string | null; total?: number }
|
|
66
|
+
|
|
67
|
+
// ClawHub v1 returns { items, nextCursor }; fall back to { skills, total } for compat
|
|
68
|
+
const rawItems = data.items || data.skills || []
|
|
69
|
+
const skills = rawItems.map(mapRawToSkill)
|
|
70
|
+
const total = data.total ?? (data.nextCursor ? skills.length + 1 : skills.length)
|
|
71
|
+
|
|
72
|
+
return { skills, total, page, nextCursor: data.nextCursor }
|
|
73
|
+
} catch (err: unknown) {
|
|
74
|
+
console.warn('[clawhub] search failed:', err instanceof Error ? err.message : String(err))
|
|
18
75
|
return { skills: [], total: 0, page }
|
|
19
76
|
}
|
|
20
77
|
}
|
|
21
78
|
|
|
22
79
|
export async function fetchSkillContent(rawUrl: string): Promise<string> {
|
|
23
|
-
|
|
80
|
+
// ClawHub skill pages are at /skills/<slug> — try raw content endpoint first
|
|
81
|
+
let contentUrl = rawUrl
|
|
82
|
+
if (contentUrl.startsWith('https://clawhub.ai/skills/') && !contentUrl.includes('/raw')) {
|
|
83
|
+
const slug = contentUrl.replace('https://clawhub.ai/skills/', '').replace(/\/$/, '')
|
|
84
|
+
// Try the raw content API first
|
|
85
|
+
const rawApiUrl = `${CLAWHUB_BASE_URL}/skills/${slug}/content`
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(rawApiUrl, { signal: AbortSignal.timeout(8000) })
|
|
88
|
+
if (res.ok) {
|
|
89
|
+
const data = await res.json() as { content?: string }
|
|
90
|
+
if (data.content) return data.content
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Fall through to direct fetch
|
|
94
|
+
}
|
|
95
|
+
// Try the raw endpoint pattern
|
|
96
|
+
contentUrl = `https://clawhub.ai/skills/${slug}/raw`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const res = await fetch(contentUrl, { signal: AbortSignal.timeout(10000) })
|
|
24
100
|
if (!res.ok) throw new Error(`Failed to fetch skill content: ${res.status}`)
|
|
25
101
|
return res.text()
|
|
26
102
|
}
|
|
@@ -335,6 +335,7 @@ export function isCurrentGeneration(connectorId: string, gen: number): boolean {
|
|
|
335
335
|
|
|
336
336
|
/** Get platform implementation lazily */
|
|
337
337
|
export async function getPlatform(platform: string) {
|
|
338
|
+
// 1. Check Built-ins
|
|
338
339
|
switch (platform) {
|
|
339
340
|
case 'discord': return (await import('./discord')).default
|
|
340
341
|
case 'telegram': return (await import('./telegram')).default
|
|
@@ -347,8 +348,33 @@ export async function getPlatform(platform: string) {
|
|
|
347
348
|
case 'googlechat': return (await import('./googlechat')).default
|
|
348
349
|
case 'matrix': return (await import('./matrix')).default
|
|
349
350
|
case 'email': return (await import('./email')).default
|
|
350
|
-
default: throw new Error(`Unknown platform: ${platform}`)
|
|
351
351
|
}
|
|
352
|
+
|
|
353
|
+
// 2. Check Plugin-provided connectors
|
|
354
|
+
try {
|
|
355
|
+
const { getPluginManager } = await import('../plugins')
|
|
356
|
+
const manager = getPluginManager()
|
|
357
|
+
const pluginConnectors = manager.getConnectors()
|
|
358
|
+
const found = pluginConnectors.find(c => c.id === platform)
|
|
359
|
+
|
|
360
|
+
if (found) {
|
|
361
|
+
return {
|
|
362
|
+
start: async (connector: Connector, token: string, onMessage: (msg: InboundMessage) => Promise<string>) => {
|
|
363
|
+
const stop = found.startListener ? await found.startListener(onMessage) : () => {}
|
|
364
|
+
return {
|
|
365
|
+
connector,
|
|
366
|
+
stop: async () => { if (stop) await stop() },
|
|
367
|
+
sendMessage: found.sendMessage,
|
|
368
|
+
authenticated: true,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} catch (err: unknown) {
|
|
374
|
+
console.warn(`[connector] Failed to check plugins for platform "${platform}":`, err instanceof Error ? err.message : String(err))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
throw new Error(`Unknown platform: ${platform}`)
|
|
352
378
|
}
|
|
353
379
|
|
|
354
380
|
export function formatMediaLine(media: InboundMedia): string {
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import type { Message } from '@/types'
|
|
2
2
|
import { getMemoryDb } from './memory-db'
|
|
3
3
|
|
|
4
|
+
import { repairTranscriptConsistency } from './transcript-repair'
|
|
5
|
+
|
|
4
6
|
// --- LLM compaction constants ---
|
|
5
7
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
8
|
+
const BASE_CHUNK_RATIO = 0.4
|
|
9
|
+
const MIN_CHUNK_RATIO = 0.15
|
|
10
|
+
const COMPACTION_SAFETY_MARGIN = 1.2
|
|
11
|
+
const COMPACTION_OVERHEAD_TOKENS = 4096
|
|
9
12
|
const MAX_TOOL_FAILURES = 8
|
|
10
13
|
const MAX_FAILURE_CHARS = 240
|
|
11
14
|
|
|
15
|
+
const MERGE_SUMMARIES_INSTRUCTIONS =
|
|
16
|
+
'Merge these partial summaries into a single cohesive summary. Preserve decisions,' +
|
|
17
|
+
' TODOs, open questions, and any constraints.'
|
|
18
|
+
|
|
19
|
+
const IDENTIFIER_PRESERVATION_INSTRUCTIONS =
|
|
20
|
+
'Preserve all opaque identifiers exactly as written (no shortening or reconstruction), ' +
|
|
21
|
+
'including UUIDs, hashes, IDs, tokens, API keys, hostnames, IPs, ports, URLs, and file names.'
|
|
22
|
+
|
|
12
23
|
/** Callback that sends a prompt to an LLM and returns response text */
|
|
13
24
|
export type LLMSummarizer = (prompt: string) => Promise<string>
|
|
14
25
|
|
|
@@ -132,6 +143,44 @@ export function getContextStatus(
|
|
|
132
143
|
}
|
|
133
144
|
}
|
|
134
145
|
|
|
146
|
+
// --- Context degradation warnings ---
|
|
147
|
+
|
|
148
|
+
/** Returns a warning string when context usage exceeds thresholds, or null if within safe bounds. */
|
|
149
|
+
export function getContextDegradationWarning(
|
|
150
|
+
messages: Message[],
|
|
151
|
+
systemPromptTokens: number,
|
|
152
|
+
provider: string,
|
|
153
|
+
model: string,
|
|
154
|
+
): string | null {
|
|
155
|
+
const status = getContextStatus(messages, systemPromptTokens, provider, model)
|
|
156
|
+
const pct = status.percentUsed
|
|
157
|
+
const remaining = status.contextWindow - status.estimatedTokens
|
|
158
|
+
const estTurnsLeft = Math.max(0, Math.floor(remaining / 2000))
|
|
159
|
+
|
|
160
|
+
if (pct >= 85) {
|
|
161
|
+
return [
|
|
162
|
+
`[CONTEXT_WARNING] Context window is ${pct}% full (${status.estimatedTokens.toLocaleString()} / ${status.contextWindow.toLocaleString()} tokens).`,
|
|
163
|
+
`Estimated remaining capacity: ~${estTurnsLeft} turns.`,
|
|
164
|
+
'CRITICAL: Save essential state to memory immediately. Summarize key findings, decisions, and next steps.',
|
|
165
|
+
'Consider completing the current subtask and storing a checkpoint before context is exhausted.',
|
|
166
|
+
].join(' ')
|
|
167
|
+
}
|
|
168
|
+
if (pct >= 70) {
|
|
169
|
+
return [
|
|
170
|
+
`[CONTEXT_WARNING] Context window is ${pct}% full.`,
|
|
171
|
+
`Estimated remaining capacity: ~${estTurnsLeft} turns.`,
|
|
172
|
+
'Recommended: Store important progress notes to memory. Prioritize completing high-value subtasks.',
|
|
173
|
+
].join(' ')
|
|
174
|
+
}
|
|
175
|
+
if (pct >= 60) {
|
|
176
|
+
return [
|
|
177
|
+
`[CONTEXT_WARNING] Context window is ${pct}% full (~${estTurnsLeft} turns remaining).`,
|
|
178
|
+
'Consider saving intermediate state to memory for continuity.',
|
|
179
|
+
].join(' ')
|
|
180
|
+
}
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
135
184
|
// --- Memory consolidation ---
|
|
136
185
|
|
|
137
186
|
/** Extract important facts from old messages before pruning */
|
|
@@ -240,6 +289,54 @@ export function splitMessagesByTokenBudget(messages: Message[], budgetPerChunk:
|
|
|
240
289
|
return chunks
|
|
241
290
|
}
|
|
242
291
|
|
|
292
|
+
/** Compute adaptive chunk ratio based on average message size. */
|
|
293
|
+
export function computeAdaptiveChunkRatio(messages: Message[], contextWindow: number): number {
|
|
294
|
+
if (messages.length === 0) return BASE_CHUNK_RATIO
|
|
295
|
+
const totalTokens = estimateMessagesTokens(messages)
|
|
296
|
+
const avgTokens = totalTokens / messages.length
|
|
297
|
+
const safeAvgTokens = avgTokens * COMPACTION_SAFETY_MARGIN
|
|
298
|
+
const avgRatio = safeAvgTokens / contextWindow
|
|
299
|
+
|
|
300
|
+
if (avgRatio > 0.1) {
|
|
301
|
+
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO)
|
|
302
|
+
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction)
|
|
303
|
+
}
|
|
304
|
+
return BASE_CHUNK_RATIO
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Summarize in hierarchical stages if context is very large */
|
|
308
|
+
export async function summarizeInStages(opts: {
|
|
309
|
+
messages: Message[]
|
|
310
|
+
contextWindow: number
|
|
311
|
+
summarize: LLMSummarizer
|
|
312
|
+
maxChunkTokens: number
|
|
313
|
+
}): Promise<string> {
|
|
314
|
+
const { messages, summarize, maxChunkTokens } = opts
|
|
315
|
+
const totalTokens = estimateMessagesTokens(messages)
|
|
316
|
+
|
|
317
|
+
if (totalTokens <= maxChunkTokens || messages.length < 4) {
|
|
318
|
+
return summarize(buildSummarizationPrompt(messages))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const chunks = splitMessagesByTokenBudget(messages, maxChunkTokens)
|
|
322
|
+
if (chunks.length <= 1) {
|
|
323
|
+
return summarize(buildSummarizationPrompt(messages))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const partialSummaries: string[] = []
|
|
327
|
+
for (const chunk of chunks) {
|
|
328
|
+
try {
|
|
329
|
+
const partial = await summarize(buildSummarizationPrompt(chunk))
|
|
330
|
+
if (partial?.trim()) partialSummaries.push(partial.trim())
|
|
331
|
+
} catch { /* skip failed chunk */ }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (partialSummaries.length === 0) return 'Summary unavailable.'
|
|
335
|
+
if (partialSummaries.length === 1) return partialSummaries[0]
|
|
336
|
+
|
|
337
|
+
return summarize(buildMergePrompt(partialSummaries))
|
|
338
|
+
}
|
|
339
|
+
|
|
243
340
|
/** Build an OpenClaw-aligned summarization prompt for a batch of messages */
|
|
244
341
|
function buildSummarizationPrompt(messages: Message[]): string {
|
|
245
342
|
const transcript = messages.map((m) => {
|
|
@@ -258,13 +355,13 @@ function buildSummarizationPrompt(messages: Message[]): string {
|
|
|
258
355
|
'Summarize the following conversation transcript into structured notes.',
|
|
259
356
|
'',
|
|
260
357
|
'Rules:',
|
|
261
|
-
'- Preserve all decisions, TODOs, open questions, and constraints',
|
|
262
|
-
|
|
263
|
-
'- Note errors encountered and their resolutions',
|
|
264
|
-
'- Keep technical details needed to continue work (versions, configs, commands)',
|
|
265
|
-
'- Aim for 20-40% of original length',
|
|
266
|
-
'- Use structured notes with bullet points, not narrative prose',
|
|
267
|
-
'- Group by topic/theme when possible',
|
|
358
|
+
'- Preserve all decisions, TODOs, open questions, and any constraints.',
|
|
359
|
+
`- ${IDENTIFIER_PRESERVATION_INSTRUCTIONS}`,
|
|
360
|
+
'- Note errors encountered and their resolutions.',
|
|
361
|
+
'- Keep technical details needed to continue work (versions, configs, commands).',
|
|
362
|
+
'- Aim for 20-40% of original length.',
|
|
363
|
+
'- Use structured notes with bullet points, not narrative prose.',
|
|
364
|
+
'- Group by topic/theme when possible.',
|
|
268
365
|
'',
|
|
269
366
|
'---TRANSCRIPT---',
|
|
270
367
|
transcript,
|
|
@@ -280,11 +377,12 @@ function buildMergePrompt(partialSummaries: string[]): string {
|
|
|
280
377
|
'Merge the following partial conversation summaries into a single cohesive summary.',
|
|
281
378
|
'',
|
|
282
379
|
'Rules:',
|
|
283
|
-
'- Remove redundancy across parts while preserving all important details',
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
'-
|
|
287
|
-
'-
|
|
380
|
+
'- Remove redundancy across parts while preserving all important details.',
|
|
381
|
+
`- ${MERGE_SUMMARIES_INSTRUCTIONS}`,
|
|
382
|
+
`- ${IDENTIFIER_PRESERVATION_INSTRUCTIONS}`,
|
|
383
|
+
'- Keep decisions, TODOs, open questions, constraints, and error resolutions.',
|
|
384
|
+
'- Use structured notes with bullet points.',
|
|
385
|
+
'- The result should be shorter than the combined input.',
|
|
288
386
|
'',
|
|
289
387
|
numbered,
|
|
290
388
|
].join('\n')
|
|
@@ -324,62 +422,46 @@ export async function llmCompact(opts: {
|
|
|
324
422
|
return { messages, prunedCount: 0, memoriesStored: 0, summaryAdded: false }
|
|
325
423
|
}
|
|
326
424
|
|
|
327
|
-
const
|
|
328
|
-
const
|
|
425
|
+
const repaired = repairTranscriptConsistency(messages)
|
|
426
|
+
const oldMessages = repaired.slice(0, -keepLastN)
|
|
427
|
+
const recentMessages = repaired.slice(-keepLastN)
|
|
329
428
|
|
|
330
|
-
// 1. Consolidate important info to memory
|
|
429
|
+
// 1. Consolidate important info to memory
|
|
331
430
|
const memoriesStored = consolidateToMemory(oldMessages, agentId, sessionId)
|
|
332
431
|
|
|
333
|
-
// 2. Extract metadata
|
|
432
|
+
// 2. Extract metadata
|
|
334
433
|
const toolFailures = extractToolFailures(oldMessages)
|
|
335
434
|
const fileOps = extractFileOperations(oldMessages)
|
|
336
435
|
|
|
337
|
-
// 3. Compute
|
|
436
|
+
// 3. Compute adaptive budget
|
|
338
437
|
const contextWindow = getContextWindowSize(provider, model)
|
|
339
|
-
const
|
|
438
|
+
const ratio = computeAdaptiveChunkRatio(oldMessages, contextWindow)
|
|
439
|
+
const chunkBudget = Math.floor((contextWindow / COMPACTION_SAFETY_MARGIN) * ratio) - COMPACTION_OVERHEAD_TOKENS
|
|
340
440
|
|
|
341
|
-
// 4.
|
|
342
|
-
const chunks = splitMessagesByTokenBudget(oldMessages, Math.max(chunkBudget, 2000))
|
|
343
|
-
|
|
344
|
-
// 5. Summarize chunks (progressive fallback on failure)
|
|
441
|
+
// 4. Hierarchical summarization
|
|
345
442
|
let finalSummary: string | null = null
|
|
346
443
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
try {
|
|
354
|
-
const partial = await summarize(buildSummarizationPrompt(chunk))
|
|
355
|
-
if (partial?.trim()) partialSummaries.push(partial.trim())
|
|
356
|
-
} catch {
|
|
357
|
-
// Skip failed chunks — progressive fallback
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
if (partialSummaries.length === 0) {
|
|
361
|
-
finalSummary = null // all chunks failed
|
|
362
|
-
} else if (partialSummaries.length === 1) {
|
|
363
|
-
finalSummary = partialSummaries[0]
|
|
364
|
-
} else {
|
|
365
|
-
finalSummary = await summarize(buildMergePrompt(partialSummaries))
|
|
366
|
-
}
|
|
367
|
-
}
|
|
444
|
+
finalSummary = await summarizeInStages({
|
|
445
|
+
messages: oldMessages,
|
|
446
|
+
contextWindow,
|
|
447
|
+
summarize,
|
|
448
|
+
maxChunkTokens: Math.max(chunkBudget, 2000),
|
|
449
|
+
})
|
|
368
450
|
} catch {
|
|
369
451
|
finalSummary = null
|
|
370
452
|
}
|
|
371
453
|
|
|
372
|
-
//
|
|
454
|
+
// 5. Fall back to sliding window if LLM summarization failed entirely
|
|
373
455
|
if (!finalSummary?.trim()) {
|
|
374
456
|
return {
|
|
375
|
-
messages: slidingWindowCompact(
|
|
457
|
+
messages: slidingWindowCompact(repaired, keepLastN),
|
|
376
458
|
prunedCount: oldMessages.length,
|
|
377
459
|
memoriesStored,
|
|
378
460
|
summaryAdded: false,
|
|
379
461
|
}
|
|
380
462
|
}
|
|
381
463
|
|
|
382
|
-
//
|
|
464
|
+
// 6. Append metadata sections
|
|
383
465
|
const metaSections: string[] = [finalSummary.trim()]
|
|
384
466
|
|
|
385
467
|
if (toolFailures.length > 0) {
|
|
@@ -392,7 +474,7 @@ export async function llmCompact(opts: {
|
|
|
392
474
|
metaSections.push('\n## File Operations\n' + parts.join('\n'))
|
|
393
475
|
}
|
|
394
476
|
|
|
395
|
-
//
|
|
477
|
+
// 7. Build context summary message
|
|
396
478
|
const summaryMessage: Message = {
|
|
397
479
|
role: 'assistant',
|
|
398
480
|
text: `[Context Summary]\n${metaSections.join('\n')}`,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import type { Agent } from '@/types'
|
|
4
|
+
import { checkAgentBudgetLimits, getAgentSpendWindows } from './cost.ts'
|
|
5
|
+
|
|
6
|
+
function buildNowTs(): number {
|
|
7
|
+
const d = new Date()
|
|
8
|
+
d.setFullYear(2026, 2, 15)
|
|
9
|
+
d.setHours(12, 0, 0, 0)
|
|
10
|
+
return d.getTime()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('getAgentSpendWindows aggregates hourly/daily/monthly windows', () => {
|
|
14
|
+
const now = buildNowTs()
|
|
15
|
+
const previousMonth = new Date(2026, 1, 20, 12, 0, 0, 0).getTime()
|
|
16
|
+
|
|
17
|
+
const sessions = {
|
|
18
|
+
s1: { agentId: 'agent-a' },
|
|
19
|
+
s2: { agentId: 'agent-b' },
|
|
20
|
+
}
|
|
21
|
+
const usage = {
|
|
22
|
+
s1: [
|
|
23
|
+
{ timestamp: now - 20 * 60_000, estimatedCost: 1.25 }, // within hour
|
|
24
|
+
{ timestamp: now - 3 * 60 * 60_000, estimatedCost: 0.5 }, // today
|
|
25
|
+
{ timestamp: now - 26 * 60 * 60_000, estimatedCost: 2.0 }, // yesterday
|
|
26
|
+
{ timestamp: previousMonth, estimatedCost: 4.0 }, // previous month
|
|
27
|
+
],
|
|
28
|
+
s2: [
|
|
29
|
+
{ timestamp: now - 5 * 60_000, estimatedCost: 99 }, // different agent
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const spend = getAgentSpendWindows('agent-a', now, { sessions, usage })
|
|
34
|
+
assert.equal(spend.hourly, 1.25)
|
|
35
|
+
assert.equal(spend.daily, 1.75)
|
|
36
|
+
assert.equal(spend.monthly, 3.75)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('checkAgentBudgetLimits reports exceeded and warning windows', () => {
|
|
40
|
+
const now = buildNowTs()
|
|
41
|
+
const sessions = { s1: { agentId: 'agent-a' } }
|
|
42
|
+
const usage = {
|
|
43
|
+
s1: [
|
|
44
|
+
{ timestamp: now - 15 * 60_000, estimatedCost: 1.25 }, // hourly over
|
|
45
|
+
{ timestamp: now - 4 * 60 * 60_000, estimatedCost: 0.5 }, // daily near
|
|
46
|
+
{ timestamp: now - 26 * 60 * 60_000, estimatedCost: 2.0 }, // monthly near
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
const agent = {
|
|
50
|
+
id: 'agent-a',
|
|
51
|
+
name: 'Agent A',
|
|
52
|
+
hourlyBudget: 1.0,
|
|
53
|
+
dailyBudget: 2.0,
|
|
54
|
+
monthlyBudget: 4.0,
|
|
55
|
+
} as Agent
|
|
56
|
+
|
|
57
|
+
const result = checkAgentBudgetLimits(agent, now, { sessions, usage })
|
|
58
|
+
assert.equal(result.ok, false)
|
|
59
|
+
assert.deepEqual(result.exceeded.map((x) => x.window), ['hourly'])
|
|
60
|
+
assert.deepEqual(result.warnings.map((x) => x.window), ['daily', 'monthly'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('checkAgentBudgetLimits is ok when no caps are configured', () => {
|
|
64
|
+
const now = buildNowTs()
|
|
65
|
+
const sessions = { s1: { agentId: 'agent-a' } }
|
|
66
|
+
const usage = { s1: [{ timestamp: now - 10 * 60_000, estimatedCost: 10 }] }
|
|
67
|
+
const agent = { id: 'agent-a', name: 'Agent A' } as Agent
|
|
68
|
+
|
|
69
|
+
const result = checkAgentBudgetLimits(agent, now, { sessions, usage })
|
|
70
|
+
assert.equal(result.ok, true)
|
|
71
|
+
assert.equal(result.exceeded.length, 0)
|
|
72
|
+
assert.equal(result.warnings.length, 0)
|
|
73
|
+
})
|