diff --git a/ventures/havre/backup.js b/ventures/havre/backup.js new file mode 100644 index 0000000..0577b7b --- /dev/null +++ b/ventures/havre/backup.js @@ -0,0 +1,77 @@ +// backup.js: a character with everything this browser keeps about them, or the whole harbor, as one file, and back. +// Pure functions, no DOM: the page imports this module, and so do the tests. +// +// a bundle { havre_backup: 1, at, cards: [card], memory: { : memory }, threads: [thread + msgs], +// media: { : data: URL }, settings?: { abilities, model, voiceOn } } +// +// A card file (.havre.json) carries the character and nothing else, by design: it's the thing you share. A bundle +// is the thing you keep: the card, what they remember, your chats with them and the pictures in those chats. "Full" +// is every character, every chat, every picture, and your settings (never your key). Restoring merges: a card already +// in the harbor is left as it is, notes are appended, chats by id are added if missing, pictures by id are put back. + +import { normalizeCard } from "./card.js"; +import { readMemory, appended } from "./memory.js"; + +export const BACKUP = 1; + +const str = (v, max = 20000) => (typeof v === "string" ? v : v == null ? "" : String(v)).slice(0, max); +const arr = (v) => (Array.isArray(v) ? v : []); +const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {}); + +/** the media ids a thread's messages point at */ +export function mediaIds(threads) { + const ids = new Set(); + for (const t of arr(threads)) for (const m of arr(t.msgs)) if (m && m.media && typeof m.media.id === "string" && m.media.id) ids.add(m.media.id); + return [...ids]; +} + +/** one character's bundle: the card, their memory, the chats with them (with messages), the pictures those chats hold */ +export function bundleOne(card, { memory = null, threads = [], media = {}, at = Date.now() } = {}) { + const mine = arr(threads).filter((t) => t && t.card === card.id); + const ids = new Set(mediaIds(mine)); + return { havre_backup: BACKUP, at, cards: [card], memory: memory ? { [card.id]: memory } : {}, threads: mine, + media: Object.fromEntries(Object.entries(obj(media)).filter(([id]) => ids.has(id))) }; +} + +/** the whole harbor: every card, memory, chat and picture, and the settings worth carrying (never the key) */ +export function bundleAll({ cards = [], memory = {}, threads = [], media = {}, settings = null, at = Date.now() } = {}) { + return { havre_backup: BACKUP, at, cards: arr(cards), memory: obj(memory), threads: arr(threads), media: obj(media), + ...(settings ? { settings } : {}) }; +} + +/** a bundle as read from a file, made safe; null if the text isn't one */ +export function readBundle(o) { + if (!o || typeof o !== "object" || Number(o.havre_backup) !== BACKUP) return null; + const cards = arr(o.cards).filter((c) => c && typeof c === "object" && c.name).map((c) => normalizeCard(c)); + const memory = {}; + for (const [id, m] of Object.entries(obj(o.memory))) { const r = readMemory(m); if (r.notes.length || Object.keys(r.upTo).length) memory[str(id, 80)] = r; } + const threads = arr(o.threads).filter((t) => t && typeof t === "object" && typeof t.id === "string" && typeof t.card === "string") + .map((t) => ({ ...t, id: str(t.id, 80), card: str(t.card, 80), msgs: arr(t.msgs).filter((m) => m && typeof m === "object" && (m.role === "user" || m.role === "assistant")) })); + const media = {}; + for (const [id, d] of Object.entries(obj(o.media))) if (typeof d === "string" && /^data:(image|audio|video)\//.test(d) && d.length < 20_000_000) media[str(id, 80)] = d; + const settings = obj(o.settings); + return { at: Number(o.at) || 0, cards, memory, threads, media, settings: Object.keys(settings).length ? settings : null }; +} + +/** + * What restoring a bundle into a harbor comes to, without touching anything: cards to add (not already there, by + * id), memories merged (notes appended to what's there), threads to add (by id, not already there). Pictures are + * put back as they are. The counts say what happened. + */ +export function merge(bundle, { cards = [], memory = {}, threads = {} } = {}) { + const have = new Set(arr(cards).map((c) => c.id)); + const addCards = bundle.cards.filter((c) => !have.has(c.id)); + const mergedMemory = {}; + for (const [id, m] of Object.entries(bundle.memory)) { + const now = readMemory(obj(memory)[id]); + mergedMemory[id] = { notes: appended(now.notes, m.notes) || now.notes, upTo: { ...m.upTo, ...now.upTo }, at: Math.max(now.at, m.at) }; + } + const addThreads = bundle.threads.filter((t) => !obj(threads)[t.id]); + return { addCards, memory: mergedMemory, addThreads, skippedCards: bundle.cards.length - addCards.length, skippedThreads: bundle.threads.length - addThreads.length }; +} + +/** a file name for a bundle: one character's, or the harbor's, with the day */ +export function bundleName(card, at = Date.now()) { + const day = new Date(at).toISOString().slice(0, 10); + return card ? `${card.id}.havre-full.json` : `havre-backup-${day}.json`; +} diff --git a/ventures/havre/tests/backup.test.mjs b/ventures/havre/tests/backup.test.mjs new file mode 100644 index 0000000..afb9381 --- /dev/null +++ b/ventures/havre/tests/backup.test.mjs @@ -0,0 +1,61 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as B from "../backup.js"; +import * as R from "../memory.js"; +import { normalizeCard } from "../card.js"; + +const ines = normalizeCard({ name: "Ines Corbel", persona: "You are Ines." }); +const wren = normalizeCard({ name: "Wren", persona: "You are Wren." }); +const threads = [ + { id: "t1", card: "ines-corbel", msgs: [{ id: "a", role: "user", content: "hi" }, { id: "b", role: "assistant", content: "", media: { kind: "photo", id: "p1" } }] }, + { id: "t2", card: "wren", msgs: [{ id: "c", role: "assistant", content: "", media: { kind: "photo", id: "p2" } }] }, +]; +const media = { p1: "data:image/webp;base64,AAAA", p2: "data:image/webp;base64,BBBB", p9: "data:image/webp;base64,CCCC" }; + +test("one character's bundle holds their card, memory, chats and only those chats' pictures", () => { + const mem = { notes: [R.note("They hate fog.", { at: 5 })], upTo: { t1: "b" }, at: 5 }; + const b = B.bundleOne(ines, { memory: mem, threads, media, at: 9 }); + assert.equal(b.havre_backup, 1); + assert.deepEqual(b.cards.map((c) => c.id), ["ines-corbel"]); + assert.deepEqual(Object.keys(b.memory), ["ines-corbel"]); + assert.deepEqual(b.threads.map((t) => t.id), ["t1"]); + assert.deepEqual(Object.keys(b.media), ["p1"]); + assert.equal(B.bundleName(ines), "ines-corbel.havre-full.json"); + assert.match(B.bundleName(null, Date.UTC(2026, 9, 20)), /^havre-backup-2026-10-20\.json$/); +}); + +test("the whole harbor: everything, and the settings, never a key", () => { + const b = B.bundleAll({ cards: [ines, wren], memory: {}, threads, media, settings: { abilities: { own: true } } }); + assert.equal(b.cards.length, 2); + assert.equal(b.threads.length, 2); + assert.deepEqual(Object.keys(b.media).sort(), ["p1", "p2", "p9"]); + assert.deepEqual(b.settings, { abilities: { own: true } }); + assert.ok(!JSON.stringify(b).includes("key")); +}); + +test("a bundle reads back safely, and anything else is not one", () => { + assert.equal(B.readBundle({ cards: [] }), null); + assert.equal(B.readBundle("x"), null); + const b = B.readBundle(JSON.parse(JSON.stringify(B.bundleOne(ines, { memory: { notes: ["old"], upTo: {}, at: 1 }, threads, media })))); + assert.equal(b.cards[0].name, "Ines Corbel"); + assert.deepEqual(b.memory["ines-corbel"].notes.map((n) => n.text), ["old"]); + assert.equal(b.threads.length, 1); + assert.deepEqual(Object.keys(b.media), ["p1"]); + assert.equal(b.settings, null); + const junk = B.readBundle({ havre_backup: 1, cards: [{ name: "X" }, null, 3], threads: [{ id: "t", card: "x", msgs: [{ role: "system" }, { role: "user", content: "k" }] }, {}], media: { ok: "data:image/png;base64,AA", bad: "javascript:x" } }); + assert.equal(junk.cards.length, 1); + assert.equal(junk.threads.length, 1); + assert.equal(junk.threads[0].msgs.length, 1); + assert.deepEqual(Object.keys(junk.media), ["ok"]); +}); + +test("restoring merges: cards already there stay, notes are appended, chats by id are added if missing", () => { + const b = B.readBundle(B.bundleAll({ cards: [ines, wren], memory: { "ines-corbel": { notes: [R.note("new", { at: 3 })], upTo: { t1: "b" }, at: 3 } }, threads, media })); + const m = B.merge(b, { cards: [ines], memory: { "ines-corbel": { notes: [R.note("old", { at: 1 })], upTo: { t5: "z" }, at: 1 } }, threads: { t1: {} } }); + assert.deepEqual(m.addCards.map((c) => c.id), ["wren"]); + assert.equal(m.skippedCards, 1); + assert.deepEqual(m.memory["ines-corbel"].notes.map((n) => n.text), ["old", "new"]); + assert.deepEqual(m.memory["ines-corbel"].upTo, { t1: "b", t5: "z" }); + assert.deepEqual(m.addThreads.map((t) => t.id), ["t2"]); + assert.equal(m.skippedThreads, 1); +});