@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
@@ -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
  }
@@ -17,6 +17,7 @@ import { createChunkLookup } from "./chunk-lookup";
17
17
  import { formatQueryForEmbedding } from "./contextual";
18
18
  import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
19
19
  import { selectBestChunkForSteering } from "./intent";
20
+ import { applyProjectAffinity, hasProjectAffinity } from "./project-affinity";
20
21
  import { detectQueryLanguage } from "./query-language";
21
22
  import { attachSearchResultContexts } from "./result-context";
22
23
  import {
@@ -82,8 +83,9 @@ export async function searchVectorWithEmbedding(
82
83
  const { store, vectorIndex } = deps;
83
84
  const limit = options.limit ?? 20;
84
85
  const minScore = options.minScore ?? 0;
86
+ const affinityActive = hasProjectAffinity(options.projectAffinity);
85
87
  const recencySort = shouldSortByRecency(query);
86
- const retrievalLimit = recencySort ? limit * 3 : limit;
88
+ const retrievalLimit = recencySort || affinityActive ? limit * 3 : limit;
87
89
  const temporalRange = resolveTemporalRange(
88
90
  query,
89
91
  options.since,
@@ -104,7 +106,7 @@ export async function searchVectorWithEmbedding(
104
106
  queryEmbedding,
105
107
  retrievalLimit,
106
108
  {
107
- minScore,
109
+ minScore: affinityActive ? undefined : minScore,
108
110
  allowedMirrorHashes: options.retrievalScope?.allowedMirrorHashes,
109
111
  }
110
112
  );
@@ -126,7 +128,7 @@ export async function searchVectorWithEmbedding(
126
128
  }
127
129
 
128
130
  // Cache docs to avoid N+1 queries (filtered by collection and tags)
129
- const docByMirrorHash = await buildDocumentMap(store, {
131
+ const docsByMirrorHash = await buildDocumentMap(store, {
130
132
  collection: options.collection,
131
133
  relPathPrefix: options.retrievalScope?.relPathPrefix,
132
134
  tagsAll: options.tagsAll,
@@ -152,12 +154,18 @@ export async function searchVectorWithEmbedding(
152
154
  // For --full, track best score per docid to de-dupe
153
155
  const bestByDocid = new Map<
154
156
  string,
155
- { doc: DocumentInfo; chunk: ChunkInfo; score: number }
157
+ {
158
+ doc: DocumentInfo;
159
+ chunk: ChunkInfo;
160
+ rankingScore: number;
161
+ rawDistance: number;
162
+ score: number;
163
+ }
156
164
  >();
157
165
 
158
166
  for (const vec of vecResults) {
159
- const score = normalizeVectorScore(vec.distance);
160
- if (score < minScore) {
167
+ const baseScore = normalizeVectorScore(vec.distance);
168
+ if (!affinityActive && baseScore < minScore) {
161
169
  continue;
162
170
  }
163
171
 
@@ -184,56 +192,36 @@ export async function searchVectorWithEmbedding(
184
192
  }
185
193
 
186
194
  // Get document (cached)
187
- const doc = docByMirrorHash.get(vec.mirrorHash);
188
- if (!doc) {
189
- continue;
190
- }
191
-
192
- const excluded =
193
- matchesExcludedText(
194
- [
195
- doc.title ?? "",
196
- doc.relPath,
197
- doc.author ?? "",
198
- doc.contentType ?? "",
199
- ...(doc.categories ?? []),
200
- ],
201
- options.exclude
202
- ) ||
203
- matchesExcludedChunks(
204
- chunksMap.get(vec.mirrorHash) ?? [],
205
- options.exclude
206
- );
207
- if (excluded) {
195
+ const matchingDocs = docsByMirrorHash.get(vec.mirrorHash);
196
+ if (!matchingDocs || matchingDocs.length === 0) {
208
197
  continue;
209
198
  }
210
-
211
- // For --full, de-dupe by docid (keep best scoring chunk per doc)
212
- if (options.full) {
213
- const existing = bestByDocid.get(doc.docid);
214
- if (!existing || score > existing.score) {
215
- bestByDocid.set(doc.docid, {
216
- doc,
217
- chunk: {
218
- text: chunk.text,
219
- language: chunk.language,
220
- startLine: chunk.startLine,
221
- endLine: chunk.endLine,
222
- seq: chunk.seq,
223
- },
224
- score,
225
- });
199
+ const docs = affinityActive ? matchingDocs : [matchingDocs.at(-1)!];
200
+ for (const doc of docs) {
201
+ const collectionPath = collectionPaths.get(doc.collection);
202
+ const excluded =
203
+ matchesExcludedText(
204
+ [
205
+ doc.title ?? "",
206
+ doc.relPath,
207
+ doc.author ?? "",
208
+ doc.contentType ?? "",
209
+ ...(doc.categories ?? []),
210
+ ],
211
+ options.exclude
212
+ ) ||
213
+ matchesExcludedChunks(
214
+ chunksMap.get(vec.mirrorHash) ?? [],
215
+ options.exclude
216
+ );
217
+ if (excluded) {
218
+ continue;
226
219
  }
227
- continue;
228
- }
229
220
 
230
- const collectionPath = collectionPaths.get(doc.collection);
231
-
232
- results.push(
233
- attachSearchResultPlannerMetadata(
221
+ const scoredResult = applyProjectAffinity(
234
222
  {
235
223
  docid: doc.docid,
236
- score,
224
+ score: baseScore,
237
225
  uri: doc.uri,
238
226
  title: doc.title ?? undefined,
239
227
  contentType: doc.contentType ?? undefined,
@@ -265,7 +253,35 @@ export async function searchVectorWithEmbedding(
265
253
  }
266
254
  : undefined,
267
255
  },
268
- {
256
+ doc.collection,
257
+ options.projectAffinity,
258
+ { kind: "vector_distance", score: vec.distance }
259
+ );
260
+ if (scoredResult.score < minScore) continue;
261
+
262
+ // For --full, de-dupe by docid (keep best scoring chunk per doc)
263
+ if (options.full) {
264
+ const existing = bestByDocid.get(doc.docid);
265
+ if (!existing || scoredResult.score > existing.rankingScore) {
266
+ bestByDocid.set(doc.docid, {
267
+ doc,
268
+ chunk: {
269
+ text: chunk.text,
270
+ language: chunk.language,
271
+ startLine: chunk.startLine,
272
+ endLine: chunk.endLine,
273
+ seq: chunk.seq,
274
+ },
275
+ rankingScore: scoredResult.score,
276
+ rawDistance: vec.distance,
277
+ score: baseScore,
278
+ });
279
+ }
280
+ continue;
281
+ }
282
+
283
+ results.push(
284
+ attachSearchResultPlannerMetadata(scoredResult, {
269
285
  retrievalRank: 0,
270
286
  mirrorHash: vec.mirrorHash,
271
287
  seq: chunk.seq,
@@ -276,9 +292,9 @@ export async function searchVectorWithEmbedding(
276
292
  passageHash: new Bun.CryptoHasher("sha256")
277
293
  .update(chunk.text)
278
294
  .digest("hex"),
279
- }
280
- )
281
- );
295
+ })
296
+ );
297
+ }
282
298
  }
283
299
 
284
300
  // For --full, fetch full content and build results
@@ -294,47 +310,52 @@ export async function searchVectorWithEmbedding(
294
310
  }
295
311
  const fullContentByHash = fullContentResult.value;
296
312
 
297
- for (const { doc, chunk, score } of bestByDocid.values()) {
313
+ for (const { doc, chunk, rawDistance, score } of bestByDocid.values()) {
298
314
  const fullContent = doc.mirrorHash
299
315
  ? fullContentByHash.get(doc.mirrorHash)
300
316
  : undefined;
301
317
 
302
318
  const collectionPath = collectionPaths.get(doc.collection);
303
319
 
304
- const result: SearchResult = {
305
- docid: doc.docid,
306
- score,
307
- uri: doc.uri,
308
- title: doc.title ?? undefined,
309
- contentType: doc.contentType ?? undefined,
310
- categories: doc.categories ?? undefined,
311
- line: chunk.startLine,
312
- snippet: fullContent ?? chunk.text,
313
- snippetLanguage: chunk.language ?? undefined,
314
- // --full: no snippetRange (full doc content)
315
- snippetRange: fullContent
316
- ? undefined
317
- : { startLine: chunk.startLine, endLine: chunk.endLine },
318
- source: {
319
- relPath: doc.relPath,
320
- absPath: collectionPath
321
- ? `${collectionPath}/${doc.relPath}`
320
+ const result = applyProjectAffinity(
321
+ {
322
+ docid: doc.docid,
323
+ score,
324
+ uri: doc.uri,
325
+ title: doc.title ?? undefined,
326
+ contentType: doc.contentType ?? undefined,
327
+ categories: doc.categories ?? undefined,
328
+ line: chunk.startLine,
329
+ snippet: fullContent ?? chunk.text,
330
+ snippetLanguage: chunk.language ?? undefined,
331
+ // --full: no snippetRange (full doc content)
332
+ snippetRange: fullContent
333
+ ? undefined
334
+ : { startLine: chunk.startLine, endLine: chunk.endLine },
335
+ source: {
336
+ relPath: doc.relPath,
337
+ absPath: collectionPath
338
+ ? `${collectionPath}/${doc.relPath}`
339
+ : undefined,
340
+ mime: doc.sourceMime,
341
+ ext: doc.sourceExt,
342
+ modifiedAt: doc.sourceMtime,
343
+ documentDate: doc.frontmatterDate ?? undefined,
344
+ sizeBytes: doc.sourceSize,
345
+ sourceHash: doc.sourceHash,
346
+ },
347
+ conversion: doc.mirrorHash
348
+ ? {
349
+ mirrorHash: doc.mirrorHash,
350
+ converterId: doc.converterId ?? undefined,
351
+ converterVersion: doc.converterVersion ?? undefined,
352
+ }
322
353
  : undefined,
323
- mime: doc.sourceMime,
324
- ext: doc.sourceExt,
325
- modifiedAt: doc.sourceMtime,
326
- documentDate: doc.frontmatterDate ?? undefined,
327
- sizeBytes: doc.sourceSize,
328
- sourceHash: doc.sourceHash,
329
354
  },
330
- conversion: doc.mirrorHash
331
- ? {
332
- mirrorHash: doc.mirrorHash,
333
- converterId: doc.converterId ?? undefined,
334
- converterVersion: doc.converterVersion ?? undefined,
335
- }
336
- : undefined,
337
- };
355
+ doc.collection,
356
+ options.projectAffinity,
357
+ { kind: "vector_distance", score: rawDistance }
358
+ );
338
359
  results.push(
339
360
  doc.mirrorHash
340
361
  ? attachSearchResultPlannerMetadata(result, {
@@ -369,6 +390,8 @@ export async function searchVectorWithEmbedding(
369
390
  }
370
391
  return b.score - a.score;
371
392
  });
393
+ } else if (affinityActive) {
394
+ results.sort((a, b) => b.score - a.score);
372
395
  }
373
396
 
374
397
  const finalResults = results.slice(0, limit);
@@ -507,8 +530,8 @@ function matchesCategoryFilter(
507
530
  async function buildDocumentMap(
508
531
  store: StorePort,
509
532
  options: DocumentMapOptions = {}
510
- ): Promise<Map<string, DocumentInfo>> {
511
- const result = new Map<string, DocumentInfo>();
533
+ ): Promise<Map<string, DocumentInfo[]>> {
534
+ const result = new Map<string, DocumentInfo[]>();
512
535
 
513
536
  if (options.mirrorHashes && options.mirrorHashes.length === 0) {
514
537
  return result;
@@ -594,7 +617,7 @@ async function buildDocumentMap(
594
617
  continue;
595
618
  }
596
619
 
597
- result.set(doc.mirrorHash!, {
620
+ const documentInfo: DocumentInfo = {
598
621
  docid: doc.docid,
599
622
  uri: doc.uri,
600
623
  title: doc.title,
@@ -612,7 +635,10 @@ async function buildDocumentMap(
612
635
  mirrorHash: doc.mirrorHash,
613
636
  converterId: doc.converterId,
614
637
  converterVersion: doc.converterVersion,
615
- });
638
+ };
639
+ const matchingDocuments = result.get(doc.mirrorHash!) ?? [];
640
+ matchingDocuments.push(documentInfo);
641
+ result.set(doc.mirrorHash!, matchingDocuments);
616
642
  }
617
643
 
618
644
  return result;