@gmickel/gno 1.4.2 → 1.5.1

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.
@@ -21,6 +21,8 @@ import { handleJobStatus } from "./job-status";
21
21
  import {
22
22
  handleBacklinks,
23
23
  handleGraph,
24
+ handleGraphNeighbors,
25
+ handleGraphPath,
24
26
  handleLinks,
25
27
  handleSimilar,
26
28
  } from "./links";
@@ -451,6 +453,10 @@ export const queryInputSchema = z.object({
451
453
  .boolean()
452
454
  .optional()
453
455
  .describe("Override: enable/disable cross-encoder reranking"),
456
+ noGraph: z
457
+ .boolean()
458
+ .optional()
459
+ .describe("Disable bounded one-hop graph neighbor expansion"),
454
460
  tagsAll: z.array(z.string()).optional().describe("Require ALL of these tags"),
455
461
  tagsAny: z.array(z.string()).optional().describe("Require ANY of these tags"),
456
462
  });
@@ -637,6 +643,40 @@ const graphInputSchema = z.object({
637
643
  .describe("Max similar docs per node when includeSimilar=true"),
638
644
  });
639
645
 
646
+ const graphNeighborsInputSchema = graphInputSchema.extend({
647
+ ref: z
648
+ .string()
649
+ .trim()
650
+ .min(1, "Reference cannot be empty")
651
+ .describe(
652
+ "Document/node reference: gno URI, #docid, collection/path, relPath, or exact title"
653
+ ),
654
+ direction: z
655
+ .enum(["both", "out", "in"])
656
+ .default("both")
657
+ .describe("Which graph edges to follow from the reference node"),
658
+ });
659
+
660
+ const graphPathInputSchema = graphInputSchema.extend({
661
+ from: z
662
+ .string()
663
+ .trim()
664
+ .min(1, "From reference cannot be empty")
665
+ .describe("Starting document/node reference"),
666
+ to: z
667
+ .string()
668
+ .trim()
669
+ .min(1, "To reference cannot be empty")
670
+ .describe("Target document/node reference"),
671
+ maxDepth: z
672
+ .number()
673
+ .int()
674
+ .min(1)
675
+ .max(12)
676
+ .default(6)
677
+ .describe("Maximum relationship hops to search"),
678
+ });
679
+
640
680
  // ─────────────────────────────────────────────────────────────────────────────
641
681
  // Tool Result Type
642
682
  // ─────────────────────────────────────────────────────────────────────────────
@@ -798,32 +838,46 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
798
838
 
799
839
  server.tool(
800
840
  "gno_links",
801
- "Get outgoing wiki ([[links]]) and markdown links from a document.",
841
+ "Get outgoing wiki ([[links]]) and markdown links from one document. Use after gno_query when you need immediate local expansion from a known source document; use gno_graph_neighbors for bidirectional graph navigation.",
802
842
  linksInputSchema.shape,
803
843
  (args) => handleLinks(args, ctx)
804
844
  );
805
845
 
806
846
  server.tool(
807
847
  "gno_backlinks",
808
- "Find all documents that link TO a given document (incoming references).",
848
+ "Find all documents that link TO a given document. Use after gno_query/gno_get to discover incoming references around a known target; use gno_graph_path for 'how are X and Y connected?' questions.",
809
849
  backlinksInputSchema.shape,
810
850
  (args) => handleBacklinks(args, ctx)
811
851
  );
812
852
 
813
853
  server.tool(
814
854
  "gno_similar",
815
- "Find semantically similar documents using vector embeddings. Requires embeddings to exist for source document.",
855
+ "Find semantically similar documents using vector embeddings. Use for local expansion when wording differs; use gno_query as the default retrieval entry point and gno_graph_neighbors/path for explicit relationship questions.",
816
856
  similarInputSchema.shape,
817
857
  (args) => handleSimilar(args, ctx)
818
858
  );
819
859
 
820
860
  server.tool(
821
861
  "gno_graph",
822
- "Get knowledge graph of document connections (wiki links, markdown links, optional similarity edges).",
862
+ "Get graph report/stats plus nodes/edges for corpus navigation. Use for unfamiliar corpus structure, hubs/isolates/unresolved links, or custom graph analysis; do not replace normal retrieval with this. Start with gno_query for content questions, then graph tools for relationship context, then gno_get for targeted reads.",
823
863
  graphInputSchema.shape,
824
864
  (args) => handleGraph(args, ctx)
825
865
  );
826
866
 
867
+ server.tool(
868
+ "gno_graph_neighbors",
869
+ "Find graph neighbors around a document/node. Use for relationship questions, missed obvious related docs, or unfamiliar corpus navigation after gno_query identifies a seed; returns incoming/outgoing wiki, markdown, and optional similarity edges. Follow with gno_get for targeted reads.",
870
+ graphNeighborsInputSchema.shape,
871
+ (args) => handleGraphNeighbors(args, ctx)
872
+ );
873
+
874
+ server.tool(
875
+ "gno_graph_path",
876
+ "Find the shortest relationship path between two documents/nodes. Use for prompts like 'how are X and Y connected?' or to explain corpus relationships. Use gno_query for finding candidate refs first, then gno_get on path nodes for evidence.",
877
+ graphPathInputSchema.shape,
878
+ (args) => handleGraphPath(args, ctx)
879
+ );
880
+
827
881
  if (ctx.enableWrite) {
828
882
  server.tool(
829
883
  "gno_capture",
@@ -10,6 +10,8 @@ import type {
10
10
  BacklinkRow,
11
11
  DocLinkRow,
12
12
  DocumentRow,
13
+ GraphLink,
14
+ GraphNode,
13
15
  GraphResult,
14
16
  } from "../../store/types";
15
17
  import type { ToolContext } from "../server";
@@ -635,8 +637,48 @@ interface GraphInput {
635
637
  similarTopK?: number;
636
638
  }
637
639
 
640
+ interface GraphNeighborsInput extends GraphInput {
641
+ ref: string;
642
+ direction?: "both" | "out" | "in";
643
+ }
644
+
645
+ interface GraphPathInput extends GraphInput {
646
+ from: string;
647
+ to: string;
648
+ maxDepth?: number;
649
+ }
650
+
651
+ interface GraphNeighbor {
652
+ node: GraphNode;
653
+ direction: "out" | "in";
654
+ edge: GraphLink;
655
+ }
656
+
657
+ interface GraphNeighborsResult {
658
+ source: GraphNode;
659
+ neighbors: GraphNeighbor[];
660
+ meta: GraphResult["meta"] & {
661
+ direction: "both" | "out" | "in";
662
+ totalNeighbors: number;
663
+ };
664
+ }
665
+
666
+ interface GraphPathResult {
667
+ from: GraphNode;
668
+ to: GraphNode;
669
+ path: {
670
+ nodes: GraphNode[];
671
+ edges: GraphLink[];
672
+ } | null;
673
+ meta: GraphResult["meta"] & {
674
+ maxDepth: number;
675
+ found: boolean;
676
+ hops: number;
677
+ };
678
+ }
679
+
638
680
  function formatGraphResult(data: GraphResult): string {
639
- const { nodes, links, meta } = data;
681
+ const { meta, report } = data;
640
682
  const lines: string[] = [];
641
683
 
642
684
  lines.push(
@@ -659,22 +701,51 @@ function formatGraphResult(data: GraphResult): string {
659
701
 
660
702
  lines.push("");
661
703
  lines.push("Top nodes by degree:");
662
- const topNodes = [...nodes].sort((a, b) => b.degree - a.degree).slice(0, 10);
663
- for (const node of topNodes) {
704
+ for (const node of report.hubs) {
664
705
  const title = node.title ? ` "${node.title}"` : "";
665
706
  lines.push(` [${node.id}] ${node.uri}${title} (degree: ${node.degree})`);
666
707
  }
667
708
 
668
- const edgeTypes = new Map<string, number>();
669
- for (const link of links) {
670
- edgeTypes.set(link.type, (edgeTypes.get(link.type) ?? 0) + 1);
709
+ if (report.bridgeCandidates.length > 0) {
710
+ lines.push("");
711
+ lines.push("Bridge candidates:");
712
+ for (const node of report.bridgeCandidates) {
713
+ const title = node.title ? ` "${node.title}"` : "";
714
+ lines.push(` [${node.id}] ${node.uri}${title} (degree: ${node.degree})`);
715
+ }
671
716
  }
672
717
 
673
718
  lines.push("");
674
719
  lines.push("Edge breakdown:");
675
- for (const [type, count] of edgeTypes) {
720
+ for (const [type, count] of Object.entries(report.edgeTypes)) {
676
721
  lines.push(` ${type}: ${count}`);
677
722
  }
723
+ lines.push("Confidence:");
724
+ for (const [confidence, count] of Object.entries(report.edgeConfidence)) {
725
+ lines.push(` ${confidence}: ${count}`);
726
+ }
727
+
728
+ if (report.communities.skipped) {
729
+ lines.push("");
730
+ lines.push("Communities: skipped for graph size");
731
+ } else {
732
+ lines.push("");
733
+ lines.push(`Communities: ${report.communities.total}`);
734
+ for (const community of report.communities.top.slice(0, 5)) {
735
+ lines.push(
736
+ ` ${community.id}: ${community.label} (${community.size} docs, ${community.edgeCount} internal edges)`
737
+ );
738
+ }
739
+ }
740
+
741
+ lines.push("");
742
+ lines.push(
743
+ `Unresolved links: ${report.unresolvedLinks.total} (wiki: ${report.unresolvedLinks.byType.wiki}, markdown: ${report.unresolvedLinks.byType.markdown})`
744
+ );
745
+ lines.push(
746
+ `Audit: inferred ${report.audit.inferredEdges}, ambiguous ${report.audit.ambiguousEdges}, similarity ${report.audit.similarityEdges}`
747
+ );
748
+ lines.push(`Isolated documents: ${report.isolated.total}`);
678
749
 
679
750
  return lines.join("\n");
680
751
  }
@@ -720,3 +791,229 @@ export function handleGraph(
720
791
  formatGraphResult
721
792
  );
722
793
  }
794
+
795
+ async function getValidatedGraph(
796
+ args: GraphInput,
797
+ ctx: ToolContext
798
+ ): Promise<GraphResult> {
799
+ let collection: string | undefined;
800
+ if (args.collection) {
801
+ collection = normalizeCollectionName(args.collection);
802
+ const exists = ctx.collections.some(
803
+ (c) => c.name.toLowerCase() === collection?.toLowerCase()
804
+ );
805
+ if (!exists) {
806
+ throw new Error(
807
+ `${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${args.collection}`
808
+ );
809
+ }
810
+ }
811
+
812
+ const result = await ctx.store.getGraph({
813
+ collection,
814
+ limitNodes: args.limit ?? 2000,
815
+ limitEdges: args.edgeLimit ?? 10000,
816
+ includeSimilar: args.includeSimilar ?? false,
817
+ threshold: args.threshold ?? 0.7,
818
+ linkedOnly: args.linkedOnly ?? true,
819
+ similarTopK: args.similarTopK ?? 5,
820
+ });
821
+
822
+ if (!result.ok) {
823
+ throw new Error(result.error.message);
824
+ }
825
+
826
+ return result.value;
827
+ }
828
+
829
+ function resolveGraphNode(graph: GraphResult, ref: string): GraphNode | null {
830
+ const normalized = ref.trim().toLowerCase();
831
+ return (
832
+ graph.nodes.find((node) => {
833
+ const title = node.title?.toLowerCase();
834
+ return (
835
+ node.id.toLowerCase() === normalized ||
836
+ node.uri.toLowerCase() === normalized ||
837
+ node.relPath.toLowerCase() === normalized ||
838
+ `${node.collection}/${node.relPath}`.toLowerCase() === normalized ||
839
+ title === normalized
840
+ );
841
+ }) ?? null
842
+ );
843
+ }
844
+
845
+ function formatGraphNeighborsResult(data: GraphNeighborsResult): string {
846
+ if (data.neighbors.length === 0) {
847
+ return `No graph neighbors found for ${data.source.uri} (direction=${data.meta.direction})`;
848
+ }
849
+
850
+ const lines = [
851
+ `Found ${data.neighbors.length} graph neighbors for ${data.source.uri}:`,
852
+ "",
853
+ ];
854
+ for (const item of data.neighbors) {
855
+ const title = item.node.title ? ` "${item.node.title}"` : "";
856
+ lines.push(
857
+ ` [${item.direction}] ${item.node.uri}${title} (${item.edge.type}, ${item.edge.confidence}, weight: ${item.edge.weight})`
858
+ );
859
+ }
860
+ return lines.join("\n");
861
+ }
862
+
863
+ export function handleGraphNeighbors(
864
+ args: GraphNeighborsInput,
865
+ ctx: ToolContext
866
+ ): Promise<ToolResult> {
867
+ return runTool(
868
+ ctx,
869
+ "gno_graph_neighbors",
870
+ async () => {
871
+ const graph = await getValidatedGraph(args, ctx);
872
+ const source = resolveGraphNode(graph, args.ref);
873
+ if (!source) {
874
+ throw new Error(
875
+ `${MCP_ERRORS.NOT_FOUND.code}: Graph node not found: ${args.ref}`
876
+ );
877
+ }
878
+
879
+ const direction = args.direction ?? "both";
880
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
881
+ const neighbors: GraphNeighbor[] = [];
882
+
883
+ for (const edge of graph.links) {
884
+ if (direction !== "in" && edge.source === source.id) {
885
+ const node = nodesById.get(edge.target);
886
+ if (node) neighbors.push({ node, direction: "out", edge });
887
+ }
888
+ if (direction !== "out" && edge.target === source.id) {
889
+ const node = nodesById.get(edge.source);
890
+ if (node) neighbors.push({ node, direction: "in", edge });
891
+ }
892
+ }
893
+
894
+ neighbors.sort((a, b) => {
895
+ if (a.direction !== b.direction) {
896
+ return a.direction.localeCompare(b.direction);
897
+ }
898
+ if (a.edge.type !== b.edge.type) {
899
+ return a.edge.type.localeCompare(b.edge.type);
900
+ }
901
+ return a.node.uri.localeCompare(b.node.uri);
902
+ });
903
+
904
+ return {
905
+ source,
906
+ neighbors,
907
+ meta: {
908
+ ...graph.meta,
909
+ direction,
910
+ totalNeighbors: neighbors.length,
911
+ },
912
+ };
913
+ },
914
+ formatGraphNeighborsResult
915
+ );
916
+ }
917
+
918
+ function findShortestPath(
919
+ graph: GraphResult,
920
+ from: GraphNode,
921
+ to: GraphNode,
922
+ maxDepth: number
923
+ ): { nodes: GraphNode[]; edges: GraphLink[] } | null {
924
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
925
+ const adjacency = new Map<string, Array<{ next: string; edge: GraphLink }>>();
926
+ for (const edge of graph.links) {
927
+ const sourceEdges = adjacency.get(edge.source) ?? [];
928
+ sourceEdges.push({ next: edge.target, edge });
929
+ adjacency.set(edge.source, sourceEdges);
930
+
931
+ const reverseEdges = adjacency.get(edge.target) ?? [];
932
+ reverseEdges.push({ next: edge.source, edge });
933
+ adjacency.set(edge.target, reverseEdges);
934
+ }
935
+
936
+ const queue: Array<{ id: string; pathEdges: GraphLink[] }> = [
937
+ { id: from.id, pathEdges: [] },
938
+ ];
939
+ const visited = new Set([from.id]);
940
+
941
+ while (queue.length > 0) {
942
+ const current = queue.shift();
943
+ if (!current) break;
944
+ if (current.id === to.id) {
945
+ const nodeIds = [from.id];
946
+ let cursor = from.id;
947
+ for (const edge of current.pathEdges) {
948
+ cursor = edge.source === cursor ? edge.target : edge.source;
949
+ nodeIds.push(cursor);
950
+ }
951
+ return {
952
+ nodes: nodeIds
953
+ .map((id) => nodesById.get(id))
954
+ .filter((node): node is GraphNode => node !== undefined),
955
+ edges: current.pathEdges,
956
+ };
957
+ }
958
+ if (current.pathEdges.length >= maxDepth) continue;
959
+
960
+ const edges = adjacency.get(current.id) ?? [];
961
+ edges.sort((a, b) => a.next.localeCompare(b.next));
962
+ for (const { next, edge } of edges) {
963
+ if (visited.has(next)) continue;
964
+ visited.add(next);
965
+ queue.push({ id: next, pathEdges: [...current.pathEdges, edge] });
966
+ }
967
+ }
968
+
969
+ return null;
970
+ }
971
+
972
+ function formatGraphPathResult(data: GraphPathResult): string {
973
+ if (!data.path) {
974
+ return `No graph path found from ${data.from.uri} to ${data.to.uri} within ${data.meta.maxDepth} hops`;
975
+ }
976
+
977
+ const route = data.path.nodes.map((node) => node.uri).join(" -> ");
978
+ return `Graph path (${data.meta.hops} hops):\n${route}`;
979
+ }
980
+
981
+ export function handleGraphPath(
982
+ args: GraphPathInput,
983
+ ctx: ToolContext
984
+ ): Promise<ToolResult> {
985
+ return runTool(
986
+ ctx,
987
+ "gno_graph_path",
988
+ async () => {
989
+ const graph = await getValidatedGraph(args, ctx);
990
+ const from = resolveGraphNode(graph, args.from);
991
+ if (!from) {
992
+ throw new Error(
993
+ `${MCP_ERRORS.NOT_FOUND.code}: Graph node not found: ${args.from}`
994
+ );
995
+ }
996
+ const to = resolveGraphNode(graph, args.to);
997
+ if (!to) {
998
+ throw new Error(
999
+ `${MCP_ERRORS.NOT_FOUND.code}: Graph node not found: ${args.to}`
1000
+ );
1001
+ }
1002
+
1003
+ const maxDepth = args.maxDepth ?? 6;
1004
+ const path = findShortestPath(graph, from, to, maxDepth);
1005
+ return {
1006
+ from,
1007
+ to,
1008
+ path,
1009
+ meta: {
1010
+ ...graph.meta,
1011
+ maxDepth,
1012
+ found: path !== null,
1013
+ hops: path ? path.edges.length : 0,
1014
+ },
1015
+ };
1016
+ },
1017
+ formatGraphPathResult
1018
+ );
1019
+ }
@@ -50,6 +50,7 @@ interface QueryInput {
50
50
  thorough?: boolean;
51
51
  expand?: boolean;
52
52
  rerank?: boolean;
53
+ noGraph?: boolean;
53
54
  tagsAll?: string[];
54
55
  tagsAny?: string[];
55
56
  }
@@ -271,6 +272,7 @@ export function handleQuery(
271
272
  author: args.author,
272
273
  noExpand,
273
274
  noRerank,
275
+ noGraph: args.noGraph || args.fast,
274
276
  queryModes,
275
277
  tagsAll: normalizeTagFilters(args.tagsAll),
276
278
  tagsAny: normalizeTagFilters(args.tagsAny),
@@ -175,6 +175,7 @@ interface StageTimingsInput {
175
175
  expansionMs: number;
176
176
  bm25Ms: number;
177
177
  vectorMs: number;
178
+ graphMs: number;
178
179
  fusionMs: number;
179
180
  rerankMs: number;
180
181
  assemblyMs: number;
@@ -190,6 +191,7 @@ export function explainTimings(timings: StageTimingsInput): ExplainLine {
190
191
  `expansion=${fmt(timings.expansionMs)}`,
191
192
  `bm25=${fmt(timings.bm25Ms)}`,
192
193
  `vector=${fmt(timings.vectorMs)}`,
194
+ `graph=${fmt(timings.graphMs)}`,
193
195
  `fusion=${fmt(timings.fusionMs)}`,
194
196
  `rerank=${fmt(timings.rerankMs)}`,
195
197
  `assembly=${fmt(timings.assemblyMs)}`,
@@ -62,6 +62,7 @@ export function rrfFuse(
62
62
  i.source === "vector_variant" ||
63
63
  i.source === "hyde"
64
64
  );
65
+ const graphInputs = inputs.filter((i) => i.source === "graph");
65
66
 
66
67
  // Process BM25 sources
67
68
  // Original query gets 2x weight to prevent dilution by expansion variants
@@ -139,6 +140,33 @@ export function rrfFuse(
139
140
  }
140
141
  }
141
142
 
143
+ // Process graph expansion sources.
144
+ for (const input of graphInputs) {
145
+ const weight = config.bm25Weight * 0.8;
146
+
147
+ for (const result of input.results) {
148
+ const key = `${result.mirrorHash}:${result.seq}`;
149
+ let candidate = candidates.get(key);
150
+
151
+ if (!candidate) {
152
+ candidate = {
153
+ mirrorHash: result.mirrorHash,
154
+ seq: result.seq,
155
+ bm25Rank: null,
156
+ vecRank: null,
157
+ fusionScore: 0,
158
+ sources: [],
159
+ };
160
+ candidates.set(key, candidate);
161
+ }
162
+
163
+ candidate.fusionScore += rrfContribution(result.rank, config.k, weight);
164
+ if (!candidate.sources.includes(input.source)) {
165
+ candidate.sources.push(input.source);
166
+ }
167
+ }
168
+ }
169
+
142
170
  // Apply tiered top-rank bonus
143
171
  // Rewards documents ranking highly in ANY list (not requiring both)
144
172
  for (const candidate of candidates.values()) {