@swarmclawai/swarmclaw 0.6.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -39
- package/next.config.ts +31 -6
- package/package.json +3 -2
- package/src/app/api/agents/[id]/thread/route.ts +1 -0
- package/src/app/api/agents/route.ts +19 -5
- package/src/app/api/approvals/route.ts +22 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +4 -0
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/eval/run/route.ts +37 -0
- package/src/app/api/eval/scenarios/route.ts +24 -0
- package/src/app/api/eval/suite/route.ts +29 -0
- package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
- package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
- package/src/app/api/memory/graph/route.ts +46 -0
- package/src/app/api/memory/route.ts +36 -5
- package/src/app/api/notifications/route.ts +3 -0
- package/src/app/api/plugins/install/route.ts +57 -5
- package/src/app/api/plugins/marketplace/route.ts +73 -22
- package/src/app/api/plugins/route.ts +61 -1
- package/src/app/api/plugins/ui/route.ts +34 -0
- package/src/app/api/sessions/[id]/checkpoints/route.ts +31 -0
- package/src/app/api/sessions/[id]/restore/route.ts +36 -0
- package/src/app/api/settings/route.ts +62 -0
- package/src/app/api/setup/doctor/route.ts +22 -5
- package/src/app/api/souls/[id]/route.ts +65 -0
- package/src/app/api/souls/route.ts +70 -0
- package/src/app/api/tasks/[id]/approve/route.ts +4 -3
- package/src/app/api/tasks/[id]/route.ts +16 -3
- package/src/app/api/tasks/route.ts +10 -2
- package/src/app/api/usage/route.ts +9 -2
- package/src/app/globals.css +27 -0
- package/src/app/page.tsx +10 -5
- package/src/cli/index.js +37 -0
- package/src/components/activity/activity-feed.tsx +9 -2
- package/src/components/agents/agent-avatar.tsx +5 -1
- package/src/components/agents/agent-card.tsx +55 -9
- package/src/components/agents/agent-sheet.tsx +112 -34
- package/src/components/agents/inspector-panel.tsx +1 -1
- package/src/components/agents/soul-library-picker.tsx +84 -13
- package/src/components/auth/access-key-gate.tsx +63 -54
- package/src/components/auth/user-picker.tsx +37 -32
- package/src/components/chat/activity-moment.tsx +2 -0
- package/src/components/chat/chat-area.tsx +11 -0
- package/src/components/chat/chat-header.tsx +69 -25
- package/src/components/chat/chat-tool-toggles.tsx +2 -2
- package/src/components/chat/checkpoint-timeline.tsx +112 -0
- package/src/components/chat/code-block.tsx +3 -1
- package/src/components/chat/exec-approval-card.tsx +8 -1
- package/src/components/chat/message-bubble.tsx +164 -4
- package/src/components/chat/message-list.tsx +46 -4
- package/src/components/chat/session-approval-card.tsx +80 -0
- package/src/components/chat/session-debug-panel.tsx +106 -84
- package/src/components/chat/streaming-bubble.tsx +6 -5
- package/src/components/chat/task-approval-card.tsx +78 -0
- package/src/components/chat/thinking-indicator.tsx +48 -12
- package/src/components/chat/tool-call-bubble.tsx +3 -0
- package/src/components/chat/tool-request-banner.tsx +39 -20
- package/src/components/chatrooms/chatroom-list.tsx +11 -4
- package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
- package/src/components/connectors/connector-list.tsx +33 -11
- package/src/components/connectors/connector-sheet.tsx +37 -7
- package/src/components/home/home-view.tsx +54 -24
- package/src/components/input/chat-input.tsx +22 -1
- package/src/components/knowledge/knowledge-list.tsx +17 -18
- package/src/components/knowledge/knowledge-sheet.tsx +9 -5
- package/src/components/layout/app-layout.tsx +87 -19
- package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
- package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
- package/src/components/memory/memory-browser.tsx +73 -45
- package/src/components/memory/memory-graph-view.tsx +203 -0
- package/src/components/memory/memory-list.tsx +20 -13
- package/src/components/plugins/plugin-list.tsx +214 -60
- package/src/components/plugins/plugin-sheet.tsx +119 -24
- package/src/components/projects/project-list.tsx +17 -9
- package/src/components/providers/provider-list.tsx +21 -6
- package/src/components/providers/provider-sheet.tsx +42 -25
- package/src/components/runs/run-list.tsx +17 -13
- package/src/components/schedules/schedule-card.tsx +10 -3
- package/src/components/schedules/schedule-list.tsx +2 -2
- package/src/components/schedules/schedule-sheet.tsx +28 -9
- package/src/components/secrets/secret-sheet.tsx +7 -2
- package/src/components/secrets/secrets-list.tsx +18 -5
- package/src/components/sessions/new-session-sheet.tsx +183 -376
- package/src/components/sessions/session-card.tsx +10 -2
- package/src/components/settings/gateway-connection-panel.tsx +9 -8
- package/src/components/shared/command-palette.tsx +13 -5
- package/src/components/shared/empty-state.tsx +20 -8
- package/src/components/shared/hint-tip.tsx +31 -0
- package/src/components/shared/notification-center.tsx +134 -86
- package/src/components/shared/profile-sheet.tsx +4 -0
- package/src/components/shared/settings/plugin-manager.tsx +360 -135
- package/src/components/shared/settings/section-capability-policy.tsx +3 -3
- package/src/components/shared/settings/section-runtime-loop.tsx +149 -4
- package/src/components/skills/clawhub-browser.tsx +1 -0
- package/src/components/skills/skill-list.tsx +31 -12
- package/src/components/skills/skill-sheet.tsx +20 -7
- package/src/components/tasks/approvals-panel.tsx +224 -0
- package/src/components/tasks/task-board.tsx +20 -12
- package/src/components/tasks/task-card.tsx +21 -7
- package/src/components/tasks/task-column.tsx +4 -3
- package/src/components/tasks/task-list.tsx +1 -1
- package/src/components/tasks/task-sheet.tsx +130 -1
- package/src/components/ui/dialog.tsx +1 -0
- package/src/components/ui/sheet.tsx +1 -0
- package/src/components/usage/metrics-dashboard.tsx +72 -48
- package/src/components/wallets/wallet-panel.tsx +65 -41
- package/src/components/wallets/wallet-section.tsx +9 -3
- package/src/components/webhooks/webhook-list.tsx +21 -12
- package/src/components/webhooks/webhook-sheet.tsx +13 -3
- package/src/lib/approval-display.test.ts +45 -0
- package/src/lib/approval-display.ts +62 -0
- package/src/lib/clipboard.ts +38 -0
- package/src/lib/memory.ts +8 -0
- package/src/lib/providers/claude-cli.ts +5 -3
- package/src/lib/providers/index.ts +67 -21
- package/src/lib/runtime-loop.ts +3 -2
- package/src/lib/server/approvals.ts +150 -0
- package/src/lib/server/chat-execution.ts +319 -74
- package/src/lib/server/chatroom-helpers.ts +63 -5
- package/src/lib/server/chatroom-orchestration.ts +74 -0
- package/src/lib/server/clawhub-client.ts +82 -6
- package/src/lib/server/connectors/manager.ts +27 -1
- package/src/lib/server/context-manager.ts +132 -50
- package/src/lib/server/cost.test.ts +73 -0
- package/src/lib/server/cost.ts +165 -34
- package/src/lib/server/daemon-state.ts +112 -1
- package/src/lib/server/data-dir.ts +18 -1
- package/src/lib/server/eval/runner.ts +126 -0
- package/src/lib/server/eval/scenarios.ts +218 -0
- package/src/lib/server/eval/scorer.ts +96 -0
- package/src/lib/server/eval/store.ts +37 -0
- package/src/lib/server/eval/types.ts +48 -0
- package/src/lib/server/execution-log.ts +12 -8
- package/src/lib/server/guardian.ts +34 -0
- package/src/lib/server/heartbeat-service.ts +53 -1
- package/src/lib/server/integrity-monitor.ts +208 -0
- package/src/lib/server/langgraph-checkpoint.ts +10 -0
- package/src/lib/server/link-understanding.ts +55 -0
- package/src/lib/server/llm-response-cache.test.ts +102 -0
- package/src/lib/server/llm-response-cache.ts +227 -0
- package/src/lib/server/main-agent-loop.ts +115 -16
- package/src/lib/server/main-session.ts +6 -3
- package/src/lib/server/mcp-conformance.test.ts +18 -0
- package/src/lib/server/mcp-conformance.ts +233 -0
- package/src/lib/server/memory-db.ts +193 -19
- package/src/lib/server/memory-retrieval.test.ts +56 -0
- package/src/lib/server/mmr.ts +73 -0
- package/src/lib/server/orchestrator-lg.ts +7 -1
- package/src/lib/server/orchestrator.ts +4 -3
- package/src/lib/server/plugins.ts +662 -132
- package/src/lib/server/process-manager.ts +18 -0
- package/src/lib/server/query-expansion.ts +57 -0
- package/src/lib/server/queue.ts +280 -11
- package/src/lib/server/runtime-settings.ts +9 -0
- package/src/lib/server/session-run-manager.test.ts +23 -0
- package/src/lib/server/session-run-manager.ts +32 -2
- package/src/lib/server/session-tools/canvas.ts +85 -50
- package/src/lib/server/session-tools/chatroom.ts +130 -127
- package/src/lib/server/session-tools/connector.ts +233 -454
- package/src/lib/server/session-tools/context-mgmt.ts +87 -105
- package/src/lib/server/session-tools/crud.ts +84 -7
- package/src/lib/server/session-tools/delegate.ts +351 -752
- package/src/lib/server/session-tools/discovery.ts +198 -0
- package/src/lib/server/session-tools/edit_file.ts +82 -0
- package/src/lib/server/session-tools/file-send.test.ts +39 -0
- package/src/lib/server/session-tools/file.ts +257 -425
- package/src/lib/server/session-tools/git.ts +87 -47
- package/src/lib/server/session-tools/http.ts +95 -33
- package/src/lib/server/session-tools/index.ts +217 -138
- package/src/lib/server/session-tools/memory.ts +154 -239
- package/src/lib/server/session-tools/monitor.ts +126 -0
- package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
- package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
- package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
- package/src/lib/server/session-tools/platform.ts +86 -0
- package/src/lib/server/session-tools/plugin-creator.ts +239 -0
- package/src/lib/server/session-tools/sample-ui.ts +97 -0
- package/src/lib/server/session-tools/sandbox.ts +175 -148
- package/src/lib/server/session-tools/schedule.ts +78 -0
- package/src/lib/server/session-tools/session-info.ts +104 -410
- package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
- package/src/lib/server/session-tools/shell.ts +171 -143
- package/src/lib/server/session-tools/subagent.ts +77 -77
- package/src/lib/server/session-tools/wallet.ts +182 -106
- package/src/lib/server/session-tools/web.ts +181 -327
- package/src/lib/server/storage.ts +36 -0
- package/src/lib/server/stream-agent-chat.ts +348 -242
- package/src/lib/server/task-quality-gate.test.ts +44 -0
- package/src/lib/server/task-quality-gate.ts +67 -0
- package/src/lib/server/task-validation.test.ts +78 -0
- package/src/lib/server/task-validation.ts +67 -2
- package/src/lib/server/tool-aliases.ts +68 -0
- package/src/lib/server/tool-capability-policy.ts +24 -5
- package/src/lib/server/tool-retry.ts +62 -0
- package/src/lib/server/transcript-repair.ts +72 -0
- package/src/lib/setup-defaults.ts +1 -0
- package/src/lib/tasks.ts +7 -1
- package/src/lib/tool-definitions.ts +24 -23
- package/src/lib/validation/schemas.ts +13 -0
- package/src/lib/view-routes.ts +2 -23
- package/src/stores/use-app-store.ts +23 -1
- package/src/types/index.ts +155 -10
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { DATA_DIR } from './data-dir'
|
|
5
|
+
import { resolveOpenClawWorkspace } from './openclaw-sync'
|
|
6
|
+
import { loadIntegrityBaselines, saveIntegrityBaselines } from './storage'
|
|
7
|
+
|
|
8
|
+
export interface IntegrityBaselineEntry {
|
|
9
|
+
id: string
|
|
10
|
+
filePath: string
|
|
11
|
+
kind: 'identity' | 'config' | 'plugin'
|
|
12
|
+
present: boolean
|
|
13
|
+
hash: string | null
|
|
14
|
+
size: number | null
|
|
15
|
+
mtimeMs: number | null
|
|
16
|
+
updatedAt: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IntegrityDrift {
|
|
20
|
+
id: string
|
|
21
|
+
filePath: string
|
|
22
|
+
kind: IntegrityBaselineEntry['kind']
|
|
23
|
+
type: 'created' | 'modified' | 'deleted'
|
|
24
|
+
previousHash: string | null
|
|
25
|
+
nextHash: string | null
|
|
26
|
+
checkedAt: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IntegrityMonitorResult {
|
|
30
|
+
enabled: boolean
|
|
31
|
+
checkedAt: number
|
|
32
|
+
checkedFiles: number
|
|
33
|
+
drifts: IntegrityDrift[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface WatchTarget {
|
|
37
|
+
id: string
|
|
38
|
+
filePath: string
|
|
39
|
+
kind: IntegrityBaselineEntry['kind']
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseBool(value: unknown, fallback: boolean): boolean {
|
|
43
|
+
if (typeof value === 'boolean') return value
|
|
44
|
+
if (typeof value === 'string') {
|
|
45
|
+
const normalized = value.trim().toLowerCase()
|
|
46
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
|
47
|
+
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
|
48
|
+
}
|
|
49
|
+
return fallback
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fileHash(filePath: string): string {
|
|
53
|
+
const hasher = crypto.createHash('sha256')
|
|
54
|
+
const content = fs.readFileSync(filePath)
|
|
55
|
+
hasher.update(content)
|
|
56
|
+
return hasher.digest('hex')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeStat(filePath: string): fs.Stats | null {
|
|
60
|
+
try {
|
|
61
|
+
return fs.statSync(filePath)
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toId(filePath: string): string {
|
|
68
|
+
return crypto.createHash('sha1').update(path.resolve(filePath)).digest('hex')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pushIfExists(targets: WatchTarget[], filePath: string, kind: WatchTarget['kind']): void {
|
|
72
|
+
if (!fs.existsSync(filePath)) return
|
|
73
|
+
targets.push({
|
|
74
|
+
id: toId(filePath),
|
|
75
|
+
filePath: path.resolve(filePath),
|
|
76
|
+
kind,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function collectWatchTargets(): WatchTarget[] {
|
|
81
|
+
const targets: WatchTarget[] = []
|
|
82
|
+
const cwd = process.cwd()
|
|
83
|
+
|
|
84
|
+
// Core workspace identity/config files.
|
|
85
|
+
pushIfExists(targets, path.join(cwd, 'AGENTS.md'), 'identity')
|
|
86
|
+
pushIfExists(targets, path.join(cwd, 'SOUL.md'), 'identity')
|
|
87
|
+
pushIfExists(targets, path.join(cwd, 'IDENTITY.md'), 'identity')
|
|
88
|
+
pushIfExists(targets, path.join(cwd, '.env.local'), 'config')
|
|
89
|
+
|
|
90
|
+
// Repo-level AGENTS.md (one level above app dir when present).
|
|
91
|
+
pushIfExists(targets, path.resolve(cwd, '..', 'AGENTS.md'), 'identity')
|
|
92
|
+
|
|
93
|
+
// Plugin files + plugin config.
|
|
94
|
+
pushIfExists(targets, path.join(DATA_DIR, 'plugins.json'), 'config')
|
|
95
|
+
const pluginDir = path.join(DATA_DIR, 'plugins')
|
|
96
|
+
if (fs.existsSync(pluginDir)) {
|
|
97
|
+
for (const entry of fs.readdirSync(pluginDir)) {
|
|
98
|
+
if (!entry.endsWith('.js') && !entry.endsWith('.mjs') && !entry.endsWith('.cjs')) continue
|
|
99
|
+
pushIfExists(targets, path.join(pluginDir, entry), 'plugin')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// OpenClaw agent identity files.
|
|
104
|
+
try {
|
|
105
|
+
const workspace = resolveOpenClawWorkspace()
|
|
106
|
+
const agentsDir = path.join(workspace, 'agents')
|
|
107
|
+
if (fs.existsSync(agentsDir)) {
|
|
108
|
+
for (const agentDirName of fs.readdirSync(agentsDir)) {
|
|
109
|
+
const dirPath = path.join(agentsDir, agentDirName)
|
|
110
|
+
if (!safeStat(dirPath)?.isDirectory()) continue
|
|
111
|
+
pushIfExists(targets, path.join(dirPath, 'SOUL.md'), 'identity')
|
|
112
|
+
pushIfExists(targets, path.join(dirPath, 'IDENTITY.md'), 'identity')
|
|
113
|
+
pushIfExists(targets, path.join(dirPath, 'TOOLS.md'), 'identity')
|
|
114
|
+
pushIfExists(targets, path.join(dirPath, 'AGENTS.md'), 'identity')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// OpenClaw workspace is optional.
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Deduplicate path collisions.
|
|
122
|
+
const seen = new Set<string>()
|
|
123
|
+
return targets.filter((target) => {
|
|
124
|
+
if (seen.has(target.id)) return false
|
|
125
|
+
seen.add(target.id)
|
|
126
|
+
return true
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toBaseline(target: WatchTarget, checkedAt: number): IntegrityBaselineEntry {
|
|
131
|
+
const stat = safeStat(target.filePath)
|
|
132
|
+
const present = !!stat && stat.isFile()
|
|
133
|
+
return {
|
|
134
|
+
id: target.id,
|
|
135
|
+
filePath: target.filePath,
|
|
136
|
+
kind: target.kind,
|
|
137
|
+
present,
|
|
138
|
+
hash: present ? fileHash(target.filePath) : null,
|
|
139
|
+
size: present ? stat!.size : null,
|
|
140
|
+
mtimeMs: present ? Math.trunc(stat!.mtimeMs) : null,
|
|
141
|
+
updatedAt: checkedAt,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function runIntegrityMonitor(settings?: Record<string, unknown> | null): IntegrityMonitorResult {
|
|
146
|
+
const enabled = parseBool(settings?.integrityMonitorEnabled, true)
|
|
147
|
+
const checkedAt = Date.now()
|
|
148
|
+
if (!enabled) {
|
|
149
|
+
return {
|
|
150
|
+
enabled: false,
|
|
151
|
+
checkedAt,
|
|
152
|
+
checkedFiles: 0,
|
|
153
|
+
drifts: [],
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const targets = collectWatchTargets()
|
|
158
|
+
const stored = loadIntegrityBaselines() as Record<string, IntegrityBaselineEntry>
|
|
159
|
+
const nextBaselines: Record<string, IntegrityBaselineEntry> = { ...stored }
|
|
160
|
+
const drifts: IntegrityDrift[] = []
|
|
161
|
+
let dirty = false
|
|
162
|
+
|
|
163
|
+
for (const target of targets) {
|
|
164
|
+
const previous = stored[target.id]
|
|
165
|
+
const current = toBaseline(target, checkedAt)
|
|
166
|
+
|
|
167
|
+
if (!previous) {
|
|
168
|
+
nextBaselines[target.id] = current
|
|
169
|
+
dirty = true
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const changed = (
|
|
174
|
+
previous.present !== current.present
|
|
175
|
+
|| previous.hash !== current.hash
|
|
176
|
+
|| previous.filePath !== current.filePath
|
|
177
|
+
|| previous.kind !== current.kind
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if (changed) {
|
|
181
|
+
let type: IntegrityDrift['type'] = 'modified'
|
|
182
|
+
if (!previous.present && current.present) type = 'created'
|
|
183
|
+
else if (previous.present && !current.present) type = 'deleted'
|
|
184
|
+
drifts.push({
|
|
185
|
+
id: current.id,
|
|
186
|
+
filePath: current.filePath,
|
|
187
|
+
kind: current.kind,
|
|
188
|
+
type,
|
|
189
|
+
previousHash: previous.hash || null,
|
|
190
|
+
nextHash: current.hash || null,
|
|
191
|
+
checkedAt,
|
|
192
|
+
})
|
|
193
|
+
nextBaselines[target.id] = current
|
|
194
|
+
dirty = true
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (dirty) {
|
|
199
|
+
saveIntegrityBaselines(nextBaselines)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
enabled: true,
|
|
204
|
+
checkedAt,
|
|
205
|
+
checkedFiles: targets.length,
|
|
206
|
+
drifts,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import type { Message } from '@/types'
|
|
4
|
+
import {
|
|
5
|
+
buildLlmResponseCacheKey,
|
|
6
|
+
clearLlmResponseCache,
|
|
7
|
+
getCachedLlmResponse,
|
|
8
|
+
resolveLlmResponseCacheConfig,
|
|
9
|
+
setCachedLlmResponse,
|
|
10
|
+
} from './llm-response-cache.ts'
|
|
11
|
+
|
|
12
|
+
const HISTORY: Message[] = [
|
|
13
|
+
{ role: 'user', text: 'Plan a release.', time: 1 },
|
|
14
|
+
{ role: 'assistant', text: 'Drafted plan.', time: 2 },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
test('buildLlmResponseCacheKey is deterministic for equivalent payloads', () => {
|
|
18
|
+
const keyA = buildLlmResponseCacheKey({
|
|
19
|
+
provider: 'openai',
|
|
20
|
+
model: 'gpt-4o-mini',
|
|
21
|
+
apiEndpoint: 'https://api.openai.com/v1',
|
|
22
|
+
systemPrompt: 'System prompt',
|
|
23
|
+
message: 'hello',
|
|
24
|
+
history: HISTORY,
|
|
25
|
+
attachedFiles: ['a.txt', 'b.txt'],
|
|
26
|
+
})
|
|
27
|
+
const keyB = buildLlmResponseCacheKey({
|
|
28
|
+
provider: 'openai',
|
|
29
|
+
model: 'gpt-4o-mini',
|
|
30
|
+
apiEndpoint: 'https://api.openai.com/v1',
|
|
31
|
+
systemPrompt: ' System prompt ',
|
|
32
|
+
message: 'hello',
|
|
33
|
+
history: [...HISTORY],
|
|
34
|
+
attachedFiles: ['a.txt', 'b.txt'],
|
|
35
|
+
})
|
|
36
|
+
assert.equal(keyA, keyB)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('set/get cached responses returns hit and increments hit count', () => {
|
|
40
|
+
clearLlmResponseCache()
|
|
41
|
+
const config = { enabled: true, ttlMs: 60_000, maxEntries: 10 }
|
|
42
|
+
const keyInput = {
|
|
43
|
+
provider: 'openai',
|
|
44
|
+
model: 'gpt-4o',
|
|
45
|
+
message: 'status',
|
|
46
|
+
history: HISTORY,
|
|
47
|
+
}
|
|
48
|
+
setCachedLlmResponse(keyInput, 'cached answer', config, 1000)
|
|
49
|
+
const hit1 = getCachedLlmResponse(keyInput, config, 1500)
|
|
50
|
+
assert.ok(hit1)
|
|
51
|
+
assert.equal(hit1?.text, 'cached answer')
|
|
52
|
+
assert.equal(hit1?.hits, 1)
|
|
53
|
+
const hit2 = getCachedLlmResponse(keyInput, config, 1600)
|
|
54
|
+
assert.equal(hit2?.hits, 2)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('expired cache entry is not returned', () => {
|
|
58
|
+
clearLlmResponseCache()
|
|
59
|
+
const config = { enabled: true, ttlMs: 1000, maxEntries: 10 }
|
|
60
|
+
const keyInput = {
|
|
61
|
+
provider: 'openai',
|
|
62
|
+
model: 'gpt-4o',
|
|
63
|
+
message: 'status',
|
|
64
|
+
history: HISTORY,
|
|
65
|
+
}
|
|
66
|
+
setCachedLlmResponse(keyInput, 'stale', config, 1000)
|
|
67
|
+
const miss = getCachedLlmResponse(keyInput, config, 3001)
|
|
68
|
+
assert.equal(miss, null)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('cache evicts least recently used entries over maxEntries', () => {
|
|
72
|
+
clearLlmResponseCache()
|
|
73
|
+
const config = { enabled: true, ttlMs: 60_000, maxEntries: 2 }
|
|
74
|
+
const inputA = { provider: 'openai', model: 'gpt-4o', message: 'a', history: HISTORY }
|
|
75
|
+
const inputB = { provider: 'openai', model: 'gpt-4o', message: 'b', history: HISTORY }
|
|
76
|
+
const inputC = { provider: 'openai', model: 'gpt-4o', message: 'c', history: HISTORY }
|
|
77
|
+
setCachedLlmResponse(inputA, 'A', config, 1000)
|
|
78
|
+
setCachedLlmResponse(inputB, 'B', config, 1001)
|
|
79
|
+
// Touch A so B becomes LRU.
|
|
80
|
+
getCachedLlmResponse(inputA, config, 1002)
|
|
81
|
+
setCachedLlmResponse(inputC, 'C', config, 1003)
|
|
82
|
+
|
|
83
|
+
assert.equal(getCachedLlmResponse(inputB, config, 1004), null)
|
|
84
|
+
assert.equal(getCachedLlmResponse(inputA, config, 1004)?.text, 'A')
|
|
85
|
+
assert.equal(getCachedLlmResponse(inputC, config, 1004)?.text, 'C')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('resolveLlmResponseCacheConfig applies defaults and bounds', () => {
|
|
89
|
+
const fallback = resolveLlmResponseCacheConfig({})
|
|
90
|
+
assert.equal(fallback.enabled, true)
|
|
91
|
+
assert.equal(fallback.ttlMs, 900_000)
|
|
92
|
+
assert.equal(fallback.maxEntries, 500)
|
|
93
|
+
|
|
94
|
+
const custom = resolveLlmResponseCacheConfig({
|
|
95
|
+
responseCacheEnabled: false,
|
|
96
|
+
responseCacheTtlSec: 1,
|
|
97
|
+
responseCacheMaxEntries: 999999,
|
|
98
|
+
})
|
|
99
|
+
assert.equal(custom.enabled, false)
|
|
100
|
+
assert.equal(custom.ttlMs, 5000)
|
|
101
|
+
assert.equal(custom.maxEntries, 20_000)
|
|
102
|
+
})
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import type { AppSettings, Message } from '@/types'
|
|
3
|
+
|
|
4
|
+
export interface LlmResponseCacheConfig {
|
|
5
|
+
enabled: boolean
|
|
6
|
+
ttlMs: number
|
|
7
|
+
maxEntries: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LlmResponseCacheKeyInput {
|
|
11
|
+
provider: string
|
|
12
|
+
model: string
|
|
13
|
+
apiEndpoint?: string | null
|
|
14
|
+
systemPrompt?: string
|
|
15
|
+
message: string
|
|
16
|
+
imagePath?: string
|
|
17
|
+
imageUrl?: string
|
|
18
|
+
attachedFiles?: string[]
|
|
19
|
+
history: Message[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LlmResponseCacheHit {
|
|
23
|
+
key: string
|
|
24
|
+
text: string
|
|
25
|
+
provider: string
|
|
26
|
+
model: string
|
|
27
|
+
createdAt: number
|
|
28
|
+
ageMs: number
|
|
29
|
+
hits: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface LlmResponseCacheEntry {
|
|
33
|
+
key: string
|
|
34
|
+
text: string
|
|
35
|
+
provider: string
|
|
36
|
+
model: string
|
|
37
|
+
createdAt: number
|
|
38
|
+
expiresAt: number
|
|
39
|
+
hits: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_ENABLED = true
|
|
43
|
+
const DEFAULT_TTL_SEC = 15 * 60
|
|
44
|
+
const DEFAULT_MAX_ENTRIES = 500
|
|
45
|
+
|
|
46
|
+
const MIN_TTL_SEC = 5
|
|
47
|
+
const MAX_TTL_SEC = 7 * 24 * 3600
|
|
48
|
+
const MIN_ENTRIES = 1
|
|
49
|
+
const MAX_ENTRIES = 20_000
|
|
50
|
+
|
|
51
|
+
const responseCache = new Map<string, LlmResponseCacheEntry>()
|
|
52
|
+
|
|
53
|
+
function normalizeText(value: unknown): string {
|
|
54
|
+
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeList(value: unknown): string[] {
|
|
58
|
+
if (!Array.isArray(value)) return []
|
|
59
|
+
return value
|
|
60
|
+
.filter((entry): entry is string => typeof entry === 'string')
|
|
61
|
+
.map((entry) => entry.trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
66
|
+
const parsed = typeof value === 'number'
|
|
67
|
+
? value
|
|
68
|
+
: typeof value === 'string'
|
|
69
|
+
? Number.parseInt(value, 10)
|
|
70
|
+
: Number.NaN
|
|
71
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
72
|
+
return Math.max(min, Math.min(max, Math.trunc(parsed)))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeBool(value: unknown, fallback: boolean): boolean {
|
|
76
|
+
if (typeof value === 'boolean') return value
|
|
77
|
+
if (typeof value === 'string') {
|
|
78
|
+
const normalized = value.trim().toLowerCase()
|
|
79
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
|
80
|
+
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
|
81
|
+
}
|
|
82
|
+
return fallback
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stableStringify(value: unknown): string {
|
|
86
|
+
if (value === null) return 'null'
|
|
87
|
+
const kind = typeof value
|
|
88
|
+
if (kind === 'number' || kind === 'boolean') return JSON.stringify(value)
|
|
89
|
+
if (kind === 'string') return JSON.stringify(value)
|
|
90
|
+
if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(',')}]`
|
|
91
|
+
if (kind === 'object') {
|
|
92
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
93
|
+
.filter(([, v]) => v !== undefined)
|
|
94
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
95
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`
|
|
96
|
+
}
|
|
97
|
+
return JSON.stringify(String(value))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeHistory(history: Message[]): Array<Record<string, unknown>> {
|
|
101
|
+
return history.map((entry) => ({
|
|
102
|
+
role: entry.role,
|
|
103
|
+
text: normalizeText(entry.text),
|
|
104
|
+
kind: entry.kind || null,
|
|
105
|
+
imagePath: entry.imagePath || null,
|
|
106
|
+
imageUrl: entry.imageUrl || null,
|
|
107
|
+
attachedFiles: normalizeList(entry.attachedFiles),
|
|
108
|
+
replyToId: entry.replyToId || null,
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function trimToCapacity(maxEntries: number): void {
|
|
113
|
+
while (responseCache.size > maxEntries) {
|
|
114
|
+
const oldestKey = responseCache.keys().next().value as string | undefined
|
|
115
|
+
if (!oldestKey) break
|
|
116
|
+
responseCache.delete(oldestKey)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function moveToMostRecent(key: string, entry: LlmResponseCacheEntry): void {
|
|
121
|
+
responseCache.delete(key)
|
|
122
|
+
responseCache.set(key, entry)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolveLlmResponseCacheConfig(
|
|
126
|
+
settings?: AppSettings | Record<string, unknown> | null,
|
|
127
|
+
): LlmResponseCacheConfig {
|
|
128
|
+
const raw = settings && typeof settings === 'object' ? settings as Record<string, unknown> : {}
|
|
129
|
+
const ttlSec = normalizeInt(raw.responseCacheTtlSec, DEFAULT_TTL_SEC, MIN_TTL_SEC, MAX_TTL_SEC)
|
|
130
|
+
const maxEntries = normalizeInt(raw.responseCacheMaxEntries, DEFAULT_MAX_ENTRIES, MIN_ENTRIES, MAX_ENTRIES)
|
|
131
|
+
const enabled = normalizeBool(raw.responseCacheEnabled, DEFAULT_ENABLED)
|
|
132
|
+
return {
|
|
133
|
+
enabled,
|
|
134
|
+
ttlMs: ttlSec * 1000,
|
|
135
|
+
maxEntries,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function buildLlmResponseCacheKey(input: LlmResponseCacheKeyInput): string {
|
|
140
|
+
const payload = {
|
|
141
|
+
provider: normalizeText(input.provider).toLowerCase(),
|
|
142
|
+
model: normalizeText(input.model),
|
|
143
|
+
apiEndpoint: normalizeText(input.apiEndpoint || ''),
|
|
144
|
+
systemPrompt: normalizeText(input.systemPrompt || ''),
|
|
145
|
+
message: normalizeText(input.message),
|
|
146
|
+
imagePath: normalizeText(input.imagePath || ''),
|
|
147
|
+
imageUrl: normalizeText(input.imageUrl || ''),
|
|
148
|
+
attachedFiles: normalizeList(input.attachedFiles),
|
|
149
|
+
history: normalizeHistory(Array.isArray(input.history) ? input.history : []),
|
|
150
|
+
}
|
|
151
|
+
const stable = stableStringify(payload)
|
|
152
|
+
return crypto.createHash('sha256').update(stable).digest('hex')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function getCachedLlmResponse(
|
|
156
|
+
input: LlmResponseCacheKeyInput,
|
|
157
|
+
config: LlmResponseCacheConfig,
|
|
158
|
+
now = Date.now(),
|
|
159
|
+
): LlmResponseCacheHit | null {
|
|
160
|
+
if (!config.enabled) return null
|
|
161
|
+
const key = buildLlmResponseCacheKey(input)
|
|
162
|
+
const found = responseCache.get(key)
|
|
163
|
+
if (!found) return null
|
|
164
|
+
if (now >= found.expiresAt) {
|
|
165
|
+
responseCache.delete(key)
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
const next = { ...found, hits: found.hits + 1 }
|
|
169
|
+
moveToMostRecent(key, next)
|
|
170
|
+
return {
|
|
171
|
+
key,
|
|
172
|
+
text: next.text,
|
|
173
|
+
provider: next.provider,
|
|
174
|
+
model: next.model,
|
|
175
|
+
createdAt: next.createdAt,
|
|
176
|
+
ageMs: Math.max(0, now - next.createdAt),
|
|
177
|
+
hits: next.hits,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function setCachedLlmResponse(
|
|
182
|
+
input: LlmResponseCacheKeyInput,
|
|
183
|
+
text: string,
|
|
184
|
+
config: LlmResponseCacheConfig,
|
|
185
|
+
now = Date.now(),
|
|
186
|
+
): void {
|
|
187
|
+
if (!config.enabled) return
|
|
188
|
+
const normalizedText = normalizeText(text)
|
|
189
|
+
if (!normalizedText) return
|
|
190
|
+
const key = buildLlmResponseCacheKey(input)
|
|
191
|
+
const existing = responseCache.get(key)
|
|
192
|
+
const createdAt = existing?.createdAt ?? now
|
|
193
|
+
const entry: LlmResponseCacheEntry = {
|
|
194
|
+
key,
|
|
195
|
+
text: normalizedText,
|
|
196
|
+
provider: normalizeText(input.provider).toLowerCase(),
|
|
197
|
+
model: normalizeText(input.model),
|
|
198
|
+
createdAt,
|
|
199
|
+
expiresAt: now + config.ttlMs,
|
|
200
|
+
hits: existing?.hits ?? 0,
|
|
201
|
+
}
|
|
202
|
+
moveToMostRecent(key, entry)
|
|
203
|
+
trimToCapacity(config.maxEntries)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function getLlmResponseCacheStats(now = Date.now()): {
|
|
207
|
+
entries: number
|
|
208
|
+
expired: number
|
|
209
|
+
oldestAgeMs: number
|
|
210
|
+
} {
|
|
211
|
+
let expired = 0
|
|
212
|
+
let oldestCreatedAt = Number.POSITIVE_INFINITY
|
|
213
|
+
for (const entry of responseCache.values()) {
|
|
214
|
+
if (entry.expiresAt <= now) expired++
|
|
215
|
+
oldestCreatedAt = Math.min(oldestCreatedAt, entry.createdAt)
|
|
216
|
+
}
|
|
217
|
+
const oldestAgeMs = Number.isFinite(oldestCreatedAt) ? Math.max(0, now - oldestCreatedAt) : 0
|
|
218
|
+
return {
|
|
219
|
+
entries: responseCache.size,
|
|
220
|
+
expired,
|
|
221
|
+
oldestAgeMs,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function clearLlmResponseCache(): void {
|
|
226
|
+
responseCache.clear()
|
|
227
|
+
}
|