@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.
- package/LICENSE +201 -0
- package/README.md +232 -9
- package/dist/src/certification/admin-parity.d.ts +96 -0
- package/dist/src/certification/admin-parity.d.ts.map +1 -0
- package/dist/src/certification/admin-parity.js +235 -0
- package/dist/src/certification/census-fixture-client.d.ts.map +1 -1
- package/dist/src/certification/census-fixture-client.js +5 -0
- package/dist/src/certification/certifier.d.ts +1 -1
- package/dist/src/certification/certifier.d.ts.map +1 -1
- package/dist/src/certification/certifier.js +28 -20
- package/dist/src/certification/cli.js +1 -1
- package/dist/src/certification/cql2.d.ts +24 -0
- package/dist/src/certification/cql2.d.ts.map +1 -0
- package/dist/src/certification/cql2.js +354 -0
- package/dist/src/certification/fixtures.d.ts +8 -0
- package/dist/src/certification/fixtures.d.ts.map +1 -1
- package/dist/src/certification/fixtures.js +26 -10
- package/dist/src/certification/ogc-data.d.ts +30 -0
- package/dist/src/certification/ogc-data.d.ts.map +1 -0
- package/dist/src/certification/ogc-data.js +726 -0
- package/dist/src/certification/ogc-fixture-client.d.ts +4 -0
- package/dist/src/certification/ogc-fixture-client.d.ts.map +1 -0
- package/dist/src/certification/ogc-fixture-client.js +190 -0
- package/dist/src/certification/target.d.ts +10 -1
- package/dist/src/certification/target.d.ts.map +1 -1
- package/dist/src/certification/target.js +63 -28
- package/dist/src/credential-endpoint.d.ts +3 -0
- package/dist/src/credential-endpoint.d.ts.map +1 -0
- package/dist/src/credential-endpoint.js +24 -0
- package/dist/src/eval/cli.js +6 -3
- package/dist/src/eval/corpus.d.ts +7 -0
- package/dist/src/eval/corpus.d.ts.map +1 -1
- package/dist/src/eval/corpus.js +12 -0
- package/dist/src/eval/ogc-corpus.d.ts +3 -0
- package/dist/src/eval/ogc-corpus.d.ts.map +1 -0
- package/dist/src/eval/ogc-corpus.js +437 -0
- package/dist/src/eval/runner.d.ts +6 -0
- package/dist/src/eval/runner.d.ts.map +1 -1
- package/dist/src/eval/runner.js +21 -7
- package/dist/src/helpers.d.ts +8 -13
- package/dist/src/helpers.d.ts.map +1 -1
- package/dist/src/helpers.js +7 -35
- package/dist/src/index.d.ts +36 -8
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +55 -18
- package/dist/src/neutral/errors.d.ts +73 -0
- package/dist/src/neutral/errors.d.ts.map +1 -0
- package/dist/src/neutral/errors.js +112 -0
- package/dist/src/neutral/filter.d.ts +108 -0
- package/dist/src/neutral/filter.d.ts.map +1 -0
- package/dist/src/neutral/filter.js +271 -0
- package/dist/src/neutral/query.d.ts +101 -0
- package/dist/src/neutral/query.d.ts.map +1 -0
- package/dist/src/neutral/query.js +180 -0
- package/dist/src/neutral/result.d.ts +33 -0
- package/dist/src/neutral/result.d.ts.map +1 -0
- package/dist/src/neutral/result.js +38 -0
- package/dist/src/neutral/source-ref.d.ts +126 -0
- package/dist/src/neutral/source-ref.d.ts.map +1 -0
- package/dist/src/neutral/source-ref.js +267 -0
- package/dist/src/nl-map-control.d.ts.map +1 -1
- package/dist/src/proxy.d.ts.map +1 -1
- package/dist/src/proxy.js +29 -14
- package/dist/src/release/zero-to-map-checkpoint.d.ts +34 -0
- package/dist/src/release/zero-to-map-checkpoint.d.ts.map +1 -0
- package/dist/src/release/zero-to-map-checkpoint.js +369 -0
- package/dist/src/release/zero-to-map-cli.d.ts +51 -0
- package/dist/src/release/zero-to-map-cli.d.ts.map +1 -0
- package/dist/src/release/zero-to-map-cli.js +880 -0
- package/dist/src/release/zero-to-map-provision.d.ts +37 -0
- package/dist/src/release/zero-to-map-provision.d.ts.map +1 -0
- package/dist/src/release/zero-to-map-provision.js +177 -0
- package/dist/src/release/zero-to-map.d.ts +296 -0
- package/dist/src/release/zero-to-map.d.ts.map +1 -0
- package/dist/src/release/zero-to-map.js +1069 -0
- package/dist/src/tools/admin-install-local.d.ts +30 -0
- package/dist/src/tools/admin-install-local.d.ts.map +1 -0
- package/dist/src/tools/admin-install-local.js +22 -0
- package/dist/src/tools/control-plane-command.d.ts +99 -0
- package/dist/src/tools/control-plane-command.d.ts.map +1 -0
- package/dist/src/tools/control-plane-command.js +180 -0
- package/dist/src/tools/count-features.d.ts +85 -11
- package/dist/src/tools/count-features.d.ts.map +1 -1
- package/dist/src/tools/count-features.js +49 -31
- package/dist/src/tools/describe-layer.d.ts +30 -6
- package/dist/src/tools/describe-layer.d.ts.map +1 -1
- package/dist/src/tools/describe-layer.js +99 -8
- package/dist/src/tools/docs-search.d.ts +100 -0
- package/dist/src/tools/docs-search.d.ts.map +1 -0
- package/dist/src/tools/docs-search.js +381 -0
- package/dist/src/tools/get-extent.d.ts +69 -11
- package/dist/src/tools/get-extent.d.ts.map +1 -1
- package/dist/src/tools/get-extent.js +28 -46
- package/dist/src/tools/list-services.d.ts +7 -0
- package/dist/src/tools/list-services.d.ts.map +1 -1
- package/dist/src/tools/list-services.js +8 -1
- package/dist/src/tools/list-sources.d.ts +44 -0
- package/dist/src/tools/list-sources.d.ts.map +1 -0
- package/dist/src/tools/list-sources.js +138 -0
- package/dist/src/tools/query-features.d.ts +99 -19
- package/dist/src/tools/query-features.d.ts.map +1 -1
- package/dist/src/tools/query-features.js +38 -45
- package/dist/src/tools/statistics.d.ts +80 -12
- package/dist/src/tools/statistics.d.ts.map +1 -1
- package/dist/src/tools/statistics.js +56 -23
- package/package.json +37 -8
- package/release/zero-to-map/README.md +174 -0
- package/release/zero-to-map/configs/claude-code.mcp.json +11 -0
- package/release/zero-to-map/configs/claude-desktop.json +11 -0
- package/release/zero-to-map/configs/cursor.json +11 -0
- package/release/zero-to-map/contracts/aws-ecs-provision-binding.schema.json +59 -0
- package/release/zero-to-map/contracts/checkpoint.schema.json +152 -0
- package/release/zero-to-map/contracts/console-receipt.schema.json +150 -0
- package/release/zero-to-map/contracts/esri-gp-mcp.v1.json +73 -0
- package/release/zero-to-map/fixtures/parcels.geojson +33 -0
- package/release/zero-to-map/fixtures/zoning.geojson +24 -0
- 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
|
-
|
|
5
|
-
layerId: z.number().int().nonnegative().describe("The layer ID within the service"),
|
|
17
|
+
...sourceRefFields,
|
|
6
18
|
});
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
}
|