@cleocode/core 2026.4.98 → 2026.4.100

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 (85) hide show
  1. package/dist/gc/daemon-entry.d.ts +15 -0
  2. package/dist/gc/daemon-entry.d.ts.map +1 -0
  3. package/dist/gc/daemon.d.ts +71 -0
  4. package/dist/gc/daemon.d.ts.map +1 -0
  5. package/dist/gc/daemon.js +481 -0
  6. package/dist/gc/daemon.js.map +7 -0
  7. package/dist/gc/index.d.ts +14 -0
  8. package/dist/gc/index.d.ts.map +1 -0
  9. package/dist/gc/index.js +669 -0
  10. package/dist/gc/index.js.map +7 -0
  11. package/dist/gc/runner.d.ts +132 -0
  12. package/dist/gc/runner.d.ts.map +1 -0
  13. package/dist/gc/runner.js +360 -0
  14. package/dist/gc/runner.js.map +7 -0
  15. package/dist/gc/state.d.ts +94 -0
  16. package/dist/gc/state.d.ts.map +1 -0
  17. package/dist/gc/state.js +49 -0
  18. package/dist/gc/state.js.map +7 -0
  19. package/dist/gc/transcript.d.ts +130 -0
  20. package/dist/gc/transcript.d.ts.map +1 -0
  21. package/dist/gc/transcript.js +209 -0
  22. package/dist/gc/transcript.js.map +7 -0
  23. package/dist/memory/brain-backfill.js +14643 -0
  24. package/dist/memory/brain-backfill.js.map +7 -0
  25. package/dist/memory/precompact-flush.js +47725 -0
  26. package/dist/memory/precompact-flush.js.map +7 -0
  27. package/dist/sentient/daemon-entry.d.ts +11 -0
  28. package/dist/sentient/daemon-entry.d.ts.map +1 -0
  29. package/dist/sentient/daemon.d.ts +160 -0
  30. package/dist/sentient/daemon.d.ts.map +1 -0
  31. package/dist/sentient/daemon.js +1100 -0
  32. package/dist/sentient/daemon.js.map +7 -0
  33. package/dist/sentient/index.d.ts +18 -0
  34. package/dist/sentient/index.d.ts.map +1 -0
  35. package/dist/sentient/index.js +1162 -0
  36. package/dist/sentient/index.js.map +7 -0
  37. package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
  38. package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
  39. package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
  40. package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
  41. package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
  42. package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
  43. package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
  44. package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
  45. package/dist/sentient/propose-tick.d.ts +105 -0
  46. package/dist/sentient/propose-tick.d.ts.map +1 -0
  47. package/dist/sentient/propose-tick.js +549 -0
  48. package/dist/sentient/propose-tick.js.map +7 -0
  49. package/dist/sentient/state.d.ts +143 -0
  50. package/dist/sentient/state.d.ts.map +1 -0
  51. package/dist/sentient/state.js +85 -0
  52. package/dist/sentient/state.js.map +7 -0
  53. package/dist/sentient/tick.d.ts +193 -0
  54. package/dist/sentient/tick.d.ts.map +1 -0
  55. package/dist/sentient/tick.js +396 -0
  56. package/dist/sentient/tick.js.map +7 -0
  57. package/dist/system/platform-paths.js +36 -0
  58. package/dist/system/platform-paths.js.map +7 -0
  59. package/package.json +76 -8
  60. package/src/gc/__tests__/runner.test.ts +367 -0
  61. package/src/gc/__tests__/state.test.ts +169 -0
  62. package/src/gc/__tests__/transcript.test.ts +371 -0
  63. package/src/gc/daemon-entry.ts +26 -0
  64. package/src/gc/daemon.ts +251 -0
  65. package/src/gc/index.ts +14 -0
  66. package/src/gc/runner.ts +378 -0
  67. package/src/gc/state.ts +140 -0
  68. package/src/gc/transcript.ts +380 -0
  69. package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
  70. package/src/sentient/__tests__/daemon.test.ts +472 -0
  71. package/src/sentient/__tests__/dream-tick.test.ts +200 -0
  72. package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
  73. package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
  74. package/src/sentient/__tests__/propose-tick.test.ts +296 -0
  75. package/src/sentient/__tests__/test-ingester.test.ts +104 -0
  76. package/src/sentient/daemon-entry.ts +20 -0
  77. package/src/sentient/daemon.ts +471 -0
  78. package/src/sentient/index.ts +18 -0
  79. package/src/sentient/ingesters/brain-ingester.ts +122 -0
  80. package/src/sentient/ingesters/nexus-ingester.ts +171 -0
  81. package/src/sentient/ingesters/test-ingester.ts +205 -0
  82. package/src/sentient/proposal-rate-limiter.ts +172 -0
  83. package/src/sentient/propose-tick.ts +415 -0
  84. package/src/sentient/state.ts +229 -0
  85. package/src/sentient/tick.ts +688 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Transcript Scanner — Inventory and age-classification of Claude session transcripts.
3
+ *
4
+ * Implements the hot/warm/cold three-tier model from memory-architecture-spec.md §6:
5
+ * - HOT (0–24h): Full JSONL retained; agents can re-read
6
+ * - WARM (1–7d): Pending extraction; scheduled at session end
7
+ * - COLD (>7d): brain.db entries only; raw JSONL deleted (tombstone in brain_obs)
8
+ *
9
+ * Storage layout scanned (§6.2):
10
+ * ```
11
+ * ~/.claude/projects/
12
+ * <project-slug>/
13
+ * <session-uuid>.jsonl ← root-level main session transcript
14
+ * <session-uuid>/ ← session UUID directory
15
+ * subagents/
16
+ * agent-<agentId>.jsonl ← subagent transcript
17
+ * agent-<agentId>.meta.json
18
+ * tool-results/
19
+ * <toolUseId>.json
20
+ * ```
21
+ *
22
+ * @see docs/specs/memory-architecture-spec.md §6.1–6.2
23
+ * @task T728
24
+ * @epic T726
25
+ */
26
+
27
+ import { lstat, readdir, stat } from 'node:fs/promises';
28
+ import { homedir } from 'node:os';
29
+ import { join } from 'node:path';
30
+ import { getPathBytes, idempotentRm } from './runner.js';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Tier boundaries (in milliseconds)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** HOT tier: sessions less than 24 hours old. */
37
+ const HOT_MAX_MS = 24 * 60 * 60 * 1000;
38
+
39
+ /** WARM tier: sessions 24h–7d old. */
40
+ const WARM_MAX_MS = 7 * 24 * 60 * 60 * 1000;
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Types
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** Hot/warm/cold lifecycle tier for a transcript session. */
47
+ export type TranscriptTier = 'hot' | 'warm' | 'cold';
48
+
49
+ /**
50
+ * Metadata for a single session transcript discovered on disk.
51
+ */
52
+ export interface SessionInfo {
53
+ /** Absolute path to the root session JSONL file. */
54
+ jsonlPath: string;
55
+ /** Project slug (directory name under `~/.claude/projects/`). */
56
+ projectSlug: string;
57
+ /** Session UUID extracted from the JSONL filename. */
58
+ sessionId: string;
59
+ /** Last modified time of the JSONL file (ms since epoch). */
60
+ mtimeMs: number;
61
+ /** Age of the session in milliseconds. */
62
+ ageMs: number;
63
+ /** Lifecycle tier. */
64
+ tier: TranscriptTier;
65
+ /** Size in bytes of the root JSONL file. */
66
+ bytes: number;
67
+ /**
68
+ * Absolute path to the session UUID directory (if it exists).
69
+ * Contains `subagents/` and `tool-results/` subdirs.
70
+ */
71
+ sessionDir: string | null;
72
+ /** Size in bytes of the session UUID directory (including subagents). */
73
+ sessionDirBytes: number;
74
+ }
75
+
76
+ /**
77
+ * Aggregate scan result: session inventory with tier-based grouping.
78
+ */
79
+ export interface TranscriptScanResult {
80
+ /** Total number of sessions found. */
81
+ totalSessions: number;
82
+ /** HOT tier sessions (< 24h). */
83
+ hot: SessionInfo[];
84
+ /** WARM tier sessions (24h–7d). */
85
+ warm: SessionInfo[];
86
+ /** Total size of all discovered transcripts in bytes. */
87
+ totalBytes: number;
88
+ /** Absolute path to `~/.claude/projects/`. */
89
+ projectsDir: string;
90
+ }
91
+
92
+ /**
93
+ * Result of a transcript prune operation.
94
+ */
95
+ export interface TranscriptPruneResult {
96
+ /** Number of sessions pruned. */
97
+ pruned: number;
98
+ /** Bytes freed. */
99
+ bytesFreed: number;
100
+ /** Paths that were deleted (or would be deleted in dry-run). */
101
+ deletedPaths: string[];
102
+ /** Whether this was a dry-run (no filesystem mutations). */
103
+ dryRun: boolean;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Helpers
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Classify a session age into a transcript tier.
112
+ *
113
+ * @param ageMs - Session age in milliseconds
114
+ * @returns Lifecycle tier
115
+ */
116
+ export function classifyTranscriptTier(ageMs: number): TranscriptTier {
117
+ if (ageMs < HOT_MAX_MS) return 'hot';
118
+ if (ageMs < WARM_MAX_MS) return 'warm';
119
+ return 'cold';
120
+ }
121
+
122
+ /**
123
+ * Parse a session UUID from a JSONL filename.
124
+ *
125
+ * Expected format: `<uuid>.jsonl` where uuid matches the standard
126
+ * UUID v4 pattern (8-4-4-4-12 hex digits).
127
+ *
128
+ * @param filename - JSONL filename (basename only)
129
+ * @returns Session UUID string, or the filename stem if not UUID format
130
+ */
131
+ function parseSessionId(filename: string): string {
132
+ return filename.replace(/\.jsonl$/, '');
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Core operations
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Scan `~/.claude/projects/` and return a structured inventory of all
141
+ * session transcripts, classified by hot/warm/cold tier.
142
+ *
143
+ * Does not modify any files. Safe to call at any time.
144
+ *
145
+ * @param projectsDir - Override the default `~/.claude/projects/` path (for testing)
146
+ * @returns Transcript scan result with tier-classified session list
147
+ */
148
+ export async function scanTranscripts(projectsDir?: string): Promise<TranscriptScanResult> {
149
+ const resolvedProjectsDir = projectsDir ?? join(homedir(), '.claude', 'projects');
150
+ const now = Date.now();
151
+
152
+ const hot: SessionInfo[] = [];
153
+ const warm: SessionInfo[] = [];
154
+ let totalBytes = 0;
155
+
156
+ // List project slugs
157
+ let slugs: string[];
158
+ try {
159
+ const entries = await readdir(resolvedProjectsDir, { withFileTypes: true });
160
+ slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
161
+ } catch {
162
+ // Directory doesn't exist yet — return empty result
163
+ return { totalSessions: 0, hot, warm, totalBytes, projectsDir: resolvedProjectsDir };
164
+ }
165
+
166
+ for (const slug of slugs) {
167
+ const slugDir = join(resolvedProjectsDir, slug);
168
+
169
+ let entries: import('fs').Dirent[];
170
+ try {
171
+ entries = await readdir(slugDir, { withFileTypes: true });
172
+ } catch {
173
+ continue;
174
+ }
175
+
176
+ for (const entry of entries) {
177
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
178
+
179
+ const jsonlPath = join(slugDir, entry.name);
180
+ const sessionId = parseSessionId(entry.name);
181
+
182
+ let fileInfo: import('fs').Stats;
183
+ try {
184
+ fileInfo = await stat(jsonlPath);
185
+ } catch {
186
+ continue; // File disappeared between readdir and stat
187
+ }
188
+
189
+ const mtimeMs = fileInfo.mtimeMs;
190
+ const ageMs = now - mtimeMs;
191
+ const tier = classifyTranscriptTier(ageMs);
192
+ const bytes = fileInfo.size;
193
+
194
+ // Check for associated session UUID directory
195
+ const candidateSessionDir = join(slugDir, sessionId);
196
+ let sessionDir: string | null = null;
197
+ let sessionDirBytes = 0;
198
+ try {
199
+ const dirInfo = await lstat(candidateSessionDir);
200
+ if (dirInfo.isDirectory()) {
201
+ sessionDir = candidateSessionDir;
202
+ sessionDirBytes = await getPathBytes(candidateSessionDir);
203
+ }
204
+ } catch {
205
+ // Session dir doesn't exist — single-file session
206
+ }
207
+
208
+ const info: SessionInfo = {
209
+ jsonlPath,
210
+ projectSlug: slug,
211
+ sessionId,
212
+ mtimeMs,
213
+ ageMs,
214
+ tier,
215
+ bytes,
216
+ sessionDir,
217
+ sessionDirBytes,
218
+ };
219
+
220
+ totalBytes += bytes + sessionDirBytes;
221
+
222
+ if (tier === 'hot') {
223
+ hot.push(info);
224
+ } else if (tier === 'warm') {
225
+ warm.push(info);
226
+ }
227
+ // COLD sessions have already had their JSONL deleted (tombstone only in brain.db)
228
+ // so they won't appear in the filesystem scan
229
+ }
230
+ }
231
+
232
+ const totalSessions = hot.length + warm.length;
233
+ return { totalSessions, hot, warm, totalBytes, projectsDir: resolvedProjectsDir };
234
+ }
235
+
236
+ /**
237
+ * Prune session transcripts older than `olderThanMs` milliseconds.
238
+ *
239
+ * Dry-run by default: pass `confirm: true` to perform actual deletion.
240
+ *
241
+ * Circuit breakers (from memory-architecture-spec.md §6.4):
242
+ * - If `ANTHROPIC_API_KEY` is absent, only delete sessions older than 30d
243
+ * (raw preservation fallback — skip extraction).
244
+ *
245
+ * @param opts - Prune options
246
+ * @param opts.olderThanMs - Delete sessions older than this many milliseconds
247
+ * @param opts.confirm - If true, perform actual deletion; dry-run if false
248
+ * @param opts.projectsDir - Override `~/.claude/projects/` (for testing)
249
+ * @returns Prune result with count, bytes freed, and deleted paths
250
+ */
251
+ export async function pruneTranscripts(opts: {
252
+ olderThanMs: number;
253
+ confirm: boolean;
254
+ projectsDir?: string;
255
+ }): Promise<TranscriptPruneResult> {
256
+ const { olderThanMs, confirm, projectsDir } = opts;
257
+ const dryRun = !confirm;
258
+
259
+ // Circuit breaker: no API key → be conservative (only prune >30d)
260
+ const hasApiKey = Boolean(process.env['ANTHROPIC_API_KEY']);
261
+ const effectiveMaxAgeMs = hasApiKey
262
+ ? olderThanMs
263
+ : Math.max(olderThanMs, 30 * 24 * 60 * 60 * 1000);
264
+
265
+ const now = Date.now();
266
+ const deletedPaths: string[] = [];
267
+ let bytesFreed = 0;
268
+ let pruned = 0;
269
+
270
+ const resolvedProjectsDir = projectsDir ?? join(homedir(), '.claude', 'projects');
271
+
272
+ let slugs: string[];
273
+ try {
274
+ const entries = await readdir(resolvedProjectsDir, { withFileTypes: true });
275
+ slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
276
+ } catch {
277
+ return { pruned: 0, bytesFreed: 0, deletedPaths: [], dryRun };
278
+ }
279
+
280
+ for (const slug of slugs) {
281
+ const slugDir = join(resolvedProjectsDir, slug);
282
+
283
+ let entries: import('fs').Dirent[];
284
+ try {
285
+ entries = await readdir(slugDir, { withFileTypes: true });
286
+ } catch {
287
+ continue;
288
+ }
289
+
290
+ for (const entry of entries) {
291
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
292
+
293
+ const jsonlPath = join(slugDir, entry.name);
294
+
295
+ let fileInfo: import('fs').Stats;
296
+ try {
297
+ fileInfo = await stat(jsonlPath);
298
+ } catch {
299
+ continue;
300
+ }
301
+
302
+ const ageMs = now - fileInfo.mtimeMs;
303
+ if (ageMs <= effectiveMaxAgeMs) continue;
304
+
305
+ const sessionId = parseSessionId(entry.name);
306
+ const sessionDir = join(slugDir, sessionId);
307
+
308
+ // Measure bytes before deletion
309
+ const jsonlBytes = fileInfo.size;
310
+ let sessionDirBytes = 0;
311
+ try {
312
+ const dirInfo = await lstat(sessionDir);
313
+ if (dirInfo.isDirectory()) {
314
+ sessionDirBytes = await getPathBytes(sessionDir);
315
+ }
316
+ } catch {
317
+ // No session dir
318
+ }
319
+
320
+ if (dryRun) {
321
+ deletedPaths.push(jsonlPath);
322
+ if (sessionDirBytes > 0) deletedPaths.push(sessionDir);
323
+ bytesFreed += jsonlBytes + sessionDirBytes;
324
+ pruned++;
325
+ continue;
326
+ }
327
+
328
+ // Actual deletion
329
+ try {
330
+ await idempotentRm(jsonlPath);
331
+ deletedPaths.push(jsonlPath);
332
+ bytesFreed += jsonlBytes;
333
+ pruned++;
334
+ } catch {
335
+ // Deletion failure: skip this file
336
+ continue;
337
+ }
338
+
339
+ // Delete associated session directory if it exists
340
+ try {
341
+ const dirInfo = await lstat(sessionDir);
342
+ if (dirInfo.isDirectory()) {
343
+ await idempotentRm(sessionDir);
344
+ deletedPaths.push(sessionDir);
345
+ bytesFreed += sessionDirBytes;
346
+ }
347
+ } catch {
348
+ // No session dir or already deleted
349
+ }
350
+ }
351
+ }
352
+
353
+ return { pruned, bytesFreed, deletedPaths, dryRun };
354
+ }
355
+
356
+ /**
357
+ * Parse a human-readable duration string into milliseconds.
358
+ *
359
+ * Supported formats: `7d`, `24h`, `30m`, `1d`, `14d`, `168h`, etc.
360
+ * Used by `cleo transcript prune --older-than <duration>`.
361
+ *
362
+ * @param duration - Duration string (e.g. `"7d"`, `"24h"`, `"30m"`)
363
+ * @returns Duration in milliseconds
364
+ * @throws Error if the format is not recognized
365
+ */
366
+ export function parseDurationMs(duration: string): number {
367
+ const match = /^(\d+(\.\d+)?)(d|h|m|s)$/.exec(duration.trim());
368
+ if (!match?.[1] || !match[3]) {
369
+ throw new Error(`Invalid duration format: "${duration}". Use format like 7d, 24h, 30m, 60s.`);
370
+ }
371
+ const value = parseFloat(match[1]);
372
+ const unit = match[3];
373
+ const multipliers: Record<string, number> = {
374
+ d: 24 * 60 * 60 * 1000,
375
+ h: 60 * 60 * 1000,
376
+ m: 60 * 1000,
377
+ s: 1000,
378
+ };
379
+ return value * (multipliers[unit] ?? 1000);
380
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tests for the BRAIN ingester.
3
+ *
4
+ * Uses a real in-memory DatabaseSync with a minimal brain_observations table.
5
+ * No external services or file I/O.
6
+ *
7
+ * @task T1008
8
+ */
9
+
10
+ import { DatabaseSync } from 'node:sqlite';
11
+ import { describe, expect, it } from 'vitest';
12
+ import {
13
+ BRAIN_INGESTER_LIMIT,
14
+ BRAIN_LOOKBACK_DAYS,
15
+ computeBrainWeight,
16
+ runBrainIngester,
17
+ } from '../ingesters/brain-ingester.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function createBrainDb(): DatabaseSync {
24
+ const db = new DatabaseSync(':memory:');
25
+ db.exec(`
26
+ CREATE TABLE brain_observations (
27
+ id TEXT PRIMARY KEY,
28
+ title TEXT,
29
+ text TEXT NOT NULL DEFAULT '',
30
+ type TEXT NOT NULL DEFAULT 'decision',
31
+ citation_count INTEGER NOT NULL DEFAULT 0,
32
+ quality_score REAL NOT NULL DEFAULT 0.5,
33
+ created_at TEXT NOT NULL
34
+ )
35
+ `);
36
+ return db;
37
+ }
38
+
39
+ function insertObservation(
40
+ db: DatabaseSync,
41
+ id: string,
42
+ opts: {
43
+ type?: string;
44
+ citationCount?: number;
45
+ qualityScore?: number;
46
+ daysAgo?: number;
47
+ title?: string;
48
+ } = {},
49
+ ) {
50
+ const {
51
+ type = 'decision',
52
+ citationCount = 3,
53
+ qualityScore = 0.8,
54
+ daysAgo = 0,
55
+ title = `Observation ${id}`,
56
+ } = opts;
57
+
58
+ const date = new Date(Date.now() - daysAgo * 86_400_000).toISOString();
59
+ db.prepare(
60
+ `INSERT INTO brain_observations (id, title, type, citation_count, quality_score, created_at)
61
+ VALUES (:id, :title, :type, :citationCount, :qualityScore, :createdAt)`,
62
+ ).run({ id, title, type, citationCount, qualityScore, createdAt: date });
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // runBrainIngester
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe('runBrainIngester', () => {
70
+ it('returns empty array when nativeDb is null', () => {
71
+ expect(runBrainIngester(null)).toEqual([]);
72
+ });
73
+
74
+ it('returns empty array when no observations match criteria (citation_count < 3)', () => {
75
+ const db = createBrainDb();
76
+ insertObservation(db, 'O1', { citationCount: 1 });
77
+ insertObservation(db, 'O2', { citationCount: 2 });
78
+ expect(runBrainIngester(db)).toHaveLength(0);
79
+ db.close();
80
+ });
81
+
82
+ it('returns entries where citation_count >= 3 AND within 7 days AND quality_score >= 0.5', () => {
83
+ const db = createBrainDb();
84
+ insertObservation(db, 'O1', { citationCount: 3, qualityScore: 0.8, daysAgo: 1 });
85
+ insertObservation(db, 'O2', { citationCount: 5, qualityScore: 0.9, daysAgo: 3 });
86
+ const results = runBrainIngester(db);
87
+ expect(results).toHaveLength(2);
88
+ expect(results.every((r) => r.source === 'brain')).toBe(true);
89
+ db.close();
90
+ });
91
+
92
+ it('excludes entries older than 7 days even if citation_count >= 3', () => {
93
+ const db = createBrainDb();
94
+ insertObservation(db, 'O1', { citationCount: 5, daysAgo: BRAIN_LOOKBACK_DAYS + 1 });
95
+ insertObservation(db, 'O2', { citationCount: 3, daysAgo: 1 });
96
+ const results = runBrainIngester(db);
97
+ expect(results).toHaveLength(1);
98
+ expect(results[0]?.sourceId).toBe('O2');
99
+ db.close();
100
+ });
101
+
102
+ it('computes weight correctly using (citation_count / 10) * quality_score capped at 1.0', () => {
103
+ const db = createBrainDb();
104
+ insertObservation(db, 'O1', { citationCount: 5, qualityScore: 0.8, daysAgo: 0 });
105
+ const results = runBrainIngester(db);
106
+ expect(results).toHaveLength(1);
107
+ const expected = computeBrainWeight(5, 0.8);
108
+ expect(results[0]?.weight).toBeCloseTo(expected, 5);
109
+ db.close();
110
+ });
111
+
112
+ it('caps weight at 1.0 for very high citation_count', () => {
113
+ const db = createBrainDb();
114
+ insertObservation(db, 'O1', { citationCount: 100, qualityScore: 1.0, daysAgo: 0 });
115
+ const results = runBrainIngester(db);
116
+ expect(results[0]?.weight).toBe(1.0);
117
+ db.close();
118
+ });
119
+
120
+ it('returns at most BRAIN_INGESTER_LIMIT candidates', () => {
121
+ const db = createBrainDb();
122
+ for (let i = 0; i < BRAIN_INGESTER_LIMIT + 5; i++) {
123
+ insertObservation(db, `O${i}`, { citationCount: 3 + i, daysAgo: 0 });
124
+ }
125
+ const results = runBrainIngester(db);
126
+ expect(results.length).toBeLessThanOrEqual(BRAIN_INGESTER_LIMIT);
127
+ db.close();
128
+ });
129
+
130
+ it('handles getBrainNativeDb-like failure gracefully (returns empty array)', () => {
131
+ // Simulate a DB with missing table
132
+ const db = new DatabaseSync(':memory:');
133
+ // No brain_observations table — should return empty not throw
134
+ const results = runBrainIngester(db);
135
+ expect(results).toEqual([]);
136
+ db.close();
137
+ });
138
+ });
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // computeBrainWeight
142
+ // ---------------------------------------------------------------------------
143
+
144
+ describe('computeBrainWeight', () => {
145
+ it('calculates (citation_count / 10) * quality_score', () => {
146
+ expect(computeBrainWeight(5, 0.8)).toBeCloseTo(0.4, 5);
147
+ expect(computeBrainWeight(3, 0.5)).toBeCloseTo(0.15, 5);
148
+ });
149
+
150
+ it('caps weight at 1.0', () => {
151
+ expect(computeBrainWeight(20, 1.0)).toBe(1.0);
152
+ expect(computeBrainWeight(100, 1.0)).toBe(1.0);
153
+ });
154
+ });