@gmickel/gno 2.12.2 → 2.14.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 (75) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +36 -5
  3. package/assets/skill/cli-reference.md +62 -11
  4. package/assets/skill/examples.md +30 -4
  5. package/assets/skill/mcp-reference.md +16 -0
  6. package/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.12.2.zip → gno-browser-clipper-v2.14.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.14.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +1 -1
  11. package/spec/cli.md +139 -16
  12. package/spec/mcp.md +111 -3
  13. package/spec/output-schemas/collection-list.schema.json +28 -0
  14. package/spec/output-schemas/convert.schema.json +97 -0
  15. package/spec/output-schemas/ls.schema.json +65 -0
  16. package/spec/output-schemas/mcp-job-status.schema.json +17 -1
  17. package/spec/output-schemas/mcp-sync-result.schema.json +9 -2
  18. package/spec/output-schemas/search-results.schema.json +7 -0
  19. package/src/cli/commands/index-cmd.ts +50 -12
  20. package/src/cli/commands/ls.ts +20 -3
  21. package/src/cli/commands/profile-apply.ts +2 -1
  22. package/src/cli/commands/query.ts +21 -8
  23. package/src/cli/commands/search.ts +11 -0
  24. package/src/cli/commands/vsearch.ts +17 -4
  25. package/src/cli/options.ts +19 -0
  26. package/src/cli/program.ts +90 -10
  27. package/src/config/loader.ts +11 -1
  28. package/src/config/types.ts +90 -1
  29. package/src/core/audit-collection-rules.ts +331 -0
  30. package/src/core/audit-links.ts +3 -1
  31. package/src/core/audit-workspace.ts +69 -5
  32. package/src/core/collection-egress-policy-service.ts +4 -1
  33. package/src/core/collection-scope.ts +105 -0
  34. package/src/core/egress-enforcement.ts +20 -4
  35. package/src/core/egress-policy.ts +4 -0
  36. package/src/core/egress-provenance.ts +33 -11
  37. package/src/core/errors.ts +5 -0
  38. package/src/core/job-manager.ts +238 -19
  39. package/src/core/project-profile-apply-state.ts +6 -15
  40. package/src/core/project-profile-apply.ts +32 -1
  41. package/src/core/reindex-paths.ts +167 -0
  42. package/src/core/retrieval-trace-request.ts +1 -0
  43. package/src/ingestion/convert-file.ts +181 -0
  44. package/src/llm/registry.ts +20 -0
  45. package/src/mcp/http-egress.ts +21 -2
  46. package/src/mcp/tools/collection-scope.ts +25 -0
  47. package/src/mcp/tools/index-cmd.ts +66 -48
  48. package/src/mcp/tools/index.ts +74 -10
  49. package/src/mcp/tools/job-status.ts +7 -0
  50. package/src/mcp/tools/query.ts +28 -7
  51. package/src/mcp/tools/search.ts +4 -0
  52. package/src/mcp/tools/sync.ts +68 -60
  53. package/src/mcp/tools/vsearch.ts +9 -3
  54. package/src/pipeline/filters.ts +48 -5
  55. package/src/pipeline/graph-retrieval.ts +1 -0
  56. package/src/pipeline/hybrid.ts +15 -2
  57. package/src/pipeline/hydration.ts +7 -2
  58. package/src/pipeline/owner-fusion.ts +1 -0
  59. package/src/pipeline/search.ts +2 -0
  60. package/src/pipeline/types.ts +7 -0
  61. package/src/pipeline/vsearch.ts +10 -5
  62. package/src/sdk/client.ts +45 -12
  63. package/src/sdk/convert.ts +27 -0
  64. package/src/sdk/documents.ts +15 -2
  65. package/src/sdk/index.ts +2 -0
  66. package/src/sdk/types.ts +4 -1
  67. package/src/serve/jobs.ts +4 -1
  68. package/src/serve/public/components/IndexingProgress.tsx +25 -3
  69. package/src/serve/routes/api.ts +164 -24
  70. package/src/store/sqlite/adapter.ts +97 -27
  71. package/src/store/sqlite/eligibility.ts +4 -0
  72. package/src/store/types.ts +7 -0
  73. package/src/store/vector/sqlite-vec.ts +67 -15
  74. package/src/store/vector/variant-search.ts +41 -4
  75. package/browser-extension/artifacts/gno-browser-clipper-v2.12.2.zip.sha256 +0 -1
@@ -27,6 +27,7 @@ import {
27
27
  readMetadataSummary,
28
28
  } from "../../core/metadata-discovery";
29
29
  import { NOTE_PRESETS, type NotePresetId } from "../../core/note-presets";
30
+ import { MAX_REINDEX_PATHS } from "../../core/reindex-paths";
30
31
  import { RETRIEVAL_TRACE_METADATA } from "../../core/retrieval-trace-session";
31
32
  import { normalizeTag } from "../../core/tags";
32
33
  import {
@@ -255,6 +256,12 @@ export const searchInputSchema = z.object({
255
256
  .string()
256
257
  .optional()
257
258
  .describe("Filter to a single collection name"),
259
+ collections: z
260
+ .array(z.string())
261
+ .optional()
262
+ .describe(
263
+ "Filter to any of these collection names (1-256); use instead of collection"
264
+ ),
258
265
  limit: z
259
266
  .number()
260
267
  .int()
@@ -437,6 +444,13 @@ const syncInputSchema = z.object({
437
444
  .boolean()
438
445
  .default(false)
439
446
  .describe("Run the collection's configured update command before syncing"),
447
+ paths: z
448
+ .array(z.string())
449
+ .max(MAX_REINDEX_PATHS)
450
+ .optional()
451
+ .describe(
452
+ "Reindex only these files (1-256, relative to the collection root, or absolute inside it). Requires collection"
453
+ ),
440
454
  });
441
455
 
442
456
  const embedInputSchema = z.object({
@@ -452,6 +466,13 @@ const indexInputSchema = z.object({
452
466
  .optional()
453
467
  .describe("Collection name to index. Omit to index all collections"),
454
468
  gitPull: z.boolean().default(false).describe("Run git pull before indexing"),
469
+ paths: z
470
+ .array(z.string())
471
+ .max(MAX_REINDEX_PATHS)
472
+ .optional()
473
+ .describe(
474
+ "Reindex only these files (1-256, relative to the collection root, or absolute inside it). Requires collection"
475
+ ),
455
476
  });
456
477
 
457
478
  const removeCollectionInputSchema = z.object({
@@ -496,6 +517,12 @@ export const vsearchInputSchema = z.object({
496
517
  .string()
497
518
  .optional()
498
519
  .describe("Filter to a single collection name"),
520
+ collections: z
521
+ .array(z.string())
522
+ .optional()
523
+ .describe(
524
+ "Filter to any of these collection names (1-256); use instead of collection"
525
+ ),
499
526
  limit: z
500
527
  .number()
501
528
  .int()
@@ -570,6 +597,12 @@ export const queryInputSchema = z.object({
570
597
  .string()
571
598
  .optional()
572
599
  .describe("Filter to a single collection name"),
600
+ collections: z
601
+ .array(z.string())
602
+ .optional()
603
+ .describe(
604
+ "Filter to any of these collection names (1-256); use instead of collection"
605
+ ),
573
606
  limit: z
574
607
  .number()
575
608
  .int()
@@ -672,15 +705,17 @@ export const queryInputSchema = z.object({
672
705
  tagsAny: z.array(z.string()).optional().describe("Require ANY of these tags"),
673
706
  });
674
707
 
675
- export const queryDiagnoseInputSchema = queryInputSchema.extend({
676
- target: z
677
- .string()
678
- .trim()
679
- .min(1, "Target reference cannot be empty")
680
- .describe(
681
- "Target document reference to diagnose (gno URI, docid, or collection/path)"
682
- ),
683
- });
708
+ export const queryDiagnoseInputSchema = queryInputSchema
709
+ .omit({ collections: true })
710
+ .extend({
711
+ target: z
712
+ .string()
713
+ .trim()
714
+ .min(1, "Target reference cannot be empty")
715
+ .describe(
716
+ "Target document reference to diagnose (gno URI, docid, or collection/path)"
717
+ ),
718
+ });
684
719
 
685
720
  const getInputSchema = z.object({
686
721
  ref: z
@@ -1019,7 +1054,8 @@ export async function runTool<T>(
1019
1054
  try {
1020
1055
  assertConfiguredCollections(ctx, name);
1021
1056
  assertInferenceActive();
1022
- const raw = await (ctx.runWithSnapshot?.(fn) ?? fn());
1057
+ const read = READ_CONNECTION_TOOLS.has(name) ? readConnection(ctx, fn) : fn;
1058
+ const raw = await (ctx.runWithSnapshot?.(read) ?? read());
1023
1059
  assertInferenceActive();
1024
1060
  const traceMetadata =
1025
1061
  raw !== null && typeof raw === "object"
@@ -1048,6 +1084,34 @@ export async function runTool<T>(
1048
1084
  }
1049
1085
  }
1050
1086
 
1087
+ /**
1088
+ * Read-only retrieval tools. They read on the store's read connection, so a
1089
+ * sync batch that is still writing is never visible to them.
1090
+ */
1091
+ const READ_CONNECTION_TOOLS = new Set([
1092
+ "gno_search",
1093
+ "gno_vsearch",
1094
+ "gno_query",
1095
+ "gno_get",
1096
+ "gno_multi_get",
1097
+ "gno_status",
1098
+ "gno_list_tags",
1099
+ "gno_links",
1100
+ "gno_backlinks",
1101
+ "gno_similar",
1102
+ ]);
1103
+
1104
+ /** Store test doubles may lack the read connection; they read as before. */
1105
+ function readConnection<T>(
1106
+ ctx: ToolContext,
1107
+ fn: () => Promise<T>
1108
+ ): () => Promise<T> {
1109
+ const store = ctx.store as Partial<ToolContext["store"]> | undefined;
1110
+ return typeof store?.withReadConnection === "function"
1111
+ ? () => ctx.store.withReadConnection(fn)
1112
+ : fn;
1113
+ }
1114
+
1051
1115
  /**
1052
1116
  * Run a tool without acquiring the mutex.
1053
1117
  * For read-only in-memory tools (job_status, list_jobs) that should not block.
@@ -24,6 +24,7 @@ interface JobStatusResult {
24
24
  result?: SyncResult;
25
25
  typedResult?: JobResult;
26
26
  error?: string;
27
+ scope?: JobRecord["scope"];
27
28
  serverInstanceId: string;
28
29
  }
29
30
 
@@ -34,6 +35,11 @@ function formatJobStatus(result: JobStatusResult): string {
34
35
  lines.push(`Type: ${result.type}`);
35
36
  lines.push(`Status: ${result.status}`);
36
37
  lines.push(`Started: ${result.startedAt}`);
38
+ if (result.scope) {
39
+ lines.push(`Collection: ${result.scope.collection ?? "(all)"}`);
40
+ if (result.scope.paths)
41
+ lines.push(`Paths: ${result.scope.paths.join(", ")}`);
42
+ }
37
43
 
38
44
  if (result.completedAt) {
39
45
  lines.push(`Completed: ${result.completedAt}`);
@@ -92,6 +98,7 @@ function toJobStatusResult(job: JobRecord): JobStatusResult {
92
98
  result: job.result,
93
99
  typedResult: job.typedResult,
94
100
  error: job.error,
101
+ ...(job.scope ? { scope: job.scope } : {}),
95
102
  serverInstanceId: job.serverInstanceId,
96
103
  };
97
104
  }
@@ -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,
@@ -235,13 +250,41 @@ export async function typedMetadataWarnings(
235
250
  excludeSuperseded: options.memoryFilter?.excludeSuperseded,
236
251
  });
237
252
  if (!result.ok) return unknown;
238
- const { pending, invalid } = result.value;
253
+ return metadataCoverageWarnings(result.value, "query");
254
+ }
255
+
256
+ /** The coverage warning for documents a typed metadata filter had to skip. */
257
+ export function metadataCoverageWarnings(
258
+ coverage: { pending: number; invalid: number },
259
+ scope: "query" | "listing"
260
+ ): { code: string; message: string }[] | undefined {
261
+ const { pending, invalid } = coverage;
239
262
  return pending || invalid
240
263
  ? [
241
264
  {
242
265
  code: "METADATA_COVERAGE_INCOMPLETE",
243
- message: `Typed metadata coverage is incomplete: ${pending} documents need re-ingestion and ${invalid} have invalid metadata within the query scope.`,
266
+ message: `Typed metadata coverage is incomplete: ${pending} documents need re-ingestion and ${invalid} have invalid metadata within the ${scope} scope.`,
244
267
  },
245
268
  ]
246
269
  : undefined;
247
270
  }
271
+
272
+ /**
273
+ * Keep the documents a typed metadata predicate matches, counting the ones it
274
+ * could not judge (pending re-ingestion or invalid metadata) for the warning.
275
+ */
276
+ export function filterDocumentsByMetadata<T extends DocumentRow>(
277
+ docs: readonly T[],
278
+ filter: MetadataPredicate
279
+ ): { matches: T[]; pending: number; invalid: number } {
280
+ const matches: T[] = [];
281
+ let pending = 0;
282
+ let invalid = 0;
283
+ for (const doc of docs) {
284
+ const reason = typedMetadataFilterReason(doc, filter);
285
+ if (reason === undefined) matches.push(doc);
286
+ else if (reason === "metadata_backfill") pending += 1;
287
+ else if (reason === "metadata_invalid") invalid += 1;
288
+ }
289
+ return { matches, pending, invalid };
290
+ }
@@ -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
  );