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,432 @@
1
+ /**
2
+ * transform/riopla-adapt — Rioplatense text adaptation for TTS
3
+ *
4
+ * Pure text transforms: Spanish→Cyrillic transliteration for TTS guidance,
5
+ * Rioplatense pronunciation adaptation, number→word conversion,
6
+ * and voice style instruct generation.
7
+ *
8
+ * Ported from Mr-Computer/automations/argentine-spanish-bot/src/utils/transliteration/riopla.js
9
+ * and instruct-generator.js
10
+ *
11
+ * @module agi-graph/packs/transform/riopla-adapt
12
+ */
13
+
14
+ // ─── Transliteration Engine ────────────────────────────────────────────
15
+
16
+ /**
17
+ * Lightweight word segmenter; uses Intl.Segmenter when available
18
+ * @param {string} text
19
+ * @returns {Array<{type: string, value: string}>}
20
+ */
21
+ function segmentWords(text) {
22
+ try {
23
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
24
+ const seg = new Intl.Segmenter('es', { granularity: 'word' });
25
+ return Array.from(seg.segment(text)).map(s => ({
26
+ type: /\p{L}/u.test(s.segment) ? 'word' : 'sep',
27
+ value: s.segment,
28
+ }));
29
+ }
30
+ } catch { /* fallback */ }
31
+ const parts = text.split(/(\p{L}+(?:[\p{Mn}\p{Pd}]?\p{L}+)*)/u);
32
+ return parts.filter(Boolean).map(p => ({ type: /\p{L}/u.test(p) ? 'word' : 'sep', value: p }));
33
+ }
34
+
35
+ /**
36
+ * Apply ordered regex rules to a single token
37
+ * @param {string} word
38
+ * @param {Array<{re: RegExp, to: Function|string}>} rules
39
+ * @returns {string}
40
+ */
41
+ function applyRules(word, rules) {
42
+ let w = word;
43
+ for (const { re, to } of rules) {
44
+ w = w.replace(re, (...args) => typeof to === 'function' ? to(...args) : to);
45
+ }
46
+ return w;
47
+ }
48
+
49
+ /**
50
+ * Find which vowel (by index, 0-based) should be stressed in a Spanish word.
51
+ * @param {string} word
52
+ * @returns {number} Index of stressed vowel, or -1
53
+ */
54
+ function findSpanishStressVowelIndex(word) {
55
+ const vowels = [];
56
+ for (let i = 0; i < word.length; i++) {
57
+ const char = word[i];
58
+ if (char.toLowerCase() === 'u') {
59
+ const prev = word[i - 1]?.toLowerCase();
60
+ const next = word[i + 1]?.toLowerCase();
61
+ if (prev === 'q' && (next === 'e' || next === 'i')) continue;
62
+ if (prev === 'g' && (next === 'e' || next === 'i')) continue;
63
+ }
64
+ if (/[aeiouüáéíóú]/i.test(char)) {
65
+ vowels.push({ index: i, char });
66
+ }
67
+ }
68
+ if (vowels.length <= 1) return -1;
69
+ const accentedIndex = vowels.findIndex(v => /[áéíóú]/i.test(v.char));
70
+ if (accentedIndex >= 0) return accentedIndex;
71
+ const cleanWord = word.replace(/[.,:;!?¡¿]+$/, '');
72
+ const lastChar = cleanWord.slice(-1).toLowerCase();
73
+ if (/[aeiouüns]/.test(lastChar)) return Math.max(0, vowels.length - 2);
74
+ return vowels.length - 1;
75
+ }
76
+
77
+ /**
78
+ * Add stress mark to the Nth vowel in Cyrillic text.
79
+ * @param {string} cyrillic
80
+ * @param {number} vowelIndex
81
+ * @returns {string}
82
+ */
83
+ function addCyrillicStressByVowelIndex(cyrillic, vowelIndex) {
84
+ const normalized = cyrillic.normalize('NFD');
85
+ const vowelPattern = /[аеиоуяёюыэАЕИОУЯЁЮЫЭ]/g;
86
+ const vowels = [];
87
+ let match;
88
+ while ((match = vowelPattern.exec(normalized)) !== null) {
89
+ const nextChar = normalized[match.index + 1];
90
+ const hasStress = nextChar === '\u0301';
91
+ vowels.push({ index: match.index, char: match[0], hasStress });
92
+ }
93
+ if (vowels.length === 0 || vowelIndex >= vowels.length) return cyrillic;
94
+ const targetVowel = vowels[vowelIndex];
95
+ if (targetVowel.hasStress) return cyrillic;
96
+ const result = normalized.substring(0, targetVowel.index + 1) + '\u0301' + normalized.substring(targetVowel.index + 1);
97
+ return result.normalize('NFC');
98
+ }
99
+
100
+ /**
101
+ * Transliterate Spanish (Rioplatense) to Cyrillic for TTS guidance
102
+ * @param {string} input
103
+ * @param {Object} [opts]
104
+ * @returns {string}
105
+ */
106
+ function transliterateSpanishToCyrillic(input, opts = {}) {
107
+ const options = {
108
+ yConj: 'и',
109
+ keepAccents: true,
110
+ autoStress: true,
111
+ normalize: 'NFC',
112
+ ...opts,
113
+ };
114
+ if (!input) return '';
115
+
116
+ const textWithNumbers = convertNumbersToSpanish(String(input));
117
+ const text = options.normalize ? textWithNumbers.normalize(options.normalize) : textWithNumbers;
118
+
119
+ function matchCase(src, dst) {
120
+ if (src.toUpperCase() === src) return dst.toUpperCase();
121
+ if (src[0] && src[0] === src[0].toUpperCase()) return dst[0].toUpperCase() + dst.slice(1);
122
+ return dst;
123
+ }
124
+
125
+ function mapVowel(v) {
126
+ const lower = v.toLowerCase();
127
+ const table = { a: 'а', e: 'е', i: 'и', o: 'о', u: 'у', á: 'а́', é: 'е́', í: 'и́', ó: 'о́', ú: 'у́', ü: 'у' };
128
+ const base = table[lower] || v;
129
+ if (!options.keepAccents) return base.replace('\u0301', '');
130
+ return matchCase(v, base);
131
+ }
132
+
133
+ const CONS_LOWER = { n: 'н', m: 'м', p: 'п', t: 'т', d: 'д', l: 'л', r: 'р', s: 'с', f: 'ф', g: 'г', k: 'к' };
134
+ const LL = 'щ';
135
+
136
+ const rules = [
137
+ { re: /\bel\b/gi, to: m => matchCase(m, 'эль') },
138
+ { re: /\bdel\b/gi, to: m => matchCase(m, 'дель') },
139
+ { re: /\bal\b/gi, to: m => matchCase(m, 'аль') },
140
+ { re: /\byo\b/gi, to: m => matchCase(m, 'що') },
141
+ { re: /(?<![a-záéíóúüñ])e/gi, to: m => matchCase(m, 'э') },
142
+ { re: /(?<![a-záéíóúüñ])é/gi, to: m => matchCase(m, 'э́') },
143
+ { re: /ch/gi, to: m => matchCase(m, 'ч') },
144
+ { re: /rr/gi, to: m => matchCase(m, 'рр') },
145
+ { re: /ll/gi, to: m => matchCase(m, LL) },
146
+ { re: /l(?=[^aeiouáéíóú\s]|$)/gi, to: m => matchCase(m, 'ль') },
147
+ { re: /qu([eiéí])/gi, to: (m, v) => matchCase(m, 'к') + mapVowel(v) },
148
+ { re: /qu([aouáóú])/gi, to: (m, v) => matchCase(m, 'к') + mapVowel(v) },
149
+ { re: /gü([ei])/gi, to: (m, v) => matchCase(m, 'гв') + mapVowel(v) },
150
+ { re: /gu([eiéí])/gi, to: (m, v) => matchCase(m[0], 'г') + mapVowel(v) },
151
+ { re: /g([eiéí])/gi, to: (m, v) => matchCase(m[0], 'х') + mapVowel(v) },
152
+ { re: /c([eiéí])/gi, to: (m, v) => matchCase(m[0], 'с') + mapVowel(v) },
153
+ { re: /c([aouáóú])/gi, to: (m, v) => matchCase(m[0], 'к') + mapVowel(v) },
154
+ { re: /c/gi, to: m => matchCase(m, 'к') },
155
+ { re: /\b[yY]\b/g, to: () => options.yConj },
156
+ { re: /y(?=[aeiouáéíóú])/gi, to: m => matchCase(m, LL) },
157
+ { re: /j/gi, to: m => matchCase(m, 'х') },
158
+ { re: /z/gi, to: m => matchCase(m, 'с') },
159
+ { re: /q/gi, to: m => matchCase(m, 'к') },
160
+ { re: /h/gi, to: () => '' },
161
+ { re: /x/gi, to: m => matchCase(m, 'кс') },
162
+ { re: /ñ/g, to: 'нь' },
163
+ { re: /Ñ/g, to: 'НЬ' },
164
+ { re: /[vb]/g, to: 'б' },
165
+ { re: /[VB]/g, to: 'Б' },
166
+ { re: /ay\b/gi, to: m => matchCase(m, 'ай') },
167
+ { re: /ey\b/gi, to: m => matchCase(m, 'эй') },
168
+ { re: /oy\b/gi, to: m => matchCase(m, 'ой') },
169
+ { re: /uy\b/gi, to: m => matchCase(m, 'уй') },
170
+ { re: /iy\b/gi, to: m => matchCase(m, 'ий') },
171
+ { re: /[aeiouáéíóúüAEIOUÁÉÍÓÚÜ]/g, to: m => mapVowel(m) },
172
+ { re: /[nmp tdlrsfgk]/g, to: m => CONS_LOWER[m] || m },
173
+ { re: /[NMP TDLRSFGK]/g, to: m => (CONS_LOWER[m.toLowerCase()] || m.toLowerCase()).toUpperCase() },
174
+ { re: /l\b/gi, to: m => matchCase(m, 'ль') },
175
+ ];
176
+
177
+ const segments = segmentWords(text);
178
+ const out = segments.map(seg => {
179
+ if (seg.type !== 'word') return seg.value;
180
+ const spanishVowelIndex = options.autoStress ? findSpanishStressVowelIndex(seg.value) : -1;
181
+ const transliterated = applyRules(seg.value, rules);
182
+ if (spanishVowelIndex >= 0) return addCyrillicStressByVowelIndex(transliterated, spanishVowelIndex);
183
+ return transliterated;
184
+ }).join('');
185
+
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * Convert numbers 0-999 to Spanish words
191
+ * @param {string} text
192
+ * @returns {string}
193
+ */
194
+ function convertNumbersToSpanish(text) {
195
+ const ones = ['', 'uno', 'dos', 'tres', 'cuatro', 'cinco', 'seis', 'siete', 'ocho', 'nueve'];
196
+ const teens = ['diez', 'once', 'doce', 'trece', 'catorce', 'quince', 'dieciséis', 'diecisiete', 'dieciocho', 'diecinueve'];
197
+ const tens = ['', '', 'veinte', 'treinta', 'cuarenta', 'cincuenta', 'sesenta', 'setenta', 'ochenta', 'noventa'];
198
+ const hundreds = ['', 'ciento', 'doscientos', 'trescientos', 'cuatrocientos', 'quinientos', 'seiscientos', 'setecientos', 'ochocientos', 'novecientos'];
199
+
200
+ function numberToSpanish(n) {
201
+ if (n === 0) return 'cero';
202
+ if (n < 10) return ones[n];
203
+ if (n < 20) return teens[n - 10];
204
+ if (n === 20) return 'veinte';
205
+ if (n < 30) return 'veinti' + ones[n - 20];
206
+ if (n < 100) {
207
+ const ten = Math.floor(n / 10);
208
+ const one = n % 10;
209
+ return tens[ten] + (one ? ' y ' + ones[one] : '');
210
+ }
211
+ if (n === 100) return 'cien';
212
+ if (n < 1000) {
213
+ const hundred = Math.floor(n / 100);
214
+ const rest = n % 100;
215
+ return hundreds[hundred] + (rest ? ' ' + numberToSpanish(rest) : '');
216
+ }
217
+ return String(n);
218
+ }
219
+
220
+ return text.replace(/\b\d+\b/g, match => {
221
+ const num = parseInt(match, 10);
222
+ if (isNaN(num) || num > 999) return match;
223
+ return numberToSpanish(num);
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Adapt Spanish text for Rioplatense pronunciation
229
+ * @param {string} text
230
+ * @returns {string}
231
+ */
232
+ function adaptSpanishToRioplatense(text) {
233
+ if (!text) return '';
234
+ return text
235
+ .replace(/ll/gi, 'sh')
236
+ .replace(/\by\b/gi, 'i')
237
+ .replace(/y(?=[aeiouáéíóú])/gi, 'sh')
238
+ .replace(/([^cs]|^)h/gi, '$1');
239
+ }
240
+
241
+ // ─── Voice Instruct Templates ──────────────────────────────────────────
242
+
243
+ const INSTRUCT_TEMPLATES = {
244
+ ru: {
245
+ neutral: ['', 'Говори спокойно и уверенно', 'Четко и разборчиво', 'В нейтральном тоне'],
246
+ friendly: ['В дружелюбном тоне', 'Тепло и приветливо', 'С улыбкой в голосе', 'Доброжелательно и открыто'],
247
+ enthusiastic: ['С энтузиазмом', 'Увлеченно и живо', 'С воодушевлением', 'Энергично и позитивно'],
248
+ teaching: ['Как опытный преподаватель', 'Терпеливо и понятно', 'Объясняя как ученику', 'Четко и методично'],
249
+ encouraging: ['Одобрительно и поддерживающе', 'С теплотой и заботой', 'Мотивирующим тоном', 'Вдохновляюще'],
250
+ },
251
+ es: {
252
+ neutral: ['', 'Habla con calma y claridad', 'De manera natural', 'Con tono neutro'],
253
+ friendly: ['Con tono amigable', 'De manera cálida y acogedora', 'Con simpatía', 'Amablemente'],
254
+ enthusiastic: ['Con entusiasmo', 'De manera animada', 'Con energía positiva', 'Alegremente'],
255
+ teaching: ['Como un profesor paciente', 'Explicando claramente', 'De forma didáctica', 'Con paciencia'],
256
+ encouraging: ['De manera alentadora', 'Con apoyo y calidez', 'Motivando al estudiante', 'Con palabras de ánimo'],
257
+ },
258
+ en: {
259
+ neutral: ['', 'Speak calmly and clearly', 'In a natural tone', 'Neutrally'],
260
+ friendly: ['In a friendly tone', 'Warm and welcoming', 'With a smile in your voice', 'Kindly'],
261
+ enthusiastic: ['With enthusiasm', 'Energetically and lively', 'With excitement', 'Positively and upbeat'],
262
+ teaching: ['Like a patient teacher', 'Explaining clearly', 'In a didactic manner', 'With patience'],
263
+ encouraging: ['Encouragingly', 'With warmth and support', 'Motivatingly', 'Inspiringly'],
264
+ },
265
+ };
266
+
267
+ const CONTEXT_PATTERNS = {
268
+ question: /[?¿]/,
269
+ exclamation: /[!¡]/,
270
+ greeting: /^(привет|hola|hello|hey|buenos|добрый|доброе)/i,
271
+ farewell: /\b(пока|adiós|chau|bye|hasta|до свидания)\b/i,
272
+ encouragement: /\b(молодец|отлично|bien|great|excellent|genial|excelente)\b/i,
273
+ correction: /\b(внимание|ошибка|error|cuidado|attention|mistake)\b/i,
274
+ };
275
+
276
+ /**
277
+ * Detect context from text content
278
+ * @param {string} text
279
+ * @returns {string}
280
+ */
281
+ function detectContext(text) {
282
+ if (CONTEXT_PATTERNS.encouragement.test(text)) return 'encouraging';
283
+ if (CONTEXT_PATTERNS.greeting.test(text)) return 'friendly';
284
+ if (CONTEXT_PATTERNS.correction.test(text)) return 'teaching';
285
+ if (CONTEXT_PATTERNS.exclamation.test(text)) return 'enthusiastic';
286
+ if (CONTEXT_PATTERNS.question.test(text)) return 'friendly';
287
+ return 'neutral';
288
+ }
289
+
290
+ /**
291
+ * @param {Array} arr
292
+ * @returns {*}
293
+ */
294
+ function randomChoice(arr) {
295
+ return arr[Math.floor(Math.random() * arr.length)];
296
+ }
297
+
298
+ /**
299
+ * Generate a voice instruct prompt for TTS
300
+ * @param {Object} options
301
+ * @param {string} options.text
302
+ * @param {string} options.lang
303
+ * @param {string} [options.context]
304
+ * @param {boolean} [options.randomize]
305
+ * @returns {string}
306
+ */
307
+ function generateVoiceInstruct({ text, lang, context = null, randomize = true }) {
308
+ const langTemplates = INSTRUCT_TEMPLATES[lang] || INSTRUCT_TEMPLATES.ru;
309
+ const effectiveContext = context || detectContext(text);
310
+ const templates = langTemplates[effectiveContext] || langTemplates.neutral;
311
+ return randomize ? randomChoice(templates) : templates[0];
312
+ }
313
+
314
+ /**
315
+ * Generate varied instructs for a batch of segments
316
+ * @param {Array<{text: string, lang: string}>} segments
317
+ * @returns {Array<string>}
318
+ */
319
+ function generateBatchInstructs(segments) {
320
+ const recentlyUsed = new Set();
321
+ const results = [];
322
+ for (const seg of segments) {
323
+ let instruct = '';
324
+ let attempts = 0;
325
+ while (attempts < 3) {
326
+ instruct = generateVoiceInstruct({ text: seg.text, lang: seg.lang });
327
+ if (!recentlyUsed.has(instruct) || instruct === '') break;
328
+ attempts++;
329
+ }
330
+ results.push(instruct);
331
+ if (instruct) {
332
+ recentlyUsed.add(instruct);
333
+ if (recentlyUsed.size > 3) {
334
+ const first = recentlyUsed.values().next().value;
335
+ recentlyUsed.delete(first);
336
+ }
337
+ }
338
+ }
339
+ return results;
340
+ }
341
+
342
+ // ─── Handler Definition ────────────────────────────────────────────────
343
+
344
+ export default {
345
+ type: 'transform/riopla-adapt',
346
+ category: 'transform',
347
+ icon: 'translate',
348
+
349
+ driver: {
350
+ description: 'Rioplatense text adaptation: transliteration, pronunciation, number conversion, voice instructs',
351
+ inputs: [
352
+ { name: 'text', type: 'string' },
353
+ ],
354
+ outputs: [
355
+ { name: 'result', type: 'any' },
356
+ { name: 'error', type: 'string' },
357
+ ],
358
+ params: {
359
+ operation: { type: 'string', default: 'transliterate', description: 'Operation: transliterate | adapt-rioplatense | numbers-to-spanish | voice-instruct | batch-instructs' },
360
+ // transliterate options
361
+ keepAccents: { type: 'boolean', default: true, description: 'Preserve acute accents in Cyrillic output' },
362
+ autoStress: { type: 'boolean', default: true, description: 'Auto-add stress marks based on Spanish rules' },
363
+ // voice-instruct options
364
+ lang: { type: 'string', default: 'es', description: 'Language code for voice instruct (ru/es/en)' },
365
+ context: { type: 'string', default: null, description: 'Voice instruct context hint (neutral/friendly/enthusiastic/teaching/encouraging)' },
366
+ // batch-instructs
367
+ segments: { type: 'any', default: null, description: 'Array of {text, lang} for batch instruct generation' },
368
+ },
369
+ },
370
+
371
+ lifecycle: {
372
+ validate: (inputs, params) => {
373
+ const op = params.operation;
374
+ if (op === 'batch-instructs') {
375
+ return Array.isArray(params.segments) && params.segments.length > 0;
376
+ }
377
+ return typeof inputs.text === 'string' && inputs.text.length > 0;
378
+ },
379
+
380
+ cacheKey: (inputs, params) => {
381
+ if (params.operation === 'batch-instructs') return null; // random, no cache
382
+ if (params.operation === 'voice-instruct') return null; // random, no cache
383
+ return `riopla:${params.operation}:${inputs.text?.slice(0, 100)}`;
384
+ },
385
+
386
+ execute: async (inputs, params) => {
387
+ const { text } = inputs;
388
+ const { operation } = params;
389
+
390
+ try {
391
+ switch (operation) {
392
+ case 'transliterate': {
393
+ const result = transliterateSpanishToCyrillic(text, {
394
+ keepAccents: params.keepAccents,
395
+ autoStress: params.autoStress,
396
+ });
397
+ return { result: { original: text, cyrillic: result } };
398
+ }
399
+
400
+ case 'adapt-rioplatense': {
401
+ const result = adaptSpanishToRioplatense(text);
402
+ return { result: { original: text, adapted: result } };
403
+ }
404
+
405
+ case 'numbers-to-spanish': {
406
+ const result = convertNumbersToSpanish(text);
407
+ return { result: { original: text, converted: result } };
408
+ }
409
+
410
+ case 'voice-instruct': {
411
+ const instruct = generateVoiceInstruct({
412
+ text,
413
+ lang: params.lang,
414
+ context: params.context,
415
+ });
416
+ return { result: { text, instruct, lang: params.lang } };
417
+ }
418
+
419
+ case 'batch-instructs': {
420
+ const instructs = generateBatchInstructs(params.segments);
421
+ return { result: { segments: params.segments, instructs } };
422
+ }
423
+
424
+ default:
425
+ return { error: `Unknown operation: ${operation}` };
426
+ }
427
+ } catch (err) {
428
+ return { error: `riopla-adapt ${operation} failed: ${err.message}` };
429
+ }
430
+ },
431
+ },
432
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * transform/set — Field mapping and value assignment
3
+ *
4
+ * Sets fields on the output data object. Values can be:
5
+ * - Static values: "hello"
6
+ * - References to input fields: "={{fieldName}}"
7
+ * - Simple expressions: "={{items.length}}"
8
+ *
9
+ * Like n8n's "Set" node — reshapes data between nodes.
10
+ *
11
+ * @module symbiote-node/packs/transform/set
12
+ */
13
+
14
+ export default {
15
+ type: 'transform/set',
16
+ category: 'transform',
17
+ icon: 'edit_note',
18
+
19
+ driver: {
20
+ description: 'Set or map fields on the data object',
21
+ inputs: [
22
+ { name: 'data', type: 'any' },
23
+ ],
24
+ outputs: [
25
+ { name: 'data', type: 'any' },
26
+ ],
27
+ params: {
28
+ fields: { type: 'object', default: {}, description: 'Map of fieldName → value or ={{expression}}' },
29
+ keepOriginal: { type: 'boolean', default: true, description: 'Keep original input fields' },
30
+ },
31
+ },
32
+
33
+ lifecycle: {
34
+ execute: async (inputs, params) => {
35
+ const inputData = inputs.data || {};
36
+ const base = params.keepOriginal ? { ...inputData } : {};
37
+
38
+ for (const [key, rawValue] of Object.entries(params.fields || {})) {
39
+ if (typeof rawValue === 'string' && rawValue.startsWith('={{') && rawValue.endsWith('}}')) {
40
+ // Expression: resolve from input data
41
+ const expr = rawValue.slice(3, -2).trim();
42
+ // Simple dot-path resolution
43
+ const value = expr.split('.').reduce((obj, k) => {
44
+ if (obj === null || obj === undefined) return undefined;
45
+ return obj[k];
46
+ }, inputData);
47
+ base[key] = value;
48
+ } else {
49
+ // Static value
50
+ base[key] = rawValue;
51
+ }
52
+ }
53
+
54
+ return { data: base };
55
+ },
56
+ },
57
+ };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * transform/template-builder — Visual template builder with dynamic field discovery
3
+ *
4
+ * Combines UE Blueprint "Format Text" pattern (auto-pin from {placeholders})
5
+ * with n8n live preview and ComfyUI inline editing.
6
+ *
7
+ * Parses {placeholder} syntax in template string, resolves values from
8
+ * upstream data, and produces interpolated text output.
9
+ *
10
+ * @module symbiote-node/packs/transform/template-builder */
11
+
12
+ /**
13
+ * Extract placeholder names from template string.
14
+ * Supports both {var} and {{var}} syntax.
15
+ *
16
+ * @param {string} template
17
+ * @returns {string[]} Unique placeholder names
18
+ */
19
+ function extractPlaceholders(template) {
20
+ if (!template) return [];
21
+ const matches = new Set();
22
+ // Match both {var} and {{var}} — normalize to single-brace names
23
+ const regex = /\{\{?([^{}]+)\}?\}/g;
24
+ let m;
25
+ while ((m = regex.exec(template)) !== null) {
26
+ matches.add(m[1].trim());
27
+ }
28
+ return [...matches];
29
+ }
30
+
31
+ /**
32
+ * Resolve a dot-notation path in an object.
33
+ *
34
+ * @param {Object} obj - Data object
35
+ * @param {string} path - Dot-separated path (e.g., 'user.name')
36
+ * @returns {*} Resolved value or undefined
37
+ */
38
+ function resolvePath(obj, path) {
39
+ return path.split('.').reduce((o, k) => {
40
+ if (o === null || o === undefined) return undefined;
41
+ return o[k];
42
+ }, obj);
43
+ }
44
+
45
+ export default {
46
+ type: 'transform/template-builder',
47
+ category: 'transform',
48
+ icon: 'edit_note',
49
+
50
+ driver: {
51
+ description: 'Visual template builder — write text with {placeholders}, auto-discovers input fields',
52
+ inputs: [
53
+ { name: 'data', type: 'any', description: 'Input data object with fields to interpolate' },
54
+ ],
55
+ outputs: [
56
+ { name: 'text', type: 'string', description: 'Interpolated text result' },
57
+ { name: 'data', type: 'any', description: 'Full data context with text field added' },
58
+ ],
59
+ params: {
60
+ template: {
61
+ type: 'textarea',
62
+ default: '',
63
+ description: 'Template with {placeholder} syntax. Fields auto-create input pins.',
64
+ },
65
+ outputField: {
66
+ type: 'string',
67
+ default: 'text',
68
+ description: 'Field name for the interpolated text in output data',
69
+ },
70
+ },
71
+
72
+ /**
73
+ * Dynamic outputs metadata — called by Inspector to show available placeholders.
74
+ *
75
+ * @param {Object} params - Current node params
76
+ * @returns {{ placeholders: string[] }}
77
+ */
78
+ meta: (params) => ({
79
+ placeholders: extractPlaceholders(params?.template),
80
+ }),
81
+ },
82
+
83
+ lifecycle: {
84
+ cacheKey: (inputs, params) =>
85
+ `tpl-builder:${params?.template}:${JSON.stringify(inputs.data)}`,
86
+
87
+ /**
88
+ * Execute template interpolation.
89
+ *
90
+ * @param {{ data: Object }} inputs - Upstream data
91
+ * @param {{ template: string, outputField: string }} params - Node params
92
+ * @returns {{ text: string, data: Object }}
93
+ */
94
+ execute: async (inputs, params) => {
95
+ const template = params?.template;
96
+ if (!template) {
97
+ console.warn('[template-builder] Empty template');
98
+ return { text: '', data: inputs.data ?? {} };
99
+ }
100
+
101
+ const data = inputs.data ?? {};
102
+ const placeholders = extractPlaceholders(template);
103
+
104
+ // Interpolate — replace both {var} and {{var}} with resolved values
105
+ const text = template.replace(/\{\{?([^{}]+)\}?\}/g, (match, key) => {
106
+ const trimmed = key.trim();
107
+ const value = resolvePath(data, trimmed);
108
+
109
+ if (value === undefined) {
110
+ console.warn(`[template-builder] ⚠️ Missing field "${trimmed}" — available: [${Object.keys(data).join(', ')}]`);
111
+ return match;
112
+ }
113
+ if (typeof value === 'object') return JSON.stringify(value);
114
+ return String(value);
115
+ });
116
+
117
+ // Log discovered vs resolved
118
+ const resolved = placeholders.filter(p => resolvePath(data, p) !== undefined);
119
+ const missing = placeholders.filter(p => resolvePath(data, p) === undefined);
120
+ if (missing.length) {
121
+ console.warn(`[template-builder] ${resolved.length}/${placeholders.length} fields resolved, missing: [${missing.join(', ')}]`);
122
+ }
123
+
124
+ const outputField = params?.outputField ?? 'text';
125
+ return {
126
+ text,
127
+ data: { ...(typeof data === 'object' ? data : {}), [outputField]: text },
128
+ };
129
+ },
130
+ },
131
+ };
132
+
133
+ // Re-export utility for Inspector use
134
+ export { extractPlaceholders };