@honua/mcp-server 0.1.2-beta.0 → 0.1.8-beta.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 (117) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +232 -9
  3. package/dist/src/certification/admin-parity.d.ts +96 -0
  4. package/dist/src/certification/admin-parity.d.ts.map +1 -0
  5. package/dist/src/certification/admin-parity.js +235 -0
  6. package/dist/src/certification/census-fixture-client.d.ts.map +1 -1
  7. package/dist/src/certification/census-fixture-client.js +5 -0
  8. package/dist/src/certification/certifier.d.ts +1 -1
  9. package/dist/src/certification/certifier.d.ts.map +1 -1
  10. package/dist/src/certification/certifier.js +28 -20
  11. package/dist/src/certification/cli.js +1 -1
  12. package/dist/src/certification/cql2.d.ts +24 -0
  13. package/dist/src/certification/cql2.d.ts.map +1 -0
  14. package/dist/src/certification/cql2.js +354 -0
  15. package/dist/src/certification/fixtures.d.ts +8 -0
  16. package/dist/src/certification/fixtures.d.ts.map +1 -1
  17. package/dist/src/certification/fixtures.js +26 -10
  18. package/dist/src/certification/ogc-data.d.ts +30 -0
  19. package/dist/src/certification/ogc-data.d.ts.map +1 -0
  20. package/dist/src/certification/ogc-data.js +726 -0
  21. package/dist/src/certification/ogc-fixture-client.d.ts +4 -0
  22. package/dist/src/certification/ogc-fixture-client.d.ts.map +1 -0
  23. package/dist/src/certification/ogc-fixture-client.js +190 -0
  24. package/dist/src/certification/target.d.ts +10 -1
  25. package/dist/src/certification/target.d.ts.map +1 -1
  26. package/dist/src/certification/target.js +63 -28
  27. package/dist/src/credential-endpoint.d.ts +3 -0
  28. package/dist/src/credential-endpoint.d.ts.map +1 -0
  29. package/dist/src/credential-endpoint.js +24 -0
  30. package/dist/src/eval/cli.js +6 -3
  31. package/dist/src/eval/corpus.d.ts +7 -0
  32. package/dist/src/eval/corpus.d.ts.map +1 -1
  33. package/dist/src/eval/corpus.js +12 -0
  34. package/dist/src/eval/ogc-corpus.d.ts +3 -0
  35. package/dist/src/eval/ogc-corpus.d.ts.map +1 -0
  36. package/dist/src/eval/ogc-corpus.js +437 -0
  37. package/dist/src/eval/runner.d.ts +6 -0
  38. package/dist/src/eval/runner.d.ts.map +1 -1
  39. package/dist/src/eval/runner.js +21 -7
  40. package/dist/src/helpers.d.ts +8 -13
  41. package/dist/src/helpers.d.ts.map +1 -1
  42. package/dist/src/helpers.js +7 -35
  43. package/dist/src/index.d.ts +36 -8
  44. package/dist/src/index.d.ts.map +1 -1
  45. package/dist/src/index.js +55 -18
  46. package/dist/src/neutral/errors.d.ts +73 -0
  47. package/dist/src/neutral/errors.d.ts.map +1 -0
  48. package/dist/src/neutral/errors.js +112 -0
  49. package/dist/src/neutral/filter.d.ts +108 -0
  50. package/dist/src/neutral/filter.d.ts.map +1 -0
  51. package/dist/src/neutral/filter.js +271 -0
  52. package/dist/src/neutral/query.d.ts +101 -0
  53. package/dist/src/neutral/query.d.ts.map +1 -0
  54. package/dist/src/neutral/query.js +180 -0
  55. package/dist/src/neutral/result.d.ts +33 -0
  56. package/dist/src/neutral/result.d.ts.map +1 -0
  57. package/dist/src/neutral/result.js +38 -0
  58. package/dist/src/neutral/source-ref.d.ts +126 -0
  59. package/dist/src/neutral/source-ref.d.ts.map +1 -0
  60. package/dist/src/neutral/source-ref.js +267 -0
  61. package/dist/src/nl-map-control.d.ts.map +1 -1
  62. package/dist/src/proxy.d.ts.map +1 -1
  63. package/dist/src/proxy.js +29 -14
  64. package/dist/src/release/zero-to-map-checkpoint.d.ts +34 -0
  65. package/dist/src/release/zero-to-map-checkpoint.d.ts.map +1 -0
  66. package/dist/src/release/zero-to-map-checkpoint.js +369 -0
  67. package/dist/src/release/zero-to-map-cli.d.ts +51 -0
  68. package/dist/src/release/zero-to-map-cli.d.ts.map +1 -0
  69. package/dist/src/release/zero-to-map-cli.js +880 -0
  70. package/dist/src/release/zero-to-map-provision.d.ts +37 -0
  71. package/dist/src/release/zero-to-map-provision.d.ts.map +1 -0
  72. package/dist/src/release/zero-to-map-provision.js +177 -0
  73. package/dist/src/release/zero-to-map.d.ts +296 -0
  74. package/dist/src/release/zero-to-map.d.ts.map +1 -0
  75. package/dist/src/release/zero-to-map.js +1069 -0
  76. package/dist/src/tools/admin-install-local.d.ts +30 -0
  77. package/dist/src/tools/admin-install-local.d.ts.map +1 -0
  78. package/dist/src/tools/admin-install-local.js +22 -0
  79. package/dist/src/tools/control-plane-command.d.ts +99 -0
  80. package/dist/src/tools/control-plane-command.d.ts.map +1 -0
  81. package/dist/src/tools/control-plane-command.js +180 -0
  82. package/dist/src/tools/count-features.d.ts +85 -11
  83. package/dist/src/tools/count-features.d.ts.map +1 -1
  84. package/dist/src/tools/count-features.js +49 -31
  85. package/dist/src/tools/describe-layer.d.ts +30 -6
  86. package/dist/src/tools/describe-layer.d.ts.map +1 -1
  87. package/dist/src/tools/describe-layer.js +99 -8
  88. package/dist/src/tools/docs-search.d.ts +100 -0
  89. package/dist/src/tools/docs-search.d.ts.map +1 -0
  90. package/dist/src/tools/docs-search.js +381 -0
  91. package/dist/src/tools/get-extent.d.ts +69 -11
  92. package/dist/src/tools/get-extent.d.ts.map +1 -1
  93. package/dist/src/tools/get-extent.js +28 -46
  94. package/dist/src/tools/list-services.d.ts +7 -0
  95. package/dist/src/tools/list-services.d.ts.map +1 -1
  96. package/dist/src/tools/list-services.js +8 -1
  97. package/dist/src/tools/list-sources.d.ts +44 -0
  98. package/dist/src/tools/list-sources.d.ts.map +1 -0
  99. package/dist/src/tools/list-sources.js +138 -0
  100. package/dist/src/tools/query-features.d.ts +99 -19
  101. package/dist/src/tools/query-features.d.ts.map +1 -1
  102. package/dist/src/tools/query-features.js +38 -45
  103. package/dist/src/tools/statistics.d.ts +80 -12
  104. package/dist/src/tools/statistics.d.ts.map +1 -1
  105. package/dist/src/tools/statistics.js +56 -23
  106. package/package.json +37 -8
  107. package/release/zero-to-map/README.md +174 -0
  108. package/release/zero-to-map/configs/claude-code.mcp.json +11 -0
  109. package/release/zero-to-map/configs/claude-desktop.json +11 -0
  110. package/release/zero-to-map/configs/cursor.json +11 -0
  111. package/release/zero-to-map/contracts/aws-ecs-provision-binding.schema.json +59 -0
  112. package/release/zero-to-map/contracts/checkpoint.schema.json +152 -0
  113. package/release/zero-to-map/contracts/console-receipt.schema.json +150 -0
  114. package/release/zero-to-map/contracts/esri-gp-mcp.v1.json +73 -0
  115. package/release/zero-to-map/fixtures/parcels.geojson +33 -0
  116. package/release/zero-to-map/fixtures/zoning.geojson +24 -0
  117. package/release/zero-to-map/journey.v1.json +1248 -0
@@ -1,19 +1,110 @@
1
1
  import { z } from "zod";
2
2
  import { jsonText } from "../helpers.js";
3
+ import { withCapabilityHonesty } from "../neutral/errors.js";
4
+ import { ogcLayoutFor, resolveSource, sourceRefFields } from "../neutral/source-ref.js";
5
+ /**
6
+ * `honua_describe_layer` — protocol-neutral source description (#1005).
7
+ *
8
+ * The envelope is the same for every protocol: the neutral source reference,
9
+ * its protocol, the capabilities the runtime will actually honor, and a field
10
+ * schema. Where the schema comes from is protocol-specific (GeoServices layer
11
+ * metadata, an OGC collection + its queryables); the shape the agent reads is
12
+ * not. Protocols with no metadata document report `schemaAvailable: false`
13
+ * with a reason instead of an empty field list, which would read as "this
14
+ * source has no attributes".
15
+ */
3
16
  export const schema = z.object({
4
- serviceId: z.string().describe("The feature service ID"),
5
- layerId: z.number().int().nonnegative().describe("The layer ID within the service"),
17
+ ...sourceRefFields,
6
18
  });
7
- export async function execute(client, input) {
8
- const meta = await client.getLayerMetadata(input.serviceId, input.layerId);
9
- return jsonText({
10
- id: meta.id,
11
- name: meta.name,
19
+ async function describeGeoServices(client, resolved) {
20
+ const { serviceId, layerId } = resolved.descriptor.locator;
21
+ const meta = await client.getLayerMetadata(serviceId, layerId);
22
+ return {
23
+ name: meta.name ?? null,
12
24
  description: meta.description ?? null,
13
25
  geometryType: meta.geometryType ?? null,
14
- fields: (meta.fields ?? []).map((f) => ({ name: f.name, type: f.type, alias: f.alias ?? f.name })),
26
+ fields: (meta.fields ?? []).map((f) => ({ name: f.name, type: f.type ?? null, alias: f.alias ?? f.name })),
15
27
  extent: meta.extent ?? null,
16
28
  spatialReference: meta.spatialReference ?? null,
17
29
  relationships: meta.relationships ?? [],
30
+ schemaAvailable: true,
31
+ };
32
+ }
33
+ function bboxToExtent(bbox) {
34
+ if (!bbox || bbox.length < 4)
35
+ return null;
36
+ const [xmin, ymin, xmax, ymax] = bbox;
37
+ return { xmin, ymin, xmax, ymax };
38
+ }
39
+ async function describeOgcCollection(client, resolved) {
40
+ const collectionId = resolved.descriptor.locator.collectionId;
41
+ const layout = await ogcLayoutFor(client, resolved.ref.layout);
42
+ const request = { collectionId, ...(layout ? { layout } : {}) };
43
+ const meta = resolved.descriptor.protocol === "ogc-records"
44
+ ? await client.getOgcRecordCollection(request)
45
+ : await client.getOgcCollection(request);
46
+ let fields = [];
47
+ let schemaAvailable = true;
48
+ let schemaReason;
49
+ try {
50
+ const queryables = await client.getOgcQueryables(request);
51
+ fields = Object.entries(queryables.properties ?? {}).map(([name, property]) => ({
52
+ name,
53
+ type: property.type ?? null,
54
+ alias: property.title ?? name,
55
+ }));
56
+ if (fields.length === 0) {
57
+ schemaAvailable = false;
58
+ schemaReason = "the collection advertises no queryable properties.";
59
+ }
60
+ }
61
+ catch (error) {
62
+ schemaAvailable = false;
63
+ schemaReason = `queryables are not published by this endpoint: ${error instanceof Error ? error.message : String(error)}`;
64
+ }
65
+ const temporal = meta.extent?.temporal?.interval?.[0];
66
+ return {
67
+ name: meta.title ?? meta.id ?? collectionId,
68
+ description: meta.description ?? null,
69
+ geometryType: null,
70
+ fields,
71
+ extent: bboxToExtent(meta.extent?.spatial?.bbox?.[0]),
72
+ spatialReference: meta.extent?.spatial?.crs ?? meta.crs?.[0] ?? null,
73
+ relationships: [],
74
+ ...(temporal ? { timeField: null } : {}),
75
+ schemaAvailable,
76
+ ...(schemaReason ? { schemaReason } : {}),
77
+ };
78
+ }
79
+ function describeFromDescriptorOnly(resolved) {
80
+ return {
81
+ name: resolved.ref.address,
82
+ description: null,
83
+ geometryType: null,
84
+ fields: [],
85
+ extent: null,
86
+ spatialReference: null,
87
+ relationships: [],
88
+ schemaAvailable: false,
89
+ schemaReason: `protocol "${resolved.descriptor.protocol}" publishes no schema document this server can read without a discovery pass; query the source and inspect the returned attributes instead.`,
90
+ };
91
+ }
92
+ export async function execute(client, input) {
93
+ return withCapabilityHonesty(async () => {
94
+ const resolved = resolveSource(client, input);
95
+ const protocol = resolved.descriptor.protocol;
96
+ const envelope = protocol === "geoservices-feature-service" || protocol === "geoservices-map-service" || protocol === "grpc"
97
+ ? await describeGeoServices(client, resolved)
98
+ : protocol === "ogc-features" || protocol === "ogc-records"
99
+ ? await describeOgcCollection(client, resolved)
100
+ : describeFromDescriptorOnly(resolved);
101
+ return jsonText({
102
+ source: resolved.ref.ref,
103
+ protocol,
104
+ capabilities: [...resolved.source.capabilities].sort(),
105
+ ...envelope,
106
+ // Retained for clients written against the pre-#1005 shape.
107
+ id: resolved.descriptor.locator.layerId ?? resolved.ref.address,
108
+ });
18
109
  });
19
110
  }
@@ -0,0 +1,100 @@
1
+ import { z } from "zod";
2
+ export declare const schema: z.ZodObject<{
3
+ query: z.ZodString;
4
+ limit: z.ZodOptional<z.ZodNumber>;
5
+ pathPrefix: z.ZodOptional<z.ZodString>;
6
+ }, "strip", z.ZodTypeAny, {
7
+ query: string;
8
+ limit?: number | undefined;
9
+ pathPrefix?: string | undefined;
10
+ }, {
11
+ query: string;
12
+ limit?: number | undefined;
13
+ pathPrefix?: string | undefined;
14
+ }>;
15
+ export type Input = z.infer<typeof schema>;
16
+ /** One document sliced out of the concatenated corpus. */
17
+ export interface CorpusDocument {
18
+ file: string;
19
+ title: string;
20
+ body: string;
21
+ }
22
+ export interface DocsVersionCitation {
23
+ package: string;
24
+ version: string;
25
+ channel: string;
26
+ /** Whether the corpus text is trunk development documentation or a release snapshot. */
27
+ documentationChannel: "development";
28
+ policy: string;
29
+ versionsManifest: string;
30
+ }
31
+ /**
32
+ * Split `llms-full.txt` into its constituent documents. The generator emits
33
+ * `# File: <path>` as the delimiter for each concatenated document.
34
+ */
35
+ export declare function splitCorpus(corpus: string): CorpusDocument[];
36
+ /**
37
+ * The terms a query is actually ranked and excerpted on: distinct tokens with
38
+ * the stopwords removed. A query made entirely of stopwords ("how do I") keeps
39
+ * its raw tokens rather than degrading to "no indexed document matched", so
40
+ * filtering can only ever improve a ranking, never empty one.
41
+ */
42
+ export declare function queryTermsOf(query: string): string[];
43
+ /** Deterministic lexical score: path and heading matches outweigh body matches. */
44
+ export declare function scoreDocument(document: CorpusDocument, queryTerms: readonly string[]): number;
45
+ /** The most relevant window of a document, trimmed to a token-friendly size. */
46
+ export declare function excerptFor(document: CorpusDocument, queryTerms: readonly string[]): string;
47
+ /** Where the corpus is, and the documentation root its citations resolve against. */
48
+ export interface DocsCorpusLocation {
49
+ /** The file that is actually read — the configured override when there is one. */
50
+ corpusPath: string;
51
+ /** The directory `docs/versions.json`, `package.json`, and the manifest are read from. */
52
+ root: string;
53
+ }
54
+ /**
55
+ * Locate the corpus: an explicit `HONUA_DOCS_CORPUS_PATH` (the corpus file
56
+ * itself, or a directory holding one), otherwise the nearest ancestor directory
57
+ * holding `llms-full.txt`.
58
+ *
59
+ * The override names the file to read, so it is read — its basename is not
60
+ * assumed to be `llms-full.txt`. Discarding it and reading `<parent>/llms-full.txt`
61
+ * instead would throw `ENOENT` for a corpus under any other name, or silently
62
+ * answer from a different sibling file.
63
+ */
64
+ export declare function resolveDocsCorpus(env?: NodeJS.ProcessEnv, from?: string): DocsCorpusLocation | undefined;
65
+ /** The documentation root holding the corpus, or `undefined` when there is none. */
66
+ export declare function resolveDocsRoot(env?: NodeJS.ProcessEnv, from?: string): string | undefined;
67
+ /**
68
+ * The release the corpus documents, from the same authoritative sources as
69
+ * `scripts/docs-versions.mjs`. Prefers the generated `docs/versions.json` and
70
+ * falls back to `package.json` + `.release-please-manifest.json`.
71
+ */
72
+ export declare function resolveDocsVersion(root: string): DocsVersionCitation | undefined;
73
+ /**
74
+ * The exact commit the searched corpus bytes come from, or `undefined` when it
75
+ * cannot be established.
76
+ *
77
+ * `docs/documentation-versions.md` is explicit that a matching version string
78
+ * is not proof that development documentation describes the npm artifact, and
79
+ * that every built page identifies its 40-character source commit. A citation
80
+ * pinned to `trunk` breaks that: `trunk` moves, and the text a reader is sent
81
+ * to can stop being the text that was searched. So citations are pinned to the
82
+ * commit instead, using the same `HONUA_SOURCE_REVISION` override
83
+ * `scripts/build-docs-site.mjs` honours.
84
+ *
85
+ * The commit is only used when the corpus at `HEAD` is byte-identical to the
86
+ * corpus that was read (`git hash-object` against `HEAD:<path>`). A locally
87
+ * regenerated or edited `llms-full.txt` is not at any commit, and pinning it to
88
+ * `HEAD` would produce a citation that looks exact and is wrong. When nothing
89
+ * can be established — an installed package with no checkout, no `git`, a dirty
90
+ * corpus — the URL stays on `trunk` and the citation reports
91
+ * `sourceRevision: null` so the consumer can see the link is floating.
92
+ */
93
+ export declare function resolveCorpusRevision(root: string, corpusPath: string, env?: NodeJS.ProcessEnv): string | undefined;
94
+ export declare function execute(_client: unknown, input: Input): Promise<{
95
+ content: Array<{
96
+ type: "text";
97
+ text: string;
98
+ }>;
99
+ }>;
100
+ //# sourceMappingURL=docs-search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"docs-search.d.ts","sourceRoot":"","sources":["../../../src/tools/docs-search.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAmHxB,eAAO,MAAM,MAAM;;;;;;;;;;;;EAUjB,CAAC;AAEH,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,MAAM,CAAC,CAAC;AAE3C,0DAA0D;AAC1D,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,wFAAwF;IACxF,oBAAoB,EAAE,aAAa,CAAC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc,EAAE,CAe5D;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAIpD;AAYD,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,QAAQ,EAAE,cAAc,EAAE,UAAU,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAgB7F;AAED,gFAAgF;AAChF,wBAAgB,UAAU,CAAC,QAAQ,EAAE,cAAc,EAAE,UAAU,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAgB1F;AAED,qFAAqF;AACrF,MAAM,WAAW,kBAAkB;IACjC,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAC;IACnB,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS,CAkBrH;AAED,oFAAoF;AACpF,wBAAgB,eAAe,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEvG;AAeD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAoBhF;AAcD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,MAAM,EAClB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,MAAM,GAAG,SAAS,CAepB;AAMD,wBAAsB,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK;;;;;GA4D3D"}
@@ -0,0 +1,381 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { z } from "zod";
6
+ import { capabilityUnavailable } from "../capability.js";
7
+ import { jsonText } from "../helpers.js";
8
+ /**
9
+ * `honua_docs_search` — answer a documentation question from the repository's
10
+ * own versioned documentation corpus, with citations.
11
+ *
12
+ * The corpus is `llms-full.txt` (generated by `npm run docs:llms`), which
13
+ * concatenates the documents indexed in `llms.txt` under `# File: <path>`
14
+ * headers. Every hit therefore carries the exact source file it came from and
15
+ * the commit those bytes are at (so the citation keeps naming the text that was
16
+ * actually searched once `trunk` moves), and the version block carries the
17
+ * release that documentation describes — derived
18
+ * from `docs/versions.json` when it has been generated
19
+ * (`npm run docs:versions:write`) and otherwise directly from `package.json` +
20
+ * `.release-please-manifest.json`, the same authoritative pair
21
+ * `scripts/docs-versions.mjs` uses. The policy those citations follow is
22
+ * documented in `docs/documentation-versions.md`.
23
+ *
24
+ * This is a LOCAL tool: it reads committed files and never makes a network
25
+ * request. When the corpus cannot be located (for example inside an installed
26
+ * `@honua/mcp-server` that does not ship the corpus), it degrades the same way
27
+ * every other surface-dependent tool does — a structured `available: false`
28
+ * result with guidance, not a crash and not a misleading empty answer.
29
+ */
30
+ const REPOSITORY = "honua-io/honua-sdk-js";
31
+ const SITE_URL = "https://honua-io.github.io/honua-sdk-js/";
32
+ const CORPUS_FILE = "llms-full.txt";
33
+ const MAX_EXCERPT_CHARS = 1200;
34
+ const EXCERPT_CONTEXT_LINES = 6;
35
+ const COMMIT_PATTERN = /^[0-9a-f]{40}$/;
36
+ /**
37
+ * Function words carry no retrieval signal but appear in every document, so on
38
+ * a natural-language question ("how do I handle a capability error") they match
39
+ * everywhere, add frequency weight everywhere, and inflate the matched-term
40
+ * multiplier everywhere -- which is exactly enough to push `docs/errors.md`
41
+ * below `docs/guide.md` and out of the default three results. Terms shorter
42
+ * than two characters are already dropped by the tokenizer.
43
+ */
44
+ const STOPWORDS = new Set([
45
+ "about",
46
+ "all",
47
+ "an",
48
+ "and",
49
+ "any",
50
+ "are",
51
+ "as",
52
+ "at",
53
+ "be",
54
+ "been",
55
+ "but",
56
+ "by",
57
+ "can",
58
+ "do",
59
+ "does",
60
+ "doing",
61
+ "done",
62
+ "for",
63
+ "from",
64
+ "get",
65
+ "getting",
66
+ "has",
67
+ "have",
68
+ "how",
69
+ "if",
70
+ "in",
71
+ "into",
72
+ "is",
73
+ "it",
74
+ "its",
75
+ "me",
76
+ "my",
77
+ "not",
78
+ "of",
79
+ "on",
80
+ "onto",
81
+ "or",
82
+ "our",
83
+ "should",
84
+ "so",
85
+ "some",
86
+ "that",
87
+ "the",
88
+ "their",
89
+ "them",
90
+ "then",
91
+ "there",
92
+ "these",
93
+ "they",
94
+ "this",
95
+ "to",
96
+ "up",
97
+ "use",
98
+ "used",
99
+ "using",
100
+ "via",
101
+ "want",
102
+ "was",
103
+ "we",
104
+ "what",
105
+ "when",
106
+ "where",
107
+ "which",
108
+ "who",
109
+ "why",
110
+ "will",
111
+ "with",
112
+ "would",
113
+ "you",
114
+ "your",
115
+ ]);
116
+ export const schema = z.object({
117
+ query: z
118
+ .string()
119
+ .min(2)
120
+ .describe("Natural-language or keyword documentation question, e.g. 'how do I handle a capability error'"),
121
+ limit: z.number().int().min(1).max(10).optional().describe("Maximum documents to return (default 3)"),
122
+ pathPrefix: z
123
+ .string()
124
+ .optional()
125
+ .describe("Restrict results to source files whose path starts with this value, e.g. 'docs/' or 'mcp/'"),
126
+ });
127
+ /**
128
+ * Split `llms-full.txt` into its constituent documents. The generator emits
129
+ * `# File: <path>` as the delimiter for each concatenated document.
130
+ */
131
+ export function splitCorpus(corpus) {
132
+ const documents = [];
133
+ const pattern = /^# File: (.+)$/gm;
134
+ const matches = [...corpus.matchAll(pattern)];
135
+ for (const [index, match] of matches.entries()) {
136
+ const start = (match.index ?? 0) + match[0].length;
137
+ const end = index + 1 < matches.length ? (matches[index + 1].index ?? corpus.length) : corpus.length;
138
+ const body = corpus
139
+ .slice(start, end)
140
+ .replace(/\n-{3,}\s*$/, "")
141
+ .trim();
142
+ const heading = /^#\s+(.+)$/m.exec(body);
143
+ documents.push({ file: match[1].trim(), title: heading?.[1]?.trim() ?? match[1].trim(), body });
144
+ }
145
+ return documents;
146
+ }
147
+ /**
148
+ * The terms a query is actually ranked and excerpted on: distinct tokens with
149
+ * the stopwords removed. A query made entirely of stopwords ("how do I") keeps
150
+ * its raw tokens rather than degrading to "no indexed document matched", so
151
+ * filtering can only ever improve a ranking, never empty one.
152
+ */
153
+ export function queryTermsOf(query) {
154
+ const tokens = [...new Set(query.toLowerCase().match(/[a-z0-9_./-]{2,}/g) ?? [])];
155
+ const significant = tokens.filter((token) => !STOPWORDS.has(token));
156
+ return significant.length > 0 ? significant : tokens;
157
+ }
158
+ function countOccurrences(haystack, needle) {
159
+ let count = 0;
160
+ let index = haystack.indexOf(needle);
161
+ while (index !== -1) {
162
+ count += 1;
163
+ index = haystack.indexOf(needle, index + needle.length);
164
+ }
165
+ return count;
166
+ }
167
+ /** Deterministic lexical score: path and heading matches outweigh body matches. */
168
+ export function scoreDocument(document, queryTerms) {
169
+ const file = document.file.toLowerCase();
170
+ const body = document.body.toLowerCase();
171
+ const headings = (document.body.match(/^#{1,4} .+$/gm) ?? []).join("\n").toLowerCase();
172
+ let score = 0;
173
+ let matched = 0;
174
+ for (const term of queryTerms) {
175
+ const inBody = countOccurrences(body, term);
176
+ const inHeadings = countOccurrences(headings, term);
177
+ const inPath = countOccurrences(file, term);
178
+ if (inBody + inHeadings + inPath > 0)
179
+ matched += 1;
180
+ score += Math.min(inBody, 20) + inHeadings * 3 + inPath * 8;
181
+ }
182
+ // Require more than a single incidental term hit on multi-term queries.
183
+ if (matched === 0)
184
+ return 0;
185
+ return score * matched;
186
+ }
187
+ /** The most relevant window of a document, trimmed to a token-friendly size. */
188
+ export function excerptFor(document, queryTerms) {
189
+ const lines = document.body.split("\n");
190
+ let bestLine = 0;
191
+ let bestHits = 0;
192
+ for (const [index, line] of lines.entries()) {
193
+ const lower = line.toLowerCase();
194
+ const hits = queryTerms.reduce((total, term) => total + countOccurrences(lower, term), 0);
195
+ if (hits > bestHits) {
196
+ bestHits = hits;
197
+ bestLine = index;
198
+ }
199
+ }
200
+ const start = Math.max(0, bestLine - EXCERPT_CONTEXT_LINES);
201
+ const end = Math.min(lines.length, bestLine + EXCERPT_CONTEXT_LINES + 1);
202
+ const excerpt = lines.slice(start, end).join("\n").trim();
203
+ return excerpt.length > MAX_EXCERPT_CHARS ? `${excerpt.slice(0, MAX_EXCERPT_CHARS)}…` : excerpt;
204
+ }
205
+ /**
206
+ * Locate the corpus: an explicit `HONUA_DOCS_CORPUS_PATH` (the corpus file
207
+ * itself, or a directory holding one), otherwise the nearest ancestor directory
208
+ * holding `llms-full.txt`.
209
+ *
210
+ * The override names the file to read, so it is read — its basename is not
211
+ * assumed to be `llms-full.txt`. Discarding it and reading `<parent>/llms-full.txt`
212
+ * instead would throw `ENOENT` for a corpus under any other name, or silently
213
+ * answer from a different sibling file.
214
+ */
215
+ export function resolveDocsCorpus(env = process.env, from) {
216
+ const override = env.HONUA_DOCS_CORPUS_PATH;
217
+ if (override) {
218
+ const resolved = path.resolve(override);
219
+ if (!fs.existsSync(resolved))
220
+ return undefined;
221
+ const corpusPath = fs.statSync(resolved).isDirectory() ? path.join(resolved, CORPUS_FILE) : resolved;
222
+ if (!fs.existsSync(corpusPath))
223
+ return undefined;
224
+ return { corpusPath, root: path.dirname(corpusPath) };
225
+ }
226
+ let dir = from ?? path.dirname(fileURLToPath(import.meta.url));
227
+ for (let depth = 0; depth < 10; depth += 1) {
228
+ const corpusPath = path.join(dir, CORPUS_FILE);
229
+ if (fs.existsSync(corpusPath))
230
+ return { corpusPath, root: dir };
231
+ const parent = path.dirname(dir);
232
+ if (parent === dir)
233
+ break;
234
+ dir = parent;
235
+ }
236
+ return undefined;
237
+ }
238
+ /** The documentation root holding the corpus, or `undefined` when there is none. */
239
+ export function resolveDocsRoot(env = process.env, from) {
240
+ return resolveDocsCorpus(env, from)?.root;
241
+ }
242
+ function readJson(file) {
243
+ try {
244
+ return JSON.parse(fs.readFileSync(file, "utf8"));
245
+ }
246
+ catch {
247
+ return undefined;
248
+ }
249
+ }
250
+ function channelOf(version) {
251
+ const prerelease = version.split("-", 2)[1];
252
+ return prerelease ? prerelease.split(".", 1)[0] : "stable";
253
+ }
254
+ /**
255
+ * The release the corpus documents, from the same authoritative sources as
256
+ * `scripts/docs-versions.mjs`. Prefers the generated `docs/versions.json` and
257
+ * falls back to `package.json` + `.release-please-manifest.json`.
258
+ */
259
+ export function resolveDocsVersion(root) {
260
+ const generated = readJson(path.join(root, "docs", "versions.json"));
261
+ const packageName = typeof generated?.package === "string" ? generated.package : "@honua/sdk-js";
262
+ const citation = (version) => ({
263
+ package: packageName,
264
+ version,
265
+ channel: channelOf(version),
266
+ documentationChannel: "development",
267
+ policy: "docs/documentation-versions.md",
268
+ versionsManifest: `${SITE_URL}versions.json`,
269
+ });
270
+ if (typeof generated?.latestRelease === "string")
271
+ return citation(generated.latestRelease);
272
+ const pkg = readJson(path.join(root, "package.json"));
273
+ const manifest = readJson(path.join(root, ".release-please-manifest.json"));
274
+ const version = typeof pkg?.version === "string" ? pkg.version : undefined;
275
+ const manifestVersion = typeof manifest?.["."] === "string" ? manifest["."] : undefined;
276
+ if (!version || !manifestVersion || version !== manifestVersion)
277
+ return undefined;
278
+ return citation(version);
279
+ }
280
+ function git(root, args) {
281
+ try {
282
+ return execFileSync("git", [...args], {
283
+ cwd: root,
284
+ encoding: "utf8",
285
+ stdio: ["ignore", "pipe", "ignore"],
286
+ }).trim();
287
+ }
288
+ catch {
289
+ return undefined;
290
+ }
291
+ }
292
+ /**
293
+ * The exact commit the searched corpus bytes come from, or `undefined` when it
294
+ * cannot be established.
295
+ *
296
+ * `docs/documentation-versions.md` is explicit that a matching version string
297
+ * is not proof that development documentation describes the npm artifact, and
298
+ * that every built page identifies its 40-character source commit. A citation
299
+ * pinned to `trunk` breaks that: `trunk` moves, and the text a reader is sent
300
+ * to can stop being the text that was searched. So citations are pinned to the
301
+ * commit instead, using the same `HONUA_SOURCE_REVISION` override
302
+ * `scripts/build-docs-site.mjs` honours.
303
+ *
304
+ * The commit is only used when the corpus at `HEAD` is byte-identical to the
305
+ * corpus that was read (`git hash-object` against `HEAD:<path>`). A locally
306
+ * regenerated or edited `llms-full.txt` is not at any commit, and pinning it to
307
+ * `HEAD` would produce a citation that looks exact and is wrong. When nothing
308
+ * can be established — an installed package with no checkout, no `git`, a dirty
309
+ * corpus — the URL stays on `trunk` and the citation reports
310
+ * `sourceRevision: null` so the consumer can see the link is floating.
311
+ */
312
+ export function resolveCorpusRevision(root, corpusPath, env = process.env) {
313
+ const declared = env.HONUA_SOURCE_REVISION?.trim().toLowerCase();
314
+ if (declared && COMMIT_PATTERN.test(declared))
315
+ return declared;
316
+ const top = git(root, ["rev-parse", "--show-toplevel"]);
317
+ if (!top)
318
+ return undefined;
319
+ const head = git(root, ["rev-parse", "HEAD"])?.toLowerCase();
320
+ if (!head || !COMMIT_PATTERN.test(head))
321
+ return undefined;
322
+ const relative = path.relative(top, path.resolve(corpusPath)).split(path.sep).join("/");
323
+ if (relative.startsWith(".."))
324
+ return undefined;
325
+ const committed = git(root, ["rev-parse", `HEAD:${relative}`]);
326
+ const working = git(root, ["hash-object", corpusPath]);
327
+ if (!committed || !working || committed !== working)
328
+ return undefined;
329
+ return head;
330
+ }
331
+ function sourceUrl(file, revision) {
332
+ return `https://github.com/${REPOSITORY}/blob/${revision ?? "trunk"}/${file}`;
333
+ }
334
+ export async function execute(_client, input) {
335
+ const located = resolveDocsCorpus();
336
+ if (!located) {
337
+ return capabilityUnavailable("Honua documentation corpus", `${CORPUS_FILE} was not found in this installation or any parent directory`, "Run `npm run docs:llms` in a honua-sdk-js checkout, or point HONUA_DOCS_CORPUS_PATH at a generated llms-full.txt.");
338
+ }
339
+ const { corpusPath, root } = located;
340
+ const version = resolveDocsVersion(root);
341
+ if (!version) {
342
+ return capabilityUnavailable("Honua documentation versions", "package.json and .release-please-manifest.json disagree or are unavailable, so no release can be cited", "Run `npm run docs:versions:write` in a honua-sdk-js checkout so documentation answers can cite a release.");
343
+ }
344
+ const documents = splitCorpus(fs.readFileSync(corpusPath, "utf8"));
345
+ const revision = resolveCorpusRevision(root, corpusPath);
346
+ const queryTerms = queryTermsOf(input.query);
347
+ const prefix = input.pathPrefix?.toLowerCase();
348
+ const results = documents
349
+ .filter((document) => (prefix ? document.file.toLowerCase().startsWith(prefix) : true))
350
+ .map((document) => ({ document, score: scoreDocument(document, queryTerms) }))
351
+ .filter((entry) => entry.score > 0)
352
+ .sort((a, b) => b.score - a.score || a.document.file.localeCompare(b.document.file))
353
+ .slice(0, input.limit ?? 3)
354
+ .map((entry) => ({
355
+ title: entry.document.title,
356
+ score: entry.score,
357
+ excerpt: excerptFor(entry.document, queryTerms),
358
+ citation: {
359
+ file: entry.document.file,
360
+ url: sourceUrl(entry.document.file, revision),
361
+ // The commit the cited text was read at. `null` means the URL floats on
362
+ // `trunk` and the text there may already differ from what was searched.
363
+ sourceRevision: revision ?? null,
364
+ package: version.package,
365
+ version: version.version,
366
+ channel: version.channel,
367
+ },
368
+ }));
369
+ return jsonText({
370
+ available: true,
371
+ query: input.query,
372
+ release: version,
373
+ corpus: { file: path.basename(corpusPath), documents: documents.length, sourceRevision: revision ?? null },
374
+ results,
375
+ ...(results.length === 0
376
+ ? {
377
+ guidance: "No indexed document matched. Try fewer or more specific terms, or drop pathPrefix. An empty result means the corpus has no matching text, not that the capability is missing.",
378
+ }
379
+ : {}),
380
+ });
381
+ }