@gmickel/gno 2.10.0 → 2.11.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 (78) hide show
  1. package/README.md +11 -11
  2. package/assets/skill/SKILL.md +25 -14
  3. package/assets/skill/cli-reference.md +6 -5
  4. package/assets/skill/examples.md +16 -12
  5. package/assets/skill/mcp-reference.md +2 -1
  6. package/browser-extension/artifacts/{gno-browser-clipper-v2.10.0.zip → gno-browser-clipper-v2.11.0.zip} +0 -0
  7. package/browser-extension/artifacts/gno-browser-clipper-v2.11.0.zip.sha256 +1 -0
  8. package/browser-extension/dist/manifest.json +1 -1
  9. package/bunfig.toml +5 -1
  10. package/package.json +4 -1
  11. package/spec/cli.md +59 -11
  12. package/spec/mcp.md +28 -4
  13. package/spec/output-schemas/ask.schema.json +8 -0
  14. package/spec/output-schemas/claim-verification.schema.json +8 -2
  15. package/spec/output-schemas/context-capsule-v1.schema.json +6 -1
  16. package/spec/output-schemas/context-capsule-verification.schema.json +4 -2
  17. package/spec/output-schemas/get.schema.json +4 -0
  18. package/spec/output-schemas/mcp-remove-result.schema.json +2 -2
  19. package/spec/output-schemas/metadata-summary.schema.json +60 -0
  20. package/spec/output-schemas/multi-get.schema.json +4 -0
  21. package/spec/output-schemas/search-result.schema.json +4 -0
  22. package/spec/output-schemas/search-results.schema.json +4 -0
  23. package/src/app/constants.ts +16 -0
  24. package/src/app/context-format.ts +1 -0
  25. package/src/app/verified-ask.ts +2 -0
  26. package/src/cli/commands/agents/block.ts +9 -8
  27. package/src/cli/commands/ask-format.ts +18 -1
  28. package/src/cli/commands/ask.ts +4 -5
  29. package/src/cli/commands/cleanup.ts +9 -1
  30. package/src/cli/commands/collection/remove.ts +50 -31
  31. package/src/cli/commands/collection/rename.ts +36 -8
  32. package/src/cli/commands/daemon.ts +5 -1
  33. package/src/cli/commands/get.ts +11 -1
  34. package/src/cli/commands/metadata.ts +79 -0
  35. package/src/cli/commands/multi-get.ts +13 -0
  36. package/src/cli/commands/shared.ts +9 -7
  37. package/src/cli/format/search-results.ts +11 -0
  38. package/src/cli/options.ts +2 -0
  39. package/src/cli/program.ts +101 -16
  40. package/src/core/context-capsule-schema.ts +2 -0
  41. package/src/core/context-capsule-verification.ts +2 -0
  42. package/src/core/context-compiler.ts +2 -5
  43. package/src/core/context-evidence.ts +1 -0
  44. package/src/core/context-verifier.ts +14 -1
  45. package/src/core/metadata-discovery.ts +169 -0
  46. package/src/ingestion/sync.ts +49 -1
  47. package/src/ingestion/walker.ts +11 -3
  48. package/src/mcp/http-egress.ts +1 -0
  49. package/src/mcp/tool-descriptions-core.ts +2 -2
  50. package/src/mcp/tool-profile.ts +16 -1
  51. package/src/mcp/tools/get.ts +10 -0
  52. package/src/mcp/tools/index.ts +39 -4
  53. package/src/mcp/tools/multi-get.ts +15 -0
  54. package/src/mcp/tools/query.ts +5 -3
  55. package/src/mcp/tools/remove-collection.ts +2 -1
  56. package/src/mcp/tools/search.ts +5 -3
  57. package/src/mcp/tools/vsearch.ts +5 -3
  58. package/src/mcp/tools/workspace-write.ts +42 -34
  59. package/src/pipeline/answer-prompt.ts +15 -2
  60. package/src/pipeline/answer.ts +3 -0
  61. package/src/pipeline/claim-verification-schema.ts +6 -1
  62. package/src/pipeline/claim-verification.ts +54 -1
  63. package/src/pipeline/explain.ts +1 -1
  64. package/src/pipeline/fusion.ts +24 -4
  65. package/src/pipeline/hybrid.ts +5 -1
  66. package/src/pipeline/result-context.ts +29 -0
  67. package/src/pipeline/search.ts +5 -1
  68. package/src/pipeline/types.ts +4 -0
  69. package/src/pipeline/vsearch.ts +5 -1
  70. package/src/sdk/client.ts +32 -4
  71. package/src/sdk/documents.ts +5 -0
  72. package/src/sdk/types.ts +5 -0
  73. package/src/serve/routes/api.ts +32 -5
  74. package/src/serve/server.ts +12 -0
  75. package/src/store/sqlite/adapter.ts +99 -6
  76. package/src/store/types.ts +16 -0
  77. package/src/store/vector/variant-search.ts +112 -74
  78. package/browser-extension/artifacts/gno-browser-clipper-v2.10.0.zip.sha256 +0 -1
@@ -2481,6 +2481,11 @@ function wireRetrievalCommands(program: Command): void {
2481
2481
  );
2482
2482
  }
2483
2483
 
2484
+ // Terminal output is the raw content; the mark goes to stderr so
2485
+ // piping the document stays byte-exact.
2486
+ if (format === "terminal" && result.data.supersededBy) {
2487
+ process.stderr.write(`Superseded by: ${result.data.supersededBy}\n`);
2488
+ }
2484
2489
  process.stdout.write(
2485
2490
  `${formatGet(result, {
2486
2491
  lineNumbers: Boolean(cmdOpts.lineNumbers),
@@ -2870,23 +2875,59 @@ function wireManagementCommands(program: Command): void {
2870
2875
  });
2871
2876
  });
2872
2877
 
2873
- collectionCmd
2874
- .command("remove <name>")
2875
- .description("Remove a collection")
2876
- .action(async (name: string) => {
2877
- const { collectionRemove } = await import("./commands/collection");
2878
- await collectionRemove(name, { configPath: getGlobals().config });
2879
- });
2878
+ addWriteLeaseFlags(
2879
+ collectionCmd
2880
+ .command("remove <name>")
2881
+ .description("Remove a collection and its indexed documents")
2882
+ ).action(async (name: string, cmdOpts: Record<string, unknown>) => {
2883
+ const { collectionRemove } = await import("./commands/collection");
2884
+ const lease = parseWriteLeaseFlags(cmdOpts);
2885
+ const result = await withCliWriteLease(
2886
+ {
2887
+ indexName: getGlobals().index,
2888
+ lockWaitMs: lease.lockWaitMs,
2889
+ noWait: lease.noWait,
2890
+ },
2891
+ async () => {
2892
+ await collectionRemove(name, {
2893
+ configPath: getGlobals().config,
2894
+ indexName: getGlobals().index,
2895
+ });
2896
+ return { success: true as const };
2897
+ }
2898
+ );
2899
+ throwIfWriteLeaseBusy(result, false);
2900
+ });
2880
2901
 
2881
- collectionCmd
2882
- .command("rename <old> <new>")
2883
- .description("Rename a collection")
2884
- .action(async (oldName: string, newName: string) => {
2902
+ addWriteLeaseFlags(
2903
+ collectionCmd
2904
+ .command("rename <old> <new>")
2905
+ .description("Rename a collection (run gno update afterwards)")
2906
+ ).action(
2907
+ async (
2908
+ oldName: string,
2909
+ newName: string,
2910
+ cmdOpts: Record<string, unknown>
2911
+ ) => {
2885
2912
  const { collectionRename } = await import("./commands/collection");
2886
- await collectionRename(oldName, newName, {
2887
- configPath: getGlobals().config,
2888
- });
2889
- });
2913
+ const lease = parseWriteLeaseFlags(cmdOpts);
2914
+ const result = await withCliWriteLease(
2915
+ {
2916
+ indexName: getGlobals().index,
2917
+ lockWaitMs: lease.lockWaitMs,
2918
+ noWait: lease.noWait,
2919
+ },
2920
+ async () => {
2921
+ await collectionRename(oldName, newName, {
2922
+ configPath: getGlobals().config,
2923
+ indexName: getGlobals().index,
2924
+ });
2925
+ return { success: true as const };
2926
+ }
2927
+ );
2928
+ throwIfWriteLeaseBusy(result, false);
2929
+ }
2930
+ );
2890
2931
 
2891
2932
  addWriteLeaseFlags(
2892
2933
  collectionCmd
@@ -3620,7 +3661,11 @@ function wireManagementCommands(program: Command): void {
3620
3661
  lockWaitMs: lease.lockWaitMs,
3621
3662
  noWait: lease.noWait,
3622
3663
  },
3623
- () => cleanup({ indexName: getGlobals().index })
3664
+ () =>
3665
+ cleanup({
3666
+ configPath: getGlobals().config,
3667
+ indexName: getGlobals().index,
3668
+ })
3624
3669
  );
3625
3670
  throwIfWriteLeaseBusy(result, false);
3626
3671
 
@@ -4144,6 +4189,46 @@ function wireAgentsCommands(program: Command): void {
4144
4189
  // ─────────────────────────────────────────────────────────────────────────────
4145
4190
 
4146
4191
  function wireTagsCommands(program: Command): void {
4192
+ program
4193
+ .command("metadata")
4194
+ .description(
4195
+ "List typed metadata keys with their types, document counts and common values"
4196
+ )
4197
+ .option("-c, --collection <name>", "only documents in this collection")
4198
+ .option(
4199
+ "--values <n>",
4200
+ "common values listed per key (1-100, default 10)",
4201
+ (value) => Number(value)
4202
+ )
4203
+ .option("--json", "JSON output")
4204
+ .option("--md", "Markdown output")
4205
+ .action(async (cmdOpts: Record<string, unknown>) => {
4206
+ const format = getFormat(cmdOpts);
4207
+ assertFormatSupported(CMD.metadataList, format);
4208
+ const globals = getGlobals();
4209
+ const { metadataList, formatMetadataList } =
4210
+ await import("./commands/metadata");
4211
+ const result = await metadataList({
4212
+ configPath: globals.config,
4213
+ indexName: globals.index,
4214
+ collection: cmdOpts.collection as string | undefined,
4215
+ values: cmdOpts.values as number | undefined,
4216
+ });
4217
+ if (!result.success) {
4218
+ throw new CliError(
4219
+ result.isValidation ? "VALIDATION" : "RUNTIME",
4220
+ result.error
4221
+ );
4222
+ }
4223
+ await writeOutput(
4224
+ formatMetadataList(result.data, {
4225
+ json: format === "json",
4226
+ md: format === "md",
4227
+ }),
4228
+ format
4229
+ );
4230
+ });
4231
+
4147
4232
  const tagsCmd = program.command("tags").description("Manage document tags");
4148
4233
 
4149
4234
  // tags list
@@ -365,6 +365,8 @@ export const contextCapsuleEvidenceSchema = z
365
365
  ]),
366
366
  egressLineage: egressLineageSchema.optional(),
367
367
  record: recordEvidenceMetadataSchema.optional(),
368
+ /** Current successor when a newer memory fact superseded this one. */
369
+ supersededBy: contextCapsuleGnoUriSchema.optional(),
368
370
  })
369
371
  .strict()
370
372
  .superRefine((value, context) => {
@@ -51,6 +51,7 @@ export const contextCapsuleVerificationEvidenceSchema = z
51
51
  "chunk_corrupt",
52
52
  "source_missing",
53
53
  "mirror_missing",
54
+ "superseded",
54
55
  ]),
55
56
  rankingStatus: z.enum(["unchanged", "reranked", "unavailable"]),
56
57
  rankingCode: z.enum([
@@ -82,6 +83,7 @@ export const contextCapsuleVerificationEvidenceSchema = z
82
83
  "passage_stale",
83
84
  "chunk_missing",
84
85
  "chunk_corrupt",
86
+ "superseded",
85
87
  ].includes(value.contentCode) &&
86
88
  hashes.every((hash) => hash !== null)) ||
87
89
  (value.contentStatus === "missing" &&
@@ -28,7 +28,7 @@ import type {
28
28
  import type { ContextConfiguredGuidance } from "./context-guidance";
29
29
  import type { MetadataPredicate } from "./typed-metadata";
30
30
 
31
- import { decorateUriForIndex } from "../app/constants";
31
+ import { decorateResultUris, decorateUriForIndex } from "../app/constants";
32
32
  import { canonicalizeIndexName } from "../app/index-name";
33
33
  import { resolveTemporalRange } from "../pipeline/temporal";
34
34
  import { attachSearchResultPlannerMetadata } from "../pipeline/trace-metadata";
@@ -383,10 +383,7 @@ export const planContextEvidence = async <T, P>(
383
383
  const decoratedResults = responses
384
384
  .flatMap((response) => response.results)
385
385
  .map((result) => {
386
- const decorated = {
387
- ...result,
388
- uri: decorateUriForIndex(result.uri, indexName),
389
- };
386
+ const decorated = decorateResultUris(result, indexName);
390
387
  const metadata = result[SEARCH_RESULT_PLANNER_METADATA];
391
388
  return metadata
392
389
  ? attachSearchResultPlannerMetadata(decorated, metadata)
@@ -502,6 +502,7 @@ export const materializeContextEvidenceCandidates = async (
502
502
  ...(projectRecordEvidenceMetadata(document)
503
503
  ? { record: projectRecordEvidenceMetadata(document) }
504
504
  : {}),
505
+ ...(result.supersededBy ? { supersededBy: result.supersededBy } : {}),
505
506
  },
506
507
  },
507
508
  };
@@ -11,6 +11,7 @@ import type { ContextCapsuleVerification } from "./context-capsule-verification"
11
11
 
12
12
  import { decorateUriForIndex, deriveDocid } from "../app/constants";
13
13
  import { chunkMatchesCanonicalContent } from "../pipeline/chunk-lookup";
14
+ import { ok } from "../store/types";
14
15
  import { type ContextCapsuleCreateOptions } from "./context-capsule";
15
16
  import { sha256Text } from "./context-capsule-validation";
16
17
  import {
@@ -35,6 +36,7 @@ type ContextVerifierStore = Pick<
35
36
  | "getCollections"
36
37
  | "getContexts"
37
38
  | "getDocumentsByDocids"
39
+ | "getSupersedingUris"
38
40
  > &
39
41
  Required<Pick<StorePort, "getContentBatch">>;
40
42
 
@@ -127,6 +129,8 @@ interface LoadedEvidence {
127
129
  chunksByHash: Map<string, ChunkRow[]>;
128
130
  documents: DocumentRow[];
129
131
  snapshot: ContextEvidenceSnapshot;
132
+ /** Current successor URI of evidence whose memory fact was superseded. */
133
+ successors: ReadonlyMap<string, string>;
130
134
  }
131
135
 
132
136
  const missingReceipt = (evidence: Evidence): EvidenceReceipt => ({
@@ -229,6 +233,8 @@ const verifyContent = (
229
233
  contentCode = "chunk_missing";
230
234
  } else if (!chunkValid) {
231
235
  contentCode = "chunk_corrupt";
236
+ } else if (loaded.successors.has(evidence.uri)) {
237
+ contentCode = "superseded";
232
238
  }
233
239
 
234
240
  return {
@@ -374,16 +380,23 @@ export const verifyContextCapsule = async (
374
380
  )
375
381
  ),
376
382
  ];
377
- const [documentResult, contentResult, chunkResult, rankResult] =
383
+ const [documentResult, contentResult, chunkResult, rankResult, successors] =
378
384
  await Promise.all([
379
385
  deps.store.getDocumentsByDocids(docids, { activeOnly: true }),
380
386
  deps.store.getContentBatch(mirrorHashes),
381
387
  deps.store.getChunksBatch(mirrorHashes),
382
388
  deps.resolveCurrentRanks?.(structuredClone(capsule)).catch(() => null) ??
383
389
  Promise.resolve(null),
390
+ deps.store.getSupersedingUris?.([...evidenceUris]) ??
391
+ Promise.resolve(ok(new Map<string, string>())),
384
392
  ]);
385
393
  const loaded: LoadedEvidence = {
386
394
  snapshot: before,
395
+ successors: unwrapStore(
396
+ successors,
397
+ "document_load_failed",
398
+ "Failed to load supersession of verification evidence"
399
+ ),
387
400
  documents: unwrapStore(
388
401
  documentResult,
389
402
  "document_load_failed",
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Typed metadata discovery: which keys the indexed documents carry, with
3
+ * their observed types, document counts and most common values, so a caller
4
+ * can write a `filter` predicate for metadata it has not seen.
5
+ *
6
+ * @module src/core/metadata-discovery
7
+ */
8
+
9
+ export type MetadataValue = string | number | boolean;
10
+
11
+ export interface MetadataKeySummary {
12
+ key: string;
13
+ /** Observed value types, e.g. `string`, `number`, `string[]`. */
14
+ types: string[];
15
+ /** Documents that carry the key. */
16
+ documents: number;
17
+ /** Most common values (array members count individually), count desc. */
18
+ values: { value: MetadataValue; documents: number }[];
19
+ /** True when more distinct values exist than `values` lists. */
20
+ valuesTruncated: boolean;
21
+ }
22
+
23
+ export interface MetadataSummary {
24
+ collection: string | null;
25
+ /** Active documents in scope, and how many carry typed metadata. */
26
+ documents: number;
27
+ documentsWithMetadata: number;
28
+ keys: MetadataKeySummary[];
29
+ }
30
+
31
+ export const DEFAULT_METADATA_VALUE_LIMIT = 10;
32
+ export const MAX_METADATA_VALUE_LIMIT = 100;
33
+
34
+ const typeOf = (value: unknown): string | null => {
35
+ if (Array.isArray(value)) {
36
+ const member = value.length > 0 ? typeOf(value[0]) : "string";
37
+ return member ? `${member}[]` : null;
38
+ }
39
+ return typeof value === "string" ||
40
+ typeof value === "number" ||
41
+ typeof value === "boolean"
42
+ ? typeof value
43
+ : null;
44
+ };
45
+
46
+ /**
47
+ * Summarize stored typed metadata JSON. Keys sort by document count then
48
+ * name; values by document count then their JSON text, so output is stable.
49
+ */
50
+ export function summarizeTypedMetadata(
51
+ input: {
52
+ collection: string | null;
53
+ documents: number;
54
+ metadata: string[];
55
+ },
56
+ valueLimit = DEFAULT_METADATA_VALUE_LIMIT
57
+ ): MetadataSummary {
58
+ const keys = new Map<
59
+ string,
60
+ { types: Set<string>; documents: number; values: Map<string, number> }
61
+ >();
62
+ let documentsWithMetadata = 0;
63
+ for (const json of input.metadata) {
64
+ let parsed: unknown;
65
+ try {
66
+ parsed = JSON.parse(json);
67
+ } catch {
68
+ continue;
69
+ }
70
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
71
+ continue;
72
+ const entries = Object.entries(parsed);
73
+ if (entries.length === 0) continue;
74
+ documentsWithMetadata += 1;
75
+ for (const [key, value] of entries) {
76
+ const type = typeOf(value);
77
+ if (!type) continue;
78
+ const summary = keys.get(key) ?? {
79
+ types: new Set<string>(),
80
+ documents: 0,
81
+ values: new Map<string, number>(),
82
+ };
83
+ summary.types.add(type);
84
+ summary.documents += 1;
85
+ const members = new Set(
86
+ (Array.isArray(value) ? value : [value]).map((member) =>
87
+ JSON.stringify(member)
88
+ )
89
+ );
90
+ for (const member of members)
91
+ summary.values.set(member, (summary.values.get(member) ?? 0) + 1);
92
+ keys.set(key, summary);
93
+ }
94
+ }
95
+ return {
96
+ collection: input.collection,
97
+ documents: input.documents,
98
+ documentsWithMetadata,
99
+ keys: [...keys.entries()]
100
+ .map(([key, summary]) => {
101
+ const values = [...summary.values.entries()].sort(
102
+ ([leftText, left], [rightText, right]) =>
103
+ right - left || (leftText < rightText ? -1 : 1)
104
+ );
105
+ return {
106
+ key,
107
+ types: [...summary.types].sort(),
108
+ documents: summary.documents,
109
+ values: values.slice(0, valueLimit).map(([text, documents]) => ({
110
+ value: JSON.parse(text) as MetadataValue,
111
+ documents,
112
+ })),
113
+ valuesTruncated: values.length > valueLimit,
114
+ };
115
+ })
116
+ .sort(
117
+ (left, right) =>
118
+ right.documents - left.documents || (left.key < right.key ? -1 : 1)
119
+ ),
120
+ };
121
+ }
122
+
123
+ /** Shared read for every surface; `values` caps listed values per key. */
124
+ export async function readMetadataSummary(
125
+ store: {
126
+ getTypedMetadataDocuments(options?: {
127
+ collection?: string;
128
+ }): Promise<
129
+ | { ok: true; value: { documents: number; metadata: string[] } }
130
+ | { ok: false; error: { message: string } }
131
+ >;
132
+ },
133
+ options: { collection?: string; values?: number } = {}
134
+ ): Promise<MetadataSummary> {
135
+ const values = options.values ?? DEFAULT_METADATA_VALUE_LIMIT;
136
+ if (
137
+ !Number.isInteger(values) ||
138
+ values < 1 ||
139
+ values > MAX_METADATA_VALUE_LIMIT
140
+ )
141
+ throw new RangeError(
142
+ `values must be an integer from 1 to ${MAX_METADATA_VALUE_LIMIT}`
143
+ );
144
+ const result = await store.getTypedMetadataDocuments({
145
+ collection: options.collection,
146
+ });
147
+ if (!result.ok) throw new Error(result.error.message);
148
+ return summarizeTypedMetadata(
149
+ { collection: options.collection ?? null, ...result.value },
150
+ values
151
+ );
152
+ }
153
+
154
+ /** Plain-text rendering shared by the CLI terminal output and MCP text. */
155
+ export function formatMetadataSummary(summary: MetadataSummary): string {
156
+ const scope = summary.collection ? ` in ${summary.collection}` : "";
157
+ const header = `${summary.documentsWithMetadata} of ${summary.documents} documents${scope} carry typed metadata`;
158
+ if (summary.keys.length === 0) return `${header}.`;
159
+ const lines = [`${header}:`, ""];
160
+ for (const key of summary.keys) {
161
+ const values = key.values
162
+ .map((item) => `${JSON.stringify(item.value)} (${item.documents})`)
163
+ .join(", ");
164
+ lines.push(
165
+ `${key.key} [${key.types.join(", ")}] ${key.documents} ${key.documents === 1 ? "doc" : "docs"}${values ? `: ${values}${key.valuesTruncated ? ", ..." : ""}` : ""}`
166
+ );
167
+ }
168
+ return lines.join("\n");
169
+ }
@@ -1,5 +1,6 @@
1
1
  // node:fs/promises for realpath/stat (no Bun equivalent for canonical paths or file stats)
2
- import { realpath, stat } from "node:fs/promises";
2
+ // opendir proves a root can be listed; Bun has no directory-handle API.
3
+ import { opendir, realpath, stat } from "node:fs/promises";
3
4
  /**
4
5
  * Sync service - orchestrates file ingestion.
5
6
  * Walks collections, converts files, chunks content, updates store.
@@ -389,6 +390,22 @@ class Semaphore {
389
390
  }
390
391
  }
391
392
 
393
+ /** Null when the collection root can be listed; otherwise why it cannot. */
394
+ async function unavailableRootMessage(root: string): Promise<string | null> {
395
+ try {
396
+ const directory = await opendir(root);
397
+ try {
398
+ // The open is lazy; the first read is what needs list permission.
399
+ await directory.read();
400
+ } finally {
401
+ await directory.close();
402
+ }
403
+ return null;
404
+ } catch (cause) {
405
+ return `Collection root could not be resolved (${cause instanceof Error ? cause.message : String(cause)}); indexed documents were kept`;
406
+ }
407
+ }
408
+
392
409
  function summarizePathResults(
393
410
  collection: string,
394
411
  results: FileSyncResult[],
@@ -1174,6 +1191,7 @@ export class SyncService {
1174
1191
  const directoryAvailability = memoizeDirectoryAvailability(
1175
1192
  this.directoryAvailabilityFactory(availabilityMode)
1176
1193
  );
1194
+ let rootUnavailable: string | null = null;
1177
1195
 
1178
1196
  for (const relPath of relPaths) {
1179
1197
  const recordDocumentsResult = await store.listRecordDocuments(
@@ -1285,6 +1303,19 @@ export class SyncService {
1285
1303
  });
1286
1304
  continue;
1287
1305
  }
1306
+ // A file is only proven deleted while its collection root can be
1307
+ // listed. Checked per absence: the root can vanish mid-batch.
1308
+ const rootError = await unavailableRootMessage(collection.path);
1309
+ if (rootError) {
1310
+ rootUnavailable = rootError;
1311
+ results.push({
1312
+ relPath,
1313
+ status: "error",
1314
+ errorCode: "SOURCE_AVAILABILITY_UNKNOWN",
1315
+ errorMessage: rootError,
1316
+ });
1317
+ continue;
1318
+ }
1288
1319
  const inactive = await this.inactivateOneAbsentSource(
1289
1320
  collection,
1290
1321
  store,
@@ -1436,6 +1467,23 @@ export class SyncService {
1436
1467
  syncOptions.projectTypedEdges === false
1437
1468
  ? []
1438
1469
  : await this.projectTypedEdges(store, syncOptions, projectionSourceIds);
1470
+ if (rootUnavailable) {
1471
+ // Collection-level, as a full sync reports it, so status, summaries and
1472
+ // the watcher's error path all carry the root and the original error.
1473
+ errors.unshift({
1474
+ relPath: "",
1475
+ code: "SOURCE_AVAILABILITY_UNKNOWN",
1476
+ message: rootUnavailable,
1477
+ });
1478
+ await store
1479
+ .recordError({
1480
+ collection: collection.name,
1481
+ relPath: "",
1482
+ code: "SOURCE_AVAILABILITY_UNKNOWN",
1483
+ message: rootUnavailable,
1484
+ })
1485
+ .catch(() => undefined);
1486
+ }
1439
1487
  return {
1440
1488
  ...summarizePathResults(
1441
1489
  collection.name,
@@ -289,9 +289,17 @@ export class FileWalker implements WalkerPort {
289
289
  let rootReal: string;
290
290
  try {
291
291
  rootReal = await realpath(rootAbs);
292
- } catch {
293
- // Root doesn't exist
294
- return { entries: [], skipped: [] };
292
+ } catch (error) {
293
+ // An unmounted drive or offline sync folder proves nothing about its
294
+ // files, so the root is an unproven prefix, never an empty listing.
295
+ skipped.push({
296
+ absPath: rootAbs,
297
+ relPath: "",
298
+ reason: "SOURCE_AVAILABILITY_UNKNOWN",
299
+ unprovenPrefix: true,
300
+ message: `Collection root could not be resolved (${error instanceof Error ? error.message : String(error)}); indexed documents were kept`,
301
+ });
302
+ return { entries, skipped };
295
303
  }
296
304
 
297
305
  const matches = new Set<string>();
@@ -48,6 +48,7 @@ export const MCP_HTTP_EGRESS_TOOLS = {
48
48
  gno_job_status: "metadata",
49
49
  gno_links: "metadata",
50
50
  gno_list_jobs: "metadata",
51
+ gno_list_metadata: "metadata",
51
52
  gno_list_tags: "metadata",
52
53
  gno_move_note: "metadata",
53
54
  gno_multi_get: "source",
@@ -18,9 +18,9 @@ import { type McpToolProfile, mcpToolProfileAllowlist } from "./tool-profile";
18
18
 
19
19
  export const MCP_CORE_TOOL_DESCRIPTIONS: Readonly<Record<string, string>> = {
20
20
  gno_query:
21
- "Call first for a question about the indexed documents when the answer may be worded differently from the question. Runs hybrid retrieval: BM25 plus vector search, fused, with bounded one-hop graph expansion and optional query expansion and reranking. Returns ranked results, each with uri, docid, score, snippet, and usually a line anchor to read next with gno_get fromLine/lineCount; meta reports the mode actually used (bm25_only when vectors are unavailable) and whether expansion and reranking ran. Set fast=true for a quick lookup, thorough=true when recall matters, and intent to disambiguate a short term. A context field on a result is configured guidance; cite the source lines.",
21
+ "Call first for a question about the indexed documents when the answer may be worded differently from the question. Runs hybrid retrieval: BM25 plus vector search, fused, with bounded one-hop graph expansion and optional query expansion and reranking. Returns ranked results, each with uri, docid, score, snippet, and usually a line anchor to read next with gno_get fromLine/lineCount; meta reports the mode actually used (bm25_only when vectors are unavailable) and whether expansion and reranking ran. Start with fast=true (no expansion or reranking, typically under a second on a warm server); when that misses, call again without fast to add expansion and reranking; set thorough=true when recall matters and intent to disambiguate a short term. A context field on a result is configured guidance; cite the source lines.",
22
22
  gno_search:
23
- "Call when you know the exact words: a name, identifier, filename, error message, or quoted phrase. Runs BM25 keyword matching only, so it needs no model and answers fast. Returns ranked results with uri, docid, score, snippet, and a line anchor when available; the match sits at that line, so read it with gno_get fromLine/lineCount. Call gno_query when the wording is uncertain.",
23
+ "Call when you know the exact words: a name, identifier, filename, error message, or quoted phrase. Runs BM25 keyword matching only, so it needs no model and answers fast. Returns ranked results with uri, docid, score, snippet, and a line anchor when available; the match sits at that line, so read it with gno_get fromLine/lineCount. For a question asked in words, call gno_query with fast=true instead.",
24
24
  gno_get:
25
25
  "Call to read one document a result named: pass its gno:// URI, #docid, or collection/path as ref. With fromLine and lineCount it returns only that range; start from the result's line anchor with a small count before fetching the whole file. Returns the content with line numbers plus uri, docid, title, totalLines, returnedLines, and the source path and modifiedAt.",
26
26
  gno_multi_get:
@@ -17,6 +17,8 @@ import type {
17
17
  * @module src/mcp/tool-profile
18
18
  */
19
19
 
20
+ import { z } from "zod";
21
+
20
22
  import { withInferenceScope } from "../llm/inference-scope";
21
23
 
22
24
  export const MCP_TOOL_PROFILES = ["core", "full"] as const;
@@ -129,6 +131,19 @@ export function createProfileToolRegistrar(
129
131
  : (cb as ToolCallback<StandardSchemaWithJSON>)(args[0], request)
130
132
  );
131
133
  };
132
- server.registerTool(name, config, wrapped as unknown as typeof cb);
134
+ server.registerTool(
135
+ name,
136
+ { ...config, inputSchema: rejectUnknownKeys(config.inputSchema) },
137
+ wrapped as unknown as typeof cb
138
+ );
133
139
  };
134
140
  }
141
+
142
+ /**
143
+ * Unknown argument keys fail validation with the key named instead of being
144
+ * dropped: a misspelled scope or receipt key must not silently widen a read
145
+ * or skip a write fence.
146
+ */
147
+ function rejectUnknownKeys<T>(schema: T): T {
148
+ return (schema instanceof z.ZodObject ? schema.strict() : schema) as T;
149
+ }
@@ -25,6 +25,7 @@ import {
25
25
  evidenceFromExactDocument,
26
26
  RetrievalTraceSession,
27
27
  } from "../../core/retrieval-trace-session";
28
+ import { supersedingUri } from "../../pipeline/result-context";
28
29
  import { openScopedIndexStore } from "../../store/sqlite/scoped-index";
29
30
  import { runTool, type ToolResult } from "./index";
30
31
 
@@ -59,6 +60,8 @@ interface GetResponse {
59
60
  mirrorHash?: string;
60
61
  };
61
62
  record?: RecordEvidenceMetadata;
63
+ /** URI of the current fact when a newer memory fact superseded this one. */
64
+ supersededBy?: string;
62
65
  capabilities: DocumentCapabilities;
63
66
  }
64
67
 
@@ -104,6 +107,9 @@ function formatGetResponse(data: GetResponse): string {
104
107
  if (data.title) {
105
108
  lines.push(`Title: ${data.title}`);
106
109
  }
110
+ if (data.supersededBy) {
111
+ lines.push(`Superseded by: ${data.supersededBy}`);
112
+ }
107
113
  lines.push(`Lines: ${data.totalLines}`);
108
114
  if (data.source.absPath) {
109
115
  lines.push(`Path: ${data.source.absPath}`);
@@ -226,7 +232,11 @@ export function handleGet(
226
232
  }
227
233
  }
228
234
 
235
+ const successor = await supersedingUri(scoped.store, doc.uri);
229
236
  const response: GetResponse = {
237
+ ...(successor
238
+ ? { supersededBy: decorateUriForIndex(successor, scoped.indexName) }
239
+ : {}),
230
240
  docid: doc.docid,
231
241
  uri: decorateUriForIndex(doc.uri, scoped.indexName),
232
242
  title: doc.title ?? undefined,