@gmickel/gno 1.9.0 → 1.10.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 (36) hide show
  1. package/assets/skill/SKILL.md +28 -16
  2. package/assets/skill/cli-reference.md +30 -0
  3. package/assets/skill/examples.md +20 -0
  4. package/assets/skill/mcp-reference.md +7 -1
  5. package/package.json +1 -1
  6. package/src/cli/commands/graph.ts +89 -2
  7. package/src/cli/commands/links.ts +237 -54
  8. package/src/cli/commands/query.ts +212 -0
  9. package/src/cli/commands/ref-parser.ts +8 -103
  10. package/src/cli/options.ts +4 -0
  11. package/src/cli/program.ts +176 -9
  12. package/src/config/content-types.ts +14 -0
  13. package/src/core/graph-query.ts +137 -0
  14. package/src/core/graph-resolver.ts +117 -0
  15. package/src/core/ref-parser.ts +145 -0
  16. package/src/ingestion/frontmatter.ts +95 -2
  17. package/src/ingestion/sync.ts +281 -1
  18. package/src/mcp/tools/get.ts +1 -1
  19. package/src/mcp/tools/index.ts +89 -1
  20. package/src/mcp/tools/links.ts +83 -1
  21. package/src/mcp/tools/multi-get.ts +1 -1
  22. package/src/mcp/tools/query.ts +207 -0
  23. package/src/pipeline/diagnose.ts +302 -0
  24. package/src/pipeline/filters.ts +119 -0
  25. package/src/pipeline/hybrid.ts +90 -17
  26. package/src/pipeline/types.ts +32 -0
  27. package/src/publish/export-service.ts +1 -1
  28. package/src/sdk/documents.ts +2 -2
  29. package/src/serve/routes/api.ts +194 -0
  30. package/src/serve/routes/graph.ts +173 -1
  31. package/src/serve/server.ts +24 -1
  32. package/src/store/migrations/010-typed-edges.ts +67 -0
  33. package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
  34. package/src/store/migrations/index.ts +4 -0
  35. package/src/store/sqlite/adapter.ts +826 -102
  36. package/src/store/types.ts +141 -0
@@ -10,11 +10,11 @@ import type { DocumentRow, StorePort } from "../../store/types";
10
10
  import type { ToolContext } from "../server";
11
11
 
12
12
  import { decorateUriForIndex, parseUri } from "../../app/constants";
13
- import { parseRef } from "../../cli/commands/ref-parser";
14
13
  import {
15
14
  getDocumentCapabilities,
16
15
  type DocumentCapabilities,
17
16
  } from "../../core/document-capabilities";
17
+ import { parseRef } from "../../core/ref-parser";
18
18
  import { runTool, type ToolResult } from "./index";
19
19
 
20
20
  interface GetInput {
@@ -23,6 +23,7 @@ import { handleJobStatus } from "./job-status";
23
23
  import {
24
24
  handleBacklinks,
25
25
  handleGraph,
26
+ handleGraphQuery,
26
27
  handleGraphNeighbors,
27
28
  handleGraphPath,
28
29
  handleLinks,
@@ -31,7 +32,7 @@ import {
31
32
  import { handleListJobs } from "./list-jobs";
32
33
  import { handleListTags } from "./list-tags";
33
34
  import { handleMultiGet } from "./multi-get";
34
- import { handleQuery } from "./query";
35
+ import { handleQuery, handleQueryDiagnose } from "./query";
35
36
  import { handleRemoveCollection } from "./remove-collection";
36
37
  import { handleSearch } from "./search";
37
38
  import { handleStatus } from "./status";
@@ -64,6 +65,8 @@ export const MCP_TOOL_DESCRIPTIONS = {
64
65
  "Vector semantic search. Finds conceptually similar docs with different wording. Best after embeddings are current; use intent to disambiguate short terms. Use gno_query for default hybrid retrieval.",
65
66
  query:
66
67
  "Hybrid search (BM25 + vector + optional expansion/reranking). Recommended default. Use intent for ambiguous terms, queryModes to combine term/intent/hyde strategies, fast=true for quick lookup, thorough=true when recall matters, and candidateLimit to trade latency for coverage.",
68
+ queryDiagnose:
69
+ "Diagnose why one target document does or does not appear for a query. Use when an important doc is missing, a filter may exclude it, or you need stage-by-stage BM25/vector/fusion/graph/rerank evidence before changing retrieval strategy.",
67
70
  get: "Retrieve one document by gno:// URI, docid (#abc123), or collection/path. After search results include line, pass fromLine and lineCount to fetch only the relevant range before expanding to the full document.",
68
71
  multiGet:
69
72
  "Retrieve multiple documents by refs array or glob pattern. Use after gno_search/gno_query to batch top result URIs/docids; set maxBytes and lineNumbers to control context size.",
@@ -499,6 +502,16 @@ export const queryInputSchema = z.object({
499
502
  tagsAny: z.array(z.string()).optional().describe("Require ANY of these tags"),
500
503
  });
501
504
 
505
+ export const queryDiagnoseInputSchema = queryInputSchema.extend({
506
+ target: z
507
+ .string()
508
+ .trim()
509
+ .min(1, "Target reference cannot be empty")
510
+ .describe(
511
+ "Target document reference to diagnose (gno URI, docid, or collection/path)"
512
+ ),
513
+ });
514
+
502
515
  const getInputSchema = z.object({
503
516
  ref: z
504
517
  .string()
@@ -715,6 +728,67 @@ const graphPathInputSchema = graphInputSchema.extend({
715
728
  .describe("Maximum relationship hops to search"),
716
729
  });
717
730
 
731
+ export const graphQueryInputSchema = z.object({
732
+ ref: z
733
+ .string()
734
+ .trim()
735
+ .min(1, "Reference cannot be empty")
736
+ .describe(
737
+ "Root document reference for typed-edge traversal: gno URI, docid, or collection/path"
738
+ ),
739
+ direction: z
740
+ .enum(["both", "out", "in"])
741
+ .default("both")
742
+ .describe("Which typed edges to traverse"),
743
+ edgeType: z
744
+ .string()
745
+ .trim()
746
+ .min(1)
747
+ .optional()
748
+ .describe("Semantic edge type filter, e.g. mentions or works_at"),
749
+ relation: z
750
+ .string()
751
+ .trim()
752
+ .min(1)
753
+ .optional()
754
+ .describe("Alias for edgeType; must match edgeType when both are set"),
755
+ maxDepth: z
756
+ .number()
757
+ .int()
758
+ .min(1)
759
+ .max(6)
760
+ .optional()
761
+ .describe("Maximum typed-edge hops to traverse"),
762
+ depth: z
763
+ .number()
764
+ .int()
765
+ .min(1)
766
+ .max(6)
767
+ .optional()
768
+ .describe("Alias for maxDepth"),
769
+ maxNodes: z
770
+ .number()
771
+ .int()
772
+ .min(1)
773
+ .max(1000)
774
+ .default(100)
775
+ .describe("Returned node cap"),
776
+ frontierLimit: z
777
+ .number()
778
+ .int()
779
+ .min(1)
780
+ .max(1000)
781
+ .default(100)
782
+ .describe("Per-depth frontier cap"),
783
+ visitedLimit: z
784
+ .number()
785
+ .int()
786
+ .min(1)
787
+ .max(5000)
788
+ .default(500)
789
+ .describe("SQL traversal visited-row cap"),
790
+ });
791
+
718
792
  // ─────────────────────────────────────────────────────────────────────────────
719
793
  // Tool Result Type
720
794
  // ─────────────────────────────────────────────────────────────────────────────
@@ -846,6 +920,13 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
846
920
  (args) => handleQuery(args, ctx)
847
921
  );
848
922
 
923
+ server.tool(
924
+ "gno_query_diagnose",
925
+ MCP_TOOL_DESCRIPTIONS.queryDiagnose,
926
+ queryDiagnoseInputSchema.shape,
927
+ (args) => handleQueryDiagnose(args, ctx)
928
+ );
929
+
849
930
  server.tool(
850
931
  "gno_get",
851
932
  MCP_TOOL_DESCRIPTIONS.get,
@@ -902,6 +983,13 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
902
983
  (args) => handleGraph(args, ctx)
903
984
  );
904
985
 
986
+ server.tool(
987
+ "gno_graph_query",
988
+ "Run bounded traversal over typed doc_edges from one root document. Use for explicit relationship questions like 'what does Alice work_at within 2 hops?' or to inspect typed graph hints; returns schemaVersion, root, nodes, edges, caps, and truncation.",
989
+ graphQueryInputSchema.shape,
990
+ (args) => handleGraphQuery(args, ctx)
991
+ );
992
+
905
993
  server.tool(
906
994
  "gno_graph_neighbors",
907
995
  "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.",
@@ -10,14 +10,18 @@ import type {
10
10
  BacklinkRow,
11
11
  DocLinkRow,
12
12
  DocumentRow,
13
+ GraphQueryDirection,
14
+ GraphQueryResult,
13
15
  GraphLink,
14
16
  GraphNode,
15
17
  GraphResult,
16
18
  } from "../../store/types";
17
19
  import type { ToolContext } from "../server";
18
20
 
19
- import { parseRef } from "../../cli/commands/ref-parser";
21
+ import { normalizeContentTypes } from "../../config";
20
22
  import { MCP_ERRORS } from "../../core/errors";
23
+ import { diagnoseGraphQuery } from "../../core/graph-query";
24
+ import { parseRef } from "../../core/ref-parser";
21
25
  import { normalizeCollectionName } from "../../core/validation";
22
26
  import { getActivePreset } from "../../llm/registry";
23
27
  import { createVectorIndexPort } from "../../store/vector";
@@ -648,6 +652,18 @@ interface GraphPathInput extends GraphInput {
648
652
  maxDepth?: number;
649
653
  }
650
654
 
655
+ interface GraphQueryInput {
656
+ ref: string;
657
+ direction?: GraphQueryDirection;
658
+ edgeType?: string;
659
+ relation?: string;
660
+ maxDepth?: number;
661
+ depth?: number;
662
+ maxNodes?: number;
663
+ frontierLimit?: number;
664
+ visitedLimit?: number;
665
+ }
666
+
651
667
  interface GraphNeighbor {
652
668
  node: GraphNode;
653
669
  direction: "out" | "in";
@@ -860,6 +876,72 @@ function formatGraphNeighborsResult(data: GraphNeighborsResult): string {
860
876
  return lines.join("\n");
861
877
  }
862
878
 
879
+ function formatGraphQueryResult(data: GraphQueryResult): string {
880
+ const lines = [
881
+ `Typed graph query from ${data.root.uri}: ${data.nodes.length} nodes, ${data.edges.length} edges`,
882
+ `Direction: ${data.meta.direction}, depth: ${data.meta.maxDepth}`,
883
+ ];
884
+ if (data.meta.edgeType) {
885
+ lines.push(`Edge type: ${data.meta.edgeType}`);
886
+ }
887
+ if (data.meta.truncated) {
888
+ lines.push("Traversal truncated by configured caps");
889
+ }
890
+ for (const edge of data.edges.slice(0, 20)) {
891
+ lines.push(
892
+ ` ${edge.source} -> ${edge.target} (${edge.edgeType}, ${edge.confidence}, ${edge.edgeSource})`
893
+ );
894
+ }
895
+ return lines.join("\n");
896
+ }
897
+
898
+ export function handleGraphQuery(
899
+ args: GraphQueryInput,
900
+ ctx: ToolContext
901
+ ): Promise<ToolResult> {
902
+ return runTool(
903
+ ctx,
904
+ "gno_graph_query",
905
+ async () => {
906
+ const edgeTypeValue = args.edgeType?.trim();
907
+ const relationValue = args.relation?.trim();
908
+ if (edgeTypeValue && relationValue && edgeTypeValue !== relationValue) {
909
+ throw new Error(
910
+ "VALIDATION: edgeType and relation are aliases and must match when both are provided"
911
+ );
912
+ }
913
+ if (
914
+ args.maxDepth !== undefined &&
915
+ args.depth !== undefined &&
916
+ args.maxDepth !== args.depth
917
+ ) {
918
+ throw new Error(
919
+ "VALIDATION: maxDepth and depth are aliases and must match when both are provided"
920
+ );
921
+ }
922
+
923
+ const result = await diagnoseGraphQuery(ctx.store, args.ref, {
924
+ direction: args.direction ?? "both",
925
+ edgeType: edgeTypeValue || relationValue || undefined,
926
+ maxDepth: args.maxDepth ?? args.depth,
927
+ maxNodes: args.maxNodes,
928
+ frontierLimit: args.frontierLimit,
929
+ visitedLimit: args.visitedLimit,
930
+ contentTypeRules: normalizeContentTypes(ctx.config.contentTypes ?? [])
931
+ .rules,
932
+ });
933
+ if (!result.success) {
934
+ throw new Error(
935
+ `${result.isValidation ? "VALIDATION" : "RUNTIME"}: ${result.error}`
936
+ );
937
+ }
938
+
939
+ return result.data;
940
+ },
941
+ formatGraphQueryResult
942
+ );
943
+ }
944
+
863
945
  export function handleGraphNeighbors(
864
946
  args: GraphNeighborsInput,
865
947
  ctx: ToolContext
@@ -10,7 +10,7 @@ import type { DocumentRow, StorePort } from "../../store/types";
10
10
  import type { ToolContext } from "../server";
11
11
 
12
12
  import { decorateUriForIndex, parseUri } from "../../app/constants";
13
- import { parseRef } from "../../cli/commands/ref-parser";
13
+ import { parseRef } from "../../core/ref-parser";
14
14
  import { runTool, type ToolResult } from "./index";
15
15
 
16
16
  interface MultiGetInput {
@@ -11,6 +11,7 @@ import type {
11
11
  GenerationPort,
12
12
  RerankPort,
13
13
  } from "../../llm/types";
14
+ import type { QueryDiagnoseResult } from "../../pipeline/diagnose";
14
15
  import type {
15
16
  QueryModeInput,
16
17
  SearchResult,
@@ -20,11 +21,16 @@ import type { ToolContext } from "../server";
20
21
 
21
22
  import { decorateUriForIndex, parseUri } from "../../app/constants";
22
23
  import { createNonTtyProgressRenderer } from "../../cli/progress";
24
+ import {
25
+ fingerprintContentTypeRules,
26
+ normalizeContentTypes,
27
+ } from "../../config";
23
28
  import { resolveDepthPolicy } from "../../core/depth-policy";
24
29
  import { normalizeStructuredQueryInput } from "../../core/structured-query";
25
30
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
26
31
  import { resolveDownloadPolicy } from "../../llm/policy";
27
32
  import { getActivePreset, resolveModelUri } from "../../llm/registry";
33
+ import { diagnoseQueryTarget } from "../../pipeline/diagnose";
28
34
  import { type HybridSearchDeps, searchHybrid } from "../../pipeline/hybrid";
29
35
  import {
30
36
  createVectorIndexPort,
@@ -56,6 +62,10 @@ interface QueryInput {
56
62
  tagsAny?: string[];
57
63
  }
58
64
 
65
+ interface QueryDiagnoseInput extends QueryInput {
66
+ target: string;
67
+ }
68
+
59
69
  /**
60
70
  * Enrich results with absPath derived from each result's URI.
61
71
  */
@@ -128,6 +138,29 @@ function formatSearchResults(data: SearchResults): string {
128
138
  return lines.join("\n");
129
139
  }
130
140
 
141
+ function formatQueryDiagnoseResult(data: QueryDiagnoseResult): string {
142
+ if (data.target.status !== "diagnosed") {
143
+ return `Target ${data.target.ref}: ${data.target.status}`;
144
+ }
145
+
146
+ const lines = [
147
+ `Query diagnose for ${data.target.uri ?? data.target.ref}`,
148
+ `Mode: ${data.meta.mode}, results: ${data.meta.totalResults}`,
149
+ "",
150
+ ];
151
+
152
+ for (const stage of data.stages) {
153
+ const rank = stage.rank === null ? "-" : `#${stage.rank}`;
154
+ const score = stage.score === null ? "-" : Number(stage.score).toFixed(3);
155
+ const reason = stage.dropReason ? `, ${stage.dropReason}` : "";
156
+ lines.push(
157
+ `${stage.id}: ${stage.status}, present=${stage.present}, rank=${rank}, score=${score}${reason}`
158
+ );
159
+ }
160
+
161
+ return lines.join("\n");
162
+ }
163
+
131
164
  /**
132
165
  * Handle gno_query tool call.
133
166
  */
@@ -311,3 +344,177 @@ export function handleQuery(
311
344
  formatSearchResults
312
345
  );
313
346
  }
347
+
348
+ /**
349
+ * Handle gno_query_diagnose tool call.
350
+ */
351
+ export function handleQueryDiagnose(
352
+ args: QueryDiagnoseInput,
353
+ ctx: ToolContext
354
+ ): Promise<ToolResult> {
355
+ return runTool(
356
+ ctx,
357
+ "gno_query_diagnose",
358
+ // oxlint-disable-next-line max-lines-per-function -- mirrors query setup for diagnostic trace ports
359
+ async () => {
360
+ let collection: string | undefined;
361
+ if (args.collection) {
362
+ const canonical = ctx.collections.find(
363
+ (c) => c.name.toLowerCase() === args.collection?.toLowerCase()
364
+ );
365
+ if (!canonical) {
366
+ throw new Error(`Collection not found: ${args.collection}`);
367
+ }
368
+ collection = canonical.name;
369
+ }
370
+
371
+ const normalizedInput = normalizeStructuredQueryInput(
372
+ args.query,
373
+ args.queryModes ?? []
374
+ );
375
+ if (!normalizedInput.ok) {
376
+ throw new Error(normalizedInput.error.message);
377
+ }
378
+ const queryText = normalizedInput.value.query;
379
+ const queryModes =
380
+ normalizedInput.value.queryModes.length > 0
381
+ ? normalizedInput.value.queryModes
382
+ : undefined;
383
+
384
+ const preset = getActivePreset(ctx.config);
385
+ const llm = new LlmAdapter(ctx.config);
386
+ const policy = resolveDownloadPolicy(process.env, {});
387
+ const downloadProgress = createNonTtyProgressRenderer();
388
+
389
+ let embedPort: EmbeddingPort | null = null;
390
+ let expandPort: GenerationPort | null = null;
391
+ let rerankPort: RerankPort | null = null;
392
+ let vectorIndex: VectorIndexPort | null = null;
393
+ const embedUri = resolveModelUri(
394
+ ctx.config,
395
+ "embed",
396
+ undefined,
397
+ collection
398
+ );
399
+
400
+ try {
401
+ const hasStructuredModes = Boolean(queryModes?.length);
402
+ const depthPolicy = resolveDepthPolicy({
403
+ presetId: preset.id,
404
+ fast: args.fast,
405
+ thorough: args.thorough,
406
+ expand: args.expand,
407
+ rerank: args.rerank,
408
+ candidateLimit: args.candidateLimit,
409
+ hasStructuredModes,
410
+ });
411
+ const { noExpand, noRerank } = depthPolicy;
412
+
413
+ if (!args.fast) {
414
+ const embedResult = await llm.createEmbeddingPort(embedUri, {
415
+ policy,
416
+ onProgress: (progress) => downloadProgress("embed", progress),
417
+ });
418
+ if (embedResult.ok) {
419
+ embedPort = embedResult.value;
420
+ }
421
+ }
422
+
423
+ if (!noExpand && !hasStructuredModes) {
424
+ const genResult = await llm.createExpansionPort(
425
+ resolveModelUri(ctx.config, "expand", undefined, collection),
426
+ {
427
+ policy,
428
+ onProgress: (progress) => downloadProgress("expand", progress),
429
+ }
430
+ );
431
+ if (genResult.ok) {
432
+ expandPort = genResult.value;
433
+ }
434
+ }
435
+
436
+ if (!noRerank) {
437
+ const rerankResult = await llm.createRerankPort(
438
+ resolveModelUri(ctx.config, "rerank", undefined, collection),
439
+ {
440
+ policy,
441
+ onProgress: (progress) => downloadProgress("rerank", progress),
442
+ }
443
+ );
444
+ if (rerankResult.ok) {
445
+ rerankPort = rerankResult.value;
446
+ }
447
+ }
448
+
449
+ if (embedPort) {
450
+ const embedInitResult = await embedPort.init();
451
+ if (embedInitResult.ok) {
452
+ const dimensions = embedPort.dimensions();
453
+ const db = ctx.store.getRawDb();
454
+ const vectorResult = await createVectorIndexPort(db, {
455
+ model: embedUri,
456
+ dimensions,
457
+ });
458
+ if (vectorResult.ok) {
459
+ vectorIndex = vectorResult.value;
460
+ }
461
+ }
462
+ }
463
+
464
+ const deps: HybridSearchDeps = {
465
+ store: ctx.store,
466
+ config: ctx.config,
467
+ vectorIndex,
468
+ embedPort,
469
+ expandPort,
470
+ rerankPort,
471
+ };
472
+ const contentTypeRules = normalizeContentTypes(
473
+ ctx.config.contentTypes ?? []
474
+ ).rules;
475
+
476
+ const result = await diagnoseQueryTarget(deps, queryText, {
477
+ target: args.target,
478
+ limit: args.limit ?? 5,
479
+ minScore: args.minScore,
480
+ collection,
481
+ queryLanguageHint: args.lang,
482
+ intent: args.intent,
483
+ candidateLimit: depthPolicy.candidateLimit,
484
+ exclude: args.exclude,
485
+ since: args.since,
486
+ until: args.until,
487
+ categories: args.categories,
488
+ author: args.author,
489
+ noExpand,
490
+ noRerank,
491
+ graph: args.graph === true,
492
+ noGraph: args.noGraph || args.fast,
493
+ queryModes,
494
+ tagsAll: normalizeTagFilters(args.tagsAll),
495
+ tagsAny: normalizeTagFilters(args.tagsAny),
496
+ contentTypeRules,
497
+ contentTypeRulesFingerprint:
498
+ fingerprintContentTypeRules(contentTypeRules),
499
+ });
500
+
501
+ if (!result.ok) {
502
+ throw new Error(result.error.message);
503
+ }
504
+
505
+ return result.value;
506
+ } finally {
507
+ if (embedPort) {
508
+ await embedPort.dispose();
509
+ }
510
+ if (expandPort) {
511
+ await expandPort.dispose();
512
+ }
513
+ if (rerankPort) {
514
+ await rerankPort.dispose();
515
+ }
516
+ }
517
+ },
518
+ formatQueryDiagnoseResult
519
+ );
520
+ }