@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
@@ -1,10 +1,13 @@
1
- import type { SyncResult } from "../ingestion";
1
+ // node:async_hooks: Bun has no separate async-context snapshot API.
2
+ import { AsyncLocalStorage } from "node:async_hooks";
2
3
  /**
3
4
  * Background job manager for MCP write operations.
4
5
  *
5
6
  * @module src/core/job-manager
6
7
  */
7
8
 
9
+ import type { SyncResult } from "../ingestion";
10
+
8
11
  import {
9
12
  assertInferenceActive,
10
13
  withOwnedInferenceScope,
@@ -12,14 +15,17 @@ import {
12
15
  } from "../llm/inference-scope";
13
16
  import { MCP_ERRORS } from "./errors";
14
17
  import { acquireWriteLock, type WriteLockHandle } from "./file-lock";
18
+ import { MAX_REINDEX_PATHS } from "./reindex-paths";
15
19
 
16
20
  const JOB_EXPIRATION_MS = 60 * 60 * 1000;
17
21
  const JOB_MAX_RECENT = 100;
18
22
  const DEFAULT_LOCK_TIMEOUT_MS = 5000;
23
+ /** Distinct reindex requests that may wait behind the running job. */
24
+ export const MAX_PENDING_REINDEX_JOBS = 64;
19
25
 
20
26
  export type JobType = "add" | "sync" | "embed" | "index";
21
27
 
22
- export type JobStatus = "running" | "completed" | "failed";
28
+ export type JobStatus = "queued" | "running" | "completed" | "failed";
23
29
 
24
30
  // ─────────────────────────────────────────────────────────────────────────────
25
31
  // Discriminated union for job results
@@ -51,9 +57,55 @@ export interface JobRecord {
51
57
  typedResult?: JobResult;
52
58
  error?: string;
53
59
  progress?: { current: number; total: number; currentFile?: string };
60
+ /** Reindex jobs: the collection (null = all) and merged paths (null = whole). */
61
+ scope?: { collection: string | null; paths: string[] | null };
54
62
  serverInstanceId: string;
55
63
  }
56
64
 
65
+ export interface ReindexOptions {
66
+ gitPull: boolean;
67
+ runUpdateCmd: boolean;
68
+ }
69
+
70
+ /** What one reindex pass covers; `paths` null means the whole collection. */
71
+ export interface ReindexScope {
72
+ collection: string | null;
73
+ paths: string[] | null;
74
+ options: ReindexOptions;
75
+ }
76
+
77
+ /**
78
+ * A reindex request. `run` must honour the scope it is given: when requests
79
+ * merge, the pass runs the latest request's `run` over the merged scope.
80
+ */
81
+ export interface ReindexRequest {
82
+ type: "sync" | "index";
83
+ collection: string | null;
84
+ paths: string[] | null;
85
+ options: ReindexOptions;
86
+ run: (scope: ReindexScope, signal: AbortSignal) => Promise<JobResult>;
87
+ }
88
+
89
+ export interface ReindexAccepted {
90
+ jobId: string;
91
+ status: "running" | "queued";
92
+ }
93
+
94
+ interface PendingReindex {
95
+ job: JobRecord;
96
+ type: ReindexRequest["type"];
97
+ collection: string | null;
98
+ paths: Set<string> | null;
99
+ /** null while only path requests joined: options come from a whole request. */
100
+ options: ReindexOptions | null;
101
+ run: ReindexRequest["run"];
102
+ }
103
+
104
+ const NO_REINDEX_OPTIONS: ReindexOptions = {
105
+ gitPull: false,
106
+ runUpdateCmd: false,
107
+ };
108
+
57
109
  export interface JobManagerOptions {
58
110
  lockPath: string;
59
111
  serverInstanceId: string;
@@ -63,10 +115,12 @@ export interface JobManagerOptions {
63
115
  lockTimeoutMs?: number;
64
116
  }
65
117
 
118
+ export type JobErrorCode = "LOCKED" | "JOB_CONFLICT" | "QUEUE_FULL";
119
+
66
120
  export class JobError extends Error {
67
- code: "LOCKED" | "JOB_CONFLICT";
121
+ code: JobErrorCode;
68
122
 
69
- constructor(code: "LOCKED" | "JOB_CONFLICT", message: string) {
123
+ constructor(code: JobErrorCode, message: string) {
70
124
  super(message);
71
125
  this.code = code;
72
126
  this.name = "JobError";
@@ -86,6 +140,10 @@ export class JobManager {
86
140
  #authorizationEpoch = "egress-epoch-uninitialized";
87
141
  #accepting = true;
88
142
  #releaseJobLocks = new Map<string, () => Promise<void>>();
143
+ /** Pending reindex jobs in arrival order. */
144
+ #pending: PendingReindex[] = [];
145
+ /** Context captured at construction; queued jobs start here, not in the previous job's. */
146
+ #rootContext = AsyncLocalStorage.snapshot();
89
147
 
90
148
  constructor(options: JobManagerOptions) {
91
149
  this.#lockPath = options.lockPath;
@@ -164,6 +222,89 @@ export class JobManager {
164
222
  return this.#startTypedJobWithLock(type, fn, lock);
165
223
  }
166
224
 
225
+ /**
226
+ * Start a reindex now when nothing else is running or waiting; otherwise
227
+ * queue it. A request joins a waiting job for the same type and collection
228
+ * when that cannot change what the job is allowed to do: a path request
229
+ * joins any of them, and a whole-collection request joins a path-only job
230
+ * (setting its options) or a whole job with the same options. Joined
231
+ * requests share one job id, so any number of them cause at most one more
232
+ * pass. Throws `LOCKED` when another process holds the write lease at an
233
+ * immediate start, and `QUEUE_FULL` at the pending bound.
234
+ */
235
+ async submitReindex(request: ReindexRequest): Promise<ReindexAccepted> {
236
+ assertInferenceActive();
237
+ this.#assertAccepting();
238
+ this.#cleanupExpiredJobs();
239
+
240
+ // Only join work accepted under the current egress policy: a stale entry
241
+ // fails at admission and must not swallow a freshly authorized request.
242
+ const pending = this.#pending.find(
243
+ (entry) =>
244
+ joins(entry, request) &&
245
+ this.#jobAuthorizationEpochs.get(entry.job.id) ===
246
+ this.#authorizationEpoch
247
+ );
248
+ if (pending) {
249
+ if (request.paths === null) {
250
+ pending.paths = null;
251
+ pending.options = request.options;
252
+ } else if (pending.paths) {
253
+ for (const path of request.paths) pending.paths.add(path);
254
+ // Too many files for one path pass: rescan the collection instead,
255
+ // with no git pull or update command, which no path request asked for.
256
+ if (pending.paths.size > MAX_REINDEX_PATHS) {
257
+ pending.paths = null;
258
+ pending.options = { gitPull: false, runUpdateCmd: false };
259
+ }
260
+ }
261
+ pending.run = request.run;
262
+ pending.job.scope = scopeRecord(pending.collection, pending.paths);
263
+ return { jobId: pending.job.id, status: "queued" };
264
+ }
265
+
266
+ if (!this.#activeJobId && this.#pending.length === 0) {
267
+ const job = this.#newJob(request.type, "running");
268
+ job.scope = scopeRecord(request.collection, request.paths);
269
+ this.#activeJobId = job.id;
270
+ const lock = await acquireWriteLock(this.#lockPath, this.#lockTimeoutMs);
271
+ if (!lock || !this.#accepting) {
272
+ await lock?.release();
273
+ this.#jobs.delete(job.id);
274
+ this.#jobAuthorizationEpochs.delete(job.id);
275
+ this.#activeJobId = null;
276
+ this.#track(this.#drainPending());
277
+ this.#assertAccepting();
278
+ throw new JobError("LOCKED", MCP_ERRORS.LOCKED.message);
279
+ }
280
+ const scope: ReindexScope = {
281
+ collection: request.collection,
282
+ paths: request.paths,
283
+ options: request.options,
284
+ };
285
+ this.#launchTyped(job, (signal) => request.run(scope, signal), lock);
286
+ return { jobId: job.id, status: "running" };
287
+ }
288
+
289
+ if (this.#pending.length >= MAX_PENDING_REINDEX_JOBS) {
290
+ throw new JobError(
291
+ "QUEUE_FULL",
292
+ `${MCP_ERRORS.QUEUE_FULL.message} (${MAX_PENDING_REINDEX_JOBS} waiting)`
293
+ );
294
+ }
295
+ const job = this.#newJob(request.type, "queued");
296
+ job.scope = scopeRecord(request.collection, request.paths);
297
+ this.#pending.push({
298
+ job,
299
+ type: request.type,
300
+ collection: request.collection,
301
+ paths: request.paths ? new Set(request.paths) : null,
302
+ options: request.paths ? null : request.options,
303
+ run: request.run,
304
+ });
305
+ return { jobId: job.id, status: "queued" };
306
+ }
307
+
167
308
  getJob(jobId: string): JobRecord | undefined {
168
309
  this.#cleanupExpiredJobs();
169
310
  return this.#jobs.get(jobId);
@@ -184,7 +325,7 @@ export class JobManager {
184
325
  let invalidated = 0;
185
326
  for (const job of this.#jobs.values()) {
186
327
  if (
187
- job.status === "running" &&
328
+ (job.status === "running" || job.status === "queued") &&
188
329
  this.#jobAuthorizationEpochs.get(job.id) !== epoch
189
330
  ) {
190
331
  invalidated += 1;
@@ -202,6 +343,7 @@ export class JobManager {
202
343
  }
203
344
 
204
345
  clear(): void {
346
+ this.#pending = [];
205
347
  this.#jobs.clear();
206
348
  this.#jobAuthorizationEpochs.clear();
207
349
  this.#activeJobId = null;
@@ -212,11 +354,11 @@ export class JobManager {
212
354
 
213
355
  const jobs = Array.from(this.#jobs.values());
214
356
  const active = jobs
215
- .filter((job) => job.status === "running")
357
+ .filter((job) => isUnfinished(job))
216
358
  .sort((a, b) => a.startedAt - b.startedAt);
217
359
 
218
360
  const recent = jobs
219
- .filter((job) => job.status !== "running")
361
+ .filter((job) => !isUnfinished(job))
220
362
  .sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0))
221
363
  .slice(0, limit);
222
364
 
@@ -238,6 +380,9 @@ export class JobManager {
238
380
 
239
381
  stop(): void {
240
382
  this.#accepting = false;
383
+ for (const { job } of this.#pending)
384
+ failJob(job, "Resident runtime is shutting down");
385
+ this.#pending = [];
241
386
  }
242
387
 
243
388
  cancel(): void {
@@ -303,6 +448,7 @@ export class JobManager {
303
448
  this.#activeJobId = null;
304
449
  await lock.release().catch(() => undefined);
305
450
  this.#cleanupExpiredJobs();
451
+ await this.#drainPending();
306
452
  }
307
453
  }
308
454
 
@@ -340,28 +486,71 @@ export class JobManager {
340
486
  fn: (signal: AbortSignal) => Promise<JobResult>,
341
487
  lock: WriteLockHandle
342
488
  ): string {
343
- const jobId = crypto.randomUUID();
344
- const ownedLock = this.#ownLock(jobId, lock);
489
+ const job = this.#newJob(type, "running");
490
+ this.#launchTyped(job, fn, lock);
491
+ return job.id;
492
+ }
493
+
494
+ /** Record a job and the authorization epoch it was accepted under. */
495
+ #newJob(type: JobType, status: "queued" | "running"): JobRecord {
345
496
  const job: JobRecord = {
346
- id: jobId,
497
+ id: crypto.randomUUID(),
347
498
  type,
348
- status: "running",
499
+ status,
349
500
  startedAt: Date.now(),
350
501
  serverInstanceId: this.#serverInstanceId,
351
502
  };
503
+ this.#jobs.set(job.id, job);
504
+ this.#jobAuthorizationEpochs.set(job.id, this.#authorizationEpoch);
505
+ return job;
506
+ }
352
507
 
353
- this.#jobControllers.set(jobId, new AbortController());
354
- this.#jobs.set(jobId, job);
355
- this.#jobAuthorizationEpochs.set(jobId, this.#authorizationEpoch);
356
- this.#activeJobId = jobId;
508
+ #launchTyped(
509
+ job: JobRecord,
510
+ fn: (signal: AbortSignal) => Promise<JobResult>,
511
+ lock: WriteLockHandle
512
+ ): void {
513
+ const ownedLock = this.#ownLock(job.id, lock);
514
+ const controller = new AbortController();
515
+ job.status = "running";
516
+ job.startedAt = Date.now();
517
+ this.#jobControllers.set(job.id, controller);
518
+ this.#activeJobId = job.id;
357
519
 
358
520
  const jobPromise = withOwnedInferenceScope(
359
- { signal: this.#jobControllers.get(jobId)!.signal },
521
+ { signal: controller.signal },
360
522
  () => withBackgroundInference(() => this.#runTypedJob(job, fn, ownedLock))
361
523
  );
362
524
  this.#track(jobPromise);
525
+ }
363
526
 
364
- return jobId;
527
+ /** Start waiting reindex jobs, in arrival order, while nothing is running. */
528
+ async #drainPending(): Promise<void> {
529
+ while (!this.#activeJobId) {
530
+ const entry = this.#pending.shift();
531
+ if (!entry) return;
532
+ this.#activeJobId = entry.job.id;
533
+ const lock = await acquireWriteLock(this.#lockPath, this.#lockTimeoutMs);
534
+ if (!lock || !this.#accepting) {
535
+ await lock?.release();
536
+ this.#activeJobId = null;
537
+ failJob(
538
+ entry.job,
539
+ this.#accepting
540
+ ? `${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`
541
+ : "Resident runtime is shutting down"
542
+ );
543
+ continue;
544
+ }
545
+ const scope: ReindexScope = {
546
+ collection: entry.collection,
547
+ paths: entry.paths ? [...entry.paths].sort() : null,
548
+ options: entry.options ?? NO_REINDEX_OPTIONS,
549
+ };
550
+ this.#rootContext(() =>
551
+ this.#launchTyped(entry.job, (signal) => entry.run(scope, signal), lock)
552
+ );
553
+ }
365
554
  }
366
555
 
367
556
  async #runTypedJob(
@@ -376,6 +565,7 @@ export class JobManager {
376
565
  assertInferenceActive();
377
566
  job.status = "completed";
378
567
  job.typedResult = result;
568
+ if (result.kind === "sync") job.result = result.value;
379
569
  } catch (e) {
380
570
  job.status = "failed";
381
571
  job.error = e instanceof Error ? e.message : String(e);
@@ -386,12 +576,13 @@ export class JobManager {
386
576
  this.#activeJobId = null;
387
577
  await lock.release().catch(() => undefined);
388
578
  this.#cleanupExpiredJobs();
579
+ await this.#drainPending();
389
580
  }
390
581
  }
391
582
 
392
583
  #cleanupExpiredJobs(now: number = Date.now()): void {
393
584
  for (const [id, job] of this.#jobs) {
394
- if (job.status === "running") {
585
+ if (isUnfinished(job)) {
395
586
  continue;
396
587
  }
397
588
  const completedAt = job.completedAt ?? job.startedAt;
@@ -402,7 +593,7 @@ export class JobManager {
402
593
  }
403
594
 
404
595
  const completed = Array.from(this.#jobs.values())
405
- .filter((job) => job.status !== "running")
596
+ .filter((job) => !isUnfinished(job))
406
597
  .sort((a, b) => (a.completedAt ?? 0) - (b.completedAt ?? 0));
407
598
 
408
599
  if (completed.length <= JOB_MAX_RECENT) {
@@ -442,3 +633,31 @@ export class JobManager {
442
633
  );
443
634
  }
444
635
  }
636
+
637
+ function joins(entry: PendingReindex, request: ReindexRequest): boolean {
638
+ if (entry.type !== request.type || entry.collection !== request.collection)
639
+ return false;
640
+ if (request.paths !== null) return true;
641
+ if (entry.options === null) return true;
642
+ return (
643
+ entry.paths === null &&
644
+ entry.options.gitPull === request.options.gitPull &&
645
+ entry.options.runUpdateCmd === request.options.runUpdateCmd
646
+ );
647
+ }
648
+
649
+ function scopeRecord(
650
+ collection: string | null,
651
+ paths: Iterable<string> | null
652
+ ): NonNullable<JobRecord["scope"]> {
653
+ return { collection, paths: paths ? [...paths].sort() : null };
654
+ }
655
+
656
+ const isUnfinished = (job: JobRecord): boolean =>
657
+ job.status === "running" || job.status === "queued";
658
+
659
+ function failJob(job: JobRecord, error: string): void {
660
+ job.status = "failed";
661
+ job.error = error;
662
+ job.completedAt = Date.now();
663
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Validate caller-supplied paths for a path-level reindex.
3
+ *
4
+ * @module src/core/reindex-paths
5
+ */
6
+
7
+ // node:fs/promises realpath, lstat/stat (symlinks, directories) and node:path
8
+ // have no Bun equivalent.
9
+ import { lstat, readlink, realpath, stat } from "node:fs/promises";
10
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
11
+
12
+ import type { Collection } from "../config/types";
13
+
14
+ import { collectionToWalkConfig } from "../ingestion/types";
15
+ import { matchesWalkPath } from "../ingestion/walker";
16
+ import { MCP_ERRORS } from "./errors";
17
+
18
+ const escapes = (rel: string): boolean =>
19
+ rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
20
+
21
+ const toPosix = (rel: string): string => rel.split(sep).join("/");
22
+
23
+ const missing = (error: unknown): boolean =>
24
+ error !== null &&
25
+ typeof error === "object" &&
26
+ "code" in error &&
27
+ (error.code === "ENOENT" || error.code === "ENOTDIR");
28
+
29
+ const MAX_SYMLINK_HOPS = 32;
30
+
31
+ /**
32
+ * Most paths one request may list, and most a queued job keeps after merging.
33
+ * Past it, reindexing the whole collection is the better call.
34
+ */
35
+ export const MAX_REINDEX_PATHS = 256;
36
+
37
+ /**
38
+ * Realpath of `path`, or of its nearest existing ancestor below `root`. A
39
+ * dangling symlink on the way resolves to where it points, so a missing file
40
+ * cannot leave the root through a link whose target does not exist.
41
+ */
42
+ async function nearestRealpath(
43
+ path: string,
44
+ root: string,
45
+ hops = 0
46
+ ): Promise<string | null> {
47
+ let current = path;
48
+ for (;;) {
49
+ try {
50
+ return await realpath(current);
51
+ } catch (error) {
52
+ if (!missing(error)) throw error;
53
+ }
54
+ const isLink = await lstat(current).then(
55
+ (stats) => stats.isSymbolicLink(),
56
+ () => false
57
+ );
58
+ if (isLink) {
59
+ const target = resolve(dirname(current), await readlink(current));
60
+ if (hops >= MAX_SYMLINK_HOPS) return target;
61
+ return (await nearestRealpath(target, root, hops + 1)) ?? target;
62
+ }
63
+ const parent = dirname(current);
64
+ if (parent === current || relative(root, parent) === "") return null;
65
+ current = parent;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Resolve paths (relative to the collection root, or absolute inside it) to
71
+ * deduplicated collection-relative POSIX paths. A path that no longer exists
72
+ * is kept so the sync can mark it inactive. Throws `INVALID_INPUT: ...` for an
73
+ * empty list or more than `MAX_REINDEX_PATHS` paths, and `INVALID_PATH: ...`
74
+ * for a directory or a path outside the root, including one that leaves it
75
+ * through a symlink.
76
+ */
77
+ export async function resolveReindexPaths(
78
+ collection: Collection,
79
+ paths: readonly unknown[]
80
+ ): Promise<string[]> {
81
+ if (paths.length === 0) {
82
+ throw new Error(
83
+ `${MCP_ERRORS.INVALID_INPUT.code}: paths must list at least one path`
84
+ );
85
+ }
86
+ if (paths.length > MAX_REINDEX_PATHS) {
87
+ throw new Error(
88
+ `${MCP_ERRORS.INVALID_INPUT.code}: paths lists ${paths.length} paths (at most ${MAX_REINDEX_PATHS}); reindex the whole collection instead`
89
+ );
90
+ }
91
+ const root = resolve(collection.path);
92
+ let realRoot: string;
93
+ try {
94
+ realRoot = await realpath(root);
95
+ } catch {
96
+ realRoot = root;
97
+ }
98
+ const resolved = new Set<string>();
99
+ for (const path of paths) {
100
+ if (typeof path !== "string" || path.trim() === "") {
101
+ throw new Error(
102
+ `${MCP_ERRORS.INVALID_INPUT.code}: paths must be non-empty strings`
103
+ );
104
+ }
105
+ const absolute = resolve(root, path);
106
+ const rel = relative(root, absolute);
107
+ if (escapes(rel)) {
108
+ throw new Error(
109
+ `${MCP_ERRORS.INVALID_PATH.code}: ${path} is outside collection ${collection.name}`
110
+ );
111
+ }
112
+ // A missing file is checked through its nearest existing folder, so a
113
+ // deletion path cannot leave the root through a symlinked parent.
114
+ const real = await nearestRealpath(absolute, root);
115
+ if (
116
+ real !== null &&
117
+ escapes(relative(realRoot, real)) &&
118
+ real !== realRoot
119
+ ) {
120
+ throw new Error(
121
+ `${MCP_ERRORS.INVALID_PATH.code}: ${path} resolves outside collection ${collection.name}`
122
+ );
123
+ }
124
+ const isDirectory = await stat(absolute).then(
125
+ (stats) => stats.isDirectory(),
126
+ () => false
127
+ );
128
+ if (isDirectory) {
129
+ throw new Error(
130
+ `${MCP_ERRORS.INVALID_PATH.code}: ${path} is a directory; list the files in it, or reindex the whole collection`
131
+ );
132
+ }
133
+ // A listed path obeys the collection's own pattern, include and exclude
134
+ // rules: an excluded file must not become searchable by naming it.
135
+ if (!matchesWalkPath(toPosix(rel), collectionToWalkConfig(collection, 0))) {
136
+ throw new Error(
137
+ `${MCP_ERRORS.INVALID_PATH.code}: ${path} is not part of collection ${collection.name} (its pattern, include or exclude rules leave it out)`
138
+ );
139
+ }
140
+ resolved.add(toPosix(rel));
141
+ }
142
+ return [...resolved];
143
+ }
144
+
145
+ /**
146
+ * Resolve an optional path list for a reindex request. Paths need a single
147
+ * collection and apply to a plain sync: git pull and update commands act on
148
+ * the whole collection, so they cannot be combined with paths.
149
+ */
150
+ export async function resolveReindexScope(
151
+ collection: Collection | null,
152
+ paths: readonly unknown[] | undefined,
153
+ options: { gitPull?: boolean; runUpdateCmd?: boolean } = {}
154
+ ): Promise<string[] | null> {
155
+ if (paths === undefined) return null;
156
+ if (!collection) {
157
+ throw new Error(
158
+ `${MCP_ERRORS.INVALID_INPUT.code}: paths require a collection`
159
+ );
160
+ }
161
+ if (options.gitPull || options.runUpdateCmd) {
162
+ throw new Error(
163
+ `${MCP_ERRORS.INVALID_INPUT.code}: paths cannot be combined with gitPull or an update command`
164
+ );
165
+ }
166
+ return resolveReindexPaths(collection, paths);
167
+ }
@@ -117,6 +117,7 @@ export const retrievalTraceFilters = (
117
117
  limit: options.limit,
118
118
  minScore: options.minScore,
119
119
  collection: options.collection,
120
+ collections: options.collections,
120
121
  lang: options.lang,
121
122
  full: options.full,
122
123
  lineNumbers: options.lineNumbers,