@gettymade/roux 0.1.3 → 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/index.js CHANGED
@@ -8,6 +8,10 @@ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
8
  var __commonJS = (cb, mod) => function __require() {
9
9
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
10
  };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
11
15
  var __copyProps = (to, from, except, desc) => {
12
16
  if (from && typeof from === "object" || typeof from === "function") {
13
17
  for (let key of __getOwnPropNames(from))
@@ -193,10 +197,83 @@ import { access as access2 } from "fs/promises";
193
197
  import { join as join4 } from "path";
194
198
 
195
199
  // src/providers/docstore/cache.ts
196
- var import_string_similarity = __toESM(require_src(), 1);
197
200
  import Database from "better-sqlite3";
198
201
  import { join as join2 } from "path";
199
202
  import { mkdirSync } from "fs";
203
+
204
+ // src/providers/docstore/cache/centrality.ts
205
+ function initCentralitySchema(db) {
206
+ db.exec(`
207
+ CREATE TABLE IF NOT EXISTS centrality (
208
+ node_id TEXT PRIMARY KEY,
209
+ pagerank REAL,
210
+ in_degree INTEGER,
211
+ out_degree INTEGER,
212
+ computed_at INTEGER,
213
+ FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
214
+ )
215
+ `);
216
+ }
217
+ function storeCentrality(db, nodeId, pagerank, inDegree, outDegree, computedAt) {
218
+ db.prepare(
219
+ `
220
+ INSERT INTO centrality (node_id, pagerank, in_degree, out_degree, computed_at)
221
+ VALUES (?, ?, ?, ?, ?)
222
+ ON CONFLICT(node_id) DO UPDATE SET
223
+ pagerank = excluded.pagerank,
224
+ in_degree = excluded.in_degree,
225
+ out_degree = excluded.out_degree,
226
+ computed_at = excluded.computed_at
227
+ `
228
+ ).run(nodeId, pagerank, inDegree, outDegree, computedAt);
229
+ }
230
+ function getCentrality(db, nodeId) {
231
+ const row = db.prepare("SELECT * FROM centrality WHERE node_id = ?").get(nodeId);
232
+ if (!row) return null;
233
+ return {
234
+ pagerank: row.pagerank,
235
+ inDegree: row.in_degree,
236
+ outDegree: row.out_degree,
237
+ computedAt: row.computed_at
238
+ };
239
+ }
240
+
241
+ // src/providers/store/resolve.ts
242
+ var import_string_similarity = __toESM(require_src(), 1);
243
+ function resolveNames(names, candidates, options) {
244
+ if (names.length === 0) return [];
245
+ const { strategy, threshold } = options;
246
+ if (candidates.length === 0) {
247
+ return names.map((query) => ({ query, match: null, score: 0 }));
248
+ }
249
+ const candidateTitles = candidates.map((c) => c.title.toLowerCase());
250
+ const titleToId = /* @__PURE__ */ new Map();
251
+ for (const c of candidates) {
252
+ titleToId.set(c.title.toLowerCase(), c.id);
253
+ }
254
+ return names.map((query) => {
255
+ const queryLower = query.toLowerCase();
256
+ if (strategy === "exact") {
257
+ const matchedId = titleToId.get(queryLower);
258
+ if (matchedId) {
259
+ return { query, match: matchedId, score: 1 };
260
+ }
261
+ return { query, match: null, score: 0 };
262
+ }
263
+ if (strategy === "fuzzy") {
264
+ const result = import_string_similarity.default.findBestMatch(queryLower, candidateTitles);
265
+ const bestMatch = result.bestMatch;
266
+ if (bestMatch.rating >= threshold) {
267
+ const matchedId = titleToId.get(bestMatch.target);
268
+ return { query, match: matchedId, score: bestMatch.rating };
269
+ }
270
+ return { query, match: null, score: 0 };
271
+ }
272
+ return { query, match: null, score: 0 };
273
+ });
274
+ }
275
+
276
+ // src/providers/docstore/cache.ts
200
277
  var Cache = class {
201
278
  db;
202
279
  constructor(cacheDir) {
@@ -220,24 +297,9 @@ var Cache = class {
220
297
  source_modified INTEGER
221
298
  );
222
299
 
223
- CREATE TABLE IF NOT EXISTS embeddings (
224
- node_id TEXT PRIMARY KEY,
225
- model TEXT,
226
- vector BLOB,
227
- FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
228
- );
229
-
230
- CREATE TABLE IF NOT EXISTS centrality (
231
- node_id TEXT PRIMARY KEY,
232
- pagerank REAL,
233
- in_degree INTEGER,
234
- out_degree INTEGER,
235
- computed_at INTEGER,
236
- FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
237
- );
238
-
239
300
  CREATE INDEX IF NOT EXISTS idx_nodes_source_path ON nodes(source_path);
240
301
  `);
302
+ initCentralitySchema(this.db);
241
303
  this.db.pragma("foreign_keys = ON");
242
304
  }
243
305
  getTableNames() {
@@ -297,25 +359,37 @@ var Cache = class {
297
359
  const rows = this.db.prepare("SELECT * FROM nodes").all();
298
360
  return rows.map((row) => this.rowToNode(row));
299
361
  }
300
- searchByTags(tags, mode) {
362
+ searchByTags(tags, mode, limit) {
301
363
  if (tags.length === 0) return [];
302
- const allNodes = this.getAllNodes();
303
364
  const lowerTags = tags.map((t) => t.toLowerCase());
304
- return allNodes.filter((node) => {
305
- const nodeTags = node.tags.map((t) => t.toLowerCase());
306
- if (mode === "any") {
307
- return lowerTags.some((t) => nodeTags.includes(t));
308
- } else {
309
- return lowerTags.every((t) => nodeTags.includes(t));
310
- }
311
- });
365
+ let query;
366
+ const params = [];
367
+ if (mode === "any") {
368
+ const tagConditions = lowerTags.map(
369
+ () => "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) = ?)"
370
+ ).join(" OR ");
371
+ query = `SELECT * FROM nodes WHERE ${tagConditions}`;
372
+ params.push(...lowerTags);
373
+ } else {
374
+ const tagConditions = lowerTags.map(
375
+ () => "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) = ?)"
376
+ ).join(" AND ");
377
+ query = `SELECT * FROM nodes WHERE ${tagConditions}`;
378
+ params.push(...lowerTags);
379
+ }
380
+ if (limit !== void 0) {
381
+ query += " LIMIT ?";
382
+ params.push(limit);
383
+ }
384
+ const rows = this.db.prepare(query).all(...params);
385
+ return rows.map((row) => this.rowToNode(row));
312
386
  }
313
387
  getModifiedTime(sourcePath) {
314
388
  const row = this.db.prepare("SELECT source_modified FROM nodes WHERE source_path = ?").get(sourcePath);
315
389
  return row?.source_modified ?? null;
316
390
  }
317
391
  getNodeByPath(sourcePath) {
318
- const row = this.db.prepare("SELECT * FROM nodes WHERE source_path = ?").get(sourcePath);
392
+ const row = this.db.prepare("SELECT * FROM nodes WHERE LOWER(source_path) = LOWER(?)").get(sourcePath);
319
393
  if (!row) return null;
320
394
  return this.rowToNode(row);
321
395
  }
@@ -323,6 +397,13 @@ var Cache = class {
323
397
  const rows = this.db.prepare("SELECT source_path FROM nodes").all();
324
398
  return new Set(rows.map((r) => r.source_path));
325
399
  }
400
+ updateSourcePath(id, newPath) {
401
+ this.db.prepare("UPDATE nodes SET source_path = ? WHERE id = ?").run(newPath, id);
402
+ }
403
+ getIdByPath(sourcePath) {
404
+ const row = this.db.prepare("SELECT id FROM nodes WHERE source_path = ?").get(sourcePath);
405
+ return row?.id ?? null;
406
+ }
326
407
  resolveTitles(ids) {
327
408
  if (ids.length === 0) return /* @__PURE__ */ new Map();
328
409
  const placeholders = ids.map(() => "?").join(",");
@@ -354,7 +435,7 @@ var Cache = class {
354
435
  params.push(filter.tag);
355
436
  }
356
437
  if (filter.path) {
357
- conditions.push("id LIKE ? || '%'");
438
+ conditions.push("LOWER(source_path) LIKE '%' || LOWER(?) || '%'");
358
439
  params.push(filter.path);
359
440
  }
360
441
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
@@ -374,99 +455,27 @@ var Cache = class {
374
455
  if (options?.tag) filter.tag = options.tag;
375
456
  if (options?.path) filter.path = options.path;
376
457
  const { nodes: candidates } = this.listNodes(filter, { limit: 1e3 });
377
- if (candidates.length === 0) {
378
- return names.map((query) => ({ query, match: null, score: 0 }));
379
- }
380
- const candidateTitles = candidates.map((c) => c.title.toLowerCase());
381
- const titleToId = /* @__PURE__ */ new Map();
382
- for (const c of candidates) {
383
- titleToId.set(c.title.toLowerCase(), c.id);
384
- }
385
- return names.map((query) => {
386
- const queryLower = query.toLowerCase();
387
- if (strategy === "exact") {
388
- const matchedId = titleToId.get(queryLower);
389
- if (matchedId) {
390
- return { query, match: matchedId, score: 1 };
391
- }
392
- return { query, match: null, score: 0 };
393
- }
394
- if (strategy === "fuzzy") {
395
- const result = import_string_similarity.default.findBestMatch(queryLower, candidateTitles);
396
- const bestMatch = result.bestMatch;
397
- if (bestMatch.rating >= threshold) {
398
- const matchedId = titleToId.get(bestMatch.target);
399
- return { query, match: matchedId, score: bestMatch.rating };
400
- }
401
- return { query, match: null, score: 0 };
402
- }
403
- return { query, match: null, score: 0 };
404
- });
458
+ return resolveNames(names, candidates, { strategy, threshold });
405
459
  }
406
460
  updateOutgoingLinks(nodeId, links) {
407
461
  this.db.prepare("UPDATE nodes SET outgoing_links = ? WHERE id = ?").run(JSON.stringify(links), nodeId);
408
462
  }
409
- storeEmbedding(nodeId, vector, model) {
410
- const buffer = Buffer.from(new Float32Array(vector).buffer);
411
- this.db.prepare(
412
- `
413
- INSERT INTO embeddings (node_id, model, vector)
414
- VALUES (?, ?, ?)
415
- ON CONFLICT(node_id) DO UPDATE SET
416
- model = excluded.model,
417
- vector = excluded.vector
418
- `
419
- ).run(nodeId, model, buffer);
420
- }
421
- getEmbedding(nodeId) {
422
- const row = this.db.prepare("SELECT model, vector FROM embeddings WHERE node_id = ?").get(nodeId);
423
- if (!row) return null;
424
- const float32 = new Float32Array(
425
- row.vector.buffer,
426
- row.vector.byteOffset,
427
- row.vector.length / 4
428
- );
429
- return {
430
- model: row.model,
431
- vector: Array.from(float32)
432
- };
433
- }
434
463
  storeCentrality(nodeId, pagerank, inDegree, outDegree, computedAt) {
435
- this.db.prepare(
436
- `
437
- INSERT INTO centrality (node_id, pagerank, in_degree, out_degree, computed_at)
438
- VALUES (?, ?, ?, ?, ?)
439
- ON CONFLICT(node_id) DO UPDATE SET
440
- pagerank = excluded.pagerank,
441
- in_degree = excluded.in_degree,
442
- out_degree = excluded.out_degree,
443
- computed_at = excluded.computed_at
444
- `
445
- ).run(nodeId, pagerank, inDegree, outDegree, computedAt);
464
+ storeCentrality(this.db, nodeId, pagerank, inDegree, outDegree, computedAt);
446
465
  }
447
466
  getCentrality(nodeId) {
448
- const row = this.db.prepare("SELECT * FROM centrality WHERE node_id = ?").get(nodeId);
449
- if (!row) return null;
450
- return {
451
- pagerank: row.pagerank,
452
- inDegree: row.in_degree,
453
- outDegree: row.out_degree,
454
- computedAt: row.computed_at
455
- };
467
+ return getCentrality(this.db, nodeId);
456
468
  }
457
469
  getStats() {
458
470
  const nodeCount = this.db.prepare("SELECT COUNT(*) as count FROM nodes").get();
459
- const embeddingCount = this.db.prepare("SELECT COUNT(*) as count FROM embeddings").get();
460
471
  const edgeSum = this.db.prepare("SELECT SUM(in_degree) as total FROM centrality").get();
461
472
  return {
462
473
  nodeCount: nodeCount.count,
463
- embeddingCount: embeddingCount.count,
464
474
  edgeCount: edgeSum.total ?? 0
465
475
  };
466
476
  }
467
477
  clear() {
468
478
  this.db.exec("DELETE FROM centrality");
469
- this.db.exec("DELETE FROM embeddings");
470
479
  this.db.exec("DELETE FROM nodes");
471
480
  }
472
481
  close() {
@@ -493,9 +502,107 @@ var Cache = class {
493
502
  // src/providers/vector/sqlite.ts
494
503
  import Database2 from "better-sqlite3";
495
504
  import { join as join3 } from "path";
496
- var SqliteVectorProvider = class {
505
+
506
+ // src/utils/heap.ts
507
+ var MinHeap = class {
508
+ data = [];
509
+ compare;
510
+ constructor(comparator) {
511
+ this.compare = comparator;
512
+ }
513
+ size() {
514
+ return this.data.length;
515
+ }
516
+ peek() {
517
+ return this.data[0];
518
+ }
519
+ push(value) {
520
+ this.data.push(value);
521
+ this.bubbleUp(this.data.length - 1);
522
+ }
523
+ pop() {
524
+ if (this.data.length === 0) return void 0;
525
+ if (this.data.length === 1) return this.data.pop();
526
+ const min = this.data[0];
527
+ this.data[0] = this.data.pop();
528
+ this.bubbleDown(0);
529
+ return min;
530
+ }
531
+ toArray() {
532
+ return [...this.data];
533
+ }
534
+ bubbleUp(index) {
535
+ while (index > 0) {
536
+ const parentIndex = Math.floor((index - 1) / 2);
537
+ if (this.compare(this.data[index], this.data[parentIndex]) >= 0) {
538
+ break;
539
+ }
540
+ this.swap(index, parentIndex);
541
+ index = parentIndex;
542
+ }
543
+ }
544
+ bubbleDown(index) {
545
+ const length = this.data.length;
546
+ while (true) {
547
+ const leftChild = 2 * index + 1;
548
+ const rightChild = 2 * index + 2;
549
+ let smallest = index;
550
+ if (leftChild < length && this.compare(this.data[leftChild], this.data[smallest]) < 0) {
551
+ smallest = leftChild;
552
+ }
553
+ if (rightChild < length && this.compare(this.data[rightChild], this.data[smallest]) < 0) {
554
+ smallest = rightChild;
555
+ }
556
+ if (smallest === index) break;
557
+ this.swap(index, smallest);
558
+ index = smallest;
559
+ }
560
+ }
561
+ swap(i, j) {
562
+ const temp = this.data[i];
563
+ this.data[i] = this.data[j];
564
+ this.data[j] = temp;
565
+ }
566
+ };
567
+
568
+ // src/utils/math.ts
569
+ function cosineSimilarity(a, b) {
570
+ if (a.length === 0 || b.length === 0) {
571
+ throw new Error("Cannot compute similarity for empty vector");
572
+ }
573
+ if (a.length !== b.length) {
574
+ throw new Error(`Dimension mismatch: ${a.length} vs ${b.length}`);
575
+ }
576
+ let dotProduct = 0;
577
+ let normA = 0;
578
+ let normB = 0;
579
+ for (let i = 0; i < a.length; i++) {
580
+ dotProduct += a[i] * b[i];
581
+ normA += a[i] * a[i];
582
+ normB += b[i] * b[i];
583
+ }
584
+ if (normA === 0 || normB === 0) return 0;
585
+ return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
586
+ }
587
+ function cosineDistance(a, b) {
588
+ const similarity = cosineSimilarity(a, b);
589
+ if (similarity === 0 && (isZeroVector(a) || isZeroVector(b))) {
590
+ return 1;
591
+ }
592
+ return 1 - similarity;
593
+ }
594
+ function isZeroVector(v) {
595
+ for (let i = 0; i < v.length; i++) {
596
+ if (v[i] !== 0) return false;
597
+ }
598
+ return true;
599
+ }
600
+
601
+ // src/providers/vector/sqlite.ts
602
+ var SqliteVectorIndex = class {
497
603
  db;
498
604
  ownsDb;
605
+ modelMismatchWarned = false;
499
606
  constructor(pathOrDb) {
500
607
  if (typeof pathOrDb === "string") {
501
608
  this.db = new Database2(join3(pathOrDb, "vectors.db"));
@@ -547,29 +654,45 @@ var SqliteVectorProvider = class {
547
654
  if (limit <= 0) {
548
655
  return [];
549
656
  }
550
- const rows = this.db.prepare("SELECT id, vector FROM vectors").all();
551
- if (rows.length === 0) {
552
- return [];
553
- }
554
- const firstStoredDim = rows[0].vector.byteLength / 4;
555
- if (vector.length !== firstStoredDim) {
556
- throw new Error(
557
- `Dimension mismatch: query has ${vector.length} dimensions, stored vectors have ${firstStoredDim}`
558
- );
657
+ if (!this.modelMismatchWarned) {
658
+ const models = this.db.prepare("SELECT DISTINCT model FROM vectors").all();
659
+ if (models.length > 1) {
660
+ console.warn(
661
+ `Vector index contains embeddings from multiple models: ${models.map((m) => m.model).join(", ")}. Search results may be unreliable. Re-sync to re-embed all documents with current model.`
662
+ );
663
+ this.modelMismatchWarned = true;
664
+ }
559
665
  }
560
666
  const queryVec = new Float32Array(vector);
561
- const results = [];
562
- for (const row of rows) {
667
+ const stmt = this.db.prepare("SELECT id, vector FROM vectors");
668
+ const heap = new MinHeap(
669
+ (a, b) => b.distance - a.distance
670
+ );
671
+ let dimensionChecked = false;
672
+ for (const row of stmt.iterate()) {
673
+ if (!dimensionChecked) {
674
+ const storedDim = row.vector.byteLength / 4;
675
+ if (vector.length !== storedDim) {
676
+ throw new Error(
677
+ `Dimension mismatch: query has ${vector.length} dimensions, stored vectors have ${storedDim}`
678
+ );
679
+ }
680
+ dimensionChecked = true;
681
+ }
563
682
  const storedVec = new Float32Array(
564
683
  row.vector.buffer,
565
684
  row.vector.byteOffset,
566
685
  row.vector.byteLength / 4
567
686
  );
568
687
  const distance = cosineDistance(queryVec, storedVec);
569
- results.push({ id: row.id, distance });
688
+ if (heap.size() < limit) {
689
+ heap.push({ id: row.id, distance });
690
+ } else if (distance < heap.peek().distance) {
691
+ heap.pop();
692
+ heap.push({ id: row.id, distance });
693
+ }
570
694
  }
571
- results.sort((a, b) => a.distance - b.distance);
572
- return results.slice(0, limit);
695
+ return heap.toArray().sort((a, b) => a.distance - b.distance);
573
696
  }
574
697
  async delete(id) {
575
698
  this.db.prepare("DELETE FROM vectors WHERE id = ?").run(id);
@@ -603,23 +726,6 @@ var SqliteVectorProvider = class {
603
726
  }
604
727
  }
605
728
  };
606
- function cosineDistance(a, b) {
607
- let dotProduct = 0;
608
- let magnitudeA = 0;
609
- let magnitudeB = 0;
610
- for (let i = 0; i < a.length; i++) {
611
- dotProduct += a[i] * b[i];
612
- magnitudeA += a[i] * a[i];
613
- magnitudeB += b[i] * b[i];
614
- }
615
- magnitudeA = Math.sqrt(magnitudeA);
616
- magnitudeB = Math.sqrt(magnitudeB);
617
- if (magnitudeA === 0 || magnitudeB === 0) {
618
- return 1;
619
- }
620
- const similarity = dotProduct / (magnitudeA * magnitudeB);
621
- return 1 - similarity;
622
- }
623
729
 
624
730
  // src/cli/commands/status.ts
625
731
  async function statusCommand(directory) {
@@ -631,7 +737,7 @@ async function statusCommand(directory) {
631
737
  }
632
738
  const cacheDir = join4(directory, ".roux");
633
739
  const cache = new Cache(cacheDir);
634
- const vectorProvider = new SqliteVectorProvider(cacheDir);
740
+ const vectorProvider = new SqliteVectorIndex(cacheDir);
635
741
  try {
636
742
  const stats = cache.getStats();
637
743
  const embeddingCount = vectorProvider.getEmbeddingCount();
@@ -650,90 +756,13 @@ async function statusCommand(directory) {
650
756
 
651
757
  // src/cli/commands/serve.ts
652
758
  import { access as access3, readFile as readFile3 } from "fs/promises";
653
- import { join as join6 } from "path";
759
+ import { join as join7 } from "path";
654
760
  import { parse as parseYaml } from "yaml";
655
761
 
656
762
  // src/providers/docstore/index.ts
657
- import { readFile as readFile2, writeFile as writeFile2, stat, readdir, mkdir as mkdir2, rm } from "fs/promises";
658
- import { join as join5, relative, dirname, resolve } from "path";
659
- import { watch } from "chokidar";
660
-
661
- // src/providers/docstore/parser.ts
662
- import matter from "gray-matter";
663
- function parseMarkdown(raw) {
664
- let parsed;
665
- try {
666
- parsed = matter(raw);
667
- } catch {
668
- return {
669
- title: void 0,
670
- tags: [],
671
- properties: {},
672
- content: raw
673
- };
674
- }
675
- const data = parsed.data;
676
- const title = typeof data["title"] === "string" ? data["title"] : void 0;
677
- let tags = [];
678
- if (Array.isArray(data["tags"])) {
679
- tags = data["tags"].filter((t) => typeof t === "string");
680
- }
681
- const properties = {};
682
- for (const [key, value] of Object.entries(data)) {
683
- if (key !== "title" && key !== "tags") {
684
- properties[key] = value;
685
- }
686
- }
687
- return {
688
- title,
689
- tags,
690
- properties,
691
- content: parsed.content.trim()
692
- };
693
- }
694
- function extractWikiLinks(content) {
695
- const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, "");
696
- const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]+`/g, "");
697
- const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
698
- const seen = /* @__PURE__ */ new Set();
699
- const links = [];
700
- let match;
701
- while ((match = linkRegex.exec(withoutInlineCode)) !== null) {
702
- const target = match[1]?.trim();
703
- if (target && !seen.has(target)) {
704
- seen.add(target);
705
- links.push(target);
706
- }
707
- }
708
- return links;
709
- }
710
- function normalizeId(path) {
711
- return path.toLowerCase().replace(/\\/g, "/");
712
- }
713
- function titleFromPath(path) {
714
- const parts = path.split(/[/\\]/);
715
- const filename = parts.at(-1);
716
- const withoutExt = filename.replace(/\.[^.]+$/, "");
717
- const spaced = withoutExt.replace(/[-_]+/g, " ").toLowerCase();
718
- return spaced.split(" ").filter((w) => w.length > 0).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
719
- }
720
- function serializeToMarkdown(parsed) {
721
- const hasFrontmatter = parsed.title !== void 0 || parsed.tags.length > 0 || Object.keys(parsed.properties).length > 0;
722
- if (!hasFrontmatter) {
723
- return parsed.content;
724
- }
725
- const frontmatter = {};
726
- if (parsed.title !== void 0) {
727
- frontmatter["title"] = parsed.title;
728
- }
729
- if (parsed.tags.length > 0) {
730
- frontmatter["tags"] = parsed.tags;
731
- }
732
- for (const [key, value] of Object.entries(parsed.properties)) {
733
- frontmatter[key] = value;
734
- }
735
- return matter.stringify(parsed.content, frontmatter);
736
- }
763
+ import { writeFile as writeFile2, mkdir as mkdir2, rm, stat as stat2 } from "fs/promises";
764
+ import { mkdirSync as mkdirSync2 } from "fs";
765
+ import { join as join6, relative as relative2, dirname, extname as extname3 } from "path";
737
766
 
738
767
  // src/graph/builder.ts
739
768
  import { DirectedGraph } from "graphology";
@@ -757,31 +786,31 @@ function buildGraph(nodes) {
757
786
  return graph;
758
787
  }
759
788
 
760
- // src/graph/operations.ts
789
+ // src/graph/traversal.ts
761
790
  import { bidirectional } from "graphology-shortest-path";
762
791
  function getNeighborIds(graph, id, options) {
763
792
  if (!graph.hasNode(id)) {
764
793
  return [];
765
794
  }
766
- let neighbors;
767
- switch (options.direction) {
768
- case "in":
769
- neighbors = graph.inNeighbors(id);
770
- break;
771
- case "out":
772
- neighbors = graph.outNeighbors(id);
773
- break;
774
- case "both":
775
- neighbors = graph.neighbors(id);
776
- break;
777
- }
778
- if (options.limit !== void 0) {
779
- if (options.limit <= 0) {
780
- return [];
781
- }
782
- if (options.limit < neighbors.length) {
783
- return neighbors.slice(0, options.limit);
795
+ const limit = options.limit;
796
+ if (limit !== void 0 && limit <= 0) {
797
+ return [];
798
+ }
799
+ const maxCount = limit ?? Infinity;
800
+ const direction = options.direction;
801
+ if (direction === "both") {
802
+ const neighbors2 = [];
803
+ for (const entry of graph.neighborEntries(id)) {
804
+ if (neighbors2.length >= maxCount) break;
805
+ neighbors2.push(entry.neighbor);
784
806
  }
807
+ return neighbors2;
808
+ }
809
+ const neighbors = [];
810
+ const iterator = direction === "in" ? graph.inNeighborEntries(id) : graph.outNeighborEntries(id);
811
+ for (const entry of iterator) {
812
+ if (neighbors.length >= maxCount) break;
813
+ neighbors.push(entry.neighbor);
785
814
  }
786
815
  return neighbors;
787
816
  }
@@ -799,25 +828,24 @@ function getHubs(graph, metric, limit) {
799
828
  if (limit <= 0) {
800
829
  return [];
801
830
  }
802
- const scores = [];
831
+ const heap = new MinHeap((a, b) => a[1] - b[1]);
803
832
  graph.forEachNode((id) => {
804
- let score;
805
- switch (metric) {
806
- case "in_degree":
807
- score = graph.inDegree(id);
808
- break;
809
- case "out_degree":
810
- score = graph.outDegree(id);
811
- break;
812
- case "pagerank":
813
- score = graph.inDegree(id);
814
- break;
833
+ const score = metric === "in_degree" ? graph.inDegree(id) : graph.outDegree(id);
834
+ if (heap.size() < limit) {
835
+ heap.push([id, score]);
836
+ } else if (score > heap.peek()[1]) {
837
+ heap.pop();
838
+ heap.push([id, score]);
815
839
  }
816
- scores.push([id, score]);
817
840
  });
818
- scores.sort((a, b) => b[1] - a[1]);
819
- return scores.slice(0, limit);
841
+ return heap.toArray().sort((a, b) => {
842
+ const scoreDiff = b[1] - a[1];
843
+ if (scoreDiff !== 0) return scoreDiff;
844
+ return a[0].localeCompare(b[0]);
845
+ });
820
846
  }
847
+
848
+ // src/graph/analysis.ts
821
849
  function computeCentrality(graph) {
822
850
  const result = /* @__PURE__ */ new Map();
823
851
  graph.forEachNode((id) => {
@@ -829,149 +857,847 @@ function computeCentrality(graph) {
829
857
  return result;
830
858
  }
831
859
 
832
- // src/providers/docstore/index.ts
833
- var DocStore = class _DocStore {
834
- cache;
835
- sourceRoot;
860
+ // src/graph/manager.ts
861
+ var GraphNotReadyError = class extends Error {
862
+ constructor() {
863
+ super("Graph not built. Call build() before querying.");
864
+ this.name = "GraphNotReadyError";
865
+ }
866
+ };
867
+ var GraphManager = class {
836
868
  graph = null;
837
- vectorProvider;
838
- ownsVectorProvider;
839
- watcher = null;
840
- debounceTimer = null;
841
- pendingChanges = /* @__PURE__ */ new Map();
842
- onChangeCallback;
843
- constructor(sourceRoot, cacheDir, vectorProvider) {
844
- this.sourceRoot = sourceRoot;
845
- this.cache = new Cache(cacheDir);
846
- this.ownsVectorProvider = !vectorProvider;
847
- this.vectorProvider = vectorProvider ?? new SqliteVectorProvider(cacheDir);
869
+ /** Build graph and return centrality metrics. Caller stores as needed. */
870
+ build(nodes) {
871
+ this.graph = buildGraph(nodes);
872
+ return computeCentrality(this.graph);
848
873
  }
849
- async sync() {
850
- const currentPaths = await this.collectMarkdownFiles(this.sourceRoot);
851
- const trackedPaths = this.cache.getAllTrackedPaths();
852
- for (const filePath of currentPaths) {
853
- try {
854
- const mtime = await this.getFileMtime(filePath);
855
- const cachedMtime = this.cache.getModifiedTime(filePath);
856
- if (cachedMtime === null || mtime > cachedMtime) {
857
- const node = await this.fileToNode(filePath);
858
- this.cache.upsertNode(node, "file", filePath, mtime);
859
- }
860
- } catch (err) {
861
- if (err.code === "ENOENT") {
862
- continue;
863
- }
864
- throw err;
865
- }
866
- }
867
- const currentSet = new Set(currentPaths);
868
- for (const tracked of trackedPaths) {
869
- if (!currentSet.has(tracked)) {
870
- const node = this.cache.getNodeByPath(tracked);
871
- if (node) {
872
- this.cache.deleteNode(node.id);
873
- }
874
- }
875
- }
876
- const filenameIndex = this.buildFilenameIndex();
877
- this.resolveOutgoingLinks(filenameIndex);
878
- this.rebuildGraph();
874
+ /** Throws GraphNotReadyError if not built. Returns graph for query use. */
875
+ assertReady() {
876
+ if (!this.graph) throw new GraphNotReadyError();
877
+ return this.graph;
879
878
  }
880
- async createNode(node) {
881
- const normalizedId = normalizeId(node.id);
882
- this.validatePathWithinSource(normalizedId);
883
- const existing = this.cache.getNode(normalizedId);
884
- if (existing) {
885
- throw new Error(`Node already exists: ${normalizedId}`);
886
- }
887
- const filePath = join5(this.sourceRoot, normalizedId);
888
- const dir = dirname(filePath);
889
- await mkdir2(dir, { recursive: true });
890
- const parsed = {
891
- title: node.title,
892
- tags: node.tags,
893
- properties: node.properties,
894
- content: node.content
895
- };
896
- const markdown = serializeToMarkdown(parsed);
897
- await writeFile2(filePath, markdown, "utf-8");
898
- const mtime = await this.getFileMtime(filePath);
899
- const normalizedNode = { ...node, id: normalizedId };
900
- this.cache.upsertNode(normalizedNode, "file", filePath, mtime);
901
- this.rebuildGraph();
879
+ isReady() {
880
+ return this.graph !== null;
902
881
  }
903
- async updateNode(id, updates) {
904
- const normalizedId = normalizeId(id);
905
- const existing = this.cache.getNode(normalizedId);
906
- if (!existing) {
907
- throw new Error(`Node not found: ${id}`);
908
- }
909
- let outgoingLinks = updates.outgoingLinks;
910
- if (updates.content !== void 0 && outgoingLinks === void 0) {
911
- const rawLinks = extractWikiLinks(updates.content);
912
- outgoingLinks = rawLinks.map((link) => this.normalizeWikiLink(link));
913
- }
914
- const updated = {
915
- ...existing,
916
- ...updates,
917
- outgoingLinks: outgoingLinks ?? existing.outgoingLinks,
918
- id: existing.id
919
- // ID cannot be changed
920
- };
921
- const filePath = join5(this.sourceRoot, existing.id);
922
- const parsed = {
923
- title: updated.title,
924
- tags: updated.tags,
925
- properties: updated.properties,
926
- content: updated.content
927
- };
928
- const markdown = serializeToMarkdown(parsed);
929
- await writeFile2(filePath, markdown, "utf-8");
930
- const mtime = await this.getFileMtime(filePath);
931
- this.cache.upsertNode(updated, "file", filePath, mtime);
932
- if (outgoingLinks !== void 0 || updates.outgoingLinks !== void 0) {
933
- this.rebuildGraph();
934
- }
882
+ getNeighborIds(id, options) {
883
+ return getNeighborIds(this.assertReady(), id, options);
935
884
  }
936
- async deleteNode(id) {
937
- const normalizedId = normalizeId(id);
938
- const existing = this.cache.getNode(normalizedId);
939
- if (!existing) {
940
- throw new Error(`Node not found: ${id}`);
941
- }
942
- const filePath = join5(this.sourceRoot, existing.id);
943
- await rm(filePath);
885
+ findPath(source, target) {
886
+ return findPath(this.assertReady(), source, target);
887
+ }
888
+ getHubs(metric, limit) {
889
+ return getHubs(this.assertReady(), metric, limit);
890
+ }
891
+ };
892
+
893
+ // src/providers/store/index.ts
894
+ var StoreProvider = class {
895
+ graphManager = new GraphManager();
896
+ vectorIndex;
897
+ constructor(options) {
898
+ this.vectorIndex = options?.vectorIndex ?? null;
899
+ }
900
+ // ── Graph operations (delegate to GraphManager) ────────────
901
+ async getNeighbors(id, options) {
902
+ if (!this.graphManager.isReady()) return [];
903
+ const neighborIds = this.graphManager.getNeighborIds(id, options);
904
+ return this.getNodesByIds(neighborIds);
905
+ }
906
+ async findPath(source, target) {
907
+ if (!this.graphManager.isReady()) return null;
908
+ return this.graphManager.findPath(source, target);
909
+ }
910
+ async getHubs(metric, limit) {
911
+ if (!this.graphManager.isReady()) return [];
912
+ return this.graphManager.getHubs(metric, limit);
913
+ }
914
+ // ── Vector operations (delegate to VectorIndex) ────────────
915
+ async storeEmbedding(id, vector, model) {
916
+ if (!this.vectorIndex) throw new Error("No VectorIndex configured");
917
+ return this.vectorIndex.store(id, vector, model);
918
+ }
919
+ async searchByVector(vector, limit) {
920
+ if (!this.vectorIndex) throw new Error("No VectorIndex configured");
921
+ return this.vectorIndex.search(vector, limit);
922
+ }
923
+ // ── Discovery ──────────────────────────────────────────────
924
+ async getRandomNode(tags) {
925
+ let candidates;
926
+ if (tags && tags.length > 0) {
927
+ candidates = await this.searchByTags(tags, "any");
928
+ } else {
929
+ candidates = await this.loadAllNodes();
930
+ }
931
+ if (candidates.length === 0) return null;
932
+ return candidates[Math.floor(Math.random() * candidates.length)];
933
+ }
934
+ // ── Default implementations (overridable) ──────────────────
935
+ async searchByTags(tags, mode, limit) {
936
+ const allNodes = await this.loadAllNodes();
937
+ const lowerTags = tags.map((t) => t.toLowerCase());
938
+ let results = allNodes.filter((node) => {
939
+ const nodeTags = node.tags.map((t) => t.toLowerCase());
940
+ return mode === "any" ? lowerTags.some((t) => nodeTags.includes(t)) : lowerTags.every((t) => nodeTags.includes(t));
941
+ });
942
+ if (limit !== void 0) results = results.slice(0, limit);
943
+ return results;
944
+ }
945
+ async listNodes(filter, options) {
946
+ let nodes = await this.loadAllNodes();
947
+ if (filter.tag) {
948
+ const lower = filter.tag.toLowerCase();
949
+ nodes = nodes.filter((n) => n.tags.some((t) => t.toLowerCase() === lower));
950
+ }
951
+ if (filter.path) {
952
+ const lowerPath = filter.path.toLowerCase();
953
+ nodes = nodes.filter((n) => n.id.startsWith(lowerPath));
954
+ }
955
+ const total = nodes.length;
956
+ const offset = options?.offset ?? 0;
957
+ const limit = Math.min(options?.limit ?? 100, 1e3);
958
+ const sliced = nodes.slice(offset, offset + limit);
959
+ return {
960
+ nodes: sliced.map((n) => ({ id: n.id, title: n.title })),
961
+ total
962
+ };
963
+ }
964
+ async nodesExist(ids) {
965
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
966
+ const found = await this.getNodesByIds(ids);
967
+ const foundIds = new Set(found.map((n) => n.id));
968
+ const result = /* @__PURE__ */ new Map();
969
+ for (const id of ids) {
970
+ result.set(id, foundIds.has(id));
971
+ }
972
+ return result;
973
+ }
974
+ async resolveTitles(ids) {
975
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
976
+ const nodes = await this.getNodesByIds(ids);
977
+ const result = /* @__PURE__ */ new Map();
978
+ for (const node of nodes) {
979
+ result.set(node.id, node.title);
980
+ }
981
+ return result;
982
+ }
983
+ async resolveNodes(names, options) {
984
+ const strategy = options?.strategy ?? "fuzzy";
985
+ if (strategy === "semantic") {
986
+ return names.map((query) => ({ query, match: null, score: 0 }));
987
+ }
988
+ const allNodes = await this.loadAllNodes();
989
+ let candidates = allNodes.map((n) => ({ id: n.id, title: n.title }));
990
+ if (options?.tag) {
991
+ const lower = options.tag.toLowerCase();
992
+ const filtered = allNodes.filter((n) => n.tags.some((t) => t.toLowerCase() === lower));
993
+ candidates = filtered.map((n) => ({ id: n.id, title: n.title }));
994
+ }
995
+ if (options?.path) {
996
+ const lowerPath = options.path.toLowerCase();
997
+ candidates = candidates.filter((c) => c.id.startsWith(lowerPath));
998
+ }
999
+ return resolveNames(names, candidates, {
1000
+ strategy,
1001
+ threshold: options?.threshold ?? 0.7
1002
+ });
1003
+ }
1004
+ // ── Graph lifecycle ────────────────────────────────────────
1005
+ async syncGraph() {
1006
+ const nodes = await this.loadAllNodes();
1007
+ const centrality = this.graphManager.build(nodes);
1008
+ this.onCentralityComputed(centrality);
1009
+ }
1010
+ onCentralityComputed(_centrality) {
1011
+ }
1012
+ };
1013
+
1014
+ // src/providers/docstore/parser.ts
1015
+ import matter from "gray-matter";
1016
+
1017
+ // src/providers/docstore/normalize.ts
1018
+ function hasFileExtension(path) {
1019
+ const match = path.match(/\.([a-z0-9]{1,4})$/i);
1020
+ if (!match?.[1]) return false;
1021
+ return /[a-z]/i.test(match[1]);
1022
+ }
1023
+ function normalizePath(path) {
1024
+ return path.toLowerCase().replace(/\\/g, "/");
1025
+ }
1026
+ function normalizeLinkTarget(target) {
1027
+ let normalized = target.trim().toLowerCase().replace(/\\/g, "/");
1028
+ if (!hasFileExtension(normalized)) {
1029
+ normalized += ".md";
1030
+ }
1031
+ return normalized;
1032
+ }
1033
+
1034
+ // src/providers/docstore/parser.ts
1035
+ var RESERVED_FRONTMATTER_KEYS = ["id", "title", "tags"];
1036
+ var RESERVED_KEYS_SET = new Set(RESERVED_FRONTMATTER_KEYS);
1037
+ function parseMarkdown(raw) {
1038
+ let parsed;
1039
+ try {
1040
+ parsed = matter(raw);
1041
+ } catch {
1042
+ return {
1043
+ title: void 0,
1044
+ tags: [],
1045
+ properties: {},
1046
+ content: raw,
1047
+ rawLinks: extractWikiLinks(raw)
1048
+ };
1049
+ }
1050
+ const data = parsed.data;
1051
+ const id = typeof data["id"] === "string" ? data["id"] : void 0;
1052
+ const title = typeof data["title"] === "string" ? data["title"] : void 0;
1053
+ let tags = [];
1054
+ if (Array.isArray(data["tags"])) {
1055
+ tags = data["tags"].filter((t) => typeof t === "string");
1056
+ }
1057
+ const properties = {};
1058
+ for (const [key, value] of Object.entries(data)) {
1059
+ if (!RESERVED_KEYS_SET.has(key)) {
1060
+ properties[key] = value;
1061
+ }
1062
+ }
1063
+ const content = parsed.content.trim();
1064
+ const result = {
1065
+ title,
1066
+ tags,
1067
+ properties,
1068
+ content,
1069
+ rawLinks: extractWikiLinks(content)
1070
+ };
1071
+ if (id !== void 0) {
1072
+ result.id = id;
1073
+ }
1074
+ return result;
1075
+ }
1076
+ function extractWikiLinks(content) {
1077
+ const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, "");
1078
+ const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]+`/g, "");
1079
+ const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
1080
+ const seen = /* @__PURE__ */ new Set();
1081
+ const links = [];
1082
+ let match;
1083
+ while ((match = linkRegex.exec(withoutInlineCode)) !== null) {
1084
+ const target = match[1]?.trim();
1085
+ if (target && !seen.has(target)) {
1086
+ seen.add(target);
1087
+ links.push(target);
1088
+ }
1089
+ }
1090
+ return links;
1091
+ }
1092
+ var normalizeId = normalizePath;
1093
+ function titleFromPath(path) {
1094
+ const parts = path.split(/[/\\]/);
1095
+ const filename = parts.at(-1);
1096
+ const withoutExt = filename.replace(/\.[^.]+$/, "");
1097
+ const spaced = withoutExt.replace(/[-_]+/g, " ").toLowerCase();
1098
+ return spaced.split(" ").filter((w) => w.length > 0).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
1099
+ }
1100
+ function serializeToMarkdown(parsed) {
1101
+ const hasFrontmatter = parsed.id !== void 0 || parsed.title !== void 0 || parsed.tags.length > 0 || Object.keys(parsed.properties).length > 0;
1102
+ if (!hasFrontmatter) {
1103
+ return parsed.content;
1104
+ }
1105
+ const frontmatter = {};
1106
+ if (parsed.id !== void 0) {
1107
+ frontmatter["id"] = parsed.id;
1108
+ }
1109
+ if (parsed.title !== void 0) {
1110
+ frontmatter["title"] = parsed.title;
1111
+ }
1112
+ if (parsed.tags.length > 0) {
1113
+ frontmatter["tags"] = parsed.tags;
1114
+ }
1115
+ for (const [key, value] of Object.entries(parsed.properties)) {
1116
+ frontmatter[key] = value;
1117
+ }
1118
+ return matter.stringify(parsed.content, frontmatter);
1119
+ }
1120
+
1121
+ // src/providers/docstore/watcher.ts
1122
+ import { watch } from "chokidar";
1123
+ import { relative, extname } from "path";
1124
+
1125
+ // src/providers/docstore/constants.ts
1126
+ var EXCLUDED_DIRS = /* @__PURE__ */ new Set([
1127
+ ".roux",
1128
+ "node_modules",
1129
+ ".git",
1130
+ ".obsidian"
1131
+ ]);
1132
+
1133
+ // src/providers/docstore/watcher.ts
1134
+ var DEFAULT_DEBOUNCE_MS = 1e3;
1135
+ var FileWatcher = class {
1136
+ root;
1137
+ extensions;
1138
+ debounceMs;
1139
+ onBatch;
1140
+ watcher = null;
1141
+ debounceTimer = null;
1142
+ pendingChanges = /* @__PURE__ */ new Map();
1143
+ isPaused = false;
1144
+ constructor(options) {
1145
+ this.root = options.root;
1146
+ this.extensions = options.extensions;
1147
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
1148
+ this.onBatch = options.onBatch;
1149
+ }
1150
+ start() {
1151
+ if (this.watcher) {
1152
+ return Promise.reject(new Error("Already watching. Call stop() first."));
1153
+ }
1154
+ return new Promise((resolve3, reject) => {
1155
+ let isReady = false;
1156
+ this.watcher = watch(this.root, {
1157
+ ignoreInitial: true,
1158
+ ignored: [...EXCLUDED_DIRS].map((dir) => `**/${dir}/**`),
1159
+ awaitWriteFinish: {
1160
+ stabilityThreshold: 100
1161
+ },
1162
+ followSymlinks: false
1163
+ });
1164
+ this.watcher.on("ready", () => {
1165
+ isReady = true;
1166
+ resolve3();
1167
+ }).on("add", (path) => this.queueChange(path, "add")).on("change", (path) => this.queueChange(path, "change")).on("unlink", (path) => this.queueChange(path, "unlink")).on("error", (err) => {
1168
+ if (err.code === "EMFILE") {
1169
+ console.error(
1170
+ "File watcher hit file descriptor limit. Try: ulimit -n 65536 or reduce watched files."
1171
+ );
1172
+ }
1173
+ if (isReady) {
1174
+ console.error("FileWatcher error:", err);
1175
+ } else {
1176
+ reject(err);
1177
+ }
1178
+ });
1179
+ });
1180
+ }
1181
+ stop() {
1182
+ if (this.debounceTimer) {
1183
+ clearTimeout(this.debounceTimer);
1184
+ this.debounceTimer = null;
1185
+ }
1186
+ this.pendingChanges.clear();
1187
+ if (this.watcher) {
1188
+ this.watcher.close();
1189
+ this.watcher = null;
1190
+ }
1191
+ }
1192
+ isWatching() {
1193
+ return this.watcher !== null;
1194
+ }
1195
+ pause() {
1196
+ this.isPaused = true;
1197
+ }
1198
+ resume() {
1199
+ this.isPaused = false;
1200
+ }
1201
+ flush() {
1202
+ if (this.debounceTimer) {
1203
+ clearTimeout(this.debounceTimer);
1204
+ this.debounceTimer = null;
1205
+ }
1206
+ if (this.pendingChanges.size === 0) {
1207
+ return;
1208
+ }
1209
+ const batch = new Map(this.pendingChanges);
1210
+ this.pendingChanges.clear();
1211
+ try {
1212
+ const result = this.onBatch(batch);
1213
+ if (result && typeof result.catch === "function") {
1214
+ result.catch((err) => {
1215
+ console.error("FileWatcher onBatch callback threw an error:", err);
1216
+ });
1217
+ }
1218
+ } catch (err) {
1219
+ console.error("FileWatcher onBatch callback threw an error:", err);
1220
+ }
1221
+ }
1222
+ queueChange(filePath, event) {
1223
+ if (this.isPaused) return;
1224
+ const relativePath = relative(this.root, filePath);
1225
+ const ext = extname(filePath).toLowerCase();
1226
+ if (!ext || !this.extensions.has(ext)) {
1227
+ return;
1228
+ }
1229
+ const pathParts = relativePath.split("/");
1230
+ for (const part of pathParts) {
1231
+ if (EXCLUDED_DIRS.has(part)) {
1232
+ return;
1233
+ }
1234
+ }
1235
+ const id = relativePath.toLowerCase().replace(/\\/g, "/");
1236
+ const existing = this.pendingChanges.get(id);
1237
+ if (existing) {
1238
+ if (existing === "add" && event === "change") {
1239
+ return;
1240
+ } else if (existing === "add" && event === "unlink") {
1241
+ this.pendingChanges.delete(id);
1242
+ if (this.pendingChanges.size === 0) {
1243
+ if (this.debounceTimer) {
1244
+ clearTimeout(this.debounceTimer);
1245
+ this.debounceTimer = null;
1246
+ }
1247
+ return;
1248
+ }
1249
+ } else if (existing === "change" && event === "unlink") {
1250
+ this.pendingChanges.set(id, "unlink");
1251
+ } else if (existing === "change" && event === "add") {
1252
+ this.pendingChanges.set(id, "add");
1253
+ } else if (existing === "unlink" && event === "add") {
1254
+ this.pendingChanges.set(id, "add");
1255
+ } else if (existing === "unlink" && event === "change") {
1256
+ return;
1257
+ }
1258
+ } else {
1259
+ this.pendingChanges.set(id, event);
1260
+ }
1261
+ if (this.debounceTimer) {
1262
+ clearTimeout(this.debounceTimer);
1263
+ }
1264
+ this.debounceTimer = setTimeout(() => {
1265
+ this.flush();
1266
+ }, this.debounceMs);
1267
+ }
1268
+ };
1269
+
1270
+ // src/providers/docstore/links.ts
1271
+ var normalizeWikiLink = normalizeLinkTarget;
1272
+ function buildFilenameIndex(nodes) {
1273
+ const index = /* @__PURE__ */ new Map();
1274
+ for (const node of nodes) {
1275
+ const path = node.sourceRef?.path ?? "";
1276
+ const titleKey = node.title.toLowerCase();
1277
+ if (!titleKey && !path) {
1278
+ console.warn(
1279
+ `Node ${node.id} has no title or path \u2014 link resolution will fail`
1280
+ );
1281
+ }
1282
+ if (titleKey) {
1283
+ const existing = index.get(titleKey) ?? [];
1284
+ existing.push(node.id);
1285
+ index.set(titleKey, existing);
1286
+ }
1287
+ if (path) {
1288
+ const filename = path.split("/").pop()?.replace(/\.[^.]+$/, "").toLowerCase();
1289
+ if (filename && filename !== titleKey) {
1290
+ const existing = index.get(filename) ?? [];
1291
+ existing.push(node.id);
1292
+ index.set(filename, existing);
1293
+ }
1294
+ }
1295
+ }
1296
+ for (const ids of index.values()) {
1297
+ ids.sort();
1298
+ }
1299
+ return index;
1300
+ }
1301
+ function resolveLinks(outgoingLinks, filenameIndex, validNodeIds) {
1302
+ return outgoingLinks.map((link) => {
1303
+ if (validNodeIds.has(link)) {
1304
+ return link;
1305
+ }
1306
+ if (link.includes("/")) {
1307
+ return link;
1308
+ }
1309
+ const lookupKey = link.replace(/\.md$/i, "").toLowerCase();
1310
+ const matches = filenameIndex.get(lookupKey);
1311
+ if (matches && matches.length > 0) {
1312
+ return matches[0];
1313
+ }
1314
+ const variant = spaceDashVariant(lookupKey);
1315
+ if (variant) {
1316
+ const variantMatches = filenameIndex.get(variant);
1317
+ if (variantMatches && variantMatches.length > 0) {
1318
+ return variantMatches[0];
1319
+ }
1320
+ }
1321
+ return link;
1322
+ });
1323
+ }
1324
+ function spaceDashVariant(filename) {
1325
+ const hasSpace = filename.includes(" ");
1326
+ const hasDash = filename.includes("-");
1327
+ if (hasSpace && !hasDash) {
1328
+ return filename.replace(/ /g, "-");
1329
+ }
1330
+ if (hasDash && !hasSpace) {
1331
+ return filename.replace(/-/g, " ");
1332
+ }
1333
+ return null;
1334
+ }
1335
+
1336
+ // src/providers/docstore/file-operations.ts
1337
+ import { readFile as readFile2, stat, readdir } from "fs/promises";
1338
+ import { join as join5, resolve, extname as extname2 } from "path";
1339
+ async function getFileMtime(filePath) {
1340
+ const stats = await stat(filePath);
1341
+ return stats.mtimeMs;
1342
+ }
1343
+ function validatePathWithinSource(sourceRoot, id) {
1344
+ const resolvedPath = resolve(sourceRoot, id);
1345
+ const resolvedRoot = resolve(sourceRoot);
1346
+ if (!resolvedPath.startsWith(resolvedRoot + "/")) {
1347
+ throw new Error(`Path traversal detected: ${id} resolves outside source root`);
1348
+ }
1349
+ }
1350
+ async function collectFiles(dir, extensions) {
1351
+ if (extensions.size === 0) {
1352
+ return [];
1353
+ }
1354
+ const results = [];
1355
+ let entries;
1356
+ try {
1357
+ entries = await readdir(dir, { withFileTypes: true });
1358
+ } catch {
1359
+ return results;
1360
+ }
1361
+ for (const entry of entries) {
1362
+ const fullPath = join5(dir, entry.name);
1363
+ if (entry.isDirectory()) {
1364
+ if (EXCLUDED_DIRS.has(entry.name)) {
1365
+ continue;
1366
+ }
1367
+ const nested = await collectFiles(fullPath, extensions);
1368
+ results.push(...nested);
1369
+ } else if (entry.isFile()) {
1370
+ const ext = extname2(entry.name).toLowerCase();
1371
+ if (ext && extensions.has(ext)) {
1372
+ results.push(fullPath);
1373
+ }
1374
+ }
1375
+ }
1376
+ return results;
1377
+ }
1378
+ async function readFileContent(filePath) {
1379
+ return readFile2(filePath, "utf-8");
1380
+ }
1381
+
1382
+ // src/providers/docstore/id.ts
1383
+ import { nanoid } from "nanoid";
1384
+ var NANOID_PATTERN = /^[A-Za-z0-9_-]{12}$/;
1385
+ var isValidId = (id) => NANOID_PATTERN.test(id);
1386
+ var generateId = () => nanoid(12);
1387
+
1388
+ // src/providers/docstore/reader-registry.ts
1389
+ var ReaderRegistry = class {
1390
+ readers = /* @__PURE__ */ new Map();
1391
+ /**
1392
+ * Register a reader for its declared extensions.
1393
+ * Throws if any extension is already registered (atomic - no partial registration).
1394
+ */
1395
+ register(reader) {
1396
+ for (const ext of reader.extensions) {
1397
+ const normalizedExt = ext.toLowerCase();
1398
+ if (this.readers.has(normalizedExt)) {
1399
+ throw new Error(`Extension already registered: ${ext}`);
1400
+ }
1401
+ }
1402
+ for (const ext of reader.extensions) {
1403
+ const normalizedExt = ext.toLowerCase();
1404
+ this.readers.set(normalizedExt, reader);
1405
+ }
1406
+ }
1407
+ /**
1408
+ * Get reader for an extension, or null if none registered.
1409
+ * Case-insensitive.
1410
+ */
1411
+ getReader(extension) {
1412
+ return this.readers.get(extension.toLowerCase()) ?? null;
1413
+ }
1414
+ /**
1415
+ * Get all registered extensions
1416
+ */
1417
+ getExtensions() {
1418
+ return new Set(this.readers.keys());
1419
+ }
1420
+ /**
1421
+ * Check if an extension has a registered reader.
1422
+ * Case-insensitive.
1423
+ */
1424
+ hasReader(extension) {
1425
+ return this.readers.has(extension.toLowerCase());
1426
+ }
1427
+ /**
1428
+ * Parse content using the appropriate reader for the file's extension.
1429
+ * Validates frontmatter ID and signals if writeback is needed.
1430
+ * Throws if no reader is registered for the extension.
1431
+ *
1432
+ * Note: Does NOT generate new IDs here - that happens in Phase 3's writeback.
1433
+ * Files without valid frontmatter IDs keep their path-based ID for now,
1434
+ * with needsIdWrite: true signaling that an ID should be generated and written.
1435
+ */
1436
+ parse(content, context) {
1437
+ const reader = this.getReader(context.extension);
1438
+ if (!reader) {
1439
+ throw new Error(`No reader registered for extension: ${context.extension}`);
1440
+ }
1441
+ const node = reader.parse(content, context);
1442
+ const needsIdWrite = !isValidId(node.id);
1443
+ return { node, needsIdWrite };
1444
+ }
1445
+ };
1446
+
1447
+ // src/providers/docstore/readers/markdown.ts
1448
+ var MarkdownReader = class {
1449
+ extensions = [".md", ".markdown"];
1450
+ parse(content, context) {
1451
+ const parsed = parseMarkdown(content);
1452
+ const id = parsed.id ?? normalizeId(context.relativePath);
1453
+ const title = parsed.title ?? titleFromPath(id);
1454
+ const rawLinks = extractWikiLinks(parsed.content);
1455
+ const outgoingLinks = rawLinks.map((link) => normalizeWikiLink(link));
1456
+ return {
1457
+ id,
1458
+ title,
1459
+ content: parsed.content,
1460
+ tags: parsed.tags,
1461
+ outgoingLinks,
1462
+ properties: parsed.properties,
1463
+ sourceRef: {
1464
+ type: "file",
1465
+ path: context.absolutePath,
1466
+ lastModified: context.mtime
1467
+ }
1468
+ };
1469
+ }
1470
+ };
1471
+
1472
+ // src/providers/docstore/index.ts
1473
+ function createDefaultRegistry() {
1474
+ const registry = new ReaderRegistry();
1475
+ registry.register(new MarkdownReader());
1476
+ return registry;
1477
+ }
1478
+ var DocStore = class extends StoreProvider {
1479
+ id;
1480
+ cache;
1481
+ sourceRoot;
1482
+ ownsVectorIndex;
1483
+ registry;
1484
+ fileWatcher = null;
1485
+ onChangeCallback;
1486
+ constructor(options) {
1487
+ const {
1488
+ sourceRoot,
1489
+ cacheDir,
1490
+ id = "docstore",
1491
+ vectorIndex,
1492
+ registry,
1493
+ fileWatcher
1494
+ } = options;
1495
+ const ownsVector = !vectorIndex;
1496
+ if (!vectorIndex) mkdirSync2(cacheDir, { recursive: true });
1497
+ const vi = vectorIndex ?? new SqliteVectorIndex(cacheDir);
1498
+ super({ vectorIndex: vi });
1499
+ this.id = id;
1500
+ this.sourceRoot = sourceRoot;
1501
+ this.cache = new Cache(cacheDir);
1502
+ this.ownsVectorIndex = ownsVector;
1503
+ this.registry = registry ?? createDefaultRegistry();
1504
+ this.fileWatcher = fileWatcher ?? null;
1505
+ }
1506
+ async sync() {
1507
+ if (this.fileWatcher?.isWatching()) {
1508
+ this.fileWatcher.pause();
1509
+ }
1510
+ try {
1511
+ const extensions = this.registry.getExtensions();
1512
+ const currentPaths = await collectFiles(this.sourceRoot, extensions);
1513
+ const trackedPaths = this.cache.getAllTrackedPaths();
1514
+ const seenIds = /* @__PURE__ */ new Map();
1515
+ for (const filePath of currentPaths) {
1516
+ try {
1517
+ const mtime = await getFileMtime(filePath);
1518
+ const cachedMtime = this.cache.getModifiedTime(filePath);
1519
+ if (cachedMtime === null || mtime > cachedMtime) {
1520
+ const { node, needsIdWrite, newMtime } = await this.parseAndMaybeWriteId(filePath, mtime);
1521
+ const existingPath = seenIds.get(node.id);
1522
+ if (existingPath) {
1523
+ console.warn(
1524
+ `Duplicate ID ${node.id} found in ${filePath} (first seen in ${existingPath}):`,
1525
+ new Error("Skipping duplicate")
1526
+ );
1527
+ continue;
1528
+ }
1529
+ seenIds.set(node.id, filePath);
1530
+ const finalMtime = needsIdWrite ? newMtime ?? mtime : mtime;
1531
+ this.cache.upsertNode(node, "file", filePath, finalMtime);
1532
+ } else {
1533
+ const existingNode = this.cache.getNodeByPath(filePath);
1534
+ if (existingNode) {
1535
+ const existingPath = seenIds.get(existingNode.id);
1536
+ if (existingPath) {
1537
+ console.warn(
1538
+ `Duplicate ID ${existingNode.id} found in ${filePath} (first seen in ${existingPath}):`,
1539
+ new Error("Skipping duplicate")
1540
+ );
1541
+ this.cache.deleteNode(existingNode.id);
1542
+ } else {
1543
+ seenIds.set(existingNode.id, filePath);
1544
+ }
1545
+ }
1546
+ }
1547
+ } catch (err) {
1548
+ if (err.code === "ENOENT") {
1549
+ continue;
1550
+ }
1551
+ console.warn(`Failed to process file ${filePath}:`, err);
1552
+ continue;
1553
+ }
1554
+ }
1555
+ const currentSet = new Set(currentPaths);
1556
+ for (const tracked of trackedPaths) {
1557
+ if (!currentSet.has(tracked)) {
1558
+ const node = this.cache.getNodeByPath(tracked);
1559
+ if (node) {
1560
+ this.cache.deleteNode(node.id);
1561
+ }
1562
+ }
1563
+ }
1564
+ this.resolveAllLinks();
1565
+ await this.syncGraph();
1566
+ } finally {
1567
+ if (this.fileWatcher?.isWatching()) {
1568
+ this.fileWatcher.resume();
1569
+ }
1570
+ }
1571
+ }
1572
+ async createNode(node) {
1573
+ const normalizedPath = normalizeId(node.id);
1574
+ validatePathWithinSource(this.sourceRoot, normalizedPath);
1575
+ const existingByPath = this.cache.getNodeByPath(join6(this.sourceRoot, normalizedPath));
1576
+ if (existingByPath) {
1577
+ throw new Error(`Node already exists: ${normalizedPath}`);
1578
+ }
1579
+ const filePath = join6(this.sourceRoot, normalizedPath);
1580
+ const dir = dirname(filePath);
1581
+ await mkdir2(dir, { recursive: true });
1582
+ const stableId = generateId();
1583
+ const rawLinks = extractWikiLinks(node.content);
1584
+ const parsed = {
1585
+ id: stableId,
1586
+ title: node.title,
1587
+ tags: node.tags,
1588
+ properties: node.properties,
1589
+ content: node.content,
1590
+ rawLinks
1591
+ };
1592
+ const markdown = serializeToMarkdown(parsed);
1593
+ await writeFile2(filePath, markdown, "utf-8");
1594
+ let outgoingLinks = node.outgoingLinks;
1595
+ if (node.content && (!outgoingLinks || outgoingLinks.length === 0)) {
1596
+ outgoingLinks = rawLinks.map((link) => normalizeWikiLink(link));
1597
+ }
1598
+ const mtime = await getFileMtime(filePath);
1599
+ const createdNode = {
1600
+ ...node,
1601
+ id: stableId,
1602
+ outgoingLinks,
1603
+ sourceRef: {
1604
+ type: "file",
1605
+ path: filePath,
1606
+ lastModified: new Date(mtime)
1607
+ }
1608
+ };
1609
+ this.cache.upsertNode(createdNode, "file", filePath, mtime);
1610
+ this.resolveAllLinks();
1611
+ await this.syncGraph();
1612
+ }
1613
+ async updateNode(id, updates) {
1614
+ let existing = this.cache.getNode(id);
1615
+ if (!existing) {
1616
+ const normalizedId = normalizeId(id);
1617
+ existing = this.cache.getNode(normalizedId);
1618
+ }
1619
+ if (!existing && (id.includes(".") || id.includes("/"))) {
1620
+ const fullPath = join6(this.sourceRoot, normalizeId(id));
1621
+ existing = this.cache.getNodeByPath(fullPath);
1622
+ }
1623
+ if (!existing) {
1624
+ throw new Error(`Node not found: ${id}`);
1625
+ }
1626
+ const { ...safeUpdates } = updates;
1627
+ const contentForLinks = safeUpdates.content ?? existing.content;
1628
+ const rawLinks = extractWikiLinks(contentForLinks);
1629
+ const outgoingLinks = rawLinks.map((link) => normalizeWikiLink(link));
1630
+ const updated = {
1631
+ ...existing,
1632
+ ...safeUpdates,
1633
+ outgoingLinks,
1634
+ id: existing.id
1635
+ // Preserve original ID
1636
+ };
1637
+ const filePath = existing.sourceRef?.path ?? join6(this.sourceRoot, existing.id);
1638
+ const parsed = {
1639
+ id: existing.id,
1640
+ // Write the stable ID back to frontmatter
1641
+ title: updated.title,
1642
+ tags: updated.tags,
1643
+ properties: updated.properties,
1644
+ content: updated.content,
1645
+ rawLinks
1646
+ };
1647
+ const markdown = serializeToMarkdown(parsed);
1648
+ await writeFile2(filePath, markdown, "utf-8");
1649
+ const mtime = await getFileMtime(filePath);
1650
+ this.cache.upsertNode(updated, "file", filePath, mtime);
1651
+ this.resolveAllLinks();
1652
+ await this.syncGraph();
1653
+ }
1654
+ async deleteNode(id) {
1655
+ let existing = this.cache.getNode(id);
1656
+ if (!existing) {
1657
+ const normalizedId = normalizeId(id);
1658
+ existing = this.cache.getNode(normalizedId);
1659
+ }
1660
+ if (!existing && (id.includes(".") || id.includes("/"))) {
1661
+ const fullPath = join6(this.sourceRoot, normalizeId(id));
1662
+ existing = this.cache.getNodeByPath(fullPath);
1663
+ }
1664
+ if (!existing) {
1665
+ throw new Error(`Node not found: ${id}`);
1666
+ }
1667
+ const filePath = existing.sourceRef?.path ?? join6(this.sourceRoot, existing.id);
1668
+ await rm(filePath);
944
1669
  this.cache.deleteNode(existing.id);
945
- await this.vectorProvider.delete(existing.id);
946
- this.rebuildGraph();
1670
+ if (this.vectorIndex) await this.vectorIndex.delete(existing.id);
1671
+ await this.syncGraph();
947
1672
  }
948
1673
  async getNode(id) {
1674
+ let node = this.cache.getNode(id);
1675
+ if (node) return node;
949
1676
  const normalizedId = normalizeId(id);
950
- return this.cache.getNode(normalizedId);
1677
+ if (normalizedId !== id) {
1678
+ node = this.cache.getNode(normalizedId);
1679
+ if (node) return node;
1680
+ }
1681
+ if (id.includes(".") || id.includes("/")) {
1682
+ const fullPath = join6(this.sourceRoot, normalizedId);
1683
+ node = this.cache.getNodeByPath(fullPath);
1684
+ }
1685
+ return node;
951
1686
  }
952
1687
  async getNodes(ids) {
953
- const normalizedIds = ids.map(normalizeId);
954
- return this.cache.getNodes(normalizedIds);
1688
+ const results = [];
1689
+ for (const id of ids) {
1690
+ const node = await this.getNode(id);
1691
+ if (node) results.push(node);
1692
+ }
1693
+ return results;
955
1694
  }
956
1695
  async getAllNodeIds() {
957
1696
  const nodes = this.cache.getAllNodes();
958
1697
  return nodes.map((n) => n.id);
959
1698
  }
960
- async searchByTags(tags, mode) {
961
- return this.cache.searchByTags(tags, mode);
962
- }
963
- async getRandomNode(tags) {
964
- let candidates;
965
- if (tags && tags.length > 0) {
966
- candidates = await this.searchByTags(tags, "any");
967
- } else {
968
- candidates = this.cache.getAllNodes();
969
- }
970
- if (candidates.length === 0) {
971
- return null;
972
- }
973
- const randomIndex = Math.floor(Math.random() * candidates.length);
974
- return candidates[randomIndex];
1699
+ async searchByTags(tags, mode, limit) {
1700
+ return this.cache.searchByTags(tags, mode, limit);
975
1701
  }
976
1702
  async resolveTitles(ids) {
977
1703
  return this.cache.resolveTitles(ids);
@@ -987,267 +1713,209 @@ var DocStore = class _DocStore {
987
1713
  return names.map((query) => ({ query, match: null, score: 0 }));
988
1714
  }
989
1715
  async nodesExist(ids) {
990
- const normalizedIds = ids.map(normalizeId);
991
- return this.cache.nodesExist(normalizedIds);
992
- }
993
- async getNeighbors(id, options) {
994
- this.ensureGraph();
995
- const neighborIds = getNeighborIds(this.graph, id, options);
996
- return this.cache.getNodes(neighborIds);
997
- }
998
- async findPath(source, target) {
999
- this.ensureGraph();
1000
- return findPath(this.graph, source, target);
1001
- }
1002
- async getHubs(metric, limit) {
1003
- this.ensureGraph();
1004
- return getHubs(this.graph, metric, limit);
1005
- }
1006
- async storeEmbedding(id, vector, model) {
1007
- return this.vectorProvider.store(id, vector, model);
1008
- }
1009
- async searchByVector(vector, limit) {
1010
- return this.vectorProvider.search(vector, limit);
1716
+ const result = /* @__PURE__ */ new Map();
1717
+ for (const id of ids) {
1718
+ const node = await this.getNode(id);
1719
+ result.set(normalizeId(id), node !== null);
1720
+ }
1721
+ return result;
1011
1722
  }
1012
1723
  hasEmbedding(id) {
1013
- return this.vectorProvider.hasEmbedding(id);
1724
+ if (!this.vectorIndex) return false;
1725
+ return this.vectorIndex.hasEmbedding(id);
1014
1726
  }
1015
1727
  close() {
1016
1728
  this.stopWatching();
1017
1729
  this.cache.close();
1018
- if (this.ownsVectorProvider && "close" in this.vectorProvider) {
1019
- this.vectorProvider.close();
1730
+ if (this.ownsVectorIndex && this.vectorIndex && "close" in this.vectorIndex) {
1731
+ this.vectorIndex.close();
1020
1732
  }
1021
1733
  }
1734
+ // Lifecycle hooks
1735
+ async onRegister() {
1736
+ await this.sync();
1737
+ }
1738
+ async onUnregister() {
1739
+ this.close();
1740
+ }
1022
1741
  startWatching(onChange) {
1023
- if (this.watcher) {
1742
+ if (this.fileWatcher?.isWatching()) {
1024
1743
  throw new Error("Already watching. Call stopWatching() first.");
1025
1744
  }
1026
1745
  this.onChangeCallback = onChange;
1027
- return new Promise((resolve3, reject) => {
1028
- this.watcher = watch(this.sourceRoot, {
1029
- ignoreInitial: true,
1030
- ignored: [..._DocStore.EXCLUDED_DIRS].map((dir) => `**/${dir}/**`),
1031
- awaitWriteFinish: {
1032
- stabilityThreshold: 100
1033
- },
1034
- followSymlinks: false
1035
- });
1036
- this.watcher.on("ready", () => resolve3()).on("add", (path) => this.queueChange(path, "add")).on("change", (path) => this.queueChange(path, "change")).on("unlink", (path) => this.queueChange(path, "unlink")).on("error", (err) => {
1037
- if (err.code === "EMFILE") {
1038
- console.error(
1039
- "File watcher hit file descriptor limit. Try: ulimit -n 65536 or reduce watched files."
1040
- );
1041
- }
1042
- reject(err);
1746
+ if (!this.fileWatcher) {
1747
+ this.fileWatcher = new FileWatcher({
1748
+ root: this.sourceRoot,
1749
+ extensions: this.registry.getExtensions(),
1750
+ onBatch: (events) => this.handleWatcherBatch(events)
1043
1751
  });
1044
- });
1752
+ }
1753
+ return this.fileWatcher.start();
1045
1754
  }
1046
1755
  stopWatching() {
1047
- if (this.debounceTimer) {
1048
- clearTimeout(this.debounceTimer);
1049
- this.debounceTimer = null;
1050
- }
1051
- this.pendingChanges.clear();
1052
- if (this.watcher) {
1053
- this.watcher.close();
1054
- this.watcher = null;
1756
+ if (this.fileWatcher) {
1757
+ this.fileWatcher.stop();
1055
1758
  }
1056
1759
  }
1057
1760
  isWatching() {
1058
- return this.watcher !== null;
1059
- }
1060
- queueChange(filePath, event) {
1061
- const relativePath = relative(this.sourceRoot, filePath);
1062
- const id = normalizeId(relativePath);
1063
- if (!filePath.endsWith(".md")) {
1064
- return;
1065
- }
1066
- const pathParts = relativePath.split("/");
1067
- for (const part of pathParts) {
1068
- if (_DocStore.EXCLUDED_DIRS.has(part)) {
1069
- return;
1070
- }
1071
- }
1072
- const existing = this.pendingChanges.get(id);
1073
- if (existing) {
1074
- if (existing === "add" && event === "change") {
1075
- return;
1076
- } else if (existing === "add" && event === "unlink") {
1077
- this.pendingChanges.delete(id);
1078
- } else if (existing === "change" && event === "unlink") {
1079
- this.pendingChanges.set(id, "unlink");
1080
- }
1081
- } else {
1082
- this.pendingChanges.set(id, event);
1083
- }
1084
- if (this.debounceTimer) {
1085
- clearTimeout(this.debounceTimer);
1086
- }
1087
- this.debounceTimer = setTimeout(() => {
1088
- this.processQueue();
1089
- }, 1e3);
1761
+ return this.fileWatcher?.isWatching() ?? false;
1090
1762
  }
1091
- async processQueue() {
1092
- const changes = new Map(this.pendingChanges);
1093
- this.pendingChanges.clear();
1094
- this.debounceTimer = null;
1763
+ async handleWatcherBatch(events) {
1764
+ this.fileWatcher?.pause();
1095
1765
  const processedIds = [];
1096
- for (const [id, event] of changes) {
1097
- try {
1098
- if (event === "unlink") {
1099
- const existing = this.cache.getNode(id);
1100
- if (existing) {
1101
- this.cache.deleteNode(id);
1102
- await this.vectorProvider.delete(id);
1103
- processedIds.push(id);
1766
+ try {
1767
+ for (const [pathId, event] of events) {
1768
+ const filePath = join6(this.sourceRoot, pathId);
1769
+ try {
1770
+ if (event === "unlink") {
1771
+ const existing = this.cache.getNodeByPath(filePath);
1772
+ if (existing) {
1773
+ this.cache.deleteNode(existing.id);
1774
+ if (this.vectorIndex) {
1775
+ try {
1776
+ await this.vectorIndex.delete(existing.id);
1777
+ } catch (vectorErr) {
1778
+ console.warn(`Vector delete failed for ${pathId}:`, vectorErr);
1779
+ }
1780
+ }
1781
+ processedIds.push(existing.id);
1782
+ }
1783
+ } else {
1784
+ const mtime = await getFileMtime(filePath);
1785
+ const { node, newMtime } = await this.parseAndMaybeWriteId(filePath, mtime);
1786
+ const finalMtime = newMtime ?? mtime;
1787
+ const existingByPath = this.cache.getNodeByPath(filePath);
1788
+ if (existingByPath && existingByPath.id !== node.id) {
1789
+ this.cache.deleteNode(existingByPath.id);
1790
+ if (this.vectorIndex) {
1791
+ try {
1792
+ await this.vectorIndex.delete(existingByPath.id);
1793
+ } catch {
1794
+ }
1795
+ }
1796
+ }
1797
+ this.cache.upsertNode(node, "file", filePath, finalMtime);
1798
+ processedIds.push(node.id);
1104
1799
  }
1105
- } else {
1106
- const filePath = join5(this.sourceRoot, id);
1107
- const node = await this.fileToNode(filePath);
1108
- const mtime = await this.getFileMtime(filePath);
1109
- this.cache.upsertNode(node, "file", filePath, mtime);
1110
- processedIds.push(id);
1800
+ } catch (err) {
1801
+ console.warn(`Failed to process file change for ${pathId}:`, err);
1111
1802
  }
1112
- } catch (err) {
1113
- console.warn(`Failed to process file change for ${id}:`, err);
1114
1803
  }
1804
+ if (processedIds.length > 0) {
1805
+ this.resolveAllLinks();
1806
+ await this.syncGraph();
1807
+ }
1808
+ if (this.onChangeCallback && processedIds.length > 0) {
1809
+ this.onChangeCallback(processedIds);
1810
+ }
1811
+ } finally {
1812
+ this.fileWatcher?.resume();
1115
1813
  }
1116
- if (processedIds.length > 0) {
1117
- const filenameIndex = this.buildFilenameIndex();
1118
- this.resolveOutgoingLinks(filenameIndex);
1119
- this.rebuildGraph();
1120
- }
1121
- if (this.onChangeCallback && processedIds.length > 0) {
1122
- this.onChangeCallback(processedIds);
1123
- }
1124
- }
1125
- buildFilenameIndex() {
1126
- const index = /* @__PURE__ */ new Map();
1127
- for (const node of this.cache.getAllNodes()) {
1128
- const basename = node.id.split("/").pop();
1129
- const existing = index.get(basename) ?? [];
1130
- existing.push(node.id);
1131
- index.set(basename, existing);
1132
- }
1133
- for (const paths of index.values()) {
1134
- paths.sort();
1135
- }
1136
- return index;
1137
1814
  }
1138
- resolveOutgoingLinks(filenameIndex) {
1139
- const validNodeIds = /* @__PURE__ */ new Set();
1140
- for (const paths of filenameIndex.values()) {
1141
- for (const path of paths) {
1142
- validNodeIds.add(path);
1815
+ resolveAllLinks() {
1816
+ const nodes = this.cache.getAllNodes();
1817
+ const filenameIndex = buildFilenameIndex(nodes);
1818
+ const validNodeIds = new Set(nodes.map((n) => n.id));
1819
+ const pathToId = /* @__PURE__ */ new Map();
1820
+ for (const node of nodes) {
1821
+ if (node.sourceRef?.path) {
1822
+ const relativePath = relative2(this.sourceRoot, node.sourceRef.path);
1823
+ const normalizedPath = normalizeId(relativePath);
1824
+ pathToId.set(normalizedPath, node.id);
1143
1825
  }
1144
1826
  }
1145
- for (const node of this.cache.getAllNodes()) {
1146
- const resolved = node.outgoingLinks.map((link) => {
1827
+ for (const node of nodes) {
1828
+ const resolvedIds = resolveLinks(
1829
+ node.outgoingLinks,
1830
+ filenameIndex,
1831
+ validNodeIds
1832
+ );
1833
+ const finalIds = resolvedIds.map((link) => {
1147
1834
  if (validNodeIds.has(link)) {
1148
1835
  return link;
1149
1836
  }
1150
- if (link.includes("/")) {
1151
- return link;
1152
- }
1153
- const matches = filenameIndex.get(link);
1154
- if (matches && matches.length > 0) {
1155
- return matches[0];
1156
- }
1157
- return link;
1837
+ const stableId = pathToId.get(link);
1838
+ return stableId ?? link;
1158
1839
  });
1159
- if (resolved.some((r, i) => r !== node.outgoingLinks[i])) {
1160
- this.cache.updateOutgoingLinks(node.id, resolved);
1840
+ if (finalIds.some((r, i) => r !== node.outgoingLinks[i])) {
1841
+ this.cache.updateOutgoingLinks(node.id, finalIds);
1161
1842
  }
1162
1843
  }
1163
1844
  }
1164
- ensureGraph() {
1165
- if (!this.graph) {
1166
- this.rebuildGraph();
1167
- }
1845
+ // ── Graph operations (override for path-based lookup) ─────
1846
+ async getNeighbors(id, options) {
1847
+ const node = await this.getNode(id);
1848
+ if (!node) return [];
1849
+ return super.getNeighbors(node.id, options);
1168
1850
  }
1169
- rebuildGraph() {
1170
- const nodes = this.cache.getAllNodes();
1171
- this.graph = buildGraph(nodes);
1172
- const centrality = computeCentrality(this.graph);
1851
+ async findPath(source, target) {
1852
+ const sourceNode = await this.getNode(source);
1853
+ const targetNode = await this.getNode(target);
1854
+ if (!sourceNode || !targetNode) return null;
1855
+ return super.findPath(sourceNode.id, targetNode.id);
1856
+ }
1857
+ async getHubs(metric, limit) {
1858
+ return super.getHubs(metric, limit);
1859
+ }
1860
+ // ── StoreProvider abstract method implementations ─────────
1861
+ async loadAllNodes() {
1862
+ return this.cache.getAllNodes();
1863
+ }
1864
+ async getNodesByIds(ids) {
1865
+ return this.cache.getNodes(ids);
1866
+ }
1867
+ onCentralityComputed(centrality) {
1173
1868
  const now = Date.now();
1174
1869
  for (const [id, metrics] of centrality) {
1175
1870
  this.cache.storeCentrality(id, 0, metrics.inDegree, metrics.outDegree, now);
1176
1871
  }
1177
1872
  }
1178
- static EXCLUDED_DIRS = /* @__PURE__ */ new Set([".roux", "node_modules", ".git", ".obsidian"]);
1179
- async collectMarkdownFiles(dir) {
1180
- const results = [];
1181
- let entries;
1182
- try {
1183
- entries = await readdir(dir, { withFileTypes: true });
1184
- } catch {
1185
- return results;
1186
- }
1187
- for (const entry of entries) {
1188
- const fullPath = join5(dir, entry.name);
1189
- if (entry.isDirectory()) {
1190
- if (_DocStore.EXCLUDED_DIRS.has(entry.name)) {
1191
- continue;
1192
- }
1193
- const nested = await this.collectMarkdownFiles(fullPath);
1194
- results.push(...nested);
1195
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
1196
- results.push(fullPath);
1197
- }
1873
+ /**
1874
+ * Parse a file and optionally write a generated ID back if missing.
1875
+ * Returns the node (with stable ID) and whether a write occurred.
1876
+ */
1877
+ async parseAndMaybeWriteId(filePath, originalMtime) {
1878
+ const content = await readFileContent(filePath);
1879
+ const relativePath = relative2(this.sourceRoot, filePath);
1880
+ const ext = extname3(filePath).toLowerCase();
1881
+ const actualMtime = new Date(originalMtime);
1882
+ const context = {
1883
+ absolutePath: filePath,
1884
+ relativePath,
1885
+ extension: ext,
1886
+ mtime: actualMtime
1887
+ };
1888
+ const { node, needsIdWrite } = this.registry.parse(content, context);
1889
+ if (!needsIdWrite) {
1890
+ return { node, needsIdWrite: false };
1198
1891
  }
1199
- return results;
1200
- }
1201
- async getFileMtime(filePath) {
1202
- const stats = await stat(filePath);
1203
- return stats.mtimeMs;
1204
- }
1205
- async fileToNode(filePath) {
1206
- const raw = await readFile2(filePath, "utf-8");
1207
- const parsed = parseMarkdown(raw);
1208
- const relativePath = relative(this.sourceRoot, filePath);
1209
- const id = normalizeId(relativePath);
1210
- const title = parsed.title ?? titleFromPath(id);
1211
- const rawLinks = extractWikiLinks(parsed.content);
1212
- const outgoingLinks = rawLinks.map((link) => this.normalizeWikiLink(link));
1213
- return {
1214
- id,
1215
- title,
1216
- content: parsed.content,
1217
- tags: parsed.tags,
1218
- outgoingLinks,
1219
- properties: parsed.properties,
1220
- sourceRef: {
1221
- type: "file",
1222
- path: filePath,
1223
- lastModified: new Date(await this.getFileMtime(filePath))
1224
- }
1892
+ const newId = generateId();
1893
+ const writebackSuccess = await this.writeIdBack(filePath, newId, originalMtime, content);
1894
+ if (!writebackSuccess) {
1895
+ console.warn(`File modified during sync, skipping ID writeback: ${filePath}`);
1896
+ return { node, needsIdWrite: true };
1897
+ }
1898
+ const updatedNode = {
1899
+ ...node,
1900
+ id: newId
1225
1901
  };
1902
+ const newMtime = await getFileMtime(filePath);
1903
+ return { node: updatedNode, needsIdWrite: true, newMtime };
1226
1904
  }
1227
1905
  /**
1228
- * Normalize a wiki-link target to an ID.
1229
- * - If it has a file extension, normalize as-is
1230
- * - If no extension, add .md
1231
- * - Lowercase, forward slashes
1906
+ * Write a generated ID back to file's frontmatter.
1907
+ * Returns false if file was modified since originalMtime (race condition).
1232
1908
  */
1233
- normalizeWikiLink(target) {
1234
- let normalized = target.toLowerCase().replace(/\\/g, "/");
1235
- if (!this.hasFileExtension(normalized)) {
1236
- normalized += ".md";
1237
- }
1238
- return normalized;
1239
- }
1240
- hasFileExtension(path) {
1241
- const match = path.match(/\.([a-z0-9]{1,4})$/i);
1242
- if (!match?.[1]) return false;
1243
- return /[a-z]/i.test(match[1]);
1244
- }
1245
- validatePathWithinSource(id) {
1246
- const resolvedPath = resolve(this.sourceRoot, id);
1247
- const resolvedRoot = resolve(this.sourceRoot);
1248
- if (!resolvedPath.startsWith(resolvedRoot + "/")) {
1249
- throw new Error(`Path traversal detected: ${id} resolves outside source root`);
1909
+ async writeIdBack(filePath, nodeId, originalMtime, originalContent) {
1910
+ const currentStat = await stat2(filePath);
1911
+ if (currentStat.mtimeMs !== originalMtime) {
1912
+ return false;
1250
1913
  }
1914
+ const parsed = parseMarkdown(originalContent);
1915
+ parsed.id = nodeId;
1916
+ const newContent = serializeToMarkdown(parsed);
1917
+ await writeFile2(filePath, newContent, "utf-8");
1918
+ return true;
1251
1919
  }
1252
1920
  };
1253
1921
 
@@ -1255,11 +1923,18 @@ var DocStore = class _DocStore {
1255
1923
  import { pipeline } from "@xenova/transformers";
1256
1924
  var DEFAULT_MODEL = "Xenova/all-MiniLM-L6-v2";
1257
1925
  var DEFAULT_DIMENSIONS = 384;
1258
- var TransformersEmbeddingProvider = class {
1926
+ var TransformersEmbedding = class {
1927
+ id;
1259
1928
  model;
1260
1929
  dims;
1261
1930
  pipe = null;
1262
- constructor(model = DEFAULT_MODEL, dimensions = DEFAULT_DIMENSIONS) {
1931
+ constructor(options = {}) {
1932
+ const {
1933
+ model = DEFAULT_MODEL,
1934
+ dimensions = DEFAULT_DIMENSIONS,
1935
+ id = "transformers-embedding"
1936
+ } = options;
1937
+ this.id = id;
1263
1938
  this.model = model;
1264
1939
  this.dims = dimensions;
1265
1940
  }
@@ -1286,33 +1961,109 @@ var TransformersEmbeddingProvider = class {
1286
1961
  modelId() {
1287
1962
  return this.model;
1288
1963
  }
1964
+ // Lifecycle hooks
1965
+ async onRegister() {
1966
+ }
1967
+ async onUnregister() {
1968
+ this.pipe = null;
1969
+ }
1289
1970
  };
1290
1971
 
1972
+ // src/types/provider.ts
1973
+ function isStoreProvider(value) {
1974
+ if (value === null || typeof value !== "object") {
1975
+ return false;
1976
+ }
1977
+ const obj = value;
1978
+ return typeof obj.id === "string" && obj.id.trim().length > 0 && typeof obj.createNode === "function" && typeof obj.updateNode === "function" && typeof obj.deleteNode === "function" && typeof obj.getNode === "function" && typeof obj.getNodes === "function" && typeof obj.getNeighbors === "function" && typeof obj.findPath === "function" && typeof obj.getHubs === "function" && typeof obj.storeEmbedding === "function" && typeof obj.searchByVector === "function" && typeof obj.searchByTags === "function" && typeof obj.getRandomNode === "function" && typeof obj.resolveTitles === "function" && typeof obj.listNodes === "function" && typeof obj.resolveNodes === "function" && typeof obj.nodesExist === "function";
1979
+ }
1980
+ function isEmbeddingProvider(value) {
1981
+ if (value === null || typeof value !== "object") {
1982
+ return false;
1983
+ }
1984
+ const obj = value;
1985
+ return typeof obj.id === "string" && obj.id.trim().length > 0 && typeof obj.embed === "function" && typeof obj.embedBatch === "function" && typeof obj.dimensions === "function" && typeof obj.modelId === "function";
1986
+ }
1987
+
1291
1988
  // src/core/graphcore.ts
1292
1989
  var GraphCoreImpl = class _GraphCoreImpl {
1293
1990
  store = null;
1294
1991
  embedding = null;
1295
- registerStore(provider) {
1992
+ async registerStore(provider) {
1296
1993
  if (!provider) {
1297
1994
  throw new Error("Store provider is required");
1298
1995
  }
1996
+ if (!isStoreProvider(provider)) {
1997
+ throw new Error("Invalid Store provider: missing required methods or id");
1998
+ }
1999
+ if (this.store?.onUnregister) {
2000
+ try {
2001
+ await this.store.onUnregister();
2002
+ } catch (err) {
2003
+ console.warn("Error during store onUnregister:", err);
2004
+ }
2005
+ }
1299
2006
  this.store = provider;
2007
+ if (provider.onRegister) {
2008
+ try {
2009
+ await provider.onRegister();
2010
+ } catch (err) {
2011
+ this.store = null;
2012
+ throw err;
2013
+ }
2014
+ }
1300
2015
  }
1301
- registerEmbedding(provider) {
2016
+ async registerEmbedding(provider) {
1302
2017
  if (!provider) {
1303
2018
  throw new Error("Embedding provider is required");
1304
2019
  }
2020
+ if (!isEmbeddingProvider(provider)) {
2021
+ throw new Error("Invalid Embedding provider: missing required methods or id");
2022
+ }
2023
+ if (this.embedding?.onUnregister) {
2024
+ try {
2025
+ await this.embedding.onUnregister();
2026
+ } catch (err) {
2027
+ console.warn("Error during embedding onUnregister:", err);
2028
+ }
2029
+ }
1305
2030
  this.embedding = provider;
2031
+ if (provider.onRegister) {
2032
+ try {
2033
+ await provider.onRegister();
2034
+ } catch (err) {
2035
+ this.embedding = null;
2036
+ throw err;
2037
+ }
2038
+ }
2039
+ }
2040
+ async destroy() {
2041
+ if (this.embedding?.onUnregister) {
2042
+ try {
2043
+ await this.embedding.onUnregister();
2044
+ } catch (err) {
2045
+ console.warn("Error during embedding onUnregister in destroy:", err);
2046
+ }
2047
+ }
2048
+ if (this.store?.onUnregister) {
2049
+ try {
2050
+ await this.store.onUnregister();
2051
+ } catch (err) {
2052
+ console.warn("Error during store onUnregister in destroy:", err);
2053
+ }
2054
+ }
2055
+ this.embedding = null;
2056
+ this.store = null;
1306
2057
  }
1307
2058
  requireStore() {
1308
2059
  if (!this.store) {
1309
- throw new Error("StoreProvider not registered");
2060
+ throw new Error("Store not registered");
1310
2061
  }
1311
2062
  return this.store;
1312
2063
  }
1313
2064
  requireEmbedding() {
1314
2065
  if (!this.embedding) {
1315
- throw new Error("EmbeddingProvider not registered");
2066
+ throw new Error("Embedding not registered");
1316
2067
  }
1317
2068
  return this.embedding;
1318
2069
  }
@@ -1405,11 +2156,7 @@ var GraphCoreImpl = class _GraphCoreImpl {
1405
2156
  }
1406
2157
  async searchByTags(tags, mode, limit) {
1407
2158
  const store = this.requireStore();
1408
- const results = await store.searchByTags(tags, mode);
1409
- if (limit !== void 0) {
1410
- return results.slice(0, limit);
1411
- }
1412
- return results;
2159
+ return store.searchByTags(tags, mode, limit);
1413
2160
  }
1414
2161
  async getRandomNode(tags) {
1415
2162
  const store = this.requireStore();
@@ -1423,7 +2170,7 @@ var GraphCoreImpl = class _GraphCoreImpl {
1423
2170
  const strategy = options?.strategy ?? "fuzzy";
1424
2171
  if (strategy === "semantic") {
1425
2172
  if (!this.embedding) {
1426
- throw new Error("Semantic resolution requires EmbeddingProvider");
2173
+ throw new Error("Semantic resolution requires Embedding");
1427
2174
  }
1428
2175
  const filter = {};
1429
2176
  if (options?.tag) filter.tag = options.tag;
@@ -1450,7 +2197,7 @@ var GraphCoreImpl = class _GraphCoreImpl {
1450
2197
  let bestScore = 0;
1451
2198
  let bestMatch = null;
1452
2199
  for (let cIdx = 0; cIdx < candidates.length; cIdx++) {
1453
- const similarity = this.cosineSimilarity(queryVector, candidateVectors[cIdx]);
2200
+ const similarity = cosineSimilarity(queryVector, candidateVectors[cIdx]);
1454
2201
  if (similarity > bestScore) {
1455
2202
  bestScore = similarity;
1456
2203
  bestMatch = candidates[cIdx].id;
@@ -1464,44 +2211,37 @@ var GraphCoreImpl = class _GraphCoreImpl {
1464
2211
  }
1465
2212
  return store.resolveNodes(names, options);
1466
2213
  }
1467
- cosineSimilarity(a, b) {
1468
- let dotProduct = 0;
1469
- let normA = 0;
1470
- let normB = 0;
1471
- for (let i = 0; i < a.length; i++) {
1472
- dotProduct += a[i] * b[i];
1473
- normA += a[i] * a[i];
1474
- normB += b[i] * b[i];
1475
- }
1476
- if (normA === 0 || normB === 0) return 0;
1477
- return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
1478
- }
1479
- static fromConfig(config) {
2214
+ static async fromConfig(config) {
1480
2215
  if (!config.providers?.store) {
1481
- throw new Error("StoreProvider configuration is required");
2216
+ throw new Error("Store configuration is required");
1482
2217
  }
1483
2218
  const core = new _GraphCoreImpl();
1484
- if (config.providers.store.type === "docstore") {
1485
- const sourcePath = config.source?.path ?? ".";
1486
- const cachePath = config.cache?.path ?? ".roux";
1487
- const store = new DocStore(sourcePath, cachePath);
1488
- core.registerStore(store);
1489
- } else {
1490
- throw new Error(
1491
- `Unsupported store provider type: ${config.providers.store.type}. Supported: docstore`
1492
- );
1493
- }
1494
- const embeddingConfig = config.providers.embedding;
1495
- if (!embeddingConfig || embeddingConfig.type === "local") {
1496
- const model = embeddingConfig?.model;
1497
- const embedding = new TransformersEmbeddingProvider(model);
1498
- core.registerEmbedding(embedding);
1499
- } else {
1500
- throw new Error(
1501
- `Unsupported embedding provider type: ${embeddingConfig.type}. Supported: local`
1502
- );
2219
+ try {
2220
+ if (config.providers.store.type === "docstore") {
2221
+ const sourcePath = config.source?.path ?? ".";
2222
+ const cachePath = config.cache?.path ?? ".roux";
2223
+ const store = new DocStore({ sourceRoot: sourcePath, cacheDir: cachePath });
2224
+ await core.registerStore(store);
2225
+ } else {
2226
+ throw new Error(
2227
+ `Unsupported store provider type: ${config.providers.store.type}. Supported: docstore`
2228
+ );
2229
+ }
2230
+ const embeddingConfig = config.providers.embedding;
2231
+ if (!embeddingConfig || embeddingConfig.type === "local") {
2232
+ const model = embeddingConfig?.model;
2233
+ const embedding = new TransformersEmbedding(model ? { model } : {});
2234
+ await core.registerEmbedding(embedding);
2235
+ } else {
2236
+ throw new Error(
2237
+ `Unsupported embedding provider type: ${embeddingConfig.type}. Supported: local`
2238
+ );
2239
+ }
2240
+ return core;
2241
+ } catch (err) {
2242
+ await core.destroy();
2243
+ throw err;
1503
2244
  }
1504
- return core;
1505
2245
  }
1506
2246
  };
1507
2247
 
@@ -1513,6 +2253,15 @@ import {
1513
2253
  ListToolsRequestSchema
1514
2254
  } from "@modelcontextprotocol/sdk/types.js";
1515
2255
 
2256
+ // src/types/config.ts
2257
+ var DEFAULT_NAMING = {
2258
+ filename: "space",
2259
+ title: "title"
2260
+ };
2261
+
2262
+ // src/index.ts
2263
+ var VERSION = "0.1.3";
2264
+
1516
2265
  // src/mcp/types.ts
1517
2266
  var McpError = class extends Error {
1518
2267
  constructor(code, message) {
@@ -1530,6 +2279,93 @@ var McpError = class extends Error {
1530
2279
  }
1531
2280
  };
1532
2281
 
2282
+ // src/mcp/handlers/search.ts
2283
+ var search_exports = {};
2284
+ __export(search_exports, {
2285
+ handler: () => handler,
2286
+ schema: () => schema
2287
+ });
2288
+
2289
+ // src/mcp/validation.ts
2290
+ function coerceInt(value, defaultValue, minValue, fieldName) {
2291
+ if (value === void 0 || value === null) {
2292
+ return defaultValue;
2293
+ }
2294
+ const num = Number(value);
2295
+ if (Number.isNaN(num)) {
2296
+ return defaultValue;
2297
+ }
2298
+ const floored = Math.floor(num);
2299
+ if (floored < minValue) {
2300
+ throw new McpError("INVALID_PARAMS", `${fieldName} must be at least ${minValue}`);
2301
+ }
2302
+ return floored;
2303
+ }
2304
+ function coerceLimit(value, defaultValue) {
2305
+ return coerceInt(value, defaultValue, 1, "limit");
2306
+ }
2307
+ function coerceOffset(value, defaultValue) {
2308
+ return coerceInt(value, defaultValue, 0, "offset");
2309
+ }
2310
+ function coerceDepth(value) {
2311
+ if (value === void 0 || value === null) {
2312
+ return 0;
2313
+ }
2314
+ const num = Number(value);
2315
+ if (Number.isNaN(num)) {
2316
+ return 0;
2317
+ }
2318
+ return num >= 1 ? 1 : 0;
2319
+ }
2320
+ function validateStringArray(value, fieldName) {
2321
+ if (!Array.isArray(value)) {
2322
+ throw new McpError("INVALID_PARAMS", `${fieldName} is required and must be an array`);
2323
+ }
2324
+ if (!value.every((item) => typeof item === "string")) {
2325
+ throw new McpError("INVALID_PARAMS", `${fieldName} must contain only strings`);
2326
+ }
2327
+ return value;
2328
+ }
2329
+ function validateRequiredString(value, fieldName) {
2330
+ if (value === void 0 || value === null || typeof value !== "string") {
2331
+ throw new McpError("INVALID_PARAMS", `${fieldName} is required and must be a string`);
2332
+ }
2333
+ return value;
2334
+ }
2335
+ function validateEnum(value, validValues, fieldName, defaultValue) {
2336
+ if (value === void 0 || value === null) {
2337
+ return defaultValue;
2338
+ }
2339
+ if (!validValues.includes(value)) {
2340
+ throw new McpError(
2341
+ "INVALID_PARAMS",
2342
+ `${fieldName} must be one of: ${validValues.join(", ")}`
2343
+ );
2344
+ }
2345
+ return value;
2346
+ }
2347
+ function validateOptionalTags(value) {
2348
+ if (value === void 0) {
2349
+ return void 0;
2350
+ }
2351
+ if (!Array.isArray(value)) {
2352
+ throw new McpError("INVALID_PARAMS", "tags must contain only strings");
2353
+ }
2354
+ if (!value.every((t) => typeof t === "string")) {
2355
+ throw new McpError("INVALID_PARAMS", "tags must contain only strings");
2356
+ }
2357
+ return value;
2358
+ }
2359
+ function validateRequiredTags(value) {
2360
+ if (!Array.isArray(value) || value.length === 0) {
2361
+ throw new McpError("INVALID_PARAMS", "tags is required and must be a non-empty array");
2362
+ }
2363
+ if (!value.every((t) => typeof t === "string")) {
2364
+ throw new McpError("INVALID_PARAMS", "tags must contain only strings");
2365
+ }
2366
+ return value;
2367
+ }
2368
+
1533
2369
  // src/mcp/truncate.ts
1534
2370
  var TRUNCATION_LIMITS = {
1535
2371
  /** Primary node (get_node, single result) */
@@ -1545,7 +2381,13 @@ function truncateContent(content, context) {
1545
2381
  if (content.length <= limit) {
1546
2382
  return content;
1547
2383
  }
1548
- const truncatedLength = Math.max(0, limit - TRUNCATION_SUFFIX.length);
2384
+ let truncatedLength = Math.max(0, limit - TRUNCATION_SUFFIX.length);
2385
+ if (truncatedLength > 0) {
2386
+ const lastCharCode = content.charCodeAt(truncatedLength - 1);
2387
+ if (lastCharCode >= 55296 && lastCharCode <= 56319) {
2388
+ truncatedLength--;
2389
+ }
2390
+ }
1549
2391
  return content.slice(0, truncatedLength) + TRUNCATION_SUFFIX;
1550
2392
  }
1551
2393
 
@@ -1580,7 +2422,7 @@ async function nodesToResponses(nodes, store, truncation, includeContent) {
1580
2422
  }
1581
2423
  const titles = await store.resolveTitles(Array.from(allLinkIds));
1582
2424
  return nodes.map((node) => {
1583
- const limitedLinks = nodeLinkLimits.get(node.id) ?? [];
2425
+ const limitedLinks = nodeLinkLimits.get(node.id);
1584
2426
  const base = {
1585
2427
  id: node.id,
1586
2428
  title: node.title,
@@ -1639,36 +2481,30 @@ function pathToResponse(path) {
1639
2481
  };
1640
2482
  }
1641
2483
 
1642
- // src/mcp/handlers.ts
1643
- function coerceLimit(value, defaultValue) {
1644
- if (value === void 0 || value === null) {
1645
- return defaultValue;
1646
- }
1647
- const num = Number(value);
1648
- if (Number.isNaN(num)) {
1649
- return defaultValue;
1650
- }
1651
- const floored = Math.floor(num);
1652
- if (floored < 1) {
1653
- throw new McpError("INVALID_PARAMS", "limit must be at least 1");
1654
- }
1655
- return floored;
1656
- }
1657
- function coerceOffset(value, defaultValue) {
1658
- if (value === void 0 || value === null) {
1659
- return defaultValue;
1660
- }
1661
- const num = Number(value);
1662
- if (Number.isNaN(num)) {
1663
- return defaultValue;
1664
- }
1665
- const floored = Math.floor(num);
1666
- if (floored < 0) {
1667
- throw new McpError("INVALID_PARAMS", "offset must be at least 0");
1668
- }
1669
- return floored;
1670
- }
1671
- async function handleSearch(ctx, args) {
2484
+ // src/mcp/handlers/search.ts
2485
+ var schema = {
2486
+ type: "object",
2487
+ properties: {
2488
+ query: {
2489
+ type: "string",
2490
+ description: "Natural language search query"
2491
+ },
2492
+ limit: {
2493
+ type: "integer",
2494
+ minimum: 1,
2495
+ maximum: 50,
2496
+ default: 10,
2497
+ description: "Maximum results to return"
2498
+ },
2499
+ include_content: {
2500
+ type: "boolean",
2501
+ default: false,
2502
+ description: "Include node content in results. Default false returns metadata only (id, title, tags, properties, links). Set true to include truncated content."
2503
+ }
2504
+ },
2505
+ required: ["query"]
2506
+ };
2507
+ async function handler(ctx, args) {
1672
2508
  if (!ctx.hasEmbedding) {
1673
2509
  throw new McpError("PROVIDER_ERROR", "Search requires embedding provider");
1674
2510
  }
@@ -1685,22 +2521,33 @@ async function handleSearch(ctx, args) {
1685
2521
  });
1686
2522
  return nodesToSearchResults(nodes, scores, ctx.store, includeContent);
1687
2523
  }
1688
- function coerceDepth(value) {
1689
- if (value === void 0 || value === null) {
1690
- return 0;
1691
- }
1692
- const num = Number(value);
1693
- if (Number.isNaN(num)) {
1694
- return 0;
1695
- }
1696
- return num >= 1 ? 1 : 0;
1697
- }
1698
- async function handleGetNode(ctx, args) {
1699
- const id = args.id;
2524
+
2525
+ // src/mcp/handlers/get_node.ts
2526
+ var get_node_exports = {};
2527
+ __export(get_node_exports, {
2528
+ handler: () => handler2,
2529
+ schema: () => schema2
2530
+ });
2531
+ var schema2 = {
2532
+ type: "object",
2533
+ properties: {
2534
+ id: {
2535
+ type: "string",
2536
+ description: 'Node ID (file path for DocStore). ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2537
+ },
2538
+ depth: {
2539
+ type: "integer",
2540
+ minimum: 0,
2541
+ maximum: 1,
2542
+ default: 0,
2543
+ description: "0 = node only, 1 = include neighbors"
2544
+ }
2545
+ },
2546
+ required: ["id"]
2547
+ };
2548
+ async function handler2(ctx, args) {
2549
+ const id = validateRequiredString(args.id, "id");
1700
2550
  const depth = coerceDepth(args.depth);
1701
- if (!id || typeof id !== "string") {
1702
- throw new McpError("INVALID_PARAMS", "id is required and must be a string");
1703
- }
1704
2551
  const node = await ctx.core.getNode(id, depth);
1705
2552
  if (!node) {
1706
2553
  return null;
@@ -1714,108 +2561,254 @@ async function handleGetNode(ctx, args) {
1714
2561
  ]);
1715
2562
  return nodeToContextResponse(node, incomingNeighbors, outgoingNeighbors, ctx.store);
1716
2563
  }
2564
+
2565
+ // src/mcp/handlers/get_neighbors.ts
2566
+ var get_neighbors_exports = {};
2567
+ __export(get_neighbors_exports, {
2568
+ handler: () => handler3,
2569
+ schema: () => schema3
2570
+ });
1717
2571
  var VALID_DIRECTIONS = ["in", "out", "both"];
1718
- async function handleGetNeighbors(ctx, args) {
1719
- const id = args.id;
1720
- const directionRaw = args.direction ?? "both";
2572
+ var schema3 = {
2573
+ type: "object",
2574
+ properties: {
2575
+ id: {
2576
+ type: "string",
2577
+ description: 'Source node ID. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2578
+ },
2579
+ direction: {
2580
+ type: "string",
2581
+ enum: ["in", "out", "both"],
2582
+ default: "both",
2583
+ description: "in = nodes linking here, out = nodes linked to, both = all"
2584
+ },
2585
+ limit: {
2586
+ type: "integer",
2587
+ minimum: 1,
2588
+ maximum: 50,
2589
+ default: 20,
2590
+ description: "Maximum neighbors to return"
2591
+ },
2592
+ include_content: {
2593
+ type: "boolean",
2594
+ default: false,
2595
+ description: "Include node content in results. Default false returns metadata only (id, title, tags, properties, links). Set true to include truncated content."
2596
+ }
2597
+ },
2598
+ required: ["id"]
2599
+ };
2600
+ async function handler3(ctx, args) {
2601
+ const id = validateRequiredString(args.id, "id");
1721
2602
  const limit = coerceLimit(args.limit, 20);
1722
2603
  const includeContent = args.include_content === true;
1723
- if (!id || typeof id !== "string") {
1724
- throw new McpError("INVALID_PARAMS", "id is required and must be a string");
1725
- }
1726
- if (!VALID_DIRECTIONS.includes(directionRaw)) {
1727
- throw new McpError(
1728
- "INVALID_PARAMS",
1729
- `direction must be one of: ${VALID_DIRECTIONS.join(", ")}`
1730
- );
1731
- }
1732
- const direction = directionRaw;
2604
+ const direction = validateEnum(args.direction, VALID_DIRECTIONS, "direction", "both");
1733
2605
  const neighbors = await ctx.core.getNeighbors(id, { direction, limit });
1734
2606
  return nodesToResponses(neighbors, ctx.store, "list", includeContent);
1735
2607
  }
1736
- async function handleFindPath(ctx, args) {
1737
- const source = args.source;
1738
- const target = args.target;
1739
- if (!source || typeof source !== "string") {
1740
- throw new McpError("INVALID_PARAMS", "source is required and must be a string");
1741
- }
1742
- if (!target || typeof target !== "string") {
1743
- throw new McpError("INVALID_PARAMS", "target is required and must be a string");
1744
- }
2608
+
2609
+ // src/mcp/handlers/find_path.ts
2610
+ var find_path_exports = {};
2611
+ __export(find_path_exports, {
2612
+ handler: () => handler4,
2613
+ schema: () => schema4
2614
+ });
2615
+ var schema4 = {
2616
+ type: "object",
2617
+ properties: {
2618
+ source: {
2619
+ type: "string",
2620
+ description: 'Start node ID. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2621
+ },
2622
+ target: {
2623
+ type: "string",
2624
+ description: 'End node ID. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2625
+ }
2626
+ },
2627
+ required: ["source", "target"]
2628
+ };
2629
+ async function handler4(ctx, args) {
2630
+ const source = validateRequiredString(args.source, "source");
2631
+ const target = validateRequiredString(args.target, "target");
1745
2632
  const path = await ctx.core.findPath(source, target);
1746
2633
  if (!path) {
1747
2634
  return null;
1748
2635
  }
1749
2636
  return pathToResponse(path);
1750
2637
  }
1751
- var VALID_METRICS = ["pagerank", "in_degree", "out_degree"];
1752
- async function handleGetHubs(ctx, args) {
1753
- const metricRaw = args.metric ?? "in_degree";
1754
- const limit = coerceLimit(args.limit, 10);
1755
- if (!VALID_METRICS.includes(metricRaw)) {
1756
- throw new McpError(
1757
- "INVALID_PARAMS",
1758
- `metric must be one of: ${VALID_METRICS.join(", ")}`
1759
- );
2638
+
2639
+ // src/mcp/handlers/get_hubs.ts
2640
+ var get_hubs_exports = {};
2641
+ __export(get_hubs_exports, {
2642
+ handler: () => handler5,
2643
+ schema: () => schema5
2644
+ });
2645
+ var VALID_METRICS = ["in_degree", "out_degree"];
2646
+ var schema5 = {
2647
+ type: "object",
2648
+ properties: {
2649
+ metric: {
2650
+ type: "string",
2651
+ enum: ["in_degree", "out_degree"],
2652
+ default: "in_degree",
2653
+ description: "Centrality metric"
2654
+ },
2655
+ limit: {
2656
+ type: "integer",
2657
+ minimum: 1,
2658
+ maximum: 50,
2659
+ default: 10,
2660
+ description: "Maximum results"
2661
+ }
1760
2662
  }
1761
- const metric = metricRaw;
2663
+ };
2664
+ async function handler5(ctx, args) {
2665
+ const limit = coerceLimit(args.limit, 10);
2666
+ const metric = validateEnum(args.metric, VALID_METRICS, "metric", "in_degree");
1762
2667
  const hubs = await ctx.core.getHubs(metric, limit);
1763
2668
  return hubsToResponses(hubs, ctx.store);
1764
2669
  }
2670
+
2671
+ // src/mcp/handlers/search_by_tags.ts
2672
+ var search_by_tags_exports = {};
2673
+ __export(search_by_tags_exports, {
2674
+ handler: () => handler6,
2675
+ schema: () => schema6
2676
+ });
1765
2677
  var VALID_TAG_MODES = ["any", "all"];
1766
- async function handleSearchByTags(ctx, args) {
1767
- const tags = args.tags;
1768
- const modeRaw = args.mode ?? "any";
2678
+ var schema6 = {
2679
+ type: "object",
2680
+ properties: {
2681
+ tags: {
2682
+ type: "array",
2683
+ items: { type: "string" },
2684
+ minItems: 1,
2685
+ description: "Tags to match"
2686
+ },
2687
+ mode: {
2688
+ type: "string",
2689
+ enum: ["any", "all"],
2690
+ default: "any",
2691
+ description: "any = OR matching, all = AND matching"
2692
+ },
2693
+ limit: {
2694
+ type: "integer",
2695
+ minimum: 1,
2696
+ maximum: 100,
2697
+ default: 20,
2698
+ description: "Maximum results"
2699
+ },
2700
+ include_content: {
2701
+ type: "boolean",
2702
+ default: false,
2703
+ description: "Include node content in results. Default false returns metadata only."
2704
+ }
2705
+ },
2706
+ required: ["tags"]
2707
+ };
2708
+ async function handler6(ctx, args) {
2709
+ const tags = validateRequiredTags(args.tags);
1769
2710
  const limit = coerceLimit(args.limit, 20);
1770
- if (!Array.isArray(tags) || tags.length === 0) {
1771
- throw new McpError("INVALID_PARAMS", "tags is required and must be a non-empty array");
1772
- }
1773
- if (!tags.every((t) => typeof t === "string")) {
1774
- throw new McpError("INVALID_PARAMS", "tags must contain only strings");
1775
- }
1776
- if (!VALID_TAG_MODES.includes(modeRaw)) {
1777
- throw new McpError(
1778
- "INVALID_PARAMS",
1779
- `mode must be one of: ${VALID_TAG_MODES.join(", ")}`
1780
- );
1781
- }
1782
- const mode = modeRaw;
2711
+ const includeContent = args.include_content === true;
2712
+ const mode = validateEnum(args.mode, VALID_TAG_MODES, "mode", "any");
1783
2713
  const nodes = await ctx.core.searchByTags(tags, mode, limit);
1784
- return nodesToResponses(nodes, ctx.store, "list", true);
2714
+ return nodesToResponses(nodes, ctx.store, "list", includeContent);
1785
2715
  }
1786
- async function handleRandomNode(ctx, args) {
1787
- const tags = args.tags;
1788
- if (tags !== void 0) {
1789
- if (!Array.isArray(tags) || !tags.every((t) => typeof t === "string")) {
1790
- throw new McpError("INVALID_PARAMS", "tags must contain only strings");
2716
+
2717
+ // src/mcp/handlers/random_node.ts
2718
+ var random_node_exports = {};
2719
+ __export(random_node_exports, {
2720
+ handler: () => handler7,
2721
+ schema: () => schema7
2722
+ });
2723
+ var schema7 = {
2724
+ type: "object",
2725
+ properties: {
2726
+ tags: {
2727
+ type: "array",
2728
+ items: { type: "string" },
2729
+ description: "Optional: limit to nodes with these tags (any match)"
1791
2730
  }
1792
2731
  }
2732
+ };
2733
+ async function handler7(ctx, args) {
2734
+ const tags = validateOptionalTags(args.tags);
1793
2735
  const node = await ctx.core.getRandomNode(tags);
1794
2736
  if (!node) {
1795
2737
  return null;
1796
2738
  }
1797
2739
  return nodeToResponse(node, ctx.store, "primary");
1798
2740
  }
1799
- async function handleCreateNode(ctx, args) {
1800
- const title = args.title;
1801
- const content = args.content;
1802
- const tagsRaw = args.tags;
1803
- const directory = args.directory;
1804
- if (!title || typeof title !== "string") {
1805
- throw new McpError("INVALID_PARAMS", "title is required and must be a string");
1806
- }
1807
- if (!content || typeof content !== "string") {
1808
- throw new McpError("INVALID_PARAMS", "content is required and must be a string");
1809
- }
1810
- let tags = [];
1811
- if (tagsRaw !== void 0) {
1812
- if (!Array.isArray(tagsRaw) || !tagsRaw.every((t) => typeof t === "string")) {
1813
- throw new McpError("INVALID_PARAMS", "tags must contain only strings");
2741
+
2742
+ // src/mcp/handlers/create_node.ts
2743
+ var create_node_exports = {};
2744
+ __export(create_node_exports, {
2745
+ deriveTitle: () => deriveTitle,
2746
+ handler: () => handler8,
2747
+ normalizeCreateId: () => normalizeCreateId,
2748
+ schema: () => schema8
2749
+ });
2750
+ var schema8 = {
2751
+ type: "object",
2752
+ properties: {
2753
+ id: {
2754
+ type: "string",
2755
+ description: 'Full path for new node (must end in .md). Will be lowercased (spaces and special characters preserved). Example: "notes/My Note.md" creates "notes/my note.md"'
2756
+ },
2757
+ title: {
2758
+ type: "string",
2759
+ description: "Optional display title. Defaults to filename without .md extension."
2760
+ },
2761
+ content: {
2762
+ type: "string",
2763
+ description: "Full text content (markdown)"
2764
+ },
2765
+ tags: {
2766
+ type: "array",
2767
+ items: { type: "string" },
2768
+ default: [],
2769
+ description: "Classification tags"
1814
2770
  }
1815
- tags = tagsRaw;
2771
+ },
2772
+ required: ["id", "content"]
2773
+ };
2774
+ function normalizeCreateId(rawId, naming = DEFAULT_NAMING) {
2775
+ let normalized = rawId.replace(/\\/g, "/").toLowerCase();
2776
+ if (naming.filename === "space") {
2777
+ normalized = normalized.replace(/-/g, " ");
2778
+ } else {
2779
+ normalized = normalized.replace(/ /g, "-");
2780
+ }
2781
+ return normalized;
2782
+ }
2783
+ function deriveTitle(id, naming) {
2784
+ const basename = id.split("/").pop() || "";
2785
+ const rawTitle = basename.replace(/\.md$/i, "");
2786
+ if (!rawTitle || !/[a-zA-Z0-9]/.test(rawTitle)) {
2787
+ return "Untitled";
2788
+ }
2789
+ if (!naming) {
2790
+ return rawTitle;
2791
+ }
2792
+ const spaced = naming.filename === "dash" ? rawTitle.replace(/-/g, " ") : rawTitle;
2793
+ switch (naming.title) {
2794
+ case "title":
2795
+ return spaced.replace(/\b\w/g, (c) => c.toUpperCase());
2796
+ case "sentence":
2797
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
2798
+ case "as-is":
2799
+ return rawTitle;
1816
2800
  }
1817
- const filename = sanitizeFilename(title) + ".md";
1818
- const id = directory ? `${directory}/${filename}` : filename;
2801
+ }
2802
+ async function handler8(ctx, args) {
2803
+ const idRaw = validateRequiredString(args.id, "id");
2804
+ if (!idRaw.toLowerCase().endsWith(".md")) {
2805
+ throw new McpError("INVALID_PARAMS", "id must end with .md extension");
2806
+ }
2807
+ const content = validateRequiredString(args.content, "content");
2808
+ const titleRaw = args.title;
2809
+ const tags = validateOptionalTags(args.tags) ?? [];
2810
+ const id = normalizeCreateId(idRaw, ctx.naming);
2811
+ const title = titleRaw ?? deriveTitle(id, ctx.naming);
1819
2812
  const existing = await ctx.core.getNode(id);
1820
2813
  if (existing) {
1821
2814
  throw new McpError("NODE_EXISTS", `Node already exists: ${id}`);
@@ -1828,27 +2821,47 @@ async function handleCreateNode(ctx, args) {
1828
2821
  });
1829
2822
  return nodeToResponse(node, ctx.store, "primary");
1830
2823
  }
1831
- async function handleUpdateNode(ctx, args) {
1832
- const id = args.id;
2824
+
2825
+ // src/mcp/handlers/update_node.ts
2826
+ var update_node_exports = {};
2827
+ __export(update_node_exports, {
2828
+ handler: () => handler9,
2829
+ schema: () => schema9
2830
+ });
2831
+ var schema9 = {
2832
+ type: "object",
2833
+ properties: {
2834
+ id: {
2835
+ type: "string",
2836
+ description: 'Node ID to update. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2837
+ },
2838
+ title: {
2839
+ type: "string",
2840
+ description: "New title (renames file for DocStore)"
2841
+ },
2842
+ content: {
2843
+ type: "string",
2844
+ description: "New content (replaces entirely)"
2845
+ },
2846
+ tags: {
2847
+ type: "array",
2848
+ items: { type: "string" },
2849
+ description: "New tags (replaces existing)"
2850
+ }
2851
+ },
2852
+ required: ["id"]
2853
+ };
2854
+ async function handler9(ctx, args) {
2855
+ const id = validateRequiredString(args.id, "id");
1833
2856
  const title = args.title;
1834
2857
  const content = args.content;
1835
- const tagsRaw = args.tags;
1836
- if (!id || typeof id !== "string") {
1837
- throw new McpError("INVALID_PARAMS", "id is required and must be a string");
1838
- }
1839
- if (title === void 0 && content === void 0 && tagsRaw === void 0) {
2858
+ const tags = validateOptionalTags(args.tags);
2859
+ if (title === void 0 && content === void 0 && tags === void 0) {
1840
2860
  throw new McpError(
1841
2861
  "INVALID_PARAMS",
1842
2862
  "At least one of title, content, or tags must be provided"
1843
2863
  );
1844
2864
  }
1845
- let tags;
1846
- if (tagsRaw !== void 0) {
1847
- if (!Array.isArray(tagsRaw) || !tagsRaw.every((t) => typeof t === "string")) {
1848
- throw new McpError("INVALID_PARAMS", "tags must contain only strings");
1849
- }
1850
- tags = tagsRaw;
1851
- }
1852
2865
  const existing = await ctx.core.getNode(id);
1853
2866
  if (!existing) {
1854
2867
  throw new McpError("NODE_NOT_FOUND", `Node not found: ${id}`);
@@ -1869,16 +2882,62 @@ async function handleUpdateNode(ctx, args) {
1869
2882
  const updated = await ctx.core.updateNode(id, updates);
1870
2883
  return nodeToResponse(updated, ctx.store, "primary");
1871
2884
  }
1872
- async function handleDeleteNode(ctx, args) {
1873
- const id = args.id;
1874
- if (!id || typeof id !== "string") {
1875
- throw new McpError("INVALID_PARAMS", "id is required and must be a string");
1876
- }
2885
+
2886
+ // src/mcp/handlers/delete_node.ts
2887
+ var delete_node_exports = {};
2888
+ __export(delete_node_exports, {
2889
+ handler: () => handler10,
2890
+ schema: () => schema10
2891
+ });
2892
+ var schema10 = {
2893
+ type: "object",
2894
+ properties: {
2895
+ id: {
2896
+ type: "string",
2897
+ description: 'Node ID to delete. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2898
+ }
2899
+ },
2900
+ required: ["id"]
2901
+ };
2902
+ async function handler10(ctx, args) {
2903
+ const id = validateRequiredString(args.id, "id");
1877
2904
  const deleted = await ctx.core.deleteNode(id);
1878
2905
  return { deleted };
1879
2906
  }
1880
- var VALID_STRATEGIES = ["exact", "fuzzy", "semantic"];
1881
- async function handleListNodes(ctx, args) {
2907
+
2908
+ // src/mcp/handlers/list_nodes.ts
2909
+ var list_nodes_exports = {};
2910
+ __export(list_nodes_exports, {
2911
+ handler: () => handler11,
2912
+ schema: () => schema11
2913
+ });
2914
+ var schema11 = {
2915
+ type: "object",
2916
+ properties: {
2917
+ tag: {
2918
+ type: "string",
2919
+ description: 'Filter by tag from the "tags" frontmatter array (case-insensitive). Does NOT search other frontmatter fields like "type" or "category".'
2920
+ },
2921
+ path: {
2922
+ type: "string",
2923
+ description: "Filter by path prefix (startsWith, case-insensitive)"
2924
+ },
2925
+ limit: {
2926
+ type: "integer",
2927
+ minimum: 1,
2928
+ maximum: 1e3,
2929
+ default: 100,
2930
+ description: "Maximum results to return"
2931
+ },
2932
+ offset: {
2933
+ type: "integer",
2934
+ minimum: 0,
2935
+ default: 0,
2936
+ description: "Skip this many results (for pagination)"
2937
+ }
2938
+ }
2939
+ };
2940
+ async function handler11(ctx, args) {
1882
2941
  const tag = args.tag;
1883
2942
  const path = args.path;
1884
2943
  const limit = coerceLimit(args.limit, 100);
@@ -1888,36 +2947,86 @@ async function handleListNodes(ctx, args) {
1888
2947
  if (path) filter.path = path;
1889
2948
  return ctx.core.listNodes(filter, { limit, offset });
1890
2949
  }
1891
- async function handleResolveNodes(ctx, args) {
1892
- const names = args.names;
1893
- const strategy = args.strategy;
2950
+
2951
+ // src/mcp/handlers/resolve_nodes.ts
2952
+ var resolve_nodes_exports = {};
2953
+ __export(resolve_nodes_exports, {
2954
+ handler: () => handler12,
2955
+ schema: () => schema12
2956
+ });
2957
+ var VALID_STRATEGIES = ["exact", "fuzzy", "semantic"];
2958
+ var schema12 = {
2959
+ type: "object",
2960
+ properties: {
2961
+ names: {
2962
+ type: "array",
2963
+ items: { type: "string" },
2964
+ description: "Names to resolve to existing nodes"
2965
+ },
2966
+ strategy: {
2967
+ type: "string",
2968
+ enum: ["exact", "fuzzy", "semantic"],
2969
+ default: "fuzzy",
2970
+ description: 'How to match names to nodes. "exact": case-insensitive title equality. "fuzzy": string similarity (Dice coefficient) \u2014 use for typos, misspellings, partial matches. "semantic": embedding cosine similarity \u2014 use for synonyms or related concepts (NOT typos). Misspellings embed poorly because they produce unrelated vectors.'
2971
+ },
2972
+ threshold: {
2973
+ type: "number",
2974
+ minimum: 0,
2975
+ maximum: 1,
2976
+ default: 0.7,
2977
+ description: "Minimum similarity score (0-1). Lower values match more loosely. For typo tolerance, use fuzzy with threshold 0.5-0.6. Ignored for exact strategy."
2978
+ },
2979
+ tag: {
2980
+ type: "string",
2981
+ description: 'Filter candidates by tag from "tags" frontmatter array (case-insensitive)'
2982
+ },
2983
+ path: {
2984
+ type: "string",
2985
+ description: "Filter candidates by path prefix (case-insensitive)"
2986
+ }
2987
+ },
2988
+ required: ["names"]
2989
+ };
2990
+ async function handler12(ctx, args) {
2991
+ const names = validateStringArray(args.names, "names");
1894
2992
  const threshold = args.threshold;
1895
2993
  const tag = args.tag;
1896
2994
  const path = args.path;
1897
- if (!Array.isArray(names)) {
1898
- throw new McpError("INVALID_PARAMS", "names is required and must be an array");
1899
- }
1900
- if (strategy !== void 0 && !VALID_STRATEGIES.includes(strategy)) {
1901
- throw new McpError(
1902
- "INVALID_PARAMS",
1903
- `strategy must be one of: ${VALID_STRATEGIES.join(", ")}`
1904
- );
1905
- }
2995
+ const strategy = validateEnum(
2996
+ args.strategy,
2997
+ VALID_STRATEGIES,
2998
+ "strategy",
2999
+ "fuzzy"
3000
+ );
1906
3001
  if (strategy === "semantic" && !ctx.hasEmbedding) {
1907
3002
  throw new McpError("PROVIDER_ERROR", "Semantic resolution requires embedding provider");
1908
3003
  }
1909
- const options = {};
1910
- if (strategy) options.strategy = strategy;
3004
+ const options = { strategy };
1911
3005
  if (threshold !== void 0) options.threshold = threshold;
1912
3006
  if (tag) options.tag = tag;
1913
3007
  if (path) options.path = path;
1914
3008
  return ctx.core.resolveNodes(names, options);
1915
3009
  }
1916
- async function handleNodesExist(ctx, args) {
1917
- const ids = args.ids;
1918
- if (!Array.isArray(ids)) {
1919
- throw new McpError("INVALID_PARAMS", "ids is required and must be an array");
1920
- }
3010
+
3011
+ // src/mcp/handlers/nodes_exist.ts
3012
+ var nodes_exist_exports = {};
3013
+ __export(nodes_exist_exports, {
3014
+ handler: () => handler13,
3015
+ schema: () => schema13
3016
+ });
3017
+ var schema13 = {
3018
+ type: "object",
3019
+ properties: {
3020
+ ids: {
3021
+ type: "array",
3022
+ items: { type: "string" },
3023
+ description: 'Node IDs to check existence. IDs are normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
3024
+ }
3025
+ },
3026
+ required: ["ids"]
3027
+ };
3028
+ async function handler13(ctx, args) {
3029
+ const ids = validateStringArray(args.ids, "ids");
1921
3030
  const result = await ctx.store.nodesExist(ids);
1922
3031
  const response = {};
1923
3032
  for (const [id, exists] of result) {
@@ -1925,378 +3034,106 @@ async function handleNodesExist(ctx, args) {
1925
3034
  }
1926
3035
  return response;
1927
3036
  }
1928
- function sanitizeFilename(title) {
1929
- const sanitized = title.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
1930
- return sanitized || "untitled";
1931
- }
1932
- async function dispatchTool(ctx, name, args) {
1933
- switch (name) {
1934
- case "search":
1935
- return handleSearch(ctx, args);
1936
- case "get_node":
1937
- return handleGetNode(ctx, args);
1938
- case "get_neighbors":
1939
- return handleGetNeighbors(ctx, args);
1940
- case "find_path":
1941
- return handleFindPath(ctx, args);
1942
- case "get_hubs":
1943
- return handleGetHubs(ctx, args);
1944
- case "search_by_tags":
1945
- return handleSearchByTags(ctx, args);
1946
- case "random_node":
1947
- return handleRandomNode(ctx, args);
1948
- case "create_node":
1949
- return handleCreateNode(ctx, args);
1950
- case "update_node":
1951
- return handleUpdateNode(ctx, args);
1952
- case "delete_node":
1953
- return handleDeleteNode(ctx, args);
1954
- case "list_nodes":
1955
- return handleListNodes(ctx, args);
1956
- case "resolve_nodes":
1957
- return handleResolveNodes(ctx, args);
1958
- case "nodes_exist":
1959
- return handleNodesExist(ctx, args);
1960
- default:
1961
- throw new McpError("INVALID_PARAMS", `Unknown tool: ${name}`);
1962
- }
1963
- }
1964
3037
 
1965
- // src/mcp/server.ts
1966
- var TOOL_SCHEMAS = {
1967
- search: {
1968
- type: "object",
1969
- properties: {
1970
- query: {
1971
- type: "string",
1972
- description: "Natural language search query"
1973
- },
1974
- limit: {
1975
- type: "integer",
1976
- minimum: 1,
1977
- maximum: 50,
1978
- default: 10,
1979
- description: "Maximum results to return"
1980
- },
1981
- include_content: {
1982
- type: "boolean",
1983
- default: false,
1984
- description: "Include node content in results. Default false returns metadata only (id, title, tags, properties, links). Set true to include truncated content."
1985
- }
1986
- },
1987
- required: ["query"]
1988
- },
1989
- get_node: {
1990
- type: "object",
1991
- properties: {
1992
- id: {
1993
- type: "string",
1994
- description: 'Node ID (file path for DocStore). ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
1995
- },
1996
- depth: {
1997
- type: "integer",
1998
- minimum: 0,
1999
- maximum: 1,
2000
- default: 0,
2001
- description: "0 = node only, 1 = include neighbors"
2002
- }
2003
- },
2004
- required: ["id"]
2005
- },
2006
- get_neighbors: {
2007
- type: "object",
2008
- properties: {
2009
- id: {
2010
- type: "string",
2011
- description: 'Source node ID. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2012
- },
2013
- direction: {
2014
- type: "string",
2015
- enum: ["in", "out", "both"],
2016
- default: "both",
2017
- description: "in = nodes linking here, out = nodes linked to, both = all"
2018
- },
2019
- limit: {
2020
- type: "integer",
2021
- minimum: 1,
2022
- maximum: 50,
2023
- default: 20,
2024
- description: "Maximum neighbors to return"
2025
- },
2026
- include_content: {
2027
- type: "boolean",
2028
- default: false,
2029
- description: "Include node content in results. Default false returns metadata only (id, title, tags, properties, links). Set true to include truncated content."
2030
- }
2031
- },
2032
- required: ["id"]
2033
- },
2034
- find_path: {
2035
- type: "object",
2036
- properties: {
2037
- source: {
2038
- type: "string",
2039
- description: 'Start node ID. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2040
- },
2041
- target: {
2042
- type: "string",
2043
- description: 'End node ID. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2044
- }
2045
- },
2046
- required: ["source", "target"]
2047
- },
2048
- get_hubs: {
2049
- type: "object",
2050
- properties: {
2051
- metric: {
2052
- type: "string",
2053
- enum: ["in_degree", "out_degree"],
2054
- default: "in_degree",
2055
- description: "Centrality metric"
2056
- },
2057
- limit: {
2058
- type: "integer",
2059
- minimum: 1,
2060
- maximum: 50,
2061
- default: 10,
2062
- description: "Maximum results"
2063
- }
2064
- }
2065
- },
2066
- search_by_tags: {
2067
- type: "object",
2068
- properties: {
2069
- tags: {
2070
- type: "array",
2071
- items: { type: "string" },
2072
- minItems: 1,
2073
- description: "Tags to match"
2074
- },
2075
- mode: {
2076
- type: "string",
2077
- enum: ["any", "all"],
2078
- default: "any",
2079
- description: "any = OR matching, all = AND matching"
2080
- },
2081
- limit: {
2082
- type: "integer",
2083
- minimum: 1,
2084
- maximum: 100,
2085
- default: 20,
2086
- description: "Maximum results"
2087
- }
2088
- },
2089
- required: ["tags"]
2090
- },
2091
- random_node: {
2092
- type: "object",
2093
- properties: {
2094
- tags: {
2095
- type: "array",
2096
- items: { type: "string" },
2097
- description: "Optional: limit to nodes with these tags (any match)"
2098
- }
2099
- }
2100
- },
2101
- create_node: {
2102
- type: "object",
2103
- properties: {
2104
- title: {
2105
- type: "string",
2106
- description: "Node title (becomes filename for DocStore). Returned ID will be normalized to lowercase."
2107
- },
2108
- content: {
2109
- type: "string",
2110
- description: "Full text content (markdown)"
2111
- },
2112
- tags: {
2113
- type: "array",
2114
- items: { type: "string" },
2115
- default: [],
2116
- description: "Classification tags"
2117
- },
2118
- directory: {
2119
- type: "string",
2120
- description: "Optional: subdirectory path (e.g., 'notes/drafts')"
2121
- }
2122
- },
2123
- required: ["title", "content"]
2124
- },
2125
- update_node: {
2126
- type: "object",
2127
- properties: {
2128
- id: {
2129
- type: "string",
2130
- description: 'Node ID to update. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2131
- },
2132
- title: {
2133
- type: "string",
2134
- description: "New title (renames file for DocStore)"
2135
- },
2136
- content: {
2137
- type: "string",
2138
- description: "New content (replaces entirely)"
2139
- },
2140
- tags: {
2141
- type: "array",
2142
- items: { type: "string" },
2143
- description: "New tags (replaces existing)"
2144
- }
2145
- },
2146
- required: ["id"]
2147
- },
2148
- delete_node: {
2149
- type: "object",
2150
- properties: {
2151
- id: {
2152
- type: "string",
2153
- description: 'Node ID to delete. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2154
- }
2155
- },
2156
- required: ["id"]
2157
- },
2158
- list_nodes: {
2159
- type: "object",
2160
- properties: {
2161
- tag: {
2162
- type: "string",
2163
- description: 'Filter by tag from the "tags" frontmatter array (case-insensitive). Does NOT search other frontmatter fields like "type" or "category".'
2164
- },
2165
- path: {
2166
- type: "string",
2167
- description: "Filter by path prefix (startsWith, case-insensitive)"
2168
- },
2169
- limit: {
2170
- type: "integer",
2171
- minimum: 1,
2172
- maximum: 1e3,
2173
- default: 100,
2174
- description: "Maximum results to return"
2175
- },
2176
- offset: {
2177
- type: "integer",
2178
- minimum: 0,
2179
- default: 0,
2180
- description: "Skip this many results (for pagination)"
2181
- }
2182
- }
2183
- },
2184
- resolve_nodes: {
2185
- type: "object",
2186
- properties: {
2187
- names: {
2188
- type: "array",
2189
- items: { type: "string" },
2190
- description: "Names to resolve to existing nodes"
2191
- },
2192
- strategy: {
2193
- type: "string",
2194
- enum: ["exact", "fuzzy", "semantic"],
2195
- default: "fuzzy",
2196
- description: 'How to match names to nodes. "exact": case-insensitive title equality. "fuzzy": string similarity (Dice coefficient) \u2014 use for typos, misspellings, partial matches. "semantic": embedding cosine similarity \u2014 use for synonyms or related concepts (NOT typos). Misspellings embed poorly because they produce unrelated vectors.'
2197
- },
2198
- threshold: {
2199
- type: "number",
2200
- minimum: 0,
2201
- maximum: 1,
2202
- default: 0.7,
2203
- description: "Minimum similarity score (0-1). Lower values match more loosely. For typo tolerance, use fuzzy with threshold 0.5-0.6. Ignored for exact strategy."
2204
- },
2205
- tag: {
2206
- type: "string",
2207
- description: 'Filter candidates by tag from "tags" frontmatter array (case-insensitive)'
2208
- },
2209
- path: {
2210
- type: "string",
2211
- description: "Filter candidates by path prefix (case-insensitive)"
2212
- }
2213
- },
2214
- required: ["names"]
2215
- },
2216
- nodes_exist: {
2217
- type: "object",
2218
- properties: {
2219
- ids: {
2220
- type: "array",
2221
- items: { type: "string" },
2222
- description: 'Node IDs to check existence. IDs are normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2223
- }
2224
- },
2225
- required: ["ids"]
2226
- }
3038
+ // src/mcp/handlers/index.ts
3039
+ var handlers = {
3040
+ search: search_exports,
3041
+ get_node: get_node_exports,
3042
+ get_neighbors: get_neighbors_exports,
3043
+ find_path: find_path_exports,
3044
+ get_hubs: get_hubs_exports,
3045
+ search_by_tags: search_by_tags_exports,
3046
+ random_node: random_node_exports,
3047
+ create_node: create_node_exports,
3048
+ update_node: update_node_exports,
3049
+ delete_node: delete_node_exports,
3050
+ list_nodes: list_nodes_exports,
3051
+ resolve_nodes: resolve_nodes_exports,
3052
+ nodes_exist: nodes_exist_exports
2227
3053
  };
3054
+ var TOOL_DESCRIPTIONS = {
3055
+ search: "Semantic similarity search across all nodes",
3056
+ get_node: "Retrieve a single node by ID with optional neighbor context",
3057
+ get_neighbors: "Get nodes linked to or from a specific node",
3058
+ find_path: "Find the shortest path between two nodes",
3059
+ get_hubs: "Get the most central nodes by graph metric",
3060
+ search_by_tags: "Filter nodes by tags (AND or OR matching)",
3061
+ random_node: "Get a random node for discovery, optionally filtered by tags",
3062
+ create_node: "Create a new node (writes file for DocStore)",
3063
+ update_node: "Update an existing node. Title changes rejected if incoming links exist.",
3064
+ delete_node: "Delete a node by ID",
3065
+ list_nodes: 'List nodes with optional filters and pagination. Tag filter searches the "tags" frontmatter array only. All IDs returned are lowercase.',
3066
+ resolve_nodes: 'Batch resolve names to existing node IDs. Strategy selection: "exact" for known titles, "fuzzy" for typos/misspellings (e.g., "chikken" -> "chicken"), "semantic" for synonyms/concepts (e.g., "poultry leg meat" -> "chicken thigh"). Semantic does NOT handle typos \u2014 misspellings produce garbage embeddings.',
3067
+ nodes_exist: "Batch check if node IDs exist. IDs are normalized to lowercase before checking."
3068
+ };
3069
+ var asSchema = (s) => s;
2228
3070
  function getToolDefinitions(hasEmbedding) {
2229
- const tools = [
2230
- {
2231
- name: "get_node",
2232
- description: "Retrieve a single node by ID with optional neighbor context",
2233
- inputSchema: TOOL_SCHEMAS.get_node
2234
- },
2235
- {
2236
- name: "get_neighbors",
2237
- description: "Get nodes linked to or from a specific node",
2238
- inputSchema: TOOL_SCHEMAS.get_neighbors
2239
- },
2240
- {
2241
- name: "find_path",
2242
- description: "Find the shortest path between two nodes",
2243
- inputSchema: TOOL_SCHEMAS.find_path
2244
- },
2245
- {
2246
- name: "get_hubs",
2247
- description: "Get the most central nodes by graph metric",
2248
- inputSchema: TOOL_SCHEMAS.get_hubs
2249
- },
2250
- {
2251
- name: "search_by_tags",
2252
- description: "Filter nodes by tags (AND or OR matching)",
2253
- inputSchema: TOOL_SCHEMAS.search_by_tags
2254
- },
2255
- {
2256
- name: "random_node",
2257
- description: "Get a random node for discovery, optionally filtered by tags",
2258
- inputSchema: TOOL_SCHEMAS.random_node
2259
- },
2260
- {
2261
- name: "create_node",
2262
- description: "Create a new node (writes file for DocStore)",
2263
- inputSchema: TOOL_SCHEMAS.create_node
2264
- },
2265
- {
2266
- name: "update_node",
2267
- description: "Update an existing node. Title changes rejected if incoming links exist.",
2268
- inputSchema: TOOL_SCHEMAS.update_node
2269
- },
2270
- {
2271
- name: "delete_node",
2272
- description: "Delete a node by ID",
2273
- inputSchema: TOOL_SCHEMAS.delete_node
2274
- },
2275
- {
2276
- name: "list_nodes",
2277
- description: 'List nodes with optional filters and pagination. Tag filter searches the "tags" frontmatter array only. All IDs returned are lowercase.',
2278
- inputSchema: TOOL_SCHEMAS.list_nodes
2279
- },
2280
- {
2281
- name: "resolve_nodes",
2282
- description: 'Batch resolve names to existing node IDs. Strategy selection: "exact" for known titles, "fuzzy" for typos/misspellings (e.g., "chikken" -> "chicken"), "semantic" for synonyms/concepts (e.g., "poultry leg meat" -> "chicken thigh"). Semantic does NOT handle typos \u2014 misspellings produce garbage embeddings.',
2283
- inputSchema: TOOL_SCHEMAS.resolve_nodes
2284
- },
2285
- {
2286
- name: "nodes_exist",
2287
- description: "Batch check if node IDs exist. IDs are normalized to lowercase before checking.",
2288
- inputSchema: TOOL_SCHEMAS.nodes_exist
2289
- }
3071
+ const toolOrder = [
3072
+ "get_node",
3073
+ "get_neighbors",
3074
+ "find_path",
3075
+ "get_hubs",
3076
+ "search_by_tags",
3077
+ "random_node",
3078
+ "create_node",
3079
+ "update_node",
3080
+ "delete_node",
3081
+ "list_nodes",
3082
+ "resolve_nodes",
3083
+ "nodes_exist"
2290
3084
  ];
3085
+ const tools = toolOrder.map((name) => ({
3086
+ name,
3087
+ description: TOOL_DESCRIPTIONS[name],
3088
+ inputSchema: asSchema(handlers[name].schema)
3089
+ }));
2291
3090
  if (hasEmbedding) {
2292
3091
  tools.unshift({
2293
3092
  name: "search",
2294
- description: "Semantic similarity search across all nodes",
2295
- inputSchema: TOOL_SCHEMAS.search
3093
+ description: TOOL_DESCRIPTIONS.search,
3094
+ inputSchema: asSchema(handlers.search.schema)
2296
3095
  });
2297
3096
  }
2298
3097
  return tools;
2299
3098
  }
3099
+ async function dispatchTool(ctx, name, args) {
3100
+ const h = handlers[name];
3101
+ if (!h) {
3102
+ throw new McpError("INVALID_PARAMS", `Unknown tool: ${name}`);
3103
+ }
3104
+ return h.handler(ctx, args);
3105
+ }
3106
+
3107
+ // src/mcp/server.ts
3108
+ function formatToolResponse(result) {
3109
+ return {
3110
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
3111
+ };
3112
+ }
3113
+ function formatErrorResponse(error) {
3114
+ if (error instanceof McpError) {
3115
+ return {
3116
+ content: [{ type: "text", text: JSON.stringify(error.toResponse()) }],
3117
+ isError: true
3118
+ };
3119
+ }
3120
+ const mcpError = new McpError(
3121
+ "PROVIDER_ERROR",
3122
+ error instanceof Error ? error.message : "Unknown error"
3123
+ );
3124
+ return {
3125
+ content: [{ type: "text", text: JSON.stringify(mcpError.toResponse()) }],
3126
+ isError: true
3127
+ };
3128
+ }
3129
+ async function executeToolCall(ctx, name, args) {
3130
+ try {
3131
+ const result = await dispatchTool(ctx, name, args);
3132
+ return formatToolResponse(result);
3133
+ } catch (error) {
3134
+ return formatErrorResponse(error);
3135
+ }
3136
+ }
2300
3137
  var McpServer = class {
2301
3138
  server;
2302
3139
  ctx;
@@ -2304,49 +3141,24 @@ var McpServer = class {
2304
3141
  this.ctx = {
2305
3142
  core: options.core,
2306
3143
  store: options.store,
2307
- hasEmbedding: options.hasEmbedding
3144
+ hasEmbedding: options.hasEmbedding,
3145
+ naming: options.naming ?? DEFAULT_NAMING
2308
3146
  };
2309
3147
  this.server = new Server(
2310
- { name: "roux", version: "0.1.0" },
3148
+ { name: "roux", version: VERSION },
2311
3149
  { capabilities: { tools: {} } }
2312
3150
  );
2313
3151
  this.setupHandlers();
2314
3152
  }
2315
- /* v8 ignore start - MCP SDK callbacks tested via integration in Phase 11 */
2316
3153
  setupHandlers() {
2317
3154
  this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
2318
3155
  tools: getToolDefinitions(this.ctx.hasEmbedding)
2319
3156
  }));
2320
3157
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
2321
3158
  const { name, arguments: args } = request.params;
2322
- try {
2323
- const result = await dispatchTool(this.ctx, name, args ?? {});
2324
- return {
2325
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
2326
- };
2327
- } catch (error) {
2328
- if (error instanceof McpError) {
2329
- return {
2330
- content: [
2331
- { type: "text", text: JSON.stringify(error.toResponse()) }
2332
- ],
2333
- isError: true
2334
- };
2335
- }
2336
- const mcpError = new McpError(
2337
- "PROVIDER_ERROR",
2338
- error instanceof Error ? error.message : "Unknown error"
2339
- );
2340
- return {
2341
- content: [
2342
- { type: "text", text: JSON.stringify(mcpError.toResponse()) }
2343
- ],
2344
- isError: true
2345
- };
2346
- }
3159
+ return executeToolCall(this.ctx, name, args ?? {});
2347
3160
  });
2348
3161
  }
2349
- /* v8 ignore stop */
2350
3162
  /**
2351
3163
  * Start the server with optional transport factory.
2352
3164
  * @param transportFactory Factory to create transport. Defaults to StdioServerTransport.
@@ -2363,7 +3175,7 @@ var McpServer = class {
2363
3175
  // src/cli/commands/serve.ts
2364
3176
  async function serveCommand(directory, options = {}) {
2365
3177
  const { watch: watch2 = true, transportFactory, onProgress } = options;
2366
- const configPath = join6(directory, "roux.yaml");
3178
+ const configPath = join7(directory, "roux.yaml");
2367
3179
  try {
2368
3180
  await access3(configPath);
2369
3181
  } catch {
@@ -2372,14 +3184,17 @@ async function serveCommand(directory, options = {}) {
2372
3184
  const configContent = await readFile3(configPath, "utf-8");
2373
3185
  const config = parseYaml(configContent);
2374
3186
  const sourcePath = config.source?.path ?? ".";
2375
- const resolvedSourcePath = join6(directory, sourcePath);
3187
+ const resolvedSourcePath = join7(directory, sourcePath);
2376
3188
  const cachePath = config.cache?.path ?? ".roux";
2377
- const resolvedCachePath = join6(directory, cachePath);
2378
- const store = new DocStore(resolvedSourcePath, resolvedCachePath);
2379
- const embedding = new TransformersEmbeddingProvider(
2380
- config.providers?.embedding?.type === "local" ? config.providers.embedding.model : void 0
3189
+ const resolvedCachePath = join7(directory, cachePath);
3190
+ const store = new DocStore({ sourceRoot: resolvedSourcePath, cacheDir: resolvedCachePath });
3191
+ const embeddingModel = config.providers?.embedding?.type === "local" ? config.providers.embedding.model : void 0;
3192
+ const embedding = new TransformersEmbedding(
3193
+ embeddingModel ? { model: embeddingModel } : {}
2381
3194
  );
2382
- await store.sync();
3195
+ const core = new GraphCoreImpl();
3196
+ await core.registerStore(store);
3197
+ await core.registerEmbedding(embedding);
2383
3198
  const allNodeIds = await store.getAllNodeIds();
2384
3199
  const total = allNodeIds.length;
2385
3200
  for (let i = 0; i < allNodeIds.length; i++) {
@@ -2395,23 +3210,36 @@ async function serveCommand(directory, options = {}) {
2395
3210
  onProgress(i + 1, total);
2396
3211
  }
2397
3212
  }
2398
- const core = new GraphCoreImpl();
2399
- core.registerStore(store);
2400
- core.registerEmbedding(embedding);
3213
+ const naming = { ...DEFAULT_NAMING, ...config.naming };
2401
3214
  const mcpServer = new McpServer({
2402
3215
  core,
2403
3216
  store,
2404
- hasEmbedding: true
3217
+ hasEmbedding: true,
3218
+ naming
2405
3219
  });
2406
- await mcpServer.start(transportFactory);
3220
+ try {
3221
+ await mcpServer.start(transportFactory);
3222
+ } catch (err) {
3223
+ await core.destroy();
3224
+ throw err;
3225
+ }
2407
3226
  if (watch2) {
2408
3227
  try {
2409
3228
  await store.startWatching(async (changedIds) => {
2410
3229
  for (const id of changedIds) {
2411
- const node = await store.getNode(id);
2412
- if (node && node.content) {
2413
- const vector = await embedding.embed(node.content);
2414
- await store.storeEmbedding(id, vector, embedding.modelId());
3230
+ try {
3231
+ const node = await store.getNode(id);
3232
+ if (node && node.content) {
3233
+ const vector = await embedding.embed(node.content);
3234
+ await store.storeEmbedding(id, vector, embedding.modelId());
3235
+ }
3236
+ } catch (err) {
3237
+ console.warn(
3238
+ "Failed to generate embedding for",
3239
+ id,
3240
+ ":",
3241
+ err.message || "Unknown error"
3242
+ );
2415
3243
  }
2416
3244
  }
2417
3245
  });
@@ -2424,8 +3252,7 @@ async function serveCommand(directory, options = {}) {
2424
3252
  }
2425
3253
  return {
2426
3254
  stop: async () => {
2427
- store.stopWatching();
2428
- store.close();
3255
+ await core.destroy();
2429
3256
  await mcpServer.close();
2430
3257
  },
2431
3258
  isWatching: store.isWatching(),
@@ -2438,16 +3265,16 @@ function hasExistingEmbedding(store, id) {
2438
3265
 
2439
3266
  // src/cli/commands/viz.ts
2440
3267
  import { access as access4, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
2441
- import { join as join7, dirname as dirname2 } from "path";
3268
+ import { join as join8, dirname as dirname2 } from "path";
2442
3269
  async function vizCommand(directory, options = {}) {
2443
- const configPath = join7(directory, "roux.yaml");
3270
+ const configPath = join8(directory, "roux.yaml");
2444
3271
  try {
2445
3272
  await access4(configPath);
2446
3273
  } catch {
2447
3274
  throw new Error(`Directory not initialized. Run 'roux init' first.`);
2448
3275
  }
2449
- const cacheDir = join7(directory, ".roux");
2450
- const outputPath = options.output ?? join7(cacheDir, "graph.html");
3276
+ const cacheDir = join8(directory, ".roux");
3277
+ const outputPath = options.output ?? join8(cacheDir, "graph.html");
2451
3278
  const cache = new Cache(cacheDir);
2452
3279
  try {
2453
3280
  const nodes = cache.getAllNodes();
@@ -2587,13 +3414,23 @@ function generateHtml(nodes, edges) {
2587
3414
  .attr("dy", 4)
2588
3415
  .text(d => d.title.length > 20 ? d.title.slice(0, 17) + "..." : d.title);
2589
3416
 
3417
+ // HTML escape function for XSS prevention
3418
+ function escapeHtml(str) {
3419
+ return str
3420
+ .replace(/&/g, '&amp;')
3421
+ .replace(/</g, '&lt;')
3422
+ .replace(/>/g, '&gt;')
3423
+ .replace(/"/g, '&quot;')
3424
+ .replace(/'/g, '&#039;');
3425
+ }
3426
+
2590
3427
  // Tooltip
2591
3428
  const tooltip = d3.select("#tooltip");
2592
3429
  node
2593
3430
  .on("mouseover", (event, d) => {
2594
3431
  tooltip
2595
3432
  .style("opacity", 1)
2596
- .html(\`<strong>\${d.title}</strong><br>ID: \${d.id}<br>Incoming links: \${d.inDegree}\`);
3433
+ .html(\`<strong>\${escapeHtml(d.title)}</strong><br>ID: \${escapeHtml(d.id)}<br>Incoming links: \${d.inDegree}\`);
2597
3434
  })
2598
3435
  .on("mousemove", (event) => {
2599
3436
  tooltip
@@ -2635,8 +3472,12 @@ function generateHtml(nodes, edges) {
2635
3472
  }
2636
3473
 
2637
3474
  // src/cli/index.ts
3475
+ function handleCliError(error) {
3476
+ console.error(error instanceof Error ? error.message : "Unknown error");
3477
+ process.exit(1);
3478
+ }
2638
3479
  var program = new Command();
2639
- program.name("roux").description("Graph Programming Interface for knowledge bases").version("0.1.0");
3480
+ program.name("roux").description("Graph Programming Interface for knowledge bases").version(VERSION);
2640
3481
  program.command("init").description("Initialize Roux in a directory").argument("[directory]", "Directory to initialize", ".").action(async (directory) => {
2641
3482
  const resolvedDir = resolve2(directory);
2642
3483
  const result = await initCommand(resolvedDir);
@@ -2667,10 +3508,7 @@ program.command("status").description("Show graph statistics").argument("[direct
2667
3508
  ` Coverage: ${(result.embeddingCoverage * 100).toFixed(1)}%`
2668
3509
  );
2669
3510
  } catch (error) {
2670
- console.error(
2671
- error instanceof Error ? error.message : "Unknown error"
2672
- );
2673
- process.exit(1);
3511
+ handleCliError(error);
2674
3512
  }
2675
3513
  });
2676
3514
  program.command("serve").description("Start MCP server with file watching").argument("[directory]", "Directory to serve", ".").option("--no-watch", "Disable file watching").action(async (directory, options) => {
@@ -2702,10 +3540,7 @@ program.command("serve").description("Start MCP server with file watching").argu
2702
3540
  await new Promise(() => {
2703
3541
  });
2704
3542
  } catch (error) {
2705
- console.error(
2706
- error instanceof Error ? error.message : "Unknown error"
2707
- );
2708
- process.exit(1);
3543
+ handleCliError(error);
2709
3544
  }
2710
3545
  });
2711
3546
  program.command("viz").description("Generate graph visualization").argument("[directory]", "Directory to visualize", ".").option("-o, --output <path>", "Output file path").option("--open", "Open in browser after generation").action(
@@ -2725,10 +3560,7 @@ program.command("viz").description("Generate graph visualization").argument("[di
2725
3560
  execFile(openCmd, [result.outputPath]);
2726
3561
  }
2727
3562
  } catch (error) {
2728
- console.error(
2729
- error instanceof Error ? error.message : "Unknown error"
2730
- );
2731
- process.exit(1);
3563
+ handleCliError(error);
2732
3564
  }
2733
3565
  }
2734
3566
  );