ether-to-astro 1.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 (138) hide show
  1. package/.env.example +13 -0
  2. package/.github/pull_request_template.md +16 -0
  3. package/.github/workflows/release.yml +35 -0
  4. package/.github/workflows/test.yml +32 -0
  5. package/AGENTS.md +99 -0
  6. package/LICENSE +18 -0
  7. package/NOTICE.md +45 -0
  8. package/README.md +301 -0
  9. package/SETUP.md +70 -0
  10. package/TESTING_SUMMARY.md +238 -0
  11. package/TEST_SUITE_STATUS.md +218 -0
  12. package/biome.json +48 -0
  13. package/dist/astro-service.d.ts +98 -0
  14. package/dist/astro-service.js +496 -0
  15. package/dist/chart-types.d.ts +52 -0
  16. package/dist/chart-types.js +51 -0
  17. package/dist/charts.d.ts +125 -0
  18. package/dist/charts.js +324 -0
  19. package/dist/cli.d.ts +7 -0
  20. package/dist/cli.js +472 -0
  21. package/dist/constants.d.ts +81 -0
  22. package/dist/constants.js +76 -0
  23. package/dist/eclipses.d.ts +85 -0
  24. package/dist/eclipses.js +184 -0
  25. package/dist/ephemeris.d.ts +120 -0
  26. package/dist/ephemeris.js +379 -0
  27. package/dist/formatter.d.ts +2 -0
  28. package/dist/formatter.js +22 -0
  29. package/dist/houses.d.ts +82 -0
  30. package/dist/houses.js +169 -0
  31. package/dist/index.d.ts +14 -0
  32. package/dist/index.js +150 -0
  33. package/dist/loader.d.ts +2 -0
  34. package/dist/loader.js +31 -0
  35. package/dist/logger.d.ts +25 -0
  36. package/dist/logger.js +73 -0
  37. package/dist/profile-store.d.ts +48 -0
  38. package/dist/profile-store.js +156 -0
  39. package/dist/riseset.d.ts +82 -0
  40. package/dist/riseset.js +185 -0
  41. package/dist/storage.d.ts +10 -0
  42. package/dist/storage.js +40 -0
  43. package/dist/time-utils.d.ts +68 -0
  44. package/dist/time-utils.js +136 -0
  45. package/dist/tool-registry.d.ts +35 -0
  46. package/dist/tool-registry.js +307 -0
  47. package/dist/tool-result.d.ts +175 -0
  48. package/dist/tool-result.js +188 -0
  49. package/dist/transits.d.ts +108 -0
  50. package/dist/transits.js +263 -0
  51. package/dist/types.d.ts +450 -0
  52. package/dist/types.js +161 -0
  53. package/example-usage.md +131 -0
  54. package/natal-chart.json +187 -0
  55. package/package.json +61 -0
  56. package/scripts/download-ephemeris.js +115 -0
  57. package/setup.sh +21 -0
  58. package/src/astro-service.ts +710 -0
  59. package/src/chart-types.ts +125 -0
  60. package/src/charts.ts +399 -0
  61. package/src/cli.ts +694 -0
  62. package/src/constants.ts +89 -0
  63. package/src/eclipses.ts +226 -0
  64. package/src/ephemeris.ts +437 -0
  65. package/src/formatter.ts +25 -0
  66. package/src/houses.ts +202 -0
  67. package/src/index.ts +170 -0
  68. package/src/loader.ts +36 -0
  69. package/src/logger.ts +104 -0
  70. package/src/profile-store.ts +285 -0
  71. package/src/riseset.ts +229 -0
  72. package/src/time-utils.ts +167 -0
  73. package/src/tool-registry.ts +357 -0
  74. package/src/tool-result.ts +283 -0
  75. package/src/transits.ts +352 -0
  76. package/src/types.ts +547 -0
  77. package/tests/README.md +173 -0
  78. package/tests/TESTING_STRATEGY.md +178 -0
  79. package/tests/fixtures/bowen-yang-chart.ts +69 -0
  80. package/tests/fixtures/calculate-expected.ts +81 -0
  81. package/tests/fixtures/expected-results.ts +117 -0
  82. package/tests/fixtures/generate-expected-simple.ts +94 -0
  83. package/tests/helpers/date-fixtures.ts +15 -0
  84. package/tests/helpers/ephem.ts +11 -0
  85. package/tests/helpers/temp.ts +9 -0
  86. package/tests/setup.ts +11 -0
  87. package/tests/unit/astro-service.test.ts +323 -0
  88. package/tests/unit/chart-types.test.ts +18 -0
  89. package/tests/unit/charts-errors.test.ts +42 -0
  90. package/tests/unit/charts.test.ts +157 -0
  91. package/tests/unit/cli-commands.test.ts +82 -0
  92. package/tests/unit/cli-profiles.test.ts +128 -0
  93. package/tests/unit/cli.test.ts +191 -0
  94. package/tests/unit/constants.test.ts +26 -0
  95. package/tests/unit/correctness-critical.test.ts +408 -0
  96. package/tests/unit/eclipses.test.ts +108 -0
  97. package/tests/unit/ephemeris.test.ts +213 -0
  98. package/tests/unit/error-handling.test.ts +116 -0
  99. package/tests/unit/formatter.test.ts +29 -0
  100. package/tests/unit/houses-errors.test.ts +27 -0
  101. package/tests/unit/houses-validation.test.ts +164 -0
  102. package/tests/unit/houses.test.ts +205 -0
  103. package/tests/unit/profile-store.test.ts +163 -0
  104. package/tests/unit/real-user-charts.test.ts +148 -0
  105. package/tests/unit/riseset.test.ts +106 -0
  106. package/tests/unit/solver-edges.test.ts +197 -0
  107. package/tests/unit/time-utils-temporal.test.ts +303 -0
  108. package/tests/unit/time-utils.test.ts +173 -0
  109. package/tests/unit/tool-registry.test.ts +222 -0
  110. package/tests/unit/tool-result.test.ts +45 -0
  111. package/tests/unit/transit-correctness.test.ts +78 -0
  112. package/tests/unit/transits.test.ts +238 -0
  113. package/tests/validation/README.md +32 -0
  114. package/tests/validation/adapters/astrolog.ts +306 -0
  115. package/tests/validation/adapters/internal.ts +184 -0
  116. package/tests/validation/compare/eclipses.ts +47 -0
  117. package/tests/validation/compare/houses.ts +76 -0
  118. package/tests/validation/compare/positions.ts +104 -0
  119. package/tests/validation/compare/riseSet.ts +48 -0
  120. package/tests/validation/compare/roots.ts +90 -0
  121. package/tests/validation/compare/transits.ts +69 -0
  122. package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
  123. package/tests/validation/fixtures/eclipses/core.ts +14 -0
  124. package/tests/validation/fixtures/houses/core.ts +47 -0
  125. package/tests/validation/fixtures/positions/core.ts +159 -0
  126. package/tests/validation/fixtures/rise-set/core.ts +20 -0
  127. package/tests/validation/fixtures/roots/core.ts +47 -0
  128. package/tests/validation/fixtures/transits/core.ts +61 -0
  129. package/tests/validation/fixtures/transits/dst.ts +21 -0
  130. package/tests/validation/oracle.spec.ts +129 -0
  131. package/tests/validation/utils/denseRootOracle.ts +269 -0
  132. package/tests/validation/utils/fixtureTypes.ts +146 -0
  133. package/tests/validation/utils/report.ts +60 -0
  134. package/tests/validation/utils/tolerances.ts +23 -0
  135. package/tests/validation/validation.spec.ts +836 -0
  136. package/tools/color-picker.html +388 -0
  137. package/tsconfig.json +17 -0
  138. package/vitest.config.ts +31 -0
@@ -0,0 +1,357 @@
1
+ import type { AstroService } from './astro-service.js';
2
+ import type { Disambiguation } from './time-utils.js';
3
+ import type { HouseSystem, NatalChart } from './types.js';
4
+
5
+ type ToolContent =
6
+ | { type: 'text'; text: string }
7
+ | { type: 'image'; data: string; mimeType: string };
8
+
9
+ export type ToolExecutionResult =
10
+ | { kind: 'state'; data: Record<string, unknown>; text: string; natalChart?: NatalChart }
11
+ | { kind: 'content'; content: ToolContent[] };
12
+
13
+ export interface ToolExecutionContext {
14
+ service: AstroService;
15
+ natalChart: NatalChart | null;
16
+ }
17
+
18
+ type ToolArgs = Record<string, unknown>;
19
+
20
+ export interface ToolSpec {
21
+ name: string;
22
+ description: string;
23
+ inputSchema: Record<string, unknown>;
24
+ requiresNatalChart: boolean;
25
+ execute: (
26
+ ctx: ToolExecutionContext,
27
+ args: ToolArgs
28
+ ) => Promise<ToolExecutionResult> | ToolExecutionResult;
29
+ }
30
+
31
+ export const MCP_TOOL_SPECS: ToolSpec[] = [
32
+ {
33
+ name: 'set_natal_chart',
34
+ description:
35
+ 'Store natal chart data for transit calculations. Birth time should be LOCAL time at the birth location (not UTC). The server converts to UTC using the timezone parameter. Optional birth_time_disambiguation handles DST overlap/gap edge cases (default: reject).',
36
+ inputSchema: {
37
+ type: 'object',
38
+ properties: {
39
+ name: { type: 'string', description: 'Name for this chart' },
40
+ year: { type: 'number', description: 'Birth year' },
41
+ month: { type: 'number', description: 'Birth month (1-12)' },
42
+ day: { type: 'number', description: 'Birth day' },
43
+ hour: { type: 'number', description: 'Birth hour (0-23, LOCAL TIME at birth location)' },
44
+ minute: { type: 'number', description: 'Birth minute' },
45
+ latitude: { type: 'number', description: 'Birth location latitude' },
46
+ longitude: { type: 'number', description: 'Birth location longitude' },
47
+ timezone: {
48
+ type: 'string',
49
+ description: 'Timezone (e.g., America/New_York, Europe/London)',
50
+ },
51
+ birth_time_disambiguation: {
52
+ type: 'string',
53
+ enum: ['compatible', 'earlier', 'later', 'reject'],
54
+ description:
55
+ 'How to handle DST-ambiguous or nonexistent local birth times. Default: reject.',
56
+ },
57
+ house_system: {
58
+ type: 'string',
59
+ description:
60
+ 'House system preference: P=Placidus (default), W=Whole Sign, K=Koch, E=Equal',
61
+ enum: ['P', 'W', 'K', 'E'],
62
+ },
63
+ },
64
+ required: [
65
+ 'name',
66
+ 'year',
67
+ 'month',
68
+ 'day',
69
+ 'hour',
70
+ 'minute',
71
+ 'latitude',
72
+ 'longitude',
73
+ 'timezone',
74
+ ],
75
+ },
76
+ requiresNatalChart: false,
77
+ execute: (ctx, args) => {
78
+ const result = ctx.service.setNatalChart({
79
+ name: args.name as string,
80
+ year: args.year as number,
81
+ month: args.month as number,
82
+ day: args.day as number,
83
+ hour: args.hour as number,
84
+ minute: args.minute as number,
85
+ latitude: args.latitude as number,
86
+ longitude: args.longitude as number,
87
+ timezone: args.timezone as string,
88
+ house_system: args.house_system as HouseSystem | undefined,
89
+ birth_time_disambiguation: args.birth_time_disambiguation as Disambiguation | undefined,
90
+ });
91
+ return { kind: 'state', data: result.data, text: result.text, natalChart: result.chart };
92
+ },
93
+ },
94
+ {
95
+ name: 'get_transits',
96
+ description:
97
+ 'Get transits (aspects between current/future planets and natal chart). Returns aspects within orb, with exact timing when close. Date defaults to today at local noon in the natal chart timezone.',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ date: {
102
+ type: 'string',
103
+ description: 'Date for transits (ISO format YYYY-MM-DD). Defaults to today.',
104
+ },
105
+ categories: {
106
+ type: 'array',
107
+ items: { type: 'string', enum: ['moon', 'personal', 'outer', 'all'] },
108
+ description:
109
+ 'Planet categories to include: moon, personal (Sun/Mercury/Venus/Mars), outer (Jupiter/Saturn/Uranus/Neptune/Pluto), or all. Defaults to ["all"].',
110
+ },
111
+ include_mundane: {
112
+ type: 'boolean',
113
+ description:
114
+ 'Include current planetary positions (not transits to natal chart). Defaults to false.',
115
+ },
116
+ days_ahead: {
117
+ type: 'number',
118
+ description:
119
+ 'Number of days to look ahead for upcoming transits. 0 = today only. Defaults to 0.',
120
+ default: 0,
121
+ },
122
+ max_orb: {
123
+ type: 'number',
124
+ description: 'Maximum orb in degrees to include. Defaults to 8.',
125
+ default: 8,
126
+ },
127
+ exact_only: {
128
+ type: 'boolean',
129
+ description:
130
+ 'Only return transits with exact times calculated (within 2° orb). Defaults to false.',
131
+ },
132
+ applying_only: {
133
+ type: 'boolean',
134
+ description: 'Only return applying (tightening) transits. Defaults to false.',
135
+ },
136
+ },
137
+ },
138
+ requiresNatalChart: true,
139
+ execute: (ctx, args) => {
140
+ const result = ctx.service.getTransits(ctx.natalChart as NatalChart, {
141
+ date: args.date as string | undefined,
142
+ categories: args.categories as string[] | undefined,
143
+ include_mundane: args.include_mundane as boolean | undefined,
144
+ days_ahead: args.days_ahead as number | undefined,
145
+ max_orb: args.max_orb as number | undefined,
146
+ exact_only: args.exact_only as boolean | undefined,
147
+ applying_only: args.applying_only as boolean | undefined,
148
+ });
149
+ return { kind: 'state', data: result.data, text: result.text };
150
+ },
151
+ },
152
+ {
153
+ name: 'get_houses',
154
+ description:
155
+ 'Calculate house cusps, Ascendant, and Midheaven for the natal chart using the specified house system',
156
+ inputSchema: {
157
+ type: 'object',
158
+ properties: {
159
+ system: {
160
+ type: 'string',
161
+ enum: ['P', 'K', 'W', 'E'],
162
+ description: 'House system: P=Placidus (default), K=Koch, W=Whole Sign, E=Equal',
163
+ default: 'P',
164
+ },
165
+ },
166
+ },
167
+ requiresNatalChart: true,
168
+ execute: (ctx, args) => {
169
+ const result = ctx.service.getHouses(ctx.natalChart as NatalChart, {
170
+ system: args.system as string | undefined,
171
+ });
172
+ return { kind: 'state', data: result.data, text: result.text };
173
+ },
174
+ },
175
+ {
176
+ name: 'get_retrograde_planets',
177
+ description: 'Show which planets are currently retrograde',
178
+ inputSchema: { type: 'object', properties: {} },
179
+ requiresNatalChart: false,
180
+ execute: (ctx, args) => {
181
+ const timezone =
182
+ (args.timezone as string | undefined) ?? ctx.natalChart?.location.timezone ?? 'UTC';
183
+ const result = ctx.service.getRetrogradePlanets(timezone);
184
+ return { kind: 'state', data: result.data, text: result.text };
185
+ },
186
+ },
187
+ {
188
+ name: 'get_rise_set_times',
189
+ description: 'Get sunrise, sunset, moonrise, moonset times for today',
190
+ inputSchema: { type: 'object', properties: {} },
191
+ requiresNatalChart: true,
192
+ execute: async (ctx) => {
193
+ const result = await ctx.service.getRiseSetTimes(ctx.natalChart as NatalChart);
194
+ return { kind: 'state', data: result.data, text: result.text };
195
+ },
196
+ },
197
+ {
198
+ name: 'get_asteroid_positions',
199
+ description: 'Get positions of major asteroids (Chiron, Ceres, Pallas, Juno, Vesta) and Nodes',
200
+ inputSchema: { type: 'object', properties: {} },
201
+ requiresNatalChart: false,
202
+ execute: (ctx, args) => {
203
+ const timezone =
204
+ (args.timezone as string | undefined) ?? ctx.natalChart?.location.timezone ?? 'UTC';
205
+ const result = ctx.service.getAsteroidPositions(timezone);
206
+ return { kind: 'state', data: result.data, text: result.text };
207
+ },
208
+ },
209
+ {
210
+ name: 'get_next_eclipses',
211
+ description: 'Find the next solar and lunar eclipses',
212
+ inputSchema: { type: 'object', properties: {} },
213
+ requiresNatalChart: false,
214
+ execute: (ctx, args) => {
215
+ const timezone =
216
+ (args.timezone as string | undefined) ?? ctx.natalChart?.location.timezone ?? 'UTC';
217
+ const result = ctx.service.getNextEclipses(timezone);
218
+ return { kind: 'state', data: result.data, text: result.text };
219
+ },
220
+ },
221
+ {
222
+ name: 'get_server_status',
223
+ description:
224
+ 'Inspect the current server state: whether a natal chart is loaded, its name and timezone, and the server version. Call this before making assumptions about loaded context.',
225
+ inputSchema: { type: 'object', properties: {} },
226
+ requiresNatalChart: false,
227
+ execute: (ctx) => {
228
+ const result = ctx.service.getServerStatus(ctx.natalChart);
229
+ return { kind: 'state', data: result.data, text: result.text };
230
+ },
231
+ },
232
+ {
233
+ name: 'generate_natal_chart',
234
+ description:
235
+ 'Generate a visual natal chart wheel with planets, houses, and aspects. Supports SVG, PNG, and WebP formats. Theme defaults to dark (6pm-6am) or light (6am-6pm) based on current time, but can be overridden with the theme parameter.',
236
+ inputSchema: {
237
+ type: 'object',
238
+ properties: {
239
+ theme: {
240
+ type: 'string',
241
+ enum: ['light', 'dark'],
242
+ description:
243
+ 'Color theme override. Defaults to time-based: dark (6pm-6am) or light (6am-6pm)',
244
+ },
245
+ format: {
246
+ type: 'string',
247
+ enum: ['svg', 'png', 'webp'],
248
+ description: 'Output format (svg, png, or webp), defaults to svg',
249
+ },
250
+ output_path: {
251
+ type: 'string',
252
+ description: 'Optional absolute file path to save the chart (e.g., /path/to/chart.webp)',
253
+ },
254
+ },
255
+ },
256
+ requiresNatalChart: true,
257
+ execute: async (ctx, args) => {
258
+ const result = await ctx.service.generateNatalChart(ctx.natalChart as NatalChart, {
259
+ theme: args.theme as 'light' | 'dark' | undefined,
260
+ format: args.format as 'svg' | 'png' | 'webp' | undefined,
261
+ output_path: args.output_path as string | undefined,
262
+ });
263
+ if (result.outputPath) {
264
+ return { kind: 'content', content: [{ type: 'text', text: result.text }] };
265
+ }
266
+ if (result.format === 'svg' && result.svg) {
267
+ return {
268
+ kind: 'content',
269
+ content: [
270
+ { type: 'text', text: result.text },
271
+ { type: 'text', text: result.svg },
272
+ ],
273
+ };
274
+ }
275
+ if (result.image) {
276
+ return {
277
+ kind: 'content',
278
+ content: [
279
+ { type: 'text', text: result.text },
280
+ { type: 'image', data: result.image.data, mimeType: result.image.mimeType },
281
+ ],
282
+ };
283
+ }
284
+ throw new Error('Chart generation returned no payload.');
285
+ },
286
+ },
287
+ {
288
+ name: 'generate_transit_chart',
289
+ description:
290
+ 'Generate a visual transit chart showing current transits overlaid on the natal chart. Date defaults to today at local noon in the natal chart timezone. Supports SVG, PNG, and WebP formats with light or dark themes.',
291
+ inputSchema: {
292
+ type: 'object',
293
+ properties: {
294
+ date: {
295
+ type: 'string',
296
+ description: 'Optional date for transits (ISO format), defaults to today at local noon',
297
+ },
298
+ theme: {
299
+ type: 'string',
300
+ enum: ['light', 'dark'],
301
+ description:
302
+ 'Color theme override. Defaults to time-based: dark (6pm-6am) or light (6am-6pm)',
303
+ },
304
+ format: {
305
+ type: 'string',
306
+ enum: ['svg', 'png', 'webp'],
307
+ description: 'Output format (svg, png, or webp), defaults to svg',
308
+ },
309
+ output_path: {
310
+ type: 'string',
311
+ description: 'Optional absolute file path to save the chart (e.g., /path/to/chart.webp)',
312
+ },
313
+ },
314
+ },
315
+ requiresNatalChart: true,
316
+ execute: async (ctx, args) => {
317
+ const result = await ctx.service.generateTransitChart(ctx.natalChart as NatalChart, {
318
+ date: args.date as string | undefined,
319
+ theme: args.theme as 'light' | 'dark' | undefined,
320
+ format: args.format as 'svg' | 'png' | 'webp' | undefined,
321
+ output_path: args.output_path as string | undefined,
322
+ });
323
+ if (result.outputPath) {
324
+ return { kind: 'content', content: [{ type: 'text', text: result.text }] };
325
+ }
326
+ if (result.format === 'svg' && result.svg) {
327
+ return {
328
+ kind: 'content',
329
+ content: [
330
+ { type: 'text', text: result.text },
331
+ { type: 'text', text: result.svg },
332
+ ],
333
+ };
334
+ }
335
+ if (result.image) {
336
+ return {
337
+ kind: 'content',
338
+ content: [
339
+ { type: 'text', text: result.text },
340
+ { type: 'image', data: result.image.data, mimeType: result.image.mimeType },
341
+ ],
342
+ };
343
+ }
344
+ throw new Error('Transit chart generation returned no payload.');
345
+ },
346
+ },
347
+ ];
348
+
349
+ export function createToolSpecIndex(specs: ToolSpec[] = MCP_TOOL_SPECS): Map<string, ToolSpec> {
350
+ return new Map(specs.map((spec) => [spec.name, spec]));
351
+ }
352
+
353
+ const TOOL_INDEX = createToolSpecIndex();
354
+
355
+ export function getToolSpec(name: string): ToolSpec | undefined {
356
+ return TOOL_INDEX.get(name);
357
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Structured error handling for MCP tool results
3
+ *
4
+ * @remarks
5
+ * Provides agent-recoverable error codes and suggestions for self-correction.
6
+ * Distinguishes between recoverable domain errors and hard infrastructure failures.
7
+ *
8
+ * Each error code includes whether it's retryable and suggested fixes
9
+ * to help the agent recover from errors automatically.
10
+ */
11
+
12
+ /**
13
+ * Error codes for MCP tool operations
14
+ *
15
+ * @remarks
16
+ * Each code represents a specific category of error that can occur
17
+ * during astrological calculations. The codes are designed to be
18
+ * machine-readable while still being descriptive.
19
+ */
20
+ export type ToolIssueCode =
21
+ | 'INVALID_INPUT'
22
+ | 'UNKNOWN_PLANET'
23
+ | 'NO_RISE_SET_EVENT'
24
+ | 'CIRCUMPOLAR_OBJECT'
25
+ | 'POLAR_LATITUDE_LIMIT'
26
+ | 'EPHEMERIS_NOT_INITIALIZED'
27
+ | 'EPHEMERIS_COMPUTE_FAILED'
28
+ | 'TIMEZONE_ERROR'
29
+ | 'INVALID_TIMEZONE'
30
+ | 'MISSING_NATAL_CHART'
31
+ | 'INVALID_DATE'
32
+ | 'INVALID_HOUSE_SYSTEM'
33
+ | 'FILE_WRITE_FAILED'
34
+ | 'CHART_RENDER_FAILED'
35
+ | 'INTERNAL_ERROR';
36
+
37
+ /**
38
+ * Structured error information for tool operations
39
+ *
40
+ * @remarks
41
+ * Contains the error code, human-readable message, and metadata
42
+ * to help agents understand and recover from errors.
43
+ */
44
+ export interface ToolIssue {
45
+ /** Machine-readable error code */
46
+ code: ToolIssueCode;
47
+ /** Human-readable error description */
48
+ message: string;
49
+ /** Whether the operation can be retried */
50
+ retryable: boolean;
51
+ /** Suggested fix for the agent (optional) */
52
+ suggestedFix?: string;
53
+ /** Additional error context (optional) */
54
+ details?: Record<string, unknown>;
55
+ }
56
+
57
+ /**
58
+ * Result type for MCP tool operations
59
+ *
60
+ * @remarks
61
+ * Discriminated union that distinguishes between successful
62
+ * and failed operations. Success includes data and optional warnings,
63
+ * while failure includes structured error information.
64
+ */
65
+ export type ToolResult<T> =
66
+ | {
67
+ /** Success flag */
68
+ ok: true;
69
+ /** Result data */
70
+ data: T;
71
+ /** Optional warnings about the operation */
72
+ warnings?: ToolIssue[];
73
+ }
74
+ | {
75
+ /** Failure flag */
76
+ ok: false;
77
+ /** Error information */
78
+ error: ToolIssue;
79
+ };
80
+
81
+ /**
82
+ * Create a successful tool result
83
+ *
84
+ * @param data - The successful result data
85
+ * @param warnings - Optional warnings about the operation
86
+ * @returns Success result with data
87
+ *
88
+ * @remarks
89
+ * Use this to wrap successful operation results. Warnings are
90
+ * optional and can indicate non-fatal issues.
91
+ */
92
+ export function success<T>(data: T, warnings?: ToolIssue[]): ToolResult<T> {
93
+ return warnings ? { ok: true, data, warnings } : { ok: true, data };
94
+ }
95
+
96
+ /**
97
+ * Create a failed tool result
98
+ *
99
+ * @param error - Structured error information
100
+ * @returns Failure result with error
101
+ *
102
+ * @remarks
103
+ * Use this to wrap failed operations. The error should include
104
+ * a code, message, and optionally retry/suggestion information.
105
+ */
106
+ export function failure(error: ToolIssue): ToolResult<never> {
107
+ return { ok: false, error };
108
+ }
109
+
110
+ /**
111
+ * Schema version for structured responses.
112
+ * Increment on breaking changes to response shapes.
113
+ */
114
+ export const SCHEMA_VERSION = '1.0';
115
+
116
+ /**
117
+ * Build a successful MCP tool response with structured data + human text.
118
+ *
119
+ * @param data - Structured payload
120
+ * @param humanText - Human-readable summary
121
+ * @param warnings - Optional warnings
122
+ * @returns MCP content array (JSON first, text second)
123
+ */
124
+ export function mcpResult<T>(data: T, humanText: string, warnings?: ToolIssue[]) {
125
+ const envelope = warnings
126
+ ? { ok: true as const, schemaVersion: SCHEMA_VERSION, data, warnings }
127
+ : { ok: true as const, schemaVersion: SCHEMA_VERSION, data };
128
+ return {
129
+ content: [
130
+ { type: 'text' as const, text: JSON.stringify(envelope, null, 2) },
131
+ { type: 'text' as const, text: humanText },
132
+ ],
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Build an error MCP tool response with structured error.
138
+ *
139
+ * @param error - Structured error information
140
+ * @returns MCP content array with isError flag
141
+ */
142
+ export function mcpError(error: ToolIssue) {
143
+ return {
144
+ content: [
145
+ {
146
+ type: 'text' as const,
147
+ text: JSON.stringify({ ok: false, schemaVersion: SCHEMA_VERSION, error }, null, 2),
148
+ },
149
+ ],
150
+ isError: true,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Map Swiss Ephemeris errors to structured tool issues
156
+ *
157
+ * @param context - Operation context where error occurred
158
+ * @param err - Raw error from Swiss Ephemeris
159
+ * @param details - Additional error context
160
+ * @returns Structured tool issue with retry information
161
+ *
162
+ * @remarks
163
+ * Converts low-level Swiss Ephemeris errors into structured,
164
+ * agent-recoverable error codes with suggested fixes.
165
+ */
166
+ export function mapSweError(
167
+ context: string,
168
+ err: unknown,
169
+ details?: Record<string, unknown>
170
+ ): ToolIssue {
171
+ const message = err instanceof Error ? err.message : String(err);
172
+
173
+ if (/not initialized/i.test(message)) {
174
+ return {
175
+ code: 'EPHEMERIS_NOT_INITIALIZED',
176
+ message: 'Swiss Ephemeris is not initialized.',
177
+ retryable: false,
178
+ suggestedFix: 'Initialize the ephemeris engine before requesting calculations.',
179
+ details,
180
+ };
181
+ }
182
+
183
+ return {
184
+ code: 'EPHEMERIS_COMPUTE_FAILED',
185
+ message: `Swiss Ephemeris failed during ${context}.`,
186
+ retryable: false,
187
+ suggestedFix: 'Check inputs and ephemeris configuration.',
188
+ details: { ...details, rawMessage: message },
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Create a structured error for missing rise/set events
194
+ *
195
+ * @param eventType - Type of event that was missing
196
+ * @param planet - Planet name for context
197
+ * @param details - Additional error context
198
+ * @returns Structured tool issue indicating no event
199
+ *
200
+ * @remarks
201
+ * Used when a planet doesn't rise/set at a location (circumpolar)
202
+ * or when meridian transits don't occur.
203
+ */
204
+ export function noRiseSetEvent(
205
+ eventType: 'rise' | 'set' | 'upper_meridian' | 'lower_meridian',
206
+ planet: string,
207
+ details: Record<string, unknown>
208
+ ): ToolIssue {
209
+ return {
210
+ code: 'NO_RISE_SET_EVENT',
211
+ message: `No ${eventType} event for ${planet} at the specified date and location.`,
212
+ retryable: true,
213
+ suggestedFix:
214
+ 'Try another date, location, or request a different event type. Object may be circumpolar.',
215
+ details,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Create a structured error for circumpolar objects
221
+ *
222
+ * @param planet - Planet name for context
223
+ * @param latitude - Observer latitude where object is circumpolar
224
+ * @param details - Additional error context
225
+ * @returns Structured tool issue for circumpolar object
226
+ *
227
+ * @remarks
228
+ * Used when a planet never rises or sets at extreme latitudes.
229
+ * The object is either always above or always below the horizon.
230
+ */
231
+ export function circumpolarObject(
232
+ planet: string,
233
+ latitude: number,
234
+ details?: Record<string, unknown>
235
+ ): ToolIssue {
236
+ return {
237
+ code: 'CIRCUMPOLAR_OBJECT',
238
+ message: `${planet} is circumpolar at latitude ${latitude.toFixed(1)}° - it does not rise or set.`,
239
+ retryable: true,
240
+ suggestedFix: 'Request meridian transit times instead, or try a different location.',
241
+ details: { planet, latitude, ...details },
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Create a structured error for missing natal chart
247
+ *
248
+ * @returns Structured tool issue for missing natal chart
249
+ *
250
+ * @remarks
251
+ * Used when transit or chart operations are requested
252
+ * before a natal chart has been set.
253
+ */
254
+ export function missingNatalChart(): ToolIssue {
255
+ return {
256
+ code: 'MISSING_NATAL_CHART',
257
+ message: 'No natal chart found. Please set natal chart first.',
258
+ retryable: true,
259
+ suggestedFix:
260
+ 'Call set_natal_chart with birth details (date, time, location, timezone) before requesting transits or chart calculations.',
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Create a warning for polar latitude limitations
266
+ *
267
+ * @param latitude - Observer latitude in degrees
268
+ * @param houseSystem - House system being used
269
+ * @returns Structured tool issue warning about polar limitations
270
+ *
271
+ * @remarks
272
+ * Some house systems (Placidus, Koch) fail at extreme latitudes.
273
+ * This warns the user and suggests Whole Sign as a fallback.
274
+ */
275
+ export function polarLatitudeWarning(latitude: number, houseSystem: string): ToolIssue {
276
+ return {
277
+ code: 'POLAR_LATITUDE_LIMIT',
278
+ message: `${houseSystem} house system may be inaccurate at polar latitudes (${latitude.toFixed(1)}°).`,
279
+ retryable: true,
280
+ suggestedFix: 'Consider using Whole Sign house system for latitudes >66°.',
281
+ details: { latitude, houseSystem },
282
+ };
283
+ }