@realtimex/realtimex-alchemy 1.0.41 → 1.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.42] - 2026-01-26
9
+
10
+ ### Added
11
+ - **Transmute Engine**: Implemented the Transmute Engine with a new service, UI components, and database schema, enabling users to transform mined content into actionable formats.
12
+ - **Discovery**: Filtered signals are now displayed directly in the DiscoveryTab upon category selection for faster access.
13
+ - **Discovery**: Added aggregated signal category counts to the CategoryGrid for better high-level visibility.
14
+ - **Content Intelligence**: Ported `ContentCleaner` to the main codebase and added support for handling gated web content, improving LLM processing reliability.
15
+
16
+ ### Improved
17
+ - **Architecture**: Moved shared types to `src/lib/types.ts` for better code organization.
18
+ - **SDK**: Updated `sdk.system.get_app_data_dir` calls to the standardized `sdk.getAppDataDir`.
19
+
8
20
  ## [1.0.41] - 2026-01-24
9
21
 
10
22
  ### Added
package/dist/api/index.js CHANGED
@@ -7,6 +7,7 @@ import { AlchemistService } from './services/AlchemistService.js';
7
7
  import { LibrarianService } from './services/LibrarianService.js';
8
8
  import { CONFIG } from './config/index.js';
9
9
  import { EventService } from './services/EventService.js';
10
+ import { transmuteService } from './services/TransmuteService.js';
10
11
  import { SupabaseService } from './services/SupabaseService.js';
11
12
  import { BrowserPathDetector } from './utils/BrowserPathDetector.js';
12
13
  import { ProcessingEventService } from './services/ProcessingEventService.js';
@@ -418,6 +419,45 @@ const staticPath = process.env.ELECTRON_STATIC_PATH || path.join(__dirname, '..'
418
419
  if (fs.existsSync(staticPath)) {
419
420
  console.log(`[Alchemy] Serving UI from ${staticPath}`);
420
421
  app.use(express.static(staticPath));
422
+ // Transmute Engine Routes
423
+ app.post('/api/engines/:id/run', async (req, res) => {
424
+ try {
425
+ const userId = req.headers['x-user-id'];
426
+ const engineId = req.params.id;
427
+ if (!userId) {
428
+ return res.status(401).json({ error: 'Unauthorized: Missing User ID' });
429
+ }
430
+ console.log(`[API] Running Engine ${engineId} for user ${userId}`);
431
+ // Get valid Supabase client
432
+ const supabase = getAuthenticatedSupabase(req);
433
+ // Execute Pipeline
434
+ const asset = await transmuteService.runEngine(engineId, userId, supabase);
435
+ res.json(asset);
436
+ }
437
+ catch (error) {
438
+ console.error('[API] Engine run failed:', error);
439
+ res.status(500).json({ error: error.message || 'Engine run failed' });
440
+ }
441
+ });
442
+ app.get('/api/engines/:id/brief', async (req, res) => {
443
+ try {
444
+ const userId = req.headers['x-user-id'];
445
+ const engineId = req.params.id;
446
+ if (!userId) {
447
+ return res.status(401).json({ error: 'Unauthorized: Missing User ID' });
448
+ }
449
+ console.log(`[API] Fetching Engine Brief ${engineId} for user ${userId}`);
450
+ // Get valid Supabase client
451
+ const supabase = getAuthenticatedSupabase(req);
452
+ // Generate Brief
453
+ const brief = await transmuteService.getProductionBrief(engineId, userId, supabase);
454
+ res.json(brief);
455
+ }
456
+ catch (error) {
457
+ console.error('[API] Engine brief fetch failed:', error);
458
+ res.status(500).json({ error: error.message || 'Engine brief fetch failed' });
459
+ }
460
+ });
421
461
  // Client-side routing fallback (Bypass path-to-regexp error in Express 5)
422
462
  app.use((req, res, next) => {
423
463
  if (!req.path.startsWith('/api') && !req.path.startsWith('/events')) {
@@ -1 +1 @@
1
- export {};
1
+ export * from '../../shared/types.js';
@@ -3,6 +3,7 @@ import { RouterService } from './RouterService.js';
3
3
  import { embeddingService } from './EmbeddingService.js';
4
4
  import { deduplicationService } from './DeduplicationService.js';
5
5
  import { SDKService } from './SDKService.js';
6
+ import { ContentCleaner } from '../utils/contentCleaner.js';
6
7
  export class AlchemistService {
7
8
  processingEvents;
8
9
  router;
@@ -103,12 +104,27 @@ export class AlchemistService {
103
104
  try {
104
105
  // 2. Extract Content via RouterService (Tier 1 → Tier 2 fallback)
105
106
  let content = '';
107
+ let finalUrl = entry.url; // Default to entry URL
108
+ let isGatedContent = false;
106
109
  try {
107
- const markdown = await this.router.extractContent(entry.url);
108
- if (markdown && markdown.length > 100) {
109
- // Truncate to avoid token limits (keep ~8000 chars)
110
- const truncated = markdown.length > 8000 ? markdown.substring(0, 8000) + '...' : markdown;
111
- content = `Page Title: ${entry.title}\nContent: ${truncated}`;
110
+ const result = await this.router.extractContent(entry.url);
111
+ let rawContent = result.content;
112
+ finalUrl = result.finalUrl;
113
+ if (rawContent && rawContent.length > 20) {
114
+ // HIGHLIGHT: Payload Hygiene - Clean Markdown content after conversion
115
+ // This strips JS/CSS patterns that survived Turndown
116
+ const cleaned = ContentCleaner.cleanContent(rawContent);
117
+ // Check if this is a login wall or paywall
118
+ isGatedContent = ContentCleaner.isGatedContent(cleaned);
119
+ if (isGatedContent) {
120
+ console.log(`[AlchemistService] Gated content detected: ${entry.url}`);
121
+ content = `Page Title: ${entry.title} (Login/paywall required - content not accessible)`;
122
+ }
123
+ else {
124
+ // Truncate to avoid token limits (keep ~8000 chars)
125
+ const truncated = cleaned.length > 10000 ? cleaned.substring(0, 10000) + '...' : cleaned;
126
+ content = `Page Title: ${entry.title}\nContent: ${truncated}`;
127
+ }
112
128
  }
113
129
  else {
114
130
  content = `Page Title: ${entry.title} (Content unavailable or too short)`;
@@ -128,7 +144,7 @@ export class AlchemistService {
128
144
  userId
129
145
  }, supabase);
130
146
  // 3. LLM Analysis
131
- const response = await this.analyzeContent(content, entry.url, settings, learningContext);
147
+ const response = await this.analyzeContent(content, finalUrl, settings, learningContext);
132
148
  const duration = Date.now() - startAnalysis;
133
149
  // 4. Save Signal (ALWAYS save for Active Learning - Low scores = candidates for boost)
134
150
  console.log(`[AlchemistService] Saving signal (${response.score}%)...`);
@@ -136,17 +152,21 @@ export class AlchemistService {
136
152
  .from('signals')
137
153
  .insert([{
138
154
  user_id: userId,
139
- url: entry.url,
155
+ url: finalUrl,
140
156
  title: entry.title,
141
- score: response.score,
142
- summary: response.summary,
157
+ score: isGatedContent ? Math.min(response.score, 20) : response.score, // Cap gated content at 20
158
+ summary: isGatedContent ? 'Login or subscription required to access this content.' : response.summary,
143
159
  category: response.category,
144
160
  entities: response.entities,
145
161
  tags: response.tags,
146
162
  content: content,
147
- // Mark as dismissed if low score so it doesn't clutter main feed,
148
- // but is available in "Low" filter
149
- is_dismissed: response.score < 50
163
+ // Mark as dismissed if low score OR gated content
164
+ is_dismissed: response.score < 50 || isGatedContent,
165
+ metadata: {
166
+ original_source_url: entry.url,
167
+ resolved_at: new Date().toISOString(),
168
+ is_gated: isGatedContent
169
+ }
150
170
  }])
151
171
  .select()
152
172
  .single();
@@ -170,7 +190,7 @@ export class AlchemistService {
170
190
  stats.signals++;
171
191
  // 5. Generate Embedding & Check for Duplicates (non-blocking)
172
192
  if (settings.embedding_model && await embeddingService.isAvailable()) {
173
- this.processEmbedding(insertedSignal, settings, userId, supabase).catch((err) => {
193
+ await this.processEmbedding(insertedSignal, settings, userId, supabase).catch((err) => {
174
194
  console.error('[AlchemistService] Embedding processing failed:', err);
175
195
  });
176
196
  }
@@ -178,7 +198,7 @@ export class AlchemistService {
178
198
  else {
179
199
  // Low Score: Emit Skipped (but it IS saved in DB now)
180
200
  // Trigger metadata-based deduplication (no embedding) to merge tracking links/redirects
181
- this.processDeduplicationOnly(insertedSignal, settings, userId, supabase).catch((err) => {
201
+ await this.processDeduplicationOnly(insertedSignal, settings, userId, supabase).catch((err) => {
182
202
  console.error('[AlchemistService] Deduplication check failed:', err);
183
203
  });
184
204
  await this.processingEvents.log({
@@ -240,25 +260,44 @@ export class AlchemistService {
240
260
  const prompt = `
241
261
  Act as "The Alchemist", a high-level intelligence analyst.
242
262
  Analyze the following article value based on the content and the User's Interests.
243
-
263
+
244
264
  ${learningContext}
245
-
265
+
246
266
  Input:
247
267
  URL: ${url}
248
268
  Content: ${content}
249
-
250
- CRITICAL SCORING:
251
- High Score (80-100): Original research, concrete data points, contrarian insights, deep technical details, official documentation. MATCHES USER INTERESTS/BOOSTED TOPICS.
252
- Medium Score (50-79): Significant Industry News (Mergers, IPOs, Major Releases), Decent summaries, useful aggregate news, tutorials, reference material.
253
- Low Score (0-49): Marketing fluff, SEO clickbait, generic listicles, navigation menus only, login pages, site footers, OR MATCHES USER DISLIKES (Dismissed topics).
254
-
269
+
270
+ CRITICAL RULES:
271
+
272
+ 1. REJECT JUNK PAGES (Score = 0):
273
+ - Login pages, authentication walls, "sign in to continue"
274
+ - Navigation menus, site footers, cookie notices
275
+ - "Page not found", error pages, access denied
276
+ - App store pages, download prompts
277
+ - Empty or placeholder content
278
+ For these, return: score=0, category="Other", summary="[Login wall/Navigation page/etc]", tags=[], entities=[]
279
+
280
+ 2. SCORING GUIDE:
281
+ - High (80-100): Original research, data, insights, technical depth. MATCHES USER INTERESTS.
282
+ - Medium (50-79): Industry news, tutorials, useful summaries.
283
+ - Low (1-49): Marketing fluff, clickbait, thin content, MATCHES USER DISLIKES.
284
+ - Zero (0): Junk pages per rule #1 above.
285
+
286
+ 3. CATEGORY - MUST be exactly one of these 8 values:
287
+ "AI & ML", "Business", "Politics", "Technology", "Finance", "Crypto", "Science", "Other"
288
+ NEVER create custom categories. If unsure, use "Other".
289
+
290
+ 4. TAGS - Only include meaningful topic tags like:
291
+ "machine learning", "startups", "regulations", "cybersecurity", "investing"
292
+ NEVER include: "login", "navigation", "authentication", "menu", "footer", "social media", "facebook", "meta"
293
+
255
294
  Return STRICT JSON:
256
295
  {
257
296
  "score": number (0-100),
258
- "category": string (one of: AI & ML, Business, Politics, Technology, Finance, Crypto, Science, Other),
259
- "summary": string (1-sentence concise gist),
260
- "entities": string[],
261
- "tags": string[] (3-5 relevant topic tags for categorization),
297
+ "category": string (MUST be one of the 8 categories above),
298
+ "summary": string (1-sentence concise gist, or "[Junk page]" if score=0),
299
+ "entities": string[] (people, companies, products mentioned),
300
+ "tags": string[] (3-5 TOPIC tags only, no platform/UI terms),
262
301
  "relevant": boolean (true if score > 50)
263
302
  }
264
303
  `;
@@ -30,11 +30,13 @@ export class DeduplicationService {
30
30
  // 2. Title Match Check (Metadata Heuristic)
31
31
  // Useful for redirected URLs or tracking links where content is same but URL differs
32
32
  if (signal.title && signal.title.length > 10) {
33
+ // Normalize title by trimming and collapsing whitespace
34
+ const normalizedTitle = signal.title.trim().replace(/\s+/g, ' ');
33
35
  const { data: titleMatch } = await supabase
34
36
  .from('signals')
35
37
  .select('id, score, title')
36
38
  .eq('user_id', userId)
37
- .ilike('title', signal.title.trim()) // Case-insensitive match
39
+ .ilike('title', normalizedTitle) // Case-insensitive match
38
40
  .neq('id', signal.id || '00000000-0000-0000-0000-000000000000') // Don't match self
39
41
  .order('created_at', { ascending: false })
40
42
  .limit(1)
@@ -88,19 +90,34 @@ export class DeduplicationService {
88
90
  }
89
91
  // Calculate boosted score
90
92
  const mentionCount = (existing.mention_count || 1) + 1;
91
- const scoreBoost = Math.min(mentionCount * 0.1, 0.5); // Max 50% boost
92
- const newScore = Math.min(existing.score + scoreBoost, 10); // Cap at 10
93
+ // SIGNIFICANT FIX: Scale boost for 0-100 scale (was capped at 10)
94
+ const scoreBoost = Math.min(mentionCount * 2, 20);
95
+ const newScore = Math.min(Math.max(existing.score, newSignal.score) + scoreBoost, 100);
93
96
  // Combine summaries using LLM
94
97
  const combinedSummary = await this.combineSummaries(existing.summary, newSignal.summary, settings);
98
+ // Merge Entities and Tags (NEW: Prevent data loss)
99
+ const combinedEntities = [...new Set([...(existing.entities || []), ...(newSignal.entities || [])])];
100
+ const combinedTags = [...new Set([...(existing.tags || []), ...(newSignal.tags || [])])];
95
101
  // Track source URLs in metadata
96
102
  const existingUrls = existing.metadata?.source_urls || [existing.url];
97
103
  const sourceUrls = [...new Set([...existingUrls, newSignal.url])]; // Deduplicate URLs
104
+ // URL Promotion: If new signal has a 'better' URL (longer and doesn't look masked), promote it
105
+ let promotedUrl = existing.url;
106
+ const isExistingMasked = existing.url.includes('t.co') || existing.url.length < 25;
107
+ const isNewBetter = !newSignal.url.includes('t.co') && newSignal.url.length > 25;
108
+ if (isExistingMasked && isNewBetter) {
109
+ console.log(`[Deduplication] Promoting new direct URL: ${newSignal.url} over masked: ${existing.url}`);
110
+ promotedUrl = newSignal.url;
111
+ }
98
112
  // Update existing signal
99
113
  const { error: updateError } = await supabase
100
114
  .from('signals')
101
115
  .update({
116
+ url: promotedUrl,
102
117
  score: newScore,
103
118
  summary: combinedSummary,
119
+ entities: combinedEntities, // Merge entities
120
+ tags: combinedTags, // Merge tags
104
121
  mention_count: mentionCount,
105
122
  metadata: {
106
123
  ...existing.metadata,
@@ -232,7 +232,8 @@ export class MinerService {
232
232
  visit_count: row.visit_count || 1,
233
233
  // Normalize back to Unix Ms for internal storage/usage
234
234
  last_visit_time: this.toUnixMs(row.last_visit_time, source.browser),
235
- browser: source.browser
235
+ browser: source.browser,
236
+ source: source.label || 'browser_history'
236
237
  }));
237
238
  // Log filtering stats
238
239
  if (skippedDuplicates > 0 || skippedNonContent > 0 || skippedBlacklist > 0) {
@@ -2,19 +2,36 @@ import axios from 'axios';
2
2
  import puppeteer from 'puppeteer';
3
3
  import TurndownService from 'turndown';
4
4
  import { EventService } from './EventService.js';
5
+ import { ContentCleaner } from '../utils/contentCleaner.js';
5
6
  export class RouterService {
6
7
  turndown = new TurndownService();
7
8
  events = EventService.getInstance();
8
9
  async extractContent(url) {
9
10
  this.events.emit({ type: 'router', message: `Attempting Tier 1 Extraction (Axios): ${url.substring(0, 30)}...` });
11
+ let finalUrl = url;
10
12
  try {
11
13
  // Tier 1: Fast Fetch
12
14
  const response = await axios.get(url, { timeout: 5000 });
13
- const html = response.data;
14
- const markdown = this.turndown.turndown(html);
15
+ // Capture final URL after redirects
16
+ if (response.request?.res?.responseUrl) {
17
+ finalUrl = response.request.res.responseUrl;
18
+ }
19
+ else if (response.request?._redirectable?._currentUrl) {
20
+ // Robust fallback for axios/node environment
21
+ finalUrl = response.request._redirectable._currentUrl;
22
+ }
23
+ else if (response.config?.url && !response.config.url.includes('t.co')) {
24
+ // Heuristic: If axios didn't capture responseUrl but config.url is different from input
25
+ // and it's not the same shortener, it might be the final one.
26
+ // But usually responseUrl is the reliable one.
27
+ }
28
+ const rawHtml = response.data;
29
+ // Payload Hygiene: Sanitize HTML before Markdown conversion
30
+ const sanitizedHtml = ContentCleaner.sanitizeHtml(rawHtml);
31
+ const markdown = this.turndown.turndown(sanitizedHtml);
15
32
  if (markdown.length > 500) {
16
- this.events.emit({ type: 'router', message: `Tier 1 Success (${markdown.length} chars)` });
17
- return markdown;
33
+ this.events.emit({ type: 'router', message: `Tier 1 Success (${markdown.length} chars) -> ${finalUrl.substring(0, 30)}...` });
34
+ return { content: markdown, finalUrl };
18
35
  }
19
36
  }
20
37
  catch (e) {
@@ -25,12 +42,16 @@ export class RouterService {
25
42
  try {
26
43
  const browser = await puppeteer.launch({ headless: true });
27
44
  const page = await browser.newPage();
28
- await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
45
+ await page.setViewport({ width: 1280, height: 800 });
46
+ const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
47
+ // Capture final URL from page object
48
+ finalUrl = page.url();
29
49
  const content = await page.content();
50
+ const sanitizedHtml = ContentCleaner.sanitizeHtml(content);
30
51
  await browser.close();
31
- const markdown = this.turndown.turndown(content);
32
- this.events.emit({ type: 'router', message: `Tier 2 Success (${markdown.length} chars)` });
33
- return markdown;
52
+ const markdown = this.turndown.turndown(sanitizedHtml);
53
+ this.events.emit({ type: 'router', message: `Tier 2 Success (${markdown.length} chars) -> ${finalUrl.substring(0, 30)}...` });
54
+ return { content: markdown, finalUrl };
34
55
  }
35
56
  catch (e) {
36
57
  this.events.emit({ type: 'router', message: `Tier 2 Failed: ${e.message}` });
@@ -1,4 +1,6 @@
1
1
  import { RealtimeXSDK } from '@realtimex/sdk';
2
+ import os from 'os';
3
+ import path from 'path';
2
4
  /**
3
5
  * Centralized SDK Service
4
6
  * Manages RealTimeX SDK initialization and provides singleton access
@@ -121,4 +123,37 @@ export class SDKService {
121
123
  clearTimeout(timeoutHandle);
122
124
  }
123
125
  }
126
+ /**
127
+ * Trigger a Desktop Agent via Webhook
128
+ * Use this to delegate tasks to the RealTimeX Desktop app
129
+ */
130
+ static async triggerAgent(event, payload) {
131
+ const sdk = this.getSDK();
132
+ if (!sdk) {
133
+ throw new Error('RealTimeX SDK not linked. Cannot trigger desktop agent.');
134
+ }
135
+ console.log(`[SDKService] Triggering Agent Event: ${event}`);
136
+ // Use the existing webhook trigger capability
137
+ // @ts-ignore - internal SDK method
138
+ await sdk.webhook.trigger(event, payload);
139
+ }
140
+ /**
141
+ * Get the App Data Directory from the SDK
142
+ * Used for constructing absolute system paths for the "Drop Zone"
143
+ */
144
+ static async getAppDataDir() {
145
+ const sdk = this.getSDK();
146
+ if (!sdk) {
147
+ throw new Error('RealTimeX SDK not linked. Cannot get app data dir.');
148
+ }
149
+ try {
150
+ // @ts-ignore - SDK method for getting app data directory
151
+ return await sdk.getAppDataDir();
152
+ }
153
+ catch (error) {
154
+ console.warn('[SDKService] getAppDataDir failed, using fallback path');
155
+ // Cross-platform fallback: ~/RealTimeX/Alchemy/data
156
+ return path.join(os.homedir(), 'RealTimeX', 'Alchemy', 'data');
157
+ }
158
+ }
124
159
  }
@@ -0,0 +1,250 @@
1
+ import path from 'path';
2
+ import os from 'os';
3
+ import { SDKService } from './SDKService.js';
4
+ import { ContentCleaner } from '../utils/contentCleaner.js';
5
+ export class TransmuteService {
6
+ /**
7
+ * Run a specific engine pipeline
8
+ */
9
+ async runEngine(engineId, userId, supabase) {
10
+ console.log(`[Transmute] Running engine ${engineId}...`);
11
+ // 1. Fetch Engine Config
12
+ const { data: engine, error } = await supabase
13
+ .from('engines')
14
+ .select('*')
15
+ .eq('id', engineId)
16
+ .single();
17
+ if (error || !engine) {
18
+ throw new Error(`Engine not found: ${error?.message}`);
19
+ }
20
+ // 2. Fetch Context (Signals)
21
+ const signals = await this.fetchContextSignals(userId, engine.config, supabase);
22
+ if (signals.length === 0) {
23
+ console.warn('[Transmute] No signals found for context');
24
+ }
25
+ // Branch logic: Local vs Desktop
26
+ const executionMode = engine.config.execution_mode || 'local';
27
+ if (executionMode === 'desktop') {
28
+ // DELEGATION FLOW
29
+ console.log('[Transmute] Delegating to Desktop Agent...');
30
+ // 3a. Create Pending Asset
31
+ const assetStub = await this.saveAsset(engine, null, signals, userId, supabase, 'pending');
32
+ // 3b. Construct Stateless Brief (Optimized)
33
+ const brief = await this.generateProductionBrief(engine, signals, userId, assetStub.id, supabase);
34
+ // 3c. Trigger Agent
35
+ try {
36
+ await SDKService.triggerAgent('alchemy.create_asset', brief);
37
+ // 3d. Update status to processing
38
+ await supabase
39
+ .from('assets')
40
+ .update({ status: 'processing' })
41
+ .eq('id', assetStub.id);
42
+ return { ...assetStub, status: 'processing' };
43
+ }
44
+ catch (error) {
45
+ console.error('[Transmute] Failed to trigger desktop agent:', error);
46
+ await supabase
47
+ .from('assets')
48
+ .update({ status: 'failed', error_message: error.message })
49
+ .eq('id', assetStub.id);
50
+ throw error;
51
+ }
52
+ }
53
+ else {
54
+ // LOCAL LLM FLOW (Legacy)
55
+ // 3. Generate Content (LLM)
56
+ const content = await this.generateAssetContent(engine, signals);
57
+ // 4. Save Asset
58
+ const asset = await this.saveAsset(engine, content, signals, userId, supabase, 'completed');
59
+ // 5. Update Engine Status
60
+ await supabase
61
+ .from('engines')
62
+ .update({ last_run_at: new Date().toISOString() })
63
+ .eq('id', engineId);
64
+ console.log(`[Transmute] Engine run complete. Asset created: ${asset.id}`);
65
+ return asset;
66
+ }
67
+ }
68
+ /**
69
+ * Get the production brief for inspection (doesn't trigger run)
70
+ */
71
+ async getProductionBrief(engineId, userId, supabase) {
72
+ // 1. Fetch Engine Config
73
+ const { data: engine, error } = await supabase
74
+ .from('engines')
75
+ .select('*')
76
+ .eq('id', engineId)
77
+ .single();
78
+ if (error || !engine) {
79
+ throw new Error(`Engine not found: ${error?.message}`);
80
+ }
81
+ // 2. Fetch Context (Signals)
82
+ const signals = await this.fetchContextSignals(userId, engine.config, supabase);
83
+ // 3. Generate Stateless Brief
84
+ return this.generateProductionBrief(engine, signals, userId, 'preview-mode', supabase);
85
+ }
86
+ /**
87
+ * Generate a stateless production brief for the Desktop Studio
88
+ * Optimized for token efficiency and high quality directives
89
+ */
90
+ async generateProductionBrief(engine, signals, userId, assetId, supabase) {
91
+ // 1. Fetch User Persona (for personalization)
92
+ const { data: persona } = await supabase
93
+ .from('user_personas')
94
+ .select('interest_summary, anti_patterns')
95
+ .eq('user_id', userId)
96
+ .maybeSingle();
97
+ // 2. Fetch App Data Dir for absolute path (Drop Zone)
98
+ // Cross-platform fallback: ~/RealTimeX/Alchemy/data
99
+ let appDataDir = path.join(os.homedir(), 'RealTimeX', 'Alchemy', 'data');
100
+ try {
101
+ appDataDir = await SDKService.getAppDataDir();
102
+ }
103
+ catch (e) {
104
+ console.warn('[Transmute] Could not fetch app data dir from SDK, using fallback');
105
+ }
106
+ // 3. Map Signals to brief format with CLEANED content and DIRECT urls
107
+ const signalsBrief = signals.map(s => {
108
+ // Aggregated all known URLs for this signal
109
+ const sourceUrls = s.metadata?.source_urls || [s.url];
110
+ const uniqueUrls = [...new Set([s.url, ...sourceUrls])];
111
+ // Pick the "best" URL: Prioritize long URLs and avoid shortened ones
112
+ // Filter out 't.co' and pick longest as heuristic for final article
113
+ const unmaskedUrls = uniqueUrls.filter(u => !u.includes('t.co') && !u.includes('bit.ly'));
114
+ const bestUrl = unmaskedUrls.length > 0
115
+ ? unmaskedUrls.reduce((a, b) => a.length > b.length ? a : b)
116
+ : s.url;
117
+ return {
118
+ title: s.title,
119
+ summary: s.summary,
120
+ url: bestUrl, // Best Available (Resolved) URL
121
+ source_urls: uniqueUrls, // All associated direct URLs
122
+ // Use ContentCleaner to strip JS/CSS noise
123
+ content: s.content ? ContentCleaner.cleanContent(s.content) : undefined
124
+ };
125
+ });
126
+ // 4. Construct high-fidelity System Prompt
127
+ let systemPrompt = "You are a specialized AI content creator.";
128
+ if (engine.type === 'newsletter') {
129
+ systemPrompt = "You are a senior tech editor. Write a newsletter summarizing the provided context. Use a professional, insight-driven tone. Structure with 'The Big Story' followed by 'Quick Hits'.";
130
+ }
131
+ else if (engine.type === 'thread') {
132
+ systemPrompt = "You are a viral storyteller. Create an engaging X/Twitter thread based on the top signal provided. Use hook-driven writing and informative formatting.";
133
+ }
134
+ else if (engine.type === 'audio') {
135
+ systemPrompt = "You are a podcast scriptwriter. Create a natural, conversational script based on the signals provided. The goal is a 5-minute daily update for a busy professional.";
136
+ }
137
+ const filename = `transmute_${engine.type}_${assetId}.md`;
138
+ const systemPath = path.join(appDataDir, 'assets', filename);
139
+ // 5. Construct the self-contained JSON
140
+ return {
141
+ agent_name: `alchemy-${engine.type}`,
142
+ auto_run: true,
143
+ raw_data: {
144
+ job_id: assetId,
145
+ context: {
146
+ title: `${engine.title} - ${new Date().toLocaleDateString()}`,
147
+ signals: signalsBrief,
148
+ user_persona: persona ? {
149
+ interest_summary: persona.interest_summary,
150
+ anti_patterns: persona.anti_patterns
151
+ } : undefined
152
+ },
153
+ directives: {
154
+ prompt: engine.config.custom_prompt || "Create a high-quality asset based on the provided context.",
155
+ system_prompt: systemPrompt,
156
+ ...engine.config,
157
+ engine_type: engine.type,
158
+ execution_mode: 'desktop'
159
+ },
160
+ output_config: {
161
+ target_asset_id: assetId,
162
+ filename: filename,
163
+ system_path: systemPath
164
+ }
165
+ }
166
+ };
167
+ }
168
+ /**
169
+ * Fetch relevant signals based on engine configuration
170
+ */
171
+ async fetchContextSignals(userId, config, supabase) {
172
+ let query = supabase
173
+ .from('signals')
174
+ .select('*')
175
+ .eq('user_id', userId)
176
+ .order('score', { ascending: false })
177
+ .limit(10);
178
+ if (config.category && config.category !== 'All') {
179
+ query = query.eq('category', config.category);
180
+ }
181
+ const { data } = await query;
182
+ return (data || []);
183
+ }
184
+ /**
185
+ * Generate the asset content using LLM
186
+ */
187
+ async generateAssetContent(engine, signals) {
188
+ // Construct Context String
189
+ const contextStr = signals.map(s => `- [${s.score}] ${s.title}\n Summary: ${s.summary}\n URL: ${s.url}`).join('\n\n');
190
+ // Select System Prompt based on Engine Type
191
+ let systemPrompt = "You are an expert editor.";
192
+ let userPrompt = "";
193
+ if (engine.config.custom_prompt) {
194
+ systemPrompt = engine.config.custom_prompt;
195
+ userPrompt = `Context:\n${contextStr}`;
196
+ }
197
+ else if (engine.type === 'newsletter') {
198
+ systemPrompt = "You are a curator writing a daily newsletter. Tone: Professional but engaging.";
199
+ userPrompt = `Write a newsletter based on these top stories:\n\n${contextStr}\n\nFormat:\n# Title\n\n## Deep Dive (Top Story)\n...\n\n## Quick Hits\n...`;
200
+ }
201
+ else if (engine.type === 'thread') {
202
+ systemPrompt = "You are a social media growth expert. Write viral threads.";
203
+ userPrompt = `Create a Twitter/X thread based on this top signal:\n${contextStr}\n\nFormat: 1 tweet per block.`;
204
+ }
205
+ else {
206
+ userPrompt = `Synthesize these signals into a report:\n\n${contextStr}`;
207
+ }
208
+ // Call LLM via SDK
209
+ const sdk = SDKService.getSDK();
210
+ if (!sdk) {
211
+ throw new Error('RealTimeX SDK not available. Please ensure the desktop app is running.');
212
+ }
213
+ // Use engine config for model/provider if available, else defaults
214
+ const provider = engine.config.llm_provider || 'realtimexai';
215
+ const model = engine.config.llm_model || 'gpt-4o-mini';
216
+ const response = await sdk.llm.chat([
217
+ { role: 'system', content: systemPrompt },
218
+ { role: 'user', content: userPrompt }
219
+ ], {
220
+ provider,
221
+ model
222
+ });
223
+ return response.response?.content || "Failed to generate content.";
224
+ }
225
+ /**
226
+ * Save the generated asset to DB
227
+ */
228
+ async saveAsset(engine, content, sourceSignals, userId, supabase, status = 'completed') {
229
+ const { data, error } = await supabase
230
+ .from('assets')
231
+ .insert({
232
+ user_id: userId,
233
+ engine_id: engine.id,
234
+ title: `${engine.title} - ${new Date().toLocaleDateString()}`,
235
+ type: 'markdown', // Default to markdown for now
236
+ content: content,
237
+ status: status,
238
+ metadata: {
239
+ source_signal_count: sourceSignals.length,
240
+ source_signals: sourceSignals.map(s => s.id)
241
+ }
242
+ })
243
+ .select()
244
+ .single();
245
+ if (error)
246
+ throw error;
247
+ return data;
248
+ }
249
+ }
250
+ export const transmuteService = new TransmuteService();