@swarmclawai/swarmclaw 0.6.6 → 0.6.7

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 (80) hide show
  1. package/README.md +57 -27
  2. package/package.json +6 -1
  3. package/src/app/api/agents/[id]/clone/route.ts +40 -0
  4. package/src/app/api/agents/route.ts +39 -14
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +17 -1
  6. package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
  7. package/src/app/api/chatrooms/[id]/route.ts +19 -1
  8. package/src/app/api/chatrooms/route.ts +12 -2
  9. package/src/app/api/connectors/[id]/health/route.ts +64 -0
  10. package/src/app/api/connectors/route.ts +17 -2
  11. package/src/app/api/knowledge/route.ts +6 -1
  12. package/src/app/api/openclaw/doctor/route.ts +17 -0
  13. package/src/app/api/sessions/[id]/chat/route.ts +5 -1
  14. package/src/app/api/sessions/route.ts +11 -2
  15. package/src/app/api/tasks/[id]/route.ts +18 -13
  16. package/src/app/api/tasks/route.ts +20 -1
  17. package/src/app/api/usage/route.ts +16 -7
  18. package/src/cli/index.js +5 -0
  19. package/src/cli/index.ts +223 -39
  20. package/src/components/agents/agent-card.tsx +37 -6
  21. package/src/components/agents/agent-chat-list.tsx +78 -2
  22. package/src/components/agents/agent-sheet.tsx +79 -0
  23. package/src/components/auth/setup-wizard.tsx +268 -353
  24. package/src/components/chat/chat-area.tsx +22 -7
  25. package/src/components/chat/message-bubble.tsx +14 -14
  26. package/src/components/chat/message-list.tsx +1 -1
  27. package/src/components/chatrooms/chatroom-message.tsx +164 -22
  28. package/src/components/chatrooms/chatroom-sheet.tsx +288 -3
  29. package/src/components/chatrooms/chatroom-view.tsx +62 -17
  30. package/src/components/connectors/connector-health.tsx +120 -0
  31. package/src/components/connectors/connector-sheet.tsx +9 -0
  32. package/src/components/home/home-view.tsx +23 -2
  33. package/src/components/input/chat-input.tsx +8 -1
  34. package/src/components/layout/app-layout.tsx +17 -1
  35. package/src/components/schedules/schedule-list.tsx +55 -9
  36. package/src/components/schedules/schedule-sheet.tsx +134 -23
  37. package/src/components/shared/command-palette.tsx +237 -0
  38. package/src/components/shared/connector-platform-icon.tsx +1 -0
  39. package/src/components/tasks/task-card.tsx +22 -2
  40. package/src/components/tasks/task-sheet.tsx +91 -16
  41. package/src/components/usage/metrics-dashboard.tsx +13 -25
  42. package/src/hooks/use-swipe.ts +49 -0
  43. package/src/lib/providers/anthropic.ts +16 -2
  44. package/src/lib/providers/claude-cli.ts +7 -1
  45. package/src/lib/providers/index.ts +7 -0
  46. package/src/lib/providers/ollama.ts +16 -2
  47. package/src/lib/providers/openai.ts +7 -2
  48. package/src/lib/providers/openclaw.ts +6 -1
  49. package/src/lib/providers/provider-defaults.ts +7 -0
  50. package/src/lib/schedule-templates.ts +115 -0
  51. package/src/lib/server/alert-dispatch.ts +64 -0
  52. package/src/lib/server/chat-execution.ts +41 -1
  53. package/src/lib/server/chatroom-helpers.ts +22 -1
  54. package/src/lib/server/chatroom-routing.ts +65 -0
  55. package/src/lib/server/connectors/discord.ts +3 -0
  56. package/src/lib/server/connectors/email.ts +267 -0
  57. package/src/lib/server/connectors/manager.ts +159 -3
  58. package/src/lib/server/connectors/openclaw.ts +3 -0
  59. package/src/lib/server/connectors/slack.ts +6 -0
  60. package/src/lib/server/connectors/telegram.ts +18 -0
  61. package/src/lib/server/connectors/types.ts +2 -0
  62. package/src/lib/server/connectors/whatsapp.ts +9 -0
  63. package/src/lib/server/cost.ts +70 -0
  64. package/src/lib/server/create-notification.ts +2 -0
  65. package/src/lib/server/daemon-state.ts +124 -0
  66. package/src/lib/server/dag-validation.ts +115 -0
  67. package/src/lib/server/memory-db.ts +12 -7
  68. package/src/lib/server/openclaw-doctor.ts +48 -0
  69. package/src/lib/server/queue.ts +12 -0
  70. package/src/lib/server/session-run-manager.ts +22 -1
  71. package/src/lib/server/session-tools/index.ts +2 -0
  72. package/src/lib/server/session-tools/memory.ts +22 -3
  73. package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
  74. package/src/lib/server/storage.ts +120 -6
  75. package/src/lib/setup-defaults.ts +277 -0
  76. package/src/lib/validation/schemas.ts +69 -0
  77. package/src/stores/use-app-store.ts +7 -3
  78. package/src/stores/use-chatroom-store.ts +52 -2
  79. package/src/types/index.ts +38 -1
  80. package/tsconfig.json +2 -1
@@ -0,0 +1,115 @@
1
+ import type { BoardTask } from '@/types'
2
+
3
+ interface DagResult {
4
+ valid: boolean
5
+ cycle?: string[]
6
+ }
7
+
8
+ /**
9
+ * Validate that adding `proposedBlockedBy` to `taskId` would not create a cycle
10
+ * in the task dependency graph. Uses DFS to check if `taskId` is reachable from
11
+ * any of its proposed blockers (which would mean a cycle).
12
+ */
13
+ export function validateDag(
14
+ tasks: Record<string, BoardTask>,
15
+ taskId: string,
16
+ proposedBlockedBy: string[],
17
+ ): DagResult {
18
+ // Build adjacency: task -> tasks it is blocked by (its dependencies)
19
+ // We temporarily add the proposed edges: taskId is blocked by proposedBlockedBy
20
+ // A cycle exists if we can reach taskId by following blockedBy edges from any
21
+ // of the proposed blockers.
22
+
23
+ // DFS from each proposed blocker, following existing blockedBy edges.
24
+ // If we reach taskId, we have a cycle.
25
+ for (const startId of proposedBlockedBy) {
26
+ if (startId === taskId) {
27
+ return { valid: false, cycle: [taskId, taskId] }
28
+ }
29
+
30
+ const visited = new Set<string>()
31
+ const path: string[] = []
32
+ const found = dfs(tasks, startId, taskId, visited, path)
33
+ if (found) {
34
+ // path contains the route from startId to taskId
35
+ // The full cycle is: taskId -> startId -> ... -> taskId
36
+ return { valid: false, cycle: [taskId, ...path] }
37
+ }
38
+ }
39
+
40
+ return { valid: true }
41
+ }
42
+
43
+ /**
44
+ * DFS through the blockedBy graph starting from `current`, looking for `target`.
45
+ * Returns true if target is found, and populates `path` with the route.
46
+ */
47
+ function dfs(
48
+ tasks: Record<string, BoardTask>,
49
+ current: string,
50
+ target: string,
51
+ visited: Set<string>,
52
+ path: string[],
53
+ ): boolean {
54
+ if (visited.has(current)) return false
55
+ visited.add(current)
56
+ path.push(current)
57
+
58
+ const task = tasks[current]
59
+ if (!task) {
60
+ path.pop()
61
+ return false
62
+ }
63
+
64
+ const blockers = Array.isArray(task.blockedBy) ? task.blockedBy : []
65
+ for (const blockerId of blockers) {
66
+ if (blockerId === target) {
67
+ path.push(blockerId)
68
+ return true
69
+ }
70
+ if (dfs(tasks, blockerId, target, visited, path)) {
71
+ return true
72
+ }
73
+ }
74
+
75
+ path.pop()
76
+ return false
77
+ }
78
+
79
+ /**
80
+ * After a task completes, find all tasks that were blocked by it and check
81
+ * if all their blockers are now done. If so, auto-queue them.
82
+ * Returns the IDs of tasks that were unblocked.
83
+ */
84
+ export function cascadeUnblock(
85
+ tasks: Record<string, BoardTask>,
86
+ completedTaskId: string,
87
+ ): string[] {
88
+ const completedTask = tasks[completedTaskId]
89
+ if (!completedTask || completedTask.status !== 'completed') return []
90
+
91
+ const unblocked: string[] = []
92
+ const blockedIds = Array.isArray(completedTask.blocks) ? completedTask.blocks : []
93
+
94
+ for (const blockedId of blockedIds) {
95
+ const blocked = tasks[blockedId]
96
+ if (!blocked) continue
97
+ // Only auto-queue tasks that are in backlog (waiting on dependencies)
98
+ if (blocked.status !== 'backlog') continue
99
+
100
+ const deps = Array.isArray(blocked.blockedBy) ? blocked.blockedBy : []
101
+ const allDone = deps.every((depId) => {
102
+ const dep = tasks[depId]
103
+ return dep?.status === 'completed'
104
+ })
105
+
106
+ if (allDone) {
107
+ blocked.status = 'queued'
108
+ blocked.queuedAt = Date.now()
109
+ blocked.updatedAt = Date.now()
110
+ unblocked.push(blockedId)
111
+ }
112
+ }
113
+
114
+ return unblocked
115
+ }
@@ -1192,21 +1192,26 @@ export function addKnowledge(params: {
1192
1192
  agentIds?: string[]
1193
1193
  createdByAgentId?: string | null
1194
1194
  createdBySessionId?: string | null
1195
+ source?: string
1196
+ sourceUrl?: string
1195
1197
  }): MemoryEntry {
1196
1198
  const db = getMemoryDb()
1199
+ const metadata: Record<string, unknown> = {
1200
+ tags: params.tags || [],
1201
+ scope: params.scope || 'global',
1202
+ agentIds: params.scope === 'agent' ? (params.agentIds || []) : [],
1203
+ createdByAgentId: params.createdByAgentId || null,
1204
+ createdBySessionId: params.createdBySessionId || null,
1205
+ }
1206
+ if (params.source) metadata.source = params.source
1207
+ if (params.sourceUrl) metadata.sourceUrl = params.sourceUrl
1197
1208
  return db.add({
1198
1209
  agentId: null,
1199
1210
  sessionId: null,
1200
1211
  category: 'knowledge',
1201
1212
  title: params.title,
1202
1213
  content: params.content,
1203
- metadata: {
1204
- tags: params.tags || [],
1205
- scope: params.scope || 'global',
1206
- agentIds: params.scope === 'agent' ? (params.agentIds || []) : [],
1207
- createdByAgentId: params.createdByAgentId || null,
1208
- createdBySessionId: params.createdBySessionId || null,
1209
- },
1214
+ metadata,
1210
1215
  })
1211
1216
  }
1212
1217
 
@@ -0,0 +1,48 @@
1
+ import { execFile } from 'child_process'
2
+ import { promisify } from 'util'
3
+ import * as path from 'path'
4
+ import * as os from 'os'
5
+ import { loadSettings } from './storage'
6
+
7
+ const execFileAsync = promisify(execFile)
8
+
9
+ export interface DoctorResult {
10
+ ok: boolean
11
+ output: string
12
+ fixed: boolean
13
+ }
14
+
15
+ function resolveWorkspacePath(override?: string): string {
16
+ if (override) return override
17
+ const settings = loadSettings()
18
+ if (typeof settings.openclawWorkspacePath === 'string' && settings.openclawWorkspacePath.trim()) {
19
+ return settings.openclawWorkspacePath.trim()
20
+ }
21
+ return path.join(os.homedir(), '.openclaw', 'workspace')
22
+ }
23
+
24
+ export async function runOpenClawDoctor(opts?: { fix?: boolean; workspace?: string }): Promise<DoctorResult> {
25
+ const workspace = resolveWorkspacePath(opts?.workspace)
26
+ const args = ['doctor']
27
+ if (opts?.fix) args.push('--fix')
28
+
29
+ try {
30
+ const { stdout, stderr } = await execFileAsync('openclaw', args, {
31
+ cwd: workspace,
32
+ timeout: 30_000,
33
+ maxBuffer: 256 * 1024,
34
+ })
35
+ return {
36
+ ok: true,
37
+ output: (stdout + stderr).trim(),
38
+ fixed: !!opts?.fix,
39
+ }
40
+ } catch (err: unknown) {
41
+ const execErr = err as { code?: number; stdout?: string; stderr?: string; message?: string }
42
+ return {
43
+ ok: false,
44
+ output: ((execErr.stdout || '') + (execErr.stderr || '') || execErr.message || String(err)).trim(),
45
+ fixed: false,
46
+ }
47
+ }
48
+ }
@@ -12,6 +12,7 @@ import { executeSessionChatTurn } from './chat-execution'
12
12
  import { extractTaskResult, formatResultBody } from './task-result'
13
13
  import { getCheckpointSaver } from './langgraph-checkpoint'
14
14
  import { isProtectedMainSession } from './main-session'
15
+ import { cascadeUnblock } from './dag-validation'
15
16
  import type { Agent, BoardTask, Connector, Message } from '@/types'
16
17
 
17
18
  // HMR-safe: pin processing flag to globalThis so hot reloads don't reset it
@@ -1111,6 +1112,17 @@ export async function processNext() {
1111
1112
  getCheckpointSaver().deleteThread(taskId).catch((e) =>
1112
1113
  console.warn(`[queue] Failed to clean up checkpoints for task ${taskId}:`, e)
1113
1114
  )
1115
+ // Cascade unblock: auto-queue tasks whose blockers are all done
1116
+ const latestTasks = loadTasks()
1117
+ const unblockedIds = cascadeUnblock(latestTasks, taskId)
1118
+ if (unblockedIds.length > 0) {
1119
+ saveTasks(latestTasks)
1120
+ for (const uid of unblockedIds) {
1121
+ enqueueTask(uid)
1122
+ console.log(`[queue] Auto-unblocked task "${latestTasks[uid]?.title}" (${uid})`)
1123
+ }
1124
+ notify('tasks')
1125
+ }
1114
1126
  console.log(`[queue] Task "${task.title}" completed`)
1115
1127
  } else {
1116
1128
  if (doneTask?.status === 'queued') {
@@ -85,6 +85,17 @@ function registerRun(run: SessionRunRecord) {
85
85
  trimRecentRuns()
86
86
  }
87
87
 
88
+ /** Chain an external AbortSignal to an internal AbortController so that
89
+ * when the caller (e.g. HTTP request) disconnects, the run is cancelled. */
90
+ function chainCallerSignal(callerSignal: AbortSignal, controller: AbortController): void {
91
+ if (callerSignal.aborted) {
92
+ controller.abort()
93
+ return
94
+ }
95
+ const onAbort = () => controller.abort()
96
+ callerSignal.addEventListener('abort', onAbort, { once: true })
97
+ }
98
+
88
99
  function emitToSubscribers(entry: QueueEntry, event: SSEEvent) {
89
100
  for (const send of entry.onEvents) {
90
101
  try {
@@ -347,6 +358,8 @@ export interface EnqueueSessionRunInput {
347
358
  modelOverride?: string
348
359
  heartbeatConfig?: { ackMaxChars: number; showOk: boolean; showAlerts: boolean; target: string | null }
349
360
  replyToId?: string
361
+ /** External abort signal (e.g. from the HTTP request) — chained to the run's internal AbortController */
362
+ callerSignal?: AbortSignal
350
363
  }
351
364
 
352
365
  export interface EnqueueSessionRunResult {
@@ -355,6 +368,8 @@ export interface EnqueueSessionRunResult {
355
368
  deduped?: boolean
356
369
  coalesced?: boolean
357
370
  promise: Promise<ExecuteChatTurnResult>
371
+ /** Abort the run's internal AbortController (cancels the LLM stream). */
372
+ abort: () => void
358
373
  }
359
374
 
360
375
  export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSessionRunResult {
@@ -371,11 +386,13 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
371
386
  const dedupe = findDedupeMatch(input.sessionId, input.dedupeKey)
372
387
  if (dedupe) {
373
388
  if (input.onEvent) dedupe.onEvents.push(input.onEvent)
389
+ if (input.callerSignal) chainCallerSignal(input.callerSignal, dedupe.signalController)
374
390
  return {
375
391
  runId: dedupe.run.id,
376
392
  position: 0,
377
393
  deduped: true,
378
394
  promise: dedupe.promise,
395
+ abort: () => dedupe.signalController.abort(),
379
396
  }
380
397
  }
381
398
 
@@ -413,12 +430,14 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
413
430
  candidate.run.queuedAt = nowMs
414
431
  }
415
432
  if (input.onEvent) candidate.onEvents.push(input.onEvent)
433
+ if (input.callerSignal) chainCallerSignal(input.callerSignal, candidate.signalController)
416
434
  emitRunMeta(candidate, 'queued', { position: 0, coalesced: true, mergedIntoRunId: candidate.run.id })
417
435
  return {
418
436
  runId: candidate.run.id,
419
437
  position: 0,
420
438
  coalesced: true,
421
439
  promise: candidate.promise,
440
+ abort: () => candidate.signalController.abort(),
422
441
  }
423
442
  }
424
443
  }
@@ -463,12 +482,14 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
463
482
  promise,
464
483
  }
465
484
 
485
+ if (input.callerSignal) chainCallerSignal(input.callerSignal, entry.signalController)
486
+
466
487
  q.push(entry)
467
488
  const position = (running ? 1 : 0) + q.length - 1
468
489
  emitRunMeta(entry, 'queued', { position })
469
490
  void drainExecution(executionKey)
470
491
 
471
- return { runId, position, promise }
492
+ return { runId, position, promise, abort: () => entry.signalController.abort() }
472
493
  }
473
494
 
474
495
  export function getSessionRunState(sessionId: string): {
@@ -22,6 +22,7 @@ import { buildCanvasTools } from './canvas'
22
22
  import { buildHttpTools } from './http'
23
23
  import { buildGitTools } from './git'
24
24
  import { buildWalletTools } from './wallet'
25
+ import { buildOpenClawWorkspaceTools } from './openclaw-workspace'
25
26
 
26
27
  export type { ToolContext, SessionToolsResult }
27
28
  export { sweepOrphanedBrowsers, cleanupSessionBrowser, getActiveBrowserCount, hasActiveBrowser }
@@ -111,6 +112,7 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
111
112
  ...buildHttpTools(bctx),
112
113
  ...buildGitTools(bctx),
113
114
  ...buildWalletTools(bctx),
115
+ ...buildOpenClawWorkspaceTools(bctx),
114
116
  )
115
117
 
116
118
  // ---------------------------------------------------------------------------
@@ -165,12 +165,16 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
165
165
  if (action === 'knowledge_store') {
166
166
  const { addKnowledge } = await import('../memory-db')
167
167
  if (!value) return 'Error: value (content) is required for knowledge_store'
168
+ const source = (input as Record<string, unknown>).source as string | undefined
169
+ const sourceUrl = (input as Record<string, unknown>).sourceUrl as string | undefined
168
170
  const entry = addKnowledge({
169
171
  title: key || 'Untitled',
170
172
  content: value,
171
173
  tags: tags,
172
174
  createdByAgentId: ctx?.agentId || null,
173
175
  createdBySessionId: ctx?.sessionId || null,
176
+ source: source || undefined,
177
+ sourceUrl: sourceUrl || undefined,
174
178
  })
175
179
  return `Knowledge stored: "${entry.title}" (id: ${entry.id})`
176
180
  }
@@ -178,11 +182,24 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
178
182
  const { searchKnowledge } = await import('../memory-db')
179
183
  const results = searchKnowledge(query || key || '', tags, 10)
180
184
  if (!results.length) return 'No knowledge entries found.'
181
- return results.map(r => `[${r.id}] ${r.title}: ${r.content.slice(0, 200)}`).join('\n---\n')
185
+ return results.map(r => {
186
+ const meta = r.metadata as Record<string, unknown> | undefined
187
+ const src = meta?.source as string | undefined
188
+ const srcUrl = meta?.sourceUrl as string | undefined
189
+ let line = `[${r.id}] ${r.title}: ${r.content.slice(0, 200)}`
190
+ if (src && srcUrl) {
191
+ line += ` [${src}](${srcUrl})`
192
+ } else if (src) {
193
+ line += ` (source: ${src})`
194
+ } else if (srcUrl) {
195
+ line += ` (${srcUrl})`
196
+ }
197
+ return line
198
+ }).join('\n---\n')
182
199
  }
183
200
  return `Unknown action "${action}". Use: store, get, search, list, delete, link, unlink, knowledge_store, or knowledge_search.`
184
- } catch (err: any) {
185
- return `Error: ${err.message}`
201
+ } catch (err: unknown) {
202
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
186
203
  }
187
204
  },
188
205
  {
@@ -224,6 +241,8 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
224
241
  linkedLimit: z.number().optional().describe('Max linked memories expanded during traversal. Respects configured server cap.'),
225
242
  targetIds: z.array(z.string()).optional().describe('Memory IDs to link/unlink (for link/unlink actions)'),
226
243
  tags: z.array(z.string()).optional().describe('Tags for categorizing knowledge entries'),
244
+ source: z.string().optional().describe("Source of the knowledge, e.g. 'user', 'web', 'document'"),
245
+ sourceUrl: z.string().optional().describe('URL where the knowledge was sourced from'),
227
246
  pinned: z.boolean().optional().describe('Mark memory as pinned (always preloaded in agent context). For store action.'),
228
247
  sharedWith: z.array(z.string()).optional().describe('Agent IDs to share this memory with (for store action). They can read it in their context.'),
229
248
  }),
@@ -0,0 +1,132 @@
1
+ import { z } from 'zod'
2
+ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
+ import { execFile } from 'child_process'
4
+ import { promisify } from 'util'
5
+ import * as path from 'path'
6
+ import * as os from 'os'
7
+ import { loadSettings } from '../storage'
8
+ import type { ToolBuildContext } from './context'
9
+ import { MAX_OUTPUT } from './context'
10
+
11
+ const execFileAsync = promisify(execFile)
12
+
13
+ function resolveWorkspacePath(): string {
14
+ const settings = loadSettings()
15
+ if (typeof settings.openclawWorkspacePath === 'string' && settings.openclawWorkspacePath.trim()) {
16
+ return settings.openclawWorkspacePath.trim()
17
+ }
18
+ return path.join(os.homedir(), '.openclaw', 'workspace')
19
+ }
20
+
21
+ async function gitInWorkspace(args: string[], timeoutMs = 15_000): Promise<{ stdout: string; stderr: string }> {
22
+ const cwd = resolveWorkspacePath()
23
+ return execFileAsync('git', args, {
24
+ cwd,
25
+ timeout: timeoutMs,
26
+ maxBuffer: MAX_OUTPUT,
27
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
28
+ })
29
+ }
30
+
31
+ export function buildOpenClawWorkspaceTools(bctx: ToolBuildContext): StructuredToolInterface[] {
32
+ if (!bctx.hasTool('openclaw_workspace')) return []
33
+
34
+ return [
35
+ tool(
36
+ async ({ message }) => {
37
+ try {
38
+ const workspace = resolveWorkspacePath()
39
+ // Verify it's a git repo
40
+ await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
41
+ cwd: workspace,
42
+ timeout: 5000,
43
+ })
44
+
45
+ const label = message || new Date().toISOString().replace(/[:.]/g, '-')
46
+ await gitInWorkspace(['add', '-A'])
47
+
48
+ // Check if there's anything to commit
49
+ const status = await gitInWorkspace(['status', '--porcelain'])
50
+ if (!status.stdout.trim()) {
51
+ return JSON.stringify({ ok: true, commitHash: null, message: 'Nothing to commit — workspace is clean.' })
52
+ }
53
+
54
+ await gitInWorkspace(['commit', '-m', `backup: ${label}`])
55
+ const { stdout } = await gitInWorkspace(['rev-parse', 'HEAD'])
56
+ return JSON.stringify({ ok: true, commitHash: stdout.trim() })
57
+ } catch (err: unknown) {
58
+ const execErr = err as { stderr?: string; message?: string }
59
+ return JSON.stringify({ ok: false, error: execErr.stderr || execErr.message || String(err) })
60
+ }
61
+ },
62
+ {
63
+ name: 'openclaw_workspace_backup',
64
+ description: 'Create a git backup of the OpenClaw workspace. Stages all changes and commits.',
65
+ schema: z.object({
66
+ message: z.string().optional().describe('Optional backup message (defaults to timestamp)'),
67
+ }),
68
+ },
69
+ ),
70
+ tool(
71
+ async ({ commitHash }) => {
72
+ try {
73
+ if (!commitHash) {
74
+ // Find first stable commit (skip auto-generated ones)
75
+ const { stdout } = await gitInWorkspace(['log', '--oneline', '--format=%H %s', '-50'])
76
+ const lines = stdout.trim().split('\n').filter(Boolean)
77
+ const autoPattern = /^(rollback|daily-backup|auto-backup|guardian-auto|backup:)/i
78
+ const stable = lines.find((line) => {
79
+ const msg = line.substring(41) // after hash + space
80
+ return !autoPattern.test(msg)
81
+ })
82
+ if (!stable) {
83
+ return JSON.stringify({ ok: false, error: 'No stable commit found to roll back to.' })
84
+ }
85
+ commitHash = stable.substring(0, 40)
86
+ }
87
+
88
+ const { stdout: prevHead } = await gitInWorkspace(['rev-parse', 'HEAD'])
89
+ await gitInWorkspace(['reset', '--hard', commitHash])
90
+ return JSON.stringify({ ok: true, rolledBackTo: commitHash, previousHead: prevHead.trim() })
91
+ } catch (err: unknown) {
92
+ const execErr = err as { stderr?: string; message?: string }
93
+ return JSON.stringify({ ok: false, error: execErr.stderr || execErr.message || String(err) })
94
+ }
95
+ },
96
+ {
97
+ name: 'openclaw_workspace_rollback',
98
+ description: 'Roll back the OpenClaw workspace to a specific commit, or automatically find the last stable (non-auto-generated) commit.',
99
+ schema: z.object({
100
+ commitHash: z.string().optional().describe('Target commit hash (if omitted, uses the last stable commit)'),
101
+ }),
102
+ },
103
+ ),
104
+ tool(
105
+ async ({ limit }) => {
106
+ try {
107
+ const count = Math.max(1, Math.min(limit ?? 20, 100))
108
+ const { stdout } = await gitInWorkspace(['log', '--oneline', `--format=%H %s`, `-${count}`])
109
+ const commits = stdout
110
+ .trim()
111
+ .split('\n')
112
+ .filter(Boolean)
113
+ .map((line) => ({
114
+ hash: line.substring(0, 40),
115
+ message: line.substring(41),
116
+ }))
117
+ return JSON.stringify({ commits })
118
+ } catch (err: unknown) {
119
+ const execErr = err as { stderr?: string; message?: string }
120
+ return JSON.stringify({ ok: false, error: execErr.stderr || execErr.message || String(err) })
121
+ }
122
+ },
123
+ {
124
+ name: 'openclaw_workspace_history',
125
+ description: 'List recent git commits in the OpenClaw workspace.',
126
+ schema: z.object({
127
+ limit: z.number().optional().describe('Number of commits to return (default 20, max 100)'),
128
+ }),
129
+ },
130
+ ),
131
+ ]
132
+ }
@@ -9,6 +9,100 @@ import type { Message } from '@/types'
9
9
  import { ensureMainSessionFlag } from './main-session'
10
10
  export const UPLOAD_DIR = path.join(DATA_DIR, 'uploads')
11
11
 
12
+ // --- LRU Cache ---
13
+
14
+ const DEFAULT_LRU_CAPACITY = 5000
15
+
16
+ /** Per-collection capacity overrides from COLLECTION_CACHE_LIMITS env var (JSON). */
17
+ function parseCacheLimits(): Record<string, number> {
18
+ const raw = process.env.COLLECTION_CACHE_LIMITS
19
+ if (!raw) return {}
20
+ try {
21
+ const parsed: unknown = JSON.parse(raw)
22
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
23
+ const result: Record<string, number> = {}
24
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
25
+ if (typeof v === 'number' && v > 0) result[k] = v
26
+ }
27
+ return result
28
+ } catch {
29
+ return {}
30
+ }
31
+ }
32
+
33
+ const cacheLimits = parseCacheLimits()
34
+
35
+ function capacityFor(collection: string): number {
36
+ return cacheLimits[collection] ?? DEFAULT_LRU_CAPACITY
37
+ }
38
+
39
+ /**
40
+ * A Map wrapper with LRU eviction. JS Maps iterate in insertion order,
41
+ * so the *first* key is the least-recently-used entry.
42
+ */
43
+ class LRUMap<K, V> {
44
+ private readonly map = new Map<K, V>()
45
+ readonly capacity: number
46
+
47
+ constructor(capacity: number) {
48
+ this.capacity = Math.max(1, capacity)
49
+ }
50
+
51
+ get(key: K): V | undefined {
52
+ if (!this.map.has(key)) return undefined
53
+ const value = this.map.get(key)!
54
+ // Move to end (most-recently-used)
55
+ this.map.delete(key)
56
+ this.map.set(key, value)
57
+ return value
58
+ }
59
+
60
+ set(key: K, value: V): this {
61
+ if (this.map.has(key)) {
62
+ this.map.delete(key)
63
+ }
64
+ this.map.set(key, value)
65
+ // Evict oldest if over capacity
66
+ if (this.map.size > this.capacity) {
67
+ const oldest = this.map.keys().next().value as K
68
+ this.map.delete(oldest)
69
+ }
70
+ return this
71
+ }
72
+
73
+ has(key: K): boolean {
74
+ return this.map.has(key)
75
+ }
76
+
77
+ delete(key: K): boolean {
78
+ return this.map.delete(key)
79
+ }
80
+
81
+ get size(): number {
82
+ return this.map.size
83
+ }
84
+
85
+ clear(): void {
86
+ this.map.clear()
87
+ }
88
+
89
+ keys(): MapIterator<K> {
90
+ return this.map.keys()
91
+ }
92
+
93
+ values(): MapIterator<V> {
94
+ return this.map.values()
95
+ }
96
+
97
+ entries(): MapIterator<[K, V]> {
98
+ return this.map.entries()
99
+ }
100
+
101
+ [Symbol.iterator](): MapIterator<[K, V]> {
102
+ return this.map[Symbol.iterator]()
103
+ }
104
+ }
105
+
12
106
  // Ensure directories exist
13
107
  for (const dir of [DATA_DIR, UPLOAD_DIR, WORKSPACE_DIR]) {
14
108
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
@@ -26,12 +120,12 @@ db.pragma('foreign_keys = ON')
26
120
 
27
121
  const collectionCacheKey = '__swarmclaw_storage_collection_cache__' as const
28
122
  type StorageGlobals = typeof globalThis & {
29
- [collectionCacheKey]?: Map<string, Map<string, string>>
123
+ [collectionCacheKey]?: Map<string, LRUMap<string, string>>
30
124
  }
31
125
  const storageGlobals = globalThis as StorageGlobals
32
- const collectionCache: Map<string, Map<string, string>> =
126
+ const collectionCache: Map<string, LRUMap<string, string>> =
33
127
  storageGlobals[collectionCacheKey]
34
- ?? (storageGlobals[collectionCacheKey] = new Map<string, Map<string, string>>())
128
+ ?? (storageGlobals[collectionCacheKey] = new Map<string, LRUMap<string, string>>())
35
129
 
36
130
  // Collection tables (id → JSON blob)
37
131
  const COLLECTIONS = [
@@ -57,6 +151,8 @@ const COLLECTIONS = [
57
151
  'wallets',
58
152
  'wallet_transactions',
59
153
  'wallet_balance_history',
154
+ 'moderation_logs',
155
+ 'connector_health',
60
156
  ] as const
61
157
 
62
158
  for (const table of COLLECTIONS) {
@@ -69,16 +165,16 @@ db.exec(`CREATE TABLE IF NOT EXISTS queue (id INTEGER PRIMARY KEY CHECK (id = 1)
69
165
  db.exec(`CREATE TABLE IF NOT EXISTS usage (session_id TEXT NOT NULL, data TEXT NOT NULL)`)
70
166
  db.exec(`CREATE INDEX IF NOT EXISTS idx_usage_session ON usage(session_id)`)
71
167
 
72
- function readCollectionRaw(table: string): Map<string, string> {
168
+ function readCollectionRaw(table: string): LRUMap<string, string> {
73
169
  const rows = db.prepare(`SELECT id, data FROM ${table}`).all() as { id: string; data: string }[]
74
- const raw = new Map<string, string>()
170
+ const raw = new LRUMap<string, string>(capacityFor(table))
75
171
  for (const row of rows) {
76
172
  raw.set(row.id, row.data)
77
173
  }
78
174
  return raw
79
175
  }
80
176
 
81
- function getCollectionRawCache(table: string): Map<string, string> {
177
+ function getCollectionRawCache(table: string): LRUMap<string, string> {
82
178
  // Always reload from SQLite so concurrent Next.js workers/processes
83
179
  // observe each other's writes immediately.
84
180
  const loaded = readCollectionRaw(table)
@@ -864,6 +960,24 @@ export function upsertWalletBalanceSnapshot(id: string, snapshot: unknown) {
864
960
  upsertCollectionItem('wallet_balance_history', id, snapshot)
865
961
  }
866
962
 
963
+ // --- Moderation Logs ---
964
+ export function loadModerationLogs(): Record<string, unknown> {
965
+ return loadCollection('moderation_logs')
966
+ }
967
+
968
+ export function appendModerationLog(id: string, entry: unknown) {
969
+ upsertCollectionItem('moderation_logs', id, entry)
970
+ }
971
+
972
+ // --- Connector Health ---
973
+ export function loadConnectorHealth(): Record<string, unknown> {
974
+ return loadCollection('connector_health')
975
+ }
976
+
977
+ export function upsertConnectorHealthEvent(id: string, event: unknown) {
978
+ upsertCollectionItem('connector_health', id, event)
979
+ }
980
+
867
981
  export function getSessionMessages(sessionId: string): Message[] {
868
982
  const stmt = db.prepare('SELECT data FROM sessions WHERE id = ?')
869
983
  const row = stmt.get(sessionId) as { data: string } | undefined