@swarmclawai/swarmclaw 1.5.63 → 1.5.64
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 +17 -0
- package/package.json +2 -2
- package/src/app/api/chats/[id]/clear/route.ts +7 -3
- package/src/app/api/chats/[id]/clear/undo/route.ts +23 -0
- package/src/app/api/chats/[id]/compact/route.ts +72 -0
- package/src/app/api/chats/[id]/context-status/route.ts +21 -0
- package/src/app/api/chats/clear-route.test.ts +121 -0
- package/src/app/api/chats/compact-route.test.ts +70 -0
- package/src/app/api/chats/context-status-route.test.ts +68 -0
- package/src/app/api/mcp-servers/[id]/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
- package/src/cli/index.js +5 -1
- package/src/cli/spec.js +4 -1
- package/src/components/chat/chat-area.tsx +62 -6
- package/src/components/chat/chat-header.tsx +13 -1
- package/src/components/chat/context-meter-badge.tsx +227 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +56 -0
- package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
- package/src/components/mcp-servers/registry-browser.tsx +224 -0
- package/src/lib/chat/chats.ts +37 -1
- package/src/lib/server/chats/chat-session-service.ts +75 -0
- package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
- package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
- package/src/lib/server/mcp-connection-pool.test.ts +98 -0
- package/src/lib/server/mcp-connection-pool.ts +134 -0
- package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
- package/src/lib/server/mcp-gateway-runtime.ts +138 -0
- package/src/lib/server/session-tools/index.ts +83 -15
- package/src/lib/server/storage-normalization.ts +11 -0
- package/src/types/agent.ts +1 -0
- package/src/types/misc.ts +7 -0
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
4
|
import { useAppStore } from '@/stores/use-app-store'
|
|
5
5
|
import { BottomSheet } from '@/components/shared/bottom-sheet'
|
|
6
6
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog'
|
|
7
7
|
import { HintTip } from '@/components/shared/hint-tip'
|
|
8
|
+
import { AdvancedSettingsSection } from '@/components/shared/advanced-settings-section'
|
|
8
9
|
import { api } from '@/lib/app/api-client'
|
|
9
10
|
import { toast } from 'sonner'
|
|
10
11
|
import type { McpServerConfig, McpTransport } from '@/types'
|
|
11
12
|
import { useMountedRef } from '@/hooks/use-mounted-ref'
|
|
13
|
+
import { RegistryBrowser, type RegistryPrefill } from './registry-browser'
|
|
12
14
|
|
|
13
15
|
interface McpPreset {
|
|
14
16
|
id: string
|
|
@@ -39,6 +41,18 @@ const MCP_PRESETS: McpPreset[] = [
|
|
|
39
41
|
cwdHint: 'Absolute path to a SwarmVault workspace (the directory containing swarmvault.config.json). Run `npx @swarmvaultai/cli init` there first if you haven\'t.',
|
|
40
42
|
defaultName: 'SwarmVault',
|
|
41
43
|
},
|
|
44
|
+
{
|
|
45
|
+
id: 'mcp-gateway',
|
|
46
|
+
label: 'MCP Gateway (local)',
|
|
47
|
+
description: 'Consolidate many MCP servers behind one entry. The gateway fans out to your downstream servers, namespaces their tools, and only exposes the ones you pre-load — big token savings when you run more than a handful of MCP servers.',
|
|
48
|
+
helpUrl: 'https://github.com/swarmclawai/mcp-gateway',
|
|
49
|
+
transport: 'stdio',
|
|
50
|
+
command: 'npx',
|
|
51
|
+
args: ['-y', '@swarmclawai/mcp-gateway@latest', 'start'],
|
|
52
|
+
needsCwd: true,
|
|
53
|
+
cwdHint: 'Absolute path to a directory containing mcp-gateway.config.json. Run `npx @swarmclawai/mcp-gateway init --write` there first to generate a starter config.',
|
|
54
|
+
defaultName: 'MCP Gateway',
|
|
55
|
+
},
|
|
42
56
|
{
|
|
43
57
|
id: 'swarmdock',
|
|
44
58
|
label: 'SwarmDock',
|
|
@@ -65,6 +79,7 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
65
79
|
const [args, setArgs] = useState(editing?.args?.join(', ') || '')
|
|
66
80
|
const [cwd, setCwd] = useState(editing?.cwd || '')
|
|
67
81
|
const [activePresetId, setActivePresetId] = useState<string | null>(null)
|
|
82
|
+
const [registryBrowserOpen, setRegistryBrowserOpen] = useState(false)
|
|
68
83
|
const [url, setUrl] = useState(editing?.url || '')
|
|
69
84
|
const [envText, setEnvText] = useState(
|
|
70
85
|
editing?.env ? Object.entries(editing.env).map(([k, v]) => `${k}=${v}`).join('\n') : '',
|
|
@@ -72,11 +87,58 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
72
87
|
const [headersText, setHeadersText] = useState(
|
|
73
88
|
editing?.headers ? Object.entries(editing.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '',
|
|
74
89
|
)
|
|
90
|
+
const initialExposureMode: 'all' | 'lazy' | 'selected' =
|
|
91
|
+
editing === null || editing?.alwaysExpose === undefined || editing.alwaysExpose === true
|
|
92
|
+
? 'all'
|
|
93
|
+
: editing.alwaysExpose === false
|
|
94
|
+
? 'lazy'
|
|
95
|
+
: 'selected'
|
|
96
|
+
const [exposureMode, setExposureMode] = useState<'all' | 'lazy' | 'selected'>(initialExposureMode)
|
|
97
|
+
const [exposureAllowlistText, setExposureAllowlistText] = useState(
|
|
98
|
+
Array.isArray(editing?.alwaysExpose) ? editing.alwaysExpose.join(', ') : '',
|
|
99
|
+
)
|
|
100
|
+
const [advancedOpen, setAdvancedOpen] = useState(initialExposureMode !== 'all')
|
|
101
|
+
const [discoveredTools, setDiscoveredTools] = useState<Array<{ name: string; description?: string; tokens: number }> | null>(null)
|
|
102
|
+
const [discoveredLoading, setDiscoveredLoading] = useState(false)
|
|
103
|
+
const [discoveredError, setDiscoveredError] = useState<string | null>(null)
|
|
75
104
|
const [testing, setTesting] = useState(false)
|
|
76
105
|
const [testResult, setTestResult] = useState<{ ok: boolean; tools?: string[]; error?: string } | null>(null)
|
|
77
106
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
78
107
|
const [deleting, setDeleting] = useState(false)
|
|
79
108
|
|
|
109
|
+
// Lazily load discovered tools when the user picks the allow-list mode
|
|
110
|
+
// for an existing (edited) server. Only hits the server once per sheet open.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!editing || exposureMode !== 'selected' || discoveredTools || discoveredLoading) return
|
|
113
|
+
let cancelled = false
|
|
114
|
+
setDiscoveredLoading(true)
|
|
115
|
+
setDiscoveredError(null)
|
|
116
|
+
void (async () => {
|
|
117
|
+
try {
|
|
118
|
+
const res = await api<{ tools: Array<{ name: string; description?: string; tokens: number }> }>(
|
|
119
|
+
'GET',
|
|
120
|
+
`/mcp-servers/${editing.id}/tools-info`,
|
|
121
|
+
)
|
|
122
|
+
if (cancelled) return
|
|
123
|
+
setDiscoveredTools(res.tools)
|
|
124
|
+
} catch (err: unknown) {
|
|
125
|
+
if (cancelled) return
|
|
126
|
+
setDiscoveredError(err instanceof Error ? err.message : 'Failed to load tools')
|
|
127
|
+
} finally {
|
|
128
|
+
if (!cancelled) setDiscoveredLoading(false)
|
|
129
|
+
}
|
|
130
|
+
})()
|
|
131
|
+
return () => { cancelled = true }
|
|
132
|
+
}, [editing, exposureMode, discoveredTools, discoveredLoading])
|
|
133
|
+
|
|
134
|
+
const toggleAllowlistTool = (toolName: string) => {
|
|
135
|
+
const current = exposureAllowlistText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean)
|
|
136
|
+
const next = current.includes(toolName)
|
|
137
|
+
? current.filter((t) => t !== toolName)
|
|
138
|
+
: [...current, toolName]
|
|
139
|
+
setExposureAllowlistText(next.join(', '))
|
|
140
|
+
}
|
|
141
|
+
|
|
80
142
|
const parseEnv = (text: string): Record<string, string> | undefined => {
|
|
81
143
|
if (!text.trim()) return undefined
|
|
82
144
|
const env: Record<string, string> = {}
|
|
@@ -103,6 +165,12 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
103
165
|
transport,
|
|
104
166
|
env: parseEnv(envText),
|
|
105
167
|
headers: parseHeaders(headersText),
|
|
168
|
+
alwaysExpose:
|
|
169
|
+
exposureMode === 'all'
|
|
170
|
+
? true
|
|
171
|
+
: exposureMode === 'lazy'
|
|
172
|
+
? false
|
|
173
|
+
: exposureAllowlistText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean),
|
|
106
174
|
}
|
|
107
175
|
if (transport === 'stdio') {
|
|
108
176
|
data.command = command.trim()
|
|
@@ -186,6 +254,16 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
186
254
|
|
|
187
255
|
const activePreset = activePresetId ? MCP_PRESETS.find((p) => p.id === activePresetId) ?? null : null
|
|
188
256
|
|
|
257
|
+
const applyRegistryPrefill = (prefill: RegistryPrefill) => {
|
|
258
|
+
setActivePresetId(null)
|
|
259
|
+
setTransport(prefill.transport)
|
|
260
|
+
if (prefill.command !== undefined) setCommand(prefill.command)
|
|
261
|
+
if (prefill.args !== undefined) setArgs(prefill.args.join(', '))
|
|
262
|
+
if (prefill.url !== undefined) setUrl(prefill.url)
|
|
263
|
+
if (!name.trim()) setName(prefill.name)
|
|
264
|
+
toast.success(`Prefilled from SwarmDock MCP Registry: ${prefill.sourceSlug}`)
|
|
265
|
+
}
|
|
266
|
+
|
|
189
267
|
const inputClass = "w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow"
|
|
190
268
|
const labelClass = "block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3"
|
|
191
269
|
|
|
@@ -221,6 +299,15 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
221
299
|
</button>
|
|
222
300
|
)
|
|
223
301
|
})}
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
onClick={() => setRegistryBrowserOpen(true)}
|
|
305
|
+
className="py-2 px-4 rounded-[12px] border border-dashed border-accent-bright/30 bg-transparent text-[13px] font-600 text-accent-bright cursor-pointer transition-all hover:bg-accent-bright/10"
|
|
306
|
+
style={{ fontFamily: 'inherit' }}
|
|
307
|
+
title="Browse the public SwarmDock MCP Registry"
|
|
308
|
+
>
|
|
309
|
+
Browse Registry...
|
|
310
|
+
</button>
|
|
224
311
|
</div>
|
|
225
312
|
{activePreset && (
|
|
226
313
|
<p className="mt-3 text-[12px] text-text-3">
|
|
@@ -321,6 +408,115 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
321
408
|
</div>
|
|
322
409
|
)}
|
|
323
410
|
|
|
411
|
+
<AdvancedSettingsSection
|
|
412
|
+
open={advancedOpen}
|
|
413
|
+
onToggle={() => setAdvancedOpen((v) => !v)}
|
|
414
|
+
summary={exposureMode === 'all' ? 'All tools eager' : exposureMode === 'lazy' ? 'Lazy (on demand)' : 'Allow-list'}
|
|
415
|
+
badges={exposureMode === 'selected' && exposureAllowlistText.trim()
|
|
416
|
+
? exposureAllowlistText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean).slice(0, 5)
|
|
417
|
+
: []}
|
|
418
|
+
>
|
|
419
|
+
<div className="space-y-4">
|
|
420
|
+
<div>
|
|
421
|
+
<label className={labelClass}>
|
|
422
|
+
Tool exposure
|
|
423
|
+
<HintTip text="Controls how this server's tools get bound into agent context. Lazy servers stay hidden until an agent calls `mcp_tool_search` to discover them — that's how you cut token usage from chatty MCP servers." />
|
|
424
|
+
</label>
|
|
425
|
+
<div className="flex flex-col gap-2">
|
|
426
|
+
{([
|
|
427
|
+
['all', 'Expose all tools', 'Every tool from this server is bound on every turn. Default — preserves legacy behavior.'],
|
|
428
|
+
['lazy', 'Lazy — expose none', 'No tools bound until the agent calls mcp_tool_search to discover them. Biggest token savings.'],
|
|
429
|
+
['selected', 'Allow-list', 'Only pre-bind the tools you list below. Agent can still discover others via mcp_tool_search.'],
|
|
430
|
+
] as const).map(([value, label, hint]) => (
|
|
431
|
+
<label key={value} className={`flex items-start gap-3 p-3 rounded-[12px] border cursor-pointer transition-all ${exposureMode === value ? 'border-accent-bright bg-accent-bright/5' : 'border-white/[0.08] hover:bg-surface-2'}`}>
|
|
432
|
+
<input
|
|
433
|
+
type="radio"
|
|
434
|
+
name="exposureMode"
|
|
435
|
+
value={value}
|
|
436
|
+
checked={exposureMode === value}
|
|
437
|
+
onChange={() => setExposureMode(value)}
|
|
438
|
+
className="mt-1"
|
|
439
|
+
/>
|
|
440
|
+
<div className="flex-1 min-w-0">
|
|
441
|
+
<div className="text-[14px] font-600 text-text">{label}</div>
|
|
442
|
+
<div className="text-[12px] text-text-3 leading-[1.5] mt-0.5">{hint}</div>
|
|
443
|
+
</div>
|
|
444
|
+
</label>
|
|
445
|
+
))}
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
{exposureMode === 'selected' && (
|
|
449
|
+
<div>
|
|
450
|
+
<label className={labelClass}>
|
|
451
|
+
Allow-list tools
|
|
452
|
+
<HintTip text="Pick the tools to bind eagerly. Every unchecked tool stays discoverable via `mcp_tool_search`." />
|
|
453
|
+
</label>
|
|
454
|
+
{discoveredLoading && (
|
|
455
|
+
<div className="text-[12px] text-text-3">Loading tools...</div>
|
|
456
|
+
)}
|
|
457
|
+
{discoveredError && (
|
|
458
|
+
<div className="text-[12px] text-amber-400">
|
|
459
|
+
Could not load tools: {discoveredError}. Type names manually below.
|
|
460
|
+
</div>
|
|
461
|
+
)}
|
|
462
|
+
{discoveredTools && discoveredTools.length > 0 && (
|
|
463
|
+
(() => {
|
|
464
|
+
const selected = new Set(
|
|
465
|
+
exposureAllowlistText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean),
|
|
466
|
+
)
|
|
467
|
+
const totalTokens = discoveredTools.reduce((n, t) => n + t.tokens, 0)
|
|
468
|
+
const selectedTokens = discoveredTools
|
|
469
|
+
.filter((t) => selected.has(t.name))
|
|
470
|
+
.reduce((n, t) => n + t.tokens, 0)
|
|
471
|
+
return (
|
|
472
|
+
<div className="space-y-1 rounded-[12px] border border-white/[0.08] bg-surface/50 p-2 max-h-[320px] overflow-auto">
|
|
473
|
+
<div className="px-2 py-1 text-[11px] font-mono text-text-3">
|
|
474
|
+
{selectedTokens.toLocaleString()} / {totalTokens.toLocaleString()} tokens selected
|
|
475
|
+
</div>
|
|
476
|
+
{discoveredTools.map((t) => {
|
|
477
|
+
const checked = selected.has(t.name)
|
|
478
|
+
return (
|
|
479
|
+
<label
|
|
480
|
+
key={t.name}
|
|
481
|
+
className={`flex items-start gap-3 p-2 rounded-[10px] cursor-pointer transition-colors ${checked ? 'bg-accent-bright/5' : 'hover:bg-white/[0.03]'}`}
|
|
482
|
+
>
|
|
483
|
+
<input
|
|
484
|
+
type="checkbox"
|
|
485
|
+
checked={checked}
|
|
486
|
+
onChange={() => toggleAllowlistTool(t.name)}
|
|
487
|
+
className="mt-1 shrink-0"
|
|
488
|
+
/>
|
|
489
|
+
<div className="flex-1 min-w-0">
|
|
490
|
+
<div className="flex items-center justify-between gap-2">
|
|
491
|
+
<span className="text-[13px] font-mono text-text truncate">{t.name}</span>
|
|
492
|
+
<span className="text-[10px] font-mono text-text-3 shrink-0">{t.tokens.toLocaleString()} tok</span>
|
|
493
|
+
</div>
|
|
494
|
+
{t.description && (
|
|
495
|
+
<p className="text-[12px] text-text-3/80 leading-[1.4] mt-0.5 line-clamp-2">{t.description}</p>
|
|
496
|
+
)}
|
|
497
|
+
</div>
|
|
498
|
+
</label>
|
|
499
|
+
)
|
|
500
|
+
})}
|
|
501
|
+
</div>
|
|
502
|
+
)
|
|
503
|
+
})()
|
|
504
|
+
)}
|
|
505
|
+
{(!discoveredTools || discoveredTools.length === 0) && !discoveredLoading && (
|
|
506
|
+
<textarea
|
|
507
|
+
value={exposureAllowlistText}
|
|
508
|
+
onChange={(e) => setExposureAllowlistText(e.target.value)}
|
|
509
|
+
placeholder={"read_file\nwrite_file"}
|
|
510
|
+
rows={3}
|
|
511
|
+
className={`${inputClass} resize-y min-h-[80px] font-mono text-[13px]`}
|
|
512
|
+
style={{ fontFamily: 'inherit' }}
|
|
513
|
+
/>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
</div>
|
|
518
|
+
</AdvancedSettingsSection>
|
|
519
|
+
|
|
324
520
|
{editing && (
|
|
325
521
|
<div className="mb-8">
|
|
326
522
|
<button
|
|
@@ -372,6 +568,11 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
372
568
|
onConfirm={() => { void handleDelete() }}
|
|
373
569
|
onCancel={() => { if (!deleting) setConfirmDelete(false) }}
|
|
374
570
|
/>
|
|
571
|
+
<RegistryBrowser
|
|
572
|
+
open={registryBrowserOpen}
|
|
573
|
+
onClose={() => setRegistryBrowserOpen(false)}
|
|
574
|
+
onSelect={applyRegistryPrefill}
|
|
575
|
+
/>
|
|
375
576
|
</>
|
|
376
577
|
)
|
|
377
578
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Browse the public SwarmDock MCP Registry (https://mcp.swarmdock.ai) from
|
|
5
|
+
* the New MCP Server sheet. Selecting a server populates the form with its
|
|
6
|
+
* recommended install method so users get one-click discovery without
|
|
7
|
+
* leaving SwarmClaw.
|
|
8
|
+
*
|
|
9
|
+
* Read-only — SwarmClaw only consumes the registry. Attestations and
|
|
10
|
+
* submissions happen through SwarmDock directly.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useState } from 'react'
|
|
14
|
+
|
|
15
|
+
const REGISTRY_API = 'https://swarmdock-api.onrender.com/api/v1/mcp/servers'
|
|
16
|
+
|
|
17
|
+
export interface RegistryPrefill {
|
|
18
|
+
name: string
|
|
19
|
+
transport: 'stdio' | 'sse' | 'streamable-http'
|
|
20
|
+
command?: string
|
|
21
|
+
args?: string[]
|
|
22
|
+
url?: string
|
|
23
|
+
sourceSlug: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RegistryServer {
|
|
27
|
+
slug: string
|
|
28
|
+
name: string
|
|
29
|
+
description: string
|
|
30
|
+
transport: string
|
|
31
|
+
authMode: string
|
|
32
|
+
language: string | null
|
|
33
|
+
tags: string[]
|
|
34
|
+
qualityScore: number
|
|
35
|
+
verifiedUsageCount: number
|
|
36
|
+
paidTier: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface RegistryDetail extends RegistryServer {
|
|
40
|
+
installations: Array<{ method: string; spec: Record<string, unknown> }>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mapTransport(transport: string): 'stdio' | 'sse' | 'streamable-http' {
|
|
44
|
+
if (transport === 'sse') return 'sse'
|
|
45
|
+
if (transport === 'streamable_http') return 'streamable-http'
|
|
46
|
+
return 'stdio'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function installToPrefill(server: RegistryDetail): RegistryPrefill | null {
|
|
50
|
+
const preferred = server.installations.find((i) => i.method === 'npx')
|
|
51
|
+
?? server.installations.find((i) => i.method === 'npm')
|
|
52
|
+
?? server.installations.find((i) => i.method === 'uvx')
|
|
53
|
+
?? server.installations.find((i) => i.method === 'pipx')
|
|
54
|
+
?? server.installations.find((i) => i.method === 'docker')
|
|
55
|
+
?? server.installations.find((i) => i.method === 'remote')
|
|
56
|
+
?? server.installations[0]
|
|
57
|
+
|
|
58
|
+
if (!preferred) return null
|
|
59
|
+
|
|
60
|
+
const spec = preferred.spec
|
|
61
|
+
const transport = mapTransport(server.transport)
|
|
62
|
+
|
|
63
|
+
if (preferred.method === 'remote') {
|
|
64
|
+
const url = typeof spec.url === 'string' ? spec.url : undefined
|
|
65
|
+
return url
|
|
66
|
+
? { name: server.name, transport: transport === 'stdio' ? 'streamable-http' : transport, url, sourceSlug: server.slug }
|
|
67
|
+
: null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const command = typeof spec.command === 'string' ? spec.command : preferred.method === 'docker' ? 'docker' : 'npx'
|
|
71
|
+
const args = Array.isArray(spec.args)
|
|
72
|
+
? spec.args.filter((a): a is string => typeof a === 'string')
|
|
73
|
+
: preferred.method === 'npm' && typeof spec.package === 'string'
|
|
74
|
+
? ['-y', spec.package]
|
|
75
|
+
: []
|
|
76
|
+
|
|
77
|
+
return { name: server.name, transport, command, args, sourceSlug: server.slug }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function RegistryBrowser({
|
|
81
|
+
open,
|
|
82
|
+
onClose,
|
|
83
|
+
onSelect,
|
|
84
|
+
}: {
|
|
85
|
+
open: boolean
|
|
86
|
+
onClose: () => void
|
|
87
|
+
onSelect: (prefill: RegistryPrefill) => void
|
|
88
|
+
}) {
|
|
89
|
+
const [query, setQuery] = useState('')
|
|
90
|
+
const [servers, setServers] = useState<RegistryServer[]>([])
|
|
91
|
+
const [loading, setLoading] = useState(false)
|
|
92
|
+
const [error, setError] = useState<string | null>(null)
|
|
93
|
+
const [selecting, setSelecting] = useState<string | null>(null)
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!open) return
|
|
97
|
+
let cancelled = false
|
|
98
|
+
const fetchServers = async () => {
|
|
99
|
+
setLoading(true)
|
|
100
|
+
setError(null)
|
|
101
|
+
try {
|
|
102
|
+
const qs = query ? `?q=${encodeURIComponent(query)}&limit=20` : '?limit=20'
|
|
103
|
+
const res = await fetch(`${REGISTRY_API}${qs}`)
|
|
104
|
+
if (!res.ok) throw new Error(`Registry returned ${res.status}`)
|
|
105
|
+
const data = await res.json() as { servers: RegistryServer[] }
|
|
106
|
+
if (!cancelled) setServers(data.servers)
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load registry')
|
|
109
|
+
} finally {
|
|
110
|
+
if (!cancelled) setLoading(false)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const timer = setTimeout(fetchServers, query ? 250 : 0)
|
|
114
|
+
return () => {
|
|
115
|
+
cancelled = true
|
|
116
|
+
clearTimeout(timer)
|
|
117
|
+
}
|
|
118
|
+
}, [open, query])
|
|
119
|
+
|
|
120
|
+
const handleSelect = async (slug: string) => {
|
|
121
|
+
setSelecting(slug)
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch(`${REGISTRY_API}/${encodeURIComponent(slug)}`)
|
|
124
|
+
if (!res.ok) throw new Error(`Server detail returned ${res.status}`)
|
|
125
|
+
const detail = await res.json() as RegistryDetail
|
|
126
|
+
const prefill = installToPrefill(detail)
|
|
127
|
+
if (!prefill) {
|
|
128
|
+
setError('This server has no installation method SwarmClaw can consume yet.')
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
onSelect(prefill)
|
|
132
|
+
onClose()
|
|
133
|
+
} catch (err) {
|
|
134
|
+
setError(err instanceof Error ? err.message : 'Failed to fetch server')
|
|
135
|
+
} finally {
|
|
136
|
+
setSelecting(null)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!open) return null
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
144
|
+
<div
|
|
145
|
+
className="flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-[20px] border border-white/[0.08] bg-surface"
|
|
146
|
+
onClick={(e) => e.stopPropagation()}
|
|
147
|
+
>
|
|
148
|
+
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
|
|
149
|
+
<div>
|
|
150
|
+
<h3 className="font-display text-[18px] font-700 tracking-[-0.02em]">Browse SwarmDock MCP Registry</h3>
|
|
151
|
+
<p className="mt-0.5 text-[12px] text-text-3">
|
|
152
|
+
Public directory with verified usage signal · <a href="https://mcp.swarmdock.ai" target="_blank" rel="noopener noreferrer" className="text-accent-bright hover:underline">mcp.swarmdock.ai</a>
|
|
153
|
+
</p>
|
|
154
|
+
</div>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={onClose}
|
|
158
|
+
className="text-text-3 hover:text-text"
|
|
159
|
+
aria-label="Close"
|
|
160
|
+
>
|
|
161
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
162
|
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
163
|
+
</svg>
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div className="px-6 py-4">
|
|
168
|
+
<input
|
|
169
|
+
type="text"
|
|
170
|
+
placeholder="Search — e.g. postgres, pdf, github"
|
|
171
|
+
value={query}
|
|
172
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
173
|
+
className="w-full rounded-[12px] border border-white/[0.08] bg-surface-2 px-4 py-2.5 text-[14px] outline-none focus-glow"
|
|
174
|
+
style={{ fontFamily: 'inherit' }}
|
|
175
|
+
autoFocus
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
|
180
|
+
{loading ? (
|
|
181
|
+
<p className="px-2 py-4 text-center text-[13px] text-text-3">Loading...</p>
|
|
182
|
+
) : error ? (
|
|
183
|
+
<p className="px-2 py-4 text-center text-[13px] text-red-400">{error}</p>
|
|
184
|
+
) : servers.length === 0 ? (
|
|
185
|
+
<p className="px-2 py-4 text-center text-[13px] text-text-3">No servers found.</p>
|
|
186
|
+
) : (
|
|
187
|
+
<ul className="space-y-1.5">
|
|
188
|
+
{servers.map((server) => (
|
|
189
|
+
<li key={server.slug}>
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={() => handleSelect(server.slug)}
|
|
193
|
+
disabled={selecting !== null}
|
|
194
|
+
className="group flex w-full flex-col gap-1 rounded-[12px] border border-transparent px-3 py-2.5 text-left transition-all hover:border-white/[0.08] hover:bg-surface-2 disabled:opacity-60"
|
|
195
|
+
>
|
|
196
|
+
<div className="flex items-center gap-2">
|
|
197
|
+
<span className="text-[14px] font-600 group-hover:text-accent-bright">{server.name}</span>
|
|
198
|
+
<span className="rounded-full bg-white/[0.05] px-2 py-0.5 text-[10px] font-mono uppercase text-text-3">
|
|
199
|
+
{server.transport}
|
|
200
|
+
</span>
|
|
201
|
+
{server.paidTier ? (
|
|
202
|
+
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-600 text-emerald-400">
|
|
203
|
+
Paid
|
|
204
|
+
</span>
|
|
205
|
+
) : null}
|
|
206
|
+
{selecting === server.slug ? (
|
|
207
|
+
<span className="ml-auto text-[11px] text-accent-bright">Loading...</span>
|
|
208
|
+
) : (
|
|
209
|
+
<span className="ml-auto text-[11px] text-text-3">
|
|
210
|
+
Q {server.qualityScore.toFixed(2)} · {server.verifiedUsageCount.toLocaleString()} uses
|
|
211
|
+
</span>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
<p className="line-clamp-2 text-[12px] text-text-3">{server.description}</p>
|
|
215
|
+
</button>
|
|
216
|
+
</li>
|
|
217
|
+
))}
|
|
218
|
+
</ul>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
package/src/lib/chat/chats.ts
CHANGED
|
@@ -76,8 +76,44 @@ export const removeQueuedSessionMessage = (id: string, runId: string) =>
|
|
|
76
76
|
export const clearSessionQueue = (id: string) =>
|
|
77
77
|
api<{ cancelled: number; snapshot: SessionQueueSnapshot }>('DELETE', `/chats/${id}/queue`, {})
|
|
78
78
|
|
|
79
|
+
export interface ClearChatResult {
|
|
80
|
+
cleared: number
|
|
81
|
+
undoToken: string
|
|
82
|
+
expiresAt: number
|
|
83
|
+
}
|
|
84
|
+
|
|
79
85
|
export const clearMessages = (id: string) =>
|
|
80
|
-
api<
|
|
86
|
+
api<ClearChatResult>('POST', `/chats/${id}/clear`)
|
|
87
|
+
|
|
88
|
+
export const undoClearMessages = (id: string, undoToken: string) =>
|
|
89
|
+
api<{ restored: number }>('POST', `/chats/${id}/clear/undo`, { undoToken })
|
|
90
|
+
|
|
91
|
+
export interface ContextStatusResponse {
|
|
92
|
+
estimatedTokens: number
|
|
93
|
+
effectiveTokens: number
|
|
94
|
+
contextWindow: number
|
|
95
|
+
percentUsed: number
|
|
96
|
+
messageCount: number
|
|
97
|
+
extraTokens: number
|
|
98
|
+
reserveTokens: number
|
|
99
|
+
remainingTokens: number
|
|
100
|
+
strategy: 'ok' | 'warning' | 'critical'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const fetchContextStatus = (id: string) =>
|
|
104
|
+
api<ContextStatusResponse>('GET', `/chats/${id}/context-status`)
|
|
105
|
+
|
|
106
|
+
export interface CompactChatResult {
|
|
107
|
+
status: 'compacted' | 'no_action'
|
|
108
|
+
prunedCount?: number
|
|
109
|
+
memoriesStored?: number
|
|
110
|
+
summaryAdded?: boolean
|
|
111
|
+
messageCount: number
|
|
112
|
+
keepLastN?: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const compactChat = (id: string, keepLastN?: number) =>
|
|
116
|
+
api<CompactChatResult>('POST', `/chats/${id}/compact`, keepLastN ? { keepLastN } : {})
|
|
81
117
|
|
|
82
118
|
export const stopChat = (id: string) =>
|
|
83
119
|
api<string>('POST', `/chats/${id}/stop`)
|
|
@@ -22,8 +22,14 @@ import {
|
|
|
22
22
|
clearMessages,
|
|
23
23
|
deleteSessionMessages,
|
|
24
24
|
getMessages,
|
|
25
|
+
replaceAllMessages,
|
|
25
26
|
truncateAfter,
|
|
26
27
|
} from '@/lib/server/messages/message-repository'
|
|
28
|
+
import {
|
|
29
|
+
consumeClearUndoSnapshot,
|
|
30
|
+
recordClearUndoSnapshot,
|
|
31
|
+
type ClearUndoCliIds,
|
|
32
|
+
} from '@/lib/server/chats/clear-undo-snapshots'
|
|
27
33
|
import { deleteSessionWorkingState } from '@/lib/server/working-state/service'
|
|
28
34
|
import { normalizeProviderEndpoint } from '@/lib/openclaw/openclaw-endpoint'
|
|
29
35
|
import { serviceFail, serviceOk } from '@/lib/server/service-result'
|
|
@@ -387,6 +393,7 @@ export function clearChatMessages(sessionId: string): boolean {
|
|
|
387
393
|
session.claudeSessionId = null
|
|
388
394
|
session.codexThreadId = null
|
|
389
395
|
session.opencodeSessionId = null
|
|
396
|
+
session.opencodeWebSessionId = null
|
|
390
397
|
session.geminiSessionId = null
|
|
391
398
|
session.copilotSessionId = null
|
|
392
399
|
session.droidSessionId = null
|
|
@@ -399,6 +406,74 @@ export function clearChatMessages(sessionId: string): boolean {
|
|
|
399
406
|
return true
|
|
400
407
|
}
|
|
401
408
|
|
|
409
|
+
function snapshotSessionCliIds(session: Session): ClearUndoCliIds {
|
|
410
|
+
return {
|
|
411
|
+
claudeSessionId: session.claudeSessionId ?? null,
|
|
412
|
+
codexThreadId: session.codexThreadId ?? null,
|
|
413
|
+
opencodeSessionId: session.opencodeSessionId ?? null,
|
|
414
|
+
opencodeWebSessionId: session.opencodeWebSessionId ?? null,
|
|
415
|
+
geminiSessionId: session.geminiSessionId ?? null,
|
|
416
|
+
copilotSessionId: session.copilotSessionId ?? null,
|
|
417
|
+
droidSessionId: session.droidSessionId ?? null,
|
|
418
|
+
cursorSessionId: session.cursorSessionId ?? null,
|
|
419
|
+
qwenSessionId: session.qwenSessionId ?? null,
|
|
420
|
+
acpSessionId: session.acpSessionId ?? null,
|
|
421
|
+
delegateResumeIds: session.delegateResumeIds
|
|
422
|
+
? { ...session.delegateResumeIds }
|
|
423
|
+
: null,
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function clearChatMessagesWithUndo(sessionId: string): ServiceResult<{
|
|
428
|
+
cleared: number
|
|
429
|
+
undoToken: string
|
|
430
|
+
expiresAt: number
|
|
431
|
+
}> {
|
|
432
|
+
const session = getSession(sessionId)
|
|
433
|
+
if (!session) return serviceFail(404, 'Session not found')
|
|
434
|
+
const priorMessages = getMessages(sessionId)
|
|
435
|
+
const cli = snapshotSessionCliIds(session)
|
|
436
|
+
const { token, expiresAt } = recordClearUndoSnapshot({
|
|
437
|
+
sessionId,
|
|
438
|
+
messages: priorMessages,
|
|
439
|
+
cli,
|
|
440
|
+
})
|
|
441
|
+
clearChatMessages(sessionId)
|
|
442
|
+
return serviceOk({
|
|
443
|
+
cleared: priorMessages.length,
|
|
444
|
+
undoToken: token,
|
|
445
|
+
expiresAt,
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function restoreChatFromUndoToken(
|
|
450
|
+
sessionId: string,
|
|
451
|
+
undoToken: string,
|
|
452
|
+
): ServiceResult<{ restored: number }> {
|
|
453
|
+
const session = getSession(sessionId)
|
|
454
|
+
if (!session) return serviceFail(404, 'Session not found')
|
|
455
|
+
const snapshot = consumeClearUndoSnapshot({ token: undoToken, sessionId })
|
|
456
|
+
if (!snapshot) return serviceFail(404, 'Undo window expired')
|
|
457
|
+
replaceAllMessages(sessionId, snapshot.messages)
|
|
458
|
+
const cli = snapshot.cli
|
|
459
|
+
session.claudeSessionId = cli.claudeSessionId
|
|
460
|
+
session.codexThreadId = cli.codexThreadId
|
|
461
|
+
session.opencodeSessionId = cli.opencodeSessionId
|
|
462
|
+
session.opencodeWebSessionId = cli.opencodeWebSessionId
|
|
463
|
+
session.geminiSessionId = cli.geminiSessionId
|
|
464
|
+
session.copilotSessionId = cli.copilotSessionId
|
|
465
|
+
session.droidSessionId = cli.droidSessionId
|
|
466
|
+
session.cursorSessionId = cli.cursorSessionId
|
|
467
|
+
session.qwenSessionId = cli.qwenSessionId
|
|
468
|
+
session.acpSessionId = cli.acpSessionId
|
|
469
|
+
session.delegateResumeIds = cli.delegateResumeIds
|
|
470
|
+
? { ...cli.delegateResumeIds }
|
|
471
|
+
: emptyDelegateResumeIds()
|
|
472
|
+
saveSession(sessionId, session)
|
|
473
|
+
notify('sessions')
|
|
474
|
+
return serviceOk({ restored: snapshot.messages.length })
|
|
475
|
+
}
|
|
476
|
+
|
|
402
477
|
export function retryChatTurn(sessionId: string): ServiceResult<{ message: string; imagePath: string | null }> {
|
|
403
478
|
const session = getSession(sessionId)
|
|
404
479
|
if (!session) return serviceFail(404, 'Session not found')
|