@soleri/core 0.0.1 → 2.0.0

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 (74) hide show
  1. package/dist/brain/brain.d.ts +85 -0
  2. package/dist/brain/brain.d.ts.map +1 -0
  3. package/dist/brain/brain.js +506 -0
  4. package/dist/brain/brain.js.map +1 -0
  5. package/dist/cognee/client.d.ts +35 -0
  6. package/dist/cognee/client.d.ts.map +1 -0
  7. package/dist/cognee/client.js +289 -0
  8. package/dist/cognee/client.js.map +1 -0
  9. package/dist/cognee/types.d.ts +46 -0
  10. package/dist/cognee/types.d.ts.map +1 -0
  11. package/dist/cognee/types.js +3 -0
  12. package/dist/cognee/types.js.map +1 -0
  13. package/dist/facades/facade-factory.d.ts +5 -0
  14. package/dist/facades/facade-factory.d.ts.map +1 -0
  15. package/dist/facades/facade-factory.js +49 -0
  16. package/dist/facades/facade-factory.js.map +1 -0
  17. package/dist/facades/types.d.ts +42 -0
  18. package/dist/facades/types.d.ts.map +1 -0
  19. package/dist/facades/types.js +6 -0
  20. package/dist/facades/types.js.map +1 -0
  21. package/dist/index.d.ts +19 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +19 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/intelligence/loader.d.ts +3 -0
  26. package/dist/intelligence/loader.d.ts.map +1 -0
  27. package/dist/intelligence/loader.js +41 -0
  28. package/dist/intelligence/loader.js.map +1 -0
  29. package/dist/intelligence/types.d.ts +20 -0
  30. package/dist/intelligence/types.d.ts.map +1 -0
  31. package/dist/intelligence/types.js +2 -0
  32. package/dist/intelligence/types.js.map +1 -0
  33. package/dist/llm/key-pool.d.ts +38 -0
  34. package/dist/llm/key-pool.d.ts.map +1 -0
  35. package/dist/llm/key-pool.js +154 -0
  36. package/dist/llm/key-pool.js.map +1 -0
  37. package/dist/llm/types.d.ts +80 -0
  38. package/dist/llm/types.d.ts.map +1 -0
  39. package/dist/llm/types.js +37 -0
  40. package/dist/llm/types.js.map +1 -0
  41. package/dist/llm/utils.d.ts +26 -0
  42. package/dist/llm/utils.d.ts.map +1 -0
  43. package/dist/llm/utils.js +197 -0
  44. package/dist/llm/utils.js.map +1 -0
  45. package/dist/planning/planner.d.ts +48 -0
  46. package/dist/planning/planner.d.ts.map +1 -0
  47. package/dist/planning/planner.js +109 -0
  48. package/dist/planning/planner.js.map +1 -0
  49. package/dist/vault/vault.d.ts +80 -0
  50. package/dist/vault/vault.d.ts.map +1 -0
  51. package/dist/vault/vault.js +353 -0
  52. package/dist/vault/vault.js.map +1 -0
  53. package/package.json +56 -4
  54. package/src/__tests__/brain.test.ts +740 -0
  55. package/src/__tests__/cognee-client.test.ts +524 -0
  56. package/src/__tests__/llm.test.ts +556 -0
  57. package/src/__tests__/loader.test.ts +176 -0
  58. package/src/__tests__/planner.test.ts +261 -0
  59. package/src/__tests__/vault.test.ts +494 -0
  60. package/src/brain/brain.ts +678 -0
  61. package/src/cognee/client.ts +350 -0
  62. package/src/cognee/types.ts +62 -0
  63. package/src/facades/facade-factory.ts +64 -0
  64. package/src/facades/types.ts +42 -0
  65. package/src/index.ts +75 -0
  66. package/src/intelligence/loader.ts +42 -0
  67. package/src/intelligence/types.ts +20 -0
  68. package/src/llm/key-pool.ts +190 -0
  69. package/src/llm/types.ts +116 -0
  70. package/src/llm/utils.ts +248 -0
  71. package/src/planning/planner.ts +151 -0
  72. package/src/vault/vault.ts +455 -0
  73. package/tsconfig.json +22 -0
  74. package/vitest.config.ts +15 -0
@@ -0,0 +1,678 @@
1
+ import type { Vault } from '../vault/vault.js';
2
+ import type { SearchResult } from '../vault/vault.js';
3
+ import type { IntelligenceEntry } from '../intelligence/types.js';
4
+ import type { CogneeClient } from '../cognee/client.js';
5
+
6
+ // ─── Types ───────────────────────────────────────────────────────────
7
+
8
+ export interface ScoringWeights {
9
+ semantic: number;
10
+ vector: number;
11
+ severity: number;
12
+ recency: number;
13
+ tagOverlap: number;
14
+ domainMatch: number;
15
+ }
16
+
17
+ export interface ScoreBreakdown {
18
+ semantic: number;
19
+ vector: number;
20
+ severity: number;
21
+ recency: number;
22
+ tagOverlap: number;
23
+ domainMatch: number;
24
+ total: number;
25
+ }
26
+
27
+ export interface RankedResult {
28
+ entry: IntelligenceEntry;
29
+ score: number;
30
+ breakdown: ScoreBreakdown;
31
+ }
32
+
33
+ export interface SearchOptions {
34
+ domain?: string;
35
+ type?: string;
36
+ severity?: string;
37
+ limit?: number;
38
+ tags?: string[];
39
+ }
40
+
41
+ export interface CaptureResult {
42
+ captured: boolean;
43
+ id: string;
44
+ autoTags: string[];
45
+ duplicate?: { id: string; similarity: number };
46
+ blocked?: boolean;
47
+ }
48
+
49
+ export interface BrainStats {
50
+ vocabularySize: number;
51
+ feedbackCount: number;
52
+ weights: ScoringWeights;
53
+ }
54
+
55
+ export interface QueryContext {
56
+ query: string;
57
+ domain?: string;
58
+ tags?: string[];
59
+ }
60
+
61
+ type SparseVector = Map<string, number>;
62
+
63
+ // ─── Stopwords ─────────────────────────────────────────────────────
64
+
65
+ const STOPWORDS = new Set([
66
+ 'a',
67
+ 'an',
68
+ 'the',
69
+ 'and',
70
+ 'or',
71
+ 'but',
72
+ 'in',
73
+ 'on',
74
+ 'at',
75
+ 'to',
76
+ 'for',
77
+ 'of',
78
+ 'with',
79
+ 'by',
80
+ 'from',
81
+ 'as',
82
+ 'is',
83
+ 'was',
84
+ 'are',
85
+ 'were',
86
+ 'been',
87
+ 'be',
88
+ 'have',
89
+ 'has',
90
+ 'had',
91
+ 'do',
92
+ 'does',
93
+ 'did',
94
+ 'will',
95
+ 'would',
96
+ 'could',
97
+ 'should',
98
+ 'may',
99
+ 'might',
100
+ 'shall',
101
+ 'can',
102
+ 'need',
103
+ 'must',
104
+ 'it',
105
+ 'its',
106
+ 'this',
107
+ 'that',
108
+ 'these',
109
+ 'those',
110
+ 'i',
111
+ 'you',
112
+ 'he',
113
+ 'she',
114
+ 'we',
115
+ 'they',
116
+ 'me',
117
+ 'him',
118
+ 'her',
119
+ 'us',
120
+ 'them',
121
+ 'my',
122
+ 'your',
123
+ 'his',
124
+ 'our',
125
+ 'their',
126
+ 'what',
127
+ 'which',
128
+ 'who',
129
+ 'whom',
130
+ 'when',
131
+ 'where',
132
+ 'why',
133
+ 'how',
134
+ 'all',
135
+ 'each',
136
+ 'every',
137
+ 'both',
138
+ 'few',
139
+ 'more',
140
+ 'most',
141
+ 'other',
142
+ 'some',
143
+ 'such',
144
+ 'no',
145
+ 'not',
146
+ 'only',
147
+ 'same',
148
+ 'so',
149
+ 'than',
150
+ 'too',
151
+ 'very',
152
+ 'just',
153
+ 'because',
154
+ 'if',
155
+ 'then',
156
+ 'else',
157
+ 'about',
158
+ 'up',
159
+ 'out',
160
+ 'into',
161
+ ]);
162
+
163
+ // ─── Text Processing (pure functions) ────────────────────────────
164
+
165
+ function tokenize(text: string): string[] {
166
+ return text
167
+ .toLowerCase()
168
+ .replace(/[^a-z0-9\s-]/g, ' ')
169
+ .split(/\s+/)
170
+ .filter((t) => t.length > 2 && !STOPWORDS.has(t));
171
+ }
172
+
173
+ function calculateTf(tokens: string[]): SparseVector {
174
+ const tf: SparseVector = new Map();
175
+ for (const token of tokens) {
176
+ tf.set(token, (tf.get(token) ?? 0) + 1);
177
+ }
178
+ const len = tokens.length || 1;
179
+ for (const [term, count] of tf) {
180
+ tf.set(term, count / len);
181
+ }
182
+ return tf;
183
+ }
184
+
185
+ function calculateTfIdf(tokens: string[], vocabulary: Map<string, number>): SparseVector {
186
+ const tf = calculateTf(tokens);
187
+ const tfidf: SparseVector = new Map();
188
+ for (const [term, tfValue] of tf) {
189
+ const idf = vocabulary.get(term) ?? 0;
190
+ if (idf > 0) {
191
+ tfidf.set(term, tfValue * idf);
192
+ }
193
+ }
194
+ return tfidf;
195
+ }
196
+
197
+ function cosineSimilarity(a: SparseVector, b: SparseVector): number {
198
+ let dot = 0;
199
+ let normA = 0;
200
+ let normB = 0;
201
+ for (const [term, valA] of a) {
202
+ normA += valA * valA;
203
+ const valB = b.get(term);
204
+ if (valB !== undefined) {
205
+ dot += valA * valB;
206
+ }
207
+ }
208
+ for (const [, valB] of b) {
209
+ normB += valB * valB;
210
+ }
211
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
212
+ return denom === 0 ? 0 : dot / denom;
213
+ }
214
+
215
+ function jaccardSimilarity(a: string[], b: string[]): number {
216
+ if (a.length === 0 && b.length === 0) return 0;
217
+ const setA = new Set(a);
218
+ const setB = new Set(b);
219
+ let intersection = 0;
220
+ for (const item of setA) {
221
+ if (setB.has(item)) intersection++;
222
+ }
223
+ const union = new Set([...a, ...b]).size;
224
+ return union === 0 ? 0 : intersection / union;
225
+ }
226
+
227
+ // ─── Severity scoring ──────────────────────────────────────────────
228
+
229
+ const SEVERITY_SCORES: Record<string, number> = {
230
+ critical: 1.0,
231
+ warning: 0.7,
232
+ suggestion: 0.4,
233
+ };
234
+
235
+ // ─── Brain Class ─────────────────────────────────────────────────
236
+
237
+ const DEFAULT_WEIGHTS: ScoringWeights = {
238
+ semantic: 0.4,
239
+ vector: 0.0,
240
+ severity: 0.15,
241
+ recency: 0.15,
242
+ tagOverlap: 0.15,
243
+ domainMatch: 0.15,
244
+ };
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
+
255
+ const WEIGHT_BOUND = 0.15;
256
+ const FEEDBACK_THRESHOLD = 30;
257
+ const DUPLICATE_BLOCK_THRESHOLD = 0.8;
258
+ const DUPLICATE_WARN_THRESHOLD = 0.6;
259
+ const RECENCY_HALF_LIFE_DAYS = 365;
260
+
261
+ export class Brain {
262
+ private vault: Vault;
263
+ private cognee: CogneeClient | undefined;
264
+ private vocabulary: Map<string, number> = new Map();
265
+ private weights: ScoringWeights = { ...DEFAULT_WEIGHTS };
266
+
267
+ constructor(vault: Vault, cognee?: CogneeClient) {
268
+ this.vault = vault;
269
+ this.cognee = cognee;
270
+ this.rebuildVocabulary();
271
+ this.recomputeWeights();
272
+ }
273
+
274
+ async intelligentSearch(query: string, options?: SearchOptions): Promise<RankedResult[]> {
275
+ const limit = options?.limit ?? 10;
276
+ const rawResults = this.vault.search(query, {
277
+ domain: options?.domain,
278
+ type: options?.type,
279
+ severity: options?.severity,
280
+ limit: Math.max(limit * 3, 30),
281
+ });
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
+ for (const cr of cogneeResults) {
290
+ if (cr.id) cogneeScoreMap.set(cr.id, cr.score);
291
+ }
292
+ // Merge cognee-only entries into candidate pool
293
+ for (const cr of cogneeResults) {
294
+ if (cr.id && !rawResults.some((r) => r.entry.id === cr.id)) {
295
+ const vaultEntry = this.vault.get(cr.id);
296
+ if (vaultEntry) {
297
+ rawResults.push({ entry: vaultEntry, score: cr.score });
298
+ }
299
+ }
300
+ }
301
+ } catch {
302
+ // Cognee failed — fall back to FTS5 only
303
+ cogneeScoreMap = new Map();
304
+ }
305
+ }
306
+
307
+ if (rawResults.length === 0) return [];
308
+
309
+ const queryTokens = tokenize(query);
310
+ const queryTags = options?.tags ?? [];
311
+ const queryDomain = options?.domain;
312
+ const now = Math.floor(Date.now() / 1000);
313
+
314
+ // Use cognee-aware weights only if at least one ranked candidate has a vector score
315
+ const hasVectorCandidate = rawResults.some((r) => cogneeScoreMap.has(r.entry.id));
316
+ const activeWeights = hasVectorCandidate ? this.getCogneeWeights() : this.weights;
317
+
318
+ const ranked = rawResults.map((result) => {
319
+ const entry = result.entry;
320
+ const vectorScore = cogneeScoreMap.get(entry.id) ?? 0;
321
+ const breakdown = this.scoreEntry(
322
+ entry,
323
+ queryTokens,
324
+ queryTags,
325
+ queryDomain,
326
+ now,
327
+ vectorScore,
328
+ activeWeights,
329
+ );
330
+ return { entry, score: breakdown.total, breakdown };
331
+ });
332
+
333
+ ranked.sort((a, b) => b.score - a.score);
334
+ return ranked.slice(0, limit);
335
+ }
336
+
337
+ enrichAndCapture(
338
+ entry: Partial<IntelligenceEntry> & {
339
+ id: string;
340
+ type: IntelligenceEntry['type'];
341
+ domain: string;
342
+ title: string;
343
+ severity: IntelligenceEntry['severity'];
344
+ description: string;
345
+ },
346
+ ): CaptureResult {
347
+ const autoTags = this.generateTags(entry.title, entry.description, entry.context);
348
+ const mergedTags = Array.from(new Set([...(entry.tags ?? []), ...autoTags]));
349
+
350
+ const duplicate = this.detectDuplicate(entry.title, entry.domain);
351
+
352
+ if (duplicate && duplicate.similarity >= DUPLICATE_BLOCK_THRESHOLD) {
353
+ return {
354
+ captured: false,
355
+ id: entry.id,
356
+ autoTags,
357
+ duplicate,
358
+ blocked: true,
359
+ };
360
+ }
361
+
362
+ const fullEntry: IntelligenceEntry = {
363
+ id: entry.id,
364
+ type: entry.type,
365
+ domain: entry.domain,
366
+ title: entry.title,
367
+ severity: entry.severity,
368
+ description: entry.description,
369
+ context: entry.context,
370
+ example: entry.example,
371
+ counterExample: entry.counterExample,
372
+ why: entry.why,
373
+ tags: mergedTags,
374
+ appliesTo: entry.appliesTo,
375
+ };
376
+
377
+ this.vault.add(fullEntry);
378
+ this.updateVocabularyIncremental(fullEntry);
379
+
380
+ // Fire-and-forget Cognee sync
381
+ if (this.cognee?.isAvailable) {
382
+ this.cognee.addEntries([fullEntry]).catch(() => {});
383
+ }
384
+
385
+ const result: CaptureResult = {
386
+ captured: true,
387
+ id: entry.id,
388
+ autoTags,
389
+ };
390
+
391
+ if (duplicate && duplicate.similarity >= DUPLICATE_WARN_THRESHOLD) {
392
+ result.duplicate = duplicate;
393
+ }
394
+
395
+ return result;
396
+ }
397
+
398
+ recordFeedback(query: string, entryId: string, action: 'accepted' | 'dismissed'): void {
399
+ const db = this.vault.getDb();
400
+ db.prepare('INSERT INTO brain_feedback (query, entry_id, action) VALUES (?, ?, ?)').run(
401
+ query,
402
+ entryId,
403
+ action,
404
+ );
405
+ this.recomputeWeights();
406
+ }
407
+
408
+ async getRelevantPatterns(context: QueryContext): Promise<RankedResult[]> {
409
+ return this.intelligentSearch(context.query, {
410
+ domain: context.domain,
411
+ tags: context.tags,
412
+ });
413
+ }
414
+
415
+ async syncToCognee(): Promise<{ synced: number; cognified: boolean }> {
416
+ if (!this.cognee?.isAvailable) return { synced: 0, cognified: false };
417
+
418
+ const batchSize = 1000;
419
+ let offset = 0;
420
+ let totalSynced = 0;
421
+
422
+ while (true) {
423
+ const batch = this.vault.list({ limit: batchSize, offset });
424
+ if (batch.length === 0) break;
425
+
426
+ const { added } = await this.cognee.addEntries(batch);
427
+ totalSynced += added;
428
+ offset += batch.length;
429
+
430
+ if (batch.length < batchSize) break;
431
+ }
432
+
433
+ if (totalSynced === 0) return { synced: 0, cognified: false };
434
+
435
+ let cognified = false;
436
+ const cognifyResult = await this.cognee.cognify();
437
+ cognified = cognifyResult.status === 'ok';
438
+ return { synced: totalSynced, cognified };
439
+ }
440
+
441
+ rebuildVocabulary(): void {
442
+ const entries = this.vault.list({ limit: 100000 });
443
+ const docCount = entries.length;
444
+ if (docCount === 0) {
445
+ this.vocabulary.clear();
446
+ this.persistVocabulary();
447
+ return;
448
+ }
449
+
450
+ const termDocFreq = new Map<string, number>();
451
+ for (const entry of entries) {
452
+ const text = [entry.title, entry.description, entry.context ?? '', entry.tags.join(' ')].join(
453
+ ' ',
454
+ );
455
+ const tokens = new Set(tokenize(text));
456
+ for (const token of tokens) {
457
+ termDocFreq.set(token, (termDocFreq.get(token) ?? 0) + 1);
458
+ }
459
+ }
460
+
461
+ this.vocabulary.clear();
462
+ for (const [term, df] of termDocFreq) {
463
+ const idf = Math.log((docCount + 1) / (df + 1)) + 1;
464
+ this.vocabulary.set(term, idf);
465
+ }
466
+
467
+ this.persistVocabulary();
468
+ }
469
+
470
+ getStats(): BrainStats {
471
+ const db = this.vault.getDb();
472
+ const feedbackCount = (
473
+ db.prepare('SELECT COUNT(*) as count FROM brain_feedback').get() as { count: number }
474
+ ).count;
475
+ return {
476
+ vocabularySize: this.vocabulary.size,
477
+ feedbackCount,
478
+ weights: { ...this.weights },
479
+ };
480
+ }
481
+
482
+ getVocabularySize(): number {
483
+ return this.vocabulary.size;
484
+ }
485
+
486
+ // ─── Private methods ─────────────────────────────────────────────
487
+
488
+ private scoreEntry(
489
+ entry: IntelligenceEntry,
490
+ queryTokens: string[],
491
+ queryTags: string[],
492
+ queryDomain: string | undefined,
493
+ now: number,
494
+ vectorScore: number = 0,
495
+ activeWeights?: ScoringWeights,
496
+ ): ScoreBreakdown {
497
+ const w = activeWeights ?? this.weights;
498
+
499
+ let semantic = 0;
500
+ if (this.vocabulary.size > 0 && queryTokens.length > 0) {
501
+ const entryText = [
502
+ entry.title,
503
+ entry.description,
504
+ entry.context ?? '',
505
+ entry.tags.join(' '),
506
+ ].join(' ');
507
+ const entryTokens = tokenize(entryText);
508
+ const queryVec = calculateTfIdf(queryTokens, this.vocabulary);
509
+ const entryVec = calculateTfIdf(entryTokens, this.vocabulary);
510
+ semantic = cosineSimilarity(queryVec, entryVec);
511
+ }
512
+
513
+ const severity = SEVERITY_SCORES[entry.severity] ?? 0.4;
514
+
515
+ const entryAge = now - (entry as unknown as { created_at?: number }).created_at!;
516
+ const halfLifeSeconds = RECENCY_HALF_LIFE_DAYS * 86400;
517
+ const recency = entryAge > 0 ? Math.exp((-Math.LN2 * entryAge) / halfLifeSeconds) : 1;
518
+
519
+ const tagOverlap = queryTags.length > 0 ? jaccardSimilarity(queryTags, entry.tags) : 0;
520
+
521
+ const domainMatch = queryDomain && entry.domain === queryDomain ? 1.0 : 0;
522
+
523
+ const vector = vectorScore;
524
+
525
+ const total =
526
+ w.semantic * semantic +
527
+ w.vector * vector +
528
+ w.severity * severity +
529
+ w.recency * recency +
530
+ w.tagOverlap * tagOverlap +
531
+ w.domainMatch * domainMatch;
532
+
533
+ return { semantic, vector, severity, recency, tagOverlap, domainMatch, total };
534
+ }
535
+
536
+ private generateTags(title: string, description: string, context?: string): string[] {
537
+ const text = [title, description, context ?? ''].join(' ');
538
+ const tokens = tokenize(text);
539
+ if (tokens.length === 0) return [];
540
+
541
+ const tf = calculateTf(tokens);
542
+ const scored: Array<[string, number]> = [];
543
+ for (const [term, tfValue] of tf) {
544
+ const idf = this.vocabulary.get(term) ?? 1;
545
+ scored.push([term, tfValue * idf]);
546
+ }
547
+
548
+ scored.sort((a, b) => b[1] - a[1]);
549
+ return scored.slice(0, 5).map(([term]) => term);
550
+ }
551
+
552
+ private detectDuplicate(
553
+ title: string,
554
+ domain: string,
555
+ ): { id: string; similarity: number } | null {
556
+ let candidates: SearchResult[];
557
+ try {
558
+ candidates = this.vault.search(title, { domain, limit: 50 });
559
+ } catch {
560
+ return null;
561
+ }
562
+ if (candidates.length === 0) return null;
563
+
564
+ const titleTokens = tokenize(title);
565
+ if (titleTokens.length === 0) return null;
566
+ const titleVec = calculateTfIdf(titleTokens, this.vocabulary);
567
+ if (titleVec.size === 0) {
568
+ const titleTf = calculateTf(titleTokens);
569
+ let bestMatch: { id: string; similarity: number } | null = null;
570
+ for (const candidate of candidates) {
571
+ const candidateTokens = tokenize(candidate.entry.title);
572
+ const candidateTf = calculateTf(candidateTokens);
573
+ const sim = cosineSimilarity(titleTf, candidateTf);
574
+ if (!bestMatch || sim > bestMatch.similarity) {
575
+ bestMatch = { id: candidate.entry.id, similarity: sim };
576
+ }
577
+ }
578
+ return bestMatch;
579
+ }
580
+
581
+ let bestMatch: { id: string; similarity: number } | null = null;
582
+ for (const candidate of candidates) {
583
+ const candidateText = [candidate.entry.title, candidate.entry.description].join(' ');
584
+ const candidateTokens = tokenize(candidateText);
585
+ const candidateVec = calculateTfIdf(candidateTokens, this.vocabulary);
586
+ const sim = cosineSimilarity(titleVec, candidateVec);
587
+ if (!bestMatch || sim > bestMatch.similarity) {
588
+ bestMatch = { id: candidate.entry.id, similarity: sim };
589
+ }
590
+ }
591
+ return bestMatch;
592
+ }
593
+
594
+ private updateVocabularyIncremental(entry: IntelligenceEntry): void {
595
+ const text = [entry.title, entry.description, entry.context ?? '', entry.tags.join(' ')].join(
596
+ ' ',
597
+ );
598
+ const tokens = new Set(tokenize(text));
599
+ const totalDocs = this.vault.stats().totalEntries;
600
+
601
+ for (const token of tokens) {
602
+ const currentDocCount = this.vocabulary.has(token)
603
+ ? Math.round(totalDocs / Math.exp(this.vocabulary.get(token)! - 1)) + 1
604
+ : 1;
605
+ const newIdf = Math.log((totalDocs + 1) / (currentDocCount + 1)) + 1;
606
+ this.vocabulary.set(token, newIdf);
607
+ }
608
+
609
+ this.persistVocabulary();
610
+ }
611
+
612
+ private persistVocabulary(): void {
613
+ const db = this.vault.getDb();
614
+ db.prepare('DELETE FROM brain_vocabulary').run();
615
+ if (this.vocabulary.size === 0) return;
616
+ const insert = db.prepare(
617
+ 'INSERT INTO brain_vocabulary (term, idf, doc_count) VALUES (?, ?, ?)',
618
+ );
619
+ const tx = db.transaction(() => {
620
+ for (const [term, idf] of this.vocabulary) {
621
+ insert.run(term, idf, 1);
622
+ }
623
+ });
624
+ tx();
625
+ }
626
+
627
+ private getCogneeWeights(): ScoringWeights {
628
+ return { ...COGNEE_WEIGHTS };
629
+ }
630
+
631
+ private recomputeWeights(): void {
632
+ const db = this.vault.getDb();
633
+ const feedbackCount = (
634
+ db.prepare('SELECT COUNT(*) as count FROM brain_feedback').get() as { count: number }
635
+ ).count;
636
+ if (feedbackCount < FEEDBACK_THRESHOLD) {
637
+ this.weights = { ...DEFAULT_WEIGHTS };
638
+ return;
639
+ }
640
+
641
+ const accepted = (
642
+ db
643
+ .prepare("SELECT COUNT(*) as count FROM brain_feedback WHERE action = 'accepted'")
644
+ .get() as { count: number }
645
+ ).count;
646
+ const acceptRate = feedbackCount > 0 ? accepted / feedbackCount : 0.5;
647
+
648
+ const semanticDelta = (acceptRate - 0.5) * WEIGHT_BOUND * 2;
649
+
650
+ const newWeights = { ...DEFAULT_WEIGHTS };
651
+ newWeights.semantic = clamp(
652
+ DEFAULT_WEIGHTS.semantic + semanticDelta,
653
+ DEFAULT_WEIGHTS.semantic - WEIGHT_BOUND,
654
+ DEFAULT_WEIGHTS.semantic + WEIGHT_BOUND,
655
+ );
656
+
657
+ // vector stays 0 in base weights (only active during hybrid search)
658
+ newWeights.vector = 0;
659
+
660
+ const remaining = 1.0 - newWeights.semantic - newWeights.vector;
661
+ const otherSum =
662
+ DEFAULT_WEIGHTS.severity +
663
+ DEFAULT_WEIGHTS.recency +
664
+ DEFAULT_WEIGHTS.tagOverlap +
665
+ DEFAULT_WEIGHTS.domainMatch;
666
+ const scale = remaining / otherSum;
667
+ newWeights.severity = DEFAULT_WEIGHTS.severity * scale;
668
+ newWeights.recency = DEFAULT_WEIGHTS.recency * scale;
669
+ newWeights.tagOverlap = DEFAULT_WEIGHTS.tagOverlap * scale;
670
+ newWeights.domainMatch = DEFAULT_WEIGHTS.domainMatch * scale;
671
+
672
+ this.weights = newWeights;
673
+ }
674
+ }
675
+
676
+ function clamp(value: number, min: number, max: number): number {
677
+ return Math.max(min, Math.min(max, value));
678
+ }