@titan-design/brain 0.2.0

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/cli.js ADDED
@@ -0,0 +1,2114 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command as Command11 } from "@commander-js/extra-typings";
5
+
6
+ // src/commands/init.ts
7
+ import { Command } from "@commander-js/extra-typings";
8
+ import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync as writeFileSync2 } from "fs";
9
+ import { join as join2 } from "path";
10
+ import { execSync } from "child_process";
11
+ import { createInterface } from "readline";
12
+
13
+ // src/services/config.ts
14
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
15
+ import { join } from "path";
16
+ import { homedir } from "os";
17
+ import envPaths from "env-paths";
18
+ var paths = envPaths("brain", { suffix: "" });
19
+ var VALID_EMBEDDERS = ["local", "ollama", "remote"];
20
+ function ensureDir(dir) {
21
+ if (!existsSync(dir)) {
22
+ mkdirSync(dir, { recursive: true });
23
+ }
24
+ }
25
+ function validateConfig(config) {
26
+ if (config.embedder !== void 0 && !VALID_EMBEDDERS.includes(config.embedder)) {
27
+ throw new Error(
28
+ `Invalid embedder "${config.embedder}". Must be one of: ${VALID_EMBEDDERS.join(", ")}`
29
+ );
30
+ }
31
+ if (config.fusionWeights !== void 0) {
32
+ const sum = config.fusionWeights.bm25 + config.fusionWeights.vector;
33
+ if (Math.abs(sum - 1) > 1e-6) {
34
+ throw new Error(
35
+ `Fusion weights must sum to 1.0, got ${sum} (bm25: ${config.fusionWeights.bm25}, vector: ${config.fusionWeights.vector})`
36
+ );
37
+ }
38
+ }
39
+ }
40
+ function getConfigDir(override) {
41
+ const dir = override ?? paths.config;
42
+ ensureDir(dir);
43
+ return dir;
44
+ }
45
+ function getDataDir(override) {
46
+ const dir = override ?? paths.data;
47
+ ensureDir(dir);
48
+ return dir;
49
+ }
50
+ function getConfigPath(configDir) {
51
+ return join(getConfigDir(configDir), "config.json");
52
+ }
53
+ function getDefaultConfig(dataDir) {
54
+ return {
55
+ notesDir: join(homedir(), "brain"),
56
+ dbPath: join(getDataDir(dataDir), "brain.db"),
57
+ embedder: "local",
58
+ fusionWeights: { bm25: 0.3, vector: 0.7 }
59
+ };
60
+ }
61
+ function loadConfig(configDir, dataDir) {
62
+ const defaults = getDefaultConfig(dataDir);
63
+ const filePath = getConfigPath(configDir);
64
+ if (!existsSync(filePath)) {
65
+ return defaults;
66
+ }
67
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
68
+ return {
69
+ ...defaults,
70
+ ...raw,
71
+ fusionWeights: {
72
+ ...defaults.fusionWeights,
73
+ ...raw.fusionWeights
74
+ }
75
+ };
76
+ }
77
+ function saveConfig(config, configDir, dataDir) {
78
+ validateConfig(config);
79
+ const existing = loadConfig(configDir, dataDir);
80
+ const merged = {
81
+ ...existing,
82
+ ...config,
83
+ fusionWeights: {
84
+ ...existing.fusionWeights,
85
+ ...config.fusionWeights
86
+ }
87
+ };
88
+ const filePath = getConfigPath(configDir);
89
+ writeFileSync(filePath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
90
+ }
91
+
92
+ // src/services/brain-db.ts
93
+ import Database from "better-sqlite3";
94
+ import * as sqliteVec from "sqlite-vec";
95
+ var SCHEMA_VERSION = 2;
96
+ var BrainDB = class {
97
+ db;
98
+ vectorDimensions = null;
99
+ constructor(dbPath) {
100
+ this.db = new Database(dbPath);
101
+ this.db.pragma("journal_mode = WAL");
102
+ this.db.pragma("foreign_keys = ON");
103
+ sqliteVec.load(this.db);
104
+ this.migrate();
105
+ }
106
+ close() {
107
+ this.db.close();
108
+ }
109
+ // --- Schema Migration ---
110
+ migrate() {
111
+ const currentVersion = this.db.pragma("user_version", { simple: true });
112
+ if (currentVersion < 1) {
113
+ this.db.exec(this.schemaV1());
114
+ this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
115
+ this.setMetaValue("schema_version", String(SCHEMA_VERSION));
116
+ }
117
+ if (currentVersion >= 1 && currentVersion < 2) {
118
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_note_id ON chunks(note_id)");
119
+ this.db.pragma("user_version = 2");
120
+ this.setMetaValue("schema_version", "2");
121
+ }
122
+ const dims = this.getMetaValue("embedding_dimensions");
123
+ if (dims) {
124
+ this.ensureVectorTable(Number(dims));
125
+ }
126
+ }
127
+ ensureVectorTable(dimensions) {
128
+ if (this.vectorDimensions === dimensions) return;
129
+ const existing = this.getMetaValue("embedding_dimensions");
130
+ if (existing && Number(existing) !== dimensions) {
131
+ this.db.exec("DROP TABLE IF EXISTS chunk_vectors");
132
+ }
133
+ this.db.exec(`
134
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunk_vectors USING vec0(
135
+ chunk_id TEXT PRIMARY KEY,
136
+ embedding float[${dimensions}]
137
+ )
138
+ `);
139
+ this.vectorDimensions = dimensions;
140
+ }
141
+ schemaV1() {
142
+ return `
143
+ CREATE TABLE IF NOT EXISTS files (
144
+ path TEXT PRIMARY KEY,
145
+ hash TEXT NOT NULL,
146
+ mtime INTEGER NOT NULL,
147
+ indexed_at INTEGER NOT NULL
148
+ );
149
+
150
+ CREATE TABLE IF NOT EXISTS notes (
151
+ id TEXT PRIMARY KEY,
152
+ file_path TEXT NOT NULL UNIQUE,
153
+ title TEXT NOT NULL,
154
+ type TEXT NOT NULL,
155
+ tier TEXT NOT NULL,
156
+ category TEXT,
157
+ tags TEXT,
158
+ summary TEXT,
159
+ confidence TEXT,
160
+ status TEXT DEFAULT 'current',
161
+ sources TEXT,
162
+ created_at TEXT,
163
+ modified_at TEXT,
164
+ last_reviewed TEXT,
165
+ review_interval TEXT,
166
+ expires TEXT,
167
+ metadata TEXT
168
+ );
169
+
170
+ CREATE TABLE IF NOT EXISTS relations (
171
+ source_id TEXT NOT NULL,
172
+ target_id TEXT NOT NULL,
173
+ type TEXT NOT NULL,
174
+ created_at INTEGER NOT NULL,
175
+ PRIMARY KEY (source_id, target_id, type)
176
+ );
177
+
178
+ CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
179
+ note_id UNINDEXED,
180
+ title,
181
+ summary,
182
+ content,
183
+ tokenize='unicode61'
184
+ );
185
+
186
+ CREATE TABLE IF NOT EXISTS chunks (
187
+ id TEXT PRIMARY KEY,
188
+ note_id TEXT NOT NULL,
189
+ heading TEXT,
190
+ content TEXT NOT NULL,
191
+ token_count INTEGER,
192
+ chunk_type TEXT DEFAULT 'section',
193
+ FOREIGN KEY (note_id) REFERENCES notes(id)
194
+ );
195
+
196
+ CREATE INDEX IF NOT EXISTS idx_chunks_note_id ON chunks(note_id);
197
+
198
+ CREATE TABLE IF NOT EXISTS db_meta (
199
+ key TEXT PRIMARY KEY,
200
+ value TEXT NOT NULL
201
+ );
202
+ `;
203
+ }
204
+ // --- Meta ---
205
+ setMetaValue(key, value) {
206
+ this.db.prepare("INSERT OR REPLACE INTO db_meta (key, value) VALUES (?, ?)").run(key, value);
207
+ }
208
+ getMetaValue(key) {
209
+ const row = this.db.prepare("SELECT value FROM db_meta WHERE key = ?").get(key);
210
+ return row?.value ?? null;
211
+ }
212
+ // --- Embedding Model ---
213
+ setEmbeddingModel(model, dimensions) {
214
+ this.setMetaValue("embedding_model", model);
215
+ this.setMetaValue("embedding_dimensions", String(dimensions));
216
+ this.ensureVectorTable(dimensions);
217
+ }
218
+ getEmbeddingModel() {
219
+ const model = this.getMetaValue("embedding_model");
220
+ const dims = this.getMetaValue("embedding_dimensions");
221
+ if (!model || !dims) return null;
222
+ return { model, dimensions: Number(dims) };
223
+ }
224
+ checkModelMatch(model) {
225
+ const stored = this.getEmbeddingModel();
226
+ if (!stored) return;
227
+ if (stored.model !== model) {
228
+ throw new Error(
229
+ `Embedding model mismatch: DB uses "${stored.model}" but "${model}" was requested. Re-index with --force to switch models.`
230
+ );
231
+ }
232
+ }
233
+ // --- Table Introspection ---
234
+ listTables() {
235
+ const rows = this.db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'").all();
236
+ return rows.map((r) => r.name);
237
+ }
238
+ // --- Note CRUD ---
239
+ upsertNote(record) {
240
+ this.db.prepare(
241
+ `INSERT OR REPLACE INTO notes
242
+ (id, file_path, title, type, tier, category, tags, summary, confidence, status, sources, created_at, modified_at, last_reviewed, review_interval, expires, metadata)
243
+ VALUES
244
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
245
+ ).run(
246
+ record.id,
247
+ record.filePath,
248
+ record.title,
249
+ record.type,
250
+ record.tier,
251
+ record.category,
252
+ record.tags,
253
+ record.summary,
254
+ record.confidence,
255
+ record.status,
256
+ record.sources,
257
+ record.createdAt,
258
+ record.modifiedAt,
259
+ record.lastReviewed,
260
+ record.reviewInterval,
261
+ record.expires,
262
+ record.metadata
263
+ );
264
+ return record;
265
+ }
266
+ getNoteById(id) {
267
+ const row = this.db.prepare("SELECT * FROM notes WHERE id = ?").get(id);
268
+ return row ? rowToNoteRecord(row) : null;
269
+ }
270
+ getAllNotes() {
271
+ const rows = this.db.prepare("SELECT * FROM notes").all();
272
+ return rows.map(rowToNoteRecord);
273
+ }
274
+ deleteNote(id) {
275
+ const txn = this.db.transaction(() => {
276
+ this.deleteChunksForNote(id);
277
+ this.db.prepare("DELETE FROM notes_fts WHERE note_id = ?").run(id);
278
+ this.db.prepare("DELETE FROM relations WHERE source_id = ? OR target_id = ?").run(id, id);
279
+ this.db.prepare("DELETE FROM notes WHERE id = ?").run(id);
280
+ });
281
+ txn();
282
+ }
283
+ // --- File Tracking ---
284
+ upsertFile(record) {
285
+ this.db.prepare(
286
+ `INSERT OR REPLACE INTO files (path, hash, mtime, indexed_at)
287
+ VALUES (?, ?, ?, ?)`
288
+ ).run(record.path, record.hash, record.mtime, record.indexedAt);
289
+ }
290
+ getFile(path) {
291
+ const row = this.db.prepare("SELECT * FROM files WHERE path = ?").get(path);
292
+ return row ? rowToFileRecord(row) : null;
293
+ }
294
+ getAllFiles() {
295
+ const rows = this.db.prepare("SELECT * FROM files").all();
296
+ const map = /* @__PURE__ */ new Map();
297
+ for (const row of rows) {
298
+ const rec = rowToFileRecord(row);
299
+ map.set(rec.path, rec);
300
+ }
301
+ return map;
302
+ }
303
+ deleteFile(path) {
304
+ this.db.prepare("DELETE FROM files WHERE path = ?").run(path);
305
+ }
306
+ // --- Chunk + Vector Operations ---
307
+ upsertChunks(noteId, chunks, embeddings) {
308
+ if (embeddings.length > 0) {
309
+ this.ensureVectorTable(embeddings[0].length);
310
+ }
311
+ this.deleteChunksForNote(noteId);
312
+ const insertChunk = this.db.prepare(
313
+ `INSERT INTO chunks (id, note_id, heading, content, token_count, chunk_type)
314
+ VALUES (?, ?, ?, ?, ?, ?)`
315
+ );
316
+ const insertVector = this.db.prepare(
317
+ `INSERT INTO chunk_vectors (chunk_id, embedding)
318
+ VALUES (?, ?)`
319
+ );
320
+ const txn = this.db.transaction(() => {
321
+ for (let i = 0; i < chunks.length; i++) {
322
+ const chunk = chunks[i];
323
+ insertChunk.run(
324
+ chunk.id,
325
+ noteId,
326
+ chunk.heading,
327
+ chunk.content,
328
+ chunk.tokenCount,
329
+ chunk.chunkType
330
+ );
331
+ insertVector.run(
332
+ chunk.id,
333
+ Buffer.from(embeddings[i].buffer)
334
+ );
335
+ }
336
+ });
337
+ txn();
338
+ }
339
+ getChunksForNote(noteId) {
340
+ const rows = this.db.prepare("SELECT * FROM chunks WHERE note_id = ? ORDER BY rowid").all(noteId);
341
+ return rows.map(rowToChunk);
342
+ }
343
+ getChunkCount() {
344
+ const row = this.db.prepare("SELECT COUNT(*) as count FROM chunks").get();
345
+ return row.count;
346
+ }
347
+ deleteChunksForNote(noteId) {
348
+ const chunkIds = this.db.prepare("SELECT id FROM chunks WHERE note_id = ?").all(noteId);
349
+ if (chunkIds.length > 0) {
350
+ const deleteVec = this.db.prepare("DELETE FROM chunk_vectors WHERE chunk_id = ?");
351
+ const txn = this.db.transaction(() => {
352
+ for (const { id } of chunkIds) {
353
+ deleteVec.run(id);
354
+ }
355
+ });
356
+ txn();
357
+ }
358
+ this.db.prepare("DELETE FROM chunks WHERE note_id = ?").run(noteId);
359
+ }
360
+ // --- Relations ---
361
+ upsertRelations(noteId, relations) {
362
+ const txn = this.db.transaction(() => {
363
+ this.db.prepare("DELETE FROM relations WHERE source_id = ?").run(noteId);
364
+ const insert = this.db.prepare(
365
+ `INSERT INTO relations (source_id, target_id, type, created_at)
366
+ VALUES (?, ?, ?, ?)`
367
+ );
368
+ for (const rel of relations) {
369
+ insert.run(rel.sourceId, rel.targetId, rel.type, Date.now());
370
+ }
371
+ });
372
+ txn();
373
+ }
374
+ getRelationsFrom(noteId) {
375
+ const rows = this.db.prepare("SELECT source_id, target_id, type FROM relations WHERE source_id = ?").all(noteId);
376
+ return rows.map(rowToRelation);
377
+ }
378
+ getRelationsTo(noteId) {
379
+ const rows = this.db.prepare("SELECT source_id, target_id, type FROM relations WHERE target_id = ?").all(noteId);
380
+ return rows.map(rowToRelation);
381
+ }
382
+ // --- Search API ---
383
+ searchVector(embedding, limit) {
384
+ try {
385
+ return this.db.prepare(
386
+ `SELECT cv.chunk_id as chunkId, c.note_id as noteId, cv.distance
387
+ FROM chunk_vectors cv
388
+ JOIN chunks c ON c.id = cv.chunk_id
389
+ WHERE embedding MATCH ? AND k = ?
390
+ ORDER BY distance`
391
+ ).all(Buffer.from(embedding.buffer), limit);
392
+ } catch {
393
+ return [];
394
+ }
395
+ }
396
+ getFilteredNoteIds(filters) {
397
+ const conditions = [];
398
+ const params = [];
399
+ if (filters.tier) {
400
+ conditions.push("tier = ?");
401
+ params.push(filters.tier);
402
+ }
403
+ if (filters.category) {
404
+ conditions.push("category = ?");
405
+ params.push(filters.category);
406
+ }
407
+ if (filters.confidence) {
408
+ conditions.push("confidence = ?");
409
+ params.push(filters.confidence);
410
+ }
411
+ if (filters.since) {
412
+ conditions.push("modified_at >= ?");
413
+ params.push(filters.since);
414
+ }
415
+ if (filters.tags?.length) {
416
+ conditions.push(`(${filters.tags.map(() => "tags LIKE ?").join(" AND ")})`);
417
+ for (const tag of filters.tags) params.push(`%${tag}%`);
418
+ }
419
+ if (conditions.length === 0) return null;
420
+ const rows = this.db.prepare(
421
+ `SELECT id FROM notes WHERE ${conditions.join(" AND ")}`
422
+ ).all(...params);
423
+ return new Set(rows.map((r) => r.id));
424
+ }
425
+ getChunkContent(chunkId) {
426
+ const row = this.db.prepare("SELECT content FROM chunks WHERE id = ?").get(chunkId);
427
+ return row?.content ?? "";
428
+ }
429
+ getFirstChunkForNote(noteId) {
430
+ const row = this.db.prepare(
431
+ "SELECT content, heading FROM chunks WHERE note_id = ? ORDER BY rowid LIMIT 1"
432
+ ).get(noteId);
433
+ return row ?? null;
434
+ }
435
+ getChunkHeading(chunkId, noteId) {
436
+ if (chunkId) {
437
+ const row2 = this.db.prepare("SELECT heading FROM chunks WHERE id = ?").get(chunkId);
438
+ if (row2?.heading) return row2.heading;
439
+ }
440
+ const row = this.db.prepare(
441
+ "SELECT heading FROM chunks WHERE note_id = ? ORDER BY rowid LIMIT 1"
442
+ ).get(noteId);
443
+ return row?.heading ?? null;
444
+ }
445
+ getNoteByFilePath(filePath) {
446
+ const row = this.db.prepare("SELECT * FROM notes WHERE file_path = ?").get(filePath);
447
+ return row ? rowToNoteRecord(row) : null;
448
+ }
449
+ // --- FTS ---
450
+ upsertNoteFTS(noteId, title, summary, content) {
451
+ this.db.prepare("DELETE FROM notes_fts WHERE note_id = ?").run(noteId);
452
+ this.db.prepare("INSERT INTO notes_fts (note_id, title, summary, content) VALUES (?, ?, ?, ?)").run(noteId, title, summary, content);
453
+ }
454
+ searchFTS(query, limit) {
455
+ if (!query.trim()) return [];
456
+ const sanitized = sanitizeFtsQuery(query);
457
+ if (!sanitized) return [];
458
+ const rows = this.db.prepare(
459
+ `SELECT note_id as noteId, rank
460
+ FROM notes_fts
461
+ WHERE notes_fts MATCH ?
462
+ ORDER BY rank
463
+ LIMIT ?`
464
+ ).all(sanitized, limit);
465
+ return rows;
466
+ }
467
+ };
468
+ function sanitizeFtsQuery(query) {
469
+ return query.split(/\s+/).filter(Boolean).map((term) => `"${term.replace(/"/g, '""')}"`).join(" ");
470
+ }
471
+ function rowToNoteRecord(row) {
472
+ return {
473
+ id: row.id,
474
+ filePath: row.file_path,
475
+ title: row.title,
476
+ type: row.type,
477
+ tier: row.tier,
478
+ category: row.category,
479
+ tags: row.tags,
480
+ summary: row.summary,
481
+ confidence: row.confidence,
482
+ status: row.status ?? "current",
483
+ sources: row.sources,
484
+ createdAt: row.created_at,
485
+ modifiedAt: row.modified_at,
486
+ lastReviewed: row.last_reviewed,
487
+ reviewInterval: row.review_interval,
488
+ expires: row.expires,
489
+ metadata: row.metadata
490
+ };
491
+ }
492
+ function rowToFileRecord(row) {
493
+ return {
494
+ path: row.path,
495
+ hash: row.hash,
496
+ mtime: row.mtime,
497
+ indexedAt: row.indexed_at
498
+ };
499
+ }
500
+ function rowToChunk(row) {
501
+ return {
502
+ id: row.id,
503
+ noteId: row.note_id,
504
+ heading: row.heading,
505
+ content: row.content,
506
+ tokenCount: row.token_count,
507
+ chunkType: row.chunk_type
508
+ };
509
+ }
510
+ function rowToRelation(row) {
511
+ return {
512
+ sourceId: row.source_id,
513
+ targetId: row.target_id,
514
+ type: row.type
515
+ };
516
+ }
517
+
518
+ // src/adapters/local-embedder.ts
519
+ import {
520
+ pipeline
521
+ } from "@huggingface/transformers";
522
+ var LocalEmbedder = class {
523
+ model = "bge-small-en-v1.5";
524
+ dimensions = 384;
525
+ extractor = null;
526
+ async embed(texts) {
527
+ if (!this.extractor) {
528
+ this.extractor = await pipeline(
529
+ "feature-extraction",
530
+ "Xenova/bge-small-en-v1.5",
531
+ { dtype: "q8" }
532
+ );
533
+ }
534
+ const output = await this.extractor(texts, {
535
+ pooling: "mean",
536
+ normalize: true
537
+ });
538
+ return output.tolist();
539
+ }
540
+ };
541
+
542
+ // src/adapters/ollama-embedder.ts
543
+ import { Ollama } from "ollama";
544
+ var OllamaEmbedder = class {
545
+ model = "nomic-embed-text";
546
+ dimensions = 768;
547
+ client;
548
+ constructor(url) {
549
+ this.client = url ? new Ollama({ host: url }) : new Ollama();
550
+ }
551
+ async embed(texts) {
552
+ const prefixed = texts.map((t) => `search_document: ${t}`);
553
+ try {
554
+ const response = await this.client.embed({
555
+ model: this.model,
556
+ input: prefixed
557
+ });
558
+ return response.embeddings;
559
+ } catch (error) {
560
+ const message = error instanceof Error ? error.message : String(error);
561
+ throw new Error(
562
+ `Ollama embedding failed: ${message}. Is Ollama running?`
563
+ );
564
+ }
565
+ }
566
+ };
567
+
568
+ // src/adapters/remote-embedder.ts
569
+ var RemoteEmbedder = class {
570
+ constructor(url) {
571
+ this.url = url;
572
+ }
573
+ model = "nomic-embed-text";
574
+ dimensions = 768;
575
+ async embed(texts) {
576
+ const prefixed = texts.map((t) => `search_document: ${t}`);
577
+ const baseUrl = this.url.replace(/\/+$/, "");
578
+ let response;
579
+ try {
580
+ response = await fetch(`${baseUrl}/api/embed`, {
581
+ method: "POST",
582
+ headers: { "Content-Type": "application/json" },
583
+ body: JSON.stringify({ model: this.model, input: prefixed }),
584
+ signal: AbortSignal.timeout(3e4)
585
+ });
586
+ } catch (err) {
587
+ if (err instanceof DOMException && err.name === "TimeoutError") {
588
+ throw new Error(`Remote embedding timed out after 30s (${baseUrl})`);
589
+ }
590
+ throw err;
591
+ }
592
+ if (!response.ok) {
593
+ throw new Error(
594
+ `Remote embedding failed: ${response.status} ${response.statusText}`
595
+ );
596
+ }
597
+ const data = await response.json();
598
+ return data.embeddings;
599
+ }
600
+ };
601
+
602
+ // src/adapters/index.ts
603
+ function getEmbedderInfo(backend) {
604
+ switch (backend) {
605
+ case "local":
606
+ return { model: "bge-small-en-v1.5", dimensions: 384 };
607
+ case "ollama":
608
+ return { model: "nomic-embed-text", dimensions: 768 };
609
+ case "remote":
610
+ return { model: "nomic-embed-text", dimensions: 768 };
611
+ default:
612
+ throw new Error(`Unknown embedder backend: ${backend}`);
613
+ }
614
+ }
615
+ function createEmbedder(config) {
616
+ switch (config.embedder) {
617
+ case "local":
618
+ return new LocalEmbedder();
619
+ case "ollama":
620
+ return new OllamaEmbedder(config.ollamaUrl);
621
+ case "remote":
622
+ if (!config.ollamaUrl) {
623
+ throw new Error(
624
+ "ollamaUrl is required when using the remote embedder"
625
+ );
626
+ }
627
+ return new RemoteEmbedder(config.ollamaUrl);
628
+ default:
629
+ throw new Error(`Unknown embedder backend: ${config.embedder}`);
630
+ }
631
+ }
632
+
633
+ // src/commands/init.ts
634
+ var SUBDIRS = [
635
+ "notes",
636
+ "decisions",
637
+ "research",
638
+ "patterns",
639
+ "logs",
640
+ "inbox",
641
+ "archive",
642
+ "_templates"
643
+ ];
644
+ var TEMPLATES = {
645
+ "note.md": `---
646
+ title: ""
647
+ type: note
648
+ tier: slow
649
+ category: ""
650
+ tags: []
651
+ summary: ""
652
+ confidence: medium
653
+ status: draft
654
+ created: "{{date}}"
655
+ ---
656
+
657
+ # {{title}}
658
+
659
+ `,
660
+ "decision.md": `---
661
+ title: ""
662
+ type: decision
663
+ tier: slow
664
+ category: ""
665
+ tags: []
666
+ summary: ""
667
+ confidence: high
668
+ status: current
669
+ created: "{{date}}"
670
+ ---
671
+
672
+ # {{title}}
673
+
674
+ ## Context
675
+
676
+ ## Decision
677
+
678
+ ## Consequences
679
+
680
+ `,
681
+ "session-log.md": `---
682
+ title: ""
683
+ type: session-log
684
+ tier: fast
685
+ category: ""
686
+ tags: []
687
+ summary: ""
688
+ date: "{{date}}"
689
+ ---
690
+
691
+ # {{title}}
692
+
693
+ ## What I Did
694
+
695
+ ## What I Learned
696
+
697
+ ## Next Steps
698
+
699
+ `
700
+ };
701
+ async function checkOllama(url, model) {
702
+ try {
703
+ const response = await fetch(`${url}/api/tags`, {
704
+ signal: AbortSignal.timeout(3e3)
705
+ });
706
+ if (!response.ok) return { running: false, hasModel: false };
707
+ const data = await response.json();
708
+ const hasModel = data.models?.some((m) => m.name.startsWith(model)) ?? false;
709
+ return { running: true, hasModel };
710
+ } catch {
711
+ return { running: false, hasModel: false };
712
+ }
713
+ }
714
+ function pullOllamaModel(model) {
715
+ try {
716
+ process.stderr.write(`Pulling ${model}...
717
+ `);
718
+ execSync(`ollama pull ${model}`, { stdio: "inherit", timeout: 12e4 });
719
+ return true;
720
+ } catch {
721
+ process.stderr.write(`Warning: could not pull ${model}. Run "ollama pull ${model}" manually.
722
+ `);
723
+ return false;
724
+ }
725
+ }
726
+ async function isLocalEmbedderAvailable() {
727
+ try {
728
+ await import("@huggingface/transformers");
729
+ return true;
730
+ } catch {
731
+ return false;
732
+ }
733
+ }
734
+ async function promptEmbedderChoice() {
735
+ if (!process.stdin.isTTY) {
736
+ process.stderr.write(
737
+ "No embedding backend detected and stdin is not interactive.\nInstall Ollama (https://ollama.com) or run with --embedder local.\n"
738
+ );
739
+ return null;
740
+ }
741
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
742
+ const ask = (q) => new Promise((resolve2) => rl.question(q, resolve2));
743
+ process.stderr.write("\nNo embedding backend detected.\n");
744
+ process.stderr.write(" 1. Install Ollama (opens ollama.com)\n");
745
+ process.stderr.write(" 2. Use local embeddings (installs @huggingface/transformers)\n");
746
+ process.stderr.write(" 3. Exit\n");
747
+ const choice = await ask("\nChoice [1/2/3]: ");
748
+ rl.close();
749
+ switch (choice.trim()) {
750
+ case "1": {
751
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
752
+ try {
753
+ execSync(`${openCmd} https://ollama.com`, { stdio: "ignore" });
754
+ } catch {
755
+ }
756
+ process.stderr.write('After installing Ollama, run "brain init" again.\n');
757
+ return null;
758
+ }
759
+ case "2": {
760
+ process.stderr.write("Installing @huggingface/transformers...\n");
761
+ try {
762
+ execSync("npm install -g @huggingface/transformers", {
763
+ stdio: "inherit",
764
+ timeout: 12e4
765
+ });
766
+ return "local";
767
+ } catch {
768
+ process.stderr.write(
769
+ 'Failed to install. Run "npm install -g @huggingface/transformers" manually.\n'
770
+ );
771
+ return null;
772
+ }
773
+ }
774
+ default:
775
+ return null;
776
+ }
777
+ }
778
+ var initCommand = new Command("init").description("Initialize a new brain workspace").option("--notes-dir <path>", "path to notes directory").option("--embedder <type>", "embedding backend (local, ollama, remote)").option("--json", "output result as JSON").action(async (opts) => {
779
+ const overrides = {};
780
+ if (opts.notesDir) overrides.notesDir = opts.notesDir;
781
+ if (opts.embedder) overrides.embedder = opts.embedder;
782
+ if (!opts.embedder) {
783
+ const ollamaUrl = "http://localhost:11434";
784
+ const ollamaModel = "nomic-embed-text";
785
+ const status = await checkOllama(ollamaUrl, ollamaModel);
786
+ if (status.running) {
787
+ if (!status.hasModel) {
788
+ pullOllamaModel(ollamaModel);
789
+ }
790
+ overrides.embedder = "ollama";
791
+ overrides.ollamaUrl = ollamaUrl;
792
+ } else if (await isLocalEmbedderAvailable()) {
793
+ overrides.embedder = "local";
794
+ } else {
795
+ const choice = await promptEmbedderChoice();
796
+ if (!choice) {
797
+ process.exitCode = 1;
798
+ return;
799
+ }
800
+ overrides.embedder = choice;
801
+ if (choice === "ollama") {
802
+ overrides.ollamaUrl = "http://localhost:11434";
803
+ }
804
+ }
805
+ }
806
+ saveConfig(overrides);
807
+ const config = loadConfig();
808
+ const created = [];
809
+ for (const sub of SUBDIRS) {
810
+ const dir = join2(config.notesDir, sub);
811
+ if (!existsSync2(dir)) {
812
+ mkdirSync2(dir, { recursive: true });
813
+ created.push(sub);
814
+ }
815
+ }
816
+ const templatesDir = join2(config.notesDir, "_templates");
817
+ for (const [filename, content] of Object.entries(TEMPLATES)) {
818
+ const filePath = join2(templatesDir, filename);
819
+ if (!existsSync2(filePath)) {
820
+ writeFileSync2(filePath, content, "utf-8");
821
+ }
822
+ }
823
+ const db = new BrainDB(config.dbPath);
824
+ const info = getEmbedderInfo(config.embedder);
825
+ db.setEmbeddingModel(info.model, info.dimensions);
826
+ db.close();
827
+ const summary = {
828
+ notesDir: config.notesDir,
829
+ dbPath: config.dbPath,
830
+ embedder: config.embedder,
831
+ dirsCreated: created
832
+ };
833
+ if (opts.json) {
834
+ process.stdout.write(JSON.stringify(summary) + "\n");
835
+ } else {
836
+ process.stderr.write(`Initialized brain at ${config.notesDir}
837
+ `);
838
+ process.stderr.write(`Database: ${config.dbPath}
839
+ `);
840
+ process.stderr.write(`Embedder: ${config.embedder}
841
+ `);
842
+ if (created.length > 0) {
843
+ process.stderr.write(`Created directories: ${created.join(", ")}
844
+ `);
845
+ }
846
+ }
847
+ });
848
+
849
+ // src/commands/index-cmd.ts
850
+ import { Command as Command2 } from "@commander-js/extra-typings";
851
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
852
+ import { basename, join as join3, relative } from "path";
853
+
854
+ // src/services/file-scanner.ts
855
+ import { createHash } from "crypto";
856
+ import { readFile, stat } from "fs/promises";
857
+ import { glob } from "glob";
858
+ function hashContent(content) {
859
+ return createHash("sha256").update(content).digest("hex");
860
+ }
861
+ async function scanForChanges(rootDir, knownFiles) {
862
+ const result = {
863
+ new: [],
864
+ modified: [],
865
+ deleted: [],
866
+ unchanged: 0
867
+ };
868
+ const files = await glob("**/*.md", {
869
+ cwd: rootDir,
870
+ absolute: true,
871
+ ignore: ["**/node_modules/**", "**/.git/**"]
872
+ });
873
+ const seen = /* @__PURE__ */ new Set();
874
+ for (const filePath of files) {
875
+ seen.add(filePath);
876
+ const fileStat = await stat(filePath);
877
+ const mtime = fileStat.mtimeMs;
878
+ const known = knownFiles.get(filePath);
879
+ if (!known) {
880
+ const content2 = await readFile(filePath, "utf-8");
881
+ const hash2 = hashContent(content2);
882
+ result.new.push({ path: filePath, hash: hash2, mtime });
883
+ continue;
884
+ }
885
+ if (mtime === known.mtime) {
886
+ result.unchanged++;
887
+ continue;
888
+ }
889
+ const content = await readFile(filePath, "utf-8");
890
+ const hash = hashContent(content);
891
+ if (hash === known.hash) {
892
+ result.unchanged++;
893
+ } else {
894
+ result.modified.push({ path: filePath, hash, mtime });
895
+ }
896
+ }
897
+ for (const [path] of knownFiles) {
898
+ if (!seen.has(path)) {
899
+ result.deleted.push(path);
900
+ }
901
+ }
902
+ return result;
903
+ }
904
+
905
+ // src/services/markdown-parser.ts
906
+ import matter from "gray-matter";
907
+ var MAX_CHUNK_TOKENS = 512;
908
+ var FENCE_OPEN = /^```/;
909
+ var FENCE_CLOSE = /^```\s*$/;
910
+ var MIN_CHUNK_LENGTH = 20;
911
+ function estimateTokens(text) {
912
+ if (text.length === 0) return 0;
913
+ return Math.ceil(text.length / 4);
914
+ }
915
+ function parseMarkdown(filePath, content) {
916
+ const { data, content: body } = matter(content);
917
+ const id = deriveId(filePath, data);
918
+ const frontmatter = buildFrontmatter(filePath, data);
919
+ const chunks = chunkBody(body);
920
+ const relations = extractRelations(id, data);
921
+ return { id, filePath, frontmatter, content: body, chunks, relations };
922
+ }
923
+ function deriveId(filePath, data) {
924
+ if (typeof data.id === "string" && data.id.length > 0) return data.id;
925
+ const filename = filePath.split("/").pop() ?? filePath;
926
+ return filename.replace(/\.md$/, "");
927
+ }
928
+ function buildFrontmatter(filePath, data) {
929
+ const filename = (filePath.split("/").pop() ?? filePath).replace(/\.md$/, "");
930
+ return {
931
+ ...data,
932
+ title: typeof data.title === "string" ? data.title : filename,
933
+ type: data.type ?? "note",
934
+ tier: data.tier ?? "slow"
935
+ };
936
+ }
937
+ function splitIntoSections(body) {
938
+ const lines2 = body.split("\n");
939
+ const sections = [];
940
+ let current = { heading: null, lines: [] };
941
+ for (const line of lines2) {
942
+ const match = line.match(/^(#{1,3})\s+(.+)$/);
943
+ if (match) {
944
+ sections.push(current);
945
+ current = { heading: match[2], lines: [] };
946
+ } else {
947
+ current.lines.push(line);
948
+ }
949
+ }
950
+ sections.push(current);
951
+ return sections;
952
+ }
953
+ function chunkBody(body) {
954
+ const sections = splitIntoSections(body);
955
+ const chunks = [];
956
+ for (const section of sections) {
957
+ const text = section.lines.join("\n").trim();
958
+ if (text.length < MIN_CHUNK_LENGTH) continue;
959
+ const tokens = estimateTokens(text);
960
+ if (tokens <= MAX_CHUNK_TOKENS) {
961
+ chunks.push({ heading: section.heading, text, tokenCount: tokens });
962
+ } else {
963
+ const subChunks = splitOversizedSection(section.heading, text);
964
+ chunks.push(...subChunks);
965
+ }
966
+ }
967
+ return chunks;
968
+ }
969
+ function splitOversizedSection(heading, text) {
970
+ const paragraphs = splitParagraphsProtectingFences(text);
971
+ const chunks = [];
972
+ let buffer = "";
973
+ let overlapPrefix = "";
974
+ for (const para of paragraphs) {
975
+ const budgetForContent = overlapPrefix.length > 0 ? MAX_CHUNK_TOKENS - estimateTokens(overlapPrefix + "\n\n") : MAX_CHUNK_TOKENS;
976
+ const bufferWithPara = buffer.length > 0 ? buffer + "\n\n" + para : para;
977
+ if (estimateTokens(bufferWithPara) > budgetForContent && buffer.length > 0) {
978
+ const chunkText = overlapPrefix.length > 0 ? overlapPrefix + "\n\n" + buffer : buffer;
979
+ const tokenCount = estimateTokens(chunkText);
980
+ chunks.push({ heading, text: chunkText.trim(), tokenCount });
981
+ overlapPrefix = extractOverlap(buffer);
982
+ buffer = para;
983
+ } else {
984
+ buffer = buffer.length > 0 ? buffer + "\n\n" + para : para;
985
+ }
986
+ }
987
+ if (buffer.length > 0) {
988
+ const chunkText = overlapPrefix.length > 0 ? overlapPrefix + "\n\n" + buffer : buffer;
989
+ const tokenCount = estimateTokens(chunkText);
990
+ chunks.push({ heading, text: chunkText.trim(), tokenCount });
991
+ }
992
+ return chunks;
993
+ }
994
+ function extractOverlap(text) {
995
+ const targetTokens = Math.ceil(estimateTokens(text) * 0.1);
996
+ const targetChars = targetTokens * 4;
997
+ if (text.length <= targetChars) return text;
998
+ return text.slice(-targetChars);
999
+ }
1000
+ function splitParagraphsProtectingFences(text) {
1001
+ const lines2 = text.split("\n");
1002
+ const paragraphs = [];
1003
+ let current = [];
1004
+ let inFence = false;
1005
+ for (let i = 0; i < lines2.length; i++) {
1006
+ const line = lines2[i];
1007
+ if (!inFence && FENCE_OPEN.test(line)) {
1008
+ if (current.length > 0) {
1009
+ const joined2 = current.join("\n").trim();
1010
+ if (joined2.length > 0) paragraphs.push(joined2);
1011
+ current = [];
1012
+ }
1013
+ inFence = true;
1014
+ current.push(line);
1015
+ continue;
1016
+ }
1017
+ if (inFence) {
1018
+ current.push(line);
1019
+ if (FENCE_CLOSE.test(line) && current.length > 1) {
1020
+ const joined2 = current.join("\n").trim();
1021
+ if (joined2.length > 0) paragraphs.push(joined2);
1022
+ current = [];
1023
+ inFence = false;
1024
+ }
1025
+ continue;
1026
+ }
1027
+ if (line.trim() === "") {
1028
+ if (current.length > 0) {
1029
+ const joined2 = current.join("\n").trim();
1030
+ if (joined2.length > 0) paragraphs.push(joined2);
1031
+ current = [];
1032
+ }
1033
+ } else {
1034
+ current.push(line);
1035
+ }
1036
+ }
1037
+ const joined = current.join("\n").trim();
1038
+ if (joined.length > 0) paragraphs.push(joined);
1039
+ return paragraphs;
1040
+ }
1041
+ function extractRelations(sourceId, data) {
1042
+ const relations = [];
1043
+ if (Array.isArray(data.related)) {
1044
+ for (const target of data.related) {
1045
+ if (typeof target === "string") {
1046
+ relations.push({
1047
+ sourceId,
1048
+ targetId: target,
1049
+ type: "related-to"
1050
+ });
1051
+ }
1052
+ }
1053
+ }
1054
+ if (typeof data.supersedes === "string") {
1055
+ relations.push({
1056
+ sourceId,
1057
+ targetId: data.supersedes,
1058
+ type: "supersedes"
1059
+ });
1060
+ }
1061
+ if (typeof data.parent === "string") {
1062
+ relations.push({
1063
+ sourceId,
1064
+ targetId: data.parent,
1065
+ type: "parent"
1066
+ });
1067
+ }
1068
+ return relations;
1069
+ }
1070
+
1071
+ // src/commands/index-cmd.ts
1072
+ var indexCommand = new Command2("index").description("Index new and modified notes").option("--force", "force full re-index (clears all chunks/vectors)").option("--quiet", "suppress output").option("--json", "output result as JSON").action(async (opts) => {
1073
+ const config = loadConfig();
1074
+ const db = new BrainDB(config.dbPath);
1075
+ const embedder = createEmbedder(config);
1076
+ try {
1077
+ if (!opts.force) {
1078
+ db.checkModelMatch(embedder.model);
1079
+ }
1080
+ if (opts.force) {
1081
+ const allNotes = db.getAllNotes();
1082
+ for (const note of allNotes) {
1083
+ db.deleteChunksForNote(note.id);
1084
+ }
1085
+ }
1086
+ db.setEmbeddingModel(embedder.model, embedder.dimensions);
1087
+ const knownFiles = opts.force ? /* @__PURE__ */ new Map() : db.getAllFiles();
1088
+ const changes = await scanForChanges(config.notesDir, knownFiles);
1089
+ const isSkipped = (filePath) => filePath.includes("/_templates/") || basename(filePath) === "_index.md";
1090
+ let indexed = 0;
1091
+ let deleted = 0;
1092
+ const toProcess = [...changes.new, ...changes.modified].filter(
1093
+ (f) => !isSkipped(f.path)
1094
+ );
1095
+ for (const file of toProcess) {
1096
+ const content = readFileSync2(file.path, "utf-8");
1097
+ const parsed = parseMarkdown(file.path, content);
1098
+ const noteRecord = frontmatterToRecord(parsed);
1099
+ db.upsertNote(noteRecord);
1100
+ db.upsertNoteFTS(
1101
+ parsed.id,
1102
+ parsed.frontmatter.title,
1103
+ parsed.frontmatter.summary ?? "",
1104
+ parsed.content
1105
+ );
1106
+ const chunks = rawChunksToChunks(parsed.id, parsed.chunks);
1107
+ if (chunks.length > 0) {
1108
+ const texts = chunks.map((c) => c.content);
1109
+ const embeddings = await embedder.embed(texts);
1110
+ const vectors = embeddings.map((e) => new Float32Array(e));
1111
+ db.upsertChunks(parsed.id, chunks, vectors);
1112
+ }
1113
+ if (parsed.relations.length > 0) {
1114
+ db.upsertRelations(parsed.id, parsed.relations);
1115
+ }
1116
+ db.upsertFile({
1117
+ path: file.path,
1118
+ hash: file.hash,
1119
+ mtime: file.mtime,
1120
+ indexedAt: Date.now()
1121
+ });
1122
+ indexed++;
1123
+ }
1124
+ for (const filePath of changes.deleted.filter((p) => !isSkipped(p))) {
1125
+ const allNotes = db.getAllNotes();
1126
+ const note = allNotes.find((n) => n.filePath === filePath);
1127
+ if (note) {
1128
+ db.deleteNote(note.id);
1129
+ }
1130
+ db.deleteFile(filePath);
1131
+ deleted++;
1132
+ }
1133
+ generateIndex(db, config.notesDir);
1134
+ const summary = {
1135
+ indexed,
1136
+ deleted,
1137
+ unchanged: changes.unchanged,
1138
+ total: db.getAllNotes().length
1139
+ };
1140
+ if (opts.json) {
1141
+ process.stdout.write(JSON.stringify(summary) + "\n");
1142
+ } else if (!opts.quiet) {
1143
+ process.stderr.write(
1144
+ `Indexed ${indexed} file(s), deleted ${deleted}, unchanged ${changes.unchanged}
1145
+ `
1146
+ );
1147
+ process.stderr.write(`Total notes: ${summary.total}
1148
+ `);
1149
+ }
1150
+ } finally {
1151
+ db.close();
1152
+ }
1153
+ });
1154
+ function frontmatterToRecord(parsed) {
1155
+ const fm = parsed.frontmatter;
1156
+ return {
1157
+ id: parsed.id,
1158
+ filePath: parsed.filePath,
1159
+ title: fm.title,
1160
+ type: fm.type,
1161
+ tier: fm.tier,
1162
+ category: fm.category ?? null,
1163
+ tags: fm.tags ? fm.tags.join(",") : null,
1164
+ summary: fm.summary ?? null,
1165
+ confidence: fm.confidence ?? null,
1166
+ status: fm.status ?? "current",
1167
+ sources: fm.sources ? JSON.stringify(fm.sources) : null,
1168
+ createdAt: fm.created ?? null,
1169
+ modifiedAt: fm.modified ?? null,
1170
+ lastReviewed: fm["last-reviewed"] ?? null,
1171
+ reviewInterval: fm["review-interval"] ?? null,
1172
+ expires: fm.expires ?? null,
1173
+ metadata: null
1174
+ };
1175
+ }
1176
+ function rawChunksToChunks(noteId, rawChunks) {
1177
+ return rawChunks.map((rc, i) => ({
1178
+ id: `${noteId}::chunk-${i}`,
1179
+ noteId,
1180
+ heading: rc.heading,
1181
+ content: rc.text,
1182
+ tokenCount: rc.tokenCount,
1183
+ chunkType: "section"
1184
+ }));
1185
+ }
1186
+ function generateIndex(db, notesDir) {
1187
+ const notes = db.getAllNotes();
1188
+ if (notes.length === 0) return;
1189
+ const byCategory = /* @__PURE__ */ new Map();
1190
+ for (const note of notes) {
1191
+ const cat = note.category ?? "uncategorized";
1192
+ const list = byCategory.get(cat) ?? [];
1193
+ list.push(note);
1194
+ byCategory.set(cat, list);
1195
+ }
1196
+ const lines2 = ["# Index", ""];
1197
+ const sortedCategories = [...byCategory.keys()].sort();
1198
+ for (const cat of sortedCategories) {
1199
+ const catNotes = byCategory.get(cat);
1200
+ lines2.push(`## ${cat}`, "");
1201
+ for (const note of catNotes) {
1202
+ const relPath = relative(notesDir, note.filePath);
1203
+ const summary = note.summary ? ` \u2014 ${note.summary}` : "";
1204
+ lines2.push(`- [${note.title}](${relPath})${summary}`);
1205
+ }
1206
+ lines2.push("");
1207
+ }
1208
+ writeFileSync3(join3(notesDir, "_index.md"), lines2.join("\n"), "utf-8");
1209
+ }
1210
+
1211
+ // src/commands/search.ts
1212
+ import { Command as Command3 } from "@commander-js/extra-typings";
1213
+
1214
+ // src/services/search.ts
1215
+ var RRF_K = 60;
1216
+ var EXCERPT_MAX_LENGTH = 200;
1217
+ var OVERFETCH_MULTIPLIER = 3;
1218
+ function truncateExcerpt(content) {
1219
+ if (content.length <= EXCERPT_MAX_LENGTH) return content;
1220
+ return content.slice(0, EXCERPT_MAX_LENGTH);
1221
+ }
1222
+ function parseTags(tagsStr) {
1223
+ if (!tagsStr) return [];
1224
+ return tagsStr.split(",").map((t) => t.trim()).filter(Boolean);
1225
+ }
1226
+ async function search(db, embedder, query, options, fusionWeights = { bm25: 0.3, vector: 0.7 }) {
1227
+ if (!query.trim()) return [];
1228
+ const limit = options.limit;
1229
+ const overfetchLimit = limit * OVERFETCH_MULTIPLIER;
1230
+ const allowedNoteIds = db.getFilteredNoteIds({
1231
+ tier: options.tier,
1232
+ category: options.category,
1233
+ confidence: options.confidence,
1234
+ since: options.since,
1235
+ tags: options.tags
1236
+ });
1237
+ const ftsResults = db.searchFTS(query, overfetchLimit);
1238
+ const filteredFts = allowedNoteIds ? ftsResults.filter((r) => allowedNoteIds.has(r.noteId)) : ftsResults;
1239
+ const queryText = embedder.model.includes("nomic") ? `search_query: ${query}` : query;
1240
+ const [queryEmbedding] = await embedder.embed([queryText]);
1241
+ const queryVec = new Float32Array(queryEmbedding);
1242
+ const vectorResults = db.searchVector(queryVec, overfetchLimit);
1243
+ const filteredVector = allowedNoteIds ? vectorResults.filter((r) => allowedNoteIds.has(r.noteId)) : vectorResults;
1244
+ const bestVectorByNote = /* @__PURE__ */ new Map();
1245
+ for (const vr of filteredVector) {
1246
+ const existing = bestVectorByNote.get(vr.noteId);
1247
+ if (!existing || vr.distance < existing.distance) {
1248
+ bestVectorByNote.set(vr.noteId, vr);
1249
+ }
1250
+ }
1251
+ const fusionMap = /* @__PURE__ */ new Map();
1252
+ for (let i = 0; i < filteredFts.length; i++) {
1253
+ const { noteId } = filteredFts[i];
1254
+ fusionMap.set(noteId, {
1255
+ noteId,
1256
+ bm25Rank: i + 1,
1257
+ vectorRank: null,
1258
+ chunkId: null
1259
+ });
1260
+ }
1261
+ let vectorRank = 1;
1262
+ for (const [noteId, vr] of bestVectorByNote) {
1263
+ const existing = fusionMap.get(noteId);
1264
+ if (existing) {
1265
+ existing.vectorRank = vectorRank;
1266
+ existing.chunkId = vr.chunkId;
1267
+ } else {
1268
+ fusionMap.set(noteId, {
1269
+ noteId,
1270
+ bm25Rank: null,
1271
+ vectorRank,
1272
+ chunkId: vr.chunkId
1273
+ });
1274
+ }
1275
+ vectorRank++;
1276
+ }
1277
+ const scored = [];
1278
+ for (const entry of fusionMap.values()) {
1279
+ let score = 0;
1280
+ if (entry.bm25Rank !== null) {
1281
+ score += fusionWeights.bm25 * (1 / (RRF_K + entry.bm25Rank));
1282
+ }
1283
+ if (entry.vectorRank !== null) {
1284
+ score += fusionWeights.vector * (1 / (RRF_K + entry.vectorRank));
1285
+ }
1286
+ scored.push({ noteId: entry.noteId, score, chunkId: entry.chunkId });
1287
+ }
1288
+ scored.sort((a, b) => b.score - a.score);
1289
+ const topResults = scored.slice(0, limit);
1290
+ const results = [];
1291
+ for (const item of topResults) {
1292
+ const note = db.getNoteById(item.noteId);
1293
+ if (!note) continue;
1294
+ const excerptContent = item.chunkId ? db.getChunkContent(item.chunkId) : db.getFirstChunkForNote(item.noteId)?.content ?? "";
1295
+ results.push({
1296
+ score: item.score,
1297
+ filePath: note.filePath,
1298
+ noteId: note.id,
1299
+ heading: db.getChunkHeading(item.chunkId, item.noteId),
1300
+ excerpt: truncateExcerpt(excerptContent),
1301
+ tier: note.tier,
1302
+ tags: parseTags(note.tags),
1303
+ confidence: note.confidence
1304
+ });
1305
+ }
1306
+ return results;
1307
+ }
1308
+
1309
+ // src/services/graph.ts
1310
+ var MAX_DEPTH = 3;
1311
+ var BIDIRECTIONAL_TYPES = /* @__PURE__ */ new Set(["related-to"]);
1312
+ function traverseGraph(db, rootId, depth) {
1313
+ const effectiveDepth = Math.min(depth, MAX_DEPTH);
1314
+ const rootNote = db.getNoteById(rootId);
1315
+ const root = {
1316
+ id: rootId,
1317
+ title: rootNote?.title ?? rootId,
1318
+ type: rootNote?.type ?? "note",
1319
+ tier: rootNote?.tier ?? "slow",
1320
+ depth: 0
1321
+ };
1322
+ const visited = /* @__PURE__ */ new Map();
1323
+ visited.set(rootId, 0);
1324
+ const allEdges = [];
1325
+ const frontier = [rootId];
1326
+ for (let d = 0; d < effectiveDepth; d++) {
1327
+ const nextFrontier = [];
1328
+ for (const nodeId of frontier) {
1329
+ const outgoing = db.getRelationsFrom(nodeId);
1330
+ for (const rel of outgoing) {
1331
+ allEdges.push(rel);
1332
+ if (!visited.has(rel.targetId)) {
1333
+ visited.set(rel.targetId, d + 1);
1334
+ nextFrontier.push(rel.targetId);
1335
+ }
1336
+ }
1337
+ const incoming = db.getRelationsTo(nodeId);
1338
+ for (const rel of incoming) {
1339
+ if (!BIDIRECTIONAL_TYPES.has(rel.type)) continue;
1340
+ allEdges.push(rel);
1341
+ if (!visited.has(rel.sourceId)) {
1342
+ visited.set(rel.sourceId, d + 1);
1343
+ nextFrontier.push(rel.sourceId);
1344
+ }
1345
+ }
1346
+ }
1347
+ frontier.length = 0;
1348
+ frontier.push(...nextFrontier);
1349
+ }
1350
+ const nodes = [];
1351
+ for (const [id, nodeDepth] of visited) {
1352
+ const note = id === rootId ? rootNote : db.getNoteById(id);
1353
+ nodes.push({
1354
+ id,
1355
+ title: note?.title ?? id,
1356
+ type: note?.type ?? "note",
1357
+ tier: note?.tier ?? "slow",
1358
+ depth: nodeDepth
1359
+ });
1360
+ }
1361
+ const nodeIds = new Set(visited.keys());
1362
+ const edges = deduplicateEdges(
1363
+ allEdges.filter((e) => nodeIds.has(e.sourceId) && nodeIds.has(e.targetId))
1364
+ );
1365
+ return { root, nodes, edges };
1366
+ }
1367
+ function expandResults(db, noteIds, depth) {
1368
+ if (noteIds.length === 0) return [];
1369
+ const inputSet = new Set(noteIds);
1370
+ const scoreMap = /* @__PURE__ */ new Map();
1371
+ for (const noteId of noteIds) {
1372
+ const result = traverseGraph(db, noteId, depth);
1373
+ for (const node of result.nodes) {
1374
+ if (inputSet.has(node.id)) continue;
1375
+ const decayed = Math.pow(0.5, node.depth);
1376
+ const existing = scoreMap.get(node.id) ?? 0;
1377
+ scoreMap.set(node.id, Math.max(existing, decayed));
1378
+ }
1379
+ }
1380
+ return Array.from(scoreMap.entries()).map(([noteId, decayedScore]) => ({ noteId, decayedScore })).sort((a, b) => b.decayedScore - a.decayedScore);
1381
+ }
1382
+ function deduplicateEdges(edges) {
1383
+ const seen = /* @__PURE__ */ new Set();
1384
+ const result = [];
1385
+ for (const edge of edges) {
1386
+ const key = `${edge.sourceId}|${edge.targetId}|${edge.type}`;
1387
+ if (!seen.has(key)) {
1388
+ seen.add(key);
1389
+ result.push(edge);
1390
+ }
1391
+ }
1392
+ return result;
1393
+ }
1394
+
1395
+ // src/commands/search.ts
1396
+ var searchCommand = new Command3("search").description("Search notes with hybrid BM25 + vector search").argument("<query>", "search query").option("--json", "output results as JSON").option("--limit <n>", "max results", "10").option("--tier <tier>", "filter by tier (slow, fast)").option("--tags <tags>", "filter by tags (comma-separated)").option("--category <cat>", "filter by category").option("--confidence <level>", "filter by confidence").option("--since <date>", "only notes modified after this date").option("--expand", "include graph-connected notes").action(async (query, opts) => {
1397
+ const config = loadConfig();
1398
+ const db = new BrainDB(config.dbPath);
1399
+ const embedder = createEmbedder(config);
1400
+ try {
1401
+ const searchOpts = {
1402
+ limit: parseInt(opts.limit, 10),
1403
+ tier: opts.tier,
1404
+ tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()) : void 0,
1405
+ category: opts.category,
1406
+ confidence: opts.confidence,
1407
+ since: opts.since
1408
+ };
1409
+ const results = await search(db, embedder, query, searchOpts, config.fusionWeights);
1410
+ const expanded = [];
1411
+ if (opts.expand && results.length > 0) {
1412
+ const noteIds = results.map((r) => r.noteId);
1413
+ const graphExpanded = expandResults(db, noteIds, 1);
1414
+ for (const item of graphExpanded) {
1415
+ const note = db.getNoteById(item.noteId);
1416
+ if (!note) continue;
1417
+ expanded.push({
1418
+ score: item.decayedScore,
1419
+ filePath: note.filePath,
1420
+ noteId: note.id,
1421
+ heading: null,
1422
+ excerpt: note.summary ?? "",
1423
+ tier: note.tier,
1424
+ tags: note.tags ? note.tags.split(",") : [],
1425
+ confidence: note.confidence
1426
+ });
1427
+ }
1428
+ }
1429
+ const allResults = [...results, ...expanded];
1430
+ if (opts.json) {
1431
+ process.stdout.write(JSON.stringify(allResults) + "\n");
1432
+ } else {
1433
+ if (allResults.length === 0) {
1434
+ process.stderr.write("No results found.\n");
1435
+ return;
1436
+ }
1437
+ for (const r of allResults) {
1438
+ const score = r.score.toFixed(3);
1439
+ process.stdout.write(`[${score}] ${r.filePath}
1440
+ `);
1441
+ if (r.heading) {
1442
+ process.stdout.write(` \xA7 ${r.heading}
1443
+ `);
1444
+ }
1445
+ if (r.excerpt) {
1446
+ process.stdout.write(` ${r.excerpt}
1447
+ `);
1448
+ }
1449
+ process.stdout.write("\n");
1450
+ }
1451
+ }
1452
+ } finally {
1453
+ db.close();
1454
+ }
1455
+ });
1456
+
1457
+ // src/commands/status.ts
1458
+ import { Command as Command4 } from "@commander-js/extra-typings";
1459
+
1460
+ // src/utils.ts
1461
+ function parseIntervalDays(interval) {
1462
+ const match = interval.match(/^(\d+)\s*(d|w|m)$/);
1463
+ if (!match) return 90;
1464
+ const value = parseInt(match[1], 10);
1465
+ switch (match[2]) {
1466
+ case "d":
1467
+ return value;
1468
+ case "w":
1469
+ return value * 7;
1470
+ case "m":
1471
+ return value * 30;
1472
+ default:
1473
+ return 90;
1474
+ }
1475
+ }
1476
+
1477
+ // src/commands/status.ts
1478
+ var statusCommand = new Command4("status").description("Show database statistics").option("--json", "output as JSON").action((opts) => {
1479
+ const config = loadConfig();
1480
+ const db = new BrainDB(config.dbPath);
1481
+ try {
1482
+ const notes = db.getAllNotes();
1483
+ const embeddingModel = db.getEmbeddingModel();
1484
+ const byTier = {};
1485
+ const byType = {};
1486
+ const staleNotes = [];
1487
+ const now = /* @__PURE__ */ new Date();
1488
+ for (const note of notes) {
1489
+ byTier[note.tier] = (byTier[note.tier] ?? 0) + 1;
1490
+ byType[note.type] = (byType[note.type] ?? 0) + 1;
1491
+ if (note.lastReviewed && note.reviewInterval) {
1492
+ const reviewed = new Date(note.lastReviewed);
1493
+ const intervalDays = parseIntervalDays(note.reviewInterval);
1494
+ const due = new Date(reviewed.getTime() + intervalDays * 864e5);
1495
+ if (due < now) {
1496
+ staleNotes.push(note.id);
1497
+ }
1498
+ }
1499
+ }
1500
+ const files = db.getAllFiles();
1501
+ let lastIndexed = null;
1502
+ for (const [, file] of files) {
1503
+ if (lastIndexed === null || file.indexedAt > lastIndexed) {
1504
+ lastIndexed = file.indexedAt;
1505
+ }
1506
+ }
1507
+ const totalChunks = db.getChunkCount();
1508
+ const summary = {
1509
+ totalNotes: notes.length,
1510
+ totalChunks,
1511
+ byTier,
1512
+ byType,
1513
+ embeddingModel: embeddingModel?.model ?? null,
1514
+ embeddingDimensions: embeddingModel?.dimensions ?? null,
1515
+ lastIndexed: lastIndexed ? new Date(lastIndexed).toISOString() : null,
1516
+ staleNotes: staleNotes.length,
1517
+ staleNoteIds: staleNotes
1518
+ };
1519
+ if (opts.json) {
1520
+ process.stdout.write(JSON.stringify(summary) + "\n");
1521
+ } else {
1522
+ process.stderr.write(`Notes: ${notes.length}
1523
+ `);
1524
+ process.stderr.write(`Chunks: ${totalChunks}
1525
+ `);
1526
+ process.stderr.write(`By tier: ${formatMap(byTier)}
1527
+ `);
1528
+ process.stderr.write(`By type: ${formatMap(byType)}
1529
+ `);
1530
+ if (embeddingModel) {
1531
+ process.stderr.write(
1532
+ `Embedding: ${embeddingModel.model} (${embeddingModel.dimensions}d)
1533
+ `
1534
+ );
1535
+ }
1536
+ if (lastIndexed) {
1537
+ process.stderr.write(
1538
+ `Last indexed: ${new Date(lastIndexed).toISOString()}
1539
+ `
1540
+ );
1541
+ }
1542
+ if (staleNotes.length > 0) {
1543
+ process.stderr.write(
1544
+ `Stale notes needing review: ${staleNotes.length}
1545
+ `
1546
+ );
1547
+ }
1548
+ }
1549
+ } finally {
1550
+ db.close();
1551
+ }
1552
+ });
1553
+ function formatMap(map) {
1554
+ return Object.entries(map).map(([k, v]) => `${k}=${v}`).join(", ");
1555
+ }
1556
+
1557
+ // src/commands/add.ts
1558
+ import { Command as Command5 } from "@commander-js/extra-typings";
1559
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync4, mkdirSync as mkdirSync3, existsSync as existsSync3 } from "fs";
1560
+ import { join as join4, dirname, resolve } from "path";
1561
+ function slugify(text) {
1562
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1563
+ }
1564
+ function buildFrontmatter2(opts) {
1565
+ const now = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1566
+ const title = opts.title ?? "Untitled";
1567
+ const type = opts.type ?? "note";
1568
+ const tier = opts.tier ?? "slow";
1569
+ const lines2 = [
1570
+ "---",
1571
+ `id: ${slugify(title)}`,
1572
+ `title: ${title}`,
1573
+ `type: ${type}`,
1574
+ `tier: ${tier}`
1575
+ ];
1576
+ if (opts.tags) {
1577
+ const tagList = opts.tags.split(",").map((t) => t.trim());
1578
+ lines2.push(`tags: [${tagList.join(", ")}]`);
1579
+ }
1580
+ lines2.push(`created: ${now}`);
1581
+ lines2.push(`modified: ${now}`);
1582
+ lines2.push("---");
1583
+ return lines2.join("\n");
1584
+ }
1585
+ function hasFrontmatter(content) {
1586
+ return content.trimStart().startsWith("---");
1587
+ }
1588
+ function resolveOutputPath(notesDir, tier, type, id) {
1589
+ if (tier === "fast") {
1590
+ const now = /* @__PURE__ */ new Date();
1591
+ const yyyy = String(now.getFullYear());
1592
+ const mm = String(now.getMonth() + 1).padStart(2, "0");
1593
+ const dd = String(now.getDate()).padStart(2, "0");
1594
+ return join4(notesDir, "logs", yyyy, mm, `${yyyy}-${mm}-${dd}-${id}.md`);
1595
+ }
1596
+ const TYPE_DIRS = {
1597
+ note: "notes",
1598
+ decision: "decisions",
1599
+ research: "research",
1600
+ pattern: "patterns",
1601
+ meeting: "logs",
1602
+ "session-log": "logs",
1603
+ guide: "notes"
1604
+ };
1605
+ const typeDir = TYPE_DIRS[type];
1606
+ return join4(notesDir, typeDir, `${id}.md`);
1607
+ }
1608
+ var addCommand = new Command5("add").description("Create a new note from file or stdin").argument("[file]", "Input file path").option("--title <title>", "Note title").option("--type <type>", "Note type (note, decision, pattern, research, meeting, session-log, guide)").option("--tier <tier>", "Note tier (slow, fast)").option("--tags <tags>", "Comma-separated tags").action(async (file, opts) => {
1609
+ const VALID_TYPES2 = ["note", "decision", "pattern", "research", "meeting", "session-log", "guide"];
1610
+ const VALID_TIERS = ["slow", "fast"];
1611
+ if (opts.type && !VALID_TYPES2.includes(opts.type)) {
1612
+ console.error(`Error: invalid type "${opts.type}". Valid types: ${VALID_TYPES2.join(", ")}`);
1613
+ process.exitCode = 1;
1614
+ return;
1615
+ }
1616
+ if (opts.tier && !VALID_TIERS.includes(opts.tier)) {
1617
+ console.error(`Error: invalid tier "${opts.tier}". Valid tiers: ${VALID_TIERS.join(", ")}`);
1618
+ process.exitCode = 1;
1619
+ return;
1620
+ }
1621
+ let content;
1622
+ if (file) {
1623
+ content = readFileSync3(resolve(file), "utf-8");
1624
+ } else if (!process.stdin.isTTY) {
1625
+ content = readFileSync3(0, "utf-8");
1626
+ } else {
1627
+ console.error("Error: provide a file argument or pipe content to stdin");
1628
+ process.exitCode = 1;
1629
+ return;
1630
+ }
1631
+ if (!hasFrontmatter(content)) {
1632
+ const fm = buildFrontmatter2(opts);
1633
+ content = fm + "\n\n" + content;
1634
+ }
1635
+ const parsed = parseMarkdown("temp.md", content);
1636
+ const id = parsed.id;
1637
+ const tier = opts.tier ?? parsed.frontmatter.tier ?? "slow";
1638
+ const type = opts.type ?? parsed.frontmatter.type ?? "note";
1639
+ const config = loadConfig();
1640
+ const outPath = resolveOutputPath(config.notesDir, tier, type, id);
1641
+ const dir = dirname(outPath);
1642
+ if (!existsSync3(dir)) {
1643
+ mkdirSync3(dir, { recursive: true });
1644
+ }
1645
+ writeFileSync4(outPath, content, "utf-8");
1646
+ console.log(outPath);
1647
+ });
1648
+
1649
+ // src/commands/stale.ts
1650
+ import { Command as Command6 } from "@commander-js/extra-typings";
1651
+ function computeDaysOverdue(note, overrideDays) {
1652
+ const now = Date.now();
1653
+ const lastReviewed = note.lastReviewed ? new Date(note.lastReviewed).getTime() : null;
1654
+ if (!lastReviewed) return null;
1655
+ if (overrideDays !== void 0) {
1656
+ const daysSince = Math.floor((now - lastReviewed) / (1e3 * 60 * 60 * 24));
1657
+ return daysSince > overrideDays ? daysSince - overrideDays : null;
1658
+ }
1659
+ const interval = note.reviewInterval ? parseIntervalDays(note.reviewInterval) : 90;
1660
+ const dueDate = lastReviewed + interval * 24 * 60 * 60 * 1e3;
1661
+ if (now < dueDate) return null;
1662
+ return Math.floor((now - dueDate) / (1e3 * 60 * 60 * 24));
1663
+ }
1664
+ var staleCommand = new Command6("stale").description("Find notes needing review").option("--days <n>", "Show notes not reviewed in N days", parseInt).option("--tier <tier>", "Filter by tier (slow, fast)").option("--json", "Output as JSON").action((opts) => {
1665
+ const config = loadConfig();
1666
+ const db = new BrainDB(config.dbPath);
1667
+ try {
1668
+ let notes = db.getAllNotes();
1669
+ if (opts.tier) {
1670
+ notes = notes.filter((n) => n.tier === opts.tier);
1671
+ }
1672
+ const staleNotes = [];
1673
+ for (const note of notes) {
1674
+ const overdue = computeDaysOverdue(note, opts.days);
1675
+ if (overdue !== null && overdue >= 0) {
1676
+ staleNotes.push({
1677
+ noteId: note.id,
1678
+ lastReviewed: note.lastReviewed ?? "never",
1679
+ reviewInterval: note.reviewInterval ?? "90d",
1680
+ daysOverdue: overdue
1681
+ });
1682
+ }
1683
+ }
1684
+ staleNotes.sort((a, b) => b.daysOverdue - a.daysOverdue);
1685
+ if (opts.json) {
1686
+ console.log(JSON.stringify(staleNotes, null, 2));
1687
+ } else if (staleNotes.length === 0) {
1688
+ console.log("No stale notes found.");
1689
+ } else {
1690
+ for (const s of staleNotes) {
1691
+ console.log(
1692
+ `${s.noteId} \u2014 last reviewed ${s.lastReviewed}, interval ${s.reviewInterval}, ${s.daysOverdue} days overdue`
1693
+ );
1694
+ }
1695
+ }
1696
+ } finally {
1697
+ db.close();
1698
+ }
1699
+ });
1700
+
1701
+ // src/commands/graph.ts
1702
+ import { Command as Command7 } from "@commander-js/extra-typings";
1703
+ function buildTree(result) {
1704
+ const edgesBySource = /* @__PURE__ */ new Map();
1705
+ for (const edge of result.edges) {
1706
+ const list = edgesBySource.get(edge.sourceId) ?? [];
1707
+ list.push(edge);
1708
+ edgesBySource.set(edge.sourceId, list);
1709
+ }
1710
+ const nodeMap = new Map(result.nodes.map((n) => [n.id, n]));
1711
+ const visited = /* @__PURE__ */ new Set([result.root.id]);
1712
+ function expand(nodeId) {
1713
+ const edges = edgesBySource.get(nodeId) ?? [];
1714
+ const children = [];
1715
+ for (const edge of edges) {
1716
+ if (visited.has(edge.targetId)) continue;
1717
+ visited.add(edge.targetId);
1718
+ const target = nodeMap.get(edge.targetId);
1719
+ children.push({
1720
+ relationType: edge.type,
1721
+ nodeId: edge.targetId,
1722
+ nodeType: target?.type ?? "note",
1723
+ children: expand(edge.targetId)
1724
+ });
1725
+ }
1726
+ return children;
1727
+ }
1728
+ return expand(result.root.id);
1729
+ }
1730
+ function printTree(rootId, rootType, children) {
1731
+ console.log(`${rootId} (${rootType})`);
1732
+ printChildren(children, "");
1733
+ }
1734
+ function printChildren(children, prefix) {
1735
+ for (let i = 0; i < children.length; i++) {
1736
+ const isLast = i === children.length - 1;
1737
+ const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
1738
+ const child = children[i];
1739
+ console.log(`${prefix}${connector}${child.relationType}: ${child.nodeId} (${child.nodeType})`);
1740
+ const childPrefix = prefix + (isLast ? " " : "\u2502 ");
1741
+ printChildren(child.children, childPrefix);
1742
+ }
1743
+ }
1744
+ var graphCommand = new Command7("graph").description("Show note relations as a graph").argument("<note-id>", "Root note ID").option("--depth <n>", "Traversal depth", parseInt, 2).option("--json", "Output as JSON").action((noteId, opts) => {
1745
+ const config = loadConfig();
1746
+ const db = new BrainDB(config.dbPath);
1747
+ try {
1748
+ const result = traverseGraph(db, noteId, opts.depth);
1749
+ if (opts.json) {
1750
+ console.log(JSON.stringify(result, null, 2));
1751
+ } else {
1752
+ const tree = buildTree(result);
1753
+ printTree(result.root.id, result.root.type, tree);
1754
+ }
1755
+ } finally {
1756
+ db.close();
1757
+ }
1758
+ });
1759
+
1760
+ // src/commands/template.ts
1761
+ import { Command as Command8 } from "@commander-js/extra-typings";
1762
+ var VALID_TYPES = /* @__PURE__ */ new Set([
1763
+ "note",
1764
+ "decision",
1765
+ "meeting",
1766
+ "research",
1767
+ "pattern",
1768
+ "session-log",
1769
+ "guide"
1770
+ ]);
1771
+ function todayISO() {
1772
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1773
+ }
1774
+ function titleCase(s) {
1775
+ return s.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
1776
+ }
1777
+ function generateTemplate(type) {
1778
+ const today = todayISO();
1779
+ const base = [
1780
+ "---",
1781
+ `id: untitled-${type}`,
1782
+ `title: Untitled ${titleCase(type)}`,
1783
+ `type: ${type}`
1784
+ ];
1785
+ switch (type) {
1786
+ case "note":
1787
+ return lines(
1788
+ ...base,
1789
+ "tier: slow",
1790
+ 'category: ""',
1791
+ "tags: []",
1792
+ 'summary: ""',
1793
+ "confidence: medium",
1794
+ "status: draft",
1795
+ `created: ${today}`,
1796
+ `modified: ${today}`,
1797
+ `last-reviewed: ${today}`,
1798
+ "review-interval: 90d",
1799
+ "sources: []",
1800
+ "related: []",
1801
+ "---",
1802
+ "",
1803
+ "## Overview",
1804
+ ""
1805
+ );
1806
+ case "decision":
1807
+ return lines(
1808
+ ...base,
1809
+ "tier: slow",
1810
+ 'category: ""',
1811
+ "tags: []",
1812
+ 'summary: ""',
1813
+ "confidence: high",
1814
+ "status: current",
1815
+ `created: ${today}`,
1816
+ `modified: ${today}`,
1817
+ `last-reviewed: ${today}`,
1818
+ "review-interval: 180d",
1819
+ "sources: []",
1820
+ "related: []",
1821
+ "---",
1822
+ "",
1823
+ "## Context",
1824
+ "",
1825
+ "## Decision",
1826
+ "",
1827
+ "## Consequences",
1828
+ ""
1829
+ );
1830
+ case "meeting":
1831
+ return lines(
1832
+ ...base,
1833
+ "tier: fast",
1834
+ `date: ${today}`,
1835
+ "participants: []",
1836
+ 'project: ""',
1837
+ "tags: []",
1838
+ 'summary: ""',
1839
+ 'outcome: ""',
1840
+ `created: ${today}`,
1841
+ `modified: ${today}`,
1842
+ "review-interval: 30d",
1843
+ "related: []",
1844
+ "---",
1845
+ "",
1846
+ "## Agenda",
1847
+ "",
1848
+ "## Notes",
1849
+ "",
1850
+ "## Action Items",
1851
+ ""
1852
+ );
1853
+ case "research":
1854
+ return lines(
1855
+ ...base,
1856
+ "tier: slow",
1857
+ 'category: ""',
1858
+ "tags: []",
1859
+ 'summary: ""',
1860
+ "confidence: medium",
1861
+ "status: draft",
1862
+ `created: ${today}`,
1863
+ `modified: ${today}`,
1864
+ `last-reviewed: ${today}`,
1865
+ "review-interval: 90d",
1866
+ "sources: []",
1867
+ "related: []",
1868
+ "---",
1869
+ "",
1870
+ "## Question",
1871
+ "",
1872
+ "## Findings",
1873
+ "",
1874
+ "## Conclusion",
1875
+ ""
1876
+ );
1877
+ case "pattern":
1878
+ return lines(
1879
+ ...base,
1880
+ "tier: slow",
1881
+ 'category: ""',
1882
+ "tags: []",
1883
+ 'summary: ""',
1884
+ "confidence: high",
1885
+ "status: current",
1886
+ `created: ${today}`,
1887
+ `modified: ${today}`,
1888
+ `last-reviewed: ${today}`,
1889
+ "review-interval: 180d",
1890
+ "sources: []",
1891
+ "related: []",
1892
+ "---",
1893
+ "",
1894
+ "## Problem",
1895
+ "",
1896
+ "## Solution",
1897
+ "",
1898
+ "## Examples",
1899
+ ""
1900
+ );
1901
+ case "session-log":
1902
+ return lines(
1903
+ ...base,
1904
+ "tier: fast",
1905
+ `date: ${today}`,
1906
+ 'project: ""',
1907
+ "tags: []",
1908
+ 'summary: ""',
1909
+ `created: ${today}`,
1910
+ `modified: ${today}`,
1911
+ 'expires: ""',
1912
+ "related: []",
1913
+ "---",
1914
+ "",
1915
+ "## Goal",
1916
+ "",
1917
+ "## Log",
1918
+ "",
1919
+ "## Outcome",
1920
+ ""
1921
+ );
1922
+ case "guide":
1923
+ return lines(
1924
+ ...base,
1925
+ "tier: slow",
1926
+ 'category: ""',
1927
+ "tags: []",
1928
+ 'summary: ""',
1929
+ "confidence: high",
1930
+ "status: current",
1931
+ `created: ${today}`,
1932
+ `modified: ${today}`,
1933
+ `last-reviewed: ${today}`,
1934
+ "review-interval: 180d",
1935
+ "sources: []",
1936
+ "related: []",
1937
+ "---",
1938
+ "",
1939
+ "## Purpose",
1940
+ "",
1941
+ "## Steps",
1942
+ "",
1943
+ "## Tips",
1944
+ ""
1945
+ );
1946
+ default:
1947
+ return "";
1948
+ }
1949
+ }
1950
+ function lines(...parts) {
1951
+ return parts.join("\n");
1952
+ }
1953
+ var templateCommand = new Command8("template").description("Output a frontmatter template for a note type").argument("<type>", "Note type (note, decision, meeting, research, pattern, session-log, guide)").action((type) => {
1954
+ if (!VALID_TYPES.has(type)) {
1955
+ console.error(
1956
+ `Error: unknown type "${type}". Valid types: ${[...VALID_TYPES].join(", ")}`
1957
+ );
1958
+ process.exitCode = 1;
1959
+ return;
1960
+ }
1961
+ process.stdout.write(generateTemplate(type));
1962
+ });
1963
+
1964
+ // src/commands/archive.ts
1965
+ import { Command as Command9 } from "@commander-js/extra-typings";
1966
+ import { renameSync, mkdirSync as mkdirSync4, existsSync as existsSync4 } from "fs";
1967
+ import { join as join5, basename as basename2 } from "path";
1968
+ var archiveCommand = new Command9("archive").description("Move expired fast-tier notes to archive").option("--dry-run", "List files that would be archived without moving them").action((opts) => {
1969
+ const config = loadConfig();
1970
+ const db = new BrainDB(config.dbPath);
1971
+ try {
1972
+ const notes = db.getAllNotes();
1973
+ const now = Date.now();
1974
+ const expired = notes.filter((n) => {
1975
+ if (n.tier !== "fast") return false;
1976
+ if (!n.expires) return false;
1977
+ return new Date(n.expires).getTime() < now;
1978
+ });
1979
+ if (expired.length === 0) {
1980
+ console.log("No expired notes to archive.");
1981
+ return;
1982
+ }
1983
+ for (const note of expired) {
1984
+ const expiresDate = new Date(note.expires);
1985
+ const yyyy = String(expiresDate.getFullYear());
1986
+ const archiveDir = join5(config.notesDir, "archive", yyyy);
1987
+ const filename = basename2(note.filePath);
1988
+ const archivePath = join5(archiveDir, filename);
1989
+ if (opts.dryRun) {
1990
+ console.log(`Would archive: ${note.filePath} \u2192 ${archivePath}`);
1991
+ continue;
1992
+ }
1993
+ if (!existsSync4(archiveDir)) {
1994
+ mkdirSync4(archiveDir, { recursive: true });
1995
+ }
1996
+ if (existsSync4(note.filePath)) {
1997
+ renameSync(note.filePath, archivePath);
1998
+ }
1999
+ db.upsertNote({ ...note, filePath: archivePath });
2000
+ }
2001
+ const verb = opts.dryRun ? "Would archive" : "Archived";
2002
+ console.log(`${verb} ${expired.length} note${expired.length === 1 ? "" : "s"}.`);
2003
+ } finally {
2004
+ db.close();
2005
+ }
2006
+ });
2007
+
2008
+ // src/commands/config.ts
2009
+ import { Command as Command10 } from "@commander-js/extra-typings";
2010
+ function getNestedValue(obj, path) {
2011
+ const parts = path.split(".");
2012
+ let current = obj;
2013
+ for (const part of parts) {
2014
+ if (current === null || current === void 0 || typeof current !== "object") {
2015
+ return void 0;
2016
+ }
2017
+ current = current[part];
2018
+ }
2019
+ return current;
2020
+ }
2021
+ function setNestedValue(obj, path, value) {
2022
+ const parts = path.split(".");
2023
+ const result = { ...obj };
2024
+ let current = result;
2025
+ for (let i = 0; i < parts.length - 1; i++) {
2026
+ const part = parts[i];
2027
+ const existing = current[part];
2028
+ if (typeof existing === "object" && existing !== null) {
2029
+ current[part] = { ...existing };
2030
+ } else {
2031
+ current[part] = {};
2032
+ }
2033
+ current = current[part];
2034
+ }
2035
+ current[parts[parts.length - 1]] = value;
2036
+ return result;
2037
+ }
2038
+ function parseValue(raw) {
2039
+ if (raw === "true") return true;
2040
+ if (raw === "false") return false;
2041
+ const num = Number(raw);
2042
+ if (!isNaN(num) && raw.length > 0) return num;
2043
+ return raw;
2044
+ }
2045
+ function camelCase(key) {
2046
+ return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2047
+ }
2048
+ function camelCasePath(path) {
2049
+ return path.split(".").map(camelCase).join(".");
2050
+ }
2051
+ var getSubcommand = new Command10("get").description("Get config value(s)").argument("[key]", "Config key (dot-notation for nested, e.g. fusion-weights.bm25)").action((key) => {
2052
+ const config = loadConfig();
2053
+ if (!key) {
2054
+ console.log(JSON.stringify(config, null, 2));
2055
+ return;
2056
+ }
2057
+ const camelKey = camelCasePath(key);
2058
+ const value = getNestedValue(config, camelKey);
2059
+ if (value === void 0) {
2060
+ console.error(`Error: unknown config key "${key}"`);
2061
+ process.exitCode = 1;
2062
+ return;
2063
+ }
2064
+ if (typeof value === "object") {
2065
+ console.log(JSON.stringify(value, null, 2));
2066
+ } else {
2067
+ console.log(String(value));
2068
+ }
2069
+ });
2070
+ var setSubcommand = new Command10("set").description("Set a config value").argument("<key>", "Config key (dot-notation for nested)").argument("<value>", "Value to set").action((key, rawValue) => {
2071
+ const camelKey = camelCasePath(key);
2072
+ const value = parseValue(rawValue);
2073
+ const config = loadConfig();
2074
+ const existing = getNestedValue(config, camelKey);
2075
+ if (existing === void 0) {
2076
+ console.error(`Error: unknown config key "${key}"`);
2077
+ process.exitCode = 1;
2078
+ return;
2079
+ }
2080
+ if (existing !== null && typeof value !== typeof existing) {
2081
+ console.error(
2082
+ `Error: type mismatch for "${key}". Expected ${typeof existing}, got ${typeof value}`
2083
+ );
2084
+ process.exitCode = 1;
2085
+ return;
2086
+ }
2087
+ const updated = setNestedValue(config, camelKey, value);
2088
+ try {
2089
+ saveConfig(updated);
2090
+ console.log(`Set ${key} = ${String(value)}`);
2091
+ } catch (err) {
2092
+ console.error(`Error: ${err.message}`);
2093
+ process.exitCode = 1;
2094
+ }
2095
+ });
2096
+ var configCommand = new Command10("config").description("Get or set configuration values").addCommand(getSubcommand).addCommand(setSubcommand);
2097
+
2098
+ // src/cli.ts
2099
+ var program = new Command11().name("brain").description("Developer second brain with hybrid RAG search").version("0.1.0").option("--config-dir <path>", "override config directory").option("--db-path <path>", "override database path");
2100
+ program.addCommand(initCommand);
2101
+ program.addCommand(indexCommand);
2102
+ program.addCommand(searchCommand);
2103
+ program.addCommand(statusCommand);
2104
+ program.addCommand(addCommand);
2105
+ program.addCommand(staleCommand);
2106
+ program.addCommand(graphCommand);
2107
+ program.addCommand(templateCommand);
2108
+ program.addCommand(archiveCommand);
2109
+ program.addCommand(configCommand);
2110
+ program.parseAsync().catch((err) => {
2111
+ process.stderr.write(`Error: ${err.message}
2112
+ `);
2113
+ process.exitCode = 1;
2114
+ });