@danielblomma/cortex-mcp 2.2.0 → 2.2.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@danielblomma/cortex-mcp",
3
3
  "mcpName": "io.github.DanielBlomma/cortex",
4
- "version": "2.2.0",
4
+ "version": "2.2.2",
5
5
  "description": "Local, repo-scoped context platform for coding assistants. Semantic search, graph relationships, and architectural rule context.",
6
6
  "type": "module",
7
7
  "author": "Daniel Blomma",
@@ -75,8 +75,12 @@ function buildRelationSearchSignals(data: ContextData): Map<string, string> {
75
75
  return new Map([...signalsByEntity.entries()].map(([id, parts]) => [id, parts.join("\n")]));
76
76
  }
77
77
 
78
- export function buildSearchEntities(data: ContextData, includeContent: boolean): SearchEntity[] {
79
- const entities: SearchEntity[] = [];
78
+ function buildSearchEntityIndexes(data: ContextData): {
79
+ fileRuleLinks: Map<string, string[]>;
80
+ relationSignals: Map<string, string>;
81
+ adrPathSet: Set<string>;
82
+ filePathById: Map<string, string>;
83
+ } {
80
84
  const fileRuleLinks = groupRuleLinks(data.relations);
81
85
  const relationSignals = buildRelationSearchSignals(data);
82
86
  const adrPathSet = new Set(
@@ -84,6 +88,15 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
84
88
  .map((adr) => adr.path.trim().toLowerCase())
85
89
  .filter((adrPath) => adrPath.length > 0)
86
90
  );
91
+ const filePathById = new Map(
92
+ data.documents.filter((document) => document.kind === "CODE").map((document) => [document.id, document.path])
93
+ );
94
+
95
+ return { fileRuleLinks, relationSignals, adrPathSet, filePathById };
96
+ }
97
+
98
+ export function* iterateSearchEntities(data: ContextData, includeContent: boolean): Generator<SearchEntity> {
99
+ const { fileRuleLinks, relationSignals, adrPathSet, filePathById } = buildSearchEntityIndexes(data);
87
100
 
88
101
  for (const document of data.documents) {
89
102
  const normalizedPath = document.path.trim().toLowerCase();
@@ -91,7 +104,7 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
91
104
  continue;
92
105
  }
93
106
 
94
- entities.push({
107
+ yield {
95
108
  id: document.id,
96
109
  entity_type: "File",
97
110
  kind: document.kind,
@@ -105,11 +118,11 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
105
118
  snippet: document.excerpt,
106
119
  matched_rules: fileRuleLinks.get(document.id) ?? [],
107
120
  content: includeContent ? document.content : undefined
108
- });
121
+ };
109
122
  }
110
123
 
111
124
  for (const rule of data.rules) {
112
- entities.push({
125
+ yield {
113
126
  id: rule.id,
114
127
  entity_type: "Rule",
115
128
  kind: "RULE",
@@ -123,11 +136,11 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
123
136
  snippet: rule.body.slice(0, 500),
124
137
  matched_rules: [rule.id],
125
138
  content: includeContent ? rule.body : undefined
126
- });
139
+ };
127
140
  }
128
141
 
129
142
  for (const adr of data.adrs) {
130
- entities.push({
143
+ yield {
131
144
  id: adr.id,
132
145
  entity_type: "ADR",
133
146
  kind: "ADR",
@@ -141,16 +154,12 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
141
154
  snippet: adr.body.slice(0, 500),
142
155
  matched_rules: [],
143
156
  content: includeContent ? adr.body : undefined
144
- });
157
+ };
145
158
  }
146
159
 
147
- const filePathById = new Map(
148
- data.documents.filter((document) => document.kind === "CODE").map((document) => [document.id, document.path])
149
- );
150
-
151
160
  for (const chunk of data.chunks) {
152
161
  const filePath = filePathById.get(chunk.file_id) ?? "";
153
- entities.push({
162
+ yield {
154
163
  id: chunk.id,
155
164
  entity_type: "Chunk",
156
165
  kind: chunk.kind || "chunk",
@@ -164,11 +173,11 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
164
173
  snippet: chunk.description || chunk.body.slice(0, 500),
165
174
  matched_rules: fileRuleLinks.get(chunk.file_id) ?? [],
166
175
  content: includeContent ? chunk.body : undefined
167
- });
176
+ };
168
177
  }
169
178
 
170
179
  for (const module of data.modules) {
171
- entities.push({
180
+ yield {
172
181
  id: module.id,
173
182
  entity_type: "Module",
174
183
  kind: "MODULE",
@@ -182,11 +191,11 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
182
191
  snippet: (module.summary || "").slice(0, 500),
183
192
  matched_rules: [],
184
193
  content: includeContent ? module.summary : undefined
185
- });
194
+ };
186
195
  }
187
196
 
188
197
  for (const project of data.projects) {
189
- entities.push({
198
+ yield {
190
199
  id: project.id,
191
200
  entity_type: "Project",
192
201
  kind: project.kind.toUpperCase() || "PROJECT",
@@ -200,10 +209,12 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
200
209
  snippet: (project.summary || "").slice(0, 500),
201
210
  matched_rules: [],
202
211
  content: includeContent ? project.summary : undefined
203
- });
212
+ };
204
213
  }
214
+ }
205
215
 
206
- return entities;
216
+ export function buildSearchEntities(data: ContextData, includeContent: boolean): SearchEntity[] {
217
+ return Array.from(iterateSearchEntities(data, includeContent));
207
218
  }
208
219
 
209
220
  export function buildChunkPartOfRelations(data: ContextData): RelationRecord[] {
@@ -2,7 +2,7 @@ import { embedQuery, getEmbeddingRuntimeWarning, loadEmbeddingIndex } from "./em
2
2
  import {
3
3
  buildChunkPartOfRelations,
4
4
  buildEntitySearchMap,
5
- buildSearchEntities,
5
+ iterateSearchEntities,
6
6
  entityCatalog
7
7
  } from "./contextEntities.js";
8
8
  import { relationDegree } from "./graphMetrics.js";
@@ -19,7 +19,7 @@ import {
19
19
  semanticScore,
20
20
  tokenize
21
21
  } from "./searchCore.js";
22
- import { buildSearchResults } from "./searchResults.js";
22
+ import { buildSearchResultsWithStats } from "./searchResults.js";
23
23
  import { traverseImpactGraph } from "./impactTraversal.js";
24
24
  import { buildEmptyRelatedResponse, buildRelatedResponseMeta } from "./relatedResponse.js";
25
25
  import { traverseRelatedGraph } from "./relatedTraversal.js";
@@ -39,6 +39,7 @@ import type {
39
39
  ImpactParams,
40
40
  RelatedParams,
41
41
  RelationType,
42
+ SearchEntity,
42
43
  SearchParams,
43
44
  ToolPayload
44
45
  } from "./types.js";
@@ -57,6 +58,17 @@ const IMPACT_RELATION_TYPES = new Set([
57
58
  "PART_OF"
58
59
  ]);
59
60
 
61
+ function* filterSearchCandidates(
62
+ candidates: Iterable<SearchEntity>,
63
+ includeDeprecated: boolean
64
+ ): Generator<SearchEntity> {
65
+ for (const entity of candidates) {
66
+ if (includeDeprecated || entity.status.toLowerCase() !== "deprecated") {
67
+ yield entity;
68
+ }
69
+ }
70
+ }
71
+
60
72
  export async function runContextSearch(parsed: SearchParams): Promise<ToolPayload> {
61
73
  const searchPresetConfig = resolveSearchResponsePreset(parsed);
62
74
  const responsePreset = searchPresetConfig.responsePreset;
@@ -68,16 +80,15 @@ export async function runContextSearch(parsed: SearchParams): Promise<ToolPayloa
68
80
  const degreeByEntity = relationDegree(allRelations);
69
81
  const queryTokens = expandQueryTokens(Array.from(new Set(tokenize(parsed.query))));
70
82
  const queryPhrase = normalizeText(parsed.query).trim();
71
- const candidates = buildSearchEntities(data, includeContent).filter(
72
- (entity) => parsed.include_deprecated || entity.status.toLowerCase() !== "deprecated"
73
- );
83
+ const candidates = () =>
84
+ filterSearchCandidates(iterateSearchEntities(data, includeContent), parsed.include_deprecated);
74
85
  const embeddings = loadEmbeddingIndex();
75
86
  const queryVector =
76
87
  embeddings.model && embeddings.vectors.size > 0
77
88
  ? await embedQuery(parsed.query, embeddings.model)
78
89
  : null;
79
90
 
80
- const results = buildSearchResults({
91
+ const { results, totalCandidates } = buildSearchResultsWithStats({
81
92
  candidates,
82
93
  degreeByEntity,
83
94
  queryTokens,
@@ -107,7 +118,7 @@ export async function runContextSearch(parsed: SearchParams): Promise<ToolPayloa
107
118
  include_matched_rules: includeMatchedRules,
108
119
  include_content: includeContent,
109
120
  ranking: data.ranking,
110
- total_candidates: candidates.length,
121
+ total_candidates: totalCandidates,
111
122
  context_source: data.source,
112
123
  warning: warningMessages.length > 0 ? warningMessages.join(" | ") : undefined,
113
124
  semantic_engine:
@@ -1,5 +1,19 @@
1
1
  import type { RankingWeights, SearchEntity } from "./types.js";
2
2
 
3
+ type SearchResult = Record<string, unknown>;
4
+
5
+ type RankedSearchResult = {
6
+ result: SearchResult;
7
+ rankScore: number;
8
+ };
9
+
10
+ type SearchCandidateSource = Iterable<SearchEntity> | (() => Iterable<SearchEntity>);
11
+
12
+ type BaseChunkMetadata = {
13
+ label: string;
14
+ path: string;
15
+ };
16
+
3
17
  function isWindowChunkId(id: string): boolean {
4
18
  return id.includes(":window:");
5
19
  }
@@ -14,8 +28,26 @@ function baseChunkLabel(label: string): string {
14
28
  return markerIndex === -1 ? label : label.slice(0, markerIndex);
15
29
  }
16
30
 
17
- export function buildSearchResults(params: {
18
- candidates: SearchEntity[];
31
+ function pruneRankedResults(bestById: Map<string, RankedSearchResult>, limit: number): void {
32
+ if (bestById.size <= limit) {
33
+ return;
34
+ }
35
+
36
+ const retained = [...bestById.entries()]
37
+ .sort(([, a], [, b]) => b.rankScore - a.rankScore)
38
+ .slice(0, limit);
39
+ bestById.clear();
40
+ for (const [id, result] of retained) {
41
+ bestById.set(id, result);
42
+ }
43
+ }
44
+
45
+ function candidatesFrom(source: SearchCandidateSource): Iterable<SearchEntity> {
46
+ return typeof source === "function" ? source() : source;
47
+ }
48
+
49
+ export function buildSearchResultsWithStats(params: {
50
+ candidates: SearchCandidateSource;
19
51
  degreeByEntity: Map<string, number>;
20
52
  queryTokens: string[];
21
53
  queryPhrase: string;
@@ -32,7 +64,11 @@ export function buildSearchResults(params: {
32
64
  vectorScorer: (a: Float32Array, b: Float32Array) => number;
33
65
  recencyScorer: (isoDate: string) => number;
34
66
  legacyDataAccessBooster: (entity: SearchEntity, queryTokens: string[], queryPhrase: string) => number;
35
- }): Record<string, unknown>[] {
67
+ }): { results: Record<string, unknown>[]; totalCandidates: number } {
68
+ if (params.topK <= 0) {
69
+ return { results: [], totalCandidates: 0 };
70
+ }
71
+
36
72
  // Graph score = midrank percentile of relation degree within the entity's
37
73
  // own type. The previous min(1, degree/4) saturated at degree >= 4, which
38
74
  // nearly every entity exceeds, making the graph weight a constant. Per-type
@@ -40,7 +76,10 @@ export function buildSearchResults(params: {
40
76
  // (every type averages ~0.5), so hub-heavy types like rules cannot drown
41
77
  // out leaf code files or doc sections.
42
78
  const sortedDegreesByType = new Map<string, number[]>();
43
- for (const entity of params.candidates) {
79
+ const chunkCandidatesById = new Map<string, BaseChunkMetadata>();
80
+ let totalCandidates = 0;
81
+ for (const entity of candidatesFrom(params.candidates)) {
82
+ totalCandidates += 1;
44
83
  const degree = params.degreeByEntity.get(entity.id) ?? 0;
45
84
  const list = sortedDegreesByType.get(entity.entity_type);
46
85
  if (list) {
@@ -48,6 +87,13 @@ export function buildSearchResults(params: {
48
87
  } else {
49
88
  sortedDegreesByType.set(entity.entity_type, [degree]);
50
89
  }
90
+
91
+ if (entity.entity_type === "Chunk" && !isWindowChunkId(entity.id)) {
92
+ chunkCandidatesById.set(entity.id, {
93
+ label: entity.label,
94
+ path: entity.path
95
+ });
96
+ }
51
97
  }
52
98
  for (const list of sortedDegreesByType.values()) {
53
99
  list.sort((a, b) => a - b);
@@ -65,102 +111,100 @@ export function buildSearchResults(params: {
65
111
  return sorted.length > 0 ? (lo + upper) / (2 * sorted.length) : 0;
66
112
  };
67
113
 
68
- const rawResults = params.candidates
69
- .map((entity) => {
70
- const lexicalSemantic = params.semanticScorer(params.queryTokens, params.queryPhrase, entity.text);
71
- const entityVector = params.embeddingVectors.get(entity.id);
72
- const vectorSemantic =
73
- params.queryVector && entityVector
74
- ? Math.max(0, Math.min(1, params.vectorScorer(params.queryVector, entityVector)))
75
- : 0;
76
- const hasRelevanceSignal =
77
- lexicalSemantic >= params.minLexicalRelevance || vectorSemantic >= params.minVectorRelevance;
78
- if (!hasRelevanceSignal) {
79
- return null;
80
- }
114
+ const resultLimit = Math.max(1, params.topK);
115
+ const pruneThreshold = Math.max(resultLimit * 4, 64);
116
+ const bestById = new Map<string, RankedSearchResult>();
81
117
 
82
- const semantic =
83
- vectorSemantic > 0 ? vectorSemantic * 0.75 + lexicalSemantic * 0.25 : lexicalSemantic;
84
- const degree = params.degreeByEntity.get(entity.id) ?? 0;
85
- const graphScore = midrankPercentile(sortedDegreesByType.get(entity.entity_type) ?? [], degree);
86
- const trustScore = Math.max(0, Math.min(1, entity.trust_level / 100));
87
- const dateScore = params.recencyScorer(entity.updated_at);
88
-
89
- let score = 0;
90
- score += params.ranking.semantic * semantic;
91
- score += params.ranking.graph * graphScore;
92
- score += params.ranking.trust * trustScore;
93
- score += params.ranking.recency * dateScore;
94
- score += params.legacyDataAccessBooster(entity, params.queryTokens, params.queryPhrase);
95
-
96
- if (entity.source_of_truth) {
97
- score += 0.1 * semantic;
98
- }
118
+ for (const entity of candidatesFrom(params.candidates)) {
119
+ const lexicalSemantic = params.semanticScorer(params.queryTokens, params.queryPhrase, entity.text);
120
+ const entityVector = params.embeddingVectors.get(entity.id);
121
+ const vectorSemantic =
122
+ params.queryVector && entityVector
123
+ ? Math.max(0, Math.min(1, params.vectorScorer(params.queryVector, entityVector)))
124
+ : 0;
125
+ const hasRelevanceSignal =
126
+ lexicalSemantic >= params.minLexicalRelevance || vectorSemantic >= params.minVectorRelevance;
127
+ if (!hasRelevanceSignal) {
128
+ continue;
129
+ }
99
130
 
100
- return {
101
- id: entity.id,
102
- entity_type: entity.entity_type,
103
- kind: entity.kind,
104
- title: entity.label,
105
- path: entity.path || undefined,
106
- source_of_truth: entity.source_of_truth,
107
- status: entity.status,
108
- updated_at: entity.updated_at,
109
- excerpt: entity.snippet,
110
- ...(params.includeScores
111
- ? {
112
- score: Number(score.toFixed(4)),
113
- semantic_score: Number(semantic.toFixed(4)),
114
- embedding_score: Number(vectorSemantic.toFixed(4)),
115
- lexical_score: Number(lexicalSemantic.toFixed(4)),
116
- graph_score: Number(graphScore.toFixed(4))
117
- }
118
- : {}),
119
- ...(params.includeMatchedRules
120
- ? {
121
- matched_rules: entity.matched_rules
122
- }
123
- : {}),
124
- ...(params.includeContent
125
- ? {
126
- content: entity.content
127
- }
128
- : {})
129
- };
130
- })
131
- .filter((result): result is NonNullable<typeof result> => result !== null)
132
- .sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0));
133
-
134
- const chunkCandidatesById = new Map(
135
- params.candidates.filter((entity) => entity.entity_type === "Chunk").map((entity) => [entity.id, entity])
136
- );
137
- const normalizedById = new Map<string, (typeof rawResults)[number]>();
138
-
139
- for (const result of rawResults) {
140
- if (result.entity_type !== "Chunk" || !isWindowChunkId(String(result.id))) {
141
- if (!normalizedById.has(String(result.id))) {
142
- normalizedById.set(String(result.id), result);
131
+ const semantic =
132
+ vectorSemantic > 0 ? vectorSemantic * 0.75 + lexicalSemantic * 0.25 : lexicalSemantic;
133
+ const degree = params.degreeByEntity.get(entity.id) ?? 0;
134
+ const graphScore = midrankPercentile(sortedDegreesByType.get(entity.entity_type) ?? [], degree);
135
+ const trustScore = Math.max(0, Math.min(1, entity.trust_level / 100));
136
+ const dateScore = params.recencyScorer(entity.updated_at);
137
+
138
+ let score = 0;
139
+ score += params.ranking.semantic * semantic;
140
+ score += params.ranking.graph * graphScore;
141
+ score += params.ranking.trust * trustScore;
142
+ score += params.ranking.recency * dateScore;
143
+ score += params.legacyDataAccessBooster(entity, params.queryTokens, params.queryPhrase);
144
+
145
+ if (entity.source_of_truth) {
146
+ score += 0.1 * semantic;
147
+ }
148
+
149
+ const result: SearchResult = {
150
+ id: entity.id,
151
+ entity_type: entity.entity_type,
152
+ kind: entity.kind,
153
+ title: entity.label,
154
+ path: entity.path || undefined,
155
+ source_of_truth: entity.source_of_truth,
156
+ status: entity.status,
157
+ updated_at: entity.updated_at,
158
+ excerpt: entity.snippet,
159
+ ...(params.includeScores
160
+ ? {
161
+ score: Number(score.toFixed(4)),
162
+ semantic_score: Number(semantic.toFixed(4)),
163
+ embedding_score: Number(vectorSemantic.toFixed(4)),
164
+ lexical_score: Number(lexicalSemantic.toFixed(4)),
165
+ graph_score: Number(graphScore.toFixed(4))
166
+ }
167
+ : {}),
168
+ ...(params.includeMatchedRules
169
+ ? {
170
+ matched_rules: entity.matched_rules
171
+ }
172
+ : {}),
173
+ ...(params.includeContent
174
+ ? {
175
+ content: entity.content
176
+ }
177
+ : {})
178
+ };
179
+
180
+ if (entity.entity_type === "Chunk" && isWindowChunkId(entity.id)) {
181
+ const canonicalId = baseChunkId(entity.id);
182
+ const baseChunk = chunkCandidatesById.get(canonicalId);
183
+ if (baseChunk) {
184
+ result.id = canonicalId;
185
+ result.title = baseChunkLabel(baseChunk.label);
186
+ result.path = baseChunk.path || undefined;
143
187
  }
144
- continue;
145
188
  }
146
189
 
147
- const canonicalId = baseChunkId(String(result.id));
148
- const baseChunk = chunkCandidatesById.get(canonicalId);
149
- const normalizedResult = baseChunk
150
- ? {
151
- ...result,
152
- id: canonicalId,
153
- title: baseChunkLabel(baseChunk.label),
154
- path: baseChunk.path || undefined
155
- }
156
- : result;
157
- const existing = normalizedById.get(String(normalizedResult.id));
158
- if (!existing || Number(normalizedResult.score ?? 0) > Number(existing.score ?? 0)) {
159
- normalizedById.set(String(normalizedResult.id), normalizedResult);
190
+ const resultId = String(result.id);
191
+ const existing = bestById.get(resultId);
192
+ if (!existing || score > existing.rankScore) {
193
+ bestById.set(resultId, { result, rankScore: score });
194
+ }
195
+
196
+ if (bestById.size > pruneThreshold) {
197
+ pruneRankedResults(bestById, resultLimit);
160
198
  }
161
199
  }
162
200
 
163
- return [...normalizedById.values()]
164
- .sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0))
165
- .slice(0, params.topK);
201
+ const results = [...bestById.values()]
202
+ .sort((a, b) => b.rankScore - a.rankScore)
203
+ .slice(0, params.topK)
204
+ .map((ranked) => ranked.result);
205
+ return { results, totalCandidates };
206
+ }
207
+
208
+ export function buildSearchResults(params: Parameters<typeof buildSearchResultsWithStats>[0]): Record<string, unknown>[] {
209
+ return buildSearchResultsWithStats(params).results;
166
210
  }
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { buildSearchResults } from "../dist/searchResults.js";
3
+ import { buildSearchResults, buildSearchResultsWithStats } from "../dist/searchResults.js";
4
4
 
5
5
  function makeEntity(id, entityType, overrides = {}) {
6
6
  return {
@@ -130,3 +130,76 @@ test("graph score never saturates to a shared constant for common degrees", () =
130
130
 
131
131
  assert.equal(unique.size, 3);
132
132
  });
133
+
134
+ test("ranking is preserved when score fields are omitted from the response", () => {
135
+ const candidates = [
136
+ makeEntity("weak", "Chunk", { text: "weak query" }),
137
+ makeEntity("strong", "Chunk", { text: "strong query" }),
138
+ makeEntity("middle", "Chunk", { text: "middle query" })
139
+ ];
140
+
141
+ const results = buildSearchResults({
142
+ candidates,
143
+ degreeByEntity: new Map(),
144
+ queryTokens: ["query"],
145
+ queryPhrase: "query",
146
+ ranking: { semantic: 1, graph: 0, trust: 0, recency: 0 },
147
+ includeScores: false,
148
+ includeMatchedRules: false,
149
+ includeContent: false,
150
+ queryVector: null,
151
+ embeddingVectors: new Map(),
152
+ topK: 2,
153
+ minLexicalRelevance: 0,
154
+ minVectorRelevance: 0,
155
+ semanticScorer: (_tokens, _phrase, text) => {
156
+ if (text.includes("strong")) return 0.9;
157
+ if (text.includes("middle")) return 0.5;
158
+ return 0.1;
159
+ },
160
+ vectorScorer: () => 0,
161
+ recencyScorer: () => 0,
162
+ legacyDataAccessBooster: () => 0
163
+ });
164
+
165
+ assert.deepEqual(results.map((result) => result.id), ["strong", "middle"]);
166
+ assert.equal("score" in results[0], false);
167
+ });
168
+
169
+ test("search results can consume a repeatable candidate generator with stats", () => {
170
+ let passes = 0;
171
+ const candidateSource = function* () {
172
+ passes += 1;
173
+ yield makeEntity("weak", "Chunk", { text: "weak query" });
174
+ yield makeEntity("strong", "Chunk", { text: "strong query" });
175
+ yield makeEntity("middle", "Chunk", { text: "middle query" });
176
+ };
177
+
178
+ const { results, totalCandidates } = buildSearchResultsWithStats({
179
+ candidates: candidateSource,
180
+ degreeByEntity: new Map(),
181
+ queryTokens: ["query"],
182
+ queryPhrase: "query",
183
+ ranking: { semantic: 1, graph: 0, trust: 0, recency: 0 },
184
+ includeScores: false,
185
+ includeMatchedRules: false,
186
+ includeContent: false,
187
+ queryVector: null,
188
+ embeddingVectors: new Map(),
189
+ topK: 2,
190
+ minLexicalRelevance: 0,
191
+ minVectorRelevance: 0,
192
+ semanticScorer: (_tokens, _phrase, text) => {
193
+ if (text.includes("strong")) return 0.9;
194
+ if (text.includes("middle")) return 0.5;
195
+ return 0.1;
196
+ },
197
+ vectorScorer: () => 0,
198
+ recencyScorer: () => 0,
199
+ legacyDataAccessBooster: () => 0
200
+ });
201
+
202
+ assert.equal(passes, 2);
203
+ assert.equal(totalCandidates, 3);
204
+ assert.deepEqual(results.map((result) => result.id), ["strong", "middle"]);
205
+ });
@@ -758,6 +758,29 @@ function writeJsonl(filePath, records) {
758
758
  }
759
759
  }
760
760
 
761
+ function stageJsonl(filePath, records) {
762
+ const stagedPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
763
+ writeJsonl(stagedPath, records);
764
+ return {
765
+ commit() {
766
+ fs.renameSync(stagedPath, filePath);
767
+ },
768
+ discard() {
769
+ fs.rmSync(stagedPath, { force: true });
770
+ }
771
+ };
772
+ }
773
+
774
+ function countFileContentRecords(fileRecords) {
775
+ let count = 0;
776
+ for (const fileRecord of fileRecords) {
777
+ if (Object.prototype.hasOwnProperty.call(fileRecord, "content")) {
778
+ count += 1;
779
+ }
780
+ }
781
+ return count;
782
+ }
783
+
761
784
  function sanitizeTsvCell(value) {
762
785
  if (value === null || value === undefined) return "";
763
786
  return String(value).replace(/\t/g, " ").replace(/\r?\n/g, " ");
@@ -2056,6 +2079,27 @@ function fileTokenSet(fileRecord) {
2056
2079
  return new Set(tokenizeKeywords(tokenSource));
2057
2080
  }
2058
2081
 
2082
+ function collectRuleKeywordMatch(ruleKeywords, tokens, minimumMatches) {
2083
+ const required = Math.min(minimumMatches, Math.max(1, ruleKeywords.length));
2084
+ let count = 0;
2085
+ const sample = [];
2086
+
2087
+ for (const keyword of ruleKeywords) {
2088
+ if (!tokens.has(keyword)) {
2089
+ continue;
2090
+ }
2091
+ count += 1;
2092
+ if (sample.length < 5) {
2093
+ sample.push(keyword);
2094
+ }
2095
+ if (count >= required && sample.length >= 5) {
2096
+ break;
2097
+ }
2098
+ }
2099
+
2100
+ return { matched: count >= required, sample };
2101
+ }
2102
+
2059
2103
  function chunkIdFor(filePath, chunk) {
2060
2104
  const startLine = Number.isFinite(chunk.startLine) ? chunk.startLine : 0;
2061
2105
  const endLine = Number.isFinite(chunk.endLine) ? chunk.endLine : startLine;
@@ -2267,7 +2311,8 @@ function resolveIngestWorkerCount(taskCount) {
2267
2311
  const raw = process.env.CORTEX_INGEST_WORKERS;
2268
2312
  const configured = raw !== undefined ? Number.parseInt(raw, 10) : Number.NaN;
2269
2313
  const cpuBudget = Math.max(1, (os.availableParallelism?.() ?? os.cpus().length) - 1);
2270
- const desired = Number.isFinite(configured) && configured >= 0 ? configured : Math.min(cpuBudget, 8);
2314
+ const defaultWorkerLimit = taskCount >= 1000 ? 4 : 8;
2315
+ const desired = Number.isFinite(configured) && configured >= 0 ? configured : Math.min(cpuBudget, defaultWorkerLimit);
2271
2316
  if (desired <= 1) return 1;
2272
2317
  // Worker spin-up plus per-worker WASM grammar init dominates on small or
2273
2318
  // incremental runs; stay sequential until there is enough work to amortize.
@@ -3331,59 +3376,98 @@ async function main() {
3331
3376
 
3332
3377
  const constrainsRelations = [];
3333
3378
  const implementsRelations = [];
3334
- const constrainsSeen = new Set();
3335
- const implementsSeen = new Set();
3379
+ const stagedDocumentCache = stageJsonl(path.join(CACHE_DIR, "documents.jsonl"), fileRecords);
3380
+ const stagedFileEntityCache = stageJsonl(path.join(CACHE_DIR, "entities.file.jsonl"), fileRecords);
3381
+ memoryTrace.checkpoint("writes:file_cache_staged", {
3382
+ files: fileRecords.length,
3383
+ file_content_records: countFileContentRecords(fileRecords)
3384
+ });
3336
3385
  memoryTrace.checkpoint("tokens:rule_matching_start", {
3337
3386
  files: fileRecords.length,
3338
3387
  rules: ruleRecords.length
3339
3388
  });
3340
- const tokenByFileId = new Map(fileRecords.map((fileRecord) => [fileRecord.id, fileTokenSet(fileRecord)]));
3341
3389
 
3342
- for (const ruleRecord of ruleRecords) {
3343
- const needle = ruleRecord.id.toLowerCase();
3344
- const ruleKeywords = normalizeRuleTokens(ruleRecord);
3390
+ const ruleMatchers = ruleRecords.map((ruleRecord) => ({
3391
+ ruleRecord,
3392
+ needle: ruleRecord.id.toLowerCase(),
3393
+ keywords: normalizeRuleTokens(ruleRecord)
3394
+ }));
3395
+ const constrainsByKey = new Map();
3396
+ const implementsByKey = new Map();
3397
+ let fileTokenSetsProcessed = 0;
3398
+ let fileContentRecordsReleased = 0;
3345
3399
 
3346
- for (const fileRecord of fileRecords) {
3347
- const lower = fileRecord.content.toLowerCase();
3400
+ for (const fileRecord of fileRecords) {
3401
+ const lower = String(fileRecord.content ?? "").toLowerCase();
3402
+ const tokens = fileTokenSet(fileRecord);
3403
+ fileTokenSetsProcessed += 1;
3404
+
3405
+ for (const { ruleRecord, needle, keywords } of ruleMatchers) {
3348
3406
  const explicitMention = lower.includes(needle);
3349
- const tokens = tokenByFileId.get(fileRecord.id) ?? new Set();
3350
- const matchedKeywords = ruleKeywords.filter((keyword) => tokens.has(keyword));
3351
3407
  const minimumMatches = fileRecord.kind === "CODE" ? 1 : 2;
3352
- const keywordMatch = matchedKeywords.length >= Math.min(minimumMatches, Math.max(1, ruleKeywords.length));
3408
+ const keywordResult = explicitMention
3409
+ ? { matched: false, sample: [] }
3410
+ : collectRuleKeywordMatch(keywords, tokens, minimumMatches);
3353
3411
 
3354
- if (!explicitMention && !keywordMatch) {
3412
+ if (!explicitMention && !keywordResult.matched) {
3355
3413
  continue;
3356
3414
  }
3357
3415
 
3358
3416
  const constrainsKey = `${ruleRecord.id}|${fileRecord.id}`;
3359
- if (!constrainsSeen.has(constrainsKey)) {
3360
- constrainsSeen.add(constrainsKey);
3361
- constrainsRelations.push({
3417
+ if (!constrainsByKey.has(constrainsKey)) {
3418
+ constrainsByKey.set(constrainsKey, {
3362
3419
  from: ruleRecord.id,
3363
3420
  to: fileRecord.id,
3364
3421
  note: explicitMention
3365
3422
  ? `Mentions ${ruleRecord.id}`
3366
- : `Keyword match ${matchedKeywords.slice(0, 5).join(", ")}`
3423
+ : `Keyword match ${keywordResult.sample.join(", ")}`
3367
3424
  });
3368
3425
  }
3369
3426
 
3370
3427
  if (fileRecord.kind === "CODE") {
3371
3428
  const implementsKey = `${fileRecord.id}|${ruleRecord.id}`;
3372
- if (!implementsSeen.has(implementsKey)) {
3373
- implementsSeen.add(implementsKey);
3374
- implementsRelations.push({
3429
+ if (!implementsByKey.has(implementsKey)) {
3430
+ implementsByKey.set(implementsKey, {
3375
3431
  from: fileRecord.id,
3376
3432
  to: ruleRecord.id,
3377
3433
  note: explicitMention
3378
3434
  ? `Code references ${ruleRecord.id}`
3379
- : `Code keywords ${matchedKeywords.slice(0, 5).join(", ")}`
3435
+ : `Code keywords ${keywordResult.sample.join(", ")}`
3380
3436
  });
3381
3437
  }
3382
3438
  }
3383
3439
  }
3440
+
3441
+ if (Object.prototype.hasOwnProperty.call(fileRecord, "content")) {
3442
+ delete fileRecord.content;
3443
+ fileContentRecordsReleased += 1;
3444
+ }
3445
+ }
3446
+
3447
+ for (const { ruleRecord } of ruleMatchers) {
3448
+ for (const fileRecord of fileRecords) {
3449
+ const constrainsKey = `${ruleRecord.id}|${fileRecord.id}`;
3450
+ const constrainsRelation = constrainsByKey.get(constrainsKey);
3451
+ if (constrainsRelation) {
3452
+ constrainsRelations.push(constrainsRelation);
3453
+ constrainsByKey.delete(constrainsKey);
3454
+ }
3455
+ if (fileRecord.kind === "CODE") {
3456
+ const implementsKey = `${fileRecord.id}|${ruleRecord.id}`;
3457
+ const implementsRelation = implementsByKey.get(implementsKey);
3458
+ if (implementsRelation) {
3459
+ implementsRelations.push(implementsRelation);
3460
+ implementsByKey.delete(implementsKey);
3461
+ }
3462
+ }
3463
+ }
3384
3464
  }
3465
+
3385
3466
  memoryTrace.checkpoint("tokens:rule_matching_complete", {
3386
- file_token_sets: tokenByFileId.size,
3467
+ file_token_sets: fileTokenSetsProcessed,
3468
+ file_token_sets_retained: 0,
3469
+ file_content_records_released: fileContentRecordsReleased,
3470
+ file_content_records_retained: countFileContentRecords(fileRecords),
3387
3471
  rules: ruleRecords.length,
3388
3472
  relations_constrains: constrainsRelations.length,
3389
3473
  relations_implements: implementsRelations.length,
@@ -3396,8 +3480,8 @@ async function main() {
3396
3480
  rules: ruleRecords.length,
3397
3481
  chunks: chunkRecords.length
3398
3482
  });
3399
- writeJsonl(path.join(CACHE_DIR, "documents.jsonl"), fileRecords);
3400
- writeJsonl(path.join(CACHE_DIR, "entities.file.jsonl"), fileRecords);
3483
+ stagedDocumentCache.commit();
3484
+ stagedFileEntityCache.commit();
3401
3485
  writeJsonl(path.join(CACHE_DIR, "entities.adr.jsonl"), adrRecords);
3402
3486
  writeJsonl(path.join(CACHE_DIR, "entities.rule.jsonl"), ruleRecords);
3403
3487
  writeJsonl(path.join(CACHE_DIR, "entities.chunk.jsonl"), chunkRecords);
@@ -3788,5 +3872,6 @@ export {
3788
3872
  generateSectionHandlerRelations,
3789
3873
  getChunkParserForExtension,
3790
3874
  parseFilesInWorkers,
3875
+ resolveIngestWorkerCount,
3791
3876
  resolveRelativeImportTargetId
3792
3877
  };