@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
@@ -3,7 +3,7 @@ import { WORKSPACE_DIR } from '@/lib/server/data-dir'
3
3
  import { log } from '@/lib/server/logger'
4
4
  import { loadSettings } from '@/lib/server/settings/settings-repository'
5
5
  import { loadSessions } from '@/lib/server/sessions/session-repository'
6
- import { appendPersistedRunEvent, persistRun } from '@/lib/server/runtime/run-ledger'
6
+ import { appendPersistedRunEvent, buildRetrievalSummary, persistRun } from '@/lib/server/runtime/run-ledger'
7
7
  import { notify } from '@/lib/server/ws-hub'
8
8
  import { captureGuardianCheckpoint } from '@/lib/server/agents/guardian'
9
9
  import {
@@ -68,6 +68,7 @@ function notifyExecutionState(sessionId: string): void {
68
68
  }
69
69
 
70
70
  function emitStatus(run: SessionRunRecord, status: SessionRunStatus, extra?: Record<string, unknown>): void {
71
+ const { citations, retrievalTrace, ...eventExtra } = extra || {}
71
72
  appendPersistedRunEvent({
72
73
  runId: run.id,
73
74
  sessionId: run.sessionId,
@@ -78,6 +79,8 @@ function emitStatus(run: SessionRunRecord, status: SessionRunStatus, extra?: Rec
78
79
  phase: 'status',
79
80
  status,
80
81
  summary: run.resultPreview || run.error || undefined,
82
+ citations: citations as import('@/types').KnowledgeCitation[] | undefined,
83
+ retrievalTrace: (retrievalTrace as import('@/types').KnowledgeRetrievalTrace | undefined) || undefined,
81
84
  event: {
82
85
  t: 'md',
83
86
  text: JSON.stringify({
@@ -90,7 +93,7 @@ function emitStatus(run: SessionRunRecord, status: SessionRunStatus, extra?: Rec
90
93
  status,
91
94
  source: run.source,
92
95
  internal: run.internal,
93
- ...extra,
96
+ ...eventExtra,
94
97
  },
95
98
  }),
96
99
  },
@@ -268,6 +271,7 @@ export function enqueueTaskAttemptExecution(
268
271
  run.endedAt = Date.now()
269
272
  run.error = controller.signal.aborted ? (run.error || 'Cancelled') : result.error
270
273
  run.resultPreview = result.text?.slice(0, 280)
274
+ run.retrievalSummary = buildRetrievalSummary(result.citations)
271
275
  if (typeof result.inputTokens === 'number') run.totalInputTokens = result.inputTokens
272
276
  if (typeof result.outputTokens === 'number') run.totalOutputTokens = result.outputTokens
273
277
  if (typeof result.estimatedCost === 'number') run.estimatedCost = result.estimatedCost
@@ -275,6 +279,8 @@ export function enqueueTaskAttemptExecution(
275
279
  emitStatus(run, run.status, {
276
280
  hasText: !!result.text,
277
281
  error: run.error || null,
282
+ citations: result.citations,
283
+ retrievalTrace: result.retrievalTrace,
278
284
  })
279
285
  return result
280
286
  } catch (err: unknown) {
@@ -0,0 +1,159 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import * as cheerio from 'cheerio'
4
+
5
+ const TEXT_EXTS = new Set([
6
+ '.txt', '.md', '.markdown', '.csv', '.tsv', '.json', '.jsonl',
7
+ '.html', '.htm', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg',
8
+ '.js', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.c', '.cpp', '.h',
9
+ '.rb', '.php', '.sh', '.bash', '.zsh', '.sql', '.r', '.swift', '.kt',
10
+ '.env', '.log', '.conf', '.properties', '.gitignore', '.dockerignore',
11
+ ])
12
+
13
+ export const MAX_KNOWLEDGE_IMPORT_BYTES = 10 * 1024 * 1024
14
+ export const MAX_KNOWLEDGE_CONTENT_CHARS = 500_000
15
+
16
+ export function isKnowledgeTextFile(filename: string): boolean {
17
+ const ext = path.extname(filename).toLowerCase()
18
+ return TEXT_EXTS.has(ext) || ext === ''
19
+ }
20
+
21
+ export function deriveKnowledgeTitle(filename: string): string {
22
+ const name = path.basename(filename, path.extname(filename))
23
+ return name
24
+ .replace(/[-_]+/g, ' ')
25
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
26
+ .replace(/\b\w/g, (char) => char.toUpperCase())
27
+ .trim() || 'Knowledge Source'
28
+ }
29
+
30
+ function normalizeKnowledgeContent(content: string): string {
31
+ const normalized = String(content || '')
32
+ .replace(/^\uFEFF/, '')
33
+ .replace(/\r\n/g, '\n')
34
+ .trim()
35
+
36
+ if (normalized.length <= MAX_KNOWLEDGE_CONTENT_CHARS) return normalized
37
+ return `${normalized.slice(0, MAX_KNOWLEDGE_CONTENT_CHARS)}\n\n[... truncated at 500k characters]`
38
+ }
39
+
40
+ async function extractPdfText(buffer: Buffer, filePathHint?: string): Promise<string> {
41
+ try {
42
+ const pdfParseModule = await import('pdf-parse') as unknown as {
43
+ default?: (input: Buffer) => Promise<{ text?: string }>
44
+ }
45
+ const pdfParse = pdfParseModule.default
46
+ if (typeof pdfParse !== 'function') throw new Error('pdf-parse loader unavailable')
47
+ const result = await pdfParse(buffer)
48
+ return normalizeKnowledgeContent(result.text || '')
49
+ } catch {
50
+ return normalizeKnowledgeContent(
51
+ `[PDF document]\n\nUnable to extract text automatically.${filePathHint ? `\n\nSaved at: ${filePathHint}` : ''}`,
52
+ )
53
+ }
54
+ }
55
+
56
+ function htmlToReadableText(html: string): { title: string | null; content: string } {
57
+ const $ = cheerio.load(html)
58
+ $('script, style, noscript, svg, nav, footer, header').remove()
59
+
60
+ const title = $('title').first().text().trim() || null
61
+ const root = $('main').first().length
62
+ ? $('main').first()
63
+ : $('article').first().length
64
+ ? $('article').first()
65
+ : $('body').first().length
66
+ ? $('body').first()
67
+ : $('html').first()
68
+
69
+ const text = root
70
+ .text()
71
+ .replace(/\u00a0/g, ' ')
72
+ .split('\n')
73
+ .map((line) => line.trim())
74
+ .filter(Boolean)
75
+ .join('\n\n')
76
+
77
+ return {
78
+ title,
79
+ content: normalizeKnowledgeContent(text),
80
+ }
81
+ }
82
+
83
+ export async function extractKnowledgeTextFromBuffer(
84
+ buffer: Buffer,
85
+ filename: string,
86
+ filePathHint?: string,
87
+ ): Promise<string> {
88
+ if (buffer.length === 0) return ''
89
+ if (buffer.length > MAX_KNOWLEDGE_IMPORT_BYTES) {
90
+ throw new Error('File too large. Maximum 10MB.')
91
+ }
92
+
93
+ const ext = path.extname(filename).toLowerCase()
94
+ if (ext === '.pdf') {
95
+ return extractPdfText(buffer, filePathHint)
96
+ }
97
+
98
+ if (isKnowledgeTextFile(filename)) {
99
+ return normalizeKnowledgeContent(buffer.toString('utf-8'))
100
+ }
101
+
102
+ return normalizeKnowledgeContent(
103
+ `[Binary file: ${filename}]${filePathHint ? `\n\nSaved at: ${filePathHint}` : ''}`,
104
+ )
105
+ }
106
+
107
+ export async function extractKnowledgeTextFromFile(filePath: string, filename?: string): Promise<string> {
108
+ const buffer = await fs.promises.readFile(filePath)
109
+ return extractKnowledgeTextFromBuffer(buffer, filename || path.basename(filePath), filePath)
110
+ }
111
+
112
+ export async function extractKnowledgeTextFromUrl(sourceUrl: string): Promise<{
113
+ title: string | null
114
+ content: string
115
+ contentType: string | null
116
+ }> {
117
+ const response = await fetch(sourceUrl, {
118
+ headers: {
119
+ 'user-agent': 'SwarmClaw/knowledge-import',
120
+ accept: 'text/html, text/plain, application/json, application/pdf, */*',
121
+ },
122
+ })
123
+
124
+ if (!response.ok) {
125
+ throw new Error(`URL fetch failed (${response.status})`)
126
+ }
127
+
128
+ const contentType = response.headers.get('content-type')
129
+ const contentLength = Number.parseInt(response.headers.get('content-length') || '', 10)
130
+ if (Number.isFinite(contentLength) && contentLength > MAX_KNOWLEDGE_IMPORT_BYTES) {
131
+ throw new Error('Remote document is too large. Maximum 10MB.')
132
+ }
133
+
134
+ if ((contentType || '').includes('application/pdf') || sourceUrl.toLowerCase().endsWith('.pdf')) {
135
+ const buffer = Buffer.from(await response.arrayBuffer())
136
+ return {
137
+ title: null,
138
+ content: await extractPdfText(buffer, sourceUrl),
139
+ contentType,
140
+ }
141
+ }
142
+
143
+ const text = await response.text()
144
+ const looksLikeHtml = (contentType || '').includes('text/html') || /<html[\s>]|<body[\s>]/i.test(text)
145
+ if (looksLikeHtml) {
146
+ const parsed = htmlToReadableText(text)
147
+ return {
148
+ title: parsed.title,
149
+ content: parsed.content,
150
+ contentType,
151
+ }
152
+ }
153
+
154
+ return {
155
+ title: null,
156
+ content: normalizeKnowledgeContent(text),
157
+ contentType,
158
+ }
159
+ }
@@ -0,0 +1,215 @@
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('buildKnowledgeRetrievalTrace returns active hits and selectKnowledgeCitations marks empty replies as no_match', () => {
6
+ const output = runWithTempDataDir<{
7
+ sourceId: string | null
8
+ hitCount: number
9
+ firstHitSourceId: string | null
10
+ matchedStatus: string | null
11
+ matchedCitationCount: number
12
+ whyMatched: string | null
13
+ unmatchedStatus: string | null
14
+ unmatchedCitationCount: number
15
+ }>(`
16
+ const knowledgeMod = await import('./src/lib/server/knowledge-sources.ts')
17
+ const knowledge = knowledgeMod.default || knowledgeMod
18
+
19
+ const detail = await knowledge.createKnowledgeSource({
20
+ kind: 'manual',
21
+ title: 'Gateway Migration Runbook',
22
+ content: 'Use blue green deployment for gateway migrations so rollback stays simple and downtime stays low.',
23
+ tags: ['deploy'],
24
+ })
25
+
26
+ const trace = await knowledge.buildKnowledgeRetrievalTrace({
27
+ query: 'gateway blue green rollback',
28
+ })
29
+
30
+ const matched = knowledge.selectKnowledgeCitations({
31
+ responseText: 'Use blue green deployment for the gateway migration so rollback stays simple.',
32
+ retrievalTrace: trace,
33
+ })
34
+
35
+ const unmatched = knowledge.selectKnowledgeCitations({
36
+ responseText: '',
37
+ retrievalTrace: trace,
38
+ })
39
+
40
+ console.log(JSON.stringify({
41
+ sourceId: detail?.source?.id || null,
42
+ hitCount: trace?.hits?.length || 0,
43
+ firstHitSourceId: trace?.hits?.[0]?.sourceId || null,
44
+ matchedStatus: matched.retrievalTrace?.selectorStatus || null,
45
+ matchedCitationCount: matched.citations.length,
46
+ whyMatched: matched.citations[0]?.whyMatched || null,
47
+ unmatchedStatus: unmatched.retrievalTrace?.selectorStatus || null,
48
+ unmatchedCitationCount: unmatched.citations.length,
49
+ }))
50
+ `, { prefix: 'swarmclaw-knowledge-trace-' })
51
+
52
+ assert.ok(output.sourceId)
53
+ assert.ok(output.hitCount >= 1)
54
+ assert.equal(output.firstHitSourceId, output.sourceId)
55
+ assert.equal(output.matchedStatus, 'selected')
56
+ assert.ok(output.matchedCitationCount >= 1)
57
+ assert.match(output.whyMatched || '', /Matched|Retrieved/)
58
+ assert.equal(output.unmatchedStatus, 'no_match')
59
+ assert.equal(output.unmatchedCitationCount, 0)
60
+ })
61
+
62
+ test('archived and superseded sources are excluded by default, restore re-enables search, and restore actions are recorded explicitly', () => {
63
+ const output = runWithTempDataDir<{
64
+ archivedDefaultCount: number
65
+ archivedIncludedCount: number
66
+ restoredCount: number
67
+ restoreActionKind: string | null
68
+ supersededDefaultCount: number
69
+ supersededIncludedCount: number
70
+ supersededFinding: boolean
71
+ }>(`
72
+ const knowledgeMod = await import('./src/lib/server/knowledge-sources.ts')
73
+ const storageMod = await import('./src/lib/server/storage.ts')
74
+ const knowledge = knowledgeMod.default || knowledgeMod
75
+ const storage = storageMod.default || storageMod
76
+
77
+ const archived = await knowledge.createKnowledgeSource({
78
+ kind: 'manual',
79
+ title: 'Orchard Rollback Notes',
80
+ content: 'orchard sentinel rollback checklist',
81
+ })
82
+
83
+ await knowledge.archiveKnowledgeSource(archived.source.id, { reason: 'manual review' })
84
+
85
+ const archivedDefault = await knowledge.searchKnowledgeHits({ query: 'orchard' })
86
+ const archivedIncluded = await knowledge.searchKnowledgeHits({
87
+ query: 'orchard',
88
+ includeArchived: true,
89
+ })
90
+
91
+ await knowledge.restoreKnowledgeSource(archived.source.id)
92
+ const restored = await knowledge.searchKnowledgeHits({ query: 'orchard' })
93
+
94
+ const older = await knowledge.createKnowledgeSource({
95
+ kind: 'manual',
96
+ title: 'Legacy API Notes',
97
+ content: 'legacy endpoint alpha is still enabled',
98
+ sourceUrl: 'https://example.com/api/reference',
99
+ })
100
+ const newer = await knowledge.createKnowledgeSource({
101
+ kind: 'manual',
102
+ title: 'Current API Notes',
103
+ content: 'modern endpoint beta replaced the older route',
104
+ sourceUrl: 'https://example.com/api/reference',
105
+ })
106
+
107
+ storage.patchKnowledgeSource(older.source.id, (current) => current ? {
108
+ ...current,
109
+ lastIndexedAt: 1_000,
110
+ updatedAt: 1_000,
111
+ } : null)
112
+ storage.patchKnowledgeSource(newer.source.id, (current) => current ? {
113
+ ...current,
114
+ lastIndexedAt: 2_000,
115
+ updatedAt: 2_000,
116
+ } : null)
117
+
118
+ await knowledge.runKnowledgeHygieneMaintenance()
119
+
120
+ const supersededDefault = await knowledge.searchKnowledgeHits({ query: 'alpha' })
121
+ const supersededIncluded = await knowledge.searchKnowledgeHits({
122
+ query: 'alpha',
123
+ includeArchived: true,
124
+ })
125
+ const summary = await knowledge.getKnowledgeHygieneSummary()
126
+
127
+ console.log(JSON.stringify({
128
+ archivedDefaultCount: archivedDefault.length,
129
+ archivedIncludedCount: archivedIncluded.length,
130
+ restoredCount: restored.length,
131
+ restoreActionKind: summary.recentActions.find((action) => action.summary === 'Restored Orchard Rollback Notes')?.kind || null,
132
+ supersededDefaultCount: supersededDefault.length,
133
+ supersededIncludedCount: supersededIncluded.length,
134
+ supersededFinding: summary.findings.some((finding) => finding.kind === 'superseded' && finding.sourceId === older.source.id),
135
+ }))
136
+ `, { prefix: 'swarmclaw-knowledge-lifecycle-' })
137
+
138
+ assert.equal(output.archivedDefaultCount, 0)
139
+ assert.equal(output.archivedIncludedCount, 1)
140
+ assert.equal(output.restoredCount, 1)
141
+ assert.equal(output.restoreActionKind, 'restore')
142
+ assert.equal(output.supersededDefaultCount, 0)
143
+ assert.equal(output.supersededIncludedCount, 1)
144
+ assert.equal(output.supersededFinding, true)
145
+ })
146
+
147
+ test('runKnowledgeHygieneMaintenance reindexes stale file sources and archives exact duplicates', () => {
148
+ const output = runWithTempDataDir<{
149
+ fileLastAutoSyncAt: number | null
150
+ fileChunksContainUpdatedText: boolean
151
+ refreshedHitCount: number
152
+ archivedDuplicateCount: number
153
+ recentActionKinds: string[]
154
+ }>(`
155
+ const fs = await import('node:fs')
156
+ const path = await import('node:path')
157
+ const knowledgeMod = await import('./src/lib/server/knowledge-sources.ts')
158
+ const storageMod = await import('./src/lib/server/storage.ts')
159
+ const knowledge = knowledgeMod.default || knowledgeMod
160
+ const storage = storageMod.default || storageMod
161
+
162
+ const filePath = path.join(process.env.WORKSPACE_DIR, 'ops-runbook.txt')
163
+ fs.writeFileSync(filePath, 'Initial runbook placeholder.')
164
+
165
+ const fileSource = await knowledge.createKnowledgeSource({
166
+ kind: 'file',
167
+ title: 'Ops Runbook',
168
+ sourcePath: filePath,
169
+ })
170
+
171
+ fs.writeFileSync(filePath, 'Updated runbook adds rollback choreography and incident checklist.')
172
+ storage.patchKnowledgeSource(fileSource.source.id, (current) => current ? {
173
+ ...current,
174
+ lastIndexedAt: 1,
175
+ nextSyncAt: 1,
176
+ updatedAt: 1,
177
+ } : null)
178
+
179
+ const duplicateA = await knowledge.createKnowledgeSource({
180
+ kind: 'manual',
181
+ title: 'Duplicate A',
182
+ content: 'duplicate payload for archival',
183
+ })
184
+ const duplicateB = await knowledge.createKnowledgeSource({
185
+ kind: 'manual',
186
+ title: 'Duplicate B',
187
+ content: 'duplicate payload for archival',
188
+ })
189
+
190
+ const summary = await knowledge.runKnowledgeHygieneMaintenance()
191
+ const refreshed = await knowledge.getKnowledgeSourceDetail(fileSource.source.id)
192
+ const duplicateADetail = await knowledge.getKnowledgeSourceDetail(duplicateA.source.id)
193
+ const duplicateBDetail = await knowledge.getKnowledgeSourceDetail(duplicateB.source.id)
194
+ const refreshedHits = await knowledge.searchKnowledgeHits({ query: 'choreography' })
195
+
196
+ const archivedDuplicateCount = [duplicateADetail, duplicateBDetail]
197
+ .filter((detail) => !!detail?.source?.archivedAt)
198
+ .length
199
+
200
+ console.log(JSON.stringify({
201
+ fileLastAutoSyncAt: refreshed?.source?.lastAutoSyncAt || null,
202
+ fileChunksContainUpdatedText: (refreshed?.chunks || []).some((chunk) => chunk.content.includes('rollback choreography')),
203
+ refreshedHitCount: refreshedHits.length,
204
+ archivedDuplicateCount,
205
+ recentActionKinds: summary.recentActions.map((action) => action.kind),
206
+ }))
207
+ `, { prefix: 'swarmclaw-knowledge-maintenance-' })
208
+
209
+ assert.ok(typeof output.fileLastAutoSyncAt === 'number' && output.fileLastAutoSyncAt > 0)
210
+ assert.equal(output.fileChunksContainUpdatedText, true)
211
+ assert.ok(output.refreshedHitCount >= 1)
212
+ assert.equal(output.archivedDuplicateCount, 1)
213
+ assert.ok(output.recentActionKinds.includes('archive'))
214
+ assert.ok(output.recentActionKinds.includes('reindex') || output.recentActionKinds.includes('sync'))
215
+ })