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, '') // normalize Unix paths .replace(/\/[^ \n\r\t]+/g, '') // normalize hex and numbers .replace(/\b0x[0-9a-f]+\b/gi, '') .replace(/\b\d+\b/g, '') // 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:" 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, };