@openephemeris/mcp-server 3.24.0 → 4.0.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 (56) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +40 -2
  4. package/dist/analytics.js +37 -5
  5. package/dist/backend/client.d.ts +7 -0
  6. package/dist/backend/client.js +39 -38
  7. package/dist/index.js +64 -2
  8. package/dist/oauth/session-utils.d.ts +42 -18
  9. package/dist/oauth/session-utils.js +79 -0
  10. package/dist/server-sse.js +114 -14
  11. package/dist/tools/apps/bazi-app.js +12 -26
  12. package/dist/tools/apps/bi-wheel-app.js +2 -2
  13. package/dist/tools/apps/bodygraph-app.d.ts +5 -5
  14. package/dist/tools/apps/bodygraph-app.js +157 -210
  15. package/dist/tools/apps/chart-wheel-app.js +5 -3
  16. package/dist/tools/apps/location-tools.js +167 -18
  17. package/dist/tools/apps/moon-phase-app.js +10 -3
  18. package/dist/tools/apps/transit-timeline-app.js +6 -4
  19. package/dist/tools/apps/vedic-chart-app.js +15 -49
  20. package/dist/tools/datetime.d.ts +65 -0
  21. package/dist/tools/datetime.js +153 -0
  22. package/dist/tools/index.d.ts +45 -2
  23. package/dist/tools/index.js +78 -2
  24. package/dist/tools/specialized/account.d.ts +1 -0
  25. package/dist/tools/specialized/account.js +100 -0
  26. package/dist/tools/specialized/acg.js +16 -14
  27. package/dist/tools/specialized/bazi.d.ts +7 -1
  28. package/dist/tools/specialized/bazi.js +86 -19
  29. package/dist/tools/specialized/bi_wheel.js +5 -4
  30. package/dist/tools/specialized/chart_wheel.js +5 -8
  31. package/dist/tools/specialized/comparative.js +13 -5
  32. package/dist/tools/specialized/electional.js +7 -7
  33. package/dist/tools/specialized/ephemeris_core.js +13 -8
  34. package/dist/tools/specialized/ephemeris_extended.js +27 -17
  35. package/dist/tools/specialized/hd_bodygraph.js +8 -10
  36. package/dist/tools/specialized/hd_cycles.js +7 -14
  37. package/dist/tools/specialized/hd_group.js +20 -13
  38. package/dist/tools/specialized/human_design.js +11 -17
  39. package/dist/tools/specialized/moon.js +8 -2
  40. package/dist/tools/specialized/natal.js +7 -9
  41. package/dist/tools/specialized/progressed.js +12 -8
  42. package/dist/tools/specialized/relocation.js +9 -3
  43. package/dist/tools/specialized/returns.js +23 -11
  44. package/dist/tools/specialized/synastry.js +17 -6
  45. package/dist/tools/specialized/transits.js +9 -5
  46. package/dist/tools/specialized/vedic.js +5 -3
  47. package/dist/tools/specialized/venus_star_points.js +14 -9
  48. package/dist/ui/bazi.html +1063 -1049
  49. package/dist/ui/bi-wheel.html +4188 -4128
  50. package/dist/ui/bodygraph.html +3673 -3616
  51. package/dist/ui/chart-wheel.html +3769 -3713
  52. package/dist/ui/moon-phase.html +3219 -3153
  53. package/dist/ui/transit-timeline.html +199 -170
  54. package/dist/ui/vedic-chart.html +1116 -1098
  55. package/package.json +3 -2
  56. package/smithery.yaml +1 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The datetime contract, stated once for the whole tool surface.
3
+ *
4
+ * A datetime that names a clock time but not a zone does not name an instant.
5
+ * The API used to read such a value as UTC, so a birth time of 09:01 in Chicago
6
+ * was computed as 09:01 UTC — five hours early. Every planet keeps its sign at
7
+ * that error scale, so the answer looked right while the Ascendant was two signs
8
+ * off and every house placement was wrong.
9
+ *
10
+ * The rules, identical here and in the Go engine
11
+ * (apps/api/go-sidecar/internal/api/handlers/datetime_contract.go):
12
+ *
13
+ * 1. A datetime WITH a clock time must carry its zone — either a `Z`/`±HH:MM`
14
+ * suffix on the string, or a companion `timezone` argument.
15
+ * 2. A date-only value ("1987-07-15") has no clock time to be ambiguous about
16
+ * and resolves to 12:00 UTC.
17
+ * 3. Nothing in this layer ever appends a `Z` to a zone-less value. Doing so
18
+ * asserts a fact the caller never stated.
19
+ *
20
+ * Violations are rejected here rather than at the API so the model gets the
21
+ * correction immediately and no credit is spent on a chart that is wrong.
22
+ */
23
+ /** Matches a `Z`, `±HH:MM`, or `±HHMM` zone suffix. */
24
+ const ZONE_SUFFIX = /([Zz]|[+-]\d{2}:?\d{2})$/;
25
+ /** Matches an ISO value that carries a clock time (as opposed to a bare date). */
26
+ const HAS_CLOCK_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
27
+ /** True when `value` states a clock time but not the zone that clock is in. */
28
+ export function isNaiveClockTime(value) {
29
+ const s = (value ?? "").trim();
30
+ return HAS_CLOCK_TIME.test(s) && !ZONE_SUFFIX.test(s);
31
+ }
32
+ /** True when `value` already carries its own zone. */
33
+ export function hasZoneSuffix(value) {
34
+ return ZONE_SUFFIX.test((value ?? "").trim());
35
+ }
36
+ // ─── Canonical parameter documentation ───────────────────────────────────────
37
+ // Every tool that accepts a datetime uses these strings verbatim, so the contract
38
+ // reads identically everywhere. `test/datetime-contract.test.ts` fails if a tool
39
+ // description grows an ISO example that violates it.
40
+ /** The canonical description for a birth / chart-moment datetime parameter. */
41
+ // Kept deliberately terse: this string is repeated on every datetime-accepting
42
+ // tool, so each character costs ~40x across the surface and is re-sent on every
43
+ // model pass. Every RULE stays; the rationale for the rule lives in the module
44
+ // docstring above, which the model never sees.
45
+ export const DATETIME_DESC = "ISO 8601 datetime that states its zone. Either put the zone on the value " +
46
+ "('1987-07-15T09:01:00-05:00', or '...T14:01:00Z' for UTC), or pass local " +
47
+ "wall-clock time plus the `timezone` argument. A zone-less time is REJECTED. " +
48
+ "A date with no time ('1987-07-15') resolves to 12:00 UTC.";
49
+ /** The canonical description for the companion `timezone` parameter. */
50
+ export const TIMEZONE_DESC = "IANA timezone name for the birth/observation location, e.g. 'America/Chicago'. " +
51
+ "Required when the datetime has no 'Z' or ±HH:MM offset; ignored when it does. " +
52
+ "Historical DST is resolved correctly.";
53
+ /** The canonical description for a search-window date parameter (date or datetime). */
54
+ export const WINDOW_DATE_DESC = "ISO 8601 date ('2026-01-01') or a zoned datetime ('2026-01-01T00:00:00Z'). " +
55
+ "A date with no time resolves to 12:00 UTC. A time without a 'Z' or ±HH:MM offset " +
56
+ "is rejected as ambiguous.";
57
+ /** The `timezone` property to spread into a tool's inputSchema. */
58
+ export const TIMEZONE_PROPERTY = {
59
+ type: "string",
60
+ description: TIMEZONE_DESC,
61
+ };
62
+ /** Build a `<prefix>_timezone` property with a tailored example. */
63
+ export function timezoneProperty(label, example = "America/Chicago") {
64
+ return {
65
+ type: "string",
66
+ description: `IANA timezone name for ${label}, e.g. '${example}'. ` +
67
+ "Required when that datetime has no 'Z' or ±HH:MM offset; ignored when it does.",
68
+ };
69
+ }
70
+ // ─── Enforcement ─────────────────────────────────────────────────────────────
71
+ /**
72
+ * The rejection message. Names both remedies, rewriting the caller's own value
73
+ * into each — the commonest failure is not realising the value was ambiguous.
74
+ */
75
+ export function ambiguousDatetimeMessage(field, value, timezoneField = "timezone") {
76
+ const v = (value ?? "").trim();
77
+ return (`\`${field}\` is "${v}", which states a clock time but not the zone that clock is in, ` +
78
+ `so it does not name a moment. OpenEphemeris will not guess. Fix it either way:\n` +
79
+ ` 1. Put the zone on the datetime: ${field}="${v}-05:00" for a local time, ` +
80
+ `or ${field}="${v}Z" if it really is UTC.\n` +
81
+ ` 2. Keep the local wall-clock time and name the zone: ` +
82
+ `${field}="${v}", ${timezoneField}="America/Chicago".\n` +
83
+ `An unstated zone moves the Ascendant by ~15° per hour and shifts every house placement.`);
84
+ }
85
+ /**
86
+ * Throws unless `value` names an unambiguous instant.
87
+ *
88
+ * Call this in every tool handler before building a request body. Passing a
89
+ * `timezone` satisfies the contract for a zone-less value; the zone itself is
90
+ * resolved server-side (or by `localToUtcIso` for the `*_utc` endpoints).
91
+ */
92
+ export function assertZonedDatetime(field, value, timezone, timezoneField = "timezone") {
93
+ if (typeof value !== "string" || value.trim() === "")
94
+ return; // required-ness is checked elsewhere
95
+ if (!isNaiveClockTime(value))
96
+ return;
97
+ if (typeof timezone === "string" && timezone.trim() !== "")
98
+ return;
99
+ throw new Error(ambiguousDatetimeMessage(field, value, timezoneField));
100
+ }
101
+ /**
102
+ * Convert a datetime to a UTC ISO 8601 string for the endpoints whose field is
103
+ * named `*_utc` and whose Go type is a strict RFC 3339 `time.Time`.
104
+ *
105
+ * Unlike the four ad-hoc `ensureTimezone` helpers this replaces, it never
106
+ * appends a bare "Z" to a zone-less value — it throws instead. Appending Z
107
+ * asserts the input was UTC, which is exactly the silent assumption that made
108
+ * the original defect invisible.
109
+ */
110
+ export function localToUtcIso(field, dt, tz, timezoneField = "timezone") {
111
+ const value = (dt ?? "").trim();
112
+ if (hasZoneSuffix(value)) {
113
+ // Normalise `±HHMM` to `±HH:MM` for Go's RFC 3339 parser.
114
+ return value.replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
115
+ }
116
+ if (!isNaiveClockTime(value)) {
117
+ // Date-only or unrecognised: leave it to the server to interpret or reject.
118
+ return value;
119
+ }
120
+ if (!tz || tz.trim() === "") {
121
+ throw new Error(ambiguousDatetimeMessage(field, value, timezoneField));
122
+ }
123
+ const [datePart, timePart = "00:00:00"] = value.split(/[T ]/);
124
+ const [year, month, day] = datePart.split("-").map(Number);
125
+ const [hour, min, sec = 0] = timePart.split(":").map(Number);
126
+ // Treat the components as local wall-clock time in `tz`, then shift by the
127
+ // offset Intl reports for that instant. Iterate twice: the offset at the UTC
128
+ // candidate can differ from the offset at the true instant across a DST edge.
129
+ const offsetMsAt = (utcMs) => {
130
+ const parts = new Intl.DateTimeFormat("en-US", {
131
+ timeZone: tz,
132
+ hour12: false,
133
+ year: "numeric",
134
+ month: "2-digit",
135
+ day: "2-digit",
136
+ hour: "2-digit",
137
+ minute: "2-digit",
138
+ second: "2-digit",
139
+ }).formatToParts(new Date(utcMs));
140
+ const get = (t) => Number(parts.find((p) => p.type === t)?.value);
141
+ const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
142
+ return asUtc - utcMs;
143
+ };
144
+ let utcMs = Date.UTC(year, month - 1, day, hour, min, sec);
145
+ utcMs -= offsetMsAt(utcMs);
146
+ utcMs = Date.UTC(year, month - 1, day, hour, min, sec) - offsetMsAt(utcMs);
147
+ const iso = new Date(utcMs).toISOString();
148
+ if (Number.isNaN(utcMs)) {
149
+ throw new Error(`\`${field}\` could not be resolved: "${value}" in timezone "${tz}". ` +
150
+ "Check the datetime format and that the IANA zone name is spelled correctly.");
151
+ }
152
+ return iso.replace(/\.000Z$/, "Z");
153
+ }
@@ -40,12 +40,55 @@ export interface ToolDefinition {
40
40
  stdioOnly?: boolean;
41
41
  handler: (args: any) => Promise<any>;
42
42
  }
43
+ /**
44
+ * Which slice of the registry a ListTools response advertises.
45
+ *
46
+ * - `core`: the curated everyday set (see CORE_TOOL_NAMES).
47
+ * - `full`: every model-visible tool.
48
+ *
49
+ * This is a *view* filter, not a registration filter. Tools outside the core
50
+ * set stay registered and stay callable by name — CallTool resolves straight
51
+ * out of toolRegistry (src/index.ts, src/server-sse.ts) and never consults the
52
+ * surface. Narrowing the surface therefore costs zero capability; it only
53
+ * shrinks the tool-definition payload the model has to reason over.
54
+ *
55
+ * Registration-time filtering (a third ToolProfile) would NOT work here: the
56
+ * registry is process-global with a one-shot `toolsInitialized` latch, so the
57
+ * multi-tenant HTTP server cannot vary it per session, and an unregistered
58
+ * tool becomes uncallable rather than merely hidden.
59
+ */
60
+ export type ToolSurface = "core" | "full";
61
+ /**
62
+ * The default advertised surface: everyday astrology work, one tool per job.
63
+ *
64
+ * Selection rules:
65
+ * - every explore_* app (the visual entry point to each tradition),
66
+ * - the primary data tool per domain,
67
+ * - geocoding (location_search / timezone_resolve) so charts can be built
68
+ * from a place name without falling back to dev_read_api,
69
+ * - the dev escape hatch, which reaches 28 further endpoints via the
70
+ * allowlist and is how anything outside this set gets discovered.
71
+ *
72
+ * Everything omitted here — the Venus family, the typed BaZi derivations, the
73
+ * comparative and returns long tail, the standalone SVG renderers — remains
74
+ * fully callable by name, and is listed by `dev_list_allowed` where the
75
+ * allowlist covers it. Clients that want the whole surface advertised can ask
76
+ * for it: `?profile=full` on HTTP, `OPENEPHEMERIS_TOOLS=full` on stdio.
77
+ *
78
+ * NOTE: scripts/test-mcp-http.ts (the 6-hourly production canary) asserts
79
+ * dev_read_api, ephemeris_moon_phase, ephemeris_natal_chart and
80
+ * explore_natal_chart are present. Do not remove those four.
81
+ */
82
+ export declare const CORE_TOOL_NAMES: ReadonlySet<string>;
43
83
  /**
44
84
  * Returns tools that should be exposed to Claude in the ListTools response.
45
85
  * Filters out tools that have visibility restricted to the UI (app-only),
46
- * and — for the HTTP transport — tools marked stdioOnly.
86
+ * for the HTTP transport tools marked stdioOnly, and — when `surface` is
87
+ * "core" — everything outside CORE_TOOL_NAMES.
47
88
  */
48
- export declare function modelVisibleTools(transport?: "stdio" | "http"): ToolDefinition[];
89
+ export declare function modelVisibleTools(transport?: "stdio" | "http", surface?: ToolSurface): ToolDefinition[];
90
+ /** Parses a caller-supplied surface hint. Anything unrecognised means "core". */
91
+ export declare function parseToolSurface(raw: unknown): ToolSurface;
49
92
  export declare const toolRegistry: Record<string, ToolDefinition>;
50
93
  export declare function registerTool(tool: ToolDefinition): void;
51
94
  export type ToolProfile = "dev" | "legacy";
@@ -12,12 +12,80 @@ export const SERVER_VERSION = (() => {
12
12
  return "0.0.0-unknown";
13
13
  }
14
14
  })();
15
+ /**
16
+ * The default advertised surface: everyday astrology work, one tool per job.
17
+ *
18
+ * Selection rules:
19
+ * - every explore_* app (the visual entry point to each tradition),
20
+ * - the primary data tool per domain,
21
+ * - geocoding (location_search / timezone_resolve) so charts can be built
22
+ * from a place name without falling back to dev_read_api,
23
+ * - the dev escape hatch, which reaches 28 further endpoints via the
24
+ * allowlist and is how anything outside this set gets discovered.
25
+ *
26
+ * Everything omitted here — the Venus family, the typed BaZi derivations, the
27
+ * comparative and returns long tail, the standalone SVG renderers — remains
28
+ * fully callable by name, and is listed by `dev_list_allowed` where the
29
+ * allowlist covers it. Clients that want the whole surface advertised can ask
30
+ * for it: `?profile=full` on HTTP, `OPENEPHEMERIS_TOOLS=full` on stdio.
31
+ *
32
+ * NOTE: scripts/test-mcp-http.ts (the 6-hourly production canary) asserts
33
+ * dev_read_api, ephemeris_moon_phase, ephemeris_natal_chart and
34
+ * explore_natal_chart are present. Do not remove those four.
35
+ */
36
+ export const CORE_TOOL_NAMES = new Set([
37
+ // Interactive apps — the visual entry point to each tradition
38
+ "explore_natal_chart",
39
+ "explore_bi_wheel",
40
+ "explore_human_design",
41
+ "explore_human_design_transit",
42
+ "explore_human_design_connection",
43
+ "explore_moon_phase",
44
+ "explore_transit_timeline",
45
+ "explore_vedic_chart",
46
+ "explore_bazi_chart",
47
+ // Core ephemeris data
48
+ "ephemeris_natal_chart",
49
+ "ephemeris_planet_position",
50
+ "ephemeris_house_cusps",
51
+ "ephemeris_moon_phase",
52
+ "ephemeris_transits",
53
+ "ephemeris_synastry",
54
+ "ephemeris_retrograde_status",
55
+ "ephemeris_next_eclipse",
56
+ "ephemeris_relocation",
57
+ "ephemeris_progressed_chart",
58
+ "ephemeris_solar_return",
59
+ // Traditions
60
+ "human_design_chart",
61
+ "vedic_chart",
62
+ "chinese_bazi",
63
+ // Timing
64
+ "ephemeris_electional",
65
+ "electional_moment_analysis",
66
+ // Astrocartography
67
+ "acg_power_lines",
68
+ "acg_hits",
69
+ // Geocoding — promoted to model visibility so chart building does not have
70
+ // to route through dev_read_api /location/autocomplete
71
+ "location_search",
72
+ "timezone_resolve",
73
+ // Account + escape hatch
74
+ "account_usage",
75
+ "dev_read_api",
76
+ "dev_list_allowed",
77
+ // Auth (stdio transport only — already fenced by stdioOnly)
78
+ "auth_login",
79
+ "auth_status",
80
+ "auth_logout",
81
+ ]);
15
82
  /**
16
83
  * Returns tools that should be exposed to Claude in the ListTools response.
17
84
  * Filters out tools that have visibility restricted to the UI (app-only),
18
- * and — for the HTTP transport — tools marked stdioOnly.
85
+ * for the HTTP transport tools marked stdioOnly, and — when `surface` is
86
+ * "core" — everything outside CORE_TOOL_NAMES.
19
87
  */
20
- export function modelVisibleTools(transport = "stdio") {
88
+ export function modelVisibleTools(transport = "stdio", surface = "full") {
21
89
  return Object.values(toolRegistry).filter((t) => {
22
90
  if (transport === "http" && t.stdioOnly)
23
91
  return false;
@@ -25,9 +93,15 @@ export function modelVisibleTools(transport = "stdio") {
25
93
  // If visibility is explicitly ["app"] (no "model"), hide from model
26
94
  if (vis && !vis.includes("model") && vis.includes("app"))
27
95
  return false;
96
+ if (surface === "core" && !CORE_TOOL_NAMES.has(t.name))
97
+ return false;
28
98
  return true;
29
99
  });
30
100
  }
101
+ /** Parses a caller-supplied surface hint. Anything unrecognised means "core". */
102
+ export function parseToolSurface(raw) {
103
+ return typeof raw === "string" && raw.toLowerCase() === "full" ? "full" : "core";
104
+ }
31
105
  export const toolRegistry = {};
32
106
  export function registerTool(tool) {
33
107
  toolRegistry[tool.name] = tool;
@@ -47,6 +121,8 @@ export async function initTools(profile) {
47
121
  const resolvedProfile = (profile || process.env.OPENEPHEMERIS_PROFILE || process.env.ASTROMCP_PROFILE || "dev").toLowerCase();
48
122
  // Always register auth tools (available in all profiles).
49
123
  await import("./auth.js");
124
+ // Always register account/usage tools (0-credit, useful in all profiles).
125
+ await import("./specialized/account.js");
50
126
  // Always register the generic proxy tools (dev_read_api + dev_write_api + dev_list_allowed).
51
127
  await import("./dev.js");
52
128
  if (resolvedProfile === "dev") {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ import { registerTool } from "../index.js";
2
+ import { getActiveClient, BackendError, DASHBOARD_ACCOUNT_URL, LOGIN_SIGNUP_URL, UPGRADE_URL, WALLET_TOPUP_URL, } from "../../backend/client.js";
3
+ import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
+ // Tiers where the primary next step is a wallet top-up rather than a plan change.
5
+ const WALLET_TIERS = new Set(["explorer", "free", "payg", "wallet"]);
6
+ registerTool({
7
+ name: "account_usage",
8
+ description: "Check the user's OpenEphemeris account usage and remaining credits. " +
9
+ "Returns their plan tier, billing period, credits used / included / remaining, " +
10
+ "percent of quota used, total API calls, and subscription status with renewal date.\n\n" +
11
+ "✅ USE THIS TOOL FOR: 'How many credits do I have left?', 'What's my usage this month?', " +
12
+ "'Am I close to my limit?', 'What plan am I on?', 'How do I upgrade or top up?'\n\n" +
13
+ "CREDIT COST: Free (0 credits).\n\n" +
14
+ "EXAMPLE: Check current usage:\n" +
15
+ " (call with no arguments)\n\n" +
16
+ "EXAMPLE: Check a past month:\n" +
17
+ " month='2026-06'",
18
+ inputSchema: {
19
+ type: "object",
20
+ properties: {
21
+ month: {
22
+ type: "string",
23
+ description: "Billing month to query in YYYY-MM format (e.g. '2026-06'). Defaults to the current month.",
24
+ },
25
+ },
26
+ required: [],
27
+ additionalProperties: false,
28
+ },
29
+ outputSchema: OUTPUT_SCHEMA_JSON,
30
+ annotations: { title: "Account Usage", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
31
+ handler: async (args) => {
32
+ const params = {};
33
+ if (args?.month)
34
+ params.month = args.month;
35
+ let usageRows;
36
+ try {
37
+ const raw = await getActiveClient().request("GET", "/api-keys/overage-status", { params });
38
+ usageRows = Array.isArray(raw) ? raw : [];
39
+ }
40
+ catch (error) {
41
+ if (error instanceof BackendError && error.status === 401) {
42
+ return {
43
+ content: [{
44
+ type: "text",
45
+ text: `**Not signed in.** To check usage, connect an OpenEphemeris account first.\n\n` +
46
+ `${error.message}\n\n` +
47
+ `New here? Create a free account at ${LOGIN_SIGNUP_URL}`,
48
+ }],
49
+ };
50
+ }
51
+ throw error;
52
+ }
53
+ // Subscription info is best-effort — usage is still useful without it.
54
+ let subscription = null;
55
+ try {
56
+ subscription = await getActiveClient().request("GET", "/billing/stripe/me");
57
+ }
58
+ catch {
59
+ // Tolerate failure (e.g. no Stripe customer on free tier).
60
+ }
61
+ const row = usageRows[0] ?? {};
62
+ const tier = (row.tier || subscription?.tier || "explorer").toLowerCase();
63
+ const period = row.month || args?.month || new Date().toISOString().substring(0, 7);
64
+ const used = row.total_units ?? 0;
65
+ const included = row.included_units ?? 0;
66
+ const remaining = Math.max(included - used, 0);
67
+ const percent = row.percent_units_used ?? (included > 0 ? Math.round((used / included) * 1000) / 10 : 0);
68
+ const totalCalls = row.total_calls ?? 0;
69
+ const lines = [
70
+ `**OpenEphemeris Account Usage**`,
71
+ ``,
72
+ `- **Plan:** ${tier}`,
73
+ `- **Period:** ${period}`,
74
+ `- **Credits:** ${used.toLocaleString()} used of ${included.toLocaleString()} included — **${remaining.toLocaleString()} remaining** (${percent}% used)`,
75
+ `- **Total API calls:** ${totalCalls.toLocaleString()}`,
76
+ ];
77
+ if ((row.overage_units ?? 0) > 0) {
78
+ lines.push(`- **Overage:** ${row.overage_units.toLocaleString()} credits over the included amount`);
79
+ }
80
+ if (subscription?.status) {
81
+ lines.push(`- **Subscription:** ${subscription.status}`);
82
+ }
83
+ if (subscription?.current_period_end) {
84
+ const renewal = new Date(subscription.current_period_end);
85
+ if (!Number.isNaN(renewal.getTime())) {
86
+ lines.push(`- **Renews:** ${renewal.toISOString().substring(0, 10)}`);
87
+ }
88
+ }
89
+ lines.push(``);
90
+ if (WALLET_TIERS.has(tier)) {
91
+ lines.push(`**Need more credits?** Top up your wallet at ${WALLET_TOPUP_URL} (from $5), ` +
92
+ `or upgrade to a monthly plan at ${UPGRADE_URL}.`);
93
+ }
94
+ else {
95
+ lines.push(`**Need more credits?** Upgrade your plan at ${UPGRADE_URL}, ` +
96
+ `or manage your subscription at ${DASHBOARD_ACCOUNT_URL}.`);
97
+ }
98
+ return { content: [{ type: "text", text: lines.join("\n") }] };
99
+ },
100
+ });
@@ -1,6 +1,7 @@
1
1
  import { registerTool, validateRequired } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
+ import { DATETIME_DESC, TIMEZONE_PROPERTY, localToUtcIso } from "../datetime.js";
4
5
  const ACG_BODY_DESCRIPTION = "List of celestial bodies for line calculation. " +
5
6
  "E.g. ['Sun', 'Moon', 'Venus', 'Mars', 'Jupiter', 'Saturn']. " +
6
7
  "Aliases: 'NorthNode'/'Node'/'Rahu' → MeanNode, 'SouthNode'/'Ketu' → SouthNode. " +
@@ -16,21 +17,21 @@ registerTool({
16
17
  "→ For a specific city/location analysis, use acg_hits instead (faster and more relevant).\n" +
17
18
  "✅ USE FOR: Getting the full global GeoJSON line geometry for map rendering or bulk geographic analysis.\n\n" +
18
19
  "CREDIT COST: 10 credits per call.\n\n" +
19
- "EXAMPLE: Saturn and Jupiter power lines for a chart born 1990-04-15 in Chicago:\n" +
20
- " birth_datetime='1990-04-15T14:30:00', birth_latitude=41.8781, birth_longitude=-87.6298,\n" +
21
- " bodies=['Saturn', 'Jupiter']",
20
+ "EXAMPLE: Saturn and Jupiter power lines for a chart born 1990-04-15 at 2:30 PM in Chicago:\n" +
21
+ " birth_datetime='1990-04-15T14:30:00', timezone='America/Chicago',\n" +
22
+ " birth_latitude=41.8781, birth_longitude=-87.6298, bodies=['Saturn', 'Jupiter']",
22
23
  inputSchema: {
23
24
  type: "object",
24
25
  properties: {
25
26
  birth_datetime: {
26
27
  type: "string",
27
- description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
28
- "e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
29
- "interpreted as UTC and will place the ACG lines in the wrong location.",
28
+ description: DATETIME_DESC +
29
+ " An unstated zone moves every ACG line by ~15° of longitude per hour.",
30
30
  },
31
+ timezone: TIMEZONE_PROPERTY,
31
32
  birth_latitude: {
32
33
  type: "number",
33
- description: "Birth latitude in decimal degrees.",
34
+ description: "Birth latitude in decimal degrees. Resolve from a place name with location_search; never recall coordinates from memory.",
34
35
  },
35
36
  birth_longitude: {
36
37
  type: "number",
@@ -66,7 +67,7 @@ registerTool({
66
67
  birthplace_lat: args.birth_latitude,
67
68
  birthplace_lon: args.birth_longitude,
68
69
  },
69
- epoch: args.birth_datetime,
70
+ epoch: localToUtcIso("birth_datetime", args.birth_datetime, args.timezone),
70
71
  };
71
72
  if (args.bodies)
72
73
  body.bodies = args.bodies;
@@ -109,20 +110,21 @@ registerTool({
109
110
  "❌ NOT FOR: Full global map geometry → use acg_power_lines for that instead.\n\n" +
110
111
  "CREDIT COST: 15 credits per call.\n\n" +
111
112
  "EXAMPLE: All ACG lines within 3° of Paris for a chart born 1990-04-15 in Chicago:\n" +
112
- " birth_datetime='1990-04-15T14:30:00', birth_latitude=41.8781, birth_longitude=-87.6298,\n" +
113
+ " birth_datetime='1990-04-15T14:30:00', timezone='America/Chicago',\n" +
114
+ " birth_latitude=41.8781, birth_longitude=-87.6298,\n" +
113
115
  " query_latitude=48.8566, query_longitude=2.3522, radius_deg=3",
114
116
  inputSchema: {
115
117
  type: "object",
116
118
  properties: {
117
119
  birth_datetime: {
118
120
  type: "string",
119
- description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
120
- "e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
121
- "interpreted as UTC and will place the ACG lines in the wrong location.",
121
+ description: DATETIME_DESC +
122
+ " An unstated zone moves every ACG line by ~15° of longitude per hour.",
122
123
  },
124
+ timezone: TIMEZONE_PROPERTY,
123
125
  birth_latitude: {
124
126
  type: "number",
125
- description: "Birth latitude in decimal degrees.",
127
+ description: "Birth latitude in decimal degrees. Resolve from a place name with location_search; never recall coordinates from memory.",
126
128
  },
127
129
  birth_longitude: {
128
130
  type: "number",
@@ -162,7 +164,7 @@ registerTool({
162
164
  birthplace_lat: args.birth_latitude,
163
165
  birthplace_lon: args.birth_longitude,
164
166
  },
165
- epoch: args.birth_datetime,
167
+ epoch: localToUtcIso("birth_datetime", args.birth_datetime, args.timezone),
166
168
  query_lat: args.query_latitude,
167
169
  query_lon: args.query_longitude,
168
170
  };
@@ -1 +1,7 @@
1
- export {};
1
+ export interface BaziComponents {
2
+ year: number;
3
+ month: number;
4
+ day: number;
5
+ hour?: number;
6
+ }
7
+ export declare function parseBaziArgs(args: any, datetimeField?: string): BaziComponents;