@realtimex/folio 0.1.2

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 (163) hide show
  1. package/.env.example +20 -0
  2. package/README.md +63 -0
  3. package/api/server.ts +130 -0
  4. package/api/src/config/index.ts +96 -0
  5. package/api/src/middleware/auth.ts +128 -0
  6. package/api/src/middleware/errorHandler.ts +88 -0
  7. package/api/src/middleware/index.ts +4 -0
  8. package/api/src/middleware/rateLimit.ts +71 -0
  9. package/api/src/middleware/validation.ts +58 -0
  10. package/api/src/routes/accounts.ts +142 -0
  11. package/api/src/routes/baseline-config.ts +124 -0
  12. package/api/src/routes/chat.ts +154 -0
  13. package/api/src/routes/health.ts +61 -0
  14. package/api/src/routes/index.ts +35 -0
  15. package/api/src/routes/ingestions.ts +275 -0
  16. package/api/src/routes/migrate.ts +112 -0
  17. package/api/src/routes/policies.ts +121 -0
  18. package/api/src/routes/processing.ts +90 -0
  19. package/api/src/routes/rules.ts +11 -0
  20. package/api/src/routes/sdk.ts +100 -0
  21. package/api/src/routes/settings.ts +80 -0
  22. package/api/src/routes/setup.ts +389 -0
  23. package/api/src/routes/stats.ts +81 -0
  24. package/api/src/routes/tts.ts +190 -0
  25. package/api/src/services/BaselineConfigService.ts +208 -0
  26. package/api/src/services/ChatService.ts +204 -0
  27. package/api/src/services/GoogleDriveService.ts +331 -0
  28. package/api/src/services/GoogleSheetsService.ts +1107 -0
  29. package/api/src/services/IngestionService.ts +1187 -0
  30. package/api/src/services/ModelCapabilityService.ts +248 -0
  31. package/api/src/services/PolicyEngine.ts +1625 -0
  32. package/api/src/services/PolicyLearningService.ts +527 -0
  33. package/api/src/services/PolicyLoader.ts +249 -0
  34. package/api/src/services/RAGService.ts +391 -0
  35. package/api/src/services/SDKService.ts +249 -0
  36. package/api/src/services/supabase.ts +113 -0
  37. package/api/src/utils/Actuator.ts +284 -0
  38. package/api/src/utils/actions/ActionHandler.ts +34 -0
  39. package/api/src/utils/actions/AppendToGSheetAction.ts +260 -0
  40. package/api/src/utils/actions/AutoRenameAction.ts +58 -0
  41. package/api/src/utils/actions/CopyAction.ts +120 -0
  42. package/api/src/utils/actions/CopyToGDriveAction.ts +64 -0
  43. package/api/src/utils/actions/LogCsvAction.ts +48 -0
  44. package/api/src/utils/actions/NotifyAction.ts +39 -0
  45. package/api/src/utils/actions/RenameAction.ts +57 -0
  46. package/api/src/utils/actions/WebhookAction.ts +58 -0
  47. package/api/src/utils/actions/utils.ts +293 -0
  48. package/api/src/utils/llmResponse.ts +61 -0
  49. package/api/src/utils/logger.ts +67 -0
  50. package/bin/folio-deploy.js +12 -0
  51. package/bin/folio-setup.js +45 -0
  52. package/bin/folio.js +65 -0
  53. package/dist/api/server.js +106 -0
  54. package/dist/api/src/config/index.js +81 -0
  55. package/dist/api/src/middleware/auth.js +93 -0
  56. package/dist/api/src/middleware/errorHandler.js +73 -0
  57. package/dist/api/src/middleware/index.js +4 -0
  58. package/dist/api/src/middleware/rateLimit.js +43 -0
  59. package/dist/api/src/middleware/validation.js +54 -0
  60. package/dist/api/src/routes/accounts.js +110 -0
  61. package/dist/api/src/routes/baseline-config.js +91 -0
  62. package/dist/api/src/routes/chat.js +114 -0
  63. package/dist/api/src/routes/health.js +52 -0
  64. package/dist/api/src/routes/index.js +31 -0
  65. package/dist/api/src/routes/ingestions.js +207 -0
  66. package/dist/api/src/routes/migrate.js +91 -0
  67. package/dist/api/src/routes/policies.js +86 -0
  68. package/dist/api/src/routes/processing.js +75 -0
  69. package/dist/api/src/routes/rules.js +8 -0
  70. package/dist/api/src/routes/sdk.js +80 -0
  71. package/dist/api/src/routes/settings.js +68 -0
  72. package/dist/api/src/routes/setup.js +315 -0
  73. package/dist/api/src/routes/stats.js +62 -0
  74. package/dist/api/src/routes/tts.js +178 -0
  75. package/dist/api/src/services/BaselineConfigService.js +168 -0
  76. package/dist/api/src/services/ChatService.js +166 -0
  77. package/dist/api/src/services/GoogleDriveService.js +280 -0
  78. package/dist/api/src/services/GoogleSheetsService.js +795 -0
  79. package/dist/api/src/services/IngestionService.js +990 -0
  80. package/dist/api/src/services/ModelCapabilityService.js +179 -0
  81. package/dist/api/src/services/PolicyEngine.js +1353 -0
  82. package/dist/api/src/services/PolicyLearningService.js +397 -0
  83. package/dist/api/src/services/PolicyLoader.js +159 -0
  84. package/dist/api/src/services/RAGService.js +295 -0
  85. package/dist/api/src/services/SDKService.js +212 -0
  86. package/dist/api/src/services/supabase.js +72 -0
  87. package/dist/api/src/utils/Actuator.js +225 -0
  88. package/dist/api/src/utils/actions/ActionHandler.js +1 -0
  89. package/dist/api/src/utils/actions/AppendToGSheetAction.js +191 -0
  90. package/dist/api/src/utils/actions/AutoRenameAction.js +49 -0
  91. package/dist/api/src/utils/actions/CopyAction.js +112 -0
  92. package/dist/api/src/utils/actions/CopyToGDriveAction.js +55 -0
  93. package/dist/api/src/utils/actions/LogCsvAction.js +42 -0
  94. package/dist/api/src/utils/actions/NotifyAction.js +32 -0
  95. package/dist/api/src/utils/actions/RenameAction.js +51 -0
  96. package/dist/api/src/utils/actions/WebhookAction.js +51 -0
  97. package/dist/api/src/utils/actions/utils.js +237 -0
  98. package/dist/api/src/utils/llmResponse.js +63 -0
  99. package/dist/api/src/utils/logger.js +51 -0
  100. package/dist/assets/index-DzN8-j-e.css +1 -0
  101. package/dist/assets/index-Uy-ai3Dh.js +113 -0
  102. package/dist/favicon.svg +31 -0
  103. package/dist/folio-logo.svg +46 -0
  104. package/dist/index.html +14 -0
  105. package/docs-dev/FPE-spec.md +196 -0
  106. package/docs-dev/folio-prd.md +47 -0
  107. package/docs-dev/foundation-checklist.md +30 -0
  108. package/docs-dev/hybrid-routing-architecture.md +205 -0
  109. package/docs-dev/ingestion-engine.md +69 -0
  110. package/docs-dev/port-from-email-automator.md +32 -0
  111. package/docs-dev/tech-spec.md +98 -0
  112. package/index.html +13 -0
  113. package/package.json +101 -0
  114. package/public/favicon.svg +31 -0
  115. package/public/folio-logo.svg +46 -0
  116. package/scripts/dev-task.mjs +51 -0
  117. package/scripts/get-latest-migration-timestamp.mjs +34 -0
  118. package/scripts/migrate.sh +91 -0
  119. package/supabase/.temp/cli-latest +1 -0
  120. package/supabase/.temp/gotrue-version +1 -0
  121. package/supabase/.temp/pooler-url +1 -0
  122. package/supabase/.temp/postgres-version +1 -0
  123. package/supabase/.temp/project-ref +1 -0
  124. package/supabase/.temp/rest-version +1 -0
  125. package/supabase/.temp/storage-migration +1 -0
  126. package/supabase/.temp/storage-version +1 -0
  127. package/supabase/config.toml +64 -0
  128. package/supabase/functions/_shared/auth.ts +35 -0
  129. package/supabase/functions/_shared/cors.ts +12 -0
  130. package/supabase/functions/_shared/supabaseAdmin.ts +17 -0
  131. package/supabase/functions/api-v1-settings/index.ts +66 -0
  132. package/supabase/functions/setup/index.ts +91 -0
  133. package/supabase/migrations/20260223000000_initial_foundation.sql +136 -0
  134. package/supabase/migrations/20260223000001_add_migration_rpc.sql +10 -0
  135. package/supabase/migrations/20260224000002_add_init_state_view.sql +20 -0
  136. package/supabase/migrations/20260224000003_port_user_creation_parity.sql +139 -0
  137. package/supabase/migrations/20260224000004_add_avatars_storage.sql +26 -0
  138. package/supabase/migrations/20260224000005_add_tts_and_embed_settings.sql +24 -0
  139. package/supabase/migrations/20260224000006_add_policies_table.sql +48 -0
  140. package/supabase/migrations/20260224000007_fix_migration_rpc.sql +9 -0
  141. package/supabase/migrations/20260224000008_add_ingestions_table.sql +42 -0
  142. package/supabase/migrations/20260225000000_setup_compatible_mode.sql +119 -0
  143. package/supabase/migrations/20260225000001_restore_ingestions.sql +49 -0
  144. package/supabase/migrations/20260225000002_add_ingestion_trace.sql +2 -0
  145. package/supabase/migrations/20260225000003_add_baseline_configs.sql +35 -0
  146. package/supabase/migrations/20260226000000_add_processing_events.sql +26 -0
  147. package/supabase/migrations/20260226000001_add_ingestion_file_hash.sql +10 -0
  148. package/supabase/migrations/20260226000002_add_dynamic_rag.sql +150 -0
  149. package/supabase/migrations/20260226000003_add_ingestion_summary.sql +4 -0
  150. package/supabase/migrations/20260226000004_add_ingestion_tags.sql +7 -0
  151. package/supabase/migrations/20260226000005_add_chat_tables.sql +60 -0
  152. package/supabase/migrations/20260227000000_harden_chat_messages_rls.sql +25 -0
  153. package/supabase/migrations/20260228000000_add_vision_model_capabilities.sql +8 -0
  154. package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +51 -0
  155. package/supabase/migrations/29991231235959_test_migration.sql +0 -0
  156. package/supabase/templates/confirmation.html +76 -0
  157. package/supabase/templates/email-change.html +76 -0
  158. package/supabase/templates/invite.html +72 -0
  159. package/supabase/templates/magic-link.html +68 -0
  160. package/supabase/templates/recovery.html +82 -0
  161. package/tsconfig.api.json +16 -0
  162. package/tsconfig.json +25 -0
  163. package/vite.config.ts +146 -0
@@ -0,0 +1,295 @@
1
+ import crypto from "node:crypto";
2
+ import { SDKService } from "./SDKService.js";
3
+ import { createLogger } from "../utils/logger.js";
4
+ const logger = createLogger("RAGService");
5
+ export class RAGService {
6
+ static MAX_CONCURRENT_EMBED_JOBS = (() => {
7
+ const parsed = Number.parseInt(process.env.RAG_MAX_CONCURRENT_EMBED_JOBS ?? "2", 10);
8
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
9
+ })();
10
+ static activeEmbedJobs = 0;
11
+ static embedJobWaiters = [];
12
+ static async acquireEmbedJobSlot() {
13
+ while (this.activeEmbedJobs >= this.MAX_CONCURRENT_EMBED_JOBS) {
14
+ await new Promise((resolve) => this.embedJobWaiters.push(resolve));
15
+ }
16
+ this.activeEmbedJobs += 1;
17
+ }
18
+ static releaseEmbedJobSlot() {
19
+ this.activeEmbedJobs = Math.max(0, this.activeEmbedJobs - 1);
20
+ const waiter = this.embedJobWaiters.shift();
21
+ if (waiter)
22
+ waiter();
23
+ }
24
+ static async resolveEmbeddingModel(settings = {}) {
25
+ const { provider, model } = await SDKService.resolveEmbedProvider(settings);
26
+ return { provider, model };
27
+ }
28
+ static async embedTextWithResolvedModel(text, resolvedModel) {
29
+ const sdk = SDKService.getSDK();
30
+ if (!sdk) {
31
+ throw new Error("RealTimeX SDK not available for embedding");
32
+ }
33
+ const response = await sdk.llm.embed(text, {
34
+ provider: resolvedModel.provider,
35
+ model: resolvedModel.model
36
+ });
37
+ const embedding = response.embeddings?.[0];
38
+ if (!embedding) {
39
+ throw new Error("No embedding returned from SDK");
40
+ }
41
+ return embedding;
42
+ }
43
+ /**
44
+ * Splits a large text into smaller semantic chunks (roughly by paragraphs).
45
+ */
46
+ static chunkText(text, maxChunkLength = 1000) {
47
+ if (!text || text.trim().length === 0)
48
+ return [];
49
+ const paragraphs = text.split(/\n\s*\n/);
50
+ const chunks = [];
51
+ let currentChunk = "";
52
+ for (const paragraph of paragraphs) {
53
+ const p = paragraph.trim();
54
+ if (!p)
55
+ continue;
56
+ if (p.length > maxChunkLength) {
57
+ // Flush pending chunk before splitting an oversized paragraph
58
+ if (currentChunk.length > 0) {
59
+ chunks.push(currentChunk.trim());
60
+ currentChunk = "";
61
+ }
62
+ // Hard split oversized paragraphs to prevent SDK payload rejection
63
+ let i = 0;
64
+ while (i < p.length) {
65
+ chunks.push(p.slice(i, i + maxChunkLength));
66
+ i += maxChunkLength;
67
+ }
68
+ continue;
69
+ }
70
+ if (currentChunk.length + p.length > maxChunkLength && currentChunk.length > 0) {
71
+ chunks.push(currentChunk.trim());
72
+ currentChunk = "";
73
+ }
74
+ currentChunk += (currentChunk.length > 0 ? "\n\n" : "") + p;
75
+ }
76
+ if (currentChunk.trim().length > 0) {
77
+ chunks.push(currentChunk.trim());
78
+ }
79
+ return chunks;
80
+ }
81
+ /**
82
+ * Generate an embedding for a text string using RealTimeX SDK
83
+ */
84
+ static async embedText(text, settings) {
85
+ const resolvedModel = await this.resolveEmbeddingModel(settings || {});
86
+ logger.debug(`Generating embedding using ${resolvedModel.provider}/${resolvedModel.model}`);
87
+ return this.embedTextWithResolvedModel(text, resolvedModel);
88
+ }
89
+ /**
90
+ * Process an ingested document's raw text: chunk it, embed it, and store in DB.
91
+ */
92
+ static async chunkAndEmbed(ingestionId, userId, rawText, supabase, settings) {
93
+ if (rawText.startsWith("[VLM_IMAGE_DATA:")) {
94
+ logger.info(`Skipping chunking and embedding for VLM base64 image data (Ingestion: ${ingestionId})`);
95
+ return;
96
+ }
97
+ const chunks = this.chunkText(rawText);
98
+ if (chunks.length === 0) {
99
+ logger.info(`No text to chunk for ingestion ${ingestionId}`);
100
+ return;
101
+ }
102
+ const resolvedModel = await this.resolveEmbeddingModel(settings || {});
103
+ logger.info(`Extracted ${chunks.length} chunks for ingestion ${ingestionId}. Embedding with ${resolvedModel.provider}/${resolvedModel.model}...`);
104
+ // Global gate: background fire-and-forget jobs are bounded process-wide.
105
+ await this.acquireEmbedJobSlot();
106
+ try {
107
+ // To avoid provider throttling, process chunks sequentially inside each job.
108
+ for (const [index, content] of chunks.entries()) {
109
+ try {
110
+ // Content hash is model-agnostic. Model identity is tracked in dedicated columns.
111
+ const hash = crypto.createHash("sha256").update(content).digest("hex");
112
+ // Check if this chunk already exists for this ingestion and this model.
113
+ const { data: existing } = await supabase
114
+ .from("document_chunks")
115
+ .select("id")
116
+ .eq("ingestion_id", ingestionId)
117
+ .eq("content_hash", hash)
118
+ .eq("embedding_provider", resolvedModel.provider)
119
+ .eq("embedding_model", resolvedModel.model)
120
+ .maybeSingle();
121
+ if (existing) {
122
+ continue; // Skip duplicate chunk
123
+ }
124
+ // Spread requests slightly to reduce burstiness against embedding APIs.
125
+ await new Promise((r) => setTimeout(r, 100));
126
+ const embedding = await this.embedTextWithResolvedModel(content, resolvedModel);
127
+ const vector_dim = embedding.length;
128
+ const { error } = await supabase.from("document_chunks").insert({
129
+ user_id: userId,
130
+ ingestion_id: ingestionId,
131
+ content,
132
+ content_hash: hash,
133
+ embedding_provider: resolvedModel.provider,
134
+ embedding_model: resolvedModel.model,
135
+ embedding,
136
+ vector_dim
137
+ });
138
+ if (error) {
139
+ logger.error(`Failed to insert chunk ${index + 1}/${chunks.length} for ${ingestionId}`, { error });
140
+ }
141
+ }
142
+ catch (err) {
143
+ logger.error(`Failed to process chunk ${index + 1}/${chunks.length} for ${ingestionId}`, {
144
+ error: err instanceof Error ? err.message : String(err)
145
+ });
146
+ }
147
+ }
148
+ }
149
+ finally {
150
+ this.releaseEmbedJobSlot();
151
+ }
152
+ logger.info(`Successfully stored semantic chunks for ingestion ${ingestionId}`);
153
+ }
154
+ /**
155
+ * Semantically search the document chunks using dynamic pgvector partial indexing.
156
+ */
157
+ static async runSearchForModel(args) {
158
+ const { userId, supabase, modelScope, queryEmbedding, queryDim, similarityThreshold, topK } = args;
159
+ const { data, error } = await supabase.rpc("search_documents", {
160
+ p_user_id: userId,
161
+ p_embedding_provider: modelScope.provider,
162
+ p_embedding_model: modelScope.model,
163
+ query_embedding: queryEmbedding,
164
+ match_threshold: similarityThreshold,
165
+ match_count: topK,
166
+ query_dim: queryDim
167
+ });
168
+ if (error) {
169
+ throw new Error(`Knowledge base search failed for ${modelScope.provider}/${modelScope.model}: ${error.message}`);
170
+ }
171
+ return (data || []);
172
+ }
173
+ static async listUserModelScopes(userId, supabase) {
174
+ const { data, error } = await supabase
175
+ .from("document_chunks")
176
+ .select("embedding_provider, embedding_model, vector_dim, created_at")
177
+ .eq("user_id", userId)
178
+ .order("created_at", { ascending: false })
179
+ .limit(2000);
180
+ if (error) {
181
+ logger.warn("Failed to list user embedding scopes for RAG fallback", { error });
182
+ return [];
183
+ }
184
+ const scopes = new Map();
185
+ for (const row of data || []) {
186
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
187
+ const provider = String(row.embedding_provider || "").trim();
188
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
189
+ const model = String(row.embedding_model || "").trim();
190
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
191
+ const vector_dim = Number(row.vector_dim);
192
+ if (!provider || !model)
193
+ continue;
194
+ const key = `${provider}::${model}`;
195
+ if (!scopes.has(key)) {
196
+ scopes.set(key, {
197
+ provider,
198
+ model,
199
+ vector_dim: Number.isFinite(vector_dim) && vector_dim > 0 ? vector_dim : undefined
200
+ });
201
+ }
202
+ }
203
+ return Array.from(scopes.values());
204
+ }
205
+ static async searchDocuments(query, userId, supabase, options = {}) {
206
+ const { topK = 5, similarityThreshold = 0.7, settings } = options;
207
+ const minThreshold = Math.max(0.1, Math.min(similarityThreshold, 0.4));
208
+ const thresholdLevels = Array.from(new Set([similarityThreshold, minThreshold]));
209
+ const preferred = await this.resolveEmbeddingModel(settings || {});
210
+ const preferredScope = { provider: preferred.provider, model: preferred.model };
211
+ const embeddingCache = new Map();
212
+ const collected = new Map();
213
+ const trySearch = async (scope, threshold) => {
214
+ const cacheKey = `${scope.provider}::${scope.model}`;
215
+ let cached = embeddingCache.get(cacheKey);
216
+ if (!cached) {
217
+ const queryEmbedding = await this.embedTextWithResolvedModel(query, {
218
+ provider: scope.provider,
219
+ model: scope.model,
220
+ });
221
+ cached = { queryEmbedding, queryDim: queryEmbedding.length };
222
+ embeddingCache.set(cacheKey, cached);
223
+ }
224
+ const { queryEmbedding, queryDim } = cached;
225
+ if (scope.vector_dim && scope.vector_dim !== queryDim) {
226
+ logger.warn("Skipping model scope due to vector dimension mismatch", {
227
+ scope,
228
+ queryDim
229
+ });
230
+ return 0;
231
+ }
232
+ logger.info(`Searching knowledge base (${scope.provider}/${scope.model}, dim=${queryDim}, topK=${topK}, threshold=${threshold})`);
233
+ const hits = await this.runSearchForModel({
234
+ userId,
235
+ supabase,
236
+ modelScope: scope,
237
+ queryEmbedding,
238
+ queryDim,
239
+ similarityThreshold: threshold,
240
+ topK
241
+ });
242
+ for (const hit of hits) {
243
+ if (!collected.has(hit.id)) {
244
+ collected.set(hit.id, hit);
245
+ }
246
+ else {
247
+ const existing = collected.get(hit.id);
248
+ if (hit.similarity > existing.similarity) {
249
+ collected.set(hit.id, hit);
250
+ }
251
+ }
252
+ }
253
+ return hits.length;
254
+ };
255
+ try {
256
+ for (const threshold of thresholdLevels) {
257
+ const hits = await trySearch(preferredScope, threshold);
258
+ if (hits > 0) {
259
+ break;
260
+ }
261
+ }
262
+ }
263
+ catch (error) {
264
+ logger.error("Semantic search failed for preferred embedding scope", {
265
+ provider: preferredScope.provider,
266
+ model: preferredScope.model,
267
+ error
268
+ });
269
+ }
270
+ if (collected.size === 0) {
271
+ const scopes = await this.listUserModelScopes(userId, supabase);
272
+ const fallbackScopes = scopes.filter((scope) => !(scope.provider === preferredScope.provider && scope.model === preferredScope.model));
273
+ for (const scope of fallbackScopes) {
274
+ try {
275
+ for (const threshold of thresholdLevels) {
276
+ await trySearch(scope, threshold);
277
+ if (collected.size >= topK)
278
+ break;
279
+ }
280
+ }
281
+ catch (error) {
282
+ logger.warn("Semantic search failed for fallback embedding scope", {
283
+ scope,
284
+ error
285
+ });
286
+ }
287
+ if (collected.size >= topK)
288
+ break;
289
+ }
290
+ }
291
+ return Array.from(collected.values())
292
+ .sort((a, b) => b.similarity - a.similarity)
293
+ .slice(0, topK);
294
+ }
295
+ }
@@ -0,0 +1,212 @@
1
+ import { RealtimeXSDK } from "@realtimex/sdk";
2
+ import { createLogger } from "../utils/logger.js";
3
+ const logger = createLogger("SDKService");
4
+ export class SDKService {
5
+ static instance = null;
6
+ static initAttempted = false;
7
+ // Default provider/model configuration
8
+ // realtimexai routes through RealTimeX Desktop to user's configured providers
9
+ static DEFAULT_LLM_PROVIDER = "realtimexai";
10
+ static DEFAULT_LLM_MODEL = "gpt-4o-mini";
11
+ static DEFAULT_EMBED_PROVIDER = "realtimexai";
12
+ static DEFAULT_EMBED_MODEL = "text-embedding-3-small";
13
+ static initialize() {
14
+ if (!this.instance && !this.initAttempted) {
15
+ this.initAttempted = true;
16
+ try {
17
+ this.instance = new RealtimeXSDK({
18
+ realtimex: {
19
+ // @ts-ignore Desktop dev bridge key
20
+ apiKey: "SXKX93J-QSWMB04-K9E0GRE-J5DA8J0"
21
+ },
22
+ permissions: [
23
+ "api.agents", // List agents
24
+ "api.workspaces", // List workspaces
25
+ "api.threads", // List threads
26
+ "webhook.trigger", // Trigger agents
27
+ "activities.read", // Read activities
28
+ "activities.write", // Write activities
29
+ "llm.chat", // Chat completion
30
+ "llm.embed", // Generate embeddings
31
+ "llm.providers", // List LLM providers (chat, embed)
32
+ "vectors.read", // Query vectors
33
+ "vectors.write", // Store vectors
34
+ ]
35
+ });
36
+ logger.info("RealTimeX SDK initialized successfully");
37
+ // @ts-ignore ping available in desktop bridge
38
+ this.instance.ping?.().catch(() => {
39
+ logger.warn("Desktop ping failed during startup");
40
+ });
41
+ }
42
+ catch (error) {
43
+ logger.error("Failed to initialize SDK", {
44
+ error: error instanceof Error ? error.message : String(error)
45
+ });
46
+ this.instance = null;
47
+ }
48
+ }
49
+ return this.instance;
50
+ }
51
+ static getSDK() {
52
+ if (!this.instance && !this.initAttempted) {
53
+ this.initialize();
54
+ }
55
+ return this.instance;
56
+ }
57
+ static async isAvailable() {
58
+ try {
59
+ const sdk = this.getSDK();
60
+ if (!sdk)
61
+ return false;
62
+ // Try to ping first (faster)
63
+ try {
64
+ // @ts-ignore ping available in desktop bridge
65
+ await sdk.ping();
66
+ return true;
67
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
68
+ }
69
+ catch (e) {
70
+ // Fallback to providers check if ping not available/fails
71
+ await sdk.llm.chatProviders();
72
+ return true;
73
+ }
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ }
76
+ catch (error) {
77
+ logger.warn("SDK not available", { error: error.message });
78
+ return false;
79
+ }
80
+ }
81
+ /**
82
+ * Helper to wrap a promise with a timeout
83
+ */
84
+ static async withTimeout(promise, timeoutMs, errorMessage) {
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ let timeoutHandle;
87
+ const timeoutPromise = new Promise((_, reject) => {
88
+ timeoutHandle = setTimeout(() => reject(new Error(errorMessage)), timeoutMs);
89
+ });
90
+ try {
91
+ const result = await Promise.race([promise, timeoutPromise]);
92
+ return result;
93
+ }
94
+ finally {
95
+ clearTimeout(timeoutHandle);
96
+ }
97
+ }
98
+ // Cache for default providers (avoid repeated SDK calls)
99
+ static defaultChatProvider = null;
100
+ static defaultEmbedProvider = null;
101
+ /**
102
+ * Get default chat provider/model from SDK dynamically
103
+ */
104
+ static async getDefaultChatProvider() {
105
+ // Return cached if available
106
+ if (this.defaultChatProvider) {
107
+ return this.defaultChatProvider;
108
+ }
109
+ const sdk = this.getSDK();
110
+ if (!sdk) {
111
+ return {
112
+ provider: this.DEFAULT_LLM_PROVIDER,
113
+ model: this.DEFAULT_LLM_MODEL,
114
+ isDefaultFallback: true
115
+ };
116
+ }
117
+ try {
118
+ const { providers } = await this.withTimeout(sdk.llm.chatProviders(), 30000, "Chat providers fetch timed out");
119
+ if (!providers || providers.length === 0) {
120
+ throw new Error("No LLM providers available");
121
+ }
122
+ const preferred = providers.find((item) => item.provider === this.DEFAULT_LLM_PROVIDER);
123
+ const chosen = preferred || providers[0];
124
+ const model = chosen.models?.[0]?.id || this.DEFAULT_LLM_MODEL;
125
+ this.defaultChatProvider = {
126
+ provider: chosen.provider,
127
+ model,
128
+ isDefaultFallback: !preferred
129
+ };
130
+ return this.defaultChatProvider;
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
+ }
133
+ catch (error) {
134
+ logger.warn("Failed to get default chat provider from SDK", error);
135
+ return {
136
+ provider: this.DEFAULT_LLM_PROVIDER,
137
+ model: this.DEFAULT_LLM_MODEL,
138
+ isDefaultFallback: true
139
+ };
140
+ }
141
+ }
142
+ /**
143
+ * Get default embedding provider/model from SDK dynamically
144
+ */
145
+ static async getDefaultEmbedProvider() {
146
+ if (this.defaultEmbedProvider) {
147
+ return this.defaultEmbedProvider;
148
+ }
149
+ const sdk = this.getSDK();
150
+ if (!sdk) {
151
+ return {
152
+ provider: this.DEFAULT_EMBED_PROVIDER,
153
+ model: this.DEFAULT_EMBED_MODEL,
154
+ isDefaultFallback: true
155
+ };
156
+ }
157
+ try {
158
+ const { providers } = await this.withTimeout(sdk.llm.embedProviders(), 30000, "Embed providers fetch timed out");
159
+ if (!providers || providers.length === 0) {
160
+ throw new Error("No embedding providers available");
161
+ }
162
+ const preferred = providers.find((p) => p.provider === this.DEFAULT_EMBED_PROVIDER);
163
+ const chosen = preferred || providers[0];
164
+ const model = chosen.models?.[0]?.id || this.DEFAULT_EMBED_MODEL;
165
+ this.defaultEmbedProvider = {
166
+ provider: chosen.provider,
167
+ model,
168
+ isDefaultFallback: !preferred
169
+ };
170
+ return this.defaultEmbedProvider;
171
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
+ }
173
+ catch (error) {
174
+ logger.warn("Failed to get default embed provider from SDK", error);
175
+ return {
176
+ provider: this.DEFAULT_EMBED_PROVIDER,
177
+ model: this.DEFAULT_EMBED_MODEL,
178
+ isDefaultFallback: true
179
+ };
180
+ }
181
+ }
182
+ /**
183
+ * Resolve LLM provider/model - use settings if available, otherwise use defaults
184
+ */
185
+ static async resolveChatProvider(settings) {
186
+ if (settings.llm_provider && settings.llm_model) {
187
+ return {
188
+ provider: settings.llm_provider,
189
+ model: settings.llm_model,
190
+ isDefaultFallback: false
191
+ };
192
+ }
193
+ return await this.getDefaultChatProvider();
194
+ }
195
+ /**
196
+ * Resolve embedding provider/model - use settings if available, otherwise use defaults
197
+ */
198
+ static async resolveEmbedProvider(settings) {
199
+ if (settings.embedding_provider && settings.embedding_model) {
200
+ return {
201
+ provider: settings.embedding_provider,
202
+ model: settings.embedding_model,
203
+ isDefaultFallback: false
204
+ };
205
+ }
206
+ return await this.getDefaultEmbedProvider();
207
+ }
208
+ static clearProviderCache() {
209
+ this.defaultChatProvider = null;
210
+ this.defaultEmbedProvider = null;
211
+ }
212
+ }
@@ -0,0 +1,72 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ import { config } from "../config/index.js";
3
+ import { createLogger } from "../utils/logger.js";
4
+ const logger = createLogger("SupabaseService");
5
+ let serverClient = null;
6
+ let lastConfigHash = "";
7
+ export function isValidUrl(url) {
8
+ return url.startsWith("http://") || url.startsWith("https://");
9
+ }
10
+ function isValidAnonKey(key) {
11
+ return key.startsWith("eyJ") || key.startsWith("sb_publishable_");
12
+ }
13
+ function getConfigHash() {
14
+ return `${config.supabase.url}_${config.supabase.anonKey}`;
15
+ }
16
+ export function getServerSupabase(forceRefresh = false) {
17
+ const currentHash = getConfigHash();
18
+ if (serverClient && !forceRefresh && currentHash === lastConfigHash) {
19
+ return serverClient;
20
+ }
21
+ const url = config.supabase.url;
22
+ const key = config.supabase.anonKey;
23
+ if (!url || !key || !isValidUrl(url)) {
24
+ return null;
25
+ }
26
+ try {
27
+ serverClient = createClient(url, key, {
28
+ auth: {
29
+ autoRefreshToken: false,
30
+ persistSession: false
31
+ }
32
+ });
33
+ lastConfigHash = currentHash;
34
+ logger.info("Server Supabase client initialized");
35
+ return serverClient;
36
+ }
37
+ catch (error) {
38
+ logger.error("Failed to initialize server Supabase client", {
39
+ error: error instanceof Error ? error.message : String(error)
40
+ });
41
+ return null;
42
+ }
43
+ }
44
+ export function getServiceRoleSupabase() {
45
+ const url = config.supabase.url;
46
+ const key = config.supabase.serviceRoleKey;
47
+ if (!url || !key || !isValidUrl(url)) {
48
+ return null;
49
+ }
50
+ try {
51
+ return createClient(url, key, {
52
+ auth: {
53
+ autoRefreshToken: false,
54
+ persistSession: false
55
+ }
56
+ });
57
+ }
58
+ catch (error) {
59
+ logger.error("Failed to initialize service-role Supabase client", {
60
+ error: error instanceof Error ? error.message : String(error)
61
+ });
62
+ return null;
63
+ }
64
+ }
65
+ export function getSupabaseConfigFromHeaders(headers) {
66
+ const url = String(headers["x-supabase-url"] || "");
67
+ const anonKey = String(headers["x-supabase-anon-key"] || "");
68
+ if (!url || !anonKey || !isValidUrl(url) || !isValidAnonKey(anonKey)) {
69
+ return null;
70
+ }
71
+ return { url, anonKey };
72
+ }