@gmickel/gno 2.12.1 → 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.1.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 +97 -2
  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 +264 -47
  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.1.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 {
@@ -287,20 +432,12 @@ export class JobManager {
287
432
  lock: { release: () => Promise<void> }
288
433
  ): Promise<void> {
289
434
  try {
290
- const release = await this.#toolMutex.acquire();
291
- try {
292
- this.#assertAuthorizationEpoch(job);
293
- assertInferenceActive();
294
- const result = await fn(this.#jobControllers.get(job.id)!.signal);
295
- assertInferenceActive();
296
- job.status = "completed";
297
- job.result = result;
298
- } catch (e) {
299
- job.status = "failed";
300
- job.error = e instanceof Error ? e.message : String(e);
301
- } finally {
302
- release();
303
- }
435
+ await this.#admit(job);
436
+ assertInferenceActive();
437
+ const result = await fn(this.#jobControllers.get(job.id)!.signal);
438
+ assertInferenceActive();
439
+ job.status = "completed";
440
+ job.result = result;
304
441
  } catch (e) {
305
442
  job.status = "failed";
306
443
  job.error = e instanceof Error ? e.message : String(e);
@@ -311,6 +448,7 @@ export class JobManager {
311
448
  this.#activeJobId = null;
312
449
  await lock.release().catch(() => undefined);
313
450
  this.#cleanupExpiredJobs();
451
+ await this.#drainPending();
314
452
  }
315
453
  }
316
454
 
@@ -348,28 +486,71 @@ export class JobManager {
348
486
  fn: (signal: AbortSignal) => Promise<JobResult>,
349
487
  lock: WriteLockHandle
350
488
  ): string {
351
- const jobId = crypto.randomUUID();
352
- 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 {
353
496
  const job: JobRecord = {
354
- id: jobId,
497
+ id: crypto.randomUUID(),
355
498
  type,
356
- status: "running",
499
+ status,
357
500
  startedAt: Date.now(),
358
501
  serverInstanceId: this.#serverInstanceId,
359
502
  };
503
+ this.#jobs.set(job.id, job);
504
+ this.#jobAuthorizationEpochs.set(job.id, this.#authorizationEpoch);
505
+ return job;
506
+ }
360
507
 
361
- this.#jobControllers.set(jobId, new AbortController());
362
- this.#jobs.set(jobId, job);
363
- this.#jobAuthorizationEpochs.set(jobId, this.#authorizationEpoch);
364
- 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;
365
519
 
366
520
  const jobPromise = withOwnedInferenceScope(
367
- { signal: this.#jobControllers.get(jobId)!.signal },
521
+ { signal: controller.signal },
368
522
  () => withBackgroundInference(() => this.#runTypedJob(job, fn, ownedLock))
369
523
  );
370
524
  this.#track(jobPromise);
525
+ }
371
526
 
372
- 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
+ }
373
554
  }
374
555
 
375
556
  async #runTypedJob(
@@ -378,20 +559,13 @@ export class JobManager {
378
559
  lock: { release: () => Promise<void> }
379
560
  ): Promise<void> {
380
561
  try {
381
- const release = await this.#toolMutex.acquire();
382
- try {
383
- this.#assertAuthorizationEpoch(job);
384
- assertInferenceActive();
385
- const result = await fn(this.#jobControllers.get(job.id)!.signal);
386
- assertInferenceActive();
387
- job.status = "completed";
388
- job.typedResult = result;
389
- } catch (e) {
390
- job.status = "failed";
391
- job.error = e instanceof Error ? e.message : String(e);
392
- } finally {
393
- release();
394
- }
562
+ await this.#admit(job);
563
+ assertInferenceActive();
564
+ const result = await fn(this.#jobControllers.get(job.id)!.signal);
565
+ assertInferenceActive();
566
+ job.status = "completed";
567
+ job.typedResult = result;
568
+ if (result.kind === "sync") job.result = result.value;
395
569
  } catch (e) {
396
570
  job.status = "failed";
397
571
  job.error = e instanceof Error ? e.message : String(e);
@@ -402,12 +576,13 @@ export class JobManager {
402
576
  this.#activeJobId = null;
403
577
  await lock.release().catch(() => undefined);
404
578
  this.#cleanupExpiredJobs();
579
+ await this.#drainPending();
405
580
  }
406
581
  }
407
582
 
408
583
  #cleanupExpiredJobs(now: number = Date.now()): void {
409
584
  for (const [id, job] of this.#jobs) {
410
- if (job.status === "running") {
585
+ if (isUnfinished(job)) {
411
586
  continue;
412
587
  }
413
588
  const completedAt = job.completedAt ?? job.startedAt;
@@ -418,7 +593,7 @@ export class JobManager {
418
593
  }
419
594
 
420
595
  const completed = Array.from(this.#jobs.values())
421
- .filter((job) => job.status !== "running")
596
+ .filter((job) => !isUnfinished(job))
422
597
  .sort((a, b) => (a.completedAt ?? 0) - (b.completedAt ?? 0));
423
598
 
424
599
  if (completed.length <= JOB_MAX_RECENT) {
@@ -435,6 +610,20 @@ export class JobManager {
435
610
  }
436
611
  }
437
612
 
613
+ /**
614
+ * Wait for the tool call in progress, then re-check authorization. The
615
+ * mutex is released before the job's work runs, so reads are not held
616
+ * behind a long sync or embed; writers stay serialized by the write lease.
617
+ */
618
+ async #admit(job: JobRecord): Promise<void> {
619
+ const release = await this.#toolMutex.acquire();
620
+ try {
621
+ this.#assertAuthorizationEpoch(job);
622
+ } finally {
623
+ release();
624
+ }
625
+ }
626
+
438
627
  #assertAuthorizationEpoch(job: JobRecord): void {
439
628
  if (this.#jobAuthorizationEpochs.get(job.id) === this.#authorizationEpoch) {
440
629
  return;
@@ -444,3 +633,31 @@ export class JobManager {
444
633
  );
445
634
  }
446
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,