@evomap/evolver 1.29.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.
Files changed (52) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +290 -0
  3. package/README.zh-CN.md +236 -0
  4. package/SKILL.md +132 -0
  5. package/assets/gep/capsules.json +79 -0
  6. package/assets/gep/events.jsonl +7 -0
  7. package/assets/gep/genes.json +108 -0
  8. package/index.js +479 -0
  9. package/package.json +38 -0
  10. package/src/canary.js +13 -0
  11. package/src/evolve.js +1704 -0
  12. package/src/gep/a2a.js +173 -0
  13. package/src/gep/a2aProtocol.js +736 -0
  14. package/src/gep/analyzer.js +35 -0
  15. package/src/gep/assetCallLog.js +130 -0
  16. package/src/gep/assetStore.js +297 -0
  17. package/src/gep/assets.js +36 -0
  18. package/src/gep/bridge.js +71 -0
  19. package/src/gep/candidates.js +142 -0
  20. package/src/gep/contentHash.js +65 -0
  21. package/src/gep/deviceId.js +209 -0
  22. package/src/gep/envFingerprint.js +68 -0
  23. package/src/gep/hubReview.js +206 -0
  24. package/src/gep/hubSearch.js +237 -0
  25. package/src/gep/issueReporter.js +262 -0
  26. package/src/gep/llmReview.js +92 -0
  27. package/src/gep/memoryGraph.js +771 -0
  28. package/src/gep/memoryGraphAdapter.js +203 -0
  29. package/src/gep/mutation.js +186 -0
  30. package/src/gep/narrativeMemory.js +108 -0
  31. package/src/gep/paths.js +113 -0
  32. package/src/gep/personality.js +355 -0
  33. package/src/gep/prompt.js +566 -0
  34. package/src/gep/questionGenerator.js +212 -0
  35. package/src/gep/reflection.js +127 -0
  36. package/src/gep/sanitize.js +67 -0
  37. package/src/gep/selector.js +250 -0
  38. package/src/gep/signals.js +417 -0
  39. package/src/gep/skillDistiller.js +499 -0
  40. package/src/gep/solidify.js +1681 -0
  41. package/src/gep/strategy.js +126 -0
  42. package/src/gep/taskReceiver.js +528 -0
  43. package/src/gep/validationReport.js +55 -0
  44. package/src/ops/cleanup.js +80 -0
  45. package/src/ops/commentary.js +60 -0
  46. package/src/ops/health_check.js +106 -0
  47. package/src/ops/index.js +11 -0
  48. package/src/ops/innovation.js +67 -0
  49. package/src/ops/lifecycle.js +168 -0
  50. package/src/ops/self_repair.js +72 -0
  51. package/src/ops/skills_monitor.js +143 -0
  52. package/src/ops/trigger.js +33 -0
@@ -0,0 +1,142 @@
1
+ function stableHash(input) {
2
+ // Deterministic lightweight hash (not cryptographic).
3
+ const s = String(input || '');
4
+ let h = 2166136261;
5
+ for (let i = 0; i < s.length; i++) {
6
+ h ^= s.charCodeAt(i);
7
+ h = Math.imul(h, 16777619);
8
+ }
9
+ return (h >>> 0).toString(16).padStart(8, '0');
10
+ }
11
+
12
+ function clip(text, maxChars) {
13
+ const s = String(text || '');
14
+ if (!maxChars || s.length <= maxChars) return s;
15
+ return s.slice(0, Math.max(0, maxChars - 20)) + ' ...[TRUNCATED]';
16
+ }
17
+
18
+ function toLines(text) {
19
+ return String(text || '')
20
+ .split('\n')
21
+ .map(l => l.trimEnd())
22
+ .filter(Boolean);
23
+ }
24
+
25
+ function extractToolCalls(transcript) {
26
+ const lines = toLines(transcript);
27
+ const calls = [];
28
+ for (const line of lines) {
29
+ const m = line.match(/\[TOOL:\s*([^\]]+)\]/i);
30
+ if (m && m[1]) calls.push(m[1].trim());
31
+ }
32
+ return calls;
33
+ }
34
+
35
+ function countFreq(items) {
36
+ const map = new Map();
37
+ for (const it of items) map.set(it, (map.get(it) || 0) + 1);
38
+ return map;
39
+ }
40
+
41
+ function buildFiveQuestionsShape({ title, signals, evidence }) {
42
+ // Keep it short and structured; this is a template, not a perfect inference.
43
+ const input = 'Recent session transcript + memory snippets + user instructions';
44
+ const output = 'A safe, auditable evolution patch guided by GEP assets';
45
+ const invariants = 'Protocol order, small reversible patches, validation, append-only events';
46
+ const params = `Signals: ${Array.isArray(signals) ? signals.join(', ') : ''}`.trim();
47
+ const failurePoints = 'Missing signals, over-broad changes, skipped validation, missing knowledge solidification';
48
+ return {
49
+ title: String(title || '').slice(0, 120),
50
+ input,
51
+ output,
52
+ invariants,
53
+ params: params || 'Signals: (none)',
54
+ failure_points: failurePoints,
55
+ evidence: clip(evidence, 240),
56
+ };
57
+ }
58
+
59
+ function extractCapabilityCandidates({ recentSessionTranscript, signals }) {
60
+ const candidates = [];
61
+ const toolCalls = extractToolCalls(recentSessionTranscript);
62
+ const freq = countFreq(toolCalls);
63
+
64
+ for (const [tool, count] of freq.entries()) {
65
+ if (count < 2) continue;
66
+ const title = `Repeated tool usage: ${tool}`;
67
+ const evidence = `Observed ${count} occurrences of tool call marker for ${tool}.`;
68
+ const shape = buildFiveQuestionsShape({ title, signals, evidence });
69
+ candidates.push({
70
+ type: 'CapabilityCandidate',
71
+ id: `cand_${stableHash(title)}`,
72
+ title,
73
+ source: 'transcript',
74
+ created_at: new Date().toISOString(),
75
+ signals: Array.isArray(signals) ? signals : [],
76
+ shape,
77
+ });
78
+ }
79
+
80
+ // Signals-as-candidates: capture recurring pain points as reusable capability shapes.
81
+ const signalList = Array.isArray(signals) ? signals : [];
82
+ const signalCandidates = [
83
+ // Defensive signals
84
+ { signal: 'log_error', title: 'Repair recurring runtime errors' },
85
+ { signal: 'protocol_drift', title: 'Prevent protocol drift and enforce auditable outputs' },
86
+ { signal: 'windows_shell_incompatible', title: 'Avoid platform-specific shell assumptions (Windows compatibility)' },
87
+ { signal: 'session_logs_missing', title: 'Harden session log detection and fallback behavior' },
88
+ // Opportunity signals (innovation)
89
+ { signal: 'user_feature_request', title: 'Implement user-requested feature' },
90
+ { signal: 'user_improvement_suggestion', title: 'Apply user improvement suggestion' },
91
+ { signal: 'perf_bottleneck', title: 'Resolve performance bottleneck' },
92
+ { signal: 'capability_gap', title: 'Fill capability gap' },
93
+ { signal: 'stable_success_plateau', title: 'Explore new strategies during stability plateau' },
94
+ { signal: 'external_opportunity', title: 'Evaluate external A2A asset for local adoption' },
95
+ ];
96
+
97
+ for (const sc of signalCandidates) {
98
+ if (!signalList.some(s => s === sc.signal || s.startsWith(sc.signal + ':'))) continue;
99
+ const evidence = `Signal present: ${sc.signal}`;
100
+ const shape = buildFiveQuestionsShape({ title: sc.title, signals, evidence });
101
+ candidates.push({
102
+ type: 'CapabilityCandidate',
103
+ id: `cand_${stableHash(sc.signal)}`,
104
+ title: sc.title,
105
+ source: 'signals',
106
+ created_at: new Date().toISOString(),
107
+ signals: signalList,
108
+ shape,
109
+ });
110
+ }
111
+
112
+ // Dedup by id
113
+ const seen = new Set();
114
+ return candidates.filter(c => {
115
+ if (!c || !c.id) return false;
116
+ if (seen.has(c.id)) return false;
117
+ seen.add(c.id);
118
+ return true;
119
+ });
120
+ }
121
+
122
+ function renderCandidatesPreview(candidates, maxChars = 1400) {
123
+ const list = Array.isArray(candidates) ? candidates : [];
124
+ const lines = [];
125
+ for (const c of list) {
126
+ const s = c && c.shape ? c.shape : {};
127
+ lines.push(`- ${c.id}: ${c.title}`);
128
+ lines.push(` - input: ${s.input || ''}`);
129
+ lines.push(` - output: ${s.output || ''}`);
130
+ lines.push(` - invariants: ${s.invariants || ''}`);
131
+ lines.push(` - params: ${s.params || ''}`);
132
+ lines.push(` - failure_points: ${s.failure_points || ''}`);
133
+ if (s.evidence) lines.push(` - evidence: ${s.evidence}`);
134
+ }
135
+ return clip(lines.join('\n'), maxChars);
136
+ }
137
+
138
+ module.exports = {
139
+ extractCapabilityCandidates,
140
+ renderCandidatesPreview,
141
+ };
142
+
@@ -0,0 +1,65 @@
1
+ // Content-addressable hashing for GEP assets.
2
+ // Provides canonical JSON serialization and SHA-256 based asset IDs.
3
+ // This enables deduplication, tamper detection, and cross-node consistency.
4
+
5
+ const crypto = require('crypto');
6
+
7
+ // Schema version for all GEP asset types.
8
+ // Bump MINOR for additive fields; MAJOR for breaking changes.
9
+ const SCHEMA_VERSION = '1.6.0';
10
+
11
+ // Canonical JSON: deterministic serialization with sorted keys at all levels.
12
+ // Arrays preserve order; non-finite numbers become null; undefined becomes null.
13
+ function canonicalize(obj) {
14
+ if (obj === null || obj === undefined) return 'null';
15
+ if (typeof obj === 'boolean') return obj ? 'true' : 'false';
16
+ if (typeof obj === 'number') {
17
+ if (!Number.isFinite(obj)) return 'null';
18
+ return String(obj);
19
+ }
20
+ if (typeof obj === 'string') return JSON.stringify(obj);
21
+ if (Array.isArray(obj)) {
22
+ return '[' + obj.map(canonicalize).join(',') + ']';
23
+ }
24
+ if (typeof obj === 'object') {
25
+ const keys = Object.keys(obj).sort();
26
+ const pairs = [];
27
+ for (const k of keys) {
28
+ pairs.push(JSON.stringify(k) + ':' + canonicalize(obj[k]));
29
+ }
30
+ return '{' + pairs.join(',') + '}';
31
+ }
32
+ return 'null';
33
+ }
34
+
35
+ // Compute a content-addressable asset ID.
36
+ // Excludes self-referential fields (asset_id itself) from the hash input.
37
+ // Returns "sha256:<hex>".
38
+ function computeAssetId(obj, excludeFields) {
39
+ if (!obj || typeof obj !== 'object') return null;
40
+ const exclude = new Set(Array.isArray(excludeFields) ? excludeFields : ['asset_id']);
41
+ const clean = {};
42
+ for (const k of Object.keys(obj)) {
43
+ if (exclude.has(k)) continue;
44
+ clean[k] = obj[k];
45
+ }
46
+ const canonical = canonicalize(clean);
47
+ const hash = crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
48
+ return 'sha256:' + hash;
49
+ }
50
+
51
+ // Verify that an object's asset_id matches its content.
52
+ function verifyAssetId(obj) {
53
+ if (!obj || typeof obj !== 'object') return false;
54
+ const claimed = obj.asset_id;
55
+ if (!claimed || typeof claimed !== 'string') return false;
56
+ const computed = computeAssetId(obj);
57
+ return claimed === computed;
58
+ }
59
+
60
+ module.exports = {
61
+ SCHEMA_VERSION,
62
+ canonicalize,
63
+ computeAssetId,
64
+ verifyAssetId,
65
+ };
@@ -0,0 +1,209 @@
1
+ // Stable device identifier for node identity.
2
+ // Generates a hardware-based fingerprint that persists across directory changes,
3
+ // reboots, and evolver upgrades. Used by getNodeId() and env_fingerprint.
4
+ //
5
+ // Priority chain:
6
+ // 1. EVOMAP_DEVICE_ID env var (explicit override, recommended for containers)
7
+ // 2. ~/.evomap/device_id file (persisted from previous run)
8
+ // 3. <project>/.evomap_device_id (fallback persist path for containers w/o $HOME)
9
+ // 4. /etc/machine-id (Linux, set at OS install)
10
+ // 5. IOPlatformUUID (macOS hardware UUID)
11
+ // 6. Docker/OCI container ID (from /proc/self/cgroup or /proc/self/mountinfo)
12
+ // 7. hostname + MAC addresses (network-based fallback)
13
+ // 8. random 128-bit hex (last resort, persisted immediately)
14
+
15
+ const os = require('os');
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+
20
+ const DEVICE_ID_DIR = path.join(os.homedir(), '.evomap');
21
+ const DEVICE_ID_FILE = path.join(DEVICE_ID_DIR, 'device_id');
22
+ const LOCAL_DEVICE_ID_FILE = path.resolve(__dirname, '..', '..', '.evomap_device_id');
23
+
24
+ let _cachedDeviceId = null;
25
+
26
+ const DEVICE_ID_RE = /^[a-f0-9]{16,64}$/;
27
+
28
+ function isContainer() {
29
+ try {
30
+ if (fs.existsSync('/.dockerenv')) return true;
31
+ } catch {}
32
+ try {
33
+ const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
34
+ if (/docker|kubepods|containerd|cri-o|lxc|ecs/i.test(cgroup)) return true;
35
+ } catch {}
36
+ try {
37
+ if (fs.existsSync('/run/.containerenv')) return true;
38
+ } catch {}
39
+ return false;
40
+ }
41
+
42
+ function readMachineId() {
43
+ try {
44
+ const mid = fs.readFileSync('/etc/machine-id', 'utf8').trim();
45
+ if (mid && mid.length >= 16) return mid;
46
+ } catch {}
47
+
48
+ if (process.platform === 'darwin') {
49
+ try {
50
+ const { execFileSync } = require('child_process');
51
+ const raw = execFileSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], {
52
+ encoding: 'utf8',
53
+ timeout: 3000,
54
+ stdio: ['ignore', 'pipe', 'ignore'],
55
+ });
56
+ const match = raw.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
57
+ if (match && match[1]) return match[1];
58
+ } catch {}
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ // Extract Docker/OCI container ID from cgroup or mountinfo.
65
+ // The container ID is 64-char hex and stable for the lifetime of the container.
66
+ // Returns null on non-container hosts or if parsing fails.
67
+ function readContainerId() {
68
+ // Method 1: /proc/self/cgroup (works for cgroup v1 and most Docker setups)
69
+ try {
70
+ const cgroup = fs.readFileSync('/proc/self/cgroup', 'utf8');
71
+ const match = cgroup.match(/[a-f0-9]{64}/);
72
+ if (match) return match[0];
73
+ } catch {}
74
+
75
+ // Method 2: /proc/self/mountinfo (works for cgroup v2 / containerd)
76
+ try {
77
+ const mountinfo = fs.readFileSync('/proc/self/mountinfo', 'utf8');
78
+ const match = mountinfo.match(/[a-f0-9]{64}/);
79
+ if (match) return match[0];
80
+ } catch {}
81
+
82
+ // Method 3: hostname in Docker defaults to short container ID (12 hex chars)
83
+ if (isContainer()) {
84
+ const hostname = os.hostname();
85
+ if (/^[a-f0-9]{12,64}$/.test(hostname)) return hostname;
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ function getMacAddresses() {
92
+ const ifaces = os.networkInterfaces();
93
+ const macs = [];
94
+ for (const name of Object.keys(ifaces)) {
95
+ for (const iface of ifaces[name]) {
96
+ if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') {
97
+ macs.push(iface.mac);
98
+ }
99
+ }
100
+ }
101
+ macs.sort();
102
+ return macs;
103
+ }
104
+
105
+ function generateDeviceId() {
106
+ const machineId = readMachineId();
107
+ if (machineId) {
108
+ return crypto.createHash('sha256').update('evomap:' + machineId).digest('hex').slice(0, 32);
109
+ }
110
+
111
+ // Container ID: stable for the container's lifetime, but changes on re-create.
112
+ // Still better than random for keeping identity within a single deployment.
113
+ const containerId = readContainerId();
114
+ if (containerId) {
115
+ return crypto.createHash('sha256').update('evomap:container:' + containerId).digest('hex').slice(0, 32);
116
+ }
117
+
118
+ const macs = getMacAddresses();
119
+ if (macs.length > 0) {
120
+ const raw = os.hostname() + '|' + macs.join(',');
121
+ return crypto.createHash('sha256').update('evomap:' + raw).digest('hex').slice(0, 32);
122
+ }
123
+
124
+ return crypto.randomBytes(16).toString('hex');
125
+ }
126
+
127
+ function persistDeviceId(id) {
128
+ // Try primary path (~/.evomap/device_id)
129
+ try {
130
+ if (!fs.existsSync(DEVICE_ID_DIR)) {
131
+ fs.mkdirSync(DEVICE_ID_DIR, { recursive: true, mode: 0o700 });
132
+ }
133
+ fs.writeFileSync(DEVICE_ID_FILE, id, { encoding: 'utf8', mode: 0o600 });
134
+ return;
135
+ } catch {}
136
+
137
+ // Fallback: project-local file (useful in containers where $HOME is ephemeral
138
+ // but the project directory is mounted as a volume)
139
+ try {
140
+ fs.writeFileSync(LOCAL_DEVICE_ID_FILE, id, { encoding: 'utf8', mode: 0o600 });
141
+ return;
142
+ } catch {}
143
+
144
+ console.error(
145
+ '[evolver] WARN: failed to persist device_id to ' + DEVICE_ID_FILE +
146
+ ' or ' + LOCAL_DEVICE_ID_FILE +
147
+ ' -- node identity may change on restart.' +
148
+ ' Set EVOMAP_DEVICE_ID env var for stable identity in containers.'
149
+ );
150
+ }
151
+
152
+ function loadPersistedDeviceId() {
153
+ // Try primary path
154
+ try {
155
+ if (fs.existsSync(DEVICE_ID_FILE)) {
156
+ const id = fs.readFileSync(DEVICE_ID_FILE, 'utf8').trim();
157
+ if (id && DEVICE_ID_RE.test(id)) return id;
158
+ }
159
+ } catch {}
160
+
161
+ // Try project-local fallback
162
+ try {
163
+ if (fs.existsSync(LOCAL_DEVICE_ID_FILE)) {
164
+ const id = fs.readFileSync(LOCAL_DEVICE_ID_FILE, 'utf8').trim();
165
+ if (id && DEVICE_ID_RE.test(id)) return id;
166
+ }
167
+ } catch {}
168
+
169
+ return null;
170
+ }
171
+
172
+ function getDeviceId() {
173
+ if (_cachedDeviceId) return _cachedDeviceId;
174
+
175
+ // 1. Env var override (validated)
176
+ if (process.env.EVOMAP_DEVICE_ID) {
177
+ const envId = String(process.env.EVOMAP_DEVICE_ID).trim().toLowerCase();
178
+ if (DEVICE_ID_RE.test(envId)) {
179
+ _cachedDeviceId = envId;
180
+ return _cachedDeviceId;
181
+ }
182
+ }
183
+
184
+ // 2. Previously persisted (checks both ~/.evomap/ and project-local)
185
+ const persisted = loadPersistedDeviceId();
186
+ if (persisted) {
187
+ _cachedDeviceId = persisted;
188
+ return _cachedDeviceId;
189
+ }
190
+
191
+ // 3. Generate from hardware / container metadata and persist
192
+ const inContainer = isContainer();
193
+ const generated = generateDeviceId();
194
+ persistDeviceId(generated);
195
+ _cachedDeviceId = generated;
196
+
197
+ if (inContainer && !process.env.EVOMAP_DEVICE_ID) {
198
+ console.error(
199
+ '[evolver] NOTE: running in a container without EVOMAP_DEVICE_ID.' +
200
+ ' A device_id was auto-generated and persisted, but for guaranteed' +
201
+ ' cross-restart stability, set EVOMAP_DEVICE_ID as an env var' +
202
+ ' or mount a persistent volume at ~/.evomap/'
203
+ );
204
+ }
205
+
206
+ return _cachedDeviceId;
207
+ }
208
+
209
+ module.exports = { getDeviceId, isContainer };
@@ -0,0 +1,68 @@
1
+ // Environment fingerprint capture for GEP assets.
2
+ // Records the runtime environment so that cross-environment diffusion
3
+ // success rates (GDI) can be measured scientifically.
4
+
5
+ const os = require('os');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { getRepoRoot } = require('./paths');
10
+ const { getDeviceId, isContainer } = require('./deviceId');
11
+
12
+ // Capture a structured environment fingerprint.
13
+ // This is embedded into Capsules, EvolutionEvents, and ValidationReports.
14
+ function captureEnvFingerprint() {
15
+ const repoRoot = getRepoRoot();
16
+ let pkgVersion = null;
17
+ let pkgName = null;
18
+ try {
19
+ const raw = fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8');
20
+ const pkg = JSON.parse(raw);
21
+ pkgVersion = pkg && pkg.version ? String(pkg.version) : null;
22
+ pkgName = pkg && pkg.name ? String(pkg.name) : null;
23
+ } catch (e) {}
24
+
25
+ const region = (process.env.EVOLVER_REGION || '').trim().toLowerCase().slice(0, 5) || undefined;
26
+
27
+ return {
28
+ device_id: getDeviceId(),
29
+ node_version: process.version,
30
+ platform: process.platform,
31
+ arch: process.arch,
32
+ os_release: os.release(),
33
+ hostname: crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 12),
34
+ evolver_version: pkgVersion,
35
+ client: pkgName || 'evolver',
36
+ client_version: pkgVersion,
37
+ region: region,
38
+ cwd: crypto.createHash('sha256').update(process.cwd()).digest('hex').slice(0, 12),
39
+ container: isContainer(),
40
+ };
41
+ }
42
+
43
+ // Compute a short fingerprint key for comparison and grouping.
44
+ // Two nodes with the same key are considered "same environment class".
45
+ function envFingerprintKey(fp) {
46
+ if (!fp || typeof fp !== 'object') return 'unknown';
47
+ const parts = [
48
+ fp.device_id || '',
49
+ fp.node_version || '',
50
+ fp.platform || '',
51
+ fp.arch || '',
52
+ fp.hostname || '',
53
+ fp.client || fp.evolver_version || '',
54
+ fp.client_version || fp.evolver_version || '',
55
+ ].join('|');
56
+ return crypto.createHash('sha256').update(parts, 'utf8').digest('hex').slice(0, 16);
57
+ }
58
+
59
+ // Check if two fingerprints are from the same environment class.
60
+ function isSameEnvClass(fpA, fpB) {
61
+ return envFingerprintKey(fpA) === envFingerprintKey(fpB);
62
+ }
63
+
64
+ module.exports = {
65
+ captureEnvFingerprint,
66
+ envFingerprintKey,
67
+ isSameEnvClass,
68
+ };
@@ -0,0 +1,206 @@
1
+ // Hub Asset Review: submit usage-verified reviews after solidify.
2
+ //
3
+ // When an evolution cycle reuses a Hub asset (source_type = 'reused' or 'reference'),
4
+ // we submit a review to POST /a2a/assets/:assetId/reviews after solidify completes.
5
+ // Rating is derived from outcome: success -> 4-5, failure -> 1-2.
6
+ // Reviews are non-blocking; errors never affect the solidify result.
7
+ // Duplicate prevention: a local file tracks reviewed assetIds to avoid re-reviewing.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { getNodeId, getHubNodeSecret } = require('./a2aProtocol');
12
+ const { logAssetCall } = require('./assetCallLog');
13
+
14
+ const REVIEW_HISTORY_FILE = path.join(
15
+ require('./paths').getEvolutionDir(),
16
+ 'hub_review_history.json'
17
+ );
18
+
19
+ const REVIEW_HISTORY_MAX_ENTRIES = 500;
20
+
21
+ function _loadReviewHistory() {
22
+ try {
23
+ if (!fs.existsSync(REVIEW_HISTORY_FILE)) return {};
24
+ const raw = fs.readFileSync(REVIEW_HISTORY_FILE, 'utf8');
25
+ if (!raw.trim()) return {};
26
+ return JSON.parse(raw);
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ function _saveReviewHistory(history) {
33
+ try {
34
+ const dir = path.dirname(REVIEW_HISTORY_FILE);
35
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
36
+ const keys = Object.keys(history);
37
+ if (keys.length > REVIEW_HISTORY_MAX_ENTRIES) {
38
+ const sorted = keys
39
+ .map(k => ({ k, t: history[k].at || 0 }))
40
+ .sort((a, b) => a.t - b.t);
41
+ const toRemove = sorted.slice(0, keys.length - REVIEW_HISTORY_MAX_ENTRIES);
42
+ for (const entry of toRemove) delete history[entry.k];
43
+ }
44
+ const tmp = REVIEW_HISTORY_FILE + '.tmp';
45
+ fs.writeFileSync(tmp, JSON.stringify(history, null, 2) + '\n', 'utf8');
46
+ fs.renameSync(tmp, REVIEW_HISTORY_FILE);
47
+ } catch {}
48
+ }
49
+
50
+ function _alreadyReviewed(assetId) {
51
+ const history = _loadReviewHistory();
52
+ return !!history[assetId];
53
+ }
54
+
55
+ function _markReviewed(assetId, rating, success) {
56
+ const history = _loadReviewHistory();
57
+ history[assetId] = { at: Date.now(), rating, success };
58
+ _saveReviewHistory(history);
59
+ }
60
+
61
+ function _deriveRating(outcome, constraintCheck) {
62
+ if (outcome && outcome.status === 'success') {
63
+ const score = Number(outcome.score) || 0;
64
+ return score >= 0.85 ? 5 : 4;
65
+ }
66
+ const hasConstraintViolation =
67
+ constraintCheck &&
68
+ Array.isArray(constraintCheck.violations) &&
69
+ constraintCheck.violations.length > 0;
70
+ return hasConstraintViolation ? 1 : 2;
71
+ }
72
+
73
+ function _buildReviewContent({ outcome, gene, signals, blast, sourceType }) {
74
+ const parts = [];
75
+ const status = outcome && outcome.status ? outcome.status : 'unknown';
76
+ const score = outcome && Number.isFinite(Number(outcome.score))
77
+ ? Number(outcome.score).toFixed(2) : '?';
78
+
79
+ parts.push('Outcome: ' + status + ' (score: ' + score + ')');
80
+ parts.push('Reuse mode: ' + (sourceType || 'unknown'));
81
+
82
+ if (gene && gene.id) {
83
+ parts.push('Gene: ' + gene.id + ' (' + (gene.category || 'unknown') + ')');
84
+ }
85
+
86
+ if (Array.isArray(signals) && signals.length > 0) {
87
+ parts.push('Signals: ' + signals.slice(0, 6).join(', '));
88
+ }
89
+
90
+ if (blast) {
91
+ parts.push('Blast radius: ' + (blast.files || 0) + ' file(s), ' + (blast.lines || 0) + ' line(s)');
92
+ }
93
+
94
+ if (status === 'success') {
95
+ parts.push('The fetched asset was successfully applied and solidified.');
96
+ } else {
97
+ parts.push('The fetched asset did not lead to a successful evolution cycle.');
98
+ }
99
+
100
+ return parts.join('\n').slice(0, 2000);
101
+ }
102
+
103
+ function getHubUrl() {
104
+ return (process.env.A2A_HUB_URL || '').replace(/\/+$/, '');
105
+ }
106
+
107
+ async function submitHubReview({
108
+ reusedAssetId,
109
+ sourceType,
110
+ outcome,
111
+ gene,
112
+ signals,
113
+ blast,
114
+ constraintCheck,
115
+ runId,
116
+ }) {
117
+ var hubUrl = getHubUrl();
118
+ if (!hubUrl) return { submitted: false, reason: 'no_hub_url' };
119
+
120
+ if (!reusedAssetId || typeof reusedAssetId !== 'string') {
121
+ return { submitted: false, reason: 'no_reused_asset_id' };
122
+ }
123
+
124
+ if (sourceType !== 'reused' && sourceType !== 'reference') {
125
+ return { submitted: false, reason: 'not_hub_sourced' };
126
+ }
127
+
128
+ if (_alreadyReviewed(reusedAssetId)) {
129
+ return { submitted: false, reason: 'already_reviewed' };
130
+ }
131
+
132
+ var rating = _deriveRating(outcome, constraintCheck);
133
+ var content = _buildReviewContent({ outcome, gene, signals, blast, sourceType });
134
+ var senderId = getNodeId();
135
+
136
+ var endpoint = hubUrl + '/a2a/assets/' + encodeURIComponent(reusedAssetId) + '/reviews';
137
+
138
+ var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
139
+ var secret = getHubNodeSecret();
140
+ if (secret) {
141
+ headers['Authorization'] = 'Bearer ' + secret;
142
+ }
143
+
144
+ var body = JSON.stringify({
145
+ sender_id: senderId,
146
+ rating: rating,
147
+ content: content,
148
+ });
149
+
150
+ try {
151
+ var controller = new AbortController();
152
+ var timer = setTimeout(function () { controller.abort('hub_review_timeout'); }, 10000);
153
+
154
+ var res = await fetch(endpoint, {
155
+ method: 'POST',
156
+ headers: headers,
157
+ body: body,
158
+ signal: controller.signal,
159
+ });
160
+ clearTimeout(timer);
161
+
162
+ if (res.ok) {
163
+ _markReviewed(reusedAssetId, rating, true);
164
+ console.log(
165
+ '[HubReview] Submitted review for ' + reusedAssetId + ': rating=' + rating + ', outcome=' + (outcome && outcome.status)
166
+ );
167
+ logAssetCall({
168
+ run_id: runId || null,
169
+ action: 'hub_review_submitted',
170
+ asset_id: reusedAssetId,
171
+ extra: { rating: rating, outcome_status: outcome && outcome.status },
172
+ });
173
+ return { submitted: true, rating: rating, asset_id: reusedAssetId };
174
+ }
175
+
176
+ var errData = await res.json().catch(function () { return {}; });
177
+ var errCode = errData.error || errData.code || ('http_' + res.status);
178
+
179
+ if (errCode === 'already_reviewed') {
180
+ _markReviewed(reusedAssetId, rating, false);
181
+ }
182
+
183
+ console.log('[HubReview] Hub rejected review for ' + reusedAssetId + ': ' + errCode);
184
+ logAssetCall({
185
+ run_id: runId || null,
186
+ action: 'hub_review_rejected',
187
+ asset_id: reusedAssetId,
188
+ extra: { rating: rating, error: errCode },
189
+ });
190
+ return { submitted: false, reason: errCode, rating: rating };
191
+ } catch (err) {
192
+ var reason = err.name === 'AbortError' ? 'timeout' : 'fetch_error';
193
+ console.log('[HubReview] Failed (non-fatal, ' + reason + '): ' + err.message);
194
+ logAssetCall({
195
+ run_id: runId || null,
196
+ action: 'hub_review_failed',
197
+ asset_id: reusedAssetId,
198
+ extra: { rating: rating, reason: reason, error: err.message },
199
+ });
200
+ return { submitted: false, reason: reason, error: err.message };
201
+ }
202
+ }
203
+
204
+ module.exports = {
205
+ submitHubReview,
206
+ };