@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
package/src/sdk/client.ts CHANGED
@@ -110,6 +110,7 @@ import {
110
110
  import { publishCapture } from "../core/capture-publish";
111
111
  import { projectCollectionEgressPolicy } from "../core/collection-egress-policy-projection";
112
112
  import { CollectionEgressPolicyService } from "../core/collection-egress-policy-service";
113
+ import { resolveCollectionScope } from "../core/collection-scope";
113
114
  import { applyConfigChange } from "../core/config-mutation";
114
115
  import { getDocumentCapabilities } from "../core/document-capabilities";
115
116
  import { EgressAuditService } from "../core/egress-audit";
@@ -196,7 +197,7 @@ import {
196
197
  } from "../llm/inference-scope";
197
198
  import { LlmAdapter } from "../llm/nodeLlamaCpp/adapter";
198
199
  import { resolveDownloadPolicy } from "../llm/policy";
199
- import { resolveModelUri } from "../llm/registry";
200
+ import { modelCollectionForList, resolveModelUri } from "../llm/registry";
200
201
  import {
201
202
  answerTraceTerminalStatus,
202
203
  generateGroundedAnswer,
@@ -476,6 +477,27 @@ class GnoClientImpl implements GnoClient {
476
477
  }
477
478
  }
478
479
 
480
+ /** Normalize and validate a search/vsearch/query `collections` allowlist. */
481
+ private withCollectionScope<
482
+ T extends { collection?: string; collections?: string[] },
483
+ >(options: T): T {
484
+ const scope = resolveCollectionScope(options, this.config.collections);
485
+ if (!scope.ok) throw sdkError("VALIDATION", scope.message);
486
+ return scope.collections === undefined
487
+ ? options
488
+ : { ...options, collections: scope.collections };
489
+ }
490
+
491
+ private modelCollection(
492
+ type: "embed" | "expand" | "rerank",
493
+ options: { collection?: string; collections?: string[] }
494
+ ): string | undefined {
495
+ return (
496
+ options.collection ??
497
+ modelCollectionForList(this.config, type, options.collections)
498
+ );
499
+ }
500
+
479
501
  private getCollections(collection?: string) {
480
502
  if (!collection) {
481
503
  return this.config.collections;
@@ -541,6 +563,7 @@ class GnoClientImpl implements GnoClient {
541
563
  answer?: boolean;
542
564
  rerank?: boolean;
543
565
  collection?: string;
566
+ collections?: string[];
544
567
  requiredEmbed?: boolean;
545
568
  requiredExpand?: boolean;
546
569
  requiredAnswer?: boolean;
@@ -551,9 +574,9 @@ class GnoClientImpl implements GnoClient {
551
574
  rerankModel?: string;
552
575
  }): Promise<RuntimePorts> {
553
576
  this.assertOpen();
554
- const egressCollections = options.collection
555
- ? [options.collection]
556
- : ("all" as const);
577
+ const egressCollections =
578
+ options.collections ??
579
+ (options.collection ? [options.collection] : ("all" as const));
557
580
 
558
581
  let embedPort: EmbeddingPort | null = null;
559
582
  let expandPort: GenerationPort | null = null;
@@ -567,7 +590,7 @@ class GnoClientImpl implements GnoClient {
567
590
  this.config,
568
591
  "embed",
569
592
  options.embedModel,
570
- options.collection
593
+ this.modelCollection("embed", options)
571
594
  ),
572
595
  {
573
596
  egressCollections,
@@ -612,7 +635,7 @@ class GnoClientImpl implements GnoClient {
612
635
  this.config,
613
636
  "expand",
614
637
  options.expandModel ?? options.genModel,
615
- options.collection
638
+ this.modelCollection("expand", options)
616
639
  ),
617
640
  {
618
641
  egressCollections,
@@ -665,7 +688,7 @@ class GnoClientImpl implements GnoClient {
665
688
  this.config,
666
689
  "rerank",
667
690
  options.rerankModel,
668
- options.collection
691
+ this.modelCollection("rerank", options)
669
692
  ),
670
693
  {
671
694
  egressCollections,
@@ -735,6 +758,7 @@ class GnoClientImpl implements GnoClient {
735
758
  options: GnoSearchOptions
736
759
  ): Promise<SearchResults> {
737
760
  this.assertOpen();
761
+ options = this.withCollectionScope(options);
738
762
  let traceSession: RetrievalTraceSession | null = null;
739
763
  try {
740
764
  const { projectHints, ...searchOptions } = options;
@@ -789,6 +813,7 @@ class GnoClientImpl implements GnoClient {
789
813
  options: GnoVectorSearchOptions
790
814
  ): Promise<SearchResults> {
791
815
  this.assertOpen();
816
+ options = this.withCollectionScope(options);
792
817
 
793
818
  let ports: RuntimePorts | null = null;
794
819
  let traceSession: RetrievalTraceSession | null = null;
@@ -805,7 +830,7 @@ class GnoClientImpl implements GnoClient {
805
830
  this.config,
806
831
  "embed",
807
832
  options.model,
808
- options.collection
833
+ this.modelCollection("embed", options)
809
834
  );
810
835
  traceSession = unwrapStore(
811
836
  await startRetrievalTraceRequest({
@@ -823,6 +848,7 @@ class GnoClientImpl implements GnoClient {
823
848
  requiredEmbed: true,
824
849
  embedModel: options.model,
825
850
  collection: options.collection,
851
+ collections: options.collections,
826
852
  });
827
853
  if (!ports.embedPort || !ports.vectorIndex) {
828
854
  throw sdkError(
@@ -882,6 +908,7 @@ class GnoClientImpl implements GnoClient {
882
908
  options: GnoQueryOptions
883
909
  ): Promise<SearchResults> {
884
910
  this.assertOpen();
911
+ options = this.withCollectionScope(options);
885
912
 
886
913
  const normalizedInput = normalizeStructuredQueryInput(
887
914
  query,
@@ -905,14 +932,14 @@ class GnoClientImpl implements GnoClient {
905
932
  this.config,
906
933
  "embed",
907
934
  options.embedModel,
908
- options.collection
935
+ this.modelCollection("embed", options)
909
936
  );
910
937
  const expandUri = expandRequested
911
938
  ? resolveModelUri(
912
939
  this.config,
913
940
  "expand",
914
941
  options.expandModel ?? options.genModel,
915
- options.collection
942
+ this.modelCollection("expand", options)
916
943
  )
917
944
  : undefined;
918
945
  const rerankUri = rerankRequested
@@ -920,7 +947,7 @@ class GnoClientImpl implements GnoClient {
920
947
  this.config,
921
948
  "rerank",
922
949
  options.rerankModel,
923
- options.collection
950
+ this.modelCollection("rerank", options)
924
951
  )
925
952
  : undefined;
926
953
  let ports: RuntimePorts | null = null;
@@ -956,6 +983,7 @@ class GnoClientImpl implements GnoClient {
956
983
  genModel: options.genModel,
957
984
  rerankModel: options.rerankModel,
958
985
  collection: options.collection,
986
+ collections: options.collections,
959
987
  });
960
988
  return attachRetrievalTraceMetadata(
961
989
  this.decorateSearchResults(
@@ -0,0 +1,27 @@
1
+ /**
2
+ * SDK conversion of one file to the Markdown GNO would index (no client,
3
+ * config or index needed).
4
+ *
5
+ * @module src/sdk/convert
6
+ */
7
+
8
+ import {
9
+ type ConvertFileResult,
10
+ convertFile as convertOneFile,
11
+ } from "../ingestion/convert-file";
12
+ import { sdkError } from "./errors";
13
+
14
+ /**
15
+ * Convert one file to the Markdown GNO would index for it; nothing is
16
+ * persisted. Failures throw `GnoSdkError` with `details.reason` set to the
17
+ * same reason `gno convert` reports.
18
+ */
19
+ export async function convertFile(path: string): Promise<ConvertFileResult> {
20
+ const result = await convertOneFile(path);
21
+ if (result.ok) return result.value;
22
+ const { reason, message, validation, details } = result.error;
23
+ let code: "NOT_FOUND" | "VALIDATION" | "RUNTIME" = "RUNTIME";
24
+ if (reason === "NOT_FOUND") code = "NOT_FOUND";
25
+ else if (validation) code = "VALIDATION";
26
+ throw sdkError(code, message, { details: { reason, ...details } });
27
+ }
package/src/sdk/index.ts CHANGED
@@ -27,6 +27,8 @@ export type { IndexStatus } from "../store/types";
27
27
 
28
28
  export { GnoSdkError, sdkError } from "./errors";
29
29
  export { createGnoClient } from "./client";
30
+ export { convertFile } from "./convert";
31
+ export type { ConvertFileResult } from "../ingestion/convert-file";
30
32
  export type {
31
33
  DocumentSection,
32
34
  GnoAskOptions,
package/src/sdk/types.ts CHANGED
@@ -179,7 +179,7 @@ export type GnoQueryOptions = Omit<
179
179
  GnoProjectHintOptions;
180
180
  export type GnoAskOptions = Omit<
181
181
  AskOptions,
182
- "contentTypeRules" | "projectAffinity"
182
+ "collections" | "contentTypeRules" | "projectAffinity"
183
183
  > &
184
184
  GnoModelOverrides &
185
185
  GnoProjectHintOptions;
package/src/serve/jobs.ts CHANGED
@@ -16,11 +16,13 @@ export interface JobProgress {
16
16
  export interface JobStatus {
17
17
  id: string;
18
18
  type: JobType;
19
- status: "running" | "completed" | "failed";
19
+ status: "queued" | "running" | "completed" | "failed";
20
20
  createdAt: number;
21
21
  progress?: JobProgress;
22
22
  result?: SyncResult;
23
23
  error?: string;
24
+ /** Reindex jobs: the collection (null = all) and merged paths (null = whole). */
25
+ scope?: JobRecord["scope"];
24
26
  }
25
27
 
26
28
  export interface StartJobSuccess {
@@ -49,6 +51,7 @@ const fromResidentRecord = (record: JobRecord): JobStatus => ({
49
51
  progress: record.progress,
50
52
  result: record.result,
51
53
  error: record.error,
54
+ ...(record.scope ? { scope: record.scope } : {}),
52
55
  });
53
56
 
54
57
  function cleanupExpiredJobs(now = Date.now()): void {
@@ -24,7 +24,7 @@ import { Badge } from "./ui/badge";
24
24
  export interface JobStatus {
25
25
  id: string;
26
26
  type: "add" | "sync";
27
- status: "running" | "completed" | "failed";
27
+ status: "queued" | "running" | "completed" | "failed";
28
28
  createdAt: number;
29
29
  result?: SyncResult;
30
30
  error?: string;
@@ -138,7 +138,7 @@ export function IndexingProgress({
138
138
  setStatus(data);
139
139
  setFetchError(null);
140
140
 
141
- if (data.status === "running") {
141
+ if (data.status === "queued" || data.status === "running") {
142
142
  pollTimeout = setTimeout(() => {
143
143
  void poll();
144
144
  }, 1000);
@@ -187,6 +187,20 @@ export function IndexingProgress({
187
187
 
188
188
  // Compact mode
189
189
  if (compact) {
190
+ if (status?.status === "queued") {
191
+ return (
192
+ <div
193
+ aria-live="polite"
194
+ className={cn("flex items-center gap-2 text-sm", className)}
195
+ >
196
+ <Loader2Icon className="size-4 animate-spin text-muted-foreground" />
197
+ <span className="text-muted-foreground">
198
+ Queued... {formatElapsed(elapsed)}
199
+ </span>
200
+ </div>
201
+ );
202
+ }
203
+
190
204
  if (status?.status === "running") {
191
205
  return (
192
206
  <div
@@ -237,6 +251,14 @@ export function IndexingProgress({
237
251
  {/* Status header */}
238
252
  <div className="flex items-center justify-between">
239
253
  <div className="flex items-center gap-2">
254
+ {status?.status === "queued" && (
255
+ <>
256
+ <Loader2Icon className="size-5 animate-spin text-muted-foreground" />
257
+ <span className="font-medium">
258
+ Queued behind another indexing job
259
+ </span>
260
+ </>
261
+ )}
240
262
  {status?.status === "running" && (
241
263
  <>
242
264
  <Loader2Icon className="size-5 animate-spin text-primary" />
@@ -264,7 +286,7 @@ export function IndexingProgress({
264
286
  <div className="flex items-center gap-1.5 text-muted-foreground text-sm">
265
287
  <TimerIcon className="size-4" />
266
288
  <span>
267
- {status?.status === "running"
289
+ {status?.status === "queued" || status?.status === "running"
268
290
  ? formatElapsed(elapsed)
269
291
  : formatDuration(status?.result?.totalDurationMs ?? elapsed)}
270
292
  </span>
@@ -16,7 +16,6 @@ import type {
16
16
  EgressPolicy,
17
17
  ModelPreset,
18
18
  } from "../../config/types";
19
- import type { JobManager } from "../../core/job-manager";
20
19
  import type { ProjectAffinityScoringInput } from "../../pipeline/project-affinity";
21
20
  import type {
22
21
  AskResult,
@@ -57,6 +56,7 @@ import {
57
56
  type EgressRelaxationConfirmation,
58
57
  } from "../../core/collection-egress-policy-service";
59
58
  import { parsePolicySetBody } from "../../core/collection-egress-policy-validation";
59
+ import { resolveCollectionScope } from "../../core/collection-scope";
60
60
  import {
61
61
  type ConnectorVerificationCode,
62
62
  getConnectorVerificationRemediation,
@@ -82,6 +82,7 @@ import {
82
82
  planCreateFolder,
83
83
  planDuplicateRefactor,
84
84
  } from "../../core/file-refactors";
85
+ import { JobError, type JobManager } from "../../core/job-manager";
85
86
  import {
86
87
  MemoryError,
87
88
  type MemoryErrorCode,
@@ -111,6 +112,7 @@ import {
111
112
  resolveRemoteProjectAffinity,
112
113
  } from "../../core/project-affinity-surface";
113
114
  import { projectRecordEvidenceMetadata } from "../../core/record-metadata";
115
+ import { resolveReindexScope } from "../../core/reindex-paths";
114
116
  import {
115
117
  isLeaseBusy,
116
118
  localRequestLedger,
@@ -303,6 +305,8 @@ export interface SearchRequestBody {
303
305
  limit?: number;
304
306
  minScore?: number;
305
307
  collection?: string;
308
+ /** Collection allowlist (1-256 names); not combined with `collection`. */
309
+ collections?: string[];
306
310
  intent?: string;
307
311
  exclude?: string;
308
312
  since?: string;
@@ -323,6 +327,8 @@ export interface QueryRequestBody {
323
327
  limit?: number;
324
328
  minScore?: number;
325
329
  collection?: string;
330
+ /** Collection allowlist (1-256 names); not combined with `collection`. */
331
+ collections?: string[];
326
332
  lang?: string;
327
333
  intent?: string;
328
334
  candidateLimit?: number;
@@ -496,6 +502,7 @@ export interface ImportPreviewRequestBody {
496
502
  export interface SyncRequestBody {
497
503
  collection?: string;
498
504
  gitPull?: boolean;
505
+ paths?: string[];
499
506
  }
500
507
 
501
508
  export interface CreateDocRequestBody {
@@ -651,6 +658,42 @@ function parseRestMetadataFilter(
651
658
  return { ok: true, filter: normalizeMetadataPredicate(parsed.data) };
652
659
  }
653
660
 
661
+ /**
662
+ * Resolve a search/query `collections` allowlist: 400 VALIDATION for malformed
663
+ * input or `collection` with `collections`, 404 NOT_FOUND for unknown names.
664
+ */
665
+ async function parseRestCollectionScope(
666
+ body: { collection?: unknown; collections?: unknown },
667
+ configured: readonly { name: string }[] | null,
668
+ store: SqliteAdapter
669
+ ): Promise<
670
+ | { ok: true; collections: string[] | undefined }
671
+ | { ok: false; response: Response }
672
+ > {
673
+ if (body.collections === undefined)
674
+ return { ok: true, collections: undefined };
675
+ let names = configured;
676
+ if (!names) {
677
+ const stored = await store.getCollections();
678
+ if (!stored.ok) {
679
+ return {
680
+ ok: false,
681
+ response: errorResponse("RUNTIME", stored.error.message, 500),
682
+ };
683
+ }
684
+ names = stored.value;
685
+ }
686
+ const scope = resolveCollectionScope(body, names);
687
+ if (scope.ok) return { ok: true, collections: scope.collections };
688
+ return {
689
+ ok: false,
690
+ response:
691
+ scope.kind === "validation"
692
+ ? errorResponse("VALIDATION", scope.message)
693
+ : errorResponse("NOT_FOUND", scope.message, 404),
694
+ };
695
+ }
696
+
654
697
  function fileRefactorHttpErrorResponse(error: FileRefactorHttpError): Response {
655
698
  return errorResponse(error.code, error.message, error.status, error.details);
656
699
  }
@@ -1780,6 +1823,9 @@ export async function handleSync(
1780
1823
  if (body.gitPull !== undefined && typeof body.gitPull !== "boolean") {
1781
1824
  return errorResponse("VALIDATION", "gitPull must be a boolean");
1782
1825
  }
1826
+ if (body.paths !== undefined && !Array.isArray(body.paths)) {
1827
+ return errorResponse("VALIDATION", "paths must be an array of strings");
1828
+ }
1783
1829
 
1784
1830
  // Get collections to sync (case-insensitive matching)
1785
1831
  const collectionName = body.collection?.toLowerCase();
@@ -1801,31 +1847,95 @@ export async function handleSync(
1801
1847
  return errorResponse("VALIDATION", "No collections to sync");
1802
1848
  }
1803
1849
 
1804
- // Start background sync job
1805
- const jobResult = await startJob(
1806
- "sync",
1807
- async (): Promise<SyncResult> => {
1808
- const result = await defaultSyncService.syncAll(
1809
- collections,
1810
- store,
1811
- withContentTypeRules(
1812
- {
1813
- gitPull: body.gitPull,
1814
- runUpdateCmd: true,
1815
- },
1816
- ctxHolder.config
1817
- )
1818
- );
1819
- recordContentMutation(result, ctxHolder.markContentMutation);
1820
- if (hasContentMutation(result)) {
1821
- if (ctxHolder.scheduler) {
1822
- await ctxHolder.scheduler.triggerNow();
1823
- }
1824
- }
1825
- return result;
1850
+ const options = withContentTypeRules(
1851
+ {
1852
+ gitPull: body.gitPull,
1853
+ runUpdateCmd: true,
1826
1854
  },
1827
- ctxHolder.jobManager
1855
+ ctxHolder.config
1828
1856
  );
1857
+ let paths: string[] | null;
1858
+ try {
1859
+ // A path-level sync runs no update command; only git pull conflicts.
1860
+ paths = await resolveReindexScope(
1861
+ body.collection ? (collections[0] ?? null) : null,
1862
+ body.paths,
1863
+ { gitPull: body.gitPull }
1864
+ );
1865
+ } catch (error) {
1866
+ const message = error instanceof Error ? error.message : String(error);
1867
+ const separator = message.indexOf(": ");
1868
+ const code = separator > 0 ? message.slice(0, separator) : "";
1869
+ return errorResponse(
1870
+ code === "INVALID_PATH" ? "INVALID_PATH" : "VALIDATION",
1871
+ separator > 0 ? message.slice(separator + 2) : message
1872
+ );
1873
+ }
1874
+
1875
+ // Captured now: a queued pass runs after this request's context is gone.
1876
+ const config = ctxHolder.config;
1877
+ const runSync = async (
1878
+ scopedPaths: string[] | null,
1879
+ passOptions: { gitPull?: boolean; runUpdateCmd?: boolean }
1880
+ ): Promise<SyncResult> => {
1881
+ const collection = collections[0];
1882
+ const result =
1883
+ scopedPaths && collection
1884
+ ? wrapCollectionSync(
1885
+ await defaultSyncService.syncPaths(
1886
+ collection,
1887
+ store,
1888
+ scopedPaths,
1889
+ withContentTypeRules({}, config)
1890
+ )
1891
+ )
1892
+ : await defaultSyncService.syncAll(
1893
+ collections,
1894
+ store,
1895
+ withContentTypeRules({ ...passOptions }, config)
1896
+ );
1897
+ recordContentMutation(result, ctxHolder.markContentMutation);
1898
+ if (hasContentMutation(result)) {
1899
+ if (ctxHolder.scheduler) {
1900
+ await ctxHolder.scheduler.triggerNow();
1901
+ }
1902
+ }
1903
+ return result;
1904
+ };
1905
+
1906
+ const manager = ctxHolder.jobManager;
1907
+ if (manager) {
1908
+ try {
1909
+ const accepted = await manager.submitReindex({
1910
+ type: "sync",
1911
+ collection: body.collection ? (collections[0]?.name ?? null) : null,
1912
+ paths,
1913
+ // A whole sync over REST always runs the collection's update command.
1914
+ options: { gitPull: body.gitPull ?? false, runUpdateCmd: true },
1915
+ run: async (scope) => ({
1916
+ kind: "sync",
1917
+ value: await runSync(scope.paths, scope.options),
1918
+ }),
1919
+ });
1920
+ return jsonResponse(
1921
+ { jobId: accepted.jobId, status: accepted.status },
1922
+ 202
1923
+ );
1924
+ } catch (error) {
1925
+ if (error instanceof JobError && error.code === "QUEUE_FULL") {
1926
+ return errorResponse("QUEUE_FULL", error.message, 429);
1927
+ }
1928
+ if (error instanceof JobError) {
1929
+ return errorResponse("CONFLICT", error.message, 409, {
1930
+ activeJobId: manager.getActiveJob()?.id ?? "",
1931
+ });
1932
+ }
1933
+ throw error;
1934
+ }
1935
+ }
1936
+
1937
+ // Standalone fallback (unit fixtures without a resident job manager).
1938
+ const jobResult = startJob("sync", () => runSync(paths, options));
1829
1939
 
1830
1940
  if (!jobResult.ok) {
1831
1941
  return jobConflictResponse(jobResult);
@@ -1834,6 +1944,20 @@ export async function handleSync(
1834
1944
  return jsonResponse({ jobId: jobResult.jobId }, 202);
1835
1945
  }
1836
1946
 
1947
+ function wrapCollectionSync(
1948
+ result: Awaited<ReturnType<typeof defaultSyncService.syncPaths>>
1949
+ ): SyncResult {
1950
+ return {
1951
+ collections: [result],
1952
+ totalDurationMs: result.durationMs,
1953
+ totalFilesProcessed: result.filesProcessed,
1954
+ totalFilesAdded: result.filesAdded,
1955
+ totalFilesUpdated: result.filesUpdated,
1956
+ totalFilesErrored: result.filesErrored,
1957
+ totalFilesSkipped: result.filesSkipped,
1958
+ };
1959
+ }
1960
+
1837
1961
  /**
1838
1962
  * GET /api/docs
1839
1963
  * Query params: collection, limit (default 20), offset (default 0), tagsAll, tagsAny
@@ -4587,6 +4711,13 @@ export async function handleSearch(
4587
4711
  const parsedFilter = parseRestMetadataFilter(body.filter);
4588
4712
  if (!parsedFilter.ok) return parsedFilter.response;
4589
4713
  const filter = parsedFilter.filter;
4714
+ const collectionScope = await parseRestCollectionScope(
4715
+ body,
4716
+ context?.config.collections ?? null,
4717
+ store
4718
+ );
4719
+ if (!collectionScope.ok) return collectionScope.response;
4720
+ const { collections } = collectionScope;
4590
4721
  let projectAffinity: ProjectAffinityScoringInput | undefined;
4591
4722
  try {
4592
4723
  projectAffinity = context
@@ -4606,6 +4737,7 @@ export async function handleSearch(
4606
4737
  limit: Math.min(body.limit || 10, 50),
4607
4738
  minScore: body.minScore,
4608
4739
  collection: body.collection,
4740
+ collections,
4609
4741
  intent: body.intent?.trim() || undefined,
4610
4742
  exclude,
4611
4743
  tagsAll,
@@ -4785,6 +4917,13 @@ export async function handleQuery(
4785
4917
  const parsedFilter = parseRestMetadataFilter(body.filter);
4786
4918
  if (!parsedFilter.ok) return parsedFilter.response;
4787
4919
  const filter = parsedFilter.filter;
4920
+ const collectionScope = await parseRestCollectionScope(
4921
+ body,
4922
+ ctx.config.collections,
4923
+ ctx.store
4924
+ );
4925
+ if (!collectionScope.ok) return collectionScope.response;
4926
+ const { collections } = collectionScope;
4788
4927
  let projectAffinity: ProjectAffinityScoringInput | undefined;
4789
4928
  try {
4790
4929
  projectAffinity = await resolveRemoteProjectAffinity(
@@ -4804,6 +4943,7 @@ export async function handleQuery(
4804
4943
  limit: Math.min(body.limit ?? 20, 50),
4805
4944
  minScore: body.minScore,
4806
4945
  collection: body.collection,
4946
+ collections,
4807
4947
  lang: body.lang,
4808
4948
  intent: body.intent?.trim() || undefined,
4809
4949
  candidateLimit: