@openephemeris/mcp-server 4.8.1 → 4.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.
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,57 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [4.10.0] — 2026-08-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **BaZi tools never forwarded `timezone` to the API.** `parseBaziArgs` used it
|
|
14
|
+
only to read a zoned `datetime` back into local calendar components, then
|
|
15
|
+
dropped it — every BaZi request from MCP (`chinese_bazi`, `bazi_ten_gods`,
|
|
16
|
+
`bazi_element_balance`, `bazi_luck_pillars`, `bazi_chart`,
|
|
17
|
+
`explore_bazi_chart`, `bazi_compatibility`) landed on the API with no zone,
|
|
18
|
+
which resolves the year/month solar-term boundary (Li Chun, the Jié) against
|
|
19
|
+
the naive local clock instead of the true birth instant — exactly the defect
|
|
20
|
+
[#532](https://github.com/openephemeris/openephemeris/pull/532) fixes at the
|
|
21
|
+
API. A birth within roughly the birthplace's UTC offset of a boundary could
|
|
22
|
+
land on the wrong side and get the wrong year or month pillar. `timezone` is
|
|
23
|
+
now forwarded on every BaZi tool.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **BaZi convention parameters.** `minute`, `year_boundary`
|
|
27
|
+
(`lichun`/`cny`), `day_boundary` (`zi_hour`/`midnight`), `true_solar_time`,
|
|
28
|
+
and `latitude`/`longitude` are now request parameters on every BaZi tool
|
|
29
|
+
that accepts them at the API — `bazi_compatibility` takes them per chart
|
|
30
|
+
(`chart_a_*`/`chart_b_*`), since partners are often born under different
|
|
31
|
+
conventions or in different timezones.
|
|
32
|
+
|
|
33
|
+
## [4.9.0] — 2026-08-03
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- **`location_search` gave pre-1970 birthplaces the wrong UTC offset.** With a
|
|
37
|
+
`date` before 1970 it answered from local tzdata alone — and tzdata models
|
|
38
|
+
only a zone's *reference city* before 1970, which is exactly the error the
|
|
39
|
+
API's historical correction overlay exists to remove. Dallas on 1952-07-15
|
|
40
|
+
came back `-05:00` (Chicago observed daylight time that summer; Texas did
|
|
41
|
+
not) instead of the correct `-06:00`. Because this tool is the documented
|
|
42
|
+
one-call alternative to `timezone_resolve`, an agent that took the top match
|
|
43
|
+
and built a zone-suffixed datetime from it produced a chart an hour off with
|
|
44
|
+
nothing downstream able to detect it. The top match is now corrected through
|
|
45
|
+
the API (1 extra credit, pre-1970 only); remaining matches keep their tzdata
|
|
46
|
+
estimate and stay labelled `historical_estimate`. Post-1970 is unchanged and
|
|
47
|
+
still free.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
- **Historical-correction provenance now reaches MCP consumers.**
|
|
51
|
+
`timezone_resolve` and `location_search` forwarded only `tzRuleSource` out of
|
|
52
|
+
the five fields the API publishes. They now also return `tzOverlayVersion`,
|
|
53
|
+
`tzOverlayHash`, `tzRuleCitation` and `serverVersion`. The first two are the
|
|
54
|
+
change signal — without them a consumer cannot tell that the correction
|
|
55
|
+
dataset moved underneath charts it already computed. `tzRuleCitation` carries
|
|
56
|
+
the actual primary source behind a correction (a statute reference, an agency
|
|
57
|
+
order, a dated almanac page), so a corrected time can show its work instead of
|
|
58
|
+
asking to be trusted. Keys are omitted rather than emitted as nulls when the
|
|
59
|
+
API sends none.
|
|
60
|
+
|
|
10
61
|
## [4.8.1] — 2026-08-02
|
|
11
62
|
|
|
12
63
|
### Fixed
|
|
@@ -25,7 +25,7 @@ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
|
25
25
|
// One BaZi component parser, shared with the specialized tools. The local copy
|
|
26
26
|
// this replaces used `new Date(naive)`, which resolves in the host process's
|
|
27
27
|
// timezone and silently shifted the hour pillar on any non-UTC server.
|
|
28
|
-
import { parseBaziArgs } from "../specialized/bazi.js";
|
|
28
|
+
import { parseBaziArgs, buildBaziConventionFields, CONVENTION_PROPERTIES } from "../specialized/bazi.js";
|
|
29
29
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
30
30
|
export const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
|
|
31
31
|
export const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
|
|
@@ -66,10 +66,11 @@ export function clearBaziBundleCache() {
|
|
|
66
66
|
* Go-rendered SVG (bazi.RenderBaziChartSVG), same shape the strict
|
|
67
67
|
* /chinese/bazi handler returns with a `visual` key attached.
|
|
68
68
|
*/
|
|
69
|
-
async function fetchBaziChart(components, theme = "dark") {
|
|
69
|
+
async function fetchBaziChart(components, theme = "dark", conventionFields = {}) {
|
|
70
70
|
const client = getActiveClient();
|
|
71
71
|
const body = {
|
|
72
72
|
...components,
|
|
73
|
+
...conventionFields,
|
|
73
74
|
include_visual: true,
|
|
74
75
|
visual_config: { format: "svg", theme, size: 800 },
|
|
75
76
|
};
|
|
@@ -129,6 +130,10 @@ registerTool({
|
|
|
129
130
|
description: "Birth hour (0–23). Optional, defaults to 12 (noon). " +
|
|
130
131
|
"Chinese shí hours are 2-hour blocks — precision within a 2-hour window is sufficient.",
|
|
131
132
|
},
|
|
133
|
+
minute: {
|
|
134
|
+
type: "integer",
|
|
135
|
+
description: "Birth minute (0–59). Optional, defaults to 0. Only matters under true_solar_time or near a boundary.",
|
|
136
|
+
},
|
|
132
137
|
datetime: {
|
|
133
138
|
type: "string",
|
|
134
139
|
description: "Alternative to year/month/day: ISO 8601 datetime read as LOCAL wall-clock time at the " +
|
|
@@ -137,9 +142,10 @@ registerTool({
|
|
|
137
142
|
},
|
|
138
143
|
timezone: {
|
|
139
144
|
type: "string",
|
|
140
|
-
description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'.
|
|
141
|
-
"datetime
|
|
145
|
+
description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'. Required to convert a zoned " +
|
|
146
|
+
"datetime back to local clock; also frames the year/month solar-term boundary.",
|
|
142
147
|
},
|
|
148
|
+
...CONVENTION_PROPERTIES,
|
|
143
149
|
},
|
|
144
150
|
additionalProperties: false,
|
|
145
151
|
},
|
|
@@ -159,17 +165,18 @@ registerTool({
|
|
|
159
165
|
},
|
|
160
166
|
handler: async (args) => {
|
|
161
167
|
const components = parseBaziArgs(args);
|
|
168
|
+
const conventionFields = buildBaziConventionFields(args);
|
|
162
169
|
const bundleAvailable = Boolean(getBaziBundle());
|
|
163
170
|
const theme = "dark";
|
|
164
171
|
if (!bundleAvailable) {
|
|
165
172
|
// Text-fallback hosts have no use for the SVG — skip the visual surcharge.
|
|
166
173
|
const data = await getActiveClient().request("POST", "/chinese/bazi", {
|
|
167
|
-
data: components,
|
|
174
|
+
data: { ...components, ...conventionFields },
|
|
168
175
|
});
|
|
169
176
|
const payload = buildModelPayload(data, components, theme);
|
|
170
177
|
return { content: [{ type: "text", text: buildSummary(payload) }] };
|
|
171
178
|
}
|
|
172
|
-
const data = await fetchBaziChart(components, theme);
|
|
179
|
+
const data = await fetchBaziChart(components, theme, conventionFields);
|
|
173
180
|
const payload = buildModelPayload(data, components, theme);
|
|
174
181
|
const summary = buildSummary(payload);
|
|
175
182
|
// MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
|
|
@@ -2,6 +2,34 @@ import { registerTool, validateRequired } from "../index.js";
|
|
|
2
2
|
import { getActiveClient } from "../../backend/client.js";
|
|
3
3
|
import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
|
|
4
4
|
import { TZDATA_AUTHORITATIVE_FROM_YEAR } from "../datetime-historical.js";
|
|
5
|
+
/**
|
|
6
|
+
* Lift the API's historical-correction provenance onto an MCP result.
|
|
7
|
+
*
|
|
8
|
+
* The server publishes five fields that say *why* a pre-1970 offset is what it
|
|
9
|
+
* is, and until now this server forwarded exactly one of them. That left an
|
|
10
|
+
* agent unable to tell a cited statutory correction from a tzdata guess, and
|
|
11
|
+
* left every MCP consumer blind to the overlay dataset changing underneath
|
|
12
|
+
* previously-computed charts — which is the whole reason the version signal
|
|
13
|
+
* exists.
|
|
14
|
+
*
|
|
15
|
+
* Placement mirrors the REST contract: `tz_overlay_version`/`tz_overlay_hash`
|
|
16
|
+
* sit at the top level of the /timezone/offset response alongside
|
|
17
|
+
* `tz_rule_source`; `tz_rule_citation` and `server_version` live in
|
|
18
|
+
* `calculation_metadata`.
|
|
19
|
+
*/
|
|
20
|
+
function historicalProvenance(off) {
|
|
21
|
+
const meta = (off?.calculation_metadata ?? {});
|
|
22
|
+
const citation = meta.tz_rule_citation;
|
|
23
|
+
const serverVersion = meta.server_version;
|
|
24
|
+
return {
|
|
25
|
+
...(typeof off.tz_rule_source === "string" ? { tzRuleSource: off.tz_rule_source } : {}),
|
|
26
|
+
...(typeof off.datetime_status === "string" ? { datetimeStatus: off.datetime_status } : {}),
|
|
27
|
+
...(typeof off.tz_overlay_version === "number" ? { tzOverlayVersion: off.tz_overlay_version } : {}),
|
|
28
|
+
...(typeof off.tz_overlay_hash === "string" ? { tzOverlayHash: off.tz_overlay_hash } : {}),
|
|
29
|
+
...(typeof citation === "string" && citation !== "" ? { tzRuleCitation: citation } : {}),
|
|
30
|
+
...(typeof serverVersion === "string" && serverVersion !== "" ? { serverVersion } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
5
33
|
/** Render minutes east of UTC as `±HH:MM`. */
|
|
6
34
|
function formatOffsetMinutes(offsetMinutes) {
|
|
7
35
|
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
@@ -98,7 +126,7 @@ function offsetAtLocalNoon(timezone, date) {
|
|
|
98
126
|
}
|
|
99
127
|
registerTool({
|
|
100
128
|
name: "location_search",
|
|
101
|
-
description: "Resolve a place name to coordinates and IANA timezone — use this first whenever a user gives a birth city rather than latitude/longitude. NEVER recall coordinates from memory; always resolve them here. CREDIT COST: 1 credit per call. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Pass the birth date as `date` to also get `utcOffsetAtDate`, the historically-correct UTC offset for that place on that date (1987 DST rules differ from today's)
|
|
129
|
+
description: "Resolve a place name to coordinates and IANA timezone — use this first whenever a user gives a birth city rather than latitude/longitude. NEVER recall coordinates from memory; always resolve them here. CREDIT COST: 1 credit per call. Returns display name, region (state/province), latitude, longitude, and IANA timezone. Pass the birth date as `date` to also get `utcOffsetAtDate`, the historically-correct UTC offset for that place on that date (1987 DST rules differ from today's). Post-1970 dates resolve locally and cost no extra credits; a pre-1970 date consults the API's historical correction overlay for the top match (1 extra credit) and returns its provenance — tzRuleSource, tzRuleCitation, tzOverlayVersion. When the result is `ambiguous` (several places share the name, e.g. \"portland\"), ASK the user which one they mean rather than assuming the first. Optional bias params (country/region/near) improve ranking; a trailing \"City, ST\" qualifier in the query is also honored.",
|
|
102
130
|
inputSchema: {
|
|
103
131
|
type: "object",
|
|
104
132
|
properties: {
|
|
@@ -124,7 +152,7 @@ registerTool({
|
|
|
124
152
|
},
|
|
125
153
|
date: {
|
|
126
154
|
type: "string",
|
|
127
|
-
description: "Optional birth/event date as 'YYYY-MM-DD'. When given, each suggestion also carries utcOffsetAtDate / utcOffsetMinutes / isDst — the UTC offset that actually applied at that place on that date, using historical DST rules.
|
|
155
|
+
description: "Optional birth/event date as 'YYYY-MM-DD'. When given, each suggestion also carries utcOffsetAtDate / utcOffsetMinutes / isDst — the UTC offset that actually applied at that place on that date, using historical DST rules. Post-1970 is free (local resolution, no API call). Pre-1970, the top match is additionally corrected through the API's historical overlay (1 extra credit) because tzdata models only the zone's reference city before 1970; the remaining suggestions keep their tzdata estimate and are labelled historical_estimate.",
|
|
128
156
|
},
|
|
129
157
|
},
|
|
130
158
|
required: ["query"],
|
|
@@ -178,6 +206,41 @@ registerTool({
|
|
|
178
206
|
}
|
|
179
207
|
return mapped;
|
|
180
208
|
});
|
|
209
|
+
// Pre-1970, the tzdata offsets just attached are the very answers the
|
|
210
|
+
// historical-correction overlay exists to replace: a 1961 Robbinsdale
|
|
211
|
+
// date gets -05:00 from tzdata where state law says -06:00. This tool
|
|
212
|
+
// is documented as the one-call alternative to timezone_resolve, so an
|
|
213
|
+
// agent that takes the top hit and builds a zone-suffixed datetime from
|
|
214
|
+
// it would produce a chart an hour off — and nothing downstream could
|
|
215
|
+
// detect it.
|
|
216
|
+
//
|
|
217
|
+
// Correct the TOP suggestion through the server (one extra credit, the
|
|
218
|
+
// same cost timezone_resolve documents). Correcting all eight would
|
|
219
|
+
// multiply that for rows the caller almost never uses; the remaining
|
|
220
|
+
// rows keep their tzdata offsets and their historical_estimate label.
|
|
221
|
+
const topHit = suggestions[0];
|
|
222
|
+
if (preTzdataEra && date && topHit && typeof topHit.latitude === "number" && typeof topHit.longitude === "number") {
|
|
223
|
+
try {
|
|
224
|
+
const off = (await getActiveClient().request("POST", "/timezone/offset", {
|
|
225
|
+
data: { lat: topHit.latitude, lon: topHit.longitude, datetime_local: `${date.slice(0, 10)}T12:00:00` },
|
|
226
|
+
}));
|
|
227
|
+
if (typeof off?.offset_seconds === "number") {
|
|
228
|
+
const minutes = off.offset_seconds / 60;
|
|
229
|
+
Object.assign(topHit, {
|
|
230
|
+
utcOffsetAtDate: formatOffsetMinutes(minutes),
|
|
231
|
+
utcOffsetMinutes: minutes,
|
|
232
|
+
isDst: Boolean(off.is_dst),
|
|
233
|
+
tzConfidence: typeof off.tz_confidence === "string" ? off.tz_confidence : "historical_estimate",
|
|
234
|
+
...historicalProvenance(off),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Server path unavailable — the tzdata estimate stands, still
|
|
240
|
+
// labelled historical_estimate. Degrading to a labelled
|
|
241
|
+
// estimate is acceptable; failing the whole search is not.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
181
244
|
// Ambiguity signal. "portland" really does match Oregon, Maine, Texas,
|
|
182
245
|
// England, ... — an agent that silently takes suggestions[0] produces a
|
|
183
246
|
// chart nothing downstream can detect as wrong.
|
|
@@ -272,8 +335,7 @@ registerTool({
|
|
|
272
335
|
isDst: Boolean(off.is_dst),
|
|
273
336
|
date,
|
|
274
337
|
tzConfidence: typeof off.tz_confidence === "string" ? off.tz_confidence : "historical_estimate",
|
|
275
|
-
...(
|
|
276
|
-
...(typeof off.datetime_status === "string" ? { datetimeStatus: off.datetime_status } : {}),
|
|
338
|
+
...historicalProvenance(off),
|
|
277
339
|
};
|
|
278
340
|
}
|
|
279
341
|
}
|
|
@@ -3,5 +3,43 @@ export interface BaziComponents {
|
|
|
3
3
|
month: number;
|
|
4
4
|
day: number;
|
|
5
5
|
hour?: number;
|
|
6
|
+
minute?: number;
|
|
6
7
|
}
|
|
7
8
|
export declare function parseBaziArgs(args: any, datetimeField?: string): BaziComponents;
|
|
9
|
+
/**
|
|
10
|
+
* Build the optional BaZi convention fields (year_boundary, day_boundary,
|
|
11
|
+
* true_solar_time, latitude, longitude, timezone) to spread into a request
|
|
12
|
+
* body. `timezone` is wrapped to the API's `TimezoneInput` object shape.
|
|
13
|
+
*
|
|
14
|
+
* Kept separate from `parseBaziArgs`/`BaziComponents`: these fields frame
|
|
15
|
+
* which convention resolves the pillars rather than naming the birth moment
|
|
16
|
+
* itself, and bazi_compatibility needs the same set built twice under a
|
|
17
|
+
* `chart_a_`/`chart_b_` prefix.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildBaziConventionFields(args: any): Record<string, unknown>;
|
|
20
|
+
/** `buildBaziConventionFields`, reading `<prefix>_<field>` args — used by bazi_compatibility. */
|
|
21
|
+
export declare function buildBaziConventionFieldsPrefixed(args: any, prefix: string): Record<string, unknown>;
|
|
22
|
+
export declare const CONVENTION_PROPERTIES: {
|
|
23
|
+
readonly year_boundary: {
|
|
24
|
+
readonly type: "string";
|
|
25
|
+
readonly enum: readonly ["lichun", "cny"];
|
|
26
|
+
readonly description: "Year-pillar cutover convention. Default lichun.";
|
|
27
|
+
};
|
|
28
|
+
readonly day_boundary: {
|
|
29
|
+
readonly type: "string";
|
|
30
|
+
readonly enum: readonly ["zi_hour", "midnight"];
|
|
31
|
+
readonly description: "Day-pillar cutover convention. Default zi_hour.";
|
|
32
|
+
};
|
|
33
|
+
readonly true_solar_time: {
|
|
34
|
+
readonly type: "boolean";
|
|
35
|
+
readonly description: "Apply true solar time correction (needs latitude+longitude).";
|
|
36
|
+
};
|
|
37
|
+
readonly latitude: {
|
|
38
|
+
readonly type: "number";
|
|
39
|
+
readonly description: "Birth latitude, decimal degrees (north+).";
|
|
40
|
+
};
|
|
41
|
+
readonly longitude: {
|
|
42
|
+
readonly type: "number";
|
|
43
|
+
readonly description: "Birth longitude, decimal degrees (east+).";
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -2,7 +2,7 @@ import { registerTool } from "../index.js";
|
|
|
2
2
|
import { getActiveClient } from "../../backend/client.js";
|
|
3
3
|
import { OUTPUT_SCHEMA_IMAGE_AND_JSON } from "../output-schemas.js";
|
|
4
4
|
export function parseBaziArgs(args, datetimeField = "datetime") {
|
|
5
|
-
let { year, month, day, hour } = args;
|
|
5
|
+
let { year, month, day, hour, minute } = args;
|
|
6
6
|
const datetime = args[datetimeField];
|
|
7
7
|
if (datetime && (!year || !month || !day)) {
|
|
8
8
|
const parts = baziLocalComponents(datetime, args.timezone, datetimeField);
|
|
@@ -11,6 +11,8 @@ export function parseBaziArgs(args, datetimeField = "datetime") {
|
|
|
11
11
|
day = parts.day;
|
|
12
12
|
if (hour == null)
|
|
13
13
|
hour = parts.hour;
|
|
14
|
+
if (minute == null)
|
|
15
|
+
minute = parts.minute;
|
|
14
16
|
}
|
|
15
17
|
if (!year || !month || !day) {
|
|
16
18
|
throw new Error("Provide year/month/day fields, or a datetime ISO string. " +
|
|
@@ -19,8 +21,48 @@ export function parseBaziArgs(args, datetimeField = "datetime") {
|
|
|
19
21
|
const out = { year, month, day };
|
|
20
22
|
if (hour != null)
|
|
21
23
|
out.hour = hour;
|
|
24
|
+
if (minute != null)
|
|
25
|
+
out.minute = minute;
|
|
22
26
|
return out;
|
|
23
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Build the optional BaZi convention fields (year_boundary, day_boundary,
|
|
30
|
+
* true_solar_time, latitude, longitude, timezone) to spread into a request
|
|
31
|
+
* body. `timezone` is wrapped to the API's `TimezoneInput` object shape.
|
|
32
|
+
*
|
|
33
|
+
* Kept separate from `parseBaziArgs`/`BaziComponents`: these fields frame
|
|
34
|
+
* which convention resolves the pillars rather than naming the birth moment
|
|
35
|
+
* itself, and bazi_compatibility needs the same set built twice under a
|
|
36
|
+
* `chart_a_`/`chart_b_` prefix.
|
|
37
|
+
*/
|
|
38
|
+
export function buildBaziConventionFields(args) {
|
|
39
|
+
const fields = {};
|
|
40
|
+
if (args.year_boundary != null)
|
|
41
|
+
fields.year_boundary = args.year_boundary;
|
|
42
|
+
if (args.day_boundary != null)
|
|
43
|
+
fields.day_boundary = args.day_boundary;
|
|
44
|
+
if (args.true_solar_time != null)
|
|
45
|
+
fields.true_solar_time = args.true_solar_time;
|
|
46
|
+
if (args.latitude != null)
|
|
47
|
+
fields.latitude = args.latitude;
|
|
48
|
+
if (args.longitude != null)
|
|
49
|
+
fields.longitude = args.longitude;
|
|
50
|
+
if (typeof args.timezone === "string" && args.timezone.trim() !== "") {
|
|
51
|
+
fields.timezone = { iana_name: args.timezone };
|
|
52
|
+
}
|
|
53
|
+
return fields;
|
|
54
|
+
}
|
|
55
|
+
/** `buildBaziConventionFields`, reading `<prefix>_<field>` args — used by bazi_compatibility. */
|
|
56
|
+
export function buildBaziConventionFieldsPrefixed(args, prefix) {
|
|
57
|
+
return buildBaziConventionFields({
|
|
58
|
+
year_boundary: args[`${prefix}_year_boundary`],
|
|
59
|
+
day_boundary: args[`${prefix}_day_boundary`],
|
|
60
|
+
true_solar_time: args[`${prefix}_true_solar_time`],
|
|
61
|
+
latitude: args[`${prefix}_latitude`],
|
|
62
|
+
longitude: args[`${prefix}_longitude`],
|
|
63
|
+
timezone: args[`${prefix}_timezone`],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
24
66
|
/**
|
|
25
67
|
* Extract the LOCAL calendar/clock components a BaZi chart is built from.
|
|
26
68
|
*
|
|
@@ -50,7 +92,7 @@ function baziLocalComponents(datetime, timezone, field) {
|
|
|
50
92
|
hour: "2-digit", minute: "2-digit",
|
|
51
93
|
}).formatToParts(new Date(value));
|
|
52
94
|
const get = (t) => Number(parts.find((p) => p.type === t)?.value);
|
|
53
|
-
return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour") % 24 };
|
|
95
|
+
return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour") % 24, minute: get("minute") };
|
|
54
96
|
}
|
|
55
97
|
const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(value);
|
|
56
98
|
if (!m) {
|
|
@@ -62,6 +104,7 @@ function baziLocalComponents(datetime, timezone, field) {
|
|
|
62
104
|
month: Number(m[2]),
|
|
63
105
|
day: Number(m[3]),
|
|
64
106
|
hour: m[4] != null ? Number(m[4]) : undefined,
|
|
107
|
+
minute: m[5] != null ? Number(m[5]) : undefined,
|
|
65
108
|
};
|
|
66
109
|
}
|
|
67
110
|
// Shared datetime input schema fragment — used across all BaZiRequest tools.
|
|
@@ -83,6 +126,10 @@ const DATETIME_PROPERTIES = {
|
|
|
83
126
|
description: "Birth hour (0–23). Optional, defaults to 12 (noon). " +
|
|
84
127
|
"Chinese shí hours are 2-hour blocks — precision within a 2-hour window is sufficient.",
|
|
85
128
|
},
|
|
129
|
+
minute: {
|
|
130
|
+
type: "integer",
|
|
131
|
+
description: "Birth minute (0–59). Optional, defaults to 0. Only matters under true_solar_time or near a boundary.",
|
|
132
|
+
},
|
|
86
133
|
datetime: {
|
|
87
134
|
type: "string",
|
|
88
135
|
description: "Alternative to year/month/day: ISO 8601 datetime read as LOCAL wall-clock time at the " +
|
|
@@ -93,11 +140,49 @@ const DATETIME_PROPERTIES = {
|
|
|
93
140
|
},
|
|
94
141
|
timezone: {
|
|
95
142
|
type: "string",
|
|
96
|
-
description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'.
|
|
97
|
-
"`datetime`
|
|
98
|
-
"
|
|
143
|
+
description: "IANA timezone name for the birth place, e.g. 'Asia/Shanghai'. Required to convert a " +
|
|
144
|
+
"zoned `datetime` back to local clock; also passed to the API to frame the year/month " +
|
|
145
|
+
"solar-term boundary at the true birth instant instead of naive local clock.",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
// Optional convention fields, shared across every BaZiRequest-shaped tool —
|
|
149
|
+
// see BaZiRequest in the Go API. Kept terse: core tool-surface budget is
|
|
150
|
+
// near its ceiling (test/tool-surface-budget.test.ts).
|
|
151
|
+
export const CONVENTION_PROPERTIES = {
|
|
152
|
+
year_boundary: {
|
|
153
|
+
type: "string",
|
|
154
|
+
enum: ["lichun", "cny"],
|
|
155
|
+
description: "Year-pillar cutover convention. Default lichun.",
|
|
156
|
+
},
|
|
157
|
+
day_boundary: {
|
|
158
|
+
type: "string",
|
|
159
|
+
enum: ["zi_hour", "midnight"],
|
|
160
|
+
description: "Day-pillar cutover convention. Default zi_hour.",
|
|
161
|
+
},
|
|
162
|
+
true_solar_time: {
|
|
163
|
+
type: "boolean",
|
|
164
|
+
description: "Apply true solar time correction (needs latitude+longitude).",
|
|
165
|
+
},
|
|
166
|
+
latitude: {
|
|
167
|
+
type: "number",
|
|
168
|
+
description: "Birth latitude, decimal degrees (north+).",
|
|
169
|
+
},
|
|
170
|
+
longitude: {
|
|
171
|
+
type: "number",
|
|
172
|
+
description: "Birth longitude, decimal degrees (east+).",
|
|
99
173
|
},
|
|
100
174
|
};
|
|
175
|
+
/** CONVENTION_PROPERTIES (+ minute) under a `chart_a_`/`chart_b_` prefix — used by bazi_compatibility. */
|
|
176
|
+
function chartConventionProperties(label) {
|
|
177
|
+
return {
|
|
178
|
+
[`chart_${label.toLowerCase()}_minute`]: { type: "integer", description: `Chart ${label} minute (0–59).` },
|
|
179
|
+
[`chart_${label.toLowerCase()}_year_boundary`]: { type: "string", enum: ["lichun", "cny"], description: `Chart ${label} year-pillar cutover.` },
|
|
180
|
+
[`chart_${label.toLowerCase()}_day_boundary`]: { type: "string", enum: ["zi_hour", "midnight"], description: `Chart ${label} day-pillar cutover.` },
|
|
181
|
+
[`chart_${label.toLowerCase()}_true_solar_time`]: { type: "boolean", description: `Chart ${label}: true solar time correction.` },
|
|
182
|
+
[`chart_${label.toLowerCase()}_latitude`]: { type: "number", description: `Chart ${label} latitude (decimal degrees, north+).` },
|
|
183
|
+
[`chart_${label.toLowerCase()}_longitude`]: { type: "number", description: `Chart ${label} longitude (decimal degrees, east+).` },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
101
186
|
// Shared visual input schema fragment — mirrors natal.ts pattern.
|
|
102
187
|
const VISUAL_PROPERTIES = {
|
|
103
188
|
include_visual: {
|
|
@@ -153,6 +238,7 @@ registerTool({
|
|
|
153
238
|
type: "object",
|
|
154
239
|
properties: {
|
|
155
240
|
...DATETIME_PROPERTIES,
|
|
241
|
+
...CONVENTION_PROPERTIES,
|
|
156
242
|
...VISUAL_PROPERTIES,
|
|
157
243
|
},
|
|
158
244
|
additionalProperties: false,
|
|
@@ -160,7 +246,7 @@ registerTool({
|
|
|
160
246
|
outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
|
|
161
247
|
annotations: { title: "BaZi Four Pillars Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
162
248
|
handler: async (args) => {
|
|
163
|
-
const body = { ...parseBaziArgs(args) };
|
|
249
|
+
const body = { ...parseBaziArgs(args), ...buildBaziConventionFields(args) };
|
|
164
250
|
applyVisualConfig(body, args);
|
|
165
251
|
return await getActiveClient().request("POST", "/chinese/bazi", { data: body });
|
|
166
252
|
},
|
|
@@ -190,6 +276,7 @@ registerTool({
|
|
|
190
276
|
type: "object",
|
|
191
277
|
properties: {
|
|
192
278
|
...DATETIME_PROPERTIES,
|
|
279
|
+
...CONVENTION_PROPERTIES,
|
|
193
280
|
...VISUAL_PROPERTIES,
|
|
194
281
|
},
|
|
195
282
|
additionalProperties: false,
|
|
@@ -197,7 +284,7 @@ registerTool({
|
|
|
197
284
|
outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
|
|
198
285
|
annotations: { title: "BaZi Ten Gods", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
199
286
|
handler: async (args) => {
|
|
200
|
-
const body = { ...parseBaziArgs(args) };
|
|
287
|
+
const body = { ...parseBaziArgs(args), ...buildBaziConventionFields(args) };
|
|
201
288
|
applyVisualConfig(body, args);
|
|
202
289
|
return await getActiveClient().request("POST", "/chinese/bazi/ten-gods", { data: body });
|
|
203
290
|
},
|
|
@@ -225,6 +312,7 @@ registerTool({
|
|
|
225
312
|
type: "object",
|
|
226
313
|
properties: {
|
|
227
314
|
...DATETIME_PROPERTIES,
|
|
315
|
+
...CONVENTION_PROPERTIES,
|
|
228
316
|
...VISUAL_PROPERTIES,
|
|
229
317
|
},
|
|
230
318
|
additionalProperties: false,
|
|
@@ -232,7 +320,7 @@ registerTool({
|
|
|
232
320
|
outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
|
|
233
321
|
annotations: { title: "BaZi Element Balance", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
234
322
|
handler: async (args) => {
|
|
235
|
-
const body = { ...parseBaziArgs(args) };
|
|
323
|
+
const body = { ...parseBaziArgs(args), ...buildBaziConventionFields(args) };
|
|
236
324
|
applyVisualConfig(body, args);
|
|
237
325
|
return await getActiveClient().request("POST", "/chinese/bazi/element-balance", { data: body });
|
|
238
326
|
},
|
|
@@ -261,6 +349,7 @@ registerTool({
|
|
|
261
349
|
type: "object",
|
|
262
350
|
properties: {
|
|
263
351
|
...DATETIME_PROPERTIES,
|
|
352
|
+
...CONVENTION_PROPERTIES,
|
|
264
353
|
gender: {
|
|
265
354
|
type: "string",
|
|
266
355
|
enum: ["male", "female"],
|
|
@@ -279,10 +368,15 @@ registerTool({
|
|
|
279
368
|
throw new Error("gender is required for luck pillar calculation. " +
|
|
280
369
|
"Provide 'male' or 'female' — the direction of Da Yun depends on gender and year polarity.");
|
|
281
370
|
}
|
|
282
|
-
const { year, month, day, hour } = parseBaziArgs(args);
|
|
283
|
-
const body = {
|
|
371
|
+
const { year, month, day, hour, minute } = parseBaziArgs(args);
|
|
372
|
+
const body = {
|
|
373
|
+
year, month, day, gender: args.gender,
|
|
374
|
+
...buildBaziConventionFields(args),
|
|
375
|
+
};
|
|
284
376
|
if (hour != null)
|
|
285
377
|
body.hour = hour;
|
|
378
|
+
if (minute != null)
|
|
379
|
+
body.minute = minute;
|
|
286
380
|
return await getActiveClient().request("POST", "/chinese/bazi/luck-pillars", { data: body });
|
|
287
381
|
},
|
|
288
382
|
});
|
|
@@ -362,9 +456,10 @@ registerTool({
|
|
|
362
456
|
},
|
|
363
457
|
chart_a_timezone: {
|
|
364
458
|
type: "string",
|
|
365
|
-
description: "IANA timezone for Chart A's birth place, e.g. 'Asia/Shanghai'.
|
|
366
|
-
"chart_a_datetime carries a 'Z' or ±HH:MM offset.",
|
|
459
|
+
description: "IANA timezone for Chart A's birth place, e.g. 'Asia/Shanghai'. Needed when " +
|
|
460
|
+
"chart_a_datetime carries a 'Z' or ±HH:MM offset; also frames Chart A's year/month boundary.",
|
|
367
461
|
},
|
|
462
|
+
...chartConventionProperties("A"),
|
|
368
463
|
// Chart B
|
|
369
464
|
chart_b_year: { type: "integer", description: "Chart B birth year." },
|
|
370
465
|
chart_b_month: { type: "integer", description: "Chart B birth month (1–12)." },
|
|
@@ -378,9 +473,10 @@ registerTool({
|
|
|
378
473
|
},
|
|
379
474
|
chart_b_timezone: {
|
|
380
475
|
type: "string",
|
|
381
|
-
description: "IANA timezone for Chart B's birth place, e.g. 'Asia/Shanghai'.
|
|
382
|
-
"chart_b_datetime carries a 'Z' or ±HH:MM offset.",
|
|
476
|
+
description: "IANA timezone for Chart B's birth place, e.g. 'Asia/Shanghai'. Needed when " +
|
|
477
|
+
"chart_b_datetime carries a 'Z' or ±HH:MM offset; also frames Chart B's year/month boundary.",
|
|
383
478
|
},
|
|
479
|
+
...chartConventionProperties("B"),
|
|
384
480
|
},
|
|
385
481
|
additionalProperties: false,
|
|
386
482
|
},
|
|
@@ -392,6 +488,7 @@ registerTool({
|
|
|
392
488
|
month: args.chart_a_month,
|
|
393
489
|
day: args.chart_a_day,
|
|
394
490
|
hour: args.chart_a_hour,
|
|
491
|
+
minute: args.chart_a_minute,
|
|
395
492
|
datetime: args.chart_a_datetime,
|
|
396
493
|
timezone: args.chart_a_timezone,
|
|
397
494
|
});
|
|
@@ -400,13 +497,14 @@ registerTool({
|
|
|
400
497
|
month: args.chart_b_month,
|
|
401
498
|
day: args.chart_b_day,
|
|
402
499
|
hour: args.chart_b_hour,
|
|
500
|
+
minute: args.chart_b_minute,
|
|
403
501
|
datetime: args.chart_b_datetime,
|
|
404
502
|
timezone: args.chart_b_timezone,
|
|
405
503
|
});
|
|
406
504
|
return await getActiveClient().request("POST", "/chinese/bazi/compatibility", {
|
|
407
505
|
data: {
|
|
408
|
-
chart_a: chartAComponents,
|
|
409
|
-
chart_b: chartBComponents,
|
|
506
|
+
chart_a: { ...chartAComponents, ...buildBaziConventionFieldsPrefixed(args, "chart_a") },
|
|
507
|
+
chart_b: { ...chartBComponents, ...buildBaziConventionFieldsPrefixed(args, "chart_b") },
|
|
410
508
|
},
|
|
411
509
|
});
|
|
412
510
|
},
|
|
@@ -434,6 +532,7 @@ registerTool({
|
|
|
434
532
|
type: "object",
|
|
435
533
|
properties: {
|
|
436
534
|
...DATETIME_PROPERTIES,
|
|
535
|
+
...CONVENTION_PROPERTIES,
|
|
437
536
|
theme: {
|
|
438
537
|
type: "string",
|
|
439
538
|
enum: ["light", "dark", "mono"],
|
|
@@ -449,7 +548,7 @@ registerTool({
|
|
|
449
548
|
outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
|
|
450
549
|
annotations: { title: "BaZi Chart (Visual)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
451
550
|
handler: async (args) => {
|
|
452
|
-
const { year, month, day, hour } = parseBaziArgs(args);
|
|
551
|
+
const { year, month, day, hour, minute } = parseBaziArgs(args);
|
|
453
552
|
const body = {
|
|
454
553
|
year, month, day,
|
|
455
554
|
visual_config: {
|
|
@@ -457,9 +556,12 @@ registerTool({
|
|
|
457
556
|
theme: args.theme ?? "light",
|
|
458
557
|
size: args.size ?? 800,
|
|
459
558
|
},
|
|
559
|
+
...buildBaziConventionFields(args),
|
|
460
560
|
};
|
|
461
561
|
if (hour != null)
|
|
462
562
|
body.hour = hour;
|
|
563
|
+
if (minute != null)
|
|
564
|
+
body.minute = minute;
|
|
463
565
|
return await getActiveClient().request("POST", "/chinese/bazi/chart", { data: body });
|
|
464
566
|
},
|
|
465
567
|
});
|