@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.
- package/README.md +57 -27
- package/package.json +6 -1
- package/src/app/api/agents/[id]/clone/route.ts +40 -0
- package/src/app/api/agents/route.ts +39 -14
- package/src/app/api/chatrooms/[id]/chat/route.ts +17 -1
- package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
- package/src/app/api/chatrooms/[id]/route.ts +19 -1
- package/src/app/api/chatrooms/route.ts +12 -2
- package/src/app/api/connectors/[id]/health/route.ts +64 -0
- package/src/app/api/connectors/route.ts +17 -2
- package/src/app/api/knowledge/route.ts +6 -1
- package/src/app/api/openclaw/doctor/route.ts +17 -0
- package/src/app/api/sessions/[id]/chat/route.ts +5 -1
- package/src/app/api/sessions/route.ts +11 -2
- package/src/app/api/tasks/[id]/route.ts +18 -13
- package/src/app/api/tasks/route.ts +20 -1
- package/src/app/api/usage/route.ts +16 -7
- package/src/cli/index.js +5 -0
- package/src/cli/index.ts +223 -39
- package/src/components/agents/agent-card.tsx +37 -6
- package/src/components/agents/agent-chat-list.tsx +78 -2
- package/src/components/agents/agent-sheet.tsx +79 -0
- package/src/components/auth/setup-wizard.tsx +268 -353
- package/src/components/chat/chat-area.tsx +22 -7
- package/src/components/chat/message-bubble.tsx +14 -14
- package/src/components/chat/message-list.tsx +1 -1
- package/src/components/chatrooms/chatroom-message.tsx +164 -22
- package/src/components/chatrooms/chatroom-sheet.tsx +288 -3
- package/src/components/chatrooms/chatroom-view.tsx +62 -17
- package/src/components/connectors/connector-health.tsx +120 -0
- package/src/components/connectors/connector-sheet.tsx +9 -0
- package/src/components/home/home-view.tsx +23 -2
- package/src/components/input/chat-input.tsx +8 -1
- package/src/components/layout/app-layout.tsx +17 -1
- package/src/components/schedules/schedule-list.tsx +55 -9
- package/src/components/schedules/schedule-sheet.tsx +134 -23
- package/src/components/shared/command-palette.tsx +237 -0
- package/src/components/shared/connector-platform-icon.tsx +1 -0
- package/src/components/tasks/task-card.tsx +22 -2
- package/src/components/tasks/task-sheet.tsx +91 -16
- package/src/components/usage/metrics-dashboard.tsx +13 -25
- package/src/hooks/use-swipe.ts +49 -0
- package/src/lib/providers/anthropic.ts +16 -2
- package/src/lib/providers/claude-cli.ts +7 -1
- package/src/lib/providers/index.ts +7 -0
- package/src/lib/providers/ollama.ts +16 -2
- package/src/lib/providers/openai.ts +7 -2
- package/src/lib/providers/openclaw.ts +6 -1
- package/src/lib/providers/provider-defaults.ts +7 -0
- package/src/lib/schedule-templates.ts +115 -0
- package/src/lib/server/alert-dispatch.ts +64 -0
- package/src/lib/server/chat-execution.ts +41 -1
- package/src/lib/server/chatroom-helpers.ts +22 -1
- package/src/lib/server/chatroom-routing.ts +65 -0
- package/src/lib/server/connectors/discord.ts +3 -0
- package/src/lib/server/connectors/email.ts +267 -0
- package/src/lib/server/connectors/manager.ts +159 -3
- package/src/lib/server/connectors/openclaw.ts +3 -0
- package/src/lib/server/connectors/slack.ts +6 -0
- package/src/lib/server/connectors/telegram.ts +18 -0
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp.ts +9 -0
- package/src/lib/server/cost.ts +70 -0
- package/src/lib/server/create-notification.ts +2 -0
- package/src/lib/server/daemon-state.ts +124 -0
- package/src/lib/server/dag-validation.ts +115 -0
- package/src/lib/server/memory-db.ts +12 -7
- package/src/lib/server/openclaw-doctor.ts +48 -0
- package/src/lib/server/queue.ts +12 -0
- package/src/lib/server/session-run-manager.ts +22 -1
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/memory.ts +22 -3
- package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
- package/src/lib/server/storage.ts +120 -6
- package/src/lib/setup-defaults.ts +277 -0
- package/src/lib/validation/schemas.ts +69 -0
- package/src/stores/use-app-store.ts +7 -3
- package/src/stores/use-chatroom-store.ts +52 -2
- package/src/types/index.ts +38 -1
- 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
|
+
}
|
package/src/lib/server/queue.ts
CHANGED
|
@@ -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 =>
|
|
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:
|
|
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,
|
|
123
|
+
[collectionCacheKey]?: Map<string, LRUMap<string, string>>
|
|
30
124
|
}
|
|
31
125
|
const storageGlobals = globalThis as StorageGlobals
|
|
32
|
-
const collectionCache: Map<string,
|
|
126
|
+
const collectionCache: Map<string, LRUMap<string, string>> =
|
|
33
127
|
storageGlobals[collectionCacheKey]
|
|
34
|
-
?? (storageGlobals[collectionCacheKey] = new Map<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):
|
|
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
|
|
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):
|
|
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
|