@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.
- package/dist/gc/daemon-entry.d.ts +15 -0
- package/dist/gc/daemon-entry.d.ts.map +1 -0
- package/dist/gc/daemon.d.ts +71 -0
- package/dist/gc/daemon.d.ts.map +1 -0
- package/dist/gc/daemon.js +481 -0
- package/dist/gc/daemon.js.map +7 -0
- package/dist/gc/index.d.ts +14 -0
- package/dist/gc/index.d.ts.map +1 -0
- package/dist/gc/index.js +669 -0
- package/dist/gc/index.js.map +7 -0
- package/dist/gc/runner.d.ts +132 -0
- package/dist/gc/runner.d.ts.map +1 -0
- package/dist/gc/runner.js +360 -0
- package/dist/gc/runner.js.map +7 -0
- package/dist/gc/state.d.ts +94 -0
- package/dist/gc/state.d.ts.map +1 -0
- package/dist/gc/state.js +49 -0
- package/dist/gc/state.js.map +7 -0
- package/dist/gc/transcript.d.ts +130 -0
- package/dist/gc/transcript.d.ts.map +1 -0
- package/dist/gc/transcript.js +209 -0
- package/dist/gc/transcript.js.map +7 -0
- package/dist/memory/brain-backfill.js +14643 -0
- package/dist/memory/brain-backfill.js.map +7 -0
- package/dist/memory/precompact-flush.js +47725 -0
- package/dist/memory/precompact-flush.js.map +7 -0
- package/dist/sentient/daemon-entry.d.ts +11 -0
- package/dist/sentient/daemon-entry.d.ts.map +1 -0
- package/dist/sentient/daemon.d.ts +160 -0
- package/dist/sentient/daemon.d.ts.map +1 -0
- package/dist/sentient/daemon.js +1100 -0
- package/dist/sentient/daemon.js.map +7 -0
- package/dist/sentient/index.d.ts +18 -0
- package/dist/sentient/index.d.ts.map +1 -0
- package/dist/sentient/index.js +1162 -0
- package/dist/sentient/index.js.map +7 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
- package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
- package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
- package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
- package/dist/sentient/propose-tick.d.ts +105 -0
- package/dist/sentient/propose-tick.d.ts.map +1 -0
- package/dist/sentient/propose-tick.js +549 -0
- package/dist/sentient/propose-tick.js.map +7 -0
- package/dist/sentient/state.d.ts +143 -0
- package/dist/sentient/state.d.ts.map +1 -0
- package/dist/sentient/state.js +85 -0
- package/dist/sentient/state.js.map +7 -0
- package/dist/sentient/tick.d.ts +193 -0
- package/dist/sentient/tick.d.ts.map +1 -0
- package/dist/sentient/tick.js +396 -0
- package/dist/sentient/tick.js.map +7 -0
- package/dist/system/platform-paths.js +36 -0
- package/dist/system/platform-paths.js.map +7 -0
- package/package.json +76 -8
- package/src/gc/__tests__/runner.test.ts +367 -0
- package/src/gc/__tests__/state.test.ts +169 -0
- package/src/gc/__tests__/transcript.test.ts +371 -0
- package/src/gc/daemon-entry.ts +26 -0
- package/src/gc/daemon.ts +251 -0
- package/src/gc/index.ts +14 -0
- package/src/gc/runner.ts +378 -0
- package/src/gc/state.ts +140 -0
- package/src/gc/transcript.ts +380 -0
- package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
- package/src/sentient/__tests__/daemon.test.ts +472 -0
- package/src/sentient/__tests__/dream-tick.test.ts +200 -0
- package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
- package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
- package/src/sentient/__tests__/propose-tick.test.ts +296 -0
- package/src/sentient/__tests__/test-ingester.test.ts +104 -0
- package/src/sentient/daemon-entry.ts +20 -0
- package/src/sentient/daemon.ts +471 -0
- package/src/sentient/index.ts +18 -0
- package/src/sentient/ingesters/brain-ingester.ts +122 -0
- package/src/sentient/ingesters/nexus-ingester.ts +171 -0
- package/src/sentient/ingesters/test-ingester.ts +205 -0
- package/src/sentient/proposal-rate-limiter.ts +172 -0
- package/src/sentient/propose-tick.ts +415 -0
- package/src/sentient/state.ts +229 -0
- 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
|
+
});
|