@gmickel/gno 1.21.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 (47) hide show
  1. package/README.md +26 -2
  2. package/assets/skill/SKILL.md +15 -0
  3. package/package.json +2 -1
  4. package/spec/cli.md +80 -20
  5. package/spec/evals-agentic.md +83 -0
  6. package/spec/evals.md +6 -0
  7. package/spec/mcp.md +18 -0
  8. package/spec/output-schemas/publish-artifact.schema.json +284 -0
  9. package/spec/output-schemas/query-diagnose-v1.schema.json +123 -0
  10. package/spec/output-schemas/query-diagnose.schema.json +89 -2
  11. package/src/app/context-runtime-types.ts +3 -0
  12. package/src/app/context-runtime.ts +1 -0
  13. package/src/app/context-surface.ts +4 -2
  14. package/src/cli/commands/ask.ts +31 -20
  15. package/src/cli/commands/context-build.ts +17 -7
  16. package/src/cli/commands/query.ts +58 -37
  17. package/src/cli/commands/search.ts +29 -19
  18. package/src/cli/commands/vsearch.ts +31 -22
  19. package/src/cli/options.ts +39 -0
  20. package/src/cli/program.ts +48 -0
  21. package/src/config/defaults.ts +10 -1
  22. package/src/config/types.ts +71 -0
  23. package/src/core/project-affinity-surface.ts +114 -0
  24. package/src/core/project-affinity.ts +330 -0
  25. package/src/core/validation.ts +20 -1
  26. package/src/mcp/tools/ask.ts +10 -1
  27. package/src/mcp/tools/context.ts +18 -0
  28. package/src/mcp/tools/index.ts +13 -2
  29. package/src/mcp/tools/query.ts +12 -0
  30. package/src/mcp/tools/search.ts +7 -0
  31. package/src/mcp/tools/vsearch.ts +7 -0
  32. package/src/pipeline/diagnose.ts +48 -3
  33. package/src/pipeline/explain.ts +54 -13
  34. package/src/pipeline/hybrid.ts +100 -59
  35. package/src/pipeline/project-affinity.ts +162 -0
  36. package/src/pipeline/search.ts +76 -10
  37. package/src/pipeline/types.ts +9 -0
  38. package/src/pipeline/vsearch.ts +117 -91
  39. package/src/publish/artifact-validation.ts +259 -0
  40. package/src/publish/artifact.ts +234 -118
  41. package/src/publish/export-service.ts +5 -9
  42. package/src/publish/metadata.ts +195 -0
  43. package/src/sdk/client.ts +80 -20
  44. package/src/sdk/index.ts +2 -0
  45. package/src/sdk/types.ts +20 -7
  46. package/src/serve/context-capsule.ts +18 -1
  47. package/src/serve/routes/api.ts +69 -0
@@ -26,6 +26,7 @@ import {
26
26
  normalizeContentTypes,
27
27
  } from "../../config";
28
28
  import { resolveDepthPolicy } from "../../core/depth-policy";
29
+ import { resolveRemoteProjectAffinity } from "../../core/project-affinity-surface";
29
30
  import {
30
31
  finishRetrievalTraceAfterError,
31
32
  retrievalTraceFilters,
@@ -49,6 +50,7 @@ import { normalizeTagFilters, runTool, type ToolResult } from "./index";
49
50
 
50
51
  interface QueryInput {
51
52
  query: string;
53
+ projectHints?: string[];
52
54
  collection?: string;
53
55
  limit?: number;
54
56
  minScore?: number;
@@ -227,6 +229,10 @@ export function handleQuery(
227
229
  hasStructuredModes,
228
230
  });
229
231
  const { noExpand, noRerank } = depthPolicy;
232
+ const projectAffinity = await resolveRemoteProjectAffinity(
233
+ ctx.config,
234
+ args.projectHints
235
+ );
230
236
  const expandUri =
231
237
  !noExpand && !hasStructuredModes
232
238
  ? resolveModelUri(ctx.config, "expand", undefined, args.collection)
@@ -253,6 +259,7 @@ export function handleQuery(
253
259
  queryModes,
254
260
  tagsAll: normalizeTagFilters(args.tagsAll),
255
261
  tagsAny: normalizeTagFilters(args.tagsAny),
262
+ projectAffinity,
256
263
  };
257
264
 
258
265
  try {
@@ -440,6 +447,10 @@ export function handleQueryDiagnose(
440
447
  hasStructuredModes,
441
448
  });
442
449
  const { noExpand, noRerank } = depthPolicy;
450
+ const projectAffinity = await resolveRemoteProjectAffinity(
451
+ ctx.config,
452
+ args.projectHints
453
+ );
443
454
 
444
455
  if (!args.fast) {
445
456
  const embedResult = await llm.createEmbeddingPort(embedUri, {
@@ -524,6 +535,7 @@ export function handleQueryDiagnose(
524
535
  queryModes,
525
536
  tagsAll: normalizeTagFilters(args.tagsAll),
526
537
  tagsAny: normalizeTagFilters(args.tagsAny),
538
+ projectAffinity,
527
539
  contentTypeRules,
528
540
  contentTypeRulesFingerprint:
529
541
  fingerprintContentTypeRules(contentTypeRules),
@@ -11,6 +11,7 @@ import type { SearchResult, SearchResults } from "../../pipeline/types";
11
11
  import type { ToolContext } from "../server";
12
12
 
13
13
  import { decorateUriForIndex, parseUri } from "../../app/constants";
14
+ import { resolveRemoteProjectAffinity } from "../../core/project-affinity-surface";
14
15
  import {
15
16
  finishRetrievalTraceAfterError,
16
17
  retrievalTraceFilters,
@@ -22,6 +23,7 @@ import { normalizeTagFilters, runTool, type ToolResult } from "./index";
22
23
 
23
24
  interface SearchInput {
24
25
  query: string;
26
+ projectHints?: string[];
25
27
  collection?: string;
26
28
  limit?: number;
27
29
  minScore?: number;
@@ -113,6 +115,10 @@ export function handleSearch(
113
115
  }
114
116
  }
115
117
 
118
+ const projectAffinity = await resolveRemoteProjectAffinity(
119
+ ctx.config,
120
+ args.projectHints
121
+ );
116
122
  const options = {
117
123
  limit: args.limit ?? 5,
118
124
  minScore: args.minScore,
@@ -126,6 +132,7 @@ export function handleSearch(
126
132
  author: args.author,
127
133
  tagsAll: normalizeTagFilters(args.tagsAll),
128
134
  tagsAny: normalizeTagFilters(args.tagsAny),
135
+ projectAffinity,
129
136
  };
130
137
  let traceSession: RetrievalTraceSession | undefined;
131
138
  try {
@@ -12,6 +12,7 @@ import type { ToolContext } from "../server";
12
12
 
13
13
  import { decorateUriForIndex, parseUri } from "../../app/constants";
14
14
  import { createNonTtyProgressRenderer } from "../../cli/progress";
15
+ import { resolveRemoteProjectAffinity } from "../../core/project-affinity-surface";
15
16
  import {
16
17
  finishRetrievalTraceAfterError,
17
18
  retrievalTraceFilters,
@@ -34,6 +35,7 @@ import { normalizeTagFilters, runTool, type ToolResult } from "./index";
34
35
 
35
36
  interface VsearchInput {
36
37
  query: string;
38
+ projectHints?: string[];
37
39
  collection?: string;
38
40
  limit?: number;
39
41
  minScore?: number;
@@ -135,6 +137,10 @@ export function handleVsearch(
135
137
  undefined,
136
138
  args.collection
137
139
  );
140
+ const projectAffinity = await resolveRemoteProjectAffinity(
141
+ ctx.config,
142
+ args.projectHints
143
+ );
138
144
  const options = {
139
145
  limit: args.limit ?? 5,
140
146
  minScore: args.minScore,
@@ -147,6 +153,7 @@ export function handleVsearch(
147
153
  author: args.author,
148
154
  tagsAll: normalizeTagFilters(args.tagsAll),
149
155
  tagsAny: normalizeTagFilters(args.tagsAny),
156
+ projectAffinity,
150
157
  };
151
158
  let traceSession: RetrievalTraceSession | undefined;
152
159
  const traceStart = await startRetrievalTraceRequest({
@@ -19,6 +19,12 @@ import { resolveDocRef } from "../core/ref-parser";
19
19
  import { err, ok } from "../store/types";
20
20
  import { evaluateQueryTargetFilters } from "./filters";
21
21
  import { searchHybrid } from "./hybrid";
22
+ import {
23
+ getProjectAffinityMetadata,
24
+ type ProjectAffinityScoringInput,
25
+ type ProjectAffinityScoreMetadata,
26
+ scoreProjectAffinity,
27
+ } from "./project-affinity";
22
28
 
23
29
  export type QueryDiagnoseTargetStatus =
24
30
  | "not_found"
@@ -46,7 +52,7 @@ export interface QueryDiagnoseStage {
46
52
  }
47
53
 
48
54
  export interface QueryDiagnoseResult {
49
- schemaVersion: "1.0";
55
+ schemaVersion: "1.0" | "1.1";
50
56
  query: string;
51
57
  target: {
52
58
  ref: string;
@@ -65,6 +71,7 @@ export interface QueryDiagnoseResult {
65
71
  filterReasons: string[];
66
72
  };
67
73
  stages: QueryDiagnoseStage[];
74
+ affinity?: ProjectAffinityScoreMetadata;
68
75
  chunk: {
69
76
  seq: number | null;
70
77
  startLine: number | null;
@@ -155,6 +162,15 @@ function findTargetCandidate(
155
162
  );
156
163
  }
157
164
 
165
+ const hasTrustedProjectAffinityInput = (
166
+ input: ProjectAffinityScoringInput | undefined
167
+ ): boolean =>
168
+ input?.enabled !== false &&
169
+ Boolean(
170
+ input?.resolution.matches.length ||
171
+ input?.resolution.roots.some((root) => root.source !== "remote_hint")
172
+ );
173
+
158
174
  export async function diagnoseQueryTarget(
159
175
  deps: HybridSearchDeps,
160
176
  query: string,
@@ -276,8 +292,28 @@ export async function diagnoseQueryTarget(
276
292
  chunks.find((chunk) => chunk.seq === firstMatched?.seq) ??
277
293
  chunks[0] ??
278
294
  null;
295
+ const targetResult = searchResult.value.results.find(
296
+ (result) => result.uri === doc.uri
297
+ );
298
+ const lastMatched = trace?.stages
299
+ .toReversed()
300
+ .flatMap((stage) => stage.candidates)
301
+ .find(
302
+ (candidate) =>
303
+ candidate.mirrorHash === doc.mirrorHash && targetSeqs.has(candidate.seq)
304
+ );
305
+ const affinity =
306
+ (targetResult ? getProjectAffinityMetadata(targetResult) : undefined) ??
307
+ (lastMatched && options.projectAffinity
308
+ ? scoreProjectAffinity(
309
+ lastMatched.score,
310
+ doc.collection,
311
+ options.projectAffinity,
312
+ { kind: "hybrid_blended", score: lastMatched.score }
313
+ )
314
+ : null);
279
315
 
280
- return ok({
316
+ const baseResult: QueryDiagnoseResult = {
281
317
  ...buildBaseResult(query, options.target, "diagnosed", doc, {
282
318
  graphHints,
283
319
  chunkCount: chunks.length,
@@ -298,5 +334,14 @@ export async function diagnoseQueryTarget(
298
334
  totalResults: searchResult.value.meta.totalResults,
299
335
  queryModes: searchResult.value.meta.queryModes,
300
336
  },
301
- });
337
+ };
338
+ return ok(
339
+ affinity && hasTrustedProjectAffinityInput(options.projectAffinity)
340
+ ? {
341
+ ...baseResult,
342
+ schemaVersion: "1.1",
343
+ affinity,
344
+ }
345
+ : baseResult
346
+ );
302
347
  }
@@ -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
+ }