@cleocode/core 2026.4.35 → 2026.4.37

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 (91) hide show
  1. package/dist/config.d.ts.map +1 -1
  2. package/dist/config.js +7 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/hooks/handlers/conduit-hooks.d.ts +72 -0
  5. package/dist/hooks/handlers/conduit-hooks.d.ts.map +1 -0
  6. package/dist/hooks/handlers/conduit-hooks.js +229 -0
  7. package/dist/hooks/handlers/conduit-hooks.js.map +1 -0
  8. package/dist/hooks/handlers/index.d.ts +2 -0
  9. package/dist/hooks/handlers/index.d.ts.map +1 -1
  10. package/dist/hooks/handlers/index.js +3 -0
  11. package/dist/hooks/handlers/index.js.map +1 -1
  12. package/dist/hooks/handlers/session-hooks.d.ts +14 -0
  13. package/dist/hooks/handlers/session-hooks.d.ts.map +1 -1
  14. package/dist/hooks/handlers/session-hooks.js +33 -0
  15. package/dist/hooks/handlers/session-hooks.js.map +1 -1
  16. package/dist/hooks/handlers/task-hooks.d.ts +2 -0
  17. package/dist/hooks/handlers/task-hooks.d.ts.map +1 -1
  18. package/dist/hooks/handlers/task-hooks.js +14 -0
  19. package/dist/hooks/handlers/task-hooks.js.map +1 -1
  20. package/dist/index.js +54928 -46853
  21. package/dist/index.js.map +4 -4
  22. package/dist/internal.d.ts +2 -0
  23. package/dist/internal.d.ts.map +1 -1
  24. package/dist/internal.js +1 -0
  25. package/dist/internal.js.map +1 -1
  26. package/dist/memory/anthropic-key-resolver.d.ts +35 -0
  27. package/dist/memory/anthropic-key-resolver.d.ts.map +1 -0
  28. package/dist/memory/anthropic-key-resolver.js +105 -0
  29. package/dist/memory/anthropic-key-resolver.js.map +1 -0
  30. package/dist/memory/auto-extract.d.ts +38 -42
  31. package/dist/memory/auto-extract.d.ts.map +1 -1
  32. package/dist/memory/auto-extract.js +38 -57
  33. package/dist/memory/auto-extract.js.map +1 -1
  34. package/dist/memory/brain-retrieval.d.ts +6 -0
  35. package/dist/memory/brain-retrieval.d.ts.map +1 -1
  36. package/dist/memory/brain-retrieval.js +145 -13
  37. package/dist/memory/brain-retrieval.js.map +1 -1
  38. package/dist/memory/brain-search.d.ts +82 -15
  39. package/dist/memory/brain-search.d.ts.map +1 -1
  40. package/dist/memory/brain-search.js +178 -93
  41. package/dist/memory/brain-search.js.map +1 -1
  42. package/dist/memory/engine-compat.d.ts +16 -1
  43. package/dist/memory/engine-compat.d.ts.map +1 -1
  44. package/dist/memory/engine-compat.js +0 -3
  45. package/dist/memory/engine-compat.js.map +1 -1
  46. package/dist/memory/learnings.d.ts.map +1 -1
  47. package/dist/memory/learnings.js +4 -3
  48. package/dist/memory/learnings.js.map +1 -1
  49. package/dist/memory/llm-extraction.d.ts +107 -0
  50. package/dist/memory/llm-extraction.d.ts.map +1 -0
  51. package/dist/memory/llm-extraction.js +425 -0
  52. package/dist/memory/llm-extraction.js.map +1 -0
  53. package/dist/memory/memory-bridge.js +23 -11
  54. package/dist/memory/memory-bridge.js.map +1 -1
  55. package/dist/memory/observer-reflector.d.ts +157 -0
  56. package/dist/memory/observer-reflector.d.ts.map +1 -0
  57. package/dist/memory/observer-reflector.js +626 -0
  58. package/dist/memory/observer-reflector.js.map +1 -0
  59. package/dist/store/brain-schema.d.ts +131 -0
  60. package/dist/store/brain-schema.d.ts.map +1 -1
  61. package/dist/store/brain-schema.js +30 -0
  62. package/dist/store/brain-schema.js.map +1 -1
  63. package/dist/store/brain-sqlite.js +41 -1
  64. package/dist/store/brain-sqlite.js.map +1 -1
  65. package/dist/tasks/complete.d.ts.map +1 -1
  66. package/dist/tasks/complete.js +7 -8
  67. package/dist/tasks/complete.js.map +1 -1
  68. package/package.json +13 -12
  69. package/src/config.ts +7 -0
  70. package/src/hooks/handlers/__tests__/conduit-hooks.test.ts +356 -0
  71. package/src/hooks/handlers/conduit-hooks.ts +258 -0
  72. package/src/hooks/handlers/index.ts +7 -0
  73. package/src/hooks/handlers/session-hooks.ts +37 -0
  74. package/src/hooks/handlers/task-hooks.ts +14 -0
  75. package/src/internal.ts +8 -0
  76. package/src/memory/__tests__/auto-extract.test.ts +43 -114
  77. package/src/memory/__tests__/brain-automation.test.ts +16 -39
  78. package/src/memory/__tests__/brain-rrf.test.ts +431 -0
  79. package/src/memory/__tests__/llm-extraction.test.ts +342 -0
  80. package/src/memory/__tests__/observer-reflector.test.ts +475 -0
  81. package/src/memory/anthropic-key-resolver.ts +113 -0
  82. package/src/memory/auto-extract.ts +40 -72
  83. package/src/memory/brain-retrieval.ts +187 -18
  84. package/src/memory/brain-search.ts +196 -128
  85. package/src/memory/engine-compat.ts +16 -4
  86. package/src/memory/learnings.ts +4 -3
  87. package/src/memory/llm-extraction.ts +524 -0
  88. package/src/memory/memory-bridge.ts +29 -12
  89. package/src/memory/observer-reflector.ts +829 -0
  90. package/src/store/brain-schema.ts +44 -0
  91. package/src/tasks/complete.ts +7 -10
@@ -1,64 +1,37 @@
1
1
  /**
2
- * Auto-extract structured memory entries from task completions and session ends.
2
+ * Transcript and task-completion memory extraction.
3
3
  *
4
- * NOTE: The two primary extraction functions in this module —
5
- * `extractTaskCompletionMemory` and `extractSessionEndMemory` have been
6
- * intentionally disabled per T523 CA1 specification to eliminate O(tasks×labels)
7
- * noise in brain.db. Only `resolveTaskDetails` and `extractFromTranscript`
8
- * remain active.
4
+ * Historical context:
5
+ * - Previously contained a keyword-regex `ACTION_PATTERNS` that produced
6
+ * 88% noise in brain.db (T543).
7
+ * - Previously contained `extractTaskCompletionMemory` and
8
+ * `extractSessionEndMemory` both disabled per T523 CA1 spec.
9
9
  *
10
- * @task T526
11
- * @epic T523
12
- */
13
-
14
- import type { Task } from '@cleocode/contracts';
15
- import type { SessionBridgeData } from '../sessions/session-memory-bridge.js';
16
-
17
- /**
18
- * Intentionally disabled per T523 CA1 specification.
10
+ * Current design (research-backed, replaces keyword gate):
11
+ * - `extractFromTranscript` forwards to the LLM-driven extraction gate in
12
+ * `llm-extraction.ts`. The LLM returns typed structured memories
13
+ * (decision / pattern / learning / constraint / correction) with
14
+ * importance scores and justifications.
15
+ * - Only memories above the configured minimum importance are stored.
16
+ * - Each stored memory is tagged `agent-llm-extracted:<sessionId>` so
17
+ * downstream dedup, quality scoring, and consolidation can distinguish
18
+ * it from other write paths.
19
19
  *
20
- * Previously auto-generated "Completed: <title>" learnings and
21
- * "Recurring label X seen in N completed tasks" patterns on every
22
- * task completion. This created O(tasks x labels) noise with no
23
- * deduplication, resulting in 2,466 duplicate patterns and 327
24
- * duplicate learnings in brain.db (96.7% noise ratio).
20
+ * All extraction is best-effort: any error is swallowed so session end
21
+ * cannot be blocked by a failed LLM call.
25
22
  *
26
- * Pattern detection is now handled by `cleo brain maintenance`
27
- * which runs deduplication-aware analysis on a schedule.
28
- *
29
- * @see .cleo/agent-outputs/T523-CA1-brain-integrity-spec.md
23
+ * Research: `.cleo/agent-outputs/R-llm-memory-systems-research.md`
30
24
  */
31
- export async function extractTaskCompletionMemory(
32
- _projectRoot: string,
33
- _task: Task,
34
- _parentTask?: Task,
35
- ): Promise<void> {
36
- // No-op: noise generation disabled
37
- return;
38
- }
39
25
 
40
- /**
41
- * Intentionally disabled per T523 CA1 specification.
42
- *
43
- * Previously auto-generated session summary decisions, duplicate
44
- * "Completed:" learnings, and workflow patterns on session end.
45
- * These duplicated data already stored in the sessions table and
46
- * task records, adding no signal to brain.db.
47
- *
48
- * @see .cleo/agent-outputs/T523-CA1-brain-integrity-spec.md
49
- */
50
- export async function extractSessionEndMemory(
51
- _projectRoot: string,
52
- _sessionData: SessionBridgeData,
53
- _taskDetails: Task[],
54
- ): Promise<void> {
55
- // No-op: noise generation disabled
56
- return;
57
- }
26
+ import type { Task } from '@cleocode/contracts';
58
27
 
59
28
  /**
60
29
  * Resolve an array of task IDs to their full Task objects.
61
30
  * Tasks that cannot be found are silently excluded.
31
+ *
32
+ * Retained from the previous implementation because it is still used by
33
+ * callers that need hydrated task details without coupling to the disabled
34
+ * extraction stubs.
62
35
  */
63
36
  export async function resolveTaskDetails(projectRoot: string, taskIds: string[]): Promise<Task[]> {
64
37
  if (taskIds.length === 0) {
@@ -74,21 +47,24 @@ export async function resolveTaskDetails(projectRoot: string, taskIds: string[])
74
47
  }
75
48
  }
76
49
 
77
- /** Action words that indicate a meaningful assistant turn worth storing. */
78
- const ACTION_PATTERNS =
79
- /\b(implement|fix|add|create|update|remove|refactor|extract|migrate|resolve|complete|found|learned|discovered)\b/i;
80
-
81
50
  /**
82
- * Extract key observations from a provider session transcript and store
83
- * them in brain.db as learnings.
51
+ * Extract durable knowledge from a provider session transcript and store it
52
+ * in brain.db via the LLM extraction gate.
84
53
  *
85
- * Filters assistant lines that contain action words, stores up to 5 as
86
- * learnings with 0.6 confidence. Always best-effort never throws.
54
+ * Replaces the legacy `ACTION_PATTERNS` keyword-regex extractor. The LLM
55
+ * returns typed, structured memories with justification and importance
56
+ * scoring; only high-value items are persisted.
57
+ *
58
+ * Behaviour:
59
+ * - Returns silently when transcript is empty/non-string.
60
+ * - Returns silently when `brain.llmExtraction.enabled` is false OR when
61
+ * `ANTHROPIC_API_KEY` is not set (best-effort degradation).
62
+ * - Never throws — all errors are swallowed so session end cannot be
63
+ * blocked by a failed extraction.
87
64
  *
88
65
  * @param projectRoot - Absolute path to project root.
89
66
  * @param sessionId - The CLEO session ID being processed.
90
67
  * @param transcript - Plain-text provider transcript (user/assistant turns).
91
- * @task T144 @epic T134
92
68
  */
93
69
  export async function extractFromTranscript(
94
70
  projectRoot: string,
@@ -96,20 +72,12 @@ export async function extractFromTranscript(
96
72
  transcript: string,
97
73
  ): Promise<void> {
98
74
  try {
99
- const lines = transcript.split('\n').filter((l) => l.trim().length > 20);
100
- const actionLines = lines.filter((l) => ACTION_PATTERNS.test(l)).slice(0, 5);
101
- if (actionLines.length === 0) return;
102
-
103
- const { storeLearning } = await import('./learnings.js');
104
- for (const line of actionLines) {
105
- await storeLearning(projectRoot, {
106
- insight: line.trim().slice(0, 250),
107
- source: `transcript:${sessionId}`,
108
- confidence: 0.6,
109
- actionable: false,
110
- });
75
+ if (typeof transcript !== 'string' || transcript.trim().length === 0) {
76
+ return;
111
77
  }
78
+ const { extractFromTranscript: runLlmExtraction } = await import('./llm-extraction.js');
79
+ await runLlmExtraction({ projectRoot, sessionId, transcript });
112
80
  } catch {
113
- // Best-effort: must never throw
81
+ // Best-effort: extraction must never throw during session end.
114
82
  }
115
83
  }
@@ -36,7 +36,7 @@ import type {
36
36
  BrainNarrativeRow,
37
37
  BrainTimelineNeighborRow,
38
38
  } from './brain-row-types.js';
39
- import { searchBrain } from './brain-search.js';
39
+ import { hybridSearch, searchBrain } from './brain-search.js';
40
40
  import { searchSimilar } from './brain-similarity.js';
41
41
  import { addGraphEdge, upsertGraphNode } from './graph-auto-populate.js';
42
42
  import { computeObservationQuality } from './quality-scoring.js';
@@ -65,6 +65,12 @@ export interface SearchBrainCompactParams {
65
65
  dateEnd?: string;
66
66
  /** T418: filter results to observations produced by a specific agent (Wave 8 mental models). */
67
67
  agent?: string;
68
+ /**
69
+ * When true (default), use Reciprocal Rank Fusion to combine FTS5 and
70
+ * vector search results for higher recall and better ranking.
71
+ * When false, fall back to FTS5-only search (faster, no embeddings needed).
72
+ */
73
+ useRRF?: boolean;
68
74
  }
69
75
 
70
76
  /** Result from searchBrainCompact. */
@@ -166,20 +172,109 @@ export async function searchBrainCompact(
166
172
  projectRoot: string,
167
173
  params: SearchBrainCompactParams,
168
174
  ): Promise<SearchBrainCompactResult> {
169
- const { query, limit, tables, dateStart, dateEnd, agent } = params;
175
+ const { query, limit, tables, dateStart, dateEnd, agent, useRRF = true } = params;
170
176
 
171
177
  if (!query?.trim()) {
172
178
  return { results: [], total: 0, tokensEstimated: 0 };
173
179
  }
174
180
 
175
- // T418: when agent filter is set, restrict search to observations table only
176
- const effectiveTables =
177
- agent !== undefined && agent !== null
178
- ? (['observations'] as Array<'decisions' | 'patterns' | 'learnings' | 'observations'>)
179
- : tables;
181
+ const effectiveLimit = limit ?? 10;
182
+
183
+ // T418: agent filter always forces FTS-only on observations table
184
+ const agentFilter = agent !== undefined && agent !== null;
185
+
186
+ // ----- RRF path (default) -----
187
+ if (useRRF && !agentFilter) {
188
+ // Run FTS (for dates + table-level data) and RRF fusion in parallel.
189
+ // FTS gives us row-level dates; RRF gives us the fused ranking order.
190
+ const [ftsResult, rrfResults] = await Promise.all([
191
+ searchBrain(projectRoot, query, { limit: effectiveLimit * 3, tables }).catch(() => ({
192
+ decisions: [],
193
+ patterns: [],
194
+ learnings: [],
195
+ observations: [],
196
+ })),
197
+ hybridSearch(query, projectRoot, { limit: effectiveLimit * 2 }),
198
+ ]);
199
+
200
+ // Build a date map from FTS rows (id -> date string)
201
+ const dateMap = new Map<string, string>();
202
+ for (const d of ftsResult.decisions) {
203
+ const raw = d as Record<string, unknown>;
204
+ dateMap.set(d.id, (d.createdAt ?? (raw['created_at'] as string)) || '');
205
+ }
206
+ for (const p of ftsResult.patterns) {
207
+ const raw = p as Record<string, unknown>;
208
+ dateMap.set(p.id, (p.extractedAt ?? (raw['extracted_at'] as string)) || '');
209
+ }
210
+ for (const l of ftsResult.learnings) {
211
+ const raw = l as Record<string, unknown>;
212
+ dateMap.set(l.id, (l.createdAt ?? (raw['created_at'] as string)) || '');
213
+ }
214
+ for (const o of ftsResult.observations) {
215
+ const raw = o as Record<string, unknown>;
216
+ dateMap.set(o.id, (o.createdAt ?? (raw['created_at'] as string)) || '');
217
+ }
218
+
219
+ // Apply table filter when specified (map singular type names to plural table names)
220
+ const singularToTable: Record<string, string> = {
221
+ decision: 'decisions',
222
+ pattern: 'patterns',
223
+ learning: 'learnings',
224
+ observation: 'observations',
225
+ };
226
+
227
+ let results: BrainCompactHit[] = rrfResults
228
+ .map((r) => ({
229
+ id: r.id,
230
+ type: r.type as 'decision' | 'pattern' | 'learning' | 'observation',
231
+ title: r.title.slice(0, 80),
232
+ date: dateMap.get(r.id) ?? '',
233
+ relevance: r.score,
234
+ }))
235
+ .filter((r) => {
236
+ // Only include items that the FTS scan returned (ensures quality gating is respected)
237
+ return dateMap.has(r.id);
238
+ });
239
+
240
+ if (tables && tables.length > 0) {
241
+ results = results.filter((r) =>
242
+ tables.includes(
243
+ singularToTable[r.type] as 'decisions' | 'patterns' | 'learnings' | 'observations',
244
+ ),
245
+ );
246
+ }
247
+
248
+ // Apply date filters client-side
249
+ if (dateStart) results = results.filter((r) => !r.date || r.date >= dateStart);
250
+ if (dateEnd) results = results.filter((r) => !r.date || r.date <= dateEnd);
251
+
252
+ results = results.slice(0, effectiveLimit);
253
+
254
+ for (const hit of results) {
255
+ hit._next = memoryFindHitNext(hit.id);
256
+ }
257
+
258
+ if (results.length > 0) {
259
+ const returnedIds = results.map((r) => r.id);
260
+ setImmediate(() => {
261
+ incrementCitationCounts(projectRoot, returnedIds).catch(() => {});
262
+ logRetrieval(projectRoot, query, returnedIds, 'find-rrf', results.length * 50).catch(
263
+ () => {},
264
+ );
265
+ });
266
+ }
267
+
268
+ return { results, total: results.length, tokensEstimated: results.length * 50 };
269
+ }
270
+
271
+ // ----- FTS-only path (useRRF=false or agent filter) -----
272
+ const effectiveTables = agentFilter
273
+ ? (['observations'] as Array<'decisions' | 'patterns' | 'learnings' | 'observations'>)
274
+ : tables;
180
275
 
181
276
  const searchResult = await searchBrain(projectRoot, query, {
182
- limit: limit ?? 10,
277
+ limit: effectiveLimit,
183
278
  tables: effectiveTables,
184
279
  });
185
280
 
@@ -189,7 +284,7 @@ export async function searchBrainCompact(
189
284
  // We handle both naming conventions for robustness.
190
285
  let results: BrainCompactHit[] = [];
191
286
 
192
- if (!agent) {
287
+ if (!agentFilter) {
193
288
  for (const d of searchResult.decisions) {
194
289
  const raw = d as Record<string, unknown>;
195
290
  results.push({
@@ -224,7 +319,7 @@ export async function searchBrainCompact(
224
319
  for (const o of searchResult.observations) {
225
320
  const raw = o as Record<string, unknown>;
226
321
  // T418: apply agent post-filter when specified
227
- if (agent) {
322
+ if (agentFilter) {
228
323
  const rowAgent = o.agent ?? (raw['agent'] as string | null) ?? null;
229
324
  if (rowAgent !== agent) continue;
230
325
  }
@@ -237,18 +332,23 @@ export async function searchBrainCompact(
237
332
  }
238
333
 
239
334
  // Apply date filters client-side if provided
240
- if (dateStart) {
241
- results = results.filter((r) => r.date >= dateStart);
242
- }
243
- if (dateEnd) {
244
- results = results.filter((r) => r.date <= dateEnd);
245
- }
335
+ if (dateStart) results = results.filter((r) => r.date >= dateStart);
336
+ if (dateEnd) results = results.filter((r) => r.date <= dateEnd);
246
337
 
247
338
  // Enrich each hit with _next progressive disclosure directives
248
339
  for (const hit of results) {
249
340
  hit._next = memoryFindHitNext(hit.id);
250
341
  }
251
342
 
343
+ // Citation tracking + retrieval logging (non-blocking)
344
+ if (results.length > 0) {
345
+ const returnedIds = results.map((r) => r.id);
346
+ setImmediate(() => {
347
+ incrementCitationCounts(projectRoot, returnedIds).catch(() => {});
348
+ logRetrieval(projectRoot, query, returnedIds, 'find', results.length * 50).catch(() => {});
349
+ });
350
+ }
351
+
252
352
  return {
253
353
  results,
254
354
  total: results.length,
@@ -499,6 +599,21 @@ export async function fetchBrainEntries(
499
599
  }
500
600
  }
501
601
 
602
+ // Citation tracking + retrieval logging (non-blocking)
603
+ if (results.length > 0) {
604
+ const fetchedIds = results.map((r) => r.id);
605
+ setImmediate(() => {
606
+ incrementCitationCounts(projectRoot, fetchedIds).catch(() => {});
607
+ logRetrieval(
608
+ projectRoot,
609
+ fetchedIds.join(','),
610
+ fetchedIds,
611
+ 'fetch',
612
+ results.length * 500,
613
+ ).catch(() => {});
614
+ });
615
+ }
616
+
502
617
  return {
503
618
  results,
504
619
  notFound,
@@ -579,7 +694,12 @@ export async function observeBrain(
579
694
  // - sourceType 'manual' → 'owner' (owner-stated facts skip short-term in consolidator)
580
695
  // - sourceType 'session-debrief' → 'task-outcome' (synthesized summaries)
581
696
  // - otherwise → 'agent' (default for all hook/agent writes)
582
- // verified is always false at write time — consolidator sets true via corroboration gate.
697
+ // Source confidence routing (spec §4.1 Decision Tree):
698
+ // - sourceType 'manual' → 'owner' (owner-stated facts are ground truth)
699
+ // - sourceType 'session-debrief' → 'task-outcome' (verified by completion)
700
+ // - otherwise → 'agent' (default for all hook/agent writes)
701
+ // Owner and task-outcome sources are auto-verified as ground truth.
702
+ // Agent-inferred entries start unverified — consolidator promotes via corroboration.
583
703
  const resolvedSourceConfidence: BrainSourceConfidence =
584
704
  sourceConfidenceParam ??
585
705
  (sourceType === 'manual'
@@ -589,7 +709,8 @@ export async function observeBrain(
589
709
  : 'agent');
590
710
  const memoryTier: BrainMemoryTier = 'short';
591
711
  const memoryType = 'episodic' as const;
592
- const verified = false;
712
+ const verified =
713
+ resolvedSourceConfidence === 'owner' || resolvedSourceConfidence === 'task-outcome';
593
714
 
594
715
  // Content-hash dedup: SHA-256 prefix of title+text
595
716
  const contentHash = createHash('sha256')
@@ -1289,3 +1410,51 @@ async function incrementCitationCounts(projectRoot: string, ids: string[]): Prom
1289
1410
  }
1290
1411
  }
1291
1412
  }
1413
+
1414
+ /**
1415
+ * Log a retrieval event to brain_retrieval_log for co-retrieval analysis.
1416
+ *
1417
+ * Creates the table on first use if it doesn't exist (self-healing).
1418
+ * Best-effort: errors are silently swallowed.
1419
+ */
1420
+ async function logRetrieval(
1421
+ projectRoot: string,
1422
+ query: string,
1423
+ entryIds: string[],
1424
+ source: string,
1425
+ tokensUsed?: number,
1426
+ ): Promise<void> {
1427
+ if (entryIds.length === 0) return;
1428
+
1429
+ const { getBrainDb, getBrainNativeDb } = await import('../store/brain-sqlite.js');
1430
+ await getBrainDb(projectRoot);
1431
+ const nativeDb = getBrainNativeDb();
1432
+ if (!nativeDb) return;
1433
+
1434
+ // Self-healing: create table if not exists
1435
+ const createSql =
1436
+ 'CREATE TABLE IF NOT EXISTS brain_retrieval_log (' +
1437
+ 'id INTEGER PRIMARY KEY AUTOINCREMENT,' +
1438
+ 'query TEXT NOT NULL,' +
1439
+ 'entry_ids TEXT NOT NULL,' +
1440
+ 'entry_count INTEGER NOT NULL,' +
1441
+ 'source TEXT NOT NULL,' +
1442
+ 'tokens_used INTEGER,' +
1443
+ "created_at TEXT NOT NULL DEFAULT (datetime('now'))" +
1444
+ ')';
1445
+ try {
1446
+ nativeDb.prepare(createSql).run();
1447
+ } catch {
1448
+ return;
1449
+ }
1450
+
1451
+ try {
1452
+ nativeDb
1453
+ .prepare(
1454
+ 'INSERT INTO brain_retrieval_log (query, entry_ids, entry_count, source, tokens_used) VALUES (?, ?, ?, ?, ?)',
1455
+ )
1456
+ .run(query, entryIds.join(','), entryIds.length, source, tokensUsed ?? null);
1457
+ } catch {
1458
+ /* best-effort */
1459
+ }
1460
+ }