@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
@@ -8,7 +8,7 @@ import type { Collection } from "../config/types";
8
8
  import type { DocumentRow, StorePort, TagRow } from "../store/types";
9
9
 
10
10
  import { parseRef } from "../core/ref-parser";
11
- import { parseFrontmatter } from "../ingestion/frontmatter";
11
+ import { parseFrontmatter, stripFrontmatter } from "../ingestion/frontmatter";
12
12
  import { getContentBatch } from "../store/content-batch";
13
13
  import {
14
14
  buildEncryptedPublishArtifact,
@@ -186,11 +186,11 @@ async function exportCollectionArtifact(
186
186
  if (isPublishDisabledByFrontmatter(rawMarkdown)) {
187
187
  continue;
188
188
  }
189
+ const frontmatter = parseFrontmatter(rawMarkdown).metadata;
189
190
  const sanitized = sanitizeObsidianMarkdown(rawMarkdown);
190
191
  warnings.push(...sanitized.warnings);
191
- const markdown = sanitized.markdown;
192
+ const markdown = stripFrontmatter(sanitized.markdown);
192
193
  const tags = tagsByDocId.get(doc.id) ?? [];
193
- const frontmatter = parseFrontmatter(markdown).metadata;
194
194
  const title = deriveExportedTitle(doc);
195
195
  notes.push({
196
196
  markdown,
@@ -240,7 +240,6 @@ async function exportCollectionArtifact(
240
240
  encryptedPayload: encrypted.encryptedPayload,
241
241
  routeSlug,
242
242
  secretToken: encrypted.secretToken,
243
- source: collection.name,
244
243
  sourceType: "collection",
245
244
  });
246
245
  }
@@ -249,7 +248,6 @@ async function exportCollectionArtifact(
249
248
  homeNoteSlug: chooseHomeNoteSlug(notes),
250
249
  notes,
251
250
  routeSlug,
252
- source: collection.name,
253
251
  sourceType: "collection",
254
252
  summary,
255
253
  title,
@@ -274,11 +272,11 @@ async function exportDocumentArtifact(
274
272
  `Refused to export: ${doc.uri} has publish: false in frontmatter`
275
273
  );
276
274
  }
275
+ const frontmatter = parseFrontmatter(rawMarkdown).metadata;
277
276
  const sanitized = sanitizeObsidianMarkdown(rawMarkdown);
278
277
  warnings.push(...sanitized.warnings);
279
- const markdown = sanitized.markdown;
278
+ const markdown = stripFrontmatter(sanitized.markdown);
280
279
  const tags = await loadDocumentTags(store, doc);
281
- const frontmatter = parseFrontmatter(markdown).metadata;
282
280
  const title = options.title ?? deriveExportedTitle(doc);
283
281
  const summary =
284
282
  options.summary ?? deriveExportedSummary(markdown, frontmatter);
@@ -315,7 +313,6 @@ async function exportDocumentArtifact(
315
313
  encryptedPayload: encrypted.encryptedPayload,
316
314
  routeSlug,
317
315
  secretToken: encrypted.secretToken,
318
- source: doc.uri,
319
316
  sourceType: "note",
320
317
  });
321
318
  }
@@ -331,7 +328,6 @@ async function exportDocumentArtifact(
331
328
  },
332
329
  ],
333
330
  routeSlug,
334
- source: doc.uri,
335
331
  sourceType: "note",
336
332
  summary,
337
333
  title,
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Reader-safe publish metadata projection.
3
+ *
4
+ * @module src/publish/metadata
5
+ */
6
+
7
+ import type { DocumentRow, TagRow } from "../store/types";
8
+
9
+ const ALLOWED_FRONTMATTER_METADATA_KEYS = new Set([
10
+ "audience",
11
+ "canonical",
12
+ "canonicalUrl",
13
+ "canonicalURL",
14
+ "coverAlt",
15
+ "coverImage",
16
+ "icon",
17
+ "image",
18
+ "layout",
19
+ "publishedAt",
20
+ "readingTime",
21
+ "series",
22
+ "seriesOrder",
23
+ "status",
24
+ "subtitle",
25
+ "theme",
26
+ "topic",
27
+ "topics",
28
+ ]);
29
+
30
+ const PUBLIC_URL_METADATA_KEYS = new Set([
31
+ "canonical",
32
+ "canonicalUrl",
33
+ "canonicalURL",
34
+ "coverImage",
35
+ "image",
36
+ ]);
37
+
38
+ const FORBIDDEN_URI_TOKEN_PATTERN =
39
+ /(?:^|[^a-z0-9+.-])(?:file:(?:\/\/)?|gno:\/\/)/iu;
40
+ const LOCAL_PATH_TOKEN_PATTERN =
41
+ /(?:^|[\s([{"'=,:;])(?:~[/\\]|[a-z]:[/\\]|\\\\[^\\/\s]+[/\\]|\/(?:Applications|bin|dev|etc|home|Library|mnt|opt|private|proc|root|srv|sys|System|tmp|Users|usr|var|Volumes)(?:[/\\]|$))/iu;
42
+ const LOCAL_HOSTNAME_SUFFIX_PATTERN =
43
+ /(?:^|\.)(?:home|internal|lan|local|localhost)$/iu;
44
+
45
+ const containsLocalReference = (value: string): boolean =>
46
+ FORBIDDEN_URI_TOKEN_PATTERN.test(value) ||
47
+ LOCAL_PATH_TOKEN_PATTERN.test(value);
48
+
49
+ const isNonPublicIpv4 = (hostname: string): boolean => {
50
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(hostname)) return false;
51
+ const octets = hostname.split(".").map(Number);
52
+ if (octets.some((octet) => octet > 255)) return true;
53
+ const [first = 0, second = 0, third = 0] = octets;
54
+ return (
55
+ first === 0 ||
56
+ first === 10 ||
57
+ first === 127 ||
58
+ first >= 224 ||
59
+ (first === 100 && second >= 64 && second <= 127) ||
60
+ (first === 169 && second === 254) ||
61
+ (first === 172 && second >= 16 && second <= 31) ||
62
+ (first === 192 && second === 0 && third === 0) ||
63
+ (first === 192 && second === 0 && third === 2) ||
64
+ (first === 192 && second === 168) ||
65
+ (first === 198 && (second === 18 || second === 19)) ||
66
+ (first === 198 && second === 51 && third === 100) ||
67
+ (first === 203 && second === 0 && third === 113)
68
+ );
69
+ };
70
+
71
+ const isNonPublicIpv6 = (hostname: string): boolean => {
72
+ const normalized = hostname.replace(/^\[|\]$/gu, "").toLowerCase();
73
+ if (!normalized.includes(":")) return false;
74
+ return (
75
+ normalized === "::" ||
76
+ normalized.startsWith("::") ||
77
+ normalized.startsWith("fc") ||
78
+ normalized.startsWith("fd") ||
79
+ /^fe[89ab]/u.test(normalized) ||
80
+ normalized.startsWith("ff") ||
81
+ normalized.startsWith("2001:db8:")
82
+ );
83
+ };
84
+
85
+ const isPublicHttpUrl = (value: string): boolean => {
86
+ let url: URL;
87
+ try {
88
+ url = new URL(value);
89
+ } catch {
90
+ return false;
91
+ }
92
+
93
+ if (
94
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
95
+ url.username.length > 0 ||
96
+ url.password.length > 0
97
+ ) {
98
+ return false;
99
+ }
100
+
101
+ const hostname = url.hostname.replace(/\.$/u, "").toLowerCase();
102
+ if (
103
+ hostname.length === 0 ||
104
+ LOCAL_HOSTNAME_SUFFIX_PATTERN.test(hostname) ||
105
+ isNonPublicIpv4(hostname) ||
106
+ isNonPublicIpv6(hostname)
107
+ ) {
108
+ return false;
109
+ }
110
+
111
+ const isIpLiteral =
112
+ hostname.includes(":") || /^\d+(?:\.\d+){3}$/u.test(hostname);
113
+ return isIpLiteral || hostname.includes(".");
114
+ };
115
+
116
+ const isSafeMetadataValue = (key: string, value: string): boolean => {
117
+ if (containsLocalReference(value)) {
118
+ return false;
119
+ }
120
+ if (
121
+ PUBLIC_URL_METADATA_KEYS.has(key) ||
122
+ (key === "icon" && /^https?:\/\//iu.test(value))
123
+ ) {
124
+ return isPublicHttpUrl(value);
125
+ }
126
+ return true;
127
+ };
128
+
129
+ const filterReaderSafeMetadata = (
130
+ metadata: Record<string, string | string[]>
131
+ ): Record<string, string | string[]> => {
132
+ const result: Record<string, string | string[]> = {};
133
+ for (const [key, value] of Object.entries(metadata)) {
134
+ if (typeof value === "string") {
135
+ if (isSafeMetadataValue(key, value)) {
136
+ result[key] = value;
137
+ }
138
+ continue;
139
+ }
140
+
141
+ const safeValues = value.filter((entry) => isSafeMetadataValue(key, entry));
142
+ if (safeValues.length > 0) {
143
+ result[key] = safeValues;
144
+ }
145
+ }
146
+ return result;
147
+ };
148
+
149
+ export const buildExportedMetadata = (
150
+ doc: Pick<
151
+ DocumentRow,
152
+ "author" | "categories" | "contentType" | "frontmatterDate" | "languageHint"
153
+ >,
154
+ parsedFrontmatter: Record<string, unknown>,
155
+ tags: TagRow[]
156
+ ): Record<string, string | string[]> => {
157
+ const metadata: Record<string, string | string[]> = {};
158
+
159
+ if (doc.author) metadata.author = doc.author;
160
+ if (doc.contentType) metadata.contentType = doc.contentType;
161
+ if (doc.languageHint) metadata.language = doc.languageHint;
162
+ if (doc.frontmatterDate) metadata.date = doc.frontmatterDate;
163
+ if (doc.categories?.length) metadata.categories = doc.categories;
164
+
165
+ const tagValues = tags.map((tag) => tag.tag);
166
+ if (tagValues.length) metadata.tags = tagValues;
167
+
168
+ for (const [key, value] of Object.entries(parsedFrontmatter)) {
169
+ if (
170
+ key === "tags" ||
171
+ key === "title" ||
172
+ key === "summary" ||
173
+ !ALLOWED_FRONTMATTER_METADATA_KEYS.has(key)
174
+ ) {
175
+ continue;
176
+ }
177
+ if (
178
+ typeof value === "string" &&
179
+ value.trim() &&
180
+ isSafeMetadataValue(key, value.trim())
181
+ ) {
182
+ metadata[key] = value.trim();
183
+ continue;
184
+ }
185
+ if (Array.isArray(value)) {
186
+ const cleaned = value
187
+ .filter((entry): entry is string => typeof entry === "string")
188
+ .map((entry) => entry.trim())
189
+ .filter((entry) => entry.length > 0 && isSafeMetadataValue(key, entry));
190
+ if (cleaned.length > 0) metadata[key] = cleaned;
191
+ }
192
+ }
193
+
194
+ return filterReaderSafeMetadata(metadata);
195
+ };
package/src/sdk/client.ts CHANGED
@@ -38,6 +38,7 @@ import type {
38
38
  GnoQueryOptions,
39
39
  GnoRefactorNoteResult,
40
40
  GnoRenameNoteOptions,
41
+ GnoSearchOptions,
41
42
  GnoUpdateOptions,
42
43
  GnoVectorSearchOptions,
43
44
  KnowledgeChangesResult,
@@ -97,6 +98,10 @@ import {
97
98
  } from "../core/knowledge-delta";
98
99
  import { resolveNoteCreatePlan } from "../core/note-creation";
99
100
  import { resolveNotePreset } from "../core/note-presets";
101
+ import {
102
+ ProjectAffinityInputError,
103
+ resolveRemoteProjectAffinity,
104
+ } from "../core/project-affinity-surface";
100
105
  import { RetrievalTraceManagementService } from "../core/retrieval-trace-management";
101
106
  import {
102
107
  finishRetrievalTraceAfterError,
@@ -160,6 +165,20 @@ interface RuntimePorts {
160
165
  vectorIndex: VectorIndexPort | null;
161
166
  }
162
167
 
168
+ const resolveSdkProjectAffinity = async (
169
+ config: Config,
170
+ projectHints: readonly string[] | undefined
171
+ ) => {
172
+ try {
173
+ return await resolveRemoteProjectAffinity(config, projectHints);
174
+ } catch (error) {
175
+ if (error instanceof ProjectAffinityInputError) {
176
+ throw sdkError("VALIDATION", error.message);
177
+ }
178
+ throw error;
179
+ }
180
+ };
181
+
163
182
  function unwrapStore<T>(
164
183
  result: StoreResult<T>,
165
184
  code: "STORE" | "RUNTIME" = "STORE"
@@ -471,17 +490,22 @@ class GnoClientImpl implements GnoClient {
471
490
 
472
491
  async search(
473
492
  query: string,
474
- options: import("../pipeline/types").SearchOptions = {}
493
+ options: GnoSearchOptions = {}
475
494
  ): Promise<SearchResults> {
476
495
  this.assertOpen();
477
496
  let traceSession: RetrievalTraceSession | null = null;
478
497
  try {
498
+ const { projectHints, ...searchOptions } = options;
499
+ const projectAffinity = await resolveSdkProjectAffinity(
500
+ this.config,
501
+ projectHints
502
+ );
479
503
  traceSession = unwrapStore(
480
504
  await startRetrievalTraceRequest({
481
505
  store: this.store,
482
506
  config: this.config,
483
507
  query,
484
- filters: retrievalTraceFilters(options),
508
+ filters: retrievalTraceFilters(searchOptions),
485
509
  pipeline: "bm25",
486
510
  indexName: this.indexName,
487
511
  })
@@ -490,7 +514,8 @@ class GnoClientImpl implements GnoClient {
490
514
  this.decorateSearchResults(
491
515
  unwrapStore(
492
516
  await searchBm25(this.store, query, {
493
- ...options,
517
+ ...searchOptions,
518
+ projectAffinity,
494
519
  traceSession: traceSession ?? undefined,
495
520
  })
496
521
  )
@@ -513,6 +538,11 @@ class GnoClientImpl implements GnoClient {
513
538
  let traceSession: RetrievalTraceSession | null = null;
514
539
 
515
540
  try {
541
+ const { projectHints, ...searchOptions } = options;
542
+ const projectAffinity = await resolveSdkProjectAffinity(
543
+ this.config,
544
+ projectHints
545
+ );
516
546
  const embedUri = resolveModelUri(
517
547
  this.config,
518
548
  "embed",
@@ -524,7 +554,7 @@ class GnoClientImpl implements GnoClient {
524
554
  store: this.store,
525
555
  config: this.config,
526
556
  query,
527
- filters: retrievalTraceFilters(options),
557
+ filters: retrievalTraceFilters(searchOptions),
528
558
  pipeline: "vector",
529
559
  indexName: this.indexName,
530
560
  modelUris: [embedUri],
@@ -564,7 +594,11 @@ class GnoClientImpl implements GnoClient {
564
594
  },
565
595
  query,
566
596
  new Float32Array(queryEmbedResult.value),
567
- { ...options, traceSession: traceSession ?? undefined }
597
+ {
598
+ ...searchOptions,
599
+ projectAffinity,
600
+ traceSession: traceSession ?? undefined,
601
+ }
568
602
  )
569
603
  )
570
604
  ),
@@ -628,12 +662,17 @@ class GnoClientImpl implements GnoClient {
628
662
  let traceSession: RetrievalTraceSession | null = null;
629
663
 
630
664
  try {
665
+ const { projectHints, ...queryOptions } = options;
666
+ const projectAffinity = await resolveSdkProjectAffinity(
667
+ this.config,
668
+ projectHints
669
+ );
631
670
  traceSession = unwrapStore(
632
671
  await startRetrievalTraceRequest({
633
672
  store: this.store,
634
673
  config: this.config,
635
674
  query,
636
- filters: retrievalTraceFilters(options),
675
+ filters: retrievalTraceFilters(queryOptions),
637
676
  pipeline: "hybrid",
638
677
  indexName: this.indexName,
639
678
  modelUris: [embedUri, expandUri, rerankUri].filter(
@@ -664,7 +703,11 @@ class GnoClientImpl implements GnoClient {
664
703
  rerankPort: ports.rerankPort,
665
704
  },
666
705
  query,
667
- { ...options, traceSession: traceSession ?? undefined }
706
+ {
707
+ ...queryOptions,
708
+ projectAffinity,
709
+ traceSession: traceSession ?? undefined,
710
+ }
668
711
  )
669
712
  )
670
713
  ),
@@ -739,12 +782,17 @@ class GnoClientImpl implements GnoClient {
739
782
  let traceSession: RetrievalTraceSession | null = null;
740
783
 
741
784
  try {
785
+ const { projectHints, ...askOptions } = options;
786
+ const projectAffinity = await resolveSdkProjectAffinity(
787
+ this.config,
788
+ projectHints
789
+ );
742
790
  traceSession = unwrapStore(
743
791
  await startRetrievalTraceRequest({
744
792
  store: this.store,
745
793
  config: this.config,
746
794
  query,
747
- filters: retrievalTraceFilters(options),
795
+ filters: retrievalTraceFilters(askOptions),
748
796
  pipeline: "ask",
749
797
  indexName: this.indexName,
750
798
  modelUris: [embedUri, expandUri, answerUri, rerankUri].filter(
@@ -777,16 +825,21 @@ class GnoClientImpl implements GnoClient {
777
825
  }
778
826
 
779
827
  if (verificationRequested && ports.answerPort) {
780
- const verified = await buildVerifiedAsk(query, options, {
781
- store: this.store,
782
- config: this.config,
783
- indexName: this.indexName,
784
- vectorIndex: ports.vectorIndex,
785
- embedPort: ports.embedPort,
786
- rerankPort: ports.rerankPort,
787
- genPort: ports.answerPort,
788
- traceSession: traceSession ?? undefined,
789
- });
828
+ const verified = await buildVerifiedAsk(
829
+ query,
830
+ { ...askOptions, projectAffinity },
831
+ {
832
+ store: this.store,
833
+ config: this.config,
834
+ indexName: this.indexName,
835
+ vectorIndex: ports.vectorIndex,
836
+ embedPort: ports.embedPort,
837
+ rerankPort: ports.rerankPort,
838
+ genPort: ports.answerPort,
839
+ projectAffinity,
840
+ traceSession: traceSession ?? undefined,
841
+ }
842
+ );
790
843
  if (traceSession) {
791
844
  unwrapStore(
792
845
  await traceSession.finish(
@@ -831,6 +884,7 @@ class GnoClientImpl implements GnoClient {
831
884
  noRerank: options.noRerank,
832
885
  candidateLimit: options.candidateLimit,
833
886
  queryLanguageHint: options.queryLanguageHint,
887
+ projectAffinity,
834
888
  traceSession: traceSession ?? undefined,
835
889
  }
836
890
  )
@@ -918,8 +972,9 @@ class GnoClientImpl implements GnoClient {
918
972
 
919
973
  async context(input: GnoContextInput): Promise<GnoContextResult> {
920
974
  this.assertOpen();
975
+ const { projectHints, ...contextInput } = input;
921
976
  validateContextCapsuleBuildInput(
922
- { ...input, indexName: this.indexName },
977
+ { ...contextInput, indexName: this.indexName },
923
978
  this.indexName,
924
979
  this.config.collections.map((collection) => collection.name)
925
980
  );
@@ -935,6 +990,10 @@ class GnoClientImpl implements GnoClient {
935
990
  let ports: RuntimePorts | null = null;
936
991
  let traceSession: RetrievalTraceSession | null = null;
937
992
  try {
993
+ const projectAffinity = await resolveSdkProjectAffinity(
994
+ this.config,
995
+ projectHints
996
+ );
938
997
  traceSession = unwrapStore(
939
998
  await startRetrievalTraceRequest({
940
999
  store: this.store,
@@ -968,7 +1027,7 @@ class GnoClientImpl implements GnoClient {
968
1027
  collection,
969
1028
  });
970
1029
  const capsule = await buildContextCapsule(
971
- { ...input, indexName: this.indexName },
1030
+ { ...contextInput, indexName: this.indexName },
972
1031
  {
973
1032
  store: this.store,
974
1033
  config: this.config,
@@ -976,6 +1035,7 @@ class GnoClientImpl implements GnoClient {
976
1035
  vectorIndex: ports.vectorIndex,
977
1036
  embedPort: ports.embedPort,
978
1037
  rerankPort: ports.rerankPort,
1038
+ projectAffinity,
979
1039
  traceSession: traceSession ?? undefined,
980
1040
  }
981
1041
  );
package/src/sdk/index.ts CHANGED
@@ -51,6 +51,8 @@ export type {
51
51
  GnoMultiGetOptions,
52
52
  GnoMultiGetResult,
53
53
  GnoQueryOptions,
54
+ GnoProjectHintOptions,
55
+ GnoSearchOptions,
54
56
  GnoSkippedDocument,
55
57
  GnoUpdateOptions,
56
58
  GnoVectorSearchOptions,
package/src/sdk/types.ts CHANGED
@@ -97,13 +97,26 @@ export interface GnoModelOverrides {
97
97
  rerankModel?: string;
98
98
  }
99
99
 
100
- export type GnoQueryOptions = HybridSearchOptions & GnoModelOverrides;
101
- export type GnoAskOptions = AskOptions & GnoModelOverrides;
102
- export type GnoVectorSearchOptions = SearchOptions & {
103
- model?: string;
104
- };
100
+ export interface GnoProjectHintOptions {
101
+ /** Opaque caller project hints; never resolved against the server filesystem. */
102
+ projectHints?: string[];
103
+ }
104
+
105
+ export type GnoSearchOptions = Omit<SearchOptions, "projectAffinity"> &
106
+ GnoProjectHintOptions;
107
+ export type GnoQueryOptions = Omit<HybridSearchOptions, "projectAffinity"> &
108
+ GnoModelOverrides &
109
+ GnoProjectHintOptions;
110
+ export type GnoAskOptions = Omit<AskOptions, "projectAffinity"> &
111
+ GnoModelOverrides &
112
+ GnoProjectHintOptions;
113
+ export type GnoVectorSearchOptions = Omit<SearchOptions, "projectAffinity"> &
114
+ GnoProjectHintOptions & {
115
+ model?: string;
116
+ };
105
117
 
106
- export type GnoContextInput = Omit<ContextCapsuleBuildInput, "indexName">;
118
+ export type GnoContextInput = Omit<ContextCapsuleBuildInput, "indexName"> &
119
+ GnoProjectHintOptions;
107
120
  export type GnoContextResult = ContextCapsuleV1;
108
121
  export type GnoContextVerificationResult = ContextCapsuleVerification;
109
122
  export type GnoContextErrorCode =
@@ -227,7 +240,7 @@ export interface GnoClient {
227
240
  readonly configPath: string | null;
228
241
  readonly configSource: "file" | "inline";
229
242
  isOpen(): boolean;
230
- search(query: string, options?: SearchOptions): Promise<SearchResults>;
243
+ search(query: string, options?: GnoSearchOptions): Promise<SearchResults>;
231
244
  vsearch(
232
245
  query: string,
233
246
  options?: GnoVectorSearchOptions
@@ -21,6 +21,10 @@ import {
21
21
  parseContextVerifySurfaceInput,
22
22
  } from "../app/context-surface";
23
23
  import { ContextCapsuleContractError } from "../core/context-capsule";
24
+ import {
25
+ ProjectAffinityInputError,
26
+ resolveRemoteProjectAffinity,
27
+ } from "../core/project-affinity-surface";
24
28
  import { startRetrievalTraceRequest } from "../core/retrieval-trace-request";
25
29
  import { withRetrievalTraceHeader } from "./retrieval-trace";
26
30
 
@@ -85,7 +89,7 @@ export const handleContextBuild = async (
85
89
  ): Promise<Response> => {
86
90
  let traceSession: RetrievalTraceSession | null = null;
87
91
  try {
88
- const { input, format } = parseContextBuildSurfaceInput(
92
+ const { input, format, projectHints } = parseContextBuildSurfaceInput(
89
93
  await parseJsonBody(request),
90
94
  context.indexName
91
95
  );
@@ -94,6 +98,18 @@ export const handleContextBuild = async (
94
98
  context.indexName,
95
99
  context.config.collections.map((collection) => collection.name)
96
100
  );
101
+ let projectAffinity;
102
+ try {
103
+ projectAffinity = await resolveRemoteProjectAffinity(
104
+ context.config,
105
+ projectHints
106
+ );
107
+ } catch (error) {
108
+ if (error instanceof ProjectAffinityInputError) {
109
+ throw new ContextCapsuleContractError("invalid_input", error.message);
110
+ }
111
+ throw error;
112
+ }
97
113
  const started = await startRetrievalTraceRequest({
98
114
  store: context.store,
99
115
  config: context.config,
@@ -137,6 +153,7 @@ export const handleContextBuild = async (
137
153
  vectorIndex: context.vectorIndex,
138
154
  embedPort: context.embedPort,
139
155
  rerankPort: context.rerankPort,
156
+ projectAffinity,
140
157
  traceSession: traceSession ?? undefined,
141
158
  });
142
159
  const finished = await traceSession?.finish(