@danielblomma/cortex-mcp 2.2.5 → 2.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.
@@ -4,6 +4,7 @@ import { readFileSync, statSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { z } from "zod";
6
6
  import { walkProjectFiles } from "./walk.js";
7
+ import { resolveChangedReviewFiles } from "../reviews/changed-files.js";
7
8
  import type { EnterpriseConfig } from "../../core/config.js";
8
9
  import type { TelemetryCollector } from "../../core/telemetry/collector.js";
9
10
  import type { AuditWriter } from "../../core/audit/writer.js";
@@ -16,6 +17,7 @@ import { queueViolation } from "../violations/push.js";
16
17
  import { getReviewPushContext, queueReviewResult } from "../reviews/push.js";
17
18
  import { partitionReviewPolicies } from "../reviews/policy-selection.js";
18
19
  import { recordQueuedReviewTrustState } from "../reviews/trust-state.js";
20
+ import { buildPatternReviewContext } from "../reviews/pattern-context.js";
19
21
  import { pushWorkflowSnapshot } from "../workflow/push.js";
20
22
  import { OUTBOUND_DATA_BOUNDARY } from "../privacy/boundary.js";
21
23
  import {
@@ -41,6 +43,25 @@ type ToolPayload = Record<string, unknown>;
41
43
 
42
44
  const VALID_ROLES = new Set<Role>(["admin", "developer", "readonly"]);
43
45
 
46
+ export function buildContextReviewAuditInput(input: {
47
+ scope: "all" | "changed";
48
+ include_passed: boolean;
49
+ include_pattern_evidence: boolean;
50
+ pattern_query?: string;
51
+ pattern_top_k: number;
52
+ pattern_limit: number;
53
+ }): Record<string, unknown> {
54
+ return {
55
+ scope: input.scope,
56
+ include_passed: input.include_passed,
57
+ include_pattern_evidence: input.include_pattern_evidence,
58
+ pattern_query_present: Boolean(input.pattern_query),
59
+ pattern_query_length: input.pattern_query?.length ?? 0,
60
+ pattern_top_k: input.pattern_top_k,
61
+ pattern_limit: input.pattern_limit,
62
+ };
63
+ }
64
+
44
65
  function snapshotReviewedFiles(
45
66
  projectRoot: string,
46
67
  changedFiles: string[] | undefined,
@@ -828,12 +849,20 @@ export function registerEnterpriseTools(
828
849
  description:
829
850
  "Run enterprise policy validators against the current project. " +
830
851
  "Checks enforced policies (test coverage, file size, external API calls, code review) " +
831
- "and returns pass/fail results with actionable details.",
852
+ "and returns pass/fail results plus non-blocking repo-local pattern evidence.",
832
853
  inputSchema: z.object({
833
854
  scope: z.enum(["all", "changed"]).default("changed")
834
855
  .describe("'changed' validates only git-modified files; 'all' validates everything"),
835
856
  include_passed: z.boolean().default(true)
836
857
  .describe("Include passing validators in results"),
858
+ include_pattern_evidence: z.boolean().default(true)
859
+ .describe("Include bounded, local-only repo pattern context without changing pass/fail"),
860
+ pattern_query: z.string().trim().min(1).max(1000).optional()
861
+ .describe("Optional shared pattern query; omitted queries are derived per target"),
862
+ pattern_top_k: z.number().int().min(1).max(5).default(2)
863
+ .describe("Maximum evidence items returned per locality tier and target"),
864
+ pattern_limit: z.number().int().min(1).max(25).default(10)
865
+ .describe("Maximum review targets analyzed for pattern evidence"),
837
866
  }),
838
867
  },
839
868
  async (input) => {
@@ -844,6 +873,10 @@ export function registerEnterpriseTools(
844
873
  const parsed = z.object({
845
874
  scope: z.enum(["all", "changed"]).default("changed"),
846
875
  include_passed: z.boolean().default(true),
876
+ include_pattern_evidence: z.boolean().default(true),
877
+ pattern_query: z.string().trim().min(1).max(1000).optional(),
878
+ pattern_top_k: z.number().int().min(1).max(5).default(2),
879
+ pattern_limit: z.number().int().min(1).max(25).default(10),
847
880
  }).parse(input ?? {});
848
881
 
849
882
  const projectRoot = process.env.CORTEX_PROJECT_ROOT?.trim() || process.cwd();
@@ -859,40 +892,7 @@ export function registerEnterpriseTools(
859
892
  // project review; no git dependency.
860
893
  let changedFiles: string[] | undefined;
861
894
  if (parsed.scope === "changed") {
862
- // Use `git rev-parse --is-inside-work-tree` to distinguish
863
- // "valid repo, nothing changed" (→ empty list, reviewer sees
864
- // nothing to scan) from "not a repo / git broken" (→ walk the
865
- // project so scope=changed still returns meaningful results
866
- // when run outside a git checkout).
867
- let inGitRepo = false;
868
- try {
869
- const { execSync } = await import("node:child_process");
870
- execSync("git rev-parse --is-inside-work-tree", {
871
- cwd: projectRoot,
872
- encoding: "utf8",
873
- timeout: 3000,
874
- stdio: ["ignore", "pipe", "ignore"],
875
- });
876
- inGitRepo = true;
877
- } catch {
878
- inGitRepo = false;
879
- }
880
-
881
- if (inGitRepo) {
882
- try {
883
- const { execSync } = await import("node:child_process");
884
- const output = execSync("git diff --name-only HEAD 2>/dev/null || git diff --name-only", {
885
- cwd: projectRoot,
886
- encoding: "utf8",
887
- timeout: 5000,
888
- });
889
- changedFiles = output.split("\n").map((f) => f.trim()).filter(Boolean);
890
- } catch {
891
- changedFiles = [];
892
- }
893
- } else {
894
- changedFiles = walkProjectFiles(projectRoot);
895
- }
895
+ changedFiles = resolveChangedReviewFiles(projectRoot) ?? walkProjectFiles(projectRoot);
896
896
  } else {
897
897
  changedFiles = walkProjectFiles(projectRoot);
898
898
  }
@@ -914,6 +914,14 @@ export function registerEnterpriseTools(
914
914
  projectRoot,
915
915
  changedFiles,
916
916
  }, config.validators);
917
+ const patternReview = await buildPatternReviewContext({
918
+ files: changedFiles ?? [],
919
+ enabled: parsed.include_pattern_evidence,
920
+ query: parsed.pattern_query,
921
+ topK: parsed.pattern_top_k,
922
+ limit: parsed.pattern_limit,
923
+ });
924
+ const patternSummary = patternReview.summary as Record<string, unknown>;
917
925
 
918
926
  // Filter out passed if requested
919
927
  const results = parsed.include_passed
@@ -944,7 +952,7 @@ export function registerEnterpriseTools(
944
952
  recordToolActivity({
945
953
  timestamp: now,
946
954
  tool: "context.review",
947
- input: parsed as Record<string, unknown>,
955
+ input: buildContextReviewAuditInput(parsed),
948
956
  result_count: output.results.length,
949
957
  entities_returned: output.results.map((r) => r.policy_id),
950
958
  rules_applied: output.results.filter((r) => !r.pass).map((r) => r.policy_id),
@@ -958,6 +966,10 @@ export function registerEnterpriseTools(
958
966
  failed: output.summary.failed,
959
967
  warnings: output.results.filter((r) => !r.pass && r.severity === "warning").length,
960
968
  skipped: skippedPolicies.length,
969
+ pattern_analyzed: patternSummary.analyzed ?? 0,
970
+ pattern_local_evidence: patternSummary.local_evidence ?? 0,
971
+ pattern_repo_fallback: patternSummary.repo_fallback ?? 0,
972
+ pattern_omitted: patternSummary.omitted ?? 0,
961
973
  },
962
974
  });
963
975
 
@@ -1018,6 +1030,7 @@ export function registerEnterpriseTools(
1018
1030
  workflow_source: trustState.workflow.source,
1019
1031
  delivery: trustState.delivery,
1020
1032
  trust_warnings: trustState.trust_warnings,
1033
+ pattern_review: patternReview,
1021
1034
  });
1022
1035
  },
1023
1036
  );
@@ -98,12 +98,10 @@ export const PATHS = {
98
98
  transformsConfigRelations: path.join(CACHE_DIR, "relations.transforms_config.jsonl")
99
99
  };
100
100
 
101
- // Graph degree mostly measures how many rules constrain an entity (docs are
102
- // hubs, leaf code is not), so it gets low weight. Tuned together with the
103
- // midrank-percentile graph_score in searchResults.ts.
101
+ // Tuned together with the midrank-percentile graph_score in searchResults.ts.
104
102
  export const DEFAULT_RANKING: RankingWeights = {
105
- semantic: 0.55,
106
- graph: 0.1,
103
+ semantic: 0.4,
104
+ graph: 0.25,
107
105
  trust: 0.2,
108
106
  recency: 0.15
109
107
  };
@@ -0,0 +1,347 @@
1
+ import { loadContextData } from "./graph.js";
2
+ import { iterateSearchEntities } from "./contextEntities.js";
3
+ import { embedQuery, loadEmbeddingIndex } from "./embeddings.js";
4
+ import { runContextSearch } from "./search.js";
5
+ import type { ChunkRecord, ContextData, PatternEvidenceParams, ToolPayload } from "./types.js";
6
+
7
+ type SearchResult = Record<string, unknown>;
8
+
9
+ type PatternTarget = {
10
+ input: string;
11
+ entity_id: string;
12
+ entity_type: "File" | "Chunk" | "ADR";
13
+ path: string;
14
+ };
15
+
16
+ type PatternEvidence = {
17
+ id: string;
18
+ entity_type: string;
19
+ kind: string;
20
+ title: string;
21
+ path: string;
22
+ start_line?: number;
23
+ end_line?: number;
24
+ excerpt: string;
25
+ score?: number;
26
+ matched_rules?: unknown[];
27
+ };
28
+
29
+ export type PatternEvidenceTierName =
30
+ | "same_file"
31
+ | "same_module"
32
+ | "same_feature_area"
33
+ | "repo_wide";
34
+
35
+ type PatternEvidenceTier = {
36
+ name: PatternEvidenceTierName;
37
+ scope: string;
38
+ evidence: PatternEvidence[];
39
+ };
40
+
41
+ const EVIDENCE_TIERS: Array<{ name: PatternEvidenceTierName; scope: string }> = [
42
+ { name: "same_file", scope: "Same file as the review target." },
43
+ { name: "same_module", scope: "Same directory or module as the review target." },
44
+ { name: "same_feature_area", scope: "Same parent feature area as the review target." },
45
+ { name: "repo_wide", scope: "Repository-wide fallback evidence." },
46
+ ];
47
+
48
+ export function normalizeRepoPath(value: string): string {
49
+ const normalized = value.trim().replace(/\\/g, "/").replace(/^\.\//, "");
50
+ return normalized.replace(/\/{2,}/g, "/").replace(/\/$/, "");
51
+ }
52
+
53
+ function dirnameRepoPath(value: string): string {
54
+ const normalized = normalizeRepoPath(value);
55
+ const separator = normalized.lastIndexOf("/");
56
+ return separator === -1 ? "." : normalized.slice(0, separator) || ".";
57
+ }
58
+
59
+ function parentRepoPath(value: string): string | null {
60
+ const normalized = normalizeRepoPath(value);
61
+ if (!normalized || normalized === ".") {
62
+ return null;
63
+ }
64
+ const separator = normalized.lastIndexOf("/");
65
+ return separator === -1 ? "." : normalized.slice(0, separator) || ".";
66
+ }
67
+
68
+ function isWithinPath(candidate: string, directory: string | null): boolean {
69
+ if (!directory || directory === ".") {
70
+ return false;
71
+ }
72
+ return candidate === directory || candidate.startsWith(`${directory}/`);
73
+ }
74
+
75
+ function resolvePatternTarget(data: ContextData, input: string): PatternTarget {
76
+ const normalizedInput = normalizeRepoPath(input);
77
+ const document = data.documents.find(
78
+ (entry) => entry.id === input || normalizeRepoPath(entry.path) === normalizedInput,
79
+ );
80
+ if (document) {
81
+ return {
82
+ input,
83
+ entity_id: document.id,
84
+ entity_type: document.kind === "ADR" ? "ADR" : "File",
85
+ path: normalizeRepoPath(document.path),
86
+ };
87
+ }
88
+
89
+ const chunk = data.chunks.find((entry) => entry.id === input);
90
+ if (chunk) {
91
+ const owner = data.documents.find((entry) => entry.id === chunk.file_id);
92
+ if (!owner) {
93
+ throw new Error(`Pattern target chunk has no indexed owner file: ${input}`);
94
+ }
95
+ return {
96
+ input,
97
+ entity_id: chunk.id,
98
+ entity_type: "Chunk",
99
+ path: normalizeRepoPath(owner.path),
100
+ };
101
+ }
102
+
103
+ const adr = data.adrs.find((entry) => entry.id === input);
104
+ if (adr?.path) {
105
+ return {
106
+ input,
107
+ entity_id: adr.id,
108
+ entity_type: "ADR",
109
+ path: normalizeRepoPath(adr.path),
110
+ };
111
+ }
112
+
113
+ const knownEntity = [
114
+ ...data.rules.map((entry) => entry.id),
115
+ ...data.modules.map((entry) => entry.id),
116
+ ...data.projects.map((entry) => entry.id),
117
+ ].includes(input);
118
+ if (knownEntity) {
119
+ throw new Error(`Pattern target is not file-backed: ${input}`);
120
+ }
121
+ throw new Error(`Pattern target was not found in indexed context: ${input}`);
122
+ }
123
+
124
+ function derivePatternQuery(data: ContextData, target: PatternTarget): string {
125
+ if (target.entity_type === "Chunk") {
126
+ const chunk = data.chunks.find((entry) => entry.id === target.entity_id);
127
+ if (chunk) {
128
+ return [chunk.name, chunk.kind, chunk.signature].filter(Boolean).join(" ");
129
+ }
130
+ }
131
+
132
+ const document = data.documents.find((entry) => entry.id === target.entity_id);
133
+ const chunkSignals = data.chunks
134
+ .filter((entry) => entry.file_id === target.entity_id && !entry.id.includes(":window:"))
135
+ .slice(0, 12)
136
+ .flatMap((entry) => [entry.name, entry.kind])
137
+ .filter(Boolean);
138
+ const basename = target.path.split("/").at(-1)?.replace(/\.[^.]+$/, "") ?? target.path;
139
+ return [basename, ...chunkSignals, document?.excerpt ?? ""].filter(Boolean).join(" ").slice(0, 1000);
140
+ }
141
+
142
+ function tierForPath(targetPath: string, candidatePath: string): PatternEvidenceTierName {
143
+ const normalizedTarget = normalizeRepoPath(targetPath);
144
+ const normalizedCandidate = normalizeRepoPath(candidatePath);
145
+ if (normalizedCandidate === normalizedTarget) {
146
+ return "same_file";
147
+ }
148
+
149
+ const targetModule = dirnameRepoPath(normalizedTarget);
150
+ if (dirnameRepoPath(normalizedCandidate) === targetModule) {
151
+ return "same_module";
152
+ }
153
+
154
+ const featureArea = parentRepoPath(targetModule);
155
+ if (isWithinPath(normalizedCandidate, featureArea)) {
156
+ return "same_feature_area";
157
+ }
158
+ return "repo_wide";
159
+ }
160
+
161
+ function toPatternEvidence(result: SearchResult, chunksById: Map<string, ChunkRecord>): PatternEvidence | null {
162
+ const id = typeof result.id === "string" ? result.id : "";
163
+ const candidatePath = typeof result.path === "string" ? normalizeRepoPath(result.path) : "";
164
+ if (!id || !candidatePath) {
165
+ return null;
166
+ }
167
+
168
+ const chunk = chunksById.get(id);
169
+ const entityType = typeof result.entity_type === "string" ? result.entity_type : "";
170
+ if (entityType === "Chunk" && (!chunk || chunk.start_line <= 0 || chunk.end_line < chunk.start_line)) {
171
+ return null;
172
+ }
173
+ const evidence: PatternEvidence = {
174
+ id,
175
+ entity_type: entityType,
176
+ kind: typeof result.kind === "string" ? result.kind : "",
177
+ title: typeof result.title === "string" ? result.title : id,
178
+ path: candidatePath,
179
+ excerpt: typeof result.excerpt === "string" ? result.excerpt : "",
180
+ };
181
+ if (chunk) {
182
+ evidence.start_line = chunk.start_line;
183
+ evidence.end_line = chunk.end_line;
184
+ }
185
+ if (typeof result.score === "number") {
186
+ evidence.score = result.score;
187
+ }
188
+ if (Array.isArray(result.matched_rules)) {
189
+ evidence.matched_rules = [...new Set(result.matched_rules)];
190
+ }
191
+ return evidence;
192
+ }
193
+
194
+ const referenceTimeCache = new WeakMap<ContextData, number>();
195
+
196
+ export function contextReferenceTimeMs(data: ContextData): number {
197
+ const cached = referenceTimeCache.get(data);
198
+ if (cached !== undefined) {
199
+ return cached;
200
+ }
201
+ let latest = 0;
202
+ const consider = (value: string): void => {
203
+ const timestamp = Date.parse(value);
204
+ if (Number.isFinite(timestamp) && timestamp > latest) {
205
+ latest = timestamp;
206
+ }
207
+ };
208
+ for (const entry of data.documents) consider(entry.updated_at);
209
+ for (const entry of data.rules) consider(entry.updated_at);
210
+ for (const entry of data.adrs) consider(entry.decision_date);
211
+ for (const entry of data.chunks) consider(entry.updated_at);
212
+ for (const entry of data.modules) consider(entry.updated_at);
213
+ for (const entry of data.projects) consider(entry.updated_at);
214
+ referenceTimeCache.set(data, latest);
215
+ return latest;
216
+ }
217
+
218
+ export function classifyPatternEvidence(input: {
219
+ target: PatternTarget;
220
+ results: SearchResult[];
221
+ chunks: ChunkRecord[];
222
+ topK: number;
223
+ }): { tiers: PatternEvidenceTier[]; localPatternFound: boolean; fallbackUsed: boolean } {
224
+ const chunksById = new Map(input.chunks.map((chunk) => [chunk.id, chunk]));
225
+ const evidenceByTier = new Map<PatternEvidenceTierName, PatternEvidence[]>(
226
+ EVIDENCE_TIERS.map((tier) => [tier.name, []]),
227
+ );
228
+ const seen = new Set<string>();
229
+
230
+ for (const result of input.results) {
231
+ if (result.id === input.target.entity_id) {
232
+ continue;
233
+ }
234
+ const evidence = toPatternEvidence(result, chunksById);
235
+ if (!evidence || seen.has(evidence.id)) {
236
+ continue;
237
+ }
238
+ seen.add(evidence.id);
239
+ const tierName = tierForPath(input.target.path, evidence.path);
240
+ const tierEvidence = evidenceByTier.get(tierName);
241
+ if (tierEvidence && tierEvidence.length < input.topK) {
242
+ tierEvidence.push(evidence);
243
+ }
244
+ }
245
+
246
+ const tiers = EVIDENCE_TIERS.map((tier) => ({
247
+ ...tier,
248
+ evidence: evidenceByTier.get(tier.name) ?? [],
249
+ }));
250
+ const localPatternFound = tiers.slice(0, 3).some((tier) => tier.evidence.length > 0);
251
+ const fallbackUsed = !localPatternFound && tiers[3].evidence.length > 0;
252
+ return { tiers, localPatternFound, fallbackUsed };
253
+ }
254
+
255
+ export async function runPatternEvidence(
256
+ parsed: PatternEvidenceParams,
257
+ options: { data?: ContextData; use_embeddings?: boolean } = {}
258
+ ): Promise<ToolPayload> {
259
+ const data = options.data ?? await loadContextData();
260
+ const target = resolvePatternTarget(data, parsed.target);
261
+ const explicitQuery = parsed.query?.trim();
262
+ const query = explicitQuery || derivePatternQuery(data, target);
263
+ if (!query) {
264
+ throw new Error(`Could not derive a pattern query for target: ${parsed.target}`);
265
+ }
266
+
267
+ const referenceTimeMs = contextReferenceTimeMs(data);
268
+ const embeddingIndex = options.use_embeddings === false
269
+ ? { model: null, vectors: new Map<string, Float32Array>() }
270
+ : loadEmbeddingIndex();
271
+ const queryVector = embeddingIndex.model && embeddingIndex.vectors.size > 0
272
+ ? await embedQuery(query, embeddingIndex.model)
273
+ : null;
274
+ const tierByEntityId = new Map<string, PatternEvidenceTierName>();
275
+ for (const entity of iterateSearchEntities(data, false)) {
276
+ if (
277
+ entity.id !== target.entity_id &&
278
+ entity.path &&
279
+ (entity.entity_type === "File" || entity.entity_type === "Chunk" || entity.entity_type === "ADR")
280
+ ) {
281
+ tierByEntityId.set(entity.id, tierForPath(target.path, entity.path));
282
+ }
283
+ }
284
+
285
+ const searchResults: SearchResult[] = [];
286
+ const warningParts: string[] = [];
287
+ let contextSource: unknown = data.source;
288
+ let semanticEngine: unknown;
289
+ for (const tier of EVIDENCE_TIERS) {
290
+ const search = await runContextSearch(
291
+ {
292
+ query,
293
+ top_k: parsed.top_k,
294
+ include_deprecated: parsed.include_deprecated ?? false,
295
+ response_preset: "full",
296
+ include_scores: true,
297
+ include_matched_rules: true,
298
+ },
299
+ {
300
+ data,
301
+ reference_time_ms: referenceTimeMs,
302
+ embedding_index: embeddingIndex,
303
+ query_vector: queryVector,
304
+ candidate_filter: (entity) => tierByEntityId.get(entity.id) === tier.name,
305
+ },
306
+ );
307
+ if (Array.isArray(search.results)) {
308
+ searchResults.push(...search.results as SearchResult[]);
309
+ }
310
+ if (typeof search.warning === "string" && !warningParts.includes(search.warning)) {
311
+ warningParts.push(search.warning);
312
+ }
313
+ contextSource = search.context_source ?? contextSource;
314
+ semanticEngine = search.semantic_engine ?? semanticEngine;
315
+ }
316
+ const classified = classifyPatternEvidence({
317
+ target,
318
+ results: searchResults,
319
+ chunks: data.chunks,
320
+ topK: parsed.top_k,
321
+ });
322
+ if (!classified.localPatternFound) {
323
+ warningParts.push("No applicable file-local, module-local, or feature-local pattern evidence was found.");
324
+ }
325
+
326
+ return {
327
+ target,
328
+ query,
329
+ query_source: explicitQuery ? "explicit" : "derived_from_target",
330
+ evidence_order: EVIDENCE_TIERS.map((tier) => tier.name),
331
+ top_k_per_tier: parsed.top_k,
332
+ ranking_reference_time: referenceTimeMs > 0 ? new Date(referenceTimeMs).toISOString() : null,
333
+ local_pattern_found: classified.localPatternFound,
334
+ fallback_used: classified.fallbackUsed,
335
+ tiers: classified.tiers,
336
+ context_source: contextSource,
337
+ semantic_engine: semanticEngine,
338
+ warning: warningParts.length > 0 ? warningParts.join(" | ") : undefined,
339
+ };
340
+ }
341
+
342
+ export async function runLocalPatternEvidence(
343
+ parsed: PatternEvidenceParams,
344
+ options: { data?: ContextData } = {}
345
+ ): Promise<ToolPayload> {
346
+ return runPatternEvidence(parsed, { ...options, use_embeddings: false });
347
+ }
@@ -17,6 +17,7 @@ import {
17
17
  normalizeText,
18
18
  recencyScore,
19
19
  semanticScore,
20
+ structuralSearchBoost,
20
21
  tokenize
21
22
  } from "./searchCore.js";
22
23
  import { buildSearchResultsWithStats } from "./searchResults.js";
@@ -36,6 +37,8 @@ import {
36
37
  resolveSearchResponsePreset
37
38
  } from "./presets.js";
38
39
  import type {
40
+ ContextData,
41
+ EmbeddingIndex,
39
42
  ImpactParams,
40
43
  RelatedParams,
41
44
  RelationType,
@@ -58,33 +61,53 @@ const IMPACT_RELATION_TYPES = new Set([
58
61
  "PART_OF"
59
62
  ]);
60
63
 
64
+ export type ContextSearchOptions = {
65
+ data?: ContextData;
66
+ candidate_filter?: (entity: SearchEntity) => boolean;
67
+ reference_time_ms?: number;
68
+ embedding_index?: EmbeddingIndex;
69
+ query_vector?: Float32Array | null;
70
+ };
71
+
61
72
  function* filterSearchCandidates(
62
73
  candidates: Iterable<SearchEntity>,
63
- includeDeprecated: boolean
74
+ includeDeprecated: boolean,
75
+ candidateFilter?: (entity: SearchEntity) => boolean
64
76
  ): Generator<SearchEntity> {
65
77
  for (const entity of candidates) {
66
- if (includeDeprecated || entity.status.toLowerCase() !== "deprecated") {
78
+ if (
79
+ (includeDeprecated || entity.status.toLowerCase() !== "deprecated") &&
80
+ (!candidateFilter || candidateFilter(entity))
81
+ ) {
67
82
  yield entity;
68
83
  }
69
84
  }
70
85
  }
71
86
 
72
- export async function runContextSearch(parsed: SearchParams): Promise<ToolPayload> {
87
+ export async function runContextSearch(
88
+ parsed: SearchParams,
89
+ options: ContextSearchOptions = {}
90
+ ): Promise<ToolPayload> {
73
91
  const searchPresetConfig = resolveSearchResponsePreset(parsed);
74
92
  const responsePreset = searchPresetConfig.responsePreset;
75
93
  const includeScores = searchPresetConfig.includeScores;
76
94
  const includeMatchedRules = searchPresetConfig.includeMatchedRules;
77
95
  const includeContent = searchPresetConfig.includeContent;
78
- const data = await loadContextData();
96
+ const data = options.data ?? await loadContextData();
79
97
  const allRelations = [...data.relations, ...buildChunkPartOfRelations(data)];
80
98
  const degreeByEntity = relationDegree(allRelations);
81
99
  const queryTokens = expandQueryTokens(Array.from(new Set(tokenize(parsed.query))));
82
100
  const queryPhrase = normalizeText(parsed.query).trim();
83
101
  const candidates = () =>
84
- filterSearchCandidates(iterateSearchEntities(data, includeContent), parsed.include_deprecated);
85
- const embeddings = loadEmbeddingIndex();
86
- const queryVector =
87
- embeddings.model && embeddings.vectors.size > 0
102
+ filterSearchCandidates(
103
+ iterateSearchEntities(data, includeContent),
104
+ parsed.include_deprecated,
105
+ options.candidate_filter
106
+ );
107
+ const embeddings = options.embedding_index ?? loadEmbeddingIndex();
108
+ const queryVector = Object.prototype.hasOwnProperty.call(options, "query_vector")
109
+ ? options.query_vector ?? null
110
+ : embeddings.model && embeddings.vectors.size > 0
88
111
  ? await embedQuery(parsed.query, embeddings.model)
89
112
  : null;
90
113
 
@@ -104,7 +127,10 @@ export async function runContextSearch(parsed: SearchParams): Promise<ToolPayloa
104
127
  minVectorRelevance: MIN_VECTOR_RELEVANCE,
105
128
  semanticScorer: semanticScore,
106
129
  vectorScorer: cosineSimilarity,
107
- recencyScorer: recencyScore,
130
+ recencyScorer: options.reference_time_ms === undefined
131
+ ? recencyScore
132
+ : (isoDate) => recencyScore(isoDate, options.reference_time_ms),
133
+ structuralSearchBooster: structuralSearchBoost,
108
134
  legacyDataAccessBooster: legacyDataAccessBoost
109
135
  });
110
136