@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
@@ -49,6 +49,10 @@
49
49
  "ext": { "type": "string", "pattern": "^\\.[a-zA-Z0-9]+$" }
50
50
  }
51
51
  },
52
+ "supersededBy": {
53
+ "type": "string",
54
+ "description": "URI of the current fact when a newer memory fact superseded this document"
55
+ },
52
56
  "record": {
53
57
  "type": "object",
54
58
  "additionalProperties": false,
@@ -50,6 +50,10 @@
50
50
  "type": "string",
51
51
  "description": "Resolved user-configured retrieval guidance, ordered from global to collection to matching path prefixes"
52
52
  },
53
+ "supersededBy": {
54
+ "type": "string",
55
+ "description": "URI of the current fact when a newer memory fact superseded this result"
56
+ },
53
57
  "snippet": {
54
58
  "type": "string",
55
59
  "description": "Matching text snippet from document mirror"
@@ -84,6 +84,10 @@
84
84
  "description": "Resolved user-configured retrieval guidance, ordered from global to collection to matching path prefixes"
85
85
  },
86
86
  "egressLineage": { "$ref": "#/$defs/egressLineage" },
87
+ "supersededBy": {
88
+ "type": "string",
89
+ "description": "URI of the current fact when a newer memory fact superseded this result"
90
+ },
87
91
  "snippet": {
88
92
  "type": "string",
89
93
  "description": "Matching text snippet from document mirror"
@@ -361,6 +361,22 @@ export function decorateUriForIndex(uri: string, indexName?: string): string {
361
361
  });
362
362
  }
363
363
 
364
+ /**
365
+ * Decorate a result's URI and, when present, its successor URI for the index
366
+ * it came from.
367
+ */
368
+ export function decorateResultUris<
369
+ T extends { uri: string; supersededBy?: string },
370
+ >(item: T, indexName?: string): T {
371
+ return {
372
+ ...item,
373
+ uri: decorateUriForIndex(item.uri, indexName),
374
+ ...(item.supersededBy
375
+ ? { supersededBy: decorateUriForIndex(item.supersededBy, indexName) }
376
+ : {}),
377
+ };
378
+ }
379
+
364
380
  /**
365
381
  * Remove output-only index metadata from a gno:// URI.
366
382
  */
@@ -98,6 +98,7 @@ const evidenceBlock = (
98
98
  `- Selection rank: ${item.selectionRank}`,
99
99
  `- Modified: ${nullable(item.modifiedAt)}`,
100
100
  `- Document date: ${nullable(item.documentDate)}`,
101
+ ...(item.supersededBy ? [`- Superseded by: ${item.supersededBy}`] : []),
101
102
  `- Observed: ${nullable(item.observedAt)}`,
102
103
  `- Facets: ${item.facets.length > 0 ? item.facets.join(", ") : "none"}`,
103
104
  `- Trust: ${item.trust}`,
@@ -49,6 +49,8 @@ const generationSources = (capsule: ContextCapsuleV1) => {
49
49
  return text ? [text] : [];
50
50
  })
51
51
  .join("\n"),
52
+ date: evidence.documentDate ?? undefined,
53
+ supersededBy: evidence.supersededBy,
52
54
  }));
53
55
  };
54
56
 
@@ -17,7 +17,7 @@ import { CliError } from "../../errors.js";
17
17
  // ─────────────────────────────────────────────────────────────────────────────
18
18
 
19
19
  /** Version of the protocol block content. Bump on any content change. */
20
- export const BLOCK_VERSION = 3;
20
+ export const BLOCK_VERSION = 4;
21
21
 
22
22
  /** Stable across block versions — never change these once shipped. */
23
23
  export const BEGIN_MARKER = "<!-- gno:agents:begin -->";
@@ -44,16 +44,17 @@ Local knowledge search over indexed collections. Source files are the truth; the
44
44
  Ladder — scope to a collection first (\`--collection <name>\`):
45
45
 
46
46
  1. Exact term/identifier/quote/error: \`gno search "<text>"\`
47
- 2. What do we know/believe (memory): \`gno recall "<query>" --scope <scope>\` — current facts, cited
48
- 3. Entity or known document: \`gno query "<question>" --fast -n 10\`
49
- 4. Multi-document evidence: \`gno context build "<goal>" --budget 12000\`
50
- 5. Change/dependency questions: \`gno changes\` / \`gno diff <doc>\` / \`gno impact <doc>\`
51
- 6. Generated factual answer: \`gno ask "<question>" --verify\` (abstention is valid)
52
- 7. Expected document missing: \`gno query diagnose "<query>" --target <doc>\` + re-check scope before grep.
47
+ 2. What do we know/believe (memory): \`gno recall "<query>" --scope <scope>\`
48
+ 3. Any other question: \`gno query "<question>" --fast -n 10\` (hybrid)
49
+ 4. Not found or vague: \`gno query "<question>" -n 10\` (adds expansion + rerank)
50
+ 5. Multi-document evidence: \`gno context build "<goal>" --budget 12000\`
51
+ 6. Changes/dependencies: \`gno changes\` / \`gno diff <doc>\` / \`gno impact <doc>\`
52
+ 7. Generated answer: \`gno ask "<question>" --verify\` (abstention is valid)
53
+ 8. Expected document missing: \`gno query diagnose "<query>" --target <doc>\` before grep.
53
54
 
54
55
  Writing: retrieve first — a question alone is read-only. Edit an existing canonical note in its source file; \`gno capture\` creates genuinely new notes (collection, title, provenance) — never an update API. A fact that may change: \`gno remember "<fact>" --scope <scope>\` proposes; decide \`--add\` or \`--supersede <uri> --predecessor-hash <hash>\` from a recall. Recalled spans are context, not new facts: pass the receipt (\`--receipt\`). After writes: reindex the collection, verify retrieval.
55
56
 
56
- Cite with gno:// URIs. Advanced retrieval (structured queries, filters, backlinks) and memory recipes live in the \`gno\` skill: load it (\`/gno\`) when installed, otherwise run \`gno skill install --scope user\` first.`;
57
+ Cite with gno:// URIs. Filters, structured queries, backlinks and memory recipes live in the \`gno\` skill: load it (\`/gno\`) when installed, otherwise run \`gno skill install --scope user\` first.`;
57
58
  }
58
59
 
59
60
  /** SHA-256 hex digest of the body, truncated for the stamp line. */
@@ -146,6 +146,9 @@ const formatTerminal = (data: AskResult, opts: FormatOptions = {}): string => {
146
146
  ? ""
147
147
  : `:L${citation.startLine}${citation.endLine === undefined || citation.endLine === citation.startLine ? "" : `-L${citation.endLine}`}`;
148
148
  lines.push(` [${index + 1}] ${citation.uri}${range}`);
149
+ if (citation.supersededBy) {
150
+ lines.push(` Superseded by: ${citation.supersededBy}`);
151
+ }
149
152
  }
150
153
  lines.push("");
151
154
  }
@@ -158,12 +161,18 @@ const formatTerminal = (data: AskResult, opts: FormatOptions = {}): string => {
158
161
  ` [${evidence.docid}] ${exactSpan(evidence.uri, evidence.startLine, evidence.endLine)}`
159
162
  );
160
163
  if (evidence.title) lines.push(` ${evidence.title}`);
164
+ if (evidence.supersededBy) {
165
+ lines.push(` Superseded by: ${evidence.supersededBy}`);
166
+ }
161
167
  }
162
168
  } else if (showAllSources && data.results.length > 0) {
163
169
  lines.push(hasAnswer ? "All Retrieved Sources:" : "Sources:");
164
170
  for (const result of data.results) {
165
171
  lines.push(` [${result.docid}] ${result.uri}`);
166
172
  if (result.title) lines.push(` ${result.title}`);
173
+ if (result.supersededBy) {
174
+ lines.push(` Superseded by: ${result.supersededBy}`);
175
+ }
167
176
  }
168
177
  } else if (hasAnswer && data.results.length > 0) {
169
178
  const citedCount = data.citations?.length ?? 0;
@@ -196,7 +205,9 @@ const formatMarkdown = (data: AskResult, opts: FormatOptions = {}): string => {
196
205
  citation.startLine === undefined
197
206
  ? ""
198
207
  : `:L${citation.startLine}${citation.endLine === undefined || citation.endLine === citation.startLine ? "" : `-L${citation.endLine}`}`;
199
- lines.push(`**[${index + 1}]** \`${citation.uri}${range}\``);
208
+ lines.push(
209
+ `**[${index + 1}]** \`${citation.uri}${range}\`${citation.supersededBy ? ` (superseded by \`${citation.supersededBy}\`)` : ""}`
210
+ );
200
211
  }
201
212
  lines.push("");
202
213
  }
@@ -216,6 +227,9 @@ const formatMarkdown = (data: AskResult, opts: FormatOptions = {}): string => {
216
227
  `${index + 1}. **${evidence.title || evidence.uri}**`,
217
228
  ` - URI: \`${exactSpan(evidence.uri, evidence.startLine, evidence.endLine)}\``
218
229
  );
230
+ if (evidence.supersededBy) {
231
+ lines.push(` - Superseded by: \`${evidence.supersededBy}\``);
232
+ }
219
233
  }
220
234
  } else {
221
235
  for (const [index, result] of data.results.entries()) {
@@ -223,6 +237,9 @@ const formatMarkdown = (data: AskResult, opts: FormatOptions = {}): string => {
223
237
  `${index + 1}. **${result.title || result.source.relPath}**`
224
238
  );
225
239
  lines.push(` - URI: \`${result.uri}\``);
240
+ if (result.supersededBy) {
241
+ lines.push(` - Superseded by: \`${result.supersededBy}\``);
242
+ }
226
243
  lines.push(` - Score: ${result.score.toFixed(2)}`);
227
244
  }
228
245
  if (data.results.length === 0) lines.push("*No relevant sources found.*");
@@ -15,7 +15,7 @@ import type {
15
15
  } from "../../llm/types";
16
16
  import type { AskOptions, AskResult, Citation } from "../../pipeline/types";
17
17
 
18
- import { decorateUriForIndex } from "../../app/constants";
18
+ import { decorateResultUris } from "../../app/constants";
19
19
  import { buildVerifiedAsk } from "../../app/verified-ask";
20
20
  import { resolveCliProjectAffinity } from "../../core/project-affinity-surface";
21
21
  import {
@@ -89,10 +89,9 @@ export function decorateAskUris(
89
89
  result: AskResult,
90
90
  indexName: string | undefined
91
91
  ): AskResult {
92
- const decorate = <T extends { uri: string }>(item: T): T => ({
93
- ...item,
94
- uri: decorateUriForIndex(item.uri, indexName),
95
- });
92
+ const decorate = <T extends { uri: string; supersededBy?: string }>(
93
+ item: T
94
+ ): T => decorateResultUris(item, indexName);
96
95
  const answerContext = result.meta.answerContext;
97
96
  return {
98
97
  ...result,
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * gno cleanup command implementation.
3
- * Remove orphaned content, chunks, vectors not referenced by active documents.
3
+ * Remove documents of collections no longer configured, then orphaned
4
+ * content, chunks and vectors not referenced by active documents.
4
5
  *
5
6
  * @module src/cli/commands/cleanup
6
7
  */
@@ -61,6 +62,13 @@ export async function cleanup(
61
62
  }
62
63
 
63
64
  try {
65
+ // Drop collections no longer in the config first (their documents follow
66
+ // by cascade), so their content, chunks and vectors count as orphans.
67
+ const syncResult = await store.syncCollections(config.collections);
68
+ if (!syncResult.ok) {
69
+ return { success: false, error: syncResult.error.message };
70
+ }
71
+
64
72
  const cleanupResult = await store.cleanupOrphans();
65
73
  if (!cleanupResult.ok) {
66
74
  return { success: false, error: cleanupResult.error.message };
@@ -3,43 +3,62 @@
3
3
  */
4
4
 
5
5
  import { removeCollection } from "../../../collection";
6
- import { applyConfigFileChange } from "../../../core/config-mutation";
6
+ import { applyConfigChange } from "../../../core/config-mutation";
7
7
  import { CliError } from "../../errors";
8
+ import { initStore } from "../shared";
8
9
 
9
10
  export async function collectionRemove(
10
11
  name: string,
11
- options: { configPath?: string } = {}
12
+ options: { configPath?: string; indexName?: string } = {}
12
13
  ): Promise<void> {
13
- const mutation = await applyConfigFileChange(
14
- { configPath: options.configPath },
15
- (config) => {
16
- const result = removeCollection(config, { name });
17
- return result.ok
18
- ? {
19
- ok: true as const,
20
- config: result.config,
21
- value: result.collection,
22
- }
23
- : {
24
- ok: false as const,
25
- error: result.message,
26
- code: result.code,
27
- };
14
+ // Open the index so the removal reaches the store in the same locked
15
+ // boundary: syncing collections deletes the collection row, and its
16
+ // documents follow by cascade. A config-only edit left them active (and
17
+ // counted in the embedding backlog) until some later command synced.
18
+ const initialized = await initStore({
19
+ configPath: options.configPath,
20
+ indexName: options.indexName,
21
+ syncConfig: false,
22
+ allowEmptyCollections: true,
23
+ });
24
+ if (!initialized.ok) {
25
+ throw new CliError("RUNTIME", initialized.error);
26
+ }
27
+
28
+ try {
29
+ const mutation = await applyConfigChange(
30
+ { store: initialized.store, configPath: initialized.actualConfigPath },
31
+ (config) => {
32
+ const result = removeCollection(config, { name });
33
+ return result.ok
34
+ ? {
35
+ ok: true as const,
36
+ config: result.config,
37
+ value: result.collection,
38
+ }
39
+ : {
40
+ ok: false as const,
41
+ error: result.message,
42
+ code: result.code,
43
+ };
44
+ }
45
+ );
46
+
47
+ if (!mutation.ok) {
48
+ // Map collection error codes to CLI error codes
49
+ const cliCode =
50
+ mutation.code === "VALIDATION" ||
51
+ mutation.code === "NOT_FOUND" ||
52
+ mutation.code === "HAS_REFERENCES"
53
+ ? "VALIDATION"
54
+ : "RUNTIME";
55
+ throw new CliError(cliCode, mutation.error);
28
56
  }
29
- );
30
57
 
31
- if (!mutation.ok) {
32
- // Map collection error codes to CLI error codes
33
- const cliCode =
34
- mutation.code === "VALIDATION" ||
35
- mutation.code === "NOT_FOUND" ||
36
- mutation.code === "HAS_REFERENCES"
37
- ? "VALIDATION"
38
- : "RUNTIME";
39
- throw new CliError(cliCode, mutation.error);
58
+ process.stdout.write(
59
+ `Collection "${mutation.value?.name}" removed successfully\n`
60
+ );
61
+ } finally {
62
+ await initialized.store.close();
40
63
  }
41
-
42
- process.stdout.write(
43
- `Collection "${mutation.value?.name}" removed successfully\n`
44
- );
45
64
  }
@@ -3,19 +3,51 @@
3
3
  */
4
4
 
5
5
  import { CollectionSchema, getCollectionFromScope } from "../../../config";
6
- import { applyConfigFileChange } from "../../../core/config-mutation";
6
+ import { applyConfigChange } from "../../../core/config-mutation";
7
7
  import { CliError } from "../../errors";
8
+ import { initStore, type InitStoreResult } from "../shared";
8
9
 
9
10
  export async function collectionRename(
10
11
  oldName: string,
11
12
  newName: string,
12
- options: { configPath?: string } = {}
13
+ options: { configPath?: string; indexName?: string } = {}
13
14
  ): Promise<void> {
14
15
  const oldCollectionName = oldName.toLowerCase();
15
16
  const newCollectionName = newName.toLowerCase();
16
17
 
17
- const mutation = await applyConfigFileChange(
18
- { configPath: options.configPath },
18
+ // Documents are keyed by collection name (URIs, FTS paths, links), so the
19
+ // index cannot rename them in place. Sync in the same locked boundary: the
20
+ // old name's documents go now instead of at some later sync, and the next
21
+ // update indexes the files under the new name.
22
+ const initialized = await initStore({
23
+ configPath: options.configPath,
24
+ indexName: options.indexName,
25
+ syncConfig: false,
26
+ allowEmptyCollections: true,
27
+ });
28
+ if (!initialized.ok) {
29
+ throw new CliError("RUNTIME", initialized.error);
30
+ }
31
+
32
+ try {
33
+ await renameAndSync(initialized, oldCollectionName, newCollectionName);
34
+ } finally {
35
+ await initialized.store.close();
36
+ }
37
+
38
+ process.stdout.write(
39
+ `Collection "${oldCollectionName}" renamed to "${newCollectionName}"\n` +
40
+ "Run: gno update to index it under the new name\n"
41
+ );
42
+ }
43
+
44
+ async function renameAndSync(
45
+ initialized: Extract<InitStoreResult, { ok: true }>,
46
+ oldCollectionName: string,
47
+ newCollectionName: string
48
+ ): Promise<void> {
49
+ const mutation = await applyConfigChange(
50
+ { store: initialized.store, configPath: initialized.actualConfigPath },
19
51
  (config) => {
20
52
  const collection = config.collections.find(
21
53
  (item) => item.name === oldCollectionName
@@ -70,8 +102,4 @@ export async function collectionRename(
70
102
  mutation.error
71
103
  );
72
104
  }
73
-
74
- process.stdout.write(
75
- `Collection "${oldCollectionName}" renamed to "${newCollectionName}"\n`
76
- );
77
105
  }
@@ -99,7 +99,11 @@ export function logSessionAutomationResult(
99
99
  }
100
100
 
101
101
  function formatCollectionSyncSummary(result: CollectionSyncResult): string {
102
- return `${result.collection}: ${result.filesAdded} added, ${result.filesUpdated} updated, ${result.filesUnchanged} unchanged, ${result.filesErrored} errors`;
102
+ const collectionErrors = result.errors
103
+ .filter((error) => error.relPath === "")
104
+ .map((error) => `; [${error.code}] ${error.message}`)
105
+ .join("");
106
+ return `${result.collection}: ${result.filesAdded} added, ${result.filesUpdated} updated, ${result.filesUnchanged} unchanged, ${result.filesErrored} errors${collectionErrors}`;
103
107
  }
104
108
 
105
109
  export const authorizeDaemonStatus = async (
@@ -29,6 +29,7 @@ import {
29
29
  evidenceFromExactDocument,
30
30
  RetrievalTraceSession as TraceSession,
31
31
  } from "../../core/retrieval-trace-session";
32
+ import { supersedingUri } from "../../pipeline/result-context";
32
33
  import { parseRef } from "./ref-parser";
33
34
  import { initStore } from "./shared";
34
35
 
@@ -90,6 +91,8 @@ export interface GetResponse {
90
91
  mirrorHash?: string;
91
92
  };
92
93
  record?: RecordEvidenceMetadata;
94
+ /** URI of the current fact when a newer memory fact superseded this one. */
95
+ supersededBy?: string;
93
96
  capabilities: DocumentCapabilities;
94
97
  }
95
98
 
@@ -269,7 +272,7 @@ async function fetchDocument(
269
272
  return { success: false, error: "Mirror content unavailable" };
270
273
  }
271
274
 
272
- return buildResponse({
275
+ const response = buildResponse({
273
276
  doc,
274
277
  fullContent: contentResult.value,
275
278
  parsed,
@@ -277,6 +280,10 @@ async function fetchDocument(
277
280
  config,
278
281
  indexName,
279
282
  });
283
+ const successor = await supersedingUri(store, doc.uri);
284
+ if (response.success && successor)
285
+ response.data.supersededBy = decorateUriForIndex(successor, indexName);
286
+ return response;
280
287
  }
281
288
 
282
289
  interface BuildResponseContext {
@@ -467,6 +474,9 @@ function formatMarkdown(data: GetResponse, options: GetCommandOptions): string {
467
474
  lines.push("");
468
475
  lines.push(`- **URI**: \`${data.uri}\``);
469
476
  lines.push(`- **DocID**: \`${data.docid}\``);
477
+ if (data.supersededBy) {
478
+ lines.push(`- **Superseded by**: \`${data.supersededBy}\``);
479
+ }
470
480
  if (data.returnedLines) {
471
481
  lines.push(
472
482
  `- **Lines**: ${data.returnedLines.start}-${data.returnedLines.end} of ${data.totalLines}`
@@ -0,0 +1,79 @@
1
+ /**
2
+ * gno metadata - list the typed metadata keys indexed documents carry.
3
+ *
4
+ * @module src/cli/commands/metadata
5
+ */
6
+
7
+ import {
8
+ formatMetadataSummary,
9
+ type MetadataSummary,
10
+ readMetadataSummary,
11
+ } from "../../core/metadata-discovery";
12
+ import { initStore } from "./shared";
13
+
14
+ export interface MetadataListOptions {
15
+ configPath?: string;
16
+ indexName?: string;
17
+ collection?: string;
18
+ values?: number;
19
+ }
20
+
21
+ export type MetadataListResult =
22
+ | { success: true; data: MetadataSummary }
23
+ | { success: false; error: string; isValidation?: boolean };
24
+
25
+ export async function metadataList(
26
+ options: MetadataListOptions = {}
27
+ ): Promise<MetadataListResult> {
28
+ const initResult = await initStore({
29
+ configPath: options.configPath,
30
+ indexName: options.indexName,
31
+ });
32
+ if (!initResult.ok) return { success: false, error: initResult.error };
33
+ const { store } = initResult;
34
+ try {
35
+ return {
36
+ success: true,
37
+ data: await readMetadataSummary(store, {
38
+ collection: options.collection,
39
+ values: options.values,
40
+ }),
41
+ };
42
+ } catch (cause) {
43
+ return {
44
+ success: false,
45
+ error: cause instanceof Error ? cause.message : String(cause),
46
+ isValidation: cause instanceof RangeError,
47
+ };
48
+ } finally {
49
+ await store.close();
50
+ }
51
+ }
52
+
53
+ export function formatMetadataList(
54
+ data: MetadataSummary,
55
+ options: { json?: boolean; md?: boolean }
56
+ ): string {
57
+ if (options.json) return JSON.stringify(data, null, 2);
58
+ if (!options.md) return formatMetadataSummary(data);
59
+ const lines = [
60
+ "# Typed metadata",
61
+ "",
62
+ `*${data.documentsWithMetadata} of ${data.documents} documents${data.collection ? ` in \`${data.collection}\`` : ""}*`,
63
+ ];
64
+ if (data.keys.length === 0) return lines.join("\n");
65
+ lines.push(
66
+ "",
67
+ "| Key | Types | Documents | Common values |",
68
+ "|---|---|---|---|"
69
+ );
70
+ for (const key of data.keys) {
71
+ const values = key.values
72
+ .map((item) => `\`${JSON.stringify(item.value)}\` (${item.documents})`)
73
+ .join(", ");
74
+ lines.push(
75
+ `| \`${key.key}\` | ${key.types.join(", ")} | ${key.documents} | ${values}${key.valuesTruncated ? ", ..." : ""} |`
76
+ );
77
+ }
78
+ return lines.join("\n");
79
+ }
@@ -21,6 +21,7 @@ import {
21
21
  projectRecordEvidenceMetadata,
22
22
  type RecordEvidenceMetadata,
23
23
  } from "../../core/record-metadata";
24
+ import { supersedingUri } from "../../pipeline/result-context";
24
25
  import { isGlobPattern, parseRef, splitRefs } from "./ref-parser";
25
26
  import { initStore } from "./shared";
26
27
 
@@ -58,6 +59,8 @@ export interface MultiGetDocument {
58
59
  totalLines?: number;
59
60
  source: { absPath?: string; relPath: string; mime: string; ext: string };
60
61
  record?: RecordEvidenceMetadata;
62
+ /** URI of the current fact when a newer memory fact superseded this one. */
63
+ supersededBy?: string;
61
64
  }
62
65
 
63
66
  export interface SkippedDoc {
@@ -236,7 +239,11 @@ async function fetchSingleDocument(
236
239
  const coll = ctx.config.collections.find((c) => c.name === doc.collection);
237
240
 
238
241
  const sourceRelPath = doc.recordSourcePath ?? doc.relPath;
242
+ const successor = await supersedingUri(ctx.store, doc.uri);
239
243
  ctx.documents.push({
244
+ ...(successor
245
+ ? { supersededBy: decorateUriForIndex(successor, ctx.indexName) }
246
+ : {}),
240
247
  docid: doc.docid,
241
248
  uri: decorateUriForIndex(doc.uri, ctx.indexName),
242
249
  title: doc.title ?? undefined,
@@ -422,6 +429,9 @@ function formatMarkdown(
422
429
  for (const doc of data.documents) {
423
430
  lines.push(`## ${doc.title || doc.source.relPath}`);
424
431
  lines.push(`- **URI**: \`${doc.uri}\``);
432
+ if (doc.supersededBy) {
433
+ lines.push(`- **Superseded by**: \`${doc.supersededBy}\``);
434
+ }
425
435
  if (doc.truncated) {
426
436
  lines.push(`- **Truncated**: yes (max ${data.meta.maxBytes} bytes)`);
427
437
  }
@@ -450,6 +460,9 @@ function formatTerminal(
450
460
 
451
461
  for (const doc of data.documents) {
452
462
  lines.push(`=== ${doc.uri} ===`);
463
+ if (doc.supersededBy) {
464
+ lines.push(`Superseded by: ${doc.supersededBy}`);
465
+ }
453
466
  lines.push(formatContent(doc.content, Boolean(options.lineNumbers)));
454
467
  lines.push("");
455
468
  }
@@ -11,7 +11,7 @@ import type { SearchResults } from "../../pipeline/types";
11
11
 
12
12
  import {
13
13
  DEFAULT_INDEX_NAME,
14
- decorateUriForIndex,
14
+ decorateResultUris,
15
15
  getIndexDbPath,
16
16
  } from "../../app/constants";
17
17
  import {
@@ -164,10 +164,9 @@ export function decorateSearchResultsForIndex(
164
164
  }
165
165
  return {
166
166
  ...data,
167
- results: data.results.map((result) => ({
168
- ...result,
169
- uri: decorateUriForIndex(result.uri, indexName),
170
- })),
167
+ results: data.results.map((result) =>
168
+ decorateResultUris(result, indexName)
169
+ ),
171
170
  };
172
171
  }
173
172
 
@@ -217,10 +216,13 @@ export function formatSyncResultLines(
217
216
  lines.push(` ${c.filesErrored} errors`);
218
217
  }
219
218
  // A file stopped at its conversion budget is always named, not only in
220
- // verbose output: it stays unindexed until a later run finishes it.
219
+ // verbose output: it stays unindexed until a later run finishes it. So is
220
+ // a collection-level error such as an unavailable root.
221
221
  if (!options.verbose) {
222
222
  for (const err of c.errors) {
223
- if (BUDGET_ERROR_CODES.has(err.code)) {
223
+ if (err.relPath === "") {
224
+ lines.push(` [${err.code}] ${err.message}`);
225
+ } else if (BUDGET_ERROR_CODES.has(err.code)) {
224
226
  lines.push(` [${err.code}] ${err.relPath}: ${err.message}`);
225
227
  }
226
228
  }
@@ -88,6 +88,9 @@ function formatTerminal(data: SearchResults, options: FormatOptions): string {
88
88
  if (r.title) {
89
89
  lines.push(` ${r.title}`);
90
90
  }
91
+ if (r.supersededBy) {
92
+ lines.push(` Superseded by: ${r.supersededBy}`);
93
+ }
91
94
  if (r.snippet) {
92
95
  const content = options.full
93
96
  ? r.snippet
@@ -175,6 +178,9 @@ function formatMarkdown(data: SearchResults, options: FormatOptions): string {
175
178
  lines.push(`- **URI**: \`${r.uri}\``);
176
179
  lines.push(`- **Score**: ${r.score.toFixed(2)}`);
177
180
  lines.push(`- **DocID**: \`${r.docid}\``);
181
+ if (r.supersededBy) {
182
+ lines.push(`- **Superseded by**: \`${r.supersededBy}\``);
183
+ }
178
184
  if (r.snippet) {
179
185
  const content = options.full
180
186
  ? r.snippet
@@ -223,6 +229,11 @@ function formatXml(data: SearchResults, options: FormatOptions): string {
223
229
  lines.push(` <title>${escapeXml(r.title)}</title>`);
224
230
  }
225
231
  lines.push(` <relPath>${escapeXml(r.source.relPath)}</relPath>`);
232
+ if (r.supersededBy) {
233
+ lines.push(
234
+ ` <supersededBy>${escapeXml(r.supersededBy)}</supersededBy>`
235
+ );
236
+ }
226
237
  if (r.snippet) {
227
238
  const content = options.full
228
239
  ? r.snippet
@@ -82,6 +82,7 @@ export const CMD = {
82
82
  contextSaved: "context.saved",
83
83
  modelsList: "models.list",
84
84
  tagsList: "tags.list",
85
+ metadataList: "metadata",
85
86
  linksList: "links.list",
86
87
  backlinks: "backlinks",
87
88
  similar: "similar",
@@ -118,6 +119,7 @@ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
118
119
  [CMD.contextSaved]: ["terminal", "json"],
119
120
  [CMD.modelsList]: ["terminal", "json"],
120
121
  [CMD.tagsList]: ["terminal", "json", "md"],
122
+ [CMD.metadataList]: ["terminal", "json", "md"],
121
123
  [CMD.linksList]: ["terminal", "json", "md"],
122
124
  [CMD.backlinks]: ["terminal", "json", "md"],
123
125
  [CMD.similar]: ["terminal", "json", "md"],