@danielblomma/cortex-mcp 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +203 -0
  2. package/bin/cortex.mjs +621 -0
  3. package/docs/MCP_MARKETPLACE.md +160 -0
  4. package/package.json +42 -0
  5. package/scaffold/.context/config.yaml +21 -0
  6. package/scaffold/.context/ontology.cypher +63 -0
  7. package/scaffold/.context/rules.yaml +25 -0
  8. package/scaffold/.githooks/_cortex-update-runner.sh +58 -0
  9. package/scaffold/.githooks/post-checkout +22 -0
  10. package/scaffold/.githooks/post-merge +14 -0
  11. package/scaffold/docs/architecture.md +22 -0
  12. package/scaffold/mcp/package-lock.json +2623 -0
  13. package/scaffold/mcp/package.json +29 -0
  14. package/scaffold/mcp/src/embed.ts +416 -0
  15. package/scaffold/mcp/src/embeddings.ts +192 -0
  16. package/scaffold/mcp/src/graph.ts +666 -0
  17. package/scaffold/mcp/src/loadGraph.ts +597 -0
  18. package/scaffold/mcp/src/paths.ts +33 -0
  19. package/scaffold/mcp/src/search.ts +412 -0
  20. package/scaffold/mcp/src/server.ts +98 -0
  21. package/scaffold/mcp/src/types.ts +109 -0
  22. package/scaffold/mcp/tests/server.test.mjs +60 -0
  23. package/scaffold/mcp/tsconfig.json +13 -0
  24. package/scaffold/scripts/bootstrap.sh +57 -0
  25. package/scaffold/scripts/capture-note.sh +55 -0
  26. package/scaffold/scripts/context.sh +109 -0
  27. package/scaffold/scripts/embed.sh +15 -0
  28. package/scaffold/scripts/ingest.mjs +1118 -0
  29. package/scaffold/scripts/ingest.sh +20 -0
  30. package/scaffold/scripts/install-git-hooks.sh +21 -0
  31. package/scaffold/scripts/load-kuzu.sh +6 -0
  32. package/scaffold/scripts/load-ryu.sh +18 -0
  33. package/scaffold/scripts/parsers/javascript.mjs +390 -0
  34. package/scaffold/scripts/parsers/package-lock.json +51 -0
  35. package/scaffold/scripts/parsers/package.json +17 -0
  36. package/scaffold/scripts/plan-state-engine.cjs +310 -0
  37. package/scaffold/scripts/plan-state.sh +71 -0
  38. package/scaffold/scripts/refresh.sh +9 -0
  39. package/scaffold/scripts/status.sh +282 -0
  40. package/scaffold/scripts/update-context.sh +18 -0
  41. package/scaffold/scripts/watch.sh +374 -0
@@ -0,0 +1,666 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import ryugraph, { type Connection, type Database, type QueryResult } from "ryugraph";
4
+ import { DB_PATH, DEFAULT_RANKING, PATHS } from "./paths.js";
5
+ import type {
6
+ AdrRecord,
7
+ ContextData,
8
+ DocumentRecord,
9
+ JsonObject,
10
+ JsonValue,
11
+ RankingWeights,
12
+ RelationRecord,
13
+ RuleRecord,
14
+ UnknownRow
15
+ } from "./types.js";
16
+
17
+ export type ReloadContextResult = {
18
+ forced: boolean;
19
+ reloaded: boolean;
20
+ context_source: "ryu" | "cache";
21
+ previous_graph_signature: string | null;
22
+ current_graph_signature: string | null;
23
+ warning?: string;
24
+ };
25
+
26
+ let ryuDb: Database | null = null;
27
+ let ryuConnection: Connection | null = null;
28
+ let ryuInitError: string | null = null;
29
+ let ryuLastInitAttemptAt = 0;
30
+ let ryuGraphSignature: string | null = null;
31
+
32
+ const RYU_INIT_RETRY_INTERVAL_MS = 2000;
33
+
34
+ function readFileIfExists(filePath: string): string | null {
35
+ if (!fs.existsSync(filePath)) {
36
+ return null;
37
+ }
38
+ return fs.readFileSync(filePath, "utf8");
39
+ }
40
+
41
+ function readJsonl(filePath: string): JsonObject[] {
42
+ const raw = readFileIfExists(filePath);
43
+ if (!raw) {
44
+ return [];
45
+ }
46
+
47
+ return raw
48
+ .split(/\r?\n/)
49
+ .map((line) => line.trim())
50
+ .filter(Boolean)
51
+ .map((line) => {
52
+ try {
53
+ return JSON.parse(line) as JsonObject;
54
+ } catch {
55
+ return null;
56
+ }
57
+ })
58
+ .filter((value): value is JsonObject => value !== null);
59
+ }
60
+
61
+ function asString(value: JsonValue | undefined, fallback = ""): string {
62
+ return typeof value === "string" ? value : fallback;
63
+ }
64
+
65
+ function asNumber(value: JsonValue | undefined, fallback = 0): number {
66
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
67
+ }
68
+
69
+ function asBoolean(value: JsonValue | undefined, fallback = false): boolean {
70
+ return typeof value === "boolean" ? value : fallback;
71
+ }
72
+
73
+ function asStringUnknown(value: unknown, fallback = ""): string {
74
+ if (typeof value === "string") return value;
75
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
76
+ return String(value);
77
+ }
78
+ return fallback;
79
+ }
80
+
81
+ function asNumberUnknown(value: unknown, fallback = 0): number {
82
+ if (typeof value === "number" && Number.isFinite(value)) return value;
83
+ if (typeof value === "bigint") return Number(value);
84
+ if (typeof value === "string") {
85
+ const parsed = Number(value);
86
+ if (Number.isFinite(parsed)) return parsed;
87
+ }
88
+ return fallback;
89
+ }
90
+
91
+ function asBooleanUnknown(value: unknown, fallback = false): boolean {
92
+ if (typeof value === "boolean") return value;
93
+ if (typeof value === "string") {
94
+ if (value.toLowerCase() === "true") return true;
95
+ if (value.toLowerCase() === "false") return false;
96
+ }
97
+ return fallback;
98
+ }
99
+
100
+ function parseDocuments(raw: JsonObject[]): DocumentRecord[] {
101
+ return raw
102
+ .map((item) => {
103
+ const id = asString(item.id);
104
+ const filePath = asString(item.path);
105
+ if (!id || !filePath) {
106
+ return null;
107
+ }
108
+
109
+ const kindRaw = asString(item.kind, "DOC").toUpperCase();
110
+ const kind: DocumentRecord["kind"] =
111
+ kindRaw === "CODE" ? "CODE" : kindRaw === "ADR" ? "ADR" : "DOC";
112
+
113
+ return {
114
+ id,
115
+ path: filePath,
116
+ kind,
117
+ updated_at: asString(item.updated_at),
118
+ source_of_truth: asBoolean(item.source_of_truth),
119
+ trust_level: asNumber(item.trust_level, 50),
120
+ status: asString(item.status, "active"),
121
+ excerpt: asString(item.excerpt),
122
+ content: asString(item.content)
123
+ };
124
+ })
125
+ .filter((item): item is DocumentRecord => item !== null);
126
+ }
127
+
128
+ function parseAdrs(raw: JsonObject[]): AdrRecord[] {
129
+ return raw
130
+ .map((item) => {
131
+ const id = asString(item.id);
132
+ if (!id) {
133
+ return null;
134
+ }
135
+
136
+ return {
137
+ id,
138
+ path: asString(item.path),
139
+ title: asString(item.title),
140
+ body: asString(item.body),
141
+ decision_date: asString(item.decision_date),
142
+ supersedes_id: asString(item.supersedes_id),
143
+ source_of_truth: asBoolean(item.source_of_truth, true),
144
+ trust_level: asNumber(item.trust_level, 95),
145
+ status: asString(item.status, "active")
146
+ };
147
+ })
148
+ .filter((item): item is AdrRecord => item !== null);
149
+ }
150
+
151
+ function parseRuleEntities(raw: JsonObject[]): RuleRecord[] {
152
+ return raw
153
+ .map((item) => {
154
+ const id = asString(item.id);
155
+ if (!id) {
156
+ return null;
157
+ }
158
+
159
+ return {
160
+ id,
161
+ title: asString(item.title, id),
162
+ body: asString(item.body),
163
+ scope: asString(item.scope, "global"),
164
+ updated_at: asString(item.updated_at, new Date(0).toISOString()),
165
+ source_of_truth: asBoolean(item.source_of_truth, true),
166
+ trust_level: asNumber(item.trust_level, 95),
167
+ status: asString(item.status, "active"),
168
+ priority: asNumber(item.priority, 0)
169
+ };
170
+ })
171
+ .filter((item): item is RuleRecord => item !== null);
172
+ }
173
+
174
+ function parseRulesYaml(yamlText: string | null): RuleRecord[] {
175
+ if (!yamlText) {
176
+ return [];
177
+ }
178
+
179
+ const lines = yamlText.split(/\r?\n/);
180
+ const rules: RuleRecord[] = [];
181
+ let current: {
182
+ id?: string;
183
+ description?: string;
184
+ priority?: number;
185
+ enforce?: boolean;
186
+ scope?: string;
187
+ } | null = null;
188
+
189
+ const pushCurrent = (): void => {
190
+ if (!current?.id) {
191
+ return;
192
+ }
193
+ rules.push({
194
+ id: current.id,
195
+ title: current.id,
196
+ body: current.description ?? "",
197
+ scope: current.scope ?? "global",
198
+ updated_at: new Date().toISOString(),
199
+ source_of_truth: true,
200
+ trust_level: 95,
201
+ status: current.enforce === false ? "draft" : "active",
202
+ priority: Number.isFinite(current.priority) ? (current.priority as number) : 0
203
+ });
204
+ };
205
+
206
+ for (const line of lines) {
207
+ const idMatch = line.match(/^\s*-\s*id:\s*(.+?)\s*$/);
208
+ if (idMatch) {
209
+ pushCurrent();
210
+ current = { id: idMatch[1].replace(/^['"]|['"]$/g, "") };
211
+ continue;
212
+ }
213
+
214
+ if (!current) {
215
+ continue;
216
+ }
217
+
218
+ const descriptionMatch = line.match(/^\s*description:\s*(.+?)\s*$/);
219
+ if (descriptionMatch) {
220
+ current.description = descriptionMatch[1].replace(/^['"]|['"]$/g, "");
221
+ continue;
222
+ }
223
+
224
+ const priorityMatch = line.match(/^\s*priority:\s*(\d+)\s*$/);
225
+ if (priorityMatch) {
226
+ current.priority = Number(priorityMatch[1]);
227
+ continue;
228
+ }
229
+
230
+ const enforceMatch = line.match(/^\s*enforce:\s*(true|false)\s*$/i);
231
+ if (enforceMatch) {
232
+ current.enforce = enforceMatch[1].toLowerCase() === "true";
233
+ continue;
234
+ }
235
+
236
+ const scopeMatch = line.match(/^\s*scope:\s*(.+?)\s*$/);
237
+ if (scopeMatch) {
238
+ current.scope = scopeMatch[1].replace(/^['"]|['"]$/g, "");
239
+ }
240
+ }
241
+
242
+ pushCurrent();
243
+ return rules;
244
+ }
245
+
246
+ function parseRelations(raw: JsonObject[], relation: RelationRecord["relation"]): RelationRecord[] {
247
+ return raw
248
+ .map((item) => {
249
+ const from = asString(item.from);
250
+ const to = asString(item.to);
251
+ if (!from || !to) {
252
+ return null;
253
+ }
254
+
255
+ return {
256
+ from,
257
+ to,
258
+ relation,
259
+ note: asString(item.note) || asString(item.reason)
260
+ };
261
+ })
262
+ .filter((item): item is RelationRecord => item !== null);
263
+ }
264
+
265
+ function parseRankingFromConfig(configText: string | null): RankingWeights {
266
+ if (!configText) {
267
+ return DEFAULT_RANKING;
268
+ }
269
+
270
+ const ranking: RankingWeights = { ...DEFAULT_RANKING };
271
+ const lines = configText.split(/\r?\n/);
272
+ let inRanking = false;
273
+
274
+ for (const line of lines) {
275
+ if (!inRanking && /^\s*ranking:\s*$/.test(line)) {
276
+ inRanking = true;
277
+ continue;
278
+ }
279
+
280
+ if (!inRanking) {
281
+ continue;
282
+ }
283
+
284
+ const entry = line.match(/^\s*(semantic|graph|trust|recency):\s*([0-9]*\.?[0-9]+)\s*$/);
285
+ if (entry) {
286
+ const key = entry[1] as keyof RankingWeights;
287
+ ranking[key] = Number(entry[2]);
288
+ continue;
289
+ }
290
+
291
+ if (line.trim() !== "" && !/^\s/.test(line)) {
292
+ break;
293
+ }
294
+ }
295
+
296
+ return ranking;
297
+ }
298
+
299
+ async function queryRows(
300
+ connection: Connection,
301
+ statement: string
302
+ ): Promise<Record<string, unknown>[]> {
303
+ const result = await connection.query(statement);
304
+ const resolved = Array.isArray(result) ? result[result.length - 1] : result;
305
+ return (resolved as QueryResult).getAll();
306
+ }
307
+
308
+ function readGraphSignature(): string | null {
309
+ if (!fs.existsSync(DB_PATH)) {
310
+ return null;
311
+ }
312
+
313
+ try {
314
+ const dbStats = fs.statSync(DB_PATH);
315
+ const dbPart = `${Math.round(dbStats.mtimeMs)}:${dbStats.size}`;
316
+
317
+ let manifestPart = "none";
318
+ if (fs.existsSync(PATHS.graphManifest)) {
319
+ const manifestStats = fs.statSync(PATHS.graphManifest);
320
+ manifestPart = `${Math.round(manifestStats.mtimeMs)}:${manifestStats.size}`;
321
+ }
322
+
323
+ return `${dbPart}:${manifestPart}`;
324
+ } catch {
325
+ return null;
326
+ }
327
+ }
328
+
329
+ function buildMissingDbMessage(): string {
330
+ const dbDir = path.dirname(DB_PATH);
331
+ const loadCommand = "./scripts/context.sh graph-load";
332
+ const bootstrapCommand = "./scripts/context.sh bootstrap";
333
+
334
+ if (!fs.existsSync(dbDir)) {
335
+ return `RyuGraph directory missing at ${dbDir}. Run ${bootstrapCommand}.`;
336
+ }
337
+
338
+ return `RyuGraph DB not found at ${DB_PATH}. Run ${loadCommand} (or ${bootstrapCommand} on cold start).`;
339
+ }
340
+
341
+ async function closeRyuGraphResources(): Promise<void> {
342
+ const currentConnection = ryuConnection;
343
+ const currentDb = ryuDb;
344
+
345
+ ryuConnection = null;
346
+ ryuDb = null;
347
+ ryuGraphSignature = null;
348
+
349
+ if (currentConnection) {
350
+ try {
351
+ await currentConnection.close();
352
+ } catch {
353
+ // Ignore close errors during refresh/reset.
354
+ }
355
+ }
356
+
357
+ if (currentDb) {
358
+ try {
359
+ await currentDb.close();
360
+ } catch {
361
+ // Ignore close errors during refresh/reset.
362
+ }
363
+ }
364
+ }
365
+
366
+ async function resetRyuGraphState(errorMessage: string): Promise<void> {
367
+ ryuInitError = errorMessage;
368
+ await closeRyuGraphResources();
369
+ }
370
+
371
+ async function getRyuGraphConnection(forceReload = false): Promise<Connection | null> {
372
+ const diskSignature = readGraphSignature();
373
+
374
+ if (ryuConnection) {
375
+ if (forceReload) {
376
+ await closeRyuGraphResources();
377
+ ryuLastInitAttemptAt = 0;
378
+ } else if (diskSignature && ryuGraphSignature && diskSignature === ryuGraphSignature) {
379
+ return ryuConnection;
380
+ } else {
381
+ await resetRyuGraphState("RyuGraph graph changed on disk; reconnecting.");
382
+ ryuLastInitAttemptAt = 0;
383
+ }
384
+ }
385
+
386
+ const now = Date.now();
387
+ if (!forceReload && now - ryuLastInitAttemptAt < RYU_INIT_RETRY_INTERVAL_MS) {
388
+ return null;
389
+ }
390
+ ryuLastInitAttemptAt = now;
391
+
392
+ if (!diskSignature) {
393
+ await resetRyuGraphState(buildMissingDbMessage());
394
+ return null;
395
+ }
396
+
397
+ try {
398
+ const nextDb = new ryugraph.Database(DB_PATH, undefined, undefined, true);
399
+ const nextConnection = new ryugraph.Connection(nextDb);
400
+ await nextDb.init();
401
+ await nextConnection.init();
402
+ ryuDb = nextDb;
403
+ ryuConnection = nextConnection;
404
+ ryuGraphSignature = readGraphSignature() ?? diskSignature;
405
+ ryuInitError = null;
406
+ return nextConnection;
407
+ } catch (error) {
408
+ await resetRyuGraphState(error instanceof Error ? error.message : "Failed to initialize RyuGraph");
409
+ return null;
410
+ }
411
+ }
412
+
413
+ function parseRyuGraphDocuments(rows: UnknownRow[], contentById: Map<string, string>): DocumentRecord[] {
414
+ return rows
415
+ .map((row) => {
416
+ const id = asStringUnknown(row.id);
417
+ const filePath = asStringUnknown(row.path);
418
+ if (!id || !filePath) {
419
+ return null;
420
+ }
421
+
422
+ const kindRaw = asStringUnknown(row.kind, "DOC").toUpperCase();
423
+ const kind: DocumentRecord["kind"] =
424
+ kindRaw === "CODE" ? "CODE" : kindRaw === "ADR" ? "ADR" : "DOC";
425
+
426
+ return {
427
+ id,
428
+ path: filePath,
429
+ kind,
430
+ updated_at: asStringUnknown(row.updated_at),
431
+ source_of_truth: asBooleanUnknown(row.source_of_truth, false),
432
+ trust_level: asNumberUnknown(row.trust_level, 50),
433
+ status: asStringUnknown(row.status, "active"),
434
+ excerpt: asStringUnknown(row.excerpt),
435
+ content: contentById.get(id) ?? ""
436
+ };
437
+ })
438
+ .filter((value): value is DocumentRecord => value !== null);
439
+ }
440
+
441
+ function parseRyuGraphRules(rows: UnknownRow[]): RuleRecord[] {
442
+ return rows
443
+ .map((row) => {
444
+ const id = asStringUnknown(row.id);
445
+ if (!id) {
446
+ return null;
447
+ }
448
+
449
+ return {
450
+ id,
451
+ title: asStringUnknown(row.title, id),
452
+ body: asStringUnknown(row.body),
453
+ scope: asStringUnknown(row.scope, "global"),
454
+ updated_at: asStringUnknown(row.updated_at),
455
+ source_of_truth: asBooleanUnknown(row.source_of_truth, true),
456
+ trust_level: asNumberUnknown(row.trust_level, 95),
457
+ status: asStringUnknown(row.status, "active"),
458
+ priority: asNumberUnknown(row.priority, 0)
459
+ };
460
+ })
461
+ .filter((value): value is RuleRecord => value !== null);
462
+ }
463
+
464
+ function parseRyuGraphAdrs(rows: UnknownRow[]): AdrRecord[] {
465
+ return rows
466
+ .map((row) => {
467
+ const id = asStringUnknown(row.id);
468
+ if (!id) {
469
+ return null;
470
+ }
471
+ return {
472
+ id,
473
+ path: asStringUnknown(row.path),
474
+ title: asStringUnknown(row.title, id),
475
+ body: asStringUnknown(row.body),
476
+ decision_date: asStringUnknown(row.decision_date),
477
+ supersedes_id: asStringUnknown(row.supersedes_id),
478
+ source_of_truth: asBooleanUnknown(row.source_of_truth, true),
479
+ trust_level: asNumberUnknown(row.trust_level, 95),
480
+ status: asStringUnknown(row.status, "active")
481
+ };
482
+ })
483
+ .filter((value): value is AdrRecord => value !== null);
484
+ }
485
+
486
+ function parseRyuGraphRelations(
487
+ rows: UnknownRow[],
488
+ relation: RelationRecord["relation"],
489
+ noteField: string
490
+ ): RelationRecord[] {
491
+ return rows
492
+ .map((row) => {
493
+ const from = asStringUnknown(row.from);
494
+ const to = asStringUnknown(row.to);
495
+ if (!from || !to) {
496
+ return null;
497
+ }
498
+ return {
499
+ from,
500
+ to,
501
+ relation,
502
+ note: asStringUnknown(row[noteField])
503
+ };
504
+ })
505
+ .filter((value): value is RelationRecord => value !== null);
506
+ }
507
+
508
+ export async function loadContextData(): Promise<ContextData> {
509
+ const ranking = parseRankingFromConfig(readFileIfExists(PATHS.config));
510
+ const cachedDocuments = parseDocuments(readJsonl(PATHS.documents));
511
+ const cachedAdrs = parseAdrs(readJsonl(PATHS.adrEntities));
512
+ const cachedRelations = [
513
+ ...parseRelations(readJsonl(PATHS.constrainsRelations), "CONSTRAINS"),
514
+ ...parseRelations(readJsonl(PATHS.implementsRelations), "IMPLEMENTS"),
515
+ ...parseRelations(readJsonl(PATHS.supersedesRelations), "SUPERSEDES")
516
+ ];
517
+
518
+ const yamlRules = parseRulesYaml(readFileIfExists(PATHS.rulesYaml));
519
+ const entityRules = parseRuleEntities(readJsonl(PATHS.ruleEntities));
520
+ const cachedRules = yamlRules.length > 0 ? yamlRules : entityRules;
521
+
522
+ const connection = await getRyuGraphConnection();
523
+ if (!connection) {
524
+ return {
525
+ documents: cachedDocuments,
526
+ adrs: cachedAdrs,
527
+ rules: cachedRules,
528
+ relations: cachedRelations,
529
+ ranking,
530
+ source: "cache",
531
+ warning: ryuInitError ?? "RyuGraph DB is not loaded yet."
532
+ };
533
+ }
534
+
535
+ try {
536
+ const [fileRows, ruleRows, adrRows, constrainsRows, implementsRows, supersedesRows] =
537
+ await Promise.all([
538
+ queryRows(
539
+ connection,
540
+ `
541
+ MATCH (f:File)
542
+ RETURN
543
+ f.id AS id,
544
+ f.path AS path,
545
+ f.kind AS kind,
546
+ f.excerpt AS excerpt,
547
+ f.updated_at AS updated_at,
548
+ f.source_of_truth AS source_of_truth,
549
+ f.trust_level AS trust_level,
550
+ f.status AS status;
551
+ `
552
+ ),
553
+ queryRows(
554
+ connection,
555
+ `
556
+ MATCH (r:Rule)
557
+ RETURN
558
+ r.id AS id,
559
+ r.title AS title,
560
+ r.body AS body,
561
+ r.scope AS scope,
562
+ r.priority AS priority,
563
+ r.updated_at AS updated_at,
564
+ r.source_of_truth AS source_of_truth,
565
+ r.trust_level AS trust_level,
566
+ r.status AS status;
567
+ `
568
+ ),
569
+ queryRows(
570
+ connection,
571
+ `
572
+ MATCH (a:ADR)
573
+ RETURN
574
+ a.id AS id,
575
+ a.path AS path,
576
+ a.title AS title,
577
+ a.body AS body,
578
+ a.decision_date AS decision_date,
579
+ a.supersedes_id AS supersedes_id,
580
+ a.source_of_truth AS source_of_truth,
581
+ a.trust_level AS trust_level,
582
+ a.status AS status;
583
+ `
584
+ ),
585
+ queryRows(
586
+ connection,
587
+ `
588
+ MATCH (r:Rule)-[c:CONSTRAINS]->(f:File)
589
+ RETURN r.id AS from, f.id AS to, c.note AS note;
590
+ `
591
+ ),
592
+ queryRows(
593
+ connection,
594
+ `
595
+ MATCH (f:File)-[i:IMPLEMENTS]->(r:Rule)
596
+ RETURN f.id AS from, r.id AS to, i.note AS note;
597
+ `
598
+ ),
599
+ queryRows(
600
+ connection,
601
+ `
602
+ MATCH (a1:ADR)-[s:SUPERSEDES]->(a2:ADR)
603
+ RETURN a1.id AS from, a2.id AS to, s.reason AS note;
604
+ `
605
+ )
606
+ ]);
607
+
608
+ const contentById = new Map(cachedDocuments.map((doc) => [doc.id, doc.content]));
609
+
610
+ const ryuDocuments = parseRyuGraphDocuments(fileRows, contentById);
611
+ const ryuRules = parseRyuGraphRules(ruleRows);
612
+ const ryuAdrs = parseRyuGraphAdrs(adrRows);
613
+ const ryuRelations = [
614
+ ...parseRyuGraphRelations(constrainsRows, "CONSTRAINS", "note"),
615
+ ...parseRyuGraphRelations(implementsRows, "IMPLEMENTS", "note"),
616
+ ...parseRyuGraphRelations(supersedesRows, "SUPERSEDES", "note")
617
+ ];
618
+
619
+ return {
620
+ documents: ryuDocuments.length > 0 ? ryuDocuments : cachedDocuments,
621
+ adrs: ryuAdrs.length > 0 ? ryuAdrs : cachedAdrs,
622
+ rules: ryuRules.length > 0 ? ryuRules : cachedRules,
623
+ relations: ryuRelations.length > 0 ? ryuRelations : cachedRelations,
624
+ ranking,
625
+ source: "ryu"
626
+ };
627
+ } catch (error) {
628
+ const message =
629
+ error instanceof Error
630
+ ? `RyuGraph query failed, using cache fallback: ${error.message}`
631
+ : "RyuGraph query failed, using cache fallback.";
632
+ await resetRyuGraphState(message);
633
+ return {
634
+ documents: cachedDocuments,
635
+ adrs: cachedAdrs,
636
+ rules: cachedRules,
637
+ relations: cachedRelations,
638
+ ranking,
639
+ source: "cache",
640
+ warning: message
641
+ };
642
+ }
643
+ }
644
+
645
+ export async function reloadContextGraph(force = true): Promise<ReloadContextResult> {
646
+ const previousSignature = ryuGraphSignature;
647
+
648
+ if (force || ryuConnection) {
649
+ await closeRyuGraphResources();
650
+ }
651
+
652
+ ryuInitError = null;
653
+ ryuLastInitAttemptAt = 0;
654
+
655
+ const nextConnection = await getRyuGraphConnection(true);
656
+ const currentSignature = readGraphSignature();
657
+
658
+ return {
659
+ forced: force,
660
+ reloaded: nextConnection !== null,
661
+ context_source: nextConnection ? "ryu" : "cache",
662
+ previous_graph_signature: previousSignature,
663
+ current_graph_signature: currentSignature,
664
+ warning: nextConnection ? undefined : ryuInitError ?? buildMissingDbMessage()
665
+ };
666
+ }