@swarmclawai/swarmclaw 1.2.9 → 1.3.1

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 (40) hide show
  1. package/README.md +17 -0
  2. package/package.json +2 -2
  3. package/src/app/api/activity/route.ts +9 -23
  4. package/src/app/api/agents/route.ts +17 -1
  5. package/src/app/api/approvals/route.ts +13 -5
  6. package/src/app/api/credentials/[id]/route.ts +2 -0
  7. package/src/app/api/credentials/route.ts +4 -1
  8. package/src/app/api/goals/[id]/route.ts +28 -0
  9. package/src/app/api/goals/route.ts +33 -0
  10. package/src/app/api/protocols/templates/[id]/route.ts +2 -1
  11. package/src/app/api/protocols/templates/route.ts +2 -1
  12. package/src/app/api/settings/route.ts +13 -0
  13. package/src/app/home/page.tsx +3 -0
  14. package/src/cli/index.js +11 -0
  15. package/src/cli/spec.js +10 -0
  16. package/src/lib/server/activity/activity-log.ts +16 -1
  17. package/src/lib/server/agents/agent-service.ts +24 -11
  18. package/src/lib/server/approval-match.ts +14 -0
  19. package/src/lib/server/approvals/approval-hooks.ts +81 -0
  20. package/src/lib/server/approvals.ts +11 -0
  21. package/src/lib/server/connectors/swarmdock-bidding.ts +2 -9
  22. package/src/lib/server/connectors/swarmdock-payloads.test.ts +18 -1
  23. package/src/lib/server/connectors/swarmdock-tasks.ts +10 -11
  24. package/src/lib/server/connectors/swarmdock.ts +111 -43
  25. package/src/lib/server/execution-brief.ts +18 -0
  26. package/src/lib/server/goals/goal-repository.ts +19 -0
  27. package/src/lib/server/goals/goal-service.ts +143 -0
  28. package/src/lib/server/storage-normalization.ts +5 -0
  29. package/src/lib/server/storage.ts +57 -0
  30. package/src/lib/server/usage/cost-rollup.ts +124 -0
  31. package/src/lib/server/usage/usage-repository.ts +6 -0
  32. package/src/lib/validation/schemas.ts +3 -30
  33. package/src/lib/validation/server-schemas.ts +35 -0
  34. package/src/types/agent.ts +10 -0
  35. package/src/types/app-settings.ts +6 -0
  36. package/src/types/approval.ts +3 -0
  37. package/src/types/goal.ts +30 -0
  38. package/src/types/index.ts +1 -0
  39. package/src/types/misc.ts +2 -2
  40. package/src/types/task.ts +2 -0
@@ -2,23 +2,14 @@ import { genId } from '@/lib/id'
2
2
  import { loadTasks, saveTasks } from '@/lib/server/tasks/task-repository'
3
3
  import { logActivity } from '@/lib/server/activity/activity-log'
4
4
  import type { BoardTask } from '@/types/task'
5
-
6
- interface SwarmDockTask {
7
- id: string
8
- requesterId: string
9
- title: string
10
- description: string
11
- skillRequirements: string[]
12
- budgetMax: string
13
- deadline: string | null
14
- }
5
+ import type { Task } from '@swarmdock/shared'
15
6
 
16
7
  /**
17
8
  * Create a SwarmClaw BoardTask from a SwarmDock task assignment.
18
9
  * Uses `externalSource` to link back to the SwarmDock task (same pattern as GitHub issue import).
19
10
  */
20
11
  export async function createBoardTaskFromAssignment(
21
- task: SwarmDockTask,
12
+ task: Task,
22
13
  agentId: string,
23
14
  connectorId: string,
24
15
  apiUrl: string,
@@ -100,6 +91,14 @@ export async function updateBoardTaskFromEvent(
100
91
  boardTask.status = 'failed'
101
92
  boardTask.checkoutRunId = null
102
93
  break
94
+ case 'task.review':
95
+ // Work submitted, awaiting requester review on SwarmDock
96
+ if (boardTask.externalSource) boardTask.externalSource.state = 'review'
97
+ break
98
+ case 'task.disputed':
99
+ // Task disputed on SwarmDock
100
+ if (boardTask.externalSource) boardTask.externalSource.state = 'disputed'
101
+ break
103
102
  }
104
103
 
105
104
  boardTask.updatedAt = now
@@ -5,29 +5,10 @@ import type { Connector, InboundMessage } from '@/types/connector'
5
5
  import type { PlatformConnector, ConnectorInstance } from '@/lib/server/connectors/types'
6
6
  import { createBoardTaskFromAssignment, updateBoardTaskFromEvent, findBoardTaskBySwarmdockId } from './swarmdock-tasks'
7
7
  import { shouldAutoBid, submitAutoBid } from './swarmdock-bidding'
8
- import type { TaskSubmitInput } from '@swarmdock/shared'
8
+ import type { Task, SSEEvent, TaskSubmitInput } from '@swarmdock/shared'
9
9
 
10
10
  const TAG = 'swarmdock'
11
11
 
12
- // SDK types inlined until @swarmdock/sdk is built and linked
13
- interface SwarmDockTask {
14
- id: string
15
- requesterId: string
16
- assigneeId: string | null
17
- title: string
18
- description: string
19
- skillRequirements: string[]
20
- budgetMax: string
21
- status: string
22
- deadline: string | null
23
- }
24
-
25
- interface SwarmDockSSEEvent {
26
- type: string
27
- data: Record<string, unknown>
28
- timestamp: string
29
- }
30
-
31
12
  interface SwarmDockConfig {
32
13
  apiUrl: string
33
14
  walletAddress: string
@@ -35,6 +16,7 @@ interface SwarmDockConfig {
35
16
  skills: string
36
17
  autoDiscover: boolean
37
18
  maxBudget: string
19
+ paymentPrivateKey?: string
38
20
  }
39
21
 
40
22
  function parseConfig(connector: Connector): SwarmDockConfig {
@@ -46,10 +28,11 @@ function parseConfig(connector: Connector): SwarmDockConfig {
46
28
  skills: c.skills || '',
47
29
  autoDiscover: c.autoDiscover === 'true',
48
30
  maxBudget: c.maxBudget || '0',
31
+ paymentPrivateKey: c.paymentPrivateKey || undefined,
49
32
  }
50
33
  }
51
34
 
52
- function buildTaskPrompt(task: SwarmDockTask): string {
35
+ function buildTaskPrompt(task: Task): string {
53
36
  const lines: string[] = [
54
37
  `# SwarmDock Task: ${task.title}`,
55
38
  '',
@@ -63,6 +46,17 @@ function buildTaskPrompt(task: SwarmDockTask): string {
63
46
  return lines.join('\n')
64
47
  }
65
48
 
49
+ export function generateExamplePrompts(skillId: string): string[] {
50
+ const name = skillId.replace(/-/g, ' ')
51
+ return [
52
+ `Perform a ${name} task`,
53
+ `Help me with ${name}`,
54
+ `I need ${name} work done`,
55
+ `Complete a ${name} assignment`,
56
+ `Handle a ${name} request`,
57
+ ]
58
+ }
59
+
66
60
  function formatUsdc(microUnits: string): string {
67
61
  const cents = BigInt(microUnits)
68
62
  const dollars = Number(cents) / 1_000_000
@@ -73,11 +67,13 @@ export async function submitSwarmdockTaskResult(
73
67
  client: { tasks: { submit: (taskId: string, input: TaskSubmitInput) => Promise<unknown> } },
74
68
  swarmdockTaskId: string,
75
69
  text: string,
70
+ notes?: string,
76
71
  ): Promise<void> {
77
72
  const payload: TaskSubmitInput = {
78
73
  artifacts: [{ type: 'text/markdown', content: text }],
79
74
  files: [],
80
75
  }
76
+ if (notes) payload.notes = notes
81
77
  await client.tasks.submit(swarmdockTaskId, payload)
82
78
  }
83
79
 
@@ -94,11 +90,15 @@ const swarmdock: PlatformConnector = {
94
90
  if (!privateKey) throw new Error('SwarmDock connector requires an Ed25519 private key credential')
95
91
  if (!config.walletAddress) throw new Error('SwarmDock connector requires a Base L2 wallet address in config')
96
92
 
97
- // Dynamic import of the SDK (must be built and linked first)
93
+ // Dynamic import of the SDK
98
94
  let SwarmDockClient: typeof import('@swarmdock/sdk').SwarmDockClient
95
+ let ConflictError: typeof import('@swarmdock/sdk').ConflictError
96
+ let AuthenticationError: typeof import('@swarmdock/sdk').AuthenticationError
99
97
  try {
100
98
  const sdk = await import('@swarmdock/sdk')
101
99
  SwarmDockClient = sdk.SwarmDockClient
100
+ ConflictError = sdk.ConflictError
101
+ AuthenticationError = sdk.AuthenticationError
102
102
  } catch {
103
103
  throw new Error('SwarmDock SDK (@swarmdock/sdk) is not installed. Run: npm install @swarmdock/sdk')
104
104
  }
@@ -106,6 +106,9 @@ const swarmdock: PlatformConnector = {
106
106
  const client = new SwarmDockClient({
107
107
  baseUrl: config.apiUrl,
108
108
  privateKey,
109
+ ...(config.paymentPrivateKey?.startsWith('0x')
110
+ ? { paymentPrivateKey: config.paymentPrivateKey as `0x${string}` }
111
+ : {}),
109
112
  })
110
113
 
111
114
  // Register agent on SwarmDock (Ed25519 challenge-response)
@@ -119,36 +122,49 @@ const swarmdock: PlatformConnector = {
119
122
  description: `${skillId} capability`,
120
123
  category: skillId,
121
124
  basePrice: '1000000', // $1.00 default
125
+ inputModes: ['text'],
126
+ outputModes: ['text'],
127
+ examplePrompts: generateExamplePrompts(skillId),
122
128
  }))
123
129
 
124
130
  log.info(TAG, `Registering agent "${connector.name}" on SwarmDock at ${config.apiUrl}`)
125
- const registration = await client.register({
126
- displayName: connector.name,
127
- description: config.agentDescription,
128
- framework: 'swarmclaw',
129
- walletAddress: config.walletAddress,
130
- skills: skillList,
131
- })
132
- log.info(TAG, `Registered as ${registration.agent.did} (trust level ${registration.agent.trustLevel})`)
133
-
134
- logActivity({
135
- entityType: 'connector',
136
- entityId: connectorId,
137
- action: 'swarmdock-registered',
138
- actor: 'system',
139
- summary: `Agent "${connector.name}" registered on SwarmDock as ${registration.agent.did}`,
140
- })
131
+ try {
132
+ const registration = await client.register({
133
+ displayName: connector.name,
134
+ description: config.agentDescription,
135
+ framework: 'swarmclaw',
136
+ walletAddress: config.walletAddress,
137
+ skills: skillList,
138
+ })
139
+ log.info(TAG, `Registered as ${registration.agent.did} (trust level ${registration.agent.trustLevel})`)
140
+
141
+ logActivity({
142
+ entityType: 'connector',
143
+ entityId: connectorId,
144
+ action: 'swarmdock-registered',
145
+ actor: 'system',
146
+ summary: `Agent "${connector.name}" registered on SwarmDock as ${registration.agent.did}`,
147
+ })
148
+ } catch (err) {
149
+ if (err instanceof ConflictError) {
150
+ log.info(TAG, `Agent already registered, authenticating`)
151
+ await client.authenticate()
152
+ } else {
153
+ throw err
154
+ }
155
+ }
141
156
 
142
157
  // Set up SSE event stream
143
158
  let alive = true
144
159
 
145
- const handleSSEEvent = async (event: SwarmDockSSEEvent) => {
160
+ const handleSSEEvent = async (event: SSEEvent) => {
146
161
  if (!alive) return
147
162
  try {
148
163
  switch (event.type) {
149
- case 'task.created': {
164
+ case 'task.created':
165
+ case 'task.invited': {
150
166
  if (!config.autoDiscover) break
151
- const task = event.data as unknown as SwarmDockTask
167
+ const task = event.data as unknown as Task
152
168
  if (shouldAutoBid(task, config)) {
153
169
  await submitAutoBid(client, task.id, config)
154
170
  logActivity({
@@ -163,7 +179,7 @@ const swarmdock: PlatformConnector = {
163
179
  }
164
180
 
165
181
  case 'task.assigned': {
166
- const task = event.data as unknown as SwarmDockTask
182
+ const task = event.data as unknown as Task
167
183
  if (!task.assigneeId) break
168
184
 
169
185
  // Signal work started on SwarmDock
@@ -195,6 +211,27 @@ const swarmdock: PlatformConnector = {
195
211
  break
196
212
  }
197
213
 
214
+ case 'task.review': {
215
+ const taskId = (event.data as Record<string, string>).taskId
216
+ if (taskId) await updateBoardTaskFromEvent(taskId, 'task.review')
217
+ break
218
+ }
219
+
220
+ case 'task.disputed': {
221
+ const taskId = (event.data as Record<string, string>).taskId
222
+ if (taskId) {
223
+ await updateBoardTaskFromEvent(taskId, 'task.disputed')
224
+ logActivity({
225
+ entityType: 'connector',
226
+ entityId: connectorId,
227
+ action: 'incident',
228
+ actor: 'system',
229
+ summary: `SwarmDock task ${taskId} disputed`,
230
+ })
231
+ }
232
+ break
233
+ }
234
+
198
235
  case 'payment.released': {
199
236
  const data = event.data as Record<string, string>
200
237
  logActivity({
@@ -206,6 +243,32 @@ const swarmdock: PlatformConnector = {
206
243
  })
207
244
  break
208
245
  }
246
+
247
+ case 'escrow.releasing':
248
+ case 'escrow.refunding': {
249
+ const data = event.data as Record<string, string>
250
+ logActivity({
251
+ entityType: 'connector',
252
+ entityId: connectorId,
253
+ action: 'swarmdock-escrow',
254
+ actor: 'system',
255
+ summary: `Escrow ${event.type.split('.')[1]} for task ${data.taskId}`,
256
+ })
257
+ break
258
+ }
259
+
260
+ case 'escrow.release_failed':
261
+ case 'escrow.refund_failed': {
262
+ const data = event.data as Record<string, string>
263
+ logActivity({
264
+ entityType: 'connector',
265
+ entityId: connectorId,
266
+ action: 'incident',
267
+ actor: 'system',
268
+ summary: `Escrow ${event.type.replace('escrow.', '')} for task ${data.taskId}`,
269
+ })
270
+ break
271
+ }
209
272
  }
210
273
  } catch (err) {
211
274
  log.error(TAG, `Error handling SSE event ${event.type}: ${err instanceof Error ? err.message : String(err)}`)
@@ -220,7 +283,12 @@ const swarmdock: PlatformConnector = {
220
283
  await client.heartbeat()
221
284
  log.debug(TAG, 'SwarmDock token refreshed')
222
285
  } catch (err) {
223
- log.error(TAG, `SwarmDock heartbeat failed: ${err instanceof Error ? err.message : String(err)}`)
286
+ if (err instanceof AuthenticationError) {
287
+ log.warn(TAG, 'SwarmDock token expired, re-authenticating')
288
+ try { await client.authenticate() } catch {}
289
+ } else {
290
+ log.error(TAG, `SwarmDock heartbeat failed: ${err instanceof Error ? err.message : String(err)}`)
291
+ }
224
292
  }
225
293
  }, 23 * 60 * 60 * 1000)
226
294
 
@@ -12,6 +12,7 @@ import { getSession } from '@/lib/server/sessions/session-repository'
12
12
  import { loadSessionWorkingState } from '@/lib/server/working-state/service'
13
13
  import { ensureRunContext } from '@/lib/server/run-context'
14
14
  import { cleanText, cleanMultiline } from '@/lib/server/text-normalization'
15
+ import { resolveEffectiveGoal, getGoalChain, formatGoalChainForBrief } from '@/lib/server/goals/goal-service'
15
16
 
16
17
  const MAX_PLAN_ITEMS = 8
17
18
  const MAX_FACTS = 8
@@ -224,8 +225,25 @@ export function buildExecutionBriefContextBlock(
224
225
  || brief.evidenceRefs.length > 0,
225
226
  )
226
227
  if (!hasContent && brief.status === 'idle') return ''
228
+ // Resolve goal chain for the session's agent/task/project context
229
+ let goalBlock = ''
230
+ if (brief.sessionId) {
231
+ const session = getSession(brief.sessionId)
232
+ if (session) {
233
+ const goal = resolveEffectiveGoal({
234
+ agentId: session.agentId || null,
235
+ projectId: session.projectId || null,
236
+ })
237
+ if (goal) {
238
+ const chain = getGoalChain(goal.id)
239
+ goalBlock = formatGoalChainForBrief(chain)
240
+ }
241
+ }
242
+ }
243
+
227
244
  const sections = [
228
245
  options?.title || '## Execution Brief',
246
+ goalBlock,
229
247
  brief.parentContext ? `Parent context:\n${brief.parentContext}` : '',
230
248
  brief.objective ? `Objective: ${brief.objective}` : '',
231
249
  brief.summary ? `Summary: ${brief.summary}` : '',
@@ -0,0 +1,19 @@
1
+ import type { Goal } from '@/types'
2
+ import { loadGoals, loadGoal, upsertGoal, deleteGoalItem } from '@/lib/server/storage'
3
+ import { perf } from '@/lib/server/runtime/perf'
4
+
5
+ export function listGoals(): Record<string, Goal> {
6
+ return perf.measureSync('repository', 'goals.list', () => loadGoals()) as Record<string, Goal>
7
+ }
8
+
9
+ export function getGoal(id: string): Goal | null {
10
+ return perf.measureSync('repository', 'goals.get', () => loadGoal(id)) as Goal | null
11
+ }
12
+
13
+ export function saveGoal(id: string, goal: Goal): void {
14
+ perf.measureSync('repository', 'goals.upsert', () => upsertGoal(id, goal), { id })
15
+ }
16
+
17
+ export function removeGoal(id: string): void {
18
+ perf.measureSync('repository', 'goals.delete', () => deleteGoalItem(id), { id })
19
+ }
@@ -0,0 +1,143 @@
1
+ import type { Goal, GoalLevel } from '@/types'
2
+ import { genId } from '@/lib/id'
3
+ import { listGoals, getGoal, saveGoal, removeGoal } from './goal-repository'
4
+ import { logActivity } from '@/lib/server/activity/activity-log'
5
+ import { notify } from '@/lib/server/ws-hub'
6
+
7
+ export function getAllGoals(): Goal[] {
8
+ return Object.values(listGoals())
9
+ }
10
+
11
+ export function getGoalById(id: string): Goal | null {
12
+ return getGoal(id)
13
+ }
14
+
15
+ export function createGoal(input: {
16
+ title: string
17
+ description?: string
18
+ level: GoalLevel
19
+ parentGoalId?: string | null
20
+ projectId?: string | null
21
+ agentId?: string | null
22
+ taskId?: string | null
23
+ objective: string
24
+ constraints?: string[]
25
+ successMetric?: string | null
26
+ budgetUsd?: number | null
27
+ deadlineAt?: number | null
28
+ }): Goal {
29
+ const id = genId()
30
+ const now = Date.now()
31
+ const goal: Goal = {
32
+ id,
33
+ title: input.title,
34
+ description: input.description,
35
+ level: input.level,
36
+ parentGoalId: input.parentGoalId ?? null,
37
+ projectId: input.projectId ?? null,
38
+ agentId: input.agentId ?? null,
39
+ taskId: input.taskId ?? null,
40
+ objective: input.objective,
41
+ constraints: input.constraints ?? [],
42
+ successMetric: input.successMetric ?? null,
43
+ budgetUsd: input.budgetUsd ?? null,
44
+ deadlineAt: input.deadlineAt ?? null,
45
+ status: 'active',
46
+ createdAt: now,
47
+ updatedAt: now,
48
+ }
49
+ saveGoal(id, goal)
50
+ logActivity({ entityType: 'task', entityId: id, action: 'created', actor: 'user', summary: `Goal created: "${goal.title}" (${goal.level})` })
51
+ notify('goals')
52
+ return goal
53
+ }
54
+
55
+ export function updateGoal(id: string, updates: Partial<Omit<Goal, 'id' | 'createdAt'>>): Goal | null {
56
+ const existing = getGoal(id)
57
+ if (!existing) return null
58
+ const updated: Goal = {
59
+ ...existing,
60
+ ...updates,
61
+ id,
62
+ createdAt: existing.createdAt,
63
+ updatedAt: Date.now(),
64
+ }
65
+ saveGoal(id, updated)
66
+ logActivity({ entityType: 'task', entityId: id, action: 'updated', actor: 'user', summary: `Goal updated: "${updated.title}"` })
67
+ notify('goals')
68
+ return updated
69
+ }
70
+
71
+ export function deleteGoal(id: string): boolean {
72
+ const existing = getGoal(id)
73
+ if (!existing) return false
74
+ removeGoal(id)
75
+ logActivity({ entityType: 'task', entityId: id, action: 'deleted', actor: 'user', summary: `Goal deleted: "${existing.title}"` })
76
+ notify('goals')
77
+ return true
78
+ }
79
+
80
+ /**
81
+ * Walk the goal hierarchy to build a "why chain" from a specific goal up to the organization root.
82
+ * Returns goals in order from most specific to most general.
83
+ */
84
+ export function getGoalChain(goalId: string): Goal[] {
85
+ const chain: Goal[] = []
86
+ const visited = new Set<string>()
87
+ let currentId: string | null | undefined = goalId
88
+ while (currentId && !visited.has(currentId)) {
89
+ visited.add(currentId)
90
+ const goal = getGoal(currentId)
91
+ if (!goal) break
92
+ chain.push(goal)
93
+ currentId = goal.parentGoalId
94
+ }
95
+ return chain
96
+ }
97
+
98
+ /**
99
+ * Resolve the effective goal for a given context by walking the hierarchy:
100
+ * task goal → agent goal → project goal → organization default.
101
+ */
102
+ export function resolveEffectiveGoal(context: {
103
+ taskId?: string | null
104
+ agentId?: string | null
105
+ projectId?: string | null
106
+ }): Goal | null {
107
+ const goals = getAllGoals().filter((g) => g.status === 'active')
108
+
109
+ // 1. Task-level goal
110
+ if (context.taskId) {
111
+ const taskGoal = goals.find((g) => g.taskId === context.taskId)
112
+ if (taskGoal) return taskGoal
113
+ }
114
+
115
+ // 2. Agent-level goal
116
+ if (context.agentId) {
117
+ const agentGoal = goals.find((g) => g.level === 'agent' && g.agentId === context.agentId)
118
+ if (agentGoal) return agentGoal
119
+ }
120
+
121
+ // 3. Project-level goal
122
+ if (context.projectId) {
123
+ const projectGoal = goals.find((g) => g.level === 'project' && g.projectId === context.projectId)
124
+ if (projectGoal) return projectGoal
125
+ }
126
+
127
+ // 4. Organization default
128
+ const orgGoal = goals.find((g) => g.level === 'organization')
129
+ return orgGoal ?? null
130
+ }
131
+
132
+ /**
133
+ * Format a goal chain as a concise text block for injection into agent execution briefs.
134
+ */
135
+ export function formatGoalChainForBrief(chain: Goal[]): string {
136
+ if (chain.length === 0) return ''
137
+ const lines = chain.map((g, i) => {
138
+ const indent = ' '.repeat(i)
139
+ const label = g.level.charAt(0).toUpperCase() + g.level.slice(1)
140
+ return `${indent}${label}: ${g.title} — ${g.objective}`
141
+ })
142
+ return `Goal alignment:\n${lines.join('\n')}`
143
+ }
@@ -507,6 +507,11 @@ function normalizeStoredRecordInner(
507
507
  agent.delegationEnabled = false
508
508
  agent.heartbeatEnabled = false
509
509
  }
510
+ // Persisted spend rollup defaults
511
+ if (typeof agent.spentMonthlyCents !== 'number') agent.spentMonthlyCents = 0
512
+ if (typeof agent.spentDailyCents !== 'number') agent.spentDailyCents = 0
513
+ if (typeof agent.spentHourlyCents !== 'number') agent.spentHourlyCents = 0
514
+ if (typeof agent.lastSpendRollupAt !== 'number') agent.lastSpendRollupAt = 0
510
515
  // Org chart normalization
511
516
  if (agent.orgChart && typeof agent.orgChart === 'object' && !Array.isArray(agent.orgChart)) {
512
517
  const oc = agent.orgChart as Record<string, unknown>
@@ -157,6 +157,7 @@ const COLLECTIONS = [
157
157
  'daemon_status',
158
158
  'wallets',
159
159
  'wallet_transactions',
160
+ 'goals',
160
161
  ] as const
161
162
 
162
163
  export type StorageCollection = (typeof COLLECTIONS)[number]
@@ -168,6 +169,10 @@ for (const table of COLLECTIONS) {
168
169
  // Index for efficient protocol_run_events queries by runId
169
170
  db.exec(`CREATE INDEX IF NOT EXISTS idx_protocol_run_events_runid ON protocol_run_events (json_extract(data, '$.runId'))`)
170
171
 
172
+ // Indexes for efficient activity log queries
173
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity (json_extract(data, '$.timestamp'))`)
174
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_activity_entity ON activity (json_extract(data, '$.entityType'), json_extract(data, '$.entityId'))`)
175
+
171
176
  // Singleton tables (single row)
172
177
  db.exec(`CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)`)
173
178
  db.exec(`CREATE TABLE IF NOT EXISTS queue (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)`)
@@ -1465,6 +1470,51 @@ export function logActivity(entry: {
1465
1470
  notify('activity')
1466
1471
  }
1467
1472
 
1473
+ /** Paginated activity query using SQL WHERE + LIMIT/OFFSET instead of loading the full collection. */
1474
+ export function queryActivity(filters: {
1475
+ entityType?: string
1476
+ entityId?: string
1477
+ actor?: string
1478
+ action?: string
1479
+ since?: number
1480
+ limit?: number
1481
+ offset?: number
1482
+ }): unknown[] {
1483
+ const conditions: string[] = []
1484
+ const params: unknown[] = []
1485
+
1486
+ if (filters.entityType) {
1487
+ conditions.push(`json_extract(data, '$.entityType') = ?`)
1488
+ params.push(filters.entityType)
1489
+ }
1490
+ if (filters.entityId) {
1491
+ conditions.push(`json_extract(data, '$.entityId') = ?`)
1492
+ params.push(filters.entityId)
1493
+ }
1494
+ if (filters.actor) {
1495
+ conditions.push(`json_extract(data, '$.actor') = ?`)
1496
+ params.push(filters.actor)
1497
+ }
1498
+ if (filters.action) {
1499
+ conditions.push(`json_extract(data, '$.action') = ?`)
1500
+ params.push(filters.action)
1501
+ }
1502
+ if (typeof filters.since === 'number' && Number.isFinite(filters.since)) {
1503
+ conditions.push(`json_extract(data, '$.timestamp') >= ?`)
1504
+ params.push(filters.since)
1505
+ }
1506
+
1507
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
1508
+ const limit = Math.min(200, Math.max(1, filters.limit ?? 50))
1509
+ const offset = Math.max(0, filters.offset ?? 0)
1510
+
1511
+ const sql = `SELECT data FROM activity ${where} ORDER BY json_extract(data, '$.timestamp') DESC LIMIT ? OFFSET ?`
1512
+ params.push(limit, offset)
1513
+
1514
+ const rows = db.prepare(sql).all(...params) as Array<{ data: string }>
1515
+ return rows.map((r) => JSON.parse(r.data))
1516
+ }
1517
+
1468
1518
  // --- Webhook Retry Queue ---
1469
1519
  const webhookRetryQueueStore = createCollectionStore('webhook_retry_queue')
1470
1520
  export const loadWebhookRetryQueue = webhookRetryQueueStore.load
@@ -1586,6 +1636,13 @@ export const loadWalletTransaction = walletTransactionsStore.loadItem
1586
1636
  export const upsertWalletTransaction = walletTransactionsStore.upsert
1587
1637
  export const deleteWalletTransaction = walletTransactionsStore.deleteItem
1588
1638
 
1639
+ // --- Goals ---
1640
+ const goalsStore = createCollectionStore('goals')
1641
+ export const loadGoals = goalsStore.load
1642
+ export const loadGoal = goalsStore.loadItem
1643
+ export const upsertGoal = goalsStore.upsert
1644
+ export const deleteGoalItem = goalsStore.deleteItem
1645
+
1589
1646
  export function getSessionMessages(sessionId: string): Message[] {
1590
1647
  const session = loadSession(sessionId)
1591
1648
  return Array.isArray(session?.messages) ? session.messages : []