project-graph-mcp 2.3.1 → 2.3.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 (54) hide show
  1. package/package.json +1 -1
  2. package/vendor/symbiote-node/engine/AgentUICommands.js +100 -0
  3. package/vendor/symbiote-node/engine/Executor.js +371 -0
  4. package/vendor/symbiote-node/engine/Graph.js +314 -0
  5. package/vendor/symbiote-node/engine/GraphServer.js +353 -0
  6. package/vendor/symbiote-node/engine/HandlerLoader.js +145 -0
  7. package/vendor/symbiote-node/engine/History.js +83 -0
  8. package/vendor/symbiote-node/engine/Lifecycle.js +118 -0
  9. package/vendor/symbiote-node/engine/Persistence.js +84 -0
  10. package/vendor/symbiote-node/engine/Registry.js +264 -0
  11. package/vendor/symbiote-node/engine/SocketTypes.js +79 -0
  12. package/vendor/symbiote-node/engine/cli.js +404 -0
  13. package/vendor/symbiote-node/engine/index.js +56 -0
  14. package/vendor/symbiote-node/engine/nanoid.js +28 -0
  15. package/vendor/symbiote-node/engine/package.json +26 -0
  16. package/vendor/symbiote-node/engine/packs/ai/beat-detect.handler.js +215 -0
  17. package/vendor/symbiote-node/engine/packs/ai/content-adapt.handler.js +238 -0
  18. package/vendor/symbiote-node/engine/packs/ai/face-detect.handler.js +287 -0
  19. package/vendor/symbiote-node/engine/packs/ai/grok-generate.handler.js +565 -0
  20. package/vendor/symbiote-node/engine/packs/ai/kling-lipsync.handler.js +414 -0
  21. package/vendor/symbiote-node/engine/packs/ai/lesson-generate.handler.js +343 -0
  22. package/vendor/symbiote-node/engine/packs/ai/opencode.handler.js +164 -0
  23. package/vendor/symbiote-node/engine/packs/ai/replicate-lipsync.handler.js +341 -0
  24. package/vendor/symbiote-node/engine/packs/ai/tts.handler.js +241 -0
  25. package/vendor/symbiote-node/engine/packs/ai/whisper.handler.js +191 -0
  26. package/vendor/symbiote-node/engine/packs/data/db-query.handler.js +67 -0
  27. package/vendor/symbiote-node/engine/packs/data/news-accumulate.handler.js +281 -0
  28. package/vendor/symbiote-node/engine/packs/data/personas.handler.js +160 -0
  29. package/vendor/symbiote-node/engine/packs/data/prompt-loader.handler.js +193 -0
  30. package/vendor/symbiote-node/engine/packs/data/roles.handler.js +216 -0
  31. package/vendor/symbiote-node/engine/packs/data/rss-feed.handler.js +244 -0
  32. package/vendor/symbiote-node/engine/packs/debug/inject.handler.js +52 -0
  33. package/vendor/symbiote-node/engine/packs/flow/agent.handler.js +73 -0
  34. package/vendor/symbiote-node/engine/packs/flow/if.handler.js +107 -0
  35. package/vendor/symbiote-node/engine/packs/flow/loop.handler.js +58 -0
  36. package/vendor/symbiote-node/engine/packs/flow/merge.handler.js +60 -0
  37. package/vendor/symbiote-node/engine/packs/flow/retry.handler.js +65 -0
  38. package/vendor/symbiote-node/engine/packs/flow/switch.handler.js +64 -0
  39. package/vendor/symbiote-node/engine/packs/flow/wait-all.handler.js +39 -0
  40. package/vendor/symbiote-node/engine/packs/io/http-request.handler.js +82 -0
  41. package/vendor/symbiote-node/engine/packs/io/read-file.handler.js +60 -0
  42. package/vendor/symbiote-node/engine/packs/io/write-file.handler.js +63 -0
  43. package/vendor/symbiote-node/engine/packs/transform/anchor-match.handler.js +494 -0
  44. package/vendor/symbiote-node/engine/packs/transform/effects-skeleton.handler.js +417 -0
  45. package/vendor/symbiote-node/engine/packs/transform/json-parse.handler.js +43 -0
  46. package/vendor/symbiote-node/engine/packs/transform/lipsync-select.handler.js +339 -0
  47. package/vendor/symbiote-node/engine/packs/transform/riopla-adapt.handler.js +432 -0
  48. package/vendor/symbiote-node/engine/packs/transform/set.handler.js +57 -0
  49. package/vendor/symbiote-node/engine/packs/transform/template-builder.handler.js +134 -0
  50. package/vendor/symbiote-node/engine/packs/transform/template.handler.js +79 -0
  51. package/vendor/symbiote-node/engine/packs/transform/timeline-build.handler.js +399 -0
  52. package/vendor/symbiote-node/engine/packs/util/delay.handler.js +39 -0
  53. package/vendor/symbiote-node/engine/packs/util/log.handler.js +44 -0
  54. package/vendor/symbiote-node/engine/packs/video-pack.js +323 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * data/prompt-loader — Dynamic Markdown template assembly
3
+ *
4
+ * Loads and processes MD prompt templates with variable substitution
5
+ * and recursive file includes. Supports {{VARIABLE}} placeholders and
6
+ * {{file.md}} file includes.
7
+ *
8
+ * Ported from Mr-Computer/automations/argentine-spanish-bot/src/utils/prompt-loader.js
9
+ *
10
+ * @module agi-graph/packs/data/prompt-loader
11
+ */
12
+
13
+ import { readFile, readdir } from 'node:fs/promises';
14
+ import path from 'node:path';
15
+
16
+ /**
17
+ * Process template with variables and file includes
18
+ * @param {string} template - Template string
19
+ * @param {Object} context - Variables to substitute
20
+ * @param {string} baseDir - Base directory for relative includes
21
+ * @returns {Promise<string>}
22
+ */
23
+ async function processTemplate(template, context, baseDir) {
24
+ let result = template;
25
+
26
+ // Process file includes: {{file.md}} or {{path/to/file.md}}
27
+ const fileIncludeRegex = /\{\{([a-zA-Z0-9_\-\/\.]+\.md)\}\}/g;
28
+ let match;
29
+
30
+ while ((match = fileIncludeRegex.exec(result)) !== null) {
31
+ const filePath = match[1];
32
+ const fullMatch = match[0];
33
+
34
+ try {
35
+ let includeContent = await readFile(path.join(baseDir, filePath), 'utf-8');
36
+ // Recursively process included content
37
+ includeContent = await processTemplate(includeContent, context, path.dirname(path.join(baseDir, filePath)));
38
+ result = result.replace(fullMatch, includeContent);
39
+ } catch {
40
+ // Leave placeholder as is
41
+ }
42
+ }
43
+
44
+ // Process variables: {{VARIABLE_NAME}}
45
+ const variableRegex = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g;
46
+
47
+ result = result.replace(variableRegex, (fullMatch, varName) => {
48
+ if (Object.hasOwn(context, varName)) {
49
+ const value = context[varName];
50
+ if (typeof value === 'string') return value;
51
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
52
+ if (Array.isArray(value)) return value.join('\n');
53
+ if (typeof value === 'object' && value !== null) return JSON.stringify(value, null, 2);
54
+ return String(value);
55
+ }
56
+ return fullMatch;
57
+ });
58
+
59
+ return result;
60
+ }
61
+
62
+ /**
63
+ * Validate prompt template — check for missing variables
64
+ * @param {string} template
65
+ * @param {Object} context
66
+ * @returns {Array<string>}
67
+ */
68
+ function validatePromptTemplate(template, context) {
69
+ const variableRegex = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g;
70
+ const missing = [];
71
+ let match;
72
+ while ((match = variableRegex.exec(template)) !== null) {
73
+ const varName = match[1];
74
+ if (!Object.hasOwn(context, varName)) missing.push(varName);
75
+ }
76
+ return [...new Set(missing)];
77
+ }
78
+
79
+ /**
80
+ * List available prompt templates in directory
81
+ * @param {string} dir
82
+ * @returns {Promise<Array<string>>}
83
+ */
84
+ async function listPromptTemplates(dir) {
85
+ try {
86
+ const entries = await readdir(dir, { recursive: true });
87
+ return entries.filter(e => e.endsWith('.md'));
88
+ } catch {
89
+ return [];
90
+ }
91
+ }
92
+
93
+ // ─── Handler Definition ────────────────────────────────────────────────
94
+
95
+ export default {
96
+ type: 'data/prompt-loader',
97
+ category: 'data',
98
+ icon: 'article',
99
+
100
+ driver: {
101
+ description: 'Dynamic Markdown template assembly with {{VARIABLE}} substitution and {{file.md}} includes',
102
+ inputs: [
103
+ { name: 'template', type: 'string' },
104
+ ],
105
+ outputs: [
106
+ { name: 'result', type: 'any' },
107
+ { name: 'error', type: 'string' },
108
+ ],
109
+ params: {
110
+ operation: { type: 'string', default: 'load', description: 'Operation: load | load-multi | validate | list' },
111
+ context: { type: 'any', default: {}, description: 'Variables map for template substitution' },
112
+ baseDir: { type: 'string', default: '.', description: 'Base directory for file includes' },
113
+ // load-multi
114
+ templates: { type: 'any', default: null, description: 'Map of {name: path} for load-multi' },
115
+ // load from file
116
+ filePath: { type: 'string', default: null, description: 'Path to template file (alternative to template input)' },
117
+ },
118
+ },
119
+
120
+ lifecycle: {
121
+ validate: (inputs, params) => {
122
+ const op = params.operation;
123
+ if (op === 'list') return typeof params.baseDir === 'string';
124
+ if (op === 'load-multi') return typeof params.templates === 'object' && params.templates !== null;
125
+ if (op === 'validate') return typeof inputs.template === 'string';
126
+ // load
127
+ return typeof inputs.template === 'string' || typeof params.filePath === 'string';
128
+ },
129
+
130
+ cacheKey: (inputs, params) => {
131
+ if (params.operation === 'list') return `prompt-list:${params.baseDir}`;
132
+ return null; // templates change with context
133
+ },
134
+
135
+ execute: async (inputs, params) => {
136
+ const { operation, context, baseDir } = params;
137
+
138
+ try {
139
+ switch (operation) {
140
+ case 'load': {
141
+ let template = inputs.template;
142
+ let resolvedBase = baseDir;
143
+
144
+ if (!template && params.filePath) {
145
+ const fullPath = path.isAbsolute(params.filePath)
146
+ ? params.filePath
147
+ : path.join(baseDir, params.filePath);
148
+ template = await readFile(fullPath, 'utf-8');
149
+ resolvedBase = path.dirname(fullPath);
150
+ }
151
+
152
+ const processed = await processTemplate(template, context, resolvedBase);
153
+ return { result: { content: processed, variablesUsed: Object.keys(context) } };
154
+ }
155
+
156
+ case 'load-multi': {
157
+ const entries = Object.entries(params.templates);
158
+ const results = {};
159
+ for (const [name, templatePath] of entries) {
160
+ const fullPath = path.isAbsolute(templatePath)
161
+ ? templatePath
162
+ : path.join(baseDir, templatePath);
163
+ const raw = await readFile(fullPath, 'utf-8');
164
+ results[name] = await processTemplate(raw, context, path.dirname(fullPath));
165
+ }
166
+ return { result: { templates: results, count: entries.length } };
167
+ }
168
+
169
+ case 'validate': {
170
+ const missing = validatePromptTemplate(inputs.template, context);
171
+ return {
172
+ result: {
173
+ valid: missing.length === 0,
174
+ missing,
175
+ totalVariables: (inputs.template.match(/\{\{([A-Z_][A-Z0-9_]*)\}\}/g) || []).length,
176
+ },
177
+ };
178
+ }
179
+
180
+ case 'list': {
181
+ const templates = await listPromptTemplates(baseDir);
182
+ return { result: { templates, count: templates.length, baseDir } };
183
+ }
184
+
185
+ default:
186
+ return { error: `Unknown operation: ${operation}` };
187
+ }
188
+ } catch (err) {
189
+ return { error: `prompt-loader ${operation} failed: ${err.message}` };
190
+ }
191
+ },
192
+ },
193
+ };
@@ -0,0 +1,216 @@
1
+ /**
2
+ * data/roles — Instruction & Role Manager
3
+ *
4
+ * Manages AI roles/instructions from Markdown files with YAML frontmatter.
5
+ * Supports listing, filtering by tags, combining roles into system prompts,
6
+ * and scanning directories for role files.
7
+ *
8
+ * Ported from Mr-Computer/modules/razrab-bot/src/services/roles-service.js
9
+ *
10
+ * @module agi-graph/packs/data/roles
11
+ */
12
+
13
+ import { readFile, readdir, stat } from 'node:fs/promises';
14
+ import path from 'node:path';
15
+
16
+ /**
17
+ * Parse YAML frontmatter from markdown content
18
+ * @param {string} content
19
+ * @returns {{ frontmatter: Object, body: string }}
20
+ */
21
+ function parseFrontmatter(content) {
22
+ const fmRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
23
+ const match = content.match(fmRegex);
24
+ if (!match) return { frontmatter: {}, body: content.trim() };
25
+
26
+ const fmText = match[1];
27
+ const body = match[2].trim();
28
+ const frontmatter = {};
29
+
30
+ for (const line of fmText.split('\n')) {
31
+ const colonIdx = line.indexOf(':');
32
+ if (colonIdx < 0) continue;
33
+ const key = line.slice(0, colonIdx).trim();
34
+ let value = line.slice(colonIdx + 1).trim();
35
+
36
+ // Parse arrays (simple YAML inline [a, b, c])
37
+ if (value.startsWith('[') && value.endsWith(']')) {
38
+ value = value.slice(1, -1).split(',').map(v => v.trim().replace(/^["']|["']$/g, ''));
39
+ }
40
+ frontmatter[key] = value;
41
+ }
42
+
43
+ return { frontmatter, body };
44
+ }
45
+
46
+ /**
47
+ * Scan directory for role markdown files
48
+ * @param {string} dirPath
49
+ * @param {string} prefix
50
+ * @returns {Promise<Map<string, Object>>}
51
+ */
52
+ async function scanRolesDirectory(dirPath, prefix = '') {
53
+ const roles = new Map();
54
+ const tags = new Set();
55
+
56
+ try {
57
+ const entries = await readdir(dirPath, { withFileTypes: true });
58
+
59
+ for (const entry of entries) {
60
+ const fullPath = path.join(dirPath, entry.name);
61
+
62
+ if (entry.isDirectory()) {
63
+ const subRoles = await scanRolesDirectory(fullPath, prefix ? `${prefix}/${entry.name}` : entry.name);
64
+ for (const [id, role] of subRoles) {
65
+ roles.set(id, role);
66
+ if (Array.isArray(role.tags)) role.tags.forEach(t => tags.add(t));
67
+ }
68
+ continue;
69
+ }
70
+
71
+ if (!entry.name.endsWith('.md')) continue;
72
+
73
+ const content = await readFile(fullPath, 'utf-8');
74
+ const { frontmatter, body } = parseFrontmatter(content);
75
+
76
+ const roleId = prefix
77
+ ? `${prefix}/${entry.name.replace('.md', '')}`
78
+ : entry.name.replace('.md', '');
79
+
80
+ const role = {
81
+ id: roleId,
82
+ name: frontmatter.name || frontmatter.title || roleId,
83
+ tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : [],
84
+ description: frontmatter.description || '',
85
+ category: frontmatter.category || (prefix || 'general'),
86
+ content: body,
87
+ path: fullPath,
88
+ };
89
+
90
+ roles.set(roleId, role);
91
+ role.tags.forEach(t => tags.add(t));
92
+ }
93
+ } catch {
94
+ // Directory not found or unreadable
95
+ }
96
+
97
+ return roles;
98
+ }
99
+
100
+ // ─── Handler Definition ────────────────────────────────────────────────
101
+
102
+ export default {
103
+ type: 'data/roles',
104
+ category: 'data',
105
+ icon: 'person',
106
+
107
+ driver: {
108
+ description: 'Manage AI roles/instructions from Markdown files with YAML frontmatter',
109
+ inputs: [
110
+ { name: 'rolesDir', type: 'string' },
111
+ ],
112
+ outputs: [
113
+ { name: 'result', type: 'any' },
114
+ { name: 'error', type: 'string' },
115
+ ],
116
+ params: {
117
+ operation: { type: 'string', default: 'list', description: 'Operation: list | get | filter-tags | combine | scan' },
118
+ roleId: { type: 'string', default: null, description: 'Role ID for get operation' },
119
+ tags: { type: 'any', default: null, description: 'Array of tags for filter-tags' },
120
+ matchAll: { type: 'boolean', default: true, description: 'Require ALL tags (true) or ANY tag (false)' },
121
+ roleIds: { type: 'any', default: null, description: 'Array of role IDs for combine operation' },
122
+ },
123
+ },
124
+
125
+ lifecycle: {
126
+ validate: (inputs) => {
127
+ return typeof inputs.rolesDir === 'string' && inputs.rolesDir.length > 0;
128
+ },
129
+
130
+ cacheKey: (inputs, params) => {
131
+ return `roles:${params.operation}:${inputs.rolesDir}:${params.roleId || ''}:${JSON.stringify(params.tags || '')}`;
132
+ },
133
+
134
+ execute: async (inputs, params) => {
135
+ const { rolesDir } = inputs;
136
+ const { operation } = params;
137
+
138
+ try {
139
+ // Scan directory for all roles
140
+ const roles = await scanRolesDirectory(rolesDir);
141
+
142
+ switch (operation) {
143
+ case 'list':
144
+ case 'scan': {
145
+ const allTags = new Set();
146
+ const rolesList = [];
147
+ for (const [id, role] of roles) {
148
+ rolesList.push({
149
+ id: role.id,
150
+ name: role.name,
151
+ tags: role.tags,
152
+ description: role.description,
153
+ category: role.category,
154
+ });
155
+ role.tags.forEach(t => allTags.add(t));
156
+ }
157
+ return {
158
+ result: {
159
+ roles: rolesList,
160
+ count: rolesList.length,
161
+ tags: [...allTags].sort(),
162
+ },
163
+ };
164
+ }
165
+
166
+ case 'get': {
167
+ if (!params.roleId) return { error: 'roleId is required for get operation' };
168
+ const role = roles.get(params.roleId);
169
+ if (!role) return { error: `Role not found: ${params.roleId}` };
170
+ return { result: { role } };
171
+ }
172
+
173
+ case 'filter-tags': {
174
+ if (!Array.isArray(params.tags)) return { error: 'tags array is required' };
175
+ const filtered = [];
176
+ for (const [, role] of roles) {
177
+ const match = params.matchAll
178
+ ? params.tags.every(t => role.tags.includes(t))
179
+ : params.tags.some(t => role.tags.includes(t));
180
+ if (match) filtered.push(role);
181
+ }
182
+ return { result: { roles: filtered, count: filtered.length } };
183
+ }
184
+
185
+ case 'combine': {
186
+ if (!Array.isArray(params.roleIds)) return { error: 'roleIds array is required' };
187
+ const parts = [];
188
+ const resolved = [];
189
+ const missing = [];
190
+ for (const id of params.roleIds) {
191
+ const role = roles.get(id);
192
+ if (role) {
193
+ parts.push(`# ${role.name}\n\n${role.content}`);
194
+ resolved.push(id);
195
+ } else {
196
+ missing.push(id);
197
+ }
198
+ }
199
+ return {
200
+ result: {
201
+ systemPrompt: parts.join('\n\n---\n\n'),
202
+ resolved,
203
+ missing,
204
+ },
205
+ };
206
+ }
207
+
208
+ default:
209
+ return { error: `Unknown operation: ${operation}` };
210
+ }
211
+ } catch (err) {
212
+ return { error: `roles ${operation} failed: ${err.message}` };
213
+ }
214
+ },
215
+ },
216
+ };
@@ -0,0 +1,244 @@
1
+ /**
2
+ * data/rss-feed — RSS Feed Fetcher
3
+ *
4
+ * Fetches and parses RSS feeds with caching, retry logic, and rate limiting.
5
+ * Supports multi-source aggregation with category rotation and topic categorization.
6
+ *
7
+ * Ported from Mr-Computer/automations/argentine-spanish-bot/src/services/rss-feed.js
8
+ *
9
+ * @module symbiote-node/packs/data/rss-feed */
10
+
11
+ /**
12
+ * Simple hash for dedup
13
+ * @param {string} str
14
+ * @returns {string}
15
+ */
16
+ function simpleHash(str) {
17
+ let hash = 0;
18
+ for (let i = 0; i < str.length; i++) {
19
+ const chr = str.charCodeAt(i);
20
+ hash = ((hash << 5) - hash) + chr;
21
+ hash |= 0;
22
+ }
23
+ return Math.abs(hash).toString(36);
24
+ }
25
+
26
+ /**
27
+ * Extract image URL from RSS item
28
+ * @param {Object} item
29
+ * @returns {string|null}
30
+ */
31
+ function extractImageUrl(item) {
32
+ if (item.enclosure?.url) return item.enclosure.url;
33
+ if (item['media:content']?.$.url) return item['media:content'].$.url;
34
+ const imgMatch = (item.content || item['content:encoded'] || '').match(/<img[^>]+src=["']([^"']+)/);
35
+ if (imgMatch) return imgMatch[1];
36
+ return null;
37
+ }
38
+
39
+ // Category definitions for topic classification
40
+ const CATEGORIES = [
41
+ { id: 'politica', name: 'Política', keywords: ['presidente', 'gobierno', 'congreso', 'diputado', 'senador', 'ley', 'decreto', 'ministerio', 'elecciones', 'votación'] },
42
+ { id: 'economia', name: 'Economía', keywords: ['dólar', 'peso', 'inflación', 'bcra', 'mercado', 'bolsa', 'precio', 'sueldo', 'impuesto', 'deuda'] },
43
+ { id: 'deportes', name: 'Deportes', keywords: ['gol', 'partido', 'fútbol', 'selección', 'liga', 'mundial', 'copa', 'técnico', 'jugador', 'cancha'] },
44
+ { id: 'sociedad', name: 'Sociedad', keywords: ['vecinos', 'barrio', 'ciudad', 'protesta', 'educación', 'salud', 'hospital', 'seguridad', 'policía'] },
45
+ { id: 'tecnologia', name: 'Tecnología', keywords: ['app', 'inteligencia artificial', 'robot', 'celular', 'internet', 'red social', 'digital', 'hacker'] },
46
+ { id: 'cultura', name: 'Cultura', keywords: ['cine', 'teatro', 'música', 'festival', 'museo', 'libro', 'artista', 'exposición', 'concierto'] },
47
+ { id: 'internacional', name: 'Internacional', keywords: ['eeuu', 'estados unidos', 'europa', 'china', 'rusia', 'brasil', 'guerra', 'otan', 'onu'] },
48
+ { id: 'clima', name: 'Clima', keywords: ['temperatura', 'lluvia', 'viento', 'tormenta', 'calor', 'frío', 'pronóstico', 'ola de calor'] },
49
+ ];
50
+
51
+ /**
52
+ * Categorize a topic based on title and content
53
+ * @param {string} title
54
+ * @param {string} content
55
+ * @returns {{ id: string, name: string }}
56
+ */
57
+ function categorizeTopic(title, content) {
58
+ const combined = `${title} ${content}`.toLowerCase();
59
+ let bestCategory = { id: 'general', name: 'General' };
60
+ let bestScore = 0;
61
+
62
+ for (const cat of CATEGORIES) {
63
+ let score = 0;
64
+ for (const kw of cat.keywords) {
65
+ if (combined.includes(kw)) score++;
66
+ }
67
+ if (score > bestScore) {
68
+ bestScore = score;
69
+ bestCategory = { id: cat.id, name: cat.name };
70
+ }
71
+ }
72
+
73
+ return bestCategory;
74
+ }
75
+
76
+ /**
77
+ * Parse RSS XML manually (lightweight, no dependency)
78
+ * @param {string} xml
79
+ * @returns {Array<Object>}
80
+ */
81
+ function parseRssXml(xml) {
82
+ const items = [];
83
+ const itemRegex = /<item>([\s\S]*?)<\/item>/gi;
84
+ let match;
85
+
86
+ while ((match = itemRegex.exec(xml)) !== null) {
87
+ const content = match[1];
88
+ const getTag = (tag) => {
89
+ const m = content.match(new RegExp(`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>|<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'));
90
+ return m ? (m[1] || m[2] || '').trim() : '';
91
+ };
92
+
93
+ items.push({
94
+ title: getTag('title'),
95
+ link: getTag('link'),
96
+ description: getTag('description'),
97
+ pubDate: getTag('pubDate'),
98
+ content: getTag('content:encoded') || getTag('description'),
99
+ });
100
+ }
101
+
102
+ return items;
103
+ }
104
+
105
+ // ─── Handler Definition ────────────────────────────────────────────────
106
+
107
+ export default {
108
+ type: 'data/rss-feed',
109
+ category: 'data',
110
+ icon: 'rss_feed',
111
+
112
+ driver: {
113
+ description: 'Fetch and parse RSS feeds with caching, categorization, and multi-source support',
114
+ inputs: [
115
+ { name: 'url', type: 'string' },
116
+ ],
117
+ outputs: [
118
+ { name: 'result', type: 'any' },
119
+ { name: 'error', type: 'string' },
120
+ ],
121
+ params: {
122
+ operation: { type: 'string', default: 'fetch', description: 'Operation: fetch | fetch-multi | categorize' },
123
+ urls: { type: 'any', default: null, description: 'Array of URLs for fetch-multi' },
124
+ maxItems: { type: 'int', default: 20, description: 'Maximum items to return per feed' },
125
+ timeout: { type: 'int', default: 10000, description: 'Fetch timeout in ms' },
126
+ // categorize
127
+ items: { type: 'any', default: null, description: 'Array of {title, content} for categorize operation' },
128
+ },
129
+ },
130
+
131
+ lifecycle: {
132
+ validate: (inputs, params) => {
133
+ const op = params.operation;
134
+ if (op === 'categorize') return Array.isArray(params.items);
135
+ if (op === 'fetch-multi') return Array.isArray(params.urls) && params.urls.length > 0;
136
+ return typeof inputs.url === 'string' && inputs.url.startsWith('http');
137
+ },
138
+
139
+ cacheKey: (inputs, params) => {
140
+ if (params.operation === 'categorize') return null;
141
+ const url = params.operation === 'fetch-multi'
142
+ ? params.urls.join(',')
143
+ : inputs.url;
144
+ return `rss:${params.operation}:${simpleHash(url)}`;
145
+ },
146
+
147
+ execute: async (inputs, params) => {
148
+ const { operation, maxItems, timeout } = params;
149
+
150
+ try {
151
+ switch (operation) {
152
+ case 'fetch': {
153
+ const response = await fetch(inputs.url, {
154
+ signal: AbortSignal.timeout(timeout),
155
+ headers: { 'User-Agent': 'symbiote-node/rss-feed/1.0' }, });
156
+ if (!response.ok) return { error: `HTTP ${response.status}: ${response.statusText}` };
157
+
158
+ const xml = await response.text();
159
+ const rawItems = parseRssXml(xml);
160
+
161
+ const items = rawItems.slice(0, maxItems).map(item => ({
162
+ id: simpleHash(item.title + item.link),
163
+ title: item.title,
164
+ link: item.link,
165
+ description: item.description,
166
+ pubDate: item.pubDate,
167
+ image: extractImageUrl(item),
168
+ category: categorizeTopic(item.title, item.description),
169
+ }));
170
+
171
+ return { result: { items, count: items.length, source: inputs.url } };
172
+ }
173
+
174
+ case 'fetch-multi': {
175
+ const allItems = [];
176
+ const errors = [];
177
+
178
+ for (const url of params.urls) {
179
+ try {
180
+ const response = await fetch(url, {
181
+ signal: AbortSignal.timeout(timeout),
182
+ headers: { 'User-Agent': 'symbiote-node/rss-feed/1.0' }, });
183
+ if (!response.ok) {
184
+ errors.push({ url, error: `HTTP ${response.status}` });
185
+ continue;
186
+ }
187
+
188
+ const xml = await response.text();
189
+ const rawItems = parseRssXml(xml);
190
+
191
+ for (const item of rawItems.slice(0, maxItems)) {
192
+ allItems.push({
193
+ id: simpleHash(item.title + item.link),
194
+ title: item.title,
195
+ link: item.link,
196
+ description: item.description,
197
+ pubDate: item.pubDate,
198
+ image: extractImageUrl(item),
199
+ category: categorizeTopic(item.title, item.description),
200
+ source: url,
201
+ });
202
+ }
203
+ } catch (err) {
204
+ errors.push({ url, error: err.message });
205
+ }
206
+ }
207
+
208
+ // Deduplicate by ID
209
+ const seen = new Set();
210
+ const unique = allItems.filter(item => {
211
+ if (seen.has(item.id)) return false;
212
+ seen.add(item.id);
213
+ return true;
214
+ });
215
+
216
+ return { result: { items: unique, count: unique.length, sources: params.urls.length, errors } };
217
+ }
218
+
219
+ case 'categorize': {
220
+ const categorized = params.items.map(item => ({
221
+ ...item,
222
+ category: categorizeTopic(item.title || '', item.content || item.description || ''),
223
+ }));
224
+
225
+ // Group by category
226
+ const grouped = {};
227
+ for (const item of categorized) {
228
+ const key = item.category.id;
229
+ if (!grouped[key]) grouped[key] = { category: item.category, items: [] };
230
+ grouped[key].items.push(item);
231
+ }
232
+
233
+ return { result: { categorized, grouped, count: categorized.length } };
234
+ }
235
+
236
+ default:
237
+ return { error: `Unknown operation: ${operation}` };
238
+ }
239
+ } catch (err) {
240
+ return { error: `rss-feed ${operation} failed: ${err.message}` };
241
+ }
242
+ },
243
+ },
244
+ };