entroly-wasm 0.9.0

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.
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Federation — Privacy-Preserving Weight Sharing (JavaScript)
3
+ * ============================================================
4
+ *
5
+ * Enables entroly-wasm users to share learned archetype weights
6
+ * across installations via GitHub Issues API. Zero infrastructure cost.
7
+ *
8
+ * What gets shared:
9
+ * - Archetype label (e.g. "js_monorepo", "react_frontend")
10
+ * - 4 weight floats (recency, frequency, semantic, entropy)
11
+ * - Sample count + confidence
12
+ * - All noised via Gaussian DP (ε=1.0)
13
+ *
14
+ * What is NEVER shared:
15
+ * - Source code, file paths, or any identifiable data
16
+ * - Client identity (uses random UUID, not username-derived)
17
+ *
18
+ * @module federation
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const https = require('https');
26
+ const crypto = require('crypto');
27
+
28
+ // ── Constants ──
29
+ const FEDERATION_REPO = 'juyterman1000/entroly';
30
+ const FEDERATION_ISSUE_TITLE = '[federation] Weight Exchange';
31
+ const GITHUB_API = 'api.github.com';
32
+
33
+ const DEFAULT_EPSILON = 1.0; // Privacy budget per contribution
34
+ const WEIGHT_CLIP = [0.0, 1.0]; // Clip before noising
35
+ const MIN_CONFIDENCE = 0.7; // Don't share until locally confident
36
+ const MIN_SAMPLE_COUNT = 5; // Need ≥5 episodes
37
+ const TRIM_FRACTION = 0.10; // Trim top/bottom 10% in aggregation
38
+
39
+ // Weight keys that participate
40
+ const WEIGHT_KEYS = ['w_r', 'w_f', 'w_s', 'w_e'];
41
+
42
+
43
+ // ── Anonymous Client ID ──
44
+
45
+ /**
46
+ * Generate or load a truly anonymous client identifier.
47
+ * Uses random UUID persisted to disk — no PII derivation.
48
+ */
49
+ function getClientId(dataDir) {
50
+ const idPath = path.join(dataDir, '.federation_id');
51
+ try {
52
+ if (fs.existsSync(idPath)) {
53
+ const stored = fs.readFileSync(idPath, 'utf-8').trim();
54
+ if (stored.length >= 32) return stored;
55
+ }
56
+ } catch {}
57
+
58
+ // Generate fresh random ID
59
+ const freshId = crypto.createHash('sha256')
60
+ .update(crypto.randomUUID())
61
+ .digest('hex');
62
+ try {
63
+ fs.mkdirSync(path.dirname(idPath), { recursive: true });
64
+ fs.writeFileSync(idPath, freshId);
65
+ } catch {}
66
+ return freshId;
67
+ }
68
+
69
+
70
+ // ── Differential Privacy ──
71
+
72
+ /**
73
+ * Calibrate Gaussian noise sigma for (ε, δ=1e-5)-DP.
74
+ * From Balle et al. (ICML 2018): σ = Δ · √(2ln(1.25/δ)) / ε
75
+ */
76
+ function calibrateSigma(epsilon = DEFAULT_EPSILON, sensitivity = 1.0) {
77
+ const delta = 1e-5;
78
+ return sensitivity * Math.sqrt(2 * Math.log(1.25 / delta)) / epsilon;
79
+ }
80
+
81
+ /**
82
+ * Add Gaussian noise to a weight value.
83
+ */
84
+ function addNoise(value, sigma) {
85
+ // Box-Muller transform for Gaussian
86
+ const u1 = Math.random() || 1e-10;
87
+ const u2 = Math.random();
88
+ const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
89
+ return Math.max(WEIGHT_CLIP[0], Math.min(WEIGHT_CLIP[1], value + sigma * z));
90
+ }
91
+
92
+
93
+ // ── Contribution Packet ──
94
+
95
+ /**
96
+ * Create a DP-noised contribution packet from current weights.
97
+ */
98
+ function createContribution(clientId, archetype, weights, sampleCount, confidence) {
99
+ if (confidence < MIN_CONFIDENCE || sampleCount < MIN_SAMPLE_COUNT) {
100
+ return null; // Not confident enough to share
101
+ }
102
+
103
+ const sigma = calibrateSigma();
104
+ const noisedWeights = {};
105
+ for (const k of WEIGHT_KEYS) {
106
+ noisedWeights[k] = addNoise(weights[k] ?? 0.25, sigma);
107
+ }
108
+
109
+ return {
110
+ version: '1.0',
111
+ client_id: clientId,
112
+ archetype: archetype || 'unknown',
113
+ weights: noisedWeights,
114
+ sample_count: sampleCount,
115
+ confidence: Math.round(confidence * 1000) / 1000,
116
+ noise_sigma: Math.round(sigma * 10000) / 10000,
117
+ dp_epsilon: DEFAULT_EPSILON,
118
+ timestamp: Date.now() / 1000,
119
+ };
120
+ }
121
+
122
+
123
+ // ── Trimmed Mean Aggregation ──
124
+
125
+ /**
126
+ * Byzantine-resilient aggregation: trim top/bottom 10%, average rest.
127
+ */
128
+ function trimmedMean(values, trimFrac = TRIM_FRACTION) {
129
+ if (values.length < 3) {
130
+ return values.reduce((a, b) => a + b, 0) / values.length;
131
+ }
132
+ const sorted = [...values].sort((a, b) => a - b);
133
+ const trimCount = Math.floor(sorted.length * trimFrac);
134
+ const trimmed = sorted.slice(trimCount, sorted.length - trimCount);
135
+ if (trimmed.length === 0) return sorted[Math.floor(sorted.length / 2)];
136
+ return trimmed.reduce((a, b) => a + b, 0) / trimmed.length;
137
+ }
138
+
139
+
140
+ // ── GitHub Transport ──
141
+
142
+ class GitHubTransport {
143
+ /**
144
+ * @param {Object} options
145
+ * @param {string} [options.repo] - GitHub repo (owner/name)
146
+ * @param {string} [options.token] - PAT (prefer ENTROLY_FEDERATION_BOT)
147
+ */
148
+ constructor(options = {}) {
149
+ this._repo = options.repo || FEDERATION_REPO;
150
+ this._token = options.token
151
+ || process.env.ENTROLY_FEDERATION_BOT
152
+ || process.env.ENTROLY_GITHUB_TOKEN
153
+ || null;
154
+ this._issueNumber = null;
155
+ }
156
+
157
+ get canWrite() { return !!this._token; }
158
+ get canRead() { return true; } // Public repos are readable without auth
159
+
160
+ /**
161
+ * Make an HTTPS request to GitHub API.
162
+ * @private
163
+ */
164
+ _request(method, urlPath, body = null) {
165
+ return new Promise((resolve, reject) => {
166
+ const headers = {
167
+ 'Accept': 'application/vnd.github.v3+json',
168
+ 'User-Agent': 'entroly-wasm-federation/1.0',
169
+ };
170
+ if (this._token) headers['Authorization'] = `token ${this._token}`;
171
+ if (body) headers['Content-Type'] = 'application/json';
172
+
173
+ const options = {
174
+ hostname: GITHUB_API,
175
+ path: urlPath,
176
+ method,
177
+ headers,
178
+ timeout: 15000,
179
+ };
180
+
181
+ const req = https.request(options, (res) => {
182
+ let data = '';
183
+ res.on('data', chunk => data += chunk);
184
+ res.on('end', () => {
185
+ try {
186
+ resolve({ status: res.statusCode, data: JSON.parse(data) });
187
+ } catch {
188
+ resolve({ status: res.statusCode, data: null });
189
+ }
190
+ });
191
+ });
192
+
193
+ req.on('error', reject);
194
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
195
+
196
+ if (body) req.write(JSON.stringify(body));
197
+ req.end();
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Find or create the federation exchange issue.
203
+ * @returns {Promise<number|null>} Issue number
204
+ */
205
+ async findOrCreateIssue() {
206
+ if (this._issueNumber) return this._issueNumber;
207
+
208
+ try {
209
+ // Search for existing
210
+ const searchPath = `/repos/${this._repo}/issues?labels=federation&state=open&per_page=1`;
211
+ const search = await this._request('GET', searchPath);
212
+ if (search.data && search.data.length > 0) {
213
+ this._issueNumber = search.data[0].number;
214
+ return this._issueNumber;
215
+ }
216
+
217
+ // Create if we have write access
218
+ if (!this.canWrite) return null;
219
+
220
+ const createPath = `/repos/${this._repo}/issues`;
221
+ const result = await this._request('POST', createPath, {
222
+ title: FEDERATION_ISSUE_TITLE,
223
+ body: '# 🌐 Entroly Federated Weight Exchange\n\n' +
224
+ 'Transport layer for federated archetype learning.\n\n' +
225
+ '**Each comment contains a DP-noised contribution packet.**\n' +
226
+ '- All weights protected by Gaussian DP (ε=1.0)\n' +
227
+ '- No source code, paths, or PII shared\n' +
228
+ '- Auto-aggregated via trimmed-mean\n\n' +
229
+ 'Do not edit or delete comments — they are machine-managed.',
230
+ labels: ['federation'],
231
+ });
232
+
233
+ if (result.data && result.data.number) {
234
+ this._issueNumber = result.data.number;
235
+ return this._issueNumber;
236
+ }
237
+ } catch (e) {
238
+ // Non-fatal
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * Push a contribution packet as an Issue comment.
245
+ * @param {Object} packet - Contribution packet
246
+ * @returns {Promise<boolean>}
247
+ */
248
+ async push(packet) {
249
+ if (!this.canWrite) return false;
250
+
251
+ const issue = await this.findOrCreateIssue();
252
+ if (!issue) return false;
253
+
254
+ try {
255
+ const body = '```json\n' + JSON.stringify(packet, null, 2) + '\n```';
256
+ const commentPath = `/repos/${this._repo}/issues/${issue}/comments`;
257
+ const result = await this._request('POST', commentPath, { body });
258
+ return result.status === 200 || result.status === 201;
259
+ } catch {
260
+ return false;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Pull contribution packets from Issue comments.
266
+ * @param {number} [sinceHours=48] - Look back this many hours
267
+ * @returns {Promise<Object[]>} Array of contribution packets
268
+ */
269
+ async pull(sinceHours = 48) {
270
+ const issue = await this.findOrCreateIssue();
271
+ if (!issue) return [];
272
+
273
+ try {
274
+ const cutoff = new Date(Date.now() - sinceHours * 3600000);
275
+ const since = cutoff.toISOString();
276
+ const commentPath = `/repos/${this._repo}/issues/${issue}/comments?since=${since}&per_page=100`;
277
+ const result = await this._request('GET', commentPath);
278
+
279
+ if (!result.data || !Array.isArray(result.data)) return [];
280
+
281
+ const packets = [];
282
+ for (const comment of result.data) {
283
+ const body = comment.body || '';
284
+ if (!body.includes('```json')) continue;
285
+ try {
286
+ const start = body.indexOf('```json') + 7;
287
+ const end = body.indexOf('```', start);
288
+ const raw = body.substring(start, end).trim();
289
+ const packet = JSON.parse(raw);
290
+ if (packet.version && packet.weights) {
291
+ packets.push(packet);
292
+ }
293
+ } catch {}
294
+ }
295
+ return packets;
296
+ } catch {
297
+ return [];
298
+ }
299
+ }
300
+ }
301
+
302
+
303
+ // ── Federation Client ──
304
+
305
+ class FederationClient {
306
+ /**
307
+ * @param {Object} options
308
+ * @param {string} options.dataDir - Directory for federation state
309
+ */
310
+ constructor(options = {}) {
311
+ this._dataDir = options.dataDir || path.join(process.cwd(), '.entroly');
312
+ this._enabled = process.env.ENTROLY_FEDERATION === '1';
313
+ this._clientId = getClientId(this._dataDir);
314
+ this._contribDir = path.join(this._dataDir, 'federation_contributions');
315
+ this._globalPath = path.join(this._dataDir, 'federation_global.json');
316
+
317
+ try { fs.mkdirSync(this._contribDir, { recursive: true }); } catch {}
318
+ }
319
+
320
+ get enabled() { return this._enabled; }
321
+ get clientId() { return this._clientId; }
322
+
323
+ /**
324
+ * Create and save a local contribution from current weights.
325
+ */
326
+ contribute(archetype, weights, sampleCount, confidence) {
327
+ const packet = createContribution(
328
+ this._clientId, archetype, weights, sampleCount, confidence,
329
+ );
330
+ if (!packet) return null;
331
+
332
+ // Save locally
333
+ const filename = `contrib_${Date.now()}_${this._clientId.slice(0, 8)}.json`;
334
+ const filepath = path.join(this._contribDir, filename);
335
+ try {
336
+ fs.writeFileSync(filepath, JSON.stringify(packet));
337
+ } catch {}
338
+
339
+ return packet;
340
+ }
341
+
342
+ /**
343
+ * Save a remote contribution locally for merge.
344
+ */
345
+ saveContribution(packet) {
346
+ if (packet.client_id === this._clientId) return false; // Anti-echo
347
+ const filename = `remote_${Date.now()}_${(packet.client_id || '').slice(0, 8)}.json`;
348
+ const filepath = path.join(this._contribDir, filename);
349
+ try {
350
+ fs.writeFileSync(filepath, JSON.stringify(packet));
351
+ return true;
352
+ } catch {
353
+ return false;
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Load all local contribution files.
359
+ */
360
+ loadContributions() {
361
+ try {
362
+ const files = fs.readdirSync(this._contribDir).filter(f => f.endsWith('.json'));
363
+ return files.map(f => {
364
+ try {
365
+ return JSON.parse(fs.readFileSync(path.join(this._contribDir, f), 'utf-8'));
366
+ } catch { return null; }
367
+ }).filter(Boolean);
368
+ } catch {
369
+ return [];
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Merge contributions via trimmed-mean aggregation.
375
+ * @returns {{ weights: Object, contributors: number } | null}
376
+ */
377
+ mergeGlobal() {
378
+ const contributions = this.loadContributions();
379
+ if (contributions.length < 3) return null;
380
+
381
+ // Group by archetype
382
+ const byArchetype = {};
383
+ for (const c of contributions) {
384
+ const arch = c.archetype || 'unknown';
385
+ if (!byArchetype[arch]) byArchetype[arch] = [];
386
+ byArchetype[arch].push(c);
387
+ }
388
+
389
+ // Aggregate each archetype
390
+ const result = {};
391
+ for (const [arch, packets] of Object.entries(byArchetype)) {
392
+ if (packets.length < 3) continue;
393
+
394
+ const merged = {};
395
+ for (const k of WEIGHT_KEYS) {
396
+ const values = packets.map(p => p.weights[k]).filter(v => typeof v === 'number');
397
+ if (values.length >= 3) {
398
+ merged[k] = trimmedMean(values);
399
+ }
400
+ }
401
+
402
+ if (Object.keys(merged).length === WEIGHT_KEYS.length) {
403
+ result[arch] = {
404
+ weights: merged,
405
+ contributors: packets.length,
406
+ confidence: packets.reduce((s, p) => s + (p.confidence || 0), 0) / packets.length,
407
+ updated_at: Date.now() / 1000,
408
+ };
409
+ }
410
+ }
411
+
412
+ if (Object.keys(result).length > 0) {
413
+ try {
414
+ fs.writeFileSync(this._globalPath, JSON.stringify(result, null, 2));
415
+ } catch {}
416
+ }
417
+
418
+ return result;
419
+ }
420
+
421
+ /**
422
+ * Get the global consensus weights for an archetype.
423
+ */
424
+ getGlobalWeights(archetype) {
425
+ try {
426
+ const data = JSON.parse(fs.readFileSync(this._globalPath, 'utf-8'));
427
+ return data[archetype] || null;
428
+ } catch {
429
+ return null;
430
+ }
431
+ }
432
+ }
433
+
434
+
435
+ module.exports = {
436
+ GitHubTransport,
437
+ FederationClient,
438
+ createContribution,
439
+ trimmedMean,
440
+ getClientId,
441
+ calibrateSigma,
442
+ addNoise,
443
+ WEIGHT_KEYS,
444
+ };
package/js/gateways.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Chat gateways — Telegram, Discord, Slack.
3
+ *
4
+ * Zero hard dependencies. Uses native fetch (Node ≥18).
5
+ * Attach a source with `.stats()` method (e.g. an EvolutionDaemon or
6
+ * any object exposing skills_promoted / skills_pruned / structural_successes /
7
+ * dream_cycles counters) and the gateway will surface deltas.
8
+ *
9
+ * Env:
10
+ * ENTROLY_TG_TOKEN + ENTROLY_TG_CHAT_ID (Telegram, 2-way)
11
+ * ENTROLY_DISCORD_WEBHOOK (Discord, webhook)
12
+ * ENTROLY_SLACK_WEBHOOK (Slack, webhook)
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ class _BaseGateway {
18
+ constructor(pollIntervalS = 30) {
19
+ this._pollS = pollIntervalS;
20
+ this._source = null;
21
+ this._timer = null;
22
+ this._last = {};
23
+ }
24
+
25
+ attach(source) { this._source = source; return this; }
26
+
27
+ start() {
28
+ if (this._timer) return;
29
+ this.send(this._onlineMsg()).catch(() => {});
30
+ this._timer = setInterval(() => this._tick().catch(() => {}), this._pollS * 1000);
31
+ this._timer.unref && this._timer.unref();
32
+ }
33
+
34
+ stop() {
35
+ if (this._timer) { clearInterval(this._timer); this._timer = null; }
36
+ }
37
+
38
+ async _tick() {
39
+ if (!this._source) return;
40
+ const now = this._source.stats();
41
+ await this._surfaceDelta(this._last, now);
42
+ this._last = JSON.parse(JSON.stringify(now));
43
+ }
44
+
45
+ async _surfaceDelta(prev, now) {
46
+ const diff = k => (Number(now[k]) || 0) - (Number(prev[k]) || 0);
47
+ if (diff('skills_promoted') > 0) await this.send(this._fmt('promoted', diff('skills_promoted'), now.skills_promoted));
48
+ if (diff('skills_pruned') > 0) await this.send(this._fmt('pruned', diff('skills_pruned')));
49
+ if (diff('structural_successes') > 0) await this.send(this._fmt('structural', diff('structural_successes')));
50
+ if (diff('dream_cycles') > 0) await this.send(this._fmt('dream', diff('dream_cycles')));
51
+ }
52
+
53
+ // overridden by subclasses
54
+ _onlineMsg() { return 'Entroly gateway online.'; }
55
+ _fmt() { return ''; }
56
+ async send() { throw new Error('send() not implemented'); }
57
+ }
58
+
59
+ // ── Telegram ──────────────────────────────────────────────────
60
+
61
+ class TelegramGateway extends _BaseGateway {
62
+ constructor({ token, chatId, pollIntervalS = 30 } = {}) {
63
+ super(pollIntervalS);
64
+ this._token = token;
65
+ this._chatId = String(chatId);
66
+ }
67
+
68
+ _onlineMsg() { return '🧬 *Entroly gateway online* — watching the self-evolution loop.'; }
69
+
70
+ _fmt(kind, delta, total) {
71
+ if (kind === 'promoted') return `✅ *Skill promoted* — +${delta}. Total: ${total}.`;
72
+ if (kind === 'pruned') return `🗑️ *Skill pruned* — +${delta}.`;
73
+ if (kind === 'structural') return `🧠 *Structural synthesis* — +${delta} ($0, deterministic).`;
74
+ if (kind === 'dream') return `💭 *Dream cycle complete* — +${delta}.`;
75
+ return '';
76
+ }
77
+
78
+ async send(text) {
79
+ const url = `https://api.telegram.org/bot${this._token}/sendMessage`;
80
+ const body = new URLSearchParams({
81
+ chat_id: this._chatId, text, parse_mode: 'Markdown',
82
+ disable_web_page_preview: 'true',
83
+ });
84
+ const r = await fetch(url, { method: 'POST', body });
85
+ return { ok: r.ok, status: r.status };
86
+ }
87
+ }
88
+
89
+ // ── Discord ───────────────────────────────────────────────────
90
+
91
+ class DiscordGateway extends _BaseGateway {
92
+ constructor({ webhookUrl, pollIntervalS = 30, username = 'Entroly' } = {}) {
93
+ super(pollIntervalS);
94
+ this._url = webhookUrl;
95
+ this._username = username;
96
+ }
97
+
98
+ _onlineMsg() { return '🧬 **Entroly gateway online** — watching the self-evolution loop.'; }
99
+
100
+ _fmt(kind, delta, total) {
101
+ if (kind === 'promoted') return `✅ **Skill promoted** — +${delta}. Total: ${total}.`;
102
+ if (kind === 'pruned') return `🗑️ **Skill pruned** — +${delta}.`;
103
+ if (kind === 'structural') return `🧠 **Structural synthesis** — +${delta} ($0, deterministic).`;
104
+ if (kind === 'dream') return `💭 **Dream cycle complete** — +${delta}.`;
105
+ return '';
106
+ }
107
+
108
+ async send(content) {
109
+ const r = await fetch(this._url, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ username: this._username, content }),
113
+ });
114
+ return { ok: r.ok, status: r.status };
115
+ }
116
+ }
117
+
118
+ // ── Slack ─────────────────────────────────────────────────────
119
+
120
+ class SlackGateway extends _BaseGateway {
121
+ constructor({ webhookUrl, pollIntervalS = 30 } = {}) {
122
+ super(pollIntervalS);
123
+ this._url = webhookUrl;
124
+ }
125
+
126
+ _onlineMsg() { return ':dna: *Entroly gateway online* — watching the self-evolution loop.'; }
127
+
128
+ _fmt(kind, delta, total) {
129
+ if (kind === 'promoted') return `:white_check_mark: *Skill promoted* — +${delta}. Total: ${total}.`;
130
+ if (kind === 'pruned') return `:wastebasket: *Skill pruned* — +${delta}.`;
131
+ if (kind === 'structural') return `:brain: *Structural synthesis* — +${delta} ($0, deterministic).`;
132
+ if (kind === 'dream') return `:thought_balloon: *Dream cycle complete* — +${delta}.`;
133
+ return '';
134
+ }
135
+
136
+ async send(text) {
137
+ const r = await fetch(this._url, {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({ text }),
141
+ });
142
+ return { ok: r.ok, status: r.status };
143
+ }
144
+ }
145
+
146
+ module.exports = { TelegramGateway, DiscordGateway, SlackGateway };
147
+
148
+ // Standalone entrypoint: picks a gateway based on env vars.
149
+ if (require.main === module) {
150
+ (async () => {
151
+ const tg = process.env.ENTROLY_TG_TOKEN && process.env.ENTROLY_TG_CHAT_ID
152
+ ? new TelegramGateway({ token: process.env.ENTROLY_TG_TOKEN, chatId: process.env.ENTROLY_TG_CHAT_ID })
153
+ : null;
154
+ const dc = process.env.ENTROLY_DISCORD_WEBHOOK
155
+ ? new DiscordGateway({ webhookUrl: process.env.ENTROLY_DISCORD_WEBHOOK })
156
+ : null;
157
+ const sl = process.env.ENTROLY_SLACK_WEBHOOK
158
+ ? new SlackGateway({ webhookUrl: process.env.ENTROLY_SLACK_WEBHOOK })
159
+ : null;
160
+
161
+ const gws = [tg, dc, sl].filter(Boolean);
162
+ if (!gws.length) {
163
+ console.error('Set ENTROLY_TG_TOKEN+ENTROLY_TG_CHAT_ID, ENTROLY_DISCORD_WEBHOOK, or ENTROLY_SLACK_WEBHOOK.');
164
+ process.exit(2);
165
+ }
166
+ for (const gw of gws) gw.start();
167
+ console.log(`Started ${gws.length} gateway(s). Ctrl+C to exit.`);
168
+ process.stdin.resume();
169
+ })().catch(err => { console.error(err); process.exit(1); });
170
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Multimodal Ingestion — pure-JS port of entroly/multimodal.py
3
+ * Diff parser + intent classifier. Stubs for diagram/voice (require native deps).
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const { parseDiff, classifyDiffIntent } = require('./cogops');
9
+
10
+ function ingestDiff(diffText, source, commitMessage = '') {
11
+ const parsed = parseDiff(diffText);
12
+ const intent = classifyDiffIntent(diffText, commitMessage);
13
+
14
+ // Extract symbols changed (simple heuristic: lines starting with def/fn/class/function)
15
+ const symbolsChanged = [];
16
+ const symRe = /^[+-]\s*(?:def|fn|pub fn|class|function|const|let|var|struct|enum|trait|impl|export)\s+(\w+)/gm;
17
+ let m;
18
+ while ((m = symRe.exec(diffText)) !== null) {
19
+ if (!symbolsChanged.includes(m[1])) symbolsChanged.push(m[1]);
20
+ }
21
+
22
+ // Build structured summary text for ingestion
23
+ const text = [
24
+ `## Change Summary: ${source}`,
25
+ commitMessage ? `\n**Commit:** ${commitMessage}` : '',
26
+ `\n**Intent:** ${intent}`,
27
+ `**Files changed:** ${parsed.files_modified.length} (${parsed.files_modified.join(', ')})`,
28
+ `**Lines:** +${parsed.lines_added} / -${parsed.lines_removed}`,
29
+ symbolsChanged.length ? `**Symbols changed:** ${symbolsChanged.join(', ')}` : '',
30
+ '',
31
+ '### Diff',
32
+ '```diff',
33
+ diffText.slice(0, 4000),
34
+ '```',
35
+ ].filter(Boolean).join('\n');
36
+
37
+ return {
38
+ text,
39
+ token_estimate: Math.ceil(text.length / 4),
40
+ metadata: {
41
+ intent,
42
+ files_changed: parsed.files_modified.length,
43
+ added_lines: parsed.lines_added,
44
+ removed_lines: parsed.lines_removed,
45
+ symbols_changed: symbolsChanged,
46
+ },
47
+ };
48
+ }
49
+
50
+ function ingestDiagram() {
51
+ return {
52
+ error: 'ingest_diagram requires image processing (PIL/OCR) which is not available in the WASM/Node runtime. Use `pip install entroly` for diagram ingestion.',
53
+ engine: 'javascript',
54
+ };
55
+ }
56
+
57
+ function ingestVoice() {
58
+ return {
59
+ error: 'ingest_voice requires audio transcription (whisper) which is not available in the WASM/Node runtime. Use `pip install entroly` for voice ingestion.',
60
+ engine: 'javascript',
61
+ };
62
+ }
63
+
64
+ module.exports = { ingestDiff, ingestDiagram, ingestVoice };