@soleri/core 1.0.0 → 2.0.1
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/dist/brain/brain.d.ts +12 -3
- package/dist/brain/brain.d.ts.map +1 -1
- package/dist/brain/brain.js +147 -12
- package/dist/brain/brain.js.map +1 -1
- package/dist/cognee/client.d.ts +35 -0
- package/dist/cognee/client.d.ts.map +1 -0
- package/dist/cognee/client.js +291 -0
- package/dist/cognee/client.js.map +1 -0
- package/dist/cognee/types.d.ts +46 -0
- package/dist/cognee/types.d.ts.map +1 -0
- package/dist/cognee/types.js +3 -0
- package/dist/cognee/types.js.map +1 -0
- package/dist/facades/types.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/brain.test.ts +265 -27
- package/src/__tests__/cognee-client.test.ts +524 -0
- package/src/brain/brain.ts +176 -12
- package/src/cognee/client.ts +352 -0
- package/src/cognee/types.ts +62 -0
- package/src/index.ts +11 -0
package/src/brain/brain.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { Vault } from '../vault/vault.js';
|
|
2
2
|
import type { SearchResult } from '../vault/vault.js';
|
|
3
3
|
import type { IntelligenceEntry } from '../intelligence/types.js';
|
|
4
|
+
import type { CogneeClient } from '../cognee/client.js';
|
|
4
5
|
|
|
5
6
|
// ─── Types ───────────────────────────────────────────────────────────
|
|
6
7
|
|
|
7
8
|
export interface ScoringWeights {
|
|
8
9
|
semantic: number;
|
|
10
|
+
vector: number;
|
|
9
11
|
severity: number;
|
|
10
12
|
recency: number;
|
|
11
13
|
tagOverlap: number;
|
|
@@ -14,6 +16,7 @@ export interface ScoringWeights {
|
|
|
14
16
|
|
|
15
17
|
export interface ScoreBreakdown {
|
|
16
18
|
semantic: number;
|
|
19
|
+
vector: number;
|
|
17
20
|
severity: number;
|
|
18
21
|
recency: number;
|
|
19
22
|
tagOverlap: number;
|
|
@@ -233,12 +236,22 @@ const SEVERITY_SCORES: Record<string, number> = {
|
|
|
233
236
|
|
|
234
237
|
const DEFAULT_WEIGHTS: ScoringWeights = {
|
|
235
238
|
semantic: 0.4,
|
|
239
|
+
vector: 0.0,
|
|
236
240
|
severity: 0.15,
|
|
237
241
|
recency: 0.15,
|
|
238
242
|
tagOverlap: 0.15,
|
|
239
243
|
domainMatch: 0.15,
|
|
240
244
|
};
|
|
241
245
|
|
|
246
|
+
const COGNEE_WEIGHTS: ScoringWeights = {
|
|
247
|
+
semantic: 0.25,
|
|
248
|
+
vector: 0.35,
|
|
249
|
+
severity: 0.1,
|
|
250
|
+
recency: 0.1,
|
|
251
|
+
tagOverlap: 0.1,
|
|
252
|
+
domainMatch: 0.1,
|
|
253
|
+
};
|
|
254
|
+
|
|
242
255
|
const WEIGHT_BOUND = 0.15;
|
|
243
256
|
const FEEDBACK_THRESHOLD = 30;
|
|
244
257
|
const DUPLICATE_BLOCK_THRESHOLD = 0.8;
|
|
@@ -247,16 +260,18 @@ const RECENCY_HALF_LIFE_DAYS = 365;
|
|
|
247
260
|
|
|
248
261
|
export class Brain {
|
|
249
262
|
private vault: Vault;
|
|
263
|
+
private cognee: CogneeClient | undefined;
|
|
250
264
|
private vocabulary: Map<string, number> = new Map();
|
|
251
265
|
private weights: ScoringWeights = { ...DEFAULT_WEIGHTS };
|
|
252
266
|
|
|
253
|
-
constructor(vault: Vault) {
|
|
267
|
+
constructor(vault: Vault, cognee?: CogneeClient) {
|
|
254
268
|
this.vault = vault;
|
|
269
|
+
this.cognee = cognee;
|
|
255
270
|
this.rebuildVocabulary();
|
|
256
271
|
this.recomputeWeights();
|
|
257
272
|
}
|
|
258
273
|
|
|
259
|
-
intelligentSearch(query: string, options?: SearchOptions): RankedResult[] {
|
|
274
|
+
async intelligentSearch(query: string, options?: SearchOptions): Promise<RankedResult[]> {
|
|
260
275
|
const limit = options?.limit ?? 10;
|
|
261
276
|
const rawResults = this.vault.search(query, {
|
|
262
277
|
domain: options?.domain,
|
|
@@ -265,6 +280,97 @@ export class Brain {
|
|
|
265
280
|
limit: Math.max(limit * 3, 30),
|
|
266
281
|
});
|
|
267
282
|
|
|
283
|
+
// Cognee vector search (parallel, with timeout fallback)
|
|
284
|
+
let cogneeScoreMap: Map<string, number> = new Map();
|
|
285
|
+
const cogneeAvailable = this.cognee?.isAvailable ?? false;
|
|
286
|
+
if (cogneeAvailable && this.cognee) {
|
|
287
|
+
try {
|
|
288
|
+
const cogneeResults = await this.cognee.search(query, { limit: Math.max(limit * 2, 20) });
|
|
289
|
+
|
|
290
|
+
// Build title → entryIds reverse index from FTS results for text-based matching.
|
|
291
|
+
// Cognee assigns its own UUIDs to chunks and may strip embedded metadata during
|
|
292
|
+
// chunking, so we need multiple strategies to cross-reference results.
|
|
293
|
+
// Multiple entries can share a title, so map to arrays of IDs.
|
|
294
|
+
const titleToIds = new Map<string, string[]>();
|
|
295
|
+
for (const r of rawResults) {
|
|
296
|
+
const key = r.entry.title.toLowerCase().trim();
|
|
297
|
+
const ids = titleToIds.get(key) ?? [];
|
|
298
|
+
ids.push(r.entry.id);
|
|
299
|
+
titleToIds.set(key, ids);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const vaultIdPattern = /\[vault-id:([^\]]+)\]/;
|
|
303
|
+
const unmatchedCogneeResults: Array<{ text: string; score: number }> = [];
|
|
304
|
+
|
|
305
|
+
for (const cr of cogneeResults) {
|
|
306
|
+
const text = cr.text ?? '';
|
|
307
|
+
|
|
308
|
+
// Strategy 1: Extract vault ID from [vault-id:XXX] prefix (if Cognee preserved it)
|
|
309
|
+
const vaultIdMatch = text.match(vaultIdPattern);
|
|
310
|
+
if (vaultIdMatch) {
|
|
311
|
+
const vaultId = vaultIdMatch[1];
|
|
312
|
+
cogneeScoreMap.set(vaultId, Math.max(cogneeScoreMap.get(vaultId) ?? 0, cr.score));
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Strategy 2: Match first line of chunk text against known entry titles.
|
|
317
|
+
// serializeEntry() puts the title on the first line after the [vault-id:] prefix,
|
|
318
|
+
// and Cognee's chunking typically preserves this as the chunk start.
|
|
319
|
+
const firstLine = text.split('\n')[0]?.trim().toLowerCase() ?? '';
|
|
320
|
+
const matchedIds = firstLine ? titleToIds.get(firstLine) : undefined;
|
|
321
|
+
if (matchedIds) {
|
|
322
|
+
for (const id of matchedIds) {
|
|
323
|
+
cogneeScoreMap.set(id, Math.max(cogneeScoreMap.get(id) ?? 0, cr.score));
|
|
324
|
+
}
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Strategy 3: Check if any known title appears as a substring in the chunk.
|
|
329
|
+
// Handles cases where the title isn't on the first line (mid-document chunks).
|
|
330
|
+
const textLower = text.toLowerCase();
|
|
331
|
+
let found = false;
|
|
332
|
+
for (const [title, ids] of titleToIds) {
|
|
333
|
+
if (title.length >= 8 && textLower.includes(title)) {
|
|
334
|
+
for (const id of ids) {
|
|
335
|
+
cogneeScoreMap.set(id, Math.max(cogneeScoreMap.get(id) ?? 0, cr.score));
|
|
336
|
+
}
|
|
337
|
+
found = true;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (!found && text.length > 0) {
|
|
342
|
+
unmatchedCogneeResults.push({ text, score: cr.score });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Strategy 4: For Cognee-only semantic matches (not in FTS results),
|
|
347
|
+
// use the first line as a vault FTS query to find the source entry.
|
|
348
|
+
// Preserve caller filters (domain/type/severity) to avoid reintroducing
|
|
349
|
+
// entries the original query excluded.
|
|
350
|
+
for (const unmatched of unmatchedCogneeResults) {
|
|
351
|
+
const searchTerm = unmatched.text.split('\n')[0]?.trim();
|
|
352
|
+
if (!searchTerm || searchTerm.length < 3) continue;
|
|
353
|
+
const vaultHits = this.vault.search(searchTerm, {
|
|
354
|
+
domain: options?.domain,
|
|
355
|
+
type: options?.type,
|
|
356
|
+
severity: options?.severity,
|
|
357
|
+
limit: 1,
|
|
358
|
+
});
|
|
359
|
+
if (vaultHits.length > 0) {
|
|
360
|
+
const id = vaultHits[0].entry.id;
|
|
361
|
+
cogneeScoreMap.set(id, Math.max(cogneeScoreMap.get(id) ?? 0, unmatched.score));
|
|
362
|
+
// Also add to FTS results pool if not already present
|
|
363
|
+
if (!rawResults.some((r) => r.entry.id === id)) {
|
|
364
|
+
rawResults.push(vaultHits[0]);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
// Cognee failed — fall back to FTS5 only
|
|
370
|
+
cogneeScoreMap = new Map();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
268
374
|
if (rawResults.length === 0) return [];
|
|
269
375
|
|
|
270
376
|
const queryTokens = tokenize(query);
|
|
@@ -272,9 +378,22 @@ export class Brain {
|
|
|
272
378
|
const queryDomain = options?.domain;
|
|
273
379
|
const now = Math.floor(Date.now() / 1000);
|
|
274
380
|
|
|
381
|
+
// Use cognee-aware weights only if at least one ranked candidate has a vector score
|
|
382
|
+
const hasVectorCandidate = rawResults.some((r) => cogneeScoreMap.has(r.entry.id));
|
|
383
|
+
const activeWeights = hasVectorCandidate ? this.getCogneeWeights() : this.weights;
|
|
384
|
+
|
|
275
385
|
const ranked = rawResults.map((result) => {
|
|
276
386
|
const entry = result.entry;
|
|
277
|
-
const
|
|
387
|
+
const vectorScore = cogneeScoreMap.get(entry.id) ?? 0;
|
|
388
|
+
const breakdown = this.scoreEntry(
|
|
389
|
+
entry,
|
|
390
|
+
queryTokens,
|
|
391
|
+
queryTags,
|
|
392
|
+
queryDomain,
|
|
393
|
+
now,
|
|
394
|
+
vectorScore,
|
|
395
|
+
activeWeights,
|
|
396
|
+
);
|
|
278
397
|
return { entry, score: breakdown.total, breakdown };
|
|
279
398
|
});
|
|
280
399
|
|
|
@@ -325,6 +444,11 @@ export class Brain {
|
|
|
325
444
|
this.vault.add(fullEntry);
|
|
326
445
|
this.updateVocabularyIncremental(fullEntry);
|
|
327
446
|
|
|
447
|
+
// Fire-and-forget Cognee sync
|
|
448
|
+
if (this.cognee?.isAvailable) {
|
|
449
|
+
this.cognee.addEntries([fullEntry]).catch(() => {});
|
|
450
|
+
}
|
|
451
|
+
|
|
328
452
|
const result: CaptureResult = {
|
|
329
453
|
captured: true,
|
|
330
454
|
id: entry.id,
|
|
@@ -348,13 +472,39 @@ export class Brain {
|
|
|
348
472
|
this.recomputeWeights();
|
|
349
473
|
}
|
|
350
474
|
|
|
351
|
-
getRelevantPatterns(context: QueryContext): RankedResult[] {
|
|
475
|
+
async getRelevantPatterns(context: QueryContext): Promise<RankedResult[]> {
|
|
352
476
|
return this.intelligentSearch(context.query, {
|
|
353
477
|
domain: context.domain,
|
|
354
478
|
tags: context.tags,
|
|
355
479
|
});
|
|
356
480
|
}
|
|
357
481
|
|
|
482
|
+
async syncToCognee(): Promise<{ synced: number; cognified: boolean }> {
|
|
483
|
+
if (!this.cognee?.isAvailable) return { synced: 0, cognified: false };
|
|
484
|
+
|
|
485
|
+
const batchSize = 1000;
|
|
486
|
+
let offset = 0;
|
|
487
|
+
let totalSynced = 0;
|
|
488
|
+
|
|
489
|
+
while (true) {
|
|
490
|
+
const batch = this.vault.list({ limit: batchSize, offset });
|
|
491
|
+
if (batch.length === 0) break;
|
|
492
|
+
|
|
493
|
+
const { added } = await this.cognee.addEntries(batch);
|
|
494
|
+
totalSynced += added;
|
|
495
|
+
offset += batch.length;
|
|
496
|
+
|
|
497
|
+
if (batch.length < batchSize) break;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (totalSynced === 0) return { synced: 0, cognified: false };
|
|
501
|
+
|
|
502
|
+
let cognified = false;
|
|
503
|
+
const cognifyResult = await this.cognee.cognify();
|
|
504
|
+
cognified = cognifyResult.status === 'ok';
|
|
505
|
+
return { synced: totalSynced, cognified };
|
|
506
|
+
}
|
|
507
|
+
|
|
358
508
|
rebuildVocabulary(): void {
|
|
359
509
|
const entries = this.vault.list({ limit: 100000 });
|
|
360
510
|
const docCount = entries.length;
|
|
@@ -408,7 +558,11 @@ export class Brain {
|
|
|
408
558
|
queryTags: string[],
|
|
409
559
|
queryDomain: string | undefined,
|
|
410
560
|
now: number,
|
|
561
|
+
vectorScore: number = 0,
|
|
562
|
+
activeWeights?: ScoringWeights,
|
|
411
563
|
): ScoreBreakdown {
|
|
564
|
+
const w = activeWeights ?? this.weights;
|
|
565
|
+
|
|
412
566
|
let semantic = 0;
|
|
413
567
|
if (this.vocabulary.size > 0 && queryTokens.length > 0) {
|
|
414
568
|
const entryText = [
|
|
@@ -433,14 +587,17 @@ export class Brain {
|
|
|
433
587
|
|
|
434
588
|
const domainMatch = queryDomain && entry.domain === queryDomain ? 1.0 : 0;
|
|
435
589
|
|
|
436
|
-
const
|
|
437
|
-
this.weights.semantic * semantic +
|
|
438
|
-
this.weights.severity * severity +
|
|
439
|
-
this.weights.recency * recency +
|
|
440
|
-
this.weights.tagOverlap * tagOverlap +
|
|
441
|
-
this.weights.domainMatch * domainMatch;
|
|
590
|
+
const vector = vectorScore;
|
|
442
591
|
|
|
443
|
-
|
|
592
|
+
const total =
|
|
593
|
+
w.semantic * semantic +
|
|
594
|
+
w.vector * vector +
|
|
595
|
+
w.severity * severity +
|
|
596
|
+
w.recency * recency +
|
|
597
|
+
w.tagOverlap * tagOverlap +
|
|
598
|
+
w.domainMatch * domainMatch;
|
|
599
|
+
|
|
600
|
+
return { semantic, vector, severity, recency, tagOverlap, domainMatch, total };
|
|
444
601
|
}
|
|
445
602
|
|
|
446
603
|
private generateTags(title: string, description: string, context?: string): string[] {
|
|
@@ -534,6 +691,10 @@ export class Brain {
|
|
|
534
691
|
tx();
|
|
535
692
|
}
|
|
536
693
|
|
|
694
|
+
private getCogneeWeights(): ScoringWeights {
|
|
695
|
+
return { ...COGNEE_WEIGHTS };
|
|
696
|
+
}
|
|
697
|
+
|
|
537
698
|
private recomputeWeights(): void {
|
|
538
699
|
const db = this.vault.getDb();
|
|
539
700
|
const feedbackCount = (
|
|
@@ -560,7 +721,10 @@ export class Brain {
|
|
|
560
721
|
DEFAULT_WEIGHTS.semantic + WEIGHT_BOUND,
|
|
561
722
|
);
|
|
562
723
|
|
|
563
|
-
|
|
724
|
+
// vector stays 0 in base weights (only active during hybrid search)
|
|
725
|
+
newWeights.vector = 0;
|
|
726
|
+
|
|
727
|
+
const remaining = 1.0 - newWeights.semantic - newWeights.vector;
|
|
564
728
|
const otherSum =
|
|
565
729
|
DEFAULT_WEIGHTS.severity +
|
|
566
730
|
DEFAULT_WEIGHTS.recency +
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CogneeConfig,
|
|
3
|
+
CogneeSearchResult,
|
|
4
|
+
CogneeSearchType,
|
|
5
|
+
CogneeStatus,
|
|
6
|
+
CogneeAddResult,
|
|
7
|
+
CogneeCognifyResult,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
import type { IntelligenceEntry } from '../intelligence/types.js';
|
|
10
|
+
|
|
11
|
+
// ─── Defaults ──────────────────────────────────────────────────────
|
|
12
|
+
// Aligned with Salvador MCP's battle-tested Cognee integration.
|
|
13
|
+
|
|
14
|
+
const DEFAULT_SERVICE_EMAIL = 'soleri-agent@cognee.dev';
|
|
15
|
+
const DEFAULT_SERVICE_PASSWORD = 'soleri-cognee-local';
|
|
16
|
+
|
|
17
|
+
/** Only allow default service credentials for local endpoints. */
|
|
18
|
+
function isLocalUrl(url: string): boolean {
|
|
19
|
+
try {
|
|
20
|
+
const { hostname } = new URL(url);
|
|
21
|
+
return (
|
|
22
|
+
hostname === 'localhost' ||
|
|
23
|
+
hostname === '127.0.0.1' ||
|
|
24
|
+
hostname === '::1' ||
|
|
25
|
+
hostname === '0.0.0.0'
|
|
26
|
+
);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_CONFIG: CogneeConfig = {
|
|
33
|
+
baseUrl: 'http://localhost:8000',
|
|
34
|
+
dataset: 'vault',
|
|
35
|
+
timeoutMs: 30_000,
|
|
36
|
+
searchTimeoutMs: 120_000, // Ollama cold start can take 90s
|
|
37
|
+
healthTimeoutMs: 5_000,
|
|
38
|
+
healthCacheTtlMs: 60_000,
|
|
39
|
+
cognifyDebounceMs: 30_000,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ─── CogneeClient ──────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export class CogneeClient {
|
|
45
|
+
private config: CogneeConfig;
|
|
46
|
+
private healthCache: { status: CogneeStatus; cachedAt: number } | null = null;
|
|
47
|
+
private accessToken: string | null = null;
|
|
48
|
+
private cognifyTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
49
|
+
private pendingDatasets: Set<string> = new Set();
|
|
50
|
+
|
|
51
|
+
constructor(config?: Partial<CogneeConfig>) {
|
|
52
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
53
|
+
// Strip trailing slash
|
|
54
|
+
this.config.baseUrl = this.config.baseUrl.replace(/\/+$/, '');
|
|
55
|
+
// Pre-set token if provided
|
|
56
|
+
if (this.config.apiToken) {
|
|
57
|
+
this.accessToken = this.config.apiToken;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Health ────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
get isAvailable(): boolean {
|
|
64
|
+
if (!this.healthCache) return false;
|
|
65
|
+
const age = Date.now() - this.healthCache.cachedAt;
|
|
66
|
+
if (age > this.config.healthCacheTtlMs) return false;
|
|
67
|
+
return this.healthCache.status.available;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async healthCheck(): Promise<CogneeStatus> {
|
|
71
|
+
const start = Date.now();
|
|
72
|
+
try {
|
|
73
|
+
// Cognee health endpoint is GET / (returns {"message":"Hello, World, I am alive!"})
|
|
74
|
+
const res = await globalThis.fetch(`${this.config.baseUrl}/`, {
|
|
75
|
+
signal: AbortSignal.timeout(this.config.healthTimeoutMs),
|
|
76
|
+
});
|
|
77
|
+
const latencyMs = Date.now() - start;
|
|
78
|
+
if (res.ok) {
|
|
79
|
+
const status: CogneeStatus = { available: true, url: this.config.baseUrl, latencyMs };
|
|
80
|
+
this.healthCache = { status, cachedAt: Date.now() };
|
|
81
|
+
return status;
|
|
82
|
+
}
|
|
83
|
+
const status: CogneeStatus = {
|
|
84
|
+
available: false,
|
|
85
|
+
url: this.config.baseUrl,
|
|
86
|
+
latencyMs,
|
|
87
|
+
error: `HTTP ${res.status}`,
|
|
88
|
+
};
|
|
89
|
+
this.healthCache = { status, cachedAt: Date.now() };
|
|
90
|
+
return status;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const latencyMs = Date.now() - start;
|
|
93
|
+
const status: CogneeStatus = {
|
|
94
|
+
available: false,
|
|
95
|
+
url: this.config.baseUrl,
|
|
96
|
+
latencyMs,
|
|
97
|
+
error: err instanceof Error ? err.message : String(err),
|
|
98
|
+
};
|
|
99
|
+
this.healthCache = { status, cachedAt: Date.now() };
|
|
100
|
+
return status;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Ingest ────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
async addEntries(entries: IntelligenceEntry[]): Promise<CogneeAddResult> {
|
|
107
|
+
if (!this.isAvailable || entries.length === 0) return { added: 0 };
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const token = await this.ensureAuth().catch(() => null);
|
|
111
|
+
|
|
112
|
+
// Cognee /add expects multipart/form-data with files + datasetName
|
|
113
|
+
const formData = new FormData();
|
|
114
|
+
formData.append('datasetName', this.config.dataset);
|
|
115
|
+
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
const text = this.serializeEntry(entry);
|
|
118
|
+
const blob = new Blob([text], { type: 'text/plain' });
|
|
119
|
+
formData.append('data', blob, `${entry.id}.txt`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const headers: Record<string, string> = {};
|
|
123
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
124
|
+
|
|
125
|
+
const res = await globalThis.fetch(`${this.config.baseUrl}/api/v1/add`, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers,
|
|
128
|
+
body: formData,
|
|
129
|
+
signal: AbortSignal.timeout(this.config.timeoutMs),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (!res.ok) return { added: 0 };
|
|
133
|
+
|
|
134
|
+
// Schedule debounced cognify (multiple rapid ingests coalesce)
|
|
135
|
+
this.scheduleCognify(this.config.dataset);
|
|
136
|
+
|
|
137
|
+
return { added: entries.length };
|
|
138
|
+
} catch {
|
|
139
|
+
return { added: 0 };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async cognify(dataset?: string): Promise<CogneeCognifyResult> {
|
|
144
|
+
if (!this.isAvailable) return { status: 'unavailable' };
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const res = await this.post('/api/v1/cognify', {
|
|
148
|
+
datasets: [dataset ?? this.config.dataset],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (!res.ok) return { status: `error: HTTP ${res.status}` };
|
|
152
|
+
return { status: 'ok' };
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return { status: `error: ${err instanceof Error ? err.message : String(err)}` };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Cognify debounce ───────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Schedule a debounced cognify for a dataset.
|
|
162
|
+
* Sliding window: each call resets the timer. When it expires,
|
|
163
|
+
* cognify fires once. Prevents pipeline dedup on rapid ingests.
|
|
164
|
+
*/
|
|
165
|
+
private scheduleCognify(dataset: string): void {
|
|
166
|
+
const existing = this.cognifyTimers.get(dataset);
|
|
167
|
+
if (existing) clearTimeout(existing);
|
|
168
|
+
|
|
169
|
+
this.pendingDatasets.add(dataset);
|
|
170
|
+
|
|
171
|
+
const timer = setTimeout(() => {
|
|
172
|
+
this.cognifyTimers.delete(dataset);
|
|
173
|
+
this.pendingDatasets.delete(dataset);
|
|
174
|
+
this.post('/api/v1/cognify', { datasets: [dataset] }).catch(() => {});
|
|
175
|
+
}, this.config.cognifyDebounceMs);
|
|
176
|
+
|
|
177
|
+
// Unref so the timer doesn't keep the process alive during shutdown
|
|
178
|
+
if (typeof timer === 'object' && 'unref' in timer) (timer as NodeJS.Timeout).unref();
|
|
179
|
+
|
|
180
|
+
this.cognifyTimers.set(dataset, timer);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Flush all pending debounced cognify calls immediately. */
|
|
184
|
+
async flushPendingCognify(): Promise<void> {
|
|
185
|
+
const datasets = [...this.pendingDatasets];
|
|
186
|
+
for (const timer of this.cognifyTimers.values()) clearTimeout(timer);
|
|
187
|
+
this.cognifyTimers.clear();
|
|
188
|
+
this.pendingDatasets.clear();
|
|
189
|
+
|
|
190
|
+
if (datasets.length === 0) return;
|
|
191
|
+
|
|
192
|
+
await Promise.allSettled(
|
|
193
|
+
datasets.map((ds) => this.post('/api/v1/cognify', { datasets: [ds] }).catch(() => {})),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Cancel all pending cognify calls without firing them. For test teardown. */
|
|
198
|
+
resetPendingCognify(): void {
|
|
199
|
+
for (const timer of this.cognifyTimers.values()) clearTimeout(timer);
|
|
200
|
+
this.cognifyTimers.clear();
|
|
201
|
+
this.pendingDatasets.clear();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Search ────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
async search(
|
|
207
|
+
query: string,
|
|
208
|
+
opts?: { searchType?: CogneeSearchType; limit?: number },
|
|
209
|
+
): Promise<CogneeSearchResult[]> {
|
|
210
|
+
if (!this.isAvailable) return [];
|
|
211
|
+
|
|
212
|
+
// Default to CHUNKS (pure vector similarity) — GRAPH_COMPLETION requires
|
|
213
|
+
// the LLM to produce instructor-compatible JSON which small local models
|
|
214
|
+
// (llama3.2) can't do reliably, causing infinite retries and timeouts.
|
|
215
|
+
const searchType = opts?.searchType ?? 'CHUNKS';
|
|
216
|
+
const topK = opts?.limit ?? 10;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const res = await this.post(
|
|
220
|
+
'/api/v1/search',
|
|
221
|
+
{ query, search_type: searchType, datasets: [this.config.dataset], topK },
|
|
222
|
+
this.config.searchTimeoutMs,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (!res.ok) return [];
|
|
226
|
+
|
|
227
|
+
const data = (await res.json()) as Array<{
|
|
228
|
+
id?: string;
|
|
229
|
+
text?: string;
|
|
230
|
+
score?: number;
|
|
231
|
+
payload?: { id?: string };
|
|
232
|
+
}>;
|
|
233
|
+
|
|
234
|
+
// Position-based scoring when Cognee omits scores.
|
|
235
|
+
// Cognee returns results ordered by relevance but may not include numeric scores.
|
|
236
|
+
return data.slice(0, topK).map((item, idx) => ({
|
|
237
|
+
id: item.payload?.id ?? item.id ?? '',
|
|
238
|
+
score: item.score ?? positionScore(idx, data.length),
|
|
239
|
+
text: typeof item.text === 'string' ? item.text : String(item.text ?? ''),
|
|
240
|
+
searchType,
|
|
241
|
+
}));
|
|
242
|
+
} catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Config access ─────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
getConfig(): Readonly<CogneeConfig> {
|
|
250
|
+
return { ...this.config };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
getStatus(): CogneeStatus | null {
|
|
254
|
+
return this.healthCache?.status ?? null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── Auth ──────────────────────────────────────────────────────
|
|
258
|
+
// Auto-register + login pattern from Salvador MCP.
|
|
259
|
+
// Tries login first (account may already exist), falls back to register.
|
|
260
|
+
|
|
261
|
+
private async ensureAuth(): Promise<string> {
|
|
262
|
+
if (this.accessToken) return this.accessToken;
|
|
263
|
+
|
|
264
|
+
const email = this.config.serviceEmail ?? DEFAULT_SERVICE_EMAIL;
|
|
265
|
+
const password = this.config.servicePassword ?? DEFAULT_SERVICE_PASSWORD;
|
|
266
|
+
|
|
267
|
+
// Refuse default credentials for non-local endpoints
|
|
268
|
+
if (
|
|
269
|
+
!isLocalUrl(this.config.baseUrl) &&
|
|
270
|
+
email === DEFAULT_SERVICE_EMAIL &&
|
|
271
|
+
password === DEFAULT_SERVICE_PASSWORD
|
|
272
|
+
) {
|
|
273
|
+
throw new Error('Explicit Cognee credentials are required for non-local endpoints');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Try login first
|
|
277
|
+
const loginResp = await globalThis.fetch(`${this.config.baseUrl}/api/v1/auth/login`, {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
280
|
+
body: `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`,
|
|
281
|
+
signal: AbortSignal.timeout(this.config.healthTimeoutMs),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
if (loginResp.ok) {
|
|
285
|
+
const data = (await loginResp.json()) as { access_token: string };
|
|
286
|
+
this.accessToken = data.access_token;
|
|
287
|
+
return this.accessToken;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Register, then retry login
|
|
291
|
+
await globalThis.fetch(`${this.config.baseUrl}/api/v1/auth/register`, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers: { 'Content-Type': 'application/json' },
|
|
294
|
+
body: JSON.stringify({ email, password }),
|
|
295
|
+
signal: AbortSignal.timeout(this.config.healthTimeoutMs),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const retryLogin = await globalThis.fetch(`${this.config.baseUrl}/api/v1/auth/login`, {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
301
|
+
body: `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`,
|
|
302
|
+
signal: AbortSignal.timeout(this.config.healthTimeoutMs),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (!retryLogin.ok) {
|
|
306
|
+
throw new Error(`Cognee auth failed: HTTP ${retryLogin.status}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const retryData = (await retryLogin.json()) as { access_token: string };
|
|
310
|
+
this.accessToken = retryData.access_token;
|
|
311
|
+
return this.accessToken;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private async authHeaders(): Promise<Record<string, string>> {
|
|
315
|
+
try {
|
|
316
|
+
const token = await this.ensureAuth();
|
|
317
|
+
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
|
|
318
|
+
} catch {
|
|
319
|
+
// Fall back to no auth (works if AUTH_REQUIRED=false)
|
|
320
|
+
return { 'Content-Type': 'application/json' };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ─── Private helpers ───────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
private serializeEntry(entry: IntelligenceEntry): string {
|
|
327
|
+
// Prefix with vault ID so we can cross-reference search results back to vault entries.
|
|
328
|
+
// Cognee assigns its own UUIDs to chunks — the vault ID would otherwise be lost.
|
|
329
|
+
const parts = [`[vault-id:${entry.id}]`, entry.title, entry.description];
|
|
330
|
+
if (entry.context) parts.push(entry.context);
|
|
331
|
+
if (entry.tags.length > 0) parts.push(`Tags: ${entry.tags.join(', ')}`);
|
|
332
|
+
return parts.join('\n');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private async post(path: string, body: unknown, timeoutMs?: number): Promise<Response> {
|
|
336
|
+
const headers = await this.authHeaders();
|
|
337
|
+
return globalThis.fetch(`${this.config.baseUrl}${path}`, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers,
|
|
340
|
+
body: JSON.stringify(body),
|
|
341
|
+
signal: AbortSignal.timeout(timeoutMs ?? this.config.timeoutMs),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ─── Helpers ──────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
/** Position-based score: first result gets ~1.0, last gets ~0.05. */
|
|
349
|
+
function positionScore(index: number, total: number): number {
|
|
350
|
+
if (total <= 1) return 1.0;
|
|
351
|
+
return Math.max(0.05, 1.0 - (index / (total - 1)) * 0.95);
|
|
352
|
+
}
|