300 lines
15 KiB
JavaScript
300 lines
15 KiB
JavaScript
// 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).
|
|
//
|
|
// Three tiers, one ladder:
|
|
//
|
|
// 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;
|
|
/** 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 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 ----------
|
|
|
|
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" || typeof n === "number") { 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: until ? "remind" : 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(have.map((n) => (typeof n === "string" ? n : n.text).toLowerCase())), out = [];
|
|
for (const n of list) {
|
|
const t = noteText(n), k = t.toLowerCase();
|
|
if (!t || seen.has(k)) continue;
|
|
seen.add(k);
|
|
out.push({ text: t, at, kind: "fact" });
|
|
if (out.length >= max) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** a memory as kept, made safe to use: an empty one if there is none */
|
|
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;
|
|
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.until && n.kind !== "mine" && n.kind !== "remind");
|
|
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, the newer batch first among equals and a batch in the order it was
|
|
* written (the most important first), 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.n.at || 0) - (a.n.at || 0) || a.i - b.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"));
|
|
|
|
/** what a chat holds that the memory hasn't read yet: everything after the message it read last (all of it if that one's gone) */
|
|
export function unread(history, upTo = "") {
|
|
const real = said(history);
|
|
return real.slice(upTo ? real.findIndex((m) => m.id === upTo) + 1 : 0);
|
|
}
|
|
|
|
/** whether a chat has said enough since the last update for the memory to read it */
|
|
export const due = (history, upTo, every = EVERY) => unread(history, upTo).length >= every;
|
|
|
|
/** messages as lines a model reads ("Them: …", "<name>: …"), with photos and clips as words */
|
|
export function transcript(card, messages, max = 400) {
|
|
return said(messages).map((m) => {
|
|
const md = m.media || {};
|
|
let text = str(m.content).replace(/\s+/g, " ").trim();
|
|
if (m.role === "assistant" && (md.kind === "photo" || md.kind === "clip")) text = `(sent ${md.kind === "clip" ? "a short video" : "a photo"}${md.shot ? ": " + str(md.shot).trim() : ""})`;
|
|
else if (md.kind === "photo-in") text = `[a photo${md.saw ? ": " + str(md.saw).trim() : ""}]${text ? " " + text : ""}`;
|
|
return text ? `${m.role === "user" ? "Them" : card.name}: ${clip(text, max)}` : "";
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
/** 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 : ""}`,
|
|
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 is new for ${card.name} to remember.`,
|
|
].join("\n\n");
|
|
return [{ role: "system", content: MEMORY_SYSTEM }, { role: "user", content: user }];
|
|
}
|
|
|
|
/** @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;
|
|
}
|
|
|
|
// ---------- 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;
|
|
}
|