@titan-design/brain 0.3.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +96 -40
  2. package/dist/cli.js +619 -121
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6,8 +6,8 @@ import { Command as Command23 } from "@commander-js/extra-typings";
6
6
 
7
7
  // src/commands/init.ts
8
8
  import { Command } from "@commander-js/extra-typings";
9
- import { mkdirSync as mkdirSync4, existsSync as existsSync4, writeFileSync as writeFileSync3 } from "fs";
10
- import { join as join5 } from "path";
9
+ import { mkdirSync as mkdirSync5, existsSync as existsSync5, writeFileSync as writeFileSync3 } from "fs";
10
+ import { join as join6 } from "path";
11
11
  import { execSync } from "child_process";
12
12
  import { createInterface } from "readline";
13
13
 
@@ -93,8 +93,8 @@ function saveConfig(config, configDir, dataDir) {
93
93
  // src/services/brain-db.ts
94
94
  import Database from "better-sqlite3";
95
95
  import * as sqliteVec from "sqlite-vec";
96
- import { mkdirSync as mkdirSync3, renameSync, existsSync as existsSync3 } from "fs";
97
- import { join as join4, dirname as dirname2, relative as relative2 } from "path";
96
+ import { mkdirSync as mkdirSync4, renameSync as renameSync2, existsSync as existsSync4 } from "fs";
97
+ import { join as join5, dirname as dirname2, relative as relative2 } from "path";
98
98
 
99
99
  // src/services/repos/note-repo.ts
100
100
  var NoteRepo = class {
@@ -106,9 +106,9 @@ var NoteRepo = class {
106
106
  upsertNote(record) {
107
107
  this.db.prepare(
108
108
  `INSERT OR REPLACE INTO notes
109
- (id, file_path, title, type, tier, category, tags, summary, confidence, status, sources, created_at, modified_at, last_reviewed, review_interval, expires, metadata)
109
+ (id, file_path, title, type, tier, category, tags, summary, confidence, status, sources, created_at, modified_at, last_reviewed, review_interval, expires, metadata, module, module_instance, content_dir)
110
110
  VALUES
111
- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
111
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
112
112
  ).run(
113
113
  record.id,
114
114
  record.filePath,
@@ -126,7 +126,10 @@ var NoteRepo = class {
126
126
  record.lastReviewed,
127
127
  record.reviewInterval,
128
128
  record.expires,
129
- record.metadata
129
+ record.metadata,
130
+ record.module,
131
+ record.moduleInstance,
132
+ record.contentDir
130
133
  );
131
134
  return record;
132
135
  }
@@ -180,8 +183,12 @@ var NoteRepo = class {
180
183
  this.db.prepare("DELETE FROM files WHERE path = ?").run(path);
181
184
  }
182
185
  // --- Chunk + Vector Operations ---
183
- // TODO: add guard for chunks.length !== embeddings.length mismatch
184
186
  upsertChunks(noteId, chunks, embeddings) {
187
+ if (chunks.length !== embeddings.length) {
188
+ throw new Error(
189
+ `upsertChunks: chunks (${chunks.length}) and embeddings (${embeddings.length}) length mismatch`
190
+ );
191
+ }
185
192
  if (embeddings.length > 0) {
186
193
  this.ensureVectorTable(embeddings[0].length);
187
194
  }
@@ -289,6 +296,25 @@ var NoteRepo = class {
289
296
  }
290
297
  return result;
291
298
  }
299
+ getRelationsFiltered(opts) {
300
+ const conditions = [];
301
+ const params = [];
302
+ if (opts.module) {
303
+ conditions.push("module = ?");
304
+ params.push(opts.module);
305
+ }
306
+ if (opts.moduleInstance) {
307
+ conditions.push("module_instance = ?");
308
+ params.push(opts.moduleInstance);
309
+ }
310
+ if (opts.type) {
311
+ conditions.push("type = ?");
312
+ params.push(opts.type);
313
+ }
314
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
315
+ const rows = this.db.prepare(`SELECT source_id, target_id, type FROM relations ${where}`).all(...params);
316
+ return rows.map(rowToRelation);
317
+ }
292
318
  // --- Lineage ---
293
319
  getDescendants(noteId, maxDepth) {
294
320
  const depthLimit = maxDepth ?? 100;
@@ -389,6 +415,29 @@ var NoteRepo = class {
389
415
  const rows = this.db.prepare(`SELECT id FROM notes WHERE ${conditions.join(" AND ")}`).all(...params);
390
416
  return new Set(rows.map((r) => r.id));
391
417
  }
418
+ getModuleNoteIds(filter) {
419
+ const conditions = [];
420
+ const params = [];
421
+ if (filter.module) {
422
+ conditions.push("module = ?");
423
+ params.push(filter.module);
424
+ }
425
+ if (filter.moduleInstance) {
426
+ conditions.push("module_instance = ?");
427
+ params.push(filter.moduleInstance);
428
+ }
429
+ if (filter.type) {
430
+ conditions.push("type = ?");
431
+ params.push(filter.type);
432
+ }
433
+ if (filter.status) {
434
+ conditions.push("status = ?");
435
+ params.push(filter.status);
436
+ }
437
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
438
+ const rows = this.db.prepare(`SELECT id FROM notes ${where}`).all(...params);
439
+ return rows.map((r) => r.id);
440
+ }
392
441
  };
393
442
  function sanitizeFtsQuery(query) {
394
443
  return query.split(/\s+/).filter(Boolean).map((term) => `"${term.replace(/"/g, '""')}"`).join(" ");
@@ -411,7 +460,10 @@ function rowToNoteRecord(row) {
411
460
  lastReviewed: row.last_reviewed,
412
461
  reviewInterval: row.review_interval,
413
462
  expires: row.expires,
414
- metadata: row.metadata
463
+ metadata: row.metadata,
464
+ module: row.module ?? null,
465
+ moduleInstance: row.module_instance ?? null,
466
+ contentDir: row.content_dir ?? null
415
467
  };
416
468
  }
417
469
  function rowToFileRecord(row) {
@@ -719,16 +771,101 @@ var CaptureRepo = class {
719
771
  }
720
772
  };
721
773
 
774
+ // src/services/repos/activity-repo.ts
775
+ function rowToActivityRecord(row) {
776
+ return {
777
+ id: row.id,
778
+ noteIds: row.note_ids,
779
+ module: row.module,
780
+ moduleInstance: row.module_instance,
781
+ activityType: row.activity_type,
782
+ actorType: row.actor_type,
783
+ actorId: row.actor_id,
784
+ sessionId: row.session_id,
785
+ metadata: row.metadata,
786
+ outcome: row.outcome,
787
+ startedAt: row.started_at,
788
+ completedAt: row.completed_at
789
+ };
790
+ }
791
+ var ActivityRepo = class {
792
+ constructor(db) {
793
+ this.db = db;
794
+ }
795
+ addActivity(record) {
796
+ this.db.prepare(
797
+ `INSERT OR REPLACE INTO activities
798
+ (id, note_ids, module, module_instance, activity_type, actor_type, actor_id, session_id, metadata, outcome, started_at, completed_at)
799
+ VALUES
800
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
801
+ ).run(
802
+ record.id,
803
+ record.noteIds,
804
+ record.module,
805
+ record.moduleInstance,
806
+ record.activityType,
807
+ record.actorType,
808
+ record.actorId,
809
+ record.sessionId,
810
+ record.metadata,
811
+ record.outcome,
812
+ record.startedAt,
813
+ record.completedAt
814
+ );
815
+ }
816
+ getActivity(id) {
817
+ const row = this.db.prepare("SELECT * FROM activities WHERE id = ?").get(id);
818
+ return row ? rowToActivityRecord(row) : null;
819
+ }
820
+ getActivities(opts) {
821
+ const conditions = [];
822
+ const params = [];
823
+ if (opts?.module) {
824
+ conditions.push("module = ?");
825
+ params.push(opts.module);
826
+ }
827
+ if (opts?.moduleInstance) {
828
+ conditions.push("module_instance = ?");
829
+ params.push(opts.moduleInstance);
830
+ }
831
+ if (opts?.activityType) {
832
+ conditions.push("activity_type = ?");
833
+ params.push(opts.activityType);
834
+ }
835
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
836
+ const rows = this.db.prepare(`SELECT * FROM activities ${where}`).all(...params);
837
+ return rows.map(rowToActivityRecord);
838
+ }
839
+ getActivitiesByNoteId(noteId) {
840
+ const rows = this.db.prepare(
841
+ `SELECT * FROM activities WHERE note_ids LIKE '%' || ? || '%'`
842
+ ).all(noteId);
843
+ return rows.map(rowToActivityRecord).filter((a) => {
844
+ if (!a.noteIds) return false;
845
+ try {
846
+ const ids = JSON.parse(a.noteIds);
847
+ return ids.includes(noteId);
848
+ } catch {
849
+ return false;
850
+ }
851
+ });
852
+ }
853
+ getActivitiesBySession(sessionId) {
854
+ const rows = this.db.prepare("SELECT * FROM activities WHERE session_id = ?").all(sessionId);
855
+ return rows.map(rowToActivityRecord);
856
+ }
857
+ };
858
+
722
859
  // src/services/indexing.ts
723
860
  import { createHash as createHash2 } from "crypto";
724
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
725
- import { basename, dirname, join as join3, relative } from "path";
861
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, existsSync as existsSync3 } from "fs";
862
+ import { basename, dirname, join as join4, relative } from "path";
726
863
 
727
864
  // src/services/markdown-parser.ts
728
865
  import matter from "gray-matter";
729
866
 
730
867
  // src/types.ts
731
- var VALID_NOTE_TYPES = [
868
+ var VALID_CORE_NOTE_TYPES = [
732
869
  "note",
733
870
  "decision",
734
871
  "pattern",
@@ -777,7 +914,7 @@ function parseMarkdown(filePath, content) {
777
914
  const frontmatter = coerceFrontmatter(filePath, data);
778
915
  const chunks = chunkBody(body);
779
916
  const relations = extractRelations(id, data);
780
- return { id, filePath, frontmatter, content: body, chunks, relations };
917
+ return { id, filePath, frontmatter, rawFrontmatter: data, content: body, chunks, relations };
781
918
  }
782
919
  function deriveId(filePath, data) {
783
920
  if (typeof data.id === "string" && data.id.length > 0) return data.id;
@@ -787,10 +924,12 @@ function deriveId(filePath, data) {
787
924
  }
788
925
  function coerceFrontmatter(filePath, data) {
789
926
  const filename = (filePath.split("/").pop() ?? filePath).replace(/\.md$/, "");
927
+ const hasModule = typeof data.module === "string";
928
+ const type = hasModule && typeof data.type === "string" ? data.type : coerceEnum(data.type, VALID_CORE_NOTE_TYPES, "note");
790
929
  return {
791
930
  id: typeof data.id === "string" ? data.id : void 0,
792
931
  title: typeof data.title === "string" ? data.title : filename,
793
- type: coerceEnum(data.type, VALID_NOTE_TYPES, "note"),
932
+ type,
794
933
  tier: coerceEnum(data.tier, VALID_NOTE_TIERS, "slow"),
795
934
  category: coerceString(data.category),
796
935
  tags: coerceTags(data.tags),
@@ -809,7 +948,10 @@ function coerceFrontmatter(filePath, data) {
809
948
  outcome: coerceString(data.outcome),
810
949
  related: coerceStringArray(data.related),
811
950
  supersedes: coerceString(data.supersedes),
812
- parent: coerceString(data.parent)
951
+ parent: coerceString(data.parent),
952
+ module: coerceString(data.module),
953
+ "module-instance": coerceString(data["module-instance"]),
954
+ "content-dir": coerceString(data["content-dir"])
813
955
  };
814
956
  }
815
957
  function coerceString(value) {
@@ -1108,12 +1250,32 @@ async function scanForChanges(rootDir, knownFiles) {
1108
1250
  return result;
1109
1251
  }
1110
1252
 
1253
+ // src/services/content-dir.ts
1254
+ import { mkdirSync as mkdirSync2, existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, renameSync, rmSync } from "fs";
1255
+ import { join as join3, extname } from "path";
1256
+ var INDEXABLE_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".yaml", ".yml"]);
1257
+ function readIndexableContent(contentDir) {
1258
+ if (!existsSync2(contentDir)) return "";
1259
+ const files = readdirSync(contentDir);
1260
+ const parts = [];
1261
+ for (const file of files.sort()) {
1262
+ const ext = extname(file).toLowerCase();
1263
+ if (!INDEXABLE_EXTENSIONS.has(ext)) continue;
1264
+ try {
1265
+ const content = readFileSync2(join3(contentDir, file), "utf-8");
1266
+ parts.push(content);
1267
+ } catch {
1268
+ }
1269
+ }
1270
+ return parts.join("\n\n");
1271
+ }
1272
+
1111
1273
  // src/services/indexing.ts
1112
1274
  function isSkippedFile(filePath) {
1113
1275
  return filePath.includes("/_templates/") || basename(filePath) === "_index.md";
1114
1276
  }
1115
1277
  function addFrontmatterField(filePath, field, value) {
1116
- const content = readFileSync2(filePath, "utf-8");
1278
+ const content = readFileSync3(filePath, "utf-8");
1117
1279
  const endOfFrontmatter = content.indexOf("\n---", 4);
1118
1280
  if (endOfFrontmatter === -1) return;
1119
1281
  const frontmatter = content.slice(0, endOfFrontmatter);
@@ -1129,6 +1291,7 @@ ${field}: ${value}` + content.slice(endOfFrontmatter);
1129
1291
  }
1130
1292
  function frontmatterToRecord(parsed) {
1131
1293
  const fm = parsed.frontmatter;
1294
+ const metadata = fm.module ? JSON.stringify(parsed.rawFrontmatter) : null;
1132
1295
  return {
1133
1296
  id: parsed.id,
1134
1297
  filePath: parsed.filePath,
@@ -1146,7 +1309,10 @@ function frontmatterToRecord(parsed) {
1146
1309
  lastReviewed: fm["last-reviewed"] ?? null,
1147
1310
  reviewInterval: fm["review-interval"] ?? null,
1148
1311
  expires: fm.expires ?? null,
1149
- metadata: null
1312
+ metadata,
1313
+ module: fm.module ?? null,
1314
+ moduleInstance: fm["module-instance"] ?? null,
1315
+ contentDir: fm["content-dir"] ?? null
1150
1316
  };
1151
1317
  }
1152
1318
  function chunkId(noteId, content) {
@@ -1193,11 +1359,18 @@ async function indexSingleFile(db, embedder, filePath, content, hash, mtime) {
1193
1359
  const parsed = parseMarkdown(filePath, content);
1194
1360
  const noteRecord = frontmatterToRecord(parsed);
1195
1361
  db.upsertNote(noteRecord);
1362
+ let ftsContent = parsed.content;
1363
+ if (noteRecord.contentDir) {
1364
+ const dirContent = readIndexableContent(noteRecord.contentDir);
1365
+ if (dirContent) {
1366
+ ftsContent = ftsContent + "\n\n" + dirContent;
1367
+ }
1368
+ }
1196
1369
  db.upsertNoteFTS(
1197
1370
  parsed.id,
1198
1371
  parsed.frontmatter.title,
1199
1372
  parsed.frontmatter.summary ?? "",
1200
- parsed.content
1373
+ ftsContent
1201
1374
  );
1202
1375
  const chunks = rawChunksToChunks(parsed.id, parsed.chunks);
1203
1376
  if (chunks.length > 0) {
@@ -1235,7 +1408,7 @@ async function indexFiles(db, embedder, notesDir, opts) {
1235
1408
  const indexedNoteIds = [];
1236
1409
  const toProcess = [...changes.new, ...changes.modified].filter((f) => !isSkippedFile(f.path));
1237
1410
  for (const file of toProcess) {
1238
- const content = readFileSync2(file.path, "utf-8");
1411
+ const content = readFileSync3(file.path, "utf-8");
1239
1412
  const noteId = await indexSingleFile(db, embedder, file.path, content, file.hash, file.mtime);
1240
1413
  indexedNoteIds.push(noteId);
1241
1414
  indexed++;
@@ -1262,10 +1435,10 @@ async function processInbox(db, notesDir, embedder) {
1262
1435
  const yyyy = String(now.getFullYear());
1263
1436
  const mm = String(now.getMonth() + 1).padStart(2, "0");
1264
1437
  const dd = String(now.getDate()).padStart(2, "0");
1265
- const outPath = join3(notesDir, "logs", yyyy, mm, `${yyyy}-${mm}-${dd}-${parsed.id}.md`);
1438
+ const outPath = join4(notesDir, "logs", yyyy, mm, `${yyyy}-${mm}-${dd}-${parsed.id}.md`);
1266
1439
  const dir = dirname(outPath);
1267
- if (!existsSync2(dir)) {
1268
- mkdirSync2(dir, { recursive: true });
1440
+ if (!existsSync3(dir)) {
1441
+ mkdirSync3(dir, { recursive: true });
1269
1442
  }
1270
1443
  writeFileSync2(outPath, markdown, "utf-8");
1271
1444
  const hash = createHash2("sha256").update(markdown).digest("hex");
@@ -1304,17 +1477,18 @@ function generateNoteIndex(db, notesDir) {
1304
1477
  }
1305
1478
  lines2.push("");
1306
1479
  }
1307
- writeFileSync2(join3(notesDir, "_index.md"), lines2.join("\n"), "utf-8");
1480
+ writeFileSync2(join4(notesDir, "_index.md"), lines2.join("\n"), "utf-8");
1308
1481
  }
1309
1482
 
1310
1483
  // src/services/brain-db.ts
1311
- var SCHEMA_VERSION = 6;
1484
+ var SCHEMA_VERSION = 7;
1312
1485
  var BrainDB = class {
1313
1486
  db;
1314
1487
  vectorDimensions = null;
1315
1488
  noteRepo;
1316
1489
  memoryRepo;
1317
1490
  captureRepo;
1491
+ activityRepo;
1318
1492
  constructor(dbPath) {
1319
1493
  this.db = new Database(dbPath);
1320
1494
  this.db.pragma("journal_mode = WAL");
@@ -1324,6 +1498,7 @@ var BrainDB = class {
1324
1498
  this.noteRepo = new NoteRepo(this.db, (dims) => this.ensureVectorTable(dims));
1325
1499
  this.memoryRepo = new MemoryRepo(this.db);
1326
1500
  this.captureRepo = new CaptureRepo(this.db);
1501
+ this.activityRepo = new ActivityRepo(this.db);
1327
1502
  }
1328
1503
  close() {
1329
1504
  this.db.close();
@@ -1352,6 +1527,7 @@ var BrainDB = class {
1352
1527
  this.applyMigration(currentVersion, 4, () => this.db.exec(this.captureDDL()));
1353
1528
  this.applyMigration(currentVersion, 5, () => this.db.exec(this.memoryDDL()));
1354
1529
  this.applyMigration(currentVersion, 6, () => this.db.exec(this.noteAccessDDL()));
1530
+ this.applyMigration(currentVersion, 7, () => this.migrateToV7());
1355
1531
  const dims = this.getMetaValue("embedding_dimensions");
1356
1532
  if (dims) {
1357
1533
  this.ensureVectorTable(Number(dims));
@@ -1462,30 +1638,35 @@ var BrainDB = class {
1462
1638
  );
1463
1639
 
1464
1640
  CREATE TABLE IF NOT EXISTS notes (
1465
- id TEXT PRIMARY KEY,
1466
- file_path TEXT NOT NULL UNIQUE,
1467
- title TEXT NOT NULL,
1468
- type TEXT NOT NULL,
1469
- tier TEXT NOT NULL,
1470
- category TEXT,
1471
- tags TEXT,
1472
- summary TEXT,
1473
- confidence TEXT,
1474
- status TEXT DEFAULT 'current',
1475
- sources TEXT,
1476
- created_at TEXT,
1477
- modified_at TEXT,
1478
- last_reviewed TEXT,
1641
+ id TEXT PRIMARY KEY,
1642
+ file_path TEXT NOT NULL UNIQUE,
1643
+ title TEXT NOT NULL,
1644
+ type TEXT NOT NULL,
1645
+ tier TEXT NOT NULL,
1646
+ category TEXT,
1647
+ tags TEXT,
1648
+ summary TEXT,
1649
+ confidence TEXT,
1650
+ status TEXT DEFAULT 'current',
1651
+ sources TEXT,
1652
+ created_at TEXT,
1653
+ modified_at TEXT,
1654
+ last_reviewed TEXT,
1479
1655
  review_interval TEXT,
1480
- expires TEXT,
1481
- metadata TEXT
1656
+ expires TEXT,
1657
+ metadata TEXT,
1658
+ module TEXT,
1659
+ module_instance TEXT,
1660
+ content_dir TEXT
1482
1661
  );
1483
1662
 
1484
1663
  CREATE TABLE IF NOT EXISTS relations (
1485
- source_id TEXT NOT NULL,
1486
- target_id TEXT NOT NULL,
1487
- type TEXT NOT NULL,
1488
- created_at INTEGER NOT NULL,
1664
+ source_id TEXT NOT NULL,
1665
+ target_id TEXT NOT NULL,
1666
+ type TEXT NOT NULL,
1667
+ created_at INTEGER NOT NULL,
1668
+ module TEXT,
1669
+ module_instance TEXT,
1489
1670
  PRIMARY KEY (source_id, target_id, type)
1490
1671
  );
1491
1672
 
@@ -1520,6 +1701,8 @@ var BrainDB = class {
1520
1701
  ${this.captureDDL()}
1521
1702
  ${this.memoryDDL()}
1522
1703
  ${this.noteAccessDDL()}
1704
+ ${this.activitiesDDL()}
1705
+ ${this.moduleIndexesDDL()}
1523
1706
  `;
1524
1707
  }
1525
1708
  migrateToV3() {
@@ -1535,6 +1718,56 @@ var BrainDB = class {
1535
1718
  this.db.exec("ALTER TABLE chunks ADD COLUMN position INTEGER DEFAULT 0");
1536
1719
  }
1537
1720
  }
1721
+ migrateToV7() {
1722
+ const noteColumns = this.db.pragma("table_info(notes)");
1723
+ const noteColNames = new Set(noteColumns.map((c) => c.name));
1724
+ if (!noteColNames.has("module")) {
1725
+ this.db.exec("ALTER TABLE notes ADD COLUMN module TEXT");
1726
+ }
1727
+ if (!noteColNames.has("module_instance")) {
1728
+ this.db.exec("ALTER TABLE notes ADD COLUMN module_instance TEXT");
1729
+ }
1730
+ if (!noteColNames.has("content_dir")) {
1731
+ this.db.exec("ALTER TABLE notes ADD COLUMN content_dir TEXT");
1732
+ }
1733
+ const relColumns = this.db.pragma("table_info(relations)");
1734
+ const relColNames = new Set(relColumns.map((c) => c.name));
1735
+ if (!relColNames.has("module")) {
1736
+ this.db.exec("ALTER TABLE relations ADD COLUMN module TEXT");
1737
+ }
1738
+ if (!relColNames.has("module_instance")) {
1739
+ this.db.exec("ALTER TABLE relations ADD COLUMN module_instance TEXT");
1740
+ }
1741
+ this.db.exec(this.activitiesDDL());
1742
+ this.db.exec(this.moduleIndexesDDL());
1743
+ }
1744
+ activitiesDDL() {
1745
+ return `
1746
+ CREATE TABLE IF NOT EXISTS activities (
1747
+ id TEXT PRIMARY KEY,
1748
+ note_ids TEXT,
1749
+ module TEXT,
1750
+ module_instance TEXT,
1751
+ activity_type TEXT,
1752
+ actor_type TEXT,
1753
+ actor_id TEXT,
1754
+ session_id TEXT,
1755
+ metadata TEXT,
1756
+ outcome TEXT,
1757
+ started_at TEXT,
1758
+ completed_at TEXT
1759
+ );
1760
+ `;
1761
+ }
1762
+ moduleIndexesDDL() {
1763
+ return `
1764
+ CREATE INDEX IF NOT EXISTS idx_notes_module ON notes(module);
1765
+ CREATE INDEX IF NOT EXISTS idx_notes_module_instance ON notes(module, module_instance);
1766
+ CREATE INDEX IF NOT EXISTS idx_activities_module ON activities(module, module_instance);
1767
+ CREATE INDEX IF NOT EXISTS idx_activities_type ON activities(module, activity_type);
1768
+ CREATE INDEX IF NOT EXISTS idx_activities_session ON activities(session_id);
1769
+ `;
1770
+ }
1538
1771
  // --- Meta ---
1539
1772
  setMetaValue(key, value) {
1540
1773
  this.db.prepare("INSERT OR REPLACE INTO db_meta (key, value) VALUES (?, ?)").run(key, value);
@@ -1606,12 +1839,12 @@ var BrainDB = class {
1606
1839
  cascadeArchive(noteId, notesDir) {
1607
1840
  const note = this.getNoteById(noteId);
1608
1841
  if (!note) throw new Error(`Note not found: ${noteId}`);
1609
- const archiveDir = join4(notesDir, ".archive");
1842
+ const archiveDir = join5(notesDir, ".archive");
1610
1843
  const relativePath = relative2(notesDir, note.filePath);
1611
- const archivePath = join4(archiveDir, relativePath);
1612
- mkdirSync3(dirname2(archivePath), { recursive: true });
1613
- if (existsSync3(note.filePath)) {
1614
- renameSync(note.filePath, archivePath);
1844
+ const archivePath = join5(archiveDir, relativePath);
1845
+ mkdirSync4(dirname2(archivePath), { recursive: true });
1846
+ if (existsSync4(note.filePath)) {
1847
+ renameSync2(note.filePath, archivePath);
1615
1848
  }
1616
1849
  const txn = this.db.transaction(() => {
1617
1850
  this.memoryRepo.deleteMemoriesForNote(noteId);
@@ -1624,7 +1857,7 @@ var BrainDB = class {
1624
1857
  const orphanedIds = [];
1625
1858
  for (const child of directChildren) {
1626
1859
  const childNote = this.getNoteById(child.id);
1627
- if (childNote && existsSync3(childNote.filePath)) {
1860
+ if (childNote && existsSync4(childNote.filePath)) {
1628
1861
  addFrontmatterField(childNote.filePath, "orphaned_from", noteId);
1629
1862
  orphanedIds.push(child.id);
1630
1863
  }
@@ -1701,6 +1934,9 @@ var BrainDB = class {
1701
1934
  getDescendants(noteId, maxDepth) {
1702
1935
  return this.noteRepo.getDescendants(noteId, maxDepth);
1703
1936
  }
1937
+ getRelationsFiltered(opts) {
1938
+ return this.noteRepo.getRelationsFiltered(opts);
1939
+ }
1704
1940
  // --- Access Delegates ---
1705
1941
  recordAccess(noteId, event) {
1706
1942
  this.noteRepo.recordAccess(noteId, event);
@@ -1725,6 +1961,9 @@ var BrainDB = class {
1725
1961
  getFilteredNoteIds(filters) {
1726
1962
  return this.noteRepo.getFilteredNoteIds(filters);
1727
1963
  }
1964
+ getModuleNoteIds(filter) {
1965
+ return this.noteRepo.getModuleNoteIds(filter);
1966
+ }
1728
1967
  // --- Memory Delegates ---
1729
1968
  addMemory(entry) {
1730
1969
  this.memoryRepo.addMemory(entry);
@@ -1805,6 +2044,22 @@ var BrainDB = class {
1805
2044
  updateFeedLastPolled(id, lastPolled) {
1806
2045
  this.captureRepo.updateFeedLastPolled(id, lastPolled);
1807
2046
  }
2047
+ // --- Activity Delegates ---
2048
+ addActivity(record) {
2049
+ this.activityRepo.addActivity(record);
2050
+ }
2051
+ getActivity(id) {
2052
+ return this.activityRepo.getActivity(id);
2053
+ }
2054
+ getActivities(opts) {
2055
+ return this.activityRepo.getActivities(opts);
2056
+ }
2057
+ getActivitiesByNoteId(noteId) {
2058
+ return this.activityRepo.getActivitiesByNoteId(noteId);
2059
+ }
2060
+ getActivitiesBySession(sessionId) {
2061
+ return this.activityRepo.getActivitiesBySession(sessionId);
2062
+ }
1808
2063
  };
1809
2064
 
1810
2065
  // src/adapters/local-embedder.ts
@@ -2174,16 +2429,16 @@ var initCommand = new Command("init").description("Initialize a new brain worksp
2174
2429
  const config = loadConfig();
2175
2430
  const created = [];
2176
2431
  for (const sub of SUBDIRS) {
2177
- const dir = join5(config.notesDir, sub);
2178
- if (!existsSync4(dir)) {
2179
- mkdirSync4(dir, { recursive: true });
2432
+ const dir = join6(config.notesDir, sub);
2433
+ if (!existsSync5(dir)) {
2434
+ mkdirSync5(dir, { recursive: true });
2180
2435
  created.push(sub);
2181
2436
  }
2182
2437
  }
2183
- const templatesDir = join5(config.notesDir, "_templates");
2438
+ const templatesDir = join6(config.notesDir, "_templates");
2184
2439
  for (const [filename, content] of Object.entries(TEMPLATES)) {
2185
- const filePath = join5(templatesDir, filename);
2186
- if (!existsSync4(filePath)) {
2440
+ const filePath = join6(templatesDir, filename);
2441
+ if (!existsSync5(filePath)) {
2187
2442
  writeFileSync3(filePath, content, "utf-8");
2188
2443
  }
2189
2444
  }
@@ -2243,7 +2498,7 @@ var initCommand = new Command("init").description("Initialize a new brain worksp
2243
2498
 
2244
2499
  // src/commands/index-cmd.ts
2245
2500
  import { Command as Command2 } from "@commander-js/extra-typings";
2246
- import { readFileSync as readFileSync3, watch } from "fs";
2501
+ import { readFileSync as readFileSync4, watch } from "fs";
2247
2502
 
2248
2503
  // src/services/memory-extractor.ts
2249
2504
  import { randomUUID } from "crypto";
@@ -2270,11 +2525,20 @@ Output valid JSON with this exact structure:
2270
2525
  {"actions":[{"type":"ADD","fact":"..."},{"type":"UPDATE","id":"...","fact":"..."},{"type":"DELETE","id":"..."},{"type":"NONE"}]}
2271
2526
 
2272
2527
  Only output the JSON object, nothing else.`;
2273
- async function extractMemoriesFromNote(db, llm, noteId, containerTag = "default", embedder) {
2528
+ async function extractMemoriesFromNote(db, llm, noteId, containerTag = "default", embedder, moduleRegistry) {
2274
2529
  const chunks = db.getChunksForNote(noteId);
2275
2530
  if (chunks.length === 0) {
2276
2531
  return { noteId, facts: [], memoriesCreated: 0, memoriesUpdated: 0, memoriesDeleted: 0 };
2277
2532
  }
2533
+ if (moduleRegistry) {
2534
+ const note = db.getNoteById(noteId);
2535
+ if (note?.module) {
2536
+ const strategy = moduleRegistry.getExtractionStrategy(note.module);
2537
+ if (strategy && !strategy.shouldExtract(note)) {
2538
+ return { noteId, facts: [], memoriesCreated: 0, memoriesUpdated: 0, memoriesDeleted: 0 };
2539
+ }
2540
+ }
2541
+ }
2278
2542
  const allFacts = [];
2279
2543
  for (const chunk of chunks) {
2280
2544
  const facts = await extractFactsFromChunk(llm, chunk);
@@ -2552,7 +2816,7 @@ function startWatcher(db, embedder, notesDir) {
2552
2816
  const changes = await scanForChanges(notesDir, knownFiles);
2553
2817
  const toProcess = [...changes.new, ...changes.modified].filter((f) => !isSkippedFile(f.path));
2554
2818
  for (const file of toProcess) {
2555
- const content = readFileSync3(file.path, "utf-8");
2819
+ const content = readFileSync4(file.path, "utf-8");
2556
2820
  await indexSingleFile(db, embedder, file.path, content, file.hash, file.mtime);
2557
2821
  process.stderr.write(` Re-indexed: ${file.path}
2558
2822
  `);
@@ -2594,15 +2858,194 @@ async function extractFromNotes(db, embedder, noteIds, ollamaUrl, model, contain
2594
2858
  // src/commands/search.ts
2595
2859
  import { Command as Command3 } from "@commander-js/extra-typings";
2596
2860
 
2861
+ // src/modules/loader.ts
2862
+ import { readdirSync as readdirSync2, existsSync as existsSync6 } from "fs";
2863
+ import { join as join7, basename as basename2, dirname as dirname3 } from "path";
2864
+ import { fileURLToPath } from "url";
2865
+
2866
+ // src/modules/registry.ts
2867
+ var ModuleRegistry = class {
2868
+ modules = /* @__PURE__ */ new Map();
2869
+ noteTypes = /* @__PURE__ */ new Map();
2870
+ relationTypes = /* @__PURE__ */ new Map();
2871
+ commands = [];
2872
+ extractionStrategies = [];
2873
+ filters = [];
2874
+ migrations = [];
2875
+ registerModule(mod) {
2876
+ if (this.modules.has(mod.name)) {
2877
+ throw new Error(`Module "${mod.name}" is already registered`);
2878
+ }
2879
+ this.modules.set(mod.name, mod);
2880
+ }
2881
+ getModule(name) {
2882
+ return this.modules.get(name);
2883
+ }
2884
+ getModuleNames() {
2885
+ return [...this.modules.keys()];
2886
+ }
2887
+ // --- Note Types ---
2888
+ registerNoteType(moduleName, noteType) {
2889
+ const key = noteType.name;
2890
+ if (this.noteTypes.has(key)) {
2891
+ const existing = this.noteTypes.get(key);
2892
+ throw new Error(
2893
+ `Note type "${key}" already registered by module "${existing.module}"`
2894
+ );
2895
+ }
2896
+ this.noteTypes.set(key, { module: moduleName, noteType });
2897
+ }
2898
+ getNoteType(name) {
2899
+ return this.noteTypes.get(name)?.noteType;
2900
+ }
2901
+ getNoteTypeModule(name) {
2902
+ return this.noteTypes.get(name)?.module;
2903
+ }
2904
+ getAllNoteTypes() {
2905
+ return [...this.noteTypes.values()];
2906
+ }
2907
+ // --- Relation Types ---
2908
+ registerRelationType(moduleName, relationType) {
2909
+ const key = relationType.name;
2910
+ if (this.relationTypes.has(key)) {
2911
+ const existing = this.relationTypes.get(key);
2912
+ throw new Error(
2913
+ `Relation type "${key}" already registered by module "${existing.module}"`
2914
+ );
2915
+ }
2916
+ this.relationTypes.set(key, { module: moduleName, relationType });
2917
+ }
2918
+ getRelationType(name) {
2919
+ return this.relationTypes.get(name)?.relationType;
2920
+ }
2921
+ // --- Commands ---
2922
+ registerCommand(moduleName, command) {
2923
+ this.commands.push({ module: moduleName, command });
2924
+ }
2925
+ getCommands() {
2926
+ return [...this.commands];
2927
+ }
2928
+ // --- Directory Schemas ---
2929
+ getDirectorySchema(noteTypeName) {
2930
+ return this.noteTypes.get(noteTypeName)?.noteType.directorySchema;
2931
+ }
2932
+ // --- Extraction Strategies ---
2933
+ registerExtractionStrategy(moduleName, strategy) {
2934
+ this.extractionStrategies.push({ module: moduleName, strategy });
2935
+ }
2936
+ getExtractionStrategy(moduleName) {
2937
+ return this.extractionStrategies.find((s) => s.module === moduleName)?.strategy;
2938
+ }
2939
+ // --- Filters ---
2940
+ registerFilter(moduleName, filter) {
2941
+ this.filters.push({ module: moduleName, filter });
2942
+ }
2943
+ getFilters() {
2944
+ return [...this.filters];
2945
+ }
2946
+ getFilterForModule(moduleName) {
2947
+ return this.filters.find((f) => f.module === moduleName)?.filter;
2948
+ }
2949
+ // --- Migrations ---
2950
+ registerMigration(moduleName, migration) {
2951
+ this.migrations.push({ module: moduleName, migration });
2952
+ }
2953
+ getMigrations(moduleName) {
2954
+ if (moduleName) {
2955
+ return this.migrations.filter((m) => m.module === moduleName);
2956
+ }
2957
+ return [...this.migrations];
2958
+ }
2959
+ };
2960
+
2961
+ // src/modules/context.ts
2962
+ function createModuleContext(registry, moduleName) {
2963
+ return {
2964
+ registerNoteType(noteType) {
2965
+ registry.registerNoteType(moduleName, noteType);
2966
+ },
2967
+ registerRelationType(relationType) {
2968
+ registry.registerRelationType(moduleName, relationType);
2969
+ },
2970
+ registerCommand(command) {
2971
+ registry.registerCommand(moduleName, command);
2972
+ },
2973
+ registerExtractionStrategy(strategy) {
2974
+ registry.registerExtractionStrategy(moduleName, strategy);
2975
+ },
2976
+ registerFilter(filter) {
2977
+ registry.registerFilter(moduleName, filter);
2978
+ },
2979
+ registerMigration(migration) {
2980
+ registry.registerMigration(moduleName, migration);
2981
+ }
2982
+ };
2983
+ }
2984
+
2985
+ // src/modules/loader.ts
2986
+ function getBuiltinModulesDir() {
2987
+ try {
2988
+ const thisDir = import.meta.dirname ?? dirname3(fileURLToPath(import.meta.url));
2989
+ return join7(thisDir, "..", "..", "modules");
2990
+ } catch {
2991
+ return "";
2992
+ }
2993
+ }
2994
+ function discoverModules(modulesDir) {
2995
+ const dir = modulesDir ?? getBuiltinModulesDir();
2996
+ if (!dir || !existsSync6(dir)) return [];
2997
+ return readdirSync2(dir).filter((f) => f.endsWith(".js") || f.endsWith(".ts")).filter((f) => !f.startsWith("_") && !f.endsWith(".test.ts") && !f.endsWith(".test.js")).map((f) => join7(dir, f));
2998
+ }
2999
+ async function loadModules(opts) {
3000
+ const registry = new ModuleRegistry();
3001
+ const errors = [];
3002
+ if (opts?.modules) {
3003
+ for (const mod of opts.modules) {
3004
+ try {
3005
+ registry.registerModule(mod);
3006
+ const ctx = createModuleContext(registry, mod.name);
3007
+ mod.register(ctx);
3008
+ } catch (err) {
3009
+ errors.push({
3010
+ module: mod.name,
3011
+ error: err instanceof Error ? err : new Error(String(err))
3012
+ });
3013
+ }
3014
+ }
3015
+ }
3016
+ const modulePaths = discoverModules(opts?.modulesDir);
3017
+ for (const modulePath of modulePaths) {
3018
+ const name = basename2(modulePath).replace(/\.[jt]s$/, "");
3019
+ try {
3020
+ const imported = await import(modulePath);
3021
+ const mod = imported.default ?? imported;
3022
+ if (!mod.name || !mod.register) {
3023
+ throw new Error(`Module at ${modulePath} does not export a valid BrainModule`);
3024
+ }
3025
+ registry.registerModule(mod);
3026
+ const ctx = createModuleContext(registry, mod.name);
3027
+ mod.register(ctx);
3028
+ } catch (err) {
3029
+ errors.push({
3030
+ module: name,
3031
+ error: err instanceof Error ? err : new Error(String(err))
3032
+ });
3033
+ }
3034
+ }
3035
+ return { registry, errors };
3036
+ }
3037
+
2597
3038
  // src/services/brain-service.ts
2598
3039
  async function withBrain(fn) {
2599
3040
  const config = loadConfig();
2600
3041
  const db = new BrainDB(config.dbPath);
2601
3042
  const embedder = createEmbedder(config);
3043
+ const { registry } = await loadModules();
2602
3044
  const svc = {
2603
3045
  db,
2604
3046
  embedder,
2605
3047
  config,
3048
+ modules: registry,
2606
3049
  close() {
2607
3050
  db.close();
2608
3051
  }
@@ -2631,7 +3074,7 @@ async function withDb(fn) {
2631
3074
  }
2632
3075
 
2633
3076
  // src/services/search.ts
2634
- import { existsSync as existsSync5 } from "fs";
3077
+ import { existsSync as existsSync7 } from "fs";
2635
3078
 
2636
3079
  // src/services/reranker.ts
2637
3080
  var DEFAULT_MODEL2 = "Xenova/ms-marco-MiniLM-L-6-v2";
@@ -2663,7 +3106,7 @@ async function rerank(query, results, topK) {
2663
3106
 
2664
3107
  // src/services/search.ts
2665
3108
  var RRF_K = 60;
2666
- var EXCERPT_MAX_LENGTH = 200;
3109
+ var EXCERPT_MAX_LENGTH = 500;
2667
3110
  var OVERFETCH_MULTIPLIER = 3;
2668
3111
  function distanceToCosineSim(distance) {
2669
3112
  return 1 - distance * distance / 2;
@@ -2787,17 +3230,33 @@ function fuseByScore(ftsResults, vectorByNote, weights) {
2787
3230
  }
2788
3231
  return scored;
2789
3232
  }
2790
- async function search(db, embedder, query, options, fusionWeights = { bm25: 0.3, vector: 0.7 }) {
3233
+ async function search(db, embedder, query, options, fusionWeights = { bm25: 0.3, vector: 0.7 }, moduleRegistry) {
2791
3234
  if (!query.trim()) return [];
2792
3235
  const limit = options.limit;
2793
3236
  const overfetchLimit = limit * OVERFETCH_MULTIPLIER;
2794
- const allowedNoteIds = db.getFilteredNoteIds({
3237
+ let allowedNoteIds = db.getFilteredNoteIds({
2795
3238
  tier: options.tier,
2796
3239
  category: options.category,
2797
3240
  confidence: options.confidence,
2798
3241
  since: options.since,
2799
3242
  tags: options.tags
2800
3243
  });
3244
+ if (moduleRegistry) {
3245
+ const privateModuleNoteIds = getPrivateModuleNoteIds(db, moduleRegistry);
3246
+ if (privateModuleNoteIds.size > 0) {
3247
+ if (allowedNoteIds) {
3248
+ for (const id of privateModuleNoteIds) {
3249
+ allowedNoteIds.delete(id);
3250
+ }
3251
+ } else {
3252
+ const allNoteIds = new Set(db.getAllNotes().map((n) => n.id));
3253
+ for (const id of privateModuleNoteIds) {
3254
+ allNoteIds.delete(id);
3255
+ }
3256
+ allowedNoteIds = allNoteIds;
3257
+ }
3258
+ }
3259
+ }
2801
3260
  const ftsResults = db.searchFTS(query, overfetchLimit);
2802
3261
  const filteredFts = allowedNoteIds ? ftsResults.filter((r) => allowedNoteIds.has(r.noteId)) : ftsResults;
2803
3262
  const queryVec = await embedQuery(embedder, query);
@@ -2872,6 +3331,18 @@ async function searchMemories(db, embedder, query, limit = 10, containerTag) {
2872
3331
  results.sort((a, b) => b.score - a.score);
2873
3332
  return results.slice(0, limit);
2874
3333
  }
3334
+ function getPrivateModuleNoteIds(db, registry) {
3335
+ const privateIds = /* @__PURE__ */ new Set();
3336
+ for (const { module: moduleName, filter } of registry.getFilters()) {
3337
+ if (filter.visibility === "private") {
3338
+ const noteIds = db.getModuleNoteIds({ module: moduleName });
3339
+ for (const id of noteIds) {
3340
+ privateIds.add(id);
3341
+ }
3342
+ }
3343
+ }
3344
+ return privateIds;
3345
+ }
2875
3346
 
2876
3347
  // src/services/graph.ts
2877
3348
  var MAX_DEPTH = 3;
@@ -3109,8 +3580,8 @@ function formatMap(map) {
3109
3580
  // src/commands/add.ts
3110
3581
  import { Command as Command5 } from "@commander-js/extra-typings";
3111
3582
  import { createHash as createHash3 } from "crypto";
3112
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, existsSync as existsSync6 } from "fs";
3113
- import { join as join6, dirname as dirname3, resolve } from "path";
3583
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync6, existsSync as existsSync8 } from "fs";
3584
+ import { join as join8, dirname as dirname4, resolve } from "path";
3114
3585
  function buildFrontmatter(opts) {
3115
3586
  const now = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3116
3587
  const title = opts.title ?? "Untitled";
@@ -3162,10 +3633,10 @@ function resolveOutputPath(notesDir, tier, type, id) {
3162
3633
  const yyyy = String(now.getFullYear());
3163
3634
  const mm = String(now.getMonth() + 1).padStart(2, "0");
3164
3635
  const dd = String(now.getDate()).padStart(2, "0");
3165
- return join6(notesDir, "logs", yyyy, mm, `${yyyy}-${mm}-${dd}-${id}.md`);
3636
+ return join8(notesDir, "logs", yyyy, mm, `${yyyy}-${mm}-${dd}-${id}.md`);
3166
3637
  }
3167
- const typeDir = TYPE_DIRS[type];
3168
- return join6(notesDir, typeDir, `${id}.md`);
3638
+ const typeDir = TYPE_DIRS[type] ?? "notes";
3639
+ return join8(notesDir, typeDir, `${id}.md`);
3169
3640
  }
3170
3641
  async function handleUrlAdd(opts) {
3171
3642
  const { fetchAndExtract } = await import("./web-extract-K4LTMRW2.js");
@@ -3213,8 +3684,8 @@ async function handleUrlAdd(opts) {
3213
3684
  const markdown = frontmatterLines.join("\n") + "\n\n" + result.markdown;
3214
3685
  await withBrain(async ({ db, embedder, config }) => {
3215
3686
  const outPath = resolveOutputPath(config.notesDir, tier, type, id);
3216
- const dir = dirname3(outPath);
3217
- if (!existsSync6(dir)) mkdirSync5(dir, { recursive: true });
3687
+ const dir = dirname4(outPath);
3688
+ if (!existsSync8(dir)) mkdirSync6(dir, { recursive: true });
3218
3689
  writeFileSync4(outPath, markdown, "utf-8");
3219
3690
  const hash = createHash3("sha256").update(markdown).digest("hex");
3220
3691
  await indexSingleFile(db, embedder, outPath, markdown, hash, Date.now());
@@ -3234,9 +3705,9 @@ var addCommand = new Command5("add").description("Create a new note from file or
3234
3705
  "--type <type>",
3235
3706
  "Note type (note, decision, pattern, research, meeting, session-log, guide)"
3236
3707
  ).option("--tier <tier>", "Note tier (slow, fast)").option("--tags <tags>", "Comma-separated tags").option("--summary <text>", "One-line summary for search excerpts").option("--confidence <level>", "Confidence level (high, medium, low, speculative)").option("--status <status>", "Note status (current, outdated, deprecated, draft)").option("--category <cat>", "Category label").option("--related <ids>", "Comma-separated related note IDs").option("--review-interval <interval>", "Review interval (e.g. 90d, 30d, 180d)").option("--created <date>", "Created date (YYYY-MM-DD), defaults to today").option("--url <url>", "Fetch URL and create note from extracted content").action(async (file, opts) => {
3237
- if (opts.type && !VALID_NOTE_TYPES.includes(opts.type)) {
3708
+ if (opts.type && !VALID_CORE_NOTE_TYPES.includes(opts.type)) {
3238
3709
  process.stderr.write(
3239
- `Error: invalid type "${opts.type}". Valid types: ${VALID_NOTE_TYPES.join(", ")}
3710
+ `Error: invalid type "${opts.type}". Valid types: ${VALID_CORE_NOTE_TYPES.join(", ")}
3240
3711
  `
3241
3712
  );
3242
3713
  process.exitCode = 1;
@@ -3272,9 +3743,9 @@ var addCommand = new Command5("add").description("Create a new note from file or
3272
3743
  }
3273
3744
  let content;
3274
3745
  if (file) {
3275
- content = readFileSync4(resolve(file), "utf-8");
3746
+ content = readFileSync5(resolve(file), "utf-8");
3276
3747
  } else if (!process.stdin.isTTY) {
3277
- content = readFileSync4(0, "utf-8");
3748
+ content = readFileSync5(0, "utf-8");
3278
3749
  } else {
3279
3750
  process.stderr.write("Error: provide a file argument or pipe content to stdin\n");
3280
3751
  process.exitCode = 1;
@@ -3290,9 +3761,9 @@ var addCommand = new Command5("add").description("Create a new note from file or
3290
3761
  const type = opts.type ?? parsed.frontmatter.type ?? "note";
3291
3762
  const config = loadConfig();
3292
3763
  const outPath = resolveOutputPath(config.notesDir, tier, type, id);
3293
- const dir = dirname3(outPath);
3294
- if (!existsSync6(dir)) {
3295
- mkdirSync5(dir, { recursive: true });
3764
+ const dir = dirname4(outPath);
3765
+ if (!existsSync8(dir)) {
3766
+ mkdirSync6(dir, { recursive: true });
3296
3767
  }
3297
3768
  try {
3298
3769
  writeFileSync4(outPath, content, "utf-8");
@@ -3622,8 +4093,8 @@ var templateCommand = new Command8("template").description("Output a frontmatter
3622
4093
 
3623
4094
  // src/commands/archive.ts
3624
4095
  import { Command as Command9 } from "@commander-js/extra-typings";
3625
- import { renameSync as renameSync2, mkdirSync as mkdirSync6, existsSync as existsSync7 } from "fs";
3626
- import { join as join7, basename as basename2 } from "path";
4096
+ import { renameSync as renameSync3, mkdirSync as mkdirSync7, existsSync as existsSync9 } from "fs";
4097
+ import { join as join9, basename as basename3 } from "path";
3627
4098
  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(async (opts) => {
3628
4099
  await withDb(({ db, config }) => {
3629
4100
  const notes = db.getAllNotes();
@@ -3640,19 +4111,19 @@ var archiveCommand = new Command9("archive").description("Move expired fast-tier
3640
4111
  for (const note of expired) {
3641
4112
  const expiresDate = new Date(note.expires);
3642
4113
  const yyyy = String(expiresDate.getFullYear());
3643
- const archiveDir = join7(config.notesDir, "archive", yyyy);
3644
- const filename = basename2(note.filePath);
3645
- const archivePath = join7(archiveDir, filename);
4114
+ const archiveDir = join9(config.notesDir, "archive", yyyy);
4115
+ const filename = basename3(note.filePath);
4116
+ const archivePath = join9(archiveDir, filename);
3646
4117
  if (opts.dryRun) {
3647
4118
  process.stdout.write(`Would archive: ${note.filePath} \u2192 ${archivePath}
3648
4119
  `);
3649
4120
  continue;
3650
4121
  }
3651
- if (!existsSync7(archiveDir)) {
3652
- mkdirSync6(archiveDir, { recursive: true });
4122
+ if (!existsSync9(archiveDir)) {
4123
+ mkdirSync7(archiveDir, { recursive: true });
3653
4124
  }
3654
- if (existsSync7(note.filePath)) {
3655
- renameSync2(note.filePath, archivePath);
4125
+ if (existsSync9(note.filePath)) {
4126
+ renameSync3(note.filePath, archivePath);
3656
4127
  }
3657
4128
  db.upsertNote({ ...note, filePath: archivePath });
3658
4129
  }
@@ -3760,13 +4231,13 @@ var configCommand = new Command10("config").description("Get or set configuratio
3760
4231
  // src/commands/quick.ts
3761
4232
  import { Command as Command11 } from "@commander-js/extra-typings";
3762
4233
  import { randomUUID as randomUUID2 } from "crypto";
3763
- import { readFileSync as readFileSync5 } from "fs";
3764
- var quickCommand = new Command11("quick").description("Quickly capture a thought into the inbox").argument("<text...>", "Text to capture (or pipe via stdin)").option("--title <title>", "Optional title for the item").option("--source <source>", "Source label (cli, api, alert)", "cli").option("--url <url>", "Source URL for reference").action(async (textParts, opts) => {
4234
+ import { readFileSync as readFileSync6 } from "fs";
4235
+ var quickCommand = new Command11("quick").description("Quickly capture a thought into the inbox").argument("[text...]", "Text to capture (or pipe via stdin)").option("--title <title>", "Optional title for the item").option("--source <source>", "Source label (cli, api, alert)", "cli").option("--url <url>", "Source URL for reference").action(async (textParts, opts) => {
3765
4236
  let content;
3766
4237
  if (textParts.length > 0) {
3767
4238
  content = textParts.join(" ");
3768
4239
  } else if (!process.stdin.isTTY) {
3769
- content = readFileSync5(0, "utf-8").trim();
4240
+ content = readFileSync6(0, "utf-8").trim();
3770
4241
  } else {
3771
4242
  process.stderr.write("Error: provide text as arguments or pipe via stdin\n");
3772
4243
  process.exitCode = 1;
@@ -3808,6 +4279,12 @@ var quickCommand = new Command11("quick").description("Quickly capture a thought
3808
4279
  import { Command as Command12 } from "@commander-js/extra-typings";
3809
4280
  var VALID_STATUSES = ["pending", "processing", "indexed", "failed", "discarded"];
3810
4281
  var inboxCommand = new Command12("inbox").description("View and manage inbox items").option("--status <status>", "Filter by status (pending, processing, indexed, failed, discarded)").option("--discard <id>", "Discard an inbox item").option("--delete <id>", "Permanently delete an inbox item").option("--count", "Show count only").action(async (opts) => {
4282
+ const actionFlags = [opts.discard, opts.delete, opts.count].filter(Boolean);
4283
+ if (actionFlags.length > 1) {
4284
+ process.stderr.write("Error: --discard, --delete, and --count are mutually exclusive\n");
4285
+ process.exitCode = 1;
4286
+ return;
4287
+ }
3811
4288
  if (opts.status && !VALID_STATUSES.includes(opts.status)) {
3812
4289
  process.stderr.write(
3813
4290
  `Error: invalid status "${opts.status}". Valid: ${VALID_STATUSES.join(", ")}
@@ -3869,8 +4346,8 @@ var inboxCommand = new Command12("inbox").description("View and manage inbox ite
3869
4346
  // src/commands/ingest.ts
3870
4347
  import { Command as Command13 } from "@commander-js/extra-typings";
3871
4348
  import { randomUUID as randomUUID3 } from "crypto";
3872
- import { readFileSync as readFileSync6, statSync } from "fs";
3873
- import { resolve as resolve2, basename as basename3 } from "path";
4349
+ import { readFileSync as readFileSync7, statSync } from "fs";
4350
+ import { resolve as resolve2, basename as basename4 } from "path";
3874
4351
  var ingestCommand = new Command13("ingest").description("Bulk-import files into the inbox").argument("[files...]", "Files to ingest").option("--source <source>", "Source label (file, crawler, api)", "file").option("--url <url>", "Source URL for all items").option("--urls <file>", "File containing URLs to import (one per line)").action(async (files, opts) => {
3875
4352
  if (opts.urls) {
3876
4353
  await handleUrlsFile(opts.urls);
@@ -3901,7 +4378,7 @@ var ingestCommand = new Command13("ingest").description("Bulk-import files into
3901
4378
  `);
3902
4379
  continue;
3903
4380
  }
3904
- const content = readFileSync6(absPath, "utf-8");
4381
+ const content = readFileSync7(absPath, "utf-8");
3905
4382
  if (!content.trim()) {
3906
4383
  process.stderr.write(`Skipping: ${filePath} (empty)
3907
4384
  `);
@@ -3910,7 +4387,7 @@ var ingestCommand = new Command13("ingest").description("Bulk-import files into
3910
4387
  const item = {
3911
4388
  id: randomUUID3(),
3912
4389
  content,
3913
- title: basename3(filePath, ".md"),
4390
+ title: basename4(filePath, ".md"),
3914
4391
  source,
3915
4392
  sourceUrl: opts.url ?? null,
3916
4393
  sourceMeta: JSON.stringify({ originalPath: absPath }),
@@ -3928,7 +4405,7 @@ var ingestCommand = new Command13("ingest").description("Bulk-import files into
3928
4405
  async function handleUrlsFile(urlsPath) {
3929
4406
  const { fetchAndExtract } = await import("./web-extract-K4LTMRW2.js");
3930
4407
  const urlFile = resolve2(urlsPath);
3931
- const content = readFileSync6(urlFile, "utf-8");
4408
+ const content = readFileSync7(urlFile, "utf-8");
3932
4409
  const urls = content.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
3933
4410
  await withDb(async ({ db }) => {
3934
4411
  let ingested = 0;
@@ -3970,6 +4447,14 @@ import { Command as Command14 } from "@commander-js/extra-typings";
3970
4447
  import { randomUUID as randomUUID4 } from "crypto";
3971
4448
  var feedCommand = new Command14("feed").description("Manage RSS feed subscriptions").addCommand(
3972
4449
  new Command14("add").description("Subscribe to an RSS feed").argument("<url>", "Feed URL").option("--name <name>", "Display name for the feed").option("--tag <tag>", "Container tag for namespacing", "default").option("--filter <prompt>", "Filter prompt to select relevant items").action(async (url, opts) => {
4450
+ try {
4451
+ new URL(url);
4452
+ } catch {
4453
+ process.stderr.write(`Error: invalid URL: ${url}
4454
+ `);
4455
+ process.exitCode = 1;
4456
+ return;
4457
+ }
3973
4458
  await withDb(({ db }) => {
3974
4459
  try {
3975
4460
  const name = opts.name ?? new URL(url).hostname;
@@ -4046,9 +4531,6 @@ var extractCommand = new Command15("extract").description("Extract memories from
4046
4531
  return;
4047
4532
  }
4048
4533
  await withBrain(async ({ db, embedder, config }) => {
4049
- const llm = await requireOllama(config.ollamaUrl, opts.model ?? config.ollamaModel);
4050
- if (!llm) return;
4051
- db.setEmbeddingModel(embedder.model, embedder.dimensions);
4052
4534
  const noteIds = [];
4053
4535
  if (opts.note) {
4054
4536
  const note = db.getNoteById(opts.note);
@@ -4063,6 +4545,9 @@ var extractCommand = new Command15("extract").description("Extract memories from
4063
4545
  const allNotes = db.getAllNotes();
4064
4546
  noteIds.push(...allNotes.map((n) => n.id));
4065
4547
  }
4548
+ const llm = await requireOllama(config.ollamaUrl, opts.model ?? config.ollamaModel);
4549
+ if (!llm) return;
4550
+ db.setEmbeddingModel(embedder.model, embedder.dimensions);
4066
4551
  let totalFacts = 0;
4067
4552
  let totalCreated = 0;
4068
4553
  let totalUpdated = 0;
@@ -4116,6 +4601,11 @@ var memoriesCommand = new Command16("memories").description("View and manage ext
4116
4601
  memories = db.getLatestMemories(opts.container);
4117
4602
  }
4118
4603
  const limit = parseInt(opts.limit, 10);
4604
+ if (isNaN(limit) || limit <= 0) {
4605
+ process.stderr.write("Error: --limit must be a positive integer\n");
4606
+ process.exitCode = 1;
4607
+ return;
4608
+ }
4119
4609
  const limited = memories.slice(0, limit);
4120
4610
  if (opts.json) {
4121
4611
  process.stdout.write(JSON.stringify(limited) + "\n");
@@ -4344,7 +4834,8 @@ var profileCommand = new Command18("profile").description("Generate a context pr
4344
4834
 
4345
4835
  // src/commands/tidy.ts
4346
4836
  import { Command as Command19 } from "@commander-js/extra-typings";
4347
- import { readFileSync as readFileSync7 } from "fs";
4837
+ import { readFileSync as readFileSync8 } from "fs";
4838
+ var TIDY_CONTENT_MAX_CHARS = 3e3;
4348
4839
  var TIDY_SYSTEM = `You are a note quality reviewer. Given a note, provide brief, actionable suggestions to improve it.
4349
4840
 
4350
4841
  Focus on:
@@ -4391,13 +4882,13 @@ var tidyCommand = new Command19("tidy").description("LLM-powered note cleanup su
4391
4882
  `);
4392
4883
  let content;
4393
4884
  try {
4394
- content = readFileSync7(note.filePath, "utf-8");
4885
+ content = readFileSync8(note.filePath, "utf-8");
4395
4886
  } catch {
4396
4887
  process.stderr.write(` Skipping: file not found
4397
4888
  `);
4398
4889
  continue;
4399
4890
  }
4400
- const truncated = content.slice(0, 3e3);
4891
+ const truncated = content.slice(0, TIDY_CONTENT_MAX_CHARS);
4401
4892
  const suggestions = await llm.generate(truncated, TIDY_SYSTEM);
4402
4893
  results.push({ noteId: note.id, title: note.title, suggestions });
4403
4894
  }
@@ -4416,18 +4907,18 @@ ${r.title} (${r.noteId})
4416
4907
 
4417
4908
  // src/commands/install-hooks.ts
4418
4909
  import { Command as Command20 } from "@commander-js/extra-typings";
4419
- import { existsSync as existsSync8, mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, unlinkSync, statSync as statSync2 } from "fs";
4420
- import { join as join8 } from "path";
4910
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync5, unlinkSync, statSync as statSync2 } from "fs";
4911
+ import { join as join10 } from "path";
4421
4912
  import { homedir as homedir2, platform } from "os";
4422
4913
  import { execSync as execSync2 } from "child_process";
4423
4914
  var PLIST_LABEL = "com.brain.index";
4424
- var PLIST_PATH = join8(homedir2(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
4425
- var SYSTEMD_DIR = join8(homedir2(), ".config", "systemd", "user");
4915
+ var PLIST_PATH = join10(homedir2(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
4916
+ var SYSTEMD_DIR = join10(homedir2(), ".config", "systemd", "user");
4426
4917
  var SYSTEMD_SERVICE = "brain-index.service";
4427
4918
  var SYSTEMD_TIMER = "brain-index.timer";
4428
4919
  function generateLaunchdPlist(brainBin, intervalMinutes, notesDir) {
4429
4920
  const intervalSeconds = intervalMinutes * 60;
4430
- const logPath = join8(notesDir, ".brain-hook.log");
4921
+ const logPath = join10(notesDir, ".brain-hook.log");
4431
4922
  return `<?xml version="1.0" encoding="UTF-8"?>
4432
4923
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4433
4924
  <plist version="1.0">
@@ -4485,7 +4976,7 @@ function findBrainBin() {
4485
4976
  function installLaunchd(intervalMinutes, notesDir) {
4486
4977
  const brainBin = findBrainBin();
4487
4978
  const plist = generateLaunchdPlist(brainBin, intervalMinutes, notesDir);
4488
- if (existsSync8(PLIST_PATH)) {
4979
+ if (existsSync10(PLIST_PATH)) {
4489
4980
  try {
4490
4981
  execSync2(`launchctl unload "${PLIST_PATH}"`, { stdio: "ignore" });
4491
4982
  } catch {
@@ -4507,9 +4998,9 @@ function installLaunchd(intervalMinutes, notesDir) {
4507
4998
  }
4508
4999
  function installSystemd(intervalMinutes) {
4509
5000
  const brainBin = findBrainBin();
4510
- const servicePath = join8(SYSTEMD_DIR, SYSTEMD_SERVICE);
4511
- const timerPath = join8(SYSTEMD_DIR, SYSTEMD_TIMER);
4512
- mkdirSync7(SYSTEMD_DIR, { recursive: true });
5001
+ const servicePath = join10(SYSTEMD_DIR, SYSTEMD_SERVICE);
5002
+ const timerPath = join10(SYSTEMD_DIR, SYSTEMD_TIMER);
5003
+ mkdirSync8(SYSTEMD_DIR, { recursive: true });
4513
5004
  writeFileSync5(servicePath, generateSystemdService(brainBin), "utf-8");
4514
5005
  writeFileSync5(timerPath, generateSystemdTimer(intervalMinutes), "utf-8");
4515
5006
  try {
@@ -4528,7 +5019,7 @@ function installSystemd(intervalMinutes) {
4528
5019
  `);
4529
5020
  }
4530
5021
  function uninstallLaunchd() {
4531
- if (!existsSync8(PLIST_PATH)) {
5022
+ if (!existsSync10(PLIST_PATH)) {
4532
5023
  process.stderr.write("No launchd agent installed.\n");
4533
5024
  return;
4534
5025
  }
@@ -4540,9 +5031,9 @@ function uninstallLaunchd() {
4540
5031
  process.stderr.write("Uninstalled launchd agent.\n");
4541
5032
  }
4542
5033
  function uninstallSystemd() {
4543
- const timerPath = join8(SYSTEMD_DIR, SYSTEMD_TIMER);
4544
- const servicePath = join8(SYSTEMD_DIR, SYSTEMD_SERVICE);
4545
- if (!existsSync8(timerPath)) {
5034
+ const timerPath = join10(SYSTEMD_DIR, SYSTEMD_TIMER);
5035
+ const servicePath = join10(SYSTEMD_DIR, SYSTEMD_SERVICE);
5036
+ if (!existsSync10(timerPath)) {
4546
5037
  process.stderr.write("No systemd timer installed.\n");
4547
5038
  return;
4548
5039
  }
@@ -4552,29 +5043,29 @@ function uninstallSystemd() {
4552
5043
  });
4553
5044
  } catch {
4554
5045
  }
4555
- if (existsSync8(timerPath)) unlinkSync(timerPath);
4556
- if (existsSync8(servicePath)) unlinkSync(servicePath);
5046
+ if (existsSync10(timerPath)) unlinkSync(timerPath);
5047
+ if (existsSync10(servicePath)) unlinkSync(servicePath);
4557
5048
  process.stderr.write("Uninstalled systemd timer.\n");
4558
5049
  }
4559
5050
  function getStatus(notesDir) {
4560
5051
  const os = platform();
4561
5052
  if (os === "darwin") {
4562
- const logPath = join8(notesDir, ".brain-hook.log");
5053
+ const logPath = join10(notesDir, ".brain-hook.log");
4563
5054
  let logSize = null;
4564
5055
  try {
4565
5056
  logSize = statSync2(logPath).size;
4566
5057
  } catch {
4567
5058
  }
4568
5059
  return {
4569
- installed: existsSync8(PLIST_PATH),
5060
+ installed: existsSync10(PLIST_PATH),
4570
5061
  platform: "macOS (launchd)",
4571
5062
  logSize
4572
5063
  };
4573
5064
  }
4574
5065
  if (os === "linux") {
4575
- const timerPath = join8(SYSTEMD_DIR, SYSTEMD_TIMER);
5066
+ const timerPath = join10(SYSTEMD_DIR, SYSTEMD_TIMER);
4576
5067
  return {
4577
- installed: existsSync8(timerPath),
5068
+ installed: existsSync10(timerPath),
4578
5069
  platform: "Linux (systemd)",
4579
5070
  logSize: null
4580
5071
  };
@@ -4597,7 +5088,7 @@ var installHooksCommand = new Command20("install-hooks").description("Set up sch
4597
5088
  `);
4598
5089
  if (status.logSize !== null) {
4599
5090
  const kb = (status.logSize / 1024).toFixed(1);
4600
- process.stderr.write(` Log: ${join8(config.notesDir, ".brain-hook.log")} (${kb} KB)
5091
+ process.stderr.write(` Log: ${join10(config.notesDir, ".brain-hook.log")} (${kb} KB)
4601
5092
  `);
4602
5093
  }
4603
5094
  }
@@ -4945,7 +5436,14 @@ program.addCommand(tidyCommand);
4945
5436
  program.addCommand(installHooksCommand);
4946
5437
  program.addCommand(doctorCommand);
4947
5438
  program.addCommand(lineageCommand);
4948
- program.parseAsync().catch((err) => {
5439
+ async function main() {
5440
+ const { registry } = await loadModules();
5441
+ for (const { command } of registry.getCommands()) {
5442
+ program.addCommand(command);
5443
+ }
5444
+ await program.parseAsync();
5445
+ }
5446
+ main().catch((err) => {
4949
5447
  process.stderr.write(`Error: ${err.message}
4950
5448
  `);
4951
5449
  process.exitCode = 1;