@gmickel/gno 2.12.2 → 2.13.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 (58) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +1 -1
  3. package/assets/skill/cli-reference.md +23 -10
  4. package/assets/skill/mcp-reference.md +2 -0
  5. package/assets/spa-production.json.gz +0 -0
  6. package/browser-extension/artifacts/{gno-browser-clipper-v2.12.2.zip → gno-browser-clipper-v2.13.0.zip} +0 -0
  7. package/browser-extension/artifacts/gno-browser-clipper-v2.13.0.zip.sha256 +1 -0
  8. package/browser-extension/dist/manifest.json +1 -1
  9. package/package.json +1 -1
  10. package/spec/cli.md +66 -5
  11. package/spec/mcp.md +96 -1
  12. package/spec/output-schemas/convert.schema.json +97 -0
  13. package/spec/output-schemas/mcp-job-status.schema.json +17 -1
  14. package/spec/output-schemas/mcp-sync-result.schema.json +9 -2
  15. package/spec/output-schemas/search-results.schema.json +7 -0
  16. package/src/cli/commands/index-cmd.ts +50 -12
  17. package/src/cli/commands/query.ts +21 -8
  18. package/src/cli/commands/search.ts +11 -0
  19. package/src/cli/commands/vsearch.ts +17 -4
  20. package/src/cli/options.ts +19 -0
  21. package/src/cli/program.ts +79 -10
  22. package/src/core/collection-scope.ts +105 -0
  23. package/src/core/errors.ts +5 -0
  24. package/src/core/job-manager.ts +238 -19
  25. package/src/core/reindex-paths.ts +167 -0
  26. package/src/core/retrieval-trace-request.ts +1 -0
  27. package/src/ingestion/convert-file.ts +181 -0
  28. package/src/llm/registry.ts +20 -0
  29. package/src/mcp/http-egress.ts +21 -2
  30. package/src/mcp/tools/collection-scope.ts +25 -0
  31. package/src/mcp/tools/index-cmd.ts +66 -48
  32. package/src/mcp/tools/index.ts +74 -10
  33. package/src/mcp/tools/job-status.ts +7 -0
  34. package/src/mcp/tools/query.ts +28 -7
  35. package/src/mcp/tools/search.ts +4 -0
  36. package/src/mcp/tools/sync.ts +68 -60
  37. package/src/mcp/tools/vsearch.ts +9 -3
  38. package/src/pipeline/filters.ts +18 -3
  39. package/src/pipeline/graph-retrieval.ts +1 -0
  40. package/src/pipeline/hybrid.ts +15 -2
  41. package/src/pipeline/hydration.ts +7 -2
  42. package/src/pipeline/owner-fusion.ts +1 -0
  43. package/src/pipeline/search.ts +2 -0
  44. package/src/pipeline/types.ts +7 -0
  45. package/src/pipeline/vsearch.ts +10 -5
  46. package/src/sdk/client.ts +39 -11
  47. package/src/sdk/convert.ts +27 -0
  48. package/src/sdk/index.ts +2 -0
  49. package/src/sdk/types.ts +1 -1
  50. package/src/serve/jobs.ts +4 -1
  51. package/src/serve/public/components/IndexingProgress.tsx +25 -3
  52. package/src/serve/routes/api.ts +164 -24
  53. package/src/store/sqlite/adapter.ts +97 -27
  54. package/src/store/sqlite/eligibility.ts +4 -0
  55. package/src/store/types.ts +7 -0
  56. package/src/store/vector/sqlite-vec.ts +67 -15
  57. package/src/store/vector/variant-search.ts +41 -4
  58. package/browser-extension/artifacts/gno-browser-clipper-v2.12.2.zip.sha256 +0 -1
@@ -44,17 +44,23 @@ import {
44
44
  } from "../../core/typed-metadata";
45
45
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
46
46
  import { resolveDownloadPolicy } from "../../llm/policy";
47
- import { getActivePreset, resolveModelUri } from "../../llm/registry";
47
+ import {
48
+ getActivePreset,
49
+ modelCollectionForList,
50
+ resolveModelUri,
51
+ } from "../../llm/registry";
48
52
  import { diagnoseQueryTarget } from "../../pipeline/diagnose";
49
53
  import { type HybridSearchDeps, searchHybrid } from "../../pipeline/hybrid";
50
54
  import { createLazyVectorIndex } from "../../store/vector/lazy";
51
55
  import { appendRetrievalWarnings } from "../retrieval-warnings";
56
+ import { requestCollections } from "./collection-scope";
52
57
  import { normalizeTagFilters, runTool, type ToolResult } from "./index";
53
58
 
54
59
  interface QueryInput {
55
60
  query: string;
56
61
  projectHints?: string[];
57
62
  collection?: string;
63
+ collections?: string[];
58
64
  limit?: number;
59
65
  minScore?: number;
60
66
  lang?: string;
@@ -198,6 +204,7 @@ export function handleQuery(
198
204
  throw new Error(`Collection not found: ${args.collection}`);
199
205
  }
200
206
  }
207
+ const collections = requestCollections(args, ctx);
201
208
 
202
209
  const normalizedInput = normalizeStructuredQueryInput(
203
210
  args.query,
@@ -219,11 +226,14 @@ export function handleQuery(
219
226
  let rerankPort: RerankPort | null = null;
220
227
  let vectorIndex: VectorIndexPort | null = null;
221
228
  let traceSession: RetrievalTraceSession | undefined;
229
+ const modelCollection = (type: "embed" | "expand" | "rerank") =>
230
+ args.collection ??
231
+ modelCollectionForList(ctx.config, type, collections);
222
232
  const embedUri = resolveModelUri(
223
233
  ctx.config,
224
234
  "embed",
225
235
  undefined,
226
- args.collection
236
+ modelCollection("embed")
227
237
  );
228
238
  const hasStructuredModes = Boolean(queryModes?.length);
229
239
  const depthPolicy = resolveDepthPolicy({
@@ -242,15 +252,26 @@ export function handleQuery(
242
252
  );
243
253
  const expandUri =
244
254
  !noExpand && !hasStructuredModes
245
- ? resolveModelUri(ctx.config, "expand", undefined, args.collection)
255
+ ? resolveModelUri(
256
+ ctx.config,
257
+ "expand",
258
+ undefined,
259
+ modelCollection("expand")
260
+ )
246
261
  : undefined;
247
262
  const rerankUri = !noRerank
248
- ? resolveModelUri(ctx.config, "rerank", undefined, args.collection)
263
+ ? resolveModelUri(
264
+ ctx.config,
265
+ "rerank",
266
+ undefined,
267
+ modelCollection("rerank")
268
+ )
249
269
  : undefined;
250
270
  const options = {
251
271
  limit: args.limit ?? 5,
252
272
  minScore: args.minScore,
253
273
  collection: args.collection,
274
+ collections,
254
275
  queryLanguageHint: args.lang,
255
276
  intent: args.intent,
256
277
  candidateLimit: depthPolicy.candidateLimit,
@@ -289,9 +310,9 @@ export function handleQuery(
289
310
  if (!traceStart.ok) throw new Error(traceStart.error.message);
290
311
  traceSession = traceStart.value ?? undefined;
291
312
  const llm = ctx.getModelAdapter?.() ?? new LlmAdapter(ctx.config);
292
- const egressCollections = args.collection
293
- ? [args.collection]
294
- : ("all" as const);
313
+ const egressCollections =
314
+ collections ??
315
+ (args.collection ? [args.collection] : ("all" as const));
295
316
 
296
317
  // Resolve download policy from env (MCP has no CLI flags)
297
318
  const policy = resolveDownloadPolicy(process.env, {});
@@ -25,12 +25,14 @@ import {
25
25
  } from "../../core/typed-metadata";
26
26
  import { searchBm25 } from "../../pipeline/search";
27
27
  import { appendRetrievalWarnings } from "../retrieval-warnings";
28
+ import { requestCollections } from "./collection-scope";
28
29
  import { normalizeTagFilters, runTool, type ToolResult } from "./index";
29
30
 
30
31
  interface SearchInput {
31
32
  query: string;
32
33
  projectHints?: string[];
33
34
  collection?: string;
35
+ collections?: string[];
34
36
  limit?: number;
35
37
  minScore?: number;
36
38
  lang?: string;
@@ -123,6 +125,7 @@ export function handleSearch(
123
125
  throw new Error(`Collection not found: ${args.collection}`);
124
126
  }
125
127
  }
128
+ const collections = requestCollections(args, ctx);
126
129
 
127
130
  const projectAffinity = await resolveRemoteProjectAffinity(
128
131
  ctx.config,
@@ -132,6 +135,7 @@ export function handleSearch(
132
135
  limit: args.limit ?? 5,
133
136
  minScore: args.minScore,
134
137
  collection: args.collection,
138
+ collections,
135
139
  lang: args.lang,
136
140
  intent: args.intent,
137
141
  exclude: args.exclude,
@@ -9,9 +9,9 @@ import type { CollectionSyncResult, SyncResult } from "../../ingestion";
9
9
  import type { ToolContext } from "../server";
10
10
 
11
11
  import { MCP_ERRORS } from "../../core/errors";
12
- import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
13
12
  import { JobError } from "../../core/job-manager";
14
13
  import { recordContentMutation } from "../../core/mutation-generations";
14
+ import { resolveReindexScope } from "../../core/reindex-paths";
15
15
  import { normalizeCollectionName } from "../../core/validation";
16
16
  import { defaultSyncService, withContentTypeRules } from "../../ingestion";
17
17
  import { enforceSyncCommandEgress } from "../sync-egress";
@@ -21,12 +21,14 @@ interface SyncInput {
21
21
  collection?: string;
22
22
  gitPull?: boolean;
23
23
  runUpdateCmd?: boolean;
24
+ paths?: string[];
24
25
  }
25
26
 
26
27
  interface SyncResultOutput {
27
28
  jobId: string;
28
29
  collections: string[];
29
- status: "started";
30
+ status: "started" | "queued";
31
+ paths?: string[];
30
32
  options: {
31
33
  gitPull: boolean;
32
34
  runUpdateCmd: boolean;
@@ -38,6 +40,7 @@ function formatSyncResult(result: SyncResultOutput): string {
38
40
  lines.push(`Job: ${result.jobId}`);
39
41
  lines.push(`Status: ${result.status}`);
40
42
  lines.push(`Collections: ${result.collections.join(", ") || "(none)"}`);
43
+ if (result.paths) lines.push(`Paths: ${result.paths.join(", ")}`);
41
44
  lines.push(`Git pull: ${result.options.gitPull ? "yes" : "no"}`);
42
45
  lines.push(`Run update cmd: ${result.options.runUpdateCmd ? "yes" : "no"}`);
43
46
  return lines.join("\n");
@@ -77,73 +80,82 @@ export function handleSync(
77
80
  throw new Error("Write tools disabled. Start MCP with --enable-write.");
78
81
  }
79
82
 
80
- let lock: WriteLockHandle | null = null;
81
- let handedOff = false;
83
+ const requestedCollection = args.collection?.trim();
84
+ const collection = requestedCollection
85
+ ? resolveCollection(requestedCollection, ctx.collections)
86
+ : null;
82
87
 
83
- try {
84
- lock = await acquireWriteLock(ctx.writeLockPath);
85
- if (!lock) {
86
- throw new Error(
87
- `${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`
88
- );
89
- }
90
-
91
- const requestedCollection = args.collection?.trim();
92
- const collection = requestedCollection
93
- ? resolveCollection(requestedCollection, ctx.collections)
94
- : null;
95
-
96
- if (requestedCollection && !collection) {
97
- throw new Error(
98
- `${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${requestedCollection}`
99
- );
100
- }
101
-
102
- const collections = collection
103
- ? [collection.name]
104
- : ctx.collections.map((entry) => entry.name);
105
-
106
- const options = withContentTypeRules(
107
- {
108
- gitPull: args.gitPull ?? false,
109
- runUpdateCmd: args.runUpdateCmd ?? false,
110
- },
111
- ctx.config
88
+ if (requestedCollection && !collection) {
89
+ throw new Error(
90
+ `${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${requestedCollection}`
112
91
  );
113
- enforceSyncCommandEgress(ctx, {
114
- collectionNames: collections,
115
- gitPull: options.gitPull,
116
- runUpdateCmd: options.runUpdateCmd,
117
- });
92
+ }
118
93
 
119
- const jobId = await ctx.jobManager.startJobWithLock(
120
- "sync",
121
- lock,
122
- async () => {
94
+ const collections = collection
95
+ ? [collection.name]
96
+ : ctx.collections.map((entry) => entry.name);
97
+
98
+ const options = withContentTypeRules(
99
+ {
100
+ gitPull: args.gitPull ?? false,
101
+ runUpdateCmd: args.runUpdateCmd ?? false,
102
+ },
103
+ ctx.config
104
+ );
105
+ const paths = await resolveReindexScope(collection, args.paths, options);
106
+ enforceSyncCommandEgress(ctx, {
107
+ collectionNames: collections,
108
+ gitPull: options.gitPull,
109
+ runUpdateCmd: options.runUpdateCmd,
110
+ });
111
+
112
+ // Captured now: a queued pass runs after this call's context is gone.
113
+ const config = ctx.config;
114
+ const allCollections = ctx.collections;
115
+ try {
116
+ const accepted = await ctx.jobManager.submitReindex({
117
+ type: "sync",
118
+ collection: collection?.name ?? null,
119
+ paths,
120
+ options: {
121
+ gitPull: options.gitPull ?? false,
122
+ runUpdateCmd: options.runUpdateCmd ?? false,
123
+ },
124
+ run: async (scope) => {
125
+ const passOptions = withContentTypeRules(
126
+ { ...scope.options },
127
+ config
128
+ );
123
129
  const result = collection
124
130
  ? wrapSingleSync(
125
- await defaultSyncService.syncCollection(
126
- collection,
127
- ctx.store,
128
- options
129
- )
131
+ scope.paths
132
+ ? await defaultSyncService.syncPaths(
133
+ collection,
134
+ ctx.store,
135
+ scope.paths,
136
+ passOptions
137
+ )
138
+ : await defaultSyncService.syncCollection(
139
+ collection,
140
+ ctx.store,
141
+ passOptions
142
+ )
130
143
  )
131
144
  : await defaultSyncService.syncAll(
132
- ctx.collections,
145
+ allCollections,
133
146
  ctx.store,
134
- options
147
+ passOptions
135
148
  );
136
149
  recordContentMutation(result, ctx.markContentMutation);
137
- return result;
138
- }
139
- );
140
-
141
- handedOff = true;
150
+ return { kind: "sync" as const, value: result };
151
+ },
152
+ });
142
153
 
143
154
  const result: SyncResultOutput = {
144
- jobId,
155
+ jobId: accepted.jobId,
145
156
  collections,
146
- status: "started",
157
+ status: accepted.status === "running" ? "started" : "queued",
158
+ ...(paths ? { paths } : {}),
147
159
  options: {
148
160
  gitPull: options.gitPull ?? false,
149
161
  runUpdateCmd: options.runUpdateCmd ?? false,
@@ -156,10 +168,6 @@ export function handleSync(
156
168
  throw new Error(`${error.code}: ${error.message}`);
157
169
  }
158
170
  throw error;
159
- } finally {
160
- if (lock && !handedOff) {
161
- await lock.release();
162
- }
163
171
  }
164
172
  },
165
173
  formatSyncResult
@@ -28,7 +28,7 @@ import {
28
28
  } from "../../core/typed-metadata";
29
29
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
30
30
  import { resolveDownloadPolicy } from "../../llm/policy";
31
- import { resolveModelUri } from "../../llm/registry";
31
+ import { modelCollectionForList, resolveModelUri } from "../../llm/registry";
32
32
  import { formatQueryForEmbedding } from "../../pipeline/contextual";
33
33
  import {
34
34
  searchVectorWithEmbedding,
@@ -36,12 +36,14 @@ import {
36
36
  } from "../../pipeline/vsearch";
37
37
  import { createVectorIndexPort } from "../../store/vector";
38
38
  import { appendRetrievalWarnings } from "../retrieval-warnings";
39
+ import { requestCollections } from "./collection-scope";
39
40
  import { normalizeTagFilters, runTool, type ToolResult } from "./index";
40
41
 
41
42
  interface VsearchInput {
42
43
  query: string;
43
44
  projectHints?: string[];
44
45
  collection?: string;
46
+ collections?: string[];
45
47
  limit?: number;
46
48
  minScore?: number;
47
49
  lang?: string;
@@ -137,13 +139,15 @@ export function handleVsearch(
137
139
  throw new Error(`Collection not found: ${args.collection}`);
138
140
  }
139
141
  }
142
+ const collections = requestCollections(args, ctx);
140
143
 
141
144
  // Get model from active preset
142
145
  const modelUri = resolveModelUri(
143
146
  ctx.config,
144
147
  "embed",
145
148
  undefined,
146
- args.collection
149
+ args.collection ??
150
+ modelCollectionForList(ctx.config, "embed", collections)
147
151
  );
148
152
  const projectAffinity = await resolveRemoteProjectAffinity(
149
153
  ctx.config,
@@ -153,6 +157,7 @@ export function handleVsearch(
153
157
  limit: args.limit ?? 5,
154
158
  minScore: args.minScore,
155
159
  collection: args.collection,
160
+ collections,
156
161
  intent: args.intent,
157
162
  exclude: args.exclude,
158
163
  since: args.since,
@@ -192,7 +197,8 @@ export function handleVsearch(
192
197
  // Create LLM adapter for embeddings
193
198
  const llm = ctx.getModelAdapter?.() ?? new LlmAdapter(ctx.config);
194
199
  const embedResult = await llm.createEmbeddingPort(modelUri, {
195
- egressCollections: args.collection ? [args.collection] : "all",
200
+ egressCollections:
201
+ collections ?? (args.collection ? [args.collection] : "all"),
196
202
  policy,
197
203
  onProgress: (progress) => downloadProgress("embed", progress),
198
204
  });
@@ -25,13 +25,27 @@ export interface QueryFilterEvaluation {
25
25
  * not replace complete ownership lineage or change ranking/deduplication.
26
26
  * Managed-memory scopes/supersession must already be enforced by the store.
27
27
  */
28
+ /** True when `collection` falls outside the request's collection scope. */
29
+ function outsideCollectionScope(
30
+ scope: Pick<HybridSearchOptions, "collection" | "collections">,
31
+ collection: string
32
+ ): boolean {
33
+ if (scope.collection && collection !== scope.collection) return true;
34
+ return (
35
+ scope.collections !== undefined && !scope.collections.includes(collection)
36
+ );
37
+ }
38
+
28
39
  export async function evaluateRetrievalEligibility(
29
40
  store: StorePort,
30
41
  query: string,
31
42
  doc: DocumentRow | undefined,
32
43
  chunks: ChunkRow[] | undefined,
33
44
  options: HybridSearchOptions,
34
- callerScope?: Pick<HybridSearchOptions, "collection" | "retrievalScope">
45
+ callerScope?: Pick<
46
+ HybridSearchOptions,
47
+ "collection" | "collections" | "retrievalScope"
48
+ >
35
49
  ): Promise<QueryFilterEvaluation & { chunks: ChunkRow[] }> {
36
50
  if (!doc || !chunks || !doc.mirrorHash) {
37
51
  return { matches: false, reasons: ["metadata"], chunks: [] };
@@ -40,7 +54,7 @@ export async function evaluateRetrievalEligibility(
40
54
  if (!doc.active) reasons.push("inactive");
41
55
  const sourcePath = doc.recordSourcePath ?? doc.relPath;
42
56
  for (const scope of [callerScope, options]) {
43
- if (scope?.collection && doc.collection !== scope.collection) {
57
+ if (scope && outsideCollectionScope(scope, doc.collection)) {
44
58
  reasons.push("collection");
45
59
  }
46
60
  if (scope?.retrievalScope) {
@@ -98,7 +112,7 @@ export function evaluateDocumentChunkFilters(
98
112
  options.until
99
113
  );
100
114
 
101
- if (options.collection && doc.collection !== options.collection) {
115
+ if (outsideCollectionScope(options, doc.collection)) {
102
116
  reasons.push("collection");
103
117
  }
104
118
  if (!isWithinTemporalRange(doc.sourceMtime, temporalRange)) {
@@ -219,6 +233,7 @@ export async function typedMetadataWarnings(
219
233
  const range = resolveTemporalRange(query, options.since, options.until);
220
234
  const result = await store.getTypedMetadataCoverage({
221
235
  collection: options.collection,
236
+ collections: options.collections,
222
237
  relPathPrefix: options.retrievalScope?.relPathPrefix,
223
238
  allowedMirrorHashes: options.retrievalScope?.allowedMirrorHashes,
224
239
  chunkLanguage: options.lang,
@@ -304,6 +304,7 @@ export async function expandGraphCandidates(
304
304
  seedHashes,
305
305
  {
306
306
  collection: options.collection,
307
+ collections: options.collections,
307
308
  activeOnly: true,
308
309
  }
309
310
  );
@@ -150,6 +150,7 @@ async function checkBm25Strength(
150
150
  query: string,
151
151
  options?: {
152
152
  collection?: string;
153
+ collections?: string[];
153
154
  lang?: string;
154
155
  tagsAll?: string[];
155
156
  tagsAny?: string[];
@@ -168,6 +169,7 @@ async function checkBm25Strength(
168
169
  const result = await store.searchFts(query, {
169
170
  limit: 5,
170
171
  collection: options?.collection,
172
+ collections: options?.collections,
171
173
  relPathPrefix: options?.relPathPrefix,
172
174
  language: options?.lang,
173
175
  chunkLanguage: options?.lang,
@@ -225,6 +227,7 @@ async function searchFtsChunks(
225
227
  options: {
226
228
  limit: number;
227
229
  collection?: string;
230
+ collections?: string[];
228
231
  lang?: string;
229
232
  tagsAll?: string[];
230
233
  tagsAny?: string[];
@@ -243,6 +246,7 @@ async function searchFtsChunks(
243
246
  const result = await store.searchFts(query, {
244
247
  limit: options.limit,
245
248
  collection: options.collection,
249
+ collections: options.collections,
246
250
  relPathPrefix: options.relPathPrefix,
247
251
  language: options.lang,
248
252
  chunkLanguage: options.lang,
@@ -500,6 +504,7 @@ async function searchHybridWithHydration(
500
504
  ? false
501
505
  : await checkBm25Strength(store, query, {
502
506
  collection: options.collection,
507
+ collections: options.collections,
503
508
  lang: options.lang,
504
509
  memoryScopesAny: options.memoryFilter?.scopes,
505
510
  excludeSuperseded: options.memoryFilter?.excludeSuperseded,
@@ -550,6 +555,7 @@ async function searchHybridWithHydration(
550
555
 
551
556
  const vectorEligibility: VectorSearchOptions["eligibility"] = {
552
557
  collection: options.collection,
558
+ collections: options.collections,
553
559
  memoryScopesAny: options.memoryFilter?.scopes,
554
560
  excludeSuperseded: options.memoryFilter?.excludeSuperseded,
555
561
  relPathPrefix: options.retrievalScope?.relPathPrefix,
@@ -575,6 +581,7 @@ async function searchHybridWithHydration(
575
581
  exclude: options.exclude,
576
582
  allowedMirrorHashes: options.retrievalScope?.allowedMirrorHashes,
577
583
  collection: options.collection,
584
+ collections: options.collections,
578
585
  lang: options.lang,
579
586
  tagsAll: options.tagsAll,
580
587
  tagsAny: options.tagsAny,
@@ -615,6 +622,7 @@ async function searchHybridWithHydration(
615
622
  exclude: options.exclude,
616
623
  allowedMirrorHashes: options.retrievalScope?.allowedMirrorHashes,
617
624
  collection: options.collection,
625
+ collections: options.collections,
618
626
  lang: options.lang,
619
627
  tagsAll: options.tagsAll,
620
628
  tagsAny: options.tagsAny,
@@ -806,7 +814,7 @@ async function searchHybridWithHydration(
806
814
  fusedCandidates,
807
815
  {
808
816
  collection: options.collection,
809
- collections: options.graphCollections,
817
+ collections: options.graphCollections ?? options.collections,
810
818
  includeSimilar: vectorAvailable,
811
819
  eligibility: vectorEligibility,
812
820
  limit,
@@ -879,6 +887,7 @@ async function searchHybridWithHydration(
879
887
  [...new Set(fusedCandidates.map((candidate) => candidate.mirrorHash))],
880
888
  {
881
889
  collection: options.collection,
890
+ collections: options.collections,
882
891
  activeOnly: true,
883
892
  }
884
893
  );
@@ -991,6 +1000,7 @@ async function searchHybridWithHydration(
991
1000
  [...neededHashes],
992
1001
  {
993
1002
  collection: options.collection,
1003
+ collections: options.collections,
994
1004
  activeOnly: true,
995
1005
  }
996
1006
  );
@@ -1374,7 +1384,9 @@ async function searchHybridWithHydration(
1374
1384
  {
1375
1385
  ownershipHashes: [...neededHashes],
1376
1386
  ownershipDocuments:
1377
- options.collection === undefined ? documents : undefined,
1387
+ options.collection === undefined && options.collections === undefined
1388
+ ? documents
1389
+ : undefined,
1378
1390
  collections: collectionsResult.ok ? collectionsResult.value : undefined,
1379
1391
  }
1380
1392
  );
@@ -1394,6 +1406,7 @@ async function searchHybridWithHydration(
1394
1406
  intent: options.intent,
1395
1407
  exclude: options.exclude,
1396
1408
  collection: options.collection,
1409
+ collections: options.collections,
1397
1410
  lang: options.lang,
1398
1411
  since: temporalRange.since,
1399
1412
  until: temporalRange.until,
@@ -142,13 +142,18 @@ export class RequestHydration {
142
142
  const store = this.store;
143
143
  if (!store) return err("QUERY_FAILED", "Hydration request released");
144
144
  // Capture caller-owned options before any asynchronous read. Collection
145
- // and activity are part of the lookup identity; titles never key by hash.
146
- const scope = { ...options };
145
+ // scope and activity are part of the lookup identity; titles never key by
146
+ // hash.
147
+ const scope = {
148
+ ...options,
149
+ collections: options?.collections ? [...options.collections] : undefined,
150
+ };
147
151
  // Cache the exact lookup, retaining the adapter's ordering (including SQL
148
152
  // batch boundaries). Regrouping by content hash would reorder documents.
149
153
  const requested = [...hashes];
150
154
  const identity = JSON.stringify([
151
155
  scope.collection ?? null,
156
+ scope.collections ?? null,
152
157
  scope.activeOnly ?? null,
153
158
  requested,
154
159
  ]);
@@ -30,6 +30,7 @@ export async function resolveFusionOwners(
30
30
  ];
31
31
  const documents = await hydration.getDocumentsByMirrorHashes(hashes, {
32
32
  collection: options.collection,
33
+ collections: options.collections,
33
34
  activeOnly: true,
34
35
  });
35
36
  const chunks = await hydration.getChunksBatch(hashes);
@@ -341,6 +341,7 @@ export async function searchBm25(
341
341
  const ftsResult = await store.searchFts(query, {
342
342
  limit: retrievalLimit,
343
343
  collection: options.collection,
344
+ collections: options.collections,
344
345
  allowedMirrorHashes: options.retrievalScope?.allowedMirrorHashes,
345
346
  exclude: options.exclude,
346
347
  relPathPrefix: options.retrievalScope?.relPathPrefix,
@@ -621,6 +622,7 @@ export async function searchBm25(
621
622
  intent: options.intent,
622
623
  exclude: options.exclude,
623
624
  collection: options.collection,
625
+ collections: options.collections,
624
626
  lang: options.lang,
625
627
  since: temporalRange.since,
626
628
  until: temporalRange.until,
@@ -114,6 +114,8 @@ export interface SearchMeta {
114
114
  totalResults: number;
115
115
  intent?: string;
116
116
  collection?: string;
117
+ /** Normalized collection allowlist; present only when the request set one. */
118
+ collections?: string[];
117
119
  lang?: string;
118
120
  /** Detected/overridden query language for prompt selection (typically BCP-47; may be user-provided via --lang) */
119
121
  queryLanguage?: string;
@@ -193,6 +195,11 @@ export interface SearchOptions extends InferenceOptions {
193
195
  minScore?: number;
194
196
  /** Filter by collection */
195
197
  collection?: string;
198
+ /**
199
+ * Filter to documents in ANY of these collections, before candidate limits.
200
+ * Never combined with `collection`; surfaces reject that pairing.
201
+ */
202
+ collections?: string[];
196
203
  /**
197
204
  * Internal graph allowlist for a request partitioned into one retrieval per
198
205
  * collection (Context Capsules, replay): graph neighbours may come from any
@@ -176,6 +176,7 @@ async function searchVectorWithEmbeddingOwned(
176
176
  excludeMetadata: true,
177
177
  semanticMetadata: true,
178
178
  collection: options.collection,
179
+ collections: options.collections,
179
180
  memoryScopesAny: options.memoryFilter?.scopes,
180
181
  excludeSuperseded: options.memoryFilter?.excludeSuperseded,
181
182
  relPathPrefix: options.retrievalScope?.relPathPrefix,
@@ -215,6 +216,7 @@ async function searchVectorWithEmbeddingOwned(
215
216
  const { documents: docsByMirrorHash, ownershipDocuments } =
216
217
  await buildDocumentMap(store, {
217
218
  collection: options.collection,
219
+ collections: options.collections,
218
220
  relPathPrefix: options.retrievalScope?.relPathPrefix,
219
221
  tagsAll: options.tagsAll,
220
222
  tagsAny: options.tagsAny,
@@ -547,6 +549,7 @@ async function searchVectorWithEmbeddingOwned(
547
549
  intent: options.intent,
548
550
  exclude: options.exclude,
549
551
  collection: options.collection,
552
+ collections: options.collections,
550
553
  lang: options.lang,
551
554
  since: temporalRange.since,
552
555
  until: temporalRange.until,
@@ -666,6 +669,7 @@ interface DocumentInfo {
666
669
 
667
670
  interface DocumentMapOptions {
668
671
  collection?: string;
672
+ collections?: string[];
669
673
  relPathPrefix?: string;
670
674
  tagsAll?: string[];
671
675
  tagsAny?: string[];
@@ -718,11 +722,12 @@ async function buildDocumentMap(
718
722
  const ownershipDocuments = options.mirrorHashes
719
723
  ? docs.value.filter((d) => d.mirrorHash)
720
724
  : docs.value.filter((d) => d.mirrorHash && d.active);
721
- const activeDocs = options.collection
722
- ? ownershipDocuments.filter(
723
- (document) => document.collection === options.collection
724
- )
725
- : ownershipDocuments;
725
+ const activeDocs = ownershipDocuments.filter(
726
+ (document) =>
727
+ (!options.collection || document.collection === options.collection) &&
728
+ (options.collections === undefined ||
729
+ options.collections.includes(document.collection))
730
+ );
726
731
  const temporalRange: TemporalRange = {
727
732
  since: options.since,
728
733
  until: options.until,