@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
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Convert one file to the Markdown GNO would index for it, with nothing
3
+ * persisted (`gno convert`, SDK `convertFile`).
4
+ *
5
+ * The file goes through the indexing path: extension MIME detection and the
6
+ * record-adapter check, then a sniffed MIME and `prepareFile` in the
7
+ * terminable file processor under the default time and memory budget. The
8
+ * Markdown and mirror hash therefore equal what `gno update` stores for the
9
+ * same bytes. No config, index, store or cache is read or written.
10
+ *
11
+ * @module src/ingestion/convert-file
12
+ */
13
+
14
+ // node:fs/promises for stat (no Bun equivalent for file stats)
15
+ import { stat } from "node:fs/promises";
16
+ // node:path for basename/resolve (no Bun path utils)
17
+ import { basename, resolve } from "node:path";
18
+
19
+ import { getDefaultMimeDetector } from "../converters/mime";
20
+ import { markdownConverter } from "../converters/native/markdown";
21
+ import { getDefaultPipeline } from "../converters/pipeline";
22
+ import { DEFAULT_LIMITS } from "../converters/types";
23
+ import {
24
+ defaultConversionMemoryMb,
25
+ prepareInProcessor,
26
+ } from "./file-processor";
27
+ import { DEFAULT_CHUNK_PARAMS } from "./types";
28
+
29
+ export const CONVERT_SCHEMA_VERSION = "convert@1.0";
30
+
31
+ const BYTES_PER_MB = 1_048_576;
32
+ const SNIFF_BYTES = 512;
33
+
34
+ export interface ConvertFileResult {
35
+ schemaVersion: typeof CONVERT_SCHEMA_VERSION;
36
+ source: { path: string; mime: string; ext: string; sizeBytes: number };
37
+ converter: { id: string; version: string };
38
+ title: string | null;
39
+ languageHint: string | null;
40
+ markdown: string;
41
+ mirrorHash: string;
42
+ warnings: { code: string; message: string }[];
43
+ }
44
+
45
+ /**
46
+ * Why a file was not converted. `validation` failures (missing file, over
47
+ * the size limit, no converter, multi-record format) exit 1 on the CLI;
48
+ * converter and processor failures exit 2.
49
+ */
50
+ export interface ConvertFileFailure {
51
+ reason: string;
52
+ message: string;
53
+ validation: boolean;
54
+ details?: Record<string, unknown>;
55
+ }
56
+
57
+ export type ConvertFileOutcome =
58
+ | { ok: true; value: ConvertFileResult }
59
+ | { ok: false; error: ConvertFileFailure };
60
+
61
+ const fail = (
62
+ reason: string,
63
+ message: string,
64
+ validation: boolean,
65
+ details?: Record<string, unknown>
66
+ ): ConvertFileOutcome => ({
67
+ ok: false,
68
+ error: { reason, message, validation, ...(details ? { details } : {}) },
69
+ });
70
+
71
+ /** Convert one file; see the module comment. */
72
+ export async function convertFile(path: string): Promise<ConvertFileOutcome> {
73
+ const absPath = resolve(path);
74
+ let sizeBytes: number;
75
+ try {
76
+ // stat follows symlinks: a link to a regular file converts, a link to
77
+ // a directory does not.
78
+ const stats = await stat(absPath);
79
+ if (!stats.isFile()) {
80
+ return fail("NOT_FOUND", `Not a regular file: ${path}`, true);
81
+ }
82
+ sizeBytes = stats.size;
83
+ } catch {
84
+ return fail("NOT_FOUND", `File not found: ${path}`, true);
85
+ }
86
+
87
+ // Refused before reading, so an oversized file is never loaded.
88
+ if (sizeBytes > DEFAULT_LIMITS.maxBytes) {
89
+ return fail(
90
+ "TOO_LARGE",
91
+ `File size ${sizeBytes} exceeds the indexing limit ${DEFAULT_LIMITS.maxBytes}`,
92
+ true,
93
+ { sizeBytes, limitBytes: DEFAULT_LIMITS.maxBytes }
94
+ );
95
+ }
96
+
97
+ const detector = getDefaultMimeDetector();
98
+ const byExtension = detector.detect(absPath, new Uint8Array());
99
+ const recordAdapter = await getDefaultPipeline().selectRecordAdapter(
100
+ byExtension.mime,
101
+ byExtension.ext
102
+ );
103
+ if (recordAdapter) {
104
+ return fail(
105
+ "MULTI_DOCUMENT",
106
+ `${recordAdapter.id} files hold several records; index them in a collection instead`,
107
+ true,
108
+ { format: recordAdapter.id }
109
+ );
110
+ }
111
+
112
+ let bytes: Uint8Array;
113
+ try {
114
+ bytes = await Bun.file(absPath).bytes();
115
+ } catch (error) {
116
+ // Readable at stat but not at read: removed meanwhile, or no permission.
117
+ const code = (error as { code?: unknown } | null)?.code;
118
+ if (code === "ENOENT") {
119
+ return fail("NOT_FOUND", `File not found: ${path}`, true);
120
+ }
121
+ const denied = code === "EACCES" || code === "EPERM";
122
+ return fail(
123
+ denied ? "PERMISSION" : "IO",
124
+ `Cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
125
+ false
126
+ );
127
+ }
128
+ const { mime, ext } = detector.detect(
129
+ absPath,
130
+ bytes.subarray(0, SNIFF_BYTES)
131
+ );
132
+ const prepared = await prepareInProcessor(
133
+ {
134
+ input: {
135
+ sourcePath: absPath,
136
+ relativePath: basename(absPath),
137
+ collection: "",
138
+ bytes,
139
+ mime,
140
+ ext,
141
+ limits: { ...DEFAULT_LIMITS },
142
+ },
143
+ metadataExt: ext,
144
+ contentTypeRules: [],
145
+ chunkParams: DEFAULT_CHUNK_PARAMS,
146
+ memoryManaged: false,
147
+ markdownSource: markdownConverter.canHandle(mime, ext),
148
+ previous: null,
149
+ },
150
+ {
151
+ timeoutMs: DEFAULT_LIMITS.timeoutMs,
152
+ maxMemoryBytes: defaultConversionMemoryMb() * BYTES_PER_MB,
153
+ },
154
+ { onPrevious: () => undefined }
155
+ );
156
+ if (!prepared.ok) {
157
+ const { code, message, details } = prepared.error;
158
+ return fail(code, message, code === "UNSUPPORTED", details);
159
+ }
160
+
161
+ const { artifact } = prepared.value;
162
+ return {
163
+ ok: true,
164
+ value: {
165
+ schemaVersion: CONVERT_SCHEMA_VERSION,
166
+ source: { path: absPath, mime, ext, sizeBytes },
167
+ converter: {
168
+ id: artifact.meta.converterId,
169
+ version: artifact.meta.converterVersion,
170
+ },
171
+ title: artifact.title ?? null,
172
+ languageHint: artifact.languageHint ?? null,
173
+ markdown: artifact.markdown,
174
+ mirrorHash: artifact.mirrorHash,
175
+ warnings: (artifact.meta.warnings ?? []).map(({ code, message }) => ({
176
+ code,
177
+ message,
178
+ })),
179
+ },
180
+ };
181
+ }
@@ -134,6 +134,26 @@ export function resolveModelUri(
134
134
  return preset[type];
135
135
  }
136
136
 
137
+ /**
138
+ * Collection to resolve models for when a request lists several collections:
139
+ * the first listed one when every listed collection resolves `type` to the
140
+ * same model, otherwise undefined (the unscoped resolution).
141
+ */
142
+ export function modelCollectionForList(
143
+ config: Config,
144
+ type: ModelType,
145
+ collections: readonly string[] | undefined
146
+ ): string | undefined {
147
+ const [first] = collections ?? [];
148
+ if (first === undefined) return undefined;
149
+ const uri = resolveModelUri(config, type, undefined, first);
150
+ return collections?.every(
151
+ (collection) => resolveModelUri(config, type, undefined, collection) === uri
152
+ )
153
+ ? first
154
+ : undefined;
155
+ }
156
+
137
157
  export function resolveModelSource(
138
158
  config: Config,
139
159
  type: ModelType,
@@ -140,6 +140,19 @@ const GRAPH_RESULT_TOOLS = new Set([
140
140
  "gno_impact",
141
141
  ]);
142
142
 
143
+ /**
144
+ * Tools whose `collections` argument bounds the collections they read. The
145
+ * retrieval tools reject `collection` with `collections`, so the union of both
146
+ * is never wider than what the handler serves.
147
+ */
148
+ const COLLECTIONS_SCOPE_TOOLS = new Set([
149
+ "gno_audit",
150
+ "gno_impact",
151
+ "gno_query",
152
+ "gno_search",
153
+ "gno_vsearch",
154
+ ]);
155
+
143
156
  const requestedCollections = (
144
157
  params: unknown,
145
158
  collections: readonly Collection[]
@@ -155,7 +168,8 @@ const requestedCollections = (
155
168
  if (typeof direct === "string" && direct.trim())
156
169
  names.add(direct.trim().toLowerCase());
157
170
  if (
158
- (record?.name === "gno_audit" || record?.name === "gno_impact") &&
171
+ typeof record?.name === "string" &&
172
+ COLLECTIONS_SCOPE_TOOLS.has(record.name) &&
159
173
  Array.isArray(args.collections)
160
174
  ) {
161
175
  for (const value of args.collections) {
@@ -192,7 +206,12 @@ const requestedCollections = (
192
206
  if (collection) names.add(collection);
193
207
  }
194
208
  }
195
- return names.size > 0 ? [...names] : collections.map(({ name }) => name);
209
+ // A name that is not configured serves nothing (the handler refuses it), so
210
+ // it never narrows or widens the check; with no configured name left, every
211
+ // collection is checked (fail closed).
212
+ const configured = new Set(collections.map(({ name }) => name.toLowerCase()));
213
+ const scoped = [...names].filter((name) => configured.has(name));
214
+ return scoped.length > 0 ? scoped : collections.map(({ name }) => name);
196
215
  };
197
216
 
198
217
  const enforceMessage = (
@@ -0,0 +1,25 @@
1
+ /**
2
+ * MCP adapter for the shared request collection allowlist.
3
+ *
4
+ * @module src/mcp/tools/collection-scope
5
+ */
6
+
7
+ import type { ToolContext } from "../server";
8
+
9
+ import { resolveCollectionScope } from "../../core/collection-scope";
10
+
11
+ /**
12
+ * Resolve `collections` for gno_search, gno_vsearch and gno_query. Throws a
13
+ * `VALIDATION:` error for malformed input or `collection` plus `collections`,
14
+ * and the existing unknown-collection error for names not configured.
15
+ */
16
+ export function requestCollections(
17
+ args: { collection?: string; collections?: string[] },
18
+ ctx: Pick<ToolContext, "collections">
19
+ ): string[] | undefined {
20
+ const scope = resolveCollectionScope(args, ctx.collections);
21
+ if (scope.ok) return scope.collections;
22
+ throw new Error(
23
+ scope.kind === "validation" ? `VALIDATION: ${scope.message}` : scope.message
24
+ );
25
+ }
@@ -8,12 +8,12 @@ import type { Collection } from "../../config/types";
8
8
  import type { ToolContext } from "../server";
9
9
 
10
10
  import { MCP_ERRORS } from "../../core/errors";
11
- import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
12
11
  import { JobError } from "../../core/job-manager";
13
12
  import {
14
13
  recordContentMutation,
15
14
  recordIndexMutation,
16
15
  } from "../../core/mutation-generations";
16
+ import { resolveReindexScope } from "../../core/reindex-paths";
17
17
  import { normalizeCollectionName } from "../../core/validation";
18
18
  import { embedBacklog } from "../../embed";
19
19
  import { defaultSyncService, withContentTypeRules } from "../../ingestion";
@@ -29,12 +29,14 @@ import { runTool, type ToolResult } from "./index";
29
29
  interface IndexInput {
30
30
  collection?: string;
31
31
  gitPull?: boolean;
32
+ paths?: string[];
32
33
  }
33
34
 
34
35
  interface IndexResultOutput {
35
36
  jobId: string;
36
- status: "started";
37
+ status: "started" | "queued";
37
38
  collections: string[];
39
+ paths?: string[];
38
40
  phases: ["sync", "embed"];
39
41
  options: {
40
42
  gitPull: boolean;
@@ -47,6 +49,7 @@ function formatIndexResult(result: IndexResultOutput): string {
47
49
  lines.push(`Job: ${result.jobId}`);
48
50
  lines.push(`Status: ${result.status}`);
49
51
  lines.push(`Collections: ${result.collections.join(", ") || "(none)"}`);
52
+ if (result.paths) lines.push(`Paths: ${result.paths.join(", ")}`);
50
53
  lines.push(`Phases: ${result.phases.join(" → ")}`);
51
54
  lines.push(`Git pull: ${result.options.gitPull ? "yes" : "no"}`);
52
55
  return lines.join("\n");
@@ -74,17 +77,7 @@ export function handleIndex(
74
77
  throw new Error("Write tools disabled. Start MCP with --enable-write.");
75
78
  }
76
79
 
77
- let lock: WriteLockHandle | null = null;
78
- let handedOff = false;
79
-
80
80
  try {
81
- lock = await acquireWriteLock(ctx.writeLockPath);
82
- if (!lock) {
83
- throw new Error(
84
- `${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`
85
- );
86
- }
87
-
88
81
  const requestedCollection = args.collection?.trim();
89
82
  const collection = requestedCollection
90
83
  ? resolveCollection(requestedCollection, ctx.collections)
@@ -108,47 +101,77 @@ export function handleIndex(
108
101
  },
109
102
  ctx.config
110
103
  );
104
+ const paths = await resolveReindexScope(
105
+ collection,
106
+ args.paths,
107
+ options
108
+ );
111
109
  enforceSyncCommandEgress(ctx, {
112
110
  collectionNames: collections,
113
111
  gitPull: options.gitPull,
114
112
  runUpdateCmd: options.runUpdateCmd,
115
113
  });
116
114
 
117
- const modelUri = resolveModelUri(
118
- ctx.config,
119
- "embed",
120
- undefined,
121
- collection?.name
122
- );
123
-
124
- const jobId = await ctx.jobManager.startTypedJobWithLock(
125
- "index",
126
- lock,
127
- async () => {
115
+ // Captured now: a queued pass runs after this call's context is gone.
116
+ const config = ctx.config;
117
+ const allCollections = ctx.collections;
118
+ const accepted = await ctx.jobManager.submitReindex({
119
+ type: "index",
120
+ collection: collection?.name ?? null,
121
+ paths,
122
+ options: {
123
+ gitPull: options.gitPull ?? false,
124
+ runUpdateCmd: options.runUpdateCmd ?? false,
125
+ },
126
+ run: async (scope) => {
128
127
  const lease = ctx.acquireModelLease?.();
128
+ const passOptions = withContentTypeRules(
129
+ { ...scope.options },
130
+ config
131
+ );
129
132
  try {
130
133
  // Phase 1: Sync
131
134
  const syncResult = collection
132
- ? await defaultSyncService
133
- .syncCollection(collection, ctx.store, options)
134
- .then((r) => ({
135
- collections: [r],
136
- totalDurationMs: r.durationMs,
137
- totalFilesProcessed: r.filesProcessed,
138
- totalFilesAdded: r.filesAdded,
139
- totalFilesUpdated: r.filesUpdated,
140
- totalFilesErrored: r.filesErrored,
141
- totalFilesSkipped: r.filesSkipped,
142
- }))
135
+ ? await (
136
+ scope.paths
137
+ ? defaultSyncService.syncPaths(
138
+ collection,
139
+ ctx.store,
140
+ scope.paths,
141
+ passOptions
142
+ )
143
+ : defaultSyncService.syncCollection(
144
+ collection,
145
+ ctx.store,
146
+ passOptions
147
+ )
148
+ ).then((r) => ({
149
+ collections: [r],
150
+ totalDurationMs: r.durationMs,
151
+ totalFilesProcessed: r.filesProcessed,
152
+ totalFilesAdded: r.filesAdded,
153
+ totalFilesUpdated: r.filesUpdated,
154
+ totalFilesErrored: r.filesErrored,
155
+ totalFilesSkipped: r.filesSkipped,
156
+ }))
143
157
  : await defaultSyncService.syncAll(
144
- ctx.collections,
158
+ allCollections,
145
159
  ctx.store,
146
- options
160
+ passOptions
147
161
  );
148
162
  recordContentMutation(syncResult, ctx.markContentMutation);
149
163
 
150
- // Phase 2: Embed
151
- const llm = ctx.getModelAdapter?.() ?? new LlmAdapter(ctx.config);
164
+ // Phase 2: Embed. The model is looked up when the pass runs: a
165
+ // queued pass outlives a server reload, which disposes the
166
+ // adapter that was current at submit.
167
+ const liveConfig = ctx.config;
168
+ const modelUri = resolveModelUri(
169
+ liveConfig,
170
+ "embed",
171
+ undefined,
172
+ collection?.name
173
+ );
174
+ const llm = ctx.getModelAdapter?.() ?? new LlmAdapter(liveConfig);
152
175
  const embedResult = await llm.createEmbeddingPort(modelUri, {
153
176
  egressCollections: collections,
154
177
  policy: { offline: true, allowDownload: false },
@@ -217,15 +240,14 @@ export function handleIndex(
217
240
  } finally {
218
241
  lease?.release();
219
242
  }
220
- }
221
- );
222
-
223
- handedOff = true;
243
+ },
244
+ });
224
245
 
225
246
  const result: IndexResultOutput = {
226
- jobId,
247
+ jobId: accepted.jobId,
227
248
  collections,
228
- status: "started",
249
+ status: accepted.status === "running" ? "started" : "queued",
250
+ ...(paths ? { paths } : {}),
229
251
  phases: ["sync", "embed"],
230
252
  options: {
231
253
  gitPull: options.gitPull ?? false,
@@ -239,10 +261,6 @@ export function handleIndex(
239
261
  throw new Error(`${error.code}: ${error.message}`);
240
262
  }
241
263
  throw error;
242
- } finally {
243
- if (lock && !handedOff) {
244
- await lock.release();
245
- }
246
264
  }
247
265
  },
248
266
  formatIndexResult
@@ -27,6 +27,7 @@ import {
27
27
  readMetadataSummary,
28
28
  } from "../../core/metadata-discovery";
29
29
  import { NOTE_PRESETS, type NotePresetId } from "../../core/note-presets";
30
+ import { MAX_REINDEX_PATHS } from "../../core/reindex-paths";
30
31
  import { RETRIEVAL_TRACE_METADATA } from "../../core/retrieval-trace-session";
31
32
  import { normalizeTag } from "../../core/tags";
32
33
  import {
@@ -255,6 +256,12 @@ export const searchInputSchema = z.object({
255
256
  .string()
256
257
  .optional()
257
258
  .describe("Filter to a single collection name"),
259
+ collections: z
260
+ .array(z.string())
261
+ .optional()
262
+ .describe(
263
+ "Filter to any of these collection names (1-256); use instead of collection"
264
+ ),
258
265
  limit: z
259
266
  .number()
260
267
  .int()
@@ -437,6 +444,13 @@ const syncInputSchema = z.object({
437
444
  .boolean()
438
445
  .default(false)
439
446
  .describe("Run the collection's configured update command before syncing"),
447
+ paths: z
448
+ .array(z.string())
449
+ .max(MAX_REINDEX_PATHS)
450
+ .optional()
451
+ .describe(
452
+ "Reindex only these files (1-256, relative to the collection root, or absolute inside it). Requires collection"
453
+ ),
440
454
  });
441
455
 
442
456
  const embedInputSchema = z.object({
@@ -452,6 +466,13 @@ const indexInputSchema = z.object({
452
466
  .optional()
453
467
  .describe("Collection name to index. Omit to index all collections"),
454
468
  gitPull: z.boolean().default(false).describe("Run git pull before indexing"),
469
+ paths: z
470
+ .array(z.string())
471
+ .max(MAX_REINDEX_PATHS)
472
+ .optional()
473
+ .describe(
474
+ "Reindex only these files (1-256, relative to the collection root, or absolute inside it). Requires collection"
475
+ ),
455
476
  });
456
477
 
457
478
  const removeCollectionInputSchema = z.object({
@@ -496,6 +517,12 @@ export const vsearchInputSchema = z.object({
496
517
  .string()
497
518
  .optional()
498
519
  .describe("Filter to a single collection name"),
520
+ collections: z
521
+ .array(z.string())
522
+ .optional()
523
+ .describe(
524
+ "Filter to any of these collection names (1-256); use instead of collection"
525
+ ),
499
526
  limit: z
500
527
  .number()
501
528
  .int()
@@ -570,6 +597,12 @@ export const queryInputSchema = z.object({
570
597
  .string()
571
598
  .optional()
572
599
  .describe("Filter to a single collection name"),
600
+ collections: z
601
+ .array(z.string())
602
+ .optional()
603
+ .describe(
604
+ "Filter to any of these collection names (1-256); use instead of collection"
605
+ ),
573
606
  limit: z
574
607
  .number()
575
608
  .int()
@@ -672,15 +705,17 @@ export const queryInputSchema = z.object({
672
705
  tagsAny: z.array(z.string()).optional().describe("Require ANY of these tags"),
673
706
  });
674
707
 
675
- export const queryDiagnoseInputSchema = queryInputSchema.extend({
676
- target: z
677
- .string()
678
- .trim()
679
- .min(1, "Target reference cannot be empty")
680
- .describe(
681
- "Target document reference to diagnose (gno URI, docid, or collection/path)"
682
- ),
683
- });
708
+ export const queryDiagnoseInputSchema = queryInputSchema
709
+ .omit({ collections: true })
710
+ .extend({
711
+ target: z
712
+ .string()
713
+ .trim()
714
+ .min(1, "Target reference cannot be empty")
715
+ .describe(
716
+ "Target document reference to diagnose (gno URI, docid, or collection/path)"
717
+ ),
718
+ });
684
719
 
685
720
  const getInputSchema = z.object({
686
721
  ref: z
@@ -1019,7 +1054,8 @@ export async function runTool<T>(
1019
1054
  try {
1020
1055
  assertConfiguredCollections(ctx, name);
1021
1056
  assertInferenceActive();
1022
- const raw = await (ctx.runWithSnapshot?.(fn) ?? fn());
1057
+ const read = READ_CONNECTION_TOOLS.has(name) ? readConnection(ctx, fn) : fn;
1058
+ const raw = await (ctx.runWithSnapshot?.(read) ?? read());
1023
1059
  assertInferenceActive();
1024
1060
  const traceMetadata =
1025
1061
  raw !== null && typeof raw === "object"
@@ -1048,6 +1084,34 @@ export async function runTool<T>(
1048
1084
  }
1049
1085
  }
1050
1086
 
1087
+ /**
1088
+ * Read-only retrieval tools. They read on the store's read connection, so a
1089
+ * sync batch that is still writing is never visible to them.
1090
+ */
1091
+ const READ_CONNECTION_TOOLS = new Set([
1092
+ "gno_search",
1093
+ "gno_vsearch",
1094
+ "gno_query",
1095
+ "gno_get",
1096
+ "gno_multi_get",
1097
+ "gno_status",
1098
+ "gno_list_tags",
1099
+ "gno_links",
1100
+ "gno_backlinks",
1101
+ "gno_similar",
1102
+ ]);
1103
+
1104
+ /** Store test doubles may lack the read connection; they read as before. */
1105
+ function readConnection<T>(
1106
+ ctx: ToolContext,
1107
+ fn: () => Promise<T>
1108
+ ): () => Promise<T> {
1109
+ const store = ctx.store as Partial<ToolContext["store"]> | undefined;
1110
+ return typeof store?.withReadConnection === "function"
1111
+ ? () => ctx.store.withReadConnection(fn)
1112
+ : fn;
1113
+ }
1114
+
1051
1115
  /**
1052
1116
  * Run a tool without acquiring the mutex.
1053
1117
  * For read-only in-memory tools (job_status, list_jobs) that should not block.
@@ -24,6 +24,7 @@ interface JobStatusResult {
24
24
  result?: SyncResult;
25
25
  typedResult?: JobResult;
26
26
  error?: string;
27
+ scope?: JobRecord["scope"];
27
28
  serverInstanceId: string;
28
29
  }
29
30
 
@@ -34,6 +35,11 @@ function formatJobStatus(result: JobStatusResult): string {
34
35
  lines.push(`Type: ${result.type}`);
35
36
  lines.push(`Status: ${result.status}`);
36
37
  lines.push(`Started: ${result.startedAt}`);
38
+ if (result.scope) {
39
+ lines.push(`Collection: ${result.scope.collection ?? "(all)"}`);
40
+ if (result.scope.paths)
41
+ lines.push(`Paths: ${result.scope.paths.join(", ")}`);
42
+ }
37
43
 
38
44
  if (result.completedAt) {
39
45
  lines.push(`Completed: ${result.completedAt}`);
@@ -92,6 +98,7 @@ function toJobStatusResult(job: JobRecord): JobStatusResult {
92
98
  result: job.result,
93
99
  typedResult: job.typedResult,
94
100
  error: job.error,
101
+ ...(job.scope ? { scope: job.scope } : {}),
95
102
  serverInstanceId: job.serverInstanceId,
96
103
  };
97
104
  }