@gmickel/gno 1.22.0 → 1.24.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 (59) hide show
  1. package/README.md +33 -12
  2. package/assets/skill/SKILL.md +41 -19
  3. package/package.json +1 -1
  4. package/spec/cli.md +127 -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/spec/output-schemas/setup-activation-result.schema.json +456 -0
  11. package/spec/output-schemas/setup-command-result.schema.json +93 -0
  12. package/spec/output-schemas/setup-receipt.schema.json +258 -0
  13. package/spec/output-schemas/setup-semantic-receipt.schema.json +195 -0
  14. package/src/app/context-runtime-types.ts +3 -0
  15. package/src/app/context-runtime.ts +1 -0
  16. package/src/app/context-surface.ts +4 -2
  17. package/src/cli/commands/ask.ts +31 -20
  18. package/src/cli/commands/completion/scripts.ts +2 -0
  19. package/src/cli/commands/context-build.ts +17 -7
  20. package/src/cli/commands/embed.ts +7 -2
  21. package/src/cli/commands/query.ts +58 -37
  22. package/src/cli/commands/search.ts +29 -19
  23. package/src/cli/commands/setup-activation.ts +324 -0
  24. package/src/cli/commands/setup-semantic.ts +591 -0
  25. package/src/cli/commands/setup.ts +410 -0
  26. package/src/cli/commands/vsearch.ts +31 -22
  27. package/src/cli/options.ts +39 -0
  28. package/src/cli/program.ts +112 -0
  29. package/src/cli/setup-semantic-worker.ts +177 -0
  30. package/src/config/defaults.ts +10 -1
  31. package/src/config/types.ts +71 -0
  32. package/src/core/config-mutation.ts +94 -64
  33. package/src/core/file-lock.ts +70 -31
  34. package/src/core/folder-setup-planning.ts +453 -0
  35. package/src/core/folder-setup.ts +490 -0
  36. package/src/core/project-affinity-surface.ts +114 -0
  37. package/src/core/project-affinity.ts +330 -0
  38. package/src/core/setup-activation.ts +309 -0
  39. package/src/core/setup-receipt.ts +321 -0
  40. package/src/core/validation.ts +20 -1
  41. package/src/mcp/tools/ask.ts +10 -1
  42. package/src/mcp/tools/context.ts +18 -0
  43. package/src/mcp/tools/index.ts +13 -2
  44. package/src/mcp/tools/query.ts +12 -0
  45. package/src/mcp/tools/search.ts +7 -0
  46. package/src/mcp/tools/vsearch.ts +7 -0
  47. package/src/pipeline/diagnose.ts +48 -3
  48. package/src/pipeline/explain.ts +54 -13
  49. package/src/pipeline/hybrid.ts +100 -59
  50. package/src/pipeline/project-affinity.ts +162 -0
  51. package/src/pipeline/search.ts +76 -10
  52. package/src/pipeline/types.ts +9 -0
  53. package/src/pipeline/vsearch.ts +117 -91
  54. package/src/sdk/client.ts +80 -20
  55. package/src/sdk/index.ts +2 -0
  56. package/src/sdk/types.ts +20 -7
  57. package/src/serve/connectors.ts +29 -2
  58. package/src/serve/context-capsule.ts +18 -1
  59. package/src/serve/routes/api.ts +69 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Canonical, privacy-bounded receipts for resumable folder setup.
3
+ *
4
+ * @module src/core/setup-receipt
5
+ */
6
+
7
+ // node:fs/promises provides private file creation, directory permissions, and atomic rename APIs that Bun does not expose.
8
+ import { chmod, mkdir, open, rename, unlink } from "node:fs/promises";
9
+ // node:path has no Bun equivalent.
10
+ import { dirname, join } from "node:path";
11
+
12
+ import type { ActivationVerificationReceipt } from "../store/types";
13
+
14
+ import { canonicalizeIndexName } from "../app/index-name";
15
+
16
+ export const SETUP_RECEIPT_SCHEMA_VERSION = "1.0" as const;
17
+
18
+ export const SETUP_STAGE_NAMES = [
19
+ "preflight",
20
+ "config_saved",
21
+ "store_synced",
22
+ "lexical_indexed",
23
+ "lexical_proved",
24
+ "completed",
25
+ ] as const;
26
+
27
+ export type SetupStageName = (typeof SETUP_STAGE_NAMES)[number];
28
+ export type SetupStageStatus = "pending" | "in_progress" | "passed" | "failed";
29
+
30
+ export interface SetupStageReceipt {
31
+ status: SetupStageStatus;
32
+ token: string | null;
33
+ startedAt: string | null;
34
+ completedAt: string | null;
35
+ code: string | null;
36
+ remediation: string | null;
37
+ }
38
+
39
+ export interface SetupFailure {
40
+ stage: SetupStageName;
41
+ code: string;
42
+ message: string;
43
+ remediation: string;
44
+ }
45
+
46
+ export interface FolderSetupReceipt {
47
+ schemaVersion: typeof SETUP_RECEIPT_SCHEMA_VERSION;
48
+ status: "in_progress" | "failed" | "completed";
49
+ generatedAt: string;
50
+ input: {
51
+ folder: string;
52
+ folderFingerprint: string;
53
+ indexName: string;
54
+ requestedName: string | null;
55
+ excludes: string[];
56
+ secretRiskAuthorized: boolean;
57
+ };
58
+ fingerprints: {
59
+ input: string;
60
+ config: string | null;
61
+ index: string | null;
62
+ };
63
+ collection: {
64
+ name: string | null;
65
+ path: string;
66
+ disposition: "pending" | "created" | "reused";
67
+ };
68
+ paths: {
69
+ config: string;
70
+ receipt: string;
71
+ };
72
+ stages: Record<SetupStageName, SetupStageReceipt>;
73
+ pending: string[];
74
+ failure: SetupFailure | null;
75
+ activation: ActivationVerificationReceipt | null;
76
+ }
77
+
78
+ type CanonicalJson =
79
+ | boolean
80
+ | null
81
+ | number
82
+ | string
83
+ | CanonicalJson[]
84
+ | { [key: string]: CanonicalJson };
85
+
86
+ function canonicalize(value: unknown): CanonicalJson {
87
+ if (
88
+ value === null ||
89
+ typeof value === "boolean" ||
90
+ typeof value === "number" ||
91
+ typeof value === "string"
92
+ ) {
93
+ return value;
94
+ }
95
+ if (Array.isArray(value)) {
96
+ return value.map((item) => canonicalize(item));
97
+ }
98
+ if (typeof value === "object") {
99
+ const output: Record<string, CanonicalJson> = {};
100
+ for (const key of Object.keys(value).sort()) {
101
+ const item = (value as Record<string, unknown>)[key];
102
+ if (item !== undefined) {
103
+ output[key] = canonicalize(item);
104
+ }
105
+ }
106
+ return output;
107
+ }
108
+ throw new TypeError(`Unsupported canonical JSON value: ${typeof value}`);
109
+ }
110
+
111
+ export function serializeSetupReceipt(receipt: FolderSetupReceipt): string {
112
+ return `${JSON.stringify(canonicalize(receipt), null, 2)}\n`;
113
+ }
114
+
115
+ export function setupFingerprint(value: unknown): string {
116
+ return new Bun.CryptoHasher("sha256")
117
+ .update(JSON.stringify(canonicalize(value)))
118
+ .digest("hex");
119
+ }
120
+
121
+ export function setupRootFingerprint(folderRealpath: string): string {
122
+ return new Bun.CryptoHasher("sha256").update(folderRealpath).digest("hex");
123
+ }
124
+
125
+ export function getSetupReceiptPath(input: {
126
+ dataDir: string;
127
+ indexName: string;
128
+ folderRealpath: string;
129
+ }): string {
130
+ const indexIdentity = canonicalizeIndexName(input.indexName);
131
+ const rootFingerprint = setupRootFingerprint(input.folderRealpath);
132
+ return join(
133
+ input.dataDir,
134
+ "setup-receipts",
135
+ indexIdentity,
136
+ `${rootFingerprint}.json`
137
+ );
138
+ }
139
+
140
+ export function createSetupReceipt(input: {
141
+ now: string;
142
+ folder: string;
143
+ indexName: string;
144
+ requestedName?: string;
145
+ excludes: string[];
146
+ secretRiskAuthorized: boolean;
147
+ configPath: string;
148
+ dataDir: string;
149
+ }): FolderSetupReceipt {
150
+ const indexName = canonicalizeIndexName(input.indexName);
151
+ const folderFingerprint = setupRootFingerprint(input.folder);
152
+ const receiptPath = getSetupReceiptPath({
153
+ dataDir: input.dataDir,
154
+ indexName,
155
+ folderRealpath: input.folder,
156
+ });
157
+ const stages = Object.fromEntries(
158
+ SETUP_STAGE_NAMES.map((stage) => [
159
+ stage,
160
+ {
161
+ status: "pending",
162
+ token: null,
163
+ startedAt: null,
164
+ completedAt: null,
165
+ code: null,
166
+ remediation: null,
167
+ },
168
+ ])
169
+ ) as Record<SetupStageName, SetupStageReceipt>;
170
+ const requestedName = input.requestedName?.trim().toLowerCase() || null;
171
+
172
+ return {
173
+ schemaVersion: SETUP_RECEIPT_SCHEMA_VERSION,
174
+ status: "in_progress",
175
+ generatedAt: input.now,
176
+ input: {
177
+ folder: input.folder,
178
+ folderFingerprint,
179
+ indexName,
180
+ requestedName,
181
+ excludes: [...new Set(input.excludes)].sort(),
182
+ secretRiskAuthorized: input.secretRiskAuthorized,
183
+ },
184
+ fingerprints: {
185
+ input: setupFingerprint({
186
+ folder: input.folder,
187
+ indexName,
188
+ requestedName,
189
+ excludes: [...new Set(input.excludes)].sort(),
190
+ secretRiskAuthorized: input.secretRiskAuthorized,
191
+ }),
192
+ config: null,
193
+ index: null,
194
+ },
195
+ collection: {
196
+ name: null,
197
+ path: input.folder,
198
+ disposition: "pending",
199
+ },
200
+ paths: {
201
+ config: input.configPath,
202
+ receipt: receiptPath,
203
+ },
204
+ stages,
205
+ pending: [],
206
+ failure: null,
207
+ activation: null,
208
+ };
209
+ }
210
+
211
+ export function startSetupStage(
212
+ receipt: FolderSetupReceipt,
213
+ stage: SetupStageName,
214
+ now: string
215
+ ): void {
216
+ receipt.status = "in_progress";
217
+ receipt.generatedAt = now;
218
+ receipt.failure = null;
219
+ receipt.stages[stage] = {
220
+ status: "in_progress",
221
+ token: setupFingerprint({
222
+ receipt: receipt.input.folderFingerprint,
223
+ stage,
224
+ startedAt: now,
225
+ }),
226
+ startedAt: now,
227
+ completedAt: null,
228
+ code: null,
229
+ remediation: null,
230
+ };
231
+ }
232
+
233
+ export function passSetupStage(
234
+ receipt: FolderSetupReceipt,
235
+ stage: SetupStageName,
236
+ now: string
237
+ ): void {
238
+ receipt.generatedAt = now;
239
+ receipt.stages[stage] = {
240
+ ...receipt.stages[stage],
241
+ status: "passed",
242
+ completedAt: now,
243
+ code: null,
244
+ remediation: null,
245
+ };
246
+ }
247
+
248
+ export function failSetupStage(
249
+ receipt: FolderSetupReceipt,
250
+ failure: SetupFailure,
251
+ now: string
252
+ ): void {
253
+ const current = receipt.stages[failure.stage];
254
+ receipt.status = "failed";
255
+ receipt.generatedAt = now;
256
+ receipt.failure = failure;
257
+ if (current.status !== "passed") {
258
+ receipt.stages[failure.stage] = {
259
+ ...current,
260
+ status: "failed",
261
+ token:
262
+ current.token ??
263
+ setupFingerprint({
264
+ receipt: receipt.input.folderFingerprint,
265
+ stage: failure.stage,
266
+ startedAt: now,
267
+ }),
268
+ startedAt: current.startedAt ?? now,
269
+ completedAt: now,
270
+ code: failure.code,
271
+ remediation: failure.remediation,
272
+ };
273
+ }
274
+ }
275
+
276
+ export async function persistSetupReceipt(
277
+ receipt: FolderSetupReceipt
278
+ ): Promise<void> {
279
+ const receiptDir = dirname(receipt.paths.receipt);
280
+ await mkdir(receiptDir, { recursive: true, mode: 0o700 });
281
+ await chmod(receiptDir, 0o700);
282
+
283
+ const tempPath = `${receipt.paths.receipt}.tmp.${crypto.randomUUID()}`;
284
+ let tempFile: Awaited<ReturnType<typeof open>> | null = null;
285
+ try {
286
+ tempFile = await open(tempPath, "wx", 0o600);
287
+ await tempFile.writeFile(serializeSetupReceipt(receipt), "utf8");
288
+ await tempFile.sync();
289
+ await tempFile.close();
290
+ tempFile = null;
291
+ await rename(tempPath, receipt.paths.receipt);
292
+ await chmod(receipt.paths.receipt, 0o600);
293
+ } catch (error) {
294
+ await tempFile?.close().catch(() => {
295
+ /* best-effort temporary receipt handle cleanup */
296
+ });
297
+ await unlink(tempPath).catch(() => {
298
+ /* best-effort temporary receipt cleanup */
299
+ });
300
+ throw error;
301
+ }
302
+ }
303
+
304
+ export async function loadSetupReceipt(
305
+ path: string
306
+ ): Promise<FolderSetupReceipt | null> {
307
+ const file = Bun.file(path);
308
+ if (!(await file.exists())) {
309
+ return null;
310
+ }
311
+ const value: unknown = await file.json();
312
+ if (
313
+ typeof value !== "object" ||
314
+ value === null ||
315
+ !("schemaVersion" in value) ||
316
+ value.schemaVersion !== SETUP_RECEIPT_SCHEMA_VERSION
317
+ ) {
318
+ return null;
319
+ }
320
+ return value as FolderSetupReceipt;
321
+ }
@@ -9,7 +9,7 @@ import { realpath } from "node:fs/promises";
9
9
  // node:os for homedir (no Bun os utils)
10
10
  import { homedir } from "node:os";
11
11
  // node:path for path utils (no Bun path utils)
12
- import { isAbsolute, join, posix as pathPosix } from "node:path";
12
+ import { isAbsolute, join, posix as pathPosix, relative, sep } from "node:path";
13
13
 
14
14
  import { toAbsolutePath } from "../config/paths";
15
15
 
@@ -58,6 +58,25 @@ export function validateRelPath(relPath: string): string {
58
58
  return normalized;
59
59
  }
60
60
 
61
+ /**
62
+ * Return whether candidate is equal to or nested beneath parent.
63
+ *
64
+ * Inputs must already be canonical absolute paths. `relative()` keeps this
65
+ * segment-safe, unlike string-prefix checks (`/project` vs `/project-old`).
66
+ */
67
+ export function isCanonicalPathContained(
68
+ parent: string,
69
+ candidate: string
70
+ ): boolean {
71
+ const relativePath = relative(parent, candidate);
72
+ return (
73
+ relativePath === "" ||
74
+ (relativePath !== ".." &&
75
+ !relativePath.startsWith(`..${sep}`) &&
76
+ !isAbsolute(relativePath))
77
+ );
78
+ }
79
+
61
80
  export async function validateCollectionRoot(
62
81
  inputPath: string
63
82
  ): Promise<string> {
@@ -8,6 +8,7 @@ import type { ToolContext } from "../server";
8
8
  import type { ToolResult } from "./index";
9
9
 
10
10
  import { buildVerifiedAsk } from "../../app/verified-ask";
11
+ import { resolveRemoteProjectAffinity } from "../../core/project-affinity-surface";
11
12
  import {
12
13
  finishRetrievalTraceAfterError,
13
14
  retrievalTraceFilters,
@@ -30,6 +31,7 @@ const queryModeSchema = z
30
31
  export const askInputSchema = z
31
32
  .object({
32
33
  query: z.string().trim().min(1),
34
+ projectHints: z.array(z.string()).max(16).optional(),
33
35
  verify: z.literal(true),
34
36
  collection: z.string().optional(),
35
37
  limit: z.number().int().min(1).max(100).default(5),
@@ -153,8 +155,14 @@ export const handleAsk = (
153
155
  );
154
156
  if (!normalized.ok) throw new Error(normalized.error.message);
155
157
  const query = normalized.value.query;
158
+ const { projectHints, ...askInput } = args;
159
+ const projectAffinity = await resolveRemoteProjectAffinity(
160
+ context.config,
161
+ projectHints
162
+ );
156
163
  const options = {
157
- ...args,
164
+ ...askInput,
165
+ projectAffinity,
158
166
  queryModes:
159
167
  normalized.value.queryModes.length > 0
160
168
  ? normalized.value.queryModes
@@ -210,6 +218,7 @@ export const handleAsk = (
210
218
  embedPort: modelPorts.embedPort,
211
219
  rerankPort: modelPorts.rerankPort,
212
220
  genPort: modelPorts.genPort,
221
+ projectAffinity,
213
222
  traceSession,
214
223
  });
215
224
  const finished = await traceSession?.finish(
@@ -25,6 +25,11 @@ import {
25
25
  parseContextVerifySurfaceInput,
26
26
  } from "../../app/context-surface";
27
27
  import { createNonTtyProgressRenderer } from "../../cli/progress";
28
+ import { ContextCapsuleContractError } from "../../core/context-capsule";
29
+ import {
30
+ ProjectAffinityInputError,
31
+ resolveRemoteProjectAffinity,
32
+ } from "../../core/project-affinity-surface";
28
33
  import {
29
34
  finishRetrievalTraceAfterError,
30
35
  startRetrievalTraceRequest,
@@ -224,6 +229,18 @@ export const handleContext = (
224
229
  null;
225
230
  let traceSession: RetrievalTraceSession | undefined;
226
231
  try {
232
+ let projectAffinity;
233
+ try {
234
+ projectAffinity = await resolveRemoteProjectAffinity(
235
+ context.config,
236
+ parsed.projectHints
237
+ );
238
+ } catch (error) {
239
+ if (error instanceof ProjectAffinityInputError) {
240
+ throw new ContextCapsuleContractError("invalid_input", error.message);
241
+ }
242
+ throw error;
243
+ }
227
244
  const traceStart = await startRetrievalTraceRequest({
228
245
  store: context.store,
229
246
  config: context.config,
@@ -261,6 +278,7 @@ export const handleContext = (
261
278
  vectorIndex: modelPorts?.vectorIndex ?? null,
262
279
  embedPort: modelPorts?.embedPort ?? null,
263
280
  rerankPort: modelPorts?.rerankPort ?? null,
281
+ projectAffinity,
264
282
  traceSession,
265
283
  });
266
284
  const finalized = await traceSession?.finish("completed");
@@ -131,13 +131,22 @@ export const MCP_WRITE_TOOL_NAMES = new Set([
131
131
  // Shared Input Schemas
132
132
  // ─────────────────────────────────────────────────────────────────────────────
133
133
 
134
- const searchInputSchema = z.object({
134
+ const projectHintsInputSchema = z
135
+ .array(z.string())
136
+ .max(16)
137
+ .optional()
138
+ .describe(
139
+ "Opaque caller project hints for cross-surface parity; remote hints never inspect server paths"
140
+ );
141
+
142
+ export const searchInputSchema = z.object({
135
143
  query: z
136
144
  .string()
137
145
  .min(1, "Query cannot be empty")
138
146
  .describe(
139
147
  "Exact keyword, identifier, filename, error text, or phrase to match with BM25"
140
148
  ),
149
+ projectHints: projectHintsInputSchema,
141
150
  collection: z
142
151
  .string()
143
152
  .optional()
@@ -378,13 +387,14 @@ const duplicateNoteInputSchema = z.object({
378
387
  name: z.string().optional(),
379
388
  });
380
389
 
381
- const vsearchInputSchema = z.object({
390
+ export const vsearchInputSchema = z.object({
382
391
  query: z
383
392
  .string()
384
393
  .min(1, "Query cannot be empty")
385
394
  .describe(
386
395
  "Natural-language concept to match semantically; use gno_search for exact error text or identifiers"
387
396
  ),
397
+ projectHints: projectHintsInputSchema,
388
398
  collection: z
389
399
  .string()
390
400
  .optional()
@@ -455,6 +465,7 @@ export const queryInputSchema = z.object({
455
465
  .describe(
456
466
  "Primary user query; combine with intent or queryModes for ambiguous requests"
457
467
  ),
468
+ projectHints: projectHintsInputSchema,
458
469
  collection: z
459
470
  .string()
460
471
  .optional()
@@ -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
  }