project-graph-mcp 2.3.0 → 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 (66) hide show
  1. package/package.json +1 -3
  2. package/project-graph-mcp-2.3.0.tgz +0 -0
  3. package/src/network/web-server.js +1 -1
  4. package/vendor/symbiote-node/engine/AgentUICommands.js +100 -0
  5. package/vendor/symbiote-node/engine/Executor.js +371 -0
  6. package/vendor/symbiote-node/engine/Graph.js +314 -0
  7. package/vendor/symbiote-node/engine/GraphServer.js +353 -0
  8. package/vendor/symbiote-node/engine/HandlerLoader.js +145 -0
  9. package/vendor/symbiote-node/engine/History.js +83 -0
  10. package/vendor/symbiote-node/engine/Lifecycle.js +118 -0
  11. package/vendor/symbiote-node/engine/Persistence.js +84 -0
  12. package/vendor/symbiote-node/engine/Registry.js +264 -0
  13. package/vendor/symbiote-node/engine/SocketTypes.js +79 -0
  14. package/vendor/symbiote-node/engine/cli.js +404 -0
  15. package/vendor/symbiote-node/engine/index.js +56 -0
  16. package/vendor/symbiote-node/engine/nanoid.js +28 -0
  17. package/vendor/symbiote-node/engine/package.json +26 -0
  18. package/vendor/symbiote-node/engine/packs/ai/beat-detect.handler.js +215 -0
  19. package/vendor/symbiote-node/engine/packs/ai/content-adapt.handler.js +238 -0
  20. package/vendor/symbiote-node/engine/packs/ai/face-detect.handler.js +287 -0
  21. package/vendor/symbiote-node/engine/packs/ai/grok-generate.handler.js +565 -0
  22. package/vendor/symbiote-node/engine/packs/ai/kling-lipsync.handler.js +414 -0
  23. package/vendor/symbiote-node/engine/packs/ai/lesson-generate.handler.js +343 -0
  24. package/vendor/symbiote-node/engine/packs/ai/opencode.handler.js +164 -0
  25. package/vendor/symbiote-node/engine/packs/ai/replicate-lipsync.handler.js +341 -0
  26. package/vendor/symbiote-node/engine/packs/ai/tts.handler.js +241 -0
  27. package/vendor/symbiote-node/engine/packs/ai/whisper.handler.js +191 -0
  28. package/vendor/symbiote-node/engine/packs/data/db-query.handler.js +67 -0
  29. package/vendor/symbiote-node/engine/packs/data/news-accumulate.handler.js +281 -0
  30. package/vendor/symbiote-node/engine/packs/data/personas.handler.js +160 -0
  31. package/vendor/symbiote-node/engine/packs/data/prompt-loader.handler.js +193 -0
  32. package/vendor/symbiote-node/engine/packs/data/roles.handler.js +216 -0
  33. package/vendor/symbiote-node/engine/packs/data/rss-feed.handler.js +244 -0
  34. package/vendor/symbiote-node/engine/packs/debug/inject.handler.js +52 -0
  35. package/vendor/symbiote-node/engine/packs/flow/agent.handler.js +73 -0
  36. package/vendor/symbiote-node/engine/packs/flow/if.handler.js +107 -0
  37. package/vendor/symbiote-node/engine/packs/flow/loop.handler.js +58 -0
  38. package/vendor/symbiote-node/engine/packs/flow/merge.handler.js +60 -0
  39. package/vendor/symbiote-node/engine/packs/flow/retry.handler.js +65 -0
  40. package/vendor/symbiote-node/engine/packs/flow/switch.handler.js +64 -0
  41. package/vendor/symbiote-node/engine/packs/flow/wait-all.handler.js +39 -0
  42. package/vendor/symbiote-node/engine/packs/io/http-request.handler.js +82 -0
  43. package/vendor/symbiote-node/engine/packs/io/read-file.handler.js +60 -0
  44. package/vendor/symbiote-node/engine/packs/io/write-file.handler.js +63 -0
  45. package/vendor/symbiote-node/engine/packs/transform/anchor-match.handler.js +494 -0
  46. package/vendor/symbiote-node/engine/packs/transform/effects-skeleton.handler.js +417 -0
  47. package/vendor/symbiote-node/engine/packs/transform/json-parse.handler.js +43 -0
  48. package/vendor/symbiote-node/engine/packs/transform/lipsync-select.handler.js +339 -0
  49. package/vendor/symbiote-node/engine/packs/transform/riopla-adapt.handler.js +432 -0
  50. package/vendor/symbiote-node/engine/packs/transform/set.handler.js +57 -0
  51. package/vendor/symbiote-node/engine/packs/transform/template-builder.handler.js +134 -0
  52. package/vendor/symbiote-node/engine/packs/transform/template.handler.js +79 -0
  53. package/vendor/symbiote-node/engine/packs/transform/timeline-build.handler.js +399 -0
  54. package/vendor/symbiote-node/engine/packs/util/delay.handler.js +39 -0
  55. package/vendor/symbiote-node/engine/packs/util/log.handler.js +44 -0
  56. package/vendor/symbiote-node/engine/packs/video-pack.js +323 -0
  57. package/vendor/symbiote-node/package.json +2 -2
  58. package/web/app.js +6 -3
  59. package/web/components/canvas-graph.js +50 -11
  60. package/web/components/code-block.js +1 -1
  61. package/web/components/event-feed/MiniGraphWidget.js +105 -15
  62. package/web/components/follow-ribbon.js +134 -0
  63. package/web/follow-controller.js +241 -0
  64. package/web/panels/code-viewer.js +1 -1
  65. package/web/panels/dep-graph.js +21 -42
  66. package/web/style.css +6 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * ai/whisper — Audio transcription with word-level timestamps
3
+ *
4
+ * Two modes:
5
+ * - SSH: uploads audio to remote server via scp, runs Whisper via SSH
6
+ * - HTTP: sends audio to a Whisper HTTP endpoint (e.g., faster-whisper-server)
7
+ *
8
+ * SSH remote config from Mr-Computer/modules/ai-music-video/whisper-ssh.js:
9
+ * Host: mr-agent@mr-agent.rnd-pro.com
10
+ * Venv: /home/mr-agent/automations/argentine-spanish-bot/venv
11
+ * Script: utils/whisper-word-timing.py
12
+ *
13
+ * @module agi-graph/packs/ai/whisper
14
+ */
15
+
16
+ import { execSync } from 'child_process';
17
+ import { promises as fs } from 'fs';
18
+ import path from 'path';
19
+
20
+ export default {
21
+ type: 'ai/whisper',
22
+ category: 'ai',
23
+ icon: 'hearing',
24
+
25
+ driver: {
26
+ description: 'Audio transcription with word-level timestamps (SSH or HTTP mode)',
27
+ inputs: [
28
+ { name: 'audioPath', type: 'string' },
29
+ ],
30
+ outputs: [
31
+ { name: 'text', type: 'string' },
32
+ { name: 'words', type: 'any' },
33
+ { name: 'duration', type: 'number' },
34
+ { name: 'error', type: 'string' },
35
+ ],
36
+ params: {
37
+ mode: { type: 'string', default: 'ssh', description: 'ssh | http' },
38
+ language: { type: 'string', default: 'es', description: 'Language code' },
39
+ model: { type: 'string', default: 'medium', description: 'Whisper model: tiny, base, small, medium, large-v3' },
40
+ device: { type: 'string', default: 'cuda', description: 'cuda | cpu' },
41
+ // SSH params
42
+ remoteHost: { type: 'string', default: 'mr-agent@mr-agent.rnd-pro.com', description: 'SSH host' },
43
+ remotePath: { type: 'string', default: '/home/mr-agent/automations/argentine-spanish-bot', description: 'Remote project path' },
44
+ remoteVenv: { type: 'string', default: '/home/mr-agent/automations/argentine-spanish-bot/venv', description: 'Remote Python venv' },
45
+ // HTTP params
46
+ endpoint: { type: 'string', default: 'http://localhost:5001', description: 'Whisper HTTP endpoint' },
47
+ timeout: { type: 'int', default: 300000, description: 'Max wait time (ms)' },
48
+ },
49
+ },
50
+
51
+ lifecycle: {
52
+ validate: (inputs) => {
53
+ if (!inputs.audioPath) return false;
54
+ return true;
55
+ },
56
+
57
+ cacheKey: (inputs, params) =>
58
+ `whisper:${params.mode}:${params.model}:${inputs.audioPath}`,
59
+
60
+ execute: async (inputs, params) => {
61
+ const { audioPath } = inputs;
62
+ const mode = params.mode || process.env.WHISPER_MODE || 'ssh';
63
+
64
+ if (mode === 'http') {
65
+ return executeHTTP(audioPath, params);
66
+ }
67
+ return executeSSH(audioPath, params);
68
+ },
69
+ },
70
+ };
71
+
72
+ /**
73
+ * SSH mode: scp upload → remote python exec → parse JSON output
74
+ * @param {string} audioPath - Local audio file path
75
+ * @param {Object} params - Node params
76
+ * @returns {Promise<Object>} Result with text, words, duration
77
+ */
78
+ async function executeSSH(audioPath, params) {
79
+ const host = params.remoteHost || process.env.WHISPER_REMOTE_HOST || 'mr-agent@mr-agent.rnd-pro.com';
80
+ const remotePath = params.remotePath || process.env.WHISPER_REMOTE_PATH || '/home/mr-agent/automations/argentine-spanish-bot';
81
+ const venv = params.remoteVenv || process.env.WHISPER_REMOTE_VENV || `${remotePath}/venv`;
82
+ const model = params.model || process.env.WHISPER_MODEL || 'medium';
83
+ const device = params.device || process.env.WHISPER_DEVICE || 'cuda';
84
+ const language = params.language || 'es';
85
+ const remoteTmpDir = '/tmp/agi-graph-whisper';
86
+
87
+ try {
88
+ // Verify file exists
89
+ await fs.access(audioPath);
90
+
91
+ const filename = path.basename(audioPath);
92
+ const remoteAudioPath = `${remoteTmpDir}/${filename}`;
93
+
94
+ // Ensure remote dir
95
+ execSync(`ssh ${host} "mkdir -p ${remoteTmpDir}"`, {
96
+ encoding: 'utf-8',
97
+ stdio: 'pipe',
98
+ timeout: 10000,
99
+ });
100
+
101
+ // Upload audio
102
+ execSync(`scp "${audioPath}" "${host}:${remoteAudioPath}"`, {
103
+ encoding: 'utf-8',
104
+ stdio: 'pipe',
105
+ timeout: 60000,
106
+ });
107
+
108
+ try {
109
+ // Run Whisper
110
+ const pythonCmd = `${venv}/bin/python3`;
111
+ const whisperScript = `${remotePath}/utils/whisper-word-timing.py`;
112
+
113
+ const cmd = `"${pythonCmd}" "${whisperScript}" "${remoteAudioPath}" "${language}" --model "${model}" --device "${device}"`;
114
+ const fullCmd = `ssh ${host} '${cmd}'`;
115
+
116
+ const output = execSync(fullCmd, {
117
+ encoding: 'utf-8',
118
+ maxBuffer: 50 * 1024 * 1024,
119
+ timeout: params.timeout || 300000,
120
+ });
121
+
122
+ const words = JSON.parse(output);
123
+ const text = words.map(w => w.word).join(' ');
124
+ const duration = words.length > 0
125
+ ? words[words.length - 1].end
126
+ : 0;
127
+
128
+ return { text, words, duration, error: null };
129
+
130
+ } finally {
131
+ // Cleanup remote file
132
+ try {
133
+ execSync(`ssh ${host} "rm -f ${remoteAudioPath}"`, {
134
+ encoding: 'utf-8',
135
+ stdio: 'pipe',
136
+ timeout: 5000,
137
+ });
138
+ } catch { /* ignore */ }
139
+ }
140
+
141
+ } catch (err) {
142
+ return { text: null, words: null, duration: 0, error: err.message };
143
+ }
144
+ }
145
+
146
+ /**
147
+ * HTTP mode: POST audio to Whisper endpoint via FormData
148
+ * @param {string} audioPath - Local audio file path
149
+ * @param {Object} params - Node params
150
+ * @returns {Promise<Object>} Result with text, words, duration
151
+ */
152
+ async function executeHTTP(audioPath, params) {
153
+ const endpoint = params.endpoint || process.env.WHISPER_ENDPOINT || 'http://localhost:5001';
154
+ const language = params.language || 'es';
155
+
156
+ try {
157
+ const audioBuffer = await fs.readFile(audioPath);
158
+ const blob = new Blob([audioBuffer], { type: 'audio/wav' });
159
+
160
+ const formData = new FormData();
161
+ formData.append('file', blob, path.basename(audioPath));
162
+ formData.append('language', language);
163
+ formData.append('word_timestamps', 'true');
164
+
165
+ if (params.model) {
166
+ formData.append('model', params.model);
167
+ }
168
+
169
+ const response = await fetch(`${endpoint}/transcribe`, {
170
+ method: 'POST',
171
+ body: formData,
172
+ signal: AbortSignal.timeout(params.timeout || 300000),
173
+ });
174
+
175
+ if (!response.ok) {
176
+ return { text: null, words: null, duration: 0, error: `Whisper API error: ${response.status}` };
177
+ }
178
+
179
+ const result = await response.json();
180
+ const words = result.words || [];
181
+ const text = result.text || words.map(w => w.word).join(' ');
182
+ const duration = words.length > 0
183
+ ? words[words.length - 1].end
184
+ : 0;
185
+
186
+ return { text, words, duration, error: null };
187
+
188
+ } catch (err) {
189
+ return { text: null, words: null, duration: 0, error: err.message };
190
+ }
191
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * data/db-query — Universal SQL query node
3
+ *
4
+ * Executes a parameterized SQL query against the connected database.
5
+ * Parameters are extracted from input data fields specified in params.paramFields.
6
+ *
7
+ * This is a generic node — the DB connection is injected by the host application
8
+ * via context.db (postgres tagged template instance).
9
+ *
10
+ * @module symbiote-node/packs/data/db-query
11
+ */
12
+
13
+ export default {
14
+ type: 'data/db-query',
15
+ category: 'data',
16
+ icon: 'database',
17
+
18
+ driver: {
19
+ description: 'Execute SQL query with parameters from input data',
20
+ inputs: [
21
+ { name: 'data', type: 'any' },
22
+ ],
23
+ outputs: [
24
+ { name: 'rows', type: 'any' },
25
+ { name: 'data', type: 'any' },
26
+ ],
27
+ params: {
28
+ query: { type: 'text', default: '', description: 'SQL query with $1, $2... placeholders' },
29
+ paramFields: { type: 'string', default: '', description: 'Comma-separated field names from input data to use as query params' },
30
+ outputField: { type: 'string', default: 'queryResult', description: 'Field name to store rows in data output' },
31
+ },
32
+ },
33
+
34
+ lifecycle: {
35
+ validate: (inputs, params) => {
36
+ return !!params.query;
37
+ },
38
+
39
+ execute: async (inputs, params, context) => {
40
+ const data = inputs.data || {};
41
+ const query = params.query;
42
+
43
+ // Extract param values from input data
44
+ const paramNames = (params.paramFields || '')
45
+ .split(',')
46
+ .map((s) => s.trim())
47
+ .filter(Boolean);
48
+ const paramValues = paramNames.map((field) => data[field]);
49
+
50
+ // Execute via context.db (injected by host)
51
+ if (!context?.db) {
52
+ return {
53
+ rows: [],
54
+ data: { ...data, [params.outputField || 'queryResult']: [], error: 'No DB context' },
55
+ };
56
+ }
57
+
58
+ const rows = await context.db.unsafe(query, paramValues);
59
+ const outputField = params.outputField || 'queryResult';
60
+
61
+ return {
62
+ rows: [...rows],
63
+ data: { ...data, [outputField]: [...rows] },
64
+ };
65
+ },
66
+ },
67
+ };
@@ -0,0 +1,281 @@
1
+ /**
2
+ * data/news-accumulate — News Accumulator
3
+ *
4
+ * Collects and stores raw news items with deduplication, filtering,
5
+ * period management, and category statistics. Supports horoscope
6
+ * and international news filtering for Argentina-focused content.
7
+ *
8
+ * Ported from Mr-Computer/automations/argentine-spanish-bot/src/services/news-accumulator.js
9
+ *
10
+ * @module agi-graph/packs/data/news-accumulate
11
+ */
12
+
13
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
14
+ import path from 'node:path';
15
+
16
+ // Non-local patterns to filter out
17
+ const NON_LOCAL_PATTERNS = ['en eeuu', 'desde estados unidos'];
18
+ const HOROSCOPE_PATTERNS = ['horóscopo', 'horoscopo', 'astrolog', 'signo del zodiaco', 'signo del zodíaco'];
19
+
20
+ /**
21
+ * Simple hash for news dedup
22
+ * @param {string} str
23
+ * @returns {string}
24
+ */
25
+ function hashString(str) {
26
+ let hash = 0;
27
+ for (let i = 0; i < str.length; i++) {
28
+ const chr = str.charCodeAt(i);
29
+ hash = ((hash << 5) - hash) + chr;
30
+ hash |= 0;
31
+ }
32
+ return Math.abs(hash).toString(36);
33
+ }
34
+
35
+ /**
36
+ * Generate unique ID for a news item
37
+ * @param {Object} item
38
+ * @returns {string}
39
+ */
40
+ function generateId(item) {
41
+ const source = item.link || item.url || '';
42
+ const title = item.title || '';
43
+ return hashString(`${title}:${source}`);
44
+ }
45
+
46
+ /**
47
+ * Standardize a news item
48
+ * @param {Object} item
49
+ * @returns {Object}
50
+ */
51
+ function standardizeItem(item) {
52
+ return {
53
+ id: item.id || generateId(item),
54
+ title: item.title || '',
55
+ description: item.description || item.content || '',
56
+ link: item.link || item.url || '',
57
+ category: item.category || { id: 'general', name: 'General' },
58
+ source: item.source || '',
59
+ image: item.image || null,
60
+ pubDate: item.pubDate || new Date().toISOString(),
61
+ addedAt: new Date().toISOString(),
62
+ processed: false,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Filter out international news
68
+ * @param {Array} items
69
+ * @returns {Array}
70
+ */
71
+ function filterArgentinaOnly(items) {
72
+ return items.filter(item => {
73
+ const text = `${item.title} ${item.description}`.toLowerCase();
74
+ return !NON_LOCAL_PATTERNS.some(p => text.includes(p));
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Filter out horoscope content
80
+ * @param {Array} items
81
+ * @returns {Array}
82
+ */
83
+ function filterOutHoroscopes(items) {
84
+ return items.filter(item => {
85
+ const text = `${item.title} ${item.description}`.toLowerCase();
86
+ return !HOROSCOPE_PATTERNS.some(p => text.includes(p));
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Load stored data from file
92
+ * @param {string} storePath
93
+ * @returns {Promise<Object>}
94
+ */
95
+ async function loadStore(storePath) {
96
+ try {
97
+ const data = await readFile(storePath, 'utf-8');
98
+ return JSON.parse(data);
99
+ } catch {
100
+ return {
101
+ news: [],
102
+ periodStart: new Date().toISOString(),
103
+ categoryCounts: {},
104
+ processedIds: [],
105
+ };
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Save data to file
111
+ * @param {string} storePath
112
+ * @param {Object} data
113
+ */
114
+ async function saveStore(storePath, data) {
115
+ await mkdir(path.dirname(storePath), { recursive: true });
116
+ await writeFile(storePath, JSON.stringify(data, null, 2));
117
+ }
118
+
119
+ // ─── Handler Definition ────────────────────────────────────────────────
120
+
121
+ export default {
122
+ type: 'data/news-accumulate',
123
+ category: 'data',
124
+ icon: 'newspaper',
125
+
126
+ driver: {
127
+ description: 'Collect and store news with dedup, filtering, periods, and category stats',
128
+ inputs: [
129
+ { name: 'storePath', type: 'string' },
130
+ ],
131
+ outputs: [
132
+ { name: 'result', type: 'any' },
133
+ { name: 'error', type: 'string' },
134
+ ],
135
+ params: {
136
+ operation: { type: 'string', default: 'get', description: 'Operation: add | get | mark-processed | new-period | stats' },
137
+ // add
138
+ newsItem: { type: 'any', default: null, description: 'News item to add' },
139
+ newsItems: { type: 'any', default: null, description: 'Array of news items to add (batch)' },
140
+ // get filters
141
+ categories: { type: 'any', default: null, description: 'Filter by categories array' },
142
+ since: { type: 'string', default: null, description: 'Get news since ISO date' },
143
+ filterLocal: { type: 'boolean', default: false, description: 'Filter out non-local (international) news' },
144
+ filterHoroscopes: { type: 'boolean', default: true, description: 'Filter out horoscope content' },
145
+ maxItems: { type: 'int', default: 100, description: 'Maximum items to return' },
146
+ // mark-processed
147
+ newsIds: { type: 'any', default: null, description: 'Array of news IDs to mark processed' },
148
+ },
149
+ },
150
+
151
+ lifecycle: {
152
+ validate: (inputs) => {
153
+ return typeof inputs.storePath === 'string' && inputs.storePath.length > 0;
154
+ },
155
+
156
+ cacheKey: () => null, // mutable state
157
+
158
+ execute: async (inputs, params) => {
159
+ const { storePath } = inputs;
160
+ const { operation } = params;
161
+
162
+ try {
163
+ const store = await loadStore(storePath);
164
+
165
+ switch (operation) {
166
+ case 'add': {
167
+ const itemsToAdd = params.newsItems
168
+ ? params.newsItems
169
+ : params.newsItem
170
+ ? [params.newsItem]
171
+ : [];
172
+
173
+ if (itemsToAdd.length === 0) return { error: 'No items to add' };
174
+
175
+ const existingIds = new Set(store.news.map(n => n.id));
176
+ let added = 0;
177
+
178
+ for (const raw of itemsToAdd) {
179
+ const item = standardizeItem(raw);
180
+ if (existingIds.has(item.id)) continue;
181
+
182
+ store.news.push(item);
183
+ existingIds.add(item.id);
184
+ added++;
185
+
186
+ // Update category counts
187
+ const catId = typeof item.category === 'object' ? item.category.id : item.category;
188
+ store.categoryCounts[catId] = (store.categoryCounts[catId] || 0) + 1;
189
+ }
190
+
191
+ await saveStore(storePath, store);
192
+ return { result: { added, total: store.news.length, duplicatesSkipped: itemsToAdd.length - added } };
193
+ }
194
+
195
+ case 'get': {
196
+ let items = store.news.filter(n => !n.processed);
197
+
198
+ // Date filter
199
+ if (params.since) {
200
+ const sinceDate = new Date(params.since);
201
+ items = items.filter(n => new Date(n.addedAt) >= sinceDate);
202
+ }
203
+
204
+ // Category filter
205
+ if (Array.isArray(params.categories) && params.categories.length > 0) {
206
+ items = items.filter(n => {
207
+ const catId = typeof n.category === 'object' ? n.category.id : n.category;
208
+ return params.categories.includes(catId);
209
+ });
210
+ }
211
+
212
+ // Content filters
213
+ if (params.filterLocal) items = filterArgentinaOnly(items);
214
+ if (params.filterHoroscopes) items = filterOutHoroscopes(items);
215
+
216
+ items = items.slice(0, params.maxItems);
217
+
218
+ return { result: { items, count: items.length } };
219
+ }
220
+
221
+ case 'mark-processed': {
222
+ if (!Array.isArray(params.newsIds)) return { error: 'newsIds array is required' };
223
+
224
+ const idsSet = new Set(params.newsIds);
225
+ let marked = 0;
226
+
227
+ for (const item of store.news) {
228
+ if (idsSet.has(item.id) && !item.processed) {
229
+ item.processed = true;
230
+ item.processedAt = new Date().toISOString();
231
+ marked++;
232
+ }
233
+ }
234
+
235
+ store.processedIds.push(...params.newsIds);
236
+ await saveStore(storePath, store);
237
+ return { result: { marked, total: params.newsIds.length } };
238
+ }
239
+
240
+ case 'new-period': {
241
+ const archived = {
242
+ periodStart: store.periodStart,
243
+ periodEnd: new Date().toISOString(),
244
+ itemCount: store.news.length,
245
+ categoryCounts: { ...store.categoryCounts },
246
+ };
247
+
248
+ store.news = [];
249
+ store.periodStart = new Date().toISOString();
250
+ store.categoryCounts = {};
251
+ store.processedIds = [];
252
+
253
+ await saveStore(storePath, store);
254
+ return { result: { archived, message: 'New period started' } };
255
+ }
256
+
257
+ case 'stats': {
258
+ const total = store.news.length;
259
+ const processed = store.news.filter(n => n.processed).length;
260
+ const unprocessed = total - processed;
261
+
262
+ return {
263
+ result: {
264
+ total,
265
+ processed,
266
+ unprocessed,
267
+ periodStart: store.periodStart,
268
+ categoryCounts: store.categoryCounts,
269
+ },
270
+ };
271
+ }
272
+
273
+ default:
274
+ return { error: `Unknown operation: ${operation}` };
275
+ }
276
+ } catch (err) {
277
+ return { error: `news-accumulate ${operation} failed: ${err.message}` };
278
+ }
279
+ },
280
+ },
281
+ };
@@ -0,0 +1,160 @@
1
+ /**
2
+ * data/personas — Character persona registry for TTS pipelines
3
+ *
4
+ * Manages character presets with voice settings, personality descriptions,
5
+ * and audio reference samples. Used as a data source node that feeds
6
+ * ai/tts with correct speaker/voice parameters.
7
+ *
8
+ * Persona structure (from Mr-Computer/argentine-spanish-bot):
9
+ * id, name, personality, voiceInstruct, speaker (Qwen3 ID),
10
+ * refAudio (per-language samples), pan (stereo position)
11
+ *
12
+ * Operations:
13
+ * get — get persona by ID
14
+ * list — list all personas (optionally filtered)
15
+ * random — pick N random personas
16
+ *
17
+ * @module agi-graph/packs/data/personas
18
+ */
19
+
20
+ /** @typedef {Object} Persona
21
+ * @property {string} id - Unique identifier
22
+ * @property {string} name - Display name
23
+ * @property {string} personality - Character description for AI prompts
24
+ * @property {string} voiceInstruct - Emotion/style instruction for TTS
25
+ * @property {string} speaker - Qwen3-TTS speaker ID (ryan, vivian, etc.)
26
+ * @property {Object<string, string>} [refAudio] - Per-language voice reference paths
27
+ * @property {number} [pan] - Stereo position (-1.0 left to 1.0 right)
28
+ * @property {string} [gender] - male | female
29
+ * @property {string} [type] - normal | meme | dj
30
+ */
31
+
32
+ /**
33
+ * Built-in persona presets (from radio-dj-config.js)
34
+ * @type {Persona[]}
35
+ */
36
+ const BUILT_IN_PRESETS = [
37
+ {
38
+ id: 'dj_matias',
39
+ name: 'Matías',
40
+ personality: 'Energetic morning host, asks probing questions, uses "che" frequently',
41
+ voiceInstruct: 'Enthusiastic and dynamic, curious tone',
42
+ speaker: 'ryan',
43
+ pan: -0.2,
44
+ gender: 'male',
45
+ type: 'dj',
46
+ },
47
+ {
48
+ id: 'dj_lucia',
49
+ name: 'Lucía',
50
+ personality: 'Analytical co-host, provides context and facts, thoughtful responses',
51
+ voiceInstruct: 'Clear articulate tone, warm and engaging',
52
+ speaker: 'vivian',
53
+ pan: 0.2,
54
+ gender: 'female',
55
+ type: 'dj',
56
+ },
57
+ {
58
+ id: 'dj_carlos',
59
+ name: 'Carlos',
60
+ personality: 'Veteran journalist, offers historical perspective, calm authority',
61
+ voiceInstruct: 'Deep calm authoritative voice, measured pace',
62
+ speaker: 'ryan',
63
+ pan: -0.1,
64
+ gender: 'male',
65
+ type: 'dj',
66
+ },
67
+ {
68
+ id: 'dj_sofia',
69
+ name: 'Sofía',
70
+ personality: 'Young reporter, brings fresh perspectives, occasionally interrupts with excitement',
71
+ voiceInstruct: 'Youthful energetic voice, sometimes excited',
72
+ speaker: 'vivian',
73
+ pan: 0.1,
74
+ gender: 'female',
75
+ type: 'dj',
76
+ },
77
+ ];
78
+
79
+ export default {
80
+ type: 'data/personas',
81
+ category: 'data',
82
+ icon: 'groups',
83
+
84
+ driver: {
85
+ description: 'Character persona registry — voice presets with personality for TTS',
86
+ inputs: [
87
+ { name: 'personaId', type: 'string' },
88
+ ],
89
+ outputs: [
90
+ { name: 'persona', type: 'any' },
91
+ { name: 'personas', type: 'any' },
92
+ { name: 'error', type: 'string' },
93
+ ],
94
+ params: {
95
+ operation: { type: 'string', default: 'get', description: 'get | list | random' },
96
+ count: { type: 'int', default: 2, description: 'Number of personas for random operation' },
97
+ filterGender: { type: 'string', default: '', description: 'Filter by gender: male | female' },
98
+ filterType: { type: 'string', default: '', description: 'Filter by type: dj | normal | meme' },
99
+ customPresets: { type: 'any', default: null, description: 'Custom persona array (overrides built-in)' },
100
+ },
101
+ },
102
+
103
+ lifecycle: {
104
+ validate: (inputs, params) => {
105
+ const op = params?.operation || 'get';
106
+ if (op === 'get' && !inputs.personaId) return false;
107
+ return true;
108
+ },
109
+
110
+ cacheKey: (inputs, params) => {
111
+ const op = params.operation || 'get';
112
+ if (op === 'get') return `personas:get:${inputs.personaId}`;
113
+ if (op === 'random') return null; // Never cache random
114
+ return `personas:list:${params.filterGender}:${params.filterType}`;
115
+ },
116
+
117
+ execute: async (inputs, params) => {
118
+ const presets = params.customPresets || BUILT_IN_PRESETS;
119
+ const op = params.operation || 'get';
120
+
121
+ if (op === 'get') {
122
+ const persona = presets.find(p => p.id === inputs.personaId);
123
+ if (!persona) {
124
+ return { persona: null, personas: null, error: `Persona not found: ${inputs.personaId}` };
125
+ }
126
+ return { persona, personas: null, error: null };
127
+ }
128
+
129
+ if (op === 'list') {
130
+ let filtered = [...presets];
131
+ if (params.filterGender) {
132
+ filtered = filtered.filter(p => p.gender === params.filterGender);
133
+ }
134
+ if (params.filterType) {
135
+ filtered = filtered.filter(p => p.type === params.filterType);
136
+ }
137
+ return { persona: null, personas: filtered, error: null };
138
+ }
139
+
140
+ if (op === 'random') {
141
+ let pool = [...presets];
142
+ if (params.filterGender) {
143
+ pool = pool.filter(p => p.gender === params.filterGender);
144
+ }
145
+ if (params.filterType) {
146
+ pool = pool.filter(p => p.type === params.filterType);
147
+ }
148
+ // Fisher-Yates shuffle
149
+ for (let i = pool.length - 1; i > 0; i--) {
150
+ const j = Math.floor(Math.random() * (i + 1));
151
+ [pool[i], pool[j]] = [pool[j], pool[i]];
152
+ }
153
+ const selected = pool.slice(0, Math.min(params.count || 2, pool.length));
154
+ return { persona: selected[0] || null, personas: selected, error: null };
155
+ }
156
+
157
+ return { persona: null, personas: null, error: `Unknown operation: ${op}` };
158
+ },
159
+ },
160
+ };