@soleri/core 9.3.1 → 9.5.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 (205) hide show
  1. package/dist/brain/intelligence.d.ts +5 -0
  2. package/dist/brain/intelligence.d.ts.map +1 -1
  3. package/dist/brain/intelligence.js +115 -26
  4. package/dist/brain/intelligence.js.map +1 -1
  5. package/dist/brain/learning-radar.d.ts +3 -3
  6. package/dist/brain/learning-radar.d.ts.map +1 -1
  7. package/dist/brain/learning-radar.js +8 -4
  8. package/dist/brain/learning-radar.js.map +1 -1
  9. package/dist/control/intent-router.d.ts +2 -2
  10. package/dist/control/intent-router.d.ts.map +1 -1
  11. package/dist/control/intent-router.js +35 -1
  12. package/dist/control/intent-router.js.map +1 -1
  13. package/dist/control/types.d.ts +10 -2
  14. package/dist/control/types.d.ts.map +1 -1
  15. package/dist/curator/curator.d.ts +4 -0
  16. package/dist/curator/curator.d.ts.map +1 -1
  17. package/dist/curator/curator.js +23 -1
  18. package/dist/curator/curator.js.map +1 -1
  19. package/dist/curator/schema.d.ts +1 -1
  20. package/dist/curator/schema.d.ts.map +1 -1
  21. package/dist/curator/schema.js +8 -0
  22. package/dist/curator/schema.js.map +1 -1
  23. package/dist/domain-packs/types.d.ts +6 -0
  24. package/dist/domain-packs/types.d.ts.map +1 -1
  25. package/dist/domain-packs/types.js +1 -0
  26. package/dist/domain-packs/types.js.map +1 -1
  27. package/dist/engine/module-manifest.js +3 -3
  28. package/dist/engine/module-manifest.js.map +1 -1
  29. package/dist/engine/register-engine.d.ts +9 -0
  30. package/dist/engine/register-engine.d.ts.map +1 -1
  31. package/dist/engine/register-engine.js +59 -1
  32. package/dist/engine/register-engine.js.map +1 -1
  33. package/dist/facades/types.d.ts +5 -1
  34. package/dist/facades/types.d.ts.map +1 -1
  35. package/dist/facades/types.js.map +1 -1
  36. package/dist/hooks/candidate-scorer.d.ts +28 -0
  37. package/dist/hooks/candidate-scorer.d.ts.map +1 -0
  38. package/dist/hooks/candidate-scorer.js +20 -0
  39. package/dist/hooks/candidate-scorer.js.map +1 -0
  40. package/dist/hooks/index.d.ts +2 -0
  41. package/dist/hooks/index.d.ts.map +1 -0
  42. package/dist/hooks/index.js +2 -0
  43. package/dist/hooks/index.js.map +1 -0
  44. package/dist/index.d.ts +4 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/operator/operator-context-store.d.ts +54 -0
  49. package/dist/operator/operator-context-store.d.ts.map +1 -0
  50. package/dist/operator/operator-context-store.js +434 -0
  51. package/dist/operator/operator-context-store.js.map +1 -0
  52. package/dist/operator/operator-context-types.d.ts +101 -0
  53. package/dist/operator/operator-context-types.d.ts.map +1 -0
  54. package/dist/operator/operator-context-types.js +27 -0
  55. package/dist/operator/operator-context-types.js.map +1 -0
  56. package/dist/packs/index.d.ts +2 -2
  57. package/dist/packs/index.d.ts.map +1 -1
  58. package/dist/packs/index.js +1 -1
  59. package/dist/packs/index.js.map +1 -1
  60. package/dist/packs/lockfile.d.ts +3 -0
  61. package/dist/packs/lockfile.d.ts.map +1 -1
  62. package/dist/packs/lockfile.js.map +1 -1
  63. package/dist/packs/types.d.ts +8 -2
  64. package/dist/packs/types.d.ts.map +1 -1
  65. package/dist/packs/types.js +6 -0
  66. package/dist/packs/types.js.map +1 -1
  67. package/dist/planning/plan-lifecycle.d.ts +12 -1
  68. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  69. package/dist/planning/plan-lifecycle.js +54 -16
  70. package/dist/planning/plan-lifecycle.js.map +1 -1
  71. package/dist/planning/planner-types.d.ts +6 -0
  72. package/dist/planning/planner-types.d.ts.map +1 -1
  73. package/dist/planning/planner.d.ts +21 -1
  74. package/dist/planning/planner.d.ts.map +1 -1
  75. package/dist/planning/planner.js +62 -3
  76. package/dist/planning/planner.js.map +1 -1
  77. package/dist/planning/task-complexity-assessor.d.ts.map +1 -1
  78. package/dist/planning/task-complexity-assessor.js.map +1 -1
  79. package/dist/plugins/types.d.ts +18 -18
  80. package/dist/runtime/admin-ops.d.ts +1 -1
  81. package/dist/runtime/admin-ops.d.ts.map +1 -1
  82. package/dist/runtime/admin-ops.js +100 -3
  83. package/dist/runtime/admin-ops.js.map +1 -1
  84. package/dist/runtime/admin-setup-ops.d.ts.map +1 -1
  85. package/dist/runtime/admin-setup-ops.js +19 -9
  86. package/dist/runtime/admin-setup-ops.js.map +1 -1
  87. package/dist/runtime/capture-ops.d.ts.map +1 -1
  88. package/dist/runtime/capture-ops.js +35 -7
  89. package/dist/runtime/capture-ops.js.map +1 -1
  90. package/dist/runtime/facades/brain-facade.d.ts.map +1 -1
  91. package/dist/runtime/facades/brain-facade.js +4 -2
  92. package/dist/runtime/facades/brain-facade.js.map +1 -1
  93. package/dist/runtime/facades/control-facade.d.ts.map +1 -1
  94. package/dist/runtime/facades/control-facade.js +8 -2
  95. package/dist/runtime/facades/control-facade.js.map +1 -1
  96. package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
  97. package/dist/runtime/facades/curator-facade.js +13 -0
  98. package/dist/runtime/facades/curator-facade.js.map +1 -1
  99. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  100. package/dist/runtime/facades/memory-facade.js +10 -12
  101. package/dist/runtime/facades/memory-facade.js.map +1 -1
  102. package/dist/runtime/facades/orchestrate-facade.d.ts.map +1 -1
  103. package/dist/runtime/facades/orchestrate-facade.js +36 -1
  104. package/dist/runtime/facades/orchestrate-facade.js.map +1 -1
  105. package/dist/runtime/facades/plan-facade.d.ts.map +1 -1
  106. package/dist/runtime/facades/plan-facade.js +20 -4
  107. package/dist/runtime/facades/plan-facade.js.map +1 -1
  108. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  109. package/dist/runtime/orchestrate-ops.js +71 -4
  110. package/dist/runtime/orchestrate-ops.js.map +1 -1
  111. package/dist/runtime/plan-feedback-helper.d.ts +21 -0
  112. package/dist/runtime/plan-feedback-helper.d.ts.map +1 -0
  113. package/dist/runtime/plan-feedback-helper.js +52 -0
  114. package/dist/runtime/plan-feedback-helper.js.map +1 -0
  115. package/dist/runtime/planning-extra-ops.d.ts.map +1 -1
  116. package/dist/runtime/planning-extra-ops.js +73 -34
  117. package/dist/runtime/planning-extra-ops.js.map +1 -1
  118. package/dist/runtime/session-briefing.d.ts.map +1 -1
  119. package/dist/runtime/session-briefing.js +9 -1
  120. package/dist/runtime/session-briefing.js.map +1 -1
  121. package/dist/runtime/types.d.ts +3 -0
  122. package/dist/runtime/types.d.ts.map +1 -1
  123. package/dist/skills/sync-skills.d.ts.map +1 -1
  124. package/dist/skills/sync-skills.js +13 -7
  125. package/dist/skills/sync-skills.js.map +1 -1
  126. package/package.json +1 -1
  127. package/src/brain/brain-intelligence.test.ts +30 -0
  128. package/src/brain/brain.ts +120 -46
  129. package/src/brain/extraction-quality.test.ts +323 -0
  130. package/src/brain/intelligence.ts +175 -64
  131. package/src/brain/learning-radar.ts +8 -5
  132. package/src/brain/second-brain-features.test.ts +1 -1
  133. package/src/chat/agent-loop.ts +1 -1
  134. package/src/chat/notifications.ts +4 -0
  135. package/src/control/intent-router.test.ts +73 -3
  136. package/src/control/intent-router.ts +48 -9
  137. package/src/control/types.ts +13 -2
  138. package/src/curator/curator.test.ts +92 -0
  139. package/src/curator/curator.ts +162 -18
  140. package/src/curator/schema.ts +8 -0
  141. package/src/domain-packs/types.ts +8 -0
  142. package/src/engine/module-manifest.test.ts +8 -2
  143. package/src/engine/module-manifest.ts +3 -3
  144. package/src/engine/register-engine.test.ts +73 -1
  145. package/src/engine/register-engine.ts +61 -1
  146. package/src/facades/types.ts +5 -0
  147. package/src/hooks/candidate-scorer.test.ts +76 -0
  148. package/src/hooks/candidate-scorer.ts +39 -0
  149. package/src/hooks/index.ts +6 -0
  150. package/src/index.ts +24 -0
  151. package/src/llm/llm-client.ts +1 -0
  152. package/src/operator/operator-context-store.test.ts +698 -0
  153. package/src/operator/operator-context-store.ts +569 -0
  154. package/src/operator/operator-context-types.ts +139 -0
  155. package/src/packs/index.ts +3 -1
  156. package/src/packs/lockfile.ts +3 -0
  157. package/src/packs/types.ts +9 -0
  158. package/src/persistence/sqlite-provider.ts +1 -0
  159. package/src/planning/github-projection.ts +48 -44
  160. package/src/planning/plan-lifecycle.ts +93 -22
  161. package/src/planning/planner-types.ts +6 -0
  162. package/src/planning/planner.ts +74 -4
  163. package/src/planning/task-complexity-assessor.test.ts +6 -2
  164. package/src/planning/task-complexity-assessor.ts +1 -4
  165. package/src/queue/pipeline-runner.ts +4 -0
  166. package/src/runtime/admin-ops.test.ts +139 -6
  167. package/src/runtime/admin-ops.ts +104 -3
  168. package/src/runtime/admin-setup-ops.ts +30 -10
  169. package/src/runtime/capture-ops.test.ts +84 -0
  170. package/src/runtime/capture-ops.ts +35 -7
  171. package/src/runtime/curator-extra-ops.test.ts +7 -0
  172. package/src/runtime/curator-extra-ops.ts +10 -1
  173. package/src/runtime/facades/admin-facade.test.ts +1 -1
  174. package/src/runtime/facades/brain-facade.ts +6 -3
  175. package/src/runtime/facades/control-facade.ts +10 -2
  176. package/src/runtime/facades/curator-facade.test.ts +7 -0
  177. package/src/runtime/facades/curator-facade.ts +18 -0
  178. package/src/runtime/facades/memory-facade.test.ts +14 -12
  179. package/src/runtime/facades/memory-facade.ts +197 -12
  180. package/src/runtime/facades/orchestrate-facade.ts +33 -1
  181. package/src/runtime/facades/plan-facade.test.ts +213 -0
  182. package/src/runtime/facades/plan-facade.ts +23 -4
  183. package/src/runtime/orchestrate-ops.test.ts +202 -2
  184. package/src/runtime/orchestrate-ops.ts +88 -7
  185. package/src/runtime/plan-feedback-helper.test.ts +173 -0
  186. package/src/runtime/plan-feedback-helper.ts +63 -0
  187. package/src/runtime/planning-extra-ops.test.ts +43 -1
  188. package/src/runtime/planning-extra-ops.ts +96 -33
  189. package/src/runtime/runtime.test.ts +50 -2
  190. package/src/runtime/runtime.ts +117 -89
  191. package/src/runtime/session-briefing.test.ts +1 -0
  192. package/src/runtime/session-briefing.ts +10 -1
  193. package/src/runtime/shutdown-registry.test.ts +151 -0
  194. package/src/runtime/shutdown-registry.ts +85 -0
  195. package/src/runtime/types.ts +7 -1
  196. package/src/skills/sync-skills.ts +14 -7
  197. package/src/transport/http-server.ts +50 -3
  198. package/src/transport/ws-server.ts +8 -0
  199. package/src/vault/linking.test.ts +12 -0
  200. package/src/vault/linking.ts +90 -44
  201. package/src/vault/vault-maintenance.ts +11 -18
  202. package/src/vault/vault-memories.ts +21 -13
  203. package/src/vault/vault-schema.ts +21 -0
  204. package/src/vault/vault.ts +8 -3
  205. package/vitest.config.ts +1 -0
@@ -10,6 +10,7 @@ import {
10
10
  cosineSimilarity,
11
11
  jaccardSimilarity,
12
12
  } from '../text/similarity.js';
13
+ import { rowToEntry } from '../vault/vault-entries.js';
13
14
  import type {
14
15
  ScoringWeights,
15
16
  ScoreBreakdown,
@@ -58,7 +59,7 @@ export class Brain {
58
59
  constructor(vault: Vault, vaultManager?: VaultManager) {
59
60
  this.vault = vault;
60
61
  this.vaultManager = vaultManager;
61
- this.rebuildVocabulary();
62
+ this.loadVocabularyFromDb();
62
63
  this.recomputeWeights();
63
64
  }
64
65
 
@@ -98,9 +99,15 @@ export class Brain {
98
99
  const queryDomain = options?.domain;
99
100
  const now = Math.floor(Date.now() / 1000);
100
101
 
102
+ // Compute queryVec once for all entries (was previously recomputed per entry in scoreEntry)
103
+ const queryVec =
104
+ this.vocabulary.size > 0 && queryTokens.length > 0
105
+ ? calculateTfIdf(queryTokens, this.vocabulary)
106
+ : null;
107
+
101
108
  const ranked = rawResults.map((result) => {
102
109
  const entry = result.entry;
103
- const breakdown = this.scoreEntry(entry, queryTokens, queryTags, queryDomain, now);
110
+ const breakdown = this.scoreEntry(entry, queryTokens, queryTags, queryDomain, now, queryVec);
104
111
  return { entry, score: breakdown.total, breakdown };
105
112
  });
106
113
 
@@ -142,14 +149,17 @@ export class Brain {
142
149
  /**
143
150
  * Two-pass retrieval — Pass 2: Load.
144
151
  * Returns full entries for specific IDs (from a previous scan).
152
+ * Uses a single WHERE id IN (...) query instead of per-ID lookups.
145
153
  */
146
154
  loadEntries(ids: string[]): IntelligenceEntry[] {
147
- const results: IntelligenceEntry[] = [];
148
- for (const id of ids) {
149
- const entry = this.vault.get(id);
150
- if (entry) results.push(entry);
151
- }
152
- return results;
155
+ if (ids.length === 0) return [];
156
+ const provider = this.vault.getProvider();
157
+ const placeholders = ids.map(() => '?').join(',');
158
+ const rows = provider.all<Record<string, unknown>>(
159
+ `SELECT * FROM entries WHERE id IN (${placeholders})`,
160
+ ids,
161
+ );
162
+ return rows.map(rowToEntry);
153
163
  }
154
164
 
155
165
  /** Rough token estimate for an entry (chars / 4). */
@@ -345,22 +355,57 @@ export class Brain {
345
355
  }
346
356
 
347
357
  rebuildVocabulary(): void {
358
+ const BATCH_SIZE = 100;
359
+ const termDocFreq = new Map<string, number>();
360
+ let docCount = 0;
361
+
362
+ // Helper to process a batch of entries into the term-doc-frequency map
363
+ const processBatch = (entries: IntelligenceEntry[]): void => {
364
+ for (const entry of entries) {
365
+ const text = [
366
+ entry.title,
367
+ entry.description,
368
+ entry.context ?? '',
369
+ entry.tags.join(' '),
370
+ ].join(' ');
371
+ const tokens = new Set(tokenize(text));
372
+ for (const token of tokens) {
373
+ termDocFreq.set(token, (termDocFreq.get(token) ?? 0) + 1);
374
+ }
375
+ }
376
+ docCount += entries.length;
377
+ };
378
+
379
+ // Helper to iterate a single vault in batches
380
+ const iterateVault = (vault: Vault, seen: Set<string> | null): void => {
381
+ let offset = 0;
382
+ while (true) {
383
+ const batch = vault.list({ limit: BATCH_SIZE, offset });
384
+ if (batch.length === 0) break;
385
+ if (seen) {
386
+ const unique = batch.filter((e) => {
387
+ if (seen.has(e.id)) return false;
388
+ seen.add(e.id);
389
+ return true;
390
+ });
391
+ processBatch(unique);
392
+ } else {
393
+ processBatch(batch);
394
+ }
395
+ if (batch.length < BATCH_SIZE) break;
396
+ offset += BATCH_SIZE;
397
+ }
398
+ };
399
+
348
400
  // Collect entries from all connected sources when VaultManager is available
349
- let entries: IntelligenceEntry[];
350
401
  if (this.vaultManager) {
351
402
  const seen = new Set<string>();
352
- entries = [];
353
403
  // Gather entries from all tier vaults and connected sources via manager
354
404
  for (const tierInfo of this.vaultManager.listTiers()) {
355
405
  if (!tierInfo.connected) continue;
356
406
  try {
357
407
  const tierVault = this.vaultManager.getTier(tierInfo.tier);
358
- for (const e of tierVault.list({ limit: 100000 })) {
359
- if (!seen.has(e.id)) {
360
- seen.add(e.id);
361
- entries.push(e);
362
- }
363
- }
408
+ iterateVault(tierVault, seen);
364
409
  } catch {
365
410
  /* tier not connected */
366
411
  }
@@ -369,44 +414,28 @@ export class Brain {
369
414
  const cv = this.vaultManager.getConnected(name);
370
415
  if (!cv) continue;
371
416
  try {
372
- for (const e of cv.vault.list({ limit: 100000 })) {
373
- if (!seen.has(e.id)) {
374
- seen.add(e.id);
375
- entries.push(e);
376
- }
377
- }
417
+ iterateVault(cv.vault, seen);
378
418
  } catch {
379
419
  /* source not accessible */
380
420
  }
381
421
  }
382
422
  } else {
383
- entries = this.vault.list({ limit: 100000 });
423
+ iterateVault(this.vault, null);
384
424
  }
385
- const docCount = entries.length;
425
+
386
426
  if (docCount === 0) {
387
427
  this.vocabulary.clear();
388
- this.persistVocabulary();
428
+ this.persistVocabularyFull();
389
429
  return;
390
430
  }
391
431
 
392
- const termDocFreq = new Map<string, number>();
393
- for (const entry of entries) {
394
- const text = [entry.title, entry.description, entry.context ?? '', entry.tags.join(' ')].join(
395
- ' ',
396
- );
397
- const tokens = new Set(tokenize(text));
398
- for (const token of tokens) {
399
- termDocFreq.set(token, (termDocFreq.get(token) ?? 0) + 1);
400
- }
401
- }
402
-
403
432
  this.vocabulary.clear();
404
433
  for (const [term, df] of termDocFreq) {
405
434
  const idf = Math.log((docCount + 1) / (df + 1)) + 1;
406
435
  this.vocabulary.set(term, idf);
407
436
  }
408
437
 
409
- this.persistVocabulary();
438
+ this.persistVocabularyFull();
410
439
  }
411
440
 
412
441
  getStats(): BrainStats {
@@ -469,11 +498,12 @@ export class Brain {
469
498
  queryTags: string[],
470
499
  queryDomain: string | undefined,
471
500
  now: number,
501
+ queryVec: Map<string, number> | null = null,
472
502
  ): ScoreBreakdown {
473
503
  const w = this.weights;
474
504
 
475
505
  let semantic = 0;
476
- if (this.vocabulary.size > 0 && queryTokens.length > 0) {
506
+ if (queryVec && queryVec.size > 0) {
477
507
  const entryText = [
478
508
  entry.title,
479
509
  entry.description,
@@ -481,7 +511,6 @@ export class Brain {
481
511
  entry.tags.join(' '),
482
512
  ].join(' ');
483
513
  const entryTokens = tokenize(entryText);
484
- const queryVec = calculateTfIdf(queryTokens, this.vocabulary);
485
514
  const entryVec = calculateTfIdf(entryTokens, this.vocabulary);
486
515
  semantic = cosineSimilarity(queryVec, entryVec);
487
516
  }
@@ -572,27 +601,72 @@ export class Brain {
572
601
  const tokens = new Set(tokenize(text));
573
602
  const totalDocs = this.vault.stats().totalEntries;
574
603
 
604
+ const changedTerms = new Map<string, number>();
575
605
  for (const token of tokens) {
576
606
  const currentDocCount = this.vocabulary.has(token)
577
607
  ? Math.round(totalDocs / Math.exp(this.vocabulary.get(token)! - 1)) + 1
578
608
  : 1;
579
609
  const newIdf = Math.log((totalDocs + 1) / (currentDocCount + 1)) + 1;
580
610
  this.vocabulary.set(token, newIdf);
611
+ changedTerms.set(token, newIdf);
612
+ }
613
+
614
+ this.persistVocabularyPartial(changedTerms);
615
+ }
616
+
617
+ private persistVocabularyPartial(terms: Map<string, number>): void {
618
+ if (terms.size === 0) return;
619
+ const db = this.vault.getDb();
620
+ const upsert = db.prepare(
621
+ 'INSERT OR REPLACE INTO brain_vocabulary (term, idf, doc_count, updated_at) VALUES (?, ?, 1, unixepoch())',
622
+ );
623
+ const tx = db.transaction(() => {
624
+ for (const [term, idf] of terms) {
625
+ upsert.run(term, idf);
626
+ }
627
+ });
628
+ tx();
629
+ }
630
+
631
+ /**
632
+ * Fast startup path: load vocabulary from the brain_vocabulary table.
633
+ * If the table is empty (first boot or after corruption), trigger a full rebuild.
634
+ */
635
+ private loadVocabularyFromDb(): void {
636
+ const db = this.vault.getDb();
637
+ const rows = db.prepare('SELECT term, idf FROM brain_vocabulary').all() as Array<{
638
+ term: string;
639
+ idf: number;
640
+ }>;
641
+
642
+ if (rows.length === 0) {
643
+ this.rebuildVocabulary();
644
+ return;
581
645
  }
582
646
 
583
- this.persistVocabulary();
647
+ this.vocabulary.clear();
648
+ for (const row of rows) {
649
+ this.vocabulary.set(row.term, row.idf);
650
+ }
584
651
  }
585
652
 
586
- private persistVocabulary(): void {
653
+ /**
654
+ * Full persist: DELETE all rows then re-INSERT. Used by rebuildVocabulary() which
655
+ * replaces the entire vocabulary and needs to remove stale terms.
656
+ */
657
+ private persistVocabularyFull(): void {
587
658
  const db = this.vault.getDb();
588
- db.prepare('DELETE FROM brain_vocabulary').run();
589
- if (this.vocabulary.size === 0) return;
590
- const insert = db.prepare(
591
- 'INSERT INTO brain_vocabulary (term, idf, doc_count) VALUES (?, ?, ?)',
659
+ if (this.vocabulary.size === 0) {
660
+ db.prepare('DELETE FROM brain_vocabulary').run();
661
+ return;
662
+ }
663
+ const upsert = db.prepare(
664
+ 'INSERT OR REPLACE INTO brain_vocabulary (term, idf, doc_count, updated_at) VALUES (?, ?, 1, unixepoch())',
592
665
  );
593
666
  const tx = db.transaction(() => {
667
+ db.prepare('DELETE FROM brain_vocabulary').run();
594
668
  for (const [term, idf] of this.vocabulary) {
595
- insert.run(term, idf, 1);
669
+ upsert.run(term, idf);
596
670
  }
597
671
  });
598
672
  tx();
@@ -0,0 +1,323 @@
1
+ /**
2
+ * TDD tests for brain extraction quality (issue #359).
3
+ *
4
+ * These tests define the DESIRED behavior of extractKnowledge().
5
+ * They are expected to FAIL against the current implementation.
6
+ * Implementation fixes come in issues #360-#366.
7
+ *
8
+ * What's wrong today:
9
+ * - plan_completed rule produces generic "Successful plan: {id}" titles
10
+ * - Extraction rules never read session.context (objective, scope, decisions)
11
+ * - No dedup: same rule + sessionId can produce duplicate proposals
12
+ * - long_session rule fires with low-value noise (to be removed in #360)
13
+ * - No drift_detected rule exists yet (to be added in #366)
14
+ * - Confidence is not adjusted based on context richness
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18
+ import { mkdirSync, rmSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { tmpdir } from 'node:os';
21
+ import { createAgentRuntime } from '../runtime/runtime.js';
22
+ import type { AgentRuntime } from '../runtime/types.js';
23
+
24
+ describe('Extraction Quality', () => {
25
+ let runtime: AgentRuntime;
26
+ let plannerDir: string;
27
+
28
+ beforeEach(() => {
29
+ plannerDir = join(tmpdir(), 'extraction-quality-test-' + Date.now());
30
+ mkdirSync(plannerDir, { recursive: true });
31
+ runtime = createAgentRuntime({
32
+ agentId: 'test-extraction-quality',
33
+ vaultPath: ':memory:',
34
+ plansPath: join(plannerDir, 'plans.json'),
35
+ });
36
+ });
37
+
38
+ afterEach(() => {
39
+ runtime.close();
40
+ rmSync(plannerDir, { recursive: true, force: true });
41
+ });
42
+
43
+ // ─── Helper ────────────────────────────────────────────────────────
44
+
45
+ function createSessionWithContext(
46
+ sessionId: string,
47
+ context: string,
48
+ overrides: {
49
+ planId?: string;
50
+ planOutcome?: string;
51
+ toolsUsed?: string[];
52
+ filesModified?: string[];
53
+ domain?: string;
54
+ } = {},
55
+ ) {
56
+ runtime.brainIntelligence.lifecycle({
57
+ action: 'start',
58
+ sessionId,
59
+ domain: overrides.domain ?? 'testing',
60
+ context,
61
+ toolsUsed: overrides.toolsUsed ?? [],
62
+ filesModified: overrides.filesModified ?? [],
63
+ planId: overrides.planId,
64
+ });
65
+ runtime.brainIntelligence.lifecycle({
66
+ action: 'end',
67
+ sessionId,
68
+ planOutcome: overrides.planOutcome,
69
+ toolsUsed: overrides.toolsUsed,
70
+ filesModified: overrides.filesModified,
71
+ });
72
+ }
73
+
74
+ // ─── 1. Actionable titles from rich context ───────────────────────
75
+
76
+ describe('actionable proposals from session context', () => {
77
+ it('should use session context objective in plan_completed proposal title', () => {
78
+ const richContext = JSON.stringify({
79
+ objective: 'Add OAuth2 authentication to the API gateway',
80
+ scope: { included: ['auth module', 'gateway routes'], excluded: ['frontend'] },
81
+ decisions: ['Use passport.js for OAuth2 strategy'],
82
+ });
83
+
84
+ createSessionWithContext('rich-ctx-1', richContext, {
85
+ planId: 'plan-oauth',
86
+ planOutcome: 'completed',
87
+ });
88
+
89
+ const result = runtime.brainIntelligence.extractKnowledge('rich-ctx-1');
90
+ const planProposal = result.proposals.find((p) => p.rule === 'plan_completed');
91
+
92
+ expect(planProposal).toBeDefined();
93
+ // The title should reference the objective, not just the plan ID
94
+ expect(planProposal!.title).not.toContain('Successful plan:');
95
+ expect(planProposal!.title.toLowerCase()).toContain('oauth');
96
+ });
97
+
98
+ it('should use session context objective in plan_abandoned proposal title', () => {
99
+ const richContext = JSON.stringify({
100
+ objective: 'Migrate database from Postgres to CockroachDB',
101
+ scope: { included: ['migration scripts', 'connection pool'] },
102
+ });
103
+
104
+ createSessionWithContext('rich-ctx-2', richContext, {
105
+ planId: 'plan-migrate',
106
+ planOutcome: 'abandoned',
107
+ });
108
+
109
+ const result = runtime.brainIntelligence.extractKnowledge('rich-ctx-2');
110
+ const abandonedProposal = result.proposals.find((p) => p.rule === 'plan_abandoned');
111
+
112
+ expect(abandonedProposal).toBeDefined();
113
+ // The title should reference what was abandoned, not just the plan ID
114
+ expect(abandonedProposal!.title).not.toContain('Abandoned plan:');
115
+ expect(abandonedProposal!.title.toLowerCase()).toContain('migrate');
116
+ });
117
+
118
+ it('should include scope details in proposal description when context has scope', () => {
119
+ const richContext = JSON.stringify({
120
+ objective: 'Refactor the billing reconciliation module',
121
+ scope: {
122
+ included: ['stripe-adapter', 'webhook-handler', 'ledger-service'],
123
+ excluded: ['billing-ui', 'invoice-generator'],
124
+ },
125
+ });
126
+
127
+ createSessionWithContext('rich-ctx-3', richContext, {
128
+ planId: 'plan-abc',
129
+ planOutcome: 'completed',
130
+ });
131
+
132
+ const result = runtime.brainIntelligence.extractKnowledge('rich-ctx-3');
133
+ const planProposal = result.proposals.find((p) => p.rule === 'plan_completed');
134
+
135
+ expect(planProposal).toBeDefined();
136
+ // Description should mention scope components, not just "can be reused for similar tasks"
137
+ expect(planProposal!.description.toLowerCase()).toMatch(/stripe|webhook|ledger/);
138
+ });
139
+ });
140
+
141
+ // ─── 2. Dedup: same rule + sessionId = 1 proposal ────────────────
142
+
143
+ describe('proposal deduplication', () => {
144
+ it('should produce exactly 1 proposal per rule per session', () => {
145
+ createSessionWithContext('dedup-1', 'some context', {
146
+ planId: 'plan-dedup',
147
+ planOutcome: 'completed',
148
+ });
149
+
150
+ // Extract twice on same session (reset extractedAt in between)
151
+ runtime.brainIntelligence.extractKnowledge('dedup-1');
152
+ runtime.brainIntelligence.resetExtracted({ sessionId: 'dedup-1' });
153
+ runtime.brainIntelligence.extractKnowledge('dedup-1');
154
+
155
+ // Query all proposals for this session
156
+ const proposals = runtime.brainIntelligence.getProposals({
157
+ sessionId: 'dedup-1',
158
+ });
159
+
160
+ // Count proposals per rule
161
+ const ruleCounts = new Map<string, number>();
162
+ for (const p of proposals) {
163
+ ruleCounts.set(p.rule, (ruleCounts.get(p.rule) ?? 0) + 1);
164
+ }
165
+
166
+ // Each rule should appear at most once per session
167
+ for (const [rule, count] of ruleCounts) {
168
+ expect(count, `rule "${rule}" should appear exactly once`).toBe(1);
169
+ }
170
+ });
171
+ });
172
+
173
+ // ─── 3. long_session rule should not fire ─────────────────────────
174
+
175
+ describe('long_session rule removal', () => {
176
+ it('should NOT produce a long_session proposal', () => {
177
+ // Create session manually with backdated start time to simulate >30 min duration.
178
+ // SQLite datetime('now') uses 'YYYY-MM-DD HH:MM:SS' format (no T/Z), so match that.
179
+ const d = new Date(Date.now() - 35 * 60 * 1000);
180
+ const thirtyFiveMinAgo = d
181
+ .toISOString()
182
+ .replace('T', ' ')
183
+ .replace(/\.\d{3}Z$/, '');
184
+ const provider = runtime.vault.getProvider();
185
+
186
+ // Insert session directly with backdated started_at so auto-extract sees the long duration
187
+ provider.run(
188
+ `INSERT INTO brain_sessions (id, started_at, domain, context, tools_used, files_modified)
189
+ VALUES (?, ?, ?, ?, ?, ?)`,
190
+ ['long-sess-1', thirtyFiveMinAgo, 'testing', null, '[]', '[]'],
191
+ );
192
+
193
+ // End the session — this sets ended_at to now(), creating a >30 min gap
194
+ runtime.brainIntelligence.lifecycle({
195
+ action: 'end',
196
+ sessionId: 'long-sess-1',
197
+ toolsUsed: ['search'], // need at least 1 tool for auto-extract gate
198
+ });
199
+
200
+ // Reset extracted_at so we can manually extract and inspect
201
+ runtime.brainIntelligence.resetExtracted({ sessionId: 'long-sess-1' });
202
+
203
+ const result = runtime.brainIntelligence.extractKnowledge('long-sess-1');
204
+
205
+ // long_session rule should no longer exist (removal in #360)
206
+ expect(result.rulesApplied).not.toContain('long_session');
207
+ expect(result.proposals.find((p) => p.rule === 'long_session')).toBeUndefined();
208
+ });
209
+ });
210
+
211
+ // ─── 4. drift_detected rule ───────────────────────────────────────
212
+
213
+ describe('drift_detected rule', () => {
214
+ it('should fire when session context contains drift indicators', () => {
215
+ const contextWithDrift = JSON.stringify({
216
+ objective: 'Implement caching layer for API responses',
217
+ drift: {
218
+ items: [
219
+ {
220
+ type: 'added',
221
+ description: 'Added Redis fallback to in-memory cache',
222
+ impact: 'medium',
223
+ },
224
+ {
225
+ type: 'skipped',
226
+ description: 'Skipped cache invalidation webhooks',
227
+ impact: 'high',
228
+ },
229
+ ],
230
+ accuracyScore: 65,
231
+ },
232
+ });
233
+
234
+ createSessionWithContext('drift-1', contextWithDrift, {
235
+ planId: 'plan-cache',
236
+ planOutcome: 'completed',
237
+ });
238
+
239
+ const result = runtime.brainIntelligence.extractKnowledge('drift-1');
240
+
241
+ // A drift_detected rule should fire (to be added in #366)
242
+ expect(result.rulesApplied).toContain('drift_detected');
243
+ const driftProposal = result.proposals.find((p) => p.rule === 'drift_detected');
244
+ expect(driftProposal).toBeDefined();
245
+ expect(driftProposal!.type).toBe('anti-pattern');
246
+ expect(driftProposal!.description.toLowerCase()).toMatch(/drift|skipped|deviation/);
247
+ });
248
+
249
+ it('should NOT fire drift_detected when context has no drift', () => {
250
+ const cleanContext = JSON.stringify({
251
+ objective: 'Add unit tests for auth module',
252
+ scope: { included: ['auth'] },
253
+ });
254
+
255
+ createSessionWithContext('no-drift-1', cleanContext, {
256
+ planId: 'plan-tests',
257
+ planOutcome: 'completed',
258
+ });
259
+
260
+ const result = runtime.brainIntelligence.extractKnowledge('no-drift-1');
261
+ expect(result.rulesApplied).not.toContain('drift_detected');
262
+ });
263
+ });
264
+
265
+ // ─── 5. Context richness affects confidence ───────────────────────
266
+
267
+ describe('confidence based on context richness', () => {
268
+ it('should assign higher confidence to proposals with rich session context', () => {
269
+ // Session with rich context
270
+ const richContext = JSON.stringify({
271
+ objective: 'Build notification service',
272
+ scope: { included: ['notifications', 'email-adapter', 'push-adapter'] },
273
+ decisions: ['Use event-driven architecture', 'SNS for push notifications'],
274
+ });
275
+
276
+ createSessionWithContext('conf-rich', richContext, {
277
+ planId: 'plan-notify-rich',
278
+ planOutcome: 'completed',
279
+ });
280
+
281
+ // Session with no context
282
+ createSessionWithContext('conf-empty', '', {
283
+ planId: 'plan-notify-empty',
284
+ planOutcome: 'completed',
285
+ });
286
+
287
+ const richResult = runtime.brainIntelligence.extractKnowledge('conf-rich');
288
+ runtime.brainIntelligence.resetExtracted({ sessionId: 'conf-empty' });
289
+ const emptyResult = runtime.brainIntelligence.extractKnowledge('conf-empty');
290
+
291
+ const richPlanProposal = richResult.proposals.find((p) => p.rule === 'plan_completed');
292
+ const emptyPlanProposal = emptyResult.proposals.find((p) => p.rule === 'plan_completed');
293
+
294
+ expect(richPlanProposal).toBeDefined();
295
+ expect(emptyPlanProposal).toBeDefined();
296
+
297
+ // Rich context should produce higher confidence than empty context
298
+ expect(richPlanProposal!.confidence).toBeGreaterThan(emptyPlanProposal!.confidence);
299
+ });
300
+
301
+ it('should assign lower confidence when session context is null', () => {
302
+ // Session with null context (no context field at all)
303
+ runtime.brainIntelligence.lifecycle({
304
+ action: 'start',
305
+ sessionId: 'conf-null',
306
+ planId: 'plan-null-ctx',
307
+ });
308
+ runtime.brainIntelligence.lifecycle({
309
+ action: 'end',
310
+ sessionId: 'conf-null',
311
+ planOutcome: 'completed',
312
+ });
313
+
314
+ runtime.brainIntelligence.resetExtracted({ sessionId: 'conf-null' });
315
+ const result = runtime.brainIntelligence.extractKnowledge('conf-null');
316
+ const planProposal = result.proposals.find((p) => p.rule === 'plan_completed');
317
+
318
+ expect(planProposal).toBeDefined();
319
+ // Without context, confidence should be below the current hardcoded 0.65
320
+ expect(planProposal!.confidence).toBeLessThan(0.65);
321
+ });
322
+ });
323
+ });