@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.
@@ -31,6 +31,31 @@ const MODEL_CACHE_DIR = PATHS.embeddingsModelCache;
31
31
  const EMBEDDINGS_DIR = path.dirname(EMBEDDINGS_PATH);
32
32
 
33
33
  export const DEFAULT_MODEL_ID = "jinaai/jina-embeddings-v2-base-code";
34
+ export const COMPACT_FILE_TEXT_STRATEGY = "compact_files_v1";
35
+ export const COMPACT_FILE_TEXT_THRESHOLD_CHARS = 32768;
36
+ export const COMPACT_FILE_TEXT_TARGET_CHARS = 16000;
37
+
38
+ const COMPACT_FILE_SIGNAL_BUDGET_CHARS = 4096;
39
+ const COMPACT_FILE_SIGNAL_MAX_LINE_CHARS = 512;
40
+ const COMPACT_FILE_MIN_HEAD_CHARS = 4096;
41
+ const COMPACT_FILE_MIN_TAIL_CHARS = 2048;
42
+
43
+ export type EmbedTextProfile = "full" | "compact-files";
44
+
45
+ type FileEmbeddingTextResult = {
46
+ text: string;
47
+ profile: EmbedTextProfile;
48
+ compacted: boolean;
49
+ original_chars: number;
50
+ text_chars: number;
51
+ omitted_chars: number;
52
+ };
53
+
54
+ type ParseFileEntitiesOptions = {
55
+ textProfile?: EmbedTextProfile;
56
+ };
57
+
58
+ type SignatureEntityType = "File" | "Rule" | "ADR" | "Module" | "Project" | "Chunk";
34
59
 
35
60
  export function resolveModelId(): string {
36
61
  return (process.env.CORTEX_EMBED_MODEL ?? DEFAULT_MODEL_ID).trim() || DEFAULT_MODEL_ID;
@@ -48,6 +73,11 @@ type FileEntity = {
48
73
  updated_at: string;
49
74
  text: string;
50
75
  signature: string;
76
+ text_profile: EmbedTextProfile;
77
+ text_compacted: boolean;
78
+ text_original_chars: number;
79
+ text_chars: number;
80
+ text_omitted_chars: number;
51
81
  };
52
82
 
53
83
  type RuleEntity = {
@@ -155,6 +185,129 @@ function normalizeText(value: string): string {
155
185
  return value.replace(/\s+/g, " ").trim();
156
186
  }
157
187
 
188
+ export function resolveEmbedTextProfile(raw = process.env.CORTEX_EMBED_TEXT_PROFILE): EmbedTextProfile {
189
+ const value = (raw ?? "").trim().toLowerCase();
190
+ if (!value || value === "full") {
191
+ return "full";
192
+ }
193
+ if (value === "compact-files") {
194
+ return "compact-files";
195
+ }
196
+ throw new Error(
197
+ `Unsupported CORTEX_EMBED_TEXT_PROFILE=${JSON.stringify(raw)}; expected "full" or "compact-files"`
198
+ );
199
+ }
200
+
201
+ function isSignalLine(line: string): boolean {
202
+ const trimmed = line.trim();
203
+ if (!trimmed) {
204
+ return false;
205
+ }
206
+ return (
207
+ /^(import|export)\b/.test(trimmed) ||
208
+ /^(abstract\s+|async\s+|public\s+|private\s+|protected\s+|static\s+|readonly\s+|override\s+)*(class|interface|type|enum|function)\b/.test(trimmed) ||
209
+ /^(const|let|var)\s+[$A-Z_a-z][$\w]*\s*=/.test(trimmed) ||
210
+ /^(describe|it|test)\s*\(/.test(trimmed) ||
211
+ /^(@[A-Z_a-z][$\w]*|#[#\s])/.test(trimmed) ||
212
+ /^```[A-Za-z0-9_-]+/.test(trimmed) ||
213
+ /\b(route|router|endpoint|controller|handler|middleware|permission|auth|token|secret|security|todo|fixme)\b/i.test(trimmed)
214
+ );
215
+ }
216
+
217
+ function collectSignalLines(content: string, budgetChars: number): string {
218
+ const lines: string[] = [];
219
+ const seen = new Set<string>();
220
+ let used = 0;
221
+
222
+ for (const line of content.split(/\r?\n/)) {
223
+ if (!isSignalLine(line)) {
224
+ continue;
225
+ }
226
+ const normalized = line.trimEnd();
227
+ const signalLine = normalized.length > COMPACT_FILE_SIGNAL_MAX_LINE_CHARS
228
+ ? `${normalized.slice(0, COMPACT_FILE_SIGNAL_MAX_LINE_CHARS)} [cortex ${COMPACT_FILE_TEXT_STRATEGY} signal_line_truncated_chars=${normalized.length - COMPACT_FILE_SIGNAL_MAX_LINE_CHARS}]`
229
+ : normalized;
230
+ if (seen.has(signalLine)) {
231
+ continue;
232
+ }
233
+ const next = used + signalLine.length + 1;
234
+ if (next > budgetChars) {
235
+ continue;
236
+ }
237
+ lines.push(signalLine);
238
+ seen.add(signalLine);
239
+ used = next;
240
+ }
241
+
242
+ return lines.join("\n");
243
+ }
244
+
245
+ export function buildFileEmbeddingText(
246
+ filePath: string,
247
+ excerpt: string,
248
+ content: string,
249
+ profile: EmbedTextProfile = "full"
250
+ ): FileEmbeddingTextResult {
251
+ const fullText = `${filePath}\n${excerpt}\n${content}`;
252
+ if (profile === "full" || fullText.length <= COMPACT_FILE_TEXT_THRESHOLD_CHARS) {
253
+ return {
254
+ text: fullText,
255
+ profile,
256
+ compacted: false,
257
+ original_chars: fullText.length,
258
+ text_chars: fullText.length,
259
+ omitted_chars: 0
260
+ };
261
+ }
262
+
263
+ const signalText = collectSignalLines(content, COMPACT_FILE_SIGNAL_BUDGET_CHARS);
264
+ const markerPrefix = `[cortex ${COMPACT_FILE_TEXT_STRATEGY} omitted_chars=`;
265
+ const staticChars =
266
+ filePath.length +
267
+ excerpt.length +
268
+ signalText.length +
269
+ markerPrefix.length +
270
+ 64;
271
+ const available = Math.max(
272
+ COMPACT_FILE_MIN_HEAD_CHARS + COMPACT_FILE_MIN_TAIL_CHARS,
273
+ COMPACT_FILE_TEXT_TARGET_CHARS - staticChars
274
+ );
275
+ const headChars = Math.max(COMPACT_FILE_MIN_HEAD_CHARS, Math.floor(available * 0.62));
276
+ const tailChars = Math.max(COMPACT_FILE_MIN_TAIL_CHARS, available - headChars);
277
+ const head = content.slice(0, headChars);
278
+ const tail = content.slice(-tailChars);
279
+ const omittedChars = Math.max(0, content.length - head.length - tail.length);
280
+ const marker = `${markerPrefix}${omittedChars}]`;
281
+ const compactText = [
282
+ filePath,
283
+ excerpt,
284
+ head,
285
+ marker,
286
+ signalText ? `[cortex ${COMPACT_FILE_TEXT_STRATEGY} signal_lines]\n${signalText}` : "",
287
+ tail
288
+ ].filter((part) => part.length > 0).join("\n");
289
+
290
+ if (compactText.length >= fullText.length) {
291
+ return {
292
+ text: fullText,
293
+ profile,
294
+ compacted: false,
295
+ original_chars: fullText.length,
296
+ text_chars: fullText.length,
297
+ omitted_chars: 0
298
+ };
299
+ }
300
+
301
+ return {
302
+ text: compactText,
303
+ profile,
304
+ compacted: true,
305
+ original_chars: fullText.length,
306
+ text_chars: compactText.length,
307
+ omitted_chars: fullText.length - compactText.length
308
+ };
309
+ }
310
+
158
311
  function ensureRequiredFiles(): void {
159
312
  const required = [
160
313
  path.join(CACHE_DIR, "documents.jsonl"),
@@ -169,7 +322,8 @@ function ensureRequiredFiles(): void {
169
322
  }
170
323
  }
171
324
 
172
- export function parseFileEntities(raw: JsonObject[]): FileEntity[] {
325
+ export function parseFileEntities(raw: JsonObject[], options: ParseFileEntitiesOptions = {}): FileEntity[] {
326
+ const textProfile = options.textProfile ?? "full";
173
327
  return raw
174
328
  .map((item) => {
175
329
  const id = asString(item.id);
@@ -182,7 +336,7 @@ export function parseFileEntities(raw: JsonObject[]): FileEntity[] {
182
336
  const excerpt = asString(item.excerpt);
183
337
  const updatedAt = asString(item.updated_at);
184
338
  const checksum = asString(item.checksum, hashText(content));
185
- const text = `${filePath}\n${excerpt}\n${content}`;
339
+ const embeddingText = buildFileEmbeddingText(filePath, excerpt, content, textProfile);
186
340
 
187
341
  return {
188
342
  id,
@@ -194,8 +348,13 @@ export function parseFileEntities(raw: JsonObject[]): FileEntity[] {
194
348
  source_of_truth: asBoolean(item.source_of_truth, false),
195
349
  trust_level: asNumber(item.trust_level, 50),
196
350
  updated_at: updatedAt,
197
- text,
198
- signature: hashText(`file|${checksum}|${updatedAt}|${hashText(text)}`)
351
+ text: embeddingText.text,
352
+ signature: hashText(`file|${checksum}|${updatedAt}|${hashText(embeddingText.text)}`),
353
+ text_profile: embeddingText.profile,
354
+ text_compacted: embeddingText.compacted,
355
+ text_original_chars: embeddingText.original_chars,
356
+ text_chars: embeddingText.text_chars,
357
+ text_omitted_chars: embeddingText.omitted_chars
199
358
  };
200
359
  })
201
360
  .filter((value): value is FileEntity => value !== null);
@@ -408,8 +567,24 @@ function roundVector(values: number[]): number[] {
408
567
  return values.map((value) => Number(value.toFixed(6)));
409
568
  }
410
569
 
411
- function resolveSignatureProfile(maxTokenCap: number | null): string {
412
- return maxTokenCap ? `embed|max_tokens=${maxTokenCap}` : "";
570
+ export function resolveSignatureProfile(
571
+ maxTokenCap: number | null,
572
+ textProfile: EmbedTextProfile = "full",
573
+ entityType?: SignatureEntityType
574
+ ): string {
575
+ const parts: string[] = [];
576
+ if (maxTokenCap) {
577
+ parts.push(`max_tokens=${maxTokenCap}`);
578
+ }
579
+ if (textProfile === "compact-files" && (entityType === undefined || entityType === "File")) {
580
+ parts.push(
581
+ "text_profile=compact-files",
582
+ COMPACT_FILE_TEXT_STRATEGY,
583
+ `threshold_chars=${COMPACT_FILE_TEXT_THRESHOLD_CHARS}`,
584
+ `target_chars=${COMPACT_FILE_TEXT_TARGET_CHARS}`
585
+ );
586
+ }
587
+ return parts.length ? `embed|${parts.join("|")}` : "";
413
588
  }
414
589
 
415
590
  function embeddingSignature(entitySignature: string, profile: string): string {
@@ -434,8 +609,19 @@ async function main(): Promise<void> {
434
609
  fs.mkdirSync(MODEL_CACHE_DIR, { recursive: true });
435
610
 
436
611
  const modelId = resolveModelId();
437
-
438
- const documents = parseFileEntities(readJsonl(path.join(CACHE_DIR, "documents.jsonl")));
612
+ const textProfile = resolveEmbedTextProfile();
613
+
614
+ const documents = parseFileEntities(readJsonl(path.join(CACHE_DIR, "documents.jsonl")), { textProfile });
615
+ const textProfileStats = {
616
+ strategy: textProfile === "compact-files" ? COMPACT_FILE_TEXT_STRATEGY : null,
617
+ threshold_chars: textProfile === "compact-files" ? COMPACT_FILE_TEXT_THRESHOLD_CHARS : null,
618
+ target_chars: textProfile === "compact-files" ? COMPACT_FILE_TEXT_TARGET_CHARS : null,
619
+ file_entities: documents.length,
620
+ compacted_files: documents.filter((doc) => doc.text_compacted).length,
621
+ original_chars: documents.reduce((total, doc) => total + doc.text_original_chars, 0),
622
+ text_chars: documents.reduce((total, doc) => total + doc.text_chars, 0),
623
+ saved_chars: documents.reduce((total, doc) => total + doc.text_omitted_chars, 0)
624
+ };
439
625
  const rules = parseRuleEntities(readJsonl(path.join(CACHE_DIR, "entities.rule.jsonl")));
440
626
  const adrs = parseAdrEntities(readJsonl(path.join(CACHE_DIR, "entities.adr.jsonl")));
441
627
  const modules = parseModuleEntities(readJsonl(path.join(CACHE_DIR, "entities.module.jsonl")));
@@ -483,7 +669,10 @@ async function main(): Promise<void> {
483
669
  memoryBytes: memoryHeadroom,
484
670
  sessions: previewPoolConfig.sessions
485
671
  });
486
- const signatureProfile = resolveSignatureProfile(tokenBudget.cap);
672
+ const defaultSignatureProfile = resolveSignatureProfile(tokenBudget.cap, "full");
673
+ const fileSignatureProfile = resolveSignatureProfile(tokenBudget.cap, textProfile, "File");
674
+ const signatureProfileForEntity = (entity: SearchEntity) =>
675
+ entity.type === "File" ? fileSignatureProfile : defaultSignatureProfile;
487
676
 
488
677
  const existing = parseExistingEmbeddings(readJsonlRecords(EMBEDDINGS_PATH), modelId);
489
678
 
@@ -494,6 +683,7 @@ async function main(): Promise<void> {
494
683
  let dimensions = 0;
495
684
 
496
685
  entities.forEach((entity, index) => {
686
+ const signatureProfile = signatureProfileForEntity(entity);
497
687
  const signature = embeddingSignature(entity.signature, signatureProfile);
498
688
  const previous = existing.get(entity.id);
499
689
  if (previous && previous.signature === signature && previous.vector.length > 0) {
@@ -628,6 +818,7 @@ async function main(): Promise<void> {
628
818
  maxInFlightTokens,
629
819
  onVector(index, rawVector) {
630
820
  const entity = entities[index];
821
+ const signatureProfile = signatureProfileForEntity(entity);
631
822
  const vector = roundVector(rawVector);
632
823
  embedded += 1;
633
824
  dimensions = dimensions || vector.length;
@@ -662,6 +853,13 @@ async function main(): Promise<void> {
662
853
  mode,
663
854
  model: modelId,
664
855
  dimensions,
856
+ text_profile: textProfile,
857
+ signature_profile: fileSignatureProfile === defaultSignatureProfile ? defaultSignatureProfile : "per_entity",
858
+ signature_profiles: {
859
+ default: defaultSignatureProfile,
860
+ file: fileSignatureProfile
861
+ },
862
+ text_profile_stats: textProfileStats,
665
863
  counts: {
666
864
  entities: entities.length,
667
865
  output: outputCount,
@@ -675,7 +873,10 @@ async function main(): Promise<void> {
675
873
  fs.writeFileSync(EMBEDDINGS_MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
676
874
 
677
875
  console.log(
678
- `[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems} max_tokens<=${modelMaxTokensUsed || tokenBudget.cap || "model"} token_budget=${tokenBudget.mode} reason=${tokenBudget.reason}`
876
+ `[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems} max_tokens<=${modelMaxTokensUsed || tokenBudget.cap || "model"} token_budget=${tokenBudget.mode} reason=${tokenBudget.reason} text_profile=${textProfile}`
877
+ );
878
+ console.log(
879
+ `[embed] text_profile=${textProfile} compacted_files=${textProfileStats.compacted_files}/${textProfileStats.file_entities} saved_chars=${textProfileStats.saved_chars}`
679
880
  );
680
881
  console.log(
681
882
  `[embed] entities=${entities.length} embedded=${embedded} reused=${reused} failed=${failed}`
@@ -21,7 +21,7 @@ import { pushWorkflowSnapshot, setWorkflowPushContext } from "./workflow/push.js
21
21
  import { hasWorkflowState, loadWorkflowState } from "./workflow/state.js";
22
22
 
23
23
  const require = createRequire(import.meta.url);
24
- const pkg = require("../package.json") as { version: string };
24
+ const pkg = require("../../package.json") as { version: string };
25
25
 
26
26
  export const name = "cortex-enterprise";
27
27
  export const version: string = pkg.version;
@@ -0,0 +1,34 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ function gitLines(projectRoot: string, args: string[]): string[] {
4
+ const output = execFileSync("git", args, {
5
+ cwd: projectRoot,
6
+ encoding: "utf8",
7
+ timeout: 5000,
8
+ stdio: ["ignore", "pipe", "ignore"],
9
+ });
10
+ return output.split("\n").map((value) => value.trim()).filter(Boolean);
11
+ }
12
+
13
+ function trackedChangedFiles(projectRoot: string): string[] {
14
+ try {
15
+ return gitLines(projectRoot, ["diff", "--name-only", "HEAD"]);
16
+ } catch {
17
+ // No HEAD yet (repo without commits): every tracked file is new.
18
+ return [
19
+ ...gitLines(projectRoot, ["diff", "--name-only"]),
20
+ ...gitLines(projectRoot, ["ls-files"]),
21
+ ];
22
+ }
23
+ }
24
+
25
+ export function resolveChangedReviewFiles(projectRoot: string): string[] | null {
26
+ try {
27
+ gitLines(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
28
+ const tracked = trackedChangedFiles(projectRoot);
29
+ const untracked = gitLines(projectRoot, ["ls-files", "--others", "--exclude-standard"]);
30
+ return [...new Set([...tracked, ...untracked])].sort();
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
@@ -0,0 +1,231 @@
1
+ import { loadContextData } from "../../graph.js";
2
+ import { normalizeRepoPath, runLocalPatternEvidence } from "../../patternEvidence.js";
3
+ import { compareText } from "../../searchResults.js";
4
+ import type { ContextData, PatternEvidenceParams, ToolPayload } from "../../types.js";
5
+
6
+ type PatternEvidenceRunner = (input: PatternEvidenceParams) => Promise<ToolPayload>;
7
+
8
+ // Loads context data lazily on first use and shares it across all targets in
9
+ // one review, instead of re-reading the index from disk per target. Load
10
+ // failures surface per target, matching the per-file error handling below.
11
+ export function createLocalPatternRunner(
12
+ loadData: () => Promise<ContextData> = loadContextData,
13
+ ): PatternEvidenceRunner {
14
+ let shared: Promise<ContextData> | undefined;
15
+ return async (params) => {
16
+ shared ??= loadData();
17
+ return runLocalPatternEvidence(params, { data: await shared });
18
+ };
19
+ }
20
+
21
+ export const PATTERN_REVIEW_QUESTION =
22
+ "Does this change follow the established pattern for this kind of problem in this repository, or does it introduce a second way to solve something that already has a local convention?";
23
+
24
+ const PATTERN_EVIDENCE_ORDER = ["same_file", "same_module", "same_feature_area", "repo_wide"] as const;
25
+
26
+ // Canonicalizes like the pattern evidence engine, then rejects paths that
27
+ // must never appear in review citations (absolute, drive-letter, or
28
+ // parent-escaping paths).
29
+ function normalizeReviewPath(value: string): string | null {
30
+ const normalized = normalizeRepoPath(value);
31
+ if (
32
+ !normalized ||
33
+ normalized.startsWith("/") ||
34
+ /^[A-Za-z]:\//.test(normalized) ||
35
+ normalized.split("/").includes("..")
36
+ ) {
37
+ return null;
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ function safeIdentifier(value: unknown): string | null {
43
+ if (typeof value !== "string" || value.length === 0 || value.length > 1000) return null;
44
+ if (/(?:^|:)\/|[A-Za-z]:[\\/]|(?:^|[:/])\.\.(?:[:/]|$)/u.test(value)) return null;
45
+ return value;
46
+ }
47
+
48
+ function emptyTiers(): ToolPayload[] {
49
+ return PATTERN_EVIDENCE_ORDER.map((name) => ({ name, evidence: [] }));
50
+ }
51
+
52
+ function sanitizeEvidenceTiers(value: unknown): { tiers: ToolPayload[]; dropped: number } {
53
+ const inputTiers = Array.isArray(value) ? value : [];
54
+ let dropped = 0;
55
+ const tiers = PATTERN_EVIDENCE_ORDER.map((name) => {
56
+ const source = inputTiers.find((candidate) =>
57
+ candidate && typeof candidate === "object" && (candidate as Record<string, unknown>).name === name
58
+ ) as Record<string, unknown> | undefined;
59
+ const rawEvidence = Array.isArray(source?.evidence) ? source.evidence : [];
60
+ const evidence: ToolPayload[] = [];
61
+ for (const candidate of rawEvidence) {
62
+ if (!candidate || typeof candidate !== "object") {
63
+ dropped += 1;
64
+ continue;
65
+ }
66
+ const row = candidate as Record<string, unknown>;
67
+ const citationPath = typeof row.path === "string" ? normalizeReviewPath(row.path) : null;
68
+ const id = safeIdentifier(row.id);
69
+ if (!citationPath || !id) {
70
+ dropped += 1;
71
+ continue;
72
+ }
73
+ evidence.push({
74
+ id,
75
+ entity_type: typeof row.entity_type === "string" ? row.entity_type.slice(0, 100) : "",
76
+ kind: typeof row.kind === "string" ? row.kind.slice(0, 100) : "",
77
+ title: typeof row.title === "string" ? row.title.slice(0, 500) : id,
78
+ path: citationPath,
79
+ start_line: Number.isInteger(row.start_line) ? row.start_line : null,
80
+ end_line: Number.isInteger(row.end_line) ? row.end_line : null,
81
+ excerpt: typeof row.excerpt === "string" ? row.excerpt.slice(0, 1000) : "",
82
+ score: typeof row.score === "number" && Number.isFinite(row.score) ? row.score : null,
83
+ matched_rules: Array.isArray(row.matched_rules)
84
+ ? row.matched_rules.filter((item): item is string => typeof item === "string").slice(0, 50)
85
+ : [],
86
+ });
87
+ }
88
+ return { name, evidence };
89
+ });
90
+ return { tiers, dropped };
91
+ }
92
+
93
+ function emptyTarget(file: string, status: "not_indexed" | "error", query?: string): ToolPayload {
94
+ return {
95
+ path: file,
96
+ status,
97
+ review_question: PATTERN_REVIEW_QUESTION,
98
+ query: query ?? null,
99
+ query_source: query ? "explicit" : null,
100
+ local_pattern_found: false,
101
+ fallback_used: false,
102
+ evidence_order: [...PATTERN_EVIDENCE_ORDER],
103
+ tiers: emptyTiers(),
104
+ warning: null,
105
+ message: status === "not_indexed"
106
+ ? "Target is not present as a file-backed entity in the current Cortex index. Run cortex update before reviewing it."
107
+ : "Pattern evidence could not be produced for this target.",
108
+ citations_dropped: 0,
109
+ };
110
+ }
111
+
112
+ function disabledPatternReview(requested: number, limit: number, topK: number): ToolPayload {
113
+ return {
114
+ enabled: false,
115
+ non_blocking: true,
116
+ affects_policy_summary: false,
117
+ review_question: PATTERN_REVIEW_QUESTION,
118
+ limit,
119
+ top_k_per_tier: topK,
120
+ targets: [],
121
+ summary: {
122
+ requested,
123
+ eligible: 0,
124
+ analyzed: 0,
125
+ local_evidence: 0,
126
+ repo_fallback: 0,
127
+ no_evidence: 0,
128
+ not_indexed: 0,
129
+ errors: 0,
130
+ omitted: 0,
131
+ invalid_paths: 0,
132
+ },
133
+ };
134
+ }
135
+
136
+ export async function buildPatternReviewContext(input: {
137
+ files: string[];
138
+ enabled: boolean;
139
+ query?: string;
140
+ topK: number;
141
+ limit: number;
142
+ runner?: PatternEvidenceRunner;
143
+ }): Promise<ToolPayload> {
144
+ if (!input.enabled) {
145
+ return disabledPatternReview(input.files.length, input.limit, input.topK);
146
+ }
147
+
148
+ const normalized = input.files.map(normalizeReviewPath);
149
+ const invalidPaths = normalized.filter((value) => value === null).length;
150
+ const eligible = [...new Set(normalized.filter((value): value is string => value !== null))]
151
+ .sort(compareText);
152
+ const selected = eligible.slice(0, input.limit);
153
+ const runner = input.runner ?? createLocalPatternRunner();
154
+ const targets: ToolPayload[] = [];
155
+ const counts = {
156
+ local_evidence: 0,
157
+ repo_fallback: 0,
158
+ no_evidence: 0,
159
+ not_indexed: 0,
160
+ errors: 0,
161
+ };
162
+
163
+ for (const file of selected) {
164
+ try {
165
+ const evidence = await runner({
166
+ target: file,
167
+ query: input.query,
168
+ top_k: input.topK,
169
+ include_deprecated: false,
170
+ });
171
+ const sanitized = sanitizeEvidenceTiers(evidence.tiers);
172
+ const localPatternFound = sanitized.tiers.slice(0, 3).some((tier) =>
173
+ Array.isArray(tier.evidence) && tier.evidence.length > 0
174
+ );
175
+ const repoEvidence = sanitized.tiers[3].evidence;
176
+ const fallbackUsed = !localPatternFound && Array.isArray(repoEvidence) && repoEvidence.length > 0;
177
+ const status = localPatternFound
178
+ ? "local_evidence"
179
+ : fallbackUsed
180
+ ? "repo_fallback"
181
+ : "no_evidence";
182
+ counts[status] += 1;
183
+ const warning = status === "local_evidence"
184
+ ? typeof evidence.warning === "string" ? "Pattern evidence completed with local runtime warnings." : null
185
+ : status === "repo_fallback"
186
+ ? "No applicable local pattern evidence was found; repository-wide fallback evidence is provided."
187
+ : "No applicable local pattern evidence was found.";
188
+ targets.push({
189
+ path: file,
190
+ status,
191
+ review_question: PATTERN_REVIEW_QUESTION,
192
+ query: typeof evidence.query === "string" ? evidence.query.slice(0, 1000) : input.query ?? null,
193
+ query_source: evidence.query_source === "explicit" || evidence.query_source === "derived_from_target"
194
+ ? evidence.query_source
195
+ : input.query ? "explicit" : null,
196
+ local_pattern_found: localPatternFound,
197
+ fallback_used: fallbackUsed,
198
+ evidence_order: [...PATTERN_EVIDENCE_ORDER],
199
+ tiers: sanitized.tiers,
200
+ warning,
201
+ message: null,
202
+ citations_dropped: sanitized.dropped,
203
+ });
204
+ } catch (error) {
205
+ const message = error instanceof Error ? error.message : String(error);
206
+ const notIndexed = /not found in indexed context|not file-backed/u.test(message);
207
+ const status = notIndexed ? "not_indexed" : "error";
208
+ counts[notIndexed ? "not_indexed" : "errors"] += 1;
209
+ targets.push(emptyTarget(file, status, input.query));
210
+ }
211
+ }
212
+
213
+ return {
214
+ enabled: true,
215
+ non_blocking: true,
216
+ affects_policy_summary: false,
217
+ review_question: PATTERN_REVIEW_QUESTION,
218
+ query: input.query ?? null,
219
+ limit: input.limit,
220
+ top_k_per_tier: input.topK,
221
+ targets,
222
+ summary: {
223
+ requested: input.files.length,
224
+ eligible: eligible.length,
225
+ analyzed: selected.length,
226
+ ...counts,
227
+ omitted: Math.max(0, eligible.length - selected.length),
228
+ invalid_paths: invalidPaths,
229
+ },
230
+ };
231
+ }