@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.
- package/README.md +203 -0
- package/bin/cortex.mjs +621 -0
- package/docs/MCP_MARKETPLACE.md +160 -0
- package/package.json +42 -0
- package/scaffold/.context/config.yaml +21 -0
- package/scaffold/.context/ontology.cypher +63 -0
- package/scaffold/.context/rules.yaml +25 -0
- package/scaffold/.githooks/_cortex-update-runner.sh +58 -0
- package/scaffold/.githooks/post-checkout +22 -0
- package/scaffold/.githooks/post-merge +14 -0
- package/scaffold/docs/architecture.md +22 -0
- package/scaffold/mcp/package-lock.json +2623 -0
- package/scaffold/mcp/package.json +29 -0
- package/scaffold/mcp/src/embed.ts +416 -0
- package/scaffold/mcp/src/embeddings.ts +192 -0
- package/scaffold/mcp/src/graph.ts +666 -0
- package/scaffold/mcp/src/loadGraph.ts +597 -0
- package/scaffold/mcp/src/paths.ts +33 -0
- package/scaffold/mcp/src/search.ts +412 -0
- package/scaffold/mcp/src/server.ts +98 -0
- package/scaffold/mcp/src/types.ts +109 -0
- package/scaffold/mcp/tests/server.test.mjs +60 -0
- package/scaffold/mcp/tsconfig.json +13 -0
- package/scaffold/scripts/bootstrap.sh +57 -0
- package/scaffold/scripts/capture-note.sh +55 -0
- package/scaffold/scripts/context.sh +109 -0
- package/scaffold/scripts/embed.sh +15 -0
- package/scaffold/scripts/ingest.mjs +1118 -0
- package/scaffold/scripts/ingest.sh +20 -0
- package/scaffold/scripts/install-git-hooks.sh +21 -0
- package/scaffold/scripts/load-kuzu.sh +6 -0
- package/scaffold/scripts/load-ryu.sh +18 -0
- package/scaffold/scripts/parsers/javascript.mjs +390 -0
- package/scaffold/scripts/parsers/package-lock.json +51 -0
- package/scaffold/scripts/parsers/package.json +17 -0
- package/scaffold/scripts/plan-state-engine.cjs +310 -0
- package/scaffold/scripts/plan-state.sh +71 -0
- package/scaffold/scripts/refresh.sh +9 -0
- package/scaffold/scripts/status.sh +282 -0
- package/scaffold/scripts/update-context.sh +18 -0
- 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
|
+
}
|