@danielblomma/cortex-mcp 0.4.5 → 0.6.5

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 (62) hide show
  1. package/README.md +38 -42
  2. package/bin/cortex.mjs +32 -60
  3. package/package.json +15 -3
  4. package/scaffold/.context/ontology.cypher +47 -0
  5. package/scaffold/.githooks/post-commit +14 -0
  6. package/scaffold/.githooks/post-rewrite +23 -0
  7. package/scaffold/mcp/package-lock.json +16 -16
  8. package/scaffold/mcp/package.json +4 -1
  9. package/scaffold/mcp/src/contextEntities.ts +311 -0
  10. package/scaffold/mcp/src/defaults.ts +6 -0
  11. package/scaffold/mcp/src/embed.ts +163 -37
  12. package/scaffold/mcp/src/frontmatter.ts +39 -0
  13. package/scaffold/mcp/src/graph.ts +253 -130
  14. package/scaffold/mcp/src/graphMetrics.ts +12 -0
  15. package/scaffold/mcp/src/impactPresentation.ts +202 -0
  16. package/scaffold/mcp/src/impactRanking.ts +237 -0
  17. package/scaffold/mcp/src/impactResponse.ts +47 -0
  18. package/scaffold/mcp/src/impactResults.ts +173 -0
  19. package/scaffold/mcp/src/impactSeed.ts +33 -0
  20. package/scaffold/mcp/src/impactTraversal.ts +83 -0
  21. package/scaffold/mcp/src/jsonl.ts +34 -0
  22. package/scaffold/mcp/src/loadGraph.ts +345 -86
  23. package/scaffold/mcp/src/paths.ts +17 -1
  24. package/scaffold/mcp/src/presets.ts +137 -0
  25. package/scaffold/mcp/src/relatedResponse.ts +30 -0
  26. package/scaffold/mcp/src/relatedTraversal.ts +101 -0
  27. package/scaffold/mcp/src/rules.ts +27 -0
  28. package/scaffold/mcp/src/search.ts +186 -455
  29. package/scaffold/mcp/src/searchCore.ts +274 -0
  30. package/scaffold/mcp/src/searchResults.ts +133 -0
  31. package/scaffold/mcp/src/server.ts +95 -3
  32. package/scaffold/mcp/src/types.ts +82 -3
  33. package/scaffold/scripts/context.sh +12 -46
  34. package/scaffold/scripts/dashboard.mjs +797 -0
  35. package/scaffold/scripts/dashboard.sh +13 -0
  36. package/scaffold/scripts/ingest.mjs +2227 -59
  37. package/scaffold/scripts/install-git-hooks.sh +3 -1
  38. package/scaffold/scripts/memory-compile.mjs +232 -0
  39. package/scaffold/scripts/memory-compile.sh +20 -0
  40. package/scaffold/scripts/memory-lint.mjs +375 -0
  41. package/scaffold/scripts/memory-lint.sh +20 -0
  42. package/scaffold/scripts/parsers/config.mjs +178 -0
  43. package/scaffold/scripts/parsers/cpp.mjs +316 -0
  44. package/scaffold/scripts/parsers/dotnet/VbNetParser/Program.cs +374 -0
  45. package/scaffold/scripts/parsers/dotnet/VbNetParser/VbNetParser.csproj +13 -0
  46. package/scaffold/scripts/parsers/javascript/ast.mjs +61 -0
  47. package/scaffold/scripts/parsers/javascript/calls.mjs +53 -0
  48. package/scaffold/scripts/parsers/javascript/chunks.mjs +388 -0
  49. package/scaffold/scripts/parsers/javascript/imports.mjs +162 -0
  50. package/scaffold/scripts/parsers/javascript/patterns.mjs +82 -0
  51. package/scaffold/scripts/parsers/javascript/scope-analysis.mjs +3 -0
  52. package/scaffold/scripts/parsers/javascript/scope-builder.mjs +305 -0
  53. package/scaffold/scripts/parsers/javascript/scope-resolver.mjs +82 -0
  54. package/scaffold/scripts/parsers/javascript.mjs +27 -350
  55. package/scaffold/scripts/parsers/resources.mjs +166 -0
  56. package/scaffold/scripts/parsers/rust.mjs +515 -0
  57. package/scaffold/scripts/parsers/sql.mjs +137 -0
  58. package/scaffold/scripts/parsers/vbnet.mjs +143 -0
  59. package/scaffold/scripts/status.sh +0 -7
  60. package/scaffold/scripts/capture-note.sh +0 -55
  61. package/scaffold/scripts/plan-state-engine.cjs +0 -310
  62. package/scaffold/scripts/plan-state.sh +0 -71
@@ -1,322 +1,74 @@
1
1
  import { embedQuery, getEmbeddingRuntimeWarning, loadEmbeddingIndex } from "./embeddings.js";
2
+ import {
3
+ buildChunkPartOfRelations,
4
+ buildEntitySearchMap,
5
+ buildSearchEntities,
6
+ entityCatalog
7
+ } from "./contextEntities.js";
8
+ import { relationDegree } from "./graphMetrics.js";
2
9
  import { loadContextData } from "./graph.js";
10
+ import { buildEmptyImpactResponse, buildImpactResponseMeta } from "./impactResponse.js";
11
+ import { buildImpactResults } from "./impactResults.js";
12
+ import { resolveImpactSeed } from "./impactSeed.js";
13
+ import {
14
+ cosineSimilarity,
15
+ expandQueryTokens,
16
+ legacyDataAccessBoost,
17
+ normalizeText,
18
+ recencyScore,
19
+ semanticScore,
20
+ tokenize
21
+ } from "./searchCore.js";
22
+ import { buildSearchResults } from "./searchResults.js";
23
+ import { traverseImpactGraph } from "./impactTraversal.js";
24
+ import { buildEmptyRelatedResponse, buildRelatedResponseMeta } from "./relatedResponse.js";
25
+ import { traverseRelatedGraph } from "./relatedTraversal.js";
26
+ import {
27
+ resolveImpactPathMustExclude,
28
+ resolveImpactPathMustInclude,
29
+ resolveImpactRelationTypes,
30
+ resolveImpactResultDomains,
31
+ resolveImpactResultEntityTypes
32
+ } from "./impactRanking.js";
33
+ import {
34
+ resolveImpactResponsePreset,
35
+ resolveRelatedResponsePreset,
36
+ resolveSearchResponsePreset
37
+ } from "./presets.js";
3
38
  import type {
4
- ContextData,
5
- JsonObject,
39
+ ImpactParams,
6
40
  RelatedParams,
7
- RelationRecord,
8
- RulesParams,
9
- SearchEntity,
41
+ RelationType,
10
42
  SearchParams,
11
43
  ToolPayload
12
44
  } from "./types.js";
13
45
 
14
46
  const MIN_LEXICAL_RELEVANCE = 0.05;
15
47
  const MIN_VECTOR_RELEVANCE = 0.2;
16
-
17
- const QUERY_TOKEN_EXPANSIONS: Record<string, string[]> = {
18
- semantisk: ["semantic"],
19
- sökning: ["search"],
20
- sokning: ["search"],
21
- regel: ["rule"],
22
- regler: ["rules"],
23
- relaterad: ["related"],
24
- meddelande: ["message"],
25
- avvikelse: ["deviation"]
26
- };
27
-
28
- function normalizeText(value: string): string {
29
- return value.normalize("NFKC").toLowerCase();
30
- }
31
-
32
- function tokenize(value: string): string[] {
33
- return normalizeText(value)
34
- .split(/[^\p{L}\p{N}]+/gu)
35
- .map((part) => part.trim())
36
- .filter((part) => part.length >= 2);
37
- }
38
-
39
- function expandQueryTokens(tokens: string[]): string[] {
40
- const expanded = new Set<string>(tokens);
41
- for (const token of tokens) {
42
- const aliases = QUERY_TOKEN_EXPANSIONS[token];
43
- if (!aliases) {
44
- continue;
45
- }
46
- for (const alias of aliases) {
47
- expanded.add(alias);
48
- }
49
- }
50
- return Array.from(expanded);
51
- }
52
-
53
- function daysSince(isoDate: string): number {
54
- const timestamp = Date.parse(isoDate);
55
- if (Number.isNaN(timestamp)) {
56
- return 3650;
57
- }
58
-
59
- const now = Date.now();
60
- return Math.max(0, (now - timestamp) / (1000 * 60 * 60 * 24));
61
- }
62
-
63
- function recencyScore(isoDate: string): number {
64
- const days = daysSince(isoDate);
65
- return 1 / (1 + days / 30);
66
- }
67
-
68
- function semanticScore(queryTokens: string[], queryPhrase: string, text: string): number {
69
- if (queryTokens.length === 0) {
70
- return 0;
71
- }
72
-
73
- const textTokenSet = new Set(tokenize(text));
74
- if (textTokenSet.size === 0) {
75
- return 0;
76
- }
77
-
78
- let matched = 0;
79
- for (const token of queryTokens) {
80
- if (textTokenSet.has(token)) {
81
- matched += 1;
82
- }
83
- }
84
-
85
- const overlap = matched / queryTokens.length;
86
- if (overlap <= 0) {
87
- return 0;
88
- }
89
-
90
- const normalizedText = normalizeText(text);
91
- const phraseBonus = queryPhrase && normalizedText.includes(queryPhrase) ? 0.15 : 0;
92
- return Math.min(1, overlap * 0.85 + phraseBonus);
93
- }
94
-
95
- function cosineSimilarity(a: number[], b: number[]): number {
96
- if (a.length === 0 || b.length === 0 || a.length !== b.length) {
97
- return 0;
98
- }
99
-
100
- let dot = 0;
101
- let normA = 0;
102
- let normB = 0;
103
- for (let index = 0; index < a.length; index += 1) {
104
- const av = a[index];
105
- const bv = b[index];
106
- dot += av * bv;
107
- normA += av * av;
108
- normB += bv * bv;
109
- }
110
-
111
- if (normA === 0 || normB === 0) {
112
- return 0;
113
- }
114
- return dot / (Math.sqrt(normA) * Math.sqrt(normB));
115
- }
116
-
117
- function groupRuleLinks(relations: RelationRecord[]): Map<string, string[]> {
118
- const links = new Map<string, string[]>();
119
- for (const relation of relations) {
120
- if (relation.relation !== "CONSTRAINS" && relation.relation !== "IMPLEMENTS") {
121
- continue;
122
- }
123
-
124
- if (relation.relation === "CONSTRAINS") {
125
- const list = links.get(relation.to) ?? [];
126
- list.push(relation.from);
127
- links.set(relation.to, list);
128
- } else {
129
- const list = links.get(relation.from) ?? [];
130
- list.push(relation.to);
131
- links.set(relation.from, list);
132
- }
133
- }
134
- return links;
135
- }
136
-
137
- function buildSearchEntities(data: ContextData, includeContent: boolean): SearchEntity[] {
138
- const entities: SearchEntity[] = [];
139
- const fileRuleLinks = groupRuleLinks(data.relations);
140
- const adrPathSet = new Set(
141
- data.adrs
142
- .map((adr) => adr.path.trim().toLowerCase())
143
- .filter((adrPath) => adrPath.length > 0)
144
- );
145
-
146
- for (const document of data.documents) {
147
- const normalizedPath = document.path.trim().toLowerCase();
148
- // ADR content is represented by ADR entities below; avoid duplicate results.
149
- if (document.kind === "ADR" && adrPathSet.has(normalizedPath)) {
150
- continue;
151
- }
152
-
153
- entities.push({
154
- id: document.id,
155
- entity_type: "File",
156
- kind: document.kind,
157
- label: document.path,
158
- path: document.path,
159
- text: `${document.path}\n${document.excerpt}\n${document.content}`,
160
- status: document.status,
161
- source_of_truth: document.source_of_truth,
162
- trust_level: document.trust_level,
163
- updated_at: document.updated_at,
164
- snippet: document.excerpt,
165
- matched_rules: fileRuleLinks.get(document.id) ?? [],
166
- content: includeContent ? document.content : undefined
167
- });
168
- }
169
-
170
- for (const rule of data.rules) {
171
- entities.push({
172
- id: rule.id,
173
- entity_type: "Rule",
174
- kind: "RULE",
175
- label: rule.title || rule.id,
176
- path: "",
177
- text: `${rule.id}\n${rule.title}\n${rule.body}`,
178
- status: rule.status,
179
- source_of_truth: rule.source_of_truth,
180
- trust_level: rule.trust_level,
181
- updated_at: rule.updated_at,
182
- snippet: rule.body.slice(0, 500),
183
- matched_rules: [rule.id],
184
- content: includeContent ? rule.body : undefined
185
- });
186
- }
187
-
188
- for (const adr of data.adrs) {
189
- entities.push({
190
- id: adr.id,
191
- entity_type: "ADR",
192
- kind: "ADR",
193
- label: adr.title || adr.id,
194
- path: adr.path,
195
- text: `${adr.path}\n${adr.title}\n${adr.body}`,
196
- status: adr.status,
197
- source_of_truth: adr.source_of_truth,
198
- trust_level: adr.trust_level,
199
- updated_at: adr.decision_date,
200
- snippet: adr.body.slice(0, 500),
201
- matched_rules: [],
202
- content: includeContent ? adr.body : undefined
203
- });
204
- }
205
-
206
- const filePathById = new Map(
207
- data.documents
208
- .filter((document) => document.kind === "CODE")
209
- .map((document) => [document.id, document.path])
210
- );
211
-
212
- for (const chunk of data.chunks) {
213
- const filePath = filePathById.get(chunk.file_id) ?? "";
214
- entities.push({
215
- id: chunk.id,
216
- entity_type: "Chunk",
217
- kind: chunk.kind || "chunk",
218
- label: chunk.name || chunk.id,
219
- path: filePath,
220
- text: `${filePath}\n${chunk.name}\n${chunk.signature}\n${chunk.body}`,
221
- status: chunk.status,
222
- source_of_truth: chunk.source_of_truth,
223
- trust_level: chunk.trust_level,
224
- updated_at: chunk.updated_at,
225
- snippet: chunk.body.slice(0, 500),
226
- matched_rules: fileRuleLinks.get(chunk.file_id) ?? [],
227
- content: includeContent ? chunk.body : undefined
228
- });
229
- }
230
-
231
- return entities;
232
- }
233
-
234
- function relationDegree(relations: RelationRecord[]): Map<string, number> {
235
- const degrees = new Map<string, number>();
236
-
237
- for (const relation of relations) {
238
- degrees.set(relation.from, (degrees.get(relation.from) ?? 0) + 1);
239
- degrees.set(relation.to, (degrees.get(relation.to) ?? 0) + 1);
240
- }
241
-
242
- return degrees;
243
- }
244
-
245
- function buildChunkPartOfRelations(data: ContextData): RelationRecord[] {
246
- const relations: RelationRecord[] = [];
247
- for (const chunk of data.chunks) {
248
- if (!chunk.file_id) {
249
- continue;
250
- }
251
-
252
- relations.push({
253
- from: chunk.id,
254
- to: chunk.file_id,
255
- relation: "PART_OF",
256
- note: "Chunk belongs to file"
257
- });
258
- }
259
- return relations;
260
- }
261
-
262
- function entityCatalog(data: ContextData): Map<string, JsonObject> {
263
- const catalog = new Map<string, JsonObject>();
264
- const fileById = new Map(data.documents.map((document) => [document.id, document]));
265
-
266
- for (const file of data.documents) {
267
- catalog.set(file.id, {
268
- id: file.id,
269
- type: "File",
270
- label: file.path,
271
- status: file.status,
272
- source_of_truth: file.source_of_truth
273
- });
274
- }
275
-
276
- for (const rule of data.rules) {
277
- catalog.set(rule.id, {
278
- id: rule.id,
279
- type: "Rule",
280
- label: rule.title,
281
- status: rule.status,
282
- source_of_truth: rule.source_of_truth
283
- });
284
- }
285
-
286
- for (const adr of data.adrs) {
287
- catalog.set(adr.id, {
288
- id: adr.id,
289
- type: "ADR",
290
- label: adr.title || adr.id,
291
- status: adr.status,
292
- source_of_truth: adr.source_of_truth
293
- });
294
- }
295
-
296
- for (const chunk of data.chunks) {
297
- const filePath = fileById.get(chunk.file_id)?.path ?? "";
298
- const chunkEntity: JsonObject = {
299
- id: chunk.id,
300
- type: "Chunk",
301
- label: chunk.name || chunk.id,
302
- status: chunk.status,
303
- source_of_truth: chunk.source_of_truth
304
- };
305
- if (filePath) {
306
- chunkEntity.path = filePath;
307
- }
308
- catalog.set(chunk.id, chunkEntity);
309
- }
310
-
311
- return catalog;
312
- }
48
+ const IMPACT_RELATION_TYPES = new Set([
49
+ "CALLS",
50
+ "CALLS_SQL",
51
+ "IMPORTS",
52
+ "USES_CONFIG_KEY",
53
+ "USES_RESOURCE_KEY",
54
+ "USES_SETTING_KEY",
55
+ "USES_CONFIG",
56
+ "TRANSFORMS_CONFIG",
57
+ "PART_OF"
58
+ ]);
313
59
 
314
60
  export async function runContextSearch(parsed: SearchParams): Promise<ToolPayload> {
61
+ const searchPresetConfig = resolveSearchResponsePreset(parsed);
62
+ const responsePreset = searchPresetConfig.responsePreset;
63
+ const includeScores = searchPresetConfig.includeScores;
64
+ const includeMatchedRules = searchPresetConfig.includeMatchedRules;
65
+ const includeContent = searchPresetConfig.includeContent;
315
66
  const data = await loadContextData();
316
- const degreeByEntity = relationDegree(data.relations);
67
+ const allRelations = [...data.relations, ...buildChunkPartOfRelations(data)];
68
+ const degreeByEntity = relationDegree(allRelations);
317
69
  const queryTokens = expandQueryTokens(Array.from(new Set(tokenize(parsed.query))));
318
70
  const queryPhrase = normalizeText(parsed.query).trim();
319
- const candidates = buildSearchEntities(data, parsed.include_content).filter(
71
+ const candidates = buildSearchEntities(data, includeContent).filter(
320
72
  (entity) => parsed.include_deprecated || entity.status.toLowerCase() !== "deprecated"
321
73
  );
322
74
  const embeddings = loadEmbeddingIndex();
@@ -325,63 +77,35 @@ export async function runContextSearch(parsed: SearchParams): Promise<ToolPayloa
325
77
  ? await embedQuery(parsed.query, embeddings.model)
326
78
  : null;
327
79
 
328
- const results = candidates
329
- .map((entity) => {
330
- const lexicalSemantic = semanticScore(queryTokens, queryPhrase, entity.text);
331
- const entityVector = embeddings.vectors.get(entity.id);
332
- const vectorSemantic =
333
- queryVector && entityVector
334
- ? Math.max(0, Math.min(1, cosineSimilarity(queryVector, entityVector)))
335
- : 0;
336
- const hasRelevanceSignal =
337
- lexicalSemantic >= MIN_LEXICAL_RELEVANCE || vectorSemantic >= MIN_VECTOR_RELEVANCE;
338
- if (!hasRelevanceSignal) {
339
- return null;
340
- }
341
- const semantic =
342
- vectorSemantic > 0 ? vectorSemantic * 0.75 + lexicalSemantic * 0.25 : lexicalSemantic;
343
- const graphScore = Math.min(1, (degreeByEntity.get(entity.id) ?? 0) / 4);
344
- const trustScore = Math.max(0, Math.min(1, entity.trust_level / 100));
345
- const dateScore = recencyScore(entity.updated_at);
346
-
347
- let score = 0;
348
- score += data.ranking.semantic * semantic;
349
- score += data.ranking.graph * graphScore;
350
- score += data.ranking.trust * trustScore;
351
- score += data.ranking.recency * dateScore;
352
-
353
- if (entity.source_of_truth) {
354
- score += 0.1 * semantic;
355
- }
356
-
357
- return {
358
- id: entity.id,
359
- entity_type: entity.entity_type,
360
- kind: entity.kind,
361
- title: entity.label,
362
- path: entity.path || undefined,
363
- score: Number(score.toFixed(4)),
364
- semantic_score: Number(semantic.toFixed(4)),
365
- embedding_score: Number(vectorSemantic.toFixed(4)),
366
- lexical_score: Number(lexicalSemantic.toFixed(4)),
367
- graph_score: Number(graphScore.toFixed(4)),
368
- source_of_truth: entity.source_of_truth,
369
- status: entity.status,
370
- updated_at: entity.updated_at,
371
- matched_rules: entity.matched_rules,
372
- excerpt: entity.snippet,
373
- content: parsed.include_content ? entity.content : undefined
374
- };
375
- })
376
- .filter((result): result is NonNullable<typeof result> => result !== null)
377
- .sort((a, b) => b.score - a.score)
378
- .slice(0, parsed.top_k);
80
+ const results = buildSearchResults({
81
+ candidates,
82
+ degreeByEntity,
83
+ queryTokens,
84
+ queryPhrase,
85
+ ranking: data.ranking,
86
+ includeScores,
87
+ includeMatchedRules,
88
+ includeContent,
89
+ queryVector,
90
+ embeddingVectors: embeddings.vectors,
91
+ topK: parsed.top_k,
92
+ minLexicalRelevance: MIN_LEXICAL_RELEVANCE,
93
+ minVectorRelevance: MIN_VECTOR_RELEVANCE,
94
+ semanticScorer: semanticScore,
95
+ vectorScorer: cosineSimilarity,
96
+ recencyScorer: recencyScore,
97
+ legacyDataAccessBooster: legacyDataAccessBoost
98
+ });
379
99
 
380
100
  const warningMessages = [data.warning, embeddings.warning, getEmbeddingRuntimeWarning()].filter(Boolean);
381
101
 
382
102
  return {
383
103
  query: parsed.query,
384
104
  top_k: parsed.top_k,
105
+ response_preset: responsePreset,
106
+ include_scores: includeScores,
107
+ include_matched_rules: includeMatchedRules,
108
+ include_content: includeContent,
385
109
  ranking: data.ranking,
386
110
  total_candidates: candidates.length,
387
111
  context_source: data.source,
@@ -393,125 +117,132 @@ export async function runContextSearch(parsed: SearchParams): Promise<ToolPayloa
393
117
  }
394
118
 
395
119
  export async function runContextRelated(parsed: RelatedParams): Promise<ToolPayload> {
120
+ const relatedPresetConfig = resolveRelatedResponsePreset(parsed);
121
+ const responsePreset = relatedPresetConfig.responsePreset;
122
+ const includeEdges = relatedPresetConfig.includeEdges;
123
+ const includeEntityMetadata = relatedPresetConfig.includeEntityMetadata;
396
124
  const data = await loadContextData();
397
125
  const catalog = entityCatalog(data);
398
126
  const relations = [...data.relations, ...buildChunkPartOfRelations(data)];
127
+ const relatedResponseMeta = buildRelatedResponseMeta({
128
+ parsed,
129
+ responsePreset,
130
+ includeEdges,
131
+ includeEntityMetadata,
132
+ contextSource: data.source
133
+ });
399
134
 
400
135
  if (!catalog.has(parsed.entity_id)) {
401
- return {
402
- entity_id: parsed.entity_id,
403
- depth: parsed.depth,
404
- related: [],
405
- edges: [],
406
- context_source: data.source,
136
+ return buildEmptyRelatedResponse({
137
+ meta: relatedResponseMeta,
407
138
  warning: "Entity not found in indexed context."
408
- };
409
- }
410
-
411
- const outgoing = new Map<string, RelationRecord[]>();
412
- const incoming = new Map<string, RelationRecord[]>();
413
-
414
- for (const relation of relations) {
415
- const outList = outgoing.get(relation.from) ?? [];
416
- outList.push(relation);
417
- outgoing.set(relation.from, outList);
418
-
419
- const inList = incoming.get(relation.to) ?? [];
420
- inList.push(relation);
421
- incoming.set(relation.to, inList);
139
+ });
422
140
  }
423
141
 
424
- const seen = new Set<string>([parsed.entity_id]);
425
- const queue: Array<{ id: string; hop: number }> = [{ id: parsed.entity_id, hop: 0 }];
426
- const related: JsonObject[] = [];
427
- const traversedEdges: JsonObject[] = [];
428
- const traversedEdgeKeys = new Set<string>();
429
-
430
- while (queue.length > 0) {
431
- const current = queue.shift() as { id: string; hop: number };
432
- if (current.hop >= parsed.depth) {
433
- continue;
434
- }
435
-
436
- const neighbors = [
437
- ...(outgoing.get(current.id) ?? []).map((edge) => ({
438
- edge,
439
- next: edge.to,
440
- direction: "outgoing"
441
- })),
442
- ...(incoming.get(current.id) ?? []).map((edge) => ({
443
- edge,
444
- next: edge.from,
445
- direction: "incoming"
446
- }))
447
- ];
448
-
449
- for (const neighbor of neighbors) {
450
- const target = neighbor.next;
451
- if (!seen.has(target)) {
452
- seen.add(target);
453
- queue.push({ id: target, hop: current.hop + 1 });
454
-
455
- const entity = catalog.get(target) ?? {
456
- id: target,
457
- type: "Unknown",
458
- label: target,
459
- status: "unknown",
460
- source_of_truth: false
461
- };
462
-
463
- related.push({
464
- ...entity,
465
- hops: current.hop + 1,
466
- via_relation: neighbor.edge.relation,
467
- direction: neighbor.direction
468
- });
469
- }
470
-
471
- const edgeKey = `${neighbor.edge.from}|${neighbor.edge.relation}|${neighbor.edge.to}|${neighbor.edge.note}`;
472
- if (!traversedEdgeKeys.has(edgeKey)) {
473
- traversedEdgeKeys.add(edgeKey);
474
- traversedEdges.push({
475
- from: neighbor.edge.from,
476
- to: neighbor.edge.to,
477
- relation: neighbor.edge.relation,
478
- note: neighbor.edge.note
479
- });
480
- }
481
- }
482
- }
142
+ const { related, traversedEdges } = traverseRelatedGraph({
143
+ entityId: parsed.entity_id,
144
+ relations,
145
+ depth: parsed.depth,
146
+ catalog,
147
+ includeEntityMetadata
148
+ });
483
149
 
484
150
  return {
485
- entity_id: parsed.entity_id,
486
- depth: parsed.depth,
487
- context_source: data.source,
151
+ ...relatedResponseMeta,
488
152
  warning: data.warning,
489
153
  related,
490
- edges: parsed.include_edges ? traversedEdges : []
154
+ edges: includeEdges ? traversedEdges : []
491
155
  };
492
156
  }
493
157
 
494
- export async function runContextRules(parsed: RulesParams): Promise<ToolPayload> {
158
+ export async function runContextImpact(parsed: ImpactParams): Promise<ToolPayload> {
495
159
  const data = await loadContextData();
160
+ const catalog = entityCatalog(data);
161
+ const relations = [...data.relations, ...buildChunkPartOfRelations(data)];
162
+ const allowedRelationTypes = resolveImpactRelationTypes(parsed);
163
+ const impactRelations = relations.filter((relation) => allowedRelationTypes.has(relation.relation));
164
+ const searchEntities = buildEntitySearchMap(data);
165
+ const degreeByEntity = relationDegree(relations);
166
+ const profile = parsed.profile ?? "all";
167
+ const sortBy = parsed.sort_by ?? "impact_score";
168
+ const responsePresetConfig = resolveImpactResponsePreset(parsed);
169
+ const responsePreset = responsePresetConfig.responsePreset;
170
+ const includeScores = responsePresetConfig.includeScores;
171
+ const includeReasons = responsePresetConfig.includeReasons;
172
+ const verbosePaths = responsePresetConfig.verbosePaths;
173
+ const maxPathHopsShown = responsePresetConfig.maxPathHopsShown;
174
+ const resultDomains = resolveImpactResultDomains(parsed);
175
+ const resultEntityTypes = resolveImpactResultEntityTypes(parsed);
176
+ const pathMustInclude = resolveImpactPathMustInclude(parsed);
177
+ const pathMustExclude = resolveImpactPathMustExclude(parsed);
178
+ const seedResolution = await resolveImpactSeed(parsed, runContextSearch);
179
+ const seedId = seedResolution.id;
180
+ const impactResponseMeta = buildImpactResponseMeta({
181
+ parsed,
182
+ responsePreset,
183
+ includeScores,
184
+ includeReasons,
185
+ verbosePaths,
186
+ maxPathHopsShown,
187
+ profile,
188
+ sortBy,
189
+ allowedRelationTypes,
190
+ contextSource: data.source
191
+ });
192
+
193
+ if (!seedId) {
194
+ return buildEmptyImpactResponse({
195
+ meta: impactResponseMeta,
196
+ warning: seedResolution.warning ?? "No matching seed entity found for impact analysis."
197
+ });
198
+ }
199
+
200
+ if (!catalog.has(seedId)) {
201
+ return buildEmptyImpactResponse({
202
+ meta: impactResponseMeta,
203
+ warning: "Seed entity not found in indexed context."
204
+ });
205
+ }
496
206
 
497
- const rules = data.rules
498
- .filter((rule) => parsed.include_inactive || rule.status === "active")
499
- .filter((rule) => !parsed.scope || rule.scope === parsed.scope || rule.scope === "global")
500
- .sort((a, b) => b.priority - a.priority)
501
- .map((rule) => ({
502
- id: rule.id,
503
- title: rule.title,
504
- description: rule.body,
505
- priority: rule.priority,
506
- scope: rule.scope,
507
- status: rule.status
508
- }));
207
+ const { visited, traversedEdges } = traverseImpactGraph({
208
+ seedId,
209
+ relations: impactRelations,
210
+ depth: parsed.depth
211
+ });
212
+ const queryTokens = parsed.query ? expandQueryTokens(Array.from(new Set(tokenize(parsed.query)))) : [];
213
+ const queryPhrase = parsed.query ? normalizeText(parsed.query).trim() : "";
214
+
215
+ const results = buildImpactResults({
216
+ visited,
217
+ seedId,
218
+ catalog,
219
+ searchEntities,
220
+ degreeByEntity,
221
+ queryTokens,
222
+ queryPhrase,
223
+ hasQuery: Boolean(parsed.query),
224
+ profile,
225
+ includeReasons,
226
+ includeScores,
227
+ verbosePaths,
228
+ maxPathHopsShown,
229
+ resultDomains,
230
+ resultEntityTypes,
231
+ pathMustInclude,
232
+ pathMustExclude,
233
+ sortBy,
234
+ topK: parsed.top_k,
235
+ semanticScorer: semanticScore
236
+ });
509
237
 
510
238
  return {
511
- scope: parsed.scope ?? "global",
512
- count: rules.length,
513
- context_source: data.source,
239
+ ...impactResponseMeta,
240
+ resolved_seed_id: seedId,
241
+ resolved_from_query: !parsed.entity_id,
242
+ seed: catalog.get(seedId),
514
243
  warning: data.warning,
515
- rules
244
+ query_results: seedResolution.query_results,
245
+ results,
246
+ edges: parsed.include_edges && verbosePaths ? traversedEdges : []
516
247
  };
517
248
  }