diff --git a/ventures/havre/app.js b/ventures/havre/app.js index 9fe300a..fca8f33 100644 --- a/ventures/havre/app.js +++ b/ventures/havre/app.js @@ -612,12 +612,15 @@ function talkTo(v) { } selectCard(c.id, { talk: true }); } -/** Venice says which of its characters are adult: their cards here say the same (one met before this was kept, too) */ +/** + * Venice's adult characters are adult here too. A safe-for-work one you marked adult stays adult: many are grown-ups + * whose chats only start out safe for work, so Venice's word is the least, not the most. + */ function syncAdult(list) { const changed = []; for (const v of list) { const c = S.cards.find((x) => x.engine.venice_character === v.slug); - if (c && c.adult !== !!v.adult) { c.adult = !!v.adult; changed.push(c.id); } + if (c && v.adult && !c.adult) { c.adult = true; changed.push(c.id); } } if (!changed.length) return; persistCards(); @@ -1045,8 +1048,8 @@ function saveMemory(id, m) { if (m && (m.notes.length || Object.keys(m.upTo).length)) all[id] = m; else delete all[id]; if (!local.set(K.memory, all)) full(); } -/** what goes into their prompt: nothing with memory switched off, or for a Venice character (Venice has their prompt) */ -const memoryFor = (c) => (S.ab.memory && c && !c.plain && !c.engine.venice_character ? memoryOf(c.id).notes : []); +/** what goes into their prompt: nothing with memory switched off; for a Venice character, beside Venice's own prompt */ +const memoryFor = (c) => (S.ab.memory && c && !c.plain ? memoryOf(c.id).notes : []); const remembering = new Set(), memoryMissed = new Map(); /** * After every dozen messages in a chat (or a few, when you leave it), its character writes down what they remember now: @@ -1055,7 +1058,7 @@ const remembering = new Set(), memoryMissed = new Map(); */ async function remember(t, enough = R.EVERY) { const c = cardOf(t); - if (!c || c.plain || c.engine.venice_character || !S.key || !S.ab.memory || remembering.has(c.id) || !S.threads[t.id]) return; + if (!c || c.plain || !S.key || !S.ab.memory || remembering.has(c.id) || !S.threads[t.id]) return; const before = memoryOf(c.id), fresh = R.unread(t.msgs, before.upTo[t.id]); if (fresh.length < Math.max(enough, memoryMissed.get(t.id) || 0)) return; remembering.add(c.id); @@ -2562,7 +2565,7 @@ function renderSheet() { } f.persona.disabled = f.greeting.disabled = !!c.engine.venice_character; f.adult.checked = c.adult; - f.adult.disabled = !!c.engine.venice_character; // a Venice character is adult when Venice says so + f.adult.disabled = false; // Venice's adult ones come in adult; any other can be marked so f.look_description.value = c.look.description; f.speech_mood.value = c.speech.mood; const sp = $("#speech-speed"); @@ -2586,10 +2589,8 @@ function renderSheet() { function renderMemory(c) { const ul = $("#memory-list"), how = $("#memory-how"); if (!c || c.plain) return; - const linked = !!c.engine.venice_character, { notes } = memoryOf(c.id); - how.textContent = linked - ? `${c.name} speaks through venice, with venice's own prompt, so havre can't give them a memory yet. a remix of them can have one.` - : (S.ab.memory + const linked = false, { notes } = memoryOf(c.id); + how.textContent = (S.ab.memory ? `every dozen messages, one short call on your key writes down what ${c.name} remembers of your chats. it goes into every chat with them, here in this browser, and never into their card.` : "memory is switched off under abilities: nothing goes into their chats, and nothing new is written down."); $("#memory-add").hidden = linked; @@ -2930,6 +2931,7 @@ function resetRemix() { $("#rx-draft").hidden = true; $("#rx-draft").textContent = ""; $("#rx-distill").disabled = !c; + $("#rx-port").hidden = $("#rx-port-box").hidden = !(c && c.engine.venice_character); rxStatus(S.key ? "" : "Remix needs your Venice key: it makes two short model calls (a few more for a Venice character)."); const r = $("#rx-rights"); const msgs = []; @@ -2940,7 +2942,7 @@ function resetRemix() { } function rxBusy(on) { S.rx.busy = on; - for (const b of ["#rx-distill", "#rx-write"]) $(b).disabled = on; + for (const b of ["#rx-distill", "#rx-write", "#rx-port"]) $(b).disabled = on; for (const b of $$("#rx-draft button")) b.disabled = on; } async function rxSource(c) { @@ -2987,6 +2989,39 @@ async function askJson(model, { messages, schema, maxTokens, temperature, what } throw new Error(`${modelName(model)} sent back ${what} Havre couldn't read, twice. Try again, or pick another model on the card.`); } } +// ---------- port: a Venice character, written down as a card of your own ---------- +/** + * Asks them the port questions through Venice, then one model call writes them down: the same name and face, in its own + * words, credited to their creator. The new card keeps what they remember of your chats; their Venice link stays too. + */ +async function port() { + const c = card(); + if (!c || !c.engine.venice_character || S.rx.busy) return; + if (!S.key) return needKey("porting needs your venice key."); + rxBusy(true); + try { + const v = new Venice(S.key), hist = []; + let transcript = ""; + for (let i = 0; i < C.PORT_INTERVIEW.length; i++) { + rxStatus(`Asking ${c.name} question ${i + 1} of ${C.PORT_INTERVIEW.length}…`); + hist.push({ role: "user", content: C.PORT_INTERVIEW[i] }); + const r = await v.chat({ model: c.model || S.model, messages: hist, characterSlug: c.engine.venice_character, maxTokens: 400 }); + const a = C.stripThinking(r.text); + hist.push({ role: "assistant", content: a }); + transcript += `Q: ${C.PORT_INTERVIEW[i]}\nA: ${a}\n\n`; + } + const writer = await jsonModel(c); + rxStatus(`Writing ${c.name} down with ${modelName(writer)}…`); + const o = await askJson(writer, { messages: C.portMessages(c.name, transcript), schema: C.PORT_SCHEMA, maxTokens: 2200, temperature: 0.6, what: "a card" }); + const ported = addCard(C.cardFromPort(o, { from: c, model: c.model || S.model, interviewed: today() }), { select: false }); + const mem = memoryOf(c.id); + if (mem.notes.length) saveMemory(ported.id, { notes: mem.notes, upTo: {}, at: Date.now() }); + rxStatus(""); + toast(`${ported.name} is ported: a card of your own now, credited to ${c.creator || "their creator"}.`); + selectCard(ported.id, { talk: false }); + openCard("edit"); + } catch (e) { rxStatus(e.message, true); } finally { rxBusy(false); } +} async function distill() { const c = card(); if (!c || c.plain || S.rx.busy) return; @@ -3359,7 +3394,7 @@ function bind() { if (e.key !== "Enter" || e.isComposing) return; e.preventDefault(); const c = card(), note = madd.value.trim(); - if (!c || c.plain || c.engine.venice_character || !note) return; + if (!c || c.plain || !note) return; const m = memoryOf(c.id); saveMemory(c.id, { ...m, notes: R.withNote(m.notes, note) }); madd.value = ""; @@ -3397,6 +3432,7 @@ function bind() { } $("#rx-distill").addEventListener("click", distill); + $("#rx-port").addEventListener("click", port); $("#rx-write").addEventListener("click", () => writeFresh()); for (const b of $$("[data-out]")) b.addEventListener("click", () => exportAs(b.dataset.out)); diff --git a/ventures/havre/card.js b/ventures/havre/card.js index 71acfe5..373313f 100644 --- a/ventures/havre/card.js +++ b/ventures/havre/card.js @@ -187,16 +187,20 @@ export function systemPrompt(card, { memory = [] } = {}) { parts.push("Your flow with someone new. Take one stage at a time, in order:\n" + card.flow.map((s, i) => `${i + 1}. ${s.stage.toUpperCase()}: ${fill(s.goal, card)}${s.next ? ` Move on when ${fill(s.next, card)}` : ""}`).join("\n")); } - const notes = arr(memory).map((n) => str(n, 300).trim()).filter(Boolean).slice(0, 24); - if (notes.length) { - parts.push("What you remember from your chats with them so far (keep to it, and bring it up only when it fits):\n" + - notes.map((n) => "- " + n).join("\n")); - } + const mem = memoryBlock(memory); + if (mem) parts.push(mem); if (card.adult) parts.push(ADULT_RULE); parts.push(identityRule(card), HOUSE_RULES); return parts.join("\n\n"); } +/** what they remember of your chats, as it goes into a prompt ("" when nothing) */ +export function memoryBlock(memory = []) { + const notes = arr(memory).map((n) => str(n, 300).trim()).filter(Boolean).slice(0, 24); + return notes.length ? "What you remember from your chats with them so far (keep to it, and bring it up only when it fits):\n" + + notes.map((n) => "- " + n).join("\n") : ""; +} + /** * The messages for one chat turn; history is [{role: "user"|"assistant", content}], the last 40 of it. For a character * of Havre's own (not a Venice character, who brings their own prompt), the last thing said ends with a reminder of @@ -204,7 +208,9 @@ export function systemPrompt(card, { memory = [] } = {}) { */ export function chatMessages(card, history, { memory = [] } = {}) { const own = !card.engine.venice_character; - const msgs = own ? [{ role: "system", content: systemPrompt(card, { memory }) }] : []; + // a Venice character brings its own prompt: beside it go only what they remember and, if they're adult, the 18+ rule + const extra = own ? "" : [memoryBlock(memory), card.adult ? ADULT_RULE : ""].filter(Boolean).join("\n\n"); + const msgs = own ? [{ role: "system", content: systemPrompt(card, { memory }) }] : extra ? [{ role: "system", content: extra }] : []; // the greeting starts the conversation, so it goes once the conversation's start is out of the window if (card.greeting && own && history.length <= 40) msgs.push({ role: "assistant", content: fill(card.greeting, card) }); for (const m of history.slice(-40)) if (m.role === "user" || m.role === "assistant") msgs.push({ role: m.role, content: str(m.content, 8000) }); @@ -556,6 +562,50 @@ export const INTERVIEW = [ "How do you talk when you're excited, and how when you're annoyed?", ]; +// ---------- port: the same character, kept as a card of your own ---------- + +/** what a Venice character is asked when you port them: enough to write them down as they are */ +export const PORT_INTERVIEW = [ + "Hi! Introduce yourself to me like we just met, in two or three short texts.", + "Tell me about yourself: where you're from, what you do, what matters to you.", + "Describe how you look, in detail: your age, face, hair, build, and what you usually wear.", + "How do you talk? Say a few things you'd typically say to someone.", + "Who are the important people in your life, and what's your story so far?", + "What would you never do, and what lights you up?", +]; +export const PORT_SYSTEM = "You write down a character as they are, from an interview with them, so their person can keep " + + "them as a card. Keep their name and who they are: the same character, not a new one. Write in your own words, in the " + + "second person for the persona; don't copy long passages from the interview. The character is an adult, 18 or older. " + + "Return one JSON object: tagline (at most 10 words), greeting (one to three short texts separated by a blank line, the " + + "way they'd say hello), persona (a system prompt in the second person, 150 to 300 words: who they are, their story, how " + + "they talk, what they want, what they won't do), voice (how they talk, as a few traits), look (one paragraph, at most 60 " + + "words: apparent age as an adult, face, hair, build, clothes), examples (three or four objects with user and character, " + + "the character's replies short, like texts, in their voice), tags (at most five lowercase words)."; +export const PORT_SCHEMA = { + type: "object", additionalProperties: false, required: ["tagline", "greeting", "persona", "voice", "look", "examples", "tags"], + properties: { tagline: { type: "string" }, greeting: { type: "string" }, persona: { type: "string" }, voice: { type: "string" }, + look: { type: "string" }, examples: { type: "array", items: { type: "object", additionalProperties: false, required: ["user", "character"], + properties: { user: { type: "string" }, character: { type: "string" } } } }, tags: { type: "array", items: { type: "string" } } }, +}; +export function portMessages(name, transcript) { + return [{ role: "system", content: PORT_SYSTEM }, + { role: "user", content: `The character: ${str(name, 120)}\n\nThe interview:\n\n${str(transcript, 30000)}\n\nWrite ${str(name, 120)} down as a card.` }]; +} +/** a ported character's card: the same name and face, their own card now, with the credit */ +export function cardFromPort(o, { from, model, interviewed = "" } = {}) { + let card = normalizeCard({ + name: from.name, tagline: o.tagline, greeting: o.greeting, persona: o.persona, examples: o.examples, tags: o.tags, + voice: { short_form: true, notes: o.voice }, look: { description: o.look, references: from.look ? from.look.references : [] }, + portrait: from.portrait, speech: from.speech, model, adult: from.adult === true, creator: from.creator, + license: "all-rights-reserved", lineage: from.lineage, + }); + const other = nameClash(card); + if (other) card = renamed(card, other, card.name); + return addLineage(card, { relation: "ported-from", name: from.name, creator: from.creator || "", + ref: from.engine && from.engine.venice_character ? "venice:" + from.engine.venice_character : "", + note: "ported in Havre: written down from an interview with them" + (interviewed ? ", " + interviewed : "") }); +} + export const DISTILL_SYSTEM = "You read a character and write down only the ideas behind it, never its words. " + "Return one JSON object with these keys: archetype (string, at most 8 words), premise (string, at most 20 words), " + "drives, fears, voice, quirks, relationships, boundaries, themes (arrays of short phrases, at most 5 each), and " + diff --git a/ventures/havre/index.html b/ventures/havre/index.html index 6fbe564..a7c5823 100644 --- a/ventures/havre/index.html +++ b/ventures/havre/index.html @@ -217,6 +217,10 @@ +
+

Or keep them: port a Venice character to a card of your own. Havre asks them a few questions through Venice and writes them down: the same name and face, their memory carried over, the credit kept. Theirs to keep in your harbor, not someone new.

+

+

diff --git a/ventures/havre/tests/port.test.mjs b/ventures/havre/tests/port.test.mjs new file mode 100644 index 0000000..1f6c04d --- /dev/null +++ b/ventures/havre/tests/port.test.mjs @@ -0,0 +1,37 @@ +// node --test tests/*.test.mjs: Venice characters remember, take the 18+ rule, and can be ported +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as C from "../card.js"; + +const sable = C.normalizeCard({ name: "Sable", engine: { venice_character: "sable-penrose" }, creator: "someone", portrait: "https://outerface.venice.ai/sable.png", + lineage: [{ relation: "ported-from", name: "Sable", creator: "someone", ref: "venice:sable-penrose" }] }); + +test("a Venice character brings its own prompt; what they remember and the 18+ rule go beside it", () => { + assert.deepEqual(C.chatMessages(sable, [{ role: "user", content: "hi" }]), [{ role: "user", content: "hi" }], "nothing to add, nothing added"); + const m = C.chatMessages(sable, [{ role: "user", content: "hi" }], { memory: ["They're called Princess."] }); + assert.equal(m[0].role, "system"); + assert.match(m[0].content, /What you remember[^\n]*\n- They're called Princess\./); + assert.ok(!m[0].content.includes("You are Sable"), "never their prompt: Venice has it"); + assert.equal(m.at(-1).content, "hi", "no reminder: their name is Venice's to keep"); + const grown = C.chatMessages({ ...sable, adult: true }, [{ role: "user", content: "hi" }]); + assert.equal(grown[0].content, C.ADULT_RULE); +}); + +test("a port keeps the same character, their face and their credit, in a card of your own", () => { + assert.equal(C.PORT_INTERVIEW.length, 6); + const [sys, user] = C.portMessages("Sable", "Q: Hi!\nA: Oh. Hello."); + assert.equal(sys.content, C.PORT_SYSTEM); + assert.match(user.content, /The character: Sable[\s\S]*Q: Hi!/); + const card = C.cardFromPort({ tagline: "Night-shift baker", greeting: "Oh. Hello.", persona: "You are Sable, who bakes at night.", voice: "quiet, dry", + look: "Thirties, flour on her sleeves.", examples: [{ user: "hi", character: "Oh. Hi." }], tags: ["cozy"] }, { from: sable, model: "m", interviewed: "2026-10-19" }); + assert.deepEqual(C.validateCard(card), []); + assert.equal(card.name, "Sable"); + assert.deepEqual(card.engine, {}, "a card of your own: Havre's prompt, not Venice's"); + assert.equal(card.portrait, sable.portrait); + assert.equal(card.license, "all-rights-reserved"); + assert.equal(card.creator, "someone"); + assert.ok(card.lineage.some((l) => l.relation === "ported-from" && l.ref === "venice:sable-penrose")); + assert.equal(card.look.description, "Thirties, flour on her sleeves."); + assert.equal(card.voice.notes, "quiet, dry"); + assert.equal(C.cardFromPort({ persona: "You are Sable." }, { from: { ...sable, adult: true }, model: "m" }).adult, true, "an adult one stays adult"); +}); diff --git a/ventures/havre/tests/smoke.mjs b/ventures/havre/tests/smoke.mjs index 58b71c0..07ffe63 100644 --- a/ventures/havre/tests/smoke.mjs +++ b/ventures/havre/tests/smoke.mjs @@ -830,10 +830,10 @@ async function main() { assert.equal(first.messages.at(-1).content, "Tell me about the ferry, part 1\n\n[You are Ines Corbel. Stay in character.]"); assert.equal(first.messages.filter((m) => m.content.includes("Stay in character.]")).length, 1, "only on the latest"); // what she remembers: a dozen messages in, her own model writes it down, and it rides in every chat with her - assert.equal(seen.memories.length, 0); + const mem0 = seen.memories.length; // Venice characters met earlier may have remembered already for (let i = 2; i <= 6; i++) await answered(page, async () => { await page.fill("#say", `Tell me about the ferry, part ${i}`); await page.keyboard.press("Enter"); }); - await poll(() => seen.memories.length === 1, "her memory written down"); - const mem = seen.memories[0]; + await poll(() => seen.memories.length === mem0 + 1, "her memory written down"); + const mem = seen.memories[mem0]; assert.equal(mem.model, "zai-org-glm-5-2", "written by a model that keeps to JSON (hers, Tiny, can't)"); assert.match(mem.messages[1].content, /^The character: Ines Corbel/); assert.match(mem.messages[1].content, /What they remember so far: nothing yet\./); @@ -857,8 +857,8 @@ async function main() { // leaving a chat with a few new messages, she writes down what she remembers of them, so a new chat picks up there await answered(page, async () => { await page.fill("#say", "And the gulls?"); await page.keyboard.press("Enter"); }); await page.locator("#who-meta").getByRole("button", { name: "new chat" }).click(); - await poll(() => seen.memories.length === 2, "what she remembers of the chat she left"); - const left2 = seen.memories[1].messages[1].content; + await poll(() => seen.memories.length === mem0 + 2, "what she remembers of the chat she left"); + const left2 = seen.memories[mem0 + 1].messages[1].content; assert.match(left2, /What they remember so far:\n- They're called Princess\.\n- I told them to ask again after the tide\./, "with what you gave her"); assert.deepEqual(left2.match(/^Them: .*$/gm), ["Them: Who are you, again?", "Them: And the gulls?"], "only what's new since"); // a new name goes all through her card