diff --git a/ventures/havre/memory.js b/ventures/havre/memory.js index 520005b..2f02eef 100644 --- a/ventures/havre/memory.js +++ b/ventures/havre/memory.js @@ -1,49 +1,96 @@ // memory.js: what a character remembers of your chats with them. Pure functions, no DOM: the page imports this // module, and so do the tests (node --test). // -// a memory { notes: ["They're called Princess.", "I told them I grew up in Brest."], upTo: { : }, at } +// Three tiers, one ladder: // -// One per character, kept in the browser with your chats and never in their card: a card you export or share carries -// none of it. Every dozen messages in a chat, and when you leave a chat with a few new ones, a model (theirs, if it keeps -// to JSON) reads what they remembered and the messages since, and writes down what they remember now. The notes ride in -// the system prompt of every chat with them -// (card.js systemPrompt), so a new chat with someone picks up where the last one left off, and a long chat keeps what -// has scrolled out of the forty messages the model reads. +// deep every message of every chat with them, as they are (threads.js): never read whole, searched +// long notes: { text, at, kind, from?, until? }. Every note ever written, dated, never pruned. A character's +// model writes them every dozen messages (notes from the messages since: facts); "remember this" adds +// one by hand, verbatim; "remind" adds one with an `until`, a time it should come back to mind +// short what rides in the prompt: a few hundred tokens' worth of long, chosen each turn by select() below: +// the newest notes, a reminder as its time nears, what was recalled. That is the one seam a memory +// strategy plugs into: everything else is a store and a writer. +// +// a memory { notes: [note], upTo: { : }, at } +// +// One per character, kept in the browser with your chats and never in their card: a card you export or share +// carries none of it. Notes older than the store keeps (KEEP) roll off the end, oldest ordinary first; a note with a +// time (a reminder) or one you added yourself stays as long as it can. const str = (v, max = 20000) => (typeof v === "string" ? v : v == null ? "" : String(v)).slice(0, max); const clip = (s, n) => (s.length <= n ? s : s.slice(0, Math.max(0, n - 1)).replace(/\s+\S*$/, "") + "…"); +const arr = (v) => (Array.isArray(v) ? v : []); +/** notes the store keeps, at most: well past what one person says to one character in a year */ +export const KEEP = 600; +/** the most notes a model is asked to write from one batch of messages */ export const MAX_NOTES = 16; -/** messages in a chat (yours and theirs) between one update of the memory and the next */ +/** notes in the prompt, at most, within the token budget */ +export const SHORT = 16; +/** the budget for notes in the prompt, in characters (a few hundred tokens) */ +export const BUDGET = 2400; +/** messages in a chat (yours and theirs) between one writing of notes and the next */ export const EVERY = 12; /** a chat you leave with at least this many messages it hasn't read is read then, so a new chat picks up from it */ export const LEAVING = 4; +/** how long a recalled note stays in mind */ +export const RECALLED_FOR = 3 * 3600e3; +/** a reminder comes to mind this long before its time */ +export const AHEAD = 36 * 3600e3; +/** and stays this long after */ +export const AFTER = 48 * 3600e3; + +export const KINDS = ["fact", "mine", "remind", "recalled", "dream"]; export const MEMORY_SYSTEM = "You keep the memory of a character in a chat app: what they remember of their chats with one " + - "person. From what they remembered before and the latest messages, write down what they remember now, as short notes: " + - "what the person has told them about themselves (their name, their life, what they like and don't), what has happened " + - "between them, plans and promises, running jokes, and what the character has said about themselves (their past, their " + - "home, their likes), so they keep to it. Write about the person as \"they\" and the character as \"I\". At most " + - MAX_NOTES + " notes, the most important first, each under 25 words. Keep what still matters, merge what repeats, update " + - "what changed, and leave out small talk. Only what the messages say: never invent anything. Reply with one JSON " + - "object: {\"notes\": [\"...\"]}."; + "person. From what they remembered before and the latest messages, write down what is new to remember, as short " + + "notes: what the person has told them about themselves (their name, their life, what they like and don't), what has " + + "happened between them, plans and promises, running jokes, and what the character has said about themselves (their " + + "past, their home, their likes), so they keep to it. Write about the person as \"they\" and the character as \"I\". " + + "Only what the latest messages add or change: never repeat what they remembered before, never invent anything, " + + "and leave out small talk. At most " + MAX_NOTES + " notes, the most important first, each under 25 words; none " + + "if nothing new was said. Reply with one JSON object: {\"notes\": [\"...\"]}."; export const MEMORY_SCHEMA = { type: "object", additionalProperties: false, required: ["notes"], properties: { notes: { type: "array", items: { type: "string" } } }, }; -/** notes as a list: short, distinct, at most `max`; null when there is no list at all (a reply that went wrong) */ -export function normalizeNotes(o, max = MAX_NOTES) { +// ---------- notes ---------- + +const noteText = (v) => clip(str(v, 2000).replace(/\s+/g, " ").trim().replace(/^(?:[-•*]|\d+[.)])\s*/, ""), 240); + +/** a note as kept, from whatever was stored (a bare string from before notes had dates reads as an undated fact) */ +export function readNote(n, at = 0) { + if (typeof n === "string") { const text = noteText(n); return text ? { text, at, kind: "fact" } : null; } + if (!n || typeof n !== "object") return null; + const text = noteText(n.text); + if (!text) return null; + const note = { text, at: Number(n.at) || at, kind: KINDS.includes(n.kind) ? n.kind : "fact" }; + if (n.from && typeof n.from === "object" && n.from.thread && n.from.msg) note.from = { thread: str(n.from.thread, 80), msg: str(n.from.msg, 80) }; + if (Number(n.until) > 0) note.until = Number(n.until); + if (Number(n.seen) > 0) note.seen = Number(n.seen); + return note; +} + +/** a new note: what the model wrote (a fact), what you added ("mine"), or a reminder with its time */ +export function note(text, { at = Date.now(), kind = "fact", from, until } = {}) { + return readNote({ text, at, kind, from, until }); +} + +/** + * The model's reply, as new notes: short, distinct, at most `max`, none already in `have`; null when there is no + * list at all (a reply that went wrong). at: when they were written. + */ +export function normalizeNotes(o, max = MAX_NOTES, { at = Date.now(), have = [] } = {}) { const list = Array.isArray(o) ? o : o && Array.isArray(o.notes) ? o.notes : null; if (!list) return null; - const seen = new Set(), out = []; + const seen = new Set(have.map((n) => (typeof n === "string" ? n : n.text).toLowerCase())), out = []; for (const n of list) { - const t = clip(str(n, 2000).replace(/\s+/g, " ").trim().replace(/^(?:[-•*]|\d+[.)])\s*/, ""), 240); - const k = t.toLowerCase(); + const t = noteText(n), k = t.toLowerCase(); if (!t || seen.has(k)) continue; seen.add(k); - out.push(t); + out.push({ text: t, at, kind: "fact" }); if (out.length >= max) break; } return out; @@ -54,9 +101,139 @@ export function readMemory(m) { const o = m && typeof m === "object" ? m : {}; const upTo = {}; for (const [k, v] of Object.entries(o.upTo && typeof o.upTo === "object" ? o.upTo : {})) if (typeof v === "string" && v) upTo[k] = v; - return { notes: normalizeNotes(o.notes || []) || [], upTo, at: Number(o.at) || 0 }; + const at = Number(o.at) || 0, seen = new Set(), notes = []; + for (const raw of arr(o.notes)) { + const n = readNote(raw, at); + if (!n || seen.has(n.text.toLowerCase())) continue; + seen.add(n.text.toLowerCase()); + notes.push(n); + } + return { notes, upTo, at }; } +/** + * Notes added to the store: new ones on the end (the store reads oldest first), none twice, and when it's past what + * it keeps, the oldest ordinary notes go, never a reminder or one you added. null: nothing to add. + */ +export function appended(notes, added, keep = KEEP) { + if (!added) return null; + const have = new Set(notes.map((n) => n.text.toLowerCase())); + const fresh = added.filter((n) => n && n.text && !have.has(n.text.toLowerCase())); + let all = [...notes, ...fresh]; + while (all.length > keep) { + const i = all.findIndex((n) => n.kind === "fact" || n.kind === "recalled" || n.kind === "dream"); + if (i < 0) break; + all = [...all.slice(0, i), ...all.slice(i + 1)]; + } + return all; +} + +/** a note you give them yourself: added as yours, once */ +export function withNote(notes, text, opts = {}) { + const n = note(text, { ...opts, kind: opts.kind || "mine" }); + return n ? appended(notes, [n]) : notes; +} + +/** the notes without one (by text) */ +export const without = (notes, text) => notes.filter((n) => n.text !== text); + +// ---------- short: what rides in the prompt ---------- + +/** + * How much a note wants to be in mind right now, 0 to 1. The one function a memory strategy changes: today it is + * recency for notes, how near its time for a reminder, and a stretch after being recalled; nothing is learned yet. + */ +export function salience(n, now = Date.now()) { + if (n.seen && now - n.seen < RECALLED_FOR && !n.until) return Math.max(0.5, 0.95 * (1 - (now - n.seen) / RECALLED_FOR)); // recalled just now: in mind + if (n.until) { + const dt = n.until - now; + if (dt > AHEAD) return 0.05 + 0.15 * Math.max(0, 1 - dt / (30 * 86400e3)); // far off: faint, a little nearer each day + if (dt >= 0) return 0.6 + 0.4 * (1 - dt / AHEAD); // coming up: climbs to 1 + if (-dt <= AFTER) return 0.7; // just passed: still in mind + return 0; // over + } + if (n.kind === "recalled") return 0; // a recalled line, once it's faded: back to deep + const age = Math.max(0, now - (n.at || 0)); + const days = age / 86400e3; + return n.kind === "mine" ? 0.5 + 0.4 / (1 + days / 30) : 0.3 + 0.6 / (1 + days / 7); +} + +/** + * The notes for the prompt now: the most salient, newest first among equals, as many as fit the budget. With no + * reminders and nothing recalled this comes down to the newest notes, which is what it was before. + */ +export function select(notes, { now = Date.now(), budget = BUDGET, max = SHORT } = {}) { + const scored = notes.map((n, i) => ({ n, i, s: salience(n, now) })).filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s || b.i - a.i); + const out = []; + let used = 0; + for (const { n } of scored) { + if (out.length >= max) break; + const cost = n.text.length + 3; + if (used + cost > budget) continue; + out.push(n); + used += cost; + } + return out; +} + +/** the selected notes as the lines a prompt carries, a reminder with its day */ +export function lines(selected, now = Date.now()) { + return selected.map((n) => { + if (!n.until) return n.text; + const d = Math.round((n.until - now) / 86400e3); + const when = d > 1 ? `in ${d} days` : d === 1 ? "tomorrow" : d === 0 ? "today" : d === -1 ? "yesterday" : `${-d} days ago`; + return `(${when}) ${n.text}`; + }); +} + +// ---------- recall: search long, and deep ---------- + +const WORDS = (s) => str(s, 4000).toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) || []; + +/** notes that share words with the query, best first; at most n */ +export function search(notes, query, n = 5) { + const q = [...new Set(WORDS(query))]; + if (!q.length) return []; + return notes.map((note) => { + const w = new Set(WORDS(note.text)); + const hits = q.filter((x) => w.has(x)).length; + return { note, hits }; + }).filter((x) => x.hits > 0).sort((a, b) => b.hits - a.hits || (b.note.at || 0) - (a.note.at || 0)).slice(0, n).map((x) => x.note); +} + +/** lines of chats (deep) that share words with the query, best first: { thread, msg, text, at }, at most n */ +export function searchDeep(threads, msgsOf, query, n = 5) { + const q = [...new Set(WORDS(query))]; + if (!q.length) return []; + const out = []; + for (const t of threads) { + for (const m of msgsOf(t)) { + if (!m || m.hidden || m.demo || (m.role !== "user" && m.role !== "assistant")) continue; + const text = str(m.content).replace(/\s+/g, " ").trim(); + if (!text) continue; + const w = new Set(WORDS(text)); + const hits = q.filter((x) => w.has(x)).length; + if (hits) out.push({ thread: t.id, msg: m.id, role: m.role, text: clip(text, 240), at: m.at || 0, hits }); + } + } + return out.sort((a, b) => b.hits - a.hits || b.at - a.at).slice(0, n).map(({ hits, ...r }) => r); +} + +/** + * What was recalled, brought to mind: notes found in long get `seen` now (and stay in mind for RECALLED_FOR); lines + * found in deep become notes of their own, kind "recalled", that fade back out after. Returns the notes to keep. + */ +export function recalled(notes, { found = [], deep = [] } = {}, now = Date.now()) { + const hit = new Set(found.map((n) => n.text)); + const marked = notes.map((n) => (hit.has(n.text) ? { ...n, seen: now } : n)); + const fresh = deep.map((f) => readNote({ text: `${f.role === "user" ? "They said" : "I said"}: ${f.text}`, at: f.at || now, kind: "recalled", + from: f.thread ? { thread: f.thread, msg: f.msg } : undefined, seen: now })).filter(Boolean); + return appended(marked, fresh) || marked; +} + +// ---------- the writer: every dozen messages ---------- + /** the things said in a chat that count: not the hidden nudges, not the replies made up without a key */ const said = (history) => (history || []).filter((m) => m && !m.demo && !m.hidden && (m.role === "user" || m.role === "assistant")); @@ -80,29 +257,42 @@ export function transcript(card, messages, max = 400) { }).filter(Boolean); } -/** the messages for the character's model to write what they remember now */ +/** the messages for the character's model to write what is new to remember (notes: what it's shown of before) */ export function memoryMessages(card, notes, lines) { + const texts = notes.map((n) => (typeof n === "string" ? n : n.text)); const user = [ `The character: ${card.name}${card.tagline ? ". " + card.tagline : ""}`, - notes.length ? "What they remember so far:\n" + notes.map((n) => "- " + n).join("\n") : "What they remember so far: nothing yet.", + texts.length ? "What they remember so far:\n" + texts.map((n) => "- " + n).join("\n") : "What they remember so far: nothing yet.", "The latest messages, oldest first:\n" + lines.join("\n"), - `Write what ${card.name} remembers now.`, + `Write what is new for ${card.name} to remember.`, ].join("\n\n"); return [{ role: "system", content: MEMORY_SYSTEM }, { role: "user", content: user }]; } -/** - * What they remember after an update: the model's notes, unless it lost everything they knew (an empty list when - * there were notes is a reply gone wrong, so the old notes stay). null: keep what was there. - */ +/** @deprecated the old whole-list rewrite; kept for a reply with no list (null: keep what was there) */ export function merged(before, after) { if (!after) return null; return !after.length && before.length ? null : after; } -/** a note you give them yourself: first in the list, not twice, and the oldest goes if there's no room */ -export function withNote(notes, text, max = MAX_NOTES) { - const t = clip(str(text, 2000).replace(/\s+/g, " ").trim(), 240); - if (!t) return notes; - return [t, ...notes.filter((n) => n.toLowerCase() !== t.toLowerCase())].slice(0, max); +// ---------- reminders: when ---------- + +/** a time from plain words: "tomorrow", "in 3 days", "in 2 weeks", "next month", "friday", "2026-11-01"; null if none */ +export function parseWhen(text, now = Date.now()) { + const s = str(text, 200).toLowerCase().trim(); + const day = 86400e3, at = new Date(now); + const morning = (d) => { const x = new Date(d); x.setHours(10, 0, 0, 0); return x.getTime(); }; + if (!s) return null; + if (/^(today|tonight)$/.test(s)) return now + 6 * 3600e3; + if (s === "tomorrow") return morning(now + day); + let m = /^in\s+(\d+)\s*(hour|day|week|month)s?$/.exec(s) || /^(\d+)\s*(hour|day|week|month)s?$/.exec(s); + if (m) { const n = Number(m[1]); const unit = { hour: 3600e3, day, week: 7 * day, month: 30 * day }[m[2]]; return unit === 3600e3 ? now + n * unit : morning(now + n * unit); } + m = /^next\s+(week|month)$/.exec(s); + if (m) return morning(now + (m[1] === "week" ? 7 : 30) * day); + const days = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; + const di = days.findIndex((d) => d === s || d.slice(0, 3) === s || ("next " + d) === s); + if (di >= 0) { let ahead = (di - at.getDay() + 7) % 7; if (!ahead) ahead = 7; return morning(now + ahead * day); } + m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); + if (m) { const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 10, 0, 0, 0).getTime(); return Number.isFinite(t) ? t : null; } + return null; } diff --git a/ventures/havre/tests/memory.test.mjs b/ventures/havre/tests/memory.test.mjs index 473dad2..4d17427 100644 --- a/ventures/havre/tests/memory.test.mjs +++ b/ventures/havre/tests/memory.test.mjs @@ -6,6 +6,7 @@ import * as C from "../card.js"; const ines = C.normalizeCard({ name: "Ines Corbel", tagline: "Ferry pilot with a pencil behind her ear", persona: "You are Ines Corbel." }); const chat = (n, from = 0) => Array.from({ length: n }, (_, i) => ({ id: "m" + (from + i), role: (from + i) % 2 ? "assistant" : "user", content: "line " + (from + i), at: 1000 + i })); +const DAY = 86400e3, NOW = 1_800_000_000_000; test("what a chat holds that the memory hasn't read: after the last message it read, without nudges or replies made without a key", () => { const h = [...chat(6), { id: "x", role: "user", content: "[Time passes]", hidden: true }, { id: "d", role: "assistant", content: "demo", demo: true }, ...chat(4, 6)]; @@ -17,7 +18,7 @@ test("what a chat holds that the memory hasn't read: after the last message it r assert.ok(!R.due(chat(20), "m10"), "nine since the last update isn't enough"); }); -test("the model reads the character, what they remembered, and the messages since, as lines", () => { +test("the model reads the character, what they remembered, and the messages since, and is asked for what is new", () => { const lines = R.transcript(ines, [ { id: "a", role: "user", content: "hi,\n\nI'm Princess" }, { id: "b", role: "assistant", content: "", media: { kind: "photo", shot: "the ferry deck at dawn" } }, @@ -26,36 +27,99 @@ test("the model reads the character, what they remembered, and the messages sinc ]); assert.deepEqual(lines, ["Them: hi, I'm Princess", "Ines Corbel: (sent a photo: the ferry deck at dawn)", "Them: [a photo: a red boat] nice", "Ines Corbel: Twelve minutes. Nine if the gulls cooperate."]); - const [sys, user] = R.memoryMessages(ines, ["They hate fog."], lines); + const [sys, user] = R.memoryMessages(ines, [R.note("They hate fog.")], lines); assert.equal(sys.content, R.MEMORY_SYSTEM); assert.match(sys.content, /never invent/); + assert.match(sys.content, /never repeat what they remembered before/); assert.match(user.content, /^The character: Ines Corbel\. Ferry pilot/); assert.match(user.content, /What they remember so far:\n- They hate fog\./); assert.match(user.content, /The latest messages, oldest first:\nThem: hi, I'm Princess/); assert.match(R.memoryMessages(ines, [], lines)[1].content, /What they remember so far: nothing yet\./); }); -test("notes come back short, distinct and at most sixteen; a reply without a list, or one that lost everything, keeps the old", () => { - assert.deepEqual(R.normalizeNotes({ notes: [" - They're called Princess. ", "they're called princess.", "", 7, "1. I grew up in Brest."] }), - ["They're called Princess.", "7", "I grew up in Brest."]); +test("new notes come back short, distinct, dated, not ones already kept, and at most sixteen", () => { + const have = [R.note("I grew up in Brest.", { at: 5 })]; + const n = R.normalizeNotes({ notes: [" - They're called Princess. ", "they're called princess.", "", 7, "1. I grew up in Brest."] }, R.MAX_NOTES, { at: 99, have }); + assert.deepEqual(n, [{ text: "They're called Princess.", at: 99, kind: "fact" }, { text: "7", at: 99, kind: "fact" }]); assert.equal(R.normalizeNotes({ nope: [] }), null); assert.equal(R.normalizeNotes({ notes: Array.from({ length: 40 }, (_, i) => "note " + i) }).length, R.MAX_NOTES); - assert.ok(R.normalizeNotes({ notes: ["x".repeat(900)] })[0].length <= 240); - assert.deepEqual(R.merged(["a"], ["b"]), ["b"]); - assert.equal(R.merged(["a"], []), null, "an empty list when there were notes is a reply gone wrong"); - assert.deepEqual(R.merged([], []), []); - assert.equal(R.merged(["a"], null), null); + assert.ok(R.normalizeNotes({ notes: ["x".repeat(900)] })[0].text.length <= 240); }); -test("a memory as kept reads safely, and a note you add goes first, once", () => { +test("notes are appended, never twice, and the store rolls the oldest ordinary notes off, never yours or a reminder", () => { + const a = R.note("a", { at: 1 }), b = R.note("b", { at: 2 }); + assert.deepEqual(R.appended([a], [b, R.note("A", { at: 3 })]).map((n) => n.text), ["a", "b"], "the same note in other case isn't added again"); + assert.equal(R.appended([a], null), null); + const mine = R.note("keep", { at: 0, kind: "mine" }), rem = R.note("call mum", { at: 0, until: 10 }); + const many = [mine, rem, ...Array.from({ length: 5 }, (_, i) => R.note("f" + i, { at: i }))]; + assert.deepEqual(R.appended(many, [R.note("f9", { at: 9 })], 5).map((n) => n.text), ["keep", "call mum", "f3", "f4", "f9"]); +}); + +test("a memory as kept reads safely, old bare strings included, and a note you add is yours", () => { assert.deepEqual(R.readMemory(null), { notes: [], upTo: {}, at: 0 }); - assert.deepEqual(R.readMemory({ notes: ["a", "a", 3], upTo: { t1: "m4", t2: 5 }, at: "12" }), { notes: ["a", "3"], upTo: { t1: "m4" }, at: 12 }); - assert.deepEqual(R.withNote(["They hate fog.", "b"], "b"), ["b", "They hate fog."]); - assert.deepEqual(R.withNote(["a"], " "), ["a"]); - assert.equal(R.withNote(Array.from({ length: R.MAX_NOTES }, (_, i) => "n" + i), "new").length, R.MAX_NOTES); + const m = R.readMemory({ notes: ["a", "a", 3, { text: "b", at: 7, kind: "remind", until: 9, from: { thread: "t", msg: "m" } }, { text: "", at: 1 }], upTo: { t1: "m4", t2: 5 }, at: "12" }); + assert.deepEqual(m, { notes: [{ text: "a", at: 12, kind: "fact" }, { text: "3", at: 12, kind: "fact" }, { text: "b", at: 7, kind: "remind", until: 9, from: { thread: "t", msg: "m" } }], upTo: { t1: "m4" }, at: 12 }); + const w = R.withNote([R.note("They hate fog.", { at: 1 })], "b", { at: 2 }); + assert.deepEqual(w.map((n) => [n.text, n.kind]), [["They hate fog.", "fact"], ["b", "mine"]]); + assert.equal(R.withNote([R.note("a")], " ").length, 1); + assert.equal(R.without(w, "b").length, 1); +}); + +test("what rides in the prompt: the newest within the budget, a reminder as its time nears, what was just recalled", () => { + const old = Array.from({ length: 30 }, (_, i) => R.note("fact " + i, { at: NOW - (30 - i) * DAY })); + const s = R.select(old, { now: NOW }); + assert.equal(s.length, R.SHORT); + assert.equal(s[0].text, "fact 29", "newest first"); + assert.ok(!s.some((n) => n.text === "fact 0"), "the oldest is out"); + const far = R.note("they're moving in three months", { at: NOW - DAY, until: NOW + 90 * DAY }); + const soon = R.note("they're going to the store", { at: NOW - DAY, until: NOW + 3 * 3600e3 }); + const over = R.note("dentist", { at: NOW - 10 * DAY, until: NOW - 5 * DAY }); + const s2 = R.select([...old, far, soon, over], { now: NOW }); + assert.equal(s2[0].text, soon.text, "a reminder whose time is near comes first"); + assert.ok(!s2.some((n) => n.text === far.text), "one three months off isn't in mind yet"); + assert.ok(!s2.some((n) => n.text === over.text), "one long past is gone"); + assert.ok(R.salience(far, NOW) > 0 && R.salience(far, NOW) < R.salience(old[0], NOW), "but it has a faint claim"); + assert.ok(R.salience(far, NOW + 90 * DAY - 3600e3) > 0.9, "and a strong one when its time comes"); + const tiny = R.select(old, { now: NOW, budget: 30 }); + assert.equal(tiny.length, 3, "the budget caps it"); + assert.match(R.lines([soon, far], NOW)[0], /^\(today\) they're going to the store$/); + assert.match(R.lines([far], NOW)[0], /^\(in 90 days\)/); +}); + +test("recall searches notes and chats by words, and what it found stays in mind for a while", () => { + const notes = [R.note("I grew up in Brest, by the sea.", { at: 1 }), R.note("They hate fog.", { at: 2 }), R.note("Their sister is called Maud.", { at: 3 })]; + assert.deepEqual(R.search(notes, "what was their sister's name?").map((n) => n.text), ["Their sister is called Maud."]); + assert.deepEqual(R.search(notes, "brest").map((n) => n.text), ["I grew up in Brest, by the sea."]); + assert.deepEqual(R.search(notes, "um"), []); + const threads = [{ id: "t1" }, { id: "t2" }]; + const msgs = { t1: [{ id: "a", role: "user", content: "my sister Maud is visiting", at: 5 }, { id: "h", role: "user", content: "Maud again", hidden: true }], t2: [{ id: "b", role: "assistant", content: "Tell Maud hello from me", at: 9 }] }; + const deep = R.searchDeep(threads, (t) => msgs[t.id], "Maud"); + assert.deepEqual(deep.map((d) => d.msg), ["b", "a"]); + const after = R.recalled(notes, { found: R.search(notes, "sister"), deep: deep.slice(0, 1) }, NOW); + assert.equal(after.length, 4); + assert.equal(after[2].seen, NOW); + assert.equal(after[3].kind, "recalled"); + assert.match(after[3].text, /^I said: Tell Maud hello from me$/); + const s = R.select(after, { now: NOW + 1000 }); + assert.deepEqual(s.slice(0, 2).map((n) => n.text).sort(), ["I said: Tell Maud hello from me", "Their sister is called Maud."].sort(), "both come to mind first"); + assert.equal(R.salience(after[3], NOW + R.RECALLED_FOR + 1), 0, "a recalled line fades back out"); + assert.ok(R.salience(after[2], NOW + R.RECALLED_FOR + 1) > 0, "a note stays a note"); +}); + +test("a time from plain words", () => { + const now = new Date(2026, 9, 19, 15, 0).getTime(); // a Monday, 3pm + const at = (t) => new Date(t); + assert.equal(R.parseWhen("tomorrow", now), new Date(2026, 9, 20, 10, 0).getTime()); + assert.equal(at(R.parseWhen("in 3 days", now)).getDate(), 22); + assert.equal(at(R.parseWhen("2 weeks", now)).getDate(), 2); + assert.equal(at(R.parseWhen("friday", now)).getDay(), 5); + assert.equal(at(R.parseWhen("monday", now)).getDate(), 26, "next Monday, not today"); + assert.equal(R.parseWhen("2026-11-01", now), new Date(2026, 10, 1, 10, 0).getTime()); + assert.equal(R.parseWhen("in 2 hours", now), now + 2 * 3600e3); + assert.equal(R.parseWhen("whenever", now), null); }); test("what they remember rides in their system prompt", () => { - const p = C.systemPrompt(ines, { memory: R.readMemory({ notes: ["They're called Princess."] }).notes }); + const p = C.systemPrompt(ines, { memory: R.lines(R.select(R.readMemory({ notes: ["They're called Princess."] }).notes)) }); assert.match(p, /What you remember from your chats with them so far[^\n]*\n- They're called Princess\./); });