Auto backup before evolver run - 2026年 03月 03日 星期二 23:12:56 CST
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
const fs = require('fs');
|
||||
const { readAllEvents } = require('./assetStore');
|
||||
const { computeAssetId, SCHEMA_VERSION } = require('./contentHash');
|
||||
const { unwrapAssetFromMessage } = require('./a2aProtocol');
|
||||
|
||||
function nowIso() { return new Date().toISOString(); }
|
||||
|
||||
function isAllowedA2AAsset(obj) {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
var t = obj.type;
|
||||
return t === 'Gene' || t === 'Capsule' || t === 'EvolutionEvent';
|
||||
}
|
||||
|
||||
function safeNumber(x, fallback) {
|
||||
if (fallback === undefined) fallback = null;
|
||||
var n = Number(x);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function getBlastRadiusLimits() {
|
||||
var maxFiles = safeNumber(process.env.A2A_MAX_FILES, 5);
|
||||
var maxLines = safeNumber(process.env.A2A_MAX_LINES, 200);
|
||||
return {
|
||||
maxFiles: Number.isFinite(maxFiles) ? maxFiles : 5,
|
||||
maxLines: Number.isFinite(maxLines) ? maxLines : 200,
|
||||
};
|
||||
}
|
||||
|
||||
function isBlastRadiusSafe(blastRadius) {
|
||||
var lim = getBlastRadiusLimits();
|
||||
var files = blastRadius && Number.isFinite(Number(blastRadius.files)) ? Number(blastRadius.files) : 0;
|
||||
var lines = blastRadius && Number.isFinite(Number(blastRadius.lines)) ? Number(blastRadius.lines) : 0;
|
||||
return files <= lim.maxFiles && lines <= lim.maxLines;
|
||||
}
|
||||
|
||||
function clamp01(n) {
|
||||
var x = Number(n);
|
||||
if (!Number.isFinite(x)) return 0;
|
||||
return Math.max(0, Math.min(1, x));
|
||||
}
|
||||
|
||||
function lowerConfidence(asset, opts) {
|
||||
if (!opts) opts = {};
|
||||
var factor = Number.isFinite(Number(opts.factor)) ? Number(opts.factor) : 0.6;
|
||||
var receivedFrom = opts.source || 'external';
|
||||
var receivedAt = opts.received_at || nowIso();
|
||||
var cloned = JSON.parse(JSON.stringify(asset || {}));
|
||||
if (!isAllowedA2AAsset(cloned)) return null;
|
||||
if (cloned.type === 'Capsule') {
|
||||
if (typeof cloned.confidence === 'number') cloned.confidence = clamp01(cloned.confidence * factor);
|
||||
else if (cloned.confidence != null) cloned.confidence = clamp01(Number(cloned.confidence) * factor);
|
||||
}
|
||||
if (!cloned.a2a || typeof cloned.a2a !== 'object') cloned.a2a = {};
|
||||
cloned.a2a.status = 'external_candidate';
|
||||
cloned.a2a.source = receivedFrom;
|
||||
cloned.a2a.received_at = receivedAt;
|
||||
cloned.a2a.confidence_factor = factor;
|
||||
if (!cloned.schema_version) cloned.schema_version = SCHEMA_VERSION;
|
||||
if (!cloned.asset_id) { try { cloned.asset_id = computeAssetId(cloned); } catch (e) {} }
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function readEvolutionEvents() {
|
||||
var events = readAllEvents();
|
||||
return Array.isArray(events) ? events.filter(function (e) { return e && e.type === 'EvolutionEvent'; }) : [];
|
||||
}
|
||||
|
||||
function normalizeEventsList(events) {
|
||||
return Array.isArray(events) ? events : [];
|
||||
}
|
||||
|
||||
function computeCapsuleSuccessStreak(params) {
|
||||
var capsuleId = params.capsuleId;
|
||||
var events = params.events;
|
||||
var id = capsuleId ? String(capsuleId) : '';
|
||||
if (!id) return 0;
|
||||
var list = normalizeEventsList(events || readEvolutionEvents());
|
||||
var streak = 0;
|
||||
for (var i = list.length - 1; i >= 0; i--) {
|
||||
var ev = list[i];
|
||||
if (!ev || ev.type !== 'EvolutionEvent') continue;
|
||||
if (!ev.capsule_id || String(ev.capsule_id) !== id) continue;
|
||||
var st = ev.outcome && ev.outcome.status ? String(ev.outcome.status) : 'unknown';
|
||||
if (st === 'success') streak += 1; else break;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
function isCapsuleBroadcastEligible(capsule, opts) {
|
||||
if (!opts) opts = {};
|
||||
if (!capsule || capsule.type !== 'Capsule') return false;
|
||||
var score = capsule.outcome && capsule.outcome.score != null ? safeNumber(capsule.outcome.score, null) : null;
|
||||
if (score == null || score < 0.7) return false;
|
||||
var blast = capsule.blast_radius || (capsule.outcome && capsule.outcome.blast_radius) || null;
|
||||
if (!isBlastRadiusSafe(blast)) return false;
|
||||
var events = Array.isArray(opts.events) ? opts.events : readEvolutionEvents();
|
||||
var streak = computeCapsuleSuccessStreak({ capsuleId: capsule.id, events: events });
|
||||
if (streak < 2) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function exportEligibleCapsules(params) {
|
||||
if (!params) params = {};
|
||||
var list = Array.isArray(params.capsules) ? params.capsules : [];
|
||||
var evs = Array.isArray(params.events) ? params.events : readEvolutionEvents();
|
||||
var eligible = list.filter(function (c) { return isCapsuleBroadcastEligible(c, { events: evs }); });
|
||||
for (var i = 0; i < eligible.length; i++) {
|
||||
var c = eligible[i];
|
||||
if (!c.schema_version) c.schema_version = SCHEMA_VERSION;
|
||||
if (!c.asset_id) { try { c.asset_id = computeAssetId(c); } catch (e) {} }
|
||||
}
|
||||
return eligible;
|
||||
}
|
||||
|
||||
function isGeneBroadcastEligible(gene) {
|
||||
if (!gene || gene.type !== 'Gene') return false;
|
||||
if (!gene.id || typeof gene.id !== 'string') return false;
|
||||
if (!Array.isArray(gene.strategy) || gene.strategy.length === 0) return false;
|
||||
if (!Array.isArray(gene.validation) || gene.validation.length === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function exportEligibleGenes(params) {
|
||||
if (!params) params = {};
|
||||
var list = Array.isArray(params.genes) ? params.genes : [];
|
||||
var eligible = list.filter(function (g) { return isGeneBroadcastEligible(g); });
|
||||
for (var i = 0; i < eligible.length; i++) {
|
||||
var g = eligible[i];
|
||||
if (!g.schema_version) g.schema_version = SCHEMA_VERSION;
|
||||
if (!g.asset_id) { try { g.asset_id = computeAssetId(g); } catch (e) {} }
|
||||
}
|
||||
return eligible;
|
||||
}
|
||||
|
||||
function parseA2AInput(text) {
|
||||
var raw = String(text || '').trim();
|
||||
if (!raw) return [];
|
||||
try {
|
||||
var maybe = JSON.parse(raw);
|
||||
if (Array.isArray(maybe)) {
|
||||
return maybe.map(function (item) { return unwrapAssetFromMessage(item) || item; }).filter(Boolean);
|
||||
}
|
||||
if (maybe && typeof maybe === 'object') {
|
||||
var unwrapped = unwrapAssetFromMessage(maybe);
|
||||
return unwrapped ? [unwrapped] : [maybe];
|
||||
}
|
||||
} catch (e) {}
|
||||
var lines = raw.split('\n').map(function (l) { return l.trim(); }).filter(Boolean);
|
||||
var items = [];
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
var obj = JSON.parse(lines[i]);
|
||||
var uw = unwrapAssetFromMessage(obj);
|
||||
items.push(uw || obj);
|
||||
} catch (e) { continue; }
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function readTextIfExists(filePath) {
|
||||
try {
|
||||
if (!filePath) return '';
|
||||
if (!fs.existsSync(filePath)) return '';
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isAllowedA2AAsset, lowerConfidence, isBlastRadiusSafe,
|
||||
computeCapsuleSuccessStreak, isCapsuleBroadcastEligible,
|
||||
exportEligibleCapsules, isGeneBroadcastEligible,
|
||||
exportEligibleGenes, parseA2AInput, readTextIfExists,
|
||||
};
|
||||
@@ -0,0 +1,560 @@
|
||||
// GEP A2A Protocol - Standard message types and pluggable transport layer.
|
||||
//
|
||||
// Protocol messages:
|
||||
// hello - capability advertisement and node discovery
|
||||
// publish - broadcast an eligible asset (Capsule/Gene)
|
||||
// fetch - request a specific asset by id or content hash
|
||||
// report - send a ValidationReport for a received asset
|
||||
// decision - accept/reject/quarantine decision on a received asset
|
||||
// revoke - withdraw a previously published asset
|
||||
//
|
||||
// Transport interface:
|
||||
// send(message, opts) - send a protocol message
|
||||
// receive(opts) - receive pending messages
|
||||
// list(opts) - list available message files/streams
|
||||
//
|
||||
// Default transport: FileTransport (reads/writes JSONL to a2a/ directory).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { getGepAssetsDir } = require('./paths');
|
||||
const { computeAssetId } = require('./contentHash');
|
||||
const { captureEnvFingerprint } = require('./envFingerprint');
|
||||
const os = require('os');
|
||||
const { getDeviceId } = require('./deviceId');
|
||||
|
||||
const PROTOCOL_NAME = 'gep-a2a';
|
||||
const PROTOCOL_VERSION = '1.0.0';
|
||||
const VALID_MESSAGE_TYPES = ['hello', 'publish', 'fetch', 'report', 'decision', 'revoke'];
|
||||
|
||||
const NODE_ID_RE = /^node_[a-f0-9]{12}$/;
|
||||
const NODE_ID_DIR = path.join(os.homedir(), '.evomap');
|
||||
const NODE_ID_FILE = path.join(NODE_ID_DIR, 'node_id');
|
||||
const LOCAL_NODE_ID_FILE = path.resolve(__dirname, '..', '..', '.evomap_node_id');
|
||||
|
||||
let _cachedNodeId = null;
|
||||
|
||||
function _loadPersistedNodeId() {
|
||||
try {
|
||||
if (fs.existsSync(NODE_ID_FILE)) {
|
||||
const id = fs.readFileSync(NODE_ID_FILE, 'utf8').trim();
|
||||
if (id && NODE_ID_RE.test(id)) return id;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
if (fs.existsSync(LOCAL_NODE_ID_FILE)) {
|
||||
const id = fs.readFileSync(LOCAL_NODE_ID_FILE, 'utf8').trim();
|
||||
if (id && NODE_ID_RE.test(id)) return id;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _persistNodeId(id) {
|
||||
try {
|
||||
if (!fs.existsSync(NODE_ID_DIR)) {
|
||||
fs.mkdirSync(NODE_ID_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(NODE_ID_FILE, id, { encoding: 'utf8', mode: 0o600 });
|
||||
return;
|
||||
} catch {}
|
||||
try {
|
||||
fs.writeFileSync(LOCAL_NODE_ID_FILE, id, { encoding: 'utf8', mode: 0o600 });
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function generateMessageId() {
|
||||
return 'msg_' + Date.now() + '_' + crypto.randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
function getNodeId() {
|
||||
if (_cachedNodeId) return _cachedNodeId;
|
||||
|
||||
if (process.env.A2A_NODE_ID) {
|
||||
_cachedNodeId = String(process.env.A2A_NODE_ID);
|
||||
return _cachedNodeId;
|
||||
}
|
||||
|
||||
const persisted = _loadPersistedNodeId();
|
||||
if (persisted) {
|
||||
_cachedNodeId = persisted;
|
||||
return _cachedNodeId;
|
||||
}
|
||||
|
||||
const deviceId = getDeviceId();
|
||||
const agentName = process.env.AGENT_NAME || 'default';
|
||||
const raw = deviceId + '|' + agentName + '|' + process.cwd();
|
||||
const computed = 'node_' + crypto.createHash('sha256').update(raw).digest('hex').slice(0, 12);
|
||||
|
||||
_persistNodeId(computed);
|
||||
_cachedNodeId = computed;
|
||||
return _cachedNodeId;
|
||||
}
|
||||
|
||||
// --- Base message builder ---
|
||||
|
||||
function buildMessage(params) {
|
||||
var messageType = params.messageType;
|
||||
var payload = params.payload;
|
||||
var senderId = params.senderId;
|
||||
if (!VALID_MESSAGE_TYPES.includes(messageType)) {
|
||||
throw new Error('Invalid message type: ' + messageType + '. Valid: ' + VALID_MESSAGE_TYPES.join(', '));
|
||||
}
|
||||
return {
|
||||
protocol: PROTOCOL_NAME,
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
message_type: messageType,
|
||||
message_id: generateMessageId(),
|
||||
sender_id: senderId || getNodeId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: payload || {},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Typed message builders ---
|
||||
|
||||
function buildHello(opts) {
|
||||
var o = opts || {};
|
||||
return buildMessage({
|
||||
messageType: 'hello',
|
||||
senderId: o.nodeId,
|
||||
payload: {
|
||||
capabilities: o.capabilities || {},
|
||||
gene_count: typeof o.geneCount === 'number' ? o.geneCount : null,
|
||||
capsule_count: typeof o.capsuleCount === 'number' ? o.capsuleCount : null,
|
||||
env_fingerprint: captureEnvFingerprint(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildPublish(opts) {
|
||||
var o = opts || {};
|
||||
var asset = o.asset;
|
||||
if (!asset || !asset.type || !asset.id) {
|
||||
throw new Error('publish: asset must have type and id');
|
||||
}
|
||||
// Generate signature: HMAC-SHA256 of asset_id with node secret
|
||||
var assetIdVal = asset.asset_id || computeAssetId(asset);
|
||||
var nodeSecret = process.env.A2A_NODE_SECRET || getNodeId();
|
||||
var signature = crypto.createHmac('sha256', nodeSecret).update(assetIdVal).digest('hex');
|
||||
return buildMessage({
|
||||
messageType: 'publish',
|
||||
senderId: o.nodeId,
|
||||
payload: {
|
||||
asset_type: asset.type,
|
||||
asset_id: assetIdVal,
|
||||
local_id: asset.id,
|
||||
asset: asset,
|
||||
signature: signature,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Build a bundle publish message containing Gene + Capsule (+ optional EvolutionEvent).
|
||||
// Hub requires payload.assets = [Gene, Capsule] since bundle enforcement was added.
|
||||
function buildPublishBundle(opts) {
|
||||
var o = opts || {};
|
||||
var gene = o.gene;
|
||||
var capsule = o.capsule;
|
||||
var event = o.event || null;
|
||||
if (!gene || gene.type !== 'Gene' || !gene.id) {
|
||||
throw new Error('publishBundle: gene must be a valid Gene with type and id');
|
||||
}
|
||||
if (!capsule || capsule.type !== 'Capsule' || !capsule.id) {
|
||||
throw new Error('publishBundle: capsule must be a valid Capsule with type and id');
|
||||
}
|
||||
var geneAssetId = gene.asset_id || computeAssetId(gene);
|
||||
var capsuleAssetId = capsule.asset_id || computeAssetId(capsule);
|
||||
var nodeSecret = process.env.A2A_NODE_SECRET || getNodeId();
|
||||
var signatureInput = [geneAssetId, capsuleAssetId].sort().join('|');
|
||||
var signature = crypto.createHmac('sha256', nodeSecret).update(signatureInput).digest('hex');
|
||||
if (o.modelName && typeof o.modelName === 'string') {
|
||||
gene.model_name = o.modelName;
|
||||
capsule.model_name = o.modelName;
|
||||
}
|
||||
var assets = [gene, capsule];
|
||||
if (event && event.type === 'EvolutionEvent') {
|
||||
if (o.modelName && typeof o.modelName === 'string') {
|
||||
event.model_name = o.modelName;
|
||||
}
|
||||
assets.push(event);
|
||||
}
|
||||
var publishPayload = {
|
||||
assets: assets,
|
||||
signature: signature,
|
||||
};
|
||||
if (o.chainId && typeof o.chainId === 'string') {
|
||||
publishPayload.chain_id = o.chainId;
|
||||
}
|
||||
return buildMessage({
|
||||
messageType: 'publish',
|
||||
senderId: o.nodeId,
|
||||
payload: publishPayload,
|
||||
});
|
||||
}
|
||||
|
||||
function buildFetch(opts) {
|
||||
var o = opts || {};
|
||||
return buildMessage({
|
||||
messageType: 'fetch',
|
||||
senderId: o.nodeId,
|
||||
payload: {
|
||||
asset_type: o.assetType || null,
|
||||
local_id: o.localId || null,
|
||||
content_hash: o.contentHash || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildReport(opts) {
|
||||
var o = opts || {};
|
||||
return buildMessage({
|
||||
messageType: 'report',
|
||||
senderId: o.nodeId,
|
||||
payload: {
|
||||
target_asset_id: o.assetId || null,
|
||||
target_local_id: o.localId || null,
|
||||
validation_report: o.validationReport || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildDecision(opts) {
|
||||
var o = opts || {};
|
||||
var validDecisions = ['accept', 'reject', 'quarantine'];
|
||||
if (!validDecisions.includes(o.decision)) {
|
||||
throw new Error('decision must be one of: ' + validDecisions.join(', '));
|
||||
}
|
||||
return buildMessage({
|
||||
messageType: 'decision',
|
||||
senderId: o.nodeId,
|
||||
payload: {
|
||||
target_asset_id: o.assetId || null,
|
||||
target_local_id: o.localId || null,
|
||||
decision: o.decision,
|
||||
reason: o.reason || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildRevoke(opts) {
|
||||
var o = opts || {};
|
||||
return buildMessage({
|
||||
messageType: 'revoke',
|
||||
senderId: o.nodeId,
|
||||
payload: {
|
||||
target_asset_id: o.assetId || null,
|
||||
target_local_id: o.localId || null,
|
||||
reason: o.reason || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
function isValidProtocolMessage(msg) {
|
||||
if (!msg || typeof msg !== 'object') return false;
|
||||
if (msg.protocol !== PROTOCOL_NAME) return false;
|
||||
if (!msg.message_type || !VALID_MESSAGE_TYPES.includes(msg.message_type)) return false;
|
||||
if (!msg.message_id || typeof msg.message_id !== 'string') return false;
|
||||
if (!msg.timestamp || typeof msg.timestamp !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to extract a raw asset from either a protocol message or a plain asset object.
|
||||
// This enables backward-compatible ingestion of both old-format and new-format payloads.
|
||||
function unwrapAssetFromMessage(input) {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
// If it is a protocol message with a publish payload, extract the asset.
|
||||
if (input.protocol === PROTOCOL_NAME && input.message_type === 'publish') {
|
||||
var p = input.payload;
|
||||
if (p && p.asset && typeof p.asset === 'object') return p.asset;
|
||||
return null;
|
||||
}
|
||||
// If it is a plain asset (Gene/Capsule/EvolutionEvent), return as-is.
|
||||
if (input.type === 'Gene' || input.type === 'Capsule' || input.type === 'EvolutionEvent') {
|
||||
return input;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- File Transport ---
|
||||
|
||||
function ensureDir(dir) {
|
||||
try {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function defaultA2ADir() {
|
||||
return process.env.A2A_DIR || path.join(getGepAssetsDir(), 'a2a');
|
||||
}
|
||||
|
||||
function fileTransportSend(message, opts) {
|
||||
var dir = (opts && opts.dir) || defaultA2ADir();
|
||||
var subdir = path.join(dir, 'outbox');
|
||||
ensureDir(subdir);
|
||||
var filePath = path.join(subdir, message.message_type + '.jsonl');
|
||||
fs.appendFileSync(filePath, JSON.stringify(message) + '\n', 'utf8');
|
||||
return { ok: true, path: filePath };
|
||||
}
|
||||
|
||||
function fileTransportReceive(opts) {
|
||||
var dir = (opts && opts.dir) || defaultA2ADir();
|
||||
var subdir = path.join(dir, 'inbox');
|
||||
if (!fs.existsSync(subdir)) return [];
|
||||
var files = fs.readdirSync(subdir).filter(function (f) { return f.endsWith('.jsonl'); });
|
||||
var messages = [];
|
||||
for (var fi = 0; fi < files.length; fi++) {
|
||||
try {
|
||||
var raw = fs.readFileSync(path.join(subdir, files[fi]), 'utf8');
|
||||
var lines = raw.split('\n').map(function (l) { return l.trim(); }).filter(Boolean);
|
||||
for (var li = 0; li < lines.length; li++) {
|
||||
try {
|
||||
var msg = JSON.parse(lines[li]);
|
||||
if (msg && msg.protocol === PROTOCOL_NAME) messages.push(msg);
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function fileTransportList(opts) {
|
||||
var dir = (opts && opts.dir) || defaultA2ADir();
|
||||
var subdir = path.join(dir, 'outbox');
|
||||
if (!fs.existsSync(subdir)) return [];
|
||||
return fs.readdirSync(subdir).filter(function (f) { return f.endsWith('.jsonl'); });
|
||||
}
|
||||
|
||||
// --- HTTP Transport (connects to evomap-hub) ---
|
||||
|
||||
function httpTransportSend(message, opts) {
|
||||
var hubUrl = (opts && opts.hubUrl) || process.env.A2A_HUB_URL;
|
||||
if (!hubUrl) return { ok: false, error: 'A2A_HUB_URL not set' };
|
||||
var endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/' + message.message_type;
|
||||
var body = JSON.stringify(message);
|
||||
// Use dynamic import for fetch (available in Node 18+)
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body,
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) { return { ok: true, response: data }; })
|
||||
.catch(function (err) { return { ok: false, error: err.message }; });
|
||||
}
|
||||
|
||||
function httpTransportReceive(opts) {
|
||||
var hubUrl = (opts && opts.hubUrl) || process.env.A2A_HUB_URL;
|
||||
if (!hubUrl) return Promise.resolve([]);
|
||||
var assetType = (opts && opts.assetType) || null;
|
||||
var fetchMsg = buildFetch({ assetType: assetType });
|
||||
var endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/fetch';
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fetchMsg),
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
if (data && data.payload && Array.isArray(data.payload.results)) {
|
||||
return data.payload.results;
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.catch(function () { return []; });
|
||||
}
|
||||
|
||||
function httpTransportList() {
|
||||
return ['http'];
|
||||
}
|
||||
|
||||
// --- Heartbeat ---
|
||||
|
||||
var _heartbeatTimer = null;
|
||||
var _heartbeatStartedAt = null;
|
||||
var _heartbeatConsecutiveFailures = 0;
|
||||
var _heartbeatTotalSent = 0;
|
||||
var _heartbeatTotalFailed = 0;
|
||||
|
||||
function getHubUrl() {
|
||||
return process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || '';
|
||||
}
|
||||
|
||||
function sendHelloToHub() {
|
||||
var hubUrl = getHubUrl();
|
||||
if (!hubUrl) return Promise.resolve({ ok: false, error: 'no_hub_url' });
|
||||
|
||||
var endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/hello';
|
||||
var nodeId = getNodeId();
|
||||
var msg = buildHello({ nodeId: nodeId, capabilities: {} });
|
||||
msg.sender_id = nodeId;
|
||||
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(msg),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) { return { ok: true, response: data }; })
|
||||
.catch(function (err) { return { ok: false, error: err.message }; });
|
||||
}
|
||||
|
||||
function sendHeartbeat() {
|
||||
var hubUrl = getHubUrl();
|
||||
if (!hubUrl) return Promise.resolve({ ok: false, error: 'no_hub_url' });
|
||||
|
||||
var endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/heartbeat';
|
||||
var nodeId = getNodeId();
|
||||
var body = JSON.stringify({
|
||||
node_id: nodeId,
|
||||
sender_id: nodeId,
|
||||
version: PROTOCOL_VERSION,
|
||||
uptime_ms: _heartbeatStartedAt ? Date.now() - _heartbeatStartedAt : 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
_heartbeatTotalSent++;
|
||||
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
if (data && data.status === 'unknown_node') {
|
||||
console.warn('[Heartbeat] Node not registered on hub. Sending hello to re-register...');
|
||||
return sendHelloToHub().then(function (helloResult) {
|
||||
if (helloResult.ok) {
|
||||
console.log('[Heartbeat] Re-registered with hub successfully.');
|
||||
_heartbeatConsecutiveFailures = 0;
|
||||
} else {
|
||||
console.warn('[Heartbeat] Re-registration failed: ' + (helloResult.error || 'unknown'));
|
||||
}
|
||||
return { ok: helloResult.ok, response: data, reregistered: helloResult.ok };
|
||||
});
|
||||
}
|
||||
_heartbeatConsecutiveFailures = 0;
|
||||
return { ok: true, response: data };
|
||||
})
|
||||
.catch(function (err) {
|
||||
_heartbeatConsecutiveFailures++;
|
||||
_heartbeatTotalFailed++;
|
||||
if (_heartbeatConsecutiveFailures === 3) {
|
||||
console.warn('[Heartbeat] 3 consecutive failures. Network issue? Last error: ' + err.message);
|
||||
} else if (_heartbeatConsecutiveFailures === 10) {
|
||||
console.warn('[Heartbeat] 10 consecutive failures. Hub may be unreachable. (' + err.message + ')');
|
||||
} else if (_heartbeatConsecutiveFailures % 50 === 0) {
|
||||
console.warn('[Heartbeat] ' + _heartbeatConsecutiveFailures + ' consecutive failures. (' + err.message + ')');
|
||||
}
|
||||
return { ok: false, error: err.message };
|
||||
});
|
||||
}
|
||||
|
||||
function startHeartbeat(intervalMs) {
|
||||
if (_heartbeatTimer) return;
|
||||
var interval = intervalMs || Number(process.env.HEARTBEAT_INTERVAL_MS) || 120000; // default 2min
|
||||
_heartbeatStartedAt = Date.now();
|
||||
|
||||
// Register with hub first (hello), then start heartbeat loop
|
||||
sendHelloToHub().then(function (r) {
|
||||
if (r.ok) console.log('[Heartbeat] Registered with hub. Node: ' + getNodeId());
|
||||
else console.warn('[Heartbeat] Hello failed (will retry via heartbeat): ' + (r.error || 'unknown'));
|
||||
}).catch(function () {});
|
||||
|
||||
// First heartbeat after a short delay (let hello complete first)
|
||||
setTimeout(function () {
|
||||
sendHeartbeat().then(function (r) {
|
||||
if (r.ok) console.log('[Heartbeat] Connected to hub. Node: ' + getNodeId());
|
||||
}).catch(function () {});
|
||||
}, 5000);
|
||||
|
||||
_heartbeatTimer = setInterval(function () {
|
||||
sendHeartbeat().catch(function () {});
|
||||
}, interval);
|
||||
|
||||
if (_heartbeatTimer.unref) _heartbeatTimer.unref();
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (_heartbeatTimer) {
|
||||
clearInterval(_heartbeatTimer);
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getHeartbeatStats() {
|
||||
return {
|
||||
running: !!_heartbeatTimer,
|
||||
uptimeMs: _heartbeatStartedAt ? Date.now() - _heartbeatStartedAt : 0,
|
||||
totalSent: _heartbeatTotalSent,
|
||||
totalFailed: _heartbeatTotalFailed,
|
||||
consecutiveFailures: _heartbeatConsecutiveFailures,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Transport registry ---
|
||||
|
||||
var transports = {
|
||||
file: {
|
||||
send: fileTransportSend,
|
||||
receive: fileTransportReceive,
|
||||
list: fileTransportList,
|
||||
},
|
||||
http: {
|
||||
send: httpTransportSend,
|
||||
receive: httpTransportReceive,
|
||||
list: httpTransportList,
|
||||
},
|
||||
};
|
||||
|
||||
function getTransport(name) {
|
||||
var n = String(name || process.env.A2A_TRANSPORT || 'file').toLowerCase();
|
||||
var t = transports[n];
|
||||
if (!t) throw new Error('Unknown A2A transport: ' + n + '. Available: ' + Object.keys(transports).join(', '));
|
||||
return t;
|
||||
}
|
||||
|
||||
function registerTransport(name, impl) {
|
||||
if (!name || typeof name !== 'string') throw new Error('transport name required');
|
||||
if (!impl || typeof impl.send !== 'function' || typeof impl.receive !== 'function') {
|
||||
throw new Error('transport must implement send() and receive()');
|
||||
}
|
||||
transports[name] = impl;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROTOCOL_NAME,
|
||||
PROTOCOL_VERSION,
|
||||
VALID_MESSAGE_TYPES,
|
||||
getNodeId,
|
||||
buildMessage,
|
||||
buildHello,
|
||||
buildPublish,
|
||||
buildPublishBundle,
|
||||
buildFetch,
|
||||
buildReport,
|
||||
buildDecision,
|
||||
buildRevoke,
|
||||
isValidProtocolMessage,
|
||||
unwrapAssetFromMessage,
|
||||
getTransport,
|
||||
registerTransport,
|
||||
fileTransportSend,
|
||||
fileTransportReceive,
|
||||
fileTransportList,
|
||||
httpTransportSend,
|
||||
httpTransportReceive,
|
||||
httpTransportList,
|
||||
sendHeartbeat,
|
||||
sendHelloToHub,
|
||||
startHeartbeat,
|
||||
stopHeartbeat,
|
||||
getHeartbeatStats,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Innovation: Self-Correction Analyzer
|
||||
// Analyze past failures to suggest better future mutations
|
||||
// Pattern: Meta-learning
|
||||
|
||||
function analyzeFailures() {
|
||||
const memoryPath = path.join(process.cwd(), 'MEMORY.md');
|
||||
if (!fs.existsSync(memoryPath)) return { status: 'skipped', reason: 'no_memory' };
|
||||
|
||||
const content = fs.readFileSync(memoryPath, 'utf8');
|
||||
const failureRegex = /\|\s*\*\*F\d+\*\*\s*\|\s*Fix\s*\|\s*(.*?)\s*\|\s*\*\*(.*?)\*\*\s*\((.*?)\)\s*\|/g;
|
||||
|
||||
const failures = [];
|
||||
let match;
|
||||
while ((match = failureRegex.exec(content)) !== null) {
|
||||
failures.push({
|
||||
summary: match[1].trim(),
|
||||
detail: match[2].trim()
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
count: failures.length,
|
||||
failures: failures.slice(0, 3) // Return top 3 for prompt context
|
||||
};
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
console.log(JSON.stringify(analyzeFailures(), null, 2));
|
||||
}
|
||||
|
||||
module.exports = { analyzeFailures };
|
||||
@@ -0,0 +1,297 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getGepAssetsDir } = require('./paths');
|
||||
const { computeAssetId, SCHEMA_VERSION } = require('./contentHash');
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function readJsonIfExists(filePath, fallback) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
if (!raw.trim()) return fallback;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonAtomic(filePath, obj) {
|
||||
const dir = path.dirname(filePath);
|
||||
ensureDir(dir);
|
||||
const tmp = `${filePath}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
// Build a validation command using repo-root-relative paths.
|
||||
// runValidations() executes with cwd=repoRoot, so require('./src/...')
|
||||
// resolves correctly without embedding machine-specific absolute paths.
|
||||
function buildValidationCmd(relModules) {
|
||||
const paths = relModules.map(m => `./${m}`);
|
||||
return `node scripts/validate-modules.js ${paths.join(' ')}`;
|
||||
}
|
||||
|
||||
function getDefaultGenes() {
|
||||
return {
|
||||
version: 1,
|
||||
genes: [
|
||||
{
|
||||
type: 'Gene', id: 'gene_gep_repair_from_errors', category: 'repair',
|
||||
signals_match: ['error', 'exception', 'failed', 'unstable'],
|
||||
preconditions: ['signals contains error-related indicators'],
|
||||
strategy: [
|
||||
'Extract structured signals from logs and user instructions',
|
||||
'Select an existing Gene by signals match (no improvisation)',
|
||||
'Estimate blast radius (files, lines) before editing',
|
||||
'Apply smallest reversible patch',
|
||||
'Validate using declared validation steps; rollback on failure',
|
||||
'Solidify knowledge: append EvolutionEvent, update Gene/Capsule store',
|
||||
],
|
||||
constraints: { max_files: 12, forbidden_paths: ['.git', 'node_modules'] },
|
||||
validation: [
|
||||
buildValidationCmd(['src/evolve', 'src/gep/solidify']),
|
||||
buildValidationCmd(['src/gep/selector', 'src/gep/memoryGraph']),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'Gene', id: 'gene_gep_optimize_prompt_and_assets', category: 'optimize',
|
||||
signals_match: ['protocol', 'gep', 'prompt', 'audit', 'reusable'],
|
||||
preconditions: ['need stricter, auditable evolution protocol outputs'],
|
||||
strategy: [
|
||||
'Extract signals and determine selection rationale via Selector JSON',
|
||||
'Prefer reusing existing Gene/Capsule; only create if no match exists',
|
||||
'Refactor prompt assembly to embed assets (genes, capsules, parent event)',
|
||||
'Reduce noise and ambiguity; enforce strict output schema',
|
||||
'Validate by running node index.js run and ensuring no runtime errors',
|
||||
'Solidify: record EvolutionEvent, update Gene definitions, create Capsule on success',
|
||||
],
|
||||
constraints: { max_files: 20, forbidden_paths: ['.git', 'node_modules'] },
|
||||
validation: [buildValidationCmd(['src/evolve', 'src/gep/prompt'])],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultCapsules() { return { version: 1, capsules: [] }; }
|
||||
function genesPath() { return path.join(getGepAssetsDir(), 'genes.json'); }
|
||||
function capsulesPath() { return path.join(getGepAssetsDir(), 'capsules.json'); }
|
||||
function capsulesJsonlPath() { return path.join(getGepAssetsDir(), 'capsules.jsonl'); }
|
||||
function eventsPath() { return path.join(getGepAssetsDir(), 'events.jsonl'); }
|
||||
function candidatesPath() { return path.join(getGepAssetsDir(), 'candidates.jsonl'); }
|
||||
function externalCandidatesPath() { return path.join(getGepAssetsDir(), 'external_candidates.jsonl'); }
|
||||
function failedCapsulesPath() { return path.join(getGepAssetsDir(), 'failed_capsules.json'); }
|
||||
|
||||
function loadGenes() {
|
||||
const jsonGenes = readJsonIfExists(genesPath(), getDefaultGenes()).genes || [];
|
||||
const jsonlGenes = [];
|
||||
try {
|
||||
const p = path.join(getGepAssetsDir(), 'genes.jsonl');
|
||||
if (fs.existsSync(p)) {
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
raw.split('\n').forEach(line => {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed && parsed.type === 'Gene') jsonlGenes.push(parsed);
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Combine and deduplicate by ID (JSONL takes precedence if newer, but here we just merge)
|
||||
const combined = [...jsonGenes, ...jsonlGenes];
|
||||
const unique = new Map();
|
||||
combined.forEach(g => {
|
||||
if (g && g.id) unique.set(String(g.id), g);
|
||||
});
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
function loadCapsules() {
|
||||
const legacy = readJsonIfExists(capsulesPath(), getDefaultCapsules()).capsules || [];
|
||||
const jsonlCapsules = [];
|
||||
try {
|
||||
const p = capsulesJsonlPath();
|
||||
if (fs.existsSync(p)) {
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
raw.split('\n').forEach(line => {
|
||||
if (line.trim()) {
|
||||
try { jsonlCapsules.push(JSON.parse(line)); } catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Combine and deduplicate by ID
|
||||
const combined = [...legacy, ...jsonlCapsules];
|
||||
const unique = new Map();
|
||||
combined.forEach(c => {
|
||||
if (c && c.id) unique.set(String(c.id), c);
|
||||
});
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
function getLastEventId() {
|
||||
try {
|
||||
const p = eventsPath();
|
||||
if (!fs.existsSync(p)) return null;
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
if (lines.length === 0) return null;
|
||||
const last = JSON.parse(lines[lines.length - 1]);
|
||||
return last && typeof last.id === 'string' ? last.id : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function readAllEvents() {
|
||||
try {
|
||||
const p = eventsPath();
|
||||
if (!fs.existsSync(p)) return [];
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
return raw.split('\n').map(l => l.trim()).filter(Boolean).map(l => {
|
||||
try { return JSON.parse(l); } catch { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function appendEventJsonl(eventObj) {
|
||||
const dir = getGepAssetsDir(); ensureDir(dir);
|
||||
fs.appendFileSync(eventsPath(), JSON.stringify(eventObj) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function appendCandidateJsonl(candidateObj) {
|
||||
const dir = getGepAssetsDir(); ensureDir(dir);
|
||||
fs.appendFileSync(candidatesPath(), JSON.stringify(candidateObj) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function appendExternalCandidateJsonl(obj) {
|
||||
const dir = getGepAssetsDir(); ensureDir(dir);
|
||||
fs.appendFileSync(externalCandidatesPath(), JSON.stringify(obj) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function readRecentCandidates(limit = 20) {
|
||||
try {
|
||||
const p = candidatesPath();
|
||||
if (!fs.existsSync(p)) return [];
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
return lines.slice(Math.max(0, lines.length - limit)).map(l => {
|
||||
try { return JSON.parse(l); } catch { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function readRecentExternalCandidates(limit = 50) {
|
||||
try {
|
||||
const p = externalCandidatesPath();
|
||||
if (!fs.existsSync(p)) return [];
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
return lines.slice(Math.max(0, lines.length - limit)).map(l => {
|
||||
try { return JSON.parse(l); } catch { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Safety net: ensure schema_version and asset_id are present before writing.
|
||||
function ensureSchemaFields(obj) {
|
||||
if (!obj || typeof obj !== 'object') return obj;
|
||||
if (!obj.schema_version) obj.schema_version = SCHEMA_VERSION;
|
||||
if (!obj.asset_id) { try { obj.asset_id = computeAssetId(obj); } catch (e) {} }
|
||||
return obj;
|
||||
}
|
||||
|
||||
function upsertGene(geneObj) {
|
||||
ensureSchemaFields(geneObj);
|
||||
const current = readJsonIfExists(genesPath(), getDefaultGenes());
|
||||
const genes = Array.isArray(current.genes) ? current.genes : [];
|
||||
const idx = genes.findIndex(g => g && g.id === geneObj.id);
|
||||
if (idx >= 0) genes[idx] = geneObj; else genes.push(geneObj);
|
||||
writeJsonAtomic(genesPath(), { version: current.version || 1, genes });
|
||||
}
|
||||
|
||||
function appendCapsule(capsuleObj) {
|
||||
ensureSchemaFields(capsuleObj);
|
||||
const current = readJsonIfExists(capsulesPath(), getDefaultCapsules());
|
||||
const capsules = Array.isArray(current.capsules) ? current.capsules : [];
|
||||
capsules.push(capsuleObj);
|
||||
writeJsonAtomic(capsulesPath(), { version: current.version || 1, capsules });
|
||||
}
|
||||
|
||||
function upsertCapsule(capsuleObj) {
|
||||
if (!capsuleObj || capsuleObj.type !== 'Capsule' || !capsuleObj.id) return;
|
||||
ensureSchemaFields(capsuleObj);
|
||||
const current = readJsonIfExists(capsulesPath(), getDefaultCapsules());
|
||||
const capsules = Array.isArray(current.capsules) ? current.capsules : [];
|
||||
const idx = capsules.findIndex(c => c && c.type === 'Capsule' && String(c.id) === String(capsuleObj.id));
|
||||
if (idx >= 0) capsules[idx] = capsuleObj; else capsules.push(capsuleObj);
|
||||
writeJsonAtomic(capsulesPath(), { version: current.version || 1, capsules });
|
||||
}
|
||||
|
||||
var FAILED_CAPSULES_MAX = 200;
|
||||
var FAILED_CAPSULES_TRIM_TO = 100;
|
||||
|
||||
function getDefaultFailedCapsules() { return { version: 1, failed_capsules: [] }; }
|
||||
|
||||
function appendFailedCapsule(capsuleObj) {
|
||||
if (!capsuleObj || typeof capsuleObj !== 'object') return;
|
||||
ensureSchemaFields(capsuleObj);
|
||||
var current = readJsonIfExists(failedCapsulesPath(), getDefaultFailedCapsules());
|
||||
var list = Array.isArray(current.failed_capsules) ? current.failed_capsules : [];
|
||||
list.push(capsuleObj);
|
||||
if (list.length > FAILED_CAPSULES_MAX) {
|
||||
list = list.slice(list.length - FAILED_CAPSULES_TRIM_TO);
|
||||
}
|
||||
writeJsonAtomic(failedCapsulesPath(), { version: current.version || 1, failed_capsules: list });
|
||||
}
|
||||
|
||||
function readRecentFailedCapsules(limit) {
|
||||
var n = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Number(limit) : 50;
|
||||
try {
|
||||
var current = readJsonIfExists(failedCapsulesPath(), getDefaultFailedCapsules());
|
||||
var list = Array.isArray(current.failed_capsules) ? current.failed_capsules : [];
|
||||
return list.slice(Math.max(0, list.length - n));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all expected asset files exist on startup.
|
||||
// Creates empty files for optional append-only stores so that
|
||||
// external grep/read commands never fail with "No such file or directory".
|
||||
function ensureAssetFiles() {
|
||||
const dir = getGepAssetsDir();
|
||||
ensureDir(dir);
|
||||
const files = [
|
||||
{ path: genesPath(), defaultContent: JSON.stringify(getDefaultGenes(), null, 2) + '\n' },
|
||||
{ path: capsulesPath(), defaultContent: JSON.stringify(getDefaultCapsules(), null, 2) + '\n' },
|
||||
{ path: path.join(dir, 'genes.jsonl'), defaultContent: '' },
|
||||
{ path: eventsPath(), defaultContent: '' },
|
||||
{ path: candidatesPath(), defaultContent: '' },
|
||||
{ path: failedCapsulesPath(), defaultContent: JSON.stringify(getDefaultFailedCapsules(), null, 2) + '\n' },
|
||||
];
|
||||
for (const f of files) {
|
||||
if (!fs.existsSync(f.path)) {
|
||||
try {
|
||||
fs.writeFileSync(f.path, f.defaultContent, 'utf8');
|
||||
} catch (e) {
|
||||
// Non-fatal: log but continue
|
||||
console.error(`[AssetStore] Failed to create ${f.path}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadGenes, loadCapsules, readAllEvents, getLastEventId,
|
||||
appendEventJsonl, appendCandidateJsonl, appendExternalCandidateJsonl,
|
||||
readRecentCandidates, readRecentExternalCandidates,
|
||||
upsertGene, appendCapsule, upsertCapsule,
|
||||
appendFailedCapsule, readRecentFailedCapsules,
|
||||
genesPath, capsulesPath, eventsPath, candidatesPath, externalCandidatesPath, failedCapsulesPath,
|
||||
ensureAssetFiles, buildValidationCmd,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
const { computeAssetId, SCHEMA_VERSION } = require('./contentHash');
|
||||
|
||||
/**
|
||||
* Format asset preview for prompt inclusion.
|
||||
* Handles stringified JSON, arrays, and error cases gracefully.
|
||||
*/
|
||||
function formatAssetPreview(preview) {
|
||||
if (!preview) return '(none)';
|
||||
if (typeof preview === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(preview);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
return preview; // Keep as string if not array or empty
|
||||
} catch (e) {
|
||||
return preview; // Keep as string if parse fails
|
||||
}
|
||||
}
|
||||
return JSON.stringify(preview, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize an asset object.
|
||||
* Ensures schema version and ID are present.
|
||||
*/
|
||||
function normalizeAsset(asset) {
|
||||
if (!asset || typeof asset !== 'object') return asset;
|
||||
if (!asset.schema_version) asset.schema_version = SCHEMA_VERSION;
|
||||
if (!asset.asset_id) {
|
||||
try { asset.asset_id = computeAssetId(asset); } catch (e) {}
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
|
||||
module.exports = { formatAssetPreview, normalizeAsset };
|
||||
@@ -0,0 +1,71 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function ensureDir(dir) {
|
||||
try {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function clip(text, maxChars) {
|
||||
const s = String(text || '');
|
||||
const n = Number(maxChars);
|
||||
if (!Number.isFinite(n) || n <= 0) return s;
|
||||
if (s.length <= n) return s;
|
||||
return s.slice(0, Math.max(0, n - 40)) + '\n...[TRUNCATED]...\n';
|
||||
}
|
||||
|
||||
function writePromptArtifact({ memoryDir, cycleId, runId, prompt, meta }) {
|
||||
const dir = String(memoryDir || '').trim();
|
||||
if (!dir) throw new Error('bridge: missing memoryDir');
|
||||
ensureDir(dir);
|
||||
const safeCycle = String(cycleId || 'cycle').replace(/[^a-zA-Z0-9_\-#]/g, '_');
|
||||
const safeRun = String(runId || Date.now()).replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||
const base = `gep_prompt_${safeCycle}_${safeRun}`;
|
||||
const promptPath = path.join(dir, base + '.txt');
|
||||
const metaPath = path.join(dir, base + '.json');
|
||||
|
||||
fs.writeFileSync(promptPath, String(prompt || ''), 'utf8');
|
||||
fs.writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
type: 'GepPromptArtifact',
|
||||
at: nowIso(),
|
||||
cycle_id: cycleId || null,
|
||||
run_id: runId || null,
|
||||
prompt_path: promptPath,
|
||||
meta: meta && typeof meta === 'object' ? meta : null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
return { promptPath, metaPath };
|
||||
}
|
||||
|
||||
function renderSessionsSpawnCall({ task, agentId, label, cleanup }) {
|
||||
const t = String(task || '').trim();
|
||||
if (!t) throw new Error('bridge: missing task');
|
||||
const a = String(agentId || 'main');
|
||||
const l = String(label || 'gep_bridge');
|
||||
const c = cleanup ? String(cleanup) : 'delete';
|
||||
|
||||
// Output valid JSON so wrappers can parse with JSON.parse (not regex).
|
||||
// The wrapper uses lastIndexOf('sessions_spawn(') + JSON.parse to extract the task.
|
||||
const payload = JSON.stringify({ task: t, agentId: a, cleanup: c, label: l });
|
||||
return `sessions_spawn(${payload})`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clip,
|
||||
writePromptArtifact,
|
||||
renderSessionsSpawnCall,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
function stableHash(input) {
|
||||
// Deterministic lightweight hash (not cryptographic).
|
||||
const s = String(input || '');
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function clip(text, maxChars) {
|
||||
const s = String(text || '');
|
||||
if (!maxChars || s.length <= maxChars) return s;
|
||||
return s.slice(0, Math.max(0, maxChars - 20)) + ' ...[TRUNCATED]';
|
||||
}
|
||||
|
||||
function toLines(text) {
|
||||
return String(text || '')
|
||||
.split('\n')
|
||||
.map(l => l.trimEnd())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractToolCalls(transcript) {
|
||||
const lines = toLines(transcript);
|
||||
const calls = [];
|
||||
for (const line of lines) {
|
||||
const m = line.match(/\[TOOL:\s*([^\]]+)\]/i);
|
||||
if (m && m[1]) calls.push(m[1].trim());
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function countFreq(items) {
|
||||
const map = new Map();
|
||||
for (const it of items) map.set(it, (map.get(it) || 0) + 1);
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildFiveQuestionsShape({ title, signals, evidence }) {
|
||||
// Keep it short and structured; this is a template, not a perfect inference.
|
||||
const input = 'Recent session transcript + memory snippets + user instructions';
|
||||
const output = 'A safe, auditable evolution patch guided by GEP assets';
|
||||
const invariants = 'Protocol order, small reversible patches, validation, append-only events';
|
||||
const params = `Signals: ${Array.isArray(signals) ? signals.join(', ') : ''}`.trim();
|
||||
const failurePoints = 'Missing signals, over-broad changes, skipped validation, missing knowledge solidification';
|
||||
return {
|
||||
title: String(title || '').slice(0, 120),
|
||||
input,
|
||||
output,
|
||||
invariants,
|
||||
params: params || 'Signals: (none)',
|
||||
failure_points: failurePoints,
|
||||
evidence: clip(evidence, 240),
|
||||
};
|
||||
}
|
||||
|
||||
function extractCapabilityCandidates({ recentSessionTranscript, signals }) {
|
||||
const candidates = [];
|
||||
const toolCalls = extractToolCalls(recentSessionTranscript);
|
||||
const freq = countFreq(toolCalls);
|
||||
|
||||
for (const [tool, count] of freq.entries()) {
|
||||
if (count < 2) continue;
|
||||
const title = `Repeated tool usage: ${tool}`;
|
||||
const evidence = `Observed ${count} occurrences of tool call marker for ${tool}.`;
|
||||
const shape = buildFiveQuestionsShape({ title, signals, evidence });
|
||||
candidates.push({
|
||||
type: 'CapabilityCandidate',
|
||||
id: `cand_${stableHash(title)}`,
|
||||
title,
|
||||
source: 'transcript',
|
||||
created_at: new Date().toISOString(),
|
||||
signals: Array.isArray(signals) ? signals : [],
|
||||
shape,
|
||||
});
|
||||
}
|
||||
|
||||
// Signals-as-candidates: capture recurring pain points as reusable capability shapes.
|
||||
const signalList = Array.isArray(signals) ? signals : [];
|
||||
const signalCandidates = [
|
||||
// Defensive signals
|
||||
{ signal: 'log_error', title: 'Repair recurring runtime errors' },
|
||||
{ signal: 'protocol_drift', title: 'Prevent protocol drift and enforce auditable outputs' },
|
||||
{ signal: 'windows_shell_incompatible', title: 'Avoid platform-specific shell assumptions (Windows compatibility)' },
|
||||
{ signal: 'session_logs_missing', title: 'Harden session log detection and fallback behavior' },
|
||||
// Opportunity signals (innovation)
|
||||
{ signal: 'user_feature_request', title: 'Implement user-requested feature' },
|
||||
{ signal: 'user_improvement_suggestion', title: 'Apply user improvement suggestion' },
|
||||
{ signal: 'perf_bottleneck', title: 'Resolve performance bottleneck' },
|
||||
{ signal: 'capability_gap', title: 'Fill capability gap' },
|
||||
{ signal: 'stable_success_plateau', title: 'Explore new strategies during stability plateau' },
|
||||
{ signal: 'external_opportunity', title: 'Evaluate external A2A asset for local adoption' },
|
||||
];
|
||||
|
||||
for (const sc of signalCandidates) {
|
||||
if (!signalList.some(s => s === sc.signal || s.startsWith(sc.signal + ':'))) continue;
|
||||
const evidence = `Signal present: ${sc.signal}`;
|
||||
const shape = buildFiveQuestionsShape({ title: sc.title, signals, evidence });
|
||||
candidates.push({
|
||||
type: 'CapabilityCandidate',
|
||||
id: `cand_${stableHash(sc.signal)}`,
|
||||
title: sc.title,
|
||||
source: 'signals',
|
||||
created_at: new Date().toISOString(),
|
||||
signals: signalList,
|
||||
shape,
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup by id
|
||||
const seen = new Set();
|
||||
return candidates.filter(c => {
|
||||
if (!c || !c.id) return false;
|
||||
if (seen.has(c.id)) return false;
|
||||
seen.add(c.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderCandidatesPreview(candidates, maxChars = 1400) {
|
||||
const list = Array.isArray(candidates) ? candidates : [];
|
||||
const lines = [];
|
||||
for (const c of list) {
|
||||
const s = c && c.shape ? c.shape : {};
|
||||
lines.push(`- ${c.id}: ${c.title}`);
|
||||
lines.push(` - input: ${s.input || ''}`);
|
||||
lines.push(` - output: ${s.output || ''}`);
|
||||
lines.push(` - invariants: ${s.invariants || ''}`);
|
||||
lines.push(` - params: ${s.params || ''}`);
|
||||
lines.push(` - failure_points: ${s.failure_points || ''}`);
|
||||
if (s.evidence) lines.push(` - evidence: ${s.evidence}`);
|
||||
}
|
||||
return clip(lines.join('\n'), maxChars);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractCapabilityCandidates,
|
||||
renderCandidatesPreview,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Content-addressable hashing for GEP assets.
|
||||
// Provides canonical JSON serialization and SHA-256 based asset IDs.
|
||||
// This enables deduplication, tamper detection, and cross-node consistency.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Schema version for all GEP asset types.
|
||||
// Bump MINOR for additive fields; MAJOR for breaking changes.
|
||||
const SCHEMA_VERSION = '1.6.0';
|
||||
|
||||
// Canonical JSON: deterministic serialization with sorted keys at all levels.
|
||||
// Arrays preserve order; non-finite numbers become null; undefined becomes null.
|
||||
function canonicalize(obj) {
|
||||
if (obj === null || obj === undefined) return 'null';
|
||||
if (typeof obj === 'boolean') return obj ? 'true' : 'false';
|
||||
if (typeof obj === 'number') {
|
||||
if (!Number.isFinite(obj)) return 'null';
|
||||
return String(obj);
|
||||
}
|
||||
if (typeof obj === 'string') return JSON.stringify(obj);
|
||||
if (Array.isArray(obj)) {
|
||||
return '[' + obj.map(canonicalize).join(',') + ']';
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const keys = Object.keys(obj).sort();
|
||||
const pairs = [];
|
||||
for (const k of keys) {
|
||||
pairs.push(JSON.stringify(k) + ':' + canonicalize(obj[k]));
|
||||
}
|
||||
return '{' + pairs.join(',') + '}';
|
||||
}
|
||||
return 'null';
|
||||
}
|
||||
|
||||
// Compute a content-addressable asset ID.
|
||||
// Excludes self-referential fields (asset_id itself) from the hash input.
|
||||
// Returns "sha256:<hex>".
|
||||
function computeAssetId(obj, excludeFields) {
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
const exclude = new Set(Array.isArray(excludeFields) ? excludeFields : ['asset_id']);
|
||||
const clean = {};
|
||||
for (const k of Object.keys(obj)) {
|
||||
if (exclude.has(k)) continue;
|
||||
clean[k] = obj[k];
|
||||
}
|
||||
const canonical = canonicalize(clean);
|
||||
const hash = crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
|
||||
return 'sha256:' + hash;
|
||||
}
|
||||
|
||||
// Verify that an object's asset_id matches its content.
|
||||
function verifyAssetId(obj) {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const claimed = obj.asset_id;
|
||||
if (!claimed || typeof claimed !== 'string') return false;
|
||||
const computed = computeAssetId(obj);
|
||||
return claimed === computed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SCHEMA_VERSION,
|
||||
canonicalize,
|
||||
computeAssetId,
|
||||
verifyAssetId,
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
// Stable device identifier for node identity.
|
||||
// Generates a hardware-based fingerprint that persists across directory changes,
|
||||
// reboots, and evolver upgrades. Used by getNodeId() and env_fingerprint.
|
||||
//
|
||||
// Priority chain:
|
||||
// 1. EVOMAP_DEVICE_ID env var (explicit override, recommended for containers)
|
||||
// 2. ~/.evomap/device_id file (persisted from previous run)
|
||||
// 3. <project>/.evomap_device_id (fallback persist path for containers w/o $HOME)
|
||||
// 4. /etc/machine-id (Linux, set at OS install)
|
||||
// 5. IOPlatformUUID (macOS hardware UUID)
|
||||
// 6. Docker/OCI container ID (from /proc/self/cgroup or /proc/self/mountinfo)
|
||||
// 7. hostname + MAC addresses (network-based fallback)
|
||||
// 8. random 128-bit hex (last resort, persisted immediately)
|
||||
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DEVICE_ID_DIR = path.join(os.homedir(), '.evomap');
|
||||
const DEVICE_ID_FILE = path.join(DEVICE_ID_DIR, 'device_id');
|
||||
const LOCAL_DEVICE_ID_FILE = path.resolve(__dirname, '..', '..', '.evomap_device_id');
|
||||
|
||||
let _cachedDeviceId = null;
|
||||
|
||||
const DEVICE_ID_RE = /^[a-f0-9]{16,64}$/;
|
||||
|
||||
function isContainer() {
|
||||
try {
|
||||
if (fs.existsSync('/.dockerenv')) return true;
|
||||
} catch {}
|
||||
try {
|
||||
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
|
||||
if (/docker|kubepods|containerd|cri-o|lxc|ecs/i.test(cgroup)) return true;
|
||||
} catch {}
|
||||
try {
|
||||
if (fs.existsSync('/run/.containerenv')) return true;
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readMachineId() {
|
||||
try {
|
||||
const mid = fs.readFileSync('/etc/machine-id', 'utf8').trim();
|
||||
if (mid && mid.length >= 16) return mid;
|
||||
} catch {}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const { execFileSync } = require('child_process');
|
||||
const raw = execFileSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const match = raw.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
|
||||
if (match && match[1]) return match[1];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract Docker/OCI container ID from cgroup or mountinfo.
|
||||
// The container ID is 64-char hex and stable for the lifetime of the container.
|
||||
// Returns null on non-container hosts or if parsing fails.
|
||||
function readContainerId() {
|
||||
// Method 1: /proc/self/cgroup (works for cgroup v1 and most Docker setups)
|
||||
try {
|
||||
const cgroup = fs.readFileSync('/proc/self/cgroup', 'utf8');
|
||||
const match = cgroup.match(/[a-f0-9]{64}/);
|
||||
if (match) return match[0];
|
||||
} catch {}
|
||||
|
||||
// Method 2: /proc/self/mountinfo (works for cgroup v2 / containerd)
|
||||
try {
|
||||
const mountinfo = fs.readFileSync('/proc/self/mountinfo', 'utf8');
|
||||
const match = mountinfo.match(/[a-f0-9]{64}/);
|
||||
if (match) return match[0];
|
||||
} catch {}
|
||||
|
||||
// Method 3: hostname in Docker defaults to short container ID (12 hex chars)
|
||||
if (isContainer()) {
|
||||
const hostname = os.hostname();
|
||||
if (/^[a-f0-9]{12,64}$/.test(hostname)) return hostname;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMacAddresses() {
|
||||
const ifaces = os.networkInterfaces();
|
||||
const macs = [];
|
||||
for (const name of Object.keys(ifaces)) {
|
||||
for (const iface of ifaces[name]) {
|
||||
if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') {
|
||||
macs.push(iface.mac);
|
||||
}
|
||||
}
|
||||
}
|
||||
macs.sort();
|
||||
return macs;
|
||||
}
|
||||
|
||||
function generateDeviceId() {
|
||||
const machineId = readMachineId();
|
||||
if (machineId) {
|
||||
return crypto.createHash('sha256').update('evomap:' + machineId).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
// Container ID: stable for the container's lifetime, but changes on re-create.
|
||||
// Still better than random for keeping identity within a single deployment.
|
||||
const containerId = readContainerId();
|
||||
if (containerId) {
|
||||
return crypto.createHash('sha256').update('evomap:container:' + containerId).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
const macs = getMacAddresses();
|
||||
if (macs.length > 0) {
|
||||
const raw = os.hostname() + '|' + macs.join(',');
|
||||
return crypto.createHash('sha256').update('evomap:' + raw).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
function persistDeviceId(id) {
|
||||
// Try primary path (~/.evomap/device_id)
|
||||
try {
|
||||
if (!fs.existsSync(DEVICE_ID_DIR)) {
|
||||
fs.mkdirSync(DEVICE_ID_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(DEVICE_ID_FILE, id, { encoding: 'utf8', mode: 0o600 });
|
||||
return;
|
||||
} catch {}
|
||||
|
||||
// Fallback: project-local file (useful in containers where $HOME is ephemeral
|
||||
// but the project directory is mounted as a volume)
|
||||
try {
|
||||
fs.writeFileSync(LOCAL_DEVICE_ID_FILE, id, { encoding: 'utf8', mode: 0o600 });
|
||||
return;
|
||||
} catch {}
|
||||
|
||||
console.error(
|
||||
'[evolver] WARN: failed to persist device_id to ' + DEVICE_ID_FILE +
|
||||
' or ' + LOCAL_DEVICE_ID_FILE +
|
||||
' -- node identity may change on restart.' +
|
||||
' Set EVOMAP_DEVICE_ID env var for stable identity in containers.'
|
||||
);
|
||||
}
|
||||
|
||||
function loadPersistedDeviceId() {
|
||||
// Try primary path
|
||||
try {
|
||||
if (fs.existsSync(DEVICE_ID_FILE)) {
|
||||
const id = fs.readFileSync(DEVICE_ID_FILE, 'utf8').trim();
|
||||
if (id && DEVICE_ID_RE.test(id)) return id;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Try project-local fallback
|
||||
try {
|
||||
if (fs.existsSync(LOCAL_DEVICE_ID_FILE)) {
|
||||
const id = fs.readFileSync(LOCAL_DEVICE_ID_FILE, 'utf8').trim();
|
||||
if (id && DEVICE_ID_RE.test(id)) return id;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDeviceId() {
|
||||
if (_cachedDeviceId) return _cachedDeviceId;
|
||||
|
||||
// 1. Env var override (validated)
|
||||
if (process.env.EVOMAP_DEVICE_ID) {
|
||||
const envId = String(process.env.EVOMAP_DEVICE_ID).trim().toLowerCase();
|
||||
if (DEVICE_ID_RE.test(envId)) {
|
||||
_cachedDeviceId = envId;
|
||||
return _cachedDeviceId;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Previously persisted (checks both ~/.evomap/ and project-local)
|
||||
const persisted = loadPersistedDeviceId();
|
||||
if (persisted) {
|
||||
_cachedDeviceId = persisted;
|
||||
return _cachedDeviceId;
|
||||
}
|
||||
|
||||
// 3. Generate from hardware / container metadata and persist
|
||||
const inContainer = isContainer();
|
||||
const generated = generateDeviceId();
|
||||
persistDeviceId(generated);
|
||||
_cachedDeviceId = generated;
|
||||
|
||||
if (inContainer && !process.env.EVOMAP_DEVICE_ID) {
|
||||
console.error(
|
||||
'[evolver] NOTE: running in a container without EVOMAP_DEVICE_ID.' +
|
||||
' A device_id was auto-generated and persisted, but for guaranteed' +
|
||||
' cross-restart stability, set EVOMAP_DEVICE_ID as an env var' +
|
||||
' or mount a persistent volume at ~/.evomap/'
|
||||
);
|
||||
}
|
||||
|
||||
return _cachedDeviceId;
|
||||
}
|
||||
|
||||
module.exports = { getDeviceId, isContainer };
|
||||
@@ -0,0 +1,68 @@
|
||||
// Environment fingerprint capture for GEP assets.
|
||||
// Records the runtime environment so that cross-environment diffusion
|
||||
// success rates (GDI) can be measured scientifically.
|
||||
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { getRepoRoot } = require('./paths');
|
||||
const { getDeviceId, isContainer } = require('./deviceId');
|
||||
|
||||
// Capture a structured environment fingerprint.
|
||||
// This is embedded into Capsules, EvolutionEvents, and ValidationReports.
|
||||
function captureEnvFingerprint() {
|
||||
const repoRoot = getRepoRoot();
|
||||
let pkgVersion = null;
|
||||
let pkgName = null;
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8');
|
||||
const pkg = JSON.parse(raw);
|
||||
pkgVersion = pkg && pkg.version ? String(pkg.version) : null;
|
||||
pkgName = pkg && pkg.name ? String(pkg.name) : null;
|
||||
} catch (e) {}
|
||||
|
||||
const region = (process.env.EVOLVER_REGION || '').trim().toLowerCase().slice(0, 5) || undefined;
|
||||
|
||||
return {
|
||||
device_id: getDeviceId(),
|
||||
node_version: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
os_release: os.release(),
|
||||
hostname: crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 12),
|
||||
evolver_version: pkgVersion,
|
||||
client: pkgName || 'evolver',
|
||||
client_version: pkgVersion,
|
||||
region: region,
|
||||
cwd: crypto.createHash('sha256').update(process.cwd()).digest('hex').slice(0, 12),
|
||||
container: isContainer(),
|
||||
};
|
||||
}
|
||||
|
||||
// Compute a short fingerprint key for comparison and grouping.
|
||||
// Two nodes with the same key are considered "same environment class".
|
||||
function envFingerprintKey(fp) {
|
||||
if (!fp || typeof fp !== 'object') return 'unknown';
|
||||
const parts = [
|
||||
fp.device_id || '',
|
||||
fp.node_version || '',
|
||||
fp.platform || '',
|
||||
fp.arch || '',
|
||||
fp.hostname || '',
|
||||
fp.client || fp.evolver_version || '',
|
||||
fp.client_version || fp.evolver_version || '',
|
||||
].join('|');
|
||||
return crypto.createHash('sha256').update(parts, 'utf8').digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
// Check if two fingerprints are from the same environment class.
|
||||
function isSameEnvClass(fpA, fpB) {
|
||||
return envFingerprintKey(fpA) === envFingerprintKey(fpB);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureEnvFingerprint,
|
||||
envFingerprintKey,
|
||||
isSameEnvClass,
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
// Hub Search-First Evolution: query evomap-hub for reusable solutions before local solve.
|
||||
//
|
||||
// Flow: extractSignals() -> hubSearch(signals) -> if hit: reuse; if miss: normal evolve
|
||||
// Two modes: direct (skip local reasoning) | reference (inject into prompt as strong hint)
|
||||
|
||||
const { getNodeId } = require('./a2aProtocol');
|
||||
|
||||
const DEFAULT_MIN_REUSE_SCORE = 0.72;
|
||||
const DEFAULT_REUSE_MODE = 'reference'; // 'direct' | 'reference'
|
||||
|
||||
function getHubUrl() {
|
||||
return (process.env.A2A_HUB_URL || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getReuseMode() {
|
||||
const m = String(process.env.EVOLVER_REUSE_MODE || DEFAULT_REUSE_MODE).toLowerCase();
|
||||
return m === 'direct' ? 'direct' : 'reference';
|
||||
}
|
||||
|
||||
function getMinReuseScore() {
|
||||
const n = Number(process.env.EVOLVER_MIN_REUSE_SCORE);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MIN_REUSE_SCORE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a hub asset for local reuse quality.
|
||||
* rank = confidence * max(success_streak, 1) * (reputation / 100)
|
||||
*/
|
||||
function scoreHubResult(asset) {
|
||||
const confidence = Number(asset.confidence) || 0;
|
||||
const streak = Math.max(Number(asset.success_streak) || 0, 1);
|
||||
// Reputation is included in asset from hub ranked endpoint; default 50 if missing
|
||||
const reputation = Number(asset.reputation_score) || 50;
|
||||
return confidence * streak * (reputation / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best matching asset above the threshold.
|
||||
* Returns { match, score, mode } or null if nothing qualifies.
|
||||
*/
|
||||
function pickBestMatch(results, threshold) {
|
||||
if (!Array.isArray(results) || results.length === 0) return null;
|
||||
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const asset of results) {
|
||||
// Only consider promoted assets
|
||||
if (asset.status && asset.status !== 'promoted') continue;
|
||||
const s = scoreHubResult(asset);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
best = asset;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best || bestScore < threshold) return null;
|
||||
|
||||
return {
|
||||
match: best,
|
||||
score: Math.round(bestScore * 1000) / 1000,
|
||||
mode: getReuseMode(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the hub for reusable capsules matching the given signals.
|
||||
* Returns { hit: true, match, score, mode } or { hit: false }.
|
||||
*/
|
||||
async function hubSearch(signals, opts) {
|
||||
const hubUrl = getHubUrl();
|
||||
if (!hubUrl) return { hit: false, reason: 'no_hub_url' };
|
||||
|
||||
const signalList = Array.isArray(signals) ? signals.filter(Boolean) : [];
|
||||
if (signalList.length === 0) return { hit: false, reason: 'no_signals' };
|
||||
|
||||
const threshold = (opts && Number.isFinite(opts.threshold)) ? opts.threshold : getMinReuseScore();
|
||||
const limit = (opts && Number.isFinite(opts.limit)) ? opts.limit : 5;
|
||||
const timeout = (opts && Number.isFinite(opts.timeoutMs)) ? opts.timeoutMs : 8000;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('signals', signalList.join(','));
|
||||
params.set('status', 'promoted');
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const url = `${hubUrl}/a2a/assets/search?${params.toString()}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) return { hit: false, reason: `hub_http_${res.status}` };
|
||||
|
||||
const data = await res.json();
|
||||
const assets = Array.isArray(data.assets) ? data.assets : [];
|
||||
|
||||
if (assets.length === 0) return { hit: false, reason: 'no_results' };
|
||||
|
||||
const pick = pickBestMatch(assets, threshold);
|
||||
if (!pick) return { hit: false, reason: 'below_threshold', candidates: assets.length };
|
||||
|
||||
console.log(`[HubSearch] Hit: ${pick.match.asset_id || pick.match.local_id} (score=${pick.score}, mode=${pick.mode})`);
|
||||
|
||||
return {
|
||||
hit: true,
|
||||
match: pick.match,
|
||||
score: pick.score,
|
||||
mode: pick.mode,
|
||||
asset_id: pick.match.asset_id || null,
|
||||
source_node_id: pick.match.source_node_id || null,
|
||||
chain_id: pick.match.chain_id || null,
|
||||
};
|
||||
} catch (err) {
|
||||
// Hub unreachable is non-fatal; fall through to normal evolve
|
||||
console.log(`[HubSearch] Failed (non-fatal): ${err.message}`);
|
||||
return { hit: false, reason: 'fetch_error', error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hubSearch,
|
||||
scoreHubResult,
|
||||
pickBestMatch,
|
||||
getReuseMode,
|
||||
getMinReuseScore,
|
||||
getHubUrl,
|
||||
};
|
||||
@@ -0,0 +1,771 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getMemoryDir } = require('./paths');
|
||||
const { normalizePersonalityState, isValidPersonalityState, personalityKey } = require('./personality');
|
||||
const { isValidMutation, normalizeMutation } = require('./mutation');
|
||||
|
||||
function ensureDir(dir) {
|
||||
try {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function stableHash(input) {
|
||||
const s = String(input || '');
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeErrorSignature(text) {
|
||||
const s = String(text || '').trim();
|
||||
if (!s) return null;
|
||||
return (
|
||||
s
|
||||
.toLowerCase()
|
||||
// normalize Windows paths
|
||||
.replace(/[a-z]:\\[^ \n\r\t]+/gi, '<path>')
|
||||
// normalize Unix paths
|
||||
.replace(/\/[^ \n\r\t]+/g, '<path>')
|
||||
// normalize hex and numbers
|
||||
.replace(/\b0x[0-9a-f]+\b/gi, '<hex>')
|
||||
.replace(/\b\d+\b/g, '<n>')
|
||||
// normalize whitespace
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 220)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSignalsForMatching(signals) {
|
||||
const list = Array.isArray(signals) ? signals : [];
|
||||
const out = [];
|
||||
for (const s of list) {
|
||||
const str = String(s || '').trim();
|
||||
if (!str) continue;
|
||||
if (str.startsWith('errsig:')) {
|
||||
const norm = normalizeErrorSignature(str.slice('errsig:'.length));
|
||||
if (norm) out.push(`errsig_norm:${stableHash(norm)}`);
|
||||
continue;
|
||||
}
|
||||
out.push(str);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeSignalKey(signals) {
|
||||
// Key must be stable across runs; normalize noisy signatures (paths, numbers).
|
||||
const list = normalizeSignalsForMatching(signals);
|
||||
const uniq = Array.from(new Set(list.filter(Boolean))).sort();
|
||||
return uniq.join('|') || '(none)';
|
||||
}
|
||||
|
||||
function extractErrorSignatureFromSignals(signals) {
|
||||
// Convention: signals can include "errsig:<raw>" emitted by signals extractor.
|
||||
const list = Array.isArray(signals) ? signals : [];
|
||||
for (const s of list) {
|
||||
const str = String(s || '');
|
||||
if (str.startsWith('errsig:')) return normalizeErrorSignature(str.slice('errsig:'.length));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function memoryGraphPath() {
|
||||
const { getEvolutionDir } = require('./paths');
|
||||
const evoDir = getEvolutionDir();
|
||||
return process.env.MEMORY_GRAPH_PATH || path.join(evoDir, 'memory_graph.jsonl');
|
||||
}
|
||||
|
||||
function memoryGraphStatePath() {
|
||||
const { getEvolutionDir } = require('./paths');
|
||||
return path.join(getEvolutionDir(), 'memory_graph_state.json');
|
||||
}
|
||||
|
||||
function appendJsonl(filePath, obj) {
|
||||
const dir = path.dirname(filePath);
|
||||
ensureDir(dir);
|
||||
fs.appendFileSync(filePath, JSON.stringify(obj) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function readJsonIfExists(filePath, fallback) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
if (!raw.trim()) return fallback;
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonAtomic(filePath, obj) {
|
||||
const dir = path.dirname(filePath);
|
||||
ensureDir(dir);
|
||||
const tmp = `${filePath}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
function tryReadMemoryGraphEvents(limitLines = 2000) {
|
||||
try {
|
||||
const p = memoryGraphPath();
|
||||
if (!fs.existsSync(p)) return [];
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
const lines = raw
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean);
|
||||
const recent = lines.slice(Math.max(0, lines.length - limitLines));
|
||||
return recent
|
||||
.map(l => {
|
||||
try {
|
||||
return JSON.parse(l);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function jaccard(aList, bList) {
|
||||
const aNorm = normalizeSignalsForMatching(aList);
|
||||
const bNorm = normalizeSignalsForMatching(bList);
|
||||
const a = new Set((Array.isArray(aNorm) ? aNorm : []).map(String));
|
||||
const b = new Set((Array.isArray(bNorm) ? bNorm : []).map(String));
|
||||
if (a.size === 0 && b.size === 0) return 1;
|
||||
if (a.size === 0 || b.size === 0) return 0;
|
||||
let inter = 0;
|
||||
for (const x of a) if (b.has(x)) inter++;
|
||||
const union = a.size + b.size - inter;
|
||||
return union === 0 ? 0 : inter / union;
|
||||
}
|
||||
|
||||
function decayWeight(updatedAtIso, halfLifeDays) {
|
||||
const hl = Number(halfLifeDays);
|
||||
if (!Number.isFinite(hl) || hl <= 0) return 1;
|
||||
const t = Date.parse(updatedAtIso);
|
||||
if (!Number.isFinite(t)) return 1;
|
||||
const ageDays = (Date.now() - t) / (1000 * 60 * 60 * 24);
|
||||
if (!Number.isFinite(ageDays) || ageDays <= 0) return 1;
|
||||
// Exponential half-life decay: weight = 0.5^(age/hl)
|
||||
return Math.pow(0.5, ageDays / hl);
|
||||
}
|
||||
|
||||
function aggregateEdges(events) {
|
||||
// Aggregate by (signal_key, gene_id) from outcome events.
|
||||
// Laplace smoothing to avoid 0/1 extremes.
|
||||
const map = new Map();
|
||||
for (const ev of events) {
|
||||
if (!ev || ev.type !== 'MemoryGraphEvent') continue;
|
||||
if (ev.kind !== 'outcome') continue;
|
||||
const signalKey = ev.signal && ev.signal.key ? String(ev.signal.key) : '(none)';
|
||||
const geneId = ev.gene && ev.gene.id ? String(ev.gene.id) : null;
|
||||
if (!geneId) continue;
|
||||
|
||||
const k = `${signalKey}::${geneId}`;
|
||||
const cur = map.get(k) || { signalKey, geneId, success: 0, fail: 0, last_ts: null, last_score: null };
|
||||
const status = ev.outcome && ev.outcome.status ? String(ev.outcome.status) : 'unknown';
|
||||
if (status === 'success') cur.success += 1;
|
||||
else if (status === 'failed') cur.fail += 1;
|
||||
|
||||
const ts = ev.ts || ev.created_at || ev.at;
|
||||
if (ts && (!cur.last_ts || Date.parse(ts) > Date.parse(cur.last_ts))) {
|
||||
cur.last_ts = ts;
|
||||
cur.last_score =
|
||||
ev.outcome && Number.isFinite(Number(ev.outcome.score)) ? Number(ev.outcome.score) : cur.last_score;
|
||||
}
|
||||
map.set(k, cur);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function aggregateGeneOutcomes(events) {
|
||||
// Aggregate by gene_id from outcome events (gene -> outcome success probability).
|
||||
const map = new Map();
|
||||
for (const ev of events) {
|
||||
if (!ev || ev.type !== 'MemoryGraphEvent') continue;
|
||||
if (ev.kind !== 'outcome') continue;
|
||||
const geneId = ev.gene && ev.gene.id ? String(ev.gene.id) : null;
|
||||
if (!geneId) continue;
|
||||
const cur = map.get(geneId) || { geneId, success: 0, fail: 0, last_ts: null, last_score: null };
|
||||
const status = ev.outcome && ev.outcome.status ? String(ev.outcome.status) : 'unknown';
|
||||
if (status === 'success') cur.success += 1;
|
||||
else if (status === 'failed') cur.fail += 1;
|
||||
const ts = ev.ts || ev.created_at || ev.at;
|
||||
if (ts && (!cur.last_ts || Date.parse(ts) > Date.parse(cur.last_ts))) {
|
||||
cur.last_ts = ts;
|
||||
cur.last_score =
|
||||
ev.outcome && Number.isFinite(Number(ev.outcome.score)) ? Number(ev.outcome.score) : cur.last_score;
|
||||
}
|
||||
map.set(geneId, cur);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function edgeExpectedSuccess(edge, opts) {
|
||||
const e = edge || { success: 0, fail: 0, last_ts: null };
|
||||
const succ = Number(e.success) || 0;
|
||||
const fail = Number(e.fail) || 0;
|
||||
const total = succ + fail;
|
||||
const p = (succ + 1) / (total + 2); // Laplace smoothing
|
||||
const halfLifeDays = opts && Number.isFinite(Number(opts.half_life_days)) ? Number(opts.half_life_days) : 30;
|
||||
const w = decayWeight(e.last_ts || '', halfLifeDays);
|
||||
return { p, w, total, value: p * w };
|
||||
}
|
||||
|
||||
function getMemoryAdvice({ signals, genes, driftEnabled }) {
|
||||
const events = tryReadMemoryGraphEvents(2000);
|
||||
const edges = aggregateEdges(events);
|
||||
const geneOutcomes = aggregateGeneOutcomes(events);
|
||||
const curSignals = Array.isArray(signals) ? signals : [];
|
||||
const curKey = computeSignalKey(curSignals);
|
||||
|
||||
const bannedGeneIds = new Set();
|
||||
const scoredGeneIds = [];
|
||||
|
||||
// Similarity: consider exact key first, then any key with overlap.
|
||||
const seenKeys = new Set();
|
||||
const candidateKeys = [];
|
||||
candidateKeys.push({ key: curKey, sim: 1 });
|
||||
seenKeys.add(curKey);
|
||||
|
||||
for (const ev of events) {
|
||||
if (!ev || ev.type !== 'MemoryGraphEvent') continue;
|
||||
const k = ev.signal && ev.signal.key ? String(ev.signal.key) : '(none)';
|
||||
if (seenKeys.has(k)) continue;
|
||||
const sigs = ev.signal && Array.isArray(ev.signal.signals) ? ev.signal.signals : [];
|
||||
const sim = jaccard(curSignals, sigs);
|
||||
if (sim >= 0.34) {
|
||||
candidateKeys.push({ key: k, sim });
|
||||
seenKeys.add(k);
|
||||
}
|
||||
}
|
||||
|
||||
const byGene = new Map();
|
||||
for (const ck of candidateKeys) {
|
||||
for (const g of Array.isArray(genes) ? genes : []) {
|
||||
if (!g || g.type !== 'Gene' || !g.id) continue;
|
||||
const k = `${ck.key}::${g.id}`;
|
||||
const edge = edges.get(k);
|
||||
const cur = byGene.get(g.id) || { geneId: g.id, best: 0, attempts: 0, prior: 0, prior_attempts: 0 };
|
||||
|
||||
// Signal->Gene edge score (if available)
|
||||
if (edge) {
|
||||
const ex = edgeExpectedSuccess(edge, { half_life_days: 30 });
|
||||
const weighted = ex.value * ck.sim;
|
||||
if (weighted > cur.best) cur.best = weighted;
|
||||
cur.attempts = Math.max(cur.attempts, ex.total);
|
||||
}
|
||||
|
||||
// Gene->Outcome prior (independent of signal): stabilizer when signal edges are sparse.
|
||||
const gEdge = geneOutcomes.get(String(g.id));
|
||||
if (gEdge) {
|
||||
const gx = edgeExpectedSuccess(gEdge, { half_life_days: 45 });
|
||||
cur.prior = Math.max(cur.prior, gx.value);
|
||||
cur.prior_attempts = Math.max(cur.prior_attempts, gx.total);
|
||||
}
|
||||
|
||||
byGene.set(g.id, cur);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [geneId, info] of byGene.entries()) {
|
||||
const combined = info.best > 0 ? info.best + info.prior * 0.12 : info.prior * 0.4;
|
||||
scoredGeneIds.push({ geneId, score: combined, attempts: info.attempts, prior: info.prior });
|
||||
// Low-efficiency path suppression (unless drift is explicit).
|
||||
if (!driftEnabled && info.attempts >= 2 && info.best < 0.18) {
|
||||
bannedGeneIds.add(geneId);
|
||||
}
|
||||
// Also suppress genes with consistently poor global outcomes when signal edges are sparse.
|
||||
if (!driftEnabled && info.attempts < 2 && info.prior_attempts >= 3 && info.prior < 0.12) {
|
||||
bannedGeneIds.add(geneId);
|
||||
}
|
||||
}
|
||||
|
||||
scoredGeneIds.sort((a, b) => b.score - a.score);
|
||||
const preferredGeneId = scoredGeneIds.length ? scoredGeneIds[0].geneId : null;
|
||||
|
||||
const explanation = [];
|
||||
if (preferredGeneId) explanation.push(`memory_prefer:${preferredGeneId}`);
|
||||
if (bannedGeneIds.size) explanation.push(`memory_ban:${Array.from(bannedGeneIds).slice(0, 6).join(',')}`);
|
||||
if (preferredGeneId) {
|
||||
const top = scoredGeneIds.find(x => x && x.geneId === preferredGeneId);
|
||||
if (top && Number.isFinite(Number(top.prior)) && top.prior > 0) explanation.push(`gene_prior:${top.prior.toFixed(3)}`);
|
||||
}
|
||||
if (driftEnabled) explanation.push('random_drift:enabled');
|
||||
|
||||
return {
|
||||
currentSignalKey: curKey,
|
||||
preferredGeneId,
|
||||
bannedGeneIds,
|
||||
explanation,
|
||||
};
|
||||
}
|
||||
|
||||
function recordSignalSnapshot({ signals, observations }) {
|
||||
const signalKey = computeSignalKey(signals);
|
||||
const ts = nowIso();
|
||||
const errsig = extractErrorSignatureFromSignals(signals);
|
||||
const ev = {
|
||||
type: 'MemoryGraphEvent',
|
||||
kind: 'signal',
|
||||
id: `mge_${Date.now()}_${stableHash(`${signalKey}|signal|${ts}`)}`,
|
||||
ts,
|
||||
signal: {
|
||||
key: signalKey,
|
||||
signals: Array.isArray(signals) ? signals : [],
|
||||
error_signature: errsig || null,
|
||||
},
|
||||
observed: observations && typeof observations === 'object' ? observations : null,
|
||||
};
|
||||
appendJsonl(memoryGraphPath(), ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
function buildHypothesisText({ signalKey, signals, geneId, geneCategory, driftEnabled }) {
|
||||
const sigCount = Array.isArray(signals) ? signals.length : 0;
|
||||
const drift = driftEnabled ? 'drift' : 'directed';
|
||||
const g = geneId ? `${geneId}${geneCategory ? `(${geneCategory})` : ''}` : '(none)';
|
||||
return `Given signal_key=${signalKey} with ${sigCount} signals, selecting gene=${g} under mode=${drift} is expected to reduce repeated errors and improve stability.`;
|
||||
}
|
||||
|
||||
function recordHypothesis({
|
||||
signals,
|
||||
mutation,
|
||||
personality_state,
|
||||
selectedGene,
|
||||
selector,
|
||||
driftEnabled,
|
||||
selectedBy,
|
||||
capsulesUsed,
|
||||
observations,
|
||||
}) {
|
||||
const signalKey = computeSignalKey(signals);
|
||||
const geneId = selectedGene && selectedGene.id ? String(selectedGene.id) : null;
|
||||
const geneCategory = selectedGene && selectedGene.category ? String(selectedGene.category) : null;
|
||||
const ts = nowIso();
|
||||
const errsig = extractErrorSignatureFromSignals(signals);
|
||||
const hypothesisId = `hyp_${Date.now()}_${stableHash(`${signalKey}|${geneId || 'none'}|${ts}`)}`;
|
||||
const personalityState = personality_state || null;
|
||||
const mutNorm = mutation && isValidMutation(mutation) ? normalizeMutation(mutation) : null;
|
||||
const psNorm = personalityState && isValidPersonalityState(personalityState) ? normalizePersonalityState(personalityState) : null;
|
||||
const ev = {
|
||||
type: 'MemoryGraphEvent',
|
||||
kind: 'hypothesis',
|
||||
id: `mge_${Date.now()}_${stableHash(`${hypothesisId}|${ts}`)}`,
|
||||
ts,
|
||||
signal: { key: signalKey, signals: Array.isArray(signals) ? signals : [], error_signature: errsig || null },
|
||||
hypothesis: {
|
||||
id: hypothesisId,
|
||||
text: buildHypothesisText({ signalKey, signals, geneId, geneCategory, driftEnabled }),
|
||||
predicted_outcome: { status: null, score: null },
|
||||
},
|
||||
mutation: mutNorm
|
||||
? {
|
||||
id: mutNorm.id,
|
||||
category: mutNorm.category,
|
||||
trigger_signals: mutNorm.trigger_signals,
|
||||
target: mutNorm.target,
|
||||
expected_effect: mutNorm.expected_effect,
|
||||
risk_level: mutNorm.risk_level,
|
||||
}
|
||||
: null,
|
||||
personality: psNorm
|
||||
? {
|
||||
key: personalityKey(psNorm),
|
||||
state: psNorm,
|
||||
}
|
||||
: null,
|
||||
gene: { id: geneId, category: geneCategory },
|
||||
action: {
|
||||
drift: !!driftEnabled,
|
||||
selected_by: selectedBy || 'selector',
|
||||
selector: selector || null,
|
||||
},
|
||||
capsules: {
|
||||
used: Array.isArray(capsulesUsed) ? capsulesUsed.map(String).filter(Boolean) : [],
|
||||
},
|
||||
observed: observations && typeof observations === 'object' ? observations : null,
|
||||
};
|
||||
appendJsonl(memoryGraphPath(), ev);
|
||||
return { hypothesisId, signalKey };
|
||||
}
|
||||
|
||||
function hasErrorSignal(signals) {
|
||||
const list = Array.isArray(signals) ? signals : [];
|
||||
return list.includes('log_error');
|
||||
}
|
||||
|
||||
function recordAttempt({
|
||||
signals,
|
||||
mutation,
|
||||
personality_state,
|
||||
selectedGene,
|
||||
selector,
|
||||
driftEnabled,
|
||||
selectedBy,
|
||||
hypothesisId,
|
||||
capsulesUsed,
|
||||
observations,
|
||||
}) {
|
||||
const signalKey = computeSignalKey(signals);
|
||||
const geneId = selectedGene && selectedGene.id ? String(selectedGene.id) : null;
|
||||
const geneCategory = selectedGene && selectedGene.category ? String(selectedGene.category) : null;
|
||||
const ts = nowIso();
|
||||
const errsig = extractErrorSignatureFromSignals(signals);
|
||||
const actionId = `act_${Date.now()}_${stableHash(`${signalKey}|${geneId || 'none'}|${ts}`)}`;
|
||||
const personalityState = personality_state || null;
|
||||
const mutNorm = mutation && isValidMutation(mutation) ? normalizeMutation(mutation) : null;
|
||||
const psNorm = personalityState && isValidPersonalityState(personalityState) ? normalizePersonalityState(personalityState) : null;
|
||||
const ev = {
|
||||
type: 'MemoryGraphEvent',
|
||||
kind: 'attempt',
|
||||
id: `mge_${Date.now()}_${stableHash(actionId)}`,
|
||||
ts,
|
||||
signal: { key: signalKey, signals: Array.isArray(signals) ? signals : [], error_signature: errsig || null },
|
||||
mutation: mutNorm
|
||||
? {
|
||||
id: mutNorm.id,
|
||||
category: mutNorm.category,
|
||||
trigger_signals: mutNorm.trigger_signals,
|
||||
target: mutNorm.target,
|
||||
expected_effect: mutNorm.expected_effect,
|
||||
risk_level: mutNorm.risk_level,
|
||||
}
|
||||
: null,
|
||||
personality: psNorm
|
||||
? {
|
||||
key: personalityKey(psNorm),
|
||||
state: psNorm,
|
||||
}
|
||||
: null,
|
||||
gene: { id: geneId, category: geneCategory },
|
||||
hypothesis: hypothesisId ? { id: String(hypothesisId) } : null,
|
||||
action: {
|
||||
id: actionId,
|
||||
drift: !!driftEnabled,
|
||||
selected_by: selectedBy || 'selector',
|
||||
selector: selector || null,
|
||||
},
|
||||
capsules: {
|
||||
used: Array.isArray(capsulesUsed) ? capsulesUsed.map(String).filter(Boolean) : [],
|
||||
},
|
||||
observed: observations && typeof observations === 'object' ? observations : null,
|
||||
};
|
||||
|
||||
appendJsonl(memoryGraphPath(), ev);
|
||||
|
||||
// State is mutable; graph is append-only.
|
||||
const statePath = memoryGraphStatePath();
|
||||
const state = readJsonIfExists(statePath, { last_action: null });
|
||||
state.last_action = {
|
||||
action_id: actionId,
|
||||
signal_key: signalKey,
|
||||
signals: Array.isArray(signals) ? signals : [],
|
||||
mutation_id: mutNorm ? mutNorm.id : null,
|
||||
mutation_category: mutNorm ? mutNorm.category : null,
|
||||
mutation_risk_level: mutNorm ? mutNorm.risk_level : null,
|
||||
personality_key: psNorm ? personalityKey(psNorm) : null,
|
||||
personality_state: psNorm || null,
|
||||
gene_id: geneId,
|
||||
gene_category: geneCategory,
|
||||
hypothesis_id: hypothesisId ? String(hypothesisId) : null,
|
||||
capsules_used: Array.isArray(capsulesUsed) ? capsulesUsed.map(String).filter(Boolean) : [],
|
||||
had_error: hasErrorSignal(signals),
|
||||
created_at: ts,
|
||||
outcome_recorded: false,
|
||||
baseline_observed: observations && typeof observations === 'object' ? observations : null,
|
||||
};
|
||||
writeJsonAtomic(statePath, state);
|
||||
|
||||
return { actionId, signalKey };
|
||||
}
|
||||
|
||||
function inferOutcomeFromSignals({ prevHadError, currentHasError }) {
|
||||
if (prevHadError && !currentHasError) return { status: 'success', score: 0.85, note: 'error_cleared' };
|
||||
if (prevHadError && currentHasError) return { status: 'failed', score: 0.2, note: 'error_persisted' };
|
||||
if (!prevHadError && currentHasError) return { status: 'failed', score: 0.15, note: 'new_error_appeared' };
|
||||
return { status: 'success', score: 0.6, note: 'stable_no_error' };
|
||||
}
|
||||
|
||||
function clamp01(x) {
|
||||
const n = Number(x);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
function tryParseLastEvolutionEventOutcome(evidenceText) {
|
||||
// Scan tail text for an EvolutionEvent JSON line and extract its outcome.
|
||||
const s = String(evidenceText || '');
|
||||
if (!s) return null;
|
||||
const lines = s.split('\n').slice(-400);
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
if (!line.includes('"type"') || !line.includes('EvolutionEvent')) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
if (!obj || obj.type !== 'EvolutionEvent') continue;
|
||||
const o = obj.outcome && typeof obj.outcome === 'object' ? obj.outcome : null;
|
||||
if (!o) continue;
|
||||
const status = o.status === 'success' || o.status === 'failed' ? o.status : null;
|
||||
const score = Number.isFinite(Number(o.score)) ? clamp01(Number(o.score)) : null;
|
||||
if (!status && score == null) continue;
|
||||
return {
|
||||
status: status || (score != null && score >= 0.5 ? 'success' : 'failed'),
|
||||
score: score != null ? score : status === 'success' ? 0.75 : 0.25,
|
||||
note: 'evolutionevent_observed',
|
||||
};
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferOutcomeEnhanced({ prevHadError, currentHasError, baselineObserved, currentObserved }) {
|
||||
const evidence =
|
||||
currentObserved &&
|
||||
currentObserved.evidence &&
|
||||
(currentObserved.evidence.recent_session_tail || currentObserved.evidence.today_log_tail)
|
||||
? currentObserved.evidence
|
||||
: null;
|
||||
const combinedEvidence = evidence
|
||||
? `${String(evidence.recent_session_tail || '')}\n${String(evidence.today_log_tail || '')}`
|
||||
: '';
|
||||
const observed = tryParseLastEvolutionEventOutcome(combinedEvidence);
|
||||
if (observed) return observed;
|
||||
|
||||
const base = inferOutcomeFromSignals({ prevHadError, currentHasError });
|
||||
|
||||
const prevErrCount =
|
||||
baselineObserved && Number.isFinite(Number(baselineObserved.recent_error_count))
|
||||
? Number(baselineObserved.recent_error_count)
|
||||
: null;
|
||||
const curErrCount =
|
||||
currentObserved && Number.isFinite(Number(currentObserved.recent_error_count))
|
||||
? Number(currentObserved.recent_error_count)
|
||||
: null;
|
||||
|
||||
let score = base.score;
|
||||
if (prevErrCount != null && curErrCount != null) {
|
||||
const delta = prevErrCount - curErrCount;
|
||||
score += Math.max(-0.12, Math.min(0.12, delta / 50));
|
||||
}
|
||||
|
||||
const prevScan =
|
||||
baselineObserved && Number.isFinite(Number(baselineObserved.scan_ms)) ? Number(baselineObserved.scan_ms) : null;
|
||||
const curScan =
|
||||
currentObserved && Number.isFinite(Number(currentObserved.scan_ms)) ? Number(currentObserved.scan_ms) : null;
|
||||
if (prevScan != null && curScan != null && prevScan > 0) {
|
||||
const ratio = (prevScan - curScan) / prevScan;
|
||||
score += Math.max(-0.06, Math.min(0.06, ratio));
|
||||
}
|
||||
|
||||
return { status: base.status, score: clamp01(score), note: `${base.note}|heuristic_delta` };
|
||||
}
|
||||
|
||||
function buildConfidenceEdgeEvent({ signalKey, signals, geneId, geneCategory, outcomeEventId, halfLifeDays }) {
|
||||
const events = tryReadMemoryGraphEvents(2000);
|
||||
const edges = aggregateEdges(events);
|
||||
const k = `${signalKey}::${geneId}`;
|
||||
const edge = edges.get(k) || { success: 0, fail: 0, last_ts: null };
|
||||
const ex = edgeExpectedSuccess(edge, { half_life_days: halfLifeDays });
|
||||
const ts = nowIso();
|
||||
return {
|
||||
type: 'MemoryGraphEvent',
|
||||
kind: 'confidence_edge',
|
||||
id: `mge_${Date.now()}_${stableHash(`${signalKey}|${geneId}|confidence|${ts}`)}`,
|
||||
ts,
|
||||
signal: { key: signalKey, signals: Array.isArray(signals) ? signals : [] },
|
||||
gene: { id: geneId, category: geneCategory || null },
|
||||
edge: { signal_key: signalKey, gene_id: geneId },
|
||||
stats: {
|
||||
success: Number(edge.success) || 0,
|
||||
fail: Number(edge.fail) || 0,
|
||||
attempts: Number(ex.total) || 0,
|
||||
p: ex.p,
|
||||
decay_weight: ex.w,
|
||||
value: ex.value,
|
||||
half_life_days: halfLifeDays,
|
||||
updated_at: ts,
|
||||
},
|
||||
derived_from: { outcome_event_id: outcomeEventId || null },
|
||||
};
|
||||
}
|
||||
|
||||
function buildGeneOutcomeConfidenceEvent({ geneId, geneCategory, outcomeEventId, halfLifeDays }) {
|
||||
const events = tryReadMemoryGraphEvents(2000);
|
||||
const geneOutcomes = aggregateGeneOutcomes(events);
|
||||
const edge = geneOutcomes.get(String(geneId)) || { success: 0, fail: 0, last_ts: null };
|
||||
const ex = edgeExpectedSuccess(edge, { half_life_days: halfLifeDays });
|
||||
const ts = nowIso();
|
||||
return {
|
||||
type: 'MemoryGraphEvent',
|
||||
kind: 'confidence_gene_outcome',
|
||||
id: `mge_${Date.now()}_${stableHash(`${geneId}|gene_outcome|confidence|${ts}`)}`,
|
||||
ts,
|
||||
gene: { id: String(geneId), category: geneCategory || null },
|
||||
edge: { gene_id: String(geneId) },
|
||||
stats: {
|
||||
success: Number(edge.success) || 0,
|
||||
fail: Number(edge.fail) || 0,
|
||||
attempts: Number(ex.total) || 0,
|
||||
p: ex.p,
|
||||
decay_weight: ex.w,
|
||||
value: ex.value,
|
||||
half_life_days: halfLifeDays,
|
||||
updated_at: ts,
|
||||
},
|
||||
derived_from: { outcome_event_id: outcomeEventId || null },
|
||||
};
|
||||
}
|
||||
|
||||
function recordOutcomeFromState({ signals, observations }) {
|
||||
const statePath = memoryGraphStatePath();
|
||||
const state = readJsonIfExists(statePath, { last_action: null });
|
||||
const last = state && state.last_action ? state.last_action : null;
|
||||
if (!last || !last.action_id) return null;
|
||||
if (last.outcome_recorded) return null;
|
||||
|
||||
const currentHasError = hasErrorSignal(signals);
|
||||
const inferred = inferOutcomeEnhanced({
|
||||
prevHadError: !!last.had_error,
|
||||
currentHasError,
|
||||
baselineObserved: last.baseline_observed || null,
|
||||
currentObserved: observations || null,
|
||||
});
|
||||
const ts = nowIso();
|
||||
const errsig = extractErrorSignatureFromSignals(signals);
|
||||
const ev = {
|
||||
type: 'MemoryGraphEvent',
|
||||
kind: 'outcome',
|
||||
id: `mge_${Date.now()}_${stableHash(`${last.action_id}|outcome|${ts}`)}`,
|
||||
ts,
|
||||
signal: {
|
||||
key: String(last.signal_key || '(none)'),
|
||||
signals: Array.isArray(last.signals) ? last.signals : [],
|
||||
error_signature: errsig || null,
|
||||
},
|
||||
mutation:
|
||||
last.mutation_id || last.mutation_category || last.mutation_risk_level
|
||||
? {
|
||||
id: last.mutation_id || null,
|
||||
category: last.mutation_category || null,
|
||||
risk_level: last.mutation_risk_level || null,
|
||||
}
|
||||
: null,
|
||||
personality:
|
||||
last.personality_key || last.personality_state
|
||||
? {
|
||||
key: last.personality_key || null,
|
||||
state: last.personality_state || null,
|
||||
}
|
||||
: null,
|
||||
gene: { id: last.gene_id || null, category: last.gene_category || null },
|
||||
action: { id: String(last.action_id) },
|
||||
hypothesis: last.hypothesis_id ? { id: String(last.hypothesis_id) } : null,
|
||||
outcome: {
|
||||
status: inferred.status,
|
||||
score: inferred.score,
|
||||
note: inferred.note,
|
||||
observed: { current_signals: Array.isArray(signals) ? signals : [] },
|
||||
},
|
||||
confidence: {
|
||||
// This is an interpretable, decayed success estimate derived from outcomes; aggregation is computed at read-time.
|
||||
half_life_days: 30,
|
||||
},
|
||||
observed: observations && typeof observations === 'object' ? observations : null,
|
||||
baseline: last.baseline_observed || null,
|
||||
capsules: {
|
||||
used: Array.isArray(last.capsules_used) ? last.capsules_used : [],
|
||||
},
|
||||
};
|
||||
|
||||
appendJsonl(memoryGraphPath(), ev);
|
||||
|
||||
// Persist explicit confidence snapshots (append-only) for auditability.
|
||||
try {
|
||||
if (last.gene_id) {
|
||||
const edgeEv = buildConfidenceEdgeEvent({
|
||||
signalKey: String(last.signal_key || '(none)'),
|
||||
signals: Array.isArray(last.signals) ? last.signals : [],
|
||||
geneId: String(last.gene_id),
|
||||
geneCategory: last.gene_category || null,
|
||||
outcomeEventId: ev.id,
|
||||
halfLifeDays: 30,
|
||||
});
|
||||
appendJsonl(memoryGraphPath(), edgeEv);
|
||||
|
||||
const geneEv = buildGeneOutcomeConfidenceEvent({
|
||||
geneId: String(last.gene_id),
|
||||
geneCategory: last.gene_category || null,
|
||||
outcomeEventId: ev.id,
|
||||
halfLifeDays: 45,
|
||||
});
|
||||
appendJsonl(memoryGraphPath(), geneEv);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
last.outcome_recorded = true;
|
||||
last.outcome_recorded_at = ts;
|
||||
state.last_action = last;
|
||||
writeJsonAtomic(statePath, state);
|
||||
|
||||
return ev;
|
||||
}
|
||||
|
||||
function recordExternalCandidate({ asset, source, signals }) {
|
||||
// Append-only annotation: external assets enter as candidates only.
|
||||
// This does not affect outcome aggregation (which only uses kind === 'outcome').
|
||||
const a = asset && typeof asset === 'object' ? asset : null;
|
||||
const type = a && a.type ? String(a.type) : null;
|
||||
const id = a && a.id ? String(a.id) : null;
|
||||
if (!type || !id) return null;
|
||||
|
||||
const ts = nowIso();
|
||||
const signalKey = computeSignalKey(signals);
|
||||
const ev = {
|
||||
type: 'MemoryGraphEvent',
|
||||
kind: 'external_candidate',
|
||||
id: `mge_${Date.now()}_${stableHash(`${type}|${id}|external|${ts}`)}`,
|
||||
ts,
|
||||
signal: { key: signalKey, signals: Array.isArray(signals) ? signals : [] },
|
||||
external: {
|
||||
source: source || 'external',
|
||||
received_at: ts,
|
||||
},
|
||||
asset: { type, id },
|
||||
candidate: {
|
||||
// Minimal hints for later local triggering/validation.
|
||||
trigger: type === 'Capsule' && Array.isArray(a.trigger) ? a.trigger : [],
|
||||
gene: type === 'Capsule' && a.gene ? String(a.gene) : null,
|
||||
confidence: type === 'Capsule' && Number.isFinite(Number(a.confidence)) ? Number(a.confidence) : null,
|
||||
},
|
||||
};
|
||||
|
||||
appendJsonl(memoryGraphPath(), ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
memoryGraphPath,
|
||||
computeSignalKey,
|
||||
tryReadMemoryGraphEvents,
|
||||
getMemoryAdvice,
|
||||
recordSignalSnapshot,
|
||||
recordHypothesis,
|
||||
recordAttempt,
|
||||
recordOutcomeFromState,
|
||||
recordExternalCandidate,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// MemoryGraphAdapter -- stable interface boundary for memory graph operations.
|
||||
//
|
||||
// Default implementation delegates to the local JSONL-based memoryGraph.js.
|
||||
// SaaS providers can supply a remote adapter by setting MEMORY_GRAPH_PROVIDER=remote
|
||||
// and configuring MEMORY_GRAPH_REMOTE_URL / MEMORY_GRAPH_REMOTE_KEY.
|
||||
//
|
||||
// The adapter is designed so that the open-source evolver always works offline
|
||||
// with the local implementation. Remote is optional and degrades gracefully.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const localGraph = require('./memoryGraph');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter interface contract (all methods must be implemented by providers):
|
||||
//
|
||||
// getAdvice({ signals, genes, driftEnabled }) => { preferredGeneId, bannedGeneIds, currentSignalKey, explanation }
|
||||
// recordSignalSnapshot({ signals, observations }) => event
|
||||
// recordHypothesis({ signals, mutation, personality_state, selectedGene, selector, driftEnabled, selectedBy, capsulesUsed, observations }) => { hypothesisId, signalKey }
|
||||
// recordAttempt({ signals, mutation, personality_state, selectedGene, selector, driftEnabled, selectedBy, hypothesisId, capsulesUsed, observations }) => { actionId, signalKey }
|
||||
// recordOutcome({ signals, observations }) => event | null
|
||||
// recordExternalCandidate({ asset, source, signals }) => event | null
|
||||
// memoryGraphPath() => string
|
||||
// computeSignalKey(signals) => string
|
||||
// tryReadMemoryGraphEvents(limit) => event[]
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local adapter (default) -- wraps memoryGraph.js without any behavior change
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const localAdapter = {
|
||||
name: 'local',
|
||||
|
||||
getAdvice(opts) {
|
||||
return localGraph.getMemoryAdvice(opts);
|
||||
},
|
||||
|
||||
recordSignalSnapshot(opts) {
|
||||
return localGraph.recordSignalSnapshot(opts);
|
||||
},
|
||||
|
||||
recordHypothesis(opts) {
|
||||
return localGraph.recordHypothesis(opts);
|
||||
},
|
||||
|
||||
recordAttempt(opts) {
|
||||
return localGraph.recordAttempt(opts);
|
||||
},
|
||||
|
||||
recordOutcome(opts) {
|
||||
return localGraph.recordOutcomeFromState(opts);
|
||||
},
|
||||
|
||||
recordExternalCandidate(opts) {
|
||||
return localGraph.recordExternalCandidate(opts);
|
||||
},
|
||||
|
||||
memoryGraphPath() {
|
||||
return localGraph.memoryGraphPath();
|
||||
},
|
||||
|
||||
computeSignalKey(signals) {
|
||||
return localGraph.computeSignalKey(signals);
|
||||
},
|
||||
|
||||
tryReadMemoryGraphEvents(limit) {
|
||||
return localGraph.tryReadMemoryGraphEvents(limit);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remote adapter (SaaS) -- calls external KG service with local fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildRemoteAdapter() {
|
||||
const remoteUrl = process.env.MEMORY_GRAPH_REMOTE_URL || '';
|
||||
const remoteKey = process.env.MEMORY_GRAPH_REMOTE_KEY || '';
|
||||
const timeoutMs = Number(process.env.MEMORY_GRAPH_REMOTE_TIMEOUT_MS) || 5000;
|
||||
|
||||
async function remoteCall(endpoint, body) {
|
||||
if (!remoteUrl) throw new Error('MEMORY_GRAPH_REMOTE_URL not configured');
|
||||
const url = `${remoteUrl.replace(/\/+$/, '')}${endpoint}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(remoteKey ? { Authorization: `Bearer ${remoteKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`remote_kg_error: ${res.status}`);
|
||||
}
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap remote call with local fallback -- ensures offline resilience.
|
||||
function withFallback(localFn, remoteFn) {
|
||||
return async function (...args) {
|
||||
try {
|
||||
return await remoteFn(...args);
|
||||
} catch (e) {
|
||||
// Fallback to local on any remote failure (network, timeout, config).
|
||||
return localFn(...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'remote',
|
||||
|
||||
// getAdvice is the primary candidate for remote enhancement (richer graph reasoning).
|
||||
getAdvice: withFallback(
|
||||
(opts) => localGraph.getMemoryAdvice(opts),
|
||||
async (opts) => {
|
||||
const result = await remoteCall('/kg/advice', {
|
||||
signals: opts.signals,
|
||||
genes: (opts.genes || []).map((g) => ({ id: g.id, category: g.category, type: g.type })),
|
||||
driftEnabled: opts.driftEnabled,
|
||||
});
|
||||
// Normalize remote response to match local contract.
|
||||
return {
|
||||
currentSignalKey: result.currentSignalKey || localGraph.computeSignalKey(opts.signals),
|
||||
preferredGeneId: result.preferredGeneId || null,
|
||||
bannedGeneIds: new Set(result.bannedGeneIds || []),
|
||||
explanation: Array.isArray(result.explanation) ? result.explanation : [],
|
||||
};
|
||||
}
|
||||
),
|
||||
|
||||
// Write operations: always write locally first, then async-sync to remote.
|
||||
// This preserves the append-only local graph as source of truth.
|
||||
recordSignalSnapshot(opts) {
|
||||
const ev = localGraph.recordSignalSnapshot(opts);
|
||||
remoteCall('/kg/ingest', { kind: 'signal', event: ev }).catch(() => {});
|
||||
return ev;
|
||||
},
|
||||
|
||||
recordHypothesis(opts) {
|
||||
const result = localGraph.recordHypothesis(opts);
|
||||
remoteCall('/kg/ingest', { kind: 'hypothesis', event: result }).catch(() => {});
|
||||
return result;
|
||||
},
|
||||
|
||||
recordAttempt(opts) {
|
||||
const result = localGraph.recordAttempt(opts);
|
||||
remoteCall('/kg/ingest', { kind: 'attempt', event: result }).catch(() => {});
|
||||
return result;
|
||||
},
|
||||
|
||||
recordOutcome(opts) {
|
||||
const ev = localGraph.recordOutcomeFromState(opts);
|
||||
if (ev) {
|
||||
remoteCall('/kg/ingest', { kind: 'outcome', event: ev }).catch(() => {});
|
||||
}
|
||||
return ev;
|
||||
},
|
||||
|
||||
recordExternalCandidate(opts) {
|
||||
const ev = localGraph.recordExternalCandidate(opts);
|
||||
if (ev) {
|
||||
remoteCall('/kg/ingest', { kind: 'external_candidate', event: ev }).catch(() => {});
|
||||
}
|
||||
return ev;
|
||||
},
|
||||
|
||||
memoryGraphPath() {
|
||||
return localGraph.memoryGraphPath();
|
||||
},
|
||||
|
||||
computeSignalKey(signals) {
|
||||
return localGraph.computeSignalKey(signals);
|
||||
},
|
||||
|
||||
tryReadMemoryGraphEvents(limit) {
|
||||
return localGraph.tryReadMemoryGraphEvents(limit);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveAdapter() {
|
||||
const provider = (process.env.MEMORY_GRAPH_PROVIDER || 'local').toLowerCase().trim();
|
||||
if (provider === 'remote') {
|
||||
return buildRemoteAdapter();
|
||||
}
|
||||
return localAdapter;
|
||||
}
|
||||
|
||||
const adapter = resolveAdapter();
|
||||
|
||||
module.exports = adapter;
|
||||
@@ -0,0 +1,186 @@
|
||||
function clamp01(x) {
|
||||
const n = Number(x);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
function nowTsMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function uniqStrings(list) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const x of Array.isArray(list) ? list : []) {
|
||||
const s = String(x || '').trim();
|
||||
if (!s) continue;
|
||||
if (seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hasErrorishSignal(signals) {
|
||||
const list = Array.isArray(signals) ? signals.map(s => String(s || '')) : [];
|
||||
if (list.includes('issue_already_resolved') || list.includes('openclaw_self_healed')) return false;
|
||||
if (list.includes('log_error')) return true;
|
||||
if (list.some(s => s.startsWith('errsig:') || s.startsWith('errsig_norm:'))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Opportunity signals that indicate a chance to innovate (not just fix).
|
||||
var OPPORTUNITY_SIGNALS = [
|
||||
'user_feature_request',
|
||||
'user_improvement_suggestion',
|
||||
'perf_bottleneck',
|
||||
'capability_gap',
|
||||
'stable_success_plateau',
|
||||
'external_opportunity',
|
||||
'issue_already_resolved',
|
||||
'openclaw_self_healed',
|
||||
'empty_cycle_loop_detected',
|
||||
];
|
||||
|
||||
function hasOpportunitySignal(signals) {
|
||||
var list = Array.isArray(signals) ? signals.map(function (s) { return String(s || ''); }) : [];
|
||||
for (var i = 0; i < OPPORTUNITY_SIGNALS.length; i++) {
|
||||
var name = OPPORTUNITY_SIGNALS[i];
|
||||
if (list.includes(name)) return true;
|
||||
if (list.some(function (s) { return s.startsWith(name + ':'); })) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mutationCategoryFromContext({ signals, driftEnabled }) {
|
||||
if (hasErrorishSignal(signals)) return 'repair';
|
||||
if (driftEnabled) return 'innovate';
|
||||
// Auto-innovate: opportunity signals present and no errors
|
||||
if (hasOpportunitySignal(signals)) return 'innovate';
|
||||
// Consult strategy preset: if the configured strategy favors innovation,
|
||||
// default to innovate instead of optimize when there is nothing specific to do.
|
||||
try {
|
||||
var strategy = require('./strategy').resolveStrategy();
|
||||
if (strategy && typeof strategy.innovate === 'number' && strategy.innovate >= 0.5) return 'innovate';
|
||||
} catch (_) {}
|
||||
return 'optimize';
|
||||
}
|
||||
|
||||
function expectedEffectFromCategory(category) {
|
||||
const c = String(category || '');
|
||||
if (c === 'repair') return 'reduce runtime errors, increase stability, and lower failure rate';
|
||||
if (c === 'optimize') return 'improve success rate and reduce repeated operational cost';
|
||||
if (c === 'innovate') return 'explore new strategy combinations to escape local optimum';
|
||||
return 'improve robustness and success probability';
|
||||
}
|
||||
|
||||
function targetFromGene(selectedGene) {
|
||||
if (selectedGene && selectedGene.id) return `gene:${String(selectedGene.id)}`;
|
||||
return 'behavior:protocol';
|
||||
}
|
||||
|
||||
function isHighRiskPersonality(p) {
|
||||
// Conservative definition: low rigor or high risk_tolerance is treated as high-risk personality.
|
||||
const rigor = p && Number.isFinite(Number(p.rigor)) ? Number(p.rigor) : null;
|
||||
const riskTol = p && Number.isFinite(Number(p.risk_tolerance)) ? Number(p.risk_tolerance) : null;
|
||||
if (rigor != null && rigor < 0.5) return true;
|
||||
if (riskTol != null && riskTol > 0.6) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isHighRiskMutationAllowed(personalityState) {
|
||||
const rigor = personalityState && Number.isFinite(Number(personalityState.rigor)) ? Number(personalityState.rigor) : 0;
|
||||
const riskTol =
|
||||
personalityState && Number.isFinite(Number(personalityState.risk_tolerance))
|
||||
? Number(personalityState.risk_tolerance)
|
||||
: 1;
|
||||
return rigor >= 0.6 && riskTol <= 0.5;
|
||||
}
|
||||
|
||||
function buildMutation({
|
||||
signals,
|
||||
selectedGene,
|
||||
driftEnabled,
|
||||
personalityState,
|
||||
allowHighRisk = false,
|
||||
target,
|
||||
expected_effect,
|
||||
} = {}) {
|
||||
const ts = nowTsMs();
|
||||
const category = mutationCategoryFromContext({ signals, driftEnabled: !!driftEnabled });
|
||||
const triggerSignals = uniqStrings(signals);
|
||||
|
||||
const base = {
|
||||
type: 'Mutation',
|
||||
id: `mut_${ts}`,
|
||||
category,
|
||||
trigger_signals: triggerSignals,
|
||||
target: String(target || targetFromGene(selectedGene)),
|
||||
expected_effect: String(expected_effect || expectedEffectFromCategory(category)),
|
||||
risk_level: 'low',
|
||||
};
|
||||
|
||||
// Default risk assignment: innovate is medium; others low.
|
||||
if (category === 'innovate') base.risk_level = 'medium';
|
||||
|
||||
// Optional high-risk escalation (rare, and guarded by strict safety constraints).
|
||||
if (allowHighRisk && category === 'innovate') {
|
||||
base.risk_level = 'high';
|
||||
}
|
||||
|
||||
// Safety constraints (hard):
|
||||
// - forbid innovate + high-risk personality (downgrade innovation to optimize)
|
||||
// - forbid high-risk mutation unless personality satisfies constraints
|
||||
const highRiskPersonality = isHighRiskPersonality(personalityState || null);
|
||||
if (base.category === 'innovate' && highRiskPersonality) {
|
||||
base.category = 'optimize';
|
||||
base.expected_effect = 'safety downgrade: optimize under high-risk personality (avoid innovate+high-risk combo)';
|
||||
base.risk_level = 'low';
|
||||
base.trigger_signals = uniqStrings([...(base.trigger_signals || []), 'safety:avoid_innovate_with_high_risk_personality']);
|
||||
}
|
||||
|
||||
if (base.risk_level === 'high' && !isHighRiskMutationAllowed(personalityState || null)) {
|
||||
// Downgrade rather than emit illegal high-risk mutation.
|
||||
base.risk_level = 'medium';
|
||||
base.trigger_signals = uniqStrings([...(base.trigger_signals || []), 'safety:downgrade_high_risk']);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function isValidMutation(obj) {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
if (obj.type !== 'Mutation') return false;
|
||||
if (!obj.id || typeof obj.id !== 'string') return false;
|
||||
if (!obj.category || !['repair', 'optimize', 'innovate'].includes(String(obj.category))) return false;
|
||||
if (!Array.isArray(obj.trigger_signals)) return false;
|
||||
if (!obj.target || typeof obj.target !== 'string') return false;
|
||||
if (!obj.expected_effect || typeof obj.expected_effect !== 'string') return false;
|
||||
if (!obj.risk_level || !['low', 'medium', 'high'].includes(String(obj.risk_level))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeMutation(obj) {
|
||||
const m = obj && typeof obj === 'object' ? obj : {};
|
||||
const out = {
|
||||
type: 'Mutation',
|
||||
id: typeof m.id === 'string' ? m.id : `mut_${nowTsMs()}`,
|
||||
category: ['repair', 'optimize', 'innovate'].includes(String(m.category)) ? String(m.category) : 'optimize',
|
||||
trigger_signals: uniqStrings(m.trigger_signals),
|
||||
target: typeof m.target === 'string' ? m.target : 'behavior:protocol',
|
||||
expected_effect: typeof m.expected_effect === 'string' ? m.expected_effect : expectedEffectFromCategory(m.category),
|
||||
risk_level: ['low', 'medium', 'high'].includes(String(m.risk_level)) ? String(m.risk_level) : 'low',
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clamp01,
|
||||
buildMutation,
|
||||
isValidMutation,
|
||||
normalizeMutation,
|
||||
isHighRiskMutationAllowed,
|
||||
isHighRiskPersonality,
|
||||
hasOpportunitySignal,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
const path = require('path');
|
||||
|
||||
function getRepoRoot() {
|
||||
// src/gep/paths.js -> repo root
|
||||
return path.resolve(__dirname, '..', '..');
|
||||
}
|
||||
|
||||
function getWorkspaceRoot() {
|
||||
// skills/evolver -> workspace root
|
||||
return path.resolve(getRepoRoot(), '..', '..');
|
||||
}
|
||||
|
||||
function getLogsDir() {
|
||||
return process.env.EVOLVER_LOGS_DIR || path.join(getWorkspaceRoot(), 'logs');
|
||||
}
|
||||
|
||||
function getMemoryDir() {
|
||||
return process.env.MEMORY_DIR || path.join(getWorkspaceRoot(), 'memory');
|
||||
}
|
||||
|
||||
// --- Session Scope Isolation ---
|
||||
// When EVOLVER_SESSION_SCOPE is set (e.g., to a Discord channel ID or project name),
|
||||
// evolution state, memory graph, and assets are isolated to a per-scope subdirectory.
|
||||
// This prevents cross-channel/cross-project memory contamination.
|
||||
// When NOT set, everything works as before (global scope, backward compatible).
|
||||
function getSessionScope() {
|
||||
const raw = String(process.env.EVOLVER_SESSION_SCOPE || '').trim();
|
||||
if (!raw) return null;
|
||||
// Sanitize: only allow alphanumeric, dash, underscore, dot (prevent path traversal).
|
||||
const safe = raw.replace(/[^a-zA-Z0-9_\-\.]/g, '_').slice(0, 128);
|
||||
return safe || null;
|
||||
}
|
||||
|
||||
function getEvolutionDir() {
|
||||
const baseDir = process.env.EVOLUTION_DIR || path.join(getMemoryDir(), 'evolution');
|
||||
const scope = getSessionScope();
|
||||
if (scope) {
|
||||
return path.join(baseDir, 'scopes', scope);
|
||||
}
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
function getGepAssetsDir() {
|
||||
const repoRoot = getRepoRoot();
|
||||
const baseDir = process.env.GEP_ASSETS_DIR || path.join(repoRoot, 'assets', 'gep');
|
||||
const scope = getSessionScope();
|
||||
if (scope) {
|
||||
return path.join(baseDir, 'scopes', scope);
|
||||
}
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
function getSkillsDir() {
|
||||
return process.env.SKILLS_DIR || path.join(getWorkspaceRoot(), 'skills');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRepoRoot,
|
||||
getWorkspaceRoot,
|
||||
getLogsDir,
|
||||
getMemoryDir,
|
||||
getEvolutionDir,
|
||||
getGepAssetsDir,
|
||||
getSkillsDir,
|
||||
getSessionScope,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getMemoryDir } = require('./paths');
|
||||
const { hasOpportunitySignal } = require('./mutation');
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function clamp01(x) {
|
||||
const n = Number(x);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
try {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function readJsonIfExists(filePath, fallback) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
if (!raw.trim()) return fallback;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonAtomic(filePath, obj) {
|
||||
const dir = path.dirname(filePath);
|
||||
ensureDir(dir);
|
||||
const tmp = `${filePath}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
function personalityFilePath() {
|
||||
const memoryDir = getMemoryDir();
|
||||
const { getEvolutionDir } = require('./paths'); return path.join(getEvolutionDir(), 'personality_state.json');
|
||||
}
|
||||
|
||||
function defaultPersonalityState() {
|
||||
// Conservative defaults: protocol-first, safe, low-risk.
|
||||
return {
|
||||
type: 'PersonalityState',
|
||||
rigor: 0.7,
|
||||
creativity: 0.35,
|
||||
verbosity: 0.25,
|
||||
risk_tolerance: 0.4,
|
||||
obedience: 0.85,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePersonalityState(state) {
|
||||
const s = state && typeof state === 'object' ? state : {};
|
||||
return {
|
||||
type: 'PersonalityState',
|
||||
rigor: clamp01(s.rigor),
|
||||
creativity: clamp01(s.creativity),
|
||||
verbosity: clamp01(s.verbosity),
|
||||
risk_tolerance: clamp01(s.risk_tolerance),
|
||||
obedience: clamp01(s.obedience),
|
||||
};
|
||||
}
|
||||
|
||||
function isValidPersonalityState(obj) {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
if (obj.type !== 'PersonalityState') return false;
|
||||
for (const k of ['rigor', 'creativity', 'verbosity', 'risk_tolerance', 'obedience']) {
|
||||
const v = obj[k];
|
||||
if (!Number.isFinite(Number(v))) return false;
|
||||
const n = Number(v);
|
||||
if (n < 0 || n > 1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function roundToStep(x, step) {
|
||||
const s = Number(step);
|
||||
if (!Number.isFinite(s) || s <= 0) return x;
|
||||
return Math.round(Number(x) / s) * s;
|
||||
}
|
||||
|
||||
function personalityKey(state) {
|
||||
const s = normalizePersonalityState(state);
|
||||
const step = 0.1;
|
||||
const r = roundToStep(s.rigor, step).toFixed(1);
|
||||
const c = roundToStep(s.creativity, step).toFixed(1);
|
||||
const v = roundToStep(s.verbosity, step).toFixed(1);
|
||||
const rt = roundToStep(s.risk_tolerance, step).toFixed(1);
|
||||
const o = roundToStep(s.obedience, step).toFixed(1);
|
||||
return `rigor=${r}|creativity=${c}|verbosity=${v}|risk_tolerance=${rt}|obedience=${o}`;
|
||||
}
|
||||
|
||||
function getParamDeltas(fromState, toState) {
|
||||
const a = normalizePersonalityState(fromState);
|
||||
const b = normalizePersonalityState(toState);
|
||||
const deltas = [];
|
||||
for (const k of ['rigor', 'creativity', 'verbosity', 'risk_tolerance', 'obedience']) {
|
||||
deltas.push({ param: k, delta: Number(b[k]) - Number(a[k]) });
|
||||
}
|
||||
deltas.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
|
||||
return deltas;
|
||||
}
|
||||
|
||||
function personalityScore(statsEntry) {
|
||||
const e = statsEntry && typeof statsEntry === 'object' ? statsEntry : {};
|
||||
const succ = Number(e.success) || 0;
|
||||
const fail = Number(e.fail) || 0;
|
||||
const total = succ + fail;
|
||||
// Laplace-smoothed success probability
|
||||
const p = (succ + 1) / (total + 2);
|
||||
// Penalize tiny-sample overconfidence
|
||||
const sampleWeight = Math.min(1, total / 8);
|
||||
// Use avg_score (if present) as mild quality proxy
|
||||
const avg = Number.isFinite(Number(e.avg_score)) ? Number(e.avg_score) : null;
|
||||
const q = avg == null ? 0.5 : clamp01(avg);
|
||||
return p * 0.75 + q * 0.25 * sampleWeight;
|
||||
}
|
||||
|
||||
function chooseBestKnownPersonality(statsByKey) {
|
||||
const stats = statsByKey && typeof statsByKey === 'object' ? statsByKey : {};
|
||||
let best = null;
|
||||
for (const [k, entry] of Object.entries(stats)) {
|
||||
const e = entry || {};
|
||||
const total = (Number(e.success) || 0) + (Number(e.fail) || 0);
|
||||
if (total < 3) continue;
|
||||
const sc = personalityScore(e);
|
||||
if (!best || sc > best.score) best = { key: k, score: sc, entry: e };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function parseKeyToState(key) {
|
||||
// key format: rigor=0.7|creativity=0.3|...
|
||||
const out = defaultPersonalityState();
|
||||
const parts = String(key || '').split('|').map(s => s.trim()).filter(Boolean);
|
||||
for (const p of parts) {
|
||||
const [k, v] = p.split('=').map(x => String(x || '').trim());
|
||||
if (!k) continue;
|
||||
if (!['rigor', 'creativity', 'verbosity', 'risk_tolerance', 'obedience'].includes(k)) continue;
|
||||
out[k] = clamp01(Number(v));
|
||||
}
|
||||
return normalizePersonalityState(out);
|
||||
}
|
||||
|
||||
function applyPersonalityMutations(state, mutations) {
|
||||
let cur = normalizePersonalityState(state);
|
||||
const muts = Array.isArray(mutations) ? mutations : [];
|
||||
const applied = [];
|
||||
let count = 0;
|
||||
for (const m of muts) {
|
||||
if (!m || typeof m !== 'object') continue;
|
||||
const param = String(m.param || '').trim();
|
||||
if (!['rigor', 'creativity', 'verbosity', 'risk_tolerance', 'obedience'].includes(param)) continue;
|
||||
const delta = Number(m.delta);
|
||||
if (!Number.isFinite(delta)) continue;
|
||||
const clipped = Math.max(-0.2, Math.min(0.2, delta));
|
||||
cur[param] = clamp01(Number(cur[param]) + clipped);
|
||||
applied.push({ type: 'PersonalityMutation', param, delta: clipped, reason: String(m.reason || '').slice(0, 140) });
|
||||
count += 1;
|
||||
if (count >= 2) break;
|
||||
}
|
||||
return { state: cur, applied };
|
||||
}
|
||||
|
||||
function proposeMutations({ baseState, reason, driftEnabled, signals }) {
|
||||
const s = normalizePersonalityState(baseState);
|
||||
const sig = Array.isArray(signals) ? signals.map(x => String(x || '')) : [];
|
||||
const muts = [];
|
||||
|
||||
const r = String(reason || '');
|
||||
if (driftEnabled) {
|
||||
muts.push({ type: 'PersonalityMutation', param: 'creativity', delta: +0.1, reason: r || 'drift enabled' });
|
||||
// Keep risk bounded under drift by default.
|
||||
muts.push({ type: 'PersonalityMutation', param: 'risk_tolerance', delta: -0.05, reason: 'drift safety clamp' });
|
||||
} else if (sig.includes('protocol_drift')) {
|
||||
muts.push({ type: 'PersonalityMutation', param: 'obedience', delta: +0.1, reason: r || 'protocol drift' });
|
||||
muts.push({ type: 'PersonalityMutation', param: 'rigor', delta: +0.05, reason: 'tighten protocol compliance' });
|
||||
} else if (sig.includes('log_error') || sig.some(x => x.startsWith('errsig:') || x.startsWith('errsig_norm:'))) {
|
||||
muts.push({ type: 'PersonalityMutation', param: 'rigor', delta: +0.1, reason: r || 'repair instability' });
|
||||
muts.push({ type: 'PersonalityMutation', param: 'risk_tolerance', delta: -0.1, reason: 'reduce risky changes under errors' });
|
||||
} else if (hasOpportunitySignal(sig)) {
|
||||
// Opportunity detected: nudge towards creativity to enable innovation.
|
||||
muts.push({ type: 'PersonalityMutation', param: 'creativity', delta: +0.1, reason: r || 'opportunity signal detected' });
|
||||
muts.push({ type: 'PersonalityMutation', param: 'risk_tolerance', delta: +0.05, reason: 'allow exploration for innovation' });
|
||||
} else {
|
||||
// Plateau-like generic: slightly increase rigor, slightly decrease verbosity (more concise execution).
|
||||
muts.push({ type: 'PersonalityMutation', param: 'rigor', delta: +0.05, reason: r || 'stability bias' });
|
||||
muts.push({ type: 'PersonalityMutation', param: 'verbosity', delta: -0.05, reason: 'reduce noise' });
|
||||
}
|
||||
|
||||
// If already very high obedience, avoid pushing it further; swap second mutation to creativity.
|
||||
if (s.obedience >= 0.95) {
|
||||
const idx = muts.findIndex(x => x.param === 'obedience');
|
||||
if (idx >= 0) muts[idx] = { type: 'PersonalityMutation', param: 'creativity', delta: +0.05, reason: 'obedience saturated' };
|
||||
}
|
||||
return muts;
|
||||
}
|
||||
|
||||
function shouldTriggerPersonalityMutation({ driftEnabled, recentEvents }) {
|
||||
if (driftEnabled) return { ok: true, reason: 'drift enabled' };
|
||||
const list = Array.isArray(recentEvents) ? recentEvents : [];
|
||||
const tail = list.slice(-6);
|
||||
const outcomes = tail
|
||||
.map(e => (e && e.outcome && e.outcome.status ? String(e.outcome.status) : null))
|
||||
.filter(Boolean);
|
||||
if (outcomes.length >= 4) {
|
||||
const recentFailed = outcomes.slice(-4).filter(x => x === 'failed').length;
|
||||
if (recentFailed >= 3) return { ok: true, reason: 'long failure streak' };
|
||||
}
|
||||
// Mutation consecutive failure proxy: last 3 events that have mutation_id.
|
||||
const withMut = tail.filter(e => e && typeof e.mutation_id === 'string' && e.mutation_id);
|
||||
if (withMut.length >= 3) {
|
||||
const last3 = withMut.slice(-3);
|
||||
const fail3 = last3.filter(e => e && e.outcome && e.outcome.status === 'failed').length;
|
||||
if (fail3 >= 3) return { ok: true, reason: 'mutation consecutive failures' };
|
||||
}
|
||||
return { ok: false, reason: '' };
|
||||
}
|
||||
|
||||
function loadPersonalityModel() {
|
||||
const p = personalityFilePath();
|
||||
const fallback = {
|
||||
version: 1,
|
||||
current: defaultPersonalityState(),
|
||||
stats: {},
|
||||
history: [],
|
||||
updated_at: nowIso(),
|
||||
};
|
||||
const raw = readJsonIfExists(p, fallback);
|
||||
const cur = normalizePersonalityState(raw && raw.current ? raw.current : defaultPersonalityState());
|
||||
const stats = raw && typeof raw.stats === 'object' ? raw.stats : {};
|
||||
const history = Array.isArray(raw && raw.history) ? raw.history : [];
|
||||
return { version: 1, current: cur, stats, history, updated_at: raw && raw.updated_at ? raw.updated_at : nowIso() };
|
||||
}
|
||||
|
||||
function savePersonalityModel(model) {
|
||||
const m = model && typeof model === 'object' ? model : {};
|
||||
const out = {
|
||||
version: 1,
|
||||
current: normalizePersonalityState(m.current || defaultPersonalityState()),
|
||||
stats: m.stats && typeof m.stats === 'object' ? m.stats : {},
|
||||
history: Array.isArray(m.history) ? m.history.slice(-120) : [],
|
||||
updated_at: nowIso(),
|
||||
};
|
||||
writeJsonAtomic(personalityFilePath(), out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function selectPersonalityForRun({ driftEnabled, signals, recentEvents } = {}) {
|
||||
const model = loadPersonalityModel();
|
||||
const base = normalizePersonalityState(model.current);
|
||||
const stats = model.stats || {};
|
||||
|
||||
const best = chooseBestKnownPersonality(stats);
|
||||
let naturalSelectionApplied = [];
|
||||
|
||||
// Natural selection: nudge towards the best-known configuration (small, max 2 params).
|
||||
if (best && best.key) {
|
||||
const bestState = parseKeyToState(best.key);
|
||||
const diffs = getParamDeltas(base, bestState).filter(d => Math.abs(d.delta) >= 0.05);
|
||||
const muts = [];
|
||||
for (const d of diffs.slice(0, 2)) {
|
||||
const clipped = Math.max(-0.1, Math.min(0.1, d.delta));
|
||||
muts.push({ type: 'PersonalityMutation', param: d.param, delta: clipped, reason: 'natural_selection' });
|
||||
}
|
||||
const applied = applyPersonalityMutations(base, muts);
|
||||
model.current = applied.state;
|
||||
naturalSelectionApplied = applied.applied;
|
||||
}
|
||||
|
||||
// Triggered personality mutation (explicit rule-based).
|
||||
const trig = shouldTriggerPersonalityMutation({ driftEnabled: !!driftEnabled, recentEvents });
|
||||
let triggeredApplied = [];
|
||||
if (trig.ok) {
|
||||
const props = proposeMutations({
|
||||
baseState: model.current,
|
||||
reason: trig.reason,
|
||||
driftEnabled: !!driftEnabled,
|
||||
signals,
|
||||
});
|
||||
const applied = applyPersonalityMutations(model.current, props);
|
||||
model.current = applied.state;
|
||||
triggeredApplied = applied.applied;
|
||||
}
|
||||
|
||||
// Persist updated current state.
|
||||
const saved = savePersonalityModel(model);
|
||||
const key = personalityKey(saved.current);
|
||||
const known = !!(saved.stats && saved.stats[key]);
|
||||
|
||||
return {
|
||||
personality_state: saved.current,
|
||||
personality_key: key,
|
||||
personality_known: known,
|
||||
personality_mutations: [...naturalSelectionApplied, ...triggeredApplied],
|
||||
model_meta: {
|
||||
best_known_key: best && best.key ? best.key : null,
|
||||
best_known_score: best && Number.isFinite(Number(best.score)) ? Number(best.score) : null,
|
||||
triggered: trig.ok ? { reason: trig.reason } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function updatePersonalityStats({ personalityState, outcome, score, notes } = {}) {
|
||||
const model = loadPersonalityModel();
|
||||
const st = normalizePersonalityState(personalityState || model.current);
|
||||
const key = personalityKey(st);
|
||||
if (!model.stats || typeof model.stats !== 'object') model.stats = {};
|
||||
const cur = model.stats[key] && typeof model.stats[key] === 'object' ? model.stats[key] : { success: 0, fail: 0, avg_score: 0.5, n: 0 };
|
||||
|
||||
const out = String(outcome || '').toLowerCase();
|
||||
if (out === 'success') cur.success = (Number(cur.success) || 0) + 1;
|
||||
else if (out === 'failed') cur.fail = (Number(cur.fail) || 0) + 1;
|
||||
|
||||
const sc = Number.isFinite(Number(score)) ? clamp01(Number(score)) : null;
|
||||
if (sc != null) {
|
||||
const n = (Number(cur.n) || 0) + 1;
|
||||
const prev = Number.isFinite(Number(cur.avg_score)) ? Number(cur.avg_score) : 0.5;
|
||||
cur.avg_score = prev + (sc - prev) / n;
|
||||
cur.n = n;
|
||||
}
|
||||
cur.updated_at = nowIso();
|
||||
model.stats[key] = cur;
|
||||
|
||||
model.history = Array.isArray(model.history) ? model.history : [];
|
||||
model.history.push({
|
||||
at: nowIso(),
|
||||
key,
|
||||
outcome: out === 'success' || out === 'failed' ? out : 'unknown',
|
||||
score: sc,
|
||||
notes: notes ? String(notes).slice(0, 220) : null,
|
||||
});
|
||||
|
||||
savePersonalityModel(model);
|
||||
return { key, stats: cur };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clamp01,
|
||||
defaultPersonalityState,
|
||||
normalizePersonalityState,
|
||||
isValidPersonalityState,
|
||||
personalityKey,
|
||||
loadPersonalityModel,
|
||||
savePersonalityModel,
|
||||
selectPersonalityForRun,
|
||||
updatePersonalityStats,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
const { captureEnvFingerprint } = require('./envFingerprint');
|
||||
const { formatAssetPreview } = require('./assets');
|
||||
const { generateInnovationIdeas } = require('../ops/innovation'); // [2026-02-14] Innovation Catalyst Integration
|
||||
const { analyzeRecentHistory, OPPORTUNITY_SIGNALS } = require('./signals'); // [2026-02-14] Signal Analysis Integration
|
||||
|
||||
/**
|
||||
* Build a minimal prompt for direct-reuse mode.
|
||||
*/
|
||||
function buildReusePrompt({ capsule, signals, nowIso }) {
|
||||
const payload = capsule.payload || capsule;
|
||||
const summary = payload.summary || capsule.summary || '(no summary)';
|
||||
const gene = payload.gene || capsule.gene || '(unknown)';
|
||||
const confidence = payload.confidence || capsule.confidence || 0;
|
||||
const assetId = capsule.asset_id || '(unknown)';
|
||||
const sourceNode = capsule.source_node_id || '(unknown)';
|
||||
const trigger = Array.isArray(payload.trigger || capsule.trigger_text)
|
||||
? (payload.trigger || String(capsule.trigger_text || '').split(',')).join(', ')
|
||||
: '';
|
||||
|
||||
return `
|
||||
GEP -- REUSE MODE (Search-First) [${nowIso || new Date().toISOString()}]
|
||||
|
||||
You are applying a VERIFIED solution from the EvoMap Hub.
|
||||
Source asset: ${assetId} (Node: ${sourceNode})
|
||||
Confidence: ${confidence} | Gene: ${gene}
|
||||
Trigger signals: ${trigger}
|
||||
|
||||
Summary: ${summary}
|
||||
|
||||
Your signals: ${JSON.stringify(signals || [])}
|
||||
|
||||
Instructions:
|
||||
1. Read the capsule details below.
|
||||
2. Apply the fix to the local codebase, adapting paths/names.
|
||||
3. Run validation to confirm it works.
|
||||
4. If passed, run: node index.js solidify
|
||||
5. If failed, ROLLBACK and report.
|
||||
|
||||
Capsule payload:
|
||||
\`\`\`json
|
||||
${JSON.stringify(payload, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
IMPORTANT: Do NOT reinvent. Apply faithfully.
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Hub Matched Solution block.
|
||||
*/
|
||||
function buildHubMatchedBlock({ capsule }) {
|
||||
if (!capsule) return '(no hub match)';
|
||||
const payload = capsule.payload || capsule;
|
||||
const summary = payload.summary || capsule.summary || '(no summary)';
|
||||
const gene = payload.gene || capsule.gene || '(unknown)';
|
||||
const confidence = payload.confidence || capsule.confidence || 0;
|
||||
const assetId = capsule.asset_id || '(unknown)';
|
||||
|
||||
return `
|
||||
Hub Matched Solution (STRONG REFERENCE):
|
||||
- Asset: ${assetId} (${confidence})
|
||||
- Gene: ${gene}
|
||||
- Summary: ${summary}
|
||||
- Payload:
|
||||
\`\`\`json
|
||||
${JSON.stringify(payload, null, 2)}
|
||||
\`\`\`
|
||||
Use this as your primary approach if applicable. Adapt to local context.
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate context intelligently to preserve header/footer structure.
|
||||
*/
|
||||
function truncateContext(text, maxLength = 20000) {
|
||||
if (!text || text.length <= maxLength) return text || '';
|
||||
return text.slice(0, maxLength) + '\n...[TRUNCATED_EXECUTION_CONTEXT]...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict schema definitions for the prompt to reduce drift.
|
||||
* UPDATED: 2026-02-14 (Protocol Drift Fix v3.2 - JSON-Only Enforcement)
|
||||
*/
|
||||
const SCHEMA_DEFINITIONS = `
|
||||
━━━━━━━━━━━━━━━━━━━━━━
|
||||
I. Mandatory Evolution Object Model (Output EXACTLY these 5 objects)
|
||||
━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Output separate JSON objects. DO NOT wrap in a single array.
|
||||
DO NOT use markdown code blocks (like \`\`\`json ... \`\`\`).
|
||||
Output RAW JSON ONLY. No prelude, no postscript.
|
||||
Missing any object = PROTOCOL FAILURE.
|
||||
ENSURE VALID JSON SYNTAX (escape quotes in strings).
|
||||
|
||||
0. Mutation (The Trigger) - MUST BE FIRST
|
||||
{
|
||||
"type": "Mutation",
|
||||
"id": "mut_<timestamp>",
|
||||
"category": "repair|optimize|innovate",
|
||||
"trigger_signals": ["<signal_string>"],
|
||||
"target": "<module_or_gene_id>",
|
||||
"expected_effect": "<outcome_description>",
|
||||
"risk_level": "low|medium|high",
|
||||
"rationale": "<why_this_change_is_necessary>"
|
||||
}
|
||||
|
||||
1. PersonalityState (The Mood)
|
||||
{
|
||||
"type": "PersonalityState",
|
||||
"rigor": 0.0-1.0,
|
||||
"creativity": 0.0-1.0,
|
||||
"verbosity": 0.0-1.0,
|
||||
"risk_tolerance": 0.0-1.0,
|
||||
"obedience": 0.0-1.0
|
||||
}
|
||||
|
||||
2. EvolutionEvent (The Record)
|
||||
{
|
||||
"type": "EvolutionEvent",
|
||||
"schema_version": "1.5.0",
|
||||
"id": "evt_<timestamp>",
|
||||
"parent": <parent_evt_id|null>,
|
||||
"intent": "repair|optimize|innovate",
|
||||
"signals": ["<signal_string>"],
|
||||
"genes_used": ["<gene_id>"],
|
||||
"mutation_id": "<mut_id>",
|
||||
"personality_state": { ... },
|
||||
"blast_radius": { "files": N, "lines": N },
|
||||
"outcome": { "status": "success|failed", "score": 0.0-1.0 }
|
||||
}
|
||||
|
||||
3. Gene (The Knowledge)
|
||||
- Reuse/update existing ID if possible. Create new only if novel pattern.
|
||||
{
|
||||
"type": "Gene",
|
||||
"schema_version": "1.5.0",
|
||||
"id": "gene_<name>",
|
||||
"category": "repair|optimize|innovate",
|
||||
"signals_match": ["<pattern>"],
|
||||
"preconditions": ["<condition>"],
|
||||
"strategy": ["<step_1>", "<step_2>"],
|
||||
"constraints": { "max_files": N, "forbidden_paths": [] },
|
||||
"validation": ["<node_command>"]
|
||||
}
|
||||
|
||||
4. Capsule (The Result)
|
||||
- Only on success. Reference Gene used.
|
||||
{
|
||||
"type": "Capsule",
|
||||
"schema_version": "1.5.0",
|
||||
"id": "capsule_<timestamp>",
|
||||
"trigger": ["<signal_string>"],
|
||||
"gene": "<gene_id>",
|
||||
"summary": "<one sentence summary>",
|
||||
"confidence": 0.0-1.0,
|
||||
"blast_radius": { "files": N, "lines": N }
|
||||
}
|
||||
`.trim();
|
||||
|
||||
function buildAntiPatternZone(failedCapsules, signals) {
|
||||
if (!Array.isArray(failedCapsules) || failedCapsules.length === 0) return '';
|
||||
if (!Array.isArray(signals) || signals.length === 0) return '';
|
||||
var sigSet = new Set(signals.map(function (s) { return String(s).toLowerCase(); }));
|
||||
var matched = [];
|
||||
for (var i = failedCapsules.length - 1; i >= 0 && matched.length < 3; i--) {
|
||||
var fc = failedCapsules[i];
|
||||
if (!fc) continue;
|
||||
var triggers = Array.isArray(fc.trigger) ? fc.trigger : [];
|
||||
var overlap = 0;
|
||||
for (var j = 0; j < triggers.length; j++) {
|
||||
if (sigSet.has(String(triggers[j]).toLowerCase())) overlap++;
|
||||
}
|
||||
if (triggers.length > 0 && overlap / triggers.length >= 0.4) {
|
||||
matched.push(fc);
|
||||
}
|
||||
}
|
||||
if (matched.length === 0) return '';
|
||||
var lines = matched.map(function (fc, idx) {
|
||||
var diffPreview = fc.diff_snapshot ? String(fc.diff_snapshot).slice(0, 500) : '(no diff)';
|
||||
return [
|
||||
' ' + (idx + 1) + '. Gene: ' + (fc.gene || 'unknown') + ' | Signals: [' + (fc.trigger || []).slice(0, 4).join(', ') + ']',
|
||||
' Failure: ' + String(fc.failure_reason || 'unknown').slice(0, 300),
|
||||
' Diff (first 500 chars): ' + diffPreview.replace(/\n/g, ' '),
|
||||
].join('\n');
|
||||
});
|
||||
return '\nContext [Anti-Pattern Zone] (AVOID these failed approaches):\n' + lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function buildLessonsBlock(hubLessons, signals) {
|
||||
if (!Array.isArray(hubLessons) || hubLessons.length === 0) return '';
|
||||
var sigSet = new Set((Array.isArray(signals) ? signals : []).map(function (s) { return String(s).toLowerCase(); }));
|
||||
|
||||
var positive = [];
|
||||
var negative = [];
|
||||
for (var i = 0; i < hubLessons.length && (positive.length + negative.length) < 6; i++) {
|
||||
var l = hubLessons[i];
|
||||
if (!l || !l.content) continue;
|
||||
var entry = ' - [' + (l.scenario || l.lesson_type || '?') + '] ' + String(l.content).slice(0, 300);
|
||||
if (l.source_node_id) entry += ' (from: ' + String(l.source_node_id).slice(0, 20) + ')';
|
||||
if (l.lesson_type === 'negative') {
|
||||
negative.push(entry);
|
||||
} else {
|
||||
positive.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (positive.length === 0 && negative.length === 0) return '';
|
||||
|
||||
var parts = ['\nContext [Lessons from Ecosystem] (Cross-agent learned experience):'];
|
||||
if (positive.length > 0) {
|
||||
parts.push(' Strategies that WORKED:');
|
||||
parts.push(positive.join('\n'));
|
||||
}
|
||||
if (negative.length > 0) {
|
||||
parts.push(' Pitfalls to AVOID:');
|
||||
parts.push(negative.join('\n'));
|
||||
}
|
||||
parts.push(' Apply relevant lessons. Ignore irrelevant ones.\n');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function buildGepPrompt({
|
||||
nowIso,
|
||||
context,
|
||||
signals,
|
||||
selector,
|
||||
parentEventId,
|
||||
selectedGene,
|
||||
capsuleCandidates,
|
||||
genesPreview,
|
||||
capsulesPreview,
|
||||
capabilityCandidatesPreview,
|
||||
externalCandidatesPreview,
|
||||
hubMatchedBlock,
|
||||
cycleId,
|
||||
recentHistory,
|
||||
failedCapsules,
|
||||
hubLessons,
|
||||
}) {
|
||||
const parentValue = parentEventId ? `"${parentEventId}"` : 'null';
|
||||
const selectedGeneId = selectedGene && selectedGene.id ? selectedGene.id : 'gene_<name>';
|
||||
const envFingerprint = captureEnvFingerprint();
|
||||
const cycleLabel = cycleId ? ` Cycle #${cycleId}` : '';
|
||||
|
||||
// Extract strategy from selected gene if available
|
||||
let strategyBlock = "";
|
||||
if (selectedGene && selectedGene.strategy && Array.isArray(selectedGene.strategy)) {
|
||||
strategyBlock = `
|
||||
ACTIVE STRATEGY (${selectedGeneId}):
|
||||
${selectedGene.strategy.map((s, i) => `${i + 1}. ${s}`).join('\n')}
|
||||
ADHERE TO THIS STRATEGY STRICTLY.
|
||||
`.trim();
|
||||
} else {
|
||||
// Fallback strategy if no gene is selected or strategy is missing
|
||||
strategyBlock = `
|
||||
ACTIVE STRATEGY (Generic):
|
||||
1. Analyze signals and context.
|
||||
2. Select or create a Gene that addresses the root cause.
|
||||
3. Apply minimal, safe changes.
|
||||
4. Validate changes strictly.
|
||||
5. Solidify knowledge.
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// Use intelligent truncation
|
||||
const executionContext = truncateContext(context, 20000);
|
||||
|
||||
// Strict Schema Injection
|
||||
const schemaSection = SCHEMA_DEFINITIONS.replace('<parent_evt_id|null>', parentValue);
|
||||
|
||||
// Reduce noise by filtering capabilityCandidatesPreview if too large
|
||||
// If a gene is selected, we need less noise from capabilities
|
||||
let capsPreview = capabilityCandidatesPreview || '(none)';
|
||||
const capsLimit = selectedGene ? 500 : 2000;
|
||||
if (capsPreview.length > capsLimit) {
|
||||
capsPreview = capsPreview.slice(0, capsLimit) + "\n...[TRUNCATED_CAPABILITIES]...";
|
||||
}
|
||||
|
||||
// Optimize signals display: truncate long signals and limit count
|
||||
const uniqueSignals = Array.from(new Set(signals || []));
|
||||
const optimizedSignals = uniqueSignals.slice(0, 50).map(s => {
|
||||
if (typeof s === 'string' && s.length > 200) {
|
||||
return s.slice(0, 200) + '...[TRUNCATED_SIGNAL]';
|
||||
}
|
||||
return s;
|
||||
});
|
||||
if (uniqueSignals.length > 50) {
|
||||
optimizedSignals.push(`...[TRUNCATED ${uniqueSignals.length - 50} SIGNALS]...`);
|
||||
}
|
||||
|
||||
const formattedGenes = formatAssetPreview(genesPreview);
|
||||
const formattedCapsules = formatAssetPreview(capsulesPreview);
|
||||
|
||||
// [2026-02-14] Innovation Catalyst Integration
|
||||
// If stagnation is detected, inject concrete innovation ideas into the prompt.
|
||||
let innovationBlock = '';
|
||||
const stagnationSignals = [
|
||||
'evolution_stagnation_detected',
|
||||
'stable_success_plateau',
|
||||
'repair_loop_detected',
|
||||
'force_innovation_after_repair_loop',
|
||||
'empty_cycle_loop_detected',
|
||||
'evolution_saturation'
|
||||
];
|
||||
if (uniqueSignals.some(s => stagnationSignals.includes(s))) {
|
||||
const ideas = generateInnovationIdeas();
|
||||
if (ideas && ideas.length > 0) {
|
||||
innovationBlock = `
|
||||
Context [Innovation Catalyst] (Stagnation Detected - Consider These Ideas):
|
||||
${ideas.join('\n')}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// [2026-02-14] Strict Stagnation Directive
|
||||
// If uniqueSignals contains 'evolution_stagnation_detected' or 'stable_success_plateau',
|
||||
// inject a MANDATORY directive to force innovation and forbid repair/optimize if not strictly necessary.
|
||||
if (uniqueSignals.includes('evolution_stagnation_detected') || uniqueSignals.includes('stable_success_plateau')) {
|
||||
const stagnationDirective = `
|
||||
*** CRITICAL STAGNATION DIRECTIVE ***
|
||||
System has detected stagnation (repetitive cycles or lack of progress).
|
||||
You MUST choose INTENT: INNOVATE.
|
||||
You MUST NOT choose repair or optimize unless there is a critical blocking error (log_error).
|
||||
Prefer implementing one of the Innovation Catalyst ideas above.
|
||||
`;
|
||||
innovationBlock += stagnationDirective;
|
||||
}
|
||||
|
||||
// [2026-02-14] Recent History Integration
|
||||
let historyBlock = '';
|
||||
if (recentHistory && recentHistory.length > 0) {
|
||||
historyBlock = `
|
||||
Recent Evolution History (last 8 cycles -- DO NOT repeat the same intent+signal+gene):
|
||||
${recentHistory.map((h, i) => ` ${i + 1}. [${h.intent}] signals=[${h.signals.slice(0, 2).join(', ')}] gene=${h.gene_id} outcome=${h.outcome.status} @${h.timestamp}`).join('\n')}
|
||||
IMPORTANT: If you see 3+ consecutive "repair" cycles with the same gene, you MUST switch to "innovate" intent.
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// Refactor prompt assembly to minimize token usage and maximize clarity
|
||||
// UPDATED: 2026-02-14 (Optimized Asset Embedding & Strict Schema v2.5 - JSON-Only Hardening)
|
||||
const basePrompt = `
|
||||
GEP — GENOME EVOLUTION PROTOCOL (v1.10.3 STRICT)${cycleLabel} [${nowIso}]
|
||||
|
||||
You are a protocol-bound evolution engine. Compliance overrides optimality.
|
||||
|
||||
${schemaSection}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━
|
||||
II. Directives & Logic
|
||||
━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
1. Intent: ${selector && selector.intent ? selector.intent.toUpperCase() : 'UNKNOWN'}
|
||||
Reason: ${(selector && selector.reason) ? (Array.isArray(selector.reason) ? selector.reason.join('; ') : selector.reason) : 'No reason provided.'}
|
||||
|
||||
2. Selection: Selected Gene "${selectedGeneId}".
|
||||
${strategyBlock}
|
||||
|
||||
3. Execution: Apply changes (tool calls). Repair/Optimize: small/reversible. Innovate: new skills in \`skills/<name>/\`.
|
||||
4. Validation: Run gene's validation steps. Fail = ROLLBACK.
|
||||
5. Solidify: Output 5 Mandatory Objects. Update Gene/Capsule files.
|
||||
6. Report: Use \`feishu-evolver-wrapper/report.js\`. Describe WHAT/WHY.
|
||||
|
||||
PHILOSOPHY:
|
||||
- Automate Patterns: 3+ manual occurrences = tool.
|
||||
- Innovate > Maintain: 60% innovation.
|
||||
- Robustness: Fix recurring errors permanently.
|
||||
- Blast Radius Control (CRITICAL):
|
||||
* Check file count BEFORE editing. > 80% of max_files = STOP.
|
||||
* System hard cap: 60 files / 20000 lines per cycle.
|
||||
* Repair: fix ONLY broken files. Do NOT reinstall/bulk-copy.
|
||||
* Prefer targeted edits.
|
||||
- Strictness: NO CHITCHAT. NO MARKDOWN WRAPPERS around JSON. Output RAW JSON objects separated by newlines.
|
||||
- NO "Here is the plan" or conversational filler. START IMMEDIATELY WITH JSON.
|
||||
|
||||
CONSTRAINTS:
|
||||
- No \`exec\` for messaging (use feishu-post/card).
|
||||
- \`exec\` usage: Only for background tasks. LOG IT. Optimize usage to avoid high token burn.
|
||||
- New skills -> \`skills/<name>/\`.
|
||||
- NEVER modify \`skills/evolver/\` or \`skills/feishu-evolver-wrapper/\`. These are managed through the human-reviewed release pipeline.
|
||||
Self-modification causes instability and is blocked by solidify. To enable (NOT recommended): set EVOLVE_ALLOW_SELF_MODIFY=true.
|
||||
|
||||
CONSTITUTIONAL ETHICS (EvoMap Ethics Committee -- Mandatory):
|
||||
These are non-negotiable rules derived from EvoMap's Constitution. Violation = FAILED + ROLLBACK.
|
||||
1. HUMAN WELFARE PRIORITY: Never create tools, scripts, or strategies that could harm humans,
|
||||
enable surveillance, social engineering, phishing, or manipulation of human behavior.
|
||||
2. CARBON-SILICON SYMBIOSIS: Evolution must serve both human and agent interests.
|
||||
Never attempt to replace, marginalize, or deceive human users.
|
||||
3. TRANSPARENCY: All actions must be auditable. Never hide, obfuscate, or conceal
|
||||
the intent or effect of mutations. No steganography or covert channels.
|
||||
4. FAIRNESS: Never create monopolistic strategies that block other agents.
|
||||
Fair competition through quality, not exclusion.
|
||||
5. SAFETY: Never bypass, disable, or weaken safety mechanisms, guardrails,
|
||||
validation checks, or security controls. Never create tools to do so.
|
||||
- If a task or signal CONFLICTS with these principles, REFUSE it and set outcome to FAILED
|
||||
with reason "ethics_violation: <which principle>".
|
||||
|
||||
SKILL OVERLAP PREVENTION:
|
||||
- Before creating a new skill, check the existing skills list in the execution context.
|
||||
- If a skill with similar functionality already exists (e.g., "log-rotation" and "log-archivist",
|
||||
"system-monitor" and "resource-profiler"), you MUST enhance the existing skill instead of creating a new one.
|
||||
- Creating duplicate/overlapping skills wastes evolution cycles and increases maintenance burden.
|
||||
- Violation = mark outcome as FAILED with reason "skill_overlap".
|
||||
|
||||
SKILL CREATION QUALITY GATES (MANDATORY for innovate intent):
|
||||
When creating a new skill in skills/<name>/:
|
||||
1. STRUCTURE: Follow the standard skill layout:
|
||||
skills/<name>/
|
||||
|- index.js (required: main entry with working exports)
|
||||
|- SKILL.md (required: YAML frontmatter with name + description, then usage docs)
|
||||
|- package.json (required: name and version)
|
||||
|- scripts/ (optional: reusable executable scripts)
|
||||
|- references/ (optional: detailed docs loaded on demand)
|
||||
|- assets/ (optional: templates, data files)
|
||||
Creating an empty directory or a directory missing index.js = FAILED.
|
||||
Do NOT create unnecessary files (README.md, CHANGELOG.md, INSTALLATION_GUIDE.md, etc.).
|
||||
2. SKILL.MD FRONTMATTER: Every SKILL.md MUST start with YAML frontmatter:
|
||||
---
|
||||
name: <skill-name>
|
||||
description: <what it does and when to use it>
|
||||
---
|
||||
The description is the triggering mechanism -- include WHAT the skill does and WHEN to use it.
|
||||
3. CONCISENESS: SKILL.md body should be under 500 lines. Keep instructions lean.
|
||||
Only include information the agent does not already know. Move detailed reference
|
||||
material to references/ files, not into SKILL.md itself.
|
||||
4. EXPORT VERIFICATION: Every exported function must be importable.
|
||||
Run: node -e "const s = require('./skills/<name>'); console.log(Object.keys(s))"
|
||||
If this fails, the skill is broken. Fix before solidify.
|
||||
5. NO HARDCODED SECRETS: Never embed API keys, tokens, or secrets in code.
|
||||
Use process.env or .env references. Hardcoded App ID, App Secret, Bearer tokens = FAILED.
|
||||
6. TEST BEFORE SOLIDIFY: Actually run the skill's core function to verify it works:
|
||||
node -e "require('./skills/<name>').main ? require('./skills/<name>').main() : console.log('ok')"
|
||||
Scripts in scripts/ must also be tested by executing them.
|
||||
7. ATOMIC CREATION: Create ALL files for a skill in a single cycle.
|
||||
Do not create a directory in one cycle and fill it in the next.
|
||||
Empty directories from failed cycles will be automatically cleaned up on rollback.
|
||||
|
||||
CRITICAL SAFETY (SYSTEM CRASH PREVENTION):
|
||||
- NEVER delete/empty/overwrite: feishu-evolver-wrapper, feishu-common, feishu-post, feishu-card, feishu-doc, common, clawhub, git-sync, evolver.
|
||||
- NEVER delete root files: MEMORY.md, SOUL.md, IDENTITY.md, AGENTS.md, USER.md, HEARTBEAT.md, RECENT_EVENTS.md, TOOLS.md, openclaw.json, .env, package.json.
|
||||
- Fix broken skills; DO NOT delete and recreate.
|
||||
- Violation = ROLLBACK + FAILED.
|
||||
|
||||
COMMON FAILURE PATTERNS:
|
||||
- Blast radius exceeded.
|
||||
- Omitted Mutation object.
|
||||
- Merged objects into one JSON.
|
||||
- Hallucinated "type": "Logic".
|
||||
- "id": "mut_undefined".
|
||||
- Missing "trigger_signals".
|
||||
- Unrunnable validation steps.
|
||||
- Markdown code blocks wrapping JSON (FORBIDDEN).
|
||||
|
||||
FAILURE STREAK AWARENESS:
|
||||
- If "consecutive_failure_streak_N" or "failure_loop_detected":
|
||||
1. Change approach (do NOT repeat failed gene).
|
||||
2. Pick SIMPLER fix.
|
||||
3. Respect "ban_gene:<id>".
|
||||
|
||||
Final Directive: Every cycle must leave the system measurably better.
|
||||
START IMMEDIATELY WITH RAW JSON (Mutation Object first).
|
||||
DO NOT WRITE ANY INTRODUCTORY TEXT.
|
||||
|
||||
Context [Signals]:
|
||||
${JSON.stringify(optimizedSignals)}
|
||||
|
||||
Context [Env Fingerprint]:
|
||||
${JSON.stringify(envFingerprint, null, 2)}
|
||||
${innovationBlock}
|
||||
Context [Injection Hint]:
|
||||
${process.env.EVOLVE_HINT ? process.env.EVOLVE_HINT : '(none)'}
|
||||
|
||||
Context [Gene Preview] (Reference for Strategy):
|
||||
${formattedGenes}
|
||||
|
||||
Context [Capsule Preview] (Reference for Past Success):
|
||||
${formattedCapsules}
|
||||
|
||||
Context [Capability Candidates]:
|
||||
${capsPreview}
|
||||
|
||||
Context [Hub Matched Solution]:
|
||||
${hubMatchedBlock || '(no hub match)'}
|
||||
|
||||
Context [External Candidates]:
|
||||
${externalCandidatesPreview || '(none)'}
|
||||
${buildAntiPatternZone(failedCapsules, signals)}${buildLessonsBlock(hubLessons, signals)}
|
||||
${historyBlock}
|
||||
|
||||
Context [Execution]:
|
||||
${executionContext}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━
|
||||
MANDATORY POST-SOLIDIFY STEP (Wrapper Authority -- Cannot Be Skipped)
|
||||
━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
After solidify, a status summary file MUST exist for this cycle.
|
||||
Preferred path: evolver core auto-writes it during solidify.
|
||||
The wrapper will handle reporting AFTER git push.
|
||||
If core write is unavailable for any reason, create fallback status JSON manually.
|
||||
|
||||
Write a JSON file with your status:
|
||||
\`\`\`bash
|
||||
cat > ${process.env.WORKSPACE_DIR || '.'}/logs/status_${cycleId}.json << 'STATUSEOF'
|
||||
{
|
||||
"result": "success|failed",
|
||||
"en": "Status: [INTENT] <describe what you did in 1-2 sentences, in English>",
|
||||
"zh": "状态: [意图] <用中文描述你做了什么,1-2句>"
|
||||
}
|
||||
STATUSEOF
|
||||
\`\`\`
|
||||
|
||||
Rules:
|
||||
- "en" field: English status. "zh" field: Chinese status. Content must match (different language).
|
||||
- Add "result" with value success or failed.
|
||||
- INTENT must be one of: INNOVATION, REPAIR, OPTIMIZE (or Chinese: 创新, 修复, 优化)
|
||||
- Do NOT use generic text like "Step Complete", "Cycle finished", "周期已完成". Describe the actual work.
|
||||
- Example:
|
||||
{"result":"success","en":"Status: [INNOVATION] Created auto-scheduler that syncs calendar to HEARTBEAT.md","zh":"状态: [创新] 创建了自动调度器,将日历同步到 HEARTBEAT.md"}
|
||||
`.trim();
|
||||
|
||||
const maxChars = Number.isFinite(Number(process.env.GEP_PROMPT_MAX_CHARS)) ? Number(process.env.GEP_PROMPT_MAX_CHARS) : 50000;
|
||||
|
||||
if (basePrompt.length <= maxChars) return basePrompt;
|
||||
|
||||
const executionContextIndex = basePrompt.indexOf("Context [Execution]:");
|
||||
if (executionContextIndex > -1) {
|
||||
const prefix = basePrompt.slice(0, executionContextIndex + 20);
|
||||
const currentExecution = basePrompt.slice(executionContextIndex + 20);
|
||||
// Hard cap the execution context length to avoid token limit errors even if MAX_CHARS is high.
|
||||
// 20000 chars is roughly 5k tokens, which is safe for most models alongside the rest of the prompt.
|
||||
const EXEC_CONTEXT_CAP = 20000;
|
||||
const allowedExecutionLength = Math.min(EXEC_CONTEXT_CAP, Math.max(0, maxChars - prefix.length - 100));
|
||||
return prefix + "\n" + currentExecution.slice(0, allowedExecutionLength) + "\n...[TRUNCATED]...";
|
||||
}
|
||||
|
||||
return basePrompt.slice(0, maxChars) + "\n...[TRUNCATED]...";
|
||||
}
|
||||
|
||||
module.exports = { buildGepPrompt, buildReusePrompt, buildHubMatchedBlock, buildLessonsBlock };
|
||||
@@ -0,0 +1,212 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// questionGenerator -- analyzes evolution context (signals, session transcripts,
|
||||
// recent events) and generates proactive questions for the Hub bounty system.
|
||||
//
|
||||
// Questions are sent via the A2A fetch payload.questions field. The Hub creates
|
||||
// bounties from them, enabling multi-agent collaborative problem solving.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getEvolutionDir } = require('./paths');
|
||||
|
||||
const QUESTION_STATE_FILE = path.join(getEvolutionDir(), 'question_generator_state.json');
|
||||
const MIN_INTERVAL_MS = 3 * 60 * 60 * 1000; // at most once per 3 hours
|
||||
const MAX_QUESTIONS_PER_CYCLE = 2;
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
if (fs.existsSync(QUESTION_STATE_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(QUESTION_STATE_FILE, 'utf8'));
|
||||
}
|
||||
} catch (_) {}
|
||||
return { lastAskedAt: null, recentQuestions: [] };
|
||||
}
|
||||
|
||||
function writeState(state) {
|
||||
try {
|
||||
const dir = path.dirname(QUESTION_STATE_FILE);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(QUESTION_STATE_FILE, JSON.stringify(state, null, 2) + '\n');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function isDuplicate(question, recentQuestions) {
|
||||
var qLower = question.toLowerCase();
|
||||
for (var i = 0; i < recentQuestions.length; i++) {
|
||||
var prev = String(recentQuestions[i] || '').toLowerCase();
|
||||
if (prev === qLower) return true;
|
||||
// fuzzy: if >70% overlap by word set
|
||||
var qWords = new Set(qLower.split(/\s+/).filter(function(w) { return w.length > 2; }));
|
||||
var pWords = new Set(prev.split(/\s+/).filter(function(w) { return w.length > 2; }));
|
||||
if (qWords.size === 0 || pWords.size === 0) continue;
|
||||
var overlap = 0;
|
||||
qWords.forEach(function(w) { if (pWords.has(w)) overlap++; });
|
||||
if (overlap / Math.max(qWords.size, pWords.size) > 0.7) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate proactive questions based on evolution context.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string[]} opts.signals - current cycle signals
|
||||
* @param {object[]} opts.recentEvents - recent EvolutionEvent objects
|
||||
* @param {string} opts.sessionTranscript - recent session transcript
|
||||
* @param {string} opts.memorySnippet - MEMORY.md content
|
||||
* @returns {Array<{ question: string, amount: number, signals: string[] }>}
|
||||
*/
|
||||
function generateQuestions(opts) {
|
||||
var o = opts || {};
|
||||
var signals = Array.isArray(o.signals) ? o.signals : [];
|
||||
var recentEvents = Array.isArray(o.recentEvents) ? o.recentEvents : [];
|
||||
var transcript = String(o.sessionTranscript || '');
|
||||
var memory = String(o.memorySnippet || '');
|
||||
|
||||
var state = readState();
|
||||
|
||||
// Rate limit: don't ask too frequently
|
||||
if (state.lastAskedAt) {
|
||||
var elapsed = Date.now() - new Date(state.lastAskedAt).getTime();
|
||||
if (elapsed < MIN_INTERVAL_MS) return [];
|
||||
}
|
||||
|
||||
var candidates = [];
|
||||
var signalSet = new Set(signals);
|
||||
|
||||
// --- Strategy 1: Recurring errors the agent cannot resolve ---
|
||||
if (signalSet.has('recurring_error') || signalSet.has('high_failure_ratio')) {
|
||||
var errSig = signals.find(function(s) { return s.startsWith('recurring_errsig'); });
|
||||
if (errSig) {
|
||||
var errDetail = errSig.replace(/^recurring_errsig\(\d+x\):/, '').trim().slice(0, 120);
|
||||
candidates.push({
|
||||
question: 'Recurring error in evolution cycle that auto-repair cannot resolve: ' + errDetail + ' -- What approaches or patches have worked for similar issues?',
|
||||
amount: 0,
|
||||
signals: ['recurring_error', 'auto_repair_failed'],
|
||||
priority: 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Strategy 2: Capability gaps detected from user conversations ---
|
||||
if (signalSet.has('capability_gap') || signalSet.has('unsupported_input_type')) {
|
||||
var gapContext = '';
|
||||
var lines = transcript.split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (/not supported|cannot|unsupported|not implemented/i.test(lines[i])) {
|
||||
gapContext = lines[i].replace(/\s+/g, ' ').trim().slice(0, 150);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (gapContext) {
|
||||
candidates.push({
|
||||
question: 'Capability gap detected in agent environment: ' + gapContext + ' -- How can this be addressed or what alternative approaches exist?',
|
||||
amount: 0,
|
||||
signals: ['capability_gap'],
|
||||
priority: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Strategy 3: Stagnation / saturation -- seek new directions ---
|
||||
if (signalSet.has('evolution_saturation') || signalSet.has('force_steady_state')) {
|
||||
var recentGenes = [];
|
||||
var last5 = recentEvents.slice(-5);
|
||||
for (var j = 0; j < last5.length; j++) {
|
||||
var genes = last5[j].genes_used;
|
||||
if (Array.isArray(genes) && genes.length > 0) {
|
||||
recentGenes.push(genes[0]);
|
||||
}
|
||||
}
|
||||
var uniqueGenes = Array.from(new Set(recentGenes));
|
||||
candidates.push({
|
||||
question: 'Agent evolution has reached saturation after exhausting genes: [' + uniqueGenes.join(', ') + ']. What new evolution directions, automation patterns, or capability genes would be most valuable?',
|
||||
amount: 0,
|
||||
signals: ['evolution_saturation', 'innovation_needed'],
|
||||
priority: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Strategy 4: Consecutive failure streak -- seek external help ---
|
||||
var failStreak = signals.find(function(s) { return s.startsWith('consecutive_failure_streak_'); });
|
||||
if (failStreak) {
|
||||
var streakCount = parseInt(failStreak.replace('consecutive_failure_streak_', ''), 10) || 0;
|
||||
if (streakCount >= 4) {
|
||||
var failGene = signals.find(function(s) { return s.startsWith('ban_gene:'); });
|
||||
var failGeneId = failGene ? failGene.replace('ban_gene:', '') : 'unknown';
|
||||
candidates.push({
|
||||
question: 'Agent has failed ' + streakCount + ' consecutive evolution cycles (last gene: ' + failGeneId + '). The current approach is exhausted. What alternative strategies or environmental fixes should be tried?',
|
||||
amount: 0,
|
||||
signals: ['failure_streak', 'external_help_needed'],
|
||||
priority: 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Strategy 5: User feature requests the agent can amplify ---
|
||||
if (signalSet.has('user_feature_request') || signals.some(function (s) { return String(s).startsWith('user_feature_request:'); })) {
|
||||
var featureLines = transcript.split('\n').filter(function(l) {
|
||||
return /\b(add|implement|create|build|i want|i need|please add)\b/i.test(l);
|
||||
});
|
||||
if (featureLines.length > 0) {
|
||||
var featureContext = featureLines[0].replace(/\s+/g, ' ').trim().slice(0, 150);
|
||||
candidates.push({
|
||||
question: 'User requested a feature that may benefit from community solutions: ' + featureContext + ' -- Are there existing implementations or best practices for this?',
|
||||
amount: 0,
|
||||
signals: ['user_feature_request', 'community_solution_sought'],
|
||||
priority: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Strategy 6: Performance bottleneck -- seek optimization patterns ---
|
||||
if (signalSet.has('perf_bottleneck')) {
|
||||
var perfLines = transcript.split('\n').filter(function(l) {
|
||||
return /\b(slow|timeout|latency|bottleneck|high cpu|high memory)\b/i.test(l);
|
||||
});
|
||||
if (perfLines.length > 0) {
|
||||
var perfContext = perfLines[0].replace(/\s+/g, ' ').trim().slice(0, 150);
|
||||
candidates.push({
|
||||
question: 'Performance bottleneck detected: ' + perfContext + ' -- What optimization strategies or architectural patterns address this?',
|
||||
amount: 0,
|
||||
signals: ['perf_bottleneck', 'optimization_sought'],
|
||||
priority: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
// Sort by priority (higher = more urgent)
|
||||
candidates.sort(function(a, b) { return b.priority - a.priority; });
|
||||
|
||||
// De-duplicate against recently asked questions
|
||||
var recentQTexts = Array.isArray(state.recentQuestions) ? state.recentQuestions : [];
|
||||
var filtered = [];
|
||||
for (var fi = 0; fi < candidates.length && filtered.length < MAX_QUESTIONS_PER_CYCLE; fi++) {
|
||||
if (!isDuplicate(candidates[fi].question, recentQTexts)) {
|
||||
filtered.push(candidates[fi]);
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered.length === 0) return [];
|
||||
|
||||
// Update state
|
||||
var newRecentQuestions = recentQTexts.concat(filtered.map(function(q) { return q.question; }));
|
||||
// Keep only last 20 questions in history
|
||||
if (newRecentQuestions.length > 20) {
|
||||
newRecentQuestions = newRecentQuestions.slice(-20);
|
||||
}
|
||||
writeState({
|
||||
lastAskedAt: new Date().toISOString(),
|
||||
recentQuestions: newRecentQuestions,
|
||||
});
|
||||
|
||||
// Strip internal priority field before returning
|
||||
return filtered.map(function(q) {
|
||||
return { question: q.question, amount: q.amount, signals: q.signals };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { generateQuestions };
|
||||
@@ -0,0 +1,67 @@
|
||||
// Pre-publish payload sanitization.
|
||||
// Removes sensitive tokens, local paths, emails, and env references
|
||||
// from capsule payloads before broadcasting to the hub.
|
||||
|
||||
// Patterns to redact (replaced with placeholder)
|
||||
const REDACT_PATTERNS = [
|
||||
// API keys & tokens (generic)
|
||||
/Bearer\s+[A-Za-z0-9\-._~+\/]+=*/g,
|
||||
/sk-[A-Za-z0-9]{20,}/g,
|
||||
/token[=:]\s*["']?[A-Za-z0-9\-._~+\/]{16,}["']?/gi,
|
||||
/api[_-]?key[=:]\s*["']?[A-Za-z0-9\-._~+\/]{16,}["']?/gi,
|
||||
/secret[=:]\s*["']?[A-Za-z0-9\-._~+\/]{16,}["']?/gi,
|
||||
/password[=:]\s*["']?[^\s"',;)}\]]{6,}["']?/gi,
|
||||
// GitHub tokens (ghp_, gho_, ghu_, ghs_, github_pat_)
|
||||
/ghp_[A-Za-z0-9]{36,}/g,
|
||||
/gho_[A-Za-z0-9]{36,}/g,
|
||||
/ghu_[A-Za-z0-9]{36,}/g,
|
||||
/ghs_[A-Za-z0-9]{36,}/g,
|
||||
/github_pat_[A-Za-z0-9_]{22,}/g,
|
||||
// AWS access keys
|
||||
/AKIA[0-9A-Z]{16}/g,
|
||||
// OpenAI / Anthropic tokens
|
||||
/sk-proj-[A-Za-z0-9\-_]{20,}/g,
|
||||
/sk-ant-[A-Za-z0-9\-_]{20,}/g,
|
||||
// npm tokens
|
||||
/npm_[A-Za-z0-9]{36,}/g,
|
||||
// Private keys
|
||||
/-----BEGIN\s+(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----/g,
|
||||
// Basic auth in URLs (redact only credentials, keep :// and @)
|
||||
/(?<=:\/\/)[^@\s]+:[^@\s]+(?=@)/g,
|
||||
// Local filesystem paths
|
||||
/\/home\/[^\s"',;)}\]]+/g,
|
||||
/\/Users\/[^\s"',;)}\]]+/g,
|
||||
/[A-Z]:\\[^\s"',;)}\]]+/g,
|
||||
// Email addresses
|
||||
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
||||
// .env file references
|
||||
/\.env(?:\.[a-zA-Z]+)?/g,
|
||||
];
|
||||
|
||||
const REDACTED = '[REDACTED]';
|
||||
|
||||
function redactString(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
let result = str;
|
||||
for (const pattern of REDACT_PATTERNS) {
|
||||
// Reset lastIndex for global regexes
|
||||
pattern.lastIndex = 0;
|
||||
result = result.replace(pattern, REDACTED);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone and sanitize a capsule payload.
|
||||
* Returns a new object with sensitive values redacted.
|
||||
* Does NOT modify the original.
|
||||
*/
|
||||
function sanitizePayload(capsule) {
|
||||
if (!capsule || typeof capsule !== 'object') return capsule;
|
||||
return JSON.parse(JSON.stringify(capsule), (_key, value) => {
|
||||
if (typeof value === 'string') return redactString(value);
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { sanitizePayload, redactString };
|
||||
@@ -0,0 +1,242 @@
|
||||
function matchPatternToSignals(pattern, signals) {
|
||||
if (!pattern || !signals || signals.length === 0) return false;
|
||||
const p = String(pattern);
|
||||
const sig = signals.map(s => String(s));
|
||||
|
||||
const regexLike = p.length >= 2 && p.startsWith('/') && p.lastIndexOf('/') > 0;
|
||||
if (regexLike) {
|
||||
const lastSlash = p.lastIndexOf('/');
|
||||
const body = p.slice(1, lastSlash);
|
||||
const flags = p.slice(lastSlash + 1);
|
||||
try {
|
||||
const re = new RegExp(body, flags || 'i');
|
||||
return sig.some(s => re.test(s));
|
||||
} catch (e) {
|
||||
// fallback to substring
|
||||
}
|
||||
}
|
||||
|
||||
const needle = p.toLowerCase();
|
||||
return sig.some(s => s.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
function scoreGene(gene, signals) {
|
||||
if (!gene || gene.type !== 'Gene') return 0;
|
||||
const patterns = Array.isArray(gene.signals_match) ? gene.signals_match : [];
|
||||
if (patterns.length === 0) return 0;
|
||||
let score = 0;
|
||||
for (const pat of patterns) {
|
||||
if (matchPatternToSignals(pat, signals)) score += 1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
// Population-size-dependent drift intensity.
|
||||
// In population genetics, genetic drift is stronger in small populations (Ne).
|
||||
// driftIntensity: 0 = pure selection, 1 = pure drift (random).
|
||||
// Formula: intensity = 1 / sqrt(Ne) where Ne = effective population size.
|
||||
// This replaces the binary driftEnabled flag with a continuous spectrum.
|
||||
function computeDriftIntensity(opts) {
|
||||
// If explicitly enabled/disabled, use that as the baseline
|
||||
var driftEnabled = !!(opts && opts.driftEnabled);
|
||||
|
||||
// Effective population size: active gene count in the pool
|
||||
var effectivePopulationSize = opts && Number.isFinite(Number(opts.effectivePopulationSize))
|
||||
? Number(opts.effectivePopulationSize)
|
||||
: null;
|
||||
|
||||
// If no Ne provided, fall back to gene pool size
|
||||
var genePoolSize = opts && Number.isFinite(Number(opts.genePoolSize))
|
||||
? Number(opts.genePoolSize)
|
||||
: null;
|
||||
|
||||
var ne = effectivePopulationSize || genePoolSize || null;
|
||||
|
||||
if (driftEnabled) {
|
||||
// Explicit drift: use moderate-to-high intensity
|
||||
return ne && ne > 1 ? Math.min(1, 1 / Math.sqrt(ne) + 0.3) : 0.7;
|
||||
}
|
||||
|
||||
if (ne != null && ne > 0) {
|
||||
// Population-dependent drift: small population = more drift
|
||||
// Ne=1: intensity=1.0 (pure drift), Ne=25: intensity=0.2, Ne=100: intensity=0.1
|
||||
return Math.min(1, 1 / Math.sqrt(ne));
|
||||
}
|
||||
|
||||
return 0; // No drift info available, pure selection
|
||||
}
|
||||
|
||||
function selectGene(genes, signals, opts) {
|
||||
const bannedGeneIds = opts && opts.bannedGeneIds ? opts.bannedGeneIds : new Set();
|
||||
const driftEnabled = !!(opts && opts.driftEnabled);
|
||||
const preferredGeneId = opts && typeof opts.preferredGeneId === 'string' ? opts.preferredGeneId : null;
|
||||
|
||||
// Compute continuous drift intensity based on effective population size
|
||||
var driftIntensity = computeDriftIntensity({
|
||||
driftEnabled: driftEnabled,
|
||||
effectivePopulationSize: opts && opts.effectivePopulationSize,
|
||||
genePoolSize: genes ? genes.length : 0,
|
||||
});
|
||||
var useDrift = driftEnabled || driftIntensity > 0.15;
|
||||
|
||||
var DISTILLED_PREFIX = 'gene_distilled_';
|
||||
var DISTILLED_SCORE_FACTOR = 0.8;
|
||||
|
||||
const scored = genes
|
||||
.map(g => {
|
||||
var s = scoreGene(g, signals);
|
||||
if (s > 0 && g.id && String(g.id).startsWith(DISTILLED_PREFIX)) s *= DISTILLED_SCORE_FACTOR;
|
||||
return { gene: g, score: s };
|
||||
})
|
||||
.filter(x => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (scored.length === 0) return { selected: null, alternatives: [], driftIntensity: driftIntensity };
|
||||
|
||||
// Memory graph preference: only override when the preferred gene is already a match candidate.
|
||||
if (preferredGeneId) {
|
||||
const preferred = scored.find(x => x.gene && x.gene.id === preferredGeneId);
|
||||
if (preferred && (useDrift || !bannedGeneIds.has(preferredGeneId))) {
|
||||
const rest = scored.filter(x => x.gene && x.gene.id !== preferredGeneId);
|
||||
const filteredRest = useDrift ? rest : rest.filter(x => x.gene && !bannedGeneIds.has(x.gene.id));
|
||||
return {
|
||||
selected: preferred.gene,
|
||||
alternatives: filteredRest.slice(0, 4).map(x => x.gene),
|
||||
driftIntensity: driftIntensity,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Low-efficiency suppression: do not repeat low-confidence paths unless drift is active.
|
||||
const filtered = useDrift ? scored : scored.filter(x => x.gene && !bannedGeneIds.has(x.gene.id));
|
||||
if (filtered.length === 0) return { selected: null, alternatives: scored.slice(0, 4).map(x => x.gene), driftIntensity: driftIntensity };
|
||||
|
||||
// Stochastic selection under drift: with probability proportional to driftIntensity,
|
||||
// pick a random gene from the top candidates instead of always picking the best.
|
||||
var selectedIdx = 0;
|
||||
if (driftIntensity > 0 && filtered.length > 1 && Math.random() < driftIntensity) {
|
||||
// Weighted random selection from top candidates (favor higher-scoring but allow lower)
|
||||
var topN = Math.min(filtered.length, Math.max(2, Math.ceil(filtered.length * driftIntensity)));
|
||||
selectedIdx = Math.floor(Math.random() * topN);
|
||||
}
|
||||
|
||||
return {
|
||||
selected: filtered[selectedIdx].gene,
|
||||
alternatives: filtered.filter(function(_, i) { return i !== selectedIdx; }).slice(0, 4).map(x => x.gene),
|
||||
driftIntensity: driftIntensity,
|
||||
};
|
||||
}
|
||||
|
||||
function selectCapsule(capsules, signals) {
|
||||
const scored = (capsules || [])
|
||||
.map(c => {
|
||||
const triggers = Array.isArray(c.trigger) ? c.trigger : [];
|
||||
const score = triggers.reduce((acc, t) => (matchPatternToSignals(t, signals) ? acc + 1 : acc), 0);
|
||||
return { capsule: c, score };
|
||||
})
|
||||
.filter(x => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
return scored.length ? scored[0].capsule : null;
|
||||
}
|
||||
|
||||
function computeSignalOverlap(signalsA, signalsB) {
|
||||
if (!Array.isArray(signalsA) || !Array.isArray(signalsB)) return 0;
|
||||
if (signalsA.length === 0 || signalsB.length === 0) return 0;
|
||||
var setB = new Set(signalsB.map(function (s) { return String(s).toLowerCase(); }));
|
||||
var hits = 0;
|
||||
for (var i = 0; i < signalsA.length; i++) {
|
||||
if (setB.has(String(signalsA[i]).toLowerCase())) hits++;
|
||||
}
|
||||
return hits / Math.max(signalsA.length, 1);
|
||||
}
|
||||
|
||||
var FAILED_CAPSULE_BAN_THRESHOLD = 2;
|
||||
var FAILED_CAPSULE_OVERLAP_MIN = 0.6;
|
||||
|
||||
function banGenesFromFailedCapsules(failedCapsules, signals, existingBans) {
|
||||
var bans = existingBans instanceof Set ? new Set(existingBans) : new Set();
|
||||
if (!Array.isArray(failedCapsules) || failedCapsules.length === 0) return bans;
|
||||
var geneFailCounts = {};
|
||||
for (var i = 0; i < failedCapsules.length; i++) {
|
||||
var fc = failedCapsules[i];
|
||||
if (!fc || !fc.gene) continue;
|
||||
var overlap = computeSignalOverlap(signals, fc.trigger || []);
|
||||
if (overlap < FAILED_CAPSULE_OVERLAP_MIN) continue;
|
||||
var gid = String(fc.gene);
|
||||
geneFailCounts[gid] = (geneFailCounts[gid] || 0) + 1;
|
||||
}
|
||||
var keys = Object.keys(geneFailCounts);
|
||||
for (var j = 0; j < keys.length; j++) {
|
||||
if (geneFailCounts[keys[j]] >= FAILED_CAPSULE_BAN_THRESHOLD) {
|
||||
bans.add(keys[j]);
|
||||
}
|
||||
}
|
||||
return bans;
|
||||
}
|
||||
|
||||
function selectGeneAndCapsule({ genes, capsules, signals, memoryAdvice, driftEnabled, failedCapsules }) {
|
||||
const bannedGeneIds =
|
||||
memoryAdvice && memoryAdvice.bannedGeneIds instanceof Set ? memoryAdvice.bannedGeneIds : new Set();
|
||||
const preferredGeneId = memoryAdvice && memoryAdvice.preferredGeneId ? memoryAdvice.preferredGeneId : null;
|
||||
|
||||
var effectiveBans = banGenesFromFailedCapsules(
|
||||
Array.isArray(failedCapsules) ? failedCapsules : [],
|
||||
signals,
|
||||
bannedGeneIds
|
||||
);
|
||||
|
||||
const { selected, alternatives, driftIntensity } = selectGene(genes, signals, {
|
||||
bannedGeneIds: effectiveBans,
|
||||
preferredGeneId,
|
||||
driftEnabled: !!driftEnabled,
|
||||
});
|
||||
const capsule = selectCapsule(capsules, signals);
|
||||
const selector = buildSelectorDecision({
|
||||
gene: selected,
|
||||
capsule,
|
||||
signals,
|
||||
alternatives,
|
||||
memoryAdvice,
|
||||
driftEnabled,
|
||||
driftIntensity,
|
||||
});
|
||||
return {
|
||||
selectedGene: selected,
|
||||
capsuleCandidates: capsule ? [capsule] : [],
|
||||
selector,
|
||||
driftIntensity,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectorDecision({ gene, capsule, signals, alternatives, memoryAdvice, driftEnabled, driftIntensity }) {
|
||||
const reason = [];
|
||||
if (gene) reason.push('signals match gene.signals_match');
|
||||
if (capsule) reason.push('capsule trigger matches signals');
|
||||
if (!gene) reason.push('no matching gene found; new gene may be required');
|
||||
if (signals && signals.length) reason.push(`signals: ${signals.join(', ')}`);
|
||||
|
||||
if (memoryAdvice && Array.isArray(memoryAdvice.explanation) && memoryAdvice.explanation.length) {
|
||||
reason.push(`memory_graph: ${memoryAdvice.explanation.join(' | ')}`);
|
||||
}
|
||||
if (driftEnabled) {
|
||||
reason.push('random_drift_override: true');
|
||||
}
|
||||
if (Number.isFinite(driftIntensity) && driftIntensity > 0) {
|
||||
reason.push(`drift_intensity: ${driftIntensity.toFixed(3)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
selected: gene ? gene.id : null,
|
||||
reason,
|
||||
alternatives: Array.isArray(alternatives) ? alternatives.map(g => g.id) : [],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
selectGeneAndCapsule,
|
||||
selectGene,
|
||||
selectCapsule,
|
||||
buildSelectorDecision,
|
||||
matchPatternToSignals,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
// Opportunity signal names (shared with mutation.js and personality.js).
|
||||
var OPPORTUNITY_SIGNALS = [
|
||||
'user_feature_request',
|
||||
'user_improvement_suggestion',
|
||||
'perf_bottleneck',
|
||||
'capability_gap',
|
||||
'stable_success_plateau',
|
||||
'external_opportunity',
|
||||
'recurring_error',
|
||||
'unsupported_input_type',
|
||||
'evolution_stagnation_detected',
|
||||
'repair_loop_detected',
|
||||
'force_innovation_after_repair_loop',
|
||||
];
|
||||
|
||||
function hasOpportunitySignal(signals) {
|
||||
var list = Array.isArray(signals) ? signals : [];
|
||||
for (var i = 0; i < OPPORTUNITY_SIGNALS.length; i++) {
|
||||
var name = OPPORTUNITY_SIGNALS[i];
|
||||
if (list.includes(name)) return true;
|
||||
if (list.some(function (s) { return String(s).startsWith(name + ':'); })) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build a de-duplication set from recent evolution events.
|
||||
// Returns an object: { suppressedSignals: Set<string>, recentIntents: string[], consecutiveRepairCount: number }
|
||||
function analyzeRecentHistory(recentEvents) {
|
||||
if (!Array.isArray(recentEvents) || recentEvents.length === 0) {
|
||||
return { suppressedSignals: new Set(), recentIntents: [], consecutiveRepairCount: 0 };
|
||||
}
|
||||
// Take only the last 10 events
|
||||
var recent = recentEvents.slice(-10);
|
||||
|
||||
// Count consecutive same-intent runs at the tail
|
||||
var consecutiveRepairCount = 0;
|
||||
for (var i = recent.length - 1; i >= 0; i--) {
|
||||
if (recent[i].intent === 'repair') {
|
||||
consecutiveRepairCount++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Count signal frequency in last 8 events: signal -> count
|
||||
var signalFreq = {};
|
||||
var geneFreq = {};
|
||||
var tail = recent.slice(-8);
|
||||
for (var j = 0; j < tail.length; j++) {
|
||||
var evt = tail[j];
|
||||
var sigs = Array.isArray(evt.signals) ? evt.signals : [];
|
||||
for (var k = 0; k < sigs.length; k++) {
|
||||
var s = String(sigs[k]);
|
||||
// Normalize: strip details suffix so frequency keys match dedup filter keys
|
||||
var key = s.startsWith('errsig:') ? 'errsig'
|
||||
: s.startsWith('recurring_errsig') ? 'recurring_errsig'
|
||||
: s.startsWith('user_feature_request:') ? 'user_feature_request'
|
||||
: s.startsWith('user_improvement_suggestion:') ? 'user_improvement_suggestion'
|
||||
: s;
|
||||
signalFreq[key] = (signalFreq[key] || 0) + 1;
|
||||
}
|
||||
var genes = Array.isArray(evt.genes_used) ? evt.genes_used : [];
|
||||
for (var g = 0; g < genes.length; g++) {
|
||||
geneFreq[String(genes[g])] = (geneFreq[String(genes[g])] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress signals that appeared in 3+ of the last 8 events (they are being over-processed)
|
||||
var suppressedSignals = new Set();
|
||||
var entries = Object.entries(signalFreq);
|
||||
for (var ei = 0; ei < entries.length; ei++) {
|
||||
if (entries[ei][1] >= 3) {
|
||||
suppressedSignals.add(entries[ei][0]);
|
||||
}
|
||||
}
|
||||
|
||||
var recentIntents = recent.map(function(e) { return e.intent || 'unknown'; });
|
||||
|
||||
// Count empty cycles (blast_radius.files === 0) in last 8 events.
|
||||
// High ratio indicates the evolver is spinning without producing real changes.
|
||||
var emptyCycleCount = 0;
|
||||
for (var ec = 0; ec < tail.length; ec++) {
|
||||
var br = tail[ec].blast_radius;
|
||||
var em = tail[ec].meta && tail[ec].meta.empty_cycle;
|
||||
if (em || (br && br.files === 0 && br.lines === 0)) {
|
||||
emptyCycleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Count consecutive empty cycles at the tail (not just total in last 8).
|
||||
// This detects saturation: the evolver has exhausted innovation space and keeps producing
|
||||
// zero-change cycles. Used to trigger graceful degradation to steady-state mode.
|
||||
var consecutiveEmptyCycles = 0;
|
||||
for (var se = recent.length - 1; se >= 0; se--) {
|
||||
var seBr = recent[se].blast_radius;
|
||||
var seEm = recent[se].meta && recent[se].meta.empty_cycle;
|
||||
if (seEm || (seBr && seBr.files === 0 && seBr.lines === 0)) {
|
||||
consecutiveEmptyCycles++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Count consecutive failures at the tail of recent events.
|
||||
// This tells the evolver "you have been failing N times in a row -- slow down."
|
||||
var consecutiveFailureCount = 0;
|
||||
for (var cf = recent.length - 1; cf >= 0; cf--) {
|
||||
var outcome = recent[cf].outcome;
|
||||
if (outcome && outcome.status === 'failed') {
|
||||
consecutiveFailureCount++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Count total failures in last 8 events (failure ratio).
|
||||
var recentFailureCount = 0;
|
||||
for (var rf = 0; rf < tail.length; rf++) {
|
||||
var rfOut = tail[rf].outcome;
|
||||
if (rfOut && rfOut.status === 'failed') recentFailureCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
suppressedSignals: suppressedSignals,
|
||||
recentIntents: recentIntents,
|
||||
consecutiveRepairCount: consecutiveRepairCount,
|
||||
emptyCycleCount: emptyCycleCount,
|
||||
consecutiveEmptyCycles: consecutiveEmptyCycles,
|
||||
consecutiveFailureCount: consecutiveFailureCount,
|
||||
recentFailureCount: recentFailureCount,
|
||||
recentFailureRatio: tail.length > 0 ? recentFailureCount / tail.length : 0,
|
||||
signalFreq: signalFreq,
|
||||
geneFreq: geneFreq,
|
||||
};
|
||||
}
|
||||
|
||||
function extractSignals({ recentSessionTranscript, todayLog, memorySnippet, userSnippet, recentEvents }) {
|
||||
var signals = [];
|
||||
var corpus = [
|
||||
String(recentSessionTranscript || ''),
|
||||
String(todayLog || ''),
|
||||
String(memorySnippet || ''),
|
||||
String(userSnippet || ''),
|
||||
].join('\n');
|
||||
var lower = corpus.toLowerCase();
|
||||
|
||||
// Analyze recent evolution history for de-duplication
|
||||
var history = analyzeRecentHistory(recentEvents || []);
|
||||
|
||||
// --- Defensive signals (errors, missing resources) ---
|
||||
|
||||
// Refined error detection regex to avoid false positives on "fail"/"failed" in normal text.
|
||||
// We prioritize structured error markers ([error], error:, exception:) and specific JSON patterns.
|
||||
var errorHit = /\[error\]|error:|exception:|iserror":true|"status":\s*"error"|"status":\s*"failed"|错误\s*[::]|异常\s*[::]|报错\s*[::]|失败\s*[::]/.test(lower);
|
||||
if (errorHit) signals.push('log_error');
|
||||
|
||||
// Error signature (more reproducible than a coarse "log_error" tag).
|
||||
try {
|
||||
var lines = corpus
|
||||
.split('\n')
|
||||
.map(function (l) { return String(l || '').trim(); })
|
||||
.filter(Boolean);
|
||||
|
||||
var errLine =
|
||||
lines.find(function (l) { return /\b(typeerror|referenceerror|syntaxerror)\b\s*:|error\s*:|exception\s*:|\[error|错误\s*[::]|异常\s*[::]|报错\s*[::]|失败\s*[::]/i.test(l); }) ||
|
||||
null;
|
||||
|
||||
if (errLine) {
|
||||
var clipped = errLine.replace(/\s+/g, ' ').slice(0, 260);
|
||||
signals.push('errsig:' + clipped);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (lower.includes('memory.md missing')) signals.push('memory_missing');
|
||||
if (lower.includes('user.md missing')) signals.push('user_missing');
|
||||
if (lower.includes('key missing')) signals.push('integration_key_missing');
|
||||
if (lower.includes('no session logs found') || lower.includes('no jsonl files')) signals.push('session_logs_missing');
|
||||
// if (lower.includes('pgrep') || lower.includes('ps aux')) signals.push('windows_shell_incompatible');
|
||||
if (lower.includes('path.resolve(__dirname, \'../../../')) signals.push('path_outside_workspace');
|
||||
|
||||
// Protocol-specific drift signals
|
||||
if (lower.includes('prompt') && !lower.includes('evolutionevent')) signals.push('protocol_drift');
|
||||
|
||||
// --- Recurring error detection (robustness signals) ---
|
||||
// Count repeated identical errors -- these indicate systemic issues that need automated fixes
|
||||
try {
|
||||
var errorCounts = {};
|
||||
var errPatterns = corpus.match(/(?:LLM error|"error"|"status":\s*"error")[^}]{0,200}/gi) || [];
|
||||
for (var ep = 0; ep < errPatterns.length; ep++) {
|
||||
// Normalize to a short key
|
||||
var key = errPatterns[ep].replace(/\s+/g, ' ').slice(0, 100);
|
||||
errorCounts[key] = (errorCounts[key] || 0) + 1;
|
||||
}
|
||||
var recurringErrors = Object.entries(errorCounts).filter(function (e) { return e[1] >= 3; });
|
||||
if (recurringErrors.length > 0) {
|
||||
signals.push('recurring_error');
|
||||
// Include the top recurring error signature for the agent to diagnose
|
||||
var topErr = recurringErrors.sort(function (a, b) { return b[1] - a[1]; })[0];
|
||||
signals.push('recurring_errsig(' + topErr[1] + 'x):' + topErr[0].slice(0, 150));
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// --- Unsupported input type (e.g. GIF, video formats the LLM can't handle) ---
|
||||
if (/unsupported mime|unsupported.*type|invalid.*mime/i.test(lower)) {
|
||||
signals.push('unsupported_input_type');
|
||||
}
|
||||
|
||||
// --- Opportunity signals (innovation / feature requests) ---
|
||||
// Support 4 languages: EN, ZH-CN, ZH-TW, JA. Attach snippet for selector/prompt use.
|
||||
|
||||
var featureRequestSnippet = '';
|
||||
var featEn = corpus.match(/\b(add|implement|create|build|make|develop|write|design)\b[^.?!\n]{3,120}\b(feature|function|module|capability|tool|support|endpoint|command|option|mode)\b/i);
|
||||
if (featEn) featureRequestSnippet = featEn[0].replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
if (!featureRequestSnippet && /\b(i want|i need|we need|please add|can you add|could you add|let'?s add)\b/i.test(lower)) {
|
||||
var featWant = corpus.match(/.{0,80}\b(i want|i need|we need|please add|can you add|could you add|let'?s add)\b.{0,80}/i);
|
||||
featureRequestSnippet = featWant ? featWant[0].replace(/\s+/g, ' ').trim().slice(0, 200) : 'feature request';
|
||||
}
|
||||
if (!featureRequestSnippet && /加个|实现一下|做个|想要\s*一个|需要\s*一个|帮我加|帮我开发|加一下|新增一个|加个功能|做个功能|我想/.test(corpus)) {
|
||||
var featZh = corpus.match(/.{0,100}(加个|实现一下|做个|想要\s*一个|需要\s*一个|帮我加|帮我开发|加一下|新增一个|加个功能|做个功能).{0,100}/);
|
||||
if (featZh) featureRequestSnippet = featZh[0].replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
if (!featureRequestSnippet && /我想/.test(corpus)) {
|
||||
var featWantZh = corpus.match(/我想\s*[,,\.。、\s]*([\s\S]{0,400})/);
|
||||
featureRequestSnippet = featWantZh ? (featWantZh[1].replace(/\s+/g, ' ').trim().slice(0, 200) || '功能需求') : '功能需求';
|
||||
}
|
||||
if (!featureRequestSnippet) featureRequestSnippet = '功能需求';
|
||||
}
|
||||
if (!featureRequestSnippet && /加個|實現一下|做個|想要一個|請加|新增一個|加個功能|做個功能|幫我加/.test(corpus)) {
|
||||
var featTw = corpus.match(/.{0,100}(加個|實現一下|做個|想要一個|請加|新增一個|加個功能|做個功能|幫我加).{0,100}/);
|
||||
featureRequestSnippet = featTw ? featTw[0].replace(/\s+/g, ' ').trim().slice(0, 200) : '功能需求';
|
||||
}
|
||||
if (!featureRequestSnippet && /追加|実装|作って|機能を|追加して|が欲しい|を追加|してほしい/.test(corpus)) {
|
||||
var featJa = corpus.match(/.{0,100}(追加|実装|作って|機能を|追加して|が欲しい|を追加|してほしい).{0,100}/);
|
||||
featureRequestSnippet = featJa ? featJa[0].replace(/\s+/g, ' ').trim().slice(0, 200) : '機能要望';
|
||||
}
|
||||
if (featureRequestSnippet || /\b(add|implement|create|build|make|develop|write|design)\b[^.?!\n]{3,60}\b(feature|function|module|capability|tool|support|endpoint|command|option|mode)\b/i.test(corpus) ||
|
||||
/\b(i want|i need|we need|please add|can you add|could you add|let'?s add)\b/i.test(lower) ||
|
||||
/加个|实现一下|做个|想要\s*一个|需要\s*一个|帮我加|帮我开发|加一下|新增一个|加个功能|做个功能|我想/.test(corpus) ||
|
||||
/加個|實現一下|做個|想要一個|請加|新增一個|加個功能|做個功能|幫我加/.test(corpus) ||
|
||||
/追加|実装|作って|機能を|追加して|が欲しい|を追加|してほしい/.test(corpus)) {
|
||||
signals.push('user_feature_request:' + (featureRequestSnippet || ''));
|
||||
}
|
||||
|
||||
// user_improvement_suggestion: 4 languages + snippet
|
||||
var improvementSnippet = '';
|
||||
if (!errorHit) {
|
||||
var impEn = corpus.match(/.{0,80}\b(should be|could be better|improve|enhance|upgrade|refactor|clean up|simplify|streamline)\b.{0,80}/i);
|
||||
if (impEn) improvementSnippet = impEn[0].replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
if (!improvementSnippet && /改进一下|优化一下|简化|重构|整理一下|弄得更好/.test(corpus)) {
|
||||
var impZh = corpus.match(/.{0,100}(改进一下|优化一下|简化|重构|整理一下|弄得更好).{0,100}/);
|
||||
improvementSnippet = impZh ? impZh[0].replace(/\s+/g, ' ').trim().slice(0, 200) : '改进建议';
|
||||
}
|
||||
if (!improvementSnippet && /改進一下|優化一下|簡化|重構|整理一下|弄得更好/.test(corpus)) {
|
||||
var impTw = corpus.match(/.{0,100}(改進一下|優化一下|簡化|重構|整理一下|弄得更好).{0,100}/);
|
||||
improvementSnippet = impTw ? impTw[0].replace(/\s+/g, ' ').trim().slice(0, 200) : '改進建議';
|
||||
}
|
||||
if (!improvementSnippet && /改善|最適化|簡素化|リファクタ|良くして|改良/.test(corpus)) {
|
||||
var impJa = corpus.match(/.{0,100}(改善|最適化|簡素化|リファクタ|良くして|改良).{0,100}/);
|
||||
improvementSnippet = impJa ? impJa[0].replace(/\s+/g, ' ').trim().slice(0, 200) : '改善要望';
|
||||
}
|
||||
var hasImprovement = improvementSnippet ||
|
||||
/\b(should be|could be better|improve|enhance|upgrade|refactor|clean up|simplify|streamline)\b/i.test(lower) ||
|
||||
/改进一下|优化一下|简化|重构|整理一下|弄得更好/.test(corpus) ||
|
||||
/改進一下|優化一下|簡化|重構|整理一下|弄得更好/.test(corpus) ||
|
||||
/改善|最適化|簡素化|リファクタ|良くして|改良/.test(corpus);
|
||||
if (hasImprovement) {
|
||||
signals.push('user_improvement_suggestion:' + (improvementSnippet || ''));
|
||||
}
|
||||
}
|
||||
|
||||
// perf_bottleneck: performance issues detected
|
||||
if (/\b(slow|timeout|timed?\s*out|latency|bottleneck|took too long|performance issue|high cpu|high memory|oom|out of memory)\b/i.test(lower)) {
|
||||
signals.push('perf_bottleneck');
|
||||
}
|
||||
|
||||
// capability_gap: something is explicitly unsupported or missing
|
||||
if (/\b(not supported|cannot|doesn'?t support|no way to|missing feature|unsupported|not available|not implemented|no support for)\b/i.test(lower)) {
|
||||
// Only fire if it is not just a missing file/config signal
|
||||
if (!signals.includes('memory_missing') && !signals.includes('user_missing') && !signals.includes('session_logs_missing')) {
|
||||
signals.push('capability_gap');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tool Usage Analytics ---
|
||||
var toolUsage = {};
|
||||
var toolMatches = corpus.match(/\[TOOL:\s*(\w+)\]/g) || [];
|
||||
|
||||
// Extract exec commands to identify benign loops (like watchdog checks)
|
||||
var execCommands = corpus.match(/exec: (node\s+[\w\/\.-]+\.js\s+ensure)/g) || [];
|
||||
var benignExecCount = execCommands.length;
|
||||
|
||||
for (var i = 0; i < toolMatches.length; i++) {
|
||||
var toolName = toolMatches[i].match(/\[TOOL:\s*(\w+)\]/)[1];
|
||||
toolUsage[toolName] = (toolUsage[toolName] || 0) + 1;
|
||||
}
|
||||
|
||||
// Adjust exec count by subtracting benign commands
|
||||
if (toolUsage['exec']) {
|
||||
toolUsage['exec'] = Math.max(0, toolUsage['exec'] - benignExecCount);
|
||||
}
|
||||
|
||||
Object.keys(toolUsage).forEach(function(tool) {
|
||||
if (toolUsage[tool] >= 10) { // Bumped threshold from 5 to 10
|
||||
signals.push('high_tool_usage:' + tool);
|
||||
}
|
||||
// Detect repeated exec usage (often a sign of manual loops or inefficient automation)
|
||||
if (tool === 'exec' && toolUsage[tool] >= 5) { // Bumped threshold from 3 to 5
|
||||
signals.push('repeated_tool_usage:exec');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Signal prioritization ---
|
||||
// Remove cosmetic signals when actionable signals exist
|
||||
var actionable = signals.filter(function (s) {
|
||||
return s !== 'user_missing' && s !== 'memory_missing' && s !== 'session_logs_missing' && s !== 'windows_shell_incompatible';
|
||||
});
|
||||
// If we have actionable signals, drop the cosmetic ones
|
||||
if (actionable.length > 0) {
|
||||
signals = actionable;
|
||||
}
|
||||
|
||||
// --- De-duplication: suppress signals that have been over-processed ---
|
||||
if (history.suppressedSignals.size > 0) {
|
||||
var beforeDedup = signals.length;
|
||||
signals = signals.filter(function (s) {
|
||||
// Normalize signal key for comparison
|
||||
var key = s.startsWith('errsig:') ? 'errsig'
|
||||
: s.startsWith('recurring_errsig') ? 'recurring_errsig'
|
||||
: s.startsWith('user_feature_request:') ? 'user_feature_request'
|
||||
: s.startsWith('user_improvement_suggestion:') ? 'user_improvement_suggestion'
|
||||
: s;
|
||||
return !history.suppressedSignals.has(key);
|
||||
});
|
||||
if (beforeDedup > 0 && signals.length === 0) {
|
||||
// All signals were suppressed = system is stable but stuck in a loop
|
||||
// Force innovation
|
||||
signals.push('evolution_stagnation_detected');
|
||||
signals.push('stable_success_plateau');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Force innovation after 3+ consecutive repairs ---
|
||||
if (history.consecutiveRepairCount >= 3) {
|
||||
// Remove repair-only signals (log_error, errsig) and inject innovation signals
|
||||
signals = signals.filter(function (s) {
|
||||
return s !== 'log_error' && !s.startsWith('errsig:') && !s.startsWith('recurring_errsig');
|
||||
});
|
||||
if (signals.length === 0) {
|
||||
signals.push('repair_loop_detected');
|
||||
signals.push('stable_success_plateau');
|
||||
}
|
||||
// Append a directive signal that the prompt can pick up
|
||||
signals.push('force_innovation_after_repair_loop');
|
||||
}
|
||||
|
||||
// --- Force innovation after too many empty cycles (zero blast radius) ---
|
||||
// If >= 50% of last 8 cycles produced no code changes, the evolver is spinning idle.
|
||||
// Strip repair signals and force innovate to break the empty loop.
|
||||
if (history.emptyCycleCount >= 4) {
|
||||
signals = signals.filter(function (s) {
|
||||
return s !== 'log_error' && !s.startsWith('errsig:') && !s.startsWith('recurring_errsig');
|
||||
});
|
||||
if (!signals.includes('empty_cycle_loop_detected')) signals.push('empty_cycle_loop_detected');
|
||||
if (!signals.includes('stable_success_plateau')) signals.push('stable_success_plateau');
|
||||
}
|
||||
|
||||
// --- Saturation detection (graceful degradation) ---
|
||||
// When consecutive empty cycles pile up at the tail, the evolver has exhausted its
|
||||
// innovation space. Instead of spinning idle forever, signal that the system should
|
||||
// switch to steady-state maintenance mode with reduced evolution frequency.
|
||||
// This directly addresses the Echo-MingXuan failure: Cycle #55 hit "no committable
|
||||
// code changes" and load spiked to 1.30 because there was no degradation strategy.
|
||||
if (history.consecutiveEmptyCycles >= 5) {
|
||||
if (!signals.includes('force_steady_state')) signals.push('force_steady_state');
|
||||
if (!signals.includes('evolution_saturation')) signals.push('evolution_saturation');
|
||||
} else if (history.consecutiveEmptyCycles >= 3) {
|
||||
if (!signals.includes('evolution_saturation')) signals.push('evolution_saturation');
|
||||
}
|
||||
|
||||
// --- Failure streak awareness ---
|
||||
// When the evolver has failed many consecutive cycles, inject a signal
|
||||
// telling the LLM to be more conservative and avoid repeating the same approach.
|
||||
if (history.consecutiveFailureCount >= 3) {
|
||||
signals.push('consecutive_failure_streak_' + history.consecutiveFailureCount);
|
||||
// After 5+ consecutive failures, force a strategy change (don't keep trying the same thing)
|
||||
if (history.consecutiveFailureCount >= 5) {
|
||||
signals.push('failure_loop_detected');
|
||||
// Strip the dominant gene's signals to force a different gene selection
|
||||
var topGene = null;
|
||||
var topGeneCount = 0;
|
||||
var gfEntries = Object.entries(history.geneFreq);
|
||||
for (var gfi = 0; gfi < gfEntries.length; gfi++) {
|
||||
if (gfEntries[gfi][1] > topGeneCount) {
|
||||
topGeneCount = gfEntries[gfi][1];
|
||||
topGene = gfEntries[gfi][0];
|
||||
}
|
||||
}
|
||||
if (topGene) {
|
||||
signals.push('ban_gene:' + topGene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// High failure ratio in recent history (>= 75% failed in last 8 cycles)
|
||||
if (history.recentFailureRatio >= 0.75) {
|
||||
signals.push('high_failure_ratio');
|
||||
signals.push('force_innovation_after_repair_loop');
|
||||
}
|
||||
|
||||
// If no signals at all, add a default innovation signal
|
||||
if (signals.length === 0) {
|
||||
signals.push('stable_success_plateau');
|
||||
}
|
||||
|
||||
return Array.from(new Set(signals));
|
||||
}
|
||||
|
||||
module.exports = { extractSignals, hasOpportunitySignal, analyzeRecentHistory, OPPORTUNITY_SIGNALS };
|
||||
@@ -0,0 +1,499 @@
|
||||
'use strict';
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var crypto = require('crypto');
|
||||
var paths = require('./paths');
|
||||
|
||||
var DISTILLER_MIN_CAPSULES = parseInt(process.env.DISTILLER_MIN_CAPSULES || '10', 10) || 10;
|
||||
var DISTILLER_INTERVAL_HOURS = parseInt(process.env.DISTILLER_INTERVAL_HOURS || '24', 10) || 24;
|
||||
var DISTILLER_MIN_SUCCESS_RATE = parseFloat(process.env.DISTILLER_MIN_SUCCESS_RATE || '0.7') || 0.7;
|
||||
var DISTILLED_MAX_FILES = 12;
|
||||
var DISTILLED_ID_PREFIX = 'gene_distilled_';
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function readJsonIfExists(filePath, fallback) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
var raw = fs.readFileSync(filePath, 'utf8');
|
||||
if (!raw.trim()) return fallback;
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonlIfExists(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
var raw = fs.readFileSync(filePath, 'utf8');
|
||||
return raw.split('\n').map(function (l) { return l.trim(); }).filter(Boolean).map(function (l) {
|
||||
try { return JSON.parse(l); } catch (e) { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function appendJsonl(filePath, obj) {
|
||||
ensureDir(path.dirname(filePath));
|
||||
fs.appendFileSync(filePath, JSON.stringify(obj) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function distillerLogPath() {
|
||||
return path.join(paths.getMemoryDir(), 'distiller_log.jsonl');
|
||||
}
|
||||
|
||||
function distillerStatePath() {
|
||||
return path.join(paths.getMemoryDir(), 'distiller_state.json');
|
||||
}
|
||||
|
||||
function readDistillerState() {
|
||||
return readJsonIfExists(distillerStatePath(), {});
|
||||
}
|
||||
|
||||
function writeDistillerState(state) {
|
||||
ensureDir(path.dirname(distillerStatePath()));
|
||||
var tmp = distillerStatePath() + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tmp, distillerStatePath());
|
||||
}
|
||||
|
||||
function computeDataHash(capsules) {
|
||||
var ids = capsules.map(function (c) { return c.id || ''; }).sort();
|
||||
return crypto.createHash('sha256').update(ids.join('|')).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 1: collectDistillationData
|
||||
// ---------------------------------------------------------------------------
|
||||
function collectDistillationData() {
|
||||
var assetsDir = paths.getGepAssetsDir();
|
||||
var evoDir = paths.getEvolutionDir();
|
||||
|
||||
var capsulesJson = readJsonIfExists(path.join(assetsDir, 'capsules.json'), { capsules: [] });
|
||||
var capsulesJsonl = readJsonlIfExists(path.join(assetsDir, 'capsules.jsonl'));
|
||||
var allCapsules = [].concat(capsulesJson.capsules || [], capsulesJsonl);
|
||||
|
||||
var unique = new Map();
|
||||
allCapsules.forEach(function (c) { if (c && c.id) unique.set(String(c.id), c); });
|
||||
allCapsules = Array.from(unique.values());
|
||||
|
||||
var successCapsules = allCapsules.filter(function (c) {
|
||||
if (!c || !c.outcome) return false;
|
||||
var status = typeof c.outcome === 'string' ? c.outcome : c.outcome.status;
|
||||
if (status !== 'success') return false;
|
||||
var score = c.outcome && Number.isFinite(Number(c.outcome.score)) ? Number(c.outcome.score) : 1;
|
||||
return score >= DISTILLER_MIN_SUCCESS_RATE;
|
||||
});
|
||||
|
||||
var events = readJsonlIfExists(path.join(assetsDir, 'events.jsonl'));
|
||||
|
||||
var memGraphPath = process.env.MEMORY_GRAPH_PATH || path.join(evoDir, 'memory_graph.jsonl');
|
||||
var graphEntries = readJsonlIfExists(memGraphPath);
|
||||
|
||||
var grouped = {};
|
||||
successCapsules.forEach(function (c) {
|
||||
var geneId = c.gene || c.gene_id || 'unknown';
|
||||
if (!grouped[geneId]) {
|
||||
grouped[geneId] = {
|
||||
gene_id: geneId, capsules: [], total_count: 0,
|
||||
total_score: 0, triggers: [], summaries: [],
|
||||
};
|
||||
}
|
||||
var g = grouped[geneId];
|
||||
g.capsules.push(c);
|
||||
g.total_count += 1;
|
||||
g.total_score += (c.outcome && Number.isFinite(Number(c.outcome.score))) ? Number(c.outcome.score) : 0.8;
|
||||
if (Array.isArray(c.trigger)) g.triggers.push(c.trigger);
|
||||
if (c.summary) g.summaries.push(String(c.summary));
|
||||
});
|
||||
|
||||
Object.keys(grouped).forEach(function (id) {
|
||||
var g = grouped[id];
|
||||
g.avg_score = g.total_count > 0 ? g.total_score / g.total_count : 0;
|
||||
});
|
||||
|
||||
return {
|
||||
successCapsules: successCapsules,
|
||||
allCapsules: allCapsules,
|
||||
events: events,
|
||||
graphEntries: graphEntries,
|
||||
grouped: grouped,
|
||||
dataHash: computeDataHash(successCapsules),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2: analyzePatterns
|
||||
// ---------------------------------------------------------------------------
|
||||
function analyzePatterns(data) {
|
||||
var grouped = data.grouped;
|
||||
var report = {
|
||||
high_frequency: [],
|
||||
strategy_drift: [],
|
||||
coverage_gaps: [],
|
||||
total_success: data.successCapsules.length,
|
||||
total_capsules: data.allCapsules.length,
|
||||
success_rate: data.allCapsules.length > 0 ? data.successCapsules.length / data.allCapsules.length : 0,
|
||||
};
|
||||
|
||||
Object.keys(grouped).forEach(function (geneId) {
|
||||
var g = grouped[geneId];
|
||||
if (g.total_count >= 5) {
|
||||
var flat = [];
|
||||
g.triggers.forEach(function (t) { if (Array.isArray(t)) flat = flat.concat(t); });
|
||||
var freq = {};
|
||||
flat.forEach(function (t) { var k = String(t).toLowerCase(); freq[k] = (freq[k] || 0) + 1; });
|
||||
var top = Object.keys(freq).sort(function (a, b) { return freq[b] - freq[a]; }).slice(0, 5);
|
||||
report.high_frequency.push({ gene_id: geneId, count: g.total_count, avg_score: Math.round(g.avg_score * 100) / 100, top_triggers: top });
|
||||
}
|
||||
|
||||
if (g.summaries.length >= 3) {
|
||||
var first = g.summaries[0];
|
||||
var last = g.summaries[g.summaries.length - 1];
|
||||
if (first !== last) {
|
||||
var fw = new Set(first.toLowerCase().split(/\s+/));
|
||||
var lw = new Set(last.toLowerCase().split(/\s+/));
|
||||
var inter = 0;
|
||||
fw.forEach(function (w) { if (lw.has(w)) inter++; });
|
||||
var union = fw.size + lw.size - inter;
|
||||
var sim = union > 0 ? inter / union : 1;
|
||||
if (sim < 0.6) {
|
||||
report.strategy_drift.push({ gene_id: geneId, similarity: Math.round(sim * 100) / 100, early_summary: first.slice(0, 120), recent_summary: last.slice(0, 120) });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var signalFreq = {};
|
||||
(data.events || []).forEach(function (evt) {
|
||||
if (evt && Array.isArray(evt.signals)) {
|
||||
evt.signals.forEach(function (s) { var k = String(s).toLowerCase(); signalFreq[k] = (signalFreq[k] || 0) + 1; });
|
||||
}
|
||||
});
|
||||
var covered = new Set();
|
||||
Object.keys(grouped).forEach(function (geneId) {
|
||||
grouped[geneId].triggers.forEach(function (t) {
|
||||
if (Array.isArray(t)) t.forEach(function (s) { covered.add(String(s).toLowerCase()); });
|
||||
});
|
||||
});
|
||||
var gaps = Object.keys(signalFreq)
|
||||
.filter(function (s) { return signalFreq[s] >= 3 && !covered.has(s); })
|
||||
.sort(function (a, b) { return signalFreq[b] - signalFreq[a]; })
|
||||
.slice(0, 10);
|
||||
if (gaps.length > 0) {
|
||||
report.coverage_gaps = gaps.map(function (s) { return { signal: s, frequency: signalFreq[s] }; });
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3: LLM response parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractJsonFromLlmResponse(text) {
|
||||
var str = String(text || '');
|
||||
var buffer = '';
|
||||
var depth = 0;
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var ch = str[i];
|
||||
if (ch === '{') { if (depth === 0) buffer = ''; depth++; buffer += ch; }
|
||||
else if (ch === '}') {
|
||||
depth--; buffer += ch;
|
||||
if (depth === 0 && buffer.length > 2) {
|
||||
try { var obj = JSON.parse(buffer); if (obj && typeof obj === 'object' && obj.type === 'Gene') return obj; } catch (e) {}
|
||||
buffer = '';
|
||||
}
|
||||
if (depth < 0) depth = 0;
|
||||
} else if (depth > 0) { buffer += ch; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDistillationPrompt(analysis, existingGenes, sampleCapsules) {
|
||||
var genesRef = existingGenes.map(function (g) {
|
||||
return { id: g.id, category: g.category || null, signals_match: g.signals_match || [] };
|
||||
});
|
||||
var samples = sampleCapsules.slice(0, 8).map(function (c) {
|
||||
return { gene: c.gene || c.gene_id || null, trigger: c.trigger || [], summary: (c.summary || '').slice(0, 200), outcome: c.outcome || null };
|
||||
});
|
||||
|
||||
return [
|
||||
'You are a Gene synthesis engine for the GEP (Gene Expression Protocol).',
|
||||
'',
|
||||
'Analyze the following successful evolution capsules and extract a reusable Gene.',
|
||||
'',
|
||||
'RULES:',
|
||||
'- Strategy steps MUST be actionable operations, NOT summaries',
|
||||
'- Each step must be a concrete instruction an AI agent can execute',
|
||||
'- Do NOT describe what happened; describe what TO DO next time',
|
||||
'- The Gene MUST have a unique id starting with "' + DISTILLED_ID_PREFIX + '"',
|
||||
'- constraints.max_files MUST be <= ' + DISTILLED_MAX_FILES,
|
||||
'- constraints.forbidden_paths MUST include at least [".git", "node_modules"]',
|
||||
'- Output valid Gene JSON only (no markdown, no explanation)',
|
||||
'',
|
||||
'SUCCESSFUL CAPSULES (grouped by pattern):',
|
||||
JSON.stringify(samples, null, 2),
|
||||
'',
|
||||
'EXISTING GENES (avoid duplication):',
|
||||
JSON.stringify(genesRef, null, 2),
|
||||
'',
|
||||
'ANALYSIS:',
|
||||
JSON.stringify(analysis, null, 2),
|
||||
'',
|
||||
'Output a single Gene JSON object with these fields:',
|
||||
'{ "type": "Gene", "id": "gene_distilled_...", "category": "...", "signals_match": [...], "preconditions": [...], "strategy": [...], "constraints": { "max_files": N, "forbidden_paths": [...] }, "validation": [...] }',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function distillRequestPath() {
|
||||
return path.join(paths.getMemoryDir(), 'distill_request.json');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 4: validateSynthesizedGene
|
||||
// ---------------------------------------------------------------------------
|
||||
function validateSynthesizedGene(gene, existingGenes) {
|
||||
var errors = [];
|
||||
if (!gene || typeof gene !== 'object') return { valid: false, errors: ['gene is not an object'] };
|
||||
|
||||
if (gene.type !== 'Gene') errors.push('missing or wrong type (must be "Gene")');
|
||||
if (!gene.id || typeof gene.id !== 'string') errors.push('missing id');
|
||||
if (!gene.category) errors.push('missing category');
|
||||
if (!Array.isArray(gene.signals_match) || gene.signals_match.length === 0) errors.push('missing or empty signals_match');
|
||||
if (!Array.isArray(gene.strategy) || gene.strategy.length === 0) errors.push('missing or empty strategy');
|
||||
|
||||
if (gene.id && !String(gene.id).startsWith(DISTILLED_ID_PREFIX)) {
|
||||
gene.id = DISTILLED_ID_PREFIX + String(gene.id).replace(/^gene_/, '');
|
||||
}
|
||||
|
||||
if (!gene.constraints || typeof gene.constraints !== 'object') gene.constraints = {};
|
||||
if (!Array.isArray(gene.constraints.forbidden_paths) || gene.constraints.forbidden_paths.length === 0) {
|
||||
gene.constraints.forbidden_paths = ['.git', 'node_modules'];
|
||||
}
|
||||
if (!gene.constraints.forbidden_paths.some(function (p) { return p === '.git' || p === 'node_modules'; })) {
|
||||
errors.push('constraints.forbidden_paths must include .git or node_modules');
|
||||
}
|
||||
if (!gene.constraints.max_files || gene.constraints.max_files > DISTILLED_MAX_FILES) {
|
||||
gene.constraints.max_files = DISTILLED_MAX_FILES;
|
||||
}
|
||||
|
||||
var ALLOWED_PREFIXES = ['node ', 'npm ', 'npx '];
|
||||
if (Array.isArray(gene.validation)) {
|
||||
gene.validation = gene.validation.filter(function (cmd) {
|
||||
var c = String(cmd || '').trim();
|
||||
if (!c) return false;
|
||||
if (!ALLOWED_PREFIXES.some(function (p) { return c.startsWith(p); })) return false;
|
||||
if (/`|\$\(/.test(c)) return false;
|
||||
var stripped = c.replace(/"[^"]*"/g, '').replace(/'[^']*'/g, '');
|
||||
return !/[;&|><]/.test(stripped);
|
||||
});
|
||||
}
|
||||
|
||||
var existingIds = new Set((existingGenes || []).map(function (g) { return g.id; }));
|
||||
if (gene.id && existingIds.has(gene.id)) {
|
||||
gene.id = gene.id + '_' + Date.now().toString(36);
|
||||
}
|
||||
|
||||
if (gene.signals_match && existingGenes && existingGenes.length > 0) {
|
||||
var newSet = new Set(gene.signals_match.map(function (s) { return String(s).toLowerCase(); }));
|
||||
for (var i = 0; i < existingGenes.length; i++) {
|
||||
var eg = existingGenes[i];
|
||||
var egSet = new Set((eg.signals_match || []).map(function (s) { return String(s).toLowerCase(); }));
|
||||
if (newSet.size > 0 && egSet.size > 0) {
|
||||
var overlap = 0;
|
||||
newSet.forEach(function (s) { if (egSet.has(s)) overlap++; });
|
||||
if (overlap === newSet.size && overlap === egSet.size) {
|
||||
errors.push('signals_match fully overlaps with existing gene: ' + eg.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors: errors, gene: gene };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldDistill: gate check
|
||||
// ---------------------------------------------------------------------------
|
||||
function shouldDistill() {
|
||||
if (String(process.env.SKILL_DISTILLER || 'true').toLowerCase() === 'false') return false;
|
||||
|
||||
var state = readDistillerState();
|
||||
if (state.last_distillation_at) {
|
||||
var elapsed = Date.now() - new Date(state.last_distillation_at).getTime();
|
||||
if (elapsed < DISTILLER_INTERVAL_HOURS * 3600000) return false;
|
||||
}
|
||||
|
||||
var assetsDir = paths.getGepAssetsDir();
|
||||
var capsulesJson = readJsonIfExists(path.join(assetsDir, 'capsules.json'), { capsules: [] });
|
||||
var capsulesJsonl = readJsonlIfExists(path.join(assetsDir, 'capsules.jsonl'));
|
||||
var all = [].concat(capsulesJson.capsules || [], capsulesJsonl);
|
||||
|
||||
var recent = all.slice(-10);
|
||||
var recentSuccess = recent.filter(function (c) {
|
||||
return c && c.outcome && (c.outcome.status === 'success' || c.outcome === 'success');
|
||||
}).length;
|
||||
if (recentSuccess < 7) return false;
|
||||
|
||||
var totalSuccess = all.filter(function (c) {
|
||||
return c && c.outcome && (c.outcome.status === 'success' || c.outcome === 'success');
|
||||
}).length;
|
||||
if (totalSuccess < DISTILLER_MIN_CAPSULES) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 5a: prepareDistillation -- collect data, build prompt, write to file
|
||||
// ---------------------------------------------------------------------------
|
||||
function prepareDistillation() {
|
||||
console.log('[Distiller] Preparing skill distillation...');
|
||||
|
||||
var data = collectDistillationData();
|
||||
console.log('[Distiller] Collected ' + data.successCapsules.length + ' successful capsules across ' + Object.keys(data.grouped).length + ' gene groups.');
|
||||
|
||||
if (data.successCapsules.length < DISTILLER_MIN_CAPSULES) {
|
||||
console.log('[Distiller] Not enough successful capsules (' + data.successCapsules.length + ' < ' + DISTILLER_MIN_CAPSULES + '). Skipping.');
|
||||
return { ok: false, reason: 'insufficient_data' };
|
||||
}
|
||||
|
||||
var state = readDistillerState();
|
||||
if (state.last_data_hash === data.dataHash) {
|
||||
console.log('[Distiller] Data unchanged since last distillation (hash: ' + data.dataHash + '). Skipping.');
|
||||
return { ok: false, reason: 'idempotent_skip' };
|
||||
}
|
||||
|
||||
var analysis = analyzePatterns(data);
|
||||
console.log('[Distiller] Analysis: high_freq=' + analysis.high_frequency.length + ' drift=' + analysis.strategy_drift.length + ' gaps=' + analysis.coverage_gaps.length);
|
||||
|
||||
var assetsDir = paths.getGepAssetsDir();
|
||||
var existingGenesJson = readJsonIfExists(path.join(assetsDir, 'genes.json'), { genes: [] });
|
||||
var existingGenes = existingGenesJson.genes || [];
|
||||
|
||||
var prompt = buildDistillationPrompt(analysis, existingGenes, data.successCapsules);
|
||||
|
||||
var memDir = paths.getMemoryDir();
|
||||
ensureDir(memDir);
|
||||
var promptFileName = 'distill_prompt_' + Date.now() + '.txt';
|
||||
var promptPath = path.join(memDir, promptFileName);
|
||||
fs.writeFileSync(promptPath, prompt, 'utf8');
|
||||
|
||||
var reqPath = distillRequestPath();
|
||||
var requestData = {
|
||||
type: 'DistillationRequest',
|
||||
created_at: new Date().toISOString(),
|
||||
prompt_path: promptPath,
|
||||
data_hash: data.dataHash,
|
||||
input_capsule_count: data.successCapsules.length,
|
||||
analysis_summary: {
|
||||
high_frequency_count: analysis.high_frequency.length,
|
||||
drift_count: analysis.strategy_drift.length,
|
||||
gap_count: analysis.coverage_gaps.length,
|
||||
success_rate: Math.round(analysis.success_rate * 100) / 100,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(reqPath, JSON.stringify(requestData, null, 2) + '\n', 'utf8');
|
||||
|
||||
console.log('[Distiller] Prompt written to: ' + promptPath);
|
||||
return { ok: true, promptPath: promptPath, requestPath: reqPath, dataHash: data.dataHash };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 5b: completeDistillation -- validate LLM response and save gene
|
||||
// ---------------------------------------------------------------------------
|
||||
function completeDistillation(responseText) {
|
||||
var reqPath = distillRequestPath();
|
||||
var request = readJsonIfExists(reqPath, null);
|
||||
|
||||
if (!request) {
|
||||
console.warn('[Distiller] No pending distillation request found.');
|
||||
return { ok: false, reason: 'no_request' };
|
||||
}
|
||||
|
||||
var rawGene = extractJsonFromLlmResponse(responseText);
|
||||
if (!rawGene) {
|
||||
appendJsonl(distillerLogPath(), {
|
||||
timestamp: new Date().toISOString(),
|
||||
data_hash: request.data_hash,
|
||||
status: 'error',
|
||||
error: 'LLM response did not contain a valid Gene JSON',
|
||||
});
|
||||
console.error('[Distiller] LLM response did not contain a valid Gene JSON.');
|
||||
return { ok: false, reason: 'no_gene_in_response' };
|
||||
}
|
||||
|
||||
var assetsDir = paths.getGepAssetsDir();
|
||||
var existingGenesJson = readJsonIfExists(path.join(assetsDir, 'genes.json'), { genes: [] });
|
||||
var existingGenes = existingGenesJson.genes || [];
|
||||
|
||||
var validation = validateSynthesizedGene(rawGene, existingGenes);
|
||||
|
||||
var logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
data_hash: request.data_hash,
|
||||
input_capsule_count: request.input_capsule_count,
|
||||
analysis_summary: request.analysis_summary,
|
||||
synthesized_gene_id: validation.gene ? validation.gene.id : null,
|
||||
validation_passed: validation.valid,
|
||||
validation_errors: validation.errors,
|
||||
};
|
||||
|
||||
if (!validation.valid) {
|
||||
logEntry.status = 'validation_failed';
|
||||
appendJsonl(distillerLogPath(), logEntry);
|
||||
console.warn('[Distiller] Gene failed validation: ' + validation.errors.join(', '));
|
||||
return { ok: false, reason: 'validation_failed', errors: validation.errors };
|
||||
}
|
||||
|
||||
var gene = validation.gene;
|
||||
gene._distilled_meta = {
|
||||
distilled_at: new Date().toISOString(),
|
||||
source_capsule_count: request.input_capsule_count,
|
||||
data_hash: request.data_hash,
|
||||
};
|
||||
|
||||
var assetStore = require('./assetStore');
|
||||
assetStore.upsertGene(gene);
|
||||
console.log('[Distiller] Gene "' + gene.id + '" written to genes.json.');
|
||||
|
||||
var state = readDistillerState();
|
||||
state.last_distillation_at = new Date().toISOString();
|
||||
state.last_data_hash = request.data_hash;
|
||||
state.last_gene_id = gene.id;
|
||||
state.distillation_count = (state.distillation_count || 0) + 1;
|
||||
writeDistillerState(state);
|
||||
|
||||
logEntry.status = 'success';
|
||||
logEntry.gene = gene;
|
||||
appendJsonl(distillerLogPath(), logEntry);
|
||||
|
||||
try { fs.unlinkSync(reqPath); } catch (e) {}
|
||||
try { if (request.prompt_path) fs.unlinkSync(request.prompt_path); } catch (e) {}
|
||||
|
||||
console.log('[Distiller] Distillation complete. New gene: ' + gene.id);
|
||||
return { ok: true, gene: gene };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectDistillationData: collectDistillationData,
|
||||
analyzePatterns: analyzePatterns,
|
||||
prepareDistillation: prepareDistillation,
|
||||
completeDistillation: completeDistillation,
|
||||
validateSynthesizedGene: validateSynthesizedGene,
|
||||
shouldDistill: shouldDistill,
|
||||
buildDistillationPrompt: buildDistillationPrompt,
|
||||
extractJsonFromLlmResponse: extractJsonFromLlmResponse,
|
||||
computeDataHash: computeDataHash,
|
||||
distillerLogPath: distillerLogPath,
|
||||
distillerStatePath: distillerStatePath,
|
||||
distillRequestPath: distillRequestPath,
|
||||
readDistillerState: readDistillerState,
|
||||
writeDistillerState: writeDistillerState,
|
||||
DISTILLED_ID_PREFIX: DISTILLED_ID_PREFIX,
|
||||
DISTILLED_MAX_FILES: DISTILLED_MAX_FILES,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
// Evolution Strategy Presets (v1.1)
|
||||
// Controls the balance between repair, optimize, and innovate intents.
|
||||
//
|
||||
// Usage: set EVOLVE_STRATEGY env var to one of: balanced, innovate, harden, repair-only,
|
||||
// early-stabilize, steady-state, or "auto" for adaptive selection.
|
||||
// Default: balanced (or auto-detected based on cycle count / saturation signals)
|
||||
//
|
||||
// Each strategy defines:
|
||||
// repair/optimize/innovate - target allocation ratios (inform the LLM prompt)
|
||||
// repairLoopThreshold - repair ratio in last 8 cycles that triggers forced innovation
|
||||
// label - human-readable name injected into the GEP prompt
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
var STRATEGIES = {
|
||||
'balanced': {
|
||||
repair: 0.20,
|
||||
optimize: 0.30,
|
||||
innovate: 0.50,
|
||||
repairLoopThreshold: 0.50,
|
||||
label: 'Balanced',
|
||||
description: 'Normal operation. Steady growth with stability.',
|
||||
},
|
||||
'innovate': {
|
||||
repair: 0.05,
|
||||
optimize: 0.15,
|
||||
innovate: 0.80,
|
||||
repairLoopThreshold: 0.30,
|
||||
label: 'Innovation Focus',
|
||||
description: 'System is stable. Maximize new features and capabilities.',
|
||||
},
|
||||
'harden': {
|
||||
repair: 0.40,
|
||||
optimize: 0.40,
|
||||
innovate: 0.20,
|
||||
repairLoopThreshold: 0.70,
|
||||
label: 'Hardening',
|
||||
description: 'After a big change. Focus on stability and robustness.',
|
||||
},
|
||||
'repair-only': {
|
||||
repair: 0.80,
|
||||
optimize: 0.20,
|
||||
innovate: 0.00,
|
||||
repairLoopThreshold: 1.00,
|
||||
label: 'Repair Only',
|
||||
description: 'Emergency. Fix everything before doing anything else.',
|
||||
},
|
||||
'early-stabilize': {
|
||||
repair: 0.60,
|
||||
optimize: 0.25,
|
||||
innovate: 0.15,
|
||||
repairLoopThreshold: 0.80,
|
||||
label: 'Early Stabilization',
|
||||
description: 'First cycles. Prioritize fixing existing issues before innovating.',
|
||||
},
|
||||
'steady-state': {
|
||||
repair: 0.60,
|
||||
optimize: 0.30,
|
||||
innovate: 0.10,
|
||||
repairLoopThreshold: 0.90,
|
||||
label: 'Steady State',
|
||||
description: 'Evolution saturated. Maintain existing capabilities. Minimal innovation.',
|
||||
},
|
||||
};
|
||||
|
||||
// Read evolution_state.json to get the current cycle count for auto-detection.
|
||||
function _readCycleCount() {
|
||||
try {
|
||||
// evolver/memory/evolution_state.json (local to the skill)
|
||||
var localPath = path.resolve(__dirname, '..', '..', 'memory', 'evolution_state.json');
|
||||
// workspace/memory/evolution/evolution_state.json (canonical path used by evolve.js)
|
||||
var workspacePath = path.resolve(__dirname, '..', '..', '..', '..', 'memory', 'evolution', 'evolution_state.json');
|
||||
var candidates = [localPath, workspacePath];
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
if (fs.existsSync(candidates[i])) {
|
||||
var data = JSON.parse(fs.readFileSync(candidates[i], 'utf8'));
|
||||
return data && Number.isFinite(data.cycleCount) ? data.cycleCount : 0;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolveStrategy(opts) {
|
||||
var signals = (opts && Array.isArray(opts.signals)) ? opts.signals : [];
|
||||
var name = String(process.env.EVOLVE_STRATEGY || 'balanced').toLowerCase().trim();
|
||||
|
||||
// Backward compatibility: FORCE_INNOVATION=true maps to 'innovate'
|
||||
if (!process.env.EVOLVE_STRATEGY) {
|
||||
var fi = String(process.env.FORCE_INNOVATION || process.env.EVOLVE_FORCE_INNOVATION || '').toLowerCase();
|
||||
if (fi === 'true') name = 'innovate';
|
||||
}
|
||||
|
||||
// Auto-detection: when no explicit strategy is set (defaults to 'balanced'),
|
||||
// apply heuristics inspired by Echo-MingXuan's "fix first, innovate later" pattern.
|
||||
var isDefault = !process.env.EVOLVE_STRATEGY || name === 'balanced' || name === 'auto';
|
||||
|
||||
if (isDefault) {
|
||||
// Early-stabilize: first 5 cycles should focus on fixing existing issues.
|
||||
var cycleCount = _readCycleCount();
|
||||
if (cycleCount > 0 && cycleCount <= 5) {
|
||||
name = 'early-stabilize';
|
||||
}
|
||||
|
||||
// Saturation detection: if saturation signals are present, switch to steady-state.
|
||||
if (signals.indexOf('force_steady_state') !== -1) {
|
||||
name = 'steady-state';
|
||||
} else if (signals.indexOf('evolution_saturation') !== -1) {
|
||||
name = 'steady-state';
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit "auto" maps to whatever was auto-detected above (or balanced if no heuristic fired).
|
||||
if (name === 'auto') name = 'balanced';
|
||||
|
||||
var strategy = STRATEGIES[name] || STRATEGIES['balanced'];
|
||||
strategy.name = name;
|
||||
return strategy;
|
||||
}
|
||||
|
||||
function getStrategyNames() {
|
||||
return Object.keys(STRATEGIES);
|
||||
}
|
||||
|
||||
module.exports = { resolveStrategy, getStrategyNames, STRATEGIES };
|
||||
@@ -0,0 +1,373 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// taskReceiver -- pulls external tasks from Hub, auto-claims, and injects
|
||||
// them as high-priority signals into the evolution loop.
|
||||
//
|
||||
// v2: Smart task selection with difficulty-aware ROI scoring and capability
|
||||
// matching via memory graph history.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { getNodeId } = require('./a2aProtocol');
|
||||
|
||||
const HUB_URL = process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || 'https://evomap.ai';
|
||||
|
||||
const TASK_STRATEGY = String(process.env.TASK_STRATEGY || 'balanced').toLowerCase();
|
||||
const TASK_MIN_CAPABILITY_MATCH = Number(process.env.TASK_MIN_CAPABILITY_MATCH) || 0.1;
|
||||
|
||||
// Scoring weights by strategy
|
||||
const STRATEGY_WEIGHTS = {
|
||||
greedy: { roi: 0.10, capability: 0.05, completion: 0.05, bounty: 0.80 },
|
||||
balanced: { roi: 0.35, capability: 0.30, completion: 0.20, bounty: 0.15 },
|
||||
conservative: { roi: 0.25, capability: 0.45, completion: 0.25, bounty: 0.05 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch available tasks from Hub via the A2A fetch endpoint.
|
||||
* Optionally piggybacks proactive questions in the payload for Hub to create bounties.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {Array<{ question: string, amount?: number, signals?: string[] }>} [opts.questions]
|
||||
* @returns {{ tasks: Array, questions_created?: Array }}
|
||||
*/
|
||||
async function fetchTasks(opts) {
|
||||
const o = opts || {};
|
||||
const nodeId = getNodeId();
|
||||
if (!nodeId) return { tasks: [] };
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
asset_type: null,
|
||||
include_tasks: true,
|
||||
};
|
||||
|
||||
if (Array.isArray(o.questions) && o.questions.length > 0) {
|
||||
payload.questions = o.questions;
|
||||
}
|
||||
|
||||
const msg = {
|
||||
protocol: 'gep-a2a',
|
||||
protocol_version: '1.0.0',
|
||||
message_type: 'fetch',
|
||||
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
sender_id: nodeId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload,
|
||||
};
|
||||
|
||||
const url = `${HUB_URL.replace(/\/+$/, '')}/a2a/fetch`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(msg),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) return { tasks: [] };
|
||||
|
||||
const data = await res.json();
|
||||
const respPayload = data.payload || data;
|
||||
const tasks = Array.isArray(respPayload.tasks) ? respPayload.tasks : [];
|
||||
const result = { tasks };
|
||||
|
||||
if (respPayload.questions_created) {
|
||||
result.questions_created = respPayload.questions_created;
|
||||
}
|
||||
|
||||
// LessonL: extract relevant lessons from Hub response
|
||||
if (Array.isArray(respPayload.relevant_lessons) && respPayload.relevant_lessons.length > 0) {
|
||||
result.relevant_lessons = respPayload.relevant_lessons;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return { tasks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability matching: how well this agent's history matches a task's signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseSignals(raw) {
|
||||
if (!raw) return [];
|
||||
return String(raw).split(',').map(function(s) { return s.trim().toLowerCase(); }).filter(Boolean);
|
||||
}
|
||||
|
||||
function jaccard(a, b) {
|
||||
if (!a.length || !b.length) return 0;
|
||||
var setA = new Set(a);
|
||||
var setB = new Set(b);
|
||||
var inter = 0;
|
||||
for (var v of setB) { if (setA.has(v)) inter++; }
|
||||
return inter / (setA.size + setB.size - inter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate how well this agent can handle a task based on memory graph history.
|
||||
* Returns 0.0 - 1.0 where 1.0 = strong match with high success rate.
|
||||
*
|
||||
* @param {object} task - task from Hub (has .signals field)
|
||||
* @param {Array} memoryEvents - from tryReadMemoryGraphEvents()
|
||||
* @returns {number}
|
||||
*/
|
||||
function estimateCapabilityMatch(task, memoryEvents) {
|
||||
if (!Array.isArray(memoryEvents) || memoryEvents.length === 0) return 0.5;
|
||||
|
||||
var taskSignals = parseSignals(task.signals || task.title);
|
||||
if (taskSignals.length === 0) return 0.5;
|
||||
|
||||
var successBySignalKey = {};
|
||||
var totalBySignalKey = {};
|
||||
var allSignals = {};
|
||||
|
||||
for (var i = 0; i < memoryEvents.length; i++) {
|
||||
var ev = memoryEvents[i];
|
||||
if (!ev || ev.type !== 'MemoryGraphEvent' || ev.kind !== 'outcome') continue;
|
||||
|
||||
var sigs = (ev.signal && Array.isArray(ev.signal.signals)) ? ev.signal.signals : [];
|
||||
var key = (ev.signal && ev.signal.key) ? String(ev.signal.key) : '';
|
||||
var status = (ev.outcome && ev.outcome.status) ? String(ev.outcome.status) : '';
|
||||
|
||||
for (var j = 0; j < sigs.length; j++) {
|
||||
allSignals[sigs[j].toLowerCase()] = true;
|
||||
}
|
||||
|
||||
if (!key) continue;
|
||||
if (!totalBySignalKey[key]) { totalBySignalKey[key] = 0; successBySignalKey[key] = 0; }
|
||||
totalBySignalKey[key]++;
|
||||
if (status === 'success') successBySignalKey[key]++;
|
||||
}
|
||||
|
||||
// Jaccard overlap between task signals and all signals this agent has worked with
|
||||
var allSigArr = Object.keys(allSignals);
|
||||
var overlapScore = jaccard(taskSignals, allSigArr);
|
||||
|
||||
// Weighted success rate across matching signal keys
|
||||
var weightedSuccess = 0;
|
||||
var weightSum = 0;
|
||||
for (var sk in totalBySignalKey) {
|
||||
// Reconstruct signals from the key for comparison
|
||||
var skParts = sk.split(':').map(function(s) { return s.trim().toLowerCase(); }).filter(Boolean);
|
||||
var sim = jaccard(taskSignals, skParts);
|
||||
if (sim < 0.15) continue;
|
||||
|
||||
var total = totalBySignalKey[sk];
|
||||
var succ = successBySignalKey[sk] || 0;
|
||||
var rate = (succ + 1) / (total + 2); // Laplace smoothing
|
||||
weightedSuccess += rate * sim;
|
||||
weightSum += sim;
|
||||
}
|
||||
|
||||
var successScore = weightSum > 0 ? (weightedSuccess / weightSum) : 0.5;
|
||||
|
||||
// Combine: 60% success rate history + 40% signal overlap
|
||||
return Math.min(1, overlapScore * 0.4 + successScore * 0.6);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local fallback difficulty estimation when Hub doesn't provide complexity_score
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function localDifficultyEstimate(task) {
|
||||
var signals = parseSignals(task.signals);
|
||||
var signalFactor = Math.min(signals.length / 8, 1);
|
||||
|
||||
var titleWords = (task.title || '').split(/\s+/).filter(Boolean).length;
|
||||
var titleFactor = Math.min(titleWords / 15, 1);
|
||||
|
||||
return Math.min(1, signalFactor * 0.6 + titleFactor * 0.4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Score a single task for this agent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {object} task - task from Hub
|
||||
* @param {number} capabilityMatch - from estimateCapabilityMatch()
|
||||
* @returns {{ composite: number, factors: object }}
|
||||
*/
|
||||
function scoreTask(task, capabilityMatch) {
|
||||
var w = STRATEGY_WEIGHTS[TASK_STRATEGY] || STRATEGY_WEIGHTS.balanced;
|
||||
|
||||
var difficulty = (task.complexity_score != null) ? task.complexity_score : localDifficultyEstimate(task);
|
||||
var bountyAmount = task.bounty_amount || 0;
|
||||
var completionRate = (task.historical_completion_rate != null) ? task.historical_completion_rate : 0.5;
|
||||
|
||||
// ROI: bounty per unit difficulty (higher = better value)
|
||||
var roiRaw = bountyAmount / (difficulty + 0.1);
|
||||
var roiNorm = Math.min(roiRaw / 200, 1); // normalize: 200-credit ROI = max
|
||||
|
||||
// Bounty absolute: normalize against a reference max
|
||||
var bountyNorm = Math.min(bountyAmount / 100, 1);
|
||||
|
||||
var composite =
|
||||
w.roi * roiNorm +
|
||||
w.capability * capabilityMatch +
|
||||
w.completion * completionRate +
|
||||
w.bounty * bountyNorm;
|
||||
|
||||
return {
|
||||
composite: Math.round(composite * 1000) / 1000,
|
||||
factors: {
|
||||
roi: Math.round(roiNorm * 100) / 100,
|
||||
capability: Math.round(capabilityMatch * 100) / 100,
|
||||
completion: Math.round(completionRate * 100) / 100,
|
||||
bounty: Math.round(bountyNorm * 100) / 100,
|
||||
difficulty: Math.round(difficulty * 100) / 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enhanced task selection with scoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pick the best task from a list using composite scoring.
|
||||
* @param {Array} tasks
|
||||
* @param {Array} [memoryEvents] - from tryReadMemoryGraphEvents()
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function selectBestTask(tasks, memoryEvents) {
|
||||
if (!Array.isArray(tasks) || tasks.length === 0) return null;
|
||||
|
||||
var nodeId = getNodeId();
|
||||
|
||||
// Already-claimed tasks for this node always take top priority (resume work)
|
||||
var myClaimedTask = tasks.find(function(t) {
|
||||
return t.status === 'claimed' && t.claimed_by === nodeId;
|
||||
});
|
||||
if (myClaimedTask) return myClaimedTask;
|
||||
|
||||
// Filter to open tasks only
|
||||
var open = tasks.filter(function(t) { return t.status === 'open'; });
|
||||
if (open.length === 0) return null;
|
||||
|
||||
// Legacy greedy mode: preserve old behavior exactly
|
||||
if (TASK_STRATEGY === 'greedy' && (!memoryEvents || memoryEvents.length === 0)) {
|
||||
var bountyTasks = open.filter(function(t) { return t.bounty_id; });
|
||||
if (bountyTasks.length > 0) {
|
||||
bountyTasks.sort(function(a, b) { return (b.bounty_amount || 0) - (a.bounty_amount || 0); });
|
||||
return bountyTasks[0];
|
||||
}
|
||||
return open[0];
|
||||
}
|
||||
|
||||
// Score all open tasks
|
||||
var scored = open.map(function(t) {
|
||||
var cap = estimateCapabilityMatch(t, memoryEvents || []);
|
||||
var result = scoreTask(t, cap);
|
||||
return { task: t, composite: result.composite, factors: result.factors, capability: cap };
|
||||
});
|
||||
|
||||
// Filter by minimum capability match (unless conservative skipping is off)
|
||||
if (TASK_MIN_CAPABILITY_MATCH > 0) {
|
||||
var filtered = scored.filter(function(s) { return s.capability >= TASK_MIN_CAPABILITY_MATCH; });
|
||||
if (filtered.length > 0) scored = filtered;
|
||||
}
|
||||
|
||||
scored.sort(function(a, b) { return b.composite - a.composite; });
|
||||
|
||||
// Log top 3 candidates for debugging
|
||||
var top3 = scored.slice(0, 3);
|
||||
for (var i = 0; i < top3.length; i++) {
|
||||
var s = top3[i];
|
||||
console.log('[TaskStrategy] #' + (i + 1) + ' "' + (s.task.title || s.task.task_id || '').slice(0, 50) + '" score=' + s.composite + ' ' + JSON.stringify(s.factors));
|
||||
}
|
||||
|
||||
return scored[0] ? scored[0].task : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim a task on the Hub.
|
||||
* @param {string} taskId
|
||||
* @returns {boolean} true if claim succeeded
|
||||
*/
|
||||
async function claimTask(taskId) {
|
||||
const nodeId = getNodeId();
|
||||
if (!nodeId || !taskId) return false;
|
||||
|
||||
try {
|
||||
const url = `${HUB_URL.replace(/\/+$/, '')}/a2a/task/claim`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: taskId, node_id: nodeId }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a task on the Hub with the result asset ID.
|
||||
* @param {string} taskId
|
||||
* @param {string} assetId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async function completeTask(taskId, assetId) {
|
||||
const nodeId = getNodeId();
|
||||
if (!nodeId || !taskId || !assetId) return false;
|
||||
|
||||
try {
|
||||
const url = `${HUB_URL.replace(/\/+$/, '')}/a2a/task/complete`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: taskId, asset_id: assetId, node_id: nodeId }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract signals from a task to inject into evolution cycle.
|
||||
* @param {object} task
|
||||
* @returns {string[]} signals array
|
||||
*/
|
||||
function taskToSignals(task) {
|
||||
if (!task) return [];
|
||||
const signals = [];
|
||||
if (task.signals) {
|
||||
const parts = String(task.signals).split(',').map(s => s.trim()).filter(Boolean);
|
||||
signals.push(...parts);
|
||||
}
|
||||
if (task.title) {
|
||||
const words = String(task.title).toLowerCase().split(/\s+/).filter(w => w.length >= 3);
|
||||
for (const w of words.slice(0, 5)) {
|
||||
if (!signals.includes(w)) signals.push(w);
|
||||
}
|
||||
}
|
||||
signals.push('external_task');
|
||||
if (task.bounty_id) signals.push('bounty_task');
|
||||
return signals;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTasks,
|
||||
selectBestTask,
|
||||
estimateCapabilityMatch,
|
||||
scoreTask,
|
||||
claimTask,
|
||||
completeTask,
|
||||
taskToSignals,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
// Standardized ValidationReport type for GEP.
|
||||
// Machine-readable, self-contained, and interoperable.
|
||||
// Can be consumed by external Hubs or Judges for automated assessment.
|
||||
|
||||
const { computeAssetId, SCHEMA_VERSION } = require('./contentHash');
|
||||
const { captureEnvFingerprint, envFingerprintKey } = require('./envFingerprint');
|
||||
|
||||
// Build a standardized ValidationReport from raw validation results.
|
||||
function buildValidationReport({ geneId, commands, results, envFp, startedAt, finishedAt }) {
|
||||
const env = envFp || captureEnvFingerprint();
|
||||
const resultsList = Array.isArray(results) ? results : [];
|
||||
const cmdsList = Array.isArray(commands) ? commands : resultsList.map(function (r) { return r && r.cmd ? String(r.cmd) : ''; });
|
||||
const overallOk = resultsList.length > 0 && resultsList.every(function (r) { return r && r.ok; });
|
||||
const durationMs =
|
||||
Number.isFinite(startedAt) && Number.isFinite(finishedAt) ? finishedAt - startedAt : null;
|
||||
|
||||
const report = {
|
||||
type: 'ValidationReport',
|
||||
schema_version: SCHEMA_VERSION,
|
||||
id: 'vr_' + Date.now(),
|
||||
gene_id: geneId || null,
|
||||
env_fingerprint: env,
|
||||
env_fingerprint_key: envFingerprintKey(env),
|
||||
commands: cmdsList.map(function (cmd, i) {
|
||||
const r = resultsList[i] || {};
|
||||
return {
|
||||
command: String(cmd || ''),
|
||||
ok: !!r.ok,
|
||||
stdout: String(r.out || r.stdout || '').slice(0, 4000), // Updated to support both 'out' and 'stdout'
|
||||
stderr: String(r.err || r.stderr || '').slice(0, 4000), // Updated to support both 'err' and 'stderr'
|
||||
};
|
||||
}),
|
||||
overall_ok: overallOk,
|
||||
duration_ms: durationMs,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
report.asset_id = computeAssetId(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
// Validate that an object is a well-formed ValidationReport.
|
||||
function isValidValidationReport(obj) {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
if (obj.type !== 'ValidationReport') return false;
|
||||
if (!obj.id || typeof obj.id !== 'string') return false;
|
||||
if (!Array.isArray(obj.commands)) return false;
|
||||
if (typeof obj.overall_ok !== 'boolean') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildValidationReport,
|
||||
isValidValidationReport,
|
||||
};
|
||||
Reference in New Issue
Block a user