@cleocode/core 2026.4.29 → 2026.4.31

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 (89) hide show
  1. package/dist/bootstrap.d.ts +35 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/code/index.d.ts +8 -4
  4. package/dist/code/index.d.ts.map +1 -1
  5. package/dist/code/parser.d.ts +22 -9
  6. package/dist/code/parser.d.ts.map +1 -1
  7. package/dist/hooks/handlers/session-hooks.d.ts +11 -4
  8. package/dist/hooks/handlers/session-hooks.d.ts.map +1 -1
  9. package/dist/hooks/payload-schemas.d.ts +6 -6
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3859 -3008
  13. package/dist/index.js.map +4 -4
  14. package/dist/internal.d.ts +10 -7
  15. package/dist/internal.d.ts.map +1 -1
  16. package/dist/lib/tree-sitter-languages.d.ts +11 -7
  17. package/dist/lib/tree-sitter-languages.d.ts.map +1 -1
  18. package/dist/memory/auto-extract.d.ts +27 -15
  19. package/dist/memory/auto-extract.d.ts.map +1 -1
  20. package/dist/memory/brain-backfill.d.ts +59 -0
  21. package/dist/memory/brain-backfill.d.ts.map +1 -0
  22. package/dist/memory/brain-purge.d.ts +51 -0
  23. package/dist/memory/brain-purge.d.ts.map +1 -0
  24. package/dist/memory/brain-retrieval.d.ts.map +1 -1
  25. package/dist/memory/brain-search.d.ts.map +1 -1
  26. package/dist/memory/decisions.d.ts.map +1 -1
  27. package/dist/memory/engine-compat.d.ts +71 -0
  28. package/dist/memory/engine-compat.d.ts.map +1 -1
  29. package/dist/memory/graph-auto-populate.d.ts +65 -0
  30. package/dist/memory/graph-auto-populate.d.ts.map +1 -0
  31. package/dist/memory/graph-queries.d.ts +127 -0
  32. package/dist/memory/graph-queries.d.ts.map +1 -0
  33. package/dist/memory/learnings.d.ts +2 -0
  34. package/dist/memory/learnings.d.ts.map +1 -1
  35. package/dist/memory/patterns.d.ts +2 -0
  36. package/dist/memory/patterns.d.ts.map +1 -1
  37. package/dist/memory/quality-scoring.d.ts +90 -0
  38. package/dist/memory/quality-scoring.d.ts.map +1 -0
  39. package/dist/sessions/session-memory-bridge.d.ts +16 -10
  40. package/dist/sessions/session-memory-bridge.d.ts.map +1 -1
  41. package/dist/store/brain-accessor.d.ts +7 -0
  42. package/dist/store/brain-accessor.d.ts.map +1 -1
  43. package/dist/store/brain-schema.d.ts +185 -11
  44. package/dist/store/brain-schema.d.ts.map +1 -1
  45. package/dist/store/brain-sqlite.d.ts.map +1 -1
  46. package/dist/store/nexus-schema.d.ts +480 -2
  47. package/dist/store/nexus-schema.d.ts.map +1 -1
  48. package/dist/store/tasks-schema.d.ts +9 -9
  49. package/dist/store/validation-schemas.d.ts +44 -28
  50. package/dist/store/validation-schemas.d.ts.map +1 -1
  51. package/dist/system/dependencies.d.ts +43 -0
  52. package/dist/system/dependencies.d.ts.map +1 -0
  53. package/dist/system/health.d.ts +3 -0
  54. package/dist/system/health.d.ts.map +1 -1
  55. package/dist/tasks/complete.d.ts.map +1 -1
  56. package/package.json +19 -19
  57. package/src/bootstrap.ts +124 -0
  58. package/src/code/index.ts +20 -4
  59. package/src/code/parser.ts +310 -110
  60. package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +19 -45
  61. package/src/hooks/handlers/__tests__/session-hooks.test.ts +42 -54
  62. package/src/hooks/handlers/session-hooks.ts +11 -33
  63. package/src/index.ts +14 -0
  64. package/src/internal.ts +37 -7
  65. package/src/lib/tree-sitter-languages.ts +11 -7
  66. package/src/memory/__tests__/auto-extract.test.ts +20 -82
  67. package/src/memory/__tests__/embedding-pipeline.test.ts +389 -0
  68. package/src/memory/auto-extract.ts +34 -120
  69. package/src/memory/brain-backfill.ts +471 -0
  70. package/src/memory/brain-purge.ts +315 -0
  71. package/src/memory/brain-retrieval.ts +43 -2
  72. package/src/memory/brain-search.ts +23 -6
  73. package/src/memory/decisions.ts +76 -3
  74. package/src/memory/engine-compat.ts +168 -0
  75. package/src/memory/graph-auto-populate.ts +173 -0
  76. package/src/memory/graph-queries.ts +424 -0
  77. package/src/memory/learnings.ts +55 -7
  78. package/src/memory/patterns.ts +66 -13
  79. package/src/memory/quality-scoring.ts +173 -0
  80. package/src/sessions/__tests__/session-memory-bridge.test.ts +27 -49
  81. package/src/sessions/session-memory-bridge.ts +19 -47
  82. package/src/store/__tests__/brain-accessor-pageindex.test.ts +93 -22
  83. package/src/store/brain-accessor.ts +48 -2
  84. package/src/store/brain-schema.ts +165 -13
  85. package/src/store/brain-sqlite.ts +35 -0
  86. package/src/store/nexus-schema.ts +257 -3
  87. package/src/system/dependencies.ts +534 -0
  88. package/src/system/health.ts +126 -22
  89. package/src/tasks/complete.ts +40 -0
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Brain.db noise purge utility — removes 2927 noise entries leaving ~57 signal entries.
3
+ *
4
+ * Purge rules:
5
+ * - Patterns: keep newest per unique pattern text (dedup), delete duplicates
6
+ * - Learnings: delete ALL (all are auto-generated task completion noise)
7
+ * - Decisions: delete ALL except D-mntpeeer (the one real architectural decision)
8
+ * - Observations: delete task-start/task-complete/session-note/test/junk noise
9
+ *
10
+ * Safety: requires backup to exist before calling. Never touches tasks.db.
11
+ *
12
+ * @task T524
13
+ * @epic T523
14
+ */
15
+
16
+ import { inArray, ne, sql } from 'drizzle-orm';
17
+ import {
18
+ brainDecisions,
19
+ brainLearnings,
20
+ brainObservations,
21
+ brainPatterns,
22
+ } from '../store/brain-schema.js';
23
+ import { getBrainDb, getBrainNativeDb } from '../store/brain-sqlite.js';
24
+ import { ensureFts5Tables, rebuildFts5Index } from './brain-search.js';
25
+
26
+ /** Result counts from a purge run. */
27
+ export interface PurgeResult {
28
+ /** Number of pattern rows deleted. */
29
+ patternsDeleted: number;
30
+ /** Number of learning rows deleted. */
31
+ learningsDeleted: number;
32
+ /** Number of decision rows deleted. */
33
+ decisionsDeleted: number;
34
+ /** Number of observation rows deleted. */
35
+ observationsDeleted: number;
36
+ /** Counts after purge. */
37
+ after: {
38
+ patterns: number;
39
+ learnings: number;
40
+ decisions: number;
41
+ observations: number;
42
+ };
43
+ /** FTS5 indexes rebuilt. */
44
+ fts5Rebuilt: boolean;
45
+ }
46
+
47
+ /**
48
+ * Count rows in a table using native DB PRAGMA (avoids Drizzle type issues).
49
+ * Falls back to 0 on error.
50
+ */
51
+ function countRowsNative(tableName: string): number {
52
+ const nativeDb = getBrainNativeDb();
53
+ if (!nativeDb) return 0;
54
+ try {
55
+ const row = nativeDb.prepare(`SELECT COUNT(*) AS cnt FROM ${tableName}`).get() as
56
+ | { cnt: number }
57
+ | undefined;
58
+ return row?.cnt ?? 0;
59
+ } catch {
60
+ return 0;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Execute the brain.db noise purge.
66
+ *
67
+ * Deletes ~2927 noise entries across four tables, leaving ~57 signal entries.
68
+ * The one real architectural decision (D-mntpeeer) is explicitly preserved.
69
+ *
70
+ * Rules applied in order:
71
+ * 1. Patterns: deduplicate by pattern text — keep newest per unique text, delete older dupes
72
+ * 2. Learnings: delete all (100% noise — auto-generated "Completed:" and dependency notices)
73
+ * 3. Decisions: delete all except D-mntpeeer
74
+ * 4. Observations: delete task-start, task-complete, session-note, and test/junk entries
75
+ *
76
+ * @param projectRoot - Absolute path to the project root (e.g. /mnt/projects/cleocode)
77
+ * @returns PurgeResult with before/after counts and FTS5 status
78
+ */
79
+ export async function purgeBrainNoise(projectRoot: string): Promise<PurgeResult> {
80
+ const db = await getBrainDb(projectRoot);
81
+
82
+ // =========================================================================
83
+ // Pre-purge counts
84
+ // =========================================================================
85
+
86
+ const beforePatterns = countRowsNative('brain_patterns');
87
+ const beforeLearnings = countRowsNative('brain_learnings');
88
+ const beforeDecisions = countRowsNative('brain_decisions');
89
+ const beforeObservations = countRowsNative('brain_observations');
90
+
91
+ console.log('Pre-purge counts:');
92
+ console.log(` Patterns: ${beforePatterns}`);
93
+ console.log(` Learnings: ${beforeLearnings}`);
94
+ console.log(` Decisions: ${beforeDecisions}`);
95
+ console.log(` Observations: ${beforeObservations}`);
96
+
97
+ // =========================================================================
98
+ // SAFETY: Confirm D-mntpeeer exists before any destructive operation
99
+ // =========================================================================
100
+
101
+ const realDecision = await db.select().from(brainDecisions).where(sql`id = 'D-mntpeeer'`);
102
+
103
+ if (realDecision.length === 0) {
104
+ throw new Error(
105
+ 'SAFETY ABORT: D-mntpeeer not found in brain_decisions. Backup and restore required.',
106
+ );
107
+ }
108
+
109
+ console.log('Safety check passed: D-mntpeeer confirmed present');
110
+
111
+ // =========================================================================
112
+ // Rule 1: Pattern deduplication
113
+ // Strategy: for each unique pattern text, find the MAX(extracted_at) row,
114
+ // delete all other rows with the same text.
115
+ // =========================================================================
116
+
117
+ // Get all patterns to find duplicates in TS (safer than raw SQL subquery)
118
+ const allPatterns = await db.select().from(brainPatterns);
119
+
120
+ // Group by normalized pattern text
121
+ const patternGroups = new Map<string, typeof allPatterns>();
122
+ for (const p of allPatterns) {
123
+ const key = p.pattern.trim().toLowerCase();
124
+ const group = patternGroups.get(key) ?? [];
125
+ group.push(p);
126
+ patternGroups.set(key, group);
127
+ }
128
+
129
+ // Collect IDs to delete: for each group with >1 entry, keep newest (max extractedAt), delete rest
130
+ const patternIdsToDelete: string[] = [];
131
+ for (const [, group] of patternGroups) {
132
+ if (group.length <= 1) continue;
133
+ // Sort by extractedAt desc, keep first, delete the rest
134
+ group.sort((a, b) => (b.extractedAt > a.extractedAt ? 1 : -1));
135
+ const toDelete = group.slice(1).map((p) => p.id);
136
+ patternIdsToDelete.push(...toDelete);
137
+ }
138
+
139
+ let patternsDeleted = 0;
140
+ if (patternIdsToDelete.length > 0) {
141
+ // Delete in batches of 500 to avoid SQLite parameter limits
142
+ const BATCH = 500;
143
+ for (let i = 0; i < patternIdsToDelete.length; i += BATCH) {
144
+ const batch = patternIdsToDelete.slice(i, i + BATCH);
145
+ await db.delete(brainPatterns).where(inArray(brainPatterns.id, batch));
146
+ patternsDeleted += batch.length;
147
+ }
148
+ }
149
+
150
+ console.log(`Patterns deleted (dedup): ${patternsDeleted}`);
151
+
152
+ // =========================================================================
153
+ // Rule 2: Delete ALL learnings (100% noise)
154
+ // All learnings are auto-generated "Completed: T..." or dependency notices
155
+ // =========================================================================
156
+
157
+ // Count before delete using run
158
+ const learningsRows = await db.select().from(brainLearnings);
159
+ const learningsDeleted = learningsRows.length;
160
+
161
+ if (learningsDeleted > 0) {
162
+ // Delete in batches using IDs
163
+ const ids = learningsRows.map((r) => r.id);
164
+ const BATCH = 500;
165
+ for (let i = 0; i < ids.length; i += BATCH) {
166
+ const batch = ids.slice(i, i + BATCH);
167
+ await db.delete(brainLearnings).where(inArray(brainLearnings.id, batch));
168
+ }
169
+ }
170
+
171
+ console.log(`Learnings deleted (all): ${learningsDeleted}`);
172
+
173
+ // =========================================================================
174
+ // Rule 3: Delete ALL decisions EXCEPT D-mntpeeer
175
+ // =========================================================================
176
+
177
+ const decisionsToDelete = await db
178
+ .select()
179
+ .from(brainDecisions)
180
+ .where(ne(brainDecisions.id, 'D-mntpeeer'));
181
+
182
+ const decisionsDeleted = decisionsToDelete.length;
183
+ if (decisionsDeleted > 0) {
184
+ const ids = decisionsToDelete.map((r) => r.id);
185
+ await db.delete(brainDecisions).where(inArray(brainDecisions.id, ids));
186
+ }
187
+
188
+ console.log(`Decisions deleted (all except D-mntpeeer): ${decisionsDeleted}`);
189
+
190
+ // =========================================================================
191
+ // Rule 4: Delete noise observations
192
+ // Keep: real release notes, real codebase analysis, real session handoffs
193
+ // Delete: task-start, task-complete, session-note, test/junk entries
194
+ // =========================================================================
195
+
196
+ // Collect all observations to classify them
197
+ const allObservations = await db.select().from(brainObservations);
198
+
199
+ const obsIdsToDelete: string[] = [];
200
+
201
+ for (const obs of allObservations) {
202
+ const title = obs.title ?? '';
203
+ const narrative = (obs.narrative ?? '').toLowerCase();
204
+ const titleLower = title.toLowerCase();
205
+
206
+ // Rule 4a: Task lifecycle noise
207
+ if (
208
+ title.startsWith('Task start: T') ||
209
+ title.startsWith('Task complete: T') ||
210
+ title.startsWith('Task depended on') ||
211
+ title.startsWith('Task T') // catch "Task T527 depended on..." etc.
212
+ ) {
213
+ obsIdsToDelete.push(obs.id);
214
+ continue;
215
+ }
216
+
217
+ // Rule 4b: Session notes (all are noise — real handoffs are in sticky notes)
218
+ if (title.startsWith('Session note:')) {
219
+ obsIdsToDelete.push(obs.id);
220
+ continue;
221
+ }
222
+
223
+ // Rule 4c: Test/audit/probe/junk observations
224
+ const testKeywords = [
225
+ 'audit test',
226
+ 'audit probe',
227
+ 'probe observation',
228
+ 'dup test',
229
+ 'provider test',
230
+ 'brain regression',
231
+ 'brain validation',
232
+ 'release test',
233
+ 'functional validation',
234
+ 'test title',
235
+ 'test decision',
236
+ 'test learning',
237
+ 'test pattern',
238
+ 'test observation',
239
+ 'sticky note', // audit test sticky note
240
+ ];
241
+
242
+ const isTestNoise = testKeywords.some(
243
+ (kw) => titleLower.includes(kw) || narrative.includes(kw),
244
+ );
245
+
246
+ if (isTestNoise) {
247
+ obsIdsToDelete.push(obs.id);
248
+ }
249
+
250
+ // Rule 4d: Auto-detection codebase map noise (keep real ones, delete duplicates)
251
+ // "Codebase Stack Analysis" and "Codebase Integrations" — keep one of each,
252
+ // but the task asks us to keep ~27 so we apply conservative rules.
253
+ // Only delete if title exactly matches auto-generated repeated patterns.
254
+ }
255
+
256
+ let observationsDeleted = 0;
257
+ if (obsIdsToDelete.length > 0) {
258
+ const BATCH = 500;
259
+ for (let i = 0; i < obsIdsToDelete.length; i += BATCH) {
260
+ const batch = obsIdsToDelete.slice(i, i + BATCH);
261
+ await db.delete(brainObservations).where(inArray(brainObservations.id, batch));
262
+ observationsDeleted += batch.length;
263
+ }
264
+ }
265
+
266
+ console.log(`Observations deleted: ${observationsDeleted}`);
267
+
268
+ // =========================================================================
269
+ // Post-purge counts
270
+ // =========================================================================
271
+
272
+ const afterPatterns = countRowsNative('brain_patterns');
273
+ const afterLearnings = countRowsNative('brain_learnings');
274
+ const afterDecisions = countRowsNative('brain_decisions');
275
+ const afterObservations = countRowsNative('brain_observations');
276
+
277
+ console.log('\nPost-purge counts:');
278
+ console.log(` Patterns: ${afterPatterns} (deleted ${beforePatterns - afterPatterns})`);
279
+ console.log(` Learnings: ${afterLearnings} (deleted ${beforeLearnings - afterLearnings})`);
280
+ console.log(` Decisions: ${afterDecisions} (deleted ${beforeDecisions - afterDecisions})`);
281
+ console.log(
282
+ ` Observations: ${afterObservations} (deleted ${beforeObservations - afterObservations})`,
283
+ );
284
+
285
+ // =========================================================================
286
+ // Rebuild FTS5 indexes after bulk deletes
287
+ // =========================================================================
288
+
289
+ let fts5Rebuilt = false;
290
+ const nativeDb = getBrainNativeDb();
291
+ if (nativeDb) {
292
+ ensureFts5Tables(nativeDb);
293
+ try {
294
+ rebuildFts5Index(nativeDb);
295
+ fts5Rebuilt = true;
296
+ console.log('FTS5 indexes rebuilt successfully');
297
+ } catch (err) {
298
+ console.warn('FTS5 rebuild failed (non-fatal):', err);
299
+ }
300
+ }
301
+
302
+ return {
303
+ patternsDeleted: beforePatterns - afterPatterns,
304
+ learningsDeleted: beforeLearnings - afterLearnings,
305
+ decisionsDeleted: beforeDecisions - afterDecisions,
306
+ observationsDeleted: beforeObservations - afterObservations,
307
+ after: {
308
+ patterns: afterPatterns,
309
+ learnings: afterLearnings,
310
+ decisions: afterDecisions,
311
+ observations: afterObservations,
312
+ },
313
+ fts5Rebuilt,
314
+ };
315
+ }
@@ -35,6 +35,8 @@ import type {
35
35
  BrainTimelineNeighborRow,
36
36
  } from './brain-row-types.js';
37
37
  import { searchBrain } from './brain-search.js';
38
+ import { addGraphEdge, upsertGraphNode } from './graph-auto-populate.js';
39
+ import { computeObservationQuality } from './quality-scoring.js';
38
40
 
39
41
  // ============================================================================
40
42
  // Types
@@ -155,7 +157,7 @@ export async function searchBrainCompact(
155
157
  ): Promise<SearchBrainCompactResult> {
156
158
  const { query, limit, tables, dateStart, dateEnd, agent } = params;
157
159
 
158
- if (!query || !query.trim()) {
160
+ if (!query?.trim()) {
159
161
  return { results: [], total: 0, tokensEstimated: 0 };
160
162
  }
161
163
 
@@ -551,7 +553,7 @@ export async function observeBrain(
551
553
  agent,
552
554
  } = params;
553
555
 
554
- if (!text || !text.trim()) {
556
+ if (!text?.trim()) {
555
557
  throw new Error('Observation text is required');
556
558
  }
557
559
 
@@ -601,6 +603,9 @@ export async function observeBrain(
601
603
  }
602
604
  }
603
605
 
606
+ // Compute quality score from text richness and title length.
607
+ const qualityScore = computeObservationQuality({ text, title });
608
+
604
609
  const id = `O-${Date.now().toString(36)}-${(observeSeq++ % 1000).toString(36)}`;
605
610
  const accessor = await getBrainAccessor(projectRoot);
606
611
 
@@ -614,6 +619,7 @@ export async function observeBrain(
614
619
  sourceSessionId: validSessionId,
615
620
  sourceType: sourceType ?? 'agent',
616
621
  agent: agent ?? null,
622
+ qualityScore,
617
623
  createdAt: now,
618
624
  });
619
625
 
@@ -655,6 +661,41 @@ export async function observeBrain(
655
661
  });
656
662
  }
657
663
 
664
+ // Auto-populate graph node + edges for this observation (best-effort, T537).
665
+ try {
666
+ await upsertGraphNode(
667
+ projectRoot,
668
+ `observation:${row.id}`,
669
+ 'observation',
670
+ row.title.substring(0, 200),
671
+ row.qualityScore ?? 0.5,
672
+ row.narrative ?? row.title,
673
+ { sourceType: row.sourceType, agent: row.agent ?? undefined },
674
+ );
675
+
676
+ // Link observation → session when the observation has a session context.
677
+ if (validSessionId) {
678
+ await upsertGraphNode(
679
+ projectRoot,
680
+ `session:${validSessionId}`,
681
+ 'session',
682
+ validSessionId,
683
+ 0.8,
684
+ '',
685
+ );
686
+ await addGraphEdge(
687
+ projectRoot,
688
+ `observation:${row.id}`,
689
+ `session:${validSessionId}`,
690
+ 'produced_by',
691
+ 1.0,
692
+ 'auto:observe',
693
+ );
694
+ }
695
+ } catch {
696
+ /* Graph population is best-effort — never block the primary return */
697
+ }
698
+
658
699
  return {
659
700
  id: row.id,
660
701
  type: row.type,
@@ -20,6 +20,7 @@ import { typedAll } from '../store/typed-query.js';
20
20
  import type { BrainSearchHit } from './brain-row-types.js';
21
21
  import type { SimilarityResult } from './brain-similarity.js';
22
22
  import { searchSimilar } from './brain-similarity.js';
23
+ import { QUALITY_SCORE_THRESHOLD } from './quality-scoring.js';
23
24
 
24
25
  /** Search result with BM25 rank. */
25
26
  export interface BrainSearchResult {
@@ -281,7 +282,7 @@ export async function searchBrain(
281
282
  query: string,
282
283
  options?: BrainSearchOptions,
283
284
  ): Promise<BrainSearchResult> {
284
- if (!query || !query.trim()) {
285
+ if (!query?.trim()) {
285
286
  return { decisions: [], patterns: [], learnings: [], observations: [] };
286
287
  }
287
288
 
@@ -339,10 +340,12 @@ function searchWithFts5(
339
340
  FROM brain_decisions_fts fts
340
341
  JOIN brain_decisions d ON d.rowid = fts.rowid
341
342
  WHERE brain_decisions_fts MATCH ?
343
+ AND (d.quality_score IS NULL OR d.quality_score >= ?)
342
344
  ORDER BY bm25(brain_decisions_fts)
343
345
  LIMIT ?
344
346
  `),
345
347
  safeQuery,
348
+ QUALITY_SCORE_THRESHOLD,
346
349
  limit,
347
350
  );
348
351
  result.decisions = rows;
@@ -360,10 +363,12 @@ function searchWithFts5(
360
363
  FROM brain_patterns_fts fts
361
364
  JOIN brain_patterns p ON p.rowid = fts.rowid
362
365
  WHERE brain_patterns_fts MATCH ?
366
+ AND (p.quality_score IS NULL OR p.quality_score >= ?)
363
367
  ORDER BY bm25(brain_patterns_fts)
364
368
  LIMIT ?
365
369
  `),
366
370
  safeQuery,
371
+ QUALITY_SCORE_THRESHOLD,
367
372
  limit,
368
373
  );
369
374
  result.patterns = rows;
@@ -380,10 +385,12 @@ function searchWithFts5(
380
385
  FROM brain_learnings_fts fts
381
386
  JOIN brain_learnings l ON l.rowid = fts.rowid
382
387
  WHERE brain_learnings_fts MATCH ?
388
+ AND (l.quality_score IS NULL OR l.quality_score >= ?)
383
389
  ORDER BY bm25(brain_learnings_fts)
384
390
  LIMIT ?
385
391
  `),
386
392
  safeQuery,
393
+ QUALITY_SCORE_THRESHOLD,
387
394
  limit,
388
395
  );
389
396
  result.learnings = rows;
@@ -400,10 +407,12 @@ function searchWithFts5(
400
407
  FROM brain_observations_fts fts
401
408
  JOIN brain_observations o ON o.rowid = fts.rowid
402
409
  WHERE brain_observations_fts MATCH ?
410
+ AND (o.quality_score IS NULL OR o.quality_score >= ?)
403
411
  ORDER BY bm25(brain_observations_fts)
404
412
  LIMIT ?
405
413
  `),
406
414
  safeQuery,
415
+ QUALITY_SCORE_THRESHOLD,
407
416
  limit,
408
417
  );
409
418
  result.observations = rows;
@@ -459,12 +468,14 @@ function likeSearchDecisions(
459
468
  return typedAll<BrainDecisionRow>(
460
469
  nativeDb.prepare(`
461
470
  SELECT * FROM brain_decisions
462
- WHERE decision LIKE ? OR rationale LIKE ?
471
+ WHERE (decision LIKE ? OR rationale LIKE ?)
472
+ AND (quality_score IS NULL OR quality_score >= ?)
463
473
  ORDER BY created_at DESC
464
474
  LIMIT ?
465
475
  `),
466
476
  likePattern,
467
477
  likePattern,
478
+ QUALITY_SCORE_THRESHOLD,
468
479
  limit,
469
480
  );
470
481
  }
@@ -478,12 +489,14 @@ function likeSearchPatterns(
478
489
  return typedAll<BrainPatternRow>(
479
490
  nativeDb.prepare(`
480
491
  SELECT * FROM brain_patterns
481
- WHERE pattern LIKE ? OR context LIKE ?
492
+ WHERE (pattern LIKE ? OR context LIKE ?)
493
+ AND (quality_score IS NULL OR quality_score >= ?)
482
494
  ORDER BY frequency DESC
483
495
  LIMIT ?
484
496
  `),
485
497
  likePattern,
486
498
  likePattern,
499
+ QUALITY_SCORE_THRESHOLD,
487
500
  limit,
488
501
  );
489
502
  }
@@ -497,12 +510,14 @@ function likeSearchLearnings(
497
510
  return typedAll<BrainLearningRow>(
498
511
  nativeDb.prepare(`
499
512
  SELECT * FROM brain_learnings
500
- WHERE insight LIKE ? OR source LIKE ?
513
+ WHERE (insight LIKE ? OR source LIKE ?)
514
+ AND (quality_score IS NULL OR quality_score >= ?)
501
515
  ORDER BY confidence DESC
502
516
  LIMIT ?
503
517
  `),
504
518
  likePattern,
505
519
  likePattern,
520
+ QUALITY_SCORE_THRESHOLD,
506
521
  limit,
507
522
  );
508
523
  }
@@ -516,12 +531,14 @@ function likeSearchObservations(
516
531
  return typedAll<BrainObservationRow>(
517
532
  nativeDb.prepare(`
518
533
  SELECT * FROM brain_observations
519
- WHERE title LIKE ? OR narrative LIKE ?
534
+ WHERE (title LIKE ? OR narrative LIKE ?)
535
+ AND (quality_score IS NULL OR quality_score >= ?)
520
536
  ORDER BY created_at DESC
521
537
  LIMIT ?
522
538
  `),
523
539
  likePattern,
524
540
  likePattern,
541
+ QUALITY_SCORE_THRESHOLD,
525
542
  limit,
526
543
  );
527
544
  }
@@ -591,7 +608,7 @@ export async function hybridSearch(
591
608
  projectRoot: string,
592
609
  options?: HybridSearchOptions,
593
610
  ): Promise<HybridResult[]> {
594
- if (!query || !query.trim()) return [];
611
+ if (!query?.trim()) return [];
595
612
 
596
613
  const maxResults = options?.limit ?? 10;
597
614
  let ftsWeight = options?.ftsWeight ?? 0.5;
@@ -13,6 +13,8 @@ import { getBrainAccessor } from '../store/brain-accessor.js';
13
13
  import type { BrainDecisionRow, NewBrainDecisionRow } from '../store/brain-schema.js';
14
14
  import { taskExistsInTasksDb } from '../store/cross-db-cleanup.js';
15
15
  import { getDb } from '../store/sqlite.js';
16
+ import { addGraphEdge, upsertGraphNode } from './graph-auto-populate.js';
17
+ import { computeDecisionQuality } from './quality-scoring.js';
16
18
 
17
19
  /** Parameters for storing a new decision. */
18
20
  export interface StoreDecisionParams {
@@ -79,10 +81,10 @@ export async function storeDecision(
79
81
  projectRoot: string,
80
82
  params: StoreDecisionParams,
81
83
  ): Promise<BrainDecisionRow> {
82
- if (!params.decision || !params.decision.trim()) {
84
+ if (!params.decision?.trim()) {
83
85
  throw new Error('Decision text is required');
84
86
  }
85
- if (!params.rationale || !params.rationale.trim()) {
87
+ if (!params.rationale?.trim()) {
86
88
  throw new Error('Rationale is required');
87
89
  }
88
90
 
@@ -107,6 +109,25 @@ export async function storeDecision(
107
109
  updatedAt: now,
108
110
  });
109
111
  const updated = await accessor.getDecision(duplicate.id);
112
+
113
+ // Refresh the graph node for the updated decision (best-effort).
114
+ const updatedQuality = computeDecisionQuality({
115
+ confidence: params.confidence,
116
+ rationale: params.rationale.trim(),
117
+ contextTaskId: params.contextTaskId ?? null,
118
+ });
119
+ upsertGraphNode(
120
+ projectRoot,
121
+ `decision:${duplicate.id}`,
122
+ 'decision',
123
+ params.decision.trim().substring(0, 200),
124
+ updatedQuality,
125
+ params.decision.trim() + params.rationale.trim(),
126
+ { type: params.type, confidence: params.confidence },
127
+ ).catch(() => {
128
+ /* best-effort */
129
+ });
130
+
110
131
  return updated!;
111
132
  }
112
133
 
@@ -126,6 +147,13 @@ export async function storeDecision(
126
147
  }
127
148
  }
128
149
 
150
+ // Compute quality score from confidence level, rationale richness, and task linkage.
151
+ const qualityScore = computeDecisionQuality({
152
+ confidence: params.confidence,
153
+ rationale: params.rationale.trim(),
154
+ contextTaskId: validTaskId ?? null,
155
+ });
156
+
129
157
  const row: NewBrainDecisionRow = {
130
158
  id,
131
159
  type: params.type,
@@ -137,9 +165,54 @@ export async function storeDecision(
137
165
  contextEpicId: validEpicId,
138
166
  contextTaskId: validTaskId,
139
167
  contextPhase: params.contextPhase,
168
+ qualityScore,
140
169
  };
141
170
 
142
- return accessor.addDecision(row);
171
+ const saved = await accessor.addDecision(row);
172
+
173
+ // Auto-populate graph node + edges for the new decision (best-effort, T537).
174
+ // All graph writes run fire-and-forget so they never block the return.
175
+ try {
176
+ await upsertGraphNode(
177
+ projectRoot,
178
+ `decision:${saved.id}`,
179
+ 'decision',
180
+ saved.decision.substring(0, 200),
181
+ qualityScore,
182
+ saved.decision + saved.rationale,
183
+ { type: saved.type, confidence: saved.confidence },
184
+ );
185
+
186
+ // Link decision → task when a task context is present.
187
+ if (validTaskId) {
188
+ await upsertGraphNode(projectRoot, `task:${validTaskId}`, 'task', validTaskId, 1.0, '');
189
+ await addGraphEdge(
190
+ projectRoot,
191
+ `decision:${saved.id}`,
192
+ `task:${validTaskId}`,
193
+ 'applies_to',
194
+ 1.0,
195
+ 'auto:store-decision',
196
+ );
197
+ }
198
+
199
+ // Link decision → epic when an epic context is present.
200
+ if (validEpicId) {
201
+ await upsertGraphNode(projectRoot, `epic:${validEpicId}`, 'epic', validEpicId, 1.0, '');
202
+ await addGraphEdge(
203
+ projectRoot,
204
+ `decision:${saved.id}`,
205
+ `epic:${validEpicId}`,
206
+ 'applies_to',
207
+ 1.0,
208
+ 'auto:store-decision',
209
+ );
210
+ }
211
+ } catch {
212
+ /* Graph population is best-effort — never block the primary return */
213
+ }
214
+
215
+ return saved;
143
216
  }
144
217
 
145
218
  /**