@gmickel/gno 1.20.0 → 1.22.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 (55) hide show
  1. package/README.md +29 -5
  2. package/assets/skill/SKILL.md +46 -15
  3. package/package.json +2 -1
  4. package/spec/cli.md +144 -0
  5. package/spec/db/schema.sql +170 -0
  6. package/spec/evals-agentic.md +48 -0
  7. package/spec/mcp.md +22 -0
  8. package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
  9. package/spec/output-schemas/changes.schema.json +280 -0
  10. package/spec/output-schemas/document-diff.schema.json +185 -0
  11. package/spec/output-schemas/impact.schema.json +122 -0
  12. package/spec/output-schemas/publish-artifact.schema.json +284 -0
  13. package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
  14. package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
  15. package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
  16. package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
  17. package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
  18. package/src/cli/commands/changes.ts +160 -0
  19. package/src/cli/commands/context-saved.ts +189 -0
  20. package/src/cli/options.ts +8 -0
  21. package/src/cli/program.ts +195 -0
  22. package/src/core/capsule-registry.ts +279 -0
  23. package/src/core/capsule-reverification-scheduler.ts +218 -0
  24. package/src/core/capsule-reverification.ts +289 -0
  25. package/src/core/change-diff.ts +182 -0
  26. package/src/core/change-journal.ts +228 -0
  27. package/src/core/knowledge-delta.ts +395 -0
  28. package/src/core/knowledge-impact.ts +202 -0
  29. package/src/ingestion/sync.ts +214 -165
  30. package/src/mcp/tools/changes.ts +80 -0
  31. package/src/mcp/tools/index.ts +29 -0
  32. package/src/publish/artifact-validation.ts +259 -0
  33. package/src/publish/artifact.ts +234 -118
  34. package/src/publish/export-service.ts +5 -9
  35. package/src/publish/metadata.ts +195 -0
  36. package/src/sdk/client.ts +42 -0
  37. package/src/sdk/index.ts +7 -0
  38. package/src/sdk/types.ts +22 -0
  39. package/src/serve/doc-events.ts +12 -1
  40. package/src/serve/resident-runtime.ts +22 -0
  41. package/src/serve/routes/api.ts +13 -0
  42. package/src/serve/routes/changes.ts +102 -0
  43. package/src/serve/server.ts +34 -0
  44. package/src/serve/watch-service.ts +9 -0
  45. package/src/store/index.ts +21 -0
  46. package/src/store/migrations/015-document-change-journal.ts +85 -0
  47. package/src/store/migrations/016-saved-capsules.ts +131 -0
  48. package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
  49. package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
  50. package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
  51. package/src/store/migrations/index.ts +10 -0
  52. package/src/store/sqlite/adapter.ts +291 -7
  53. package/src/store/sqlite/capsule-registry-store.ts +534 -0
  54. package/src/store/sqlite/change-journal-store.ts +473 -0
  55. package/src/store/types.ts +262 -0
@@ -40,6 +40,13 @@ import {
40
40
  getDefaultPipeline,
41
41
  } from "../converters/pipeline";
42
42
  import { DEFAULT_LIMITS } from "../converters/types";
43
+ import {
44
+ diffDocumentStructure,
45
+ extractDocumentStructure,
46
+ isRelationMap,
47
+ normalizeRelationEdgeType,
48
+ normalizeRelationTarget,
49
+ } from "../core/change-diff";
43
50
  import {
44
51
  normalizeMarkdownPath,
45
52
  normalizeWikiName,
@@ -83,35 +90,6 @@ const NON_RETRYABLE_CONVERSION_ERROR_CODES = new Set([
83
90
  "UNSUPPORTED",
84
91
  ]);
85
92
 
86
- type RelationMap = Record<string, string[]>;
87
-
88
- function isRelationMap(value: unknown): value is RelationMap {
89
- if (!value || typeof value !== "object" || Array.isArray(value)) {
90
- return false;
91
- }
92
-
93
- return Object.values(value).every(
94
- (targets) =>
95
- Array.isArray(targets) &&
96
- targets.every((target) => typeof target === "string")
97
- );
98
- }
99
-
100
- function normalizeRelationTarget(raw: string): string {
101
- const trimmed = raw.trim();
102
- if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
103
- return trimmed.slice(2, -2).split("|")[0]?.trim() ?? "";
104
- }
105
- return trimmed;
106
- }
107
-
108
- function normalizeRelationEdgeType(raw: string): string {
109
- return raw
110
- .trim()
111
- .toLowerCase()
112
- .replace(/[-\s]+/g, "_");
113
- }
114
-
115
93
  function findDocByWikiRef(
116
94
  docs: DocumentRow[],
117
95
  targetRef: string,
@@ -747,7 +725,24 @@ export class SyncService {
747
725
  // Log but continue - error recording is best-effort
748
726
  }
749
727
 
750
- // Upsert document with error info, explicitly clear mirrorHash
728
+ const hadRetrievableEvidence = Boolean(existing?.mirrorHash);
729
+ const structureDelta = hadRetrievableEvidence
730
+ ? diffDocumentStructure(
731
+ await this.readPreviousStructure(store, existing),
732
+ {
733
+ headings: [],
734
+ links: [],
735
+ typedEdges: [],
736
+ dates: {},
737
+ }
738
+ ).delta
739
+ : undefined;
740
+
741
+ // Upsert document with error info, explicitly clear mirrorHash. This
742
+ // transition is journaled only when retrievable evidence previously
743
+ // existed; initial/repeated error placeholders are not evidence
744
+ // creates. Real evidence disappearance stays in the same transaction
745
+ // so saved Capsule freshness checks cannot miss it.
751
746
  const upsertResult = await store.upsertDocument({
752
747
  collection: collection.name,
753
748
  relPath: entry.relPath,
@@ -761,6 +756,7 @@ export class SyncService {
761
756
  lastErrorMessage: convertResult.error.message,
762
757
  ingestVersion: INGEST_VERSION,
763
758
  contentTypeRulesFingerprint,
759
+ changeJournal: structureDelta ? { structureDelta } : false,
764
760
  // mirrorHash intentionally omitted (will be null)
765
761
  });
766
762
 
@@ -788,155 +784,186 @@ export class SyncService {
788
784
  mime.ext,
789
785
  contentTypeRules
790
786
  );
791
-
792
- // 7. Upsert document - EXPLICITLY clear error fields on success
793
- const docidResult = await store.upsertDocument({
794
- collection: collection.name,
795
- relPath: entry.relPath,
796
- sourceHash,
797
- sourceMime: mime.mime,
798
- sourceExt: mime.ext,
799
- sourceSize,
800
- sourceMtime,
801
- sourceCtime,
802
- title: artifact.title,
803
- mirrorHash: artifact.mirrorHash,
804
- converterId: artifact.meta.converterId,
805
- converterVersion: artifact.meta.converterVersion,
806
- languageHint: artifact.languageHint ?? collection.languageHint,
807
- contentType: extractedMetadata.contentType,
808
- contentTypeSource: extractedMetadata.contentTypeSource,
809
- categories: extractedMetadata.categories,
810
- author: extractedMetadata.author,
811
- frontmatterDate: extractedMetadata.frontmatterDate,
812
- dateFields: extractedMetadata.dateFields,
813
- contentTypeRulesFingerprint,
814
- // Clear error fields on success (requires store to handle undefined → null)
815
- lastErrorCode: undefined,
816
- lastErrorMessage: undefined,
817
- ingestVersion: INGEST_VERSION,
818
- });
819
-
820
- const { id: docId, docid } = mustOk(docidResult, "upsertDocument", {
821
- collection: collection.name,
822
- relPath: entry.relPath,
823
- });
824
-
825
- // 8. Upsert content (content-addressed dedupe) - CHECKED
826
- const contentResult = await store.upsertContent(
827
- artifact.mirrorHash,
828
- artifact.markdown
787
+ const previousStructure = await this.readPreviousStructure(
788
+ store,
789
+ existing
829
790
  );
830
- mustOk(contentResult, "upsertContent", {
831
- mirrorHash: artifact.mirrorHash,
832
- });
833
-
834
- // 9. Chunk content
835
- const chunks = this.chunker.chunk(
791
+ const nextStructure = extractDocumentStructure(
836
792
  artifact.markdown,
837
- DEFAULT_CHUNK_PARAMS,
838
- artifact.languageHint ?? collection.languageHint,
839
- entry.relPath
793
+ entry.relPath,
794
+ extractedMetadata.dateFields
840
795
  );
796
+ const structureDelta = diffDocumentStructure(
797
+ previousStructure,
798
+ nextStructure
799
+ ).delta;
800
+
801
+ const persistSuccessfulFile = async (): Promise<FileSyncResult> => {
802
+ // 7. Upsert document - EXPLICITLY clear error fields on success
803
+ const docidResult = await store.upsertDocument({
804
+ collection: collection.name,
805
+ relPath: entry.relPath,
806
+ sourceHash,
807
+ sourceMime: mime.mime,
808
+ sourceExt: mime.ext,
809
+ sourceSize,
810
+ sourceMtime,
811
+ sourceCtime,
812
+ title: artifact.title,
813
+ mirrorHash: artifact.mirrorHash,
814
+ converterId: artifact.meta.converterId,
815
+ converterVersion: artifact.meta.converterVersion,
816
+ languageHint: artifact.languageHint ?? collection.languageHint,
817
+ contentType: extractedMetadata.contentType,
818
+ contentTypeSource: extractedMetadata.contentTypeSource,
819
+ categories: extractedMetadata.categories,
820
+ author: extractedMetadata.author,
821
+ frontmatterDate: extractedMetadata.frontmatterDate,
822
+ dateFields: extractedMetadata.dateFields,
823
+ contentTypeRulesFingerprint,
824
+ // Clear error fields on success (requires store to handle undefined → null)
825
+ lastErrorCode: undefined,
826
+ lastErrorMessage: undefined,
827
+ ingestVersion: INGEST_VERSION,
828
+ changeJournal: { structureDelta },
829
+ });
841
830
 
842
- // 10. Convert to ChunkInput for store
843
- const chunkInputs: ChunkInput[] = chunks.map((c) => ({
844
- seq: c.seq,
845
- pos: c.pos,
846
- text: c.text,
847
- startLine: c.startLine,
848
- endLine: c.endLine,
849
- language: c.language ?? undefined,
850
- tokenCount: c.tokenCount ?? undefined,
851
- }));
852
-
853
- // 11. Upsert chunks - CHECKED
854
- const chunksResult = await store.upsertChunks(
855
- artifact.mirrorHash,
856
- chunkInputs
857
- );
858
- mustOk(chunksResult, "upsertChunks", {
859
- mirrorHash: artifact.mirrorHash,
860
- chunkCount: chunkInputs.length,
861
- });
831
+ const { id: docId, docid } = mustOk(docidResult, "upsertDocument", {
832
+ collection: collection.name,
833
+ relPath: entry.relPath,
834
+ });
862
835
 
863
- // 12. Rebuild FTS for this hash - CHECKED
864
- const ftsResult = await store.rebuildFtsForHash(artifact.mirrorHash);
865
- mustOk(ftsResult, "rebuildFtsForHash", {
866
- mirrorHash: artifact.mirrorHash,
867
- });
836
+ // 8. Upsert content (content-addressed dedupe) - CHECKED
837
+ const contentResult = await store.upsertContent(
838
+ artifact.mirrorHash,
839
+ artifact.markdown
840
+ );
841
+ mustOk(contentResult, "upsertContent", {
842
+ mirrorHash: artifact.mirrorHash,
843
+ });
868
844
 
869
- // 13. Extract and store tags from frontmatter and body hashtags
870
- // Always call setDocTags to clear removed tags on re-sync
871
- const extractedTags = extractTags(artifact.markdown);
872
- const tagsResult = await store.setDocTags(
873
- docId,
874
- extractedTags,
875
- "frontmatter"
876
- );
877
- mustOk(tagsResult, "setDocTags", {
878
- docId,
879
- tagCount: extractedTags.length,
880
- });
845
+ // 9. Chunk content
846
+ const chunks = this.chunker.chunk(
847
+ artifact.markdown,
848
+ DEFAULT_CHUNK_PARAMS,
849
+ artifact.languageHint ?? collection.languageHint,
850
+ entry.relPath
851
+ );
881
852
 
882
- // 14. Extract and store links (wiki and markdown links)
883
- const excludedRanges = getExcludedRanges(artifact.markdown);
884
- const lineOffsets = buildLineOffsets(artifact.markdown);
885
- const parsedLinks = parseLinks(
886
- artifact.markdown,
887
- lineOffsets,
888
- excludedRanges
889
- );
853
+ // 10. Convert to ChunkInput for store
854
+ const chunkInputs: ChunkInput[] = chunks.map((c) => ({
855
+ seq: c.seq,
856
+ pos: c.pos,
857
+ text: c.text,
858
+ startLine: c.startLine,
859
+ endLine: c.endLine,
860
+ language: c.language ?? undefined,
861
+ tokenCount: c.tokenCount ?? undefined,
862
+ }));
863
+
864
+ // 11. Upsert chunks - CHECKED
865
+ const chunksResult = await store.upsertChunks(
866
+ artifact.mirrorHash,
867
+ chunkInputs
868
+ );
869
+ mustOk(chunksResult, "upsertChunks", {
870
+ mirrorHash: artifact.mirrorHash,
871
+ chunkCount: chunkInputs.length,
872
+ });
890
873
 
891
- const linkInputs: DocLinkInput[] = [];
892
- for (const link of parsedLinks) {
893
- // Compute target_ref_norm based on link type
894
- let targetRefNorm: string;
895
- if (link.kind === "wiki") {
896
- targetRefNorm = normalizeWikiName(link.targetRef);
897
- } else {
898
- // Markdown links with collection prefix are not supported
899
- // (use wiki links for cross-collection references)
900
- if (link.targetCollection) {
901
- continue;
902
- }
903
- const resolved = normalizeMarkdownPath(link.targetRef, entry.relPath);
904
- if (!resolved) {
905
- // Link escapes collection root - skip silently
906
- continue;
874
+ // 12. Rebuild FTS for this hash - CHECKED
875
+ const ftsResult = await store.rebuildFtsForHash(artifact.mirrorHash);
876
+ mustOk(ftsResult, "rebuildFtsForHash", {
877
+ mirrorHash: artifact.mirrorHash,
878
+ });
879
+
880
+ // 13. Extract and store tags from frontmatter and body hashtags
881
+ // Always call setDocTags to clear removed tags on re-sync
882
+ const extractedTags = extractTags(artifact.markdown);
883
+ const tagsResult = await store.setDocTags(
884
+ docId,
885
+ extractedTags,
886
+ "frontmatter"
887
+ );
888
+ mustOk(tagsResult, "setDocTags", {
889
+ docId,
890
+ tagCount: extractedTags.length,
891
+ });
892
+
893
+ // 14. Extract and store links (wiki and markdown links)
894
+ const excludedRanges = getExcludedRanges(artifact.markdown);
895
+ const lineOffsets = buildLineOffsets(artifact.markdown);
896
+ const parsedLinks = parseLinks(
897
+ artifact.markdown,
898
+ lineOffsets,
899
+ excludedRanges
900
+ );
901
+
902
+ const linkInputs: DocLinkInput[] = [];
903
+ for (const link of parsedLinks) {
904
+ // Compute target_ref_norm based on link type
905
+ let targetRefNorm: string;
906
+ if (link.kind === "wiki") {
907
+ targetRefNorm = normalizeWikiName(link.targetRef);
908
+ } else {
909
+ // Markdown links with collection prefix are not supported
910
+ // (use wiki links for cross-collection references)
911
+ if (link.targetCollection) {
912
+ continue;
913
+ }
914
+ const resolved = normalizeMarkdownPath(
915
+ link.targetRef,
916
+ entry.relPath
917
+ );
918
+ if (!resolved) {
919
+ // Link escapes collection root - skip silently
920
+ continue;
921
+ }
922
+ targetRefNorm = resolved;
907
923
  }
908
- targetRefNorm = resolved;
924
+
925
+ linkInputs.push({
926
+ targetRef: link.targetRef,
927
+ targetRefNorm,
928
+ targetAnchor: link.targetAnchor,
929
+ targetCollection: link.targetCollection,
930
+ linkType: link.kind,
931
+ linkText: link.displayText,
932
+ startLine: link.startLine,
933
+ startCol: link.startCol,
934
+ endLine: link.endLine,
935
+ endCol: link.endCol,
936
+ });
909
937
  }
910
938
 
911
- linkInputs.push({
912
- targetRef: link.targetRef,
913
- targetRefNorm,
914
- targetAnchor: link.targetAnchor,
915
- targetCollection: link.targetCollection,
916
- linkType: link.kind,
917
- linkText: link.displayText,
918
- startLine: link.startLine,
919
- startCol: link.startCol,
920
- endLine: link.endLine,
921
- endCol: link.endCol,
939
+ const linksResult = await store.setDocLinks(
940
+ docId,
941
+ linkInputs,
942
+ "parsed"
943
+ );
944
+ mustOk(linksResult, "setDocLinks", {
945
+ docId,
946
+ linkCount: linkInputs.length,
922
947
  });
923
- }
924
948
 
925
- const linksResult = await store.setDocLinks(docId, linkInputs, "parsed");
926
- mustOk(linksResult, "setDocLinks", {
927
- docId,
928
- linkCount: linkInputs.length,
929
- });
930
-
931
- const status = existing ? "updated" : "added";
932
- return {
933
- relPath: entry.relPath,
934
- status,
935
- docid,
936
- mirrorHash: artifact.mirrorHash,
937
- contentType: extractedMetadata.contentType,
938
- contentTypeSource: extractedMetadata.contentTypeSource,
949
+ const status = existing ? "updated" : "added";
950
+ return {
951
+ relPath: entry.relPath,
952
+ status,
953
+ docid,
954
+ mirrorHash: artifact.mirrorHash,
955
+ contentType: extractedMetadata.contentType,
956
+ contentTypeSource: extractedMetadata.contentTypeSource,
957
+ };
939
958
  };
959
+ if (!store.withTransaction) {
960
+ return await persistSuccessfulFile();
961
+ }
962
+ const persisted = await store.withTransaction(persistSuccessfulFile);
963
+ return mustOk(persisted, "persistSuccessfulFile", {
964
+ collection: collection.name,
965
+ relPath: entry.relPath,
966
+ });
940
967
  } catch (error) {
941
968
  const message = error instanceof Error ? error.message : "Unknown error";
942
969
  // Distinguish store errors from other internal errors
@@ -977,6 +1004,7 @@ export class SyncService {
977
1004
  existingResult.value.sourceMtime,
978
1005
  lastErrorCode: code,
979
1006
  lastErrorMessage: message,
1007
+ changeJournal: false,
980
1008
  });
981
1009
  }
982
1010
  } catch {
@@ -992,6 +1020,27 @@ export class SyncService {
992
1020
  }
993
1021
  }
994
1022
 
1023
+ private async readPreviousStructure(
1024
+ store: StorePort,
1025
+ existing: DocumentRow | null
1026
+ ): Promise<ReturnType<typeof extractDocumentStructure> | null | undefined> {
1027
+ if (!existing) return null;
1028
+ if (!existing.mirrorHash) return undefined;
1029
+
1030
+ const content = await store.getContent(existing.mirrorHash);
1031
+ if (!content.ok) {
1032
+ throw new Error(`Store operation failed: ${content.error.message}`, {
1033
+ cause: content.error,
1034
+ });
1035
+ }
1036
+ if (content.value === null) return undefined;
1037
+ return extractDocumentStructure(
1038
+ content.value,
1039
+ existing.relPath,
1040
+ existing.dateFields
1041
+ );
1042
+ }
1043
+
995
1044
  /**
996
1045
  * Sync a specific set of files within a collection.
997
1046
  */
@@ -0,0 +1,80 @@
1
+ /** Read-only MCP adapters for knowledge change, diff, and impact services. */
2
+
3
+ import { z } from "zod";
4
+
5
+ import type { ToolContext } from "../server";
6
+
7
+ import {
8
+ analyzeKnowledgeImpact,
9
+ getKnowledgeDiff,
10
+ listKnowledgeChanges,
11
+ } from "../../core/knowledge-delta";
12
+ import { runTool, type ToolResult } from "./index";
13
+
14
+ export const changesInputSchema = z.object({
15
+ since: z.string().trim().min(1).max(512).optional(),
16
+ collection: z.string().trim().min(1).max(256).optional(),
17
+ limit: z.number().int().min(1).max(1000).default(100),
18
+ });
19
+
20
+ export const diffInputSchema = z.object({
21
+ ref: z.string().trim().min(1).max(4096),
22
+ change: z.string().trim().min(1).max(512).optional(),
23
+ });
24
+
25
+ export const impactInputSchema = z.object({
26
+ ref: z.string().trim().min(1).max(4096),
27
+ maxDepth: z.number().int().min(1).max(6).default(3),
28
+ maxNodes: z.number().int().min(1).max(1000).default(100),
29
+ maxEdges: z.number().int().min(1).max(5000).default(250),
30
+ frontierLimit: z.number().int().min(1).max(1000).default(100),
31
+ visitedLimit: z.number().int().min(1).max(5000).default(500),
32
+ });
33
+
34
+ const unwrap = <T>(
35
+ result:
36
+ | { success: true; data: T }
37
+ | { success: false; error: string; isValidation?: boolean }
38
+ ): T => {
39
+ if (result.success) return result.data;
40
+ throw new Error(
41
+ `${result.isValidation ? "VALIDATION" : "RUNTIME"}: ${result.error}`
42
+ );
43
+ };
44
+
45
+ export const handleChanges = (
46
+ args: z.infer<typeof changesInputSchema>,
47
+ ctx: ToolContext
48
+ ): Promise<ToolResult> =>
49
+ runTool(
50
+ ctx,
51
+ "gno_changes",
52
+ async () => unwrap(await listKnowledgeChanges(ctx.store, args)),
53
+ (data) =>
54
+ `${data.changes.length} retained document changes${data.page.truncated ? " (more available)" : ""}`
55
+ );
56
+
57
+ export const handleDiff = (
58
+ args: z.infer<typeof diffInputSchema>,
59
+ ctx: ToolContext
60
+ ): Promise<ToolResult> =>
61
+ runTool(
62
+ ctx,
63
+ "gno_diff",
64
+ async () =>
65
+ unwrap(await getKnowledgeDiff(ctx.store, args.ref, args.change)),
66
+ (data) =>
67
+ `Structural diff for ${data.document.uri}: ${data.status}; history ${data.history.status}; source bodies not retained`
68
+ );
69
+
70
+ export const handleImpact = (
71
+ args: z.infer<typeof impactInputSchema>,
72
+ ctx: ToolContext
73
+ ): Promise<ToolResult> =>
74
+ runTool(
75
+ ctx,
76
+ "gno_impact",
77
+ async () => unwrap(await analyzeKnowledgeImpact(ctx.store, args.ref, args)),
78
+ (data) =>
79
+ `${data.impacted.length} documents depend on ${data.root.uri}${data.meta.truncated ? " (truncated)" : ""}`
80
+ );
@@ -21,6 +21,14 @@ import { normalizeTag } from "../../core/tags";
21
21
  import { handleAddCollection } from "./add-collection";
22
22
  import { askInputSchema, handleAsk } from "./ask";
23
23
  import { handleCapture } from "./capture";
24
+ import {
25
+ changesInputSchema,
26
+ diffInputSchema,
27
+ handleChanges,
28
+ handleDiff,
29
+ handleImpact,
30
+ impactInputSchema,
31
+ } from "./changes";
24
32
  import { handleClearCollectionEmbeddings } from "./clear-collection-embeddings";
25
33
  import { handleContext, handleContextVerify } from "./context";
26
34
  import { handleEmbed } from "./embed";
@@ -1036,6 +1044,27 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
1036
1044
  (args) => handleStatus(args, ctx)
1037
1045
  );
1038
1046
 
1047
+ server.tool(
1048
+ "gno_changes",
1049
+ "List retained metadata-only document changes with opaque cursor pagination and retention disclosure.",
1050
+ changesInputSchema.shape,
1051
+ (args) => handleChanges(args, ctx)
1052
+ );
1053
+
1054
+ server.tool(
1055
+ "gno_diff",
1056
+ "Inspect one retained metadata-only structural document change. Source bodies are never returned.",
1057
+ diffInputSchema.shape,
1058
+ (args) => handleDiff(args, ctx)
1059
+ );
1060
+
1061
+ server.tool(
1062
+ "gno_impact",
1063
+ "Find bounded inbound typed, wiki, and Markdown dependencies with deterministic evidence paths.",
1064
+ impactInputSchema.shape,
1065
+ (args) => handleImpact(args, ctx)
1066
+ );
1067
+
1039
1068
  server.tool(
1040
1069
  "gno_trace_list",
1041
1070
  "List bounded metadata-only summaries of private local retrieval traces. Raw replay queries are omitted from history.",