ex-brain 0.2.3 → 0.2.4
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.
- package/README.md +1 -1
- package/package.json +2 -1
- package/src/ai/ax-adapter.ts +80 -0
- package/src/ai/compiler.ts +148 -428
- package/src/ai/entity-link.ts +102 -109
- package/src/ai/timeline-extractor.ts +149 -306
- package/src/commands/index.ts +1 -1
- package/src/ai/llm-client.ts +0 -291
package/src/ai/llm-client.ts
DELETED
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unified LLM Client Module
|
|
3
|
-
*
|
|
4
|
-
* Provides centralized LLM calling functionality with:
|
|
5
|
-
* - Retry mechanism (exponential backoff, max 3 retries)
|
|
6
|
-
* - Error classification (APIError, TimeoutError, RateLimitError)
|
|
7
|
-
* - Timeout control
|
|
8
|
-
* - Unified API key resolution
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { ResolvedLLM } from "../settings";
|
|
12
|
-
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Error Classes
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
export class LLMError extends Error {
|
|
18
|
-
constructor(
|
|
19
|
-
message: string,
|
|
20
|
-
public readonly code: string,
|
|
21
|
-
public readonly statusCode?: number,
|
|
22
|
-
public readonly retryable: boolean = false,
|
|
23
|
-
) {
|
|
24
|
-
super(message);
|
|
25
|
-
this.name = "LLMError";
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class APIError extends LLMError {
|
|
30
|
-
constructor(message: string, statusCode?: number) {
|
|
31
|
-
super(message, "API_ERROR", statusCode, false);
|
|
32
|
-
this.name = "APIError";
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export class TimeoutError extends LLMError {
|
|
37
|
-
constructor(message: string = "LLM request timed out") {
|
|
38
|
-
super(message, "TIMEOUT_ERROR", undefined, true);
|
|
39
|
-
this.name = "TimeoutError";
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export class RateLimitError extends LLMError {
|
|
44
|
-
constructor(message: string = "Rate limit exceeded", retryAfter?: number) {
|
|
45
|
-
super(message, "RATE_LIMIT_ERROR", 429, true);
|
|
46
|
-
this.name = "RateLimitError";
|
|
47
|
-
this.retryAfter = retryAfter;
|
|
48
|
-
}
|
|
49
|
-
readonly retryAfter?: number;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
// Configuration
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
|
|
56
|
-
export interface LLMClientConfig {
|
|
57
|
-
/** Maximum number of retry attempts (default: 3) */
|
|
58
|
-
maxRetries?: number;
|
|
59
|
-
/** Base delay for exponential backoff in ms (default: 1000) */
|
|
60
|
-
baseDelay?: number;
|
|
61
|
-
/** Maximum delay cap in ms (default: 10000) */
|
|
62
|
-
maxDelay?: number;
|
|
63
|
-
/** Request timeout in ms (default: 60000) */
|
|
64
|
-
timeout?: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const DEFAULT_CONFIG: Required<LLMClientConfig> = {
|
|
68
|
-
maxRetries: 3,
|
|
69
|
-
baseDelay: 1000,
|
|
70
|
-
maxDelay: 10000,
|
|
71
|
-
timeout: 60000,
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// API Key Resolution
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Resolve API key from LLM configuration.
|
|
80
|
-
* Checks direct apiKey first, then falls back to environment variable.
|
|
81
|
-
*/
|
|
82
|
-
export function resolveApiKey(llm: ResolvedLLM): string {
|
|
83
|
-
if (llm.apiKey) return llm.apiKey;
|
|
84
|
-
if (llm.apiKeyEnv) return process.env[llm.apiKeyEnv] ?? "";
|
|
85
|
-
return "";
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Check if LLM is properly configured with an API key.
|
|
90
|
-
*/
|
|
91
|
-
export function isLLMConfigured(llm: ResolvedLLM): boolean {
|
|
92
|
-
return !!resolveApiKey(llm);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
// LLM Call with Retry
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Call LLM with unified fetch, retry mechanism, error handling, and timeout.
|
|
101
|
-
*
|
|
102
|
-
* @param llm - Resolved LLM configuration
|
|
103
|
-
* @param prompt - Prompt to send to the LLM
|
|
104
|
-
* @param maxTokens - Maximum tokens in response
|
|
105
|
-
* @param systemPrompt - Optional system prompt (default provided)
|
|
106
|
-
* @param config - Optional client configuration
|
|
107
|
-
* @returns Raw response text from LLM, or empty string on failure
|
|
108
|
-
*/
|
|
109
|
-
export async function callLLM(
|
|
110
|
-
llm: ResolvedLLM,
|
|
111
|
-
prompt: string,
|
|
112
|
-
maxTokens: number,
|
|
113
|
-
systemPrompt: string = "You are a helpful assistant. Always output valid JSON.",
|
|
114
|
-
config: LLMClientConfig = {},
|
|
115
|
-
): Promise<string> {
|
|
116
|
-
const apiKey = resolveApiKey(llm);
|
|
117
|
-
if (!apiKey) {
|
|
118
|
-
return "";
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
122
|
-
const url = llm.baseURL.endsWith("/")
|
|
123
|
-
? llm.baseURL + "chat/completions"
|
|
124
|
-
: llm.baseURL + "/chat/completions";
|
|
125
|
-
|
|
126
|
-
const body = {
|
|
127
|
-
model: llm.model,
|
|
128
|
-
messages: [
|
|
129
|
-
{ role: "system", content: systemPrompt },
|
|
130
|
-
{ role: "user", content: prompt },
|
|
131
|
-
],
|
|
132
|
-
temperature: 0.1,
|
|
133
|
-
max_tokens: maxTokens,
|
|
134
|
-
enable_thinking: false,
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
let lastError: LLMError | null = null;
|
|
138
|
-
|
|
139
|
-
for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) {
|
|
140
|
-
try {
|
|
141
|
-
const response = await callWithTimeout(
|
|
142
|
-
fetch(url, {
|
|
143
|
-
method: "POST",
|
|
144
|
-
headers: {
|
|
145
|
-
"Content-Type": "application/json",
|
|
146
|
-
Authorization: `Bearer ${apiKey}`,
|
|
147
|
-
},
|
|
148
|
-
body: JSON.stringify(body),
|
|
149
|
-
}),
|
|
150
|
-
cfg.timeout,
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
if (!response.ok) {
|
|
154
|
-
const text = await response.text().catch(() => "");
|
|
155
|
-
lastError = classifyError(response.status, text, response.statusText);
|
|
156
|
-
|
|
157
|
-
// Don't retry for non-retryable errors
|
|
158
|
-
if (!lastError.retryable || attempt === cfg.maxRetries) {
|
|
159
|
-
console.warn(`[llm-client] LLM call failed after ${attempt + 1} attempt(s): ${lastError.message}`);
|
|
160
|
-
return "";
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const delay = calculateBackoff(attempt, cfg.baseDelay, cfg.maxDelay, (lastError as RateLimitError).retryAfter);
|
|
164
|
-
console.warn(`[llm-client] Retrying after ${delay}ms (attempt ${attempt + 1}/${cfg.maxRetries})`);
|
|
165
|
-
await sleep(delay);
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
|
|
170
|
-
return data.choices?.[0]?.message?.content?.trim() ?? "";
|
|
171
|
-
|
|
172
|
-
} catch (error) {
|
|
173
|
-
// Classify the error
|
|
174
|
-
if (error instanceof TimeoutError) {
|
|
175
|
-
lastError = error;
|
|
176
|
-
} else if (error instanceof LLMError) {
|
|
177
|
-
lastError = error;
|
|
178
|
-
} else {
|
|
179
|
-
// Unknown error - wrap it
|
|
180
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
181
|
-
lastError = new APIError(`Unexpected error: ${msg}`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Don't retry if we've exhausted attempts
|
|
185
|
-
if (attempt === cfg.maxRetries) {
|
|
186
|
-
console.warn(`[llm-client] LLM call failed after ${attempt + 1} attempt(s): ${lastError.message}`);
|
|
187
|
-
return "";
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Check if error is retryable
|
|
191
|
-
if (!lastError.retryable) {
|
|
192
|
-
console.warn(`[llm-client] Non-retryable error: ${lastError.message}`);
|
|
193
|
-
return "";
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const delay = calculateBackoff(attempt, cfg.baseDelay, cfg.maxDelay);
|
|
197
|
-
console.warn(`[llm-client] Retrying after ${delay}ms (attempt ${attempt + 1}/${cfg.maxRetries}): ${lastError.message}`);
|
|
198
|
-
await sleep(delay);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return "";
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Classify HTTP error into appropriate error type.
|
|
207
|
-
*/
|
|
208
|
-
function classifyError(status: number, responseText: string, statusText: string): LLMError {
|
|
209
|
-
const truncatedText = responseText.slice(0, 200);
|
|
210
|
-
|
|
211
|
-
switch (status) {
|
|
212
|
-
case 429:
|
|
213
|
-
// Try to extract retry-after from response
|
|
214
|
-
const retryAfterMatch = responseText.match(/retry[- ]?after["']?\s*[:=]\s*(\d+)/i);
|
|
215
|
-
const retryAfter = retryAfterMatch?.[1] ? parseInt(retryAfterMatch[1], 10) : undefined;
|
|
216
|
-
return new RateLimitError(`Rate limited: ${statusText} - ${truncatedText}`, retryAfter);
|
|
217
|
-
|
|
218
|
-
case 408:
|
|
219
|
-
case 504:
|
|
220
|
-
return new TimeoutError(`Request timeout: ${statusText}`);
|
|
221
|
-
|
|
222
|
-
case 500:
|
|
223
|
-
case 502:
|
|
224
|
-
case 503:
|
|
225
|
-
return new APIError(`Server error (${status}): ${truncatedText}`, status);
|
|
226
|
-
|
|
227
|
-
default:
|
|
228
|
-
if (status >= 500) {
|
|
229
|
-
return new APIError(`Server error (${status}): ${truncatedText}`, status);
|
|
230
|
-
}
|
|
231
|
-
if (status >= 400) {
|
|
232
|
-
return new APIError(`Client error (${status}): ${truncatedText}`, status);
|
|
233
|
-
}
|
|
234
|
-
return new APIError(`HTTP error (${status}): ${truncatedText}`, status);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Calculate exponential backoff delay with jitter.
|
|
240
|
-
*/
|
|
241
|
-
function calculateBackoff(
|
|
242
|
-
attempt: number,
|
|
243
|
-
baseDelay: number,
|
|
244
|
-
maxDelay: number,
|
|
245
|
-
retryAfter?: number,
|
|
246
|
-
): number {
|
|
247
|
-
// If server specified retry-after, use that
|
|
248
|
-
if (retryAfter && retryAfter > 0) {
|
|
249
|
-
return Math.min(retryAfter * 1000, maxDelay);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Exponential backoff: baseDelay * 2^attempt
|
|
253
|
-
const exponentialDelay = baseDelay * Math.pow(2, attempt);
|
|
254
|
-
|
|
255
|
-
// Add jitter (±25%)
|
|
256
|
-
const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
|
|
257
|
-
|
|
258
|
-
return Math.min(Math.round(exponentialDelay + jitter), maxDelay);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Sleep for specified milliseconds.
|
|
263
|
-
*/
|
|
264
|
-
function sleep(ms: number): Promise<void> {
|
|
265
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Wrap fetch with timeout using Promise.race.
|
|
270
|
-
*/
|
|
271
|
-
async function callWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
272
|
-
let timeoutId: NodeJS.Timeout;
|
|
273
|
-
|
|
274
|
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
275
|
-
timeoutId = setTimeout(() => {
|
|
276
|
-
reject(new TimeoutError(`Request timed out after ${timeoutMs}ms`));
|
|
277
|
-
}, timeoutMs);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
try {
|
|
281
|
-
return await Promise.race([promise, timeoutPromise]);
|
|
282
|
-
} finally {
|
|
283
|
-
clearTimeout(timeoutId!);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
// Re-export settings type for convenience
|
|
289
|
-
// ---------------------------------------------------------------------------
|
|
290
|
-
|
|
291
|
-
export type { ResolvedLLM } from "../settings";
|