Havre: memory as three tiers — deep (chats), long (every note, dated, kinds), short (a budgeted selection each turn); recall, reminders
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
This commit is contained in:
1 parent
b87db442c8
commit
e0885cae65
2 files changed
+304
-50
No files matched your search
+224
-34
@@ -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: { <chat id>: <message id> }, 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: { <chat id>: <message id> }, 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;
|
||||
}
|
||||
@@ -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\./);
|
||||
});
|
||||
Reference in new issue
Block a user