@swarmclawai/swarmclaw 1.3.3 → 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 (76) hide show
  1. package/README.md +19 -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/providers/cli-utils.ts +3 -4
  33. package/src/lib/providers/index.ts +12 -22
  34. package/src/lib/providers/openclaw.ts +1 -2
  35. package/src/lib/server/agents/subagent-swarm.ts +2 -7
  36. package/src/lib/server/api-routes.test.ts +43 -2
  37. package/src/lib/server/chat-execution/chat-execution-grounding.test.ts +127 -0
  38. package/src/lib/server/chat-execution/chat-execution-types.ts +8 -1
  39. package/src/lib/server/chat-execution/chat-execution.ts +1 -0
  40. package/src/lib/server/chat-execution/chat-turn-finalization.ts +23 -6
  41. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +6 -1
  42. package/src/lib/server/chat-execution/post-stream-finalization.ts +15 -3
  43. package/src/lib/server/chat-execution/prompt-builder.ts +4 -6
  44. package/src/lib/server/chat-execution/prompt-sections.ts +29 -3
  45. package/src/lib/server/chat-execution/stream-agent-chat.ts +6 -1
  46. package/src/lib/server/connectors/openclaw.ts +1 -2
  47. package/src/lib/server/execution-engine/task-attempt.ts +8 -2
  48. package/src/lib/server/knowledge-import.ts +159 -0
  49. package/src/lib/server/knowledge-sources.test.ts +215 -0
  50. package/src/lib/server/knowledge-sources.ts +1266 -0
  51. package/src/lib/server/memory/dream-cycles.ts +49 -0
  52. package/src/lib/server/memory/dream-idle-callback.ts +38 -0
  53. package/src/lib/server/memory/dream-service.ts +315 -0
  54. package/src/lib/server/memory/memory-db.ts +37 -2
  55. package/src/lib/server/protocols/protocol-agent-turn.ts +7 -0
  56. package/src/lib/server/protocols/protocol-run-lifecycle.ts +19 -6
  57. package/src/lib/server/protocols/protocol-service.test.ts +99 -0
  58. package/src/lib/server/protocols/protocol-step-helpers.ts +7 -1
  59. package/src/lib/server/protocols/protocol-step-processors.ts +16 -3
  60. package/src/lib/server/protocols/protocol-types.ts +4 -0
  61. package/src/lib/server/provider-health.ts +2 -7
  62. package/src/lib/server/runtime/daemon-state/core.ts +6 -1
  63. package/src/lib/server/runtime/run-ledger.test.ts +120 -0
  64. package/src/lib/server/runtime/run-ledger.ts +27 -1
  65. package/src/lib/server/runtime/session-run-manager/drain.ts +5 -0
  66. package/src/lib/server/runtime/session-run-manager/state.ts +19 -2
  67. package/src/lib/server/storage-normalization.ts +5 -0
  68. package/src/lib/server/storage.ts +16 -1
  69. package/src/stores/slices/ui-slice.ts +4 -0
  70. package/src/types/agent.ts +7 -0
  71. package/src/types/dream.ts +45 -0
  72. package/src/types/index.ts +1 -0
  73. package/src/types/message.ts +3 -0
  74. package/src/types/misc.ts +131 -0
  75. package/src/types/protocol.ts +4 -0
  76. package/src/types/run.ts +4 -1
@@ -19,6 +19,8 @@ export function MemoryCard({ entry, active, agentName, agentAvatarSeed, agentAva
19
19
  const [now] = useState(() => Date.now())
20
20
  const scope = deriveMemoryScope(entry)
21
21
  const tier = getMemoryTier(entry)
22
+ const isDreamOrigin = entry.category === 'dream_reflection'
23
+ || (entry.metadata as Record<string, unknown> | undefined)?.origin === 'dream'
22
24
 
23
25
  return (
24
26
  <div
@@ -36,6 +38,11 @@ export function MemoryCard({ entry, active, agentName, agentAvatarSeed, agentAva
36
38
  <span className="shrink-0 text-[9px] font-700 uppercase tracking-wider text-accent-bright/70 bg-accent-soft px-1.5 py-0.5 rounded-[5px]">
37
39
  {entry.category || 'note'}
38
40
  </span>
41
+ {isDreamOrigin && (
42
+ <span className="shrink-0 text-[9px] font-700 uppercase tracking-wider text-violet-300/70 bg-violet-400/10 px-1.5 py-0.5 rounded-[5px]">
43
+ dream
44
+ </span>
45
+ )}
39
46
  {entry.pinned && (
40
47
  <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className="shrink-0 text-amber-400/80">
41
48
  <path d="M16 2l-4 4-4-4-2 2 4 4-5 5v1h1l5-5 4 4 2-2-4-4 4-4z" transform="rotate(45 12 12)" />
@@ -175,6 +175,19 @@ export function MemoryDetail() {
175
175
  const inputClass = "w-full px-4 py-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] text-text outline-none transition-all duration-200 placeholder:text-text-3/70 focus:border-accent-bright/20 focus:bg-white/[0.03]"
176
176
  const refs = entry.references || []
177
177
  const showRefsCollapse = refs.length > 3
178
+ const entryMeta = entry.metadata && typeof entry.metadata === 'object'
179
+ ? entry.metadata as Record<string, unknown>
180
+ : {}
181
+ const knowledgeSourceId = typeof entryMeta.sourceId === 'string' ? entryMeta.sourceId : null
182
+ const knowledgeSourceTitle = typeof entryMeta.sourceTitle === 'string' ? entryMeta.sourceTitle : null
183
+ const knowledgeSourceKind = typeof entryMeta.sourceKind === 'string' ? entryMeta.sourceKind : null
184
+ const knowledgeSourceLabel = typeof entryMeta.sourceLabel === 'string' ? entryMeta.sourceLabel : null
185
+ const knowledgeSourceUrl = typeof entryMeta.sourceUrl === 'string' ? entryMeta.sourceUrl : null
186
+ const knowledgeChunkIndex = typeof entryMeta.chunkIndex === 'number' ? entryMeta.chunkIndex : null
187
+ const knowledgeChunkCount = typeof entryMeta.chunkCount === 'number' ? entryMeta.chunkCount : null
188
+ const knowledgeSectionLabel = typeof entryMeta.sectionLabel === 'string' ? entryMeta.sectionLabel : null
189
+ const knowledgeCharStart = typeof entryMeta.charStart === 'number' ? entryMeta.charStart : null
190
+ const knowledgeCharEnd = typeof entryMeta.charEnd === 'number' ? entryMeta.charEnd : null
178
191
 
179
192
  return (
180
193
  <div className="flex-1 flex flex-col h-full min-h-0">
@@ -438,6 +451,33 @@ export function MemoryDetail() {
438
451
  {entry.content || '(empty)'}
439
452
  </div>
440
453
 
454
+ {knowledgeSourceId && (
455
+ <div className="rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-3">
456
+ <label className="block text-[11px] font-600 text-text-3/60 uppercase tracking-[0.06em] mb-2">Source</label>
457
+ <div className="space-y-1.5">
458
+ <p className="text-[13px] text-text-2">
459
+ {knowledgeSourceTitle || entry.title}
460
+ {knowledgeSourceKind ? ` • ${knowledgeSourceKind}` : ''}
461
+ </p>
462
+ {knowledgeSourceLabel && (
463
+ <p className="text-[12px] text-text-3/65">{knowledgeSourceLabel}</p>
464
+ )}
465
+ {knowledgeSourceUrl && (
466
+ <a href={knowledgeSourceUrl} target="_blank" rel="noreferrer" className="text-[12px] text-accent-bright hover:underline break-all">
467
+ {knowledgeSourceUrl}
468
+ </a>
469
+ )}
470
+ <p className="text-[11px] text-text-3/55">
471
+ {knowledgeChunkIndex != null && knowledgeChunkCount != null
472
+ ? `Chunk ${knowledgeChunkIndex + 1} of ${knowledgeChunkCount}`
473
+ : 'Source-backed knowledge'}
474
+ {knowledgeSectionLabel ? ` • ${knowledgeSectionLabel}` : ''}
475
+ {knowledgeCharStart != null && knowledgeCharEnd != null ? ` • chars ${knowledgeCharStart}-${knowledgeCharEnd}` : ''}
476
+ </p>
477
+ </div>
478
+ </div>
479
+ )}
480
+
441
481
  {/* Shared with (read mode) */}
442
482
  {entry.sharedWith && entry.sharedWith.length > 0 && (
443
483
  <div>
@@ -575,6 +615,12 @@ export function MemoryDetail() {
575
615
  <span className="text-text-3/70 block mb-1">Tier</span>
576
616
  <span className="text-text-3/60 font-mono">{tier}</span>
577
617
  </div>
618
+ {knowledgeSourceId && (
619
+ <div>
620
+ <span className="text-text-3/70 block mb-1">Knowledge Source</span>
621
+ <span className="text-text-3/60 font-mono">{knowledgeSourceId}</span>
622
+ </div>
623
+ )}
578
624
  {entry.sessionId && (
579
625
  <div>
580
626
  <span className="text-text-3/70 block mb-1">Chat</span>
@@ -8,6 +8,7 @@ import { BottomSheet } from '@/components/shared/bottom-sheet'
8
8
  import type { RunEventRecord, SessionRunRecord, SessionRunStatus } from '@/types'
9
9
  import { PageLoader } from '@/components/ui/page-loader'
10
10
  import { formatElapsed } from '@/lib/format-display'
11
+ import { GroundingPanel } from '@/components/knowledge/grounding-panel'
11
12
 
12
13
  const STATUS_COLORS: Record<SessionRunStatus, { bg: string; text: string }> = {
13
14
  queued: { bg: 'bg-yellow-500/10', text: 'text-yellow-400' },
@@ -84,6 +85,10 @@ export function RunList() {
84
85
  }, [])
85
86
 
86
87
  const filtered = statusFilter ? runs.filter((r) => r.status === statusFilter) : runs
88
+ const selectedResultGrounding = selectedEvents
89
+ .slice()
90
+ .reverse()
91
+ .find((event) => event.phase === 'status' && ((event.citations?.length || 0) > 0 || event.retrievalTrace?.hits?.length))
87
92
 
88
93
  if (loading) {
89
94
  return <PageLoader label="Loading runs..." />
@@ -240,6 +245,15 @@ export function RunList() {
240
245
  <pre className="text-[11px] text-text-3/80 font-mono whitespace-pre-wrap break-all bg-white/[0.02] rounded-[12px] p-4 max-h-[200px] overflow-auto border border-white/[0.04]">
241
246
  {selected.resultPreview}
242
247
  </pre>
248
+ {selectedResultGrounding && (
249
+ <div className="mt-3">
250
+ <GroundingPanel
251
+ citations={selectedResultGrounding.citations}
252
+ retrievalTrace={selectedResultGrounding.retrievalTrace}
253
+ compact
254
+ />
255
+ </div>
256
+ )}
243
257
  </div>
244
258
  )}
245
259
 
@@ -262,6 +276,15 @@ export function RunList() {
262
276
  <div className="text-[11px] text-text-2 whitespace-pre-wrap break-words">
263
277
  {event.summary || event.event.text || event.event.toolOutput || event.event.toolName || event.event.t}
264
278
  </div>
279
+ {(event.citations?.length || event.retrievalTrace?.hits?.length) ? (
280
+ <div className="mt-2">
281
+ <GroundingPanel
282
+ citations={event.citations}
283
+ retrievalTrace={event.retrievalTrace}
284
+ compact
285
+ />
286
+ </div>
287
+ ) : null}
265
288
  </div>
266
289
  ))}
267
290
  </div>
@@ -8,6 +8,7 @@
8
8
 
9
9
  import fs from 'fs'
10
10
  import os from 'os'
11
+ import { findBinaryOnPath } from '../server/session-tools/context'
11
12
  import path from 'path'
12
13
  import { spawnSync, type ChildProcess } from 'child_process'
13
14
  import { log } from '../server/logger'
@@ -63,10 +64,8 @@ function getNvmBinaryPaths(name: string): string[] {
63
64
  * then falls back to known paths + nvm paths.
64
65
  */
65
66
  export function resolveCliBinary(name: string, extraPaths?: string[]): string | null {
66
- // Lazy import to avoid circular dependency at module load
67
- // eslint-disable-next-line @typescript-eslint/no-require-imports
68
- const { findBinaryOnPath } = require('../server/session-tools/context')
69
- const fromPath = findBinaryOnPath(name) as string | null
67
+ const fromPath = findBinaryOnPath(name)
68
+
70
69
  if (fromPath) return fromPath
71
70
 
72
71
  const paths = [
@@ -11,6 +11,8 @@ import { errorMessage, sleep, jitteredBackoff } from '@/lib/shared-utils'
11
11
  import { classifyProviderError } from './error-classification'
12
12
  import { log } from '@/lib/server/logger'
13
13
  import type { ProviderInfo, ProviderConfig as CustomProviderConfig, ProviderType, ProviderId } from '../../types'
14
+ import { loadProviderConfigs, loadModelOverrides, loadStoredItem, loadCredentials, decryptKey } from '@/lib/server/storage'
15
+ import { getExtensionManager } from '@/lib/server/extensions'
14
16
 
15
17
  const TAG = 'providers'
16
18
 
@@ -289,8 +291,6 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
289
291
  /** Merge built-in providers with custom providers from storage */
290
292
  function getCustomProviders(): Record<string, CustomProviderConfig> {
291
293
  try {
292
- // eslint-disable-next-line @typescript-eslint/no-require-imports
293
- const { loadProviderConfigs } = require('../server/storage') as typeof import('@/lib/server/storage')
294
294
  const configs = loadProviderConfigs() as Record<string, CustomProviderConfig>
295
295
  return Object.fromEntries(
296
296
  Object.entries(configs).filter(([, config]) => config?.type === 'custom'),
@@ -303,8 +303,6 @@ function getCustomProviders(): Record<string, CustomProviderConfig> {
303
303
 
304
304
  function getModelOverrides(): Record<string, string[]> {
305
305
  try {
306
- // eslint-disable-next-line @typescript-eslint/no-require-imports
307
- const { loadModelOverrides } = require('../server/storage') as typeof import('@/lib/server/storage')
308
306
  return loadModelOverrides()
309
307
  } catch {
310
308
  return {}
@@ -342,17 +340,15 @@ export function getProviderList(): ProviderInfo[] {
342
340
 
343
341
  let extensionProviders: ProviderInfo[] = []
344
342
  try {
345
- // eslint-disable-next-line @typescript-eslint/no-require-imports
346
- const { getExtensionManager } = require('../server/extensions')
347
- extensionProviders = getExtensionManager().getProviders().map((p: Record<string, unknown>) => ({
348
- id: String(p.id) as ProviderId,
349
- name: String(p.name),
350
- models: p.models as string[],
351
- defaultModels: p.models as string[],
352
- supportsModelDiscovery: Boolean(p.supportsModelDiscovery),
353
- requiresApiKey: Boolean(p.requiresApiKey),
354
- requiresEndpoint: Boolean(p.requiresEndpoint),
355
- defaultEndpoint: p.defaultEndpoint as string | undefined,
343
+ extensionProviders = getExtensionManager().getProviders().map((p) => ({
344
+ id: p.id as ProviderId,
345
+ name: p.name,
346
+ models: p.models,
347
+ defaultModels: p.models,
348
+ supportsModelDiscovery: false,
349
+ requiresApiKey: p.requiresApiKey,
350
+ requiresEndpoint: p.requiresEndpoint,
351
+ defaultEndpoint: p.defaultEndpoint,
356
352
  }))
357
353
  } catch { /* ignore if running somewhere extensions aren't available */ }
358
354
 
@@ -391,8 +387,6 @@ export function getProvider(id: string): BuiltinProviderConfig | null {
391
387
  // Fallback: direct single-item DB lookup for custom-* providers
392
388
  if (id.startsWith('custom-') && !custom) {
393
389
  try {
394
- // eslint-disable-next-line @typescript-eslint/no-require-imports
395
- const { loadStoredItem } = require('../server/storage') as typeof import('@/lib/server/storage')
396
390
  const directConfig = loadStoredItem('provider_configs', id) as CustomProviderConfig | null
397
391
  if (directConfig?.type === 'custom' && directConfig.isEnabled) {
398
392
  log.info(TAG, `Resolved custom provider '${id}' via direct DB lookup (batch load missed it)`)
@@ -405,10 +399,8 @@ export function getProvider(id: string): BuiltinProviderConfig | null {
405
399
 
406
400
  // Check Extension Providers
407
401
  try {
408
- // eslint-disable-next-line @typescript-eslint/no-require-imports
409
- const { getExtensionManager } = require('../server/extensions')
410
402
  const extensionProviders = getExtensionManager().getProviders()
411
- const found = extensionProviders.find((p: Record<string, unknown>) => p.id === id)
403
+ const found = extensionProviders.find((p) => p.id === id)
412
404
  if (found) {
413
405
  return {
414
406
  id: found.id as ProviderId,
@@ -455,8 +447,6 @@ export async function streamChatWithFailover(
455
447
  let apiKey: string | null = opts.apiKey || null
456
448
  if (credId && i > 0) {
457
449
  // Need to decrypt fallback credential
458
- // eslint-disable-next-line @typescript-eslint/no-require-imports
459
- const { loadCredentials, decryptKey } = require('../server/storage') as typeof import('@/lib/server/storage')
460
450
  const creds = loadCredentials()
461
451
  const cred = creds[credId]
462
452
  if (cred?.encryptedKey) {
@@ -8,6 +8,7 @@ import type { Agent } from '@/types'
8
8
  import { deriveOpenClawWsUrl } from '@/lib/openclaw/openclaw-endpoint'
9
9
  import { normalizeOpenClawAgentId } from '@/lib/openclaw/openclaw-agent-id'
10
10
  import { loadAgents } from '../server/storage'
11
+ import { getSharedDeviceToken } from '../server/openclaw/sync'
11
12
  import {
12
13
  resolveOpenClawGatewayAgentIdFromList,
13
14
  type OpenClawGatewayAgentSummary,
@@ -74,8 +75,6 @@ function tryLoadIdentityFile(filePath: string): DeviceIdentity | null {
74
75
  function loadOrCreateDeviceIdentity(): DeviceIdentity {
75
76
  // 0. Check shared device token for cross-synced identity
76
77
  try {
77
- // eslint-disable-next-line @typescript-eslint/no-require-imports
78
- const { getSharedDeviceToken } = require('../server/openclaw/sync')
79
78
  const sharedToken = getSharedDeviceToken()
80
79
  if (sharedToken) {
81
80
  // Shared token exists — the connector has already paired.
@@ -17,6 +17,7 @@ import { logActivity } from '@/lib/server/activity/activity-log'
17
17
  import { createNotification } from '@/lib/server/create-notification'
18
18
  import { notify } from '@/lib/server/ws-hub'
19
19
  import { loadAgents } from '@/lib/server/agents/agent-repository'
20
+ import { upsertStoredItem, loadStoredItem, loadCollection } from '../storage'
20
21
  import {
21
22
  spawnSubagent,
22
23
  type SubagentContext,
@@ -159,8 +160,6 @@ function notifySwarmChanged() {
159
160
 
160
161
  function persistSwarmSnapshot(swarm: SwarmHandle): void {
161
162
  try {
162
- // eslint-disable-next-line @typescript-eslint/no-require-imports
163
- const { upsertStoredItem } = require('../storage')
164
163
  upsertStoredItem('swarm_snapshots', swarm.swarmId, {
165
164
  swarmId: swarm.swarmId,
166
165
  parentSessionId: swarm.parentSessionId,
@@ -549,8 +548,6 @@ export function getSwarmSnapshot(swarmId: string): SwarmSnapshot | null {
549
548
  if (swarm) return buildSwarmSnapshot(swarm)
550
549
  // Fallback to persisted store for swarms from previous process lifetimes
551
550
  try {
552
- // eslint-disable-next-line @typescript-eslint/no-require-imports
553
- const { loadStoredItem } = require('../storage')
554
551
  const persisted = loadStoredItem('swarm_snapshots', swarmId)
555
552
  return persisted ? (persisted as SwarmSnapshot) : null
556
553
  } catch { return null }
@@ -640,9 +637,7 @@ function buildSwarmSnapshot(swarm: SwarmHandle): SwarmSnapshot {
640
637
  */
641
638
  export function restoreSwarmRegistry(): number {
642
639
  try {
643
- // eslint-disable-next-line @typescript-eslint/no-require-imports
644
- const { loadCollection, upsertStoredItem } = require('../storage')
645
- const persisted = loadCollection('swarm_snapshots') as Record<string, SwarmSnapshot>
640
+ const persisted = loadCollection('swarm_snapshots') as unknown as Record<string, SwarmSnapshot>
646
641
  let lost = 0
647
642
  for (const [id, record] of Object.entries(persisted)) {
648
643
  if (swarmRegistry.has(id)) continue
@@ -78,9 +78,16 @@ function parseKnowledgeQueryParams(url: string) {
78
78
  const q = searchParams.get('q')
79
79
  const tagsParam = searchParams.get('tags')
80
80
  const limitParam = searchParams.get('limit')
81
+ const includeArchivedParam = searchParams.get('includeArchived')
81
82
  const tags = tagsParam ? tagsParam.split(',').map((t) => t.trim()).filter(Boolean) : undefined
82
83
  const limit = limitParam ? Math.max(1, Math.min(500, Number.parseInt(limitParam, 10) || 50)) : undefined
83
- return { q, tags, limit }
84
+ const normalizedIncludeArchived = typeof includeArchivedParam === 'string'
85
+ ? includeArchivedParam.trim().toLowerCase()
86
+ : ''
87
+ const includeArchived = normalizedIncludeArchived === '1'
88
+ || normalizedIncludeArchived === 'true'
89
+ || normalizedIncludeArchived === 'yes'
90
+ return { q, tags, limit, includeArchived }
84
91
  }
85
92
 
86
93
  // ---------------------------------------------------------------------------
@@ -91,7 +98,12 @@ const thisFile = new URL(import.meta.url).pathname
91
98
  const routeDir = path.resolve(path.dirname(thisFile), '../../app/api')
92
99
 
93
100
  function readRoute(...segments: string[]): string {
94
- return fs.readFileSync(path.join(routeDir, ...segments), 'utf-8')
101
+ const direct = path.join(routeDir, ...segments)
102
+ if (fs.existsSync(direct)) {
103
+ return fs.readFileSync(direct, 'utf-8')
104
+ }
105
+ const withTs = direct.endsWith('.ts') ? direct : `${direct}.ts`
106
+ return fs.readFileSync(withTs, 'utf-8')
95
107
  }
96
108
 
97
109
  // ===========================================================================
@@ -225,6 +237,12 @@ describe('Knowledge API contract', () => {
225
237
  it('returns undefined limit when param is absent', () => {
226
238
  assert.equal(parseKnowledgeQueryParams('http://localhost/api/knowledge').limit, undefined)
227
239
  })
240
+
241
+ it('parses includeArchived as a boolean flag', () => {
242
+ assert.equal(parseKnowledgeQueryParams('http://localhost/api/knowledge?includeArchived=true').includeArchived, true)
243
+ assert.equal(parseKnowledgeQueryParams('http://localhost/api/knowledge?includeArchived=1').includeArchived, true)
244
+ assert.equal(parseKnowledgeQueryParams('http://localhost/api/knowledge?includeArchived=no').includeArchived, false)
245
+ })
228
246
  })
229
247
 
230
248
  // --- Route file structure -----------------------------------------------
@@ -241,6 +259,29 @@ describe('Knowledge API contract', () => {
241
259
  assert.match(src, /export\s+async\s+function\s+PUT/)
242
260
  assert.match(src, /export\s+async\s+function\s+DELETE/)
243
261
  })
262
+
263
+ it('knowledge/hygiene/route.ts exports GET and POST', () => {
264
+ const src = readRoute('knowledge', 'hygiene', 'route.ts')
265
+ assert.match(src, /export\s+async\s+function\s+GET/)
266
+ assert.match(src, /export\s+async\s+function\s+POST/)
267
+ })
268
+
269
+ it('knowledge/sources/route.ts re-exports GET and POST', () => {
270
+ const src = readRoute('knowledge', 'sources', 'route.ts')
271
+ assert.match(src, /export\s+\{\s*GET,\s*POST\s*\}/)
272
+ })
273
+
274
+ it('knowledge/sources/[id]/route.ts re-exports GET, PUT, DELETE', () => {
275
+ const src = readRoute('knowledge', 'sources', '[id]', 'route.ts')
276
+ assert.match(src, /export\s+\{\s*GET,\s*PUT,\s*DELETE\s*\}/)
277
+ })
278
+
279
+ it('knowledge source action routes export POST', () => {
280
+ for (const route of ['archive', 'restore', 'supersede', 'sync']) {
281
+ const src = readRoute('knowledge', 'sources', '[id]', route, 'route.ts')
282
+ assert.match(src, /export\s+async\s+function\s+POST/)
283
+ }
284
+ })
244
285
  })
245
286
  })
246
287
 
@@ -0,0 +1,127 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import test from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-chat-grounding-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: path.join(tempDir, 'data'),
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'),
20
+ },
21
+ encoding: 'utf-8',
22
+ })
23
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
24
+ const lines = (result.stdout || '')
25
+ .trim()
26
+ .split('\n')
27
+ .map((line) => line.trim())
28
+ .filter(Boolean)
29
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
30
+ return JSON.parse(jsonLine || '{}')
31
+ } finally {
32
+ fs.rmSync(tempDir, { recursive: true, force: true })
33
+ }
34
+ }
35
+
36
+ test('executeSessionChatTurn persists citations and retrieval traces on grounded assistant messages', () => {
37
+ const output = runWithTempDataDir(`
38
+ const storageMod = await import('@/lib/server/storage')
39
+ const providersMod = await import('@/lib/providers/index')
40
+ const threadMod = await import('@/lib/server/agents/agent-thread-session')
41
+ const execMod = await import('@/lib/server/chat-execution/chat-execution')
42
+ const messageRepoMod = await import('@/lib/server/messages/message-repository')
43
+ const knowledgeMod = await import('@/lib/server/knowledge-sources')
44
+
45
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
46
+ const ensureAgentThreadSession = threadMod.ensureAgentThreadSession
47
+ || threadMod.default?.ensureAgentThreadSession
48
+ || threadMod['module.exports']?.ensureAgentThreadSession
49
+ const executeSessionChatTurn = execMod.executeSessionChatTurn
50
+ || execMod.default?.executeSessionChatTurn
51
+ || execMod['module.exports']?.executeSessionChatTurn
52
+ const getMessages = messageRepoMod.getMessages
53
+ || messageRepoMod.default?.getMessages
54
+ || messageRepoMod['module.exports']?.getMessages
55
+ const knowledge = knowledgeMod.default || knowledgeMod
56
+ const providers = providersMod.PROVIDERS
57
+ || providersMod.default?.PROVIDERS
58
+ || providersMod['module.exports']?.PROVIDERS
59
+
60
+ providers['test-provider'] = {
61
+ id: 'test-provider',
62
+ name: 'Test Provider',
63
+ models: ['unit'],
64
+ requiresApiKey: false,
65
+ requiresEndpoint: false,
66
+ handler: {
67
+ async streamChat() {
68
+ return 'Use blue green deployment for the gateway migration so rollback stays simple.'
69
+ },
70
+ },
71
+ }
72
+
73
+ const now = Date.now()
74
+ storage.saveAgents({
75
+ molly: {
76
+ id: 'molly',
77
+ name: 'Molly',
78
+ description: 'Grounding test',
79
+ provider: 'test-provider',
80
+ model: 'unit',
81
+ credentialId: null,
82
+ apiEndpoint: null,
83
+ fallbackCredentialIds: [],
84
+ disabled: false,
85
+ proactiveMemory: true,
86
+ extensions: ['memory'],
87
+ createdAt: now,
88
+ updatedAt: now,
89
+ },
90
+ })
91
+
92
+ await knowledge.createKnowledgeSource({
93
+ kind: 'manual',
94
+ title: 'Gateway Migration Runbook',
95
+ content: 'Use blue green deployment for gateway migrations so rollback stays simple and downtime stays low.',
96
+ tags: ['deploy'],
97
+ })
98
+
99
+ const session = ensureAgentThreadSession('molly')
100
+ const result = await executeSessionChatTurn({
101
+ sessionId: session.id,
102
+ message: 'gateway blue green rollback',
103
+ runId: 'run-grounding-chat',
104
+ })
105
+
106
+ const messages = getMessages(session.id)
107
+ const lastMessage = messages[messages.length - 1]
108
+
109
+ console.log(JSON.stringify({
110
+ persisted: result.persisted || false,
111
+ resultCitationCount: Array.isArray(result.citations) ? result.citations.length : 0,
112
+ resultSelectorStatus: result.retrievalTrace?.selectorStatus || null,
113
+ messageCitationCount: Array.isArray(lastMessage?.citations) ? lastMessage.citations.length : 0,
114
+ messageTraceHitCount: Array.isArray(lastMessage?.retrievalTrace?.hits) ? lastMessage.retrievalTrace.hits.length : 0,
115
+ messageSelectorStatus: lastMessage?.retrievalTrace?.selectorStatus || null,
116
+ messageSourceTitle: lastMessage?.citations?.[0]?.sourceTitle || null,
117
+ }))
118
+ `)
119
+
120
+ assert.equal(output.persisted, true)
121
+ assert.equal(output.resultCitationCount >= 1, true)
122
+ assert.equal(output.resultSelectorStatus, 'selected')
123
+ assert.equal(output.messageCitationCount >= 1, true)
124
+ assert.equal(output.messageTraceHitCount >= 1, true)
125
+ assert.equal(output.messageSelectorStatus, 'selected')
126
+ assert.equal(output.messageSourceTitle, 'Gateway Migration Runbook')
127
+ })
@@ -1,4 +1,9 @@
1
- import type { MessageToolEvent, SSEEvent } from '@/types'
1
+ import type {
2
+ KnowledgeCitation,
3
+ KnowledgeRetrievalTrace,
4
+ MessageToolEvent,
5
+ SSEEvent,
6
+ } from '@/types'
2
7
 
3
8
  export interface ExecuteChatTurnInput {
4
9
  sessionId: string
@@ -33,4 +38,6 @@ export interface ExecuteChatTurnResult {
33
38
  inputTokens?: number
34
39
  outputTokens?: number
35
40
  estimatedCost?: number
41
+ citations?: KnowledgeCitation[]
42
+ retrievalTrace?: KnowledgeRetrievalTrace | null
36
43
  }
@@ -112,6 +112,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
112
112
  responseCacheHit: streamResult.responseCacheHit,
113
113
  directUsage: streamResult.directUsage,
114
114
  durationMs: streamResult.durationMs,
115
+ knowledgeRetrievalTrace: streamResult.knowledgeRetrievalTrace || null,
115
116
  emit: partialPersistence.emit,
116
117
  })
117
118
 
@@ -1,4 +1,12 @@
1
- import type { Message, MessageToolEvent, SSEEvent, Session, UsageRecord } from '@/types'
1
+ import type {
2
+ KnowledgeRetrievalTrace,
3
+ Message,
4
+ MessageToolEvent,
5
+ SSEEvent,
6
+ Session,
7
+ UsageRecord,
8
+ } from '@/types'
9
+ import { sendConnectorMessage } from '../connectors/manager'
2
10
  import { applyExactOutputContract, classifyExactOutputContract, type ExactOutputContract } from '@/lib/server/chat-execution/exact-output-contract'
3
11
  import { stripMainLoopMetaForPersistence } from '@/lib/server/agents/main-agent-loop'
4
12
  import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '@/lib/server/agents/assistant-control'
@@ -42,6 +50,7 @@ import {
42
50
  import { appendUsage } from '@/lib/server/usage/usage-repository'
43
51
  import { synchronizeWorkingStateForTurn } from '@/lib/server/working-state/service'
44
52
  import { notify } from '@/lib/server/ws-hub'
53
+ import { selectKnowledgeCitations } from '@/lib/server/knowledge-sources'
45
54
 
46
55
  import type { ExecuteChatTurnInput, ExecuteChatTurnResult } from './chat-execution-types'
47
56
  import type { PartialAssistantPersistence } from '@/lib/server/chat-execution/chat-turn-partial-persistence'
@@ -153,6 +162,7 @@ export async function finalizeChatTurn(params: {
153
162
  received: boolean
154
163
  }
155
164
  durationMs: number
165
+ knowledgeRetrievalTrace?: KnowledgeRetrievalTrace | null
156
166
  emit: (event: SSEEvent) => void
157
167
  }): Promise<ExecuteChatTurnResult> {
158
168
  const {
@@ -163,6 +173,7 @@ export async function finalizeChatTurn(params: {
163
173
  responseCacheHit,
164
174
  directUsage,
165
175
  durationMs,
176
+ knowledgeRetrievalTrace,
166
177
  emit,
167
178
  } = params
168
179
  let { fullResponse, errorMessage } = params
@@ -312,6 +323,12 @@ export async function finalizeChatTurn(params: {
312
323
  const hiddenControlOnly = shouldSuppressHiddenControlText(rawTextForPersistence)
313
324
  const textForPersistence = stripHiddenControlTokens(rawTextForPersistence)
314
325
  const persistedText = getPersistedAssistantText(textForPersistence, persistedToolEvents)
326
+ const grounding = hiddenControlOnly
327
+ ? { citations: [], retrievalTrace: knowledgeRetrievalTrace || null }
328
+ : selectKnowledgeCitations({
329
+ responseText: persistedText,
330
+ retrievalTrace: knowledgeRetrievalTrace || null,
331
+ })
315
332
  let persistedResponseForHooks = textForPersistence
316
333
 
317
334
  if (isHeartbeatRun && rawTextForPersistence) {
@@ -412,6 +429,8 @@ export async function finalizeChatTurn(params: {
412
429
  thinking: thinkingText || undefined,
413
430
  toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
414
431
  kind: persistedKind,
432
+ citations: grounding.citations.length > 0 ? grounding.citations : undefined,
433
+ retrievalTrace: grounding.retrievalTrace || undefined,
415
434
  },
416
435
  enabledIds: extensionsForRun,
417
436
  phase: isHeartbeatRun ? 'heartbeat' : 'assistant_final',
@@ -478,8 +497,6 @@ export async function finalizeChatTurn(params: {
478
497
  && heartbeatConfig.target !== 'none'
479
498
  ) {
480
499
  try {
481
- // eslint-disable-next-line @typescript-eslint/no-require-imports
482
- const { sendConnectorMessage } = require('../connectors/manager')
483
500
  let connectorId: string | undefined
484
501
  let channelId: string | undefined
485
502
  if (heartbeatConfig.target === 'last') {
@@ -527,9 +544,7 @@ export async function finalizeChatTurn(params: {
527
544
  : ''
528
545
  if (!recentInbound && channelId) {
529
546
  try {
530
- // eslint-disable-next-line @typescript-eslint/no-require-imports
531
- const { sendConnectorMessage: sendMsg } = require('../connectors/manager')
532
- sendMsg({ connectorId: connectorId || undefined, channelId, text: nextAssistantMessage.text }).catch((err: unknown) => {
547
+ sendConnectorMessage({ connectorId: connectorId || undefined, channelId, text: nextAssistantMessage.text }).catch((err: unknown) => {
533
548
  log.warn('connector', 'Auto-route connector delivery failed', {
534
549
  connectorId,
535
550
  channelId,
@@ -646,5 +661,7 @@ export async function finalizeChatTurn(params: {
646
661
  inputTokens: accumulatedUsage.inputTokens || undefined,
647
662
  outputTokens: accumulatedUsage.outputTokens || undefined,
648
663
  estimatedCost: accumulatedUsage.estimatedCost || undefined,
664
+ citations: grounding.citations.length > 0 ? grounding.citations : undefined,
665
+ retrievalTrace: grounding.retrievalTrace || undefined,
649
666
  }
650
667
  }
@@ -1,5 +1,5 @@
1
1
  import { CONTEXT_OVERFLOW_RE } from '@/lib/providers/error-classification'
2
- import type { ProviderType } from '@/types'
2
+ import type { KnowledgeRetrievalTrace, ProviderType } from '@/types'
3
3
  import { getEnabledCapabilityIds } from '@/lib/capability-selection'
4
4
  import { isLocalOpenClawEndpoint } from '@/lib/openclaw/openclaw-endpoint'
5
5
  import { streamAgentChat } from '@/lib/server/chat-execution/stream-agent-chat'
@@ -42,6 +42,7 @@ export interface ExecutedPreparedChatTurn {
42
42
  outputTokens: number
43
43
  received: boolean
44
44
  }
45
+ knowledgeRetrievalTrace?: KnowledgeRetrievalTrace | null
45
46
  }
46
47
 
47
48
  export async function executePreparedChatTurn(params: {
@@ -90,6 +91,7 @@ export async function executePreparedChatTurn(params: {
90
91
  let responseCacheHit = false
91
92
  let responseCacheInput: LlmResponseCacheKeyInput | null = null
92
93
  let durationMs = 0
94
+ let knowledgeRetrievalTrace: KnowledgeRetrievalTrace | null = null
93
95
  const startTs = Date.now()
94
96
  const endLlmPerf = perf.start('chat-execution', 'llm-round-trip', {
95
97
  sessionId,
@@ -111,6 +113,7 @@ export async function executePreparedChatTurn(params: {
111
113
  responseCacheHit,
112
114
  durationMs,
113
115
  directUsage,
116
+ knowledgeRetrievalTrace: null,
114
117
  }
115
118
  }
116
119
 
@@ -157,6 +160,7 @@ export async function executePreparedChatTurn(params: {
157
160
  promptMode,
158
161
  })
159
162
  fullResponse = result.finalResponse || result.fullText
163
+ knowledgeRetrievalTrace = result.knowledgeRetrievalTrace || null
160
164
  } else {
161
165
  let directHistorySnapshot = isAutoRunNoHistory
162
166
  ? (heartbeatLightContext ? [] : getSessionMessages(sessionId).slice(-6))
@@ -298,5 +302,6 @@ export async function executePreparedChatTurn(params: {
298
302
  responseCacheHit,
299
303
  durationMs,
300
304
  directUsage,
305
+ knowledgeRetrievalTrace,
301
306
  }
302
307
  }