@gmickel/gno 2.12.2 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +36 -5
  3. package/assets/skill/cli-reference.md +62 -11
  4. package/assets/skill/examples.md +30 -4
  5. package/assets/skill/mcp-reference.md +16 -0
  6. package/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.12.2.zip → gno-browser-clipper-v2.14.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.14.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +1 -1
  11. package/spec/cli.md +139 -16
  12. package/spec/mcp.md +111 -3
  13. package/spec/output-schemas/collection-list.schema.json +28 -0
  14. package/spec/output-schemas/convert.schema.json +97 -0
  15. package/spec/output-schemas/ls.schema.json +65 -0
  16. package/spec/output-schemas/mcp-job-status.schema.json +17 -1
  17. package/spec/output-schemas/mcp-sync-result.schema.json +9 -2
  18. package/spec/output-schemas/search-results.schema.json +7 -0
  19. package/src/cli/commands/index-cmd.ts +50 -12
  20. package/src/cli/commands/ls.ts +20 -3
  21. package/src/cli/commands/profile-apply.ts +2 -1
  22. package/src/cli/commands/query.ts +21 -8
  23. package/src/cli/commands/search.ts +11 -0
  24. package/src/cli/commands/vsearch.ts +17 -4
  25. package/src/cli/options.ts +19 -0
  26. package/src/cli/program.ts +90 -10
  27. package/src/config/loader.ts +11 -1
  28. package/src/config/types.ts +90 -1
  29. package/src/core/audit-collection-rules.ts +331 -0
  30. package/src/core/audit-links.ts +3 -1
  31. package/src/core/audit-workspace.ts +69 -5
  32. package/src/core/collection-egress-policy-service.ts +4 -1
  33. package/src/core/collection-scope.ts +105 -0
  34. package/src/core/egress-enforcement.ts +20 -4
  35. package/src/core/egress-policy.ts +4 -0
  36. package/src/core/egress-provenance.ts +33 -11
  37. package/src/core/errors.ts +5 -0
  38. package/src/core/job-manager.ts +238 -19
  39. package/src/core/project-profile-apply-state.ts +6 -15
  40. package/src/core/project-profile-apply.ts +32 -1
  41. package/src/core/reindex-paths.ts +167 -0
  42. package/src/core/retrieval-trace-request.ts +1 -0
  43. package/src/ingestion/convert-file.ts +181 -0
  44. package/src/llm/registry.ts +20 -0
  45. package/src/mcp/http-egress.ts +21 -2
  46. package/src/mcp/tools/collection-scope.ts +25 -0
  47. package/src/mcp/tools/index-cmd.ts +66 -48
  48. package/src/mcp/tools/index.ts +74 -10
  49. package/src/mcp/tools/job-status.ts +7 -0
  50. package/src/mcp/tools/query.ts +28 -7
  51. package/src/mcp/tools/search.ts +4 -0
  52. package/src/mcp/tools/sync.ts +68 -60
  53. package/src/mcp/tools/vsearch.ts +9 -3
  54. package/src/pipeline/filters.ts +48 -5
  55. package/src/pipeline/graph-retrieval.ts +1 -0
  56. package/src/pipeline/hybrid.ts +15 -2
  57. package/src/pipeline/hydration.ts +7 -2
  58. package/src/pipeline/owner-fusion.ts +1 -0
  59. package/src/pipeline/search.ts +2 -0
  60. package/src/pipeline/types.ts +7 -0
  61. package/src/pipeline/vsearch.ts +10 -5
  62. package/src/sdk/client.ts +45 -12
  63. package/src/sdk/convert.ts +27 -0
  64. package/src/sdk/documents.ts +15 -2
  65. package/src/sdk/index.ts +2 -0
  66. package/src/sdk/types.ts +4 -1
  67. package/src/serve/jobs.ts +4 -1
  68. package/src/serve/public/components/IndexingProgress.tsx +25 -3
  69. package/src/serve/routes/api.ts +164 -24
  70. package/src/store/sqlite/adapter.ts +97 -27
  71. package/src/store/sqlite/eligibility.ts +4 -0
  72. package/src/store/types.ts +7 -0
  73. package/src/store/vector/sqlite-vec.ts +67 -15
  74. package/src/store/vector/variant-search.ts +41 -4
  75. package/browser-extension/artifacts/gno-browser-clipper-v2.12.2.zip.sha256 +0 -1
@@ -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
@@ -2556,6 +2625,7 @@ function wireRetrievalCommands(program: Command): void {
2556
2625
  "Skip first N results",
2557
2626
  parsePositiveInt.bind(null, "offset")
2558
2627
  )
2628
+ .option("--filter <json>", "typed custom metadata predicate (JSON)")
2559
2629
  .option("--json", "JSON output")
2560
2630
  .option("--files", "File protocol output")
2561
2631
  .option("--md", "Markdown output")
@@ -2564,6 +2634,7 @@ function wireRetrievalCommands(program: Command): void {
2564
2634
  const format = getFormat(cmdOpts);
2565
2635
  assertFormatSupported(CMD.ls, format);
2566
2636
  const globals = getGlobals();
2637
+ const filter = parseCliMetadataFilter(cmdOpts.filter);
2567
2638
 
2568
2639
  const { ls, formatLs } = await import("./commands/ls");
2569
2640
  const result = await ls(scope, {
@@ -2571,6 +2642,7 @@ function wireRetrievalCommands(program: Command): void {
2571
2642
  indexName: globals.index,
2572
2643
  limit: cmdOpts.limit as number | undefined,
2573
2644
  offset: cmdOpts.offset as number | undefined,
2645
+ filter,
2574
2646
  json: format === "json",
2575
2647
  files: format === "files",
2576
2648
  md: format === "md",
@@ -2583,6 +2655,14 @@ function wireRetrievalCommands(program: Command): void {
2583
2655
  );
2584
2656
  }
2585
2657
 
2658
+ // JSON carries warnings in meta; other formats keep stdout clean.
2659
+ if (format !== "json") {
2660
+ for (const warning of result.data.meta.warnings ?? []) {
2661
+ process.stderr.write(
2662
+ `Warning: ${warning.code}: ${warning.message}\n`
2663
+ );
2664
+ }
2665
+ }
2586
2666
  const output = formatLs(result, {
2587
2667
  json: format === "json",
2588
2668
  files: format === "files",
@@ -126,11 +126,21 @@ export async function loadConfigFromPath(
126
126
  const result = ConfigSchema.safeParse(parsed);
127
127
 
128
128
  if (!result.success) {
129
+ // Collection `audit` mistakes name the setting; other issues keep the
130
+ // generic message.
131
+ const auditIssues = result.error.issues.filter((issue) =>
132
+ issue.path.includes("audit")
133
+ );
129
134
  return {
130
135
  ok: false,
131
136
  error: {
132
137
  code: "VALIDATION_ERROR",
133
- message: "Config validation failed",
138
+ message:
139
+ auditIssues.length > 0
140
+ ? `Config validation failed: ${auditIssues
141
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
142
+ .join("; ")}`
143
+ : "Config validation failed",
134
144
  issues: result.error.issues,
135
145
  },
136
146
  };
@@ -114,6 +114,74 @@ const COLLECTION_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
114
114
  /** Collection scope key pattern: name (1-64 chars) followed by colon */
115
115
  const COLLECTION_SCOPE_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}:$/;
116
116
 
117
+ /** Most globs one collection's `audit.paths` may list. */
118
+ export const COLLECTION_AUDIT_MAX_PATHS = 64;
119
+ const COLLECTION_AUDIT_MAX_GLOB_CHARS = 1024;
120
+
121
+ /**
122
+ * A path glob in `pattern` syntax: relative, no `..` segment, balanced
123
+ * `{}` / `[]`. Bun.Glob accepts nearly any string, so the shape is checked.
124
+ */
125
+ export function isValidAuditPathGlob(glob: string): boolean {
126
+ if (glob.trim() !== glob || glob.length === 0) return false;
127
+ if (glob.includes("\0") || glob.startsWith("/")) return false;
128
+ if (glob.split("/").includes("..")) return false;
129
+ let braces = 0;
130
+ let inClass = false;
131
+ for (const char of glob) {
132
+ if (inClass) {
133
+ if (char === "]") inClass = false;
134
+ continue;
135
+ }
136
+ if (char === "[") inClass = true;
137
+ else if (char === "{") braces += 1;
138
+ else if (char === "}") {
139
+ braces -= 1;
140
+ if (braces < 0) return false;
141
+ }
142
+ }
143
+ return braces === 0 && !inClass;
144
+ }
145
+
146
+ /**
147
+ * Opt-in, report-only audit rules for one collection (`gno audit links`).
148
+ * They never block indexing, retrieval or editing.
149
+ */
150
+ export const CollectionAuditSchema = z
151
+ .object({
152
+ /** Other collections this one may link to; its own is always allowed. */
153
+ linkCollections: z
154
+ .array(
155
+ z
156
+ .string()
157
+ .regex(
158
+ COLLECTION_NAME_REGEX,
159
+ "audit.linkCollections entries must be collection names"
160
+ )
161
+ )
162
+ .max(256)
163
+ .optional(),
164
+ /** `report` lists external URLs written in documents. Not egress. */
165
+ externalUrls: z.enum(["report", "ignore"]).optional(),
166
+ /** Each document path must match at least one glob. */
167
+ paths: z
168
+ .array(
169
+ z
170
+ .string()
171
+ .max(COLLECTION_AUDIT_MAX_GLOB_CHARS)
172
+ .refine(isValidAuditPathGlob, {
173
+ message:
174
+ "audit.paths entries must be relative globs without '..' and with balanced {} and []",
175
+ })
176
+ )
177
+ .min(1, "audit.paths must list at least one glob")
178
+ .max(COLLECTION_AUDIT_MAX_PATHS)
179
+ .optional(),
180
+ })
181
+ .strict();
182
+
183
+ export type CollectionAuditConfig = z.infer<typeof CollectionAuditSchema>;
184
+
117
185
  export const CollectionSchema = z.object({
118
186
  /** Unique collection identifier (lowercase) */
119
187
  name: z
@@ -184,6 +252,9 @@ export const CollectionSchema = z.object({
184
252
  */
185
253
  memoryManaged: z.boolean().optional(),
186
254
 
255
+ /** Opt-in report-only link and path rules for `gno audit links`. */
256
+ audit: CollectionAuditSchema.optional(),
257
+
187
258
  /** Optional per-collection model overrides */
188
259
  models: z
189
260
  .object({
@@ -616,7 +687,25 @@ export const ConfigSchema = z.object({
616
687
  editorUriTemplate: z.string().min(1).optional(),
617
688
 
618
689
  /** Collection definitions */
619
- collections: z.array(CollectionSchema).default([]),
690
+ collections: z
691
+ .array(CollectionSchema)
692
+ .superRefine((collections, ctx) => {
693
+ const names = new Set(collections.map(({ name }) => name));
694
+ for (const [index, collection] of collections.entries()) {
695
+ for (const [
696
+ position,
697
+ name,
698
+ ] of collection.audit?.linkCollections?.entries() ?? []) {
699
+ if (names.has(name)) continue;
700
+ ctx.addIssue({
701
+ code: "custom",
702
+ message: `Collection "${collection.name}" audit.linkCollections names unknown collection "${name}"`,
703
+ path: [index, "audit", "linkCollections", position],
704
+ });
705
+ }
706
+ }
707
+ })
708
+ .default([]),
620
709
 
621
710
  /** Context metadata */
622
711
  contexts: z.array(ContextSchema).default([]),