@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
package/dist/tools/dev.js CHANGED
@@ -46,10 +46,11 @@ const DEV_API_REFERENCE = "Target API: Open Ephemeris REST API (https://api.open
46
46
  "Call dev_list_allowed to see all currently available endpoint paths.\n\n" +
47
47
  // Prices mirror go-sidecar/internal/api/auth/usage_meter.go; tiers mirror
48
48
  // auth/middleware.go requiredTierForPath. /iching/* aliases /human-design/*.
49
- "CREDIT COST: 1 for most GET calls; /calendar/* 10; /acg/* 10 (Pro tier); /electional/* 5 " +
50
- "(find-window and aspect-search are Pro tier; moment-analysis and station-tracker are free); " +
51
- "/human-design/* and /iching/* 2; catalogs/health 0; requests over 30s add 1 " +
52
- "per 30s. Pass query {format: 'llm'} for compact output.\n\n";
49
+ "CREDIT COST: 1 for most; /predictive/* 5 (transits/search 5-70 by span); /comparative/* 3; " +
50
+ "/calendar/* 10 (moon-phases, lunar-standstill 2; cross-quarter 5); /acg/* 10 (Pro); " +
51
+ "/electional/* 5 (Pro, span-priced searches; moment-analysis, station-tracker free); " +
52
+ "/human-design/*, /iching/* 2 (composite, transit-chart 3; penta 2/member); catalogs 0; " +
53
+ "4xx/5xx refunded. Query {format: 'llm'} = compact output.\n\n";
53
54
  const READ_COMMON_CALLS = "COMMON CALLS:\n" +
54
55
  " GET /ephemeris/moon/phase — Current/queried moon phase\n" +
55
56
  " GET /ephemeris/moon/void-of-course — Next void-of-course period\n" +
@@ -60,13 +61,13 @@ const READ_COMMON_CALLS = "COMMON CALLS:\n" +
60
61
  " GET /eclipse/solar/global — Next global solar eclipse (query: date=YYYY-MM-DD)\n" +
61
62
  " GET /eclipse/solar/local — Local solar eclipse (query: lat, lon)\n" +
62
63
  " GET /tidal/forcing — Gravitational tidal forcing index\n" +
63
- " GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
64
+ " GET /calendar/astrology/moon-phases — next New/Quarter/Full Moon after a date or jd\n" +
64
65
  " GET /location/autocomplete — Geocode a place name (query: query=City Name)\n" +
65
66
  " GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
66
67
  " GET /catalogs/bodies — List all supported celestial bodies\n";
67
68
  const WRITE_COMMON_CALLS = "COMMON CALLS:\n" +
68
69
  " POST /ephemeris/natal-chart — Full natal chart (body: {subject: {name: 'Name', birth_datetime: {iso: '1990-04-15T14:30:00-05:00'}, birth_location: {latitude: {decimal: 40.0}, longitude: {decimal: -70.0}, timezone: {}}}})\n" +
69
- " POST /ephemeris/natal/batch — Up to 50 natal charts in one request\n" +
70
+ " POST /ephemeris/natal/batch — Up to 100 natal charts in one request (Startup tier)\n" +
70
71
  " POST /ephemeris/relocation — Relocated chart (same natal, new location)\n" +
71
72
  " POST /predictive/transits/search — Transit event search over a date range\n" +
72
73
  " POST /predictive/returns/solar — Solar return chart\n" +
@@ -10,7 +10,8 @@ registerTool({
10
10
  "date range, 'find me a good day next month' or 'pick an auspicious window'. Returns the best " +
11
11
  "continuous timing windows in the range, scored hourly on essential dignity, aspect quality, sect " +
12
12
  "and void-of-course Moon penalties, with optional filters (avoid_voc, lunar_phase).\n\n" +
13
- "CREDIT COST: 5 credits per call. Pro tier.\n\n" +
13
+ "CREDIT COST: ≤30 days 5, ≤60 days 8, ≤120 days 12. Pro tier; max window Pro 30 days, Startup " +
14
+ "60, Scale 120.\n\n" +
14
15
  "Do not use to score one specific moment or 'right now' — use electional_moment_analysis; for " +
15
16
  "retrograde dates use electional_station_tracker.",
16
17
  inputSchema: {
@@ -222,7 +223,7 @@ registerTool({
222
223
  description: "Find all active aspects between planets at a specific moment. Returns aspect type, " +
223
224
  "orb, quality score, and whether it's applying or separating. Great for checking " +
224
225
  "the 'weather' of a given day.\n\n" +
225
- "CREDIT COST: 5 credits per call.\n\n" +
226
+ "CREDIT COST: 5 credits per call. Pro tier.\n\n" +
226
227
  "EXAMPLE: What aspects are active on March 21, 2026?\n" +
227
228
  " date='2026-03-21T12:00:00Z'",
228
229
  inputSchema: {
@@ -276,7 +277,7 @@ registerTool({
276
277
  "Sun, Moon, Mercury, Venus, Mars and the lunar nodes — the same body set and formula the CCG hybrid " +
277
278
  "transit map (/acg/ccg mode=ccg_hybrid) draws with; every other body stays a transit. Progressed " +
278
279
  "crossings carry progressed: true and search_summary.progression names what was progressed.\n\n" +
279
- "CREDIT COST: 5 credits per call.\n\n" +
280
+ "CREDIT COST: priced by start→end span — ≤1 month 5, ≤3 months 8, ≤6 months 10, ≤1 year 12. Pro tier.\n\n" +
280
281
  "EXAMPLE: When does Mercury cross the Descendant over Warsaw on 2026-07-30?\n" +
281
282
  " latitude=52.2297, longitude=21.0122, bodies='Mercury', angles='DC',\n" +
282
283
  " start='2026-07-30T00:00:00Z', end='2026-07-31T00:00:00Z'",
@@ -1,5 +1,5 @@
1
1
  import { registerTool, validateRequired, validateCoordinates } from "../index.js";
2
- import { getActiveClient } from "../../backend/client.js";
2
+ import { getActiveClient, rethrowBillingOrAuth } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  import { DATETIME_DESC, TIMEZONE_PROPERTY, assertZonedDatetime, toDateTimeInputBody } from "../datetime.js";
5
5
  // `field`/`timezoneField` name the caller's own argument path (e.g.
@@ -134,10 +134,19 @@ registerTool({
134
134
  }
135
135
  // Fan-out: the backend only handles one planet per call; query all 10 in parallel.
136
136
  const PLANET_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
137
- const results = await Promise.all(PLANET_IDS.map((pid) => client.post("/ephemeris/retrograde-status", {
137
+ const settled = await Promise.allSettled(PLANET_IDS.map((pid) => client.post("/ephemeris/retrograde-status", {
138
138
  date_time: toDateTimeInputBody("datetime", args.datetime, args.timezone),
139
139
  planet_id: pid,
140
- }).catch(() => null)));
140
+ })));
141
+ // Out of credits / signed out / tier-gated: throw the credit-wall (or
142
+ // auth) error so its top-up link reaches the assistant. Swallowing it
143
+ // used to return `{planets: {}, success: true}` at zero credits.
144
+ rethrowBillingOrAuth(settled);
145
+ const results = settled.map((s) => (s.status === "fulfilled" ? s.value : null));
146
+ if (results.every((r) => r == null)) {
147
+ // Every planet failed for a non-billing reason — never report success with no data.
148
+ throw settled[0].reason;
149
+ }
141
150
  // Merge into a keyed object: { planet_name: {...status} }
142
151
  const merged = {};
143
152
  for (const res of results) {
@@ -151,7 +160,13 @@ registerTool({
151
160
  };
152
161
  }
153
162
  }
154
- return { planets: merged, datetime: args.datetime, success: true };
163
+ const failed = PLANET_IDS.filter((_, i) => results[i] == null);
164
+ return {
165
+ planets: merged,
166
+ datetime: args.datetime,
167
+ success: true,
168
+ ...(failed.length ? { failed_planet_ids: failed } : {}),
169
+ };
155
170
  },
156
171
  });
157
172
  // POST /ephemeris/midpoints
@@ -8,7 +8,7 @@ registerTool({
8
8
  name: "human_design_composite",
9
9
  description: "Calculate a Human Design composite chart for two people. Merges both bodygraphs " +
10
10
  "to show shared channels, authority dynamics, and relationship type.\n\n" +
11
- "CREDIT COST: 4 credits per call.\n\n" +
11
+ "CREDIT COST: 3 credits per call.\n\n" +
12
12
  "EXAMPLE (local times + zones):\n" +
13
13
  " person_a_datetime='1990-04-15T14:30:00', person_a_timezone='America/Chicago',\n" +
14
14
  " person_b_datetime='1988-09-22T08:15:00', person_b_timezone='America/Los_Angeles'\n" +
@@ -74,7 +74,7 @@ registerTool({
74
74
  name: "human_design_penta",
75
75
  description: "Calculate a Human Design Penta (group) chart for 3-5 people. Shows functional " +
76
76
  "attributes, leadership dynamics, channels, redundancies, and a group stability score.\n\n" +
77
- "CREDIT COST: 6 credits per call.\n\n" +
77
+ "CREDIT COST: 2 credits per member (6-10 for 3-5 people).\n\n" +
78
78
  "EXAMPLE (3 people — each datetime states its zone):\n" +
79
79
  " group_name='Team Alpha',\n" +
80
80
  " members=[\n" +
@@ -1 +1 @@
1
- export {};
1
+ export declare const NEXT_LUNAR_PHASE_MAX_COUNT = 12;
@@ -1,5 +1,5 @@
1
1
  import { registerTool, validateCoordinates } from "../index.js";
2
- import { getActiveClient } from "../../backend/client.js";
2
+ import { getActiveClient, rethrowBillingOrAuth } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  import { TIMEZONE_PROPERTY } from "../datetime.js";
5
5
  import { localToUtcIsoHistorical } from "../datetime-historical.js";
@@ -53,8 +53,15 @@ registerTool({
53
53
  getActiveClient().request("GET", "/ephemeris/moon/phase", { params }),
54
54
  getActiveClient().request("GET", "/ephemeris/moon/void-of-course", { params }),
55
55
  ]);
56
+ // Out of credits / signed out / tier-gated must reach the assistant as
57
+ // the thrown credit-wall (or auth) error with its top-up link — never
58
+ // as an `error` string tucked inside a success-shaped result.
59
+ rethrowBillingOrAuth([phase, voc]);
60
+ // The phase IS the answer; with it gone there is nothing to degrade to.
61
+ if (phase.status === "rejected")
62
+ throw phase.reason;
56
63
  return {
57
- phase: phase.status === "fulfilled" ? phase.value : { error: phase.reason?.message },
64
+ phase: phase.value,
58
65
  void_of_course: voc.status === "fulfilled" ? voc.value : { error: voc.reason?.message },
59
66
  };
60
67
  },
@@ -65,7 +72,7 @@ registerTool({
65
72
  "date is the next quarter moon' or 'list the next 3 full moons'. Returns the exact UTC datetime " +
66
73
  "of each upcoming occurrence of the requested phase (phase = new_moon | full_moon | first_quarter " +
67
74
  "| last_quarter), optionally after a given date and for count occurrences.\n\n" +
68
- "CREDIT COST: 10-20 credits per occurrence (calendar endpoint); keep count=1 unless asked.\n\n" +
75
+ "CREDIT COST: 2 credits per occurrence; count max 12.\n\n" +
69
76
  "Do not use for the phase or sign the Moon is in right now — use ephemeris_moon_phase; for " +
70
77
  "eclipses use ephemeris_next_eclipse.",
71
78
  inputSchema: {
@@ -86,7 +93,7 @@ registerTool({
86
93
  },
87
94
  count: {
88
95
  type: "integer",
89
- description: "Number of upcoming occurrences to return (1-12). Default 1.",
96
+ description: "Number of upcoming occurrences to return (1-12, 2 credits each). Default 1.",
90
97
  },
91
98
  },
92
99
  required: ["phase"],
@@ -113,55 +120,24 @@ registerTool({
113
120
  if (!wanted) {
114
121
  throw new Error(`Unknown phase '${args.phase}'. Use one of: ${Object.keys(PHASE_IDS).join(", ")}.`);
115
122
  }
116
- const count = Math.max(1, Math.min(12, args.count ?? 1));
123
+ const requested = Number.isFinite(Number(args.count)) ? Math.trunc(Number(args.count)) : 1;
124
+ const count = Math.max(1, Math.min(NEXT_LUNAR_PHASE_MAX_COUNT, requested));
117
125
  const startDate = (args.after_date ?? new Date().toISOString()).substring(0, 10);
118
126
  const startMs = Date.parse(startDate + "T00:00:00Z");
119
127
  if (Number.isNaN(startMs)) {
120
128
  throw new Error(`after_date must be an ISO date like '2026-06-01', got '${args.after_date}'.`);
121
129
  }
122
- const HALF_DAY = 12 * 60 * 60 * 1000;
123
- // The calendar endpoint takes a single `date` (it searches forward from noon UTC
124
- // that day) and returns the first occurrence of each principal phase within the
125
- // following ~30 days. So one request yields at most one hit for the phase we want.
126
- async function phaseTimesFrom(probeDay) {
127
- const result = await getActiveClient().request("GET", "/calendar/astrology/moon-phases", { params: { date: probeDay } });
128
- const events = Array.isArray(result?.data?.events) ? result.data.events : [];
129
- // The endpoint prepends the Moon's *current* 8-phase bucket, stamped with the
130
- // query instant (noon UTC of `date`) rather than an event time. Left in, it
131
- // reads as a real phase occurring at exactly 12:00:00. Computed moments always
132
- // carry sub-second precision, so an exact hit on noon is that placeholder.
133
- const probeNoonMs = Date.parse(probeDay + "T12:00:00Z");
134
- return events
135
- .filter((e) => wanted.includes(String(e?.phase ?? e?.phase_name ?? "").toLowerCase()))
136
- .map((e) => Date.parse(e?.time ?? e?.iso_time ?? ""))
137
- .filter((ms) => !Number.isNaN(ms) && ms !== probeNoonMs)
138
- .sort((a, b) => a - b);
139
- }
140
- // Probe from the day containing `afterMs - 12h`, so its noon-UTC search origin is
141
- // always at or before `afterMs` and an occurrence earlier the same day is still
142
- // visible. That leaves under a day of slack behind the cursor, which can hold at
143
- // most one occurrence (they are ~29.5 days apart) — hence a single re-probe.
144
- async function findNext(afterMs) {
145
- let probeMs = afterMs - HALF_DAY;
146
- for (let probe = 0; probe < 2; probe++) {
147
- const hits = await phaseTimesFrom(new Date(probeMs).toISOString().substring(0, 10));
148
- const next = hits.find((ms) => ms > afterMs);
149
- if (next != null)
150
- return next;
151
- if (hits.length === 0)
152
- return null;
153
- probeMs = hits[hits.length - 1] + HALF_DAY;
154
- }
155
- return null;
156
- }
130
+ const client = getActiveClient();
157
131
  const results = [];
158
- let cursorMs = startMs - 1; // include an occurrence at exactly 00:00 on after_date
132
+ // Include an occurrence at exactly 00:00 on after_date: the engine
133
+ // reports phases strictly after the probe instant.
134
+ let probeMs = startMs - 1000;
159
135
  for (let i = 0; i < count; i++) {
160
- const hitMs = await findNext(cursorMs);
136
+ const hitMs = await nextPhaseAfter(client, probeMs, wanted);
161
137
  if (hitMs == null)
162
138
  break;
163
139
  results.push({ phase: args.phase, datetime: new Date(hitMs).toISOString() });
164
- cursorMs = hitMs;
140
+ probeMs = hitMs + PROBE_STEP_PAST_HIT_MS;
165
141
  }
166
142
  // Every principal phase recurs every ~29.5 days, so an empty result is impossible.
167
143
  // Fail loudly rather than handing back a plausible "there isn't one" non-answer.
@@ -179,3 +155,35 @@ registerTool({
179
155
  };
180
156
  },
181
157
  });
158
+ // ── Next-lunar-phase lookup ─────────────────────────────────────────────────
159
+ //
160
+ // /calendar/astrology/moon-phases is the engine's exact principal-phase
161
+ // finder. It takes a single instant (`jd`, or a `date` = noon UTC), not a
162
+ // range, and returns the first New / First Quarter / Full / Third Quarter
163
+ // strictly after it within 30 days — so one call yields exactly one
164
+ // occurrence of each phase. The Go meter prices it at a flat 2 credits (one
165
+ // fixed 30-day span; usage_meter.go). There is no range form to widen, so
166
+ // `count` occurrences of one phase take `count` calls: 2 credits each.
167
+ //
168
+ // Probing with `jd` (the exact instant) rather than `date` (noon UTC) is what
169
+ // makes it one call per occurrence: the search origin sits exactly on the
170
+ // cursor, so no occurrence between midnight and noon can be skipped and no
171
+ // re-probe is needed.
172
+ export const NEXT_LUNAR_PHASE_MAX_COUNT = 12;
173
+ const UNIX_EPOCH_JD = 2440587.5;
174
+ const DAY_MS = 86_400_000;
175
+ /** Step past a found root before the next probe, so the solver's tolerance cannot return it again. */
176
+ const PROBE_STEP_PAST_HIT_MS = 60_000;
177
+ async function nextPhaseAfter(client, afterMs, wanted) {
178
+ const jd = afterMs / DAY_MS + UNIX_EPOCH_JD;
179
+ const result = await client.request("GET", "/calendar/astrology/moon-phases", {
180
+ params: { jd: jd.toFixed(6) },
181
+ });
182
+ const events = Array.isArray(result?.data?.events) ? result.data.events : [];
183
+ const hits = events
184
+ .filter((e) => wanted.includes(String(e?.phase ?? e?.phase_name ?? "").toLowerCase()))
185
+ .map((e) => Date.parse(e?.time ?? e?.iso_time ?? ""))
186
+ .filter((ms) => !Number.isNaN(ms) && ms > afterMs)
187
+ .sort((a, b) => a - b);
188
+ return hits.length ? hits[0] : null;
189
+ }
@@ -1,6 +1,6 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
- import { OUTPUT_SCHEMA_IMAGE_AND_JSON } from "../output-schemas.js";
3
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  import { DATETIME_DESC, TIMEZONE_PROPERTY, WINDOW_DATE_DESC, assertZonedDatetime } from "../datetime.js";
5
5
  registerTool({
6
6
  name: "ephemeris_progressed_chart",
@@ -44,26 +44,11 @@ registerTool({
44
44
  type: "boolean",
45
45
  description: "Whether to include aspect grid in the response. Default false.",
46
46
  },
47
- include_visual: {
48
- type: "boolean",
49
- description: "When true, renders the progressed chart wheel as an SVG and embeds it in the response. " +
50
- "The chart image will display in the conversation alongside the data. " +
51
- "Costs 2 additional credits on top of the base call cost.",
52
- },
53
- visual_config: {
54
- type: "object",
55
- description: "Optional rendering preferences (only used when include_visual=true).",
56
- properties: {
57
- theme: { type: "string", enum: ["light", "dark"], description: "Color theme. Default: light." },
58
- size: { type: "number", description: "Canvas size in pixels, 200–4000. Default: 800." },
59
- },
60
- additionalProperties: false,
61
- },
62
47
  },
63
48
  required: ["birth_datetime", "birth_latitude", "birth_longitude", "target_datetime"],
64
49
  additionalProperties: false,
65
50
  },
66
- outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
51
+ outputSchema: OUTPUT_SCHEMA_JSON,
67
52
  annotations: { title: "Progressed Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
68
53
  handler: async (args) => {
69
54
  validateRequired(args, ["birth_datetime", "birth_latitude", "birth_longitude", "target_datetime"]);
@@ -91,14 +76,6 @@ registerTool({
91
76
  if (args.include_aspects != null) {
92
77
  body.options = { include_aspects: args.include_aspects };
93
78
  }
94
- if (args.include_visual) {
95
- body.include_visual = true;
96
- body.visual_config = {
97
- format: "svg",
98
- theme: args.visual_config?.theme || "light",
99
- size: args.visual_config?.size || 800,
100
- };
101
- }
102
79
  return await getActiveClient().post("/ephemeris/progressed", body);
103
80
  },
104
81
  });
@@ -11,11 +11,11 @@ registerTool({
11
11
  "ASPECT ANGLES (aspect_angle): 0 = conjunction/return (default), 180 = opposition, 90 = square, " +
12
12
  "120 = trine. Saturn return → transiting_planets=['saturn'], natal_points=['saturn'], " +
13
13
  "aspect_angle=0.\n" +
14
- "EFFICIENCY: always pass transiting_planets and natal_points — omitting both sweeps all outer " +
15
- "planets against all core points and is slow. DEFAULT natal_points: sun, moon, mercury, venus, " +
16
- "mars, jupiter, saturn.\n" +
17
- "SEARCH RANGE LIMITS: Explorer/PayG → 1 year; Pro → 5 years; Startup → 10 years.\n" +
18
- "CREDIT COST: 6 credits per call (natal chart + search).\n\n" +
14
+ "EFFICIENCY: always pass transiting_planets and natal_points (omitting both is slow). DEFAULT " +
15
+ "natal_points: sun, moon, mercury, venus, mars, jupiter, saturn.\n" +
16
+ "MAX SPAN: Explorer/PayG 1y, Pro 5y, Startup 10y, Scale 40y (longer = 400).\n" +
17
+ "CREDIT COST: 1 + span price: ≤1y 5, ≤3y 10, ≤5y 15, ≤10y 25, ≤20y 40, ≤40y 70. truncated:true = " +
18
+ "partial; re-call from truncation.resume_from.\n\n" +
19
19
  "Do not use when the user should SEE a forecast — use explore_transit_timeline; for the sky today " +
20
20
  "with no birth data use electional_moment_analysis; for retrograde dates use " +
21
21
  "electional_station_tracker.",