@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/README.md +24 -41
- package/dist/cli/index.js +2058 -1226
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +278 -54
- package/dist/index.js +1365 -575
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
658
|
-
import {
|
|
659
|
-
import {
|
|
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/
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
|
831
|
+
const heap = new MinHeap((a, b) => a[1] - b[1]);
|
|
803
832
|
graph.forEachNode((id) => {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
819
|
-
|
|
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/
|
|
833
|
-
var
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
881
|
-
|
|
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
|
-
|
|
904
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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.
|
|
946
|
-
this.
|
|
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
|
-
|
|
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
|
|
954
|
-
|
|
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
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
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.
|
|
1019
|
-
this.
|
|
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.
|
|
1742
|
+
if (this.fileWatcher?.isWatching()) {
|
|
1024
1743
|
throw new Error("Already watching. Call stopWatching() first.");
|
|
1025
1744
|
}
|
|
1026
1745
|
this.onChangeCallback = onChange;
|
|
1027
|
-
|
|
1028
|
-
this.
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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.
|
|
1048
|
-
|
|
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.
|
|
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
|
|
1092
|
-
|
|
1093
|
-
this.pendingChanges.clear();
|
|
1094
|
-
this.debounceTimer = null;
|
|
1763
|
+
async handleWatcherBatch(events) {
|
|
1764
|
+
this.fileWatcher?.pause();
|
|
1095
1765
|
const processedIds = [];
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
if (
|
|
1101
|
-
this.cache.
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
}
|
|
1106
|
-
|
|
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
|
-
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
|
1146
|
-
const
|
|
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
|
-
|
|
1151
|
-
|
|
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 (
|
|
1160
|
-
this.cache.updateOutgoingLinks(node.id,
|
|
1840
|
+
if (finalIds.some((r, i) => r !== node.outgoingLinks[i])) {
|
|
1841
|
+
this.cache.updateOutgoingLinks(node.id, finalIds);
|
|
1161
1842
|
}
|
|
1162
1843
|
}
|
|
1163
1844
|
}
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
*
|
|
1229
|
-
*
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
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
|
|
1926
|
+
var TransformersEmbedding = class {
|
|
1927
|
+
id;
|
|
1259
1928
|
model;
|
|
1260
1929
|
dims;
|
|
1261
1930
|
pipe = null;
|
|
1262
|
-
constructor(
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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("
|
|
2216
|
+
throw new Error("Store configuration is required");
|
|
1482
2217
|
}
|
|
1483
2218
|
const core = new _GraphCoreImpl();
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
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
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
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
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
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
|
-
|
|
1771
|
-
|
|
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",
|
|
2714
|
+
return nodesToResponses(nodes, ctx.store, "list", includeContent);
|
|
1785
2715
|
}
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
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
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1818
|
-
|
|
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
|
-
|
|
1832
|
-
|
|
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
|
|
1836
|
-
if (
|
|
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
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
-
|
|
1881
|
-
|
|
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
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
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
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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/
|
|
1966
|
-
var
|
|
1967
|
-
search:
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
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
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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:
|
|
2295
|
-
inputSchema:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
3187
|
+
const resolvedSourcePath = join7(directory, sourcePath);
|
|
2376
3188
|
const cachePath = config.cache?.path ?? ".roux";
|
|
2377
|
-
const resolvedCachePath =
|
|
2378
|
-
const store = new DocStore(resolvedSourcePath, resolvedCachePath);
|
|
2379
|
-
const
|
|
2380
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
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
|
-
|
|
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
|
|
3268
|
+
import { join as join8, dirname as dirname2 } from "path";
|
|
2442
3269
|
async function vizCommand(directory, options = {}) {
|
|
2443
|
-
const configPath =
|
|
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 =
|
|
2450
|
-
const outputPath = options.output ??
|
|
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, '&')
|
|
3421
|
+
.replace(/</g, '<')
|
|
3422
|
+
.replace(/>/g, '>')
|
|
3423
|
+
.replace(/"/g, '"')
|
|
3424
|
+
.replace(/'/g, ''');
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2729
|
-
error instanceof Error ? error.message : "Unknown error"
|
|
2730
|
-
);
|
|
2731
|
-
process.exit(1);
|
|
3563
|
+
handleCliError(error);
|
|
2732
3564
|
}
|
|
2733
3565
|
}
|
|
2734
3566
|
);
|