@openephemeris/mcp-server 4.17.0 → 4.18.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 (35) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +10 -10
  3. package/dist/backend/client.d.ts +57 -1
  4. package/dist/backend/client.js +125 -15
  5. package/dist/prompts.js +26 -24
  6. package/dist/server-sse.d.ts +18 -0
  7. package/dist/server-sse.js +41 -6
  8. package/dist/tools/apps/_render-token.d.ts +34 -0
  9. package/dist/tools/apps/_render-token.js +50 -0
  10. package/dist/tools/apps/bazi-app.js +36 -12
  11. package/dist/tools/apps/bi-wheel-app.d.ts +9 -2
  12. package/dist/tools/apps/bi-wheel-app.js +76 -117
  13. package/dist/tools/apps/bodygraph-app.js +88 -41
  14. package/dist/tools/apps/chart-wheel-app.js +56 -16
  15. package/dist/tools/apps/location-tools.js +10 -3
  16. package/dist/tools/apps/moon-phase-app.js +10 -2
  17. package/dist/tools/apps/transit-timeline-app.js +3 -4
  18. package/dist/tools/apps/vedic-chart-app.js +10 -1
  19. package/dist/tools/datetime-historical.js +7 -2
  20. package/dist/tools/datetime.js +2 -1
  21. package/dist/tools/dev.js +7 -6
  22. package/dist/tools/specialized/electional.js +4 -3
  23. package/dist/tools/specialized/ephemeris_extended.js +19 -4
  24. package/dist/tools/specialized/hd_group.js +2 -2
  25. package/dist/tools/specialized/moon.d.ts +1 -1
  26. package/dist/tools/specialized/moon.js +51 -43
  27. package/dist/tools/specialized/progressed.js +2 -25
  28. package/dist/tools/specialized/transits.js +5 -5
  29. package/dist/ui/bazi.html +1523 -1522
  30. package/dist/ui/bi-wheel.html +406 -374
  31. package/dist/ui/bodygraph.html +83 -79
  32. package/dist/ui/chart-wheel.html +394 -361
  33. package/dist/ui/transit-timeline.html +1 -1
  34. package/dist/ui/vedic-chart.html +818 -818
  35. package/package.json +1 -1
@@ -0,0 +1,50 @@
1
+ /**
2
+ * _render-token.ts — plumbing for the API's server-issued render token.
3
+ *
4
+ * The Go API stamps every charged visual on a render-family route
5
+ * (/human-design/chart + /visualization/bodygraph, /human-design/transit-chart,
6
+ * /human-design/composite, /chinese/bazi, /vedic/chart) with a short-lived
7
+ * token bound to the account and the chart-defining inputs
8
+ * (auth/render_token.go). Presenting it on a re-render of the SAME chart —
9
+ * the iframe's host light/dark reconciliation, a layout flip — makes that
10
+ * re-render free (base charge and visual surcharge). Anything else is charged
11
+ * normally, so passing a stale or foreign token is harmless.
12
+ *
13
+ * Flow: explore_* puts the token on the payload as `_render_token`; the iframe
14
+ * hands it back as the `render_token` argument of its recalc / refetch call;
15
+ * the tool sends it in the X-OE-Render-Token header.
16
+ */
17
+ export const RENDER_TOKEN_HEADER = "X-OE-Render-Token";
18
+ /** Input-schema property for the app-only recalc args. Never set by the model. */
19
+ export const RENDER_TOKEN_PROPERTY = {
20
+ type: "string",
21
+ description: "Set automatically by the embedded app when it re-renders a chart it already paid for " +
22
+ "(e.g. to match the host's light/dark theme). Never pass this yourself.",
23
+ };
24
+ /** Request headers presenting `token`, or undefined when there is none. */
25
+ export function renderTokenHeaders(token) {
26
+ return typeof token === "string" && token.trim() !== ""
27
+ ? { [RENDER_TOKEN_HEADER]: token.trim() }
28
+ : undefined;
29
+ }
30
+ /**
31
+ * The token the API injected on the root <svg> of a /visualization/bodygraph
32
+ * response (a binary route, whose response headers the backend client does
33
+ * not surface).
34
+ */
35
+ export function renderTokenFromSvg(svg) {
36
+ if (!svg)
37
+ return undefined;
38
+ const m = /<svg\b[^>]*\sdata-oe-render-token="([A-Za-z0-9._-]+)"/.exec(svg);
39
+ return m ? m[1] : undefined;
40
+ }
41
+ /** `visual.render_token` from an include_visual JSON response. */
42
+ export function renderTokenFromVisual(visual) {
43
+ const t = visual?.render_token;
44
+ return typeof t === "string" && t !== "" ? t : undefined;
45
+ }
46
+ /** Copy of tool args with the per-call token removed (for `_refetch.args`). */
47
+ export function withoutRenderToken(args) {
48
+ const { render_token: _drop, ...rest } = args;
49
+ return rest;
50
+ }
@@ -26,6 +26,7 @@ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
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
28
  import { parseBaziArgs, buildBaziConventionFields, CONVENTION_PROPERTIES } from "../specialized/bazi.js";
29
+ import { RENDER_TOKEN_PROPERTY, renderTokenFromVisual, renderTokenHeaders } from "./_render-token.js";
29
30
  // ── Constants ─────────────────────────────────────────────────────────────────
30
31
  export const BAZI_RESOURCE_URI = "ui://openephemeris/bazi";
31
32
  export const BAZI_MIME_TYPE = "text/html;profile=mcp-app";
@@ -60,26 +61,39 @@ export function getBaziBundle() {
60
61
  export function clearBaziBundleCache() {
61
62
  cachedBundle = null;
62
63
  }
64
+ const CONVENTION_ARG_KEYS = ["year_boundary", "day_boundary", "true_solar_time", "latitude", "longitude", "timezone"];
65
+ function pickConventionArgs(args) {
66
+ const out = {};
67
+ for (const k of CONVENTION_ARG_KEYS) {
68
+ if (args?.[k] != null && args[k] !== "")
69
+ out[k] = args[k];
70
+ }
71
+ return out;
72
+ }
63
73
  /**
64
74
  * Fetch the BaZi chart via the include_visual intercept — one call returns
65
75
  * both the structured pillar data (year/month/day/hour/day_master) and the
66
76
  * Go-rendered SVG (bazi.RenderBaziChartSVG), same shape the strict
67
77
  * /chinese/bazi handler returns with a `visual` key attached.
68
78
  */
69
- async function fetchBaziChart(components, theme = "dark", conventionFields = {}, reconcile = false) {
79
+ async function fetchBaziChart(components, theme = "dark", conventionFields = {}, renderToken) {
70
80
  const client = getActiveClient();
71
81
  const body = {
72
82
  ...components,
73
83
  ...conventionFields,
74
84
  include_visual: true,
75
85
  visual_config: { format: "svg", theme, size: 800 },
76
- // Set only by bazi_recalculate's theme-reconcile path below — waives the
77
- // visual surcharge server-side for a same-birth-data re-render (#551).
78
- ...(reconcile ? { _visual_reconcile: true } : {}),
79
86
  };
80
- return await client.request("POST", "/chinese/bazi", { data: body });
87
+ // bazi_recalculate's theme reconcile presents the token the first render
88
+ // was issued; the API waives the whole re-render only when it verifies for
89
+ // this account and these exact inputs (the old self-serve
90
+ // `_visual_reconcile` flag is gone).
91
+ return await client.request("POST", "/chinese/bazi", {
92
+ data: body,
93
+ headers: renderTokenHeaders(renderToken),
94
+ });
81
95
  }
82
- function buildModelPayload(data, components, theme) {
96
+ function buildModelPayload(data, components, theme, conventionArgs = {}) {
83
97
  return {
84
98
  year: data.year,
85
99
  month: data.month,
@@ -88,7 +102,9 @@ function buildModelPayload(data, components, theme) {
88
102
  day_master: data.day_master,
89
103
  _svg: data.visual?.data,
90
104
  _birth_params: components,
105
+ ...(Object.keys(conventionArgs).length ? { _convention_args: conventionArgs } : {}),
91
106
  _theme: theme,
107
+ ...(renderTokenFromVisual(data.visual) ? { _render_token: renderTokenFromVisual(data.visual) } : {}),
92
108
  };
93
109
  }
94
110
  function buildSummary(payload) {
@@ -180,7 +196,7 @@ registerTool({
180
196
  return { content: [{ type: "text", text: buildSummary(payload) }] };
181
197
  }
182
198
  const data = await fetchBaziChart(components, theme, conventionFields);
183
- const payload = buildModelPayload(data, components, theme);
199
+ const payload = buildModelPayload(data, components, theme, pickConventionArgs(args));
184
200
  const summary = buildSummary(payload);
185
201
  // MCP Apps wire format: the UI is declared via `_meta.ui.resourceUri` and
186
202
  // delivered through resources/read — NOT as a content block.
@@ -199,9 +215,9 @@ registerTool({
199
215
  name: "bazi_recalculate",
200
216
  description: "Recalculates a BaZi Four Pillars chart with new birth data or theme. " +
201
217
  "App-only: called by the embedded chart itself to reconcile the initial " +
202
- "server-rendered SVG to the host's actual light/dark theme. Billed at 1 " +
203
- "credit (base only) — the visual-render surcharge is waived because this " +
204
- "re-renders already-computed data, not a new chart.",
218
+ "server-rendered SVG to the host's actual light/dark theme. Free when it " +
219
+ "presents the render_token of the chart it re-renders (same birth data and " +
220
+ "conventions); otherwise billed like a new chart (3 credits).",
205
221
  inputSchema: {
206
222
  type: "object",
207
223
  properties: {
@@ -209,11 +225,15 @@ registerTool({
209
225
  month: { type: "integer" },
210
226
  day: { type: "integer" },
211
227
  hour: { type: "integer" },
228
+ minute: { type: "integer" },
229
+ ...CONVENTION_PROPERTIES,
230
+ timezone: { type: "string", description: "IANA timezone name the chart was cast in." },
212
231
  theme: {
213
232
  type: "string",
214
233
  enum: ["light", "dark"],
215
234
  description: "Render palette for the BaZi SVG. Mirrors the MCP host's light/dark color scheme.",
216
235
  },
236
+ render_token: RENDER_TOKEN_PROPERTY,
217
237
  },
218
238
  required: ["year", "month", "day"],
219
239
  },
@@ -223,8 +243,12 @@ registerTool({
223
243
  handler: async (args) => {
224
244
  const components = parseBaziArgs(args);
225
245
  const theme = args.theme === "light" ? "light" : "dark";
226
- const data = await fetchBaziChart(components, theme, {}, true);
227
- const payload = buildModelPayload(data, components, theme);
246
+ // Re-cast under the SAME conventions as the original chart — dropping them
247
+ // (as this tool used to) rendered a different chart on any non-default
248
+ // year/day boundary, and would not match the render token either.
249
+ const conventionArgs = pickConventionArgs(args);
250
+ const data = await fetchBaziChart(components, theme, buildBaziConventionFields(conventionArgs), args.render_token);
251
+ const payload = buildModelPayload(data, components, theme, conventionArgs);
228
252
  return {
229
253
  content: [{ type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) }],
230
254
  };
@@ -12,20 +12,27 @@
12
12
  * Supported modes (BiWheelMode):
13
13
  * synastry — two natal charts (person1 inner, person2 outer)
14
14
  * transit — natal inner + transiting planets outer
15
- * progressed — natal inner + secondary progressions outer (POST /ephemeris/progressed)
15
+ * progressed — natal inner + secondary progressions outer (POST /ephemeris/progressed, method=secondary)
16
16
  * solar_return — natal inner + solar return chart outer (POST /predictive/returns/solar)
17
17
  * lunar_return — natal inner + lunar return chart outer (POST /predictive/returns/lunar)
18
- * solar_arc — natal inner + solar arc directions outer (client-side Naibod approximation)
18
+ * solar_arc — natal inner + solar arc directions outer (POST /ephemeris/progressed, method=solar_arc)
19
19
  *
20
20
  * Cross-aspects are computed client-side (UI) AND server-side (for summary/fallback).
21
21
  * The payload cross_aspects array is capped at 30 tightest-orb aspects.
22
22
  */
23
+ import { getActiveClient } from "../../backend/client.js";
23
24
  export declare const BI_WHEEL_RESOURCE_URI = "ui://openephemeris/bi-wheel";
24
25
  export declare const BI_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
25
26
  /** Read the pre-built HTML bundle. Returns null if not yet built. */
26
27
  export declare function getBiWheelBundle(): string | null;
27
28
  export declare function clearBiWheelBundleCache(): void;
28
29
  export type BiWheelMode = "synastry" | "transit" | "progressed" | "solar_return" | "lunar_return" | "solar_arc";
30
+ /**
31
+ * Route outer-wheel fetch to the correct API endpoint based on mode, and hand
32
+ * back a chart in the natal-chart shape (top-level planets/houses/angles):
33
+ * /ephemeris/progressed nests it under `data`, the return endpoints under `chart`.
34
+ */
35
+ export declare function fetchOuterChart(client: Pick<ReturnType<typeof getActiveClient>, "post">, mode: BiWheelMode, dt1: string, lat1: number | undefined, lon1: number | undefined, tz1: string | undefined, dt2: string, lat2: number | undefined, lon2: number | undefined, tz2: string | undefined, houseSystem?: string): Promise<Record<string, unknown>>;
29
36
  interface PlanetPoint {
30
37
  name: string;
31
38
  longitude: number;
@@ -12,10 +12,10 @@
12
12
  * Supported modes (BiWheelMode):
13
13
  * synastry — two natal charts (person1 inner, person2 outer)
14
14
  * transit — natal inner + transiting planets outer
15
- * progressed — natal inner + secondary progressions outer (POST /ephemeris/progressed)
15
+ * progressed — natal inner + secondary progressions outer (POST /ephemeris/progressed, method=secondary)
16
16
  * solar_return — natal inner + solar return chart outer (POST /predictive/returns/solar)
17
17
  * lunar_return — natal inner + lunar return chart outer (POST /predictive/returns/lunar)
18
- * solar_arc — natal inner + solar arc directions outer (client-side Naibod approximation)
18
+ * solar_arc — natal inner + solar arc directions outer (POST /ephemeris/progressed, method=solar_arc)
19
19
  *
20
20
  * Cross-aspects are computed client-side (UI) AND server-side (for summary/fallback).
21
21
  * The payload cross_aspects array is capped at 30 tightest-orb aspects.
@@ -26,7 +26,7 @@ import { fileURLToPath } from "node:url";
26
26
  import { registerTool, SERVER_VERSION } from "../index.js";
27
27
  import { getActiveClient } from "../../backend/client.js";
28
28
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
29
- import { DATETIME_DESC, timezoneProperty } from "../datetime.js";
29
+ import { DATETIME_DESC, timezoneProperty, toDateTimeInputBody } from "../datetime.js";
30
30
  import { coordsFromArgsOrLocation } from "./_location-resolver.js";
31
31
  // ── Constants ─────────────────────────────────────────────────────────────────
32
32
  export const BI_WHEEL_RESOURCE_URI = "ui://openephemeris/bi-wheel";
@@ -130,120 +130,68 @@ function buildNatalBody(datetime, lat, lon, timezone, houseSystem = "placidus")
130
130
  }
131
131
  return body;
132
132
  }
133
- function buildTransitBody(transitDate, natalDatetime, lat, lon, timezone) {
133
+ function locationBody(lat, lon, timezone) {
134
134
  return {
135
- subject: {
136
- name: "MCP Request",
137
- birth_datetime: { iso: natalDatetime },
138
- birth_location: {
139
- latitude: { decimal: lat ?? 0 },
140
- longitude: { decimal: lon ?? 0 },
141
- },
142
- },
143
- transit_datetime: { iso: transitDate },
135
+ latitude: { decimal: lat },
136
+ longitude: { decimal: lon },
144
137
  ...(timezone ? { timezone: { iana_name: timezone } } : {}),
145
- configuration: { house_system: HOUSE_SYSTEM_MAP["placidus"] },
146
138
  };
147
139
  }
148
- /** POST /ephemeris/progressed — secondary progressions */
149
- function buildProgressedBody(natalDatetime, lat, lon, timezone, targetDatetime) {
150
- const body = {
151
- subject: {
152
- name: "MCP Request",
153
- birth_datetime: { iso: natalDatetime },
154
- birth_location: {
155
- latitude: { decimal: lat ?? 0 },
156
- longitude: { decimal: lon ?? 0 },
157
- },
158
- },
159
- configuration: {
160
- target_datetime: { iso: targetDatetime },
161
- method: "secondary",
162
- house_system: HOUSE_SYSTEM_MAP["placidus"],
163
- },
164
- };
165
- if (timezone) {
166
- body.subject.birth_location.timezone = { iana_name: timezone };
167
- }
140
+ /**
141
+ * POST /ephemeris/progressed. `method` picks secondary progressions or true
142
+ * solar arc directions — the server directs planets, angles AND house cusps by
143
+ * the Sun's real arc (ProgressedChartRequest; target_datetime is top-level,
144
+ * the method rides in progression_options).
145
+ */
146
+ function buildProgressedBody(natalDatetime, lat, lon, timezone, targetDatetime, targetTimezone, method, houseSystem) {
147
+ const body = buildNatalBody(natalDatetime, lat, lon, timezone, houseSystem);
148
+ body.target_datetime = toDateTimeInputBody("person2_datetime", targetDatetime, targetTimezone);
149
+ body.progression_options = { method };
168
150
  return body;
169
151
  }
170
- /** POST /predictive/returns/solar or /predictive/returns/lunar */
171
- function buildReturnBody(natalDatetime, natalLat, natalLon, timezone, targetDatetime, returnLat, returnLon) {
172
- const body = {
173
- subject: {
174
- name: "MCP Request",
175
- birth_datetime: { iso: natalDatetime },
176
- birth_location: {
177
- latitude: { decimal: natalLat ?? 0 },
178
- longitude: { decimal: natalLon ?? 0 },
179
- },
180
- },
181
- return_location: {
182
- latitude: { decimal: returnLat ?? natalLat ?? 0 },
183
- longitude: { decimal: returnLon ?? natalLon ?? 0 },
184
- },
185
- target_datetime: { iso: targetDatetime },
186
- configuration: { house_system: HOUSE_SYSTEM_MAP["placidus"] },
152
+ /**
153
+ * POST /predictive/returns/solar or /predictive/returns/lunar. These take a
154
+ * flat body (birth_datetime at the top level, no `subject`) — see
155
+ * SolarReturnRequest / LunarReturnRequest in openapi.json.
156
+ */
157
+ function buildReturnBody(natalDatetime, natalLat, natalLon, timezone, targetDatetime, targetTimezone, returnLat, returnLon, houseSystem) {
158
+ return {
159
+ birth_datetime: toDateTimeInputBody("person1_datetime", natalDatetime, timezone, "person1_timezone"),
160
+ birth_location: locationBody(natalLat, natalLon, timezone),
161
+ return_location: locationBody(returnLat, returnLon),
162
+ target_datetime: toDateTimeInputBody("person2_datetime", targetDatetime, targetTimezone, "person2_timezone"),
163
+ house_system: HOUSE_SYSTEM_MAP[houseSystem] ?? HOUSE_SYSTEM_MAP["placidus"],
187
164
  };
188
- if (timezone) {
189
- body.subject.birth_location.timezone = { iana_name: timezone };
190
- }
191
- return body;
192
165
  }
193
166
  /**
194
- * Client-side solar arc: advance every natal planet longitude by elapsed years in degrees.
195
- * Uses the Naibod rate (1 tropical year ≈ 0.9856°). The natal data object is cloned and
196
- * the planets array/map is offset — houses are left at natal positions (standard practice).
167
+ * Route outer-wheel fetch to the correct API endpoint based on mode, and hand
168
+ * back a chart in the natal-chart shape (top-level planets/houses/angles):
169
+ * /ephemeris/progressed nests it under `data`, the return endpoints under `chart`.
197
170
  */
198
- function applySolarArcOffset(natalData, natalDatetime, targetDatetime) {
199
- const natalYear = new Date(natalDatetime).getFullYear() +
200
- new Date(natalDatetime).getMonth() / 12;
201
- const targetYear = new Date(targetDatetime).getFullYear() +
202
- new Date(targetDatetime).getMonth() / 12;
203
- const arcDegrees = (targetYear - natalYear) * 0.9856; // Naibod rate
204
- const offsetPlanets = (raw) => {
205
- if (Array.isArray(raw)) {
206
- return raw.map((p) => ({
207
- ...p,
208
- longitude: ((p.longitude ?? 0) + arcDegrees + 360) % 360,
209
- }));
171
+ export async function fetchOuterChart(client, mode, dt1, lat1, lon1, tz1, dt2, lat2, lon2, tz2, houseSystem = "placidus") {
172
+ const post = (path, body) => client.post(path, body);
173
+ const unwrap = (resp, key) => {
174
+ const inner = resp?.[key];
175
+ if (!inner || typeof inner !== "object") {
176
+ throw new Error(`Unexpected ${mode} response: no \`${key}\` chart in the API reply.`);
210
177
  }
211
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
212
- const out = {};
213
- for (const [k, v] of Object.entries(raw)) {
214
- if (v && typeof v === "object" && "longitude" in v) {
215
- out[k] = { ...v, longitude: ((v.longitude ?? 0) + arcDegrees + 360) % 360 };
216
- }
217
- else {
218
- out[k] = v;
219
- }
220
- }
221
- return out;
222
- }
223
- return raw;
178
+ return inner;
224
179
  };
225
- return {
226
- ...natalData,
227
- planets: offsetPlanets(natalData.planets),
228
- _solar_arc_degrees: Math.round(arcDegrees * 1000) / 1000,
229
- };
230
- }
231
- /** Route outer-wheel fetch to the correct API endpoint based on mode. */
232
- async function fetchOuterChart(client, mode, dt1, lat1, lon1, tz1, dt2, lat2, lon2, tz2) {
233
180
  switch (mode) {
234
181
  case "synastry":
235
- return client.post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2, lon2, tz2));
182
+ return post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2, lon2, tz2, houseSystem));
236
183
  case "transit":
237
- return client.post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2 ?? lat1, lon2 ?? lon1, tz2 ?? tz1));
184
+ return post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2 ?? lat1, lon2 ?? lon1, tz2 ?? tz1, houseSystem));
238
185
  case "progressed":
239
- return client.post("/ephemeris/progressed", buildProgressedBody(dt1, lat1, lon1, tz1, dt2));
186
+ case "solar_arc":
187
+ return unwrap(await post("/ephemeris/progressed", buildProgressedBody(dt1, lat1, lon1, tz1, dt2, tz2 ?? tz1, mode === "progressed" ? "secondary" : "solar_arc", houseSystem)), "data");
240
188
  case "solar_return":
241
- return client.post("/predictive/returns/solar", buildReturnBody(dt1, lat1, lon1, tz1, dt2, lat2 ?? lat1, lon2 ?? lon1));
242
- case "lunar_return":
243
- return client.post("/predictive/returns/lunar", buildReturnBody(dt1, lat1, lon1, tz1, dt2, lat2 ?? lat1, lon2 ?? lon1));
244
- case "solar_arc": {
245
- const natal = await client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1));
246
- return applySolarArcOffset(natal, dt1, dt2);
189
+ case "lunar_return": {
190
+ if (lat1 == null || lon1 == null) {
191
+ throw new Error(`Internal: ${mode} requires Person 1's latitude and longitude.`);
192
+ }
193
+ const path = mode === "solar_return" ? "/predictive/returns/solar" : "/predictive/returns/lunar";
194
+ return unwrap(await post(path, buildReturnBody(dt1, lat1, lon1, tz1, dt2, tz2 ?? tz1, lat2 ?? lat1, lon2 ?? lon1, houseSystem)), "chart");
247
195
  }
248
196
  }
249
197
  }
@@ -292,25 +240,35 @@ export function computeCrossAspects(planets1, planets2) {
292
240
  const CLASSICAL_PLANETS = new Set([
293
241
  "sun", "moon", "mercury", "venus", "mars",
294
242
  "jupiter", "saturn", "uranus", "neptune", "pluto",
295
- "chiron", "north_node", "south_node", "true_node", "asc", "mc",
243
+ "chiron", "asc", "mc",
244
+ // The four lunar nodes keep the API's own names (lowercased) — Mean and True
245
+ // are different points and must never share a name.
246
+ "north node (mean)", "north node (true)", "south node (mean)", "south node (true)",
296
247
  ]);
297
248
  /**
298
- * Normalise the Go backend's verbose planet key (e.g. "North Node (Mean)")
299
- * to the canonical lowercase underscore form the renderer expects.
249
+ * Normalise the Go backend's verbose planet key to the canonical lowercase form
250
+ * the renderer expects. The lunar nodes keep their full names ("north node
251
+ * (mean)" etc.), matching packages/widget-ui/shared/lunar-nodes.ts.
300
252
  */
301
253
  function canonicalizePlanetName(raw) {
302
254
  const lower = raw.toLowerCase();
303
255
  // Various node/chiron aliases the Go PlanetName() function emits.
304
- // Keep mean vs true DISTINCT so the UI can offer a node toggle; collapsing
305
- // both to "north_node" (the old behaviour) produced two overlapping ☊ glyphs.
256
+ // Keep mean vs true DISTINCT (North and South alike) so the UI can offer a
257
+ // node toggle and every label says which node it is.
306
258
  if (lower === "north node (true)" || lower === "true node")
307
- return "true_node";
259
+ return "north node (true)";
308
260
  if (lower === "north node (mean)" || lower === "mean node" || lower === "north node")
309
- return "north_node";
310
- if (lower === "south node (mean)" || lower === "south node (true)")
311
- return "south_node";
312
- if (lower === "mean apogee" || lower === "black moon lilith" || lower === "lilith (mean)" || lower === "lilith (true)")
313
- return "lilith";
261
+ return "north node (mean)";
262
+ if (lower === "south node (true)" || lower === "true south node")
263
+ return "south node (true)";
264
+ if (lower === "south node (mean)" || lower === "south node")
265
+ return "south node (mean)";
266
+ if (lower === "lilith (true)" || lower === "lilith_true" || lower === "true_lilith" || lower === "osculating apogee")
267
+ return "lilith (true)";
268
+ if (lower === "lilith (interpolated)" || lower === "lilith_interpolated" || lower === "lilith_natural" || lower === "interpolated apogee")
269
+ return "lilith (interpolated)";
270
+ if (lower === "lilith (mean)" || lower === "lilith" || lower === "mean_lilith" || lower === "mean apogee" || lower === "black moon lilith")
271
+ return "lilith (mean)";
314
272
  // Strip parenthetical qualifiers for any other bodies just in case
315
273
  return lower.replace(/\s*\(.*?\)/g, "").replace(/\s+/g, "_");
316
274
  }
@@ -613,7 +571,7 @@ registerTool({
613
571
  const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
614
572
  const [innerData, outerData] = await Promise.all([
615
573
  client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1, houseSystem)),
616
- fetchOuterChart(client, mode, dt1, lat1, lon1, tz1, dt2, lat2, lon2, tz2),
574
+ fetchOuterChart(client, mode, dt1, lat1, lon1, tz1, dt2, lat2, lon2, tz2, houseSystem),
617
575
  ]);
618
576
  const params1 = { datetime: dt1, timezone: tz1 ?? null, latitude: lat1 ?? null, longitude: lon1 ?? null, location: loc1 };
619
577
  const params2 = { datetime: dt2, timezone: tz2 ?? null, latitude: lat2 ?? null, longitude: lon2 ?? null, location: loc2 };
@@ -721,9 +679,10 @@ const PLANET_THEMES = {
721
679
  neptune: "dreams, idealism, compassion, and dissolution",
722
680
  pluto: "transformation, power dynamics, and profound change",
723
681
  chiron: "the wounded healer — deep vulnerability, mastery through pain, and the gift of empathy forged in suffering",
724
- north_node: "soul direction and karmic growth edge; the evolutionary path forward in this lifetime",
725
- south_node: "accumulated karma and innate past-life gifts; the comfort zone that must be transcended",
726
- true_node: "soul direction and karmic growth edge; the evolutionary path forward in this lifetime",
682
+ "north node (mean)": "soul direction and karmic growth edge; the evolutionary path forward in this lifetime",
683
+ "north node (true)": "soul direction and karmic growth edge; the evolutionary path forward in this lifetime",
684
+ "south node (mean)": "accumulated karma and innate past-life gifts; the comfort zone that must be transcended",
685
+ "south node (true)": "accumulated karma and innate past-life gifts; the comfort zone that must be transcended",
727
686
  asc: "the rising sign and outer personality mask; the social self, physical appearance, and the lens through which life is encountered",
728
687
  mc: "public vocation, highest ambition, and the world's face; the pinnacle of achievement this person strives toward",
729
688
  };
@@ -797,7 +756,7 @@ function interpretBiWheelPlanet(planet, sign, house, wheel, retrograde = false,
797
756
  `Unlike transits, progressed planets move slowly — a sign ingress can take years to unfold. This position describes a chapter of inner development: the themes of ${capitalize(planet)} are now being expressed with the colouring of ${signQuality}. Aspects from this progressed position to natal points mark turning points in that developmental arc.`);
798
757
  }
799
758
  else if (mode === "solar_arc") {
800
- return (`**Solar Arc ${capitalize(planet)}:** This directed position represents natal ${capitalize(planet)} advanced by approximately 1° per year of life.${houseCtx}${retroCtx}\n\n` +
759
+ return (`**Solar Arc ${capitalize(planet)}:** This directed position represents natal ${capitalize(planet)} advanced by the solar arc — the distance the progressed Sun has travelled since birth (roughly 1° a year).${houseCtx}${retroCtx}\n\n` +
801
760
  `Solar arc directions symbolise the symbolic maturation of each natal archetype. Where the directed ${capitalize(planet)} now falls — and especially which natal planets or angles it contacts — marks a significant chapter in the long development of ${theme}. Conjunctions, squares, and oppositions to natal points are the most potent activation signals.`);
802
761
  }
803
762
  else if (mode === "solar_return") {
@@ -990,7 +949,7 @@ registerTool({
990
949
  progressed: `**Secondary Progressions Overview: ${label1} × ${label2}**\n\n` +
991
950
  `This progression bi-wheel places the natal chart inside and secondary progressed positions in the outer ring. Progressed planets reflect the gradual inner maturation of natal archetypal themes using the day-for-a-year method. Cross-aspects from progressed to natal points mark the defining chapters of inner development.`,
992
951
  solar_arc: `**Solar Arc Directions Overview: ${label1} × ${label2}**\n\n` +
993
- `This solar arc bi-wheel shows natal positions inside and solar arc directed positions in the outer ring. Solar arc direction advances every natal planet by approximately 1° per year of life (Naibod rate). Contacts from directed positions to natal points — especially conjunctions — mark pivotal life moments.`,
952
+ `This solar arc bi-wheel shows natal positions inside and solar arc directed positions in the outer ring. Solar arc direction advances every natal planet, angle and house cusp by the same arc: the distance the progressed Sun has travelled since birth (roughly 1° a year). Contacts from directed positions to natal points — especially conjunctions — mark pivotal life moments.`,
994
953
  solar_return: `**Solar Return Overview: ${label1} × ${label2}**\n\n` +
995
954
  `This solar return bi-wheel compares the natal chart against the solar return for the target year. The outer ring shows where planets fall at the exact moment the Sun returns to its natal degree, describing the dominant themes and developmental focus of the coming solar year.`,
996
955
  lunar_return: `**Lunar Return Overview: ${label1} × ${label2}**\n\n` +