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.
- package/index.js +67 -0
- package/js/agentskills_export.js +129 -0
- package/js/auto_index.js +262 -0
- package/js/autotune.js +627 -0
- package/js/checkpoint.js +108 -0
- package/js/cli.js +321 -0
- package/js/cogops.js +513 -0
- package/js/config.js +53 -0
- package/js/distill.js +155 -0
- package/js/evolution_daemon.js +335 -0
- package/js/federation.js +444 -0
- package/js/gateways.js +170 -0
- package/js/multimodal.js +64 -0
- package/js/repo_map.js +112 -0
- package/js/server.js +465 -0
- package/js/skills.js +124 -0
- package/js/value_tracker.js +181 -0
- package/js/vault.js +237 -0
- package/js/vault_observer.js +129 -0
- package/js/workspace.js +116 -0
- package/package.json +51 -0
- package/pkg/entroly_wasm.d.ts +156 -0
- package/pkg/entroly_wasm.js +468 -0
- package/pkg/entroly_wasm_bg.wasm +0 -0
package/js/federation.js
ADDED
|
@@ -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
|
+
}
|
package/js/multimodal.js
ADDED
|
@@ -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 };
|