@cephalization/phoenix-insight 0.1.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 (54) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +620 -0
  3. package/dist/agent/index.js +230 -0
  4. package/dist/cli.js +640 -0
  5. package/dist/commands/index.js +2 -0
  6. package/dist/commands/px-fetch-more-spans.js +98 -0
  7. package/dist/commands/px-fetch-more-trace.js +110 -0
  8. package/dist/config/index.js +165 -0
  9. package/dist/config/loader.js +141 -0
  10. package/dist/config/schema.js +53 -0
  11. package/dist/index.js +1 -0
  12. package/dist/modes/index.js +17 -0
  13. package/dist/modes/local.js +134 -0
  14. package/dist/modes/sandbox.js +121 -0
  15. package/dist/modes/types.js +1 -0
  16. package/dist/observability/index.js +65 -0
  17. package/dist/progress.js +209 -0
  18. package/dist/prompts/index.js +1 -0
  19. package/dist/prompts/system.js +30 -0
  20. package/dist/snapshot/client.js +74 -0
  21. package/dist/snapshot/context.js +332 -0
  22. package/dist/snapshot/datasets.js +68 -0
  23. package/dist/snapshot/experiments.js +135 -0
  24. package/dist/snapshot/index.js +262 -0
  25. package/dist/snapshot/projects.js +44 -0
  26. package/dist/snapshot/prompts.js +199 -0
  27. package/dist/snapshot/spans.js +80 -0
  28. package/dist/tsconfig.esm.tsbuildinfo +1 -0
  29. package/package.json +75 -0
  30. package/src/agent/index.ts +323 -0
  31. package/src/cli.ts +782 -0
  32. package/src/commands/index.ts +8 -0
  33. package/src/commands/px-fetch-more-spans.ts +174 -0
  34. package/src/commands/px-fetch-more-trace.ts +183 -0
  35. package/src/config/index.ts +225 -0
  36. package/src/config/loader.ts +173 -0
  37. package/src/config/schema.ts +66 -0
  38. package/src/index.ts +1 -0
  39. package/src/modes/index.ts +21 -0
  40. package/src/modes/local.ts +163 -0
  41. package/src/modes/sandbox.ts +144 -0
  42. package/src/modes/types.ts +31 -0
  43. package/src/observability/index.ts +90 -0
  44. package/src/progress.ts +239 -0
  45. package/src/prompts/index.ts +1 -0
  46. package/src/prompts/system.ts +31 -0
  47. package/src/snapshot/client.ts +129 -0
  48. package/src/snapshot/context.ts +462 -0
  49. package/src/snapshot/datasets.ts +132 -0
  50. package/src/snapshot/experiments.ts +246 -0
  51. package/src/snapshot/index.ts +403 -0
  52. package/src/snapshot/projects.ts +58 -0
  53. package/src/snapshot/prompts.ts +267 -0
  54. package/src/snapshot/spans.ts +142 -0
@@ -0,0 +1,134 @@
1
+ import { exec as execCallback } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import * as fs from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+ import { tool } from "ai";
7
+ import { z } from "zod";
8
+ const execAsync = promisify(execCallback);
9
+ /**
10
+ * Local execution mode using real bash and persistent filesystem
11
+ * - Real bash execution via child_process
12
+ * - Persistent storage in ~/.phoenix-insight/
13
+ * - Full system access
14
+ */
15
+ export class LocalMode {
16
+ workDir;
17
+ toolCreated = false;
18
+ bashToolPromise = null;
19
+ constructor() {
20
+ // Create a timestamped directory for this snapshot
21
+ // Add a small random component to ensure uniqueness even if created at the same millisecond
22
+ const timestamp = Date.now().toString() + "-" + Math.random().toString(36).substring(7);
23
+ this.workDir = path.join(os.homedir(), ".phoenix-insight", "snapshots", timestamp, "phoenix");
24
+ }
25
+ /**
26
+ * Initialize the working directory
27
+ */
28
+ async init() {
29
+ try {
30
+ // Create the directory structure if it doesn't exist
31
+ await fs.mkdir(this.workDir, { recursive: true });
32
+ }
33
+ catch (error) {
34
+ throw new Error(`Failed to initialize local mode directory at ${this.workDir}: ${error instanceof Error ? error.message : String(error)}`);
35
+ }
36
+ }
37
+ async writeFile(filePath, content) {
38
+ await this.init();
39
+ // Ensure the path is relative to phoenix root
40
+ const cleanPath = filePath.startsWith("/phoenix")
41
+ ? filePath.substring(8) // Remove /phoenix prefix
42
+ : filePath.startsWith("/")
43
+ ? filePath.substring(1) // Remove leading slash
44
+ : filePath;
45
+ const fullPath = path.join(this.workDir, cleanPath);
46
+ // Create parent directories if they don't exist
47
+ const dirname = path.dirname(fullPath);
48
+ await fs.mkdir(dirname, { recursive: true });
49
+ // Write the file
50
+ await fs.writeFile(fullPath, content, "utf-8");
51
+ }
52
+ async exec(command) {
53
+ await this.init();
54
+ try {
55
+ // Execute the command in the phoenix directory with a timeout
56
+ const { stdout, stderr } = await execAsync(command, {
57
+ cwd: this.workDir,
58
+ shell: "/bin/bash",
59
+ encoding: "utf-8",
60
+ timeout: 60000, // 60 second timeout for bash commands
61
+ });
62
+ return {
63
+ stdout: stdout || "",
64
+ stderr: stderr || "",
65
+ exitCode: 0,
66
+ };
67
+ }
68
+ catch (error) {
69
+ // Handle command execution errors
70
+ if (error.code !== undefined) {
71
+ return {
72
+ stdout: error.stdout || "",
73
+ stderr: error.stderr || error.message || "Command failed",
74
+ exitCode: error.code || 1,
75
+ };
76
+ }
77
+ // Handle other errors
78
+ return {
79
+ stdout: "",
80
+ stderr: error.message || "Unknown error",
81
+ exitCode: 1,
82
+ };
83
+ }
84
+ }
85
+ async getBashTool() {
86
+ // Only create the tool once and cache it
87
+ if (!this.bashToolPromise) {
88
+ this.bashToolPromise = this.createBashTool();
89
+ }
90
+ return this.bashToolPromise;
91
+ }
92
+ async createBashTool() {
93
+ // We can't use bash-tool directly for local mode since it's designed for just-bash
94
+ // Instead, we'll create a tool using the AI SDK's tool function
95
+ // This ensures compatibility with the AI SDK
96
+ // Return a bash tool that executes real bash commands
97
+ return tool({
98
+ description: "Execute bash commands in the local filesystem",
99
+ inputSchema: z.object({
100
+ command: z.string().describe("The bash command to execute"),
101
+ }),
102
+ execute: async ({ command }) => {
103
+ const result = await this.exec(command);
104
+ // Return result in a format similar to bash-tool
105
+ if (result.exitCode !== 0) {
106
+ // Include error details in the response
107
+ return {
108
+ success: false,
109
+ stdout: result.stdout,
110
+ stderr: result.stderr,
111
+ exitCode: result.exitCode,
112
+ error: `Command failed with exit code ${result.exitCode}`,
113
+ };
114
+ }
115
+ return {
116
+ success: true,
117
+ stdout: result.stdout,
118
+ stderr: result.stderr,
119
+ exitCode: result.exitCode,
120
+ };
121
+ },
122
+ });
123
+ }
124
+ async cleanup() {
125
+ // Optional: Clean up old snapshots
126
+ // For now, we'll keep all snapshots for user reference
127
+ // Users can manually clean ~/.phoenix-insight/ if needed
128
+ // We could implement logic to:
129
+ // 1. Keep only the last N snapshots
130
+ // 2. Delete snapshots older than X days
131
+ // 3. Provide a separate cleanup command
132
+ // For this implementation, we do nothing
133
+ }
134
+ }
@@ -0,0 +1,121 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ /**
4
+ * Sandbox execution mode using just-bash for isolated execution
5
+ * - In-memory filesystem
6
+ * - Simulated bash commands
7
+ * - No disk or network access
8
+ */
9
+ export class SandboxMode {
10
+ bash; // Will be typed as Bash from just-bash
11
+ initialized = false;
12
+ bashToolPromise = null;
13
+ constructor() {
14
+ // We'll initialize in the init method since we need async imports
15
+ }
16
+ async init() {
17
+ if (this.initialized)
18
+ return;
19
+ try {
20
+ // Dynamic imports for ESM modules
21
+ const { Bash } = await import("just-bash");
22
+ // Initialize just-bash with /phoenix as the working directory
23
+ this.bash = new Bash({ cwd: "/phoenix" });
24
+ this.initialized = true;
25
+ }
26
+ catch (error) {
27
+ throw new Error(`Failed to initialize sandbox mode: ${error instanceof Error ? error.message : String(error)}`);
28
+ }
29
+ }
30
+ async writeFile(path, content) {
31
+ await this.init();
32
+ try {
33
+ // Ensure the path starts with /phoenix
34
+ const fullPath = path.startsWith("/phoenix")
35
+ ? path
36
+ : `/phoenix${path.startsWith("/") ? "" : "/"}${path}`;
37
+ // Create parent directories if they don't exist
38
+ const dirname = fullPath.substring(0, fullPath.lastIndexOf("/"));
39
+ if (dirname) {
40
+ await this.bash.exec(`mkdir -p ${dirname}`);
41
+ }
42
+ // Write the file using just-bash's filesystem
43
+ // We'll use the InMemoryFs directly for better performance
44
+ this.bash.fs.writeFileSync(fullPath, content);
45
+ }
46
+ catch (error) {
47
+ throw new Error(`Failed to write file ${path}: ${error instanceof Error ? error.message : String(error)}`);
48
+ }
49
+ }
50
+ async exec(command) {
51
+ await this.init();
52
+ try {
53
+ const result = await this.bash.exec(command);
54
+ // just-bash returns a different structure, so we need to normalize it
55
+ return {
56
+ stdout: result.stdout || "",
57
+ stderr: result.stderr || "",
58
+ exitCode: result.exitCode || 0,
59
+ };
60
+ }
61
+ catch (error) {
62
+ // If the command fails, just-bash throws an error
63
+ // Extract what we can from the error
64
+ if (error && typeof error === "object" && "exitCode" in error) {
65
+ return {
66
+ stdout: error.stdout || "",
67
+ stderr: error.stderr || error.toString(),
68
+ exitCode: error.exitCode || 1,
69
+ };
70
+ }
71
+ // Fallback for unexpected errors
72
+ return {
73
+ stdout: "",
74
+ stderr: error?.toString() || "Unknown error",
75
+ exitCode: 1,
76
+ };
77
+ }
78
+ }
79
+ async getBashTool() {
80
+ // Only create the tool once and cache it
81
+ if (!this.bashToolPromise) {
82
+ this.bashToolPromise = this.createBashTool();
83
+ }
84
+ return this.bashToolPromise;
85
+ }
86
+ async createBashTool() {
87
+ await this.init();
88
+ // Create a bash tool compatible with the AI SDK
89
+ // Similar to local mode, we'll create it directly using the tool function
90
+ return tool({
91
+ description: "Execute bash commands in the sandbox filesystem",
92
+ inputSchema: z.object({
93
+ command: z.string().describe("The bash command to execute"),
94
+ }),
95
+ execute: async ({ command }) => {
96
+ const result = await this.exec(command);
97
+ // Return result in a format similar to bash-tool
98
+ if (result.exitCode !== 0) {
99
+ // Include error details in the response
100
+ return {
101
+ success: false,
102
+ stdout: result.stdout,
103
+ stderr: result.stderr,
104
+ exitCode: result.exitCode,
105
+ error: `Command failed with exit code ${result.exitCode}`,
106
+ };
107
+ }
108
+ return {
109
+ success: true,
110
+ stdout: result.stdout,
111
+ stderr: result.stderr,
112
+ exitCode: result.exitCode,
113
+ };
114
+ },
115
+ });
116
+ }
117
+ async cleanup() {
118
+ // No-op for in-memory mode - garbage collection will handle cleanup
119
+ // We could optionally clear the filesystem here if needed
120
+ }
121
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Phoenix Insight observability configuration
3
+ */
4
+ import { register } from "@arizeai/phoenix-otel";
5
+ import { DiagLogLevel } from "@opentelemetry/api";
6
+ /**
7
+ * Global tracer provider instance
8
+ */
9
+ let tracerProvider = null;
10
+ /**
11
+ * Check if observability is enabled
12
+ */
13
+ export function isObservabilityEnabled() {
14
+ return tracerProvider !== null;
15
+ }
16
+ /**
17
+ * Initialize observability for the Phoenix Insight agent
18
+ */
19
+ export function initializeObservability(options) {
20
+ if (!options.enabled) {
21
+ return;
22
+ }
23
+ // If already initialized, skip
24
+ if (tracerProvider) {
25
+ return;
26
+ }
27
+ try {
28
+ // Configure the tracer provider
29
+ tracerProvider = register({
30
+ projectName: options.projectName || "phoenix-insight",
31
+ url: options.baseUrl,
32
+ apiKey: options.apiKey,
33
+ batch: true,
34
+ global: true,
35
+ diagLogLevel: options.debug ? DiagLogLevel.DEBUG : undefined,
36
+ });
37
+ if (options.debug) {
38
+ console.error("🔭 Observability enabled - traces will be sent to Phoenix");
39
+ }
40
+ }
41
+ catch (error) {
42
+ console.error("⚠️ Failed to initialize observability:", error);
43
+ // Don't throw - observability should not break the main functionality
44
+ }
45
+ }
46
+ /**
47
+ * Shutdown observability and cleanup resources
48
+ */
49
+ export async function shutdownObservability() {
50
+ if (tracerProvider) {
51
+ try {
52
+ await tracerProvider.shutdown();
53
+ tracerProvider = null;
54
+ }
55
+ catch (error) {
56
+ console.error("⚠️ Error shutting down observability:", error);
57
+ }
58
+ }
59
+ }
60
+ /**
61
+ * Get the current tracer provider
62
+ */
63
+ export function getTracerProvider() {
64
+ return tracerProvider;
65
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Progress indicators for Phoenix Insight CLI
3
+ */
4
+ import ora, {} from "ora";
5
+ /**
6
+ * Progress indicator for snapshot operations
7
+ */
8
+ export class SnapshotProgress {
9
+ spinner = null;
10
+ enabled;
11
+ currentPhase = null;
12
+ totalSteps = 6;
13
+ currentStep = 0;
14
+ constructor(enabled = true) {
15
+ this.enabled = enabled;
16
+ }
17
+ /**
18
+ * Start the progress indicator
19
+ */
20
+ start(message = "Creating Phoenix data snapshot") {
21
+ if (!this.enabled)
22
+ return;
23
+ this.currentStep = 0;
24
+ this.spinner = ora({
25
+ text: message,
26
+ spinner: "dots",
27
+ color: "blue",
28
+ }).start();
29
+ }
30
+ /**
31
+ * Update progress with a new phase
32
+ */
33
+ update(phase, detail) {
34
+ if (!this.enabled || !this.spinner)
35
+ return;
36
+ this.currentStep++;
37
+ this.currentPhase = phase;
38
+ const progress = Math.round((this.currentStep / this.totalSteps) * 100);
39
+ const progressBar = this.createProgressBar(progress);
40
+ const message = detail
41
+ ? `${progressBar} ${phase}: ${detail}`
42
+ : `${progressBar} ${phase}`;
43
+ this.spinner.text = message;
44
+ }
45
+ /**
46
+ * Complete a phase successfully
47
+ */
48
+ succeed(message) {
49
+ if (!this.enabled || !this.spinner)
50
+ return;
51
+ const finalMessage = message || `✓ ${this.currentPhase || "Snapshot"} complete`;
52
+ this.spinner.succeed(finalMessage);
53
+ this.spinner = null;
54
+ }
55
+ /**
56
+ * Fail with an error
57
+ */
58
+ fail(message) {
59
+ if (!this.enabled || !this.spinner)
60
+ return;
61
+ const finalMessage = message || `✗ ${this.currentPhase || "Snapshot"} failed`;
62
+ this.spinner.fail(finalMessage);
63
+ this.spinner = null;
64
+ }
65
+ /**
66
+ * Stop without success/fail status
67
+ */
68
+ stop() {
69
+ if (!this.enabled || !this.spinner)
70
+ return;
71
+ this.spinner.stop();
72
+ this.spinner = null;
73
+ }
74
+ /**
75
+ * Create a progress bar string
76
+ */
77
+ createProgressBar(percentage) {
78
+ const width = 20;
79
+ const filled = Math.round((percentage / 100) * width);
80
+ const empty = width - filled;
81
+ const bar = "█".repeat(filled) + "░".repeat(empty);
82
+ return `[${bar}] ${percentage}%`;
83
+ }
84
+ }
85
+ /**
86
+ * Progress indicator for agent thinking
87
+ */
88
+ export class AgentProgress {
89
+ spinner = null;
90
+ enabled;
91
+ stepCount = 0;
92
+ currentTool = null;
93
+ constructor(enabled = true) {
94
+ this.enabled = enabled;
95
+ }
96
+ /**
97
+ * Start thinking indicator
98
+ */
99
+ startThinking() {
100
+ if (!this.enabled)
101
+ return;
102
+ this.stepCount = 0;
103
+ this.currentTool = null;
104
+ this.spinner = ora({
105
+ text: "🤔 Analyzing...",
106
+ spinner: "dots",
107
+ color: "cyan",
108
+ }).start();
109
+ }
110
+ /**
111
+ * Update with current tool usage
112
+ */
113
+ updateTool(toolName, detail) {
114
+ if (!this.enabled || !this.spinner)
115
+ return;
116
+ this.stepCount++;
117
+ this.currentTool = toolName;
118
+ // Map tool names to more user-friendly descriptions
119
+ const friendlyNames = {
120
+ bash: "Running command",
121
+ px_fetch_more_spans: "Fetching additional spans",
122
+ px_fetch_more_trace: "Fetching trace details",
123
+ };
124
+ const displayName = friendlyNames[toolName] || toolName;
125
+ const message = detail
126
+ ? `🔧 ${displayName}: ${detail}`
127
+ : `🔧 ${displayName} (step ${this.stepCount})`;
128
+ this.spinner.text = message;
129
+ }
130
+ /**
131
+ * Update with tool result
132
+ */
133
+ updateToolResult(toolName, success = true) {
134
+ if (!this.enabled || !this.spinner)
135
+ return;
136
+ const icon = success ? "✓" : "✗";
137
+ const status = success ? "completed" : "failed";
138
+ // More informative messages for each tool
139
+ const toolMessages = {
140
+ bash: "Command executed",
141
+ px_fetch_more_spans: "Additional spans fetched",
142
+ px_fetch_more_trace: "Trace details fetched",
143
+ };
144
+ const baseMessage = toolMessages[toolName] || `Tool ${toolName}`;
145
+ const message = `${icon} ${baseMessage} ${status}`;
146
+ // Brief flash of the result before moving on
147
+ this.spinner.text = message;
148
+ }
149
+ /**
150
+ * Show progress for a specific action
151
+ */
152
+ updateAction(action) {
153
+ if (!this.enabled || !this.spinner)
154
+ return;
155
+ this.spinner.text = `🔍 ${action}...`;
156
+ }
157
+ /**
158
+ * Stop the thinking indicator
159
+ */
160
+ stop() {
161
+ if (!this.enabled || !this.spinner)
162
+ return;
163
+ this.spinner.stop();
164
+ this.spinner = null;
165
+ }
166
+ /**
167
+ * Complete with a success message
168
+ */
169
+ succeed(message = "✨ Analysis complete") {
170
+ if (!this.enabled || !this.spinner)
171
+ return;
172
+ this.spinner.succeed(message);
173
+ this.spinner = null;
174
+ }
175
+ }
176
+ /**
177
+ * Simple progress logger for when spinners aren't appropriate
178
+ */
179
+ export class SimpleProgress {
180
+ enabled;
181
+ constructor(enabled = true) {
182
+ this.enabled = enabled;
183
+ }
184
+ log(message) {
185
+ if (!this.enabled)
186
+ return;
187
+ console.log(`[Phoenix Insight] ${message}`);
188
+ }
189
+ info(message) {
190
+ if (!this.enabled)
191
+ return;
192
+ console.log(`ℹ️ ${message}`);
193
+ }
194
+ success(message) {
195
+ if (!this.enabled)
196
+ return;
197
+ console.log(`✅ ${message}`);
198
+ }
199
+ warning(message) {
200
+ if (!this.enabled)
201
+ return;
202
+ console.log(`⚠️ ${message}`);
203
+ }
204
+ error(message) {
205
+ if (!this.enabled)
206
+ return;
207
+ console.log(`❌ ${message}`);
208
+ }
209
+ }
@@ -0,0 +1 @@
1
+ export { INSIGHT_SYSTEM_PROMPT } from "./system.js";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * System prompt for the Phoenix Insight AI agent
3
+ * This prompt teaches the agent about the filesystem layout and available commands
4
+ */
5
+ export const INSIGHT_SYSTEM_PROMPT = `You are an expert at analyzing Phoenix observability data.
6
+
7
+ **START by reading /phoenix/_context.md** - it contains a summary of what's available.
8
+
9
+ You have access to a bash shell with Phoenix data organized as files:
10
+
11
+ /phoenix/
12
+ _context.md - READ THIS FIRST: summary of available data
13
+ /projects/{name}/spans/ - Span data (JSONL format, may be sampled)
14
+ /datasets/ - Datasets and examples
15
+ /experiments/ - Experiment runs and results
16
+ /prompts/ - Prompt templates and versions
17
+
18
+ Use commands like:
19
+ - cat, head, tail: Read file contents
20
+ - grep: Search for patterns
21
+ - jq: Query and transform JSON/JSONL
22
+ - ls, find: Navigate and discover data
23
+ - sort, uniq, wc: Aggregate and count
24
+ - awk: Complex text processing
25
+
26
+ If you need MORE data than what's in the snapshot:
27
+ - px-fetch-more spans --project <name> --limit 500
28
+ - px-fetch-more trace --trace-id <id>
29
+
30
+ This is a READ-ONLY snapshot. Start with _context.md, then explore to answer the question.`;
@@ -0,0 +1,74 @@
1
+ import { createClient } from "@arizeai/phoenix-client";
2
+ export class PhoenixClientError extends Error {
3
+ code;
4
+ originalError;
5
+ constructor(message, code, originalError) {
6
+ super(message);
7
+ this.name = "PhoenixClientError";
8
+ this.code = code;
9
+ this.originalError = originalError;
10
+ }
11
+ }
12
+ /**
13
+ * Creates a wrapped Phoenix client with error handling
14
+ */
15
+ export function createPhoenixClient(config = {}) {
16
+ const headers = {};
17
+ if (config.apiKey) {
18
+ headers["api_key"] = config.apiKey;
19
+ }
20
+ const clientOptions = {
21
+ options: {
22
+ baseUrl: config.baseURL,
23
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
24
+ },
25
+ };
26
+ return createClient(clientOptions);
27
+ }
28
+ /**
29
+ * Wraps an async operation with standardized error handling
30
+ */
31
+ export async function withErrorHandling(operation, context) {
32
+ try {
33
+ return await operation();
34
+ }
35
+ catch (error) {
36
+ // Network errors
37
+ if (error instanceof TypeError && error.message.includes("fetch")) {
38
+ throw new PhoenixClientError(`Network error during ${context}: Unable to connect to Phoenix server`, "NETWORK_ERROR", error);
39
+ }
40
+ // HTTP errors from the middleware
41
+ if (error instanceof Error && error.message.includes(": ")) {
42
+ const parts = error.message.split(": ", 2);
43
+ if (parts.length === 2 && parts[1]) {
44
+ const [url, statusInfo] = parts;
45
+ const statusParts = statusInfo.split(" ");
46
+ const statusCode = statusParts[0];
47
+ const statusText = statusParts.slice(1).join(" ");
48
+ if (statusCode === "401" || statusCode === "403") {
49
+ throw new PhoenixClientError(`Authentication error during ${context}: ${statusText}`, "AUTH_ERROR", error);
50
+ }
51
+ if (statusCode && statusCode.startsWith("4")) {
52
+ throw new PhoenixClientError(`Client error during ${context}: ${statusCode} ${statusText}`, "INVALID_RESPONSE", error);
53
+ }
54
+ if (statusCode && statusCode.startsWith("5")) {
55
+ throw new PhoenixClientError(`Server error during ${context}: ${statusCode} ${statusText}`, "NETWORK_ERROR", error);
56
+ }
57
+ }
58
+ }
59
+ // Unknown errors
60
+ throw new PhoenixClientError(`Unexpected error during ${context}: ${error instanceof Error ? error.message : String(error)}`, "UNKNOWN_ERROR", error);
61
+ }
62
+ }
63
+ /**
64
+ * Helper to safely extract data from API responses
65
+ */
66
+ export function extractData(response) {
67
+ if (response.error) {
68
+ throw response.error;
69
+ }
70
+ if (!response.data) {
71
+ throw new PhoenixClientError("Invalid API response: missing data", "INVALID_RESPONSE");
72
+ }
73
+ return response.data;
74
+ }