@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.
- package/README.md +15 -76
- package/package.json +1 -1
- package/skills/swarmclaw.md +17 -0
- package/src/app/api/agents/[id]/dream/route.ts +45 -0
- package/src/app/api/knowledge/[id]/route.ts +48 -49
- package/src/app/api/knowledge/hygiene/route.ts +13 -0
- package/src/app/api/knowledge/route.ts +70 -42
- package/src/app/api/knowledge/sources/[id]/archive/route.ts +15 -0
- package/src/app/api/knowledge/sources/[id]/restore/route.ts +10 -0
- package/src/app/api/knowledge/sources/[id]/route.ts +1 -0
- package/src/app/api/knowledge/sources/[id]/supersede/route.ts +26 -0
- package/src/app/api/knowledge/sources/[id]/sync/route.ts +17 -0
- package/src/app/api/knowledge/sources/route.ts +1 -0
- package/src/app/api/knowledge/upload/route.ts +3 -51
- package/src/app/api/memory/dream/[id]/route.ts +19 -0
- package/src/app/api/memory/dream/route.ts +34 -0
- package/src/app/knowledge/layout.tsx +1 -1
- package/src/app/knowledge/page.tsx +2 -22
- package/src/app/protocols/page.tsx +21 -2
- package/src/cli/index.js +16 -0
- package/src/cli/spec.js +5 -0
- package/src/components/agents/agent-sheet.tsx +65 -0
- package/src/components/chat/message-bubble.tsx +10 -0
- package/src/components/knowledge/grounding-panel.tsx +99 -0
- package/src/components/knowledge/knowledge-detail.tsx +402 -0
- package/src/components/knowledge/knowledge-list.tsx +351 -126
- package/src/components/knowledge/knowledge-sheet.tsx +208 -119
- package/src/components/memory/dream-history.tsx +155 -0
- package/src/components/memory/memory-card.tsx +7 -0
- package/src/components/memory/memory-detail.tsx +46 -0
- package/src/components/runs/run-list.tsx +23 -0
- package/src/lib/server/api-routes.test.ts +43 -2
- package/src/lib/server/chat-execution/chat-execution-grounding.test.ts +127 -0
- package/src/lib/server/chat-execution/chat-execution-types.ts +8 -1
- package/src/lib/server/chat-execution/chat-execution.ts +1 -0
- package/src/lib/server/chat-execution/chat-turn-finalization.ts +21 -1
- package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +6 -1
- package/src/lib/server/chat-execution/post-stream-finalization.ts +15 -3
- package/src/lib/server/chat-execution/prompt-sections.ts +29 -3
- package/src/lib/server/chat-execution/stream-agent-chat.ts +6 -1
- package/src/lib/server/execution-engine/task-attempt.ts +8 -2
- package/src/lib/server/knowledge-import.ts +159 -0
- package/src/lib/server/knowledge-sources.test.ts +215 -0
- package/src/lib/server/knowledge-sources.ts +1266 -0
- package/src/lib/server/memory/dream-cycles.ts +49 -0
- package/src/lib/server/memory/dream-idle-callback.ts +38 -0
- package/src/lib/server/memory/dream-service.ts +315 -0
- package/src/lib/server/memory/memory-db.ts +37 -2
- package/src/lib/server/protocols/protocol-agent-turn.ts +7 -0
- package/src/lib/server/protocols/protocol-run-lifecycle.ts +19 -6
- package/src/lib/server/protocols/protocol-service.test.ts +99 -0
- package/src/lib/server/protocols/protocol-step-helpers.ts +7 -1
- package/src/lib/server/protocols/protocol-step-processors.ts +16 -3
- package/src/lib/server/protocols/protocol-types.ts +4 -0
- package/src/lib/server/runtime/daemon-state/core.ts +6 -1
- package/src/lib/server/runtime/run-ledger.test.ts +120 -0
- package/src/lib/server/runtime/run-ledger.ts +27 -1
- package/src/lib/server/runtime/session-run-manager/drain.ts +5 -0
- package/src/lib/server/runtime/session-run-manager/state.ts +19 -2
- package/src/lib/server/storage-normalization.ts +5 -0
- package/src/lib/server/storage.ts +15 -0
- package/src/stores/slices/ui-slice.ts +4 -0
- package/src/types/agent.ts +7 -0
- package/src/types/dream.ts +45 -0
- package/src/types/index.ts +1 -0
- package/src/types/message.ts +3 -0
- package/src/types/misc.ts +131 -0
- package/src/types/protocol.ts +4 -0
- 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
|
-
...
|
|
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
|
+
})
|