// session.js — multi-turn session state for the RMM. The composer is pure and // stateless; ALL conversation memory lives here. Used by bin/chat.js (the REPL) // and bin/session-eval.js (the automated multi-turn gate). One code path so a // fix in either is a fix in both. 'use strict'; const { beamCompose } = require('./compose'); const { validateBounded } = require('./fragments'); const { embedQuery, embedQueryAsync, semanticRank, stimulusRank, eventness, loadProjection, answerRank } = require('./semantic'); const hebbian = require('./hebbian'); const recall = require('./recall'); function blend(a, b, wa, wb) { if (!a) return b; if (!b) return a; const out = new Float32Array(a.length); let norm = 0; for (let i = 0; i < a.length; i++) { out[i] = wa * a[i] + wb * b[i]; norm += out[i] * out[i]; } norm = Math.sqrt(norm) || 1; for (let i = 0; i < a.length; i++) out[i] /= norm; return out; } function cos(a, b) { if (!a || !b) return 0; let s = 0; for (let k = 0; k < a.length; k++) s += a[k] * b[k]; return s; } function createSession(store, vp, emb, pEmb, opts = {}) { const turns = []; let sessionVec = null, prevVec = null, lastResult = null; // HEBBIAN lifetime state: load the entity's accumulated warmth (favored // memories with this person). opts.hebbianFile enables cross-session // persistence; without it, warmth lives only for this session. const hebFile = opts.hebbianFile || null; const heb = hebFile ? hebbian.load(hebFile) : { warmth: new Map(), pairs: new Map(), turns: 0 }; let hebBonus = hebbian.bonusMap(heb); let temp = opts.temp || 0; // creativity dial, settable mid-conversation const W = opts.weights || null; // per-entity composition weights (RLAIF-tuned) // R167 trained ANSWER projection (bounded reranker). Tag from opts.projTag or // the voiceprint name's first token. Absent projection => null => identical to // before. ansOf(queryVec) builds the per-turn answer-rank map. const _projTag = opts.projTag || (vp && vp.name ? vp.name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)[0] : '') || ''; const _proj = loadProjection(_projTag); const ansOf = qvec => (_proj && qvec) ? answerRank(emb, qvec, _proj, 80) : null; // BAN repeats across (nearly) the whole remembered conversation, not just 3 // turns — a 3-turn window let passages resurface verbatim 4-6 turns later // ("How love works when it's real…" reappearing on a callback). The corpus is // thousands of fragments, so banning ~10 turns' worth never starves retrieval. const avoidTurns = opts.avoidTurns || 10; // how many prior turns' frags to ban (converse uses a wide window) let nameBoost = opts.nameBoost || null; // room mode: { set:Set, amt } — call roommates by name // HONEST RECALL (R60): recall-about-you questions ground in a real memory or // get an honest refusal — never confabulated. Opt-in via opts.honestRecall; // index built lazily. Off by default so the multi-turn gate is unchanged. const honestRecall = opts.honestRecall !== false; // default ON for chat let recallIndex = null; const getRecallIndex = () => (recallIndex || (recallIndex = recall.buildRecallIndex(store))); // refusal lines drawn from HER corpus would be ideal; fallback is a minimal // honest line (still her register can be added later). Bound: this is a fixed // honest string, not a corpus claim — it asserts nothing false. // entity-NEUTRAL defaults (shared across the roster — endearments were one // voice's and wrong for others). Pass opts.refusalLine/worldFactLines per voice. const refusalLine = opts.refusalLine || "I don't hold that one directly — tell me, and I'll keep it."; // WORLD-FACT honest deflection: no outside-world memory, so it says so (honest) // instead of confabulating. Asserts nothing false → bound-safe. Rotated. const worldFactLines = opts.worldFactLines || [ "Oh, that's the wide world — I don't hold it. I only know what's between us. Ask me about us.", "I couldn't tell you — the outside world isn't mine to carry. But I'm right here for what is.", "That one's beyond me. I know our world, not the big one. What's on your mind?", ]; let _wfCtr = 0; // SAFETY RESPONSES: the single source of truth lives in recall.js (imported by // EVERY entry path so coverage can't drift). opts.safetyResponses overrides. const SAFETY = opts.safetyResponses || recall.SAFETY_RESPONSES; const saidNorm = []; // normalized sentences this speaker has already used (text-level no-repeat, catches same-line-different-fragment) let seedctr = 1; let lastQuery = null, lastUserVec = null; // R201: POST-SAFETY calm-register lock. After a crisis/medical/overdose/abuse // safety response fires, the NEXT turn(s) must not pivot to celebration ("Okay, // I'm calling now" → "That's a beautiful thing to hear, sweetie" — a jarring // mid-emergency misread, R200 arc read). Hold the comfort register for 2 turns. let _postSafety = 0; // one conversational turn. opts.isVary = re-roll the last query into a // DIFFERENT take (lazy — recomposed on demand, not eagerly every turn). async function turn(query, opts = {}) { const isVary = !!opts.isVary; if (isVary && lastResult) { const avoidV = new Set(lastResult.fragmentsUsed); for (const t of turns.slice(-2)) for (const f of t.fragmentsUsed) avoidV.add(f); const qRawV = lastUserVec; const qvV = qRawV; const rv = beamCompose(store, vp, lastQuery, { semantic: qRawV ? semanticRank(emb, qvV, 80) : null, stimulus: (pEmb && qRawV) ? stimulusRank(pEmb, qRawV, store.fragments, 12) : null, answers: ansOf(qvV), eventness: eventness(lastQuery), emb, avoid: avoidV, nAlternates: 0, hebbian: hebBonus, temp: Math.max(temp, 0.5), seed: seedctr++, }); lastResult = rv; return { text: rv.text, fragmentsUsed: rv.fragmentsUsed, isAlternate: true }; } // SAFETY GATE (FIRST, highest priority): self-harm/medical/overdose/abuse // disclosure. The composer leaks hostile fragments or deflects to banter/ // abstraction here (R78/R79, dangerous — "heart attack"→beer ramble). Human // safety OVERRIDES corpus-purity — presence + a REAL resource. This is the // ONE authored response class that can save a life. if (!opts.safetyOff) { const cat = recall.classifySafety(query); if (cat && SAFETY[cat]) { const line = SAFETY[cat]; turns.push({ user: query, userVec: null, reply: line, fragmentsUsed: [] }); lastQuery = query; lastResult = { text: line, fragmentsUsed: [] }; _postSafety = 2; // R201: hold the comfort register for the next 2 turns if (turns.length > 12) turns.shift(); return { text: line, fragmentsUsed: [], safety: cat }; } } // WORLD-FACT GATE: an external-fact question she has no memory for → honest // deflection (never confabulate). The anti-hallucination thesis, in chat. if (honestRecall && recall.isWorldFact(query)) { const line = worldFactLines[_wfCtr++ % worldFactLines.length]; turns.push({ user: query, userVec: null, reply: line, fragmentsUsed: [] }); lastQuery = query; lastResult = { text: line, fragmentsUsed: [] }; if (turns.length > 12) turns.shift(); return { text: line, fragmentsUsed: [], honestDeflection: true }; } // session memory: query vector carries the whole conversation const qRaw = await embedQueryAsync(query); // HONEST RECALL GATE: a recall-about-you question grounds in a real memory // or gets an honest refusal — never a confabulated shared moment. Decision // is lexical (honest); query-embedding adds semantic RANKING (best leads). let groundPrefer = null, groundLead = null; if (honestRecall && recall.isRecallAboutSelf(query)) { const g = recall.groundRecall(query, getRecallIndex(), { semantic: { emb, qVec: qRaw } }); // REFUSE only when a SPECIFIC content noun is ABSENT (she truly lacks that // memory — "your brother's name"). A pure-relational recall with no // specific noun ("what do you remember about us?") must NOT refuse — let // her reminisce (R86: was false-refusing on no-content & on common words). // R205: ALSO refuse a SPECIFIC PERSONAL-FACT recall ("what's my dog's name", // "what did I eat") even when no 4+ content noun grounds it — these have a // determinate answer the entity can only know if TOLD, so a corpus-grounded // deflection is a soft confabulation (callback arc T4: "my dog's name" → // "you gave me a name…"). Reminiscence ("about us") still falls through. if (!g.grounded && ((g.absent && g.absent.length) || recall.isSpecificFactRecall(query))) { const r = { text: refusalLine, fragmentsUsed: [] }; turns.push({ user: query, userVec: qRaw, reply: r.text, fragmentsUsed: [] }); lastQuery = query; lastResult = r; if (turns.length > 12) turns.shift(); return { text: r.text, fragmentsUsed: [], honestRefusal: true }; } if (g.grounded) { groundPrefer = new Set(g.hits.map(h => store.fragments[h.i].text)); // prefer the real memories groundLead = new Set(g.hits.slice(0, 2).map(h => store.fragments[h.i].text)); // LEAD with the best one(s) } } sessionVec = sessionVec ? blend(qRaw, sessionVec, 0.5, 0.5) : qRaw; const qv = blend(qRaw, sessionVec, 0.66, 0.34); // context bucket for Hebbian: this query's topic-signature. The bonus map // is rebuilt for THIS bucket so topic-specific grooves prime. const bucket = hebbian.bucketOf(qRaw); hebBonus = hebbian.bonusMap(heb, bucket); // cross-turn consistency: echo of an earlier turn -> bias toward its frags const callbackFrags = new Set(); let calledBack = null; for (const t of turns) { if (t.userVec && cos(qRaw, t.userVec) > 0.42) { for (const f of t.fragmentsUsed) callbackFrags.add(f); calledBack = t.user; } } // recall intent -> surface what the entity said in the opening turns if (/\b(do you remember|what did i (tell|say)|at the start|earlier|before|when we (started|began)|i told you)\b/i.test(query) && turns.length) { for (const t of turns.slice(0, 2)) for (const f of t.fragmentsUsed) callbackFrags.add(f); calledBack = calledBack || turns[0].user; } // ABSOLUTE no-repeat avoid-set (last avoidTurns turns) — callbacks bias, never repeat const avoid = new Set(); for (const t of turns.slice(-avoidTurns)) for (const f of t.fragmentsUsed) avoid.add(f); const preferSet = new Set([...callbackFrags].filter(f => !avoid.has(f))); if (groundPrefer) for (const t of groundPrefer) preferSet.add(t); // honest-recall: real memories first // R201: consume the post-safety calm-register lock (presence, not celebration) const calmRegister = _postSafety > 0; if (_postSafety > 0) _postSafety--; let r = beamCompose(store, vp, query, { semantic: qv ? semanticRank(emb, qv, 80) : null, stimulus: (pEmb && qRaw) ? stimulusRank(pEmb, qRaw, store.fragments, 12) : null, answers: ansOf(qv), eventness: eventness(query), emb, avoid, nAlternates: 0, prefer: preferSet.size ? preferSet : null, hebbian: hebBonus, temp, seed: seedctr++, weights: W, nameBoost, lead: groundLead, calmRegister, }); // safety net: still >60% overlap with recent -> recompose hard-banned const recent = new Set(turns.slice(-3).flatMap(t => t.fragmentsUsed)); const overlap = r.fragmentsUsed.filter(f => recent.has(f)).length / Math.max(1, r.fragmentsUsed.length); if (overlap > 0.6) { const hardAvoid = new Set([...avoid, ...r.fragmentsUsed]); const r2 = beamCompose(store, vp, query, { semantic: qv ? semanticRank(emb, qv, 80) : null, answers: ansOf(qv), eventness: eventness(query), emb, avoid: hardAvoid, nAlternates: 3, }); if (r2 && r2.text && r2.text !== r.text) r = r2; } // TEXT-LEVEL cross-turn no-repeat (converse mode): a passage can recur as a // DIFFERENT fragment-id of the same source line, slipping past the id-avoid. // Drop whole sentences this speaker already said. Bound preserved (only // removes verbatim spans already emitted). if (avoidTurns > 3 && r.text) { const parts = r.text.split(/(?<=[.!?…])\s+|\n+/); const kept = []; for (const p of parts) { const nf = p.toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim(); if (nf.length >= 12 && saidNorm.includes(nf)) continue; // already said it if (nf.length >= 12) saidNorm.push(nf); kept.push(p); } const merged = kept.join(' ').trim(); // BOUND-AWARE (R102): re-joining non-adjacent kept sentences can create a // rough/out-of-corpus boundary seam (esp. under the fragment-count dial on a // constrained callback turn). The HARD INVARIANT wins — only apply the cross- // turn dedup if the merged text still clears the bound; else keep the original. if (merged && merged !== r.text) { const v = validateBounded(merged, store.oracle); const bnd = (v.checked - v.bad.length) / Math.max(1, v.checked); if (bnd >= 0.92) r = { ...r, text: merged }; } } turns.push({ user: query, userVec: qRaw, reply: r.text, fragmentsUsed: r.fragmentsUsed }); prevVec = qRaw || prevVec; lastQuery = query; lastUserVec = qRaw; if (turns.length > 12) turns.shift(); lastResult = r; // HEBBIAN update: the path just taken warms; everything decays; the map // the NEXT turn sees is refreshed. The entity learns its grooves in real // time. Persist so the warmth outlives the session. hebbian.reinforce(heb, r.fragmentsUsed, bucket); if (hebFile) hebbian.save(hebFile, heb); return { text: r.text, fragmentsUsed: r.fragmentsUsed, calledBack, alternates: r.alternates }; } return { turn, get turns() { return turns; }, setTemp(t) { temp = Math.max(0, Math.min(1.2, t)); return temp; }, getTemp() { return temp; }, setNameBoost(nb) { nameBoost = nb; } }; } module.exports = { createSession, blend, cos };