@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.
- package/README.md +54 -0
- package/bin/cortex.mjs +31 -167
- package/package.json +2 -2
- package/scaffold/mcp/package-lock.json +24 -21
- package/scaffold/mcp/src/cli/query.ts +36 -2
- package/scaffold/mcp/src/embed.ts +211 -10
- package/scaffold/mcp/src/enterprise/index.ts +1 -1
- package/scaffold/mcp/src/enterprise/reviews/changed-files.ts +34 -0
- package/scaffold/mcp/src/enterprise/reviews/pattern-context.ts +231 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +49 -36
- package/scaffold/mcp/src/paths.ts +3 -5
- package/scaffold/mcp/src/patternEvidence.ts +347 -0
- package/scaffold/mcp/src/search.ts +35 -9
- package/scaffold/mcp/src/searchCore.ts +254 -12
- package/scaffold/mcp/src/searchResults.ts +96 -9
- package/scaffold/mcp/src/types.ts +7 -0
- package/scaffold/mcp/tests/changed-files.test.mjs +41 -0
- package/scaffold/mcp/tests/embed-entities.test.mjs +109 -1
- package/scaffold/mcp/tests/enterprise-pattern-context.test.mjs +322 -0
- package/scaffold/mcp/tests/paths.test.mjs +11 -3
- package/scaffold/mcp/tests/pattern-evidence.test.mjs +321 -0
- package/scaffold/mcp/tests/query-cli.test.mjs +73 -1
- package/scaffold/mcp/tests/search-graph-score.test.mjs +167 -0
- package/scaffold/scripts/dashboard.mjs +29 -18
- package/scaffold/scripts/doctor.sh +26 -8
- package/scaffold/scripts/ingest.mjs +37 -4
- package/scaffold/scripts/status.sh +13 -6
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
106
|
-
graph: 0.
|
|
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 (
|
|
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(
|
|
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(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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:
|
|
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
|
|