@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
@@ -7,6 +7,7 @@
7
7
  * @module src/cli/commands/indexCmd
8
8
  */
9
9
 
10
+ import { resolveReindexScope } from "../../core/reindex-paths";
10
11
  import {
11
12
  type CliWriteLeaseOptions,
12
13
  type WriteLeaseContention,
@@ -27,6 +28,7 @@ import {
27
28
  type SyncResult,
28
29
  withContentTypeRules,
29
30
  } from "../../ingestion";
31
+ import { CliError } from "../errors";
30
32
  import { type EmbedResult, embedStageOutcome } from "./embed";
31
33
  import {
32
34
  formatSyncResultLines,
@@ -44,6 +46,8 @@ export interface IndexOptions extends CliWriteLeaseOptions {
44
46
  indexName?: string;
45
47
  /** Scope to single collection */
46
48
  collection?: string;
49
+ /** Reindex only these files of `collection` */
50
+ paths?: string[];
47
51
  /** Run ingestion only, skip embedding */
48
52
  noEmbed?: boolean;
49
53
  /** Download models if missing */
@@ -186,6 +190,18 @@ export async function index(options: IndexOptions = {}): Promise<IndexResult> {
186
190
  }
187
191
 
188
192
  const { store, collections, config } = initResult;
193
+ let paths: string[] | null;
194
+ try {
195
+ paths = await resolveReindexScope(
196
+ options.collection ? (collections[0] ?? null) : null,
197
+ options.paths,
198
+ { gitPull: options.gitPull }
199
+ );
200
+ } catch (e) {
201
+ await store.close();
202
+ const message = e instanceof Error ? e.message : String(e);
203
+ throw new CliError("VALIDATION", message.replace(/^[A-Z_]+: /, ""));
204
+ }
189
205
  const db = store.getRawDb();
190
206
  const embedSkipped = options.noEmbed ?? false;
191
207
 
@@ -202,18 +218,40 @@ export async function index(options: IndexOptions = {}): Promise<IndexResult> {
202
218
  markIndexStageRunning(db, "lexical", { collection: options.collection });
203
219
  let syncResult: SyncResult;
204
220
  try {
205
- syncResult = await defaultSyncService.syncAll(
206
- collections,
207
- store,
208
- withContentTypeRules(
209
- {
210
- gitPull: options.gitPull,
211
- runUpdateCmd: true,
212
- onSlowConversion: reportSlowConversion,
213
- },
214
- config
215
- )
216
- );
221
+ const collection = collections[0];
222
+ if (paths && collection) {
223
+ const pathResult = await defaultSyncService.syncPaths(
224
+ collection,
225
+ store,
226
+ paths,
227
+ withContentTypeRules(
228
+ { onSlowConversion: reportSlowConversion },
229
+ config
230
+ )
231
+ );
232
+ syncResult = {
233
+ collections: [pathResult],
234
+ totalDurationMs: pathResult.durationMs,
235
+ totalFilesProcessed: pathResult.filesProcessed,
236
+ totalFilesAdded: pathResult.filesAdded,
237
+ totalFilesUpdated: pathResult.filesUpdated,
238
+ totalFilesErrored: pathResult.filesErrored,
239
+ totalFilesSkipped: pathResult.filesSkipped,
240
+ };
241
+ } else {
242
+ syncResult = await defaultSyncService.syncAll(
243
+ collections,
244
+ store,
245
+ withContentTypeRules(
246
+ {
247
+ gitPull: options.gitPull,
248
+ runUpdateCmd: true,
249
+ onSlowConversion: reportSlowConversion,
250
+ },
251
+ config
252
+ )
253
+ );
254
+ }
217
255
  } catch (e) {
218
256
  const message = e instanceof Error ? e.message : String(e);
219
257
  markIndexStageFinished(db, "lexical", "failed");
@@ -19,6 +19,7 @@ import {
19
19
  fingerprintContentTypeMetadataRules,
20
20
  normalizeContentTypes,
21
21
  } from "../../config";
22
+ import { resolveCollectionScope } from "../../core/collection-scope";
22
23
  import { resolveCliProjectAffinity } from "../../core/project-affinity-surface";
23
24
  import {
24
25
  finishRetrievalTraceAfterError,
@@ -27,7 +28,7 @@ import {
27
28
  } from "../../core/retrieval-trace-request";
28
29
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
29
30
  import { resolveDownloadPolicy } from "../../llm/policy";
30
- import { resolveModelUri } from "../../llm/registry";
31
+ import { modelCollectionForList, resolveModelUri } from "../../llm/registry";
31
32
  import {
32
33
  diagnoseQueryTarget,
33
34
  type QueryDiagnoseResult as PipelineQueryDiagnoseResult,
@@ -91,7 +92,7 @@ export type QueryResult =
91
92
  data: SearchResults;
92
93
  metadata?: RetrievalTraceSurfaceMetadata;
93
94
  }
94
- | { success: false; error: string };
95
+ | { success: false; error: string; isValidation?: boolean };
95
96
 
96
97
  export type QueryDiagnoseCommandOptions = Omit<
97
98
  HybridSearchOptions,
@@ -151,17 +152,29 @@ export async function query(
151
152
  let traceSession: RetrievalTraceSession | undefined;
152
153
 
153
154
  try {
155
+ const scope = resolveCollectionScope(options, config.collections);
156
+ if (!scope.ok) {
157
+ return {
158
+ success: false,
159
+ error: scope.message,
160
+ isValidation: scope.kind === "validation",
161
+ };
162
+ }
163
+ options = { ...options, collections: scope.collections };
154
164
  const { projectAffinityDisabled, projectRoots, ...queryOptions } = options;
155
165
  const projectAffinity = await resolveCliProjectAffinity(config, {
156
166
  cwd: process.cwd(),
157
167
  disabled: projectAffinityDisabled,
158
168
  projectRoots,
159
169
  });
170
+ const modelCollection = (type: "embed" | "expand" | "rerank") =>
171
+ options.collection ??
172
+ modelCollectionForList(config, type, options.collections);
160
173
  const embedUri = resolveModelUri(
161
174
  config,
162
175
  "embed",
163
176
  options.embedModel,
164
- options.collection
177
+ modelCollection("embed")
165
178
  );
166
179
  const expandUri =
167
180
  !options.noExpand && !options.queryModes?.length
@@ -169,7 +182,7 @@ export async function query(
169
182
  config,
170
183
  "expand",
171
184
  options.expandModel ?? options.genModel,
172
- options.collection
185
+ modelCollection("expand")
173
186
  )
174
187
  : undefined;
175
188
  const rerankUri = !options.noRerank
@@ -177,7 +190,7 @@ export async function query(
177
190
  config,
178
191
  "rerank",
179
192
  options.rerankModel,
180
- options.collection
193
+ modelCollection("rerank")
181
194
  )
182
195
  : undefined;
183
196
  const traceStart = await startRetrievalTraceRequest({
@@ -196,9 +209,9 @@ export async function query(
196
209
  }
197
210
  traceSession = traceStart.value ?? undefined;
198
211
  const llm = new LlmAdapter(config);
199
- const egressCollections = options.collection
200
- ? [options.collection]
201
- : ("all" as const);
212
+ const egressCollections =
213
+ options.collections ??
214
+ (options.collection ? [options.collection] : ("all" as const));
202
215
 
203
216
  // Resolve download policy from env/flags
204
217
  const globals = getGlobals();
@@ -13,6 +13,7 @@ import type {
13
13
  import type { SearchOptions, SearchResults } from "../../pipeline/types";
14
14
 
15
15
  import { normalizeContentTypes } from "../../config";
16
+ import { resolveCollectionScope } from "../../core/collection-scope";
16
17
  import { resolveCliProjectAffinity } from "../../core/project-affinity-surface";
17
18
  import {
18
19
  finishRetrievalTraceAfterError,
@@ -91,6 +92,15 @@ export async function search(
91
92
  let traceSession: RetrievalTraceSession | undefined;
92
93
 
93
94
  try {
95
+ const scope = resolveCollectionScope(options, config.collections);
96
+ if (!scope.ok) {
97
+ return {
98
+ success: false,
99
+ error: scope.message,
100
+ isValidation: scope.kind === "validation",
101
+ };
102
+ }
103
+ options = { ...options, collections: scope.collections };
94
104
  const { projectAffinityDisabled, projectRoots, ...searchOptions } = options;
95
105
  const projectAffinity = await resolveCliProjectAffinity(config, {
96
106
  cwd: process.cwd(),
@@ -104,6 +114,7 @@ export async function search(
104
114
  filters: {
105
115
  limit,
106
116
  collection: options.collection,
117
+ collections: options.collections,
107
118
  lang: options.lang,
108
119
  full: options.full,
109
120
  lineNumbers: options.lineNumbers,
@@ -13,6 +13,7 @@ import type {
13
13
  import type { EmbeddingPort } from "../../llm/types";
14
14
  import type { SearchOptions, SearchResults } from "../../pipeline/types";
15
15
 
16
+ import { resolveCollectionScope } from "../../core/collection-scope";
16
17
  import { resolveCliProjectAffinity } from "../../core/project-affinity-surface";
17
18
  import {
18
19
  finishRetrievalTraceAfterError,
@@ -20,7 +21,7 @@ import {
20
21
  startRetrievalTraceRequest,
21
22
  } from "../../core/retrieval-trace-request";
22
23
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
23
- import { resolveModelUri } from "../../llm/registry";
24
+ import { modelCollectionForList, resolveModelUri } from "../../llm/registry";
24
25
  import { formatQueryForEmbedding } from "../../pipeline/contextual";
25
26
  import {
26
27
  searchVectorWithEmbedding,
@@ -65,7 +66,7 @@ export type VsearchResult =
65
66
  data: SearchResults;
66
67
  metadata?: RetrievalTraceSurfaceMetadata;
67
68
  }
68
- | { success: false; error: string };
69
+ | { success: false; error: string; isValidation?: boolean };
69
70
 
70
71
  // ─────────────────────────────────────────────────────────────────────────────
71
72
  // Command Implementation
@@ -99,6 +100,15 @@ export async function vsearch(
99
100
  let traceSession: RetrievalTraceSession | undefined;
100
101
 
101
102
  try {
103
+ const scope = resolveCollectionScope(options, config.collections);
104
+ if (!scope.ok) {
105
+ return {
106
+ success: false,
107
+ error: scope.message,
108
+ isValidation: scope.kind === "validation",
109
+ };
110
+ }
111
+ options = { ...options, collections: scope.collections };
102
112
  const { projectAffinityDisabled, projectRoots, ...searchOptions } = options;
103
113
  const projectAffinity = await resolveCliProjectAffinity(config, {
104
114
  cwd: process.cwd(),
@@ -110,7 +120,8 @@ export async function vsearch(
110
120
  config,
111
121
  "embed",
112
122
  options.model,
113
- options.collection
123
+ options.collection ??
124
+ modelCollectionForList(config, "embed", options.collections)
114
125
  );
115
126
  const traceStart = await startRetrievalTraceRequest({
116
127
  store,
@@ -129,7 +140,9 @@ export async function vsearch(
129
140
  // Create LLM adapter for embeddings
130
141
  const llm = new LlmAdapter(config);
131
142
  const embedResult = await llm.createEmbeddingPort(modelUri, {
132
- egressCollections: options.collection ? [options.collection] : "all",
143
+ egressCollections:
144
+ options.collections ??
145
+ (options.collection ? [options.collection] : "all"),
133
146
  });
134
147
  if (!embedResult.ok) {
135
148
  await traceSession?.finish("failed");
@@ -26,6 +26,23 @@ export interface CliProjectAffinityOptions {
26
26
  projectRoots: string[];
27
27
  }
28
28
 
29
+ /**
30
+ * Map repeatable `-c` values for search/vsearch/query: one value keeps the
31
+ * single-collection `collection` path; two or more form the allowlist.
32
+ */
33
+ export const splitCollectionFlags = (
34
+ value: unknown
35
+ ): { collection?: string; collections?: string[] } => {
36
+ const values = Array.isArray(value)
37
+ ? (value as string[])
38
+ : typeof value === "string"
39
+ ? [value]
40
+ : [];
41
+ if (values.length === 0) return {};
42
+ if (values.length === 1) return { collection: values[0] };
43
+ return { collections: values };
44
+ };
45
+
29
46
  export const collectRepeatableValue = (
30
47
  value: string,
31
48
  previous: string[] = []
@@ -70,6 +87,7 @@ export const CMD = {
70
87
  ask: "ask",
71
88
  get: "get",
72
89
  multiGet: "multi-get",
90
+ convert: "convert",
73
91
  ls: "ls",
74
92
  status: "status",
75
93
  peek: "peek",
@@ -107,6 +125,7 @@ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
107
125
  [CMD.ask]: ["terminal", "json", "md"],
108
126
  [CMD.get]: ["terminal", "json", "md"],
109
127
  [CMD.multiGet]: ["terminal", "json", "files", "md"],
128
+ [CMD.convert]: ["terminal", "json"],
110
129
  [CMD.ls]: ["terminal", "json", "files", "md"],
111
130
  [CMD.status]: ["terminal", "json"],
112
131
  [CMD.peek]: ["terminal", "json"],
@@ -52,6 +52,7 @@ import {
52
52
  assertFormatSupported,
53
53
  CMD,
54
54
  collectRepeatableValue,
55
+ splitCollectionFlags,
55
56
  getDefaultLimit,
56
57
  parseCliProjectAffinityOptions,
57
58
  parseCliMetadataFilter,
@@ -325,6 +326,18 @@ function validateContextOutputPath(value: unknown): string | undefined {
325
326
  // Program Factory
326
327
  // ─────────────────────────────────────────────────────────────────────────────
327
328
 
329
+ /** `query --diagnose` explains one collection scope; several `-c` are refused. */
330
+ function singleDiagnoseCollection(value: unknown): string | undefined {
331
+ const { collection, collections } = splitCollectionFlags(value);
332
+ if (collections) {
333
+ throw new CliError(
334
+ "VALIDATION",
335
+ "query --diagnose accepts a single -c collection"
336
+ );
337
+ }
338
+ return collection;
339
+ }
340
+
328
341
  export function createProgram(): Command {
329
342
  const program = new Command();
330
343
 
@@ -349,7 +362,7 @@ export function createProgram(): Command {
349
362
  .option("--no-pager", "disable automatic paging of long output");
350
363
 
351
364
  // Resolve globals ONCE before any command runs (ensures consistency)
352
- program.hook("preAction", async (thisCommand) => {
365
+ program.hook("preAction", async (thisCommand, actionCommand) => {
353
366
  const rootOpts = thisCommand.optsWithGlobals();
354
367
  const globals = parseGlobalOptions(rootOpts);
355
368
  if (!isValidIndexName(globals.index)) {
@@ -360,6 +373,8 @@ export function createProgram(): Command {
360
373
  }
361
374
  applyGlobalOptions(globals);
362
375
  globalState.current = globals;
376
+ // convert reads no config or index, so there is no binding to check.
377
+ if (actionCommand === convertCommand) return;
363
378
  const { assertCliSessionBinding } = await import("./session-binding");
364
379
  await assertCliSessionBinding(globals.config, globals.index);
365
380
  });
@@ -375,6 +390,7 @@ export function createProgram(): Command {
375
390
  wirePublishCommand(program);
376
391
  wireVecCommands(program);
377
392
  wireRetrievalCommands(program);
393
+ const convertCommand = wireConvertCommand(program);
378
394
  wireTagsCommands(program);
379
395
  wireLinksCommands(program);
380
396
  wireGraphCommand(program);
@@ -650,7 +666,11 @@ function wireSearchCommands(program: Command): void {
650
666
  )
651
667
  .option("-n, --limit <num>", "max results")
652
668
  .option("--min-score <num>", "minimum score threshold")
653
- .option("-c, --collection <name>", "filter by collection")
669
+ .option(
670
+ "-c, --collection <name>",
671
+ "filter by collection (repeat for several)",
672
+ collectRepeatableValue
673
+ )
654
674
  .option("--lang <code>", "language filter/hint (BCP-47)")
655
675
  .option(
656
676
  "--since <date>",
@@ -740,7 +760,7 @@ function wireSearchCommands(program: Command): void {
740
760
  indexName: globals.index,
741
761
  limit,
742
762
  minScore,
743
- collection: cmdOpts.collection as string | undefined,
763
+ ...splitCollectionFlags(cmdOpts.collection),
744
764
  lang: cmdOpts.lang as string | undefined,
745
765
  since: cmdOpts.since as string | undefined,
746
766
  until: cmdOpts.until as string | undefined,
@@ -790,7 +810,11 @@ function wireSearchCommands(program: Command): void {
790
810
  .description("Vector similarity search")
791
811
  .option("-n, --limit <num>", "max results")
792
812
  .option("--min-score <num>", "minimum score threshold")
793
- .option("-c, --collection <name>", "filter by collection")
813
+ .option(
814
+ "-c, --collection <name>",
815
+ "filter by collection (repeat for several)",
816
+ collectRepeatableValue
817
+ )
794
818
  .option("--lang <code>", "language filter/hint (BCP-47)")
795
819
  .option(
796
820
  "--since <date>",
@@ -870,7 +894,7 @@ function wireSearchCommands(program: Command): void {
870
894
  indexName: globals.index,
871
895
  limit,
872
896
  minScore,
873
- collection: cmdOpts.collection as string | undefined,
897
+ ...splitCollectionFlags(cmdOpts.collection),
874
898
  lang: cmdOpts.lang as string | undefined,
875
899
  since: cmdOpts.since as string | undefined,
876
900
  until: cmdOpts.until as string | undefined,
@@ -892,7 +916,10 @@ function wireSearchCommands(program: Command): void {
892
916
  });
893
917
 
894
918
  if (!result.success) {
895
- throw new CliError("RUNTIME", result.error);
919
+ throw new CliError(
920
+ result.isValidation ? "VALIDATION" : "RUNTIME",
921
+ result.error
922
+ );
896
923
  }
897
924
  const output = formatVsearch(result, {
898
925
  json: format === "json",
@@ -918,7 +945,11 @@ function wireSearchCommands(program: Command): void {
918
945
  )
919
946
  .option("-n, --limit <num>", "max results")
920
947
  .option("--min-score <num>", "minimum score threshold")
921
- .option("-c, --collection <name>", "filter by collection")
948
+ .option(
949
+ "-c, --collection <name>",
950
+ "filter by collection (repeat for several)",
951
+ collectRepeatableValue
952
+ )
922
953
  .option("--lang <code>", "language hint (BCP-47)")
923
954
  .option(
924
955
  "--since <date>",
@@ -1088,7 +1119,7 @@ function wireSearchCommands(program: Command): void {
1088
1119
  target: cmdOpts.target as string,
1089
1120
  limit,
1090
1121
  minScore,
1091
- collection: cmdOpts.collection as string | undefined,
1122
+ collection: singleDiagnoseCollection(cmdOpts.collection),
1092
1123
  lang: cmdOpts.lang as string | undefined,
1093
1124
  since: cmdOpts.since as string | undefined,
1094
1125
  until: cmdOpts.until as string | undefined,
@@ -1125,7 +1156,7 @@ function wireSearchCommands(program: Command): void {
1125
1156
  indexName: globals.index,
1126
1157
  limit,
1127
1158
  minScore,
1128
- collection: cmdOpts.collection as string | undefined,
1159
+ ...splitCollectionFlags(cmdOpts.collection),
1129
1160
  lang: cmdOpts.lang as string | undefined,
1130
1161
  since: cmdOpts.since as string | undefined,
1131
1162
  until: cmdOpts.until as string | undefined,
@@ -1154,7 +1185,10 @@ function wireSearchCommands(program: Command): void {
1154
1185
  });
1155
1186
 
1156
1187
  if (!result.success) {
1157
- throw new CliError("RUNTIME", result.error);
1188
+ throw new CliError(
1189
+ result.isValidation ? "VALIDATION" : "RUNTIME",
1190
+ result.error
1191
+ );
1158
1192
  }
1159
1193
  const output = formatQuery(result, {
1160
1194
  format,
@@ -1624,6 +1658,10 @@ function wireOnboardingCommands(program: Command): void {
1624
1658
  program
1625
1659
  .command("index [collection]")
1626
1660
  .description("Index files from collections")
1661
+ .option(
1662
+ "--path <path...>",
1663
+ "reindex only these files of the collection (relative to its root)"
1664
+ )
1627
1665
  .option("--no-embed", "skip embedding after sync")
1628
1666
  .option("--git-pull", "run git pull in git repositories")
1629
1667
  .option("--models-pull", "download models if missing")
@@ -1640,6 +1678,7 @@ function wireOnboardingCommands(program: Command): void {
1640
1678
  configPath: globals.config,
1641
1679
  indexName: globals.index,
1642
1680
  collection,
1681
+ paths: cmdOpts.path as string[] | undefined,
1643
1682
  noEmbed: cmdOpts.embed === false,
1644
1683
  gitPull: Boolean(cmdOpts.gitPull),
1645
1684
  modelsPull: Boolean(cmdOpts.modelsPull),
@@ -2436,6 +2475,36 @@ function wireSessionsAutomationCommands(
2436
2475
  // Retrieval Commands (get, multi-get, ls)
2437
2476
  // ─────────────────────────────────────────────────────────────────────────────
2438
2477
 
2478
+ function wireConvertCommand(program: Command): Command {
2479
+ return program
2480
+ .command("convert <file>")
2481
+ .description("Print the Markdown GNO would index for one file")
2482
+ .option("--json", "JSON output")
2483
+ .action(async (file: string, cmdOpts: Record<string, unknown>) => {
2484
+ const format = getFormat(cmdOpts);
2485
+ assertFormatSupported(CMD.convert, format);
2486
+
2487
+ const { convertFile } = await import("../ingestion/convert-file");
2488
+ const result = await convertFile(file);
2489
+ if (!result.ok) {
2490
+ const { reason, message, validation, details } = result.error;
2491
+ throw new CliError(validation ? "VALIDATION" : "RUNTIME", message, {
2492
+ details: { reason, ...details },
2493
+ });
2494
+ }
2495
+
2496
+ if (format === "json") {
2497
+ process.stdout.write(`${JSON.stringify(result.value, null, 2)}\n`);
2498
+ return;
2499
+ }
2500
+ // Warnings go to stderr so stdout stays exactly the Markdown.
2501
+ for (const warning of result.value.warnings) {
2502
+ process.stderr.write(`Warning: ${warning.code}: ${warning.message}\n`);
2503
+ }
2504
+ process.stdout.write(result.value.markdown);
2505
+ });
2506
+ }
2507
+
2439
2508
  function wireRetrievalCommands(program: Command): void {
2440
2509
  // get - Retrieve document by URI or docid
2441
2510
  program
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Request-level collection allowlist shared by search, vsearch and query on
3
+ * every surface (CLI, MCP, REST, SDK).
4
+ *
5
+ * @module src/core/collection-scope
6
+ */
7
+
8
+ /** Most collections one request may list; matches the audit scope bound. */
9
+ export const MAX_REQUEST_COLLECTIONS = 256;
10
+
11
+ export type CollectionListResult =
12
+ | { ok: true; value: string[] }
13
+ | { ok: false; message: string };
14
+
15
+ /**
16
+ * Normalize a `collections` allowlist: trim, lowercase, de-duplicate, sort.
17
+ * Rejects non-arrays, non-string or blank members, an empty list, and lists
18
+ * longer than {@link MAX_REQUEST_COLLECTIONS} after normalization.
19
+ */
20
+ export function normalizeCollectionList(input: unknown): CollectionListResult {
21
+ if (!Array.isArray(input)) {
22
+ return { ok: false, message: "collections must be an array of names" };
23
+ }
24
+ const names = new Set<string>();
25
+ for (const value of input) {
26
+ if (typeof value !== "string" || value.trim() === "") {
27
+ return {
28
+ ok: false,
29
+ message: "collections must contain only non-empty collection names",
30
+ };
31
+ }
32
+ names.add(value.trim().toLowerCase());
33
+ }
34
+ if (names.size === 0) {
35
+ return { ok: false, message: "collections must list at least one name" };
36
+ }
37
+ if (names.size > MAX_REQUEST_COLLECTIONS) {
38
+ return {
39
+ ok: false,
40
+ message: `collections must list at most ${MAX_REQUEST_COLLECTIONS} names`,
41
+ };
42
+ }
43
+ return {
44
+ ok: true,
45
+ value: [...names].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
46
+ };
47
+ }
48
+
49
+ /** Error text when a request names both `collection` and `collections`. */
50
+ export const COLLECTION_AND_COLLECTIONS_MESSAGE =
51
+ "Pass either collection or collections, not both";
52
+
53
+ /** Names in `requested` that are not configured, in request order. */
54
+ export function unknownCollections(
55
+ requested: readonly string[],
56
+ configured: readonly { name: string }[]
57
+ ): string[] {
58
+ const known = new Set(configured.map(({ name }) => name.toLowerCase()));
59
+ return requested.filter((name) => !known.has(name));
60
+ }
61
+
62
+ /** The existing unknown-collection message, naming every unknown name. */
63
+ export function unknownCollectionsMessage(names: readonly string[]): string {
64
+ return names.length === 1
65
+ ? `Collection not found: ${names[0]}`
66
+ : `Collections not found: ${names.join(", ")}`;
67
+ }
68
+
69
+ export type CollectionScopeResult =
70
+ | { ok: true; collections: string[] | undefined }
71
+ | { ok: false; kind: "validation" | "not_found"; message: string };
72
+
73
+ /**
74
+ * Resolve a request's `collections` allowlist against the configured
75
+ * collections. Absent `collections` resolves to undefined (unchanged
76
+ * single-collection behaviour). Both inputs together, a malformed list, or
77
+ * any unknown name is refused.
78
+ */
79
+ export function resolveCollectionScope(
80
+ input: { collection?: unknown; collections?: unknown },
81
+ configured: readonly { name: string }[]
82
+ ): CollectionScopeResult {
83
+ if (input.collections === undefined)
84
+ return { ok: true, collections: undefined };
85
+ if (input.collection !== undefined) {
86
+ return {
87
+ ok: false,
88
+ kind: "validation",
89
+ message: COLLECTION_AND_COLLECTIONS_MESSAGE,
90
+ };
91
+ }
92
+ const normalized = normalizeCollectionList(input.collections);
93
+ if (!normalized.ok) {
94
+ return { ok: false, kind: "validation", message: normalized.message };
95
+ }
96
+ const unknown = unknownCollections(normalized.value, configured);
97
+ if (unknown.length > 0) {
98
+ return {
99
+ ok: false,
100
+ kind: "not_found",
101
+ message: unknownCollectionsMessage(unknown),
102
+ };
103
+ }
104
+ return { ok: true, collections: normalized.value };
105
+ }
@@ -13,6 +13,11 @@ export const MCP_ERRORS = {
13
13
  code: "JOB_CONFLICT",
14
14
  message: "Another job is already running.",
15
15
  },
16
+ QUEUE_FULL: {
17
+ code: "QUEUE_FULL",
18
+ message:
19
+ "Too many reindex jobs are waiting. Retry after the running job finishes.",
20
+ },
16
21
  INVALID_PATH: {
17
22
  code: "INVALID_PATH",
18
23
  message: "Path violates safety rules.",