@swarmclawai/swarmclaw 0.6.7 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +24 -6
  2. package/package.json +1 -1
  3. package/src/app/api/agents/route.ts +1 -0
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -0
  5. package/src/app/api/eval/run/route.ts +37 -0
  6. package/src/app/api/eval/scenarios/route.ts +24 -0
  7. package/src/app/api/eval/suite/route.ts +29 -0
  8. package/src/app/api/memory/graph/route.ts +46 -0
  9. package/src/app/api/sessions/[id]/checkpoints/route.ts +31 -0
  10. package/src/app/api/sessions/[id]/restore/route.ts +36 -0
  11. package/src/app/api/souls/[id]/route.ts +65 -0
  12. package/src/app/api/souls/route.ts +70 -0
  13. package/src/app/api/tasks/[id]/route.ts +5 -0
  14. package/src/app/api/tasks/route.ts +2 -0
  15. package/src/app/api/usage/route.ts +9 -2
  16. package/src/cli/index.js +24 -0
  17. package/src/components/agents/agent-sheet.tsx +27 -6
  18. package/src/components/agents/soul-library-picker.tsx +84 -13
  19. package/src/components/chat/activity-moment.tsx +2 -0
  20. package/src/components/chat/checkpoint-timeline.tsx +112 -0
  21. package/src/components/chat/message-list.tsx +19 -3
  22. package/src/components/chat/session-debug-panel.tsx +106 -84
  23. package/src/components/chat/task-approval-card.tsx +78 -0
  24. package/src/components/chat/tool-call-bubble.tsx +3 -0
  25. package/src/components/connectors/connector-sheet.tsx +8 -1
  26. package/src/components/home/home-view.tsx +39 -15
  27. package/src/components/layout/app-layout.tsx +18 -2
  28. package/src/components/memory/memory-browser.tsx +73 -45
  29. package/src/components/memory/memory-graph-view.tsx +203 -0
  30. package/src/components/plugins/plugin-list.tsx +1 -1
  31. package/src/components/schedules/schedule-sheet.tsx +9 -2
  32. package/src/components/shared/hint-tip.tsx +31 -0
  33. package/src/components/shared/settings/section-runtime-loop.tsx +5 -4
  34. package/src/components/tasks/approvals-panel.tsx +120 -0
  35. package/src/components/usage/metrics-dashboard.tsx +25 -3
  36. package/src/lib/server/chat-execution.ts +96 -12
  37. package/src/lib/server/chatroom-helpers.ts +63 -5
  38. package/src/lib/server/chatroom-orchestration.ts +74 -0
  39. package/src/lib/server/context-manager.ts +132 -50
  40. package/src/lib/server/daemon-state.ts +70 -1
  41. package/src/lib/server/eval/runner.ts +126 -0
  42. package/src/lib/server/eval/scenarios.ts +218 -0
  43. package/src/lib/server/eval/scorer.ts +96 -0
  44. package/src/lib/server/eval/store.ts +37 -0
  45. package/src/lib/server/eval/types.ts +48 -0
  46. package/src/lib/server/execution-log.ts +12 -8
  47. package/src/lib/server/guardian.ts +34 -0
  48. package/src/lib/server/heartbeat-service.ts +53 -1
  49. package/src/lib/server/langgraph-checkpoint.ts +10 -0
  50. package/src/lib/server/link-understanding.ts +55 -0
  51. package/src/lib/server/main-agent-loop.ts +114 -15
  52. package/src/lib/server/memory-db.ts +18 -7
  53. package/src/lib/server/mmr.ts +73 -0
  54. package/src/lib/server/orchestrator-lg.ts +3 -0
  55. package/src/lib/server/plugins.ts +44 -22
  56. package/src/lib/server/query-expansion.ts +57 -0
  57. package/src/lib/server/queue.ts +27 -0
  58. package/src/lib/server/session-run-manager.ts +21 -1
  59. package/src/lib/server/session-tools/http.ts +19 -9
  60. package/src/lib/server/session-tools/index.ts +34 -0
  61. package/src/lib/server/session-tools/memory.ts +39 -11
  62. package/src/lib/server/session-tools/schedule.ts +43 -0
  63. package/src/lib/server/session-tools/web.ts +35 -11
  64. package/src/lib/server/storage.ts +12 -0
  65. package/src/lib/server/stream-agent-chat.ts +57 -8
  66. package/src/lib/server/tool-capability-policy.ts +1 -0
  67. package/src/lib/server/tool-retry.ts +62 -0
  68. package/src/lib/server/transcript-repair.ts +72 -0
  69. package/src/lib/setup-defaults.ts +1 -0
  70. package/src/lib/tool-definitions.ts +1 -0
  71. package/src/lib/validation/schemas.ts +1 -0
  72. package/src/lib/view-routes.ts +1 -0
  73. package/src/types/index.ts +34 -3
@@ -0,0 +1,34 @@
1
+ import { execSync } from 'child_process'
2
+ import path from 'path'
3
+ import fs from 'fs'
4
+
5
+ /**
6
+ * OpenClaw Guardian — Auto-Rollback capability.
7
+ * If an agent fails a task critically and has autoRecovery enabled,
8
+ * we attempt to roll back the workspace to the last known good state.
9
+ */
10
+ export function performGuardianRollback(cwd: string): { ok: boolean; reason?: string } {
11
+ try {
12
+ const gitDir = path.join(cwd, '.git')
13
+ if (!fs.existsSync(gitDir)) {
14
+ return { ok: false, reason: 'Workspace is not a git repository. Cannot rollback.' }
15
+ }
16
+
17
+ // Check if dirty
18
+ const status = execSync('git status --porcelain', { cwd, encoding: 'utf8' })
19
+ if (!status.trim()) {
20
+ return { ok: false, reason: 'Workspace is clean. Nothing to rollback.' }
21
+ }
22
+
23
+ console.log(`[guardian] Auto-recovery triggered in ${cwd}. Rolling back changes...`)
24
+
25
+ // Perform rollback
26
+ execSync('git reset --hard HEAD', { cwd, encoding: 'utf8' })
27
+ execSync('git clean -fd', { cwd, encoding: 'utf8' })
28
+
29
+ return { ok: true }
30
+ } catch (err: unknown) {
31
+ console.error('[guardian] Auto-rollback failed:', err)
32
+ return { ok: false, reason: `Git operation failed: ${err instanceof Error ? err.message : String(err)}` }
33
+ }
34
+ }
@@ -132,6 +132,45 @@ function readHeartbeatFile(session: any): string {
132
132
  return ''
133
133
  }
134
134
 
135
+ function readIdentityFile(session: Record<string, unknown>): Record<string, string> {
136
+ try {
137
+ const filePath = path.join(typeof session.cwd === 'string' ? session.cwd : WORKSPACE_DIR, 'IDENTITY.md')
138
+ if (fs.existsSync(filePath)) {
139
+ const content = fs.readFileSync(filePath, 'utf-8')
140
+ const identity: Record<string, string> = {}
141
+ for (const line of content.split('\n')) {
142
+ const cleaned = line.trim().replace(/^\s*-\s*/, '')
143
+ const colonIndex = cleaned.indexOf(':')
144
+ if (colonIndex === -1) continue
145
+ const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, '').trim().toLowerCase()
146
+ const value = cleaned.slice(colonIndex + 1).replace(/^[*_]+|[*_]+$/g, '').trim()
147
+ if (value) identity[label] = value
148
+ }
149
+ return identity
150
+ }
151
+ } catch { /* ignore */ }
152
+ return {}
153
+ }
154
+
155
+ export function buildIdentityContext(session: Record<string, unknown> | undefined | null, agent: Record<string, unknown> | undefined | null): string {
156
+ const fileId = session ? readIdentityFile(session) : {}
157
+ const name = fileId.name || agent?.name || ''
158
+ const emoji = fileId.emoji || agent?.emoji || ''
159
+ const creature = fileId.creature || agent?.creature || ''
160
+ const vibe = fileId.vibe || agent?.vibe || ''
161
+ const theme = fileId.theme || agent?.theme || ''
162
+
163
+ const lines = []
164
+ if (name) lines.push(`Name: ${name}`)
165
+ if (emoji) lines.push(`Emoji: ${emoji}`)
166
+ if (creature) lines.push(`Creature: ${creature}`)
167
+ if (vibe) lines.push(`Vibe: ${vibe}`)
168
+ if (theme) lines.push(`Theme: ${theme}`)
169
+
170
+ if (lines.length === 0) return ''
171
+ return `## Your Identity\n${lines.join('\n')}`
172
+ }
173
+
135
174
  /** Detect HEARTBEAT.md files that contain only skeleton structure (headers, empty list items) but no real content. */
136
175
  export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
137
176
  if (!content || typeof content !== 'string') return true
@@ -148,6 +187,7 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
148
187
  function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: string, heartbeatFileContent: string): string {
149
188
  if (!agent) return fallbackPrompt
150
189
 
190
+ const identityContext = buildIdentityContext(session, agent)
151
191
  // Drain system events accumulated since last heartbeat
152
192
  const events = drainSystemEvents(session.id)
153
193
  const eventBlock = events.length > 0
@@ -178,7 +218,7 @@ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: str
178
218
  return [
179
219
  'AGENT_HEARTBEAT_TICK',
180
220
  `Time: ${new Date().toISOString()}`,
181
- `Agent: ${agent.name}`,
221
+ identityContext,
182
222
  description ? `Description: ${description}` : '',
183
223
  eventBlock ? `Events since last heartbeat:\n${eventBlock}` : '',
184
224
  dynamicGoal
@@ -202,6 +242,14 @@ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: str
202
242
  ].filter(Boolean).join('\n')
203
243
  }
204
244
 
245
+ function applyMomentumMultiplier(intervalSec: number, momentumScore: number): number {
246
+ let multiplier = 1.0
247
+ if (momentumScore >= 80) multiplier = 0.5
248
+ else if (momentumScore < 40) multiplier = 2.0
249
+ const adjusted = Math.round(intervalSec * multiplier)
250
+ return Math.max(30, Math.min(7200, adjusted))
251
+ }
252
+
205
253
  function resolveInterval(obj: Record<string, any>, currentSec: number): number {
206
254
  // Prefer heartbeatInterval (duration string) over heartbeatIntervalSec (raw number)
207
255
  if (obj.heartbeatInterval !== undefined && obj.heartbeatInterval !== null) {
@@ -347,6 +395,10 @@ async function tickHeartbeats() {
347
395
  const cfg = heartbeatConfigForSession(session, settings, agents)
348
396
  if (!cfg.enabled) continue
349
397
 
398
+ // Apply momentum-based multiplier to heartbeat interval
399
+ const momentumScore = session.mainLoopState?.momentumScore ?? 40
400
+ cfg.intervalSec = applyMomentumMultiplier(cfg.intervalSec, momentumScore)
401
+
350
402
  // For sessions with explicit opt-in, use a shorter idle threshold (just intervalSec * 2).
351
403
  // For inherited/global heartbeats, keep the 180s minimum to avoid noisy auto-fire.
352
404
  const defaultIdleSec = explicitOptIn
@@ -266,6 +266,16 @@ export class SqliteCheckpointSaver extends BaseCheckpointSaver {
266
266
  this.db.prepare(`DELETE FROM langgraph_checkpoints WHERE thread_id = ?`).run(threadId)
267
267
  this.db.prepare(`DELETE FROM langgraph_writes WHERE thread_id = ?`).run(threadId)
268
268
  }
269
+
270
+ async deleteCheckpoint(threadId: string, checkpointId: string): Promise<void> {
271
+ this.db.prepare(`DELETE FROM langgraph_checkpoints WHERE thread_id = ? AND checkpoint_id = ?`).run(threadId, checkpointId)
272
+ this.db.prepare(`DELETE FROM langgraph_writes WHERE thread_id = ? AND checkpoint_id = ?`).run(threadId, checkpointId)
273
+ }
274
+
275
+ async deleteCheckpointsAfter(threadId: string, timestamp: number): Promise<void> {
276
+ this.db.prepare(`DELETE FROM langgraph_checkpoints WHERE thread_id = ? AND created_at > ?`).run(threadId, timestamp)
277
+ this.db.prepare(`DELETE FROM langgraph_writes WHERE thread_id = ? AND checkpoint_id NOT IN (SELECT checkpoint_id FROM langgraph_checkpoints WHERE thread_id = ?)`).run(threadId, threadId)
278
+ }
269
279
  }
270
280
 
271
281
  let _saver: SqliteCheckpointSaver | undefined
@@ -0,0 +1,55 @@
1
+ import * as cheerio from 'cheerio'
2
+ import { truncate } from './session-tools/context'
3
+
4
+ const BARE_LINK_RE = /https?:\/\/\S+/gi
5
+
6
+ /**
7
+ * Automatically fetch and summarize links found in user messages.
8
+ * This aligns SwarmClaw with OpenClaw's proactive link-understanding feature.
9
+ */
10
+ export async function runLinkUnderstanding(message: string): Promise<string[]> {
11
+ const links = message.match(BARE_LINK_RE)
12
+ if (!links || links.length === 0) return []
13
+
14
+ const uniqueLinks = Array.from(new Set(links)).slice(0, 3) // Limit to first 3 links
15
+ const results: string[] = []
16
+
17
+ for (const url of uniqueLinks) {
18
+ try {
19
+ const res = await fetch(url, {
20
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SwarmClaw/1.0)' },
21
+ signal: AbortSignal.timeout(8000),
22
+ })
23
+ if (!res.ok) continue
24
+
25
+ const contentType = res.headers.get('content-type') || ''
26
+ if (contentType.includes('text/html')) {
27
+ const html = await res.text()
28
+ const $ = cheerio.load(html)
29
+
30
+ // Handle YouTube specifically (OpenClaw favorite)
31
+ if (url.includes('youtube.com/') || url.includes('youtu.be/')) {
32
+ const title = $('meta[property="og:title"]').attr('content') || $('title').text()
33
+ const desc = $('meta[property="og:description"]').attr('content') || ''
34
+ results.push(`[Link Analysis: YouTube] ${url}\nTitle: ${title}\nDescription: ${desc}`)
35
+ continue
36
+ }
37
+
38
+ // General web page extraction
39
+ $('script, style, noscript, nav, footer, header').remove()
40
+ const title = $('title').text().trim()
41
+ const main = $('article, main, [role="main"]').first()
42
+ const bodyText = (main.length ? main.text() : $('body').text())
43
+ .replace(/\s+/g, ' ')
44
+ .trim()
45
+
46
+ results.push(`[Link Analysis] ${url}\nTitle: ${title}\nContent: ${truncate(bodyText, 1000)}`)
47
+ }
48
+ } catch (err) {
49
+ // Fail silently for link understanding — don't block the main run
50
+ console.error(`Link understanding failed for ${url}:`, err)
51
+ }
52
+ }
53
+
54
+ return results
55
+ }
@@ -1,22 +1,40 @@
1
1
  import { genId } from '@/lib/id'
2
2
  import { z } from 'zod'
3
3
  import type { GoalContract, MessageToolEvent } from '@/types'
4
- import { loadSessions, saveSessions, loadAgents, saveAgents, loadTasks, saveTasks } from './storage'
4
+ import { loadSessions, saveSessions, loadAgents, saveAgents, loadTasks, saveTasks, loadSettings } from './storage'
5
5
  import { log } from './logger'
6
6
  import { getMemoryDb } from './memory-db'
7
7
  import { isProtectedMainSession } from './main-session'
8
+ import { logExecution } from './execution-log'
8
9
  import {
9
10
  mergeGoalContracts,
10
11
  parseGoalContractFromText,
11
12
  parseMainLoopPlan,
12
13
  parseMainLoopReview,
13
14
  } from './autonomy-contract'
14
- const MAX_PENDING_EVENTS = 40
15
- const MAX_TIMELINE_EVENTS = 80
15
+ import { buildIdentityContext } from './heartbeat-service'
16
+
17
+ const MAX_PENDING_EVENTS = 60
18
+ const MAX_TIMELINE_EVENTS = 120
16
19
  const EVENT_TTL_MS = 7 * 24 * 60 * 60 * 1000
17
- const MEMORY_NOTE_MIN_INTERVAL_MS = 90 * 60 * 1000
20
+ const MEMORY_NOTE_MIN_INTERVAL_MS = 30 * 60 * 1000
18
21
  const DEFAULT_FOLLOWUP_DELAY_SEC = 45
19
- const MAX_FOLLOWUP_CHAIN = 6
22
+ const DEFAULT_MAX_FOLLOWUP_CHAIN = 20
23
+ function getMaxFollowupChain(agentId: string | undefined): number {
24
+ if (agentId) {
25
+ const agents = loadAgents()
26
+ const agent = agents[agentId]
27
+ if (typeof agent?.maxFollowupChain === 'number' && agent.maxFollowupChain > 0) {
28
+ return Math.min(agent.maxFollowupChain, 100)
29
+ }
30
+ }
31
+ const settings = loadSettings()
32
+ if (typeof settings?.maxFollowupChain === 'number' && settings.maxFollowupChain > 0) {
33
+ return Math.min(settings.maxFollowupChain, 100)
34
+ }
35
+ return DEFAULT_MAX_FOLLOWUP_CHAIN
36
+ }
37
+
20
38
  const META_LINE_RE = /\[MAIN_LOOP_META\]\s*(\{[^\n]*\})/i
21
39
  const AGENT_HEARTBEAT_META_RE = /\[AGENT_HEARTBEAT_META\]\s*(\{[^\n]*\})/i
22
40
  const SCREENSHOT_GOAL_HINT = /\b(screenshot|screen shot|snapshot|capture)\b/i
@@ -25,6 +43,14 @@ const SCHEDULE_GOAL_HINT = /\b(schedule|scheduled|every\s+\w+|interval|cron|recu
25
43
  const UPLOAD_ARTIFACT_HINT = /(?:sandbox:)?\/api\/uploads\/[^\s)\]]+|https?:\/\/[^\s)\]]+\.(?:png|jpe?g|webp|gif|pdf)\b/i
26
44
  const SENT_ARTIFACT_HINT = /\b(sent|shared|uploaded|returned)\b[^.]*\b(screenshot|snapshot|image|file)\b/i
27
45
 
46
+ const COMPANION_GOAL_PROMPT = `
47
+ ## Identity & Vibe
48
+ You are a persistent companion.
49
+ 1. **Identity**: Embody your creature, theme, and vibe. Your emoji is your signature.
50
+ 2. **Workspace Context**: Respect the current workspace. Read IDENTITY.md and HEARTBEAT.md if they exist.
51
+ 3. **Continuity**: Maintain awareness of the user's long-term journey. Proactively help with open-ended goals without being asked for every step.
52
+ `.trim()
53
+
28
54
  interface MainLoopSessionMessageLike {
29
55
  text?: string
30
56
  }
@@ -45,13 +71,12 @@ export interface MainLoopTimelineEntry {
45
71
  at: number
46
72
  source: string
47
73
  note: string
48
- status?: 'idle' | 'progress' | 'blocked' | 'ok'
74
+ status?: 'idle' | 'progress' | 'blocked' | 'ok' | 'reflection'
49
75
  }
50
76
 
51
77
  export interface MainLoopState {
52
78
  goal: string | null
53
79
  goalContract: GoalContract | null
54
- status: 'idle' | 'progress' | 'blocked' | 'ok'
55
80
  summary: string | null
56
81
  nextAction: string | null
57
82
  planSteps: string[]
@@ -61,9 +86,12 @@ export interface MainLoopState {
61
86
  missionTaskId: string | null
62
87
  momentumScore: number
63
88
  paused: boolean
89
+ status: 'idle' | 'progress' | 'blocked' | 'ok'
64
90
  autonomyMode: 'assist' | 'autonomous'
65
91
  pendingEvents: MainLoopEvent[]
66
92
  timeline: MainLoopTimelineEntry[]
93
+ missionTokens: number
94
+ missionCostUsd: number
67
95
  followupChainCount: number
68
96
  metaMissCount: number
69
97
  workingMemoryNotes: string[]
@@ -104,6 +132,9 @@ export interface HandleMainLoopRunResultInput {
104
132
  resultText: string
105
133
  error?: string
106
134
  toolEvents?: MessageToolEvent[]
135
+ inputTokens?: number
136
+ outputTokens?: number
137
+ estimatedCost?: number
107
138
  }
108
139
 
109
140
  function toOneLine(value: string, max = 240): string {
@@ -143,7 +174,7 @@ function appendTimeline(
143
174
  source: string,
144
175
  note: string,
145
176
  now = Date.now(),
146
- status?: 'idle' | 'progress' | 'blocked' | 'ok',
177
+ status?: 'idle' | 'progress' | 'blocked' | 'ok' | 'reflection',
147
178
  ) {
148
179
  const normalizedNote = toOneLine(note, 400)
149
180
  if (!normalizedNote) return
@@ -278,6 +309,8 @@ function normalizeState(raw: any, now = Date.now()): MainLoopState {
278
309
  autonomyMode: raw?.autonomyMode === 'assist' ? 'assist' : 'autonomous',
279
310
  pendingEvents,
280
311
  timeline,
312
+ missionTokens: typeof raw?.missionTokens === 'number' && Number.isFinite(raw.missionTokens) ? raw.missionTokens : 0,
313
+ missionCostUsd: typeof raw?.missionCostUsd === 'number' && Number.isFinite(raw.missionCostUsd) ? raw.missionCostUsd : 0,
281
314
  followupChainCount: clampInt(raw?.followupChainCount, 0, 0, 100),
282
315
  metaMissCount: clampInt(raw?.metaMissCount, 0, 0, 100),
283
316
  workingMemoryNotes: normalizeStringList(raw?.workingMemoryNotes, 24, 260),
@@ -489,6 +522,7 @@ function upsertMissionTask(session: any, state: MainLoopState, now: number): str
489
522
  const statusMap = {
490
523
  idle: 'backlog',
491
524
  progress: 'running',
525
+ reflection: 'running',
492
526
  blocked: 'failed',
493
527
  ok: 'completed',
494
528
  } as const
@@ -603,6 +637,9 @@ function maybeStoreMissionMemoryNote(
603
637
  state.reviewNote ? `review: ${state.reviewNote}` : '',
604
638
  typeof state.reviewConfidence === 'number' ? `review_confidence: ${state.reviewConfidence}` : '',
605
639
  state.missionTaskId ? `mission_task_id: ${state.missionTaskId}` : '',
640
+ typeof state.missionTokens === 'number' ? `mission_tokens: ${state.missionTokens}` : '',
641
+ typeof state.missionCostUsd === 'number' ? `mission_cost_usd: $${state.missionCostUsd.toFixed(4)}` : '',
642
+ state.workingMemoryNotes?.length ? `working_notes: ${state.workingMemoryNotes.slice(-5).join('; ')}` : '',
606
643
  ].filter(Boolean).join('\n')
607
644
 
608
645
  try {
@@ -622,20 +659,27 @@ function maybeStoreMissionMemoryNote(
622
659
  category: 'mission',
623
660
  title,
624
661
  content,
625
- } as any)
662
+ })
626
663
  state.lastMemoryNoteAt = now
627
- } catch (err: any) {
628
- appendEvent(state, 'memory_note_error', `Failed to store mission memory note: ${toOneLine(err?.message || String(err), 240)}`, now)
664
+ logExecution(session.id, 'mission_checkpoint', `Checkpoint: ${toOneLine(state.goal || '', 120)}`, {
665
+ agentId: session.agentId,
666
+ detail: { momentumScore: state.momentumScore, followupChainCount: state.followupChainCount, missionTokens: state.missionTokens, missionCostUsd: state.missionCostUsd },
667
+ })
668
+ } catch (err: unknown) {
669
+ appendEvent(state, 'memory_note_error', `Failed to store mission memory note: ${toOneLine(err instanceof Error ? err.message : String(err), 240)}`, now)
629
670
  }
630
671
  }
631
672
 
632
- function buildFollowupPrompt(state: MainLoopState, opts?: { hasMemoryTool?: boolean }): string {
673
+ function buildFollowupPrompt(state: MainLoopState, opts?: { hasMemoryTool?: boolean; agent?: Record<string, unknown> | null; session?: Record<string, unknown> | null }): string {
633
674
  const hasMemoryTool = opts?.hasMemoryTool === true
675
+ const identityContext = buildIdentityContext(opts?.session, opts?.agent)
634
676
  const goal = state.goal || 'No explicit goal yet. Continue with the strongest actionable objective from recent context.'
635
677
  const nextAction = state.nextAction || 'Determine the next highest-impact action and execute it.'
636
678
  const contractLines = buildGoalContractLines(state)
637
679
  return [
638
680
  'SWARM_MAIN_AUTO_FOLLOWUP',
681
+ identityContext,
682
+ COMPANION_GOAL_PROMPT,
639
683
  `Mission goal: ${goal}`,
640
684
  `Next action to execute now: ${nextAction}`,
641
685
  `Current status: ${state.status}`,
@@ -647,6 +691,9 @@ function buildFollowupPrompt(state: MainLoopState, opts?: { hasMemoryTool?: bool
647
691
  state.reviewNote ? `Last review: ${state.reviewNote}` : '',
648
692
  buildPendingEventLines(state),
649
693
  buildTimelineLines(state),
694
+ state.planSteps.length === 0 && state.followupChainCount === 0
695
+ ? 'Before executing, break the mission goal into 3-7 concrete subtasks. Output a [MAIN_LOOP_PLAN] JSON line with your plan, then execute the first step immediately.'
696
+ : '',
650
697
  'Act autonomously. Use available tools to execute work, verify results, and keep momentum.',
651
698
  state.autonomyMode === 'assist'
652
699
  ? 'Assist mode: execute safe internal analysis by default, and ask before irreversible external side effects (sending messages, purchases, account mutations).'
@@ -673,6 +720,9 @@ export function isMainSession(session: any): boolean {
673
720
 
674
721
  export function buildMainLoopHeartbeatPrompt(session: any, fallbackPrompt: string): string {
675
722
  const now = Date.now()
723
+ const agents = loadAgents()
724
+ const agent = session.agentId ? agents[session.agentId] : null
725
+ const identityContext = buildIdentityContext(session, agent)
676
726
  const state = normalizeState(session?.mainLoopState, now)
677
727
  const goal = state.goal || inferGoalFromSessionMessages(session) || null
678
728
  const hasMemoryTool = Array.isArray(session?.tools) && session.tools.includes('memory')
@@ -684,6 +734,8 @@ export function buildMainLoopHeartbeatPrompt(session: any, fallbackPrompt: strin
684
734
 
685
735
  return [
686
736
  'SWARM_MAIN_MISSION_TICK',
737
+ identityContext,
738
+ COMPANION_GOAL_PROMPT,
687
739
  `Time: ${new Date(now).toISOString()}`,
688
740
  `Mission goal: ${promptGoal}`,
689
741
  `Current status: ${state.status}`,
@@ -902,6 +954,10 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
902
954
  appendTimeline(state, 'user_goal', `Goal updated: ${userGoal}`, now, state.status)
903
955
  appendWorkingMemoryNote(state, `goal:${userGoal}`)
904
956
  forceMemoryNote = true
957
+ logExecution(input.sessionId, 'mission_start', `New goal: ${toOneLine(userGoal, 200)}`, {
958
+ agentId: session.agentId,
959
+ detail: { goal: userGoal, planSteps: state.planSteps },
960
+ })
905
961
  } else if (userGoalContract?.objective) {
906
962
  state.goal = userGoalContract.objective
907
963
  state.goalContract = mergeGoalContracts(state.goalContract, userGoalContract)
@@ -909,8 +965,22 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
909
965
  appendTimeline(state, 'user_goal_contract', `Goal contract updated: ${userGoalContract.objective}`, now, state.status)
910
966
  appendWorkingMemoryNote(state, `contract:${userGoalContract.objective}`)
911
967
  forceMemoryNote = true
968
+ logExecution(input.sessionId, 'mission_start', `New goal contract: ${toOneLine(userGoalContract.objective, 200)}`, {
969
+ agentId: session.agentId,
970
+ detail: { goal: userGoalContract.objective, contract: userGoalContract, planSteps: state.planSteps },
971
+ })
912
972
  }
913
973
  state.followupChainCount = 0
974
+ state.missionTokens = 0
975
+ state.missionCostUsd = 0
976
+ }
977
+
978
+ // Accumulate per-mission token/cost tracking
979
+ if (typeof input.inputTokens === 'number') {
980
+ state.missionTokens = (state.missionTokens || 0) + input.inputTokens + (input.outputTokens || 0)
981
+ }
982
+ if (typeof input.estimatedCost === 'number') {
983
+ state.missionCostUsd = (state.missionCostUsd || 0) + input.estimatedCost
914
984
  }
915
985
 
916
986
  if (state.paused && input.internal) {
@@ -958,7 +1028,7 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
958
1028
 
959
1029
  if (shouldAutoKickFromUserGoal) {
960
1030
  followup = {
961
- message: buildFollowupPrompt(state, { hasMemoryTool }),
1031
+ message: buildFollowupPrompt(state, { hasMemoryTool, agent: session.agentId ? loadAgents()[session.agentId] : null, session }),
962
1032
  delayMs: 1500,
963
1033
  dedupeKey: `main-loop-user-kickoff:${input.sessionId}`,
964
1034
  }
@@ -1020,11 +1090,33 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
1020
1090
  )
1021
1091
  consumeEvents(state, meta.consume_event_ids)
1022
1092
 
1023
- if (meta.follow_up === true && !input.error && !isHeartbeatOk && !state.paused && state.followupChainCount < MAX_FOLLOWUP_CHAIN) {
1093
+ // Budget enforcement: check mission cost against goalContract.budgetUsd
1094
+ const budgetUsd = state.goalContract?.budgetUsd
1095
+ if (typeof budgetUsd === 'number' && budgetUsd > 0 && typeof state.missionCostUsd === 'number') {
1096
+ const usageRatio = state.missionCostUsd / budgetUsd
1097
+ if (usageRatio >= 1.0 && !state.paused) {
1098
+ state.paused = true
1099
+ state.status = 'blocked'
1100
+ appendTimeline(state, 'budget_exceeded', `Mission budget exceeded ($${state.missionCostUsd.toFixed(4)} / $${budgetUsd.toFixed(4)}). Mission paused.`, now, 'blocked')
1101
+ appendEvent(state, 'budget_exceeded', `Budget limit reached: $${state.missionCostUsd.toFixed(4)} of $${budgetUsd.toFixed(4)}`, now)
1102
+ logExecution(input.sessionId, 'budget_warning', `Budget exceeded: $${state.missionCostUsd.toFixed(4)} / $${budgetUsd.toFixed(4)}`, {
1103
+ agentId: session.agentId,
1104
+ detail: { missionCostUsd: state.missionCostUsd, budgetUsd, missionTokens: state.missionTokens },
1105
+ })
1106
+ } else if (usageRatio >= 0.8) {
1107
+ appendEvent(state, 'budget_warning', `Mission approaching budget limit: $${state.missionCostUsd.toFixed(4)} of $${budgetUsd.toFixed(4)} (${Math.round(usageRatio * 100)}%)`, now)
1108
+ logExecution(input.sessionId, 'budget_warning', `Budget at ${Math.round(usageRatio * 100)}%: $${state.missionCostUsd.toFixed(4)} / $${budgetUsd.toFixed(4)}`, {
1109
+ agentId: session.agentId,
1110
+ detail: { missionCostUsd: state.missionCostUsd, budgetUsd, usagePercent: Math.round(usageRatio * 100) },
1111
+ })
1112
+ }
1113
+ }
1114
+
1115
+ if (meta.follow_up === true && !input.error && !isHeartbeatOk && !state.paused && state.followupChainCount < getMaxFollowupChain(session.agentId)) {
1024
1116
  state.followupChainCount += 1
1025
1117
  const delaySec = clampInt(meta.delay_sec, DEFAULT_FOLLOWUP_DELAY_SEC, 5, 900)
1026
1118
  followup = {
1027
- message: buildFollowupPrompt(state, { hasMemoryTool }),
1119
+ message: buildFollowupPrompt(state, { hasMemoryTool, agent: session.agentId ? loadAgents()[session.agentId] : null, session }),
1028
1120
  delayMs: delaySec * 1000,
1029
1121
  dedupeKey: `main-loop-followup:${input.sessionId}`,
1030
1122
  }
@@ -1034,6 +1126,12 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
1034
1126
  }
1035
1127
  if (state.status === 'ok' || state.status === 'blocked') {
1036
1128
  forceMemoryNote = true
1129
+ if (state.status === 'ok') {
1130
+ logExecution(input.sessionId, 'mission_complete', `Mission completed: ${toOneLine(state.goal || 'unknown', 200)}`, {
1131
+ agentId: session.agentId,
1132
+ detail: { momentumScore: state.momentumScore, followupChainCount: state.followupChainCount, missionTokens: state.missionTokens, missionCostUsd: state.missionCostUsd },
1133
+ })
1134
+ }
1037
1135
  }
1038
1136
  } else if (!isHeartbeatOk && trimmedText) {
1039
1137
  state.metaMissCount = Math.min(100, state.metaMissCount + 1)
@@ -1064,6 +1162,7 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
1064
1162
  state.missionTaskId = upsertMissionTask(session, state, now)
1065
1163
  const shouldWritePeriodicMemory = !!state.summary && state.status === 'progress'
1066
1164
  maybeStoreMissionMemoryNote(session, state, now, input.source, forceMemoryNote || shouldWritePeriodicMemory)
1165
+
1067
1166
  state.momentumScore = computeMomentumScore(state)
1068
1167
 
1069
1168
  state.updatedAt = now
@@ -5,6 +5,7 @@ import { createHash } from 'crypto'
5
5
  import { genId } from '@/lib/id'
6
6
  import type { MemoryEntry, FileReference, MemoryImage, MemoryReference } from '@/types'
7
7
  import { getEmbedding, cosineSimilarity, serializeEmbedding, deserializeEmbedding } from './embeddings'
8
+ import { applyMMR } from './mmr'
8
9
  import { loadSettings } from './storage'
9
10
  import {
10
11
  normalizeLinkedMemoryIds,
@@ -23,8 +24,8 @@ const MAX_IMAGE_INPUT_BYTES = 10 * 1024 * 1024 // 10MB
23
24
  const IMAGE_EXT_WHITELIST = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff'])
24
25
  export const MAX_FTS_QUERY_TERMS = 6
25
26
  export const MAX_FTS_TERM_LENGTH = 48
26
- const MAX_FTS_RESULT_ROWS = 30
27
- const MAX_MERGED_RESULTS = 50
27
+ const MAX_FTS_RESULT_ROWS = 50
28
+ const MAX_MERGED_RESULTS = 80
28
29
 
29
30
  export const MEMORY_FTS_STOP_WORDS = new Set([
30
31
  'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'how',
@@ -865,9 +866,12 @@ function initDb() {
865
866
 
866
867
  // Attempt vector search (synchronous — uses cached embedding if available)
867
868
  const vectorSimilarityScores = new Map<string, number>()
869
+ const rawEmbeddings = new Map<string, number[]>()
868
870
  let vectorResults: MemoryEntry[] = []
871
+ let queryEmbeddingResult: number[] | undefined
869
872
  try {
870
873
  const queryEmbedding = getEmbeddingSync(query)
874
+ queryEmbeddingResult = queryEmbedding || undefined
871
875
  if (queryEmbedding) {
872
876
  const rows = agentId
873
877
  ? getAllWithEmbeddingsByAgent.all(agentId) as any[]
@@ -877,7 +881,7 @@ function initDb() {
877
881
  .map((row) => {
878
882
  const emb = deserializeEmbedding(row.embedding)
879
883
  const score = cosineSimilarity(queryEmbedding, emb)
880
- return { row, score }
884
+ return { row, score, emb }
881
885
  })
882
886
  .filter((s) => s.score > 0.3) // relevance threshold
883
887
  .sort((a, b) => b.score - a.score)
@@ -886,6 +890,7 @@ function initDb() {
886
890
  vectorResults = scored.map((s) => {
887
891
  const entry = rowToEntry(s.row)
888
892
  vectorSimilarityScores.set(entry.id, s.score)
893
+ rawEmbeddings.set(entry.id, s.emb)
889
894
  return entry
890
895
  })
891
896
  }
@@ -913,11 +918,17 @@ function initDb() {
913
918
  const reinforcement = Math.log((entry.reinforcementCount || 0) + 1) + 1
914
919
  const pinnedBoost = entry.pinned ? 1.5 : 1.0
915
920
  const salience = similarity * recencyDecay * reinforcement * pinnedBoost
916
- return { entry, salience }
921
+ return { entry, salience, embedding: rawEmbeddings.get(entry.id) }
917
922
  })
918
- salienceScored.sort((a, b) => b.salience - a.salience)
919
-
920
- const out = salienceScored.slice(0, MAX_MERGED_RESULTS).map((s) => s.entry)
923
+
924
+ // Apply MMR if we have a query embedding, otherwise standard sort
925
+ let out: MemoryEntry[] = []
926
+ if (queryEmbeddingResult) {
927
+ out = applyMMR(queryEmbeddingResult, salienceScored, MAX_MERGED_RESULTS, 0.6) // Lambda 0.6 = favor relevance slightly over diversity
928
+ } else {
929
+ salienceScored.sort((a, b) => b.salience - a.salience)
930
+ out = salienceScored.slice(0, MAX_MERGED_RESULTS).map((s) => s.entry)
931
+ }
921
932
 
922
933
  // Bump access counts for returned results (non-blocking)
923
934
  if (out.length) {
@@ -0,0 +1,73 @@
1
+ import { cosineSimilarity } from './embeddings'
2
+ import type { MemoryEntry } from '@/types'
3
+
4
+ /**
5
+ * Applies Maximal Marginal Relevance (MMR) to diversify search results.
6
+ * It balances relevance to the query (salience/similarity) against novelty
7
+ * compared to already-selected documents.
8
+ */
9
+ export function applyMMR(
10
+ queryEmbedding: number[],
11
+ candidates: Array<{ entry: MemoryEntry; salience: number; embedding?: number[] }>,
12
+ limit: number,
13
+ lambda: number = 0.5
14
+ ): MemoryEntry[] {
15
+ if (candidates.length === 0) return []
16
+
17
+ // Normalize salience to [0, 1] range
18
+ const maxSalience = Math.max(...candidates.map(c => c.salience))
19
+ const minSalience = Math.min(...candidates.map(c => c.salience))
20
+ const salienceRange = maxSalience - minSalience || 1
21
+
22
+ const candidatesWithNormalizedSalience = candidates.map(c => ({
23
+ ...c,
24
+ normSalience: (c.salience - minSalience) / salienceRange
25
+ }))
26
+
27
+ const selected: typeof candidatesWithNormalizedSalience = []
28
+ const remaining = [...candidatesWithNormalizedSalience]
29
+
30
+ // Debug: uncomment for troubleshooting
31
+ // console.log(`[mmr] Starting MMR for ${remaining.length} candidates, limit=${limit}, lambda=${lambda}`)
32
+
33
+ while (selected.length < limit && remaining.length > 0) {
34
+ let bestMmrScore = -Infinity
35
+ let bestIndex = -1
36
+
37
+ for (let i = 0; i < remaining.length; i++) {
38
+ const candidate = remaining[i]
39
+
40
+ let maxSimToSelected = 0
41
+ if (selected.length > 0 && candidate.embedding) {
42
+ for (const sel of selected) {
43
+ if (sel.embedding) {
44
+ const sim = cosineSimilarity(candidate.embedding, sel.embedding)
45
+ if (sim > maxSimToSelected) maxSimToSelected = sim
46
+ }
47
+ }
48
+ }
49
+
50
+ // MMR Score = Lambda * Relevance - (1 - Lambda) * Diversity (max similarity to selected)
51
+ const mmrScore = (lambda * candidate.normSalience) - ((1 - lambda) * maxSimToSelected)
52
+
53
+ // DEBUG LOG
54
+ // console.log(` Candidate ${candidate.entry.id}: rel=${candidate.normSalience.toFixed(3)}, div_penalty=${maxSimToSelected.toFixed(3)}, mmr=${mmrScore.toFixed(3)}`)
55
+
56
+ if (mmrScore > bestMmrScore) {
57
+ bestMmrScore = mmrScore
58
+ bestIndex = i
59
+ }
60
+ }
61
+
62
+ if (bestIndex !== -1) {
63
+ const picked = remaining[bestIndex]
64
+ // console.log(`[mmr] Picked ${picked.entry.id} with score ${bestMmrScore.toFixed(3)}`)
65
+ selected.push(picked)
66
+ remaining.splice(bestIndex, 1)
67
+ } else {
68
+ break
69
+ }
70
+ }
71
+
72
+ return selected.map(s => s.entry)
73
+ }
@@ -12,6 +12,7 @@ import { getCheckpointSaver } from './langgraph-checkpoint'
12
12
  import { notify } from './ws-hub'
13
13
  import { pushMainLoopEventToMainSessions } from './main-agent-loop'
14
14
  import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
15
+ import { getPluginManager } from './plugins'
15
16
  import { genId } from '@/lib/id'
16
17
  import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
17
18
  import type { Agent, TaskComment, MessageToolEvent } from '@/types'
@@ -176,6 +177,7 @@ export async function executeLangGraphOrchestrator(
176
177
  return `Agent "${agentName}" not found. Available: ${agents.map((a) => a.name).join(', ')}`
177
178
  }
178
179
  console.log(`[orchestrator-lg] Delegating to ${agent.name}: ${agentTask.slice(0, 80)}`)
180
+ getPluginManager().runHook('onAgentDelegation', { sourceAgentId: orchestrator.id, targetAgentId: agent.id, task: agentTask })
179
181
  const result = await executeSubTaskViaCli(agent, agentTask, sessionId)
180
182
  saveMessage(sessionId, 'assistant', `Delegated to ${agent.name}: ${agentTask.slice(0, 100)}`, [{
181
183
  name: 'delegate_to_agent',
@@ -623,6 +625,7 @@ export async function resumeLangGraphOrchestrator(
623
625
  async ({ agentName, task: agentTask }) => {
624
626
  const agent = agents.find((a) => a.name.toLowerCase() === agentName.toLowerCase())
625
627
  if (!agent) return `Agent "${agentName}" not found. Available: ${agents.map((a) => a.name).join(', ')}`
628
+ getPluginManager().runHook('onAgentDelegation', { sourceAgentId: orchestrator.id, targetAgentId: agent.id, task: agentTask })
626
629
  const result = await executeSubTaskViaCli(agent, agentTask, sessionId)
627
630
  saveMessage(sessionId, 'assistant', `Delegated to ${agent.name}: ${agentTask.slice(0, 100)}`, [{
628
631
  name: 'delegate_to_agent',