@swarmclawai/swarmclaw 1.3.4 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +20 -76
  2. package/package.json +3 -2
  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-disabled.test.ts +14 -31
  34. package/src/lib/server/chat-execution/chat-execution-eval-history.test.ts +11 -34
  35. package/src/lib/server/chat-execution/chat-execution-grounding.test.ts +108 -0
  36. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +35 -36
  37. package/src/lib/server/chat-execution/chat-execution-types.ts +8 -1
  38. package/src/lib/server/chat-execution/chat-execution.ts +1 -0
  39. package/src/lib/server/chat-execution/chat-turn-finalization.ts +21 -1
  40. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +6 -1
  41. package/src/lib/server/chat-execution/post-stream-finalization.ts +15 -3
  42. package/src/lib/server/chat-execution/prompt-sections.ts +29 -3
  43. package/src/lib/server/chat-execution/stream-agent-chat.ts +6 -1
  44. package/src/lib/server/execution-engine/task-attempt.ts +8 -2
  45. package/src/lib/server/knowledge-import.ts +159 -0
  46. package/src/lib/server/knowledge-sources.test.ts +261 -0
  47. package/src/lib/server/knowledge-sources.ts +1284 -0
  48. package/src/lib/server/memory/dream-cycles.ts +49 -0
  49. package/src/lib/server/memory/dream-idle-callback.ts +38 -0
  50. package/src/lib/server/memory/dream-service.ts +315 -0
  51. package/src/lib/server/memory/memory-db.ts +37 -2
  52. package/src/lib/server/protocols/protocol-agent-turn.ts +7 -0
  53. package/src/lib/server/protocols/protocol-run-lifecycle.ts +19 -6
  54. package/src/lib/server/protocols/protocol-service.test.ts +99 -0
  55. package/src/lib/server/protocols/protocol-step-helpers.ts +7 -1
  56. package/src/lib/server/protocols/protocol-step-processors.ts +16 -3
  57. package/src/lib/server/protocols/protocol-types.ts +4 -0
  58. package/src/lib/server/runtime/daemon-state/core.ts +6 -1
  59. package/src/lib/server/runtime/run-ledger.test.ts +120 -0
  60. package/src/lib/server/runtime/run-ledger.ts +27 -1
  61. package/src/lib/server/runtime/session-run-manager/drain.ts +5 -0
  62. package/src/lib/server/runtime/session-run-manager/state.ts +19 -2
  63. package/src/lib/server/storage-normalization.ts +5 -0
  64. package/src/lib/server/storage.ts +15 -0
  65. package/src/lib/server/test-utils/run-with-temp-data-dir.ts +15 -2
  66. package/src/stores/slices/ui-slice.ts +4 -0
  67. package/src/types/agent.ts +7 -0
  68. package/src/types/dream.ts +45 -0
  69. package/src/types/index.ts +1 -0
  70. package/src/types/message.ts +3 -0
  71. package/src/types/misc.ts +131 -0
  72. package/src/types/protocol.ts +4 -0
  73. 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>
@@ -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
 
@@ -1,40 +1,23 @@
1
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
2
  import test from 'node:test'
3
+ import { runWithTempDataDir as runWithSharedTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
7
4
 
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-disabled-'))
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
- }
5
+ function runWithTempDataDir<T = unknown>(script: string): T {
6
+ return runWithSharedTempDataDir<T>(script, {
7
+ prefix: 'swarmclaw-chat-disabled-',
8
+ dataDir: 'data',
9
+ browserProfilesDir: 'browser-profiles',
10
+ })
34
11
  }
35
12
 
36
13
  test('executeSessionChatTurn persists a visible error for disabled agents', () => {
37
- const output = runWithTempDataDir(`
14
+ const output = runWithTempDataDir<{
15
+ error: string | null
16
+ text: string | null
17
+ persisted: boolean
18
+ lastRole: string | null
19
+ lastText: string | null
20
+ }>(`
38
21
  const storageMod = await import('@/lib/server/storage')
39
22
  const storage = storageMod.default || storageMod['module.exports'] || storageMod
40
23
  const threadMod = await import('@/lib/server/agents/agent-thread-session')
@@ -1,40 +1,13 @@
1
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
2
  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-eval-history-'))
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
- }
3
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
35
4
 
36
5
  test('executeSessionChatTurn persists internal eval user turns for same-thread recall', () => {
37
- const output = runWithTempDataDir(`
6
+ const output = runWithTempDataDir<{
7
+ recallText: string | null
8
+ roles: string[]
9
+ texts: string[]
10
+ }>(`
38
11
  const storageMod = await import('@/lib/server/storage')
39
12
  const storage = storageMod.default || storageMod['module.exports'] || storageMod
40
13
  const providersMod = await import('@/lib/providers/index')
@@ -103,7 +76,11 @@ test('executeSessionChatTurn persists internal eval user turns for same-thread r
103
76
  roles: storedSession.messages.map((entry) => entry.role),
104
77
  texts: storedSession.messages.map((entry) => entry.text),
105
78
  }))
106
- `)
79
+ `, {
80
+ prefix: 'swarmclaw-chat-eval-history-',
81
+ dataDir: 'data',
82
+ browserProfilesDir: 'browser-profiles',
83
+ })
107
84
 
108
85
  assert.match(String(output.recallText || ''), /Sunbird/)
109
86
  assert.deepEqual(output.roles, ['user', 'assistant', 'user', 'assistant'])
@@ -0,0 +1,108 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
4
+
5
+ test('executeSessionChatTurn persists citations and retrieval traces on grounded assistant messages', () => {
6
+ const output = runWithTempDataDir<{
7
+ persisted: boolean
8
+ resultCitationCount: number
9
+ resultSelectorStatus: string | null
10
+ messageCitationCount: number
11
+ messageTraceHitCount: number
12
+ messageSelectorStatus: string | null
13
+ messageSourceTitle: string | null
14
+ }>(`
15
+ const storageMod = await import('@/lib/server/storage')
16
+ const providersMod = await import('@/lib/providers/index')
17
+ const threadMod = await import('@/lib/server/agents/agent-thread-session')
18
+ const execMod = await import('@/lib/server/chat-execution/chat-execution')
19
+ const messageRepoMod = await import('@/lib/server/messages/message-repository')
20
+ const knowledgeMod = await import('@/lib/server/knowledge-sources')
21
+
22
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
23
+ const ensureAgentThreadSession = threadMod.ensureAgentThreadSession
24
+ || threadMod.default?.ensureAgentThreadSession
25
+ || threadMod['module.exports']?.ensureAgentThreadSession
26
+ const executeSessionChatTurn = execMod.executeSessionChatTurn
27
+ || execMod.default?.executeSessionChatTurn
28
+ || execMod['module.exports']?.executeSessionChatTurn
29
+ const getMessages = messageRepoMod.getMessages
30
+ || messageRepoMod.default?.getMessages
31
+ || messageRepoMod['module.exports']?.getMessages
32
+ const knowledge = knowledgeMod.default || knowledgeMod
33
+ const providers = providersMod.PROVIDERS
34
+ || providersMod.default?.PROVIDERS
35
+ || providersMod['module.exports']?.PROVIDERS
36
+
37
+ providers['test-provider'] = {
38
+ id: 'test-provider',
39
+ name: 'Test Provider',
40
+ models: ['unit'],
41
+ requiresApiKey: false,
42
+ requiresEndpoint: false,
43
+ handler: {
44
+ async streamChat() {
45
+ return 'Use blue green deployment for the gateway migration so rollback stays simple.'
46
+ },
47
+ },
48
+ }
49
+
50
+ const now = Date.now()
51
+ storage.saveAgents({
52
+ molly: {
53
+ id: 'molly',
54
+ name: 'Molly',
55
+ description: 'Grounding test',
56
+ provider: 'test-provider',
57
+ model: 'unit',
58
+ credentialId: null,
59
+ apiEndpoint: null,
60
+ fallbackCredentialIds: [],
61
+ disabled: false,
62
+ proactiveMemory: true,
63
+ extensions: ['memory'],
64
+ createdAt: now,
65
+ updatedAt: now,
66
+ },
67
+ })
68
+
69
+ await knowledge.createKnowledgeSource({
70
+ kind: 'manual',
71
+ title: 'Gateway Migration Runbook',
72
+ content: 'Use blue green deployment for gateway migrations so rollback stays simple and downtime stays low.',
73
+ tags: ['deploy'],
74
+ })
75
+
76
+ const session = ensureAgentThreadSession('molly')
77
+ const result = await executeSessionChatTurn({
78
+ sessionId: session.id,
79
+ message: 'gateway blue green rollback',
80
+ runId: 'run-grounding-chat',
81
+ })
82
+
83
+ const messages = getMessages(session.id)
84
+ const lastMessage = messages[messages.length - 1]
85
+
86
+ console.log(JSON.stringify({
87
+ persisted: result.persisted || false,
88
+ resultCitationCount: Array.isArray(result.citations) ? result.citations.length : 0,
89
+ resultSelectorStatus: result.retrievalTrace?.selectorStatus || null,
90
+ messageCitationCount: Array.isArray(lastMessage?.citations) ? lastMessage.citations.length : 0,
91
+ messageTraceHitCount: Array.isArray(lastMessage?.retrievalTrace?.hits) ? lastMessage.retrievalTrace.hits.length : 0,
92
+ messageSelectorStatus: lastMessage?.retrievalTrace?.selectorStatus || null,
93
+ messageSourceTitle: lastMessage?.citations?.[0]?.sourceTitle || null,
94
+ }))
95
+ `, {
96
+ prefix: 'swarmclaw-chat-grounding-',
97
+ dataDir: 'data',
98
+ browserProfilesDir: 'browser-profiles',
99
+ })
100
+
101
+ assert.equal(output.persisted, true)
102
+ assert.equal(output.resultCitationCount >= 1, true)
103
+ assert.equal(output.resultSelectorStatus, 'selected')
104
+ assert.equal(output.messageCitationCount >= 1, true)
105
+ assert.equal(output.messageTraceHitCount >= 1, true)
106
+ assert.equal(output.messageSelectorStatus, 'selected')
107
+ assert.equal(output.messageSourceTitle, 'Gateway Migration Runbook')
108
+ })
@@ -1,40 +1,24 @@
1
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
2
  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-session-sync-'))
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
- }
3
+ import { runWithTempDataDir as runWithSharedTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
4
+
5
+ function runWithTempDataDir<T = unknown>(script: string): T {
6
+ return runWithSharedTempDataDir<T>(script, {
7
+ prefix: 'swarmclaw-chat-session-sync-',
8
+ dataDir: 'data',
9
+ browserProfilesDir: 'browser-profiles',
10
+ })
34
11
  }
35
12
 
36
13
  test('executeSessionChatTurn syncs updated agent runtime fields onto its thread session', () => {
37
- const output = runWithTempDataDir(`
14
+ const output = runWithTempDataDir<{
15
+ provider: string | null
16
+ model: string | null
17
+ extensions: string[]
18
+ heartbeatEnabled: boolean | null
19
+ heartbeatIntervalSec: number | null
20
+ connectorContext: Record<string, unknown> | null
21
+ }>(`
38
22
  const storageMod = await import('@/lib/server/storage')
39
23
  const storage = storageMod.default || storageMod['module.exports'] || storageMod
40
24
  const providersMod = await import('@/lib/providers/index')
@@ -126,7 +110,12 @@ test('executeSessionChatTurn syncs updated agent runtime fields onto its thread
126
110
  })
127
111
 
128
112
  test('executeSessionChatTurn keeps tool-only heartbeats off the visible main-thread history and clears stale connector state', () => {
129
- const output = runWithTempDataDir(`
113
+ const output = runWithTempDataDir<{
114
+ connectorContext: Record<string, unknown> | null
115
+ messageCount: number
116
+ lastMessageText: string | null
117
+ heartbeatKinds: number
118
+ }>(`
130
119
  const storageMod = await import('@/lib/server/storage')
131
120
  const providersMod = await import('@/lib/providers/index')
132
121
  const execMod = await import('@/lib/server/chat-execution/chat-execution')
@@ -232,7 +221,11 @@ test('executeSessionChatTurn keeps tool-only heartbeats off the visible main-thr
232
221
  })
233
222
 
234
223
  test('executeSessionChatTurn hides internal main-loop followup output from the visible transcript', () => {
235
- const output = runWithTempDataDir(`
224
+ const output = runWithTempDataDir<{
225
+ messageCount: number
226
+ lastMessageText: string | null
227
+ hasStreamingArtifacts: boolean
228
+ }>(`
236
229
  const storageMod = await import('@/lib/server/storage')
237
230
  const providersMod = await import('@/lib/providers/index')
238
231
  const execMod = await import('@/lib/server/chat-execution/chat-execution')
@@ -324,7 +317,10 @@ test('executeSessionChatTurn hides internal main-loop followup output from the v
324
317
  })
325
318
 
326
319
  test('executeSessionChatTurn forces external connector sessions onto session-scoped memory', () => {
327
- const output = runWithTempDataDir(`
320
+ const output = runWithTempDataDir<{
321
+ memoryScopeMode: string | null
322
+ connectorContext: { isOwnerConversation?: boolean } | null
323
+ }>(`
328
324
  const storageMod = await import('@/lib/server/storage')
329
325
  const providersMod = await import('@/lib/providers/index')
330
326
  const execMod = await import('@/lib/server/chat-execution/chat-execution')
@@ -418,7 +414,10 @@ test('executeSessionChatTurn forces external connector sessions onto session-sco
418
414
  })
419
415
 
420
416
  test('executeSessionChatTurn applies lifecycle hooks for model resolution and message persistence', () => {
421
- const output = runWithTempDataDir(`
417
+ const output = runWithTempDataDir<{
418
+ lastMessageText: string
419
+ marks: string[]
420
+ }>(`
422
421
  const storageMod = await import('@/lib/server/storage')
423
422
  const providersMod = await import('@/lib/providers/index')
424
423
  const extMod = await import('@/lib/server/extensions')
@@ -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,11 @@
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'
2
9
  import { sendConnectorMessage } from '../connectors/manager'
3
10
  import { applyExactOutputContract, classifyExactOutputContract, type ExactOutputContract } from '@/lib/server/chat-execution/exact-output-contract'
4
11
  import { stripMainLoopMetaForPersistence } from '@/lib/server/agents/main-agent-loop'
@@ -43,6 +50,7 @@ import {
43
50
  import { appendUsage } from '@/lib/server/usage/usage-repository'
44
51
  import { synchronizeWorkingStateForTurn } from '@/lib/server/working-state/service'
45
52
  import { notify } from '@/lib/server/ws-hub'
53
+ import { selectKnowledgeCitations } from '@/lib/server/knowledge-sources'
46
54
 
47
55
  import type { ExecuteChatTurnInput, ExecuteChatTurnResult } from './chat-execution-types'
48
56
  import type { PartialAssistantPersistence } from '@/lib/server/chat-execution/chat-turn-partial-persistence'
@@ -154,6 +162,7 @@ export async function finalizeChatTurn(params: {
154
162
  received: boolean
155
163
  }
156
164
  durationMs: number
165
+ knowledgeRetrievalTrace?: KnowledgeRetrievalTrace | null
157
166
  emit: (event: SSEEvent) => void
158
167
  }): Promise<ExecuteChatTurnResult> {
159
168
  const {
@@ -164,6 +173,7 @@ export async function finalizeChatTurn(params: {
164
173
  responseCacheHit,
165
174
  directUsage,
166
175
  durationMs,
176
+ knowledgeRetrievalTrace,
167
177
  emit,
168
178
  } = params
169
179
  let { fullResponse, errorMessage } = params
@@ -313,6 +323,12 @@ export async function finalizeChatTurn(params: {
313
323
  const hiddenControlOnly = shouldSuppressHiddenControlText(rawTextForPersistence)
314
324
  const textForPersistence = stripHiddenControlTokens(rawTextForPersistence)
315
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
+ })
316
332
  let persistedResponseForHooks = textForPersistence
317
333
 
318
334
  if (isHeartbeatRun && rawTextForPersistence) {
@@ -413,6 +429,8 @@ export async function finalizeChatTurn(params: {
413
429
  thinking: thinkingText || undefined,
414
430
  toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
415
431
  kind: persistedKind,
432
+ citations: grounding.citations.length > 0 ? grounding.citations : undefined,
433
+ retrievalTrace: grounding.retrievalTrace || undefined,
416
434
  },
417
435
  enabledIds: extensionsForRun,
418
436
  phase: isHeartbeatRun ? 'heartbeat' : 'assistant_final',
@@ -643,5 +661,7 @@ export async function finalizeChatTurn(params: {
643
661
  inputTokens: accumulatedUsage.inputTokens || undefined,
644
662
  outputTokens: accumulatedUsage.outputTokens || undefined,
645
663
  estimatedCost: accumulatedUsage.estimatedCost || undefined,
664
+ citations: grounding.citations.length > 0 ? grounding.citations : undefined,
665
+ retrievalTrace: grounding.retrievalTrace || undefined,
646
666
  }
647
667
  }