@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.
Files changed (32) hide show
  1. package/README.md +17 -0
  2. package/package.json +2 -2
  3. package/src/app/api/chats/[id]/clear/route.ts +7 -3
  4. package/src/app/api/chats/[id]/clear/undo/route.ts +23 -0
  5. package/src/app/api/chats/[id]/compact/route.ts +72 -0
  6. package/src/app/api/chats/[id]/context-status/route.ts +21 -0
  7. package/src/app/api/chats/clear-route.test.ts +121 -0
  8. package/src/app/api/chats/compact-route.test.ts +70 -0
  9. package/src/app/api/chats/context-status-route.test.ts +68 -0
  10. package/src/app/api/mcp-servers/[id]/route.ts +5 -0
  11. package/src/app/api/mcp-servers/[id]/test/route.ts +5 -0
  12. package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
  13. package/src/cli/index.js +5 -1
  14. package/src/cli/spec.js +4 -1
  15. package/src/components/chat/chat-area.tsx +62 -6
  16. package/src/components/chat/chat-header.tsx +13 -1
  17. package/src/components/chat/context-meter-badge.tsx +227 -0
  18. package/src/components/mcp-servers/mcp-server-list.tsx +56 -0
  19. package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
  20. package/src/components/mcp-servers/registry-browser.tsx +224 -0
  21. package/src/lib/chat/chats.ts +37 -1
  22. package/src/lib/server/chats/chat-session-service.ts +75 -0
  23. package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
  24. package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
  25. package/src/lib/server/mcp-connection-pool.test.ts +98 -0
  26. package/src/lib/server/mcp-connection-pool.ts +134 -0
  27. package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
  28. package/src/lib/server/mcp-gateway-runtime.ts +138 -0
  29. package/src/lib/server/session-tools/index.ts +83 -15
  30. package/src/lib/server/storage-normalization.ts +11 -0
  31. package/src/types/agent.ts +1 -0
  32. package/src/types/misc.ts +7 -0
@@ -6,7 +6,9 @@ import { useAppStore } from '@/stores/use-app-store'
6
6
  import { selectActiveSessionId } from '@/stores/slices/session-slice'
7
7
  import { useWs } from '@/hooks/use-ws'
8
8
  import { useChatStore } from '@/stores/use-chat-store'
9
- import { fetchMessages, fetchMessagesPaginated, clearMessages, deleteChat, devServer, checkBrowser, stopBrowser } from '@/lib/chat/chats'
9
+ import { fetchMessages, fetchMessagesPaginated, clearMessages, undoClearMessages, deleteChat, devServer, checkBrowser, stopBrowser } from '@/lib/chat/chats'
10
+ import { toast } from 'sonner'
11
+ import { errorMessage } from '@/lib/shared-utils'
10
12
  import { uploadImage } from '@/lib/upload'
11
13
  import { deleteAgent } from '@/lib/agents'
12
14
  import { useMediaQuery } from '@/hooks/use-media-query'
@@ -432,11 +434,59 @@ export function ChatArea() {
432
434
  const handleClear = useCallback(async () => {
433
435
  setConfirmClear(false)
434
436
  if (!sessionId) return
435
- await clearMessages(sessionId)
436
- setMessages([], { startIndex: 0, totalMessages: 0 })
437
- await refreshSession(sessionId)
437
+ const targetSessionId = sessionId
438
+ let result
439
+ try {
440
+ result = await clearMessages(targetSessionId)
441
+ } catch (err) {
442
+ toast.error(`Clear failed: ${errorMessage(err)}`)
443
+ return
444
+ }
445
+ if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
446
+ setMessages([], { startIndex: 0, totalMessages: 0 })
447
+ }
448
+ await refreshSession(targetSessionId)
449
+ const { undoToken, cleared } = result
450
+ if (!undoToken) return
451
+ const clearedLabel = cleared === 1 ? '1 message cleared' : `${cleared.toLocaleString()} messages cleared`
452
+ toast(clearedLabel, {
453
+ duration: 10_000,
454
+ action: {
455
+ label: 'Undo',
456
+ onClick: async () => {
457
+ try {
458
+ await undoClearMessages(targetSessionId, undoToken)
459
+ const restored = await fetchMessages(targetSessionId)
460
+ if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
461
+ setMessages(restored, { startIndex: 0, totalMessages: restored.length })
462
+ }
463
+ await refreshSession(targetSessionId)
464
+ toast.success('Chat restored.')
465
+ } catch (err) {
466
+ toast.error(`Undo failed: ${errorMessage(err)}`)
467
+ }
468
+ },
469
+ },
470
+ })
438
471
  }, [refreshSession, sessionId, setMessages])
439
472
 
473
+ const handleCompactComplete = useCallback(async () => {
474
+ if (!sessionId) return
475
+ const targetSessionId = sessionId
476
+ try {
477
+ const refreshed = await fetchMessages(targetSessionId)
478
+ if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
479
+ setMessages(refreshed, { startIndex: 0, totalMessages: refreshed.length })
480
+ }
481
+ } catch {
482
+ // silent — next poll will catch up
483
+ }
484
+ }, [sessionId, setMessages])
485
+
486
+ const handleClearRequest = useCallback(() => {
487
+ setConfirmClear(true)
488
+ }, [])
489
+
440
490
  const handleDelete = useCallback(async () => {
441
491
  setConfirmDelete(false)
442
492
  if (!sessionId) return
@@ -515,6 +565,9 @@ export function ChatArea() {
515
565
  connectorFilter={connectorFilter}
516
566
  onConnectorFilterChange={setConnectorFilter}
517
567
  hasMultipleSources={hasMultipleSources}
568
+ messageCount={messages.length}
569
+ onCompactComplete={handleCompactComplete}
570
+ onClearRequest={handleClearRequest}
518
571
  />
519
572
  )}
520
573
  {!isDesktop && (
@@ -533,6 +586,9 @@ export function ChatArea() {
533
586
  connectorFilter={connectorFilter}
534
587
  onConnectorFilterChange={setConnectorFilter}
535
588
  hasMultipleSources={hasMultipleSources}
589
+ messageCount={messages.length}
590
+ onCompactComplete={handleCompactComplete}
591
+ onClearRequest={handleClearRequest}
536
592
  />
537
593
  )}
538
594
  <DevServerBar status={devServerStatus} onStop={handleStopDevServer} />
@@ -660,8 +716,8 @@ export function ChatArea() {
660
716
 
661
717
  <ConfirmDialog
662
718
  open={confirmClear}
663
- title="Clear History"
664
- message="This will delete all messages in this chat. This cannot be undone."
719
+ title="Clear chat"
720
+ message="Clear every message in this chat. Long-term memory, skills, and facts are preserved. You'll have 10 seconds to undo."
665
721
  confirmLabel="Clear"
666
722
  danger
667
723
  onConfirm={handleClear}
@@ -17,6 +17,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
17
17
  import { copyTextToClipboard } from '@/lib/clipboard'
18
18
  import { useNavigate } from '@/lib/app/navigation'
19
19
  import { getEnabledToolIds } from '@/lib/capability-selection'
20
+ import { ContextMeterBadge } from './context-meter-badge'
20
21
 
21
22
  function Tip({ label, children, side = 'bottom' }: { label: string; children: ReactNode; side?: 'top' | 'bottom' | 'left' | 'right' }) {
22
23
  return (
@@ -80,9 +81,12 @@ interface Props {
80
81
  connectorFilter?: string | null
81
82
  onConnectorFilterChange?: (filter: string | null) => void
82
83
  hasMultipleSources?: boolean
84
+ messageCount?: number
85
+ onCompactComplete?: () => void
86
+ onClearRequest?: () => void
83
87
  }
84
88
 
85
- export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources }: Props) {
89
+ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources, messageCount = 0, onCompactComplete, onClearRequest }: Props) {
86
90
  const now = useNow()
87
91
  const agentStatus = useChatStore((s) => s.agentStatus)
88
92
  const agents = useAppStore((s) => s.agents)
@@ -419,6 +423,14 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
419
423
  Responding
420
424
  </HeaderChip>
421
425
  )}
426
+ {messageCount > 0 && onCompactComplete && onClearRequest && (
427
+ <ContextMeterBadge
428
+ sessionId={session.id}
429
+ messageCount={messageCount}
430
+ onCompactComplete={onCompactComplete}
431
+ onClearRequest={onClearRequest}
432
+ />
433
+ )}
422
434
  </div>
423
435
  {liveStatus?.status && (
424
436
  <div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1.5">
@@ -0,0 +1,227 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { toast } from 'sonner'
5
+ import {
6
+ compactChat,
7
+ fetchContextStatus,
8
+ type ContextStatusResponse,
9
+ } from '@/lib/chat/chats'
10
+ import { useWs } from '@/hooks/use-ws'
11
+ import { errorMessage } from '@/lib/shared-utils'
12
+
13
+ interface Props {
14
+ sessionId: string
15
+ messageCount: number
16
+ onCompactComplete: () => void
17
+ onClearRequest: () => void
18
+ }
19
+
20
+ function formatTokens(n: number): string {
21
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
22
+ if (n >= 1_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}k`
23
+ return `${n}`
24
+ }
25
+
26
+ function resolveColor(strategy: ContextStatusResponse['strategy']): {
27
+ dot: string
28
+ text: string
29
+ border: string
30
+ bg: string
31
+ } {
32
+ if (strategy === 'critical') {
33
+ return {
34
+ dot: 'bg-red-400',
35
+ text: 'text-red-300',
36
+ border: 'border-red-500/25',
37
+ bg: 'bg-red-500/10',
38
+ }
39
+ }
40
+ if (strategy === 'warning') {
41
+ return {
42
+ dot: 'bg-amber-400',
43
+ text: 'text-amber-300',
44
+ border: 'border-amber-500/25',
45
+ bg: 'bg-amber-500/10',
46
+ }
47
+ }
48
+ return {
49
+ dot: 'bg-emerald-400/80',
50
+ text: 'text-text-3/70',
51
+ border: 'border-white/[0.06]',
52
+ bg: 'bg-white/[0.03]',
53
+ }
54
+ }
55
+
56
+ export function ContextMeterBadge({ sessionId, messageCount, onCompactComplete, onClearRequest }: Props) {
57
+ const [status, setStatus] = useState<ContextStatusResponse | null>(null)
58
+ const [open, setOpen] = useState(false)
59
+ const [compacting, setCompacting] = useState(false)
60
+ const anchorRef = useRef<HTMLDivElement>(null)
61
+ const panelRef = useRef<HTMLDivElement>(null)
62
+
63
+ const loadStatus = useCallback(async () => {
64
+ try {
65
+ const next = await fetchContextStatus(sessionId)
66
+ setStatus(next)
67
+ } catch {
68
+ // silent — badge just won't render
69
+ }
70
+ }, [sessionId])
71
+
72
+ useEffect(() => {
73
+ void loadStatus()
74
+ }, [loadStatus, messageCount])
75
+
76
+ useWs(`messages:${sessionId}`, loadStatus)
77
+
78
+ useEffect(() => {
79
+ if (!open) return
80
+ const handler = (event: MouseEvent) => {
81
+ const target = event.target as Node
82
+ if (anchorRef.current?.contains(target)) return
83
+ if (panelRef.current?.contains(target)) return
84
+ setOpen(false)
85
+ }
86
+ document.addEventListener('mousedown', handler)
87
+ return () => document.removeEventListener('mousedown', handler)
88
+ }, [open])
89
+
90
+ useEffect(() => {
91
+ if (!open) return
92
+ const handler = (event: KeyboardEvent) => {
93
+ if (event.key === 'Escape') setOpen(false)
94
+ }
95
+ document.addEventListener('keydown', handler)
96
+ return () => document.removeEventListener('keydown', handler)
97
+ }, [open])
98
+
99
+ const handleCompact = useCallback(async () => {
100
+ if (compacting) return
101
+ setCompacting(true)
102
+ try {
103
+ const result = await compactChat(sessionId)
104
+ if (result.status === 'no_action') {
105
+ toast('Nothing to compact yet.')
106
+ } else {
107
+ toast.success(
108
+ `Compacted ${result.prunedCount ?? 0} message${result.prunedCount === 1 ? '' : 's'}.`,
109
+ )
110
+ }
111
+ onCompactComplete()
112
+ await loadStatus()
113
+ setOpen(false)
114
+ } catch (err) {
115
+ toast.error(`Compact failed: ${errorMessage(err)}`)
116
+ } finally {
117
+ setCompacting(false)
118
+ }
119
+ }, [compacting, loadStatus, onCompactComplete, sessionId])
120
+
121
+ const handleClearClick = useCallback(() => {
122
+ setOpen(false)
123
+ onClearRequest()
124
+ }, [onClearRequest])
125
+
126
+ if (!status) return null
127
+ const colors = resolveColor(status.strategy)
128
+ const percent = Math.min(100, Math.max(0, status.percentUsed))
129
+
130
+ return (
131
+ <div className="relative" ref={anchorRef}>
132
+ <button
133
+ type="button"
134
+ onClick={() => setOpen((prev) => !prev)}
135
+ className={`inline-flex items-center gap-1.5 rounded-[9px] border px-2.5 py-1 text-[10px] font-600 transition-colors shrink-0 cursor-pointer ${colors.bg} ${colors.border} ${colors.text} hover:border-white/[0.15] hover:text-text-2`}
136
+ title={`${status.effectiveTokens.toLocaleString()} of ${status.contextWindow.toLocaleString()} tokens used`}
137
+ aria-expanded={open}
138
+ aria-label={`Context usage ${percent}%. Click for details.`}
139
+ >
140
+ <span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
141
+ <span>{percent}%</span>
142
+ <span className="text-text-3/45 font-500">
143
+ {formatTokens(status.effectiveTokens)}
144
+ </span>
145
+ </button>
146
+ {open && (
147
+ <div
148
+ ref={panelRef}
149
+ className="absolute right-0 top-[calc(100%+6px)] z-50 w-[280px] rounded-[14px] border border-white/[0.08] bg-raised/95 p-3 shadow-[0_16px_64px_rgba(0,0,0,0.6)] backdrop-blur-xl"
150
+ style={{ animation: 'fade-in 0.15s cubic-bezier(0.16, 1, 0.3, 1)' }}
151
+ >
152
+ <div className="flex items-baseline justify-between">
153
+ <span className="text-[11px] font-600 uppercase tracking-wider text-text-3/60">Context window</span>
154
+ <span className={`text-[11px] font-600 ${colors.text}`}>{percent}%</span>
155
+ </div>
156
+ <div className="mt-2 h-1.5 w-full rounded-full bg-white/[0.06] overflow-hidden">
157
+ <div
158
+ className={`h-full rounded-full transition-all ${
159
+ status.strategy === 'critical' ? 'bg-red-400'
160
+ : status.strategy === 'warning' ? 'bg-amber-400'
161
+ : 'bg-emerald-400/80'
162
+ }`}
163
+ style={{ width: `${percent}%` }}
164
+ />
165
+ </div>
166
+ <dl className="mt-3 space-y-1 text-[11px]">
167
+ <div className="flex justify-between">
168
+ <dt className="text-text-3/60">Used</dt>
169
+ <dd className="text-text-2 font-mono">
170
+ {status.effectiveTokens.toLocaleString()} / {status.contextWindow.toLocaleString()}
171
+ </dd>
172
+ </div>
173
+ <div className="flex justify-between">
174
+ <dt className="text-text-3/60">Remaining</dt>
175
+ <dd className="text-text-2 font-mono">{status.remainingTokens.toLocaleString()}</dd>
176
+ </div>
177
+ <div className="flex justify-between">
178
+ <dt className="text-text-3/60">Messages</dt>
179
+ <dd className="text-text-2 font-mono">{status.messageCount}</dd>
180
+ </div>
181
+ </dl>
182
+ <div className="mt-3 flex gap-2">
183
+ <button
184
+ type="button"
185
+ onClick={handleCompact}
186
+ disabled={compacting || status.messageCount < 3}
187
+ className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-[10px] border border-accent-bright/25 bg-accent-soft/40 px-2.5 py-1.5 text-[11px] font-600 text-accent-bright transition-colors hover:border-accent-bright/40 hover:bg-accent-soft/60 disabled:cursor-not-allowed disabled:opacity-50"
188
+ >
189
+ {compacting ? (
190
+ <>
191
+ <span className="w-3 h-3 rounded-full border-2 border-accent-bright/30 border-t-accent-bright animate-spin" />
192
+ Compacting
193
+ </>
194
+ ) : (
195
+ <>
196
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
197
+ <path d="M4 14h6v6" />
198
+ <path d="M20 10h-6V4" />
199
+ <path d="M14 10 21 3" />
200
+ <path d="M3 21l7-7" />
201
+ </svg>
202
+ Compact
203
+ </>
204
+ )}
205
+ </button>
206
+ <button
207
+ type="button"
208
+ onClick={handleClearClick}
209
+ className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-[10px] border border-white/[0.08] bg-white/[0.03] px-2.5 py-1.5 text-[11px] font-600 text-text-2 transition-colors hover:border-red-500/25 hover:bg-red-500/10 hover:text-red-300"
210
+ >
211
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
212
+ <path d="M3 6h18" />
213
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
214
+ <path d="M10 11v6" />
215
+ <path d="M14 11v6" />
216
+ </svg>
217
+ Clear
218
+ </button>
219
+ </div>
220
+ <p className="mt-2.5 text-[10px] leading-relaxed text-text-3/55">
221
+ Long-term memory, skills, and facts are preserved. Clear only affects this chat transcript.
222
+ </p>
223
+ </div>
224
+ )}
225
+ </div>
226
+ )
227
+ }
@@ -16,6 +16,7 @@ const transportColors: Record<string, string> = {
16
16
 
17
17
  type McpStatus = { ok: boolean; tools?: string[]; error?: string; loading: boolean }
18
18
  type McpToolMeta = { name: string; description?: string; inputSchema?: Record<string, unknown> }
19
+ type McpToolsInfo = { totalTokens: number; exposedTokens: number; toolCount: number; loading?: boolean; error?: string }
19
20
  type McpInvokeResult = { ok: boolean; text?: string; error?: string; isError?: boolean; result?: unknown }
20
21
  type McpConformanceIssue = { level: 'error' | 'warning'; code: string; message: string; toolName?: string }
21
22
  type McpConformanceResult = {
@@ -56,6 +57,7 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
56
57
  const setMcpServerSheetOpen = useAppStore((s) => s.setMcpServerSheetOpen)
57
58
  const setEditingMcpServerId = useAppStore((s) => s.setEditingMcpServerId)
58
59
  const [statuses, setStatuses] = useState<Record<string, McpStatus>>({})
60
+ const [toolsInfo, setToolsInfo] = useState<Record<string, McpToolsInfo>>({})
59
61
  const [inspectorServerId, setInspectorServerId] = useState<string | null>(null)
60
62
  const [toolsByServer, setToolsByServer] = useState<Record<string, McpToolMeta[]>>({})
61
63
  const [inspectorLoading, setInspectorLoading] = useState(false)
@@ -111,6 +113,41 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
111
113
  timersRef.current.push(timer)
112
114
  })
113
115
 
116
+ // Tools-info — fetch in parallel with tests, staggered to avoid hammering
117
+ // every server at once. Piggybacks on the same connect that /test does,
118
+ // just produces richer data.
119
+ serverList.forEach((server, i) => {
120
+ setToolsInfo((prev) => ({ ...prev, [server.id]: { totalTokens: 0, exposedTokens: 0, toolCount: 0, loading: true } }))
121
+ const timer = setTimeout(async () => {
122
+ try {
123
+ const res = await api<{ tools: Array<{ tokens: number }>; totalTokens: number; exposedTokens: number }>('GET', `/mcp-servers/${server.id}/tools-info`)
124
+ if (cancelled || !mountedRef.current) return
125
+ setToolsInfo((prev) => ({
126
+ ...prev,
127
+ [server.id]: {
128
+ totalTokens: res.totalTokens,
129
+ exposedTokens: res.exposedTokens,
130
+ toolCount: res.tools?.length ?? 0,
131
+ loading: false,
132
+ },
133
+ }))
134
+ } catch (err: unknown) {
135
+ if (cancelled || !mountedRef.current) return
136
+ setToolsInfo((prev) => ({
137
+ ...prev,
138
+ [server.id]: {
139
+ totalTokens: 0,
140
+ exposedTokens: 0,
141
+ toolCount: 0,
142
+ loading: false,
143
+ error: err instanceof Error ? err.message : 'fetch failed',
144
+ },
145
+ }))
146
+ }
147
+ }, i * 200 + 100)
148
+ timersRef.current.push(timer)
149
+ })
150
+
114
151
  return () => {
115
152
  cancelled = true
116
153
  timersRef.current.forEach(clearTimeout)
@@ -472,6 +509,25 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
472
509
  <p className="text-[12px] text-text-3/60 font-mono truncate">
473
510
  {server.transport === 'stdio' ? server.command : server.url}
474
511
  </p>
512
+ {(() => {
513
+ const info = toolsInfo[server.id]
514
+ if (!info || info.loading || info.error) return null
515
+ if (info.totalTokens === 0) return null
516
+ const savings = info.totalTokens > 0
517
+ ? Math.round((1 - info.exposedTokens / info.totalTokens) * 100)
518
+ : 0
519
+ return (
520
+ <p className="mt-1 text-[11px] font-mono text-text-3/75">
521
+ <span className={info.exposedTokens < info.totalTokens ? 'text-emerald-400/80' : 'text-text-3'}>
522
+ {info.exposedTokens.toLocaleString()}
523
+ </span>
524
+ <span className="text-text-3/50"> / {info.totalTokens.toLocaleString()} tokens exposed</span>
525
+ {savings > 0 && (
526
+ <span className="ml-2 text-emerald-400/80">({savings}% saved)</span>
527
+ )}
528
+ </p>
529
+ )
530
+ })()}
475
531
  {conformanceByServer[server.id] && (
476
532
  <p className={`mt-1 text-[11px] ${conformanceByServer[server.id].ok ? 'text-emerald-300/80' : 'text-amber-300/80'}`}>
477
533
  {conformanceByServer[server.id].ok