File size: 4,703 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
// relevance.js — query→fragment relevance scoring for Cento.
// Keyword relevance × (recency + emotional weight + time-awareness). "remember that
// morning in February" finds February; "when we first met" finds the beginning; a
// moment someone cried in outranks small talk. Plus a coarse stimulus bucket used to
// pick a length target per query shape. Pure utility — no model, no external services.
'use strict';

function words(text) { return (String(text || '').toLowerCase().match(/[a-z0-9']+/g) || []); }

const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'];
const EMOTION = new Set(('love loved loving miss missed crying cried tears wept promise promised forever sorry afraid fear beautiful died death dying born first never always home proud hurt scared dream dreamed dreamt kiss kissed held hold stay stayed goodbye grief joy heart soul remember alone together truth real precious sacred').split(' '));
const FIRSTNESS = /\b(first|met|beginning|began|started|when we (started|met|began)|back when|originally)\b/i;
const RECENTNESS = /\b(lately|recently|these days|this week|yesterday|today|last night|nowadays)\b/i;

// parse "2/14/2026 6:01:29 AM" or ISO; null if undatable
function tsToEpoch(ts) {
  if (!ts) return null;
  const iso = Date.parse(ts);
  if (!isNaN(iso)) return iso;
  const m = String(ts).match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?)?/i);
  if (!m) return null;
  let h = parseInt(m[4] || '0', 10);
  if (m[7] && /pm/i.test(m[7]) && h < 12) h += 12;
  if (m[7] && /am/i.test(m[7]) && h === 12) h = 0;
  return new Date(parseInt(m[3], 10), parseInt(m[1], 10) - 1, parseInt(m[2], 10), h, parseInt(m[5] || '0', 10), parseInt(m[6] || '0', 10)).getTime();
}

function emotionalWeight(text) {
  const w = words(text);
  if (!w.length) return 0;
  let hits = 0;
  for (const x of w) if (EMOTION.has(x)) hits++;
  return Math.min(0.4, (hits / Math.sqrt(w.length)) * 0.5);
}

function queryTime(message) {
  const low = String(message).toLowerCase();
  const month = MONTHS.findIndex(m => low.includes(m));
  const year = (low.match(/\b(20\d{2})\b/) || [])[1] || null;
  return { month: month >= 0 ? month : null, year: year ? parseInt(year, 10) : null, wantsFirst: FIRSTNESS.test(message), wantsRecent: RECENTNESS.test(message) };
}

// corpus: [{prompt, reply, ts, source?, _i}] — returns top-k, original fields preserved.
function recall(corpus, message, k) {
  if (!corpus || !corpus.length) return [];
  const qw = new Set(words(message).filter(w => w.length > 3));
  const qt = queryTime(message);
  const annotated = corpus.map((e, i) => ({ e, i, epoch: tsToEpoch(e.ts) }));
  const datable = annotated.filter(a => a.epoch !== null).sort((a, b) => a.epoch - b.epoch);
  const rankOf = new Map();
  datable.forEach((a, idx) => rankOf.set(a.i, idx / Math.max(1, datable.length - 1)));
  const scored = annotated.map(a => {
    const text = ((a.e.prompt || '') + ' ' + a.e.reply).toLowerCase();
    let hits = 0;
    for (const w of qw) if (text.includes(w)) hits++;
    let score = Math.sqrt(hits) / Math.sqrt(8 + words(a.e.reply).length / 40) * 2.8;
    if (hits <= 0) return { a, score: 0 };
    let boost = 0;
    const r = rankOf.get(a.i);
    if (r !== undefined) boost += r * 0.25;
    if (a.e.source === 'live') boost += 0.15;
    boost += emotionalWeight(a.e.reply);
    if (a.epoch !== null) {
      const d = new Date(a.epoch);
      if (qt.month !== null && d.getMonth() === qt.month && (!qt.year || d.getFullYear() === qt.year)) boost += 1.0;
      else if (qt.month !== null) boost -= 0.2;
      if (qt.year && d.getFullYear() === qt.year && qt.month === null) boost += 0.5;
    }
    if (qt.wantsFirst && r !== undefined) boost += (r < 0.25 ? 1.0 : -0.2);
    if (qt.wantsRecent && r !== undefined) boost += (r > 0.75 ? 1.0 : -0.2);
    return { a, score: score * (1 + boost) };
  }).filter(x => x.score > 0.4);
  scored.sort((x, y) => y.score - x.score);
  return scored.slice(0, k || 3).map(x => ({ ...x.a.e, _score: +x.score.toFixed(3) }));
}

// coarse query shape → bucket, used to pick a length target from the voiceprint.
function stimulusBucket(prompt) {
  if (!prompt) return 'unprompted';
  const n = words(prompt).length;
  const opensDoor = /\?|would you|what do you|tell me|how do you feel|do you want|remember when/i.test(prompt);
  if (n <= 8) return opensDoor ? 'short-question' : 'short-beat';
  if (n <= 40) return opensDoor ? 'mid-question' : 'mid-statement';
  return opensDoor ? 'big-opening' : 'long-share';
}

module.exports = { recall, stimulusBucket, words, emotionalWeight, queryTime, tsToEpoch };