@swarmclawai/swarmclaw 1.3.4 → 1.3.5

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 (69) hide show
  1. package/README.md +15 -76
  2. package/package.json +1 -1
  3. package/skills/swarmclaw.md +17 -0
  4. package/src/app/api/agents/[id]/dream/route.ts +45 -0
  5. package/src/app/api/knowledge/[id]/route.ts +48 -49
  6. package/src/app/api/knowledge/hygiene/route.ts +13 -0
  7. package/src/app/api/knowledge/route.ts +70 -42
  8. package/src/app/api/knowledge/sources/[id]/archive/route.ts +15 -0
  9. package/src/app/api/knowledge/sources/[id]/restore/route.ts +10 -0
  10. package/src/app/api/knowledge/sources/[id]/route.ts +1 -0
  11. package/src/app/api/knowledge/sources/[id]/supersede/route.ts +26 -0
  12. package/src/app/api/knowledge/sources/[id]/sync/route.ts +17 -0
  13. package/src/app/api/knowledge/sources/route.ts +1 -0
  14. package/src/app/api/knowledge/upload/route.ts +3 -51
  15. package/src/app/api/memory/dream/[id]/route.ts +19 -0
  16. package/src/app/api/memory/dream/route.ts +34 -0
  17. package/src/app/knowledge/layout.tsx +1 -1
  18. package/src/app/knowledge/page.tsx +2 -22
  19. package/src/app/protocols/page.tsx +21 -2
  20. package/src/cli/index.js +16 -0
  21. package/src/cli/spec.js +5 -0
  22. package/src/components/agents/agent-sheet.tsx +65 -0
  23. package/src/components/chat/message-bubble.tsx +10 -0
  24. package/src/components/knowledge/grounding-panel.tsx +99 -0
  25. package/src/components/knowledge/knowledge-detail.tsx +402 -0
  26. package/src/components/knowledge/knowledge-list.tsx +351 -126
  27. package/src/components/knowledge/knowledge-sheet.tsx +208 -119
  28. package/src/components/memory/dream-history.tsx +155 -0
  29. package/src/components/memory/memory-card.tsx +7 -0
  30. package/src/components/memory/memory-detail.tsx +46 -0
  31. package/src/components/runs/run-list.tsx +23 -0
  32. package/src/lib/server/api-routes.test.ts +43 -2
  33. package/src/lib/server/chat-execution/chat-execution-grounding.test.ts +127 -0
  34. package/src/lib/server/chat-execution/chat-execution-types.ts +8 -1
  35. package/src/lib/server/chat-execution/chat-execution.ts +1 -0
  36. package/src/lib/server/chat-execution/chat-turn-finalization.ts +21 -1
  37. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +6 -1
  38. package/src/lib/server/chat-execution/post-stream-finalization.ts +15 -3
  39. package/src/lib/server/chat-execution/prompt-sections.ts +29 -3
  40. package/src/lib/server/chat-execution/stream-agent-chat.ts +6 -1
  41. package/src/lib/server/execution-engine/task-attempt.ts +8 -2
  42. package/src/lib/server/knowledge-import.ts +159 -0
  43. package/src/lib/server/knowledge-sources.test.ts +215 -0
  44. package/src/lib/server/knowledge-sources.ts +1266 -0
  45. package/src/lib/server/memory/dream-cycles.ts +49 -0
  46. package/src/lib/server/memory/dream-idle-callback.ts +38 -0
  47. package/src/lib/server/memory/dream-service.ts +315 -0
  48. package/src/lib/server/memory/memory-db.ts +37 -2
  49. package/src/lib/server/protocols/protocol-agent-turn.ts +7 -0
  50. package/src/lib/server/protocols/protocol-run-lifecycle.ts +19 -6
  51. package/src/lib/server/protocols/protocol-service.test.ts +99 -0
  52. package/src/lib/server/protocols/protocol-step-helpers.ts +7 -1
  53. package/src/lib/server/protocols/protocol-step-processors.ts +16 -3
  54. package/src/lib/server/protocols/protocol-types.ts +4 -0
  55. package/src/lib/server/runtime/daemon-state/core.ts +6 -1
  56. package/src/lib/server/runtime/run-ledger.test.ts +120 -0
  57. package/src/lib/server/runtime/run-ledger.ts +27 -1
  58. package/src/lib/server/runtime/session-run-manager/drain.ts +5 -0
  59. package/src/lib/server/runtime/session-run-manager/state.ts +19 -2
  60. package/src/lib/server/storage-normalization.ts +5 -0
  61. package/src/lib/server/storage.ts +15 -0
  62. package/src/stores/slices/ui-slice.ts +4 -0
  63. package/src/types/agent.ts +7 -0
  64. package/src/types/dream.ts +45 -0
  65. package/src/types/index.ts +1 -0
  66. package/src/types/message.ts +3 -0
  67. package/src/types/misc.ts +131 -0
  68. package/src/types/protocol.ts +4 -0
  69. package/src/types/run.ts +4 -1
@@ -8,105 +8,211 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'
8
8
  import { EmptyState } from '@/components/shared/empty-state'
9
9
  import { PageLoader } from '@/components/ui/page-loader'
10
10
  import { SearchInput } from '@/components/ui/search-input'
11
- import type { MemoryEntry } from '@/types'
11
+ import type { KnowledgeHygieneSummary, KnowledgeSearchHit, KnowledgeSourceSummary } from '@/types'
12
+ import { toast } from 'sonner'
12
13
 
13
14
  export function KnowledgeList() {
14
15
  const [search, setSearch] = useState('')
15
- const [entries, setEntries] = useState<MemoryEntry[]>([])
16
+ const [sources, setSources] = useState<KnowledgeSourceSummary[]>([])
17
+ const [hits, setHits] = useState<KnowledgeSearchHit[]>([])
16
18
  const [loaded, setLoaded] = useState(false)
17
19
  const [error, setError] = useState<string | null>(null)
18
20
  const [activeTag, setActiveTag] = useState<string | null>(null)
21
+ const [includeArchived, setIncludeArchived] = useState(false)
22
+ const [hygiene, setHygiene] = useState<KnowledgeHygieneSummary | null>(null)
23
+ const [maintaining, setMaintaining] = useState(false)
19
24
  const searchRef = useRef(search)
20
- const agents = useAppStore((s) => s.agents)
21
- const loadAgents = useAppStore((s) => s.loadAgents)
22
- const setKnowledgeSheetOpen = useAppStore((s) => s.setKnowledgeSheetOpen)
23
- const setEditingKnowledgeId = useAppStore((s) => s.setEditingKnowledgeId)
25
+
26
+ const agents = useAppStore((state) => state.agents)
27
+ const loadAgents = useAppStore((state) => state.loadAgents)
28
+ const refreshKey = useAppStore((state) => state.knowledgeRefreshKey)
29
+ const openKnowledgeSheet = useAppStore((state) => state.setKnowledgeSheetOpen)
30
+ const setEditingKnowledgeId = useAppStore((state) => state.setEditingKnowledgeId)
31
+ const selectedKnowledgeSourceId = useAppStore((state) => state.selectedKnowledgeSourceId)
32
+ const setSelectedKnowledgeSourceId = useAppStore((state) => state.setSelectedKnowledgeSourceId)
33
+ const triggerKnowledgeRefresh = useAppStore((state) => state.triggerKnowledgeRefresh)
24
34
 
25
35
  const openSheet = useCallback((id?: string) => {
26
36
  setEditingKnowledgeId(id ?? null)
27
- setKnowledgeSheetOpen(true)
28
- }, [setEditingKnowledgeId, setKnowledgeSheetOpen])
37
+ openKnowledgeSheet(true)
38
+ }, [openKnowledgeSheet, setEditingKnowledgeId])
29
39
 
30
40
  const load = useCallback(async (query: string, tag?: string | null) => {
31
41
  try {
32
42
  const params = new URLSearchParams()
33
- if (query) params.set('q', query)
34
43
  if (tag) params.set('tags', tag)
35
- const qs = params.toString()
36
- const results = await api<MemoryEntry[]>('GET', `/knowledge${qs ? `?${qs}` : ''}`)
37
- setEntries(Array.isArray(results) ? results : [])
44
+ if (includeArchived) params.set('includeArchived', 'true')
45
+ const currentSelectedId = useAppStore.getState().selectedKnowledgeSourceId
46
+
47
+ if (query.trim()) {
48
+ params.set('q', query.trim())
49
+ const results = await api<KnowledgeSearchHit[]>('GET', `/knowledge?${params.toString()}`)
50
+ const nextHits = Array.isArray(results) ? results : []
51
+ setHits(nextHits)
52
+ setSources([])
53
+ if (!currentSelectedId || !nextHits.some((hit) => hit.sourceId === currentSelectedId)) {
54
+ setSelectedKnowledgeSourceId(nextHits[0]?.sourceId || null)
55
+ }
56
+ } else {
57
+ const qs = params.toString()
58
+ const results = await api<KnowledgeSourceSummary[]>('GET', `/knowledge/sources${qs ? `?${qs}` : ''}`)
59
+ const nextSources = Array.isArray(results) ? results : []
60
+ setSources(nextSources)
61
+ setHits([])
62
+ if (!currentSelectedId || !nextSources.some((source) => source.id === currentSelectedId)) {
63
+ setSelectedKnowledgeSourceId(nextSources[0]?.id || null)
64
+ }
65
+ }
38
66
  setError(null)
39
67
  } catch {
40
- setError('Unable to load knowledge entries.')
68
+ setError('Unable to load knowledge sources.')
41
69
  }
42
70
  setLoaded(true)
71
+ }, [includeArchived, setSelectedKnowledgeSourceId])
72
+
73
+ const loadHygiene = useCallback(async () => {
74
+ try {
75
+ const summary = await api<KnowledgeHygieneSummary>('GET', '/knowledge/hygiene')
76
+ setHygiene(summary)
77
+ } catch {
78
+ setHygiene(null)
79
+ }
43
80
  }, [])
44
81
 
45
- useEffect(() => { searchRef.current = search }, [search])
82
+ useEffect(() => {
83
+ searchRef.current = search
84
+ }, [search])
46
85
 
47
- // Initial load
48
86
  useEffect(() => {
49
87
  loadAgents()
50
- const timer = setTimeout(() => { void load(searchRef.current, activeTag) }, 0)
88
+ }, [loadAgents])
89
+
90
+ useEffect(() => {
91
+ const timer = setTimeout(() => {
92
+ void load(searchRef.current, activeTag)
93
+ }, 0)
51
94
  return () => clearTimeout(timer)
52
- // eslint-disable-next-line react-hooks/exhaustive-deps
53
- }, [load, activeTag])
95
+ }, [activeTag, load, refreshKey])
96
+
97
+ useEffect(() => {
98
+ void loadHygiene()
99
+ }, [loadHygiene, refreshKey])
54
100
 
55
- // Debounced search
56
101
  useEffect(() => {
57
- const timer = setTimeout(() => { void load(search, activeTag) }, 300)
102
+ const timer = setTimeout(() => {
103
+ void load(search, activeTag)
104
+ }, 250)
58
105
  return () => clearTimeout(timer)
59
- }, [search, load, activeTag])
106
+ }, [activeTag, includeArchived, load, search])
60
107
 
61
108
  const uniqueTags = useMemo(() => {
62
109
  const tags = new Set<string>()
63
- for (const e of entries) {
64
- const meta = e.metadata as { tags?: string[] } | undefined
65
- if (meta?.tags) for (const t of meta.tags) tags.add(t)
110
+ const items = search.trim() ? hits : sources
111
+ for (const item of items) {
112
+ for (const tag of item.tags) tags.add(tag)
66
113
  }
67
- return Array.from(tags).sort()
68
- }, [entries])
114
+ return Array.from(tags).sort((left, right) => left.localeCompare(right))
115
+ }, [hits, search, sources])
69
116
 
70
- const handleDelete = async (id: string) => {
117
+ const handleDelete = useCallback(async (id: string) => {
71
118
  try {
72
- await api('DELETE', `/knowledge/${id}`)
73
- setEntries((prev) => prev.filter((e) => e.id !== id))
119
+ await api('DELETE', `/knowledge/sources/${id}`)
120
+ if (selectedKnowledgeSourceId === id) {
121
+ setSelectedKnowledgeSourceId(null)
122
+ }
123
+ triggerKnowledgeRefresh()
74
124
  } catch {
75
- // silent
125
+ // Best-effort delete; caller can retry from refreshed list.
76
126
  }
77
- }
127
+ }, [selectedKnowledgeSourceId, setSelectedKnowledgeSourceId, triggerKnowledgeRefresh])
128
+
129
+ const runMaintenance = useCallback(async () => {
130
+ setMaintaining(true)
131
+ try {
132
+ await api('POST', '/knowledge/hygiene')
133
+ triggerKnowledgeRefresh()
134
+ void loadHygiene()
135
+ toast.success('Knowledge maintenance completed')
136
+ } catch {
137
+ toast.error('Knowledge maintenance failed')
138
+ } finally {
139
+ setMaintaining(false)
140
+ }
141
+ }, [loadHygiene, triggerKnowledgeRefresh])
78
142
 
79
- const formatDate = (ts: number) => {
80
- return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
143
+ const formatDate = (timestamp?: number | null) => {
144
+ if (!timestamp) return 'Not indexed'
145
+ return new Date(timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
81
146
  }
82
147
 
148
+ const scopedAgentsFor = (agentIds: string[]) => agentIds.map((id) => agents[id]).filter(Boolean)
149
+
83
150
  if (!loaded) {
84
151
  return <PageLoader label="Loading knowledge..." />
85
152
  }
86
153
 
154
+ const showingHits = search.trim().length > 0
155
+ const items = showingHits ? hits : sources
156
+
87
157
  return (
88
158
  <div className="flex-1 flex flex-col overflow-y-auto">
89
- {/* Search only show when there are entries */}
90
- {entries.length > 0 && (
91
- <div className="px-5 py-2 shrink-0" style={{ animation: 'fade-up 0.4s var(--ease-spring)' }}>
92
- <SearchInput
93
- size="sm"
94
- value={search}
95
- onChange={(e) => setSearch(e.target.value)}
96
- onClear={() => setSearch('')}
97
- placeholder="Search knowledge..."
98
- />
159
+ <div className="px-5 py-2 shrink-0" style={{ animation: 'fade-up 0.4s var(--ease-spring)' }}>
160
+ <SearchInput
161
+ size="sm"
162
+ value={search}
163
+ onChange={(event) => setSearch(event.target.value)}
164
+ onClear={() => setSearch('')}
165
+ placeholder="Search knowledge..."
166
+ />
167
+ </div>
168
+
169
+ {hygiene && (
170
+ <div className="px-5 pb-2 shrink-0">
171
+ <div className="rounded-[12px] border border-white/[0.06] bg-white/[0.03] p-3">
172
+ <div className="flex items-center justify-between gap-3">
173
+ <div>
174
+ <div className="text-[10px] font-700 uppercase tracking-[0.12em] text-text-3/55">Hygiene</div>
175
+ <div className="mt-1 flex flex-wrap gap-2 text-[11px] text-text-2/80">
176
+ <span>stale {hygiene.counts.stale}</span>
177
+ <span>duplicates {hygiene.counts.duplicate}</span>
178
+ <span>broken {hygiene.counts.broken}</span>
179
+ <span>archived {hygiene.counts.archived}</span>
180
+ <span>superseded {hygiene.counts.superseded}</span>
181
+ </div>
182
+ </div>
183
+ <button
184
+ onClick={() => { void runMaintenance() }}
185
+ disabled={maintaining}
186
+ className="rounded-[9px] border border-white/[0.08] bg-white/[0.04] px-2.5 py-1.5 text-[11px] font-600 text-text-2 transition-all cursor-pointer disabled:opacity-50"
187
+ >
188
+ {maintaining ? 'Running…' : 'Maintain'}
189
+ </button>
190
+ </div>
191
+ <div className="mt-3 flex items-center justify-between gap-3">
192
+ <div className="text-[10px] text-text-3/55">
193
+ Last scan {new Date(hygiene.scannedAt).toLocaleTimeString()}
194
+ </div>
195
+ <button
196
+ onClick={() => setIncludeArchived((current) => !current)}
197
+ className={`rounded-[8px] px-2 py-1 text-[10px] font-700 uppercase tracking-[0.08em] cursor-pointer ${
198
+ includeArchived ? 'bg-amber-500/12 text-amber-200' : 'bg-white/[0.04] text-text-3/75'
199
+ }`}
200
+ >
201
+ {includeArchived ? 'Showing archived' : 'Hide archived'}
202
+ </button>
203
+ </div>
204
+ </div>
99
205
  </div>
100
206
  )}
101
207
 
102
- {/* Tag filters */}
103
208
  {uniqueTags.length > 0 && (
104
209
  <div className="px-5 pb-1.5 shrink-0" style={{ animation: 'fade-up 0.4s var(--ease-spring) 0.05s both' }}>
105
210
  <div className="flex gap-1 flex-wrap">
106
211
  <button
107
212
  onClick={() => setActiveTag(null)}
108
- className={`px-2 py-0.5 rounded-[6px] text-[9px] font-600 cursor-pointer transition-all uppercase tracking-wider
109
- ${!activeTag ? 'bg-white/[0.06] text-text-2' : 'bg-transparent text-text-3/70 hover:text-text-3'}`}
213
+ className={`px-2 py-0.5 rounded-[6px] text-[9px] font-600 cursor-pointer transition-all uppercase tracking-wider ${
214
+ !activeTag ? 'bg-white/[0.06] text-text-2' : 'bg-transparent text-text-3/70 hover:text-text-3'
215
+ }`}
110
216
  style={{ fontFamily: 'inherit' }}
111
217
  >
112
218
  all
@@ -115,8 +221,9 @@ export function KnowledgeList() {
115
221
  <button
116
222
  key={tag}
117
223
  onClick={() => setActiveTag(activeTag === tag ? null : tag)}
118
- className={`px-2 py-0.5 rounded-[6px] text-[9px] font-600 cursor-pointer transition-all uppercase tracking-wider
119
- ${activeTag === tag ? 'bg-white/[0.06] text-text-2' : 'bg-transparent text-text-3/70 hover:text-text-3'}`}
224
+ className={`px-2 py-0.5 rounded-[6px] text-[9px] font-600 cursor-pointer transition-all uppercase tracking-wider ${
225
+ activeTag === tag ? 'bg-white/[0.06] text-text-2' : 'bg-transparent text-text-3/70 hover:text-text-3'
226
+ }`}
120
227
  style={{ fontFamily: 'inherit' }}
121
228
  >
122
229
  {tag}
@@ -126,84 +233,202 @@ export function KnowledgeList() {
126
233
  </div>
127
234
  )}
128
235
 
129
- {/* Entries */}
130
- {entries.length > 0 ? (
131
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3 px-5 pb-6">
132
- {entries.map((entry, idx) => {
133
- const meta = entry.metadata as { tags?: string[]; scope?: 'global' | 'agent'; agentIds?: string[] } | undefined
134
- const tags = meta?.tags || []
135
- const entryScope = meta?.scope || 'global'
136
- const entryAgentIds = meta?.agentIds || []
137
- const scopeLabel = entryScope === 'global' ? 'Global' : `${entryAgentIds.length} agent(s)`
138
- const scopedAgents = entryScope === 'agent'
139
- ? entryAgentIds.map((id) => agents[id]).filter(Boolean)
140
- : []
141
- return (
142
- <div
143
- key={entry.id}
144
- className="p-3 rounded-[12px] border border-white/[0.04] bg-transparent hover:bg-surface-2 transition-all relative group hover:scale-[1.01] hover:border-white/[0.1]"
145
- style={{
146
- animation: 'spring-in 0.5s var(--ease-spring) both',
147
- animationDelay: `${0.1 + idx * 0.03}s`
148
- }}
149
- >
150
- <div className="flex items-start justify-between gap-2 mb-1">
151
- <span className="font-display text-[13px] font-600 text-text truncate">{entry.title}</span>
152
- <div className="flex items-center gap-1.5 shrink-0">
153
- <button
154
- onClick={() => openSheet(entry.id)}
155
- className="text-text-3/40 hover:text-accent-bright transition-colors p-0.5 cursor-pointer"
156
- title="Edit"
157
- >
158
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
159
- <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
160
- <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
161
- </svg>
162
- </button>
163
- <button
164
- onClick={() => void handleDelete(entry.id)}
165
- className="text-text-3/40 hover:text-red-400 transition-colors p-0.5 cursor-pointer"
166
- title="Delete"
167
- >
168
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
169
- <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
170
- </svg>
171
- </button>
172
- <span className="text-[10px] text-text-3/50">{formatDate(entry.createdAt)}</span>
173
- </div>
174
- </div>
175
- <p className="text-[11px] text-text-3/60 line-clamp-2 mb-2">
176
- {entry.content.slice(0, 200)}
177
- </p>
178
- {tags.length > 0 && (
179
- <div className="flex gap-1 flex-wrap">
180
- {tags.map((t) => (
181
- <Badge key={t} variant="secondary" className="text-[9px] px-1.5 py-0">{t}</Badge>
182
- ))}
236
+ {items.length > 0 ? (
237
+ <div className="grid grid-cols-1 gap-3 px-5 pb-6">
238
+ {showingHits
239
+ ? hits.map((hit, idx) => {
240
+ const scopedAgents = scopedAgentsFor(hit.agentIds)
241
+ const active = selectedKnowledgeSourceId === hit.sourceId
242
+ return (
243
+ <div
244
+ key={hit.id}
245
+ onClick={() => setSelectedKnowledgeSourceId(hit.sourceId)}
246
+ className={`p-3 rounded-[12px] border transition-all relative group cursor-pointer ${
247
+ active
248
+ ? 'border-accent-bright/25 bg-accent-soft/10'
249
+ : 'border-white/[0.04] bg-transparent hover:bg-surface-2 hover:border-white/[0.1]'
250
+ }`}
251
+ style={{
252
+ animation: 'spring-in 0.5s var(--ease-spring) both',
253
+ animationDelay: `${0.08 + idx * 0.02}s`,
254
+ }}
255
+ >
256
+ <div className="flex items-start justify-between gap-2 mb-1.5">
257
+ <div className="min-w-0">
258
+ <div className="flex items-center gap-1.5 mb-1">
259
+ <span className="font-display text-[13px] font-600 text-text truncate">{hit.sourceTitle}</span>
260
+ <Badge variant="secondary" className="text-[9px] px-1.5 py-0 uppercase">{hit.sourceKind}</Badge>
261
+ </div>
262
+ <p className="text-[10px] text-text-3/55">
263
+ Chunk {hit.chunkIndex + 1} of {hit.chunkCount}
264
+ {hit.sectionLabel ? ` • ${hit.sectionLabel}` : ''}
265
+ </p>
266
+ {hit.whyMatched && (
267
+ <p className="mt-1 text-[10px] text-sky-200/70">{hit.whyMatched}</p>
268
+ )}
269
+ </div>
270
+ <button
271
+ onClick={(event) => {
272
+ event.stopPropagation()
273
+ openSheet(hit.sourceId)
274
+ }}
275
+ className="text-text-3/40 hover:text-accent-bright transition-colors p-0.5 cursor-pointer"
276
+ title="Edit"
277
+ >
278
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
279
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
280
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
281
+ </svg>
282
+ </button>
283
+ </div>
284
+
285
+ <p className="text-[11px] text-text-2/80 line-clamp-4">{hit.snippet}</p>
286
+
287
+ <div className="flex items-center gap-2 mt-2.5 flex-wrap">
288
+ {hit.tags.map((tag) => (
289
+ <Badge key={`${hit.id}-${tag}`} variant="secondary" className="text-[9px] px-1.5 py-0">{tag}</Badge>
290
+ ))}
291
+ </div>
292
+
293
+ <div className="flex items-center gap-2 mt-2.5">
294
+ <span className={`text-[10px] font-600 ${hit.scope === 'global' ? 'text-emerald-400' : 'text-amber-400'}`}>
295
+ {hit.scope === 'global' ? 'Global' : `${hit.agentIds.length} agent(s)`}
296
+ </span>
297
+ {scopedAgents.length > 0 && (
298
+ <div className="flex items-center -space-x-1.5">
299
+ {scopedAgents.slice(0, 5).map((agent) => (
300
+ <AgentAvatar
301
+ key={agent.id}
302
+ seed={agent.avatarSeed}
303
+ avatarUrl={agent.avatarUrl}
304
+ name={agent.name}
305
+ size={16}
306
+ className="ring-1 ring-surface"
307
+ />
308
+ ))}
309
+ </div>
310
+ )}
311
+ </div>
183
312
  </div>
184
- )}
185
- <div className="flex items-center gap-2 mt-1.5">
186
- <span className={`text-[10px] font-600 ${
187
- entryScope === 'global' ? 'text-emerald-400' : 'text-amber-400'
188
- }`}>
189
- {scopeLabel}
190
- </span>
191
- {scopedAgents.length > 0 && (
192
- <div className="flex items-center gap-1.5">
193
- <div className="flex items-center -space-x-1.5">
194
- {scopedAgents.slice(0, 5).map((agent) => (
195
- <AgentAvatar key={agent.id} seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={16} className="ring-1 ring-surface" />
196
- ))}
313
+ )
314
+ })
315
+ : sources.map((source, idx) => {
316
+ const scopedAgents = scopedAgentsFor(source.agentIds)
317
+ const active = selectedKnowledgeSourceId === source.id
318
+ return (
319
+ <div
320
+ key={source.id}
321
+ onClick={() => setSelectedKnowledgeSourceId(source.id)}
322
+ className={`p-3 rounded-[12px] border transition-all relative group cursor-pointer ${
323
+ active
324
+ ? 'border-accent-bright/25 bg-accent-soft/10'
325
+ : 'border-white/[0.04] bg-transparent hover:bg-surface-2 hover:border-white/[0.1]'
326
+ }`}
327
+ style={{
328
+ animation: 'spring-in 0.5s var(--ease-spring) both',
329
+ animationDelay: `${0.08 + idx * 0.02}s`,
330
+ }}
331
+ >
332
+ <div className="flex items-start justify-between gap-2 mb-1">
333
+ <div className="min-w-0">
334
+ <div className="flex items-center gap-1.5 mb-1">
335
+ <span className="font-display text-[13px] font-600 text-text truncate">{source.title}</span>
336
+ <Badge variant="secondary" className="text-[9px] px-1.5 py-0 uppercase">{source.kind}</Badge>
337
+ {source.archivedAt ? (
338
+ <Badge variant="secondary" className="text-[9px] px-1.5 py-0 uppercase text-amber-200">archived</Badge>
339
+ ) : source.supersededBySourceId ? (
340
+ <Badge variant="secondary" className="text-[9px] px-1.5 py-0 uppercase text-text-3">superseded</Badge>
341
+ ) : null}
342
+ </div>
343
+ <p className="text-[10px] text-text-3/55">
344
+ {source.chunkCount} chunk{source.chunkCount === 1 ? '' : 's'}
345
+ {' • '}
346
+ {formatDate(source.lastIndexedAt)}
347
+ </p>
197
348
  </div>
198
- {scopedAgents.length > 5 && (
199
- <span className="text-[10px] font-600 text-text-3/60 ml-0.5">+{scopedAgents.length - 5}</span>
349
+
350
+ <div className="flex items-center gap-1 shrink-0">
351
+ <button
352
+ onClick={(event) => {
353
+ event.stopPropagation()
354
+ openSheet(source.id)
355
+ }}
356
+ className="text-text-3/40 hover:text-accent-bright transition-colors p-0.5 cursor-pointer"
357
+ title="Edit"
358
+ >
359
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
360
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
361
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
362
+ </svg>
363
+ </button>
364
+ <button
365
+ onClick={(event) => {
366
+ event.stopPropagation()
367
+ void handleDelete(source.id)
368
+ }}
369
+ className="text-text-3/40 hover:text-red-400 transition-colors p-0.5 cursor-pointer"
370
+ title="Delete"
371
+ >
372
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
373
+ <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
374
+ </svg>
375
+ </button>
376
+ </div>
377
+ </div>
378
+
379
+ {source.topSnippet && (
380
+ <p className="text-[11px] text-text-3/70 line-clamp-3 mb-2">{source.topSnippet}</p>
381
+ )}
382
+
383
+ <div className="flex items-center gap-2 flex-wrap">
384
+ <span className={`text-[10px] font-600 ${
385
+ source.syncStatus === 'error'
386
+ ? 'text-red-300'
387
+ : source.stale
388
+ ? 'text-amber-300'
389
+ : 'text-emerald-300'
390
+ }`}
391
+ >
392
+ {source.syncStatus === 'error' ? 'Sync error' : source.stale ? 'Stale' : 'Ready'}
393
+ </span>
394
+ <span className={`text-[10px] font-600 ${source.scope === 'global' ? 'text-emerald-400' : 'text-amber-400'}`}>
395
+ {source.scope === 'global' ? 'Global' : `${source.agentIds.length} agent(s)`}
396
+ </span>
397
+ {source.sourceLabel && (
398
+ <span className="text-[10px] text-text-3/55 truncate">{source.sourceLabel}</span>
200
399
  )}
201
400
  </div>
202
- )}
203
- </div>
204
- </div>
205
- )
206
- })}
401
+
402
+ {source.tags.length > 0 && (
403
+ <div className="flex items-center gap-1 mt-2 flex-wrap">
404
+ {source.tags.map((tag) => (
405
+ <Badge key={`${source.id}-${tag}`} variant="secondary" className="text-[9px] px-1.5 py-0">{tag}</Badge>
406
+ ))}
407
+ </div>
408
+ )}
409
+
410
+ {scopedAgents.length > 0 && (
411
+ <div className="flex items-center gap-1.5 mt-2">
412
+ <div className="flex items-center -space-x-1.5">
413
+ {scopedAgents.slice(0, 5).map((agent) => (
414
+ <AgentAvatar
415
+ key={agent.id}
416
+ seed={agent.avatarSeed}
417
+ avatarUrl={agent.avatarUrl}
418
+ name={agent.name}
419
+ size={16}
420
+ className="ring-1 ring-surface"
421
+ />
422
+ ))}
423
+ </div>
424
+ {scopedAgents.length > 5 && (
425
+ <span className="text-[10px] font-600 text-text-3/60">+{scopedAgents.length - 5}</span>
426
+ )}
427
+ </div>
428
+ )}
429
+ </div>
430
+ )
431
+ })}
207
432
  </div>
208
433
  ) : error ? (
209
434
  <div className="flex-1 flex flex-col items-center justify-center gap-3 text-text-3 p-8 text-center" style={{ animation: 'fade-up 0.5s var(--ease-spring)' }}>
@@ -217,7 +442,7 @@ export function KnowledgeList() {
217
442
  Retry
218
443
  </button>
219
444
  </div>
220
- ) : loaded ? (
445
+ ) : (
221
446
  <EmptyState
222
447
  icon={
223
448
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-accent-bright">
@@ -225,11 +450,11 @@ export function KnowledgeList() {
225
450
  <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
226
451
  </svg>
227
452
  }
228
- title="No knowledge entries yet"
229
- subtitle="Add shared knowledge for your agents"
453
+ title={showingHits ? 'No matching knowledge chunks' : 'No knowledge sources yet'}
454
+ subtitle={showingHits ? 'Try a broader query or clear filters' : 'Add a manual note, upload a file, or import a URL'}
230
455
  action={{ label: '+ Add Knowledge', onClick: () => openSheet() }}
231
456
  />
232
- ) : null}
457
+ )}
233
458
  </div>
234
459
  )
235
460
  }