@gmickel/gno 1.22.0 → 1.23.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.
Files changed (42) hide show
  1. package/README.md +16 -1
  2. package/assets/skill/SKILL.md +15 -0
  3. package/package.json +1 -1
  4. package/spec/cli.md +36 -20
  5. package/spec/evals-agentic.md +35 -0
  6. package/spec/evals.md +6 -0
  7. package/spec/mcp.md +18 -0
  8. package/spec/output-schemas/query-diagnose-v1.schema.json +123 -0
  9. package/spec/output-schemas/query-diagnose.schema.json +89 -2
  10. package/src/app/context-runtime-types.ts +3 -0
  11. package/src/app/context-runtime.ts +1 -0
  12. package/src/app/context-surface.ts +4 -2
  13. package/src/cli/commands/ask.ts +31 -20
  14. package/src/cli/commands/context-build.ts +17 -7
  15. package/src/cli/commands/query.ts +58 -37
  16. package/src/cli/commands/search.ts +29 -19
  17. package/src/cli/commands/vsearch.ts +31 -22
  18. package/src/cli/options.ts +39 -0
  19. package/src/cli/program.ts +48 -0
  20. package/src/config/defaults.ts +10 -1
  21. package/src/config/types.ts +71 -0
  22. package/src/core/project-affinity-surface.ts +114 -0
  23. package/src/core/project-affinity.ts +330 -0
  24. package/src/core/validation.ts +20 -1
  25. package/src/mcp/tools/ask.ts +10 -1
  26. package/src/mcp/tools/context.ts +18 -0
  27. package/src/mcp/tools/index.ts +13 -2
  28. package/src/mcp/tools/query.ts +12 -0
  29. package/src/mcp/tools/search.ts +7 -0
  30. package/src/mcp/tools/vsearch.ts +7 -0
  31. package/src/pipeline/diagnose.ts +48 -3
  32. package/src/pipeline/explain.ts +54 -13
  33. package/src/pipeline/hybrid.ts +100 -59
  34. package/src/pipeline/project-affinity.ts +162 -0
  35. package/src/pipeline/search.ts +76 -10
  36. package/src/pipeline/types.ts +9 -0
  37. package/src/pipeline/vsearch.ts +117 -91
  38. package/src/sdk/client.ts +80 -20
  39. package/src/sdk/index.ts +2 -0
  40. package/src/sdk/types.ts +20 -7
  41. package/src/serve/context-capsule.ts +18 -1
  42. package/src/serve/routes/api.ts +69 -0
@@ -11,8 +11,11 @@ import type {
11
11
  ExplainResult,
12
12
  QueryModeSummary,
13
13
  RerankedCandidate,
14
+ SearchResult,
14
15
  } from "./types";
15
16
 
17
+ import { SEARCH_RESULT_PLANNER_METADATA } from "./types";
18
+
16
19
  // ─────────────────────────────────────────────────────────────────────────────
17
20
  // Formatter
18
21
  // ─────────────────────────────────────────────────────────────────────────────
@@ -35,7 +38,8 @@ export function formatResultExplain(results: ExplainResult[]): string {
35
38
  r.fusionScore !== undefined ||
36
39
  r.bm25Score !== undefined ||
37
40
  r.vecScore !== undefined ||
38
- r.rerankScore !== undefined
41
+ r.rerankScore !== undefined ||
42
+ r.projectAffinity !== undefined
39
43
  ) {
40
44
  msg += " (";
41
45
  if (r.fusionScore !== undefined) {
@@ -59,6 +63,12 @@ export function formatResultExplain(results: ExplainResult[]): string {
59
63
  }
60
64
  msg += `rerank=${r.rerankScore.toFixed(2)}`;
61
65
  }
66
+ if (r.projectAffinity) {
67
+ if (msg.at(-1) !== "(") {
68
+ msg += ", ";
69
+ }
70
+ msg += `raw=${r.projectAffinity.rawScoreKind}:${r.projectAffinity.rawScore.toFixed(3)}, base=${r.projectAffinity.baseScore.toFixed(3)}, affinity=${r.projectAffinity.affinityApplied.toFixed(3)}/${r.projectAffinity.affinityRequested.toFixed(3)}, auxiliary=${r.projectAffinity.combinedAuxiliaryApplied.toFixed(3)}/${r.projectAffinity.combinedAuxiliaryCap.toFixed(3)}, collection=${r.projectAffinity.collectionAlias}, root=${r.projectAffinity.rootAlias}, source=${r.projectAffinity.source}, final=${r.projectAffinity.finalScore.toFixed(3)}`;
71
+ }
62
72
  msg += ")";
63
73
  }
64
74
  lines.push(`[explain] result ${r.rank}: ${r.docid} ${msg}`);
@@ -206,18 +216,49 @@ export function explainTimings(timings: StageTimingsInput): ExplainLine {
206
216
 
207
217
  export function buildExplainResults(
208
218
  candidates: RerankedCandidate[],
209
- docidMap: Map<string, string>
219
+ docidMap: Map<string, string>,
220
+ finalResults?: SearchResult[]
210
221
  ): ExplainResult[] {
211
- return candidates.slice(0, 20).map((c, i) => {
212
- const key = `${c.mirrorHash}:${c.seq}`;
213
- return {
214
- rank: i + 1,
215
- docid: docidMap.get(key) ?? "#unknown",
216
- score: c.blendedScore,
217
- fusionScore: c.fusionScore,
218
- bm25Score: c.bm25Rank !== null ? 1 / (60 + c.bm25Rank) : undefined,
219
- vecScore: c.vecRank !== null ? 1 / (60 + c.vecRank) : undefined,
220
- rerankScore: c.rerankScore ?? undefined,
221
- };
222
+ if (finalResults) {
223
+ return finalResults.slice(0, 20).map((result, index) => {
224
+ const planner = result[SEARCH_RESULT_PLANNER_METADATA];
225
+ const candidate = candidates.find(
226
+ (entry) =>
227
+ entry.mirrorHash === result.conversion?.mirrorHash &&
228
+ entry.seq === (planner?.retrievalSeq ?? planner?.seq)
229
+ );
230
+ return buildExplainResult(result.docid, result.score, index, candidate);
231
+ });
232
+ }
233
+ return candidates.slice(0, 20).map((candidate, index) => {
234
+ const key = `${candidate.mirrorHash}:${candidate.seq}`;
235
+ return buildExplainResult(
236
+ docidMap.get(key) ?? "#unknown",
237
+ candidate.blendedScore,
238
+ index,
239
+ candidate
240
+ );
222
241
  });
223
242
  }
243
+
244
+ function buildExplainResult(
245
+ docid: string,
246
+ score: number,
247
+ index: number,
248
+ candidate?: RerankedCandidate
249
+ ): ExplainResult {
250
+ if (!candidate) {
251
+ return { rank: index + 1, docid, score };
252
+ }
253
+ return {
254
+ rank: index + 1,
255
+ docid,
256
+ score,
257
+ fusionScore: candidate.fusionScore,
258
+ bm25Score:
259
+ candidate.bm25Rank !== null ? 1 / (60 + candidate.bm25Rank) : undefined,
260
+ vecScore:
261
+ candidate.vecRank !== null ? 1 / (60 + candidate.vecRank) : undefined,
262
+ rerankScore: candidate.rerankScore ?? undefined,
263
+ };
264
+ }
@@ -41,6 +41,11 @@ import { evaluateDocumentChunkFilters } from "./filters";
41
41
  import { type RankedInput, rrfFuse, toRankedInput } from "./fusion";
42
42
  import { expandGraphCandidates } from "./graph-retrieval";
43
43
  import { selectBestChunkForSteering } from "./intent";
44
+ import {
45
+ applyProjectAffinity,
46
+ getProjectAffinityMetadata,
47
+ hasProjectAffinity,
48
+ } from "./project-affinity";
44
49
  import { detectQueryLanguage } from "./query-language";
45
50
  import {
46
51
  buildExpansionFromQueryModes,
@@ -301,6 +306,7 @@ export async function searchHybrid(
301
306
  const runStartedAt = performance.now();
302
307
  const { store, vectorIndex, embedPort, expandPort, rerankPort } = deps;
303
308
  const pipelineConfig = deps.pipelineConfig ?? DEFAULT_PIPELINE_CONFIG;
309
+ const affinityActive = hasProjectAffinity(options.projectAffinity);
304
310
 
305
311
  const limit = options.limit ?? 20;
306
312
  const recencySort = shouldSortByRecency(query);
@@ -719,7 +725,7 @@ export async function searchHybrid(
719
725
  // ─────────────────────────────────────────────────────────────────────────
720
726
  const minScore = options.minScore ?? 0;
721
727
  const filteredCandidates =
722
- minScore > 0
728
+ minScore > 0 && !affinityActive
723
729
  ? rerankResult.candidates.filter((c) => c.blendedScore >= minScore)
724
730
  : rerankResult.candidates;
725
731
 
@@ -875,7 +881,7 @@ export async function searchHybrid(
875
881
  // Iterate until we have enough results (don't slice early - deduping may skip candidates)
876
882
  for (const [candidateIndex, candidate] of filteredCandidates.entries()) {
877
883
  // Stop when we have enough results
878
- if (results.length >= assemblyLimit) {
884
+ if (!affinityActive && results.length >= assemblyLimit) {
879
885
  break;
880
886
  }
881
887
 
@@ -939,64 +945,76 @@ export async function searchHybrid(
939
945
  }
940
946
 
941
947
  for (const doc of candidateDocs) {
942
- if (results.length >= assemblyLimit) break;
948
+ if (!affinityActive && results.length >= assemblyLimit) break;
943
949
  const filterEval = evaluateDocumentChunkFilters(
944
950
  query,
945
951
  doc,
946
952
  docChunks,
947
953
  options
948
954
  );
949
- if (!filterEval.matches || (options.full && seenDocids.has(doc.docid))) {
955
+ if (
956
+ !filterEval.matches ||
957
+ (options.full && !affinityActive && seenDocids.has(doc.docid))
958
+ ) {
950
959
  continue;
951
960
  }
952
961
  const docidKey = `${candidate.mirrorHash}:${candidate.seq}`;
953
962
  if (!docidMap.has(docidKey)) docidMap.set(docidKey, doc.docid);
954
963
  const collectionPath = collectionPaths.get(doc.collection);
955
- seenDocids.add(doc.docid);
956
- results.push(
957
- attachSearchResultPlannerMetadata(
958
- {
959
- docid: doc.docid,
960
- score: candidate.blendedScore,
961
- uri: doc.uri,
962
- title: doc.title ?? undefined,
963
- contentType: doc.contentType ?? undefined,
964
- categories: doc.categories ?? undefined,
965
- line: snippetChunk.startLine,
966
- snippet,
967
- snippetLanguage: chunk.language ?? undefined,
968
- snippetRange,
969
- source: {
970
- relPath: doc.relPath,
971
- absPath: collectionPath
972
- ? `${collectionPath}/${doc.relPath}`
973
- : undefined,
974
- mime: doc.sourceMime,
975
- ext: doc.sourceExt,
976
- modifiedAt: doc.sourceMtime,
977
- documentDate: doc.frontmatterDate ?? undefined,
978
- sizeBytes: doc.sourceSize,
979
- sourceHash: doc.sourceHash,
980
- },
981
- conversion: {
982
- mirrorHash: candidate.mirrorHash,
983
- converterId: doc.converterId ?? undefined,
984
- converterVersion: doc.converterVersion ?? undefined,
985
- },
964
+ if (options.full && !affinityActive) {
965
+ seenDocids.add(doc.docid);
966
+ }
967
+ const scoredResult = applyProjectAffinity(
968
+ {
969
+ docid: doc.docid,
970
+ score: candidate.blendedScore,
971
+ uri: doc.uri,
972
+ title: doc.title ?? undefined,
973
+ contentType: doc.contentType ?? undefined,
974
+ categories: doc.categories ?? undefined,
975
+ line: snippetChunk.startLine,
976
+ snippet,
977
+ snippetLanguage: chunk.language ?? undefined,
978
+ snippetRange,
979
+ source: {
980
+ relPath: doc.relPath,
981
+ absPath: collectionPath
982
+ ? `${collectionPath}/${doc.relPath}`
983
+ : undefined,
984
+ mime: doc.sourceMime,
985
+ ext: doc.sourceExt,
986
+ modifiedAt: doc.sourceMtime,
987
+ documentDate: doc.frontmatterDate ?? undefined,
988
+ sizeBytes: doc.sourceSize,
989
+ sourceHash: doc.sourceHash,
986
990
  },
987
- {
988
- retrievalRank: candidateIndex + 1,
991
+ conversion: {
989
992
  mirrorHash: candidate.mirrorHash,
990
- seq: snippetChunk.seq,
991
- sources: [...candidate.sources].sort(),
992
- graphExpanded: candidate.sources.includes("graph"),
993
- startLine: snippetChunk.startLine,
994
- endLine: snippetChunk.endLine,
995
- passageHash: new Bun.CryptoHasher("sha256")
996
- .update(snippetChunk.text)
997
- .digest("hex"),
998
- }
999
- )
993
+ converterId: doc.converterId ?? undefined,
994
+ converterVersion: doc.converterVersion ?? undefined,
995
+ },
996
+ },
997
+ doc.collection,
998
+ options.projectAffinity,
999
+ { kind: "hybrid_blended", score: candidate.blendedScore }
1000
+ );
1001
+ if (scoredResult.score < minScore) continue;
1002
+ results.push(
1003
+ attachSearchResultPlannerMetadata(scoredResult, {
1004
+ retrievalRank: candidateIndex + 1,
1005
+ mirrorHash: candidate.mirrorHash,
1006
+ seq: snippetChunk.seq,
1007
+ ...(affinityActive && snippetChunk.seq !== candidate.seq
1008
+ ? { retrievalSeq: candidate.seq }
1009
+ : {}),
1010
+ sources: [...candidate.sources].sort(),
1011
+ graphExpanded: candidate.sources.includes("graph"),
1012
+ startLine: snippetChunk.startLine,
1013
+ endLine: snippetChunk.endLine,
1014
+ passageHash: new Bun.CryptoHasher("sha256")
1015
+ .update(snippetChunk.text)
1016
+ .digest("hex"),
1017
+ })
1000
1018
  );
1001
1019
  }
1002
1020
  }
@@ -1008,21 +1026,16 @@ export async function searchHybrid(
1008
1026
  // ─────────────────────────────────────────────────────────────────────────
1009
1027
  // 6. Build explain data (if requested)
1010
1028
  // ─────────────────────────────────────────────────────────────────────────
1011
- const explainData = options.explain
1012
- ? {
1013
- lines: explainLines,
1014
- results: buildExplainResults(
1015
- filteredCandidates.slice(0, limit),
1016
- docidMap
1017
- ),
1018
- }
1019
- : undefined;
1020
-
1021
1029
  // ─────────────────────────────────────────────────────────────────────────
1022
1030
  // 7. Return results
1023
1031
  // ─────────────────────────────────────────────────────────────────────────
1032
+ const dedupedResults =
1033
+ options.full && affinityActive
1034
+ ? dedupeFullResultsByDocid(results)
1035
+ : results;
1036
+
1024
1037
  if (recencySort) {
1025
- results.sort((a, b) => {
1038
+ dedupedResults.sort((a, b) => {
1026
1039
  const aTs = resolveRecencyTimestamp(
1027
1040
  a.source.documentDate,
1028
1041
  a.source.modifiedAt
@@ -1036,9 +1049,26 @@ export async function searchHybrid(
1036
1049
  }
1037
1050
  return b.score - a.score;
1038
1051
  });
1052
+ } else if (affinityActive) {
1053
+ dedupedResults.sort((a, b) => b.score - a.score);
1039
1054
  }
1040
1055
 
1041
- const finalResults = results.slice(0, limit);
1056
+ const finalResults = dedupedResults.slice(0, limit);
1057
+ const explainData = options.explain
1058
+ ? {
1059
+ lines: explainLines,
1060
+ results: affinityActive
1061
+ ? buildExplainResults(filteredCandidates, docidMap, finalResults).map(
1062
+ (result, index) => ({
1063
+ ...result,
1064
+ projectAffinity: getProjectAffinityMetadata(
1065
+ finalResults[index]!
1066
+ ),
1067
+ })
1068
+ )
1069
+ : buildExplainResults(filteredCandidates.slice(0, limit), docidMap),
1070
+ }
1071
+ : undefined;
1042
1072
  await attachSearchResultContexts(store, finalResults);
1043
1073
 
1044
1074
  const output: SearchResults = {
@@ -1143,3 +1173,14 @@ export async function searchHybrid(
1143
1173
  }
1144
1174
  return ok(output);
1145
1175
  }
1176
+
1177
+ function dedupeFullResultsByDocid(results: SearchResult[]): SearchResult[] {
1178
+ const bestByDocid = new Map<string, SearchResult>();
1179
+ for (const result of results) {
1180
+ const existing = bestByDocid.get(result.docid);
1181
+ if (!existing || result.score > existing.score) {
1182
+ bestByDocid.set(result.docid, result);
1183
+ }
1184
+ }
1185
+ return [...bestByDocid.values()];
1186
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Bounded auxiliary scoring for trusted project affinity.
3
+ *
4
+ * @module src/pipeline/project-affinity
5
+ */
6
+
7
+ import type {
8
+ ProjectAffinityMatch,
9
+ ProjectAffinityResolution,
10
+ } from "../core/project-affinity";
11
+ import type { SearchResult } from "./types";
12
+
13
+ import {
14
+ AUXILIARY_RANKING_MAX_CONTRIBUTION,
15
+ PROJECT_AFFINITY_MAX_CONTRIBUTION,
16
+ } from "../config/types";
17
+
18
+ export interface ProjectAffinityScoringInput {
19
+ enabled?: boolean;
20
+ contribution?: number;
21
+ resolution: ProjectAffinityResolution;
22
+ }
23
+
24
+ export interface ProjectAffinityScoreMetadata {
25
+ affinityAdjustedScore: number;
26
+ affinityApplied: number;
27
+ affinityRequested: number;
28
+ affinityWeight: number;
29
+ baseScore: number;
30
+ collectionAlias: string | null;
31
+ combinedAuxiliaryApplied: number;
32
+ combinedAuxiliaryCap: number;
33
+ combinedAuxiliaryRequested: number;
34
+ finalBlendedScore: number;
35
+ finalScore: number;
36
+ matched: boolean;
37
+ rawScore: number;
38
+ rawScoreKind: "bm25" | "hybrid_blended" | "normalized" | "vector_distance";
39
+ rootAlias: string | null;
40
+ source: ProjectAffinityMatch["source"] | null;
41
+ }
42
+
43
+ export const SEARCH_RESULT_AFFINITY_METADATA = Symbol(
44
+ "gno.searchResultAffinityMetadata"
45
+ );
46
+
47
+ const clamp = (value: number, min: number, max: number): number =>
48
+ Math.min(max, Math.max(min, value));
49
+
50
+ export function applyAuxiliaryScore(
51
+ baseScore: number,
52
+ contributions: readonly number[]
53
+ ): {
54
+ applied: number;
55
+ finalScore: number;
56
+ requested: number;
57
+ } {
58
+ const requested = [...contributions]
59
+ .sort((left, right) => left - right)
60
+ .reduce((total, contribution) => total + contribution, 0);
61
+ const applied = clamp(
62
+ requested,
63
+ -AUXILIARY_RANKING_MAX_CONTRIBUTION,
64
+ AUXILIARY_RANKING_MAX_CONTRIBUTION
65
+ );
66
+ return {
67
+ requested,
68
+ applied,
69
+ finalScore: clamp(baseScore + applied, 0, 1),
70
+ };
71
+ }
72
+
73
+ const matchingCollection = (
74
+ input: ProjectAffinityScoringInput | undefined,
75
+ collection: string
76
+ ): ProjectAffinityMatch | undefined => {
77
+ if (input?.enabled === false) return undefined;
78
+ return input?.resolution.matches.find(
79
+ (match) => match.collection === collection
80
+ );
81
+ };
82
+
83
+ export function scoreProjectAffinity(
84
+ baseScore: number,
85
+ collection: string,
86
+ input: ProjectAffinityScoringInput | undefined,
87
+ raw: {
88
+ kind: ProjectAffinityScoreMetadata["rawScoreKind"];
89
+ score: number;
90
+ } = { kind: "normalized", score: baseScore }
91
+ ): ProjectAffinityScoreMetadata {
92
+ const match = matchingCollection(input, collection);
93
+ const configuredWeight = clamp(
94
+ input?.contribution ?? PROJECT_AFFINITY_MAX_CONTRIBUTION,
95
+ 0,
96
+ PROJECT_AFFINITY_MAX_CONTRIBUTION
97
+ );
98
+ const affinityRequested = match ? configuredWeight : 0;
99
+ const auxiliary = applyAuxiliaryScore(baseScore, [affinityRequested]);
100
+ const affinityApplied = auxiliary.finalScore - baseScore;
101
+
102
+ return {
103
+ affinityAdjustedScore: auxiliary.finalScore,
104
+ affinityApplied,
105
+ affinityRequested,
106
+ affinityWeight: configuredWeight,
107
+ baseScore,
108
+ collectionAlias: match?.collectionAlias ?? null,
109
+ combinedAuxiliaryApplied: auxiliary.applied,
110
+ combinedAuxiliaryCap: AUXILIARY_RANKING_MAX_CONTRIBUTION,
111
+ combinedAuxiliaryRequested: auxiliary.requested,
112
+ finalBlendedScore: auxiliary.finalScore,
113
+ finalScore: auxiliary.finalScore,
114
+ matched: Boolean(match),
115
+ rawScore: raw.score,
116
+ rawScoreKind: raw.kind,
117
+ rootAlias: match?.rootAlias ?? null,
118
+ source: match?.source ?? null,
119
+ };
120
+ }
121
+
122
+ export function hasProjectAffinity(
123
+ input: ProjectAffinityScoringInput | undefined
124
+ ): boolean {
125
+ return (
126
+ Boolean(input) &&
127
+ input?.enabled !== false &&
128
+ (input?.contribution ?? PROJECT_AFFINITY_MAX_CONTRIBUTION) > 0 &&
129
+ (input?.resolution.matches.length ?? 0) !== 0
130
+ );
131
+ }
132
+
133
+ export function applyProjectAffinity(
134
+ result: SearchResult,
135
+ collection: string,
136
+ input: ProjectAffinityScoringInput | undefined,
137
+ raw?: {
138
+ kind: ProjectAffinityScoreMetadata["rawScoreKind"];
139
+ score: number;
140
+ }
141
+ ): SearchResult {
142
+ if (!hasProjectAffinity(input)) return result;
143
+ const metadata = scoreProjectAffinity(result.score, collection, input, raw);
144
+ result.score = metadata.finalScore;
145
+ Object.defineProperty(result, SEARCH_RESULT_AFFINITY_METADATA, {
146
+ configurable: true,
147
+ enumerable: false,
148
+ value: metadata,
149
+ writable: true,
150
+ });
151
+ return result;
152
+ }
153
+
154
+ export function getProjectAffinityMetadata(
155
+ result: SearchResult
156
+ ): ProjectAffinityScoreMetadata | undefined {
157
+ return (
158
+ result as SearchResult & {
159
+ [SEARCH_RESULT_AFFINITY_METADATA]?: ProjectAffinityScoreMetadata;
160
+ }
161
+ )[SEARCH_RESULT_AFFINITY_METADATA];
162
+ }
@@ -20,6 +20,7 @@ import { err, ok } from "../store/types";
20
20
  import { createChunkLookup } from "./chunk-lookup";
21
21
  import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
22
22
  import { selectBestChunkForSteering } from "./intent";
23
+ import { applyProjectAffinity, hasProjectAffinity } from "./project-affinity";
23
24
  import { detectQueryLanguage } from "./query-language";
24
25
  import { attachSearchResultContexts } from "./result-context";
25
26
  import {
@@ -160,8 +161,9 @@ export async function searchBm25(
160
161
  const traceStartedAt = options.traceSession ? performance.now() : 0;
161
162
  const limit = options.limit ?? 20;
162
163
  const minScore = options.minScore ?? 0;
164
+ const affinityActive = hasProjectAffinity(options.projectAffinity);
163
165
  const recencySort = shouldSortByRecency(query);
164
- const retrievalLimit = recencySort ? limit * 3 : limit;
166
+ const retrievalLimit = recencySort || affinityActive ? limit * 3 : limit;
165
167
  const temporalRange = resolveTemporalRange(
166
168
  query,
167
169
  options.since,
@@ -208,6 +210,10 @@ export async function searchBm25(
208
210
 
209
211
  // Build results
210
212
  const results: SearchResult[] = [];
213
+ const scoringByResult = new WeakMap<
214
+ SearchResult,
215
+ { collection: string; rawScore: number }
216
+ >();
211
217
 
212
218
  // Pre-fetch all chunks in one batch query (eliminates N+1)
213
219
  const uniqueHashes = [
@@ -229,6 +235,11 @@ export async function searchBm25(
229
235
  string,
230
236
  { fts: FtsResult; chunk: ChunkRow | null; score: number }
231
237
  >();
238
+ const fullAffinityEntries: {
239
+ fts: FtsResult;
240
+ chunk: ChunkRow | null;
241
+ score: number;
242
+ }[] = [];
232
243
 
233
244
  for (const fts of ftsResult.value) {
234
245
  // Dedup by uri+seq - eliminates rows from mirror_hash join fan-out
@@ -278,6 +289,10 @@ export async function searchBm25(
278
289
  // For --full, de-dupe by docid (keep best scoring chunk per doc)
279
290
  // Raw BM25: smaller (more negative) is better
280
291
  if (options.full) {
292
+ if (affinityActive) {
293
+ fullAffinityEntries.push({ fts, chunk, score: fts.score });
294
+ continue;
295
+ }
281
296
  const docid = fts.docid ?? "";
282
297
  const existing = bestByDocid.get(docid);
283
298
  if (!existing || fts.score < existing.score) {
@@ -289,16 +304,22 @@ export async function searchBm25(
289
304
  const collectionPath = fts.collection
290
305
  ? collectionPaths.get(fts.collection)
291
306
  : undefined;
292
-
293
- results.push(buildSearchResult({ fts, chunk, collectionPath, options }));
307
+ const result = buildSearchResult({ fts, chunk, collectionPath, options });
308
+ if (fts.collection) {
309
+ scoringByResult.set(result, {
310
+ collection: fts.collection,
311
+ rawScore: fts.score,
312
+ });
313
+ }
314
+ results.push(result);
294
315
  }
295
316
 
296
317
  // For --full, fetch full content and build results
297
318
  if (options.full) {
298
319
  // Sort by raw BM25 score (smaller = better) before building results
299
- const sortedEntries = [...bestByDocid.values()].sort(
300
- (a, b) => a.score - b.score
301
- );
320
+ const sortedEntries = (
321
+ affinityActive ? fullAffinityEntries : [...bestByDocid.values()]
322
+ ).sort((a, b) => a.score - b.score);
302
323
  const fullContentResult = await getContentBatch(
303
324
  store,
304
325
  sortedEntries
@@ -317,18 +338,50 @@ export async function searchBm25(
317
338
  const collectionPath = fts.collection
318
339
  ? collectionPaths.get(fts.collection)
319
340
  : undefined;
320
- results.push(
321
- buildSearchResult({ fts, chunk, collectionPath, options, fullContent })
322
- );
341
+ const result = buildSearchResult({
342
+ fts,
343
+ chunk,
344
+ collectionPath,
345
+ options,
346
+ fullContent,
347
+ });
348
+ if (fts.collection) {
349
+ scoringByResult.set(result, {
350
+ collection: fts.collection,
351
+ rawScore: fts.score,
352
+ });
353
+ }
354
+ results.push(result);
323
355
  }
324
356
  }
325
357
 
326
358
  // Normalize scores to 0-1 range (batch min-max)
327
359
  normalizeBm25Scores(results);
328
360
 
361
+ if (affinityActive) {
362
+ for (const result of results) {
363
+ const scoring = scoringByResult.get(result);
364
+ if (scoring) {
365
+ applyProjectAffinity(
366
+ result,
367
+ scoring.collection,
368
+ options.projectAffinity,
369
+ { kind: "bm25", score: scoring.rawScore }
370
+ );
371
+ }
372
+ }
373
+ }
374
+
375
+ const dedupedResults =
376
+ options.full && affinityActive
377
+ ? dedupeFullResultsByDocid(results)
378
+ : results;
379
+
329
380
  // Apply minScore filter after normalization
330
381
  const filteredResults =
331
- minScore > 0 ? results.filter((r) => r.score >= minScore) : results;
382
+ minScore > 0
383
+ ? dedupedResults.filter((r) => r.score >= minScore)
384
+ : dedupedResults;
332
385
 
333
386
  if (recencySort) {
334
387
  filteredResults.sort((a, b) => {
@@ -345,6 +398,8 @@ export async function searchBm25(
345
398
  }
346
399
  return b.score - a.score;
347
400
  });
401
+ } else if (affinityActive) {
402
+ filteredResults.sort((a, b) => b.score - a.score);
348
403
  }
349
404
 
350
405
  const finalResults = filteredResults.slice(0, limit);
@@ -384,3 +439,14 @@ export async function searchBm25(
384
439
  }
385
440
  return ok(output);
386
441
  }
442
+
443
+ function dedupeFullResultsByDocid(results: SearchResult[]): SearchResult[] {
444
+ const bestByDocid = new Map<string, SearchResult>();
445
+ for (const result of results) {
446
+ const existing = bestByDocid.get(result.docid);
447
+ if (!existing || result.score > existing.score) {
448
+ bestByDocid.set(result.docid, result);
449
+ }
450
+ }
451
+ return [...bestByDocid.values()];
452
+ }
@@ -13,6 +13,10 @@ import type { RetrievalTraceSession } from "../core/retrieval-trace-session";
13
13
  import type { StoreResult } from "../store/types";
14
14
  import type { ClaimVerificationResult } from "./claim-verification";
15
15
  import type { SemanticVerificationCapability } from "./claim-verifier";
16
+ import type {
17
+ ProjectAffinityScoreMetadata,
18
+ ProjectAffinityScoringInput,
19
+ } from "./project-affinity";
16
20
 
17
21
  // ─────────────────────────────────────────────────────────────────────────────
18
22
  // Search Result Types
@@ -53,6 +57,8 @@ export interface SearchResultPlannerMetadata {
53
57
  retrievalRank: number;
54
58
  mirrorHash: string;
55
59
  seq: number;
60
+ /** Original retrieval seq when intent steering selects another snippet. */
61
+ retrievalSeq?: number;
56
62
  sources: FusionSource[];
57
63
  graphExpanded: boolean;
58
64
  /** Exact canonical chunk coordinates, retained even for full-content output. */
@@ -164,6 +170,8 @@ export interface SearchResults {
164
170
  export interface SearchOptions {
165
171
  /** Internal receipt seam; never serialized or included in public schemas. */
166
172
  traceSession?: RetrievalTraceSession;
173
+ /** Trusted, already-resolved project affinity; never accepts raw roots. */
174
+ projectAffinity?: ProjectAffinityScoringInput;
167
175
  /** Max results */
168
176
  limit?: number;
169
177
  /** Min score threshold (0-1) */
@@ -536,4 +544,5 @@ export interface ExplainResult {
536
544
  bm25Score?: number;
537
545
  vecScore?: number;
538
546
  rerankScore?: number;
547
+ projectAffinity?: ProjectAffinityScoreMetadata;
539
548
  }