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 };