@swarmclawai/swarmclaw 0.6.7 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +24 -6
  2. package/package.json +1 -1
  3. package/src/app/api/agents/route.ts +1 -0
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -0
  5. package/src/app/api/eval/run/route.ts +37 -0
  6. package/src/app/api/eval/scenarios/route.ts +24 -0
  7. package/src/app/api/eval/suite/route.ts +29 -0
  8. package/src/app/api/memory/graph/route.ts +46 -0
  9. package/src/app/api/sessions/[id]/checkpoints/route.ts +31 -0
  10. package/src/app/api/sessions/[id]/restore/route.ts +36 -0
  11. package/src/app/api/souls/[id]/route.ts +65 -0
  12. package/src/app/api/souls/route.ts +70 -0
  13. package/src/app/api/tasks/[id]/route.ts +5 -0
  14. package/src/app/api/tasks/route.ts +2 -0
  15. package/src/app/api/usage/route.ts +9 -2
  16. package/src/cli/index.js +24 -0
  17. package/src/components/agents/agent-sheet.tsx +27 -6
  18. package/src/components/agents/soul-library-picker.tsx +84 -13
  19. package/src/components/chat/activity-moment.tsx +2 -0
  20. package/src/components/chat/checkpoint-timeline.tsx +112 -0
  21. package/src/components/chat/message-list.tsx +19 -3
  22. package/src/components/chat/session-debug-panel.tsx +106 -84
  23. package/src/components/chat/task-approval-card.tsx +78 -0
  24. package/src/components/chat/tool-call-bubble.tsx +3 -0
  25. package/src/components/connectors/connector-sheet.tsx +8 -1
  26. package/src/components/home/home-view.tsx +39 -15
  27. package/src/components/layout/app-layout.tsx +18 -2
  28. package/src/components/memory/memory-browser.tsx +73 -45
  29. package/src/components/memory/memory-graph-view.tsx +203 -0
  30. package/src/components/plugins/plugin-list.tsx +1 -1
  31. package/src/components/schedules/schedule-sheet.tsx +9 -2
  32. package/src/components/shared/hint-tip.tsx +31 -0
  33. package/src/components/shared/settings/section-runtime-loop.tsx +5 -4
  34. package/src/components/tasks/approvals-panel.tsx +120 -0
  35. package/src/components/usage/metrics-dashboard.tsx +25 -3
  36. package/src/lib/server/chat-execution.ts +96 -12
  37. package/src/lib/server/chatroom-helpers.ts +63 -5
  38. package/src/lib/server/chatroom-orchestration.ts +74 -0
  39. package/src/lib/server/context-manager.ts +132 -50
  40. package/src/lib/server/daemon-state.ts +70 -1
  41. package/src/lib/server/eval/runner.ts +126 -0
  42. package/src/lib/server/eval/scenarios.ts +218 -0
  43. package/src/lib/server/eval/scorer.ts +96 -0
  44. package/src/lib/server/eval/store.ts +37 -0
  45. package/src/lib/server/eval/types.ts +48 -0
  46. package/src/lib/server/execution-log.ts +12 -8
  47. package/src/lib/server/guardian.ts +34 -0
  48. package/src/lib/server/heartbeat-service.ts +53 -1
  49. package/src/lib/server/langgraph-checkpoint.ts +10 -0
  50. package/src/lib/server/link-understanding.ts +55 -0
  51. package/src/lib/server/main-agent-loop.ts +114 -15
  52. package/src/lib/server/memory-db.ts +18 -7
  53. package/src/lib/server/mmr.ts +73 -0
  54. package/src/lib/server/orchestrator-lg.ts +3 -0
  55. package/src/lib/server/plugins.ts +44 -22
  56. package/src/lib/server/query-expansion.ts +57 -0
  57. package/src/lib/server/queue.ts +27 -0
  58. package/src/lib/server/session-run-manager.ts +21 -1
  59. package/src/lib/server/session-tools/http.ts +19 -9
  60. package/src/lib/server/session-tools/index.ts +34 -0
  61. package/src/lib/server/session-tools/memory.ts +39 -11
  62. package/src/lib/server/session-tools/schedule.ts +43 -0
  63. package/src/lib/server/session-tools/web.ts +35 -11
  64. package/src/lib/server/storage.ts +12 -0
  65. package/src/lib/server/stream-agent-chat.ts +57 -8
  66. package/src/lib/server/tool-capability-policy.ts +1 -0
  67. package/src/lib/server/tool-retry.ts +62 -0
  68. package/src/lib/server/transcript-repair.ts +72 -0
  69. package/src/lib/setup-defaults.ts +1 -0
  70. package/src/lib/tool-definitions.ts +1 -0
  71. package/src/lib/validation/schemas.ts +1 -0
  72. package/src/lib/view-routes.ts +1 -0
  73. package/src/types/index.ts +34 -3
@@ -1,18 +1,21 @@
1
1
  import fs from 'fs'
2
2
  import path from 'path'
3
3
  import { createRequire } from 'module'
4
- import type { Plugin, PluginHooks, PluginMeta } from '@/types'
4
+ import type { Plugin, PluginHooks, PluginMeta, PluginToolDef } from '@/types'
5
5
 
6
6
  import { DATA_DIR } from './data-dir'
7
7
 
8
8
  const PLUGINS_DIR = path.join(DATA_DIR, 'plugins')
9
9
  const PLUGINS_CONFIG = path.join(DATA_DIR, 'plugins.json')
10
10
 
11
+ // Hook registrar: maps event names to functions that register a handler
12
+ type HookRegistrar = Record<string, (fn: (...args: unknown[]) => unknown) => void>
13
+
11
14
  // OpenClaw plugin format: { name, version, activate(ctx), deactivate() }
12
15
  interface OpenClawPlugin {
13
16
  name: string
14
17
  version?: string
15
- activate: (ctx: Record<string, (fn: (...args: any[]) => any) => void>) => void
18
+ activate: (ctx: HookRegistrar) => void
16
19
  deactivate?: () => void
17
20
  }
18
21
 
@@ -21,33 +24,39 @@ interface OpenClawPlugin {
21
24
  * Supports both SwarmClaw format ({ name, hooks }) and OpenClaw format
22
25
  * ({ name, activate(ctx) }) where activate receives event hook registrars.
23
26
  */
24
- function normalizePlugin(mod: any): Plugin | null {
25
- const raw = mod.default || mod
27
+ function normalizePlugin(mod: unknown): Plugin | null {
28
+ const modObj = mod as Record<string, unknown>
29
+ const raw: Record<string, unknown> = (modObj?.default as Record<string, unknown>) || modObj
26
30
 
27
31
  // SwarmClaw native format
28
32
  if (raw.name && raw.hooks) {
29
- return raw as Plugin
33
+ return {
34
+ name: raw.name,
35
+ description: raw.description,
36
+ hooks: raw.hooks,
37
+ tools: raw.tools,
38
+ } as Plugin
30
39
  }
31
40
 
32
41
  // OpenClaw format: { name, activate(ctx), deactivate() }
33
42
  if (raw.name && typeof raw.activate === 'function') {
34
- const oc = raw as OpenClawPlugin
43
+ const oc = raw as unknown as OpenClawPlugin
35
44
  const hooks: PluginHooks = {}
36
45
 
37
46
  // OpenClaw's activate receives an object of hook registrars.
38
47
  // Map OpenClaw lifecycle names to SwarmClaw hook names.
39
- const registrar: Record<string, (fn: (...args: any[]) => any) => void> = {
40
- onAgentStart: (fn) => { hooks.beforeAgentStart = fn },
41
- onAgentComplete: (fn) => { hooks.afterAgentComplete = fn },
42
- onToolCall: (fn) => { hooks.beforeToolExec = fn },
43
- onToolResult: (fn) => { hooks.afterToolExec = fn },
44
- onMessage: (fn) => { hooks.onMessage = fn },
48
+ const registrar: HookRegistrar = {
49
+ onAgentStart: (fn) => { hooks.beforeAgentStart = fn as PluginHooks['beforeAgentStart'] },
50
+ onAgentComplete: (fn) => { hooks.afterAgentComplete = fn as PluginHooks['afterAgentComplete'] },
51
+ onToolCall: (fn) => { hooks.beforeToolExec = fn as PluginHooks['beforeToolExec'] },
52
+ onToolResult: (fn) => { hooks.afterToolExec = fn as PluginHooks['afterToolExec'] },
53
+ onMessage: (fn) => { hooks.onMessage = fn as PluginHooks['onMessage'] },
45
54
  }
46
55
 
47
56
  try {
48
57
  oc.activate(registrar)
49
- } catch (err: any) {
50
- console.error(`[plugins] OpenClaw activate() failed for ${oc.name}:`, err.message)
58
+ } catch (err: unknown) {
59
+ console.error(`[plugins] OpenClaw activate() failed for ${oc.name}:`, err instanceof Error ? err.message : String(err))
51
60
  return null
52
61
  }
53
62
 
@@ -68,9 +77,10 @@ if (!fs.existsSync(PLUGINS_CONFIG)) fs.writeFileSync(PLUGINS_CONFIG, '{}')
68
77
  // Use createRequire to avoid Turbopack static analysis of require()
69
78
  const dynamicRequire = createRequire(import.meta.url || __filename)
70
79
 
71
- interface LoadedPlugin {
80
+ interface LoadedPlugin {
72
81
  meta: PluginMeta
73
82
  hooks: PluginHooks
83
+ tools?: PluginToolDef[]
74
84
  }
75
85
 
76
86
  class PluginManager {
@@ -112,11 +122,12 @@ class PluginManager {
112
122
  enabled: true,
113
123
  },
114
124
  hooks: plugin.hooks,
125
+ tools: plugin.tools,
115
126
  })
116
127
  console.log(`[plugins] Loaded: ${plugin.name} (${file})`)
117
128
  }
118
- } catch (err: any) {
119
- console.error(`[plugins] Failed to load ${file}:`, err.message)
129
+ } catch (err: unknown) {
130
+ console.error(`[plugins] Failed to load ${file}:`, err instanceof Error ? err.message : String(err))
120
131
  }
121
132
  }
122
133
  } catch {
@@ -126,6 +137,17 @@ class PluginManager {
126
137
  this.loaded = true
127
138
  }
128
139
 
140
+ getPluginTools(): PluginToolDef[] {
141
+ this.load()
142
+ const allTools: PluginToolDef[] = []
143
+ for (const plugin of this.plugins) {
144
+ if (plugin.tools && Array.isArray(plugin.tools)) {
145
+ allTools.push(...plugin.tools)
146
+ }
147
+ }
148
+ return allTools
149
+ }
150
+
129
151
  async runHook<K extends keyof PluginHooks>(
130
152
  hookName: K,
131
153
  ctx: Parameters<NonNullable<PluginHooks[K]>>[0],
@@ -135,9 +157,9 @@ class PluginManager {
135
157
  const hook = plugin.hooks[hookName]
136
158
  if (hook) {
137
159
  try {
138
- await (hook as any)(ctx)
139
- } catch (err: any) {
140
- console.error(`[plugins] Error in ${plugin.meta.name}.${hookName}:`, err.message)
160
+ await (hook as (ctx: Parameters<NonNullable<PluginHooks[K]>>[0]) => Promise<void> | void)(ctx)
161
+ } catch (err: unknown) {
162
+ console.error(`[plugins] Error in ${plugin.meta.name}.${hookName}:`, err instanceof Error ? err.message : String(err))
141
163
  }
142
164
  }
143
165
  }
@@ -198,8 +220,8 @@ class PluginManager {
198
220
  fs.writeFileSync(path.join(PLUGINS_DIR, sanitized), code, 'utf8')
199
221
  this.reload()
200
222
  return { ok: true }
201
- } catch (err: any) {
202
- return { ok: false, error: err.message }
223
+ } catch (err: unknown) {
224
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
203
225
  }
204
226
  }
205
227
 
@@ -0,0 +1,57 @@
1
+ import { loadAgents, loadSettings, loadCredentials, decryptKey } from './storage'
2
+ import { getProvider } from '../providers'
3
+
4
+ /**
5
+ * Expands a single user query into multiple semantic search variants
6
+ * to improve vector database recall (OpenClaw-style).
7
+ */
8
+ export async function expandQuery(query: string): Promise<string[]> {
9
+ const agents = loadAgents()
10
+ const settings = loadSettings()
11
+ const defaultAgent = agents[settings.defaultAgentId]
12
+ if (!defaultAgent) return [query]
13
+
14
+ const providerEntry = getProvider(defaultAgent.provider)
15
+ if (!providerEntry?.handler?.streamChat) return [query]
16
+
17
+ const creds = loadCredentials()
18
+ const cred = creds[defaultAgent.credentialId || '']
19
+ const apiKey = cred ? decryptKey(cred.encryptedKey) : undefined
20
+
21
+ const systemPrompt = `You are a search query expansion assistant.
22
+ Given a user's question, generate 3 different semantic search queries that would help find the answer in a vector database.
23
+ Use different vocabulary and focus on different aspects of the intent.
24
+ Format your response as a simple newline-separated list. No numbering, no bullets, no introduction.`
25
+
26
+ let expanded = ''
27
+ try {
28
+ await providerEntry.handler.streamChat({
29
+ session: { id: 'expansion', messages: [], model: defaultAgent.model, provider: defaultAgent.provider },
30
+ message: query,
31
+ apiKey,
32
+ systemPrompt,
33
+ write: (raw: string) => {
34
+ const lines = raw.split('\n').filter(Boolean)
35
+ for (const line of lines) {
36
+ if (!line.startsWith('data: ')) continue
37
+ try {
38
+ const ev = JSON.parse(line.slice(6))
39
+ if (ev.t === 'd' && ev.text) expanded += ev.text
40
+ } catch { /* skip */ }
41
+ }
42
+ },
43
+ active: new Map(),
44
+ loadHistory: () => [],
45
+ })
46
+
47
+ const variants = expanded.split('\n').map(l => l.trim()).filter(Boolean)
48
+ if (variants.length > 0) {
49
+ // Return original query + variants
50
+ return [query, ...variants.slice(0, 3)]
51
+ }
52
+ } catch (err) {
53
+ console.error('[query-expansion] Failed to expand query:', err)
54
+ }
55
+
56
+ return [query]
57
+ }
@@ -13,6 +13,7 @@ import { extractTaskResult, formatResultBody } from './task-result'
13
13
  import { getCheckpointSaver } from './langgraph-checkpoint'
14
14
  import { isProtectedMainSession } from './main-session'
15
15
  import { cascadeUnblock } from './dag-validation'
16
+ import { performGuardianRollback } from './guardian'
16
17
  import type { Agent, BoardTask, Connector, Message } from '@/types'
17
18
 
18
19
  // HMR-safe: pin processing flag to globalThis so hot reloads don't reset it
@@ -802,6 +803,32 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
802
803
  text: `Task moved to dead-letter after ${task.attempts}/${task.maxAttempts} attempts.\n\nReason: ${reason}`,
803
804
  createdAt: now,
804
805
  })
806
+
807
+ // Guardian Auto-Rollback
808
+ const agents = loadAgents()
809
+ const agent = task.agentId ? agents[task.agentId] : null
810
+ if (agent?.autoRecovery) {
811
+ const cwd = task.projectId
812
+ ? path.join(WORKSPACE_DIR, 'projects', task.projectId)
813
+ : WORKSPACE_DIR
814
+ const rollback = performGuardianRollback(cwd)
815
+ if (rollback.ok) {
816
+ task.comments.push({
817
+ id: genId(),
818
+ author: 'Guardian',
819
+ text: `Auto-recovery triggered: Workspace successfully rolled back to last clean state.`,
820
+ createdAt: now + 1,
821
+ })
822
+ } else {
823
+ task.comments.push({
824
+ id: genId(),
825
+ author: 'Guardian',
826
+ text: `Auto-recovery failed: ${rollback.reason}`,
827
+ createdAt: now + 1,
828
+ })
829
+ }
830
+ }
831
+
805
832
  return 'dead_lettered'
806
833
  }
807
834
 
@@ -271,6 +271,9 @@ async function drainExecution(executionKey: string): Promise<void> {
271
271
  resultText: result.text,
272
272
  error: result.error,
273
273
  toolEvents: result.toolEvents,
274
+ inputTokens: result.inputTokens,
275
+ outputTokens: result.outputTokens,
276
+ estimatedCost: result.estimatedCost,
274
277
  })
275
278
  } catch (mainLoopErr: any) {
276
279
  log.warn('session-run', `Main-loop update failed for ${next.run.id}`, mainLoopErr?.message || String(mainLoopErr))
@@ -372,6 +375,19 @@ export interface EnqueueSessionRunResult {
372
375
  abort: () => void
373
376
  }
374
377
 
378
+ const LONG_TOOL_NAMES: ReadonlySet<string> = new Set(['claude_code', 'codex_cli', 'opencode_cli'])
379
+
380
+ function computeEffectiveRunTimeoutMs(
381
+ baseTimeoutMs: number,
382
+ sessionTools: string[],
383
+ runtime: { claudeCodeTimeoutMs: number },
384
+ ): number {
385
+ const hasLongTool = sessionTools.some(t => LONG_TOOL_NAMES.has(t))
386
+ if (!hasLongTool) return baseTimeoutMs
387
+ const toolTimeout = runtime.claudeCodeTimeoutMs + 120_000
388
+ return Math.max(baseTimeoutMs, toolTimeout)
389
+ }
390
+
375
391
  export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSessionRunResult {
376
392
  const internal = input.internal === true
377
393
  const mode = normalizeMode(input.mode, internal)
@@ -379,9 +395,13 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
379
395
  const executionKey = executionKeyForSession(input.sessionId)
380
396
  const runtime = loadRuntimeSettings()
381
397
  const defaultMaxRuntimeMs = runtime.ongoingLoopMaxRuntimeMs ?? (10 * 60_000)
398
+ const sessions = loadSessions()
399
+ const sessionData = sessions[input.sessionId]
400
+ const sessionTools: string[] = sessionData?.tools || []
401
+ const adjustedDefaultMs = computeEffectiveRunTimeoutMs(defaultMaxRuntimeMs, sessionTools, runtime)
382
402
  const effectiveMaxRuntimeMs = typeof input.maxRuntimeMs === 'number'
383
403
  ? input.maxRuntimeMs
384
- : defaultMaxRuntimeMs
404
+ : adjustedDefaultMs
385
405
 
386
406
  const dedupe = findDedupeMatch(input.sessionId, input.dedupeKey)
387
407
  if (dedupe) {
@@ -2,27 +2,37 @@ import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
3
  import type { ToolBuildContext } from './context'
4
4
  import { truncate, MAX_OUTPUT } from './context'
5
+ import { withRetry } from '../tool-retry'
6
+
7
+ interface HttpRequestArgs {
8
+ method: string
9
+ url: string
10
+ headers?: Record<string, string>
11
+ body?: string
12
+ timeoutSec?: number
13
+ followRedirects?: boolean
14
+ }
5
15
 
6
16
  export function buildHttpTools(bctx: ToolBuildContext): StructuredToolInterface[] {
7
17
  if (!bctx.hasTool('http_request')) return []
8
18
 
9
19
  return [
10
20
  tool(
11
- async ({ method, url, headers, body, timeoutSec, followRedirects }) => {
21
+ (args: HttpRequestArgs) => withRetry(async (_a: HttpRequestArgs) => {
12
22
  try {
13
- const timeout = Math.max(1, Math.min(timeoutSec ?? 30, 120)) * 1000
23
+ const timeout = Math.max(1, Math.min(_a.timeoutSec ?? 30, 120)) * 1000
14
24
  const init: RequestInit = {
15
- method,
16
- headers: (headers ?? undefined) as Record<string, string> | undefined,
25
+ method: _a.method,
26
+ headers: (_a.headers ?? undefined) as Record<string, string> | undefined,
17
27
  signal: AbortSignal.timeout(timeout),
18
28
  }
19
- if (body && method !== 'GET' && method !== 'HEAD') {
20
- init.body = body
29
+ if (_a.body && _a.method !== 'GET' && _a.method !== 'HEAD') {
30
+ init.body = _a.body
21
31
  }
22
- if (followRedirects === false) {
32
+ if (_a.followRedirects === false) {
23
33
  init.redirect = 'manual'
24
34
  }
25
- const res = await fetch(url, init)
35
+ const res = await fetch(_a.url, init)
26
36
  const resHeaders: Record<string, string> = {}
27
37
  for (const key of ['content-type', 'location', 'x-request-id', 'retry-after', 'content-length']) {
28
38
  const val = res.headers.get(key)
@@ -39,7 +49,7 @@ export function buildHttpTools(bctx: ToolBuildContext): StructuredToolInterface[
39
49
  } catch (err: unknown) {
40
50
  return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
41
51
  }
42
- },
52
+ }, args),
43
53
  {
44
54
  name: 'http_request',
45
55
  description: 'Make an HTTP API request. Supports all methods, custom headers, and request bodies. Returns status, headers, and body.',
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
+ import type { Session } from '@/types'
3
4
  import { loadSettings, loadSessions, saveSessions, loadMcpServers } from '../storage'
4
5
  import { loadRuntimeSettings } from '../runtime-settings'
5
6
  import { log } from '../logger'
@@ -23,6 +24,9 @@ import { buildHttpTools } from './http'
23
24
  import { buildGitTools } from './git'
24
25
  import { buildWalletTools } from './wallet'
25
26
  import { buildOpenClawWorkspaceTools } from './openclaw-workspace'
27
+ import { buildScheduleTools } from './schedule'
28
+ import { getPluginManager } from '../plugins'
29
+ import { jsonSchemaToZod } from '../mcp-client'
26
30
 
27
31
  export type { ToolContext, SessionToolsResult }
28
32
  export { sweepOrphanedBrowsers, cleanupSessionBrowser, getActiveBrowserCount, hasActiveBrowser }
@@ -113,6 +117,7 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
113
117
  ...buildGitTools(bctx),
114
118
  ...buildWalletTools(bctx),
115
119
  ...buildOpenClawWorkspaceTools(bctx),
120
+ ...buildScheduleTools(bctx),
116
121
  )
117
122
 
118
123
  // ---------------------------------------------------------------------------
@@ -151,6 +156,35 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
151
156
  })
152
157
  }
153
158
 
159
+ // ---------------------------------------------------------------------------
160
+ // Plugin tools — native tools provided by SwarmClaw plugins
161
+ // ---------------------------------------------------------------------------
162
+ try {
163
+ const pluginTools = getPluginManager().getPluginTools()
164
+ for (const pt of pluginTools) {
165
+ if (!disabledMcpToolNames.has(pt.name)) {
166
+ tools.push(
167
+ tool(
168
+ async (args) => {
169
+ const res = await pt.execute(args as Record<string, unknown>, {
170
+ session: { id: ctx?.sessionId || 'unknown', agentId: ctx?.agentId } as Session,
171
+ message: '',
172
+ })
173
+ return typeof res === 'string' ? res : JSON.stringify(res)
174
+ },
175
+ {
176
+ name: pt.name,
177
+ description: pt.description,
178
+ schema: jsonSchemaToZod(pt.parameters),
179
+ }
180
+ )
181
+ )
182
+ }
183
+ }
184
+ } catch (err: unknown) {
185
+ log.error('session-tools', 'Failed to load plugin tools', { error: err instanceof Error ? err.message : String(err) })
186
+ }
187
+
154
188
  // request_tool_access: always available
155
189
  tools.push(
156
190
  tool(
@@ -4,6 +4,8 @@ import fs from 'fs'
4
4
  import { genId } from '@/lib/id'
5
5
  import { getMemoryDb, getMemoryLookupLimits, storeMemoryImageAsset } from '../memory-db'
6
6
  import { loadSettings } from '../storage'
7
+ import { expandQuery } from '../query-expansion'
8
+ import type { MemoryEntry } from '@/types'
7
9
  import type { ToolBuildContext } from './context'
8
10
 
9
11
  export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterface[] {
@@ -20,8 +22,9 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
20
22
  try {
21
23
  const scopeMode = scope || 'auto'
22
24
  const currentAgentId = ctx?.agentId || null
23
- const canAccessMemory = (m: any) => !m?.agentId || m.agentId === currentAgentId
24
- const filterScope = (rows: any[]) => {
25
+ const canAccessMemory = (m: MemoryEntry) => !m?.agentId || m.agentId === currentAgentId
26
+ const filterScope = (rows: MemoryEntry[]) => {
27
+ if (scopeMode === 'all') return rows
25
28
  if (scopeMode === 'shared') return rows.filter((m) => !m.agentId)
26
29
  if (scopeMode === 'agent') return rows.filter((m) => currentAgentId && m.agentId === currentAgentId)
27
30
  return rows.filter(canAccessMemory)
@@ -126,17 +129,42 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
126
129
  return formatEntry(found)
127
130
  }
128
131
  if (action === 'search') {
132
+ const queries = query ? await expandQuery(query) : [key || '']
133
+
129
134
  if (effectiveDepth > 0) {
130
- const result = memDb.searchWithLinked(query || key, undefined, effectiveDepth, maxPerLookup, effectiveLinkedLimit)
131
- const accessible = filterScope(result.entries)
135
+ const allResults: MemoryEntry[] = []
136
+ const seenIds = new Set<string>()
137
+ let anyTruncated = false
138
+ for (const q of queries) {
139
+ const result = memDb.searchWithLinked(q, undefined, effectiveDepth, maxPerLookup, effectiveLinkedLimit)
140
+ if (result.truncated) anyTruncated = true
141
+ for (const r of result.entries) {
142
+ if (!seenIds.has(r.id)) {
143
+ seenIds.add(r.id)
144
+ allResults.push(r)
145
+ }
146
+ }
147
+ }
148
+ const accessible = filterScope(allResults)
132
149
  if (!accessible.length) return 'No memories found.'
133
- let output = accessible.map(formatEntry).join('\n')
134
- if (result.truncated) output += `\n\n[Results truncated at ${maxPerLookup} memories / ${effectiveLinkedLimit} linked expansions]`
150
+ let output = accessible.slice(0, maxPerLookup).map(formatEntry).join('\n')
151
+ if (anyTruncated) output += `\n\n[Results truncated at ${maxPerLookup} memories / ${effectiveLinkedLimit} linked expansions]`
135
152
  return output
136
153
  }
137
- const results = filterScope(memDb.search(query || key))
138
- if (!results.length) return 'No memories found.'
139
- return results.slice(0, maxPerLookup).map(formatEntry).join('\n')
154
+
155
+ const allResults: MemoryEntry[] = []
156
+ const seenIds = new Set<string>()
157
+ for (const q of queries) {
158
+ const results = filterScope(memDb.search(q))
159
+ for (const r of results) {
160
+ if (!seenIds.has(r.id)) {
161
+ seenIds.add(r.id)
162
+ allResults.push(r)
163
+ }
164
+ }
165
+ }
166
+ if (!allResults.length) return 'No memories found.'
167
+ return allResults.slice(0, maxPerLookup).map(formatEntry).join('\n')
140
168
  }
141
169
  if (action === 'list') {
142
170
  const results = filterScope(memDb.list(undefined, maxPerLookup))
@@ -204,14 +232,14 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
204
232
  },
205
233
  {
206
234
  name: 'memory_tool',
207
- description: 'My long-term memory — things I remember across conversations. I can store personal notes, recall past context, and build up knowledge over time. Memories can be private to me or shared with other agents. I can also attach files, link related memories, and contribute to a shared knowledge base. Actions: store, get, search, list, delete, link, unlink, knowledge_store, knowledge_search.',
235
+ description: `My long-term memory — things I remember across conversations. I can store personal notes, recall past context, and build up knowledge over time. Memories can be private to me or shared with other agents. I can also attach files, link related memories, and contribute to a shared knowledge base. Use \`scope: 'all'\` to search memories across all agents (useful when you need context from other agents' work).${bctx.hasTool('manage_agents') || bctx.hasTool('manage_sessions') ? ' As an orchestrator, cross-agent search with scope=all is especially useful for gathering context from sub-agents.' : ''} Actions: store, get, search, list, delete, link, unlink, knowledge_store, knowledge_search.`,
208
236
  schema: z.object({
209
237
  action: z.enum(['store', 'get', 'search', 'list', 'delete', 'link', 'unlink', 'knowledge_store', 'knowledge_search']).describe('The action to perform'),
210
238
  key: z.string().describe('For store: memory title. For get/delete/link/unlink: memory ID. For search: optional query fallback.'),
211
239
  value: z.string().optional().describe('The memory content (for store action)'),
212
240
  category: z.string().optional().describe('Category like "note", "fact", "preference", "project", "identity" (for store action, defaults to "note")'),
213
241
  query: z.string().optional().describe('Search query (alternative to key for search action)'),
214
- scope: z.enum(['auto', 'shared', 'agent']).optional().describe('Scope hint: auto (shared + own), shared, or agent'),
242
+ scope: z.enum(['auto', 'shared', 'agent', 'all']).optional().describe('Scope hint: auto (shared + own), shared (shared only), agent (own only), or all (every agent — cross-agent search)'),
215
243
  filePaths: z.array(z.object({
216
244
  path: z.string().describe('File or folder path'),
217
245
  contextSnippet: z.string().optional().describe('Brief context about this file reference'),
@@ -0,0 +1,43 @@
1
+ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
2
+ import { z } from 'zod'
3
+ import { enqueueSystemEvent } from '../system-events'
4
+ import { requestHeartbeatNow } from '../heartbeat-wake'
5
+ import type { ToolBuildContext } from './context'
6
+
7
+ export function buildScheduleTools(bctx: ToolBuildContext): StructuredToolInterface[] {
8
+ const tools: StructuredToolInterface[] = []
9
+ const { ctx, hasTool } = bctx
10
+
11
+ if (hasTool('schedule_wake')) {
12
+ tools.push(
13
+ tool(
14
+ async ({ delayMinutes, message }) => {
15
+ if (!ctx?.sessionId) return 'Cannot schedule wake: no session context.'
16
+ if (delayMinutes <= 0 || delayMinutes > 1440) return 'delayMinutes must be between 1 and 1440 (24 hours).'
17
+
18
+ // Non-durable in-memory timeout for conversational wake events
19
+ // (For durable cron, use manage_schedules)
20
+ const delayMs = delayMinutes * 60 * 1000
21
+ setTimeout(() => {
22
+ if (ctx.sessionId) {
23
+ enqueueSystemEvent(ctx.sessionId, `[Scheduled Wake Event / Reminder] ${message}`)
24
+ requestHeartbeatNow({ sessionId: ctx.sessionId, reason: 'scheduled_wake' })
25
+ }
26
+ }, delayMs)
27
+
28
+ return `Successfully scheduled a wake event in ${delayMinutes} minutes with message: "${message}".`
29
+ },
30
+ {
31
+ name: 'schedule_wake',
32
+ description: 'Schedule a wake event (reminder) for yourself in this chatroom. Use this to proactively check back on a long-running process or to remind yourself to follow up with the user later.',
33
+ schema: z.object({
34
+ delayMinutes: z.number().describe('How many minutes from now to wake up (1-1440).'),
35
+ message: z.string().describe('The reminder text that will be passed back to you when you wake.'),
36
+ }),
37
+ },
38
+ ),
39
+ )
40
+ }
41
+
42
+ return tools
43
+ }
@@ -9,6 +9,7 @@ import { spawnSync } from 'child_process'
9
9
  import { safePath, truncate, MAX_OUTPUT, findBinaryOnPath } from './context'
10
10
  import { getSearchProvider } from './search-providers'
11
11
  import { dedupeScreenshotMarkdownLines } from './web-output'
12
+ import { withRetry } from '../tool-retry'
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Search result compression — summarize verbose results before injecting into context
@@ -32,7 +33,7 @@ async function compressSearchResults(
32
33
  if (session.credentialId) {
33
34
  const creds = loadCredentials()
34
35
  const cred = creds[session.credentialId]
35
- if (cred) apiKey = decryptKey(cred)
36
+ if (cred) apiKey = decryptKey(cred.encryptedKey)
36
37
  }
37
38
 
38
39
  const systemPrompt = 'You are a search result summarizer. Condense search results into a concise reference. Keep key facts, URLs, and data points. Remove filler and redundancy. Output plain text, not JSON.'
@@ -116,19 +117,19 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
116
117
  if (bctx.hasTool('web_search')) {
117
118
  tools.push(
118
119
  tool(
119
- async ({ query, maxResults }) => {
120
+ ({ query, maxResults }) => withRetry(async (_args: { query: string; maxResults?: number }) => {
120
121
  try {
121
- const limit = Math.min(maxResults || 5, 10)
122
+ const limit = Math.min(_args.maxResults || 5, 10)
122
123
  const { loadSettings } = await import('../storage')
123
124
  const settings = loadSettings()
124
125
  const provider = await getSearchProvider(settings)
125
- const results = await provider.search(query, limit)
126
+ const results = await provider.search(_args.query, limit)
126
127
  if (results.length === 0) return 'No results found.'
127
128
  const raw = JSON.stringify(results, null, 2)
128
129
  // Compress search results if they exceed 2000 chars
129
130
  if (raw.length > 2000) {
130
131
  try {
131
- const compressed = await compressSearchResults(results, query, bctx)
132
+ const compressed = await compressSearchResults(results, _args.query, bctx)
132
133
  if (compressed) return compressed
133
134
  } catch {
134
135
  // Compression failed — fall through to raw results
@@ -138,7 +139,7 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
138
139
  } catch (err: unknown) {
139
140
  return `Error searching web: ${err instanceof Error ? err.message : String(err)}`
140
141
  }
141
- },
142
+ }, { query, maxResults }),
142
143
  {
143
144
  name: 'web_search',
144
145
  description: 'Search the web for information. Returns results with title, url, and snippet.',
@@ -156,30 +157,53 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
156
157
  if (bctx.hasTool('web_fetch')) {
157
158
  tools.push(
158
159
  tool(
159
- async ({ url }) => {
160
+ ({ url }) => withRetry(async (_args: { url: string }) => {
160
161
  try {
161
- const res = await fetch(url, {
162
+ const res = await fetch(_args.url, {
162
163
  headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SwarmClaw/1.0)' },
163
164
  signal: AbortSignal.timeout(15000),
164
165
  })
165
166
  if (!res.ok) return `HTTP ${res.status}: ${res.statusText}`
167
+
168
+ const contentType = res.headers.get('content-type') || ''
169
+ if (contentType.includes('application/pdf')) {
170
+ try {
171
+ // @ts-expect-error pdf-parse has no type declarations
172
+ const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
173
+ const arrayBuffer = await res.arrayBuffer()
174
+ const result = await pdfParse(Buffer.from(arrayBuffer))
175
+ return truncate(result.text, MAX_OUTPUT)
176
+ } catch (err: unknown) {
177
+ return `Error parsing PDF: ${err instanceof Error ? err.message : String(err)}`
178
+ }
179
+ }
180
+
166
181
  const html = await res.text()
182
+
183
+ // Basic YouTube extraction (title and description)
184
+ if (_args.url.includes('youtube.com/watch') || _args.url.includes('youtu.be/')) {
185
+ const $ = cheerio.load(html)
186
+ const title = $('meta[property="og:title"]').attr('content') || $('title').text()
187
+ const description = $('meta[property="og:description"]').attr('content') || ''
188
+ return truncate(`YouTube Video: ${title}\n\nDescription:\n${description}\n\n(Transcript extraction requires a specialized tool or API)`, MAX_OUTPUT)
189
+ }
190
+
167
191
  // Use cheerio for robust HTML text extraction
168
192
  const $ = cheerio.load(html)
169
193
  $('script, style, noscript, nav, footer, header').remove()
170
194
  // Prefer article/main content if available
171
195
  const main = $('article, main, [role="main"]').first()
172
- let text = (main.length ? main.text() : $('body').text())
196
+ const text = (main.length ? main.text() : $('body').text())
173
197
  .replace(/\s+/g, ' ')
174
198
  .trim()
175
199
  return truncate(text, MAX_OUTPUT)
176
200
  } catch (err: unknown) {
177
201
  return `Error fetching URL: ${err instanceof Error ? err.message : String(err)}`
178
202
  }
179
- },
203
+ }, { url }),
180
204
  {
181
205
  name: 'web_fetch',
182
- description: 'Fetch a URL and read its content (HTML stripped to text). How I read web pages and pull in external information.',
206
+ description: 'Fetch a URL and read its content. Supports HTML web pages and direct PDF links. Provides basic metadata for YouTube videos.',
183
207
  schema: z.object({
184
208
  url: z.string().describe('The URL to fetch'),
185
209
  }),