jblyons15-research-sdk 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.
@@ -0,0 +1,120 @@
1
+ import { SupabaseClient } from '@supabase/supabase-js';
2
+ import type { ResearchRequest, ResearchResult, ResearchJob, ResearchJobStatus, ResearchError, CacheEntry, RetryPolicy } from '@crossover/research-types';
3
+ export declare class ResearchErrorHandler {
4
+ static create(type: ResearchError['type'], code: string, message: string, details?: Record<string, any>, retryable?: boolean): ResearchError;
5
+ static fromPerplexityError(error: any): ResearchError;
6
+ static fromFirecrawlError(error: any): ResearchError;
7
+ static isRetryable(error: ResearchError): boolean;
8
+ }
9
+ export declare class RetryEngine {
10
+ private policy;
11
+ constructor(policy?: RetryPolicy);
12
+ execute<T>(fn: () => Promise<T>, context?: string): Promise<T>;
13
+ private calculateDelay;
14
+ private sleep;
15
+ }
16
+ export declare class CacheClient {
17
+ private cache;
18
+ get(queryHash: string): Promise<CacheEntry | null>;
19
+ set(queryHash: string, result: ResearchResult, ttlMs?: any): Promise<void>;
20
+ clear(): Promise<void>;
21
+ getStats(): {
22
+ size: number;
23
+ totalHits: number;
24
+ };
25
+ }
26
+ export declare class PerplexityClient {
27
+ private apiKey;
28
+ private retryEngine;
29
+ constructor(apiKey: string);
30
+ search(query: string, options?: {
31
+ domainFilter?: {
32
+ allow?: string[];
33
+ deny?: string[];
34
+ };
35
+ recency?: 'day' | 'week' | 'month';
36
+ }): Promise<{
37
+ sources: Array<{
38
+ url: string;
39
+ title: string;
40
+ snippet: string;
41
+ }>;
42
+ totalResults: number;
43
+ executionMs: number;
44
+ }>;
45
+ agent(request: {
46
+ messages: Array<{
47
+ role: string;
48
+ content: string;
49
+ }>;
50
+ model?: string;
51
+ tools?: any[];
52
+ systemPrompt?: string;
53
+ }): Promise<{
54
+ synthesis: string;
55
+ citations: string[];
56
+ executionMs: number;
57
+ }>;
58
+ }
59
+ export declare class FirecrawlClient {
60
+ private apiKey;
61
+ private retryEngine;
62
+ constructor(apiKey: string);
63
+ scrape(url: string, schema?: Record<string, any>): Promise<{
64
+ data: Record<string, any>;
65
+ markdown: string;
66
+ executionMs: number;
67
+ }>;
68
+ batchScrape(urls: string[]): Promise<Array<{
69
+ url: string;
70
+ data: Record<string, any>;
71
+ error?: string;
72
+ }>>;
73
+ }
74
+ export declare class CortexClient {
75
+ private supabase;
76
+ constructor(supabase: SupabaseClient);
77
+ createResearchRequest(request: Omit<ResearchRequest, 'id' | 'status' | 'createdAt' | 'updatedAt'>): Promise<string>;
78
+ getResearchJob(jobId: string): Promise<ResearchJob | null>;
79
+ updateResearchJob(jobId: string, updates: Partial<ResearchJob>): Promise<void>;
80
+ createReport(report: {
81
+ entityType: string;
82
+ reportType: string;
83
+ content: string;
84
+ model: string;
85
+ cost: number;
86
+ latencyMs: number;
87
+ }): Promise<string>;
88
+ subscribe(jobId: string, callback: (status: ResearchJobStatus) => void): void;
89
+ }
90
+ export declare class ResearchOrchestrator {
91
+ private perplexity;
92
+ private firecrawl;
93
+ private cortex;
94
+ private cache;
95
+ private retryEngine;
96
+ constructor(config: {
97
+ perplexityApiKey: string;
98
+ firecrawlApiKey: string;
99
+ supabaseUrl: string;
100
+ supabaseAnonKey: string;
101
+ });
102
+ submitRequest(request: Omit<ResearchRequest, 'id' | 'status' | 'createdAt' | 'updatedAt'>): Promise<string>;
103
+ getStatus(jobId: string): Promise<ResearchJobStatus | null>;
104
+ getResults(jobId: string): Promise<ResearchResult | null>;
105
+ onStatusChange(jobId: string, callback: (status: ResearchJobStatus) => void): void;
106
+ getCacheStats(): {
107
+ size: number;
108
+ totalHits: number;
109
+ };
110
+ }
111
+ declare const _default: {
112
+ ResearchOrchestrator: typeof ResearchOrchestrator;
113
+ ResearchErrorHandler: typeof ResearchErrorHandler;
114
+ RetryEngine: typeof RetryEngine;
115
+ CacheClient: typeof CacheClient;
116
+ PerplexityClient: typeof PerplexityClient;
117
+ FirecrawlClient: typeof FirecrawlClient;
118
+ CortexClient: typeof CortexClient;
119
+ };
120
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,377 @@
1
+ // @crossover/research-sdk - Complete Package Definition
2
+ // Client libraries for Supabase, Perplexity, Firecrawl, error handling, caching
3
+ import { createClient } from '@supabase/supabase-js';
4
+ // ============================================================================
5
+ // ERROR HANDLER - Canonical error handling (SSOT)
6
+ // ============================================================================
7
+ export class ResearchErrorHandler {
8
+ static create(type, code, message, details = {}, retryable = false) {
9
+ return {
10
+ type,
11
+ code,
12
+ message,
13
+ retryable,
14
+ details,
15
+ timestamp: new Date(),
16
+ };
17
+ }
18
+ static fromPerplexityError(error) {
19
+ if (error?.status === 429) {
20
+ return this.create('RATE_LIMIT', 'PERPLEXITY_RATE_LIMITED', error.message, { error }, true);
21
+ }
22
+ if (error?.status === 401) {
23
+ return this.create('AUTH', 'PERPLEXITY_AUTH_FAILED', error.message, { error }, false);
24
+ }
25
+ if (error?.status >= 500) {
26
+ return this.create('API', 'PERPLEXITY_SERVER_ERROR', error.message, { error }, true);
27
+ }
28
+ return this.create('API', 'PERPLEXITY_ERROR', error.message, { error }, false);
29
+ }
30
+ static fromFirecrawlError(error) {
31
+ if (error?.status === 429) {
32
+ return this.create('RATE_LIMIT', 'FIRECRAWL_RATE_LIMITED', error.message, { error }, true);
33
+ }
34
+ if (error?.status === 401) {
35
+ return this.create('AUTH', 'FIRECRAWL_AUTH_FAILED', error.message, { error }, false);
36
+ }
37
+ if (error?.status >= 500) {
38
+ return this.create('API', 'FIRECRAWL_SERVER_ERROR', error.message, { error }, true);
39
+ }
40
+ return this.create('API', 'FIRECRAWL_ERROR', error.message, { error }, false);
41
+ }
42
+ static isRetryable(error) {
43
+ return error.retryable && error.type !== 'VALIDATION';
44
+ }
45
+ }
46
+ // ============================================================================
47
+ // RETRY ENGINE - Canonical retry logic (SSOT)
48
+ // ============================================================================
49
+ export class RetryEngine {
50
+ constructor(policy = CANONICAL_RETRY_POLICY) {
51
+ this.policy = policy;
52
+ }
53
+ async execute(fn, context = 'operation') {
54
+ let lastError = null;
55
+ for (let attempt = 0; attempt <= this.policy.maxRetries; attempt++) {
56
+ try {
57
+ return await fn();
58
+ }
59
+ catch (error) {
60
+ lastError = error instanceof Error ? error : new Error(String(error));
61
+ if (attempt === this.policy.maxRetries) {
62
+ throw lastError;
63
+ }
64
+ const delayMs = this.calculateDelay(attempt);
65
+ console.log(`${context} failed, retrying in ${delayMs}ms (attempt ${attempt + 1}/${this.policy.maxRetries})`);
66
+ await this.sleep(delayMs);
67
+ }
68
+ }
69
+ throw lastError;
70
+ }
71
+ calculateDelay(attempt) {
72
+ const exponential = this.policy.initialDelayMs * Math.pow(this.policy.backoffMultiplier, attempt);
73
+ return Math.min(exponential, this.policy.maxDelayMs);
74
+ }
75
+ sleep(ms) {
76
+ return new Promise(resolve => setTimeout(resolve, ms));
77
+ }
78
+ }
79
+ // ============================================================================
80
+ // CACHE CLIENT - Canonical caching (SSOT)
81
+ // ============================================================================
82
+ export class CacheClient {
83
+ constructor() {
84
+ this.cache = new Map();
85
+ }
86
+ async get(queryHash) {
87
+ const entry = this.cache.get(queryHash);
88
+ if (!entry)
89
+ return null;
90
+ if (entry.expiresAt < new Date()) {
91
+ this.cache.delete(queryHash);
92
+ return null;
93
+ }
94
+ // Record hit
95
+ entry.hitCount += 1;
96
+ return entry;
97
+ }
98
+ async set(queryHash, result, ttlMs = CANONICAL_CACHE_TTL_MS) {
99
+ const entry = {
100
+ queryHash,
101
+ query: '', // Would be populated in real implementation
102
+ result,
103
+ hitCount: 0,
104
+ createdAt: new Date(),
105
+ expiresAt: new Date(Date.now() + ttlMs),
106
+ };
107
+ this.cache.set(queryHash, entry);
108
+ }
109
+ async clear() {
110
+ this.cache.clear();
111
+ }
112
+ getStats() {
113
+ const totalHits = Array.from(this.cache.values()).reduce((sum, e) => sum + e.hitCount, 0);
114
+ return { size: this.cache.size, totalHits };
115
+ }
116
+ }
117
+ // ============================================================================
118
+ // PERPLEXITY CLIENT - Canonical Perplexity integration
119
+ // ============================================================================
120
+ export class PerplexityClient {
121
+ constructor(apiKey) {
122
+ this.apiKey = apiKey;
123
+ this.retryEngine = new RetryEngine();
124
+ }
125
+ async search(query, options) {
126
+ const startTime = Date.now();
127
+ return this.retryEngine.execute(async () => {
128
+ const response = await fetch('https://api.perplexity.ai/search', {
129
+ method: 'POST',
130
+ headers: {
131
+ 'Authorization': `Bearer ${this.apiKey}`,
132
+ 'Content-Type': 'application/json',
133
+ },
134
+ body: JSON.stringify({
135
+ query,
136
+ search_type: 'web',
137
+ search_domain_filter: options?.domainFilter,
138
+ recency: options?.recency || 'month',
139
+ }),
140
+ });
141
+ if (!response.ok) {
142
+ throw ResearchErrorHandler.fromPerplexityError({
143
+ status: response.status,
144
+ message: `HTTP ${response.status}`,
145
+ });
146
+ }
147
+ const data = await response.json();
148
+ const executionMs = Date.now() - startTime;
149
+ return {
150
+ sources: data.results?.map((r) => ({
151
+ url: r.url,
152
+ title: r.title,
153
+ snippet: r.snippet,
154
+ })) || [],
155
+ totalResults: data.total_results || 0,
156
+ executionMs,
157
+ };
158
+ }, 'Perplexity search');
159
+ }
160
+ async agent(request) {
161
+ const startTime = Date.now();
162
+ return this.retryEngine.execute(async () => {
163
+ const response = await fetch('https://api.perplexity.ai/chat/completions', {
164
+ method: 'POST',
165
+ headers: {
166
+ 'Authorization': `Bearer ${this.apiKey}`,
167
+ 'Content-Type': 'application/json',
168
+ },
169
+ body: JSON.stringify({
170
+ model: request.model || 'sonar-pro',
171
+ messages: request.messages,
172
+ tools: request.tools,
173
+ system: request.systemPrompt,
174
+ temperature: 0.7,
175
+ top_p: 1,
176
+ }),
177
+ });
178
+ if (!response.ok) {
179
+ throw ResearchErrorHandler.fromPerplexityError({
180
+ status: response.status,
181
+ message: `HTTP ${response.status}`,
182
+ });
183
+ }
184
+ const data = await response.json();
185
+ const executionMs = Date.now() - startTime;
186
+ return {
187
+ synthesis: data.choices?.[0]?.message?.content || '',
188
+ citations: data.citations || [],
189
+ executionMs,
190
+ };
191
+ }, 'Perplexity agent');
192
+ }
193
+ }
194
+ // ============================================================================
195
+ // FIRECRAWL CLIENT - Canonical web scraping
196
+ // ============================================================================
197
+ export class FirecrawlClient {
198
+ constructor(apiKey) {
199
+ this.apiKey = apiKey;
200
+ this.retryEngine = new RetryEngine();
201
+ }
202
+ async scrape(url, schema) {
203
+ const startTime = Date.now();
204
+ return this.retryEngine.execute(async () => {
205
+ const response = await fetch('https://api.firecrawl.dev/v1/scrape', {
206
+ method: 'POST',
207
+ headers: {
208
+ 'Authorization': `Bearer ${this.apiKey}`,
209
+ 'Content-Type': 'application/json',
210
+ },
211
+ body: JSON.stringify({
212
+ url,
213
+ extractorOptions: schema ? {
214
+ mode: 'llm-extraction',
215
+ extractionSchema: schema,
216
+ } : undefined,
217
+ }),
218
+ });
219
+ if (!response.ok) {
220
+ throw ResearchErrorHandler.fromFirecrawlError({
221
+ status: response.status,
222
+ message: `HTTP ${response.status}`,
223
+ });
224
+ }
225
+ const data = await response.json();
226
+ const executionMs = Date.now() - startTime;
227
+ return {
228
+ data: data.data || {},
229
+ markdown: data.markdown || '',
230
+ executionMs,
231
+ };
232
+ }, `Firecrawl scrape ${url}`);
233
+ }
234
+ async batchScrape(urls) {
235
+ return Promise.all(urls.map(async (url) => {
236
+ try {
237
+ const result = await this.scrape(url);
238
+ return { url, data: result.data };
239
+ }
240
+ catch (error) {
241
+ return {
242
+ url,
243
+ data: {},
244
+ error: error instanceof Error ? error.message : String(error),
245
+ };
246
+ }
247
+ }));
248
+ }
249
+ }
250
+ // ============================================================================
251
+ // CORTEX CLIENT - Supabase integration
252
+ // ============================================================================
253
+ export class CortexClient {
254
+ constructor(supabase) {
255
+ this.supabase = supabase;
256
+ }
257
+ async createResearchRequest(request) {
258
+ const { data, error } = await this.supabase
259
+ .from('cortex.client_research_requests')
260
+ .insert({
261
+ ...request,
262
+ status: 'pending',
263
+ created_at: new Date(),
264
+ updated_at: new Date(),
265
+ })
266
+ .select('id')
267
+ .single();
268
+ if (error)
269
+ throw error;
270
+ return data.id;
271
+ }
272
+ async getResearchJob(jobId) {
273
+ const { data, error } = await this.supabase
274
+ .from('cortex.research_jobs')
275
+ .select('*')
276
+ .eq('id', jobId)
277
+ .single();
278
+ if (error && error.code !== 'PGRST116')
279
+ throw error;
280
+ return data || null;
281
+ }
282
+ async updateResearchJob(jobId, updates) {
283
+ const { error } = await this.supabase
284
+ .from('cortex.research_jobs')
285
+ .update({
286
+ ...updates,
287
+ updated_at: new Date(),
288
+ })
289
+ .eq('id', jobId);
290
+ if (error)
291
+ throw error;
292
+ }
293
+ async createReport(report) {
294
+ const { data, error } = await this.supabase
295
+ .from('ai_reports')
296
+ .insert({
297
+ entity_type: report.entityType,
298
+ report_type: report.reportType,
299
+ content: report.content,
300
+ model: report.model,
301
+ cost: report.cost,
302
+ latency_ms: report.latencyMs,
303
+ generated_at: new Date(),
304
+ })
305
+ .select('id')
306
+ .single();
307
+ if (error)
308
+ throw error;
309
+ return data.id;
310
+ }
311
+ subscribe(jobId, callback) {
312
+ this.supabase
313
+ .from(`cortex.research_jobs:id=eq.${jobId}`)
314
+ .on('*', (payload) => {
315
+ callback({
316
+ jobId,
317
+ status: payload.new.status,
318
+ progress: payload.new.progress || 0,
319
+ error: payload.new.error,
320
+ lastUpdate: new Date(),
321
+ });
322
+ })
323
+ .subscribe();
324
+ }
325
+ }
326
+ // ============================================================================
327
+ // RESEARCH ORCHESTRATOR - Main entry point
328
+ // ============================================================================
329
+ export class ResearchOrchestrator {
330
+ constructor(config) {
331
+ this.perplexity = new PerplexityClient(config.perplexityApiKey);
332
+ this.firecrawl = new FirecrawlClient(config.firecrawlApiKey);
333
+ this.cortex = new CortexClient(createClient(config.supabaseUrl, config.supabaseAnonKey));
334
+ this.cache = new CacheClient();
335
+ this.retryEngine = new RetryEngine();
336
+ }
337
+ async submitRequest(request) {
338
+ // This just creates the request record
339
+ // The actual execution happens in the Edge Function
340
+ const jobId = await this.cortex.createResearchRequest(request);
341
+ return jobId;
342
+ }
343
+ async getStatus(jobId) {
344
+ const job = await this.cortex.getResearchJob(jobId);
345
+ if (!job)
346
+ return null;
347
+ return {
348
+ jobId,
349
+ status: job.status,
350
+ progress: job.progress || 0,
351
+ error: job.error,
352
+ lastUpdate: job.updatedAt,
353
+ };
354
+ }
355
+ async getResults(jobId) {
356
+ const job = await this.cortex.getResearchJob(jobId);
357
+ return job?.result || null;
358
+ }
359
+ onStatusChange(jobId, callback) {
360
+ this.cortex.subscribe(jobId, callback);
361
+ }
362
+ getCacheStats() {
363
+ return this.cache.getStats();
364
+ }
365
+ }
366
+ // ============================================================================
367
+ // EXPORTS
368
+ // ============================================================================
369
+ export default {
370
+ ResearchOrchestrator,
371
+ ResearchErrorHandler,
372
+ RetryEngine,
373
+ CacheClient,
374
+ PerplexityClient,
375
+ FirecrawlClient,
376
+ CortexClient,
377
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "jblyons15-research-sdk",
3
+ "version": "1.0.0",
4
+ "description": "Research orchestration SDK for CommandCenter",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "keywords": [
8
+ "research",
9
+ "orchestration",
10
+ "perplexity",
11
+ "firecrawl",
12
+ "supabase"
13
+ ],
14
+ "author": "Crossover Research",
15
+ "license": "MIT",
16
+ "dependencies": {
17
+ "@supabase/supabase-js": "^2.38.0",
18
+ "jblyons15-research-types": "^1.0.0",
19
+ "zod": "^3.22.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.0.0",
23
+ "typescript": "^5.0.0"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "type-check": "tsc --noEmit"
28
+ }
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,496 @@
1
+ // @crossover/research-sdk - Complete Package Definition
2
+ // Client libraries for Supabase, Perplexity, Firecrawl, error handling, caching
3
+
4
+ import { createClient, SupabaseClient } from '@supabase/supabase-js'
5
+ import type {
6
+ ResearchRequest,
7
+ ResearchResult,
8
+ ResearchJob,
9
+ ResearchJobStatus,
10
+ ResearchError,
11
+ CacheEntry,
12
+ RetryPolicy,
13
+ CANONICAL_RETRY_POLICY,
14
+ CANONICAL_CACHE_TTL_MS,
15
+ } from '@crossover/research-types'
16
+
17
+ // ============================================================================
18
+ // ERROR HANDLER - Canonical error handling (SSOT)
19
+ // ============================================================================
20
+
21
+ export class ResearchErrorHandler {
22
+ static create(
23
+ type: ResearchError['type'],
24
+ code: string,
25
+ message: string,
26
+ details: Record<string, any> = {},
27
+ retryable = false
28
+ ): ResearchError {
29
+ return {
30
+ type,
31
+ code,
32
+ message,
33
+ retryable,
34
+ details,
35
+ timestamp: new Date(),
36
+ }
37
+ }
38
+
39
+ static fromPerplexityError(error: any): ResearchError {
40
+ if (error?.status === 429) {
41
+ return this.create('RATE_LIMIT', 'PERPLEXITY_RATE_LIMITED', error.message, { error }, true)
42
+ }
43
+ if (error?.status === 401) {
44
+ return this.create('AUTH', 'PERPLEXITY_AUTH_FAILED', error.message, { error }, false)
45
+ }
46
+ if (error?.status >= 500) {
47
+ return this.create('API', 'PERPLEXITY_SERVER_ERROR', error.message, { error }, true)
48
+ }
49
+ return this.create('API', 'PERPLEXITY_ERROR', error.message, { error }, false)
50
+ }
51
+
52
+ static fromFirecrawlError(error: any): ResearchError {
53
+ if (error?.status === 429) {
54
+ return this.create('RATE_LIMIT', 'FIRECRAWL_RATE_LIMITED', error.message, { error }, true)
55
+ }
56
+ if (error?.status === 401) {
57
+ return this.create('AUTH', 'FIRECRAWL_AUTH_FAILED', error.message, { error }, false)
58
+ }
59
+ if (error?.status >= 500) {
60
+ return this.create('API', 'FIRECRAWL_SERVER_ERROR', error.message, { error }, true)
61
+ }
62
+ return this.create('API', 'FIRECRAWL_ERROR', error.message, { error }, false)
63
+ }
64
+
65
+ static isRetryable(error: ResearchError): boolean {
66
+ return error.retryable && error.type !== 'VALIDATION'
67
+ }
68
+ }
69
+
70
+ // ============================================================================
71
+ // RETRY ENGINE - Canonical retry logic (SSOT)
72
+ // ============================================================================
73
+
74
+ export class RetryEngine {
75
+ constructor(private policy: RetryPolicy = CANONICAL_RETRY_POLICY) {}
76
+
77
+ async execute<T>(
78
+ fn: () => Promise<T>,
79
+ context: string = 'operation'
80
+ ): Promise<T> {
81
+ let lastError: Error | null = null
82
+
83
+ for (let attempt = 0; attempt <= this.policy.maxRetries; attempt++) {
84
+ try {
85
+ return await fn()
86
+ } catch (error) {
87
+ lastError = error instanceof Error ? error : new Error(String(error))
88
+
89
+ if (attempt === this.policy.maxRetries) {
90
+ throw lastError
91
+ }
92
+
93
+ const delayMs = this.calculateDelay(attempt)
94
+ console.log(`${context} failed, retrying in ${delayMs}ms (attempt ${attempt + 1}/${this.policy.maxRetries})`)
95
+ await this.sleep(delayMs)
96
+ }
97
+ }
98
+
99
+ throw lastError
100
+ }
101
+
102
+ private calculateDelay(attempt: number): number {
103
+ const exponential = this.policy.initialDelayMs * Math.pow(this.policy.backoffMultiplier, attempt)
104
+ return Math.min(exponential, this.policy.maxDelayMs)
105
+ }
106
+
107
+ private sleep(ms: number): Promise<void> {
108
+ return new Promise(resolve => setTimeout(resolve, ms))
109
+ }
110
+ }
111
+
112
+ // ============================================================================
113
+ // CACHE CLIENT - Canonical caching (SSOT)
114
+ // ============================================================================
115
+
116
+ export class CacheClient {
117
+ private cache = new Map<string, CacheEntry>()
118
+
119
+ async get(queryHash: string): Promise<CacheEntry | null> {
120
+ const entry = this.cache.get(queryHash)
121
+
122
+ if (!entry) return null
123
+ if (entry.expiresAt < new Date()) {
124
+ this.cache.delete(queryHash)
125
+ return null
126
+ }
127
+
128
+ // Record hit
129
+ entry.hitCount += 1
130
+ return entry
131
+ }
132
+
133
+ async set(
134
+ queryHash: string,
135
+ result: ResearchResult,
136
+ ttlMs = CANONICAL_CACHE_TTL_MS
137
+ ): Promise<void> {
138
+ const entry: CacheEntry = {
139
+ queryHash,
140
+ query: '', // Would be populated in real implementation
141
+ result,
142
+ hitCount: 0,
143
+ createdAt: new Date(),
144
+ expiresAt: new Date(Date.now() + ttlMs),
145
+ }
146
+
147
+ this.cache.set(queryHash, entry)
148
+ }
149
+
150
+ async clear(): Promise<void> {
151
+ this.cache.clear()
152
+ }
153
+
154
+ getStats(): { size: number; totalHits: number } {
155
+ const totalHits = Array.from(this.cache.values()).reduce((sum, e) => sum + e.hitCount, 0)
156
+ return { size: this.cache.size, totalHits }
157
+ }
158
+ }
159
+
160
+ // ============================================================================
161
+ // PERPLEXITY CLIENT - Canonical Perplexity integration
162
+ // ============================================================================
163
+
164
+ export class PerplexityClient {
165
+ private retryEngine = new RetryEngine()
166
+
167
+ constructor(private apiKey: string) {}
168
+
169
+ async search(query: string, options?: {
170
+ domainFilter?: { allow?: string[]; deny?: string[] }
171
+ recency?: 'day' | 'week' | 'month'
172
+ }): Promise<{
173
+ sources: Array<{ url: string; title: string; snippet: string }>
174
+ totalResults: number
175
+ executionMs: number
176
+ }> {
177
+ const startTime = Date.now()
178
+
179
+ return this.retryEngine.execute(async () => {
180
+ const response = await fetch('https://api.perplexity.ai/search', {
181
+ method: 'POST',
182
+ headers: {
183
+ 'Authorization': `Bearer ${this.apiKey}`,
184
+ 'Content-Type': 'application/json',
185
+ },
186
+ body: JSON.stringify({
187
+ query,
188
+ search_type: 'web',
189
+ search_domain_filter: options?.domainFilter,
190
+ recency: options?.recency || 'month',
191
+ }),
192
+ })
193
+
194
+ if (!response.ok) {
195
+ throw ResearchErrorHandler.fromPerplexityError({
196
+ status: response.status,
197
+ message: `HTTP ${response.status}`,
198
+ })
199
+ }
200
+
201
+ const data = await response.json()
202
+ const executionMs = Date.now() - startTime
203
+
204
+ return {
205
+ sources: data.results?.map((r: any) => ({
206
+ url: r.url,
207
+ title: r.title,
208
+ snippet: r.snippet,
209
+ })) || [],
210
+ totalResults: data.total_results || 0,
211
+ executionMs,
212
+ }
213
+ }, 'Perplexity search')
214
+ }
215
+
216
+ async agent(request: {
217
+ messages: Array<{ role: string; content: string }>
218
+ model?: string
219
+ tools?: any[]
220
+ systemPrompt?: string
221
+ }): Promise<{
222
+ synthesis: string
223
+ citations: string[]
224
+ executionMs: number
225
+ }> {
226
+ const startTime = Date.now()
227
+
228
+ return this.retryEngine.execute(async () => {
229
+ const response = await fetch('https://api.perplexity.ai/chat/completions', {
230
+ method: 'POST',
231
+ headers: {
232
+ 'Authorization': `Bearer ${this.apiKey}`,
233
+ 'Content-Type': 'application/json',
234
+ },
235
+ body: JSON.stringify({
236
+ model: request.model || 'sonar-pro',
237
+ messages: request.messages,
238
+ tools: request.tools,
239
+ system: request.systemPrompt,
240
+ temperature: 0.7,
241
+ top_p: 1,
242
+ }),
243
+ })
244
+
245
+ if (!response.ok) {
246
+ throw ResearchErrorHandler.fromPerplexityError({
247
+ status: response.status,
248
+ message: `HTTP ${response.status}`,
249
+ })
250
+ }
251
+
252
+ const data = await response.json()
253
+ const executionMs = Date.now() - startTime
254
+
255
+ return {
256
+ synthesis: data.choices?.[0]?.message?.content || '',
257
+ citations: data.citations || [],
258
+ executionMs,
259
+ }
260
+ }, 'Perplexity agent')
261
+ }
262
+ }
263
+
264
+ // ============================================================================
265
+ // FIRECRAWL CLIENT - Canonical web scraping
266
+ // ============================================================================
267
+
268
+ export class FirecrawlClient {
269
+ private retryEngine = new RetryEngine()
270
+
271
+ constructor(private apiKey: string) {}
272
+
273
+ async scrape(url: string, schema?: Record<string, any>): Promise<{
274
+ data: Record<string, any>
275
+ markdown: string
276
+ executionMs: number
277
+ }> {
278
+ const startTime = Date.now()
279
+
280
+ return this.retryEngine.execute(async () => {
281
+ const response = await fetch('https://api.firecrawl.dev/v1/scrape', {
282
+ method: 'POST',
283
+ headers: {
284
+ 'Authorization': `Bearer ${this.apiKey}`,
285
+ 'Content-Type': 'application/json',
286
+ },
287
+ body: JSON.stringify({
288
+ url,
289
+ extractorOptions: schema ? {
290
+ mode: 'llm-extraction',
291
+ extractionSchema: schema,
292
+ } : undefined,
293
+ }),
294
+ })
295
+
296
+ if (!response.ok) {
297
+ throw ResearchErrorHandler.fromFirecrawlError({
298
+ status: response.status,
299
+ message: `HTTP ${response.status}`,
300
+ })
301
+ }
302
+
303
+ const data = await response.json()
304
+ const executionMs = Date.now() - startTime
305
+
306
+ return {
307
+ data: data.data || {},
308
+ markdown: data.markdown || '',
309
+ executionMs,
310
+ }
311
+ }, `Firecrawl scrape ${url}`)
312
+ }
313
+
314
+ async batchScrape(urls: string[]): Promise<Array<{
315
+ url: string
316
+ data: Record<string, any>
317
+ error?: string
318
+ }>> {
319
+ return Promise.all(
320
+ urls.map(async url => {
321
+ try {
322
+ const result = await this.scrape(url)
323
+ return { url, data: result.data }
324
+ } catch (error) {
325
+ return {
326
+ url,
327
+ data: {},
328
+ error: error instanceof Error ? error.message : String(error),
329
+ }
330
+ }
331
+ })
332
+ )
333
+ }
334
+ }
335
+
336
+ // ============================================================================
337
+ // CORTEX CLIENT - Supabase integration
338
+ // ============================================================================
339
+
340
+ export class CortexClient {
341
+ constructor(private supabase: SupabaseClient) {}
342
+
343
+ async createResearchRequest(request: Omit<ResearchRequest, 'id' | 'status' | 'createdAt' | 'updatedAt'>): Promise<string> {
344
+ const { data, error } = await this.supabase
345
+ .from('cortex.client_research_requests')
346
+ .insert({
347
+ ...request,
348
+ status: 'pending',
349
+ created_at: new Date(),
350
+ updated_at: new Date(),
351
+ })
352
+ .select('id')
353
+ .single()
354
+
355
+ if (error) throw error
356
+ return data.id
357
+ }
358
+
359
+ async getResearchJob(jobId: string): Promise<ResearchJob | null> {
360
+ const { data, error } = await this.supabase
361
+ .from('cortex.research_jobs')
362
+ .select('*')
363
+ .eq('id', jobId)
364
+ .single()
365
+
366
+ if (error && error.code !== 'PGRST116') throw error
367
+ return data || null
368
+ }
369
+
370
+ async updateResearchJob(jobId: string, updates: Partial<ResearchJob>): Promise<void> {
371
+ const { error } = await this.supabase
372
+ .from('cortex.research_jobs')
373
+ .update({
374
+ ...updates,
375
+ updated_at: new Date(),
376
+ })
377
+ .eq('id', jobId)
378
+
379
+ if (error) throw error
380
+ }
381
+
382
+ async createReport(report: {
383
+ entityType: string
384
+ reportType: string
385
+ content: string
386
+ model: string
387
+ cost: number
388
+ latencyMs: number
389
+ }): Promise<string> {
390
+ const { data, error } = await this.supabase
391
+ .from('ai_reports')
392
+ .insert({
393
+ entity_type: report.entityType,
394
+ report_type: report.reportType,
395
+ content: report.content,
396
+ model: report.model,
397
+ cost: report.cost,
398
+ latency_ms: report.latencyMs,
399
+ generated_at: new Date(),
400
+ })
401
+ .select('id')
402
+ .single()
403
+
404
+ if (error) throw error
405
+ return data.id
406
+ }
407
+
408
+ subscribe(jobId: string, callback: (status: ResearchJobStatus) => void): void {
409
+ this.supabase
410
+ .from(`cortex.research_jobs:id=eq.${jobId}`)
411
+ .on('*', (payload) => {
412
+ callback({
413
+ jobId,
414
+ status: payload.new.status,
415
+ progress: payload.new.progress || 0,
416
+ error: payload.new.error,
417
+ lastUpdate: new Date(),
418
+ })
419
+ })
420
+ .subscribe()
421
+ }
422
+ }
423
+
424
+ // ============================================================================
425
+ // RESEARCH ORCHESTRATOR - Main entry point
426
+ // ============================================================================
427
+
428
+ export class ResearchOrchestrator {
429
+ private perplexity: PerplexityClient
430
+ private firecrawl: FirecrawlClient
431
+ private cortex: CortexClient
432
+ private cache: CacheClient
433
+ private retryEngine: RetryEngine
434
+
435
+ constructor(config: {
436
+ perplexityApiKey: string
437
+ firecrawlApiKey: string
438
+ supabaseUrl: string
439
+ supabaseAnonKey: string
440
+ }) {
441
+ this.perplexity = new PerplexityClient(config.perplexityApiKey)
442
+ this.firecrawl = new FirecrawlClient(config.firecrawlApiKey)
443
+ this.cortex = new CortexClient(
444
+ createClient(config.supabaseUrl, config.supabaseAnonKey)
445
+ )
446
+ this.cache = new CacheClient()
447
+ this.retryEngine = new RetryEngine()
448
+ }
449
+
450
+ async submitRequest(request: Omit<ResearchRequest, 'id' | 'status' | 'createdAt' | 'updatedAt'>): Promise<string> {
451
+ // This just creates the request record
452
+ // The actual execution happens in the Edge Function
453
+ const jobId = await this.cortex.createResearchRequest(request)
454
+ return jobId
455
+ }
456
+
457
+ async getStatus(jobId: string): Promise<ResearchJobStatus | null> {
458
+ const job = await this.cortex.getResearchJob(jobId)
459
+ if (!job) return null
460
+
461
+ return {
462
+ jobId,
463
+ status: job.status,
464
+ progress: job.progress || 0,
465
+ error: job.error,
466
+ lastUpdate: job.updatedAt,
467
+ }
468
+ }
469
+
470
+ async getResults(jobId: string): Promise<ResearchResult | null> {
471
+ const job = await this.cortex.getResearchJob(jobId)
472
+ return job?.result || null
473
+ }
474
+
475
+ onStatusChange(jobId: string, callback: (status: ResearchJobStatus) => void): void {
476
+ this.cortex.subscribe(jobId, callback)
477
+ }
478
+
479
+ getCacheStats(): { size: number; totalHits: number } {
480
+ return this.cache.getStats()
481
+ }
482
+ }
483
+
484
+ // ============================================================================
485
+ // EXPORTS
486
+ // ============================================================================
487
+
488
+ export default {
489
+ ResearchOrchestrator,
490
+ ResearchErrorHandler,
491
+ RetryEngine,
492
+ CacheClient,
493
+ PerplexityClient,
494
+ FirecrawlClient,
495
+ CortexClient,
496
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020"],
6
+ "declaration": true,
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "moduleResolution": "bundler"
14
+ },
15
+ "include": ["src/**/*.ts"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }