@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,424 @@
1
+ /**
2
+ * Brain graph traversal queries using recursive CTEs and native SQLite.
3
+ *
4
+ * Implements BFS traversal, typed neighbor lookup, 360-degree context view,
5
+ * and aggregate statistics for the brain_page_nodes / brain_page_edges graph
6
+ * populated by T528 + T530.
7
+ *
8
+ * Uses getBrainNativeDb() (DatabaseSync) for recursive CTEs that Drizzle's
9
+ * ORM layer cannot express directly.
10
+ *
11
+ * @task T535
12
+ * @epic T523
13
+ */
14
+
15
+ import type { BrainEdgeType, BrainPageEdgeRow, BrainPageNodeRow } from '../store/brain-schema.js';
16
+ import { getBrainDb, getBrainNativeDb } from '../store/brain-sqlite.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Return types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** A node returned from a BFS traversal, annotated with its discovery depth. */
23
+ export interface TraceNode extends BrainPageNodeRow {
24
+ /** Distance from the seed node (0 = seed itself). */
25
+ depth: number;
26
+ }
27
+
28
+ /** A neighbor node with the edge that connects it to the queried node. */
29
+ export interface RelatedNode {
30
+ /** The neighbour node. */
31
+ node: BrainPageNodeRow;
32
+ /** Edge relationship type. */
33
+ edgeType: BrainEdgeType;
34
+ /** Direction: 'out' (queried node is from_id) or 'in' (queried node is to_id). */
35
+ direction: 'out' | 'in';
36
+ /** Edge weight/confidence 0.0–1.0. */
37
+ weight: number;
38
+ }
39
+
40
+ /** Full context for a single node: itself, incoming edges, outgoing edges, and neighbour nodes. */
41
+ export interface NodeContext {
42
+ /** The node itself. */
43
+ node: BrainPageNodeRow;
44
+ /** Edges where this node is the target (in-edges). */
45
+ inEdges: BrainPageEdgeRow[];
46
+ /** Edges where this node is the source (out-edges). */
47
+ outEdges: BrainPageEdgeRow[];
48
+ /** All neighbour nodes reachable via in- or out-edges, deduplicated. */
49
+ neighbors: RelatedNode[];
50
+ }
51
+
52
+ /** Aggregate statistics for the graph. */
53
+ export interface GraphStats {
54
+ /** Node counts grouped by node_type. */
55
+ nodesByType: Array<{ nodeType: string; count: number }>;
56
+ /** Edge counts grouped by edge_type. */
57
+ edgesByType: Array<{ edgeType: string; count: number }>;
58
+ /** Total node count. */
59
+ totalNodes: number;
60
+ /** Total edge count. */
61
+ totalEdges: number;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Internal raw-row types (native db returns plain objects)
66
+ // ---------------------------------------------------------------------------
67
+
68
+ interface RawNode {
69
+ id: string;
70
+ node_type: string;
71
+ label: string;
72
+ quality_score: number;
73
+ content_hash: string | null;
74
+ last_activity_at: string;
75
+ metadata_json: string | null;
76
+ created_at: string;
77
+ updated_at: string | null;
78
+ }
79
+
80
+ interface RawEdge {
81
+ from_id: string;
82
+ to_id: string;
83
+ edge_type: string;
84
+ weight: number;
85
+ provenance: string | null;
86
+ created_at: string;
87
+ }
88
+
89
+ /** Map a snake_case raw row to a camelCase BrainPageNodeRow. */
90
+ function mapNode(raw: RawNode): BrainPageNodeRow {
91
+ return {
92
+ id: raw.id,
93
+ nodeType: raw.node_type as BrainPageNodeRow['nodeType'],
94
+ label: raw.label,
95
+ qualityScore: raw.quality_score,
96
+ contentHash: raw.content_hash,
97
+ lastActivityAt: raw.last_activity_at,
98
+ metadataJson: raw.metadata_json,
99
+ createdAt: raw.created_at,
100
+ updatedAt: raw.updated_at,
101
+ };
102
+ }
103
+
104
+ /** Map a snake_case raw row to a camelCase BrainPageEdgeRow. */
105
+ function mapEdge(raw: RawEdge): BrainPageEdgeRow {
106
+ return {
107
+ fromId: raw.from_id,
108
+ toId: raw.to_id,
109
+ edgeType: raw.edge_type as BrainEdgeType,
110
+ weight: raw.weight,
111
+ provenance: raw.provenance,
112
+ createdAt: raw.created_at,
113
+ };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // traceBrainGraph — BFS traversal via recursive CTE
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Traverse the brain knowledge graph using BFS from a seed node.
122
+ *
123
+ * Uses a recursive CTE that follows edges in both directions. Nodes are
124
+ * returned in ascending depth order, then descending quality_score. The seed
125
+ * node itself is returned at depth 0.
126
+ *
127
+ * @param projectRoot - Absolute path to the project root (locates brain.db)
128
+ * @param nodeId - Starting node ID (format: '<type>:<source-id>')
129
+ * @param maxDepth - Maximum traversal depth (default 3)
130
+ * @returns Array of nodes annotated with their discovery depth
131
+ *
132
+ * @example
133
+ * ```typescript
134
+ * const nodes = await traceBrainGraph('/project', 'decision:D-abc123', 2);
135
+ * for (const n of nodes) console.log(n.depth, n.id, n.label);
136
+ * ```
137
+ */
138
+ export async function traceBrainGraph(
139
+ projectRoot: string,
140
+ nodeId: string,
141
+ maxDepth = 3,
142
+ ): Promise<TraceNode[]> {
143
+ await getBrainDb(projectRoot);
144
+ const nativeDb = getBrainNativeDb();
145
+
146
+ if (!nativeDb) return [];
147
+
148
+ // Check whether the seed node exists
149
+ const seedCheck = nativeDb
150
+ .prepare('SELECT 1 FROM brain_page_nodes WHERE id = ?')
151
+ .get(nodeId) as unknown as { 1: number } | undefined;
152
+ if (!seedCheck) return [];
153
+
154
+ // Recursive CTE — bidirectional BFS with cycle detection via path string.
155
+ // The path is '|'-delimited node IDs. We check membership with a LIKE guard.
156
+ const rows = nativeDb
157
+ .prepare(
158
+ `
159
+ WITH RECURSIVE connected(id, depth, path) AS (
160
+ SELECT id, 0, id
161
+ FROM brain_page_nodes
162
+ WHERE id = ?
163
+
164
+ UNION ALL
165
+
166
+ SELECT
167
+ CASE WHEN e.from_id = c.id THEN e.to_id ELSE e.from_id END AS next_id,
168
+ c.depth + 1,
169
+ c.path || '|' || CASE WHEN e.from_id = c.id THEN e.to_id ELSE e.from_id END
170
+ FROM brain_page_edges e
171
+ JOIN connected c ON (e.from_id = c.id OR e.to_id = c.id)
172
+ WHERE c.depth < ?
173
+ AND ('|' || c.path || '|') NOT LIKE (
174
+ '%|' || CASE WHEN e.from_id = c.id THEN e.to_id ELSE e.from_id END || '|%'
175
+ )
176
+ )
177
+ SELECT DISTINCT n.id, n.node_type, n.label, n.quality_score,
178
+ n.content_hash, n.last_activity_at, n.metadata_json,
179
+ n.created_at, n.updated_at,
180
+ MIN(c.depth) AS depth
181
+ FROM connected c
182
+ JOIN brain_page_nodes n ON n.id = c.id
183
+ GROUP BY n.id
184
+ ORDER BY depth ASC, n.quality_score DESC
185
+ `,
186
+ )
187
+ .all(nodeId, maxDepth) as unknown as Array<RawNode & { depth: number }>;
188
+
189
+ return rows.map((r) => ({ ...mapNode(r), depth: r.depth }));
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // relatedBrainNodes — 1-hop neighbours with edge metadata
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Return the immediate (1-hop) neighbours of a node, including edge metadata.
198
+ *
199
+ * Follows edges in both directions. Results are sorted by edge weight
200
+ * descending, then quality_score descending.
201
+ *
202
+ * @param projectRoot - Absolute path to the project root
203
+ * @param nodeId - Node to inspect (format: '<type>:<source-id>')
204
+ * @param edgeType - Optional edge type filter (e.g. 'applies_to')
205
+ * @returns Array of neighbour nodes with edge relationship info
206
+ *
207
+ * @example
208
+ * ```typescript
209
+ * const related = await relatedBrainNodes('/project', 'decision:D-abc123', 'applies_to');
210
+ * ```
211
+ */
212
+ export async function relatedBrainNodes(
213
+ projectRoot: string,
214
+ nodeId: string,
215
+ edgeType?: string,
216
+ ): Promise<RelatedNode[]> {
217
+ await getBrainDb(projectRoot);
218
+ const nativeDb = getBrainNativeDb();
219
+
220
+ if (!nativeDb) return [];
221
+
222
+ const edgeFilter = edgeType ? 'AND e.edge_type = ?' : '';
223
+
224
+ // Build params array for the union query
225
+ // Each half needs: nodeId [, edgeType]
226
+ const queryParams: string[] = edgeType ? [nodeId, edgeType, nodeId, edgeType] : [nodeId, nodeId];
227
+
228
+ // Two unions: outgoing (this node is from_id) and incoming (this node is to_id)
229
+ const rows = nativeDb
230
+ .prepare(
231
+ `
232
+ SELECT n.id, n.node_type, n.label, n.quality_score,
233
+ n.content_hash, n.last_activity_at, n.metadata_json,
234
+ n.created_at, n.updated_at,
235
+ e.edge_type, e.weight, 'out' AS direction
236
+ FROM brain_page_edges e
237
+ JOIN brain_page_nodes n ON n.id = e.to_id
238
+ WHERE e.from_id = ? ${edgeFilter}
239
+
240
+ UNION
241
+
242
+ SELECT n.id, n.node_type, n.label, n.quality_score,
243
+ n.content_hash, n.last_activity_at, n.metadata_json,
244
+ n.created_at, n.updated_at,
245
+ e.edge_type, e.weight, 'in' AS direction
246
+ FROM brain_page_edges e
247
+ JOIN brain_page_nodes n ON n.id = e.from_id
248
+ WHERE e.to_id = ? ${edgeFilter}
249
+
250
+ ORDER BY weight DESC, quality_score DESC
251
+ `,
252
+ )
253
+ .all(...queryParams) as unknown as Array<
254
+ RawNode & { edge_type: string; weight: number; direction: string }
255
+ >;
256
+
257
+ return rows.map((r) => ({
258
+ node: mapNode(r),
259
+ edgeType: r.edge_type as BrainEdgeType,
260
+ direction: r.direction as 'out' | 'in',
261
+ weight: r.weight,
262
+ }));
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // contextBrainNode — 360-degree view of a single node
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /**
270
+ * Return a 360-degree view of a single graph node.
271
+ *
272
+ * Provides the node itself, all incoming edges, all outgoing edges, and the
273
+ * full set of neighbour nodes with their edge relationships.
274
+ *
275
+ * @param projectRoot - Absolute path to the project root
276
+ * @param nodeId - Node to inspect (format: '<type>:<source-id>')
277
+ * @returns Full context record, or null if the node does not exist
278
+ *
279
+ * @example
280
+ * ```typescript
281
+ * const ctx = await contextBrainNode('/project', 'decision:D-abc123');
282
+ * if (ctx) {
283
+ * console.log(ctx.node.label, ctx.outEdges.length, 'outgoing edges');
284
+ * }
285
+ * ```
286
+ */
287
+ export async function contextBrainNode(
288
+ projectRoot: string,
289
+ nodeId: string,
290
+ ): Promise<NodeContext | null> {
291
+ await getBrainDb(projectRoot);
292
+ const nativeDb = getBrainNativeDb();
293
+
294
+ if (!nativeDb) return null;
295
+
296
+ const rawNode = nativeDb
297
+ .prepare(
298
+ `SELECT id, node_type, label, quality_score, content_hash,
299
+ last_activity_at, metadata_json, created_at, updated_at
300
+ FROM brain_page_nodes WHERE id = ?`,
301
+ )
302
+ .get(nodeId) as unknown as RawNode | undefined;
303
+
304
+ if (!rawNode) return null;
305
+
306
+ const node = mapNode(rawNode);
307
+
308
+ const rawOutEdges = nativeDb
309
+ .prepare(
310
+ `SELECT from_id, to_id, edge_type, weight, provenance, created_at
311
+ FROM brain_page_edges WHERE from_id = ? ORDER BY weight DESC`,
312
+ )
313
+ .all(nodeId) as unknown as RawEdge[];
314
+
315
+ const rawInEdges = nativeDb
316
+ .prepare(
317
+ `SELECT from_id, to_id, edge_type, weight, provenance, created_at
318
+ FROM brain_page_edges WHERE to_id = ? ORDER BY weight DESC`,
319
+ )
320
+ .all(nodeId) as unknown as RawEdge[];
321
+
322
+ const outEdges = rawOutEdges.map(mapEdge);
323
+ const inEdges = rawInEdges.map(mapEdge);
324
+
325
+ // Collect unique neighbour IDs from both directions
326
+ const seenIds = new Set<string>([nodeId]);
327
+ const neighbors: RelatedNode[] = [];
328
+
329
+ for (const e of rawOutEdges) {
330
+ if (!seenIds.has(e.to_id)) {
331
+ seenIds.add(e.to_id);
332
+ const rawNeighbour = nativeDb
333
+ .prepare(
334
+ `SELECT id, node_type, label, quality_score, content_hash,
335
+ last_activity_at, metadata_json, created_at, updated_at
336
+ FROM brain_page_nodes WHERE id = ?`,
337
+ )
338
+ .get(e.to_id) as unknown as RawNode | undefined;
339
+ if (rawNeighbour) {
340
+ neighbors.push({
341
+ node: mapNode(rawNeighbour),
342
+ edgeType: e.edge_type as BrainEdgeType,
343
+ direction: 'out',
344
+ weight: e.weight,
345
+ });
346
+ }
347
+ }
348
+ }
349
+
350
+ for (const e of rawInEdges) {
351
+ if (!seenIds.has(e.from_id)) {
352
+ seenIds.add(e.from_id);
353
+ const rawNeighbour = nativeDb
354
+ .prepare(
355
+ `SELECT id, node_type, label, quality_score, content_hash,
356
+ last_activity_at, metadata_json, created_at, updated_at
357
+ FROM brain_page_nodes WHERE id = ?`,
358
+ )
359
+ .get(e.from_id) as unknown as RawNode | undefined;
360
+ if (rawNeighbour) {
361
+ neighbors.push({
362
+ node: mapNode(rawNeighbour),
363
+ edgeType: e.edge_type as BrainEdgeType,
364
+ direction: 'in',
365
+ weight: e.weight,
366
+ });
367
+ }
368
+ }
369
+ }
370
+
371
+ // Sort neighbours by weight descending
372
+ neighbors.sort((a, b) => b.weight - a.weight);
373
+
374
+ return { node, inEdges, outEdges, neighbors };
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // graphStats — aggregate counts by type
379
+ // ---------------------------------------------------------------------------
380
+
381
+ /**
382
+ * Return aggregate counts for brain_page_nodes and brain_page_edges by type.
383
+ *
384
+ * @param projectRoot - Absolute path to the project root
385
+ * @returns Counts by node type and edge type, plus totals
386
+ *
387
+ * @example
388
+ * ```typescript
389
+ * const stats = await graphStats('/project');
390
+ * console.log(stats.totalNodes, stats.totalEdges);
391
+ * ```
392
+ */
393
+ export async function graphStats(projectRoot: string): Promise<GraphStats> {
394
+ await getBrainDb(projectRoot);
395
+ const nativeDb = getBrainNativeDb();
396
+
397
+ if (!nativeDb) {
398
+ return { nodesByType: [], edgesByType: [], totalNodes: 0, totalEdges: 0 };
399
+ }
400
+
401
+ const nodeRows = nativeDb
402
+ .prepare(
403
+ `SELECT node_type, COUNT(*) AS count
404
+ FROM brain_page_nodes GROUP BY node_type ORDER BY count DESC`,
405
+ )
406
+ .all() as unknown as Array<{ node_type: string; count: number }>;
407
+
408
+ const edgeRows = nativeDb
409
+ .prepare(
410
+ `SELECT edge_type, COUNT(*) AS count
411
+ FROM brain_page_edges GROUP BY edge_type ORDER BY count DESC`,
412
+ )
413
+ .all() as unknown as Array<{ edge_type: string; count: number }>;
414
+
415
+ const totalNodes = nodeRows.reduce((s, r) => s + r.count, 0);
416
+ const totalEdges = edgeRows.reduce((s, r) => s + r.count, 0);
417
+
418
+ return {
419
+ nodesByType: nodeRows.map((r) => ({ nodeType: r.node_type, count: r.count })),
420
+ edgesByType: edgeRows.map((r) => ({ edgeType: r.edge_type, count: r.count })),
421
+ totalNodes,
422
+ totalEdges,
423
+ };
424
+ }
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { randomBytes } from 'node:crypto';
14
14
  import { getBrainAccessor } from '../store/brain-accessor.js';
15
+ import { upsertGraphNode } from './graph-auto-populate.js';
16
+ import { computeLearningQuality } from './quality-scoring.js';
15
17
 
16
18
  /** Parameters for storing a new learning. */
17
19
  export interface StoreLearningParams {
@@ -44,10 +46,10 @@ function generateLearningId(): string {
44
46
  * @task T4769, T5241
45
47
  */
46
48
  export async function storeLearning(projectRoot: string, params: StoreLearningParams) {
47
- if (!params.insight || !params.insight.trim()) {
49
+ if (!params.insight?.trim()) {
48
50
  throw new Error('Insight text is required');
49
51
  }
50
- if (!params.source || !params.source.trim()) {
52
+ if (!params.source?.trim()) {
51
53
  throw new Error('Source is required');
52
54
  }
53
55
  if (params.confidence < 0 || params.confidence > 1) {
@@ -55,18 +57,50 @@ export async function storeLearning(projectRoot: string, params: StoreLearningPa
55
57
  }
56
58
 
57
59
  const accessor = await getBrainAccessor(projectRoot);
58
- const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
59
60
 
60
- // Check for duplicate insight
61
+ // Check for duplicate insight by normalized text
61
62
  const existingLearnings = await accessor.findLearnings();
63
+ const normalizedInput = params.insight.trim().toLowerCase();
62
64
  const duplicate = existingLearnings.find(
63
- (e) => e.insight.toLowerCase() === params.insight.toLowerCase(),
65
+ (e) => e.insight.trim().toLowerCase() === normalizedInput,
64
66
  );
65
67
 
66
68
  if (duplicate) {
67
- // We would ideally increment confidence here or update. Let's assume we don't have update method on accessor yet.
69
+ // Take the higher confidence value and update the timestamp
70
+ const maxConfidence = Math.max(duplicate.confidence, params.confidence);
71
+ await accessor.updateLearning(duplicate.id, {
72
+ confidence: maxConfidence,
73
+ });
74
+
75
+ const updated = await accessor.getLearning(duplicate.id);
76
+
77
+ // Refresh graph node for the updated learning (best-effort, T537).
78
+ upsertGraphNode(
79
+ projectRoot,
80
+ `learning:${duplicate.id}`,
81
+ 'learning',
82
+ duplicate.insight.substring(0, 200),
83
+ duplicate.qualityScore ?? 0.5,
84
+ duplicate.insight + (duplicate.application ?? ''),
85
+ { source: duplicate.source, confidence: maxConfidence, actionable: duplicate.actionable },
86
+ ).catch(() => {
87
+ /* best-effort */
88
+ });
89
+
90
+ return {
91
+ ...updated!,
92
+ applicableTypes: JSON.parse(updated!.applicableTypesJson || '[]'),
93
+ };
68
94
  }
69
95
 
96
+ // Compute quality score from confidence, actionability, and content richness.
97
+ const qualityScore = computeLearningQuality({
98
+ confidence: params.confidence,
99
+ actionable: params.actionable ?? false,
100
+ insight: params.insight.trim(),
101
+ application: params.application ?? null,
102
+ });
103
+
70
104
  // Create new entry
71
105
  const entry = {
72
106
  id: generateLearningId(),
@@ -76,10 +110,24 @@ export async function storeLearning(projectRoot: string, params: StoreLearningPa
76
110
  actionable: params.actionable ?? false,
77
111
  application: params.application ?? null,
78
112
  applicableTypesJson: params.applicableTypes ? JSON.stringify(params.applicableTypes) : '[]',
79
- extractedAt: now,
113
+ qualityScore,
80
114
  };
81
115
 
82
116
  const saved = await accessor.addLearning(entry);
117
+
118
+ // Auto-populate graph node for the new learning (best-effort, T537).
119
+ upsertGraphNode(
120
+ projectRoot,
121
+ `learning:${saved.id}`,
122
+ 'learning',
123
+ saved.insight.substring(0, 200),
124
+ qualityScore,
125
+ saved.insight + (saved.application ?? ''),
126
+ { source: saved.source, confidence: saved.confidence, actionable: saved.actionable },
127
+ ).catch(() => {
128
+ /* best-effort */
129
+ });
130
+
83
131
  return {
84
132
  ...saved,
85
133
  applicableTypes: JSON.parse(saved.applicableTypesJson || '[]'),
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { randomBytes } from 'node:crypto';
14
14
  import { getBrainAccessor } from '../store/brain-accessor.js';
15
+ import { upsertGraphNode } from './graph-auto-populate.js';
16
+ import { computePatternQuality } from './quality-scoring.js';
15
17
 
16
18
  /** Pattern types from ADR-009. */
17
19
  export type PatternType = 'workflow' | 'blocker' | 'success' | 'failure' | 'optimization';
@@ -53,34 +55,70 @@ function generatePatternId(): string {
53
55
  * @task T4768, T5241
54
56
  */
55
57
  export async function storePattern(projectRoot: string, params: StorePatternParams) {
56
- if (!params.pattern || !params.pattern.trim()) {
58
+ if (!params.pattern?.trim()) {
57
59
  throw new Error('Pattern description is required');
58
60
  }
59
- if (!params.context || !params.context.trim()) {
61
+ if (!params.context?.trim()) {
60
62
  throw new Error('Pattern context is required');
61
63
  }
62
64
 
63
65
  const accessor = await getBrainAccessor(projectRoot);
64
66
 
65
- // First search for duplicate pattern
66
- // Currently we just match on type and exact text
67
+ // Search for duplicate pattern by normalized text within same type
67
68
  const existingPatterns = await accessor.findPatterns({ type: params.type });
69
+ const normalizedInput = params.pattern.trim().toLowerCase();
68
70
  const duplicate = existingPatterns.find(
69
- (e) => e.pattern.toLowerCase() === params.pattern.toLowerCase(),
71
+ (e) => e.pattern.trim().toLowerCase() === normalizedInput,
70
72
  );
71
73
 
72
74
  const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
73
75
 
74
76
  if (duplicate) {
75
- // We would ideally increment frequency here
76
- // However, accessor.addPattern handles inserts. Let's just insert it again or
77
- // we would need an update method on accessor.
78
- // For now, since accessor.updatePattern might not exist, we just insert.
79
- // Let's assume brain accessor should support updating, or we'll just add it.
80
- // Wait, let's look if accessor has updatePattern. If not, we just insert anew? No, we should update.
81
- // Actually, brain.db is meant to store new records or update them. Let's check accessor again.
77
+ // Merge examples arrays (union of task IDs)
78
+ const existingExamples: string[] = JSON.parse(duplicate.examplesJson || '[]');
79
+ const newExamples: string[] = params.examples ?? [];
80
+ const mergedExamples = Array.from(new Set([...existingExamples, ...newExamples]));
81
+
82
+ await accessor.updatePattern(duplicate.id, {
83
+ frequency: duplicate.frequency + 1,
84
+ extractedAt: now,
85
+ examplesJson: JSON.stringify(mergedExamples),
86
+ });
87
+
88
+ const updated = await accessor.getPattern(duplicate.id);
89
+
90
+ // Refresh graph node for the updated (incremented) pattern (best-effort, T537).
91
+ upsertGraphNode(
92
+ projectRoot,
93
+ `pattern:${duplicate.id}`,
94
+ 'pattern',
95
+ duplicate.pattern.substring(0, 200),
96
+ duplicate.qualityScore ?? 0.5,
97
+ duplicate.pattern + duplicate.context,
98
+ {
99
+ type: duplicate.type,
100
+ impact: duplicate.impact ?? undefined,
101
+ frequency: duplicate.frequency + 1,
102
+ },
103
+ ).catch(() => {
104
+ /* best-effort */
105
+ });
106
+
107
+ return {
108
+ ...updated!,
109
+ examples: mergedExamples,
110
+ };
82
111
  }
83
112
 
113
+ // Compute quality score based on type, content richness, and examples.
114
+ const examplesJson = params.examples ? JSON.stringify(params.examples) : '[]';
115
+ const qualityScore = computePatternQuality({
116
+ type: params.type,
117
+ pattern: params.pattern.trim(),
118
+ context: params.context.trim(),
119
+ examples_json: examplesJson,
120
+ });
121
+
84
122
  // Create new entry
85
123
  const entry = {
86
124
  id: generatePatternId(),
@@ -92,11 +130,26 @@ export async function storePattern(projectRoot: string, params: StorePatternPara
92
130
  impact: params.impact ?? null,
93
131
  antiPattern: params.antiPattern ?? null,
94
132
  mitigation: params.mitigation ?? null,
95
- examplesJson: params.examples ? JSON.stringify(params.examples) : '[]',
133
+ examplesJson,
96
134
  extractedAt: now,
135
+ qualityScore,
97
136
  };
98
137
 
99
138
  const saved = await accessor.addPattern(entry);
139
+
140
+ // Auto-populate graph node for the new pattern (best-effort, T537).
141
+ upsertGraphNode(
142
+ projectRoot,
143
+ `pattern:${saved.id}`,
144
+ 'pattern',
145
+ saved.pattern.substring(0, 200),
146
+ qualityScore,
147
+ saved.pattern + saved.context,
148
+ { type: saved.type, impact: saved.impact ?? undefined },
149
+ ).catch(() => {
150
+ /* best-effort */
151
+ });
152
+
100
153
  return {
101
154
  ...saved,
102
155
  examples: JSON.parse(saved.examplesJson || '[]'),