File size: 14,328 Bytes
8494d00 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | // 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<fragIdx>, 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 };
|