@gmickel/gno 1.4.2 → 1.5.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.
- package/assets/skill/SKILL.md +11 -1
- package/assets/skill/cli-reference.md +9 -1
- package/assets/skill/mcp-reference.md +16 -0
- package/package.json +1 -1
- package/src/cli/commands/graph.ts +237 -4
- package/src/cli/program.ts +31 -0
- package/src/core/graph-analysis.ts +201 -0
- package/src/mcp/tools/index.ts +58 -4
- package/src/mcp/tools/links.ts +304 -7
- package/src/mcp/tools/query.ts +2 -0
- package/src/pipeline/explain.ts +2 -0
- package/src/pipeline/fusion.ts +28 -0
- package/src/pipeline/graph-retrieval.ts +368 -0
- package/src/pipeline/hybrid.ts +45 -3
- package/src/pipeline/types.ts +18 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/GraphView.tsx +237 -16
- package/src/serve/public/pages/Search.tsx +1 -0
- package/src/serve/routes/api.ts +2 -0
- package/src/store/sqlite/adapter.ts +343 -16
- package/src/store/types.ts +120 -0
package/src/mcp/tools/index.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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",
|
package/src/mcp/tools/links.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
+
}
|
package/src/mcp/tools/query.ts
CHANGED
|
@@ -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),
|
package/src/pipeline/explain.ts
CHANGED
|
@@ -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)}`,
|
package/src/pipeline/fusion.ts
CHANGED
|
@@ -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()) {
|