@openephemeris/mcp-server 3.13.9 → 3.14.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 (70) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +3 -3
  3. package/config/dev-allowlist.json +28 -4
  4. package/dist/index.js +20 -13
  5. package/dist/oauth/dcr.d.ts +11 -0
  6. package/dist/oauth/dcr.js +49 -0
  7. package/dist/oauth/discovery.d.ts +12 -0
  8. package/dist/oauth/discovery.js +45 -0
  9. package/dist/oauth/pkce.d.ts +18 -0
  10. package/dist/oauth/pkce.js +34 -0
  11. package/dist/oauth/rate-limit.d.ts +20 -0
  12. package/dist/oauth/rate-limit.js +65 -0
  13. package/dist/oauth/store.d.ts +88 -0
  14. package/dist/oauth/store.js +361 -0
  15. package/dist/oauth/supabase-jwt.d.ts +39 -0
  16. package/dist/oauth/supabase-jwt.js +194 -0
  17. package/dist/oauth/token.d.ts +21 -0
  18. package/dist/oauth/token.js +215 -0
  19. package/dist/prompts.js +180 -159
  20. package/dist/server-sse.js +154 -49
  21. package/dist/tools/apps/bazi-app.d.ts +23 -0
  22. package/dist/tools/apps/bazi-app.js +226 -0
  23. package/dist/tools/apps/bi-wheel-app.d.ts +14 -7
  24. package/dist/tools/apps/bi-wheel-app.js +347 -113
  25. package/dist/tools/apps/bodygraph-app.js +106 -13
  26. package/dist/tools/apps/chart-wheel-app.js +53 -4
  27. package/dist/tools/apps/location-tools.js +3 -0
  28. package/dist/tools/apps/moon-phase-app.d.ts +19 -0
  29. package/dist/tools/apps/moon-phase-app.js +244 -0
  30. package/dist/tools/apps/transit-timeline-app.d.ts +20 -0
  31. package/dist/tools/apps/transit-timeline-app.js +295 -0
  32. package/dist/tools/apps/vedic-chart-app.d.ts +17 -0
  33. package/dist/tools/apps/vedic-chart-app.js +228 -0
  34. package/dist/tools/auth.js +4 -0
  35. package/dist/tools/dev.js +3 -0
  36. package/dist/tools/index.d.ts +6 -0
  37. package/dist/tools/index.js +9 -4
  38. package/dist/tools/output-schemas.d.ts +37 -0
  39. package/dist/tools/output-schemas.js +130 -0
  40. package/dist/tools/specialized/acg.js +5 -2
  41. package/dist/tools/specialized/bazi.js +375 -44
  42. package/dist/tools/specialized/bi_wheel.js +3 -1
  43. package/dist/tools/specialized/chart_wheel.js +3 -1
  44. package/dist/tools/specialized/comparative.js +9 -4
  45. package/dist/tools/specialized/eclipse.js +3 -1
  46. package/dist/tools/specialized/electional.js +9 -4
  47. package/dist/tools/specialized/ephemeris_core.js +5 -2
  48. package/dist/tools/specialized/ephemeris_extended.js +17 -8
  49. package/dist/tools/specialized/hd_bodygraph.js +3 -1
  50. package/dist/tools/specialized/hd_cycles.js +5 -2
  51. package/dist/tools/specialized/hd_group.js +5 -2
  52. package/dist/tools/specialized/human_design.js +3 -1
  53. package/dist/tools/specialized/moon.js +5 -2
  54. package/dist/tools/specialized/natal.js +3 -1
  55. package/dist/tools/specialized/progressed.js +3 -1
  56. package/dist/tools/specialized/relocation.js +3 -1
  57. package/dist/tools/specialized/returns.js +7 -3
  58. package/dist/tools/specialized/synastry.js +3 -1
  59. package/dist/tools/specialized/transits.js +53 -44
  60. package/dist/tools/specialized/vedic.js +3 -1
  61. package/dist/tools/specialized/venus_star_points.js +13 -6
  62. package/dist/ui/bazi.html +213 -0
  63. package/dist/ui/bi-wheel.html +3714 -3048
  64. package/dist/ui/bodygraph.html +1952 -1766
  65. package/dist/ui/chart-wheel.html +3431 -2964
  66. package/dist/ui/moon-phase.html +6764 -0
  67. package/dist/ui/transit-timeline.html +6874 -0
  68. package/dist/ui/vedic-chart.html +210 -0
  69. package/package.json +15 -12
  70. package/smithery.yaml +16 -1
@@ -1,15 +1,21 @@
1
1
  /**
2
2
  * bi-wheel-app.ts — MCP App tool registration for the Bi-Wheel Explorer.
3
3
  *
4
- * Registers 3 tools:
5
- * • explore_bi_wheel — primary entry point: fetches two charts, computes
6
- * cross-aspects, returns data + UI resource [model]
4
+ * Registers 6 tools:
5
+ * • explore_bi_wheel — primary entry point [model + app]
6
+ * bi_wheel_recalculate — re-fetch with new params [app-only]
7
7
  * • bi_wheel_on_cross_aspect_click — cross-aspect click handler [app-only]
8
- * • bi_wheel_on_planet_click — planet click handler (inner or outer wheel) [app-only]
8
+ * • bi_wheel_on_planet_click — planet click handler [app-only]
9
+ * • bi_wheel_on_house_click — house cusp click handler [app-only]
10
+ * • bi_wheel_synopsis — full reading overview [model + app]
9
11
  *
10
- * Modes:
11
- * synastry fetch two natal charts in parallel (person1 inner, person2 outer)
12
- * transit fetch one natal chart (inner) + one transit chart (outer)
12
+ * Supported modes (BiWheelMode):
13
+ * synastry — two natal charts (person1 inner, person2 outer)
14
+ * transit — natal inner + transiting planets outer
15
+ * progressed — natal inner + secondary progressions outer (POST /ephemeris/progressed)
16
+ * solar_return — natal inner + solar return chart outer (POST /predictive/returns/solar)
17
+ * lunar_return — natal inner + lunar return chart outer (POST /predictive/returns/lunar)
18
+ * solar_arc — natal inner + solar arc directions outer (client-side Naibod approximation)
13
19
  *
14
20
  * Cross-aspects are computed client-side (UI) AND server-side (for summary/fallback).
15
21
  * The payload cross_aspects array is capped at 30 tightest-orb aspects.
@@ -19,6 +25,7 @@ import path from "node:path";
19
25
  import { fileURLToPath } from "node:url";
20
26
  import { registerTool, SERVER_VERSION } from "../index.js";
21
27
  import { getActiveClient } from "../../backend/client.js";
28
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
22
29
  // ── Constants ─────────────────────────────────────────────────────────────────
23
30
  export const BI_WHEEL_RESOURCE_URI = "ui://openephemeris/bi-wheel";
24
31
  export const BI_WHEEL_MIME_TYPE = "text/html;profile=mcp-app";
@@ -42,12 +49,59 @@ export function getBiWheelBundle() {
42
49
  export function clearBiWheelBundleCache() {
43
50
  cachedBiWheelBundle = null;
44
51
  }
52
+ const ALL_MODES = [
53
+ "synastry", "transit", "progressed", "solar_return", "lunar_return", "solar_arc",
54
+ ];
55
+ const MODE_META = {
56
+ synastry: {
57
+ outerLabel: (_, name) => name ?? "Person 2",
58
+ innerRole: "Person 1 (inner)",
59
+ outerRole: "Person 2 (outer)",
60
+ credits: 1,
61
+ modeLabel: "Synastry",
62
+ },
63
+ transit: {
64
+ outerLabel: (dt) => `Transit · ${dt.slice(0, 10)}`,
65
+ innerRole: "Natal (inner)",
66
+ outerRole: "Transits (outer)",
67
+ credits: 1,
68
+ modeLabel: "Transit",
69
+ },
70
+ progressed: {
71
+ outerLabel: (dt) => `Progressions · ${dt.slice(0, 10)}`,
72
+ innerRole: "Natal (inner)",
73
+ outerRole: "Progressed (outer)",
74
+ credits: 1,
75
+ modeLabel: "Secondary Progressions",
76
+ },
77
+ solar_return: {
78
+ outerLabel: (dt) => `Solar Return · ${dt.slice(0, 4)}`,
79
+ innerRole: "Natal (inner)",
80
+ outerRole: "Solar Return (outer)",
81
+ credits: 5,
82
+ modeLabel: "Solar Return",
83
+ },
84
+ lunar_return: {
85
+ outerLabel: (dt) => `Lunar Return · ${dt.slice(0, 10)}`,
86
+ innerRole: "Natal (inner)",
87
+ outerRole: "Lunar Return (outer)",
88
+ credits: 5,
89
+ modeLabel: "Lunar Return",
90
+ },
91
+ solar_arc: {
92
+ outerLabel: (dt) => `Solar Arc · ${dt.slice(0, 10)}`,
93
+ innerRole: "Natal (inner)",
94
+ outerRole: "Solar Arc Directions (outer)",
95
+ credits: 1,
96
+ modeLabel: "Solar Arc Directions",
97
+ },
98
+ };
45
99
  // ── House system helpers (shared with chart-wheel) ────────────────────────────
46
100
  const HOUSE_SYSTEM_MAP = {
47
101
  placidus: "P", whole_sign: "W", equal: "E", koch: "K",
48
102
  campanus: "C", regiomontanus: "R",
49
103
  };
50
- function buildNatalBody(datetime, lat, lon, timezone) {
104
+ function buildNatalBody(datetime, lat, lon, timezone, houseSystem = "placidus") {
51
105
  const body = {
52
106
  subject: {
53
107
  name: "MCP Request",
@@ -58,7 +112,7 @@ function buildNatalBody(datetime, lat, lon, timezone) {
58
112
  },
59
113
  },
60
114
  configuration: {
61
- house_system: HOUSE_SYSTEM_MAP["placidus"],
115
+ house_system: HOUSE_SYSTEM_MAP[houseSystem] ?? HOUSE_SYSTEM_MAP["placidus"],
62
116
  },
63
117
  };
64
118
  if (timezone) {
@@ -81,6 +135,108 @@ function buildTransitBody(transitDate, natalDatetime, lat, lon, timezone) {
81
135
  configuration: { house_system: HOUSE_SYSTEM_MAP["placidus"] },
82
136
  };
83
137
  }
138
+ /** POST /ephemeris/progressed — secondary progressions */
139
+ function buildProgressedBody(natalDatetime, lat, lon, timezone, targetDatetime) {
140
+ const body = {
141
+ subject: {
142
+ name: "MCP Request",
143
+ birth_datetime: { iso: natalDatetime },
144
+ birth_location: {
145
+ latitude: { decimal: lat ?? 0 },
146
+ longitude: { decimal: lon ?? 0 },
147
+ },
148
+ },
149
+ configuration: {
150
+ target_datetime: { iso: targetDatetime },
151
+ method: "secondary",
152
+ house_system: HOUSE_SYSTEM_MAP["placidus"],
153
+ },
154
+ };
155
+ if (timezone) {
156
+ body.subject.birth_location.timezone = { iana_name: timezone };
157
+ }
158
+ return body;
159
+ }
160
+ /** POST /predictive/returns/solar or /predictive/returns/lunar */
161
+ function buildReturnBody(natalDatetime, natalLat, natalLon, timezone, targetDatetime, returnLat, returnLon) {
162
+ const body = {
163
+ subject: {
164
+ name: "MCP Request",
165
+ birth_datetime: { iso: natalDatetime },
166
+ birth_location: {
167
+ latitude: { decimal: natalLat ?? 0 },
168
+ longitude: { decimal: natalLon ?? 0 },
169
+ },
170
+ },
171
+ return_location: {
172
+ latitude: { decimal: returnLat ?? natalLat ?? 0 },
173
+ longitude: { decimal: returnLon ?? natalLon ?? 0 },
174
+ },
175
+ target_datetime: { iso: targetDatetime },
176
+ configuration: { house_system: HOUSE_SYSTEM_MAP["placidus"] },
177
+ };
178
+ if (timezone) {
179
+ body.subject.birth_location.timezone = { iana_name: timezone };
180
+ }
181
+ return body;
182
+ }
183
+ /**
184
+ * Client-side solar arc: advance every natal planet longitude by elapsed years in degrees.
185
+ * Uses the Naibod rate (1 tropical year ≈ 0.9856°). The natal data object is cloned and
186
+ * the planets array/map is offset — houses are left at natal positions (standard practice).
187
+ */
188
+ function applySolarArcOffset(natalData, natalDatetime, targetDatetime) {
189
+ const natalYear = new Date(natalDatetime).getFullYear() +
190
+ new Date(natalDatetime).getMonth() / 12;
191
+ const targetYear = new Date(targetDatetime).getFullYear() +
192
+ new Date(targetDatetime).getMonth() / 12;
193
+ const arcDegrees = (targetYear - natalYear) * 0.9856; // Naibod rate
194
+ const offsetPlanets = (raw) => {
195
+ if (Array.isArray(raw)) {
196
+ return raw.map((p) => ({
197
+ ...p,
198
+ longitude: ((p.longitude ?? 0) + arcDegrees + 360) % 360,
199
+ }));
200
+ }
201
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
202
+ const out = {};
203
+ for (const [k, v] of Object.entries(raw)) {
204
+ if (v && typeof v === "object" && "longitude" in v) {
205
+ out[k] = { ...v, longitude: ((v.longitude ?? 0) + arcDegrees + 360) % 360 };
206
+ }
207
+ else {
208
+ out[k] = v;
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+ return raw;
214
+ };
215
+ return {
216
+ ...natalData,
217
+ planets: offsetPlanets(natalData.planets),
218
+ _solar_arc_degrees: Math.round(arcDegrees * 1000) / 1000,
219
+ };
220
+ }
221
+ /** Route outer-wheel fetch to the correct API endpoint based on mode. */
222
+ async function fetchOuterChart(client, mode, dt1, lat1, lon1, tz1, dt2, lat2, lon2, tz2) {
223
+ switch (mode) {
224
+ case "synastry":
225
+ return client.post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2, lon2, tz2));
226
+ case "transit":
227
+ return client.post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2 ?? lat1, lon2 ?? lon1, tz2 ?? tz1));
228
+ case "progressed":
229
+ return client.post("/ephemeris/progressed", buildProgressedBody(dt1, lat1, lon1, tz1, dt2));
230
+ case "solar_return":
231
+ return client.post("/predictive/returns/solar", buildReturnBody(dt1, lat1, lon1, tz1, dt2, lat2 ?? lat1, lon2 ?? lon1));
232
+ case "lunar_return":
233
+ return client.post("/predictive/returns/lunar", buildReturnBody(dt1, lat1, lon1, tz1, dt2, lat2 ?? lat1, lon2 ?? lon1));
234
+ case "solar_arc": {
235
+ const natal = await client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1));
236
+ return applySolarArcOffset(natal, dt1, dt2);
237
+ }
238
+ }
239
+ }
84
240
  const CROSS_ASPECT_DEFS = [
85
241
  { type: "conjunction", angle: 0, orb: 8 },
86
242
  { type: "opposition", angle: 180, orb: 8 },
@@ -120,19 +276,39 @@ export function computeCrossAspects(planets1, planets2) {
120
276
  return results.sort((a, b) => a.orb - b.orb).slice(0, 30);
121
277
  }
122
278
  // ── Planet normalisation ───────────────────────────────────────────────────────
279
+ // Canonical underscore names accepted by the renderer.
123
280
  const CLASSICAL_PLANETS = new Set([
124
281
  "sun", "moon", "mercury", "venus", "mars",
125
282
  "jupiter", "saturn", "uranus", "neptune", "pluto",
126
283
  "chiron", "north_node", "south_node", "true_node", "asc", "mc",
127
284
  ]);
285
+ /**
286
+ * Normalise the Go backend's verbose planet key (e.g. "North Node (Mean)")
287
+ * to the canonical lowercase underscore form the renderer expects.
288
+ */
289
+ function canonicalizePlanetName(raw) {
290
+ const lower = raw.toLowerCase();
291
+ // Various node/chiron aliases the Go PlanetName() function emits
292
+ if (lower === "north node (mean)" || lower === "north node (true)" || lower === "mean node" || lower === "true node")
293
+ return "north_node";
294
+ if (lower === "south node (mean)" || lower === "south node (true)")
295
+ return "south_node";
296
+ if (lower === "mean apogee" || lower === "black moon lilith" || lower === "lilith (mean)" || lower === "lilith (true)")
297
+ return "lilith";
298
+ // Strip parenthetical qualifiers for any other bodies just in case
299
+ return lower.replace(/\s*\(.*?\)/g, "").replace(/\s+/g, "_");
300
+ }
128
301
  function normalizePlanets(raw) {
129
302
  if (Array.isArray(raw))
130
303
  return raw;
131
304
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
132
305
  return Object.entries(raw)
133
- .filter(([k]) => CLASSICAL_PLANETS.has(k.toLowerCase()))
306
+ .filter(([k]) => {
307
+ const canonical = canonicalizePlanetName(k);
308
+ return CLASSICAL_PLANETS.has(canonical);
309
+ })
134
310
  .map(([name, p]) => ({
135
- name: name.toLowerCase(),
311
+ name: canonicalizePlanetName(name),
136
312
  longitude: (p.longitude ?? p.lon ?? 0),
137
313
  speed: (p.longitude_speed ?? p.speed),
138
314
  retrograde: Boolean(p.is_retrograde ?? p.retrograde),
@@ -154,14 +330,30 @@ function normalizeHouses(raw) {
154
330
  return [];
155
331
  }
156
332
  // ── Payload builder ────────────────────────────────────────────────────────────
333
+ function extractAngles(data) {
334
+ const rawAngles = data.angles;
335
+ const rawHouseObj = data.houses;
336
+ const ascendant = rawAngles?.ascendant ??
337
+ data.ascendant ??
338
+ rawHouseObj?.cusps?.[0] ??
339
+ 0;
340
+ const midheaven = rawAngles?.midheaven ??
341
+ data.midheaven ??
342
+ rawHouseObj?.cusps?.[9] ??
343
+ 0;
344
+ return { ascendant, midheaven };
345
+ }
157
346
  function buildBiWheelPayload(innerData, outerData, mode, params1, params2) {
158
347
  const innerPlanets = normalizePlanets(innerData.planets);
159
348
  const outerPlanets = normalizePlanets(outerData.planets);
160
349
  const innerHouses = normalizeHouses(innerData.houses);
161
350
  const outerHouses = normalizeHouses(outerData.houses);
162
351
  const crossAspects = computeCrossAspects(innerPlanets, outerPlanets);
352
+ const { ascendant, midheaven } = extractAngles(innerData);
163
353
  return {
164
354
  mode,
355
+ ascendant,
356
+ midheaven,
165
357
  inner: innerPlanets,
166
358
  outer: outerPlanets,
167
359
  inner_houses: innerHouses,
@@ -196,10 +388,10 @@ function buildBiWheelSummary(innerPlanets, outerPlanets, crossAspects, mode, loc
196
388
  square: "□", sextile: "⚹",
197
389
  };
198
390
  const top5 = crossAspects.slice(0, 5).map((ca) => `• ${capitalize(ca.planet1)} ${ASPECT_SYMBOL[ca.type] ?? ca.type} ${capitalize(ca.planet2)} (${ca.orb.toFixed(1)}° orb${ca.applying ? ", applying" : ""})`);
199
- const modeLabel = mode === "synastry" ? "Synastry" : "Transit";
200
- const label1 = mode === "synastry" ? "Person 1 (inner)" : "Natal (inner)";
201
- const label2 = mode === "synastry" ? "Person 2 (outer)" : "Transit (outer)";
202
- return (`**${modeLabel} Bi-Wheel**\n\n` +
391
+ const meta = MODE_META[mode];
392
+ const label1 = meta.innerRole;
393
+ const label2 = meta.outerRole;
394
+ return (`**${meta.modeLabel} Bi-Wheel**\n\n` +
203
395
  `${label1}: ${location1} — ${innerBasics || "chart loaded"}\n` +
204
396
  `${label2}: ${location2}\n\n` +
205
397
  `**Top Cross-Aspects:**\n${top5.join("\n") || "No major cross-aspects within orb."}\n\n` +
@@ -208,12 +400,16 @@ function buildBiWheelSummary(innerPlanets, outerPlanets, crossAspects, mode, loc
208
400
  // ── Tool: explore_bi_wheel ─────────────────────────────────────────────────────
209
401
  registerTool({
210
402
  name: "explore_bi_wheel",
211
- description: "Generate an interactive bi-wheel chart for synastry (two natal charts) or transit analysis " +
212
- "(natal chart + transiting planets). In synastry mode, the inner wheel shows Person 1's natal " +
213
- "chart and the outer ring shows Person 2's natal chart. In transit mode, the inner wheel shows " +
214
- "the natal chart and the outer ring shows transiting planets for the given date. " +
403
+ description: "Generate an interactive bi-wheel chart comparing two astrological chart positions. " +
404
+ "The inner wheel is always Person 1's natal chart. The outer ring depends on mode:\n" +
405
+ " synastry — Person 2's natal chart (two-person compatibility). 1 credit.\n" +
406
+ " transit — Transiting planets for a given date. 1 credit.\n" +
407
+ "• progressed — Secondary progressed positions (person2_datetime = target date). 1 credit.\n" +
408
+ "• solar_return — Nearest solar return chart (person2_datetime = year to return for). 5 credits.\n" +
409
+ "• lunar_return — Nearest lunar return chart (person2_datetime = target month). 5 credits.\n" +
410
+ "• solar_arc — Solar arc directed positions (person2_datetime = target date). 1 credit.\n" +
215
411
  "Cross-aspects between both wheels are computed and displayed as coloured dashed lines. " +
216
- "Click any planet or cross-aspect line for interpretation.",
412
+ "Click any planet, aspect line, or house cusp for interpretation.",
217
413
  inputSchema: {
218
414
  type: "object",
219
415
  properties: {
@@ -230,14 +426,16 @@ registerTool({
230
426
  },
231
427
  person2_datetime: {
232
428
  type: "string",
233
- description: "ISO 8601 datetime for Person 2 (synastry) or the transit date (transit mode). Required.",
429
+ description: "ISO 8601 datetime for the outer wheel. Meaning depends on mode: " +
430
+ "synastry = Person 2 birth datetime; transit/progressed/solar_arc = target date; " +
431
+ "solar_return/lunar_return = any date within the target year/month.",
234
432
  },
235
- person2_latitude: { type: "number", description: "Birth latitude for Person 2 / transit location (decimal degrees)." },
236
- person2_longitude: { type: "number", description: "Birth longitude for Person 2 / transit location (decimal degrees)." },
237
- person2_timezone: { type: "string", description: "IANA timezone for Person 2 / transit (e.g. 'America/Chicago')." },
433
+ person2_latitude: { type: "number", description: "Birth latitude for Person 2 (synastry) or return/transit location. Defaults to Person 1 coords for single-person modes." },
434
+ person2_longitude: { type: "number", description: "Birth longitude for Person 2 (synastry) or return/transit location. Defaults to Person 1 coords for single-person modes." },
435
+ person2_timezone: { type: "string", description: "IANA timezone for Person 2 / outer chart (e.g. 'America/Chicago')." },
238
436
  person2_name: {
239
437
  type: "string",
240
- description: "Display name for Person 2 / Transit chart (e.g. 'Bob'). Used in the badge and summary.",
438
+ description: "Display name for Person 2 (synastry mode). Used in the badge and summary.",
241
439
  },
242
440
  location: {
243
441
  type: "string",
@@ -245,14 +443,14 @@ registerTool({
245
443
  },
246
444
  mode: {
247
445
  type: "string",
248
- enum: ["synastry", "transit"],
249
- description: "Chart mode: 'synastry' (two natal charts) or 'transit' (natal + transits). Defaults to 'synastry'.",
446
+ enum: ["synastry", "transit", "progressed", "solar_return", "lunar_return", "solar_arc"],
447
+ description: "Chart comparison mode. Defaults to 'synastry'.",
250
448
  },
251
449
  },
252
450
  required: ["person1_datetime", "person2_datetime",
253
- "person1_latitude", "person1_longitude",
254
- "person2_latitude", "person2_longitude"],
451
+ "person1_latitude", "person1_longitude"],
255
452
  },
453
+ outputSchema: OUTPUT_SCHEMA_JSON,
256
454
  annotations: {
257
455
  title: "Interactive Bi-Wheel Explorer",
258
456
  readOnlyHint: true,
@@ -278,25 +476,12 @@ registerTool({
278
476
  const lon2 = args.person2_longitude;
279
477
  const tz2 = args.person2_timezone;
280
478
  const loc1 = String(args.person1_name ?? args.location ?? `${lat1 ?? "?"},${lon1 ?? "?"}`);
281
- const loc2 = mode === "transit"
282
- ? `Transit ${dt2.slice(0, 10)}`
283
- : String(args.person2_name ?? `${lat2 ?? ""},${lon2 ?? ""}`);
284
- // Fetch both charts in parallel
285
- let innerData;
286
- let outerData;
287
- if (mode === "synastry") {
288
- [innerData, outerData] = await Promise.all([
289
- client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
290
- client.post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2, lon2, tz2)),
291
- ]);
292
- }
293
- else {
294
- // Transit mode: inner = natal (dt1), outer = transits for dt2 at natal location
295
- [innerData, outerData] = await Promise.all([
296
- client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
297
- client.post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2 ?? lat1, lon2 ?? lon1, tz2 ?? tz1)),
298
- ]);
299
- }
479
+ const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
480
+ // Fetch inner (natal) and outer (mode-dependent) charts in parallel where possible
481
+ const [innerData, outerData] = await Promise.all([
482
+ client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
483
+ fetchOuterChart(client, mode, dt1, lat1, lon1, tz1, dt2, lat2, lon2, tz2),
484
+ ]);
300
485
  const innerPlanets = normalizePlanets(innerData.planets);
301
486
  const outerPlanets = normalizePlanets(outerData.planets);
302
487
  const crossAspects = computeCrossAspects(innerPlanets, outerPlanets);
@@ -324,11 +509,11 @@ registerTool({
324
509
  // ── Tool: bi_wheel_recalculate ──────────────────────────────────────────────
325
510
  registerTool({
326
511
  name: "bi_wheel_recalculate",
327
- description: "Recalculates a bi-wheel chart (synastry, transit, or progressed mode) with new parameters.",
512
+ description: "Recalculates a bi-wheel chart with new parameters. Supports all modes: synastry, transit, progressed, solar_return, lunar_return, solar_arc.",
328
513
  inputSchema: {
329
514
  type: "object",
330
515
  properties: {
331
- mode: { type: "string", enum: ["synastry", "transit", "progressed"] },
516
+ mode: { type: "string", enum: ALL_MODES },
332
517
  person1_datetime: { type: "string" },
333
518
  person1_latitude: { type: "number" },
334
519
  person1_longitude: { type: "number" },
@@ -339,15 +524,22 @@ registerTool({
339
524
  person2_longitude: { type: "number" },
340
525
  person2_timezone: { type: "string" },
341
526
  person2_name: { type: "string" },
342
- location: { type: "string" }, // fallback
527
+ location: { type: "string" },
528
+ house_system: {
529
+ type: "string",
530
+ enum: Object.keys(HOUSE_SYSTEM_MAP),
531
+ description: "House system for the inner (natal) wheel. Defaults to placidus.",
532
+ },
343
533
  },
344
534
  required: ["mode", "person1_datetime", "person1_latitude", "person1_longitude", "person2_datetime"],
345
535
  },
536
+ outputSchema: OUTPUT_SCHEMA_JSON,
346
537
  annotations: { title: "Recalculate Bi-Wheel", readOnlyHint: true, openWorldHint: false },
347
538
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
348
539
  handler: async (args) => {
349
540
  const client = getActiveClient();
350
541
  const mode = args.mode ?? "synastry";
542
+ const houseSystem = args.house_system ?? "placidus";
351
543
  const dt1 = String(args.person1_datetime);
352
544
  const lat1 = args.person1_latitude;
353
545
  const lon1 = args.person1_longitude;
@@ -357,35 +549,16 @@ registerTool({
357
549
  const lon2 = args.person2_longitude;
358
550
  const tz2 = args.person2_timezone;
359
551
  const loc1 = String(args.person1_name ?? args.location ?? `${lat1 ?? "?"},${lon1 ?? "?"}`);
360
- const loc2 = mode === "transit" ? `Transit ${dt2.slice(0, 10)}` : String(args.person2_name ?? `${lat2 ?? ""},${lon2 ?? ""}`);
361
- let innerData;
362
- let outerData;
363
- if (mode === "synastry") {
364
- [innerData, outerData] = await Promise.all([
365
- client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
366
- client.post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2, lon2, tz2)),
367
- ]);
368
- }
369
- else if (mode === "progressed") {
370
- [innerData, outerData] = await Promise.all([
371
- client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
372
- client.post("/predictive/progressed", {
373
- subject: { birth_datetime: { iso: dt1 }, birth_location: { latitude: { decimal: lat1 ?? 0 }, longitude: { decimal: lon1 ?? 0 }, timezone: tz1 ? { iana_name: tz1 } : undefined } },
374
- configuration: { target_datetime: { iso: dt2 }, method: "secondary" }
375
- }),
376
- ]);
377
- }
378
- else {
379
- [innerData, outerData] = await Promise.all([
380
- client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1)),
381
- client.post("/ephemeris/natal-chart", buildNatalBody(dt2, lat2 ?? lat1, lon2 ?? lon1, tz2 ?? tz1)),
382
- ]);
383
- }
552
+ const loc2 = MODE_META[mode].outerLabel(dt2, args.person2_name);
553
+ const [innerData, outerData] = await Promise.all([
554
+ client.post("/ephemeris/natal-chart", buildNatalBody(dt1, lat1, lon1, tz1, houseSystem)),
555
+ fetchOuterChart(client, mode, dt1, lat1, lon1, tz1, dt2, lat2, lon2, tz2),
556
+ ]);
384
557
  const params1 = { datetime: dt1, timezone: tz1 ?? null, latitude: lat1 ?? null, longitude: lon1 ?? null, location: loc1 };
385
558
  const params2 = { datetime: dt2, timezone: tz2 ?? null, latitude: lat2 ?? null, longitude: lon2 ?? null, location: loc2 };
386
559
  const payload = buildBiWheelPayload(innerData, outerData, mode, params1, params2);
387
560
  return {
388
- content: [{ type: "text", text: JSON.stringify({ ...payload, server_version: SERVER_VERSION }) }],
561
+ content: [{ type: "text", text: JSON.stringify({ ...payload, house_system: houseSystem, server_version: SERVER_VERSION }) }],
389
562
  };
390
563
  },
391
564
  });
@@ -393,22 +566,23 @@ registerTool({
393
566
  registerTool({
394
567
  name: "bi_wheel_on_cross_aspect_click",
395
568
  description: "Event handler called when the user clicks a cross-aspect line in the bi-wheel. " +
396
- "Returns a synastry or transit interpretation of that aspect between the two wheels. " +
569
+ "Returns an interpretation of that aspect contextualised by chart mode (synastry, transit, progressed, etc.). " +
397
570
  "This tool is called automatically by the bi-wheel UI; you do not need to call it directly.",
398
571
  inputSchema: {
399
572
  type: "object",
400
573
  properties: {
401
574
  planet1: { type: "string", description: "Planet name from the inner (natal) wheel" },
402
575
  wheel1: { type: "string", enum: ["inner", "outer"], description: "Which wheel planet1 belongs to" },
403
- planet2: { type: "string", description: "Planet name from the outer (synastry/transit) wheel" },
576
+ planet2: { type: "string", description: "Planet name from the outer wheel" },
404
577
  wheel2: { type: "string", enum: ["inner", "outer"], description: "Which wheel planet2 belongs to" },
405
578
  aspect_type: { type: "string", description: "Aspect type: conjunction, opposition, trine, square, sextile" },
406
579
  orb: { type: "number", description: "Orb in degrees" },
407
580
  applying: { type: "boolean", description: "True if the aspect is applying (tightening)" },
408
- mode: { type: "string", enum: ["synastry", "transit"], description: "Chart mode (synastry or transit). Determines interpretation style." },
581
+ mode: { type: "string", enum: ALL_MODES, description: "Chart mode. Determines interpretation style." },
409
582
  },
410
583
  required: ["planet1", "planet2", "aspect_type"],
411
584
  },
585
+ outputSchema: OUTPUT_SCHEMA_JSON,
412
586
  annotations: { title: "Cross-Aspect Interpretation", readOnlyHint: true, openWorldHint: false },
413
587
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
414
588
  handler: async (args) => {
@@ -438,30 +612,32 @@ registerTool({
438
612
  type: "object",
439
613
  properties: {
440
614
  planet: { type: "string", description: "Planet name (e.g. 'saturn')" },
441
- wheel: { type: "string", enum: ["inner", "outer"], description: "Which wheel (inner=natal/person1, outer=synastry/transit)" },
615
+ wheel: { type: "string", enum: ["inner", "outer"], description: "Which wheel (inner=natal/person1, outer=mode-dependent)" },
442
616
  longitude: { type: "number", description: "Ecliptic longitude in degrees" },
443
617
  sign: { type: "string", description: "Zodiac sign" },
444
618
  house: { type: "number", description: "House number (1–12) if available" },
445
619
  retrograde: { type: "boolean", description: "Whether the planet is retrograde" },
446
- mode: { type: "string", enum: ["synastry", "transit"], description: "Chart mode" },
620
+ mode: { type: "string", enum: ALL_MODES, description: "Chart mode" },
447
621
  },
448
622
  required: ["planet", "wheel", "longitude"],
449
623
  },
624
+ outputSchema: OUTPUT_SCHEMA_JSON,
450
625
  annotations: { title: "Bi-Wheel Planet Interpretation", readOnlyHint: true, openWorldHint: false },
451
626
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
452
627
  handler: async (args) => {
453
628
  const planet = String(args.planet);
454
629
  const wheel = args.wheel ?? "inner";
630
+ const mode = args.mode ?? "synastry";
455
631
  const sign = args.sign ? String(args.sign) : zodSignFromLon(Number(args.longitude));
456
632
  const houseNum = args.house != null ? Number(args.house) : null;
457
633
  const house = houseNum != null && houseNum > 0 ? ` in the ${ordinal(houseNum)} house` : "";
458
- const wheelLabel = wheel === "inner" ? "Natal / Person 1" : "Synastry / Transit";
634
+ const wheelLabel = wheel === "inner" ? MODE_META[mode].innerRole : MODE_META[mode].outerRole;
459
635
  const deg = `${Math.floor(Number(args.longitude) % 30)}° ${sign}`;
460
636
  return {
461
637
  content: [{
462
638
  type: "text",
463
639
  text: `**${capitalize(planet)} at ${deg}${house}** *(${wheelLabel})*\n\n` +
464
- interpretBiWheelPlanet(planet, sign, houseNum ?? 0, wheel, Boolean(args.retrograde)),
640
+ interpretBiWheelPlanet(planet, sign, houseNum ?? 0, wheel, Boolean(args.retrograde), mode),
465
641
  }],
466
642
  };
467
643
  },
@@ -555,7 +731,24 @@ function interpretBiWheelPlanet(planet, sign, house, wheel, retrograde = false,
555
731
  return (`**Transiting Planet:** Transiting ${capitalize(planet)} represents ${theme}. Moving through ${capitalize(sign)}, it carries the quality of ${signQuality}.${houseCtx}${retroCtx}\n\n` +
556
732
  `As a **transit**, this planet is temporarily activating the natal chart. Its influence is current and time-limited — watch for events, shifts in awareness, or opportunities related to its themes in the houses and planets it touches.`);
557
733
  }
734
+ else if (mode === "progressed") {
735
+ return (`**Progressed Planet:** Progressed ${capitalize(planet)} in ${capitalize(sign)} shows how the natal ${capitalize(planet)} archetype has evolved through lived experience.${houseCtx}${retroCtx}\n\n` +
736
+ `Unlike transits, progressed planets move slowly — a sign ingress can take years to unfold. This position describes a chapter of inner development: the themes of ${capitalize(planet)} are now being expressed with the colouring of ${signQuality}. Aspects from this progressed position to natal points mark turning points in that developmental arc.`);
737
+ }
738
+ else if (mode === "solar_arc") {
739
+ return (`**Solar Arc ${capitalize(planet)}:** This directed position represents natal ${capitalize(planet)} advanced by approximately 1° per year of life.${houseCtx}${retroCtx}\n\n` +
740
+ `Solar arc directions symbolise the symbolic maturation of each natal archetype. Where the directed ${capitalize(planet)} now falls — and especially which natal planets or angles it contacts — marks a significant chapter in the long development of ${theme}. Conjunctions, squares, and oppositions to natal points are the most potent activation signals.`);
741
+ }
742
+ else if (mode === "solar_return") {
743
+ return (`**Solar Return ${capitalize(planet)}:** In this solar return chart, ${capitalize(planet)} falls at ${Math.floor((Number(sign) || 0) % 30)}° ${capitalize(sign)}.${houseCtx}${retroCtx}\n\n` +
744
+ `Solar return planets describe themes that will be especially prominent during this **solar year** — from this birthday to the next. ${capitalize(planet)} in the return chart highlights where the energy of ${theme} will be most active, especially in the house it occupies and through contacts to natal planets.`);
745
+ }
746
+ else if (mode === "lunar_return") {
747
+ return (`**Lunar Return ${capitalize(planet)}:** In this lunar return chart, ${capitalize(planet)} sets the tone for the coming **lunar month**.${houseCtx}${retroCtx}\n\n` +
748
+ `Lunar returns reset monthly as the Moon returns to its natal degree. ${capitalize(planet)}'s position here — through ${signQuality} — shapes the emotional and circumstantial flavour of the next four weeks. Contacts to natal planets or angles sharpen its influence considerably.`);
749
+ }
558
750
  else {
751
+ // synastry fallback
559
752
  return (`**Synastry Planet:** ${capitalize(planet)} represents ${theme}. In ${capitalize(sign)}, this manifests through ${signQuality}.${houseCtx}${retroCtx}\n\n` +
560
753
  `This is the **outer person's natal ${capitalize(planet)}** — when this planet makes contact with the inner chart, it activates the natal themes of both parties simultaneously, creating a dynamic of mutual recognition and activation.`);
561
754
  }
@@ -584,8 +777,11 @@ const CROSS_ASPECT_NATURE = {
584
777
  };
585
778
  function interpretCrossAspect(p1, p2, type, applying, mode) {
586
779
  const natures = CROSS_ASPECT_NATURE[type.toLowerCase()];
780
+ // Single-person time-based modes share the transit nature text; synastry/solar arc use synastry
781
+ const isTimeBased = mode === "transit" || mode === "progressed" || mode === "solar_arc" ||
782
+ mode === "solar_return" || mode === "lunar_return";
587
783
  const nature = natures
588
- ? (mode === "transit" ? natures.transit : natures.synastry)
784
+ ? (isTimeBased ? natures.transit : natures.synastry)
589
785
  : "creates a notable connection across the two charts.";
590
786
  let timing;
591
787
  if (mode === "transit") {
@@ -593,6 +789,17 @@ function interpretCrossAspect(p1, p2, type, applying, mode) {
593
789
  ? "This aspect is **applying** — its influence is building and moving toward maximum expression. The themes involved are intensifying now."
594
790
  : "This aspect is **separating** — its peak has passed; the energy is waning and the lessons of this transit are moving into integration.";
595
791
  }
792
+ else if (mode === "progressed" || mode === "solar_arc") {
793
+ timing = applying
794
+ ? "This aspect is **applying** — progressed and directed aspects move slowly, often unfolding over months or years before reaching exactitude. The themes it represents are gradually intensifying."
795
+ : "This aspect is **separating** — it has passed its peak of exactitude and is now in a phase of integration. The defining moment of this developmental aspect lies in the recent past.";
796
+ }
797
+ else if (mode === "solar_return") {
798
+ timing = "As a **solar return aspect**, this connection is active for the entire solar year from this birthday to the next. It describes a consistent theme or dynamic that will colour the whole year rather than arriving as a dated event.";
799
+ }
800
+ else if (mode === "lunar_return") {
801
+ timing = "As a **lunar return aspect**, this connection is active for the coming lunar month. It shapes the emotional atmosphere and events of the next four weeks before the next lunar return resets the cycle.";
802
+ }
596
803
  else {
597
804
  timing = "As a **synastry aspect**, this connection exists as a permanent feature of this relationship dynamic — it neither builds nor fades, but remains as a constant undercurrent in how these two people interact.";
598
805
  }
@@ -611,10 +818,11 @@ registerTool({
611
818
  house_number: { type: "number", description: "House number (1–12)" },
612
819
  sign: { type: "string", description: "Sign on the house cusp (inner natal wheel)" },
613
820
  cusp_longitude: { type: "number", description: "Cusp longitude in degrees" },
614
- mode: { type: "string", enum: ["synastry", "transit"], description: "Chart mode" },
821
+ mode: { type: "string", enum: ALL_MODES, description: "Chart mode" },
615
822
  },
616
823
  required: ["house_number"],
617
824
  },
825
+ outputSchema: OUTPUT_SCHEMA_JSON,
618
826
  annotations: { title: "Bi-Wheel House Interpretation", readOnlyHint: true, openWorldHint: false },
619
827
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app"] } },
620
828
  handler: async (args) => {
@@ -625,14 +833,19 @@ registerTool({
625
833
  const signFlavor = sign
626
834
  ? `\n\n${capitalize(sign)} on this cusp emphasizes ${SIGN_HOUSE_FLAVOR[sign.toLowerCase()] ?? "its natural qualities"} in the matters of this house.`
627
835
  : "";
628
- const modeNote = mode === "transit"
629
- ? "\n\nWatch for transiting planets moving through this house they will activate and bring events related to these life areas during their passage."
630
- : "\n\nIn synastry, planets from the outer chart falling in this house create experiences related to these themes in the relationship — often powerfully felt by the person whose house is being activated.";
836
+ const HOUSE_MODE_NOTES = {
837
+ synastry: "\n\nIn synastry, planets from the outer natal chart falling in this house create experiences related to these themes in the relationship — often powerfully felt by the person whose house is being activated.",
838
+ transit: "\n\nWatch for transiting planets moving through this house they will activate and bring events related to these life areas during their passage.",
839
+ progressed: "\n\nAs progressed planets move through this house, they gradually shift the inner developmental focus toward these life themes. Natal house boundaries mark important life-chapter transitions in secondary progression work.",
840
+ solar_arc: "\n\nSolar arc directed planets entering this house signal a new chapter of life experience in these domains. The ingress year often corresponds to a major shift in the themes of this house.",
841
+ solar_return: "\n\nPlanets in this house in the solar return chart will be emphasised life areas for the coming year. A concentration of solar return planets here intensifies activity and attention in this domain.",
842
+ lunar_return: "\n\nPlanets in this house in the lunar return chart highlight the month's dominant emotional themes and practical focus areas. The lunar return ascendant and its house emphasis set the tone for the entire lunar cycle.",
843
+ };
631
844
  return {
632
845
  content: [{
633
846
  type: "text",
634
847
  text: `**House ${num}${sign ? ` — ${capitalize(sign)} on the cusp` : ""}** *(Inner Natal Wheel)*\n\n` +
635
- meaning + signFlavor + modeNote,
848
+ meaning + signFlavor + HOUSE_MODE_NOTES[mode],
636
849
  }],
637
850
  };
638
851
  },
@@ -648,9 +861,9 @@ registerTool({
648
861
  inputSchema: {
649
862
  type: "object",
650
863
  properties: {
651
- mode: { type: "string", enum: ["synastry", "transit"], description: "Chart mode" },
652
- person1_name: { type: "string", description: "Name or label for the inner wheel (natal/person 1)" },
653
- person2_name: { type: "string", description: "Name or label for the outer wheel (synastry person 2 or transit date)" },
864
+ mode: { type: "string", enum: ALL_MODES, description: "Chart mode" },
865
+ person1_name: { type: "string", description: "Name or label for the inner wheel (natal / person 1)" },
866
+ person2_name: { type: "string", description: "Name or label for the outer wheel (person 2, transit date, progression target, etc.)" },
654
867
  cross_aspects: {
655
868
  type: "array",
656
869
  description: "Array of cross-aspects from the bi-wheel payload (up to 30, sorted by orb)",
@@ -669,12 +882,14 @@ registerTool({
669
882
  },
670
883
  required: ["mode"],
671
884
  },
885
+ outputSchema: OUTPUT_SCHEMA_JSON,
672
886
  annotations: { title: "Bi-Wheel Synopsis", readOnlyHint: true, openWorldHint: false },
673
887
  _meta: { ui: { resourceUri: BI_WHEEL_RESOURCE_URI, visibility: ["app", "model"] } },
674
888
  handler: async (args) => {
675
889
  const mode = args.mode ?? "synastry";
676
- const label1 = args.person1_name ? String(args.person1_name) : (mode === "transit" ? "Natal Chart" : "Person 1");
677
- const label2 = args.person2_name ? String(args.person2_name) : (mode === "transit" ? "Transits" : "Person 2");
890
+ const meta = MODE_META[mode];
891
+ const label1 = args.person1_name ? String(args.person1_name) : meta.innerRole;
892
+ const label2 = args.person2_name ? String(args.person2_name) : meta.outerRole;
678
893
  const topN = args.top_n ? Number(args.top_n) : 8;
679
894
  const rawAspects = args.cross_aspects ?? [];
680
895
  const aspects = rawAspects.slice(0, topN);
@@ -684,36 +899,55 @@ registerTool({
684
899
  const harmonious = aspects.filter((a) => ["trine", "sextile"].includes((a.aspect_type ?? a.type ?? "").toLowerCase()));
685
900
  const dynamic = aspects.filter((a) => ["square", "opposition"].includes((a.aspect_type ?? a.type ?? "").toLowerCase()));
686
901
  const fusing = aspects.filter((a) => (a.aspect_type ?? a.type ?? "").toLowerCase() === "conjunction");
902
+ // For time-based modes, show applying/separating; for relational modes show tightness
903
+ const isTimeBased = mode !== "synastry";
687
904
  const lines = aspects.map((ca) => {
688
905
  const type = ca.aspect_type ?? ca.type ?? "aspect";
689
906
  const sym = ASPECT_SYM[type.toLowerCase()] ?? type;
690
- const dir = mode === "transit"
907
+ const dir = isTimeBased
691
908
  ? (ca.applying ? "↗ applying" : "↘ separating")
692
909
  : (ca.orb <= 1 ? "exact" : ca.orb <= 3 ? "tight" : "wide");
693
910
  return `• ${capitalize(ca.planet1)} ${sym} ${capitalize(ca.planet2)} (${ca.orb.toFixed(1)}° — ${dir})`;
694
911
  });
695
- const themeSummary = mode === "synastry"
696
- ? [
697
- fusing.length > 0 ? `**${fusing.length} conjunction${fusing.length > 1 ? "s" : ""}** — areas of deep merging and fusion between the two charts.` : "",
698
- harmonious.length > 0 ? `**${harmonious.length} flowing aspect${harmonious.length > 1 ? "s" : ""}** (trines/sextiles) — natural ease, support, and compatibility.` : "",
699
- dynamic.length > 0 ? `**${dynamic.length} dynamic aspect${dynamic.length > 1 ? "s" : ""}** (squares/oppositions) — areas of growth, friction, and mutual challenge.` : "",
700
- ].filter(Boolean).join("\n")
701
- : [
702
- fusing.length > 0 ? `**${fusing.length} conjunction${fusing.length > 1 ? "s" : ""}** — direct activation of natal points; peak transit influence.` : "",
703
- harmonious.length > 0 ? `**${harmonious.length} flowing transit${harmonious.length > 1 ? "s" : ""}** — periods of ease, opportunity, and support.` : "",
704
- dynamic.length > 0 ? `**${dynamic.length} challenging transit${dynamic.length > 1 ? "s" : ""}** — active pressure points requiring conscious engagement.` : "",
705
- ].filter(Boolean).join("\n");
706
- const intro = mode === "synastry"
707
- ? `**Synastry Overview: ${label1} × ${label2}**\n\nThis synastry bi-wheel compares two natal charts to reveal the dynamics of interaction and connection between these two individuals. The cross-aspects show how their planetary energies align, challenge, and transform each other.`
708
- : `**Transit Overview: ${label1} × ${label2}**\n\nThis transit bi-wheel overlays current planetary positions onto the natal chart, revealing which natal points are being activated right now and the nature of those influences.`;
912
+ // Theme summary copy varies by mode
913
+ const synastryTheme = [
914
+ fusing.length > 0 ? `**${fusing.length} conjunction${fusing.length > 1 ? "s" : ""}** — areas of deep merging and fusion between the two charts.` : "",
915
+ harmonious.length > 0 ? `**${harmonious.length} flowing aspect${harmonious.length > 1 ? "s" : ""}** (trines/sextiles) — natural ease, support, and compatibility.` : "",
916
+ dynamic.length > 0 ? `**${dynamic.length} dynamic aspect${dynamic.length > 1 ? "s" : ""}** (squares/oppositions) — areas of growth, friction, and mutual challenge.` : "",
917
+ ].filter(Boolean).join("\n");
918
+ const timeBasedTheme = [
919
+ fusing.length > 0 ? `**${fusing.length} conjunction${fusing.length > 1 ? "s" : ""}** — direct activation of natal points; peak influence for this ${meta.modeLabel.toLowerCase()}.` : "",
920
+ harmonious.length > 0 ? `**${harmonious.length} flowing aspect${harmonious.length > 1 ? "s" : ""}** (trines/sextiles) — periods of ease, opportunity, and natural support.` : "",
921
+ dynamic.length > 0 ? `**${dynamic.length} dynamic aspect${dynamic.length > 1 ? "s" : ""}** (squares/oppositions) — active pressure points requiring conscious engagement.` : "",
922
+ ].filter(Boolean).join("\n");
923
+ const themeSummary = mode === "synastry" ? synastryTheme : timeBasedTheme;
924
+ const SYNOPSIS_INTRO = {
925
+ synastry: `**Synastry Overview: ${label1} × ${label2}**\n\n` +
926
+ `This synastry bi-wheel compares two natal charts to reveal the dynamics of interaction and connection between these two individuals. The cross-aspects show how their planetary energies align, challenge, and transform each other.`,
927
+ transit: `**Transit Overview: ${label1} × ${label2}**\n\n` +
928
+ `This transit bi-wheel overlays current planetary positions onto the natal chart, revealing which natal points are being activated right now and the nature of those influences.`,
929
+ progressed: `**Secondary Progressions Overview: ${label1} × ${label2}**\n\n` +
930
+ `This progression bi-wheel places the natal chart inside and secondary progressed positions in the outer ring. Progressed planets reflect the gradual inner maturation of natal archetypal themes using the day-for-a-year method. Cross-aspects from progressed to natal points mark the defining chapters of inner development.`,
931
+ solar_arc: `**Solar Arc Directions Overview: ${label1} × ${label2}**\n\n` +
932
+ `This solar arc bi-wheel shows natal positions inside and solar arc directed positions in the outer ring. Solar arc direction advances every natal planet by approximately 1° per year of life (Naibod rate). Contacts from directed positions to natal points — especially conjunctions — mark pivotal life moments.`,
933
+ solar_return: `**Solar Return Overview: ${label1} × ${label2}**\n\n` +
934
+ `This solar return bi-wheel compares the natal chart against the solar return for the target year. The outer ring shows where planets fall at the exact moment the Sun returns to its natal degree, describing the dominant themes and developmental focus of the coming solar year.`,
935
+ lunar_return: `**Lunar Return Overview: ${label1} × ${label2}**\n\n` +
936
+ `This lunar return bi-wheel compares the natal chart against the lunar return. The outer ring shows planetary positions when the Moon returned to its natal degree, setting the emotional tone and circumstantial focus of the coming lunar month.`,
937
+ };
938
+ const SYNOPSIS_CLOSING = {
939
+ synastry: "\n\nClick any planet or aspect line in the bi-wheel for a deeper interpretation of individual connections.",
940
+ transit: "\n\nClick any planet or cross-aspect line for a detailed transit interpretation. Navigate the date forward or backward using the step controls to track how transits evolve.",
941
+ progressed: "\n\nClick any progressed planet or cross-aspect line for a detailed interpretation. The most significant turning points occur when a progressed planet reaches exactitude with a natal planet or angle.",
942
+ solar_arc: "\n\nClick any directed planet or cross-aspect line for interpretation. Solar arc contacts to natal angles (ASC, MC, IC, DSC) are among the most powerful triggers in predictive astrology.",
943
+ solar_return: "\n\nClick any solar return planet or cross-aspect line for interpretation. The solar return ascendant and its ruler are the master theme-setters for the year.",
944
+ lunar_return: "\n\nClick any lunar return planet or cross-aspect line for interpretation. The lunar return Moon's house placement describes this month's emotional centre of gravity.",
945
+ };
709
946
  const body = lines.length > 0
710
947
  ? `\n\n**Top Cross-Aspects by Orb:**\n${lines.join("\n")}\n\n**Thematic Summary:**\n${themeSummary || "No major aspects within standard orbs."}`
711
948
  : "\n\nNo major cross-aspects are within orb at this time.";
712
- const closing = mode === "synastry"
713
- ? "\n\nClick any planet or aspect line in the bi-wheel for a deeper interpretation of individual connections."
714
- : "\n\nClick any planet or cross-aspect line for a detailed transit interpretation. Navigate the date forward or backward using the step controls to track how transits evolve.";
715
949
  return {
716
- content: [{ type: "text", text: intro + body + closing }],
950
+ content: [{ type: "text", text: SYNOPSIS_INTRO[mode] + body + SYNOPSIS_CLOSING[mode] }],
717
951
  };
718
952
  },
719
953
  });