@openephemeris/mcp-server 4.7.0 → 4.8.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,37 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [4.8.0] — 2026-08-02
11
+
12
+ ### Added
13
+ - **Toolsets by tradition.** Tool definitions are re-sent to the model on every
14
+ message, so callers who work in one tradition can now advertise just that
15
+ tradition instead of the general-purpose default: `OPENEPHEMERIS_TOOLS=hd`
16
+ (stdio) or `?profile=hd,bazi` (hosted). Eight toolsets — `astrology`, `moon`,
17
+ `hd`, `bazi`, `vedic`, `acg`, `electional`, `venus` — each including
18
+ geocoding, account usage, and the API escape hatch. A Human Design session
19
+ drops from ~19,100 to ~7,800 advertised tokens (−59%); Vedic to ~3,400
20
+ (−82%). `astrology,moon` matches the default's tool count at ~1,700 fewer
21
+ tokens while covering the long tail (dignities, midpoints, hermetic lots,
22
+ fixed stars, composites) the curated set omits. As with `core`/`full`, this
23
+ filters `tools/list` only — every tool stays callable by name. Also exposed
24
+ as the `toolSurface` option in the Smithery config.
25
+
26
+ ### Fixed
27
+ - `explore_human_design` now states its real credit cost: 4 where the bodygraph
28
+ renders (2 chart + 2 visual), 2 in text-only hosts — it previously claimed a
29
+ flat 2. `ephemeris_moon_phase` (2, not 1) and `ephemeris_next_lunar_phase`
30
+ (1–2 per occurrence) corrected the same way.
31
+ - `explore_bi_wheel` in synastry mode now requires Person 2's coordinates (or a
32
+ resolvable `person2_location`) instead of silently casting Person 2's chart
33
+ for 0°N 0°E.
34
+ - Chart-wheel widget escapes birth parameters before rendering them into the
35
+ info chips.
36
+ - OAuth token endpoint binds authorization codes to their `redirect_uri` and
37
+ `client_id`; both checks were previously skippable by omitting the parameter.
38
+
39
+ ---
40
+
10
41
  ## [4.7.0] — 2026-08-02
11
42
 
12
43
  Historically correct birth-time conversion. IANA timezone databases — including
package/README.md CHANGED
@@ -358,6 +358,38 @@ On the remote HTTP server, append `?profile=full` to the connector URL (or send
358
358
  https://mcp.openephemeris.com/mcp?profile=full
359
359
  ```
360
360
 
361
+ ### Toolsets by tradition
362
+
363
+ If you work in one tradition, ask for it by name instead of taking the general-purpose default. You get that tradition in full — including the long-tail tools the core set leaves out — for a fraction of the context.
364
+
365
+ ```bash
366
+ OPENEPHEMERIS_TOOLS=hd npx -y @openephemeris/mcp-server # Human Design
367
+ OPENEPHEMERIS_TOOLS=astrology,moon npx -y @openephemeris/mcp-server
368
+ ```
369
+
370
+ ```
371
+ https://mcp.openephemeris.com/mcp?profile=hd,bazi
372
+ ```
373
+
374
+ | Toolset | What it covers | Tools | Approx. tokens |
375
+ |---|---|---:|---:|
376
+ | `astrology` | Natal, transits, synastry, progressions, returns, relocation, dignities, midpoints, lots, fixed stars, composites | 32 | 15,800 |
377
+ | `hd` | Human Design charts, transits, connection charts, penta, bodygraph | 14 | 7,800 |
378
+ | `bazi` | Four Pillars, Ten Gods, element balance, luck pillars, compatibility | 13 | 7,200 |
379
+ | `electional` | Timing windows, angle crossings, stations, moment analysis | 10 | 4,600 |
380
+ | `moon` | Phases, void-of-course, eclipses | 9 | 4,100 |
381
+ | `venus` | Star points, phases, elongations, stations | 11 | 3,700 |
382
+ | `acg` | Astrocartography lines and hits | 7 | 3,700 |
383
+ | `vedic` | Jyotish Rashi chart | 7 | 3,400 |
384
+ | — | *core (default)* | 36 | 19,100 |
385
+ | — | *full* | 70 | 34,600 |
386
+
387
+ Every selection also includes geocoding (`location_search`, `timezone_resolve`), `account_usage`, and the allowlist-gated proxy — so a birthplace is always resolvable and nothing is stranded.
388
+
389
+ Combine with commas; unknown names are ignored rather than rejected, so a typo degrades to a smaller surface instead of a dead connector. As with `core`/`full`, this only filters `tools/list` — every tool remains callable by name.
390
+
391
+ Why it matters: tool definitions are re-sent to the model on **every** pass. `astrology,moon` advertises the same number of tools as the default but costs ~1,700 fewer tokens per message and covers more of the tradition.
392
+
361
393
  The surface is fixed when the session initializes — this server does not advertise `tools.listChanged`, so switching requires reconnecting. `dev_list_allowed` enumerates every operation reachable through the generic proxy regardless of surface.
362
394
 
363
395
  ## Contributing & Support
@@ -443,8 +475,8 @@ Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live
443
475
  - Allowlisted operations: **31**
444
476
  - Methods: `GET=5`, `POST=26`, `PUT=0`, `PATCH=0`, `DELETE=0`
445
477
  - Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **92**
446
- - Typed tools: `account_usage`, `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_recalculate`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `dev_list_allowed`, `dev_read_api`, `dev_write_api`, `electional_angle_crossings`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bazi_chart`, `explore_bi_wheel`, `explore_human_design`, `explore_human_design_connection`, `explore_human_design_transit`, `explore_moon_phase`, `explore_natal_chart`, `explore_transit_timeline`, `explore_vedic_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_connection_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_transit_channel_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `moon_phase_recalculate`, `timezone_resolve`, `vedic_chart`, `vedic_chart_recalculate`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
447
- - Generic tools:
478
+ - Typed tools: `account_usage`, `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_recalculate`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `electional_angle_crossings`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bazi_chart`, `explore_bi_wheel`, `explore_human_design`, `explore_human_design_connection`, `explore_human_design_transit`, `explore_moon_phase`, `explore_natal_chart`, `explore_transit_timeline`, `explore_vedic_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_connection_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_transit_channel_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `moon_phase_recalculate`, `timezone_resolve`, `vedic_chart`, `vedic_chart_recalculate`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
479
+ - Generic tools: `dev_list_allowed`, `dev_read_api`, `dev_write_api`
448
480
 
449
481
  ### Allowlist Families
450
482
 
@@ -466,5 +498,5 @@ Most LLMs (like Claude and ChatGPT) struggle heavily with astronomical calculati
466
498
 
467
499
  By pairing LLMs with the OpenEphemeris MCP server, your agents can instantly access:
468
500
  - **Zero-hallucination coordinates**: Direct, sub-arcsecond NASA JPL DE440 calculations spanning 1,100 years of astronomical data.
469
- - **LLM-optimized tokens (`format=llm`)**: We compress standard 25,000 token JSON chart responses into minimal text blocks, cutting your inference costs by 50%.
501
+ - **LLM-optimized tokens (`format=llm`)**: We compress standard 25,000 token JSON chart responses into minimal text blocks, cutting your inference costs by 50–73% depending on endpoint.
470
502
  - **Ready-to-use astrology layers**: Built-in support for Astrocartography geoJSON lines, Hermetic Lots, Fixed Stars, and complex Human Design matrix generation.
package/dist/index.js CHANGED
@@ -73,9 +73,12 @@ const server = new Server({
73
73
  "See the 'welcome_to_open_ephemeris' prompt for orientation.\n\n" +
74
74
  DATETIME_CONTRACT_INSTRUCTIONS,
75
75
  });
76
- // Which slice of the registry this process advertises. Tools outside the core
76
+ // Which slice of the registry this process advertises. Tools outside the
77
77
  // surface stay callable by name — this only controls what tools/list returns.
78
- // Set OPENEPHEMERIS_TOOLS=full to advertise everything.
78
+ // OPENEPHEMERIS_TOOLS=full advertise everything
79
+ // OPENEPHEMERIS_TOOLS=hd,bazi advertise those traditions (+ geocoding,
80
+ // account and the escape hatch)
81
+ // unset the curated core set
79
82
  const STDIO_SURFACE = parseToolSurface(process.env.OPENEPHEMERIS_TOOLS);
80
83
  /**
81
84
  * Stable pseudonymous id for this install. Derived from the configured
@@ -106,11 +106,38 @@ async function handleAuthorizationCode(req, res, store) {
106
106
  });
107
107
  return;
108
108
  }
109
- // Verify redirect_uri exact match
110
- if (redirect_uri && !timingSafeStringEqual(redirect_uri, stored.redirect_uri)) {
109
+ // Verify redirect_uri exact match.
110
+ //
111
+ // This used to read `if (redirect_uri && ...)`, so a client that simply omitted
112
+ // the parameter skipped the check entirely. RFC 6749 §4.1.3 requires the value
113
+ // to be present AND identical whenever the authorization request carried one,
114
+ // precisely so a dropped parameter cannot be the bypass. PKCE still gates the
115
+ // exchange, but that is the last line, not the only one.
116
+ if (stored.redirect_uri) {
117
+ if (!redirect_uri) {
118
+ res.status(400).json({
119
+ error: "invalid_request",
120
+ error_description: "Missing redirect_uri. It is required because the authorization request included one.",
121
+ });
122
+ return;
123
+ }
124
+ if (!timingSafeStringEqual(redirect_uri, stored.redirect_uri)) {
125
+ res.status(400).json({
126
+ error: "invalid_grant",
127
+ error_description: "redirect_uri does not match the value used during authorization.",
128
+ });
129
+ return;
130
+ }
131
+ }
132
+ // Bind the code to the client it was issued to. `client_id` was read off the
133
+ // request but never compared, so any client holding the verifier could redeem
134
+ // another client's code. DCR client_ids are deterministic and non-secret
135
+ // (see oauth/dcr.ts), which makes this binding the only thing distinguishing
136
+ // one registered client from another.
137
+ if (stored.client_id && client_id && !timingSafeStringEqual(client_id, stored.client_id)) {
111
138
  res.status(400).json({
112
139
  error: "invalid_grant",
113
- error_description: "redirect_uri does not match the value used during authorization.",
140
+ error_description: "client_id does not match the client the authorization code was issued to.",
114
141
  });
115
142
  return;
116
143
  }
@@ -23,7 +23,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
23
23
  import { InMemoryEventStore } from "./event-store.js";
24
24
  import { captureEvent, distinctIdFor } from "./analytics.js";
25
25
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
26
- import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface } from "./tools/index.js";
26
+ import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface, describeSurface } from "./tools/index.js";
27
27
  import { DATETIME_CONTRACT_INSTRUCTIONS } from "./tools/datetime.js";
28
28
  import { BackendClient, runWithClient } from "./backend/client.js";
29
29
  import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
@@ -215,7 +215,10 @@ function transportProps(server, surface) {
215
215
  const info = server.getClientVersion();
216
216
  return {
217
217
  transport: "http",
218
- surface,
218
+ // describeSurface, not the raw value: a toolset selection is an object and
219
+ // would land in PostHog as "[object Object]", making the property useless
220
+ // for exactly the question it exists to answer — which surfaces get used.
221
+ surface: describeSurface(surface),
219
222
  client_name: info?.name ?? "unknown",
220
223
  client_version: info?.version ?? "unknown",
221
224
  server_version: version,
@@ -848,6 +851,8 @@ export async function createSseApp() {
848
851
  // `tools.listChanged`, so a host has no obligation to re-fetch the list.
849
852
  // Opt into the full surface with `?profile=full` on the connector URL, or
850
853
  // an `X-OE-Tool-Surface: full` header where the host allows custom headers.
854
+ // A comma list of traditions works the same way — `?profile=hd,bazi` —
855
+ // and advertises just those plus geocoding, account and the escape hatch.
851
856
  const surface = parseToolSurface(req.query.profile ?? req.headers["x-oe-tool-surface"]);
852
857
  const server = createMcpServer(analyticsId, surface);
853
858
  // Fire session_init after the handshake so getClientVersion() is populated
@@ -115,9 +115,9 @@ registerTool({
115
115
  "(Year = ancestry/early life, Month = parents/career, Day = self/spouse, Hour = children/later life).\n\n" +
116
116
  "CREDIT COST: 3 credits per call (1 base + 2 visual render).\n\n" +
117
117
  "Use this for a rich, interactive BaZi experience in MCP Apps-capable hosts (Claude Desktop). " +
118
- "Falls back to a text summary in other hosts. For non-visual data lookups, use chinese_bazi " +
119
- "instead; deeper derivations (Ten Gods, element balance, luck pillars) have dedicated tools " +
120
- "on the full tool surface (`?profile=full`).",
118
+ "Falls back to a text summary in other hosts, so it is also the right call without a " +
119
+ "renderer. Deeper derivations (Ten Gods, element balance, luck pillars) and a plain " +
120
+ "non-visual pillars lookup have dedicated tools on the full surface (`?profile=full`).",
121
121
  inputSchema: {
122
122
  type: "object",
123
123
  properties: {
@@ -104,13 +104,21 @@ const HOUSE_SYSTEM_MAP = {
104
104
  campanus: "C", regiomontanus: "R",
105
105
  };
106
106
  function buildNatalBody(datetime, lat, lon, timezone, houseSystem = "placidus") {
107
+ // A natal chart with no birthplace is never meaningful — houses and angles are
108
+ // derived entirely from it. This used to read `lat ?? 0`, which cast the chart
109
+ // for 0°N 0°E and returned it as though nothing were wrong. Callers that can
110
+ // legitimately omit coords resolve them first (see coordsFromArgsOrLocation) or
111
+ // fall back to Person 1's; anything reaching here without them is a bug.
112
+ if (lat == null || lon == null) {
113
+ throw new Error("Internal: buildNatalBody requires latitude and longitude — refusing to cast a chart for 0°N 0°E.");
114
+ }
107
115
  const body = {
108
116
  subject: {
109
117
  name: "MCP Request",
110
118
  birth_datetime: { iso: datetime },
111
119
  birth_location: {
112
- latitude: { decimal: lat ?? 0 },
113
- longitude: { decimal: lon ?? 0 },
120
+ latitude: { decimal: lat },
121
+ longitude: { decimal: lon },
114
122
  },
115
123
  },
116
124
  configuration: {
@@ -413,16 +421,15 @@ registerTool({
413
421
  "CREDIT COST: 2 credits per call for most modes (1 per wheel computed); " +
414
422
  "solar_return/lunar_return modes cost 6 (5 for the return + 1 for the natal wheel).\n\n" +
415
423
  "The inner wheel is always Person 1's natal chart. The outer ring depends on mode:\n" +
416
- "• synastry — Person 2's natal chart (two-person compatibility). 1 credit.\n" +
417
- "• transit — Transiting planets for a given date. 1 credit.\n" +
418
- "• progressed — Secondary progressed positions (person2_datetime = target date). 1 credit.\n" +
419
- "• solar_return — Nearest solar return chart (person2_datetime = year to return for). 5 credits.\n" +
420
- "• lunar_return — Nearest lunar return chart (person2_datetime = target month). 5 credits.\n" +
421
- "• solar_arc — Solar arc directed positions (person2_datetime = target date). 1 credit.\n" +
422
- "Cross-aspects between both wheels are computed and displayed as coloured dashed lines. " +
423
- "Click any planet, aspect line, or house cusp for interpretation. " +
424
- "Prefer this whenever the user should SEE the comparison — it renders interactively in " +
425
- "MCP Apps-capable hosts (Claude Desktop) and falls back to static SVG in other hosts. " +
424
+ "• synastry — Person 2's natal chart (needs Person 2's own birthplace).\n" +
425
+ "• transit — Transiting planets for a given date.\n" +
426
+ "• progressed — Secondary progressed positions (person2_datetime = target date).\n" +
427
+ "• solar_return — Nearest solar return chart (person2_datetime = year to return for).\n" +
428
+ "• lunar_return — Nearest lunar return chart (person2_datetime = target month).\n" +
429
+ "• solar_arc — Solar arc directed positions (person2_datetime = target date).\n" +
430
+ "Cross-aspects are drawn as coloured dashed lines; click any planet, aspect line, or " +
431
+ "house cusp for interpretation. Prefer this whenever the user should SEE the comparison " +
432
+ "— it renders interactively in MCP Apps hosts and falls back to static SVG elsewhere. " +
426
433
  "For raw synastry data without a visual, use ephemeris_synastry.",
427
434
  inputSchema: {
428
435
  type: "object",
@@ -444,8 +451,8 @@ registerTool({
444
451
  "synastry = Person 2 birth datetime; transit/progressed/solar_arc = target date; " +
445
452
  "solar_return/lunar_return = any date within the target year/month. " + DATETIME_DESC,
446
453
  },
447
- person2_latitude: { type: "number", description: "Birth latitude for Person 2 (synastry) or return/transit location. Defaults to Person 1 coords for single-person modes." },
448
- person2_longitude: { type: "number", description: "Birth longitude for Person 2 (synastry) or return/transit location. Defaults to Person 1 coords for single-person modes." },
454
+ person2_latitude: { type: "number", description: "Birth latitude for Person 2. REQUIRED in synastry unless `person2_location` is given — never inferred from Person 1. Other modes default to Person 1's coords." },
455
+ person2_longitude: { type: "number", description: "Birth longitude for Person 2. REQUIRED in synastry unless `person2_location` is given. Other modes default to Person 1's coords." },
449
456
  person2_timezone: timezoneProperty("Person 2 / the outer chart", "America/Chicago"),
450
457
  person2_name: {
451
458
  type: "string",
@@ -453,7 +460,11 @@ registerTool({
453
460
  },
454
461
  location: {
455
462
  type: "string",
456
- description: "Person 1 / Natal location. Prefer a plain place name like 'New York, NY' — the server resolves it via the same lookup `location_search` uses (unambiguous names → coords + timezone; ambiguous names throw with a disambiguation hint). Supplied person1_latitude/person1_longitude always win.",
463
+ description: "Person 1 / Natal location. Prefer a plain place name like 'New York, NY' — resolved via the same lookup `location_search` uses; ambiguous names throw with a disambiguation hint. Supplied person1_latitude/person1_longitude win.",
464
+ },
465
+ person2_location: {
466
+ type: "string",
467
+ description: "Person 2 / outer-chart place name, resolved like `location`. person2_latitude/person2_longitude win.",
457
468
  },
458
469
  mode: {
459
470
  type: "string",
@@ -493,10 +504,26 @@ registerTool({
493
504
  const lat1 = resolved1.latitude;
494
505
  const lon1 = resolved1.longitude;
495
506
  const tz1 = args.person1_timezone ?? resolved1.timezone;
507
+ const resolved2 = await coordsFromArgsOrLocation({
508
+ latitude: args.person2_latitude,
509
+ longitude: args.person2_longitude,
510
+ timezone: args.person2_timezone,
511
+ location: args.person2_location,
512
+ });
513
+ // Only synastry casts a standalone chart from Person 2's own birthplace. The
514
+ // other modes either fall back to Person 1's coords (transit, *_return) or
515
+ // ignore lat2/lon2 entirely (progressed, solar_arc), so an unresolved Person 2
516
+ // is a hard error there alone. Without this the coords fell through to
517
+ // `lat ?? 0` in buildNatalBody and silently produced a chart cast for
518
+ // 0°N 0°E — no error, no warning, wrong houses and angles.
519
+ if (mode === "synastry" && (resolved2.latitude == null || resolved2.longitude == null)) {
520
+ throw new Error("explore_bi_wheel in synastry mode requires either `person2_latitude` + `person2_longitude` " +
521
+ "or a resolvable `person2_location` name. Person 2's birthplace is never inferred from Person 1's.");
522
+ }
496
523
  const dt2 = String(args.person2_datetime);
497
- const lat2 = args.person2_latitude;
498
- const lon2 = args.person2_longitude;
499
- const tz2 = args.person2_timezone;
524
+ const lat2 = resolved2.latitude;
525
+ const lon2 = resolved2.longitude;
526
+ const tz2 = args.person2_timezone ?? resolved2.timezone;
500
527
  const loc1 = String(args.person1_name ?? resolved1.location ?? args.location ?? `${lat1},${lon1}`);
501
528
  const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
502
529
  // Fetch inner (natal) and outer (mode-dependent) charts in parallel where possible
@@ -575,6 +602,13 @@ registerTool({
575
602
  const lat2 = args.person2_latitude;
576
603
  const lon2 = args.person2_longitude;
577
604
  const tz2 = args.person2_timezone;
605
+ // Same 0°N 0°E trap as explore_bi_wheel: person1 coords are required by the
606
+ // schema but person2's cannot be (they are only needed in synastry mode), so
607
+ // the mode-specific requirement is enforced here. The widget always supplies
608
+ // them; a hand-rolled call need not.
609
+ if (mode === "synastry" && (lat2 == null || lon2 == null)) {
610
+ throw new Error("bi_wheel_recalculate in synastry mode requires `person2_latitude` and `person2_longitude`.");
611
+ }
578
612
  const loc1 = String(args.person1_name ?? args.location ?? `${lat1 ?? "?"},${lon1 ?? "?"}`);
579
613
  const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
580
614
  const [innerData, outerData] = await Promise.all([
@@ -306,7 +306,10 @@ function buildHdSummary(payload, location) {
306
306
  registerTool({
307
307
  name: "explore_human_design",
308
308
  description: "Generate an interactive Human Design Bodygraph with clickable centers, gates, and channels.\n\n" +
309
- "CREDIT COST: 2 credits per call.\n\n" +
309
+ "CREDIT COST: 4 credits where the bodygraph renders (2 for the chart + 2 for the " +
310
+ "visual), 2 in text-only hosts that skip the render. For chart data alone at 2 " +
311
+ "credits — including the activations, design, personality, strategy and variables " +
312
+ "this tool does not return — use human_design_chart.\n\n" +
310
313
  "Returns an embedded visual bodygraph explorer that lets you click any center or gate " +
311
314
  "for instant Human Design interpretation. " +
312
315
  "Shows defined/undefined centers, active gates, channels, Type, Profile, Authority, " +
@@ -57,7 +57,11 @@ export interface ToolDefinition {
57
57
  * multi-tenant HTTP server cannot vary it per session, and an unregistered
58
58
  * tool becomes uncallable rather than merely hidden.
59
59
  */
60
- export type ToolSurface = "core" | "full";
60
+ export type ToolSurface = "core" | "full" | ToolsetSurface;
61
+ /** A caller-chosen set of traditions, e.g. `?profile=hd,bazi`. */
62
+ export interface ToolsetSurface {
63
+ readonly toolsets: readonly string[];
64
+ }
61
65
  /**
62
66
  * The default advertised surface: everyday astrology work, one tool per job.
63
67
  *
@@ -80,6 +84,28 @@ export type ToolSurface = "core" | "full";
80
84
  * explore_natal_chart are present. Do not remove those four.
81
85
  */
82
86
  export declare const CORE_TOOL_NAMES: ReadonlySet<string>;
87
+ /**
88
+ * Tool surfaces grouped by tradition, so a caller pays context only for the
89
+ * work they actually do: `?profile=hd,bazi` on HTTP, `OPENEPHEMERIS_TOOLS=hd`
90
+ * on stdio. BASE_TOOLSET is always included.
91
+ *
92
+ * This is the same *view* filter as `core`/`full` — nothing is unregistered,
93
+ * every tool stays callable by name — so a toolset is a context-budget choice,
94
+ * never a capability one.
95
+ *
96
+ * Why this exists: the core surface is re-sent on every model pass and sat at
97
+ * its ceiling with ~0 headroom, and the only levers left were deleting the
98
+ * disambiguation prose that makes tool selection work. Grouping by tradition
99
+ * is the structural fix — a Human Design user should not pay for Venus Star
100
+ * Points on every message.
101
+ *
102
+ * Names here are asserted against the registry by a test; a typo fails the
103
+ * build rather than silently narrowing someone's surface.
104
+ */
105
+ export declare const TOOLSETS: Readonly<Record<string, readonly string[]>>;
106
+ export declare const TOOLSET_NAMES: readonly string[];
107
+ /** Resolve a toolset selection to the tool names it advertises. */
108
+ export declare function toolsetSelectionNames(names: readonly string[]): Set<string>;
83
109
  /**
84
110
  * Returns tools that should be exposed to Claude in the ListTools response.
85
111
  * Filters out tools that have visibility restricted to the UI (app-only),
@@ -87,8 +113,21 @@ export declare const CORE_TOOL_NAMES: ReadonlySet<string>;
87
113
  * "core" — everything outside CORE_TOOL_NAMES.
88
114
  */
89
115
  export declare function modelVisibleTools(transport?: "stdio" | "http", surface?: ToolSurface): ToolDefinition[];
90
- /** Parses a caller-supplied surface hint. Anything unrecognised means "core". */
116
+ /**
117
+ * Parses a caller-supplied surface hint.
118
+ *
119
+ * "full" -> every model-visible tool
120
+ * "hd" | "hd,bazi" -> those traditions plus BASE_TOOLSET
121
+ * anything else -> "core" (the curated default)
122
+ *
123
+ * Unknown names in a comma list are ignored rather than rejected: a typo
124
+ * degrades to a smaller surface, never to an error at `initialize` that would
125
+ * leave the connector dead. If NO name in the list is recognised, fall back to
126
+ * "core" so a mistyped profile can't strand a caller on BASE alone.
127
+ */
91
128
  export declare function parseToolSurface(raw: unknown): ToolSurface;
129
+ /** Stable label for logs/analytics — an object surface has no useful toString. */
130
+ export declare function describeSurface(surface: ToolSurface): string;
92
131
  export declare const toolRegistry: Record<string, ToolDefinition>;
93
132
  export declare function registerTool(tool: ToolDefinition): void;
94
133
  export type ToolProfile = "dev" | "legacy";
@@ -12,6 +12,9 @@ export const SERVER_VERSION = (() => {
12
12
  return "0.0.0-unknown";
13
13
  }
14
14
  })();
15
+ function isToolsetSurface(s) {
16
+ return typeof s === "object" && Array.isArray(s.toolsets);
17
+ }
15
18
  /**
16
19
  * The default advertised surface: everyday astrology work, one tool per job.
17
20
  *
@@ -53,6 +56,13 @@ export const CORE_TOOL_NAMES = new Set([
53
56
  "ephemeris_transits",
54
57
  "ephemeris_synastry",
55
58
  "ephemeris_retrograde_status",
59
+ // Promoted 2026-08-02 on usage: both were reachable only via profile=full,
60
+ // yet ephemeris_aspect_check drew 19 calls from 3 users over 90 days (more
61
+ // than 20 of the tools already in this set) and ephemeris_angles_points drew
62
+ // 9. People found them while they were hidden; advertising them is cheap
63
+ // (202 and 227 tokens) and earns its place.
64
+ "ephemeris_aspect_check",
65
+ "ephemeris_angles_points",
56
66
  "ephemeris_next_eclipse",
57
67
  "ephemeris_relocation",
58
68
  "ephemeris_progressed_chart",
@@ -60,13 +70,31 @@ export const CORE_TOOL_NAMES = new Set([
60
70
  // Traditions
61
71
  "human_design_chart",
62
72
  "vedic_chart",
63
- "chinese_bazi",
64
73
  "bazi_annual_pillar",
74
+ // chinese_bazi demoted 2026-08-02: explore_bazi_chart POSTs the same
75
+ // /chinese/bazi at the same 1-credit base and keeps every substantive field
76
+ // (year/month/day/hour pillars + day_master), dropping only the `metadata`
77
+ // and `success` envelope keys — verified against a live response, not
78
+ // inferred from the names. It also degrades to a text summary on hosts that
79
+ // cannot render the iframe, so nothing is lost without an MCP Apps host.
80
+ // chinese_bazi drew 0 calls in 90 days while advertised; explore_bazi_chart
81
+ // drew 3. Still registered and callable by name, and still in profile=full.
82
+ //
83
+ // NOTE: this reasoning does NOT extend to the sibling pairs. vedic_chart (1
84
+ // credit) vs explore_vedic_chart (3), and human_design_chart (2 credits, full
85
+ // response) vs explore_human_design (4 credits, and buildHdModelPayload drops
86
+ // activations/design/personality/strategy/variables) are not folds — they
87
+ // would cost users money and data. Verify in the handler before adding to
88
+ // this list.
65
89
  // Timing
66
90
  "ephemeris_electional",
67
91
  "electional_moment_analysis",
68
92
  "electional_station_tracker",
69
- "electional_angle_crossings",
93
+ // electional_angle_crossings demoted 2026-08-02: 715 tokens (the fattest
94
+ // non-proxy tool in this set) for 0 calls in 90 days, and at 5 credits it is
95
+ // advanced-practitioner work rather than an everyday ask. Three electional
96
+ // entry points remain advertised; this one is a profile=full / skill-named
97
+ // tool now.
70
98
  // Astrocartography
71
99
  "acg_power_lines",
72
100
  "acg_hits",
@@ -83,6 +111,141 @@ export const CORE_TOOL_NAMES = new Set([
83
111
  "auth_status",
84
112
  "auth_logout",
85
113
  ]);
114
+ /**
115
+ * Tools every session needs regardless of tradition: turning a place name into
116
+ * coordinates, checking credits, and the allowlist-gated escape hatch. Always
117
+ * unioned into a toolset selection — a caller who asks for `hd` still has to be
118
+ * able to resolve a birthplace, or they are back to recalling coordinates from
119
+ * memory, which is the bug BASE exists to prevent.
120
+ */
121
+ const BASE_TOOLSET = [
122
+ "location_search",
123
+ "timezone_resolve",
124
+ "account_usage",
125
+ "dev_read_api",
126
+ // The write half of the escape hatch. 26 of the 31 allowlisted operations
127
+ // are POST — BASE without dev_write_api would strand a toolset caller with
128
+ // only the 5 GET proxies. (Deliberately NOT advertised in `core`, where the
129
+ // curated typed tools cover the write surface; a toolset selection has no
130
+ // such coverage, so it needs the proxy whole.)
131
+ "dev_write_api",
132
+ "dev_list_allowed",
133
+ "auth_login",
134
+ "auth_status",
135
+ "auth_logout",
136
+ ];
137
+ /**
138
+ * Tool surfaces grouped by tradition, so a caller pays context only for the
139
+ * work they actually do: `?profile=hd,bazi` on HTTP, `OPENEPHEMERIS_TOOLS=hd`
140
+ * on stdio. BASE_TOOLSET is always included.
141
+ *
142
+ * This is the same *view* filter as `core`/`full` — nothing is unregistered,
143
+ * every tool stays callable by name — so a toolset is a context-budget choice,
144
+ * never a capability one.
145
+ *
146
+ * Why this exists: the core surface is re-sent on every model pass and sat at
147
+ * its ceiling with ~0 headroom, and the only levers left were deleting the
148
+ * disambiguation prose that makes tool selection work. Grouping by tradition
149
+ * is the structural fix — a Human Design user should not pay for Venus Star
150
+ * Points on every message.
151
+ *
152
+ * Names here are asserted against the registry by a test; a typo fails the
153
+ * build rather than silently narrowing someone's surface.
154
+ */
155
+ export const TOOLSETS = {
156
+ /** Western natal/predictive work — the default tradition. */
157
+ astrology: [
158
+ "ephemeris_natal_chart",
159
+ "explore_natal_chart",
160
+ "ephemeris_planet_position",
161
+ "ephemeris_house_cusps",
162
+ "ephemeris_angles_points",
163
+ "ephemeris_aspect_check",
164
+ "ephemeris_transits",
165
+ "ephemeris_natal_transits",
166
+ "explore_transit_timeline",
167
+ "ephemeris_synastry",
168
+ "explore_bi_wheel",
169
+ "bi_wheel_synopsis",
170
+ "ephemeris_retrograde_status",
171
+ "ephemeris_progressed_chart",
172
+ "ephemeris_solar_return",
173
+ "ephemeris_lunar_return",
174
+ "ephemeris_planetary_return",
175
+ "ephemeris_relocation",
176
+ "ephemeris_dignities",
177
+ "ephemeris_midpoints",
178
+ "ephemeris_hermetic_lots",
179
+ "ephemeris_fixed_stars",
180
+ "ephemeris_composite",
181
+ "ephemeris_composite_midpoint",
182
+ "ephemeris_overlay",
183
+ "ephemeris_chart_wheel",
184
+ "ephemeris_bi_wheel",
185
+ "ephemeris_natal_batch",
186
+ ],
187
+ /** Lunar phase, void-of-course and eclipse work. */
188
+ moon: [
189
+ "ephemeris_moon_phase",
190
+ "explore_moon_phase",
191
+ "ephemeris_next_lunar_phase",
192
+ "ephemeris_next_eclipse",
193
+ ],
194
+ /** Human Design. */
195
+ hd: [
196
+ "human_design_chart",
197
+ "explore_human_design",
198
+ "explore_human_design_transit",
199
+ "explore_human_design_connection",
200
+ "human_design_bodygraph",
201
+ "human_design_composite",
202
+ "human_design_penta",
203
+ "hd_opposition",
204
+ "hd_planetary_return",
205
+ ],
206
+ /** Chinese BaZi / Four Pillars. */
207
+ bazi: [
208
+ "explore_bazi_chart",
209
+ "chinese_bazi",
210
+ "bazi_chart",
211
+ "bazi_ten_gods",
212
+ "bazi_element_balance",
213
+ "bazi_luck_pillars",
214
+ "bazi_annual_pillar",
215
+ "bazi_compatibility",
216
+ ],
217
+ /** Vedic / Jyotish. */
218
+ vedic: ["vedic_chart", "explore_vedic_chart"],
219
+ /** Astrocartography. */
220
+ acg: ["acg_hits", "acg_power_lines"],
221
+ /** Electional timing. */
222
+ electional: [
223
+ "ephemeris_electional",
224
+ "electional_moment_analysis",
225
+ "electional_station_tracker",
226
+ "electional_angle_crossings",
227
+ "electional_aspect_search",
228
+ ],
229
+ /** Venus Star Point / synodic-cycle work. */
230
+ venus: [
231
+ "venus_star_points",
232
+ "venus_star_points_conjunctions",
233
+ "venus_phase",
234
+ "venus_elongations",
235
+ "venus_stations",
236
+ "venus_eight_year_star",
237
+ ],
238
+ };
239
+ export const TOOLSET_NAMES = Object.keys(TOOLSETS);
240
+ /** Resolve a toolset selection to the tool names it advertises. */
241
+ export function toolsetSelectionNames(names) {
242
+ const out = new Set(BASE_TOOLSET);
243
+ for (const n of names) {
244
+ for (const t of TOOLSETS[n] ?? [])
245
+ out.add(t);
246
+ }
247
+ return out;
248
+ }
86
249
  /**
87
250
  * Returns tools that should be exposed to Claude in the ListTools response.
88
251
  * Filters out tools that have visibility restricted to the UI (app-only),
@@ -90,6 +253,9 @@ export const CORE_TOOL_NAMES = new Set([
90
253
  * "core" — everything outside CORE_TOOL_NAMES.
91
254
  */
92
255
  export function modelVisibleTools(transport = "stdio", surface = "full") {
256
+ const allowed = isToolsetSurface(surface)
257
+ ? toolsetSelectionNames(surface.toolsets)
258
+ : null;
93
259
  return Object.values(toolRegistry).filter((t) => {
94
260
  if (transport === "http" && t.stdioOnly)
95
261
  return false;
@@ -97,14 +263,45 @@ export function modelVisibleTools(transport = "stdio", surface = "full") {
97
263
  // If visibility is explicitly ["app"] (no "model"), hide from model
98
264
  if (vis && !vis.includes("model") && vis.includes("app"))
99
265
  return false;
266
+ if (allowed)
267
+ return allowed.has(t.name);
100
268
  if (surface === "core" && !CORE_TOOL_NAMES.has(t.name))
101
269
  return false;
102
270
  return true;
103
271
  });
104
272
  }
105
- /** Parses a caller-supplied surface hint. Anything unrecognised means "core". */
273
+ /**
274
+ * Parses a caller-supplied surface hint.
275
+ *
276
+ * "full" -> every model-visible tool
277
+ * "hd" | "hd,bazi" -> those traditions plus BASE_TOOLSET
278
+ * anything else -> "core" (the curated default)
279
+ *
280
+ * Unknown names in a comma list are ignored rather than rejected: a typo
281
+ * degrades to a smaller surface, never to an error at `initialize` that would
282
+ * leave the connector dead. If NO name in the list is recognised, fall back to
283
+ * "core" so a mistyped profile can't strand a caller on BASE alone.
284
+ */
106
285
  export function parseToolSurface(raw) {
107
- return typeof raw === "string" && raw.toLowerCase() === "full" ? "full" : "core";
286
+ if (typeof raw !== "string")
287
+ return "core";
288
+ const value = raw.trim().toLowerCase();
289
+ if (value === "full")
290
+ return "full";
291
+ if (value === "" || value === "core")
292
+ return "core";
293
+ const requested = value
294
+ .split(",")
295
+ .map((s) => s.trim())
296
+ .filter(Boolean);
297
+ const known = requested.filter((n) => Object.hasOwn(TOOLSETS, n));
298
+ if (known.length === 0)
299
+ return "core";
300
+ return { toolsets: known };
301
+ }
302
+ /** Stable label for logs/analytics — an object surface has no useful toString. */
303
+ export function describeSurface(surface) {
304
+ return isToolsetSurface(surface) ? surface.toolsets.join("+") : surface;
108
305
  }
109
306
  export const toolRegistry = {};
110
307
  export function registerTool(tool) {
@@ -6,20 +6,16 @@ import { localToUtcIsoHistorical } from "../datetime-historical.js";
6
6
  registerTool({
7
7
  name: "ephemeris_moon_phase",
8
8
  description: "Get the Moon's current phase angle, illumination, sign and void-of-course status AT a " +
9
- "specific point in time. Returns phase name (New, Waxing Crescent, First Quarter, etc.), exact " +
10
- "angle, illumination %, the zodiac sign and degree the Moon occupies, and next void-of-course " +
11
- "period.\n\n" +
9
+ "specific point in time. Returns phase name (New, Waxing Crescent, etc.), illumination %, " +
10
+ "the zodiac sign and degree the Moon occupies, and next void-of-course period.\n\n" +
12
11
  "⚠️ THIS TOOL ANSWERS: 'What phase is the moon in right now (or at a given datetime)?' and " +
13
12
  "'What sign is the moon in?'\n" +
14
13
  "❌ THIS TOOL DOES NOT ANSWER: 'When is the next new moon / full moon?'\n" +
15
14
  "→ For upcoming phase DATES use ephemeris_next_lunar_phase instead.\n\n" +
16
15
  "For a user-facing interactive moon-phase dial, use explore_moon_phase instead.\n\n" +
17
- "CREDIT COST: 1 credit per call.\n\n" +
18
- "If no datetime is provided, returns the current (live) moon phase.\n\n" +
19
- "EXAMPLE: Get moon phase for a specific date/time:\n" +
20
- " datetime='2026-03-20T12:00:00Z'\n\n" +
21
- "EXAMPLE: Get the current moon phase right now:\n" +
22
- " (call with no arguments)",
16
+ "CREDIT COST: 2 credits — phase and void-of-course are metered separately. For " +
17
+ "phase alone at 1 credit, use dev_read_api path='/ephemeris/moon/phase'.\n\n" +
18
+ "If no datetime is provided, returns the current (live) moon phase.",
23
19
  inputSchema: {
24
20
  type: "object",
25
21
  properties: {
@@ -74,8 +70,9 @@ registerTool({
74
70
  "'What date is the next quarter moon?', or any question about UPCOMING phase dates.\n" +
75
71
  "❌ NOT FOR: 'What phase is the moon in right now?' or 'What sign is the moon in?'\n" +
76
72
  "→ For the phase, sign and degree AT a moment, use ephemeris_moon_phase.\n\n" +
77
- "CREDIT COST: about 1 credit per occurrence returned — count=1 costs 1 credit, " +
78
- "count=3 costs 3.\n\n" +
73
+ "CREDIT COST: 1-2 credits per occurrence returned — each occurrence takes one " +
74
+ "calendar search, plus a second search when the first window misses. So count=3 " +
75
+ "costs 3-6 credits.\n\n" +
79
76
  "EXAMPLE: Find the next new moon:\n" +
80
77
  " phase='new_moon'\n\n" +
81
78
  "EXAMPLE: Find the next full moon after a specific date:\n" +
@@ -7690,8 +7690,8 @@ function Cy(e5) {
7690
7690
  vy.ontoolresult = (e5) => {
7691
7691
  var t2;
7692
7692
  let n2 = ny(e5);
7693
- if (qy(), !n2 || !n2.planets) {
7694
- console.error("[ChartWheel] \u274C No chart data found in tool result"), Jy("Chart data could not be parsed from the server response.", Yy);
7693
+ if (Jy(), !n2 || !n2.planets) {
7694
+ console.error("[ChartWheel] \u274C No chart data found in tool result"), Yy("Chart data could not be parsed from the server response.", Xy);
7695
7695
  return;
7696
7696
  }
7697
7697
  if ($ = n2, yy = (t2 = n2.house_system) == null ? "placidus" : t2, !by) {
@@ -7718,9 +7718,9 @@ vy.ontoolresult = (e5) => {
7718
7718
  };
7719
7719
  function wy(e5) {
7720
7720
  var t2, n2, r2, i2, a2, o2, s2;
7721
- ky = null, ly.setAttribute("hidden", ""), uy.removeAttribute("hidden"), _y.removeAttribute("hidden"), py.removeAttribute("hidden"), zv(uy, e5, Ly, Ry, zy, Wy, Sy ? By() : void 0), mv(Cy), Hv(py, yy, Gy), Ty(e5), ty("chart-wheel", uy);
7721
+ Ay = null, ly.setAttribute("hidden", ""), uy.removeAttribute("hidden"), _y.removeAttribute("hidden"), py.removeAttribute("hidden"), zv(uy, e5, Ry, zy, By, Gy, Sy ? Vy() : void 0), mv(Cy), Hv(py, yy, Ky), Ey(e5), ty("chart-wheel", uy);
7722
7722
  let c2 = K_(e5.planets), l2 = (n2 = (r2 = (i2 = ((t2 = e5.houses) == null ? [] : t2)[0]) == null ? void 0 : i2.cusp_lon) == null ? e5.ascendant : r2) == null ? 0 : n2, u2 = (a2 = e5.aspects) == null ? [] : a2;
7723
- c2.length > 0 && Dy(c2, u2), Ay(cv(u2, c2), c2, l2), Fy();
7723
+ c2.length > 0 && Oy(c2, u2), jy(cv(u2, c2), c2, l2), Iy();
7724
7724
  let d2 = document.getElementById("cw-action-bar");
7725
7725
  if (!d2) {
7726
7726
  d2 = document.createElement("div"), d2.id = "cw-action-bar", d2.className = "hd-action-bar";
@@ -7731,9 +7731,12 @@ function wy(e5) {
7731
7731
  (f2 = document.getElementById("app")) == null || f2.prepend(d2);
7732
7732
  }
7733
7733
  }
7734
- d2.innerHTML = '\n <button class="action-btn action-btn--primary" id="cw-interpret-btn">\u2728 Interpret this chart</button>\n <button class="action-btn" id="cw-questions-btn">\u{1F52E} What should I ask?</button>\n ', (o2 = document.getElementById("cw-interpret-btn")) == null || o2.addEventListener("click", () => void Hy()), (s2 = document.getElementById("cw-questions-btn")) == null || s2.addEventListener("click", () => void Uy());
7734
+ d2.innerHTML = '\n <button class="action-btn action-btn--primary" id="cw-interpret-btn">\u2728 Interpret this chart</button>\n <button class="action-btn" id="cw-questions-btn">\u{1F52E} What should I ask?</button>\n ', (o2 = document.getElementById("cw-interpret-btn")) == null || o2.addEventListener("click", () => void Uy()), (s2 = document.getElementById("cw-questions-btn")) == null || s2.addEventListener("click", () => void Wy());
7735
7735
  }
7736
7736
  function Ty(e5) {
7737
+ return String(e5).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
7738
+ }
7739
+ function Ey(e5) {
7737
7740
  var t2, n2, r2, i2, a2, o2, s2, c2, l2, u2;
7738
7741
  if (!dy) return;
7739
7742
  let d2 = (t2 = e5._birth_params) == null ? {} : t2, f2 = (n2 = (r2 = (i2 = d2.datetime) == null ? e5.datetime : i2) == null ? e5.birth_datetime : r2) == null ? "" : n2, p2 = (a2 = (o2 = d2.location) == null ? e5.location : o2) == null ? "" : a2, m2 = (s2 = (c2 = d2.timezone) == null ? e5.timezone : c2) == null ? "" : s2, h2 = (l2 = d2.latitude) == null ? e5.latitude : l2, g2 = (u2 = d2.longitude) == null ? e5.longitude : u2;
@@ -7747,14 +7750,14 @@ function Ty(e5) {
7747
7750
  } catch (e6) {
7748
7751
  }
7749
7752
  let y2 = typeof h2 == "number" && typeof g2 == "number" ? "".concat(Math.abs(h2).toFixed(2), "\xB0").concat(h2 >= 0 ? "N" : "S", ", ").concat(Math.abs(g2).toFixed(2), "\xB0").concat(g2 >= 0 ? "E" : "W") : "", b2 = [];
7750
- if (ee2 && b2.push('<span class="info-chip" title="Birth datetime">\u{1F4C5} '.concat(ee2, "</span>")), p2 && b2.push('<span class="info-chip" title="Birth location">\u{1F4CD} '.concat(p2, "</span>")), y2 && !p2 && b2.push('<span class="info-chip" title="Coordinates">\u{1F4CD} '.concat(y2, "</span>")), m2 && b2.push('<span class="info-chip" title="Timezone">\u{1F550} '.concat(m2, "</span>")), v2 && b2.push('<span class="info-chip sect" title="Sect">'.concat(v2, "</span>")), b2.length === 0) {
7753
+ if (ee2 && b2.push('<span class="info-chip" title="Birth datetime">\u{1F4C5} '.concat(Ty(ee2), "</span>")), p2 && b2.push('<span class="info-chip" title="Birth location">\u{1F4CD} '.concat(Ty(p2), "</span>")), y2 && !p2 && b2.push('<span class="info-chip" title="Coordinates">\u{1F4CD} '.concat(Ty(y2), "</span>")), m2 && b2.push('<span class="info-chip" title="Timezone">\u{1F550} '.concat(Ty(m2), "</span>")), v2 && b2.push('<span class="info-chip sect" title="Sect">'.concat(Ty(v2), "</span>")), b2.length === 0) {
7751
7754
  dy.setAttribute("hidden", "");
7752
7755
  return;
7753
7756
  }
7754
7757
  dy.innerHTML = b2.join(""), dy.removeAttribute("hidden");
7755
7758
  }
7756
- var Ey = /* @__PURE__ */ new Set(["mean_lilith", "lilith", "true_lilith", "ceres", "juno", "vesta", "pallas", "pholus", "vertex", "part_of_fortune", "part of fortune", "eris", "sedna"]);
7757
- function Dy(e5, t2) {
7759
+ var Dy = /* @__PURE__ */ new Set(["mean_lilith", "lilith", "true_lilith", "ceres", "juno", "vesta", "pallas", "pholus", "vertex", "part_of_fortune", "part of fortune", "eris", "sedna"]);
7760
+ function Oy(e5, t2) {
7758
7761
  if (!gy || e5.length === 0) return;
7759
7762
  let n2 = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto", "chiron", "north node", "south node", "true node"], r2 = [...e5].sort((e6, t3) => {
7760
7763
  let r3 = n2.indexOf(e6.name.toLowerCase()), i3 = n2.indexOf(t3.name.toLowerCase());
@@ -7766,7 +7769,7 @@ function Dy(e5, t2) {
7766
7769
  }
7767
7770
  let s2 = (e6) => e6.replace(/(^|\s|_)\w/g, (e7) => e7.toUpperCase()).replace(/_/g, " "), c2 = (e6) => ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"][Math.floor((e6 % 360 + 360) % 360 / 30)], l2 = r2.map((e6) => {
7768
7771
  var t3, n3, r3, a3;
7769
- let o3 = e6.name.toLowerCase(), l3 = (t3 = j_[o3]) == null ? "\u2605" : t3, u3 = (n3 = e6.sign) == null ? c2(e6.longitude) : n3, d3 = Math.floor((e6.longitude % 360 + 360) % 360 % 30), f3 = Math.floor((e6.longitude % 360 + 360) % 360 % 1 * 60), p3 = Ey.has(o3), m2 = e6.retrograde ? '<span class="retro-mark">\u211E</span>' : "", h2 = (r3 = i2[o3]) == null ? 0 : r3;
7772
+ let o3 = e6.name.toLowerCase(), l3 = (t3 = j_[o3]) == null ? "\u2605" : t3, u3 = (n3 = e6.sign) == null ? c2(e6.longitude) : n3, d3 = Math.floor((e6.longitude % 360 + 360) % 360 % 30), f3 = Math.floor((e6.longitude % 360 + 360) % 360 % 1 * 60), p3 = Dy.has(o3), m2 = e6.retrograde ? '<span class="retro-mark">\u211E</span>' : "", h2 = (r3 = i2[o3]) == null ? 0 : r3;
7770
7773
  return '<tr class="'.concat(p3 ? "extended-row" : "", '" data-planet="').concat(o3, '">\n <td><span class="planet-sym">').concat(l3, "</span> ").concat(s2(e6.name)).concat(m2, "</td>\n <td>").concat(d3, "\xB0 ").concat(u3, " ").concat(String(f3).padStart(2, "0"), "\u2032</td>\n <td>H").concat((a3 = e6.house) == null ? "\u2013" : a3, "</td>\n <td>").concat(h2 > 0 ? h2 : "\u2013", "</td>\n </tr>");
7771
7774
  }).join(""), u2 = "| Body | Sign | Degree | House | Retrograde |\n| --- | --- | --- | --- | --- |\n" + r2.map((e6) => {
7772
7775
  var t3;
@@ -7784,7 +7787,7 @@ function Dy(e5, t2) {
7784
7787
  d2.textContent = "Copy", d2.classList.remove("copied");
7785
7788
  }, 1600);
7786
7789
  } catch (e7) {
7787
- Oy("Copy unavailable");
7790
+ ky("Copy unavailable");
7788
7791
  }
7789
7792
  });
7790
7793
  let f2 = gy.querySelector(".overview-toggle"), p2 = gy.querySelector(".overview-body");
@@ -7803,7 +7806,7 @@ function Dy(e5, t2) {
7803
7806
  let t4 = document.querySelector("svg");
7804
7807
  t4 && (t4.querySelectorAll(".planet-glyph-group").forEach((e7) => {
7805
7808
  var t5;
7806
- let n4 = Ey.has((t5 = e7.dataset.planet) == null ? "" : t5);
7809
+ let n4 = Dy.has((t5 = e7.dataset.planet) == null ? "" : t5);
7807
7810
  e7.style.opacity = n4 ? "0.65" : "1";
7808
7811
  }), e6.classList.remove("active-row"));
7809
7812
  }), e6.addEventListener("click", () => {
@@ -7814,7 +7817,7 @@ function Dy(e5, t2) {
7814
7817
  }));
7815
7818
  });
7816
7819
  }
7817
- function Oy(e5) {
7820
+ function ky(e5) {
7818
7821
  var t2;
7819
7822
  (t2 = document.getElementById("copy-toast")) == null || t2.remove();
7820
7823
  let n2 = document.createElement("div");
@@ -7822,8 +7825,8 @@ function Oy(e5) {
7822
7825
  n2.classList.remove("visible"), setTimeout(() => n2.remove(), 300);
7823
7826
  }, 2500);
7824
7827
  }
7825
- var ky = null;
7826
- function Ay(e5, t2, n2) {
7828
+ var Ay = null;
7829
+ function jy(e5, t2, n2) {
7827
7830
  if (!my) return;
7828
7831
  if (e5.length === 0) {
7829
7832
  my.setAttribute("hidden", ""), my.innerHTML = "";
@@ -7838,10 +7841,10 @@ function Ay(e5, t2, n2) {
7838
7841
  let t3 = document.createElement("button");
7839
7842
  t3.className = "pattern-ask-claude-btn", t3.textContent = "\u2728 Ask Claude", t3.style.display = "none", my.appendChild(t3), t3.addEventListener("click", () => {
7840
7843
  var n3;
7841
- let r3 = ky;
7844
+ let r3 = Ay;
7842
7845
  if (r3 === null) return;
7843
7846
  let i2 = e5[r3];
7844
- t3.disabled = true, t3.textContent = "\u23F3 Sending\u2026", Wy({ type: "pattern", data: { name: i2.name, planets: i2.planets, description: (n3 = i2.description) == null ? "" : n3 } }).then(() => {
7847
+ t3.disabled = true, t3.textContent = "\u23F3 Sending\u2026", Gy({ type: "pattern", data: { name: i2.name, planets: i2.planets, description: (n3 = i2.description) == null ? "" : n3 } }).then(() => {
7845
7848
  t3.textContent = "\u2713 Sent", setTimeout(() => {
7846
7849
  t3.textContent = "\u2728 Ask Claude", t3.disabled = false;
7847
7850
  }, 2500);
@@ -7854,12 +7857,12 @@ function Ay(e5, t2, n2) {
7854
7857
  r3.addEventListener("click", () => {
7855
7858
  var i2;
7856
7859
  let a2 = Number(r3.dataset.idx), o2 = e5[a2], s2 = (i2 = r3.dataset.color) == null ? "#888" : i2, c2 = my.querySelector(".pattern-ask-claude-btn");
7857
- ky === a2 ? (ky = null, Mv(), c2 && (c2.style.display = "none"), my.querySelectorAll(".pattern-pill").forEach((e6) => {
7860
+ Ay === a2 ? (Ay = null, Mv(), c2 && (c2.style.display = "none"), my.querySelectorAll(".pattern-pill").forEach((e6) => {
7858
7861
  var t3;
7859
7862
  e6.classList.remove("active");
7860
7863
  let n3 = (t3 = e6.dataset.color) == null ? "#888" : t3;
7861
7864
  e6.style.color = n3, e6.style.borderColor = n3, e6.style.background = "transparent";
7862
- })) : (ky = a2, my.querySelectorAll(".pattern-pill").forEach((e6) => {
7865
+ })) : (Ay = a2, my.querySelectorAll(".pattern-pill").forEach((e6) => {
7863
7866
  var t3;
7864
7867
  e6.classList.remove("active");
7865
7868
  let n3 = (t3 = e6.dataset.color) == null ? "#888" : t3;
@@ -7868,8 +7871,8 @@ function Ay(e5, t2, n2) {
7868
7871
  });
7869
7872
  });
7870
7873
  }
7871
- var jy = [{ label: "Tight", maxOrb: 4 }, { label: "Standard", maxOrb: 8 }, { label: "Wide", maxOrb: 12 }], My = true, Ny = 8, Py = "true";
7872
- function Fy() {
7874
+ var My = [{ label: "Tight", maxOrb: 4 }, { label: "Standard", maxOrb: 8 }, { label: "Wide", maxOrb: 12 }], Ny = true, Py = 8, Fy = "true";
7875
+ function Iy() {
7873
7876
  var e5;
7874
7877
  if (!hy) return;
7875
7878
  let t2 = (() => {
@@ -7881,9 +7884,9 @@ function Fy() {
7881
7884
  let n3 = t3.some((e7) => e7 === "north node" || e7 === "north node (mean)" || e7 === "mean node"), r3 = t3.some((e7) => e7 === "true node" || e7 === "north node (true)");
7882
7885
  return n3 && r3;
7883
7886
  })();
7884
- W_(Py);
7885
- let n2 = t2 ? '\n <div class="asp-section">\n <span class="asp-section-label">Nodes</span>\n <button class="orb-preset-btn node-btn'.concat(Py === "mean" ? " active" : "", '" data-node="mean" title="Mean lunar node (averaged orbit)">Mean</button>\n <button class="orb-preset-btn node-btn').concat(Py === "true" ? " active" : "", '" data-node="true" title="True (osculating) lunar node">True</button>\n </div>') : "";
7886
- My = true, hy.innerHTML = '\n <div class="asp-panel">\n <div class="asp-section">\n <span class="asp-section-label">Aspects</span>\n <button class="aspect-toggle-btn active" id="cw-aspect-vis-toggle"\n title="Toggle all natal aspect lines">\n <span class="toggle-icon">\u2295</span> Natal aspects\n </button>\n </div>\n <div class="asp-section">\n <span class="asp-section-label">Orb</span>\n '.concat(jy.map((e6) => '<button class="orb-preset-btn'.concat(e6.maxOrb === Ny ? " active" : "", '" data-orb="').concat(e6.maxOrb, '" title="').concat(e6.label, ' orbs">').concat(e6.label, "</button>")).join(""), "\n </div>").concat(n2, "\n </div>\n ");
7887
+ W_(Fy);
7888
+ let n2 = t2 ? '\n <div class="asp-section">\n <span class="asp-section-label">Nodes</span>\n <button class="orb-preset-btn node-btn'.concat(Fy === "mean" ? " active" : "", '" data-node="mean" title="Mean lunar node (averaged orbit)">Mean</button>\n <button class="orb-preset-btn node-btn').concat(Fy === "true" ? " active" : "", '" data-node="true" title="True (osculating) lunar node">True</button>\n </div>') : "";
7889
+ Ny = true, hy.innerHTML = '\n <div class="asp-panel">\n <div class="asp-section">\n <span class="asp-section-label">Aspects</span>\n <button class="aspect-toggle-btn active" id="cw-aspect-vis-toggle"\n title="Toggle all natal aspect lines">\n <span class="toggle-icon">\u2295</span> Natal aspects\n </button>\n </div>\n <div class="asp-section">\n <span class="asp-section-label">Orb</span>\n '.concat(My.map((e6) => '<button class="orb-preset-btn'.concat(e6.maxOrb === Py ? " active" : "", '" data-orb="').concat(e6.maxOrb, '" title="').concat(e6.label, ' orbs">').concat(e6.label, "</button>")).join(""), "\n </div>").concat(n2, "\n </div>\n ");
7887
7890
  let r2 = K_($ == null ? void 0 : $.planets), i2 = /* @__PURE__ */ new Set(), a2 = [];
7888
7891
  for (let e6 of r2) {
7889
7892
  let t3 = e6.name.toLowerCase();
@@ -7902,21 +7905,21 @@ function Fy() {
7902
7905
  hy.insertAdjacentHTML("beforeend", Kv([{ title: "Bodies", items: a2 }, { title: "Aspects", items: o2 }])), hy.removeAttribute("hidden");
7903
7906
  let s2 = document.getElementById("cw-aspect-vis-toggle");
7904
7907
  s2 == null || s2.addEventListener("click", () => {
7905
- My = !My, Lv(My), s2.classList.toggle("active", My), s2.querySelector(".toggle-icon").textContent = My ? "\u2295" : "\u2296";
7908
+ Ny = !Ny, Lv(Ny), s2.classList.toggle("active", Ny), s2.querySelector(".toggle-icon").textContent = Ny ? "\u2295" : "\u2296";
7906
7909
  }), hy.querySelectorAll(".orb-preset-btn:not(.node-btn)").forEach((e6) => {
7907
7910
  e6.addEventListener("click", () => {
7908
7911
  var t3;
7909
7912
  let n3 = parseFloat((t3 = e6.dataset.orb) == null ? "8" : t3);
7910
- n3 !== Ny && (Ny = n3, hy.querySelectorAll(".orb-preset-btn:not(.node-btn)").forEach((e7) => e7.classList.toggle("active", e7.dataset.orb === String(n3))), Rv(n3));
7913
+ n3 !== Py && (Py = n3, hy.querySelectorAll(".orb-preset-btn:not(.node-btn)").forEach((e7) => e7.classList.toggle("active", e7.dataset.orb === String(n3))), Rv(n3));
7911
7914
  });
7912
7915
  }), hy.querySelectorAll(".node-btn").forEach((e6) => {
7913
7916
  e6.addEventListener("click", () => {
7914
7917
  let t3 = e6.dataset.node === "mean" ? "mean" : "true";
7915
- t3 === Py || !$ || (Py = t3, W_(t3), wy($));
7918
+ t3 === Fy || !$ || (Fy = t3, W_(t3), wy($));
7916
7919
  });
7917
7920
  });
7918
7921
  }
7919
- async function Iy(e5, t2) {
7922
+ async function Ly(e5, t2) {
7920
7923
  if (xy) try {
7921
7924
  let n2 = "[Chart Wheel] Selected ".concat(e5, " \u2014 ").concat(Object.entries(t2).filter(([, e6]) => e6 != null && e6 !== false && e6 !== "").map(([e6, t3]) => "".concat(e6, ": ").concat(t3)).join(", "));
7922
7925
  await vy.updateModelContext({ content: [{ type: "text", text: n2 }] });
@@ -7924,24 +7927,24 @@ async function Iy(e5, t2) {
7924
7927
  console.warn("[ChartWheel] \u26A0\uFE0F updateModelContext failed:", e6);
7925
7928
  }
7926
7929
  }
7927
- async function Ly(e5) {
7930
+ async function Ry(e5) {
7928
7931
  var t2, n2, r2, i2;
7929
- ky = null, my == null || my.querySelectorAll(".pattern-pill").forEach((e6) => {
7932
+ Ay = null, my == null || my.querySelectorAll(".pattern-pill").forEach((e6) => {
7930
7933
  var t3;
7931
7934
  e6.classList.remove("active");
7932
7935
  let n3 = (t3 = e6.dataset.color) == null ? "#888" : t3;
7933
7936
  e6.style.color = n3, e6.style.borderColor = n3, e6.style.background = "transparent";
7934
- }), await Iy("planet", { planet: e5.name, longitude: e5.longitude, sign: (t2 = e5.sign) == null ? null : t2, house: (n2 = e5.house) == null ? null : n2, retrograde: (r2 = e5.retrograde) == null ? false : r2, speed: (i2 = e5.speed) == null ? null : i2 });
7937
+ }), await Ly("planet", { planet: e5.name, longitude: e5.longitude, sign: (t2 = e5.sign) == null ? null : t2, house: (n2 = e5.house) == null ? null : n2, retrograde: (r2 = e5.retrograde) == null ? false : r2, speed: (i2 = e5.speed) == null ? null : i2 });
7935
7938
  }
7936
- async function Ry(e5) {
7939
+ async function zy(e5) {
7937
7940
  var t2, n2;
7938
- await Iy("house", { house_number: e5.number, sign: (t2 = e5.sign) == null ? null : t2, cusp_longitude: e5.cusp_lon, ruler: (n2 = e5.ruler) == null ? null : n2 });
7941
+ await Ly("house", { house_number: e5.number, sign: (t2 = e5.sign) == null ? null : t2, cusp_longitude: e5.cusp_lon, ruler: (n2 = e5.ruler) == null ? null : n2 });
7939
7942
  }
7940
- async function zy(e5) {
7943
+ async function By(e5) {
7941
7944
  var t2, n2, r2, i2;
7942
- await Iy("aspect", { planet1: e5.planet1, planet2: e5.planet2, aspect_type: (t2 = e5.type) == null ? null : t2, orb: (n2 = e5.orb) == null ? null : n2, applying: (r2 = e5.applying) == null ? null : r2, angle: (i2 = e5.angle) == null ? null : i2 });
7945
+ await Ly("aspect", { planet1: e5.planet1, planet2: e5.planet2, aspect_type: (t2 = e5.type) == null ? null : t2, orb: (n2 = e5.orb) == null ? null : n2, applying: (r2 = e5.applying) == null ? null : r2, angle: (i2 = e5.angle) == null ? null : i2 });
7943
7946
  }
7944
- function By() {
7947
+ function Vy() {
7945
7948
  return async (e5) => {
7946
7949
  try {
7947
7950
  var t2, n2;
@@ -7952,30 +7955,30 @@ function By() {
7952
7955
  }
7953
7956
  };
7954
7957
  }
7955
- async function Vy(e5) {
7958
+ async function Hy(e5) {
7956
7959
  try {
7957
7960
  (await vy.sendMessage({ role: "user", content: [{ type: "text", text: e5 }] })).isError && console.error("[ChartWheel] Host rejected sendMessage");
7958
7961
  } catch (e6) {
7959
7962
  console.error("[ChartWheel] sendMessage failed:", e6);
7960
7963
  }
7961
7964
  }
7962
- async function Hy() {
7965
+ async function Uy() {
7963
7966
  var e5, t2, n2, r2;
7964
7967
  if (!$) return;
7965
7968
  let i2 = $, a2 = (e5 = i2._birth_params) == null ? {} : e5, o2 = (t2 = (n2 = a2.name) == null ? a2.location : n2) == null ? "this person" : t2, s2 = K_(i2.planets), c2 = ((r2 = i2.aspects) == null ? [] : r2).filter((e6) => {
7966
7969
  var t3;
7967
7970
  return ["conjunction", "opposition", "trine", "square", "sextile"].includes((t3 = e6.type) == null ? "" : t3);
7968
7971
  }).slice(0, 8).map((e6) => " \u2022 ".concat(e6.planet1, " ").concat(e6.type, " ").concat(e6.planet2, " (").concat(Number(e6.orb).toFixed(1), "\xB0, ").concat(e6.applying ? "applying" : "separating", ")")).join("\n") || " \u2022 No major aspects found";
7969
- await Vy("Please give a complete natal chart interpretation for ".concat(o2, ".\n\nKey planet placements:\n").concat(s2.slice(0, 5).map((e6) => {
7972
+ await Hy("Please give a complete natal chart interpretation for ".concat(o2, ".\n\nKey planet placements:\n").concat(s2.slice(0, 5).map((e6) => {
7970
7973
  var t3;
7971
7974
  return " \u2022 ".concat(e6.name, " in ").concat((t3 = e6.sign) == null ? "?" : t3).concat(e6.house ? ", House ".concat(e6.house) : "").concat(e6.retrograde ? " \u211E" : "");
7972
7975
  }).join("\n"), "\n\nMajor aspects:\n").concat(c2, "\n\nCover: (1) Core identity and soul purpose (Sun, Moon, Ascendant), (2) The chart's dominant element, modality, and overall signature, (3) The most significant aspects and their psychological themes, (4) Key life areas of focus and challenge, (5) A practical synthesis \u2014 who is this person and what is their path?"));
7973
7976
  }
7974
- async function Uy() {
7977
+ async function Wy() {
7975
7978
  var e5, t2, n2, r2;
7976
7979
  if (!$) return;
7977
7980
  let i2 = $, a2 = (e5 = i2._birth_params) == null ? {} : e5;
7978
- await Vy("I'm exploring the natal chart for ".concat((t2 = (n2 = a2.name) == null ? a2.location : n2) == null ? "this chart" : t2, ". Key placements: ").concat(K_(i2.planets).slice(0, 4).map((e6) => {
7981
+ await Hy("I'm exploring the natal chart for ".concat((t2 = (n2 = a2.name) == null ? a2.location : n2) == null ? "this chart" : t2, ". Key placements: ").concat(K_(i2.planets).slice(0, 4).map((e6) => {
7979
7982
  var t3;
7980
7983
  return "".concat(e6.name, " in ").concat((t3 = e6.sign) == null ? "?" : t3).concat(e6.house ? " H".concat(e6.house) : "");
7981
7984
  }).join(", ") || "unknown placements", ". Major aspects: ").concat(((r2 = i2.aspects) == null ? [] : r2).filter((e6) => {
@@ -7983,7 +7986,7 @@ async function Uy() {
7983
7986
  return ["conjunction", "opposition", "trine", "square", "sextile"].includes((t3 = e6.type) == null ? "" : t3);
7984
7987
  }).slice(0, 5).map((e6) => "".concat(e6.planet1, " ").concat(e6.type, " ").concat(e6.planet2, " (").concat(Number(e6.orb).toFixed(1), "\xB0)")).join(", ") || "none detected", ".\n\nBased on this chart, please suggest 3-4 specific, insightful questions I could ask to understand this person more deeply \u2014 such as how particular placements manifest in real life, the most significant aspect dynamics, or how to integrate the chart's core tensions. Present each as a tappable question I can ask you."));
7985
7988
  }
7986
- async function Wy(e5) {
7989
+ async function Gy(e5) {
7987
7990
  let t2 = "";
7988
7991
  if (e5.type === "planet") {
7989
7992
  var n2;
@@ -8018,7 +8021,7 @@ async function Wy(e5) {
8018
8021
  throw console.error("[ChartWheel] \u274C sendMessage failed:", e6), e6;
8019
8022
  }
8020
8023
  }
8021
- async function Gy(e5) {
8024
+ async function Ky(e5) {
8022
8025
  if (!($ != null && $._birth_params)) {
8023
8026
  Uv(py, yy);
8024
8027
  return;
@@ -8031,21 +8034,21 @@ async function Gy(e5) {
8031
8034
  console.error("[Chart] Recalculate failed:", e6), Uv(py, yy);
8032
8035
  }
8033
8036
  }
8034
- var Ky;
8035
- function qy() {
8036
- Ky && (clearTimeout(Ky), Ky = void 0);
8037
+ var qy;
8038
+ function Jy() {
8039
+ qy && (clearTimeout(qy), qy = void 0);
8037
8040
  }
8038
- function Jy(e5, t2) {
8039
- qy(), ly.setAttribute("hidden", ""), iy({ container: uy, message: e5, onRetry: t2 });
8041
+ function Yy(e5, t2) {
8042
+ Jy(), ly.setAttribute("hidden", ""), iy({ container: uy, message: e5, onRetry: t2 });
8040
8043
  }
8041
- function Yy() {
8042
- qy(), uy.setAttribute("hidden", ""), ly.removeAttribute("hidden"), Ky = setTimeout(() => {
8043
- Jy("The chart didn't load. Tap to try again.", Yy);
8044
+ function Xy() {
8045
+ Jy(), uy.setAttribute("hidden", ""), ly.removeAttribute("hidden"), qy = setTimeout(() => {
8046
+ Yy("The chart didn't load. Tap to try again.", Xy);
8044
8047
  }, 3e4), vy.sendMessage({ role: "user", content: [{ type: "text", text: "Please regenerate the chart wheel." }] }).catch(() => {
8045
8048
  });
8046
8049
  }
8047
- oy(vy, { loadingEl: ly, contentEl: uy, statusFn: Cy, paramControls: () => by }), Ky = setTimeout(() => {
8048
- Jy("Chart data is taking longer than expected.", Yy);
8050
+ oy(vy, { loadingEl: ly, contentEl: uy, statusFn: Cy, paramControls: () => by }), qy = setTimeout(() => {
8051
+ Yy("Chart data is taking longer than expected.", Xy);
8049
8052
  }, 3e4), (async () => {
8050
8053
  try {
8051
8054
  await vy.connect();
@@ -8054,7 +8057,7 @@ oy(vy, { loadingEl: ly, contentEl: uy, statusFn: Cy, paramControls: () => by }),
8054
8057
  $ && wy($);
8055
8058
  }), $ && wy($);
8056
8059
  } catch (e5) {
8057
- console.error("[ChartWheel] \u274C app.connect() FAILED:", e5), Jy("The chart didn't load. Tap to try again.", Yy);
8060
+ console.error("[ChartWheel] \u274C app.connect() FAILED:", e5), Yy("The chart didn't load. Tap to try again.", Xy);
8058
8061
  }
8059
8062
  })();
8060
8063
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openephemeris/mcp-server",
3
- "version": "4.7.0",
3
+ "version": "4.8.0",
4
4
  "description": "Model Context Protocol server for the Open Ephemeris astronomical computation API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -41,6 +41,7 @@
41
41
  "check:schema-packs": "tsx scripts/schema-packs.ts --check",
42
42
  "sync:readme": "tsx scripts/sync-readme.ts --write",
43
43
  "check:readme": "tsx scripts/sync-readme.ts --check",
44
+ "check:counts": "tsx scripts/check-counts.ts",
44
45
  "check:pack": "tsx scripts/pack-audit.ts",
45
46
  "regen:plugin": "tsx scripts/plugin-audit.ts --write",
46
47
  "check:plugin": "tsx scripts/plugin-audit.ts --check",
@@ -48,7 +49,8 @@
48
49
  "check:skills": "node scripts/build-skills.mjs --check",
49
50
  "sync:public": "node scripts/sync-public-repo.mjs --write",
50
51
  "check:public": "node scripts/sync-public-repo.mjs --check",
51
- "verify:release": "npm run check:dev-allowlist && npm run check:schema-packs && npm run check:readme && npm run check:skills && npm run check:plugin && npm run check:public && npm run typecheck && npm test && npm run build && npm run check:pack",
52
+ "check:public:strict": "node scripts/sync-public-repo.mjs --check --require-mirror",
53
+ "verify:release": "npm run check:dev-allowlist && npm run check:schema-packs && npm run check:readme && npm run check:counts && npm run check:skills && npm run check:plugin && npm run check:public:strict && npm run typecheck && npm test && npm run build && npm run check:pack",
52
54
  "prepack": "npm run build",
53
55
  "prepublishOnly": "npm run verify:release",
54
56
  "capture:fixtures": "tsx scripts/capture-harness-fixtures.ts"
package/smithery.yaml CHANGED
@@ -7,12 +7,12 @@
7
7
 
8
8
  name: Open Ephemeris
9
9
  description: >
10
- NASA JPL DE440-backed astronomical computation engine for AI agents. 90+ typed tools covering
10
+ NASA JPL DE440-backed astronomical computation engine for AI agents. Typed tools spanning
11
11
  natal charts, transit forecasting, Human Design charts, eclipses, astrocartography power
12
12
  lines, Venus Star Points, electional timing windows, synastry, composite charts, Vedic
13
13
  charts, Chinese BaZi, and more — powered by JPL DE440 ephemerides for sub-arcsecond
14
14
  zero-hallucination accuracy. Includes an LLM-optimized output format
15
- (format=llm) that compresses chart responses by ~50%. Free Explorer tier available.
15
+ (format=llm) that compresses chart responses by 50–73%. Free Explorer tier available.
16
16
  iconUrl: https://mcp.openephemeris.com/icon.png
17
17
  homepage: https://openephemeris.com
18
18
 
@@ -38,7 +38,7 @@ configSchema:
38
38
  - llm
39
39
  description: >
40
40
  Default output format for all tools. 'llm' returns compact,
41
- token-optimized output (~50% smaller). 'json' returns full verbose JSON.
41
+ token-optimized output (50–73% smaller). 'json' returns full verbose JSON.
42
42
  Individual tool calls can override this setting.
43
43
  default: json
44
44
  baseUrl:
@@ -49,6 +49,16 @@ configSchema:
49
49
  default production endpoint (https://api.openephemeris.com). Useful
50
50
  for self-hosted or staging deployments.
51
51
  default: ""
52
+ toolSurface:
53
+ type: string
54
+ title: Tool Surface
55
+ description: >
56
+ Which tools to advertise. Leave blank for the curated default set,
57
+ 'full' for the whole catalog, or a comma list of traditions —
58
+ astrology, moon, hd, bazi, vedic, acg, electional, venus — to pay
59
+ context only for the work you do (e.g. 'hd,bazi'). Every tool stays
60
+ callable by name regardless of surface.
61
+ default: ""
52
62
 
53
63
  # How to start the server locally (stdio transport for Claude Desktop / Cursor)
54
64
  startCommand:
@@ -61,5 +71,6 @@ startCommand:
61
71
  OPENEPHEMERIS_API_KEY: config.apiKey,
62
72
  ...(config.format && config.format !== "json" ? { OPENEPHEMERIS_FORMAT: config.format } : {}),
63
73
  ...(config.baseUrl ? { OPENEPHEMERIS_BACKEND_URL: config.baseUrl } : {}),
74
+ ...(config.toolSurface ? { OPENEPHEMERIS_TOOLS: config.toolSurface } : {}),
64
75
  }
65
76
  })