@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 +12 -0
- package/dist/api/index.js +40 -0
- package/dist/api/lib/types.js +1 -1
- package/dist/api/services/AlchemistService.js +65 -26
- package/dist/api/services/DeduplicationService.js +20 -3
- package/dist/api/services/MinerService.js +2 -1
- package/dist/api/services/RouterService.js +29 -8
- package/dist/api/services/SDKService.js +35 -0
- package/dist/api/services/TransmuteService.js +250 -0
- package/dist/api/utils/contentCleaner.js +326 -21
- package/dist/assets/index-BdYsvKvV.css +1 -0
- package/dist/assets/index-BoqZas2I.js +124 -0
- package/dist/index.html +2 -2
- package/dist/shared/types.js +1 -0
- package/package.json +1 -1
- package/dist/assets/index-CCCrZ6Ub.js +0 -124
- package/dist/assets/index-Ck7Fq1Iu.css +0 -1
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')) {
|
package/dist/api/lib/types.js
CHANGED
|
@@ -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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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,
|
|
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:
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
259
|
-
"summary": string (1-sentence concise gist),
|
|
260
|
-
"entities": string[],
|
|
261
|
-
"tags": string[] (3-5
|
|
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',
|
|
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
|
-
|
|
92
|
-
const
|
|
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
|
-
|
|
14
|
-
|
|
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.
|
|
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(
|
|
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();
|