@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,249 @@
1
+ import { RealtimeXSDK, ProvidersResponse } from "@realtimex/sdk";
2
+
3
+ import { createLogger } from "../utils/logger.js";
4
+
5
+ const logger = createLogger("SDKService");
6
+
7
+ export interface ProviderResult {
8
+ provider: string;
9
+ model: string;
10
+ isDefaultFallback?: boolean;
11
+ }
12
+
13
+ export class SDKService {
14
+ private static instance: RealtimeXSDK | null = null;
15
+ private static initAttempted = false;
16
+
17
+ // Default provider/model configuration
18
+ // realtimexai routes through RealTimeX Desktop to user's configured providers
19
+ static readonly DEFAULT_LLM_PROVIDER = "realtimexai";
20
+ static readonly DEFAULT_LLM_MODEL = "gpt-4o-mini";
21
+ static readonly DEFAULT_EMBED_PROVIDER = "realtimexai";
22
+ static readonly DEFAULT_EMBED_MODEL = "text-embedding-3-small";
23
+
24
+ static initialize(): RealtimeXSDK {
25
+ if (!this.instance && !this.initAttempted) {
26
+ this.initAttempted = true;
27
+
28
+ try {
29
+ this.instance = new RealtimeXSDK({
30
+ realtimex: {
31
+ // @ts-ignore Desktop dev bridge key
32
+ apiKey: "SXKX93J-QSWMB04-K9E0GRE-J5DA8J0"
33
+ },
34
+ permissions: [
35
+ "api.agents", // List agents
36
+ "api.workspaces", // List workspaces
37
+ "api.threads", // List threads
38
+ "webhook.trigger", // Trigger agents
39
+ "activities.read", // Read activities
40
+ "activities.write", // Write activities
41
+ "llm.chat", // Chat completion
42
+ "llm.embed", // Generate embeddings
43
+ "llm.providers", // List LLM providers (chat, embed)
44
+ "vectors.read", // Query vectors
45
+ "vectors.write", // Store vectors
46
+ ]
47
+ });
48
+
49
+ logger.info("RealTimeX SDK initialized successfully");
50
+
51
+ // @ts-ignore ping available in desktop bridge
52
+ this.instance.ping?.().catch(() => {
53
+ logger.warn("Desktop ping failed during startup");
54
+ });
55
+ } catch (error) {
56
+ logger.error("Failed to initialize SDK", {
57
+ error: error instanceof Error ? error.message : String(error)
58
+ });
59
+ this.instance = null;
60
+ }
61
+ }
62
+
63
+ return this.instance!;
64
+ }
65
+
66
+ static getSDK(): RealtimeXSDK | null {
67
+ if (!this.instance && !this.initAttempted) {
68
+ this.initialize();
69
+ }
70
+ return this.instance;
71
+ }
72
+
73
+ static async isAvailable(): Promise<boolean> {
74
+ try {
75
+ const sdk = this.getSDK();
76
+ if (!sdk) return false;
77
+
78
+ // Try to ping first (faster)
79
+ try {
80
+ // @ts-ignore ping available in desktop bridge
81
+ await sdk.ping();
82
+ return true;
83
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
84
+ } catch (e) {
85
+ // Fallback to providers check if ping not available/fails
86
+ await sdk.llm.chatProviders();
87
+ return true;
88
+ }
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ } catch (error: any) {
91
+ logger.warn("SDK not available", { error: error.message });
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Helper to wrap a promise with a timeout
98
+ */
99
+ static async withTimeout<T>(promise: Promise<T>, timeoutMs: number, errorMessage: string): Promise<T> {
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
+ let timeoutHandle: any;
102
+ const timeoutPromise = new Promise<never>((_, reject) => {
103
+ timeoutHandle = setTimeout(() => reject(new Error(errorMessage)), timeoutMs);
104
+ });
105
+
106
+ try {
107
+ const result = await Promise.race([promise, timeoutPromise]);
108
+ return result as T;
109
+ } finally {
110
+ clearTimeout(timeoutHandle);
111
+ }
112
+ }
113
+
114
+ // Cache for default providers (avoid repeated SDK calls)
115
+ private static defaultChatProvider: ProviderResult | null = null;
116
+ private static defaultEmbedProvider: ProviderResult | null = null;
117
+
118
+ /**
119
+ * Get default chat provider/model from SDK dynamically
120
+ */
121
+ static async getDefaultChatProvider(): Promise<ProviderResult> {
122
+ // Return cached if available
123
+ if (this.defaultChatProvider) {
124
+ return this.defaultChatProvider;
125
+ }
126
+
127
+ const sdk = this.getSDK();
128
+ if (!sdk) {
129
+ return {
130
+ provider: this.DEFAULT_LLM_PROVIDER,
131
+ model: this.DEFAULT_LLM_MODEL,
132
+ isDefaultFallback: true
133
+ };
134
+ }
135
+
136
+ try {
137
+ const { providers } = await this.withTimeout<ProvidersResponse>(
138
+ sdk.llm.chatProviders(),
139
+ 30000,
140
+ "Chat providers fetch timed out"
141
+ );
142
+
143
+ if (!providers || providers.length === 0) {
144
+ throw new Error("No LLM providers available");
145
+ }
146
+
147
+ const preferred = providers.find((item) => item.provider === this.DEFAULT_LLM_PROVIDER);
148
+ const chosen = preferred || providers[0];
149
+ const model = chosen.models?.[0]?.id || this.DEFAULT_LLM_MODEL;
150
+
151
+ this.defaultChatProvider = {
152
+ provider: chosen.provider,
153
+ model,
154
+ isDefaultFallback: !preferred
155
+ };
156
+ return this.defaultChatProvider;
157
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
+ } catch (error: any) {
159
+ logger.warn("Failed to get default chat provider from SDK", error);
160
+ return {
161
+ provider: this.DEFAULT_LLM_PROVIDER,
162
+ model: this.DEFAULT_LLM_MODEL,
163
+ isDefaultFallback: true
164
+ };
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Get default embedding provider/model from SDK dynamically
170
+ */
171
+ static async getDefaultEmbedProvider(): Promise<ProviderResult> {
172
+ if (this.defaultEmbedProvider) {
173
+ return this.defaultEmbedProvider;
174
+ }
175
+
176
+ const sdk = this.getSDK();
177
+ if (!sdk) {
178
+ return {
179
+ provider: this.DEFAULT_EMBED_PROVIDER,
180
+ model: this.DEFAULT_EMBED_MODEL,
181
+ isDefaultFallback: true
182
+ };
183
+ }
184
+
185
+ try {
186
+ const { providers } = await this.withTimeout<ProvidersResponse>(
187
+ sdk.llm.embedProviders(),
188
+ 30000,
189
+ "Embed providers fetch timed out"
190
+ );
191
+
192
+ if (!providers || providers.length === 0) {
193
+ throw new Error("No embedding providers available");
194
+ }
195
+
196
+ const preferred = providers.find((p) => p.provider === this.DEFAULT_EMBED_PROVIDER);
197
+ const chosen = preferred || providers[0];
198
+ const model = chosen.models?.[0]?.id || this.DEFAULT_EMBED_MODEL;
199
+
200
+ this.defaultEmbedProvider = {
201
+ provider: chosen.provider,
202
+ model,
203
+ isDefaultFallback: !preferred
204
+ };
205
+ return this.defaultEmbedProvider;
206
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
207
+ } catch (error: any) {
208
+ logger.warn("Failed to get default embed provider from SDK", error);
209
+ return {
210
+ provider: this.DEFAULT_EMBED_PROVIDER,
211
+ model: this.DEFAULT_EMBED_MODEL,
212
+ isDefaultFallback: true
213
+ };
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Resolve LLM provider/model - use settings if available, otherwise use defaults
219
+ */
220
+ static async resolveChatProvider(settings: { llm_provider?: string; llm_model?: string }): Promise<ProviderResult> {
221
+ if (settings.llm_provider && settings.llm_model) {
222
+ return {
223
+ provider: settings.llm_provider,
224
+ model: settings.llm_model,
225
+ isDefaultFallback: false
226
+ };
227
+ }
228
+ return await this.getDefaultChatProvider();
229
+ }
230
+
231
+ /**
232
+ * Resolve embedding provider/model - use settings if available, otherwise use defaults
233
+ */
234
+ static async resolveEmbedProvider(settings: { embedding_provider?: string; embedding_model?: string }): Promise<ProviderResult> {
235
+ if (settings.embedding_provider && settings.embedding_model) {
236
+ return {
237
+ provider: settings.embedding_provider,
238
+ model: settings.embedding_model,
239
+ isDefaultFallback: false
240
+ };
241
+ }
242
+ return await this.getDefaultEmbedProvider();
243
+ }
244
+
245
+ static clearProviderCache(): void {
246
+ this.defaultChatProvider = null;
247
+ this.defaultEmbedProvider = null;
248
+ }
249
+ }
@@ -0,0 +1,113 @@
1
+ import { createClient, SupabaseClient } from "@supabase/supabase-js";
2
+
3
+ import { config } from "../config/index.js";
4
+ import { createLogger } from "../utils/logger.js";
5
+
6
+ const logger = createLogger("SupabaseService");
7
+
8
+ let serverClient: SupabaseClient | null = null;
9
+ let lastConfigHash = "";
10
+
11
+ export function isValidUrl(url: string): boolean {
12
+ return url.startsWith("http://") || url.startsWith("https://");
13
+ }
14
+
15
+ function isValidAnonKey(key: string): boolean {
16
+ return key.startsWith("eyJ") || key.startsWith("sb_publishable_");
17
+ }
18
+
19
+ function getConfigHash() {
20
+ return `${config.supabase.url}_${config.supabase.anonKey}`;
21
+ }
22
+
23
+ export function getServerSupabase(forceRefresh = false): SupabaseClient | null {
24
+ const currentHash = getConfigHash();
25
+
26
+ if (serverClient && !forceRefresh && currentHash === lastConfigHash) {
27
+ return serverClient;
28
+ }
29
+
30
+ const url = config.supabase.url;
31
+ const key = config.supabase.anonKey;
32
+
33
+ if (!url || !key || !isValidUrl(url)) {
34
+ return null;
35
+ }
36
+
37
+ try {
38
+ serverClient = createClient(url, key, {
39
+ auth: {
40
+ autoRefreshToken: false,
41
+ persistSession: false
42
+ }
43
+ });
44
+ lastConfigHash = currentHash;
45
+ logger.info("Server Supabase client initialized");
46
+ return serverClient;
47
+ } catch (error) {
48
+ logger.error("Failed to initialize server Supabase client", {
49
+ error: error instanceof Error ? error.message : String(error)
50
+ });
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export function getServiceRoleSupabase(): SupabaseClient | null {
56
+ const url = config.supabase.url;
57
+ const key = config.supabase.serviceRoleKey;
58
+
59
+ if (!url || !key || !isValidUrl(url)) {
60
+ return null;
61
+ }
62
+
63
+ try {
64
+ return createClient(url, key, {
65
+ auth: {
66
+ autoRefreshToken: false,
67
+ persistSession: false
68
+ }
69
+ });
70
+ } catch (error) {
71
+ logger.error("Failed to initialize service-role Supabase client", {
72
+ error: error instanceof Error ? error.message : String(error)
73
+ });
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export function getSupabaseConfigFromHeaders(headers: Record<string, unknown>): {
79
+ url: string;
80
+ anonKey: string;
81
+ } | null {
82
+ const url = String(headers["x-supabase-url"] || "");
83
+ const anonKey = String(headers["x-supabase-anon-key"] || "");
84
+
85
+ if (!url || !anonKey || !isValidUrl(url) || !isValidAnonKey(anonKey)) {
86
+ return null;
87
+ }
88
+
89
+ return { url, anonKey };
90
+ }
91
+
92
+ export interface UserSettings {
93
+ id: string;
94
+ user_id: string;
95
+ llm_provider: string | null;
96
+ llm_model: string | null;
97
+ vision_model_capabilities: Record<string, unknown> | null;
98
+ sync_interval_minutes: number;
99
+ created_at: string;
100
+ updated_at: string;
101
+ }
102
+
103
+ export interface ProcessingJob {
104
+ id: string;
105
+ user_id: string;
106
+ status: "queued" | "running" | "completed" | "failed";
107
+ source_type: string;
108
+ payload: Record<string, unknown>;
109
+ runtime_key: string | null;
110
+ error_message: string | null;
111
+ created_at: string;
112
+ updated_at: string;
113
+ }
@@ -0,0 +1,284 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import { createLogger } from "./logger.js";
3
+ import type { ExtractField } from "../services/PolicyLoader.js";
4
+ import { getServiceRoleSupabase } from "../services/supabase.js";
5
+ import { ActionHandler, ActionContext } from "./actions/ActionHandler.js";
6
+ import { ActionInput, ExtractedData, deriveVariables } from "./actions/utils.js";
7
+
8
+ import { RenameAction } from "./actions/RenameAction.js";
9
+ import { AutoRenameAction } from "./actions/AutoRenameAction.js";
10
+ import { CopyAction } from "./actions/CopyAction.js";
11
+ import { CopyToGDriveAction } from "./actions/CopyToGDriveAction.js";
12
+ import { AppendToGSheetAction } from "./actions/AppendToGSheetAction.js";
13
+ import { LogCsvAction } from "./actions/LogCsvAction.js";
14
+ import { NotifyAction } from "./actions/NotifyAction.js";
15
+ import { WebhookAction } from "./actions/WebhookAction.js";
16
+
17
+ const logger = createLogger("Actuator");
18
+
19
+ let warnedMissingServiceRole = false;
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return value !== null && typeof value === "object" && !Array.isArray(value);
23
+ }
24
+
25
+ function toVariableString(value: unknown): string | undefined {
26
+ if (value == null) return undefined;
27
+ if (typeof value === "string") return value;
28
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
29
+ try {
30
+ return JSON.stringify(value);
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ function ensureRecord(container: ExtractedData, key: string): Record<string, unknown> {
37
+ const existing = container[key];
38
+ if (isRecord(existing)) {
39
+ return existing;
40
+ }
41
+ const next: Record<string, unknown> = {};
42
+ container[key] = next;
43
+ return next;
44
+ }
45
+
46
+ export interface ActuatorResult {
47
+ success: boolean;
48
+ actionsExecuted: string[];
49
+ errors: string[];
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ trace: { timestamp: string; step: string; details?: any }[];
52
+ }
53
+
54
+ export class Actuator {
55
+ private static handlers: Map<string, ActionHandler> = new Map([
56
+ ["rename", new RenameAction()],
57
+ ["auto_rename", new AutoRenameAction()],
58
+ ["copy", new CopyAction()],
59
+ ["copy_to_gdrive", new CopyToGDriveAction()],
60
+ ["append_to_google_sheet", new AppendToGSheetAction()],
61
+ ["log_csv", new LogCsvAction()],
62
+ ["notify", new NotifyAction()],
63
+ ["webhook", new WebhookAction()],
64
+ ]);
65
+
66
+ static registerAction(type: string, handler: ActionHandler) {
67
+ this.handlers.set(type, handler);
68
+ }
69
+
70
+ private static mergeActionOutputs(
71
+ runtimeData: ExtractedData,
72
+ runtimeVariables: Record<string, string>,
73
+ actionType: string,
74
+ actionIndex: number,
75
+ outputs: Record<string, unknown>
76
+ ): string[] {
77
+ const outputKeys = Object.keys(outputs);
78
+ if (outputKeys.length === 0) return outputKeys;
79
+
80
+ const actionsByType = ensureRecord(runtimeData, "_actions");
81
+ const previousByType = actionsByType[actionType];
82
+ const mergedByType = {
83
+ ...(isRecord(previousByType) ? previousByType : {}),
84
+ ...outputs,
85
+ };
86
+ actionsByType[actionType] = mergedByType;
87
+
88
+ const history = Array.isArray(runtimeData._action_history) ? [...runtimeData._action_history] : [];
89
+ const historyEntry: Record<string, unknown> = {
90
+ index: actionIndex,
91
+ type: actionType,
92
+ ...outputs,
93
+ };
94
+ history.push(historyEntry);
95
+ runtimeData._action_history = history;
96
+ runtimeData._last = historyEntry;
97
+ runtimeData._last_action_type = actionType;
98
+ runtimeVariables._last_action_type = actionType;
99
+
100
+ for (const key of outputKeys) {
101
+ const value = outputs[key];
102
+ const serialized = toVariableString(value);
103
+
104
+ if (runtimeData[key] === undefined) {
105
+ runtimeData[key] = value;
106
+ }
107
+
108
+ if (serialized === undefined) continue;
109
+
110
+ runtimeVariables[`${actionType}.${key}`] = serialized;
111
+ runtimeVariables[`_actions.${actionType}.${key}`] = serialized;
112
+ runtimeVariables[`_last.${key}`] = serialized;
113
+
114
+ if (runtimeVariables[key] === undefined) {
115
+ runtimeVariables[key] = serialized;
116
+ }
117
+ }
118
+
119
+ return outputKeys;
120
+ }
121
+
122
+ static async logEvent(
123
+ ingestionId: string | null,
124
+ userId: string | null,
125
+ eventType: "info" | "action" | "error" | "analysis",
126
+ state: string,
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ details: any,
129
+ supabaseClient?: SupabaseClient | null
130
+ ) {
131
+ // Fire and forget, don't await blocking execution
132
+ const supabase = supabaseClient ?? getServiceRoleSupabase();
133
+ if (!supabase) {
134
+ if (!warnedMissingServiceRole) {
135
+ logger.warn("Service-role Supabase client unavailable; skipping processing_events stream writes.");
136
+ warnedMissingServiceRole = true;
137
+ }
138
+ return;
139
+ }
140
+
141
+ void supabase.from("processing_events").insert({
142
+ ingestion_id: ingestionId ?? null,
143
+ user_id: userId ?? null,
144
+ event_type: eventType,
145
+ agent_state: state,
146
+ details,
147
+ }).then(({ error }: { error: unknown }) => {
148
+ if (error) logger.warn("Failed to stream actuator log", { error });
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Executes an array of abstract action definitions.
154
+ */
155
+ static async execute(
156
+ ingestionId: string,
157
+ userId: string,
158
+ actions: ActionInput[],
159
+ data: ExtractedData,
160
+ file: { path: string; name: string },
161
+ fields: ExtractField[] = [],
162
+ supabase?: SupabaseClient | null
163
+ ): Promise<ActuatorResult> {
164
+ const result: ActuatorResult = {
165
+ success: true,
166
+ actionsExecuted: [],
167
+ errors: [],
168
+ trace: [],
169
+ };
170
+
171
+ result.trace.push({ timestamp: new Date().toISOString(), step: "Initializing Actuator", details: { actionsCount: actions.length } });
172
+ Actuator.logEvent(ingestionId, userId, "info", "Actuator Initialized", { actionsCount: actions.length }, supabase);
173
+
174
+ // Convert the 13-digit Dropzone timestamp into a human-readable local time
175
+ // (e.g. "1772148849046-bill.pdf" -> "2026-02-26_15-34-09_bill.pdf")
176
+ // Note: only `currentFile.name` is normalized; `file.path` (disk path) is unchanged.
177
+ const tsMatch = file.name.match(/^(\d{13})-(.*)$/);
178
+ let currentFile = { ...file };
179
+ if (tsMatch) {
180
+ const date = new Date(parseInt(tsMatch[1], 10));
181
+ const yyyy = date.getFullYear();
182
+ const MM = String(date.getMonth() + 1).padStart(2, "0");
183
+ const dd = String(date.getDate()).padStart(2, "0");
184
+ const HH = String(date.getHours()).padStart(2, "0");
185
+ const mm = String(date.getMinutes()).padStart(2, "0");
186
+ const ss = String(date.getSeconds()).padStart(2, "0");
187
+ currentFile.name = `${yyyy}-${MM}-${dd}_${HH}-${mm}-${ss}_${tsMatch[2]}`;
188
+ }
189
+
190
+ const runtimeData: ExtractedData = { ...data };
191
+ const runtimeVariables = deriveVariables(runtimeData, fields);
192
+ runtimeData.current_file_name = currentFile.name;
193
+ runtimeData.current_file_path = currentFile.path;
194
+ runtimeVariables.current_file_name = currentFile.name;
195
+ runtimeVariables.current_file_path = currentFile.path;
196
+
197
+ for (const [actionIndex, action] of actions.entries()) {
198
+ const handler = this.handlers.get(action.type);
199
+
200
+ if (!handler) {
201
+ const msg = `Action failed: Unsupported action type '${action.type}'`;
202
+ logger.error(msg);
203
+ result.errors.push(msg);
204
+ result.success = false;
205
+ result.trace.push({ timestamp: new Date().toISOString(), step: `Unsupported action type`, details: { type: action.type } });
206
+ Actuator.logEvent(ingestionId, userId, "error", "Action Execution", { action: action.type, error: msg }, supabase);
207
+ continue;
208
+ }
209
+
210
+ try {
211
+ runtimeData.current_file_name = currentFile.name;
212
+ runtimeData.current_file_path = currentFile.path;
213
+ runtimeVariables.current_file_name = currentFile.name;
214
+ runtimeVariables.current_file_path = currentFile.path;
215
+
216
+ const context: ActionContext = {
217
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
218
+ action: action as any,
219
+ data: runtimeData,
220
+ file: currentFile,
221
+ variables: runtimeVariables,
222
+ userId,
223
+ ingestionId,
224
+ supabase,
225
+ };
226
+
227
+ const handlerResult = await handler.execute(context);
228
+
229
+ result.trace.push(...handlerResult.trace);
230
+
231
+ if (handlerResult.success) {
232
+ if (handlerResult.newFileState) {
233
+ currentFile = handlerResult.newFileState;
234
+ runtimeData.current_file_name = currentFile.name;
235
+ runtimeData.current_file_path = currentFile.path;
236
+ runtimeVariables.current_file_name = currentFile.name;
237
+ runtimeVariables.current_file_path = currentFile.path;
238
+ }
239
+ if (handlerResult.outputs && Object.keys(handlerResult.outputs).length > 0) {
240
+ const outputKeys = this.mergeActionOutputs(
241
+ runtimeData,
242
+ runtimeVariables,
243
+ action.type,
244
+ actionIndex,
245
+ handlerResult.outputs
246
+ );
247
+ result.trace.push({
248
+ timestamp: new Date().toISOString(),
249
+ step: "Captured action outputs",
250
+ details: {
251
+ action: action.type,
252
+ outputKeys,
253
+ },
254
+ });
255
+ }
256
+ result.actionsExecuted.push(...handlerResult.logs);
257
+ } else {
258
+ const msg = `Action failed (${action.type}): ${handlerResult.error}`;
259
+ logger.error(msg);
260
+ result.errors.push(msg);
261
+ result.success = false;
262
+ const eventDetails: Record<string, unknown> = {
263
+ action: action.type,
264
+ error: handlerResult.error,
265
+ };
266
+ if (handlerResult.errorDetails && Object.keys(handlerResult.errorDetails).length > 0) {
267
+ Object.assign(eventDetails, handlerResult.errorDetails);
268
+ }
269
+ Actuator.logEvent(ingestionId, userId, "error", "Action Execution", eventDetails, supabase);
270
+ }
271
+ } catch (err: unknown) {
272
+ const errMsg = err instanceof Error ? err.message : String(err);
273
+ const msg = `Action failed (${action.type}): ${errMsg}`;
274
+ logger.error(msg);
275
+ result.errors.push(msg);
276
+ result.success = false;
277
+ result.trace.push({ timestamp: new Date().toISOString(), step: `Action execution error`, details: { type: action.type, error: errMsg } });
278
+ Actuator.logEvent(ingestionId, userId, "error", "Action Execution", { action: action.type, error: errMsg }, supabase);
279
+ }
280
+ }
281
+
282
+ return result;
283
+ }
284
+ }
@@ -0,0 +1,34 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import { PolicyAction } from "../../services/PolicyLoader.js";
3
+ import type { ExtractedData } from "./utils.js";
4
+
5
+ export interface TraceLog {
6
+ timestamp: string;
7
+ step: string;
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ details?: any;
10
+ }
11
+
12
+ export interface ActionContext {
13
+ action: PolicyAction;
14
+ data: ExtractedData;
15
+ file: { path: string; name: string };
16
+ variables: Record<string, string>;
17
+ userId: string;
18
+ ingestionId: string;
19
+ supabase?: SupabaseClient | null;
20
+ }
21
+
22
+ export interface ActionResult {
23
+ success: boolean;
24
+ newFileState?: { path: string; name: string };
25
+ logs: string[];
26
+ trace: TraceLog[];
27
+ outputs?: Record<string, unknown>;
28
+ error?: string;
29
+ errorDetails?: Record<string, unknown>;
30
+ }
31
+
32
+ export interface ActionHandler {
33
+ execute(context: ActionContext): Promise<ActionResult>;
34
+ }