@gettymade/roux 0.1.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.
@@ -0,0 +1,2736 @@
1
+ #!/usr/bin/env node
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __commonJS = (cb, mod) => function __require() {
9
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+
28
+ // node_modules/string-similarity/src/index.js
29
+ var require_src = __commonJS({
30
+ "node_modules/string-similarity/src/index.js"(exports, module) {
31
+ "use strict";
32
+ module.exports = {
33
+ compareTwoStrings,
34
+ findBestMatch
35
+ };
36
+ function compareTwoStrings(first, second) {
37
+ first = first.replace(/\s+/g, "");
38
+ second = second.replace(/\s+/g, "");
39
+ if (first === second) return 1;
40
+ if (first.length < 2 || second.length < 2) return 0;
41
+ let firstBigrams = /* @__PURE__ */ new Map();
42
+ for (let i = 0; i < first.length - 1; i++) {
43
+ const bigram = first.substring(i, i + 2);
44
+ const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) + 1 : 1;
45
+ firstBigrams.set(bigram, count);
46
+ }
47
+ ;
48
+ let intersectionSize = 0;
49
+ for (let i = 0; i < second.length - 1; i++) {
50
+ const bigram = second.substring(i, i + 2);
51
+ const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) : 0;
52
+ if (count > 0) {
53
+ firstBigrams.set(bigram, count - 1);
54
+ intersectionSize++;
55
+ }
56
+ }
57
+ return 2 * intersectionSize / (first.length + second.length - 2);
58
+ }
59
+ function findBestMatch(mainString, targetStrings) {
60
+ if (!areArgsValid(mainString, targetStrings)) throw new Error("Bad arguments: First argument should be a string, second should be an array of strings");
61
+ const ratings = [];
62
+ let bestMatchIndex = 0;
63
+ for (let i = 0; i < targetStrings.length; i++) {
64
+ const currentTargetString = targetStrings[i];
65
+ const currentRating = compareTwoStrings(mainString, currentTargetString);
66
+ ratings.push({ target: currentTargetString, rating: currentRating });
67
+ if (currentRating > ratings[bestMatchIndex].rating) {
68
+ bestMatchIndex = i;
69
+ }
70
+ }
71
+ const bestMatch = ratings[bestMatchIndex];
72
+ return { ratings, bestMatch, bestMatchIndex };
73
+ }
74
+ function areArgsValid(mainString, targetStrings) {
75
+ if (typeof mainString !== "string") return false;
76
+ if (!Array.isArray(targetStrings)) return false;
77
+ if (!targetStrings.length) return false;
78
+ if (targetStrings.find(function(s) {
79
+ return typeof s !== "string";
80
+ })) return false;
81
+ return true;
82
+ }
83
+ }
84
+ });
85
+
86
+ // src/cli/index.ts
87
+ import { Command } from "commander";
88
+ import { resolve as resolve2 } from "path";
89
+ import { execFile } from "child_process";
90
+
91
+ // src/cli/commands/init.ts
92
+ import { mkdir, writeFile, readFile, access } from "fs/promises";
93
+ import { join } from "path";
94
+ var DEFAULT_CONFIG = `providers:
95
+ store:
96
+ type: docstore
97
+ `;
98
+ var ROUX_MCP_CONFIG = {
99
+ command: "npx",
100
+ args: ["roux", "serve", "."],
101
+ env: {}
102
+ };
103
+ var HOOK_MARKER = "roux-enforce-mcp";
104
+ var ENFORCE_MCP_HOOK_COMMAND = `node -e "/* ${HOOK_MARKER} */ const d=JSON.parse(require('fs').readFileSync(0,'utf8'));const p=d.tool_input?.file_path||'';if(p.endsWith('.md')){console.error('Use mcp__roux__* tools for markdown files instead of Read/Edit/Write');process.exit(2)}"`;
105
+ var ROUX_HOOK_ENTRY = {
106
+ matcher: "Read|Edit|Write",
107
+ hooks: [{ type: "command", command: ENFORCE_MCP_HOOK_COMMAND }]
108
+ };
109
+ async function initCommand(directory) {
110
+ const configPath = join(directory, "roux.yaml");
111
+ const rouxDir = join(directory, ".roux");
112
+ let configExists = false;
113
+ try {
114
+ await access(configPath);
115
+ configExists = true;
116
+ } catch {
117
+ }
118
+ await mkdir(rouxDir, { recursive: true });
119
+ await updateMcpConfig(directory);
120
+ const storeType = await getStoreType(directory, configExists);
121
+ let hooksInstalled = false;
122
+ if (storeType === "docstore") {
123
+ hooksInstalled = await updateClaudeSettings(directory);
124
+ }
125
+ if (configExists) {
126
+ return { created: false, configPath, hooksInstalled };
127
+ }
128
+ await writeFile(configPath, DEFAULT_CONFIG, "utf-8");
129
+ return { created: true, configPath, hooksInstalled };
130
+ }
131
+ async function updateMcpConfig(directory) {
132
+ const mcpPath = join(directory, ".mcp.json");
133
+ let config = {};
134
+ try {
135
+ const content = await readFile(mcpPath, "utf-8");
136
+ config = JSON.parse(content);
137
+ } catch {
138
+ }
139
+ if (!config.mcpServers) {
140
+ config.mcpServers = {};
141
+ }
142
+ config.mcpServers.roux = ROUX_MCP_CONFIG;
143
+ await writeFile(mcpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
144
+ }
145
+ async function getStoreType(directory, configExists) {
146
+ if (!configExists) {
147
+ return "docstore";
148
+ }
149
+ try {
150
+ const configPath = join(directory, "roux.yaml");
151
+ const content = await readFile(configPath, "utf-8");
152
+ const typeMatch = content.match(/store:\s*\n\s*type:\s*(\w+)/);
153
+ if (typeMatch?.[1]) {
154
+ return typeMatch[1];
155
+ }
156
+ } catch {
157
+ }
158
+ return "docstore";
159
+ }
160
+ async function updateClaudeSettings(directory) {
161
+ const claudeDir = join(directory, ".claude");
162
+ const settingsPath = join(claudeDir, "settings.json");
163
+ await mkdir(claudeDir, { recursive: true });
164
+ let config = {};
165
+ let existingContent = null;
166
+ try {
167
+ existingContent = await readFile(settingsPath, "utf-8");
168
+ config = JSON.parse(existingContent);
169
+ } catch (err) {
170
+ if (existingContent !== null) {
171
+ return false;
172
+ }
173
+ }
174
+ if (!config.hooks) {
175
+ config.hooks = {};
176
+ }
177
+ if (!config.hooks.PreToolUse) {
178
+ config.hooks.PreToolUse = [];
179
+ }
180
+ const hasRouxHook = config.hooks.PreToolUse.some(
181
+ (entry) => entry.hooks?.some((h) => h.command?.includes(HOOK_MARKER))
182
+ );
183
+ if (hasRouxHook) {
184
+ return false;
185
+ }
186
+ config.hooks.PreToolUse.push(ROUX_HOOK_ENTRY);
187
+ await writeFile(settingsPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
188
+ return true;
189
+ }
190
+
191
+ // src/cli/commands/status.ts
192
+ import { access as access2 } from "fs/promises";
193
+ import { join as join4 } from "path";
194
+
195
+ // src/providers/docstore/cache.ts
196
+ var import_string_similarity = __toESM(require_src(), 1);
197
+ import Database from "better-sqlite3";
198
+ import { join as join2 } from "path";
199
+ import { mkdirSync } from "fs";
200
+ var Cache = class {
201
+ db;
202
+ constructor(cacheDir) {
203
+ mkdirSync(cacheDir, { recursive: true });
204
+ const dbPath = join2(cacheDir, "cache.db");
205
+ this.db = new Database(dbPath);
206
+ this.db.pragma("journal_mode = WAL");
207
+ this.initSchema();
208
+ }
209
+ initSchema() {
210
+ this.db.exec(`
211
+ CREATE TABLE IF NOT EXISTS nodes (
212
+ id TEXT PRIMARY KEY,
213
+ title TEXT,
214
+ content TEXT,
215
+ tags TEXT,
216
+ outgoing_links TEXT,
217
+ properties TEXT,
218
+ source_type TEXT,
219
+ source_path TEXT,
220
+ source_modified INTEGER
221
+ );
222
+
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
+ CREATE INDEX IF NOT EXISTS idx_nodes_source_path ON nodes(source_path);
240
+ `);
241
+ this.db.pragma("foreign_keys = ON");
242
+ }
243
+ getTableNames() {
244
+ const rows = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
245
+ return rows.map((r) => r.name);
246
+ }
247
+ upsertNode(node, sourceType, sourcePath, sourceModified) {
248
+ const stmt = this.db.prepare(`
249
+ INSERT INTO nodes (id, title, content, tags, outgoing_links, properties, source_type, source_path, source_modified)
250
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
251
+ ON CONFLICT(id) DO UPDATE SET
252
+ title = excluded.title,
253
+ content = excluded.content,
254
+ tags = excluded.tags,
255
+ outgoing_links = excluded.outgoing_links,
256
+ properties = excluded.properties,
257
+ source_type = excluded.source_type,
258
+ source_path = excluded.source_path,
259
+ source_modified = excluded.source_modified
260
+ `);
261
+ stmt.run(
262
+ node.id,
263
+ node.title,
264
+ node.content,
265
+ JSON.stringify(node.tags),
266
+ JSON.stringify(node.outgoingLinks),
267
+ JSON.stringify(node.properties),
268
+ sourceType,
269
+ sourcePath,
270
+ sourceModified
271
+ );
272
+ }
273
+ getNode(id) {
274
+ const row = this.db.prepare("SELECT * FROM nodes WHERE id = ?").get(id);
275
+ if (!row) return null;
276
+ return this.rowToNode(row);
277
+ }
278
+ getNodes(ids) {
279
+ if (ids.length === 0) return [];
280
+ const placeholders = ids.map(() => "?").join(",");
281
+ const rows = this.db.prepare(`SELECT * FROM nodes WHERE id IN (${placeholders})`).all(...ids);
282
+ const nodeMap = /* @__PURE__ */ new Map();
283
+ for (const row of rows) {
284
+ nodeMap.set(row.id, this.rowToNode(row));
285
+ }
286
+ const result = [];
287
+ for (const id of ids) {
288
+ const node = nodeMap.get(id);
289
+ if (node) result.push(node);
290
+ }
291
+ return result;
292
+ }
293
+ deleteNode(id) {
294
+ this.db.prepare("DELETE FROM nodes WHERE id = ?").run(id);
295
+ }
296
+ getAllNodes() {
297
+ const rows = this.db.prepare("SELECT * FROM nodes").all();
298
+ return rows.map((row) => this.rowToNode(row));
299
+ }
300
+ searchByTags(tags, mode) {
301
+ if (tags.length === 0) return [];
302
+ const allNodes = this.getAllNodes();
303
+ const lowerTags = tags.map((t) => t.toLowerCase());
304
+ return allNodes.filter((node) => {
305
+ const nodeTags = node.tags.map((t) => t.toLowerCase());
306
+ if (mode === "any") {
307
+ return lowerTags.some((t) => nodeTags.includes(t));
308
+ } else {
309
+ return lowerTags.every((t) => nodeTags.includes(t));
310
+ }
311
+ });
312
+ }
313
+ getModifiedTime(sourcePath) {
314
+ const row = this.db.prepare("SELECT source_modified FROM nodes WHERE source_path = ?").get(sourcePath);
315
+ return row?.source_modified ?? null;
316
+ }
317
+ getNodeByPath(sourcePath) {
318
+ const row = this.db.prepare("SELECT * FROM nodes WHERE source_path = ?").get(sourcePath);
319
+ if (!row) return null;
320
+ return this.rowToNode(row);
321
+ }
322
+ getAllTrackedPaths() {
323
+ const rows = this.db.prepare("SELECT source_path FROM nodes").all();
324
+ return new Set(rows.map((r) => r.source_path));
325
+ }
326
+ resolveTitles(ids) {
327
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
328
+ const placeholders = ids.map(() => "?").join(",");
329
+ const rows = this.db.prepare(`SELECT id, title FROM nodes WHERE id IN (${placeholders})`).all(...ids);
330
+ const result = /* @__PURE__ */ new Map();
331
+ for (const row of rows) {
332
+ result.set(row.id, row.title);
333
+ }
334
+ return result;
335
+ }
336
+ nodesExist(ids) {
337
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
338
+ const placeholders = ids.map(() => "?").join(",");
339
+ const rows = this.db.prepare(`SELECT id FROM nodes WHERE id IN (${placeholders})`).all(...ids);
340
+ const existingIds = new Set(rows.map((r) => r.id));
341
+ const result = /* @__PURE__ */ new Map();
342
+ for (const id of ids) {
343
+ result.set(id, existingIds.has(id));
344
+ }
345
+ return result;
346
+ }
347
+ listNodes(filter, options) {
348
+ const limit = Math.min(options?.limit ?? 100, 1e3);
349
+ const offset = options?.offset ?? 0;
350
+ const conditions = [];
351
+ const params = [];
352
+ if (filter.tag) {
353
+ conditions.push("EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) = LOWER(?))");
354
+ params.push(filter.tag);
355
+ }
356
+ if (filter.path) {
357
+ conditions.push("id LIKE ? || '%'");
358
+ params.push(filter.path);
359
+ }
360
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
361
+ const countQuery = `SELECT COUNT(*) as count FROM nodes ${whereClause}`;
362
+ const countRow = this.db.prepare(countQuery).get(...params);
363
+ const total = countRow.count;
364
+ const query = `SELECT id, title FROM nodes ${whereClause} LIMIT ? OFFSET ?`;
365
+ const rows = this.db.prepare(query).all(...params, limit, offset);
366
+ const nodes = rows.map((row) => ({ id: row.id, title: row.title }));
367
+ return { nodes, total };
368
+ }
369
+ resolveNodes(names, options) {
370
+ if (names.length === 0) return [];
371
+ const strategy = options?.strategy ?? "fuzzy";
372
+ const threshold = options?.threshold ?? 0.7;
373
+ const filter = {};
374
+ if (options?.tag) filter.tag = options.tag;
375
+ if (options?.path) filter.path = options.path;
376
+ const { nodes: candidates } = this.listNodes(filter, { limit: 1e3 });
377
+ if (candidates.length === 0) {
378
+ return names.map((query) => ({ query, match: null, score: 0 }));
379
+ }
380
+ const candidateTitles = candidates.map((c) => c.title.toLowerCase());
381
+ const titleToId = /* @__PURE__ */ new Map();
382
+ for (const c of candidates) {
383
+ titleToId.set(c.title.toLowerCase(), c.id);
384
+ }
385
+ return names.map((query) => {
386
+ const queryLower = query.toLowerCase();
387
+ if (strategy === "exact") {
388
+ const matchedId = titleToId.get(queryLower);
389
+ if (matchedId) {
390
+ return { query, match: matchedId, score: 1 };
391
+ }
392
+ return { query, match: null, score: 0 };
393
+ }
394
+ if (strategy === "fuzzy") {
395
+ const result = import_string_similarity.default.findBestMatch(queryLower, candidateTitles);
396
+ const bestMatch = result.bestMatch;
397
+ if (bestMatch.rating >= threshold) {
398
+ const matchedId = titleToId.get(bestMatch.target);
399
+ return { query, match: matchedId, score: bestMatch.rating };
400
+ }
401
+ return { query, match: null, score: 0 };
402
+ }
403
+ return { query, match: null, score: 0 };
404
+ });
405
+ }
406
+ updateOutgoingLinks(nodeId, links) {
407
+ this.db.prepare("UPDATE nodes SET outgoing_links = ? WHERE id = ?").run(JSON.stringify(links), nodeId);
408
+ }
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
+ storeCentrality(nodeId, pagerank, inDegree, outDegree, computedAt) {
435
+ this.db.prepare(
436
+ `
437
+ INSERT INTO centrality (node_id, pagerank, in_degree, out_degree, computed_at)
438
+ VALUES (?, ?, ?, ?, ?)
439
+ ON CONFLICT(node_id) DO UPDATE SET
440
+ pagerank = excluded.pagerank,
441
+ in_degree = excluded.in_degree,
442
+ out_degree = excluded.out_degree,
443
+ computed_at = excluded.computed_at
444
+ `
445
+ ).run(nodeId, pagerank, inDegree, outDegree, computedAt);
446
+ }
447
+ getCentrality(nodeId) {
448
+ const row = this.db.prepare("SELECT * FROM centrality WHERE node_id = ?").get(nodeId);
449
+ if (!row) return null;
450
+ return {
451
+ pagerank: row.pagerank,
452
+ inDegree: row.in_degree,
453
+ outDegree: row.out_degree,
454
+ computedAt: row.computed_at
455
+ };
456
+ }
457
+ getStats() {
458
+ 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
+ const edgeSum = this.db.prepare("SELECT SUM(in_degree) as total FROM centrality").get();
461
+ return {
462
+ nodeCount: nodeCount.count,
463
+ embeddingCount: embeddingCount.count,
464
+ edgeCount: edgeSum.total ?? 0
465
+ };
466
+ }
467
+ clear() {
468
+ this.db.exec("DELETE FROM centrality");
469
+ this.db.exec("DELETE FROM embeddings");
470
+ this.db.exec("DELETE FROM nodes");
471
+ }
472
+ close() {
473
+ this.db.close();
474
+ }
475
+ rowToNode(row) {
476
+ const sourceRef = {
477
+ type: row.source_type,
478
+ path: row.source_path,
479
+ lastModified: new Date(row.source_modified)
480
+ };
481
+ return {
482
+ id: row.id,
483
+ title: row.title,
484
+ content: row.content,
485
+ tags: JSON.parse(row.tags),
486
+ outgoingLinks: JSON.parse(row.outgoing_links),
487
+ properties: JSON.parse(row.properties),
488
+ sourceRef
489
+ };
490
+ }
491
+ };
492
+
493
+ // src/providers/vector/sqlite.ts
494
+ import Database2 from "better-sqlite3";
495
+ import { join as join3 } from "path";
496
+ var SqliteVectorProvider = class {
497
+ db;
498
+ ownsDb;
499
+ constructor(pathOrDb) {
500
+ if (typeof pathOrDb === "string") {
501
+ this.db = new Database2(join3(pathOrDb, "vectors.db"));
502
+ this.ownsDb = true;
503
+ } else {
504
+ this.db = pathOrDb;
505
+ this.ownsDb = false;
506
+ }
507
+ this.init();
508
+ }
509
+ init() {
510
+ this.db.exec(`
511
+ CREATE TABLE IF NOT EXISTS vectors (
512
+ id TEXT PRIMARY KEY,
513
+ model TEXT NOT NULL,
514
+ vector BLOB NOT NULL
515
+ )
516
+ `);
517
+ }
518
+ async store(id, vector, model) {
519
+ if (vector.length === 0) {
520
+ throw new Error("Cannot store empty vector");
521
+ }
522
+ for (const v of vector) {
523
+ if (!Number.isFinite(v)) {
524
+ throw new Error(`Invalid vector value: ${v}`);
525
+ }
526
+ }
527
+ const existing = this.db.prepare("SELECT LENGTH(vector) / 4 as dim FROM vectors WHERE id != ? LIMIT 1").get(id);
528
+ if (existing && existing.dim !== vector.length) {
529
+ throw new Error(
530
+ `Dimension mismatch: cannot store ${vector.length}-dim vector, existing vectors have ${existing.dim} dimensions`
531
+ );
532
+ }
533
+ const blob = Buffer.from(new Float32Array(vector).buffer);
534
+ this.db.prepare(
535
+ `INSERT OR REPLACE INTO vectors (id, model, vector) VALUES (?, ?, ?)`
536
+ ).run(id, model, blob);
537
+ }
538
+ async search(vector, limit) {
539
+ if (vector.length === 0) {
540
+ throw new Error("Cannot search with empty vector");
541
+ }
542
+ for (const v of vector) {
543
+ if (!Number.isFinite(v)) {
544
+ throw new Error(`Invalid vector value: ${v}`);
545
+ }
546
+ }
547
+ if (limit <= 0) {
548
+ return [];
549
+ }
550
+ const rows = this.db.prepare("SELECT id, vector FROM vectors").all();
551
+ if (rows.length === 0) {
552
+ return [];
553
+ }
554
+ const firstStoredDim = rows[0].vector.byteLength / 4;
555
+ if (vector.length !== firstStoredDim) {
556
+ throw new Error(
557
+ `Dimension mismatch: query has ${vector.length} dimensions, stored vectors have ${firstStoredDim}`
558
+ );
559
+ }
560
+ const queryVec = new Float32Array(vector);
561
+ const results = [];
562
+ for (const row of rows) {
563
+ const storedVec = new Float32Array(
564
+ row.vector.buffer,
565
+ row.vector.byteOffset,
566
+ row.vector.byteLength / 4
567
+ );
568
+ const distance = cosineDistance(queryVec, storedVec);
569
+ results.push({ id: row.id, distance });
570
+ }
571
+ results.sort((a, b) => a.distance - b.distance);
572
+ return results.slice(0, limit);
573
+ }
574
+ async delete(id) {
575
+ this.db.prepare("DELETE FROM vectors WHERE id = ?").run(id);
576
+ }
577
+ async getModel(id) {
578
+ const row = this.db.prepare("SELECT model FROM vectors WHERE id = ?").get(id);
579
+ return row?.model ?? null;
580
+ }
581
+ hasEmbedding(id) {
582
+ const row = this.db.prepare("SELECT 1 FROM vectors WHERE id = ?").get(id);
583
+ return row !== void 0;
584
+ }
585
+ /** For testing: get table names */
586
+ getTableNames() {
587
+ const rows = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
588
+ return rows.map((r) => r.name);
589
+ }
590
+ /** For testing: get vector blob size */
591
+ getVectorBlobSize(id) {
592
+ const row = this.db.prepare("SELECT LENGTH(vector) as size FROM vectors WHERE id = ?").get(id);
593
+ return row?.size ?? null;
594
+ }
595
+ /** Get total number of stored embeddings */
596
+ getEmbeddingCount() {
597
+ const row = this.db.prepare("SELECT COUNT(*) as count FROM vectors").get();
598
+ return row.count;
599
+ }
600
+ close() {
601
+ if (this.ownsDb) {
602
+ this.db.close();
603
+ }
604
+ }
605
+ };
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
+
624
+ // src/cli/commands/status.ts
625
+ async function statusCommand(directory) {
626
+ const configPath = join4(directory, "roux.yaml");
627
+ try {
628
+ await access2(configPath);
629
+ } catch {
630
+ throw new Error(`Directory not initialized. Run 'roux init' first.`);
631
+ }
632
+ const cacheDir = join4(directory, ".roux");
633
+ const cache = new Cache(cacheDir);
634
+ const vectorProvider = new SqliteVectorProvider(cacheDir);
635
+ try {
636
+ const stats = cache.getStats();
637
+ const embeddingCount = vectorProvider.getEmbeddingCount();
638
+ const embeddingCoverage = stats.nodeCount === 0 ? 1 : embeddingCount / stats.nodeCount;
639
+ return {
640
+ nodeCount: stats.nodeCount,
641
+ edgeCount: stats.edgeCount,
642
+ embeddingCount,
643
+ embeddingCoverage
644
+ };
645
+ } finally {
646
+ cache.close();
647
+ vectorProvider.close();
648
+ }
649
+ }
650
+
651
+ // src/cli/commands/serve.ts
652
+ import { access as access3, readFile as readFile3 } from "fs/promises";
653
+ import { join as join6 } from "path";
654
+ import { parse as parseYaml } from "yaml";
655
+
656
+ // src/providers/docstore/index.ts
657
+ import { readFile as readFile2, writeFile as writeFile2, stat, readdir, mkdir as mkdir2, rm } from "fs/promises";
658
+ import { join as join5, relative, dirname, resolve } from "path";
659
+ import { watch } from "chokidar";
660
+
661
+ // src/providers/docstore/parser.ts
662
+ import matter from "gray-matter";
663
+ function parseMarkdown(raw) {
664
+ let parsed;
665
+ try {
666
+ parsed = matter(raw);
667
+ } catch {
668
+ return {
669
+ title: void 0,
670
+ tags: [],
671
+ properties: {},
672
+ content: raw
673
+ };
674
+ }
675
+ const data = parsed.data;
676
+ const title = typeof data["title"] === "string" ? data["title"] : void 0;
677
+ let tags = [];
678
+ if (Array.isArray(data["tags"])) {
679
+ tags = data["tags"].filter((t) => typeof t === "string");
680
+ }
681
+ const properties = {};
682
+ for (const [key, value] of Object.entries(data)) {
683
+ if (key !== "title" && key !== "tags") {
684
+ properties[key] = value;
685
+ }
686
+ }
687
+ return {
688
+ title,
689
+ tags,
690
+ properties,
691
+ content: parsed.content.trim()
692
+ };
693
+ }
694
+ function extractWikiLinks(content) {
695
+ const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, "");
696
+ const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]+`/g, "");
697
+ const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
698
+ const seen = /* @__PURE__ */ new Set();
699
+ const links = [];
700
+ let match;
701
+ while ((match = linkRegex.exec(withoutInlineCode)) !== null) {
702
+ const target = match[1]?.trim();
703
+ if (target && !seen.has(target)) {
704
+ seen.add(target);
705
+ links.push(target);
706
+ }
707
+ }
708
+ return links;
709
+ }
710
+ function normalizeId(path) {
711
+ return path.toLowerCase().replace(/\\/g, "/");
712
+ }
713
+ function titleFromPath(path) {
714
+ const parts = path.split(/[/\\]/);
715
+ const filename = parts.at(-1);
716
+ const withoutExt = filename.replace(/\.[^.]+$/, "");
717
+ const spaced = withoutExt.replace(/[-_]+/g, " ").toLowerCase();
718
+ return spaced.split(" ").filter((w) => w.length > 0).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
719
+ }
720
+ function serializeToMarkdown(parsed) {
721
+ const hasFrontmatter = parsed.title !== void 0 || parsed.tags.length > 0 || Object.keys(parsed.properties).length > 0;
722
+ if (!hasFrontmatter) {
723
+ return parsed.content;
724
+ }
725
+ const frontmatter = {};
726
+ if (parsed.title !== void 0) {
727
+ frontmatter["title"] = parsed.title;
728
+ }
729
+ if (parsed.tags.length > 0) {
730
+ frontmatter["tags"] = parsed.tags;
731
+ }
732
+ for (const [key, value] of Object.entries(parsed.properties)) {
733
+ frontmatter[key] = value;
734
+ }
735
+ return matter.stringify(parsed.content, frontmatter);
736
+ }
737
+
738
+ // src/graph/builder.ts
739
+ import { DirectedGraph } from "graphology";
740
+ function buildGraph(nodes) {
741
+ const graph = new DirectedGraph();
742
+ const nodeIds = /* @__PURE__ */ new Set();
743
+ for (const node of nodes) {
744
+ graph.addNode(node.id);
745
+ nodeIds.add(node.id);
746
+ }
747
+ for (const node of nodes) {
748
+ const seen = /* @__PURE__ */ new Set();
749
+ for (const target of node.outgoingLinks) {
750
+ if (!nodeIds.has(target) || seen.has(target)) {
751
+ continue;
752
+ }
753
+ seen.add(target);
754
+ graph.addDirectedEdge(node.id, target);
755
+ }
756
+ }
757
+ return graph;
758
+ }
759
+
760
+ // src/graph/operations.ts
761
+ import { bidirectional } from "graphology-shortest-path";
762
+ function getNeighborIds(graph, id, options) {
763
+ if (!graph.hasNode(id)) {
764
+ return [];
765
+ }
766
+ let neighbors;
767
+ switch (options.direction) {
768
+ case "in":
769
+ neighbors = graph.inNeighbors(id);
770
+ break;
771
+ case "out":
772
+ neighbors = graph.outNeighbors(id);
773
+ break;
774
+ case "both":
775
+ neighbors = graph.neighbors(id);
776
+ break;
777
+ }
778
+ if (options.limit !== void 0) {
779
+ if (options.limit <= 0) {
780
+ return [];
781
+ }
782
+ if (options.limit < neighbors.length) {
783
+ return neighbors.slice(0, options.limit);
784
+ }
785
+ }
786
+ return neighbors;
787
+ }
788
+ function findPath(graph, source, target) {
789
+ if (!graph.hasNode(source) || !graph.hasNode(target)) {
790
+ return null;
791
+ }
792
+ if (source === target) {
793
+ return [source];
794
+ }
795
+ const path = bidirectional(graph, source, target);
796
+ return path;
797
+ }
798
+ function getHubs(graph, metric, limit) {
799
+ if (limit <= 0) {
800
+ return [];
801
+ }
802
+ const scores = [];
803
+ graph.forEachNode((id) => {
804
+ let score;
805
+ switch (metric) {
806
+ case "in_degree":
807
+ score = graph.inDegree(id);
808
+ break;
809
+ case "out_degree":
810
+ score = graph.outDegree(id);
811
+ break;
812
+ case "pagerank":
813
+ score = graph.inDegree(id);
814
+ break;
815
+ }
816
+ scores.push([id, score]);
817
+ });
818
+ scores.sort((a, b) => b[1] - a[1]);
819
+ return scores.slice(0, limit);
820
+ }
821
+ function computeCentrality(graph) {
822
+ const result = /* @__PURE__ */ new Map();
823
+ graph.forEachNode((id) => {
824
+ result.set(id, {
825
+ inDegree: graph.inDegree(id),
826
+ outDegree: graph.outDegree(id)
827
+ });
828
+ });
829
+ return result;
830
+ }
831
+
832
+ // src/providers/docstore/index.ts
833
+ var DocStore = class _DocStore {
834
+ cache;
835
+ sourceRoot;
836
+ graph = null;
837
+ vectorProvider;
838
+ ownsVectorProvider;
839
+ watcher = null;
840
+ debounceTimer = null;
841
+ pendingChanges = /* @__PURE__ */ new Map();
842
+ onChangeCallback;
843
+ constructor(sourceRoot, cacheDir, vectorProvider) {
844
+ this.sourceRoot = sourceRoot;
845
+ this.cache = new Cache(cacheDir);
846
+ this.ownsVectorProvider = !vectorProvider;
847
+ this.vectorProvider = vectorProvider ?? new SqliteVectorProvider(cacheDir);
848
+ }
849
+ async sync() {
850
+ const currentPaths = await this.collectMarkdownFiles(this.sourceRoot);
851
+ const trackedPaths = this.cache.getAllTrackedPaths();
852
+ for (const filePath of currentPaths) {
853
+ try {
854
+ const mtime = await this.getFileMtime(filePath);
855
+ const cachedMtime = this.cache.getModifiedTime(filePath);
856
+ if (cachedMtime === null || mtime > cachedMtime) {
857
+ const node = await this.fileToNode(filePath);
858
+ this.cache.upsertNode(node, "file", filePath, mtime);
859
+ }
860
+ } catch (err) {
861
+ if (err.code === "ENOENT") {
862
+ continue;
863
+ }
864
+ throw err;
865
+ }
866
+ }
867
+ const currentSet = new Set(currentPaths);
868
+ for (const tracked of trackedPaths) {
869
+ if (!currentSet.has(tracked)) {
870
+ const node = this.cache.getNodeByPath(tracked);
871
+ if (node) {
872
+ this.cache.deleteNode(node.id);
873
+ }
874
+ }
875
+ }
876
+ const filenameIndex = this.buildFilenameIndex();
877
+ this.resolveOutgoingLinks(filenameIndex);
878
+ this.rebuildGraph();
879
+ }
880
+ async createNode(node) {
881
+ const normalizedId = normalizeId(node.id);
882
+ this.validatePathWithinSource(normalizedId);
883
+ const existing = this.cache.getNode(normalizedId);
884
+ if (existing) {
885
+ throw new Error(`Node already exists: ${normalizedId}`);
886
+ }
887
+ const filePath = join5(this.sourceRoot, normalizedId);
888
+ const dir = dirname(filePath);
889
+ await mkdir2(dir, { recursive: true });
890
+ const parsed = {
891
+ title: node.title,
892
+ tags: node.tags,
893
+ properties: node.properties,
894
+ content: node.content
895
+ };
896
+ const markdown = serializeToMarkdown(parsed);
897
+ await writeFile2(filePath, markdown, "utf-8");
898
+ const mtime = await this.getFileMtime(filePath);
899
+ const normalizedNode = { ...node, id: normalizedId };
900
+ this.cache.upsertNode(normalizedNode, "file", filePath, mtime);
901
+ this.rebuildGraph();
902
+ }
903
+ async updateNode(id, updates) {
904
+ const normalizedId = normalizeId(id);
905
+ const existing = this.cache.getNode(normalizedId);
906
+ if (!existing) {
907
+ throw new Error(`Node not found: ${id}`);
908
+ }
909
+ let outgoingLinks = updates.outgoingLinks;
910
+ if (updates.content !== void 0 && outgoingLinks === void 0) {
911
+ const rawLinks = extractWikiLinks(updates.content);
912
+ outgoingLinks = rawLinks.map((link) => this.normalizeWikiLink(link));
913
+ }
914
+ const updated = {
915
+ ...existing,
916
+ ...updates,
917
+ outgoingLinks: outgoingLinks ?? existing.outgoingLinks,
918
+ id: existing.id
919
+ // ID cannot be changed
920
+ };
921
+ const filePath = join5(this.sourceRoot, existing.id);
922
+ const parsed = {
923
+ title: updated.title,
924
+ tags: updated.tags,
925
+ properties: updated.properties,
926
+ content: updated.content
927
+ };
928
+ const markdown = serializeToMarkdown(parsed);
929
+ await writeFile2(filePath, markdown, "utf-8");
930
+ const mtime = await this.getFileMtime(filePath);
931
+ this.cache.upsertNode(updated, "file", filePath, mtime);
932
+ if (outgoingLinks !== void 0 || updates.outgoingLinks !== void 0) {
933
+ this.rebuildGraph();
934
+ }
935
+ }
936
+ async deleteNode(id) {
937
+ const normalizedId = normalizeId(id);
938
+ const existing = this.cache.getNode(normalizedId);
939
+ if (!existing) {
940
+ throw new Error(`Node not found: ${id}`);
941
+ }
942
+ const filePath = join5(this.sourceRoot, existing.id);
943
+ await rm(filePath);
944
+ this.cache.deleteNode(existing.id);
945
+ await this.vectorProvider.delete(existing.id);
946
+ this.rebuildGraph();
947
+ }
948
+ async getNode(id) {
949
+ const normalizedId = normalizeId(id);
950
+ return this.cache.getNode(normalizedId);
951
+ }
952
+ async getNodes(ids) {
953
+ const normalizedIds = ids.map(normalizeId);
954
+ return this.cache.getNodes(normalizedIds);
955
+ }
956
+ async getAllNodeIds() {
957
+ const nodes = this.cache.getAllNodes();
958
+ return nodes.map((n) => n.id);
959
+ }
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];
975
+ }
976
+ async resolveTitles(ids) {
977
+ return this.cache.resolveTitles(ids);
978
+ }
979
+ async listNodes(filter, options) {
980
+ return this.cache.listNodes(filter, options);
981
+ }
982
+ async resolveNodes(names, options) {
983
+ const strategy = options?.strategy ?? "fuzzy";
984
+ if (strategy === "exact" || strategy === "fuzzy") {
985
+ return this.cache.resolveNodes(names, options);
986
+ }
987
+ return names.map((query) => ({ query, match: null, score: 0 }));
988
+ }
989
+ async nodesExist(ids) {
990
+ const normalizedIds = ids.map(normalizeId);
991
+ return this.cache.nodesExist(normalizedIds);
992
+ }
993
+ async getNeighbors(id, options) {
994
+ this.ensureGraph();
995
+ const neighborIds = getNeighborIds(this.graph, id, options);
996
+ return this.cache.getNodes(neighborIds);
997
+ }
998
+ async findPath(source, target) {
999
+ this.ensureGraph();
1000
+ return findPath(this.graph, source, target);
1001
+ }
1002
+ async getHubs(metric, limit) {
1003
+ this.ensureGraph();
1004
+ return getHubs(this.graph, metric, limit);
1005
+ }
1006
+ async storeEmbedding(id, vector, model) {
1007
+ return this.vectorProvider.store(id, vector, model);
1008
+ }
1009
+ async searchByVector(vector, limit) {
1010
+ return this.vectorProvider.search(vector, limit);
1011
+ }
1012
+ hasEmbedding(id) {
1013
+ return this.vectorProvider.hasEmbedding(id);
1014
+ }
1015
+ close() {
1016
+ this.stopWatching();
1017
+ this.cache.close();
1018
+ if (this.ownsVectorProvider && "close" in this.vectorProvider) {
1019
+ this.vectorProvider.close();
1020
+ }
1021
+ }
1022
+ startWatching(onChange) {
1023
+ if (this.watcher) {
1024
+ throw new Error("Already watching. Call stopWatching() first.");
1025
+ }
1026
+ this.onChangeCallback = onChange;
1027
+ return new Promise((resolve3, reject) => {
1028
+ this.watcher = watch(this.sourceRoot, {
1029
+ ignoreInitial: true,
1030
+ ignored: [..._DocStore.EXCLUDED_DIRS].map((dir) => `**/${dir}/**`),
1031
+ awaitWriteFinish: {
1032
+ stabilityThreshold: 100
1033
+ },
1034
+ followSymlinks: false
1035
+ });
1036
+ this.watcher.on("ready", () => resolve3()).on("add", (path) => this.queueChange(path, "add")).on("change", (path) => this.queueChange(path, "change")).on("unlink", (path) => this.queueChange(path, "unlink")).on("error", (err) => {
1037
+ if (err.code === "EMFILE") {
1038
+ console.error(
1039
+ "File watcher hit file descriptor limit. Try: ulimit -n 65536 or reduce watched files."
1040
+ );
1041
+ }
1042
+ reject(err);
1043
+ });
1044
+ });
1045
+ }
1046
+ stopWatching() {
1047
+ if (this.debounceTimer) {
1048
+ clearTimeout(this.debounceTimer);
1049
+ this.debounceTimer = null;
1050
+ }
1051
+ this.pendingChanges.clear();
1052
+ if (this.watcher) {
1053
+ this.watcher.close();
1054
+ this.watcher = null;
1055
+ }
1056
+ }
1057
+ isWatching() {
1058
+ return this.watcher !== null;
1059
+ }
1060
+ queueChange(filePath, event) {
1061
+ const relativePath = relative(this.sourceRoot, filePath);
1062
+ const id = normalizeId(relativePath);
1063
+ if (!filePath.endsWith(".md")) {
1064
+ return;
1065
+ }
1066
+ const pathParts = relativePath.split("/");
1067
+ for (const part of pathParts) {
1068
+ if (_DocStore.EXCLUDED_DIRS.has(part)) {
1069
+ return;
1070
+ }
1071
+ }
1072
+ const existing = this.pendingChanges.get(id);
1073
+ if (existing) {
1074
+ if (existing === "add" && event === "change") {
1075
+ return;
1076
+ } else if (existing === "add" && event === "unlink") {
1077
+ this.pendingChanges.delete(id);
1078
+ } else if (existing === "change" && event === "unlink") {
1079
+ this.pendingChanges.set(id, "unlink");
1080
+ }
1081
+ } else {
1082
+ this.pendingChanges.set(id, event);
1083
+ }
1084
+ if (this.debounceTimer) {
1085
+ clearTimeout(this.debounceTimer);
1086
+ }
1087
+ this.debounceTimer = setTimeout(() => {
1088
+ this.processQueue();
1089
+ }, 1e3);
1090
+ }
1091
+ async processQueue() {
1092
+ const changes = new Map(this.pendingChanges);
1093
+ this.pendingChanges.clear();
1094
+ this.debounceTimer = null;
1095
+ const processedIds = [];
1096
+ for (const [id, event] of changes) {
1097
+ try {
1098
+ if (event === "unlink") {
1099
+ const existing = this.cache.getNode(id);
1100
+ if (existing) {
1101
+ this.cache.deleteNode(id);
1102
+ await this.vectorProvider.delete(id);
1103
+ processedIds.push(id);
1104
+ }
1105
+ } else {
1106
+ const filePath = join5(this.sourceRoot, id);
1107
+ const node = await this.fileToNode(filePath);
1108
+ const mtime = await this.getFileMtime(filePath);
1109
+ this.cache.upsertNode(node, "file", filePath, mtime);
1110
+ processedIds.push(id);
1111
+ }
1112
+ } catch (err) {
1113
+ console.warn(`Failed to process file change for ${id}:`, err);
1114
+ }
1115
+ }
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
+ }
1138
+ resolveOutgoingLinks(filenameIndex) {
1139
+ const validNodeIds = /* @__PURE__ */ new Set();
1140
+ for (const paths of filenameIndex.values()) {
1141
+ for (const path of paths) {
1142
+ validNodeIds.add(path);
1143
+ }
1144
+ }
1145
+ for (const node of this.cache.getAllNodes()) {
1146
+ const resolved = node.outgoingLinks.map((link) => {
1147
+ if (validNodeIds.has(link)) {
1148
+ return link;
1149
+ }
1150
+ if (link.includes("/")) {
1151
+ return link;
1152
+ }
1153
+ const matches = filenameIndex.get(link);
1154
+ if (matches && matches.length > 0) {
1155
+ return matches[0];
1156
+ }
1157
+ return link;
1158
+ });
1159
+ if (resolved.some((r, i) => r !== node.outgoingLinks[i])) {
1160
+ this.cache.updateOutgoingLinks(node.id, resolved);
1161
+ }
1162
+ }
1163
+ }
1164
+ ensureGraph() {
1165
+ if (!this.graph) {
1166
+ this.rebuildGraph();
1167
+ }
1168
+ }
1169
+ rebuildGraph() {
1170
+ const nodes = this.cache.getAllNodes();
1171
+ this.graph = buildGraph(nodes);
1172
+ const centrality = computeCentrality(this.graph);
1173
+ const now = Date.now();
1174
+ for (const [id, metrics] of centrality) {
1175
+ this.cache.storeCentrality(id, 0, metrics.inDegree, metrics.outDegree, now);
1176
+ }
1177
+ }
1178
+ static EXCLUDED_DIRS = /* @__PURE__ */ new Set([".roux", "node_modules", ".git", ".obsidian"]);
1179
+ async collectMarkdownFiles(dir) {
1180
+ const results = [];
1181
+ let entries;
1182
+ try {
1183
+ entries = await readdir(dir, { withFileTypes: true });
1184
+ } catch {
1185
+ return results;
1186
+ }
1187
+ for (const entry of entries) {
1188
+ const fullPath = join5(dir, entry.name);
1189
+ if (entry.isDirectory()) {
1190
+ if (_DocStore.EXCLUDED_DIRS.has(entry.name)) {
1191
+ continue;
1192
+ }
1193
+ const nested = await this.collectMarkdownFiles(fullPath);
1194
+ results.push(...nested);
1195
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
1196
+ results.push(fullPath);
1197
+ }
1198
+ }
1199
+ return results;
1200
+ }
1201
+ async getFileMtime(filePath) {
1202
+ const stats = await stat(filePath);
1203
+ return stats.mtimeMs;
1204
+ }
1205
+ async fileToNode(filePath) {
1206
+ const raw = await readFile2(filePath, "utf-8");
1207
+ const parsed = parseMarkdown(raw);
1208
+ const relativePath = relative(this.sourceRoot, filePath);
1209
+ const id = normalizeId(relativePath);
1210
+ const title = parsed.title ?? titleFromPath(id);
1211
+ const rawLinks = extractWikiLinks(parsed.content);
1212
+ const outgoingLinks = rawLinks.map((link) => this.normalizeWikiLink(link));
1213
+ return {
1214
+ id,
1215
+ title,
1216
+ content: parsed.content,
1217
+ tags: parsed.tags,
1218
+ outgoingLinks,
1219
+ properties: parsed.properties,
1220
+ sourceRef: {
1221
+ type: "file",
1222
+ path: filePath,
1223
+ lastModified: new Date(await this.getFileMtime(filePath))
1224
+ }
1225
+ };
1226
+ }
1227
+ /**
1228
+ * Normalize a wiki-link target to an ID.
1229
+ * - If it has a file extension, normalize as-is
1230
+ * - If no extension, add .md
1231
+ * - Lowercase, forward slashes
1232
+ */
1233
+ normalizeWikiLink(target) {
1234
+ let normalized = target.toLowerCase().replace(/\\/g, "/");
1235
+ if (!this.hasFileExtension(normalized)) {
1236
+ normalized += ".md";
1237
+ }
1238
+ return normalized;
1239
+ }
1240
+ hasFileExtension(path) {
1241
+ const match = path.match(/\.([a-z0-9]{1,4})$/i);
1242
+ if (!match?.[1]) return false;
1243
+ return /[a-z]/i.test(match[1]);
1244
+ }
1245
+ validatePathWithinSource(id) {
1246
+ const resolvedPath = resolve(this.sourceRoot, id);
1247
+ const resolvedRoot = resolve(this.sourceRoot);
1248
+ if (!resolvedPath.startsWith(resolvedRoot + "/")) {
1249
+ throw new Error(`Path traversal detected: ${id} resolves outside source root`);
1250
+ }
1251
+ }
1252
+ };
1253
+
1254
+ // src/providers/embedding/transformers.ts
1255
+ import { pipeline } from "@xenova/transformers";
1256
+ var DEFAULT_MODEL = "Xenova/all-MiniLM-L6-v2";
1257
+ var DEFAULT_DIMENSIONS = 384;
1258
+ var TransformersEmbeddingProvider = class {
1259
+ model;
1260
+ dims;
1261
+ pipe = null;
1262
+ constructor(model = DEFAULT_MODEL, dimensions = DEFAULT_DIMENSIONS) {
1263
+ this.model = model;
1264
+ this.dims = dimensions;
1265
+ }
1266
+ async getPipeline() {
1267
+ if (!this.pipe) {
1268
+ this.pipe = await pipeline("feature-extraction", this.model);
1269
+ }
1270
+ return this.pipe;
1271
+ }
1272
+ async embed(text) {
1273
+ const pipe = await this.getPipeline();
1274
+ const output = await pipe(text, { pooling: "mean", normalize: true });
1275
+ return Array.from(output.data);
1276
+ }
1277
+ async embedBatch(texts) {
1278
+ if (texts.length === 0) {
1279
+ return [];
1280
+ }
1281
+ return Promise.all(texts.map((t) => this.embed(t)));
1282
+ }
1283
+ dimensions() {
1284
+ return this.dims;
1285
+ }
1286
+ modelId() {
1287
+ return this.model;
1288
+ }
1289
+ };
1290
+
1291
+ // src/core/graphcore.ts
1292
+ var GraphCoreImpl = class _GraphCoreImpl {
1293
+ store = null;
1294
+ embedding = null;
1295
+ registerStore(provider) {
1296
+ if (!provider) {
1297
+ throw new Error("Store provider is required");
1298
+ }
1299
+ this.store = provider;
1300
+ }
1301
+ registerEmbedding(provider) {
1302
+ if (!provider) {
1303
+ throw new Error("Embedding provider is required");
1304
+ }
1305
+ this.embedding = provider;
1306
+ }
1307
+ requireStore() {
1308
+ if (!this.store) {
1309
+ throw new Error("StoreProvider not registered");
1310
+ }
1311
+ return this.store;
1312
+ }
1313
+ requireEmbedding() {
1314
+ if (!this.embedding) {
1315
+ throw new Error("EmbeddingProvider not registered");
1316
+ }
1317
+ return this.embedding;
1318
+ }
1319
+ async search(query, options) {
1320
+ const store = this.requireStore();
1321
+ const embedding = this.requireEmbedding();
1322
+ const limit = options?.limit ?? 10;
1323
+ const vector = await embedding.embed(query);
1324
+ const results = await store.searchByVector(vector, limit);
1325
+ const ids = results.map((r) => r.id);
1326
+ return store.getNodes(ids);
1327
+ }
1328
+ async getNode(id, depth) {
1329
+ const store = this.requireStore();
1330
+ const node = await store.getNode(id);
1331
+ if (!node) {
1332
+ return null;
1333
+ }
1334
+ if (!depth || depth === 0) {
1335
+ return node;
1336
+ }
1337
+ const [incomingNeighbors, outgoingNeighbors] = await Promise.all([
1338
+ store.getNeighbors(id, { direction: "in" }),
1339
+ store.getNeighbors(id, { direction: "out" })
1340
+ ]);
1341
+ const neighborMap = /* @__PURE__ */ new Map();
1342
+ for (const n of [...incomingNeighbors, ...outgoingNeighbors]) {
1343
+ neighborMap.set(n.id, n);
1344
+ }
1345
+ const result = {
1346
+ ...node,
1347
+ neighbors: Array.from(neighborMap.values()),
1348
+ incomingCount: incomingNeighbors.length,
1349
+ outgoingCount: outgoingNeighbors.length
1350
+ };
1351
+ return result;
1352
+ }
1353
+ async createNode(partial) {
1354
+ const store = this.requireStore();
1355
+ if (!partial.id || partial.id.trim() === "") {
1356
+ throw new Error("Node id is required and cannot be empty");
1357
+ }
1358
+ if (!partial.title) {
1359
+ throw new Error("Node title is required");
1360
+ }
1361
+ const node = {
1362
+ id: partial.id,
1363
+ title: partial.title,
1364
+ content: partial.content ?? "",
1365
+ tags: partial.tags ?? [],
1366
+ outgoingLinks: partial.outgoingLinks ?? [],
1367
+ properties: partial.properties ?? {},
1368
+ ...partial.sourceRef && { sourceRef: partial.sourceRef }
1369
+ };
1370
+ await store.createNode(node);
1371
+ return await store.getNode(node.id) ?? node;
1372
+ }
1373
+ async updateNode(id, updates) {
1374
+ const store = this.requireStore();
1375
+ await store.updateNode(id, updates);
1376
+ const updated = await store.getNode(id);
1377
+ if (!updated) {
1378
+ throw new Error(`Node not found after update: ${id}`);
1379
+ }
1380
+ return updated;
1381
+ }
1382
+ async deleteNode(id) {
1383
+ const store = this.requireStore();
1384
+ try {
1385
+ await store.deleteNode(id);
1386
+ return true;
1387
+ } catch (err) {
1388
+ if (err instanceof Error && /not found/i.test(err.message)) {
1389
+ return false;
1390
+ }
1391
+ throw err;
1392
+ }
1393
+ }
1394
+ async getNeighbors(id, options) {
1395
+ const store = this.requireStore();
1396
+ return store.getNeighbors(id, options);
1397
+ }
1398
+ async findPath(source, target) {
1399
+ const store = this.requireStore();
1400
+ return store.findPath(source, target);
1401
+ }
1402
+ async getHubs(metric, limit) {
1403
+ const store = this.requireStore();
1404
+ return store.getHubs(metric, limit);
1405
+ }
1406
+ async searchByTags(tags, mode, limit) {
1407
+ const store = this.requireStore();
1408
+ const results = await store.searchByTags(tags, mode);
1409
+ if (limit !== void 0) {
1410
+ return results.slice(0, limit);
1411
+ }
1412
+ return results;
1413
+ }
1414
+ async getRandomNode(tags) {
1415
+ const store = this.requireStore();
1416
+ return store.getRandomNode(tags);
1417
+ }
1418
+ async listNodes(filter, options) {
1419
+ return this.requireStore().listNodes(filter, options);
1420
+ }
1421
+ async resolveNodes(names, options) {
1422
+ const store = this.requireStore();
1423
+ const strategy = options?.strategy ?? "fuzzy";
1424
+ if (strategy === "semantic") {
1425
+ if (!this.embedding) {
1426
+ throw new Error("Semantic resolution requires EmbeddingProvider");
1427
+ }
1428
+ const filter = {};
1429
+ if (options?.tag) filter.tag = options.tag;
1430
+ if (options?.path) filter.path = options.path;
1431
+ const { nodes: candidates } = await store.listNodes(filter, { limit: 1e3 });
1432
+ if (candidates.length === 0 || names.length === 0) {
1433
+ return names.map((query) => ({ query, match: null, score: 0 }));
1434
+ }
1435
+ const threshold = options?.threshold ?? 0.7;
1436
+ const queryVectors = await this.embedding.embedBatch(names);
1437
+ const candidateTitles = candidates.map((c) => c.title);
1438
+ const candidateVectors = await this.embedding.embedBatch(candidateTitles);
1439
+ if (queryVectors.length > 0 && candidateVectors.length > 0) {
1440
+ const queryDim = queryVectors[0].length;
1441
+ const candidateDim = candidateVectors[0].length;
1442
+ if (queryDim !== candidateDim) {
1443
+ throw new Error(
1444
+ `Embedding dimension mismatch: query=${queryDim}, candidate=${candidateDim}`
1445
+ );
1446
+ }
1447
+ }
1448
+ return names.map((query, qIdx) => {
1449
+ const queryVector = queryVectors[qIdx];
1450
+ let bestScore = 0;
1451
+ let bestMatch = null;
1452
+ for (let cIdx = 0; cIdx < candidates.length; cIdx++) {
1453
+ const similarity = this.cosineSimilarity(queryVector, candidateVectors[cIdx]);
1454
+ if (similarity > bestScore) {
1455
+ bestScore = similarity;
1456
+ bestMatch = candidates[cIdx].id;
1457
+ }
1458
+ }
1459
+ if (bestScore >= threshold) {
1460
+ return { query, match: bestMatch, score: bestScore };
1461
+ }
1462
+ return { query, match: null, score: 0 };
1463
+ });
1464
+ }
1465
+ return store.resolveNodes(names, options);
1466
+ }
1467
+ cosineSimilarity(a, b) {
1468
+ let dotProduct = 0;
1469
+ let normA = 0;
1470
+ let normB = 0;
1471
+ for (let i = 0; i < a.length; i++) {
1472
+ dotProduct += a[i] * b[i];
1473
+ normA += a[i] * a[i];
1474
+ normB += b[i] * b[i];
1475
+ }
1476
+ if (normA === 0 || normB === 0) return 0;
1477
+ return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
1478
+ }
1479
+ static fromConfig(config) {
1480
+ if (!config.providers?.store) {
1481
+ throw new Error("StoreProvider configuration is required");
1482
+ }
1483
+ const core = new _GraphCoreImpl();
1484
+ if (config.providers.store.type === "docstore") {
1485
+ const sourcePath = config.source?.path ?? ".";
1486
+ const cachePath = config.cache?.path ?? ".roux";
1487
+ const store = new DocStore(sourcePath, cachePath);
1488
+ core.registerStore(store);
1489
+ } else {
1490
+ throw new Error(
1491
+ `Unsupported store provider type: ${config.providers.store.type}. Supported: docstore`
1492
+ );
1493
+ }
1494
+ const embeddingConfig = config.providers.embedding;
1495
+ if (!embeddingConfig || embeddingConfig.type === "local") {
1496
+ const model = embeddingConfig?.model;
1497
+ const embedding = new TransformersEmbeddingProvider(model);
1498
+ core.registerEmbedding(embedding);
1499
+ } else {
1500
+ throw new Error(
1501
+ `Unsupported embedding provider type: ${embeddingConfig.type}. Supported: local`
1502
+ );
1503
+ }
1504
+ return core;
1505
+ }
1506
+ };
1507
+
1508
+ // src/mcp/server.ts
1509
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1510
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1511
+ import {
1512
+ CallToolRequestSchema,
1513
+ ListToolsRequestSchema
1514
+ } from "@modelcontextprotocol/sdk/types.js";
1515
+
1516
+ // src/mcp/types.ts
1517
+ var McpError = class extends Error {
1518
+ constructor(code, message) {
1519
+ super(message);
1520
+ this.code = code;
1521
+ this.name = "McpError";
1522
+ }
1523
+ toResponse() {
1524
+ return {
1525
+ error: {
1526
+ code: this.code,
1527
+ message: this.message
1528
+ }
1529
+ };
1530
+ }
1531
+ };
1532
+
1533
+ // src/mcp/truncate.ts
1534
+ var TRUNCATION_LIMITS = {
1535
+ /** Primary node (get_node, single result) */
1536
+ primary: 1e4,
1537
+ /** List results (search, neighbors) */
1538
+ list: 500,
1539
+ /** Neighbor nodes in context */
1540
+ neighbor: 200
1541
+ };
1542
+ var TRUNCATION_SUFFIX = "... [truncated]";
1543
+ function truncateContent(content, context) {
1544
+ const limit = TRUNCATION_LIMITS[context];
1545
+ if (content.length <= limit) {
1546
+ return content;
1547
+ }
1548
+ const truncatedLength = Math.max(0, limit - TRUNCATION_SUFFIX.length);
1549
+ return content.slice(0, truncatedLength) + TRUNCATION_SUFFIX;
1550
+ }
1551
+
1552
+ // src/mcp/transforms.ts
1553
+ var MAX_NEIGHBORS = 20;
1554
+ var MAX_LINKS_TO_RESOLVE = 100;
1555
+ async function nodeToResponse(node, store, truncation) {
1556
+ const linksToResolve = node.outgoingLinks.slice(0, MAX_LINKS_TO_RESOLVE);
1557
+ const titles = await store.resolveTitles(linksToResolve);
1558
+ const links = linksToResolve.map((id) => ({
1559
+ id,
1560
+ title: titles.get(id) ?? id
1561
+ }));
1562
+ return {
1563
+ id: node.id,
1564
+ title: node.title,
1565
+ content: truncateContent(node.content, truncation),
1566
+ tags: node.tags,
1567
+ links,
1568
+ properties: node.properties
1569
+ };
1570
+ }
1571
+ async function nodesToResponses(nodes, store, truncation, includeContent) {
1572
+ const allLinkIds = /* @__PURE__ */ new Set();
1573
+ const nodeLinkLimits = /* @__PURE__ */ new Map();
1574
+ for (const node of nodes) {
1575
+ const limitedLinks = node.outgoingLinks.slice(0, MAX_LINKS_TO_RESOLVE);
1576
+ nodeLinkLimits.set(node.id, limitedLinks);
1577
+ for (const linkId of limitedLinks) {
1578
+ allLinkIds.add(linkId);
1579
+ }
1580
+ }
1581
+ const titles = await store.resolveTitles(Array.from(allLinkIds));
1582
+ return nodes.map((node) => {
1583
+ const limitedLinks = nodeLinkLimits.get(node.id) ?? [];
1584
+ const base = {
1585
+ id: node.id,
1586
+ title: node.title,
1587
+ tags: node.tags,
1588
+ links: limitedLinks.map((id) => ({
1589
+ id,
1590
+ title: titles.get(id) ?? id
1591
+ })),
1592
+ properties: node.properties
1593
+ };
1594
+ if (includeContent) {
1595
+ return {
1596
+ ...base,
1597
+ content: truncateContent(node.content, truncation)
1598
+ };
1599
+ }
1600
+ return base;
1601
+ });
1602
+ }
1603
+ async function nodeToContextResponse(node, incomingNeighbors, outgoingNeighbors, store) {
1604
+ const primary = await nodeToResponse(node, store, "primary");
1605
+ const limitedIncoming = incomingNeighbors.slice(0, MAX_NEIGHBORS);
1606
+ const limitedOutgoing = outgoingNeighbors.slice(0, MAX_NEIGHBORS);
1607
+ const [incomingResponses, outgoingResponses] = await Promise.all([
1608
+ nodesToResponses(limitedIncoming, store, "neighbor", true),
1609
+ nodesToResponses(limitedOutgoing, store, "neighbor", true)
1610
+ ]);
1611
+ return {
1612
+ ...primary,
1613
+ incomingNeighbors: incomingResponses,
1614
+ outgoingNeighbors: outgoingResponses,
1615
+ incomingCount: incomingNeighbors.length,
1616
+ outgoingCount: outgoingNeighbors.length
1617
+ };
1618
+ }
1619
+ async function nodesToSearchResults(nodes, scores, store, includeContent) {
1620
+ const responses = await nodesToResponses(nodes, store, "list", includeContent);
1621
+ return responses.map((response) => ({
1622
+ ...response,
1623
+ score: scores.get(response.id) ?? 0
1624
+ }));
1625
+ }
1626
+ async function hubsToResponses(hubs, store) {
1627
+ const ids = hubs.map(([id]) => id);
1628
+ const titles = await store.resolveTitles(ids);
1629
+ return hubs.map(([id, score]) => ({
1630
+ id,
1631
+ title: titles.get(id) ?? id,
1632
+ score
1633
+ }));
1634
+ }
1635
+ function pathToResponse(path) {
1636
+ return {
1637
+ path,
1638
+ length: path.length - 1
1639
+ };
1640
+ }
1641
+
1642
+ // src/mcp/handlers.ts
1643
+ function coerceLimit(value, defaultValue) {
1644
+ if (value === void 0 || value === null) {
1645
+ return defaultValue;
1646
+ }
1647
+ const num = Number(value);
1648
+ if (Number.isNaN(num)) {
1649
+ return defaultValue;
1650
+ }
1651
+ const floored = Math.floor(num);
1652
+ if (floored < 1) {
1653
+ throw new McpError("INVALID_PARAMS", "limit must be at least 1");
1654
+ }
1655
+ return floored;
1656
+ }
1657
+ function coerceOffset(value, defaultValue) {
1658
+ if (value === void 0 || value === null) {
1659
+ return defaultValue;
1660
+ }
1661
+ const num = Number(value);
1662
+ if (Number.isNaN(num)) {
1663
+ return defaultValue;
1664
+ }
1665
+ const floored = Math.floor(num);
1666
+ if (floored < 0) {
1667
+ throw new McpError("INVALID_PARAMS", "offset must be at least 0");
1668
+ }
1669
+ return floored;
1670
+ }
1671
+ async function handleSearch(ctx, args) {
1672
+ if (!ctx.hasEmbedding) {
1673
+ throw new McpError("PROVIDER_ERROR", "Search requires embedding provider");
1674
+ }
1675
+ const query = args.query;
1676
+ const limit = coerceLimit(args.limit, 10);
1677
+ const includeContent = args.include_content === true;
1678
+ if (typeof query !== "string" || query.trim() === "") {
1679
+ throw new McpError("INVALID_PARAMS", "query is required and must be a non-empty string");
1680
+ }
1681
+ const nodes = await ctx.core.search(query, { limit });
1682
+ const scores = /* @__PURE__ */ new Map();
1683
+ nodes.forEach((node, index) => {
1684
+ scores.set(node.id, Math.max(0, 1 - index * 0.05));
1685
+ });
1686
+ return nodesToSearchResults(nodes, scores, ctx.store, includeContent);
1687
+ }
1688
+ function coerceDepth(value) {
1689
+ if (value === void 0 || value === null) {
1690
+ return 0;
1691
+ }
1692
+ const num = Number(value);
1693
+ if (Number.isNaN(num)) {
1694
+ return 0;
1695
+ }
1696
+ return num >= 1 ? 1 : 0;
1697
+ }
1698
+ async function handleGetNode(ctx, args) {
1699
+ const id = args.id;
1700
+ 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
+ const node = await ctx.core.getNode(id, depth);
1705
+ if (!node) {
1706
+ return null;
1707
+ }
1708
+ if (depth === 0) {
1709
+ return nodeToResponse(node, ctx.store, "primary");
1710
+ }
1711
+ const [incomingNeighbors, outgoingNeighbors] = await Promise.all([
1712
+ ctx.core.getNeighbors(id, { direction: "in" }),
1713
+ ctx.core.getNeighbors(id, { direction: "out" })
1714
+ ]);
1715
+ return nodeToContextResponse(node, incomingNeighbors, outgoingNeighbors, ctx.store);
1716
+ }
1717
+ var VALID_DIRECTIONS = ["in", "out", "both"];
1718
+ async function handleGetNeighbors(ctx, args) {
1719
+ const id = args.id;
1720
+ const directionRaw = args.direction ?? "both";
1721
+ const limit = coerceLimit(args.limit, 20);
1722
+ const includeContent = args.include_content === true;
1723
+ if (!id || typeof id !== "string") {
1724
+ throw new McpError("INVALID_PARAMS", "id is required and must be a string");
1725
+ }
1726
+ if (!VALID_DIRECTIONS.includes(directionRaw)) {
1727
+ throw new McpError(
1728
+ "INVALID_PARAMS",
1729
+ `direction must be one of: ${VALID_DIRECTIONS.join(", ")}`
1730
+ );
1731
+ }
1732
+ const direction = directionRaw;
1733
+ const neighbors = await ctx.core.getNeighbors(id, { direction, limit });
1734
+ return nodesToResponses(neighbors, ctx.store, "list", includeContent);
1735
+ }
1736
+ async function handleFindPath(ctx, args) {
1737
+ const source = args.source;
1738
+ const target = args.target;
1739
+ if (!source || typeof source !== "string") {
1740
+ throw new McpError("INVALID_PARAMS", "source is required and must be a string");
1741
+ }
1742
+ if (!target || typeof target !== "string") {
1743
+ throw new McpError("INVALID_PARAMS", "target is required and must be a string");
1744
+ }
1745
+ const path = await ctx.core.findPath(source, target);
1746
+ if (!path) {
1747
+ return null;
1748
+ }
1749
+ return pathToResponse(path);
1750
+ }
1751
+ var VALID_METRICS = ["pagerank", "in_degree", "out_degree"];
1752
+ async function handleGetHubs(ctx, args) {
1753
+ const metricRaw = args.metric ?? "in_degree";
1754
+ const limit = coerceLimit(args.limit, 10);
1755
+ if (!VALID_METRICS.includes(metricRaw)) {
1756
+ throw new McpError(
1757
+ "INVALID_PARAMS",
1758
+ `metric must be one of: ${VALID_METRICS.join(", ")}`
1759
+ );
1760
+ }
1761
+ const metric = metricRaw;
1762
+ const hubs = await ctx.core.getHubs(metric, limit);
1763
+ return hubsToResponses(hubs, ctx.store);
1764
+ }
1765
+ var VALID_TAG_MODES = ["any", "all"];
1766
+ async function handleSearchByTags(ctx, args) {
1767
+ const tags = args.tags;
1768
+ const modeRaw = args.mode ?? "any";
1769
+ const limit = coerceLimit(args.limit, 20);
1770
+ if (!Array.isArray(tags) || tags.length === 0) {
1771
+ throw new McpError("INVALID_PARAMS", "tags is required and must be a non-empty array");
1772
+ }
1773
+ if (!tags.every((t) => typeof t === "string")) {
1774
+ throw new McpError("INVALID_PARAMS", "tags must contain only strings");
1775
+ }
1776
+ if (!VALID_TAG_MODES.includes(modeRaw)) {
1777
+ throw new McpError(
1778
+ "INVALID_PARAMS",
1779
+ `mode must be one of: ${VALID_TAG_MODES.join(", ")}`
1780
+ );
1781
+ }
1782
+ const mode = modeRaw;
1783
+ const nodes = await ctx.core.searchByTags(tags, mode, limit);
1784
+ return nodesToResponses(nodes, ctx.store, "list", true);
1785
+ }
1786
+ async function handleRandomNode(ctx, args) {
1787
+ const tags = args.tags;
1788
+ if (tags !== void 0) {
1789
+ if (!Array.isArray(tags) || !tags.every((t) => typeof t === "string")) {
1790
+ throw new McpError("INVALID_PARAMS", "tags must contain only strings");
1791
+ }
1792
+ }
1793
+ const node = await ctx.core.getRandomNode(tags);
1794
+ if (!node) {
1795
+ return null;
1796
+ }
1797
+ return nodeToResponse(node, ctx.store, "primary");
1798
+ }
1799
+ async function handleCreateNode(ctx, args) {
1800
+ const title = args.title;
1801
+ const content = args.content;
1802
+ const tagsRaw = args.tags;
1803
+ const directory = args.directory;
1804
+ if (!title || typeof title !== "string") {
1805
+ throw new McpError("INVALID_PARAMS", "title is required and must be a string");
1806
+ }
1807
+ if (!content || typeof content !== "string") {
1808
+ throw new McpError("INVALID_PARAMS", "content is required and must be a string");
1809
+ }
1810
+ let tags = [];
1811
+ if (tagsRaw !== void 0) {
1812
+ if (!Array.isArray(tagsRaw) || !tagsRaw.every((t) => typeof t === "string")) {
1813
+ throw new McpError("INVALID_PARAMS", "tags must contain only strings");
1814
+ }
1815
+ tags = tagsRaw;
1816
+ }
1817
+ const filename = sanitizeFilename(title) + ".md";
1818
+ const id = directory ? `${directory}/${filename}` : filename;
1819
+ const existing = await ctx.core.getNode(id);
1820
+ if (existing) {
1821
+ throw new McpError("NODE_EXISTS", `Node already exists: ${id}`);
1822
+ }
1823
+ const node = await ctx.core.createNode({
1824
+ id,
1825
+ title,
1826
+ content,
1827
+ tags
1828
+ });
1829
+ return nodeToResponse(node, ctx.store, "primary");
1830
+ }
1831
+ async function handleUpdateNode(ctx, args) {
1832
+ const id = args.id;
1833
+ const title = args.title;
1834
+ const content = args.content;
1835
+ const tagsRaw = args.tags;
1836
+ if (!id || typeof id !== "string") {
1837
+ throw new McpError("INVALID_PARAMS", "id is required and must be a string");
1838
+ }
1839
+ if (title === void 0 && content === void 0 && tagsRaw === void 0) {
1840
+ throw new McpError(
1841
+ "INVALID_PARAMS",
1842
+ "At least one of title, content, or tags must be provided"
1843
+ );
1844
+ }
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
+ const existing = await ctx.core.getNode(id);
1853
+ if (!existing) {
1854
+ throw new McpError("NODE_NOT_FOUND", `Node not found: ${id}`);
1855
+ }
1856
+ if (title !== void 0 && title !== existing.title) {
1857
+ const incomingNeighbors = await ctx.core.getNeighbors(id, { direction: "in" });
1858
+ if (incomingNeighbors.length > 0) {
1859
+ throw new McpError(
1860
+ "LINK_INTEGRITY",
1861
+ `Cannot rename node with ${incomingNeighbors.length} incoming links`
1862
+ );
1863
+ }
1864
+ }
1865
+ const updates = {};
1866
+ if (title !== void 0) updates.title = title;
1867
+ if (content !== void 0) updates.content = content;
1868
+ if (tags !== void 0) updates.tags = tags;
1869
+ const updated = await ctx.core.updateNode(id, updates);
1870
+ return nodeToResponse(updated, ctx.store, "primary");
1871
+ }
1872
+ async function handleDeleteNode(ctx, args) {
1873
+ const id = args.id;
1874
+ if (!id || typeof id !== "string") {
1875
+ throw new McpError("INVALID_PARAMS", "id is required and must be a string");
1876
+ }
1877
+ const deleted = await ctx.core.deleteNode(id);
1878
+ return { deleted };
1879
+ }
1880
+ var VALID_STRATEGIES = ["exact", "fuzzy", "semantic"];
1881
+ async function handleListNodes(ctx, args) {
1882
+ const tag = args.tag;
1883
+ const path = args.path;
1884
+ const limit = coerceLimit(args.limit, 100);
1885
+ const offset = coerceOffset(args.offset, 0);
1886
+ const filter = {};
1887
+ if (tag) filter.tag = tag;
1888
+ if (path) filter.path = path;
1889
+ return ctx.core.listNodes(filter, { limit, offset });
1890
+ }
1891
+ async function handleResolveNodes(ctx, args) {
1892
+ const names = args.names;
1893
+ const strategy = args.strategy;
1894
+ const threshold = args.threshold;
1895
+ const tag = args.tag;
1896
+ const path = args.path;
1897
+ if (!Array.isArray(names)) {
1898
+ throw new McpError("INVALID_PARAMS", "names is required and must be an array");
1899
+ }
1900
+ if (strategy !== void 0 && !VALID_STRATEGIES.includes(strategy)) {
1901
+ throw new McpError(
1902
+ "INVALID_PARAMS",
1903
+ `strategy must be one of: ${VALID_STRATEGIES.join(", ")}`
1904
+ );
1905
+ }
1906
+ if (strategy === "semantic" && !ctx.hasEmbedding) {
1907
+ throw new McpError("PROVIDER_ERROR", "Semantic resolution requires embedding provider");
1908
+ }
1909
+ const options = {};
1910
+ if (strategy) options.strategy = strategy;
1911
+ if (threshold !== void 0) options.threshold = threshold;
1912
+ if (tag) options.tag = tag;
1913
+ if (path) options.path = path;
1914
+ return ctx.core.resolveNodes(names, options);
1915
+ }
1916
+ async function handleNodesExist(ctx, args) {
1917
+ const ids = args.ids;
1918
+ if (!Array.isArray(ids)) {
1919
+ throw new McpError("INVALID_PARAMS", "ids is required and must be an array");
1920
+ }
1921
+ const result = await ctx.store.nodesExist(ids);
1922
+ const response = {};
1923
+ for (const [id, exists] of result) {
1924
+ response[id] = exists;
1925
+ }
1926
+ return response;
1927
+ }
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
+
1965
+ // src/mcp/server.ts
1966
+ var TOOL_SCHEMAS = {
1967
+ search: {
1968
+ type: "object",
1969
+ properties: {
1970
+ query: {
1971
+ type: "string",
1972
+ description: "Natural language search query"
1973
+ },
1974
+ limit: {
1975
+ type: "integer",
1976
+ minimum: 1,
1977
+ maximum: 50,
1978
+ default: 10,
1979
+ description: "Maximum results to return"
1980
+ },
1981
+ include_content: {
1982
+ type: "boolean",
1983
+ default: false,
1984
+ description: "Include node content in results. Default false returns metadata only (id, title, tags, properties, links). Set true to include truncated content."
1985
+ }
1986
+ },
1987
+ required: ["query"]
1988
+ },
1989
+ get_node: {
1990
+ type: "object",
1991
+ properties: {
1992
+ id: {
1993
+ type: "string",
1994
+ description: 'Node ID (file path for DocStore). ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
1995
+ },
1996
+ depth: {
1997
+ type: "integer",
1998
+ minimum: 0,
1999
+ maximum: 1,
2000
+ default: 0,
2001
+ description: "0 = node only, 1 = include neighbors"
2002
+ }
2003
+ },
2004
+ required: ["id"]
2005
+ },
2006
+ get_neighbors: {
2007
+ type: "object",
2008
+ properties: {
2009
+ id: {
2010
+ type: "string",
2011
+ description: 'Source node ID. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2012
+ },
2013
+ direction: {
2014
+ type: "string",
2015
+ enum: ["in", "out", "both"],
2016
+ default: "both",
2017
+ description: "in = nodes linking here, out = nodes linked to, both = all"
2018
+ },
2019
+ limit: {
2020
+ type: "integer",
2021
+ minimum: 1,
2022
+ maximum: 50,
2023
+ default: 20,
2024
+ description: "Maximum neighbors to return"
2025
+ },
2026
+ include_content: {
2027
+ type: "boolean",
2028
+ default: false,
2029
+ description: "Include node content in results. Default false returns metadata only (id, title, tags, properties, links). Set true to include truncated content."
2030
+ }
2031
+ },
2032
+ required: ["id"]
2033
+ },
2034
+ find_path: {
2035
+ type: "object",
2036
+ properties: {
2037
+ source: {
2038
+ type: "string",
2039
+ description: 'Start node ID. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2040
+ },
2041
+ target: {
2042
+ type: "string",
2043
+ description: 'End node ID. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2044
+ }
2045
+ },
2046
+ required: ["source", "target"]
2047
+ },
2048
+ get_hubs: {
2049
+ type: "object",
2050
+ properties: {
2051
+ metric: {
2052
+ type: "string",
2053
+ enum: ["in_degree", "out_degree"],
2054
+ default: "in_degree",
2055
+ description: "Centrality metric"
2056
+ },
2057
+ limit: {
2058
+ type: "integer",
2059
+ minimum: 1,
2060
+ maximum: 50,
2061
+ default: 10,
2062
+ description: "Maximum results"
2063
+ }
2064
+ }
2065
+ },
2066
+ search_by_tags: {
2067
+ type: "object",
2068
+ properties: {
2069
+ tags: {
2070
+ type: "array",
2071
+ items: { type: "string" },
2072
+ minItems: 1,
2073
+ description: "Tags to match"
2074
+ },
2075
+ mode: {
2076
+ type: "string",
2077
+ enum: ["any", "all"],
2078
+ default: "any",
2079
+ description: "any = OR matching, all = AND matching"
2080
+ },
2081
+ limit: {
2082
+ type: "integer",
2083
+ minimum: 1,
2084
+ maximum: 100,
2085
+ default: 20,
2086
+ description: "Maximum results"
2087
+ }
2088
+ },
2089
+ required: ["tags"]
2090
+ },
2091
+ random_node: {
2092
+ type: "object",
2093
+ properties: {
2094
+ tags: {
2095
+ type: "array",
2096
+ items: { type: "string" },
2097
+ description: "Optional: limit to nodes with these tags (any match)"
2098
+ }
2099
+ }
2100
+ },
2101
+ create_node: {
2102
+ type: "object",
2103
+ properties: {
2104
+ title: {
2105
+ type: "string",
2106
+ description: "Node title (becomes filename for DocStore). Returned ID will be normalized to lowercase."
2107
+ },
2108
+ content: {
2109
+ type: "string",
2110
+ description: "Full text content (markdown)"
2111
+ },
2112
+ tags: {
2113
+ type: "array",
2114
+ items: { type: "string" },
2115
+ default: [],
2116
+ description: "Classification tags"
2117
+ },
2118
+ directory: {
2119
+ type: "string",
2120
+ description: "Optional: subdirectory path (e.g., 'notes/drafts')"
2121
+ }
2122
+ },
2123
+ required: ["title", "content"]
2124
+ },
2125
+ update_node: {
2126
+ type: "object",
2127
+ properties: {
2128
+ id: {
2129
+ type: "string",
2130
+ description: 'Node ID to update. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2131
+ },
2132
+ title: {
2133
+ type: "string",
2134
+ description: "New title (renames file for DocStore)"
2135
+ },
2136
+ content: {
2137
+ type: "string",
2138
+ description: "New content (replaces entirely)"
2139
+ },
2140
+ tags: {
2141
+ type: "array",
2142
+ items: { type: "string" },
2143
+ description: "New tags (replaces existing)"
2144
+ }
2145
+ },
2146
+ required: ["id"]
2147
+ },
2148
+ delete_node: {
2149
+ type: "object",
2150
+ properties: {
2151
+ id: {
2152
+ type: "string",
2153
+ description: 'Node ID to delete. ID is normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2154
+ }
2155
+ },
2156
+ required: ["id"]
2157
+ },
2158
+ list_nodes: {
2159
+ type: "object",
2160
+ properties: {
2161
+ tag: {
2162
+ type: "string",
2163
+ description: 'Filter by tag from the "tags" frontmatter array (case-insensitive). Does NOT search other frontmatter fields like "type" or "category".'
2164
+ },
2165
+ path: {
2166
+ type: "string",
2167
+ description: "Filter by path prefix (startsWith, case-insensitive)"
2168
+ },
2169
+ limit: {
2170
+ type: "integer",
2171
+ minimum: 1,
2172
+ maximum: 1e3,
2173
+ default: 100,
2174
+ description: "Maximum results to return"
2175
+ },
2176
+ offset: {
2177
+ type: "integer",
2178
+ minimum: 0,
2179
+ default: 0,
2180
+ description: "Skip this many results (for pagination)"
2181
+ }
2182
+ }
2183
+ },
2184
+ resolve_nodes: {
2185
+ type: "object",
2186
+ properties: {
2187
+ names: {
2188
+ type: "array",
2189
+ items: { type: "string" },
2190
+ description: "Names to resolve to existing nodes"
2191
+ },
2192
+ strategy: {
2193
+ type: "string",
2194
+ enum: ["exact", "fuzzy", "semantic"],
2195
+ default: "fuzzy",
2196
+ description: 'How to match names to nodes. "exact": case-insensitive title equality. "fuzzy": string similarity (Dice coefficient) \u2014 use for typos, misspellings, partial matches. "semantic": embedding cosine similarity \u2014 use for synonyms or related concepts (NOT typos). Misspellings embed poorly because they produce unrelated vectors.'
2197
+ },
2198
+ threshold: {
2199
+ type: "number",
2200
+ minimum: 0,
2201
+ maximum: 1,
2202
+ default: 0.7,
2203
+ description: "Minimum similarity score (0-1). Lower values match more loosely. For typo tolerance, use fuzzy with threshold 0.5-0.6. Ignored for exact strategy."
2204
+ },
2205
+ tag: {
2206
+ type: "string",
2207
+ description: 'Filter candidates by tag from "tags" frontmatter array (case-insensitive)'
2208
+ },
2209
+ path: {
2210
+ type: "string",
2211
+ description: "Filter candidates by path prefix (case-insensitive)"
2212
+ }
2213
+ },
2214
+ required: ["names"]
2215
+ },
2216
+ nodes_exist: {
2217
+ type: "object",
2218
+ properties: {
2219
+ ids: {
2220
+ type: "array",
2221
+ items: { type: "string" },
2222
+ description: 'Node IDs to check existence. IDs are normalized to lowercase (e.g., "Recipes/Bulgogi.md" becomes "recipes/bulgogi.md").'
2223
+ }
2224
+ },
2225
+ required: ["ids"]
2226
+ }
2227
+ };
2228
+ function getToolDefinitions(hasEmbedding) {
2229
+ const tools = [
2230
+ {
2231
+ name: "get_node",
2232
+ description: "Retrieve a single node by ID with optional neighbor context",
2233
+ inputSchema: TOOL_SCHEMAS.get_node
2234
+ },
2235
+ {
2236
+ name: "get_neighbors",
2237
+ description: "Get nodes linked to or from a specific node",
2238
+ inputSchema: TOOL_SCHEMAS.get_neighbors
2239
+ },
2240
+ {
2241
+ name: "find_path",
2242
+ description: "Find the shortest path between two nodes",
2243
+ inputSchema: TOOL_SCHEMAS.find_path
2244
+ },
2245
+ {
2246
+ name: "get_hubs",
2247
+ description: "Get the most central nodes by graph metric",
2248
+ inputSchema: TOOL_SCHEMAS.get_hubs
2249
+ },
2250
+ {
2251
+ name: "search_by_tags",
2252
+ description: "Filter nodes by tags (AND or OR matching)",
2253
+ inputSchema: TOOL_SCHEMAS.search_by_tags
2254
+ },
2255
+ {
2256
+ name: "random_node",
2257
+ description: "Get a random node for discovery, optionally filtered by tags",
2258
+ inputSchema: TOOL_SCHEMAS.random_node
2259
+ },
2260
+ {
2261
+ name: "create_node",
2262
+ description: "Create a new node (writes file for DocStore)",
2263
+ inputSchema: TOOL_SCHEMAS.create_node
2264
+ },
2265
+ {
2266
+ name: "update_node",
2267
+ description: "Update an existing node. Title changes rejected if incoming links exist.",
2268
+ inputSchema: TOOL_SCHEMAS.update_node
2269
+ },
2270
+ {
2271
+ name: "delete_node",
2272
+ description: "Delete a node by ID",
2273
+ inputSchema: TOOL_SCHEMAS.delete_node
2274
+ },
2275
+ {
2276
+ name: "list_nodes",
2277
+ description: 'List nodes with optional filters and pagination. Tag filter searches the "tags" frontmatter array only. All IDs returned are lowercase.',
2278
+ inputSchema: TOOL_SCHEMAS.list_nodes
2279
+ },
2280
+ {
2281
+ name: "resolve_nodes",
2282
+ description: 'Batch resolve names to existing node IDs. Strategy selection: "exact" for known titles, "fuzzy" for typos/misspellings (e.g., "chikken" -> "chicken"), "semantic" for synonyms/concepts (e.g., "poultry leg meat" -> "chicken thigh"). Semantic does NOT handle typos \u2014 misspellings produce garbage embeddings.',
2283
+ inputSchema: TOOL_SCHEMAS.resolve_nodes
2284
+ },
2285
+ {
2286
+ name: "nodes_exist",
2287
+ description: "Batch check if node IDs exist. IDs are normalized to lowercase before checking.",
2288
+ inputSchema: TOOL_SCHEMAS.nodes_exist
2289
+ }
2290
+ ];
2291
+ if (hasEmbedding) {
2292
+ tools.unshift({
2293
+ name: "search",
2294
+ description: "Semantic similarity search across all nodes",
2295
+ inputSchema: TOOL_SCHEMAS.search
2296
+ });
2297
+ }
2298
+ return tools;
2299
+ }
2300
+ var McpServer = class {
2301
+ server;
2302
+ ctx;
2303
+ constructor(options) {
2304
+ this.ctx = {
2305
+ core: options.core,
2306
+ store: options.store,
2307
+ hasEmbedding: options.hasEmbedding
2308
+ };
2309
+ this.server = new Server(
2310
+ { name: "roux", version: "0.1.0" },
2311
+ { capabilities: { tools: {} } }
2312
+ );
2313
+ this.setupHandlers();
2314
+ }
2315
+ /* v8 ignore start - MCP SDK callbacks tested via integration in Phase 11 */
2316
+ setupHandlers() {
2317
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
2318
+ tools: getToolDefinitions(this.ctx.hasEmbedding)
2319
+ }));
2320
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
2321
+ const { name, arguments: args } = request.params;
2322
+ try {
2323
+ const result = await dispatchTool(this.ctx, name, args ?? {});
2324
+ return {
2325
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
2326
+ };
2327
+ } catch (error) {
2328
+ if (error instanceof McpError) {
2329
+ return {
2330
+ content: [
2331
+ { type: "text", text: JSON.stringify(error.toResponse()) }
2332
+ ],
2333
+ isError: true
2334
+ };
2335
+ }
2336
+ const mcpError = new McpError(
2337
+ "PROVIDER_ERROR",
2338
+ error instanceof Error ? error.message : "Unknown error"
2339
+ );
2340
+ return {
2341
+ content: [
2342
+ { type: "text", text: JSON.stringify(mcpError.toResponse()) }
2343
+ ],
2344
+ isError: true
2345
+ };
2346
+ }
2347
+ });
2348
+ }
2349
+ /* v8 ignore stop */
2350
+ /**
2351
+ * Start the server with optional transport factory.
2352
+ * @param transportFactory Factory to create transport. Defaults to StdioServerTransport.
2353
+ */
2354
+ async start(transportFactory) {
2355
+ const transport = transportFactory ? transportFactory() : new StdioServerTransport();
2356
+ await this.server.connect(transport);
2357
+ }
2358
+ async close() {
2359
+ await this.server.close();
2360
+ }
2361
+ };
2362
+
2363
+ // src/cli/commands/serve.ts
2364
+ async function serveCommand(directory, options = {}) {
2365
+ const { watch: watch2 = true, transportFactory, onProgress } = options;
2366
+ const configPath = join6(directory, "roux.yaml");
2367
+ try {
2368
+ await access3(configPath);
2369
+ } catch {
2370
+ throw new Error(`Directory not initialized. Run 'roux init' first.`);
2371
+ }
2372
+ const configContent = await readFile3(configPath, "utf-8");
2373
+ const config = parseYaml(configContent);
2374
+ const sourcePath = config.source?.path ?? ".";
2375
+ const resolvedSourcePath = join6(directory, sourcePath);
2376
+ const cachePath = config.cache?.path ?? ".roux";
2377
+ const resolvedCachePath = join6(directory, cachePath);
2378
+ const store = new DocStore(resolvedSourcePath, resolvedCachePath);
2379
+ const embedding = new TransformersEmbeddingProvider(
2380
+ config.providers?.embedding?.type === "local" ? config.providers.embedding.model : void 0
2381
+ );
2382
+ await store.sync();
2383
+ const allNodeIds = await store.getAllNodeIds();
2384
+ const total = allNodeIds.length;
2385
+ for (let i = 0; i < allNodeIds.length; i++) {
2386
+ const id = allNodeIds[i];
2387
+ if (!hasExistingEmbedding(store, id)) {
2388
+ const node = await store.getNode(id);
2389
+ if (node && node.content) {
2390
+ const vector = await embedding.embed(node.content);
2391
+ await store.storeEmbedding(id, vector, embedding.modelId());
2392
+ }
2393
+ }
2394
+ if (onProgress) {
2395
+ onProgress(i + 1, total);
2396
+ }
2397
+ }
2398
+ const core = new GraphCoreImpl();
2399
+ core.registerStore(store);
2400
+ core.registerEmbedding(embedding);
2401
+ const mcpServer = new McpServer({
2402
+ core,
2403
+ store,
2404
+ hasEmbedding: true
2405
+ });
2406
+ await mcpServer.start(transportFactory);
2407
+ if (watch2) {
2408
+ try {
2409
+ await store.startWatching(async (changedIds) => {
2410
+ for (const id of changedIds) {
2411
+ const node = await store.getNode(id);
2412
+ if (node && node.content) {
2413
+ const vector = await embedding.embed(node.content);
2414
+ await store.storeEmbedding(id, vector, embedding.modelId());
2415
+ }
2416
+ }
2417
+ });
2418
+ } catch (err) {
2419
+ console.warn(
2420
+ "File watching disabled:",
2421
+ err.message || "Unknown error"
2422
+ );
2423
+ }
2424
+ }
2425
+ return {
2426
+ stop: async () => {
2427
+ store.stopWatching();
2428
+ store.close();
2429
+ await mcpServer.close();
2430
+ },
2431
+ isWatching: store.isWatching(),
2432
+ nodeCount: allNodeIds.length
2433
+ };
2434
+ }
2435
+ function hasExistingEmbedding(store, id) {
2436
+ return store.hasEmbedding(id);
2437
+ }
2438
+
2439
+ // src/cli/commands/viz.ts
2440
+ import { access as access4, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
2441
+ import { join as join7, dirname as dirname2 } from "path";
2442
+ async function vizCommand(directory, options = {}) {
2443
+ const configPath = join7(directory, "roux.yaml");
2444
+ try {
2445
+ await access4(configPath);
2446
+ } catch {
2447
+ throw new Error(`Directory not initialized. Run 'roux init' first.`);
2448
+ }
2449
+ const cacheDir = join7(directory, ".roux");
2450
+ const outputPath = options.output ?? join7(cacheDir, "graph.html");
2451
+ const cache = new Cache(cacheDir);
2452
+ try {
2453
+ const nodes = cache.getAllNodes();
2454
+ const graphNodes = [];
2455
+ const graphEdges = [];
2456
+ const existingNodeIds = new Set(nodes.map((n) => n.id));
2457
+ for (const node of nodes) {
2458
+ const centrality = cache.getCentrality(node.id);
2459
+ graphNodes.push({
2460
+ id: node.id,
2461
+ title: node.title,
2462
+ inDegree: centrality?.inDegree ?? 0
2463
+ });
2464
+ for (const target of node.outgoingLinks) {
2465
+ if (existingNodeIds.has(target)) {
2466
+ graphEdges.push({
2467
+ source: node.id,
2468
+ target
2469
+ });
2470
+ }
2471
+ }
2472
+ }
2473
+ const html = generateHtml(graphNodes, graphEdges);
2474
+ await mkdir3(dirname2(outputPath), { recursive: true });
2475
+ await writeFile3(outputPath, html, "utf-8");
2476
+ return {
2477
+ outputPath,
2478
+ nodeCount: graphNodes.length,
2479
+ edgeCount: graphEdges.length,
2480
+ shouldOpen: options.open ?? false
2481
+ };
2482
+ } finally {
2483
+ cache.close();
2484
+ }
2485
+ }
2486
+ function generateHtml(nodes, edges) {
2487
+ const nodesJson = JSON.stringify(nodes);
2488
+ const edgesJson = JSON.stringify(edges);
2489
+ return `<!DOCTYPE html>
2490
+ <html lang="en">
2491
+ <head>
2492
+ <meta charset="UTF-8">
2493
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2494
+ <title>Roux Graph Visualization</title>
2495
+ <script src="https://d3js.org/d3.v7.min.js"></script>
2496
+ <style>
2497
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2498
+ body { font-family: system-ui, sans-serif; background: #1a1a2e; overflow: hidden; }
2499
+ svg { display: block; width: 100vw; height: 100vh; }
2500
+ .node circle { cursor: pointer; }
2501
+ .node text { fill: #e0e0e0; font-size: 10px; pointer-events: none; }
2502
+ .link { stroke: #4a4a6a; stroke-opacity: 0.6; fill: none; }
2503
+ .tooltip {
2504
+ position: absolute;
2505
+ background: #16213e;
2506
+ color: #e0e0e0;
2507
+ padding: 8px 12px;
2508
+ border-radius: 4px;
2509
+ font-size: 12px;
2510
+ pointer-events: none;
2511
+ opacity: 0;
2512
+ transition: opacity 0.2s;
2513
+ border: 1px solid #4a4a6a;
2514
+ }
2515
+ </style>
2516
+ </head>
2517
+ <body>
2518
+ <div class="tooltip" id="tooltip"></div>
2519
+ <svg></svg>
2520
+ <script>
2521
+ const nodes = ${nodesJson};
2522
+ const links = ${edgesJson};
2523
+
2524
+ const width = window.innerWidth;
2525
+ const height = window.innerHeight;
2526
+
2527
+ const svg = d3.select("svg")
2528
+ .attr("viewBox", [0, 0, width, height]);
2529
+
2530
+ const g = svg.append("g");
2531
+
2532
+ // Zoom behavior
2533
+ svg.call(d3.zoom()
2534
+ .scaleExtent([0.1, 4])
2535
+ .on("zoom", (event) => g.attr("transform", event.transform)));
2536
+
2537
+ // Node size based on in-degree
2538
+ const maxDegree = Math.max(1, ...nodes.map(n => n.inDegree));
2539
+ const nodeRadius = d => 5 + (d.inDegree / maxDegree) * 15;
2540
+
2541
+ // Force simulation
2542
+ const simulation = d3.forceSimulation(nodes)
2543
+ .force("link", d3.forceLink(links).id(d => d.id).distance(100))
2544
+ .force("charge", d3.forceManyBody().strength(-200))
2545
+ .force("center", d3.forceCenter(width / 2, height / 2))
2546
+ .force("collision", d3.forceCollide().radius(d => nodeRadius(d) + 5));
2547
+
2548
+ // Draw links with arrows
2549
+ svg.append("defs").append("marker")
2550
+ .attr("id", "arrow")
2551
+ .attr("viewBox", "0 -5 10 10")
2552
+ .attr("refX", 20)
2553
+ .attr("refY", 0)
2554
+ .attr("markerWidth", 6)
2555
+ .attr("markerHeight", 6)
2556
+ .attr("orient", "auto")
2557
+ .append("path")
2558
+ .attr("fill", "#4a4a6a")
2559
+ .attr("d", "M0,-5L10,0L0,5");
2560
+
2561
+ const link = g.append("g")
2562
+ .selectAll("line")
2563
+ .data(links)
2564
+ .join("line")
2565
+ .attr("class", "link")
2566
+ .attr("marker-end", "url(#arrow)");
2567
+
2568
+ // Draw nodes
2569
+ const node = g.append("g")
2570
+ .selectAll(".node")
2571
+ .data(nodes)
2572
+ .join("g")
2573
+ .attr("class", "node")
2574
+ .call(d3.drag()
2575
+ .on("start", dragstarted)
2576
+ .on("drag", dragged)
2577
+ .on("end", dragended));
2578
+
2579
+ node.append("circle")
2580
+ .attr("r", nodeRadius)
2581
+ .attr("fill", "#0f4c75")
2582
+ .attr("stroke", "#3282b8")
2583
+ .attr("stroke-width", 2);
2584
+
2585
+ node.append("text")
2586
+ .attr("dx", d => nodeRadius(d) + 5)
2587
+ .attr("dy", 4)
2588
+ .text(d => d.title.length > 20 ? d.title.slice(0, 17) + "..." : d.title);
2589
+
2590
+ // Tooltip
2591
+ const tooltip = d3.select("#tooltip");
2592
+ node
2593
+ .on("mouseover", (event, d) => {
2594
+ tooltip
2595
+ .style("opacity", 1)
2596
+ .html(\`<strong>\${d.title}</strong><br>ID: \${d.id}<br>Incoming links: \${d.inDegree}\`);
2597
+ })
2598
+ .on("mousemove", (event) => {
2599
+ tooltip
2600
+ .style("left", (event.pageX + 10) + "px")
2601
+ .style("top", (event.pageY - 10) + "px");
2602
+ })
2603
+ .on("mouseout", () => tooltip.style("opacity", 0));
2604
+
2605
+ // Simulation tick
2606
+ simulation.on("tick", () => {
2607
+ link
2608
+ .attr("x1", d => d.source.x)
2609
+ .attr("y1", d => d.source.y)
2610
+ .attr("x2", d => d.target.x)
2611
+ .attr("y2", d => d.target.y);
2612
+
2613
+ node.attr("transform", d => \`translate(\${d.x},\${d.y})\`);
2614
+ });
2615
+
2616
+ function dragstarted(event, d) {
2617
+ if (!event.active) simulation.alphaTarget(0.3).restart();
2618
+ d.fx = d.x;
2619
+ d.fy = d.y;
2620
+ }
2621
+
2622
+ function dragged(event, d) {
2623
+ d.fx = event.x;
2624
+ d.fy = event.y;
2625
+ }
2626
+
2627
+ function dragended(event, d) {
2628
+ if (!event.active) simulation.alphaTarget(0);
2629
+ d.fx = null;
2630
+ d.fy = null;
2631
+ }
2632
+ </script>
2633
+ </body>
2634
+ </html>`;
2635
+ }
2636
+
2637
+ // src/cli/index.ts
2638
+ var program = new Command();
2639
+ program.name("roux").description("Graph Programming Interface for knowledge bases").version("0.1.0");
2640
+ program.command("init").description("Initialize Roux in a directory").argument("[directory]", "Directory to initialize", ".").action(async (directory) => {
2641
+ const resolvedDir = resolve2(directory);
2642
+ const result = await initCommand(resolvedDir);
2643
+ if (result.created) {
2644
+ console.log(`Initialized Roux in ${resolvedDir}`);
2645
+ console.log(` Config: ${result.configPath}`);
2646
+ if (result.hooksInstalled) {
2647
+ console.log(` Claude hooks: installed`);
2648
+ }
2649
+ } else {
2650
+ if (result.hooksInstalled) {
2651
+ console.log(`Upgraded Roux in ${resolvedDir}`);
2652
+ console.log(` Claude hooks: installed`);
2653
+ } else {
2654
+ console.log(`Already initialized: ${result.configPath}`);
2655
+ }
2656
+ }
2657
+ });
2658
+ program.command("status").description("Show graph statistics").argument("[directory]", "Directory to check", ".").action(async (directory) => {
2659
+ const resolvedDir = resolve2(directory);
2660
+ try {
2661
+ const result = await statusCommand(resolvedDir);
2662
+ console.log("Graph Status:");
2663
+ console.log(` Nodes: ${result.nodeCount}`);
2664
+ console.log(` Edges: ${result.edgeCount}`);
2665
+ console.log(` Embeddings: ${result.embeddingCount}/${result.nodeCount}`);
2666
+ console.log(
2667
+ ` Coverage: ${(result.embeddingCoverage * 100).toFixed(1)}%`
2668
+ );
2669
+ } catch (error) {
2670
+ console.error(
2671
+ error instanceof Error ? error.message : "Unknown error"
2672
+ );
2673
+ process.exit(1);
2674
+ }
2675
+ });
2676
+ 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) => {
2677
+ const resolvedDir = resolve2(directory);
2678
+ try {
2679
+ console.log("Starting Roux server...");
2680
+ const handle = await serveCommand(resolvedDir, {
2681
+ watch: options.watch,
2682
+ onProgress: (current, total) => {
2683
+ process.stdout.write(
2684
+ `\r[${current}/${total}] Generating embeddings...`
2685
+ );
2686
+ if (current === total) {
2687
+ console.log(" Done.");
2688
+ }
2689
+ }
2690
+ });
2691
+ console.log(`Serving ${handle.nodeCount} nodes`);
2692
+ if (handle.isWatching) {
2693
+ console.log("Watching for file changes...");
2694
+ }
2695
+ const shutdown = async () => {
2696
+ console.log("\nShutting down...");
2697
+ await handle.stop();
2698
+ process.exit(0);
2699
+ };
2700
+ process.on("SIGINT", shutdown);
2701
+ process.on("SIGTERM", shutdown);
2702
+ await new Promise(() => {
2703
+ });
2704
+ } catch (error) {
2705
+ console.error(
2706
+ error instanceof Error ? error.message : "Unknown error"
2707
+ );
2708
+ process.exit(1);
2709
+ }
2710
+ });
2711
+ 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(
2712
+ async (directory, options) => {
2713
+ const resolvedDir = resolve2(directory);
2714
+ try {
2715
+ const result = await vizCommand(resolvedDir, {
2716
+ output: options.output,
2717
+ open: options.open
2718
+ });
2719
+ console.log(
2720
+ `Generated visualization: ${result.nodeCount} nodes, ${result.edgeCount} edges`
2721
+ );
2722
+ console.log(` Output: ${result.outputPath}`);
2723
+ if (result.shouldOpen) {
2724
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2725
+ execFile(openCmd, [result.outputPath]);
2726
+ }
2727
+ } catch (error) {
2728
+ console.error(
2729
+ error instanceof Error ? error.message : "Unknown error"
2730
+ );
2731
+ process.exit(1);
2732
+ }
2733
+ }
2734
+ );
2735
+ program.parse();
2736
+ //# sourceMappingURL=index.js.map