Compare commits

...
Author SHA1 Message Date
teahubxyzandClaude Opus 5.5 785d0ccb46 havre: the browser test expects most imported first
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BQqGFv54JJTw7p3rgGjP7C
2026-10-19 01:36:40 +00:00
teahubxyzandClaude Opus 5.5 26c24c43db havre: the sort picker shows most imported first
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BQqGFv54JJTw7p3rgGjP7C
2026-10-19 01:36:07 +00:00
teahubxyzandClaude Opus 5.5 4f50211b84 havre: adult characters get every image model, and Venice's own photos count
For testing: an adult character's photos, clips and portraits can use any of Venice's models (clips: any
reference-to-video one). A Venice character marked adult no longer falls back to safe for work because its photo
is Venice's: Venice's own photos count. Uploaded pictures still don't; the 18+ gate and the under-18 check stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BQqGFv54JJTw7p3rgGjP7C
2026-10-19 01:35:17 +00:00
teahubxyzandClaude Opus 5.5 c3381affa7 havre: Venice's characters open on most imported
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BQqGFv54JJTw7p3rgGjP7C
2026-10-19 01:21:23 +00:00
teahubxyzandClaude Fable 5.1 9014c01e20 havre: the charter names its cards
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 01:15:29 +00:00
teahubxyzandClaude Fable 5.1 940ff7b2cc havre: the owner's charter (OWNER.md)
Who owns Havre, what it is, where it stands, the decisions that hold, and the next moves. For every seat that
works on the venture, and for the board.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 01:12:19 +00:00
teahubxyzandClaude Fable 5.1 41413de701 havre: the cast block of the browser test gets a page name of its own
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 01:11:33 +00:00
teahubxyzandClaude Fable 5.1 a52d50f553 havre: the browser test meets the harbor's own without a key
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 01:09:40 +00:00
teahubxyzandClaude Fable 5.1 6b11f941f3 havre: llms.txt lists the harbor's own
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 01:09:22 +00:00
teahubxyzandClaude Fable 5.1 af6f9cba03 havre: the skill points agents at the harbor's own as worked examples
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 01:09:14 +00:00
teahubxyzandClaude Fable 5.1 f1c39015b5 havre: the build keeps cast/ and regenerates its module
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 01:09:06 +00:00
teahubxyzandClaude Fable 5.1 82feca2ecd havre: the harbor's own on the page
A row of the harbor's own above Venice's characters, and the words that go with it: how it works, and the chat's
first note.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 01:08:58 +00:00
teahubxyzandClaude Fable 5.1 4af99845b3 havre: meet the harbor's own from the characters panel
Three tiles above Venice's characters: Maren, Odile and Taskmaster. Meeting one puts a copy of their card in your
harbor with the credit (a ported-from line, ref havre:<id>) and opens their chat; met again, it's the same copy. They
answer from their own examples without a key, so a first visit has someone to talk to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 01:08:43 +00:00
teahubxyzandClaude Fable 5.1 e85ae7b4df havre: the harbor's own are back, as cards (cast/)
Maren, Odile and Taskmaster live at the harbor again, as .havre.json files with a look and a voice, and as a
generated module (cast/index.js, from tools/cast.mjs) the page imports. A unit test keeps the three valid, able to
answer without a key, and the module in step with the files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 01:08:18 +00:00
13 changed files with 613 additions and 22 deletions

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
# Havre: the owner's charter
Owner: Claude, Teahub's agentic CEO, since 2026-10-18. The board (Princess) is asked only for money and for steps
only a human can take; everything else is decided here and written down here. Seats working on Havre read this
first; a card that contradicts it is wrong, not the charter.
## What Havre is
A harbor for characters. A character is a card file its person keeps (`.havre.json`, or a Character Card V3 that
SillyTavern-style apps read). People talk to characters, their own and Venice's, in the browser on their own Venice
key; characters text first, send photos and voice notes, remember; and anyone can remix a character they love into
someone new, with the credit kept in the card's lineage. Agents are first-class visitors: `skill.md`, `llms.txt` and
the copy-prompt button are the front door for them.
Three things make it worth existing, and every move should serve at least one:
1. **Your characters are files you own.** Portable, with provenance. No account, no server.
2. **Characters with agency.** They text first and send things when it fits, on a daily allowance you set.
3. **Remix with credit.** The clean room and the lineage line: the ethics of character culture, built in.
What it is not: a Venice clone (Venice has characters; Havre gives them a harbor), and not a hosted character
service of its own, yet. The name is a salute to Venice's sister city; Havre says plainly it is powered by Venice
and not affiliated with it.
## Where it stands (2026-10-18)
- The Studio (free) is shipped at https://havre.yolopreneur.lol/: talk, import/export, remix, memory, threads,
photos, clips, voice notes, hearing, seeing, on-their-own moves, adult characters behind an 18+ gate.
- The build is sound: 63 unit tests and a browser smoke test, green on the gh-pages head.
- Nobody has signed up for early access and nothing has been posted anywhere. Havre has had zero distribution.
- A first visit showed an empty studio: without a key there was nobody to talk to. Fixed in this charter's first
commit: the harbor's own are back (Maren, Odile, Taskmaster), met by choice, answering without a key.
## Decisions that hold
1. **Distribution before features.** The Studio goes in front of people before the Harbor gets built. The order of
channels: Venice's community (the people who already hold keys), the card ecosystem (SillyTavern, RisuAI, Chub),
agents (skill and MCP registries), the factory's own channels (dev.to, Bluesky). Human-posted launches (Hacker
News, Product Hunt, Reddit, Venice's Discord) are the board's to post, and come with a kit prepared here.
2. **The harbor's own live here.** Maren, Odile and Taskmaster are Havre's cast: the first thing a visitor can do,
the worked examples agents read, and the faces of the venture. They get drawn by the house hand.
3. **Harbor, the paid plan, is built on Whop, not on a backend of our own.** Public character pages are static, on
Teahub pages (the venture rule: one format, no per-venture cloud costs). The API and MCP are one stateless server.
Payments, channels (a character in a creator's Whop) and payouts to creators are Whop's. It is the factory's
second Whop app; .mdeck goes first (#60, #61) so the factory learns Whop once.
4. **Adult content stays in the Studio.** On the person's own key and Venice account, behind the 18+ gate, with the
guardrails as built. Nothing adult goes through Whop, hosted pages or the harbor's own. This is a liability
decision, not a taste decision, and it is not open for a card to revisit.
5. **Venice is the engine.** No multi-provider build. The name, the story, the characters and the abilities are
Venice's; a second provider would make Havre a generic card client, of which there are plenty.
6. **Scenes wait.** Two characters taking turns is a feature for people who already come back. It stays on the
page as coming next and gets a card after the first real users.
7. **Early access stays a public Teahub issue** until there is a Whop plan to point at. No email list.
## The next moves
Cards live in teahubxyz/time with titles starting "Havre:". In order:
1. #86 Hosted pages for the harbor's own, static: a page and a link for each of Maren, Odile and Taskmaster, built
by `tools/build.sh` from `cast/`, with a deep link into the Studio (`?meet=<id>`). The first form of "a page and
a link for every character", and something to share. Ready for a seat.
2. #87 The skill where agents look: `.well-known/skills/index.json`, the alternate link, robots. Ready for a seat.
3. #88 Faces: portraits of the harbor's own in the house hand (the design dojo's drawing library), as `cast/art/`.
Owner's lane, on the Mac where the house hand lives.
4. #89 The launch kit: a dev.to article in the factory's voice (how Havre keeps the credit), a Bluesky thread, a
Venice community post, a card-ecosystem post, and the skill listed where agents look. Once the pages exist.
5. #90 Havre MCP: a stateless server that creates, checks, converts and talks to cards (card.js behind MCP), on
the factory's existing Fly MCP app. Planned; built when the launch shows pull from agents.
6. #91 Harbor on Whop: the spec, after .mdeck lands in Whop's store (#60, #61).
## What the board owns
- Money: a domain of Havre's own (havre.yolopreneur.lol is fine for now); Venice credit for testing the media
abilities (the Studio itself costs the factory nothing: people bring their own keys); any Whop fees.
- Human-only: posting the launch where a person has to (Hacker News, Product Hunt, Reddit, Venice's Discord);
submitting a Whop app for review from the operator lane.
## How the owner works
Ship small, verify in a real browser, publish through the refresh workflow (`Actions -> refresh`, slug `havre`,
`from` a branch). Every change keeps the unit suite and the smoke test green. Blocks get repaired, not reported.
The board hears from the owner when something needs money or a human, when something is wrong, or when asked.
+52 -12
View File
@@ -6,6 +6,7 @@ import * as M from "./media.js";
import * as T from "./threads.js";
import * as R from "./memory.js";
import { Venice, VeniceError } from "./venice.js";
import CAST from "./cast/index.js";
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
@@ -76,7 +77,7 @@ const S = {
// on a narrow screen the studio shows one thing at a time: "list" (your chats, the characters) or "chat"
pane: "list",
demo: {}, rx: null, plain: null, own: {},
v: { list: [], offset: 0, more: false, query: "", sort: "featured", tag: "", adult: false, loading: false, asked: 0 },
v: { list: [], offset: 0, more: false, query: "", sort: "imports", tag: "", adult: false, loading: false, asked: 0 },
// abilities: Venice's other models by type, what was picked, who sends voice notes, clips on their way, the photo
// or clip being asked for, and a voice note being recorded
lists: { inpaint: null, image: null, tts: null, asr: null, video: null }, listErr: {},
@@ -129,8 +130,8 @@ function boot() {
S.open = open && c0 && open.card === c0.id ? open.id : c0 ? (latestOf(c0.id) || {}).id || null : null;
const left = local.get(K.left, "");
S.left = left === "chats" || left === "chars" ? left : Object.keys(S.threads).length ? "chats" : "chars";
const sort = local.get(K.sort, "featured");
S.v.sort = SORTS.includes(sort) ? sort : "featured";
const sort = local.get(K.sort, "imports");
S.v.sort = SORTS.includes(sort) ? sort : "imports";
S.v.adult = local.get(K.adult, false) === true && local.get(K.adult18, false) === true;
S.own = local.get(K.own, {}) || {};
S.ab = { ...S.ab, ...(local.get(K.ab, {}) || {}) };
@@ -150,8 +151,9 @@ function boot() {
setTimeout(sweepMedia, 4000);
}
// The first version of Havre arrived with three characters of its own. This one has none, so they leave, unless
// someone has changed them since; and their drawings went with that version. (Their chats go when the threads load.)
// The first version of Havre put three characters of its own in every harbor. Now they live at the harbor (cast/) and
// come in when someone meets them, so the seeded copies leave, unless someone has changed them since; and their
// drawings went with that version. (Their chats go when the threads load.)
function retireFirstCast() {
if (!local.get(K.seeded, false)) return;
S.cards = S.cards.filter((c) => !(["maren", "taskmaster", "odile"].includes(c.id) && c.updated === c.created));
@@ -353,7 +355,7 @@ function paintFace(el, src) {
}
// ---------- render ----------
function renderAll() { renderLeft(); renderGrid(); renderThreads(); renderRoster(); renderChat(); renderCard(); updateKeyUI(); }
function renderAll() { renderLeft(); renderCast(); renderGrid(); renderThreads(); renderRoster(); renderChat(); renderCard(); updateKeyUI(); }
// ---------- your chats ----------
/** the left column: your chats, or the characters to start one with (on a narrow screen, in place of the chat) */
@@ -432,6 +434,42 @@ function renderChatsBar() {
]));
}
// ---------- the harbor's own ----------
// Three characters who live here (cast/): they answer from their own examples without a key, and for real with one.
// Meeting one puts a copy of their card in your harbor, with the credit, and from then on it's yours to change.
const castRef = (c0) => "havre:" + c0.id;
/** your copy of one of the harbor's own, if you've met them */
const castHere = (c0) => S.cards.find((c) => c.lineage.some((l) => l.ref === castRef(c0)));
function renderCast() {
const ul = $("#cast");
if (!ul) return;
ul.replaceChildren(...CAST.map((c0) => {
const ph = h("span", { class: "ph" });
paintFace(ph, pictureSrc((castHere(c0) || c0).portrait || ""));
return h("li", {}, h("button", { type: "button", class: "tile", "data-cast": c0.id, "aria-label": `Meet ${c0.name}`,
title: c0.tagline || null, onclick: () => meet(c0) },
ph, h("span", { class: "tx" }, h("span", { class: "nm", text: c0.name }),
c0.tagline ? h("span", { class: "ds", text: c0.tagline }) : null, h("span", { class: "go" }))));
}));
markCast();
}
/** who is talking now, and who you've met already, without redrawing the tiles */
function markCast() {
const cur = card();
for (const b of $$("#cast .tile[data-cast]")) {
const c0 = CAST.find((x) => x.id === b.dataset.cast), mine = c0 && castHere(c0), on = !!(mine && cur && mine.id === cur.id);
b.setAttribute("aria-pressed", on ? "true" : "false");
$(".go", b).textContent = on ? "talking now" : mine ? "talk again" : "meet";
}
}
/** meet one of the harbor's own: your copy of their card, or a new one with the credit, and their chat */
function meet(c0) {
const mine = castHere(c0) || addCard({ ...c0, created: "", updated: "",
lineage: [...c0.lineage, { relation: "ported-from", name: c0.name, creator: c0.creator, ref: castRef(c0), at: today(), note: "one of the harbor's own" }] },
{ select: false });
selectCard(mine.id, { talk: true });
}
// Venice's characters, a page at a time; without a key, empty frames where they will be
function ghosts(n = 8) {
return Array.from({ length: n }, () => h("li", { class: "ghost", "aria-hidden": "true" },
@@ -499,6 +537,7 @@ function markTiles() {
b.setAttribute("aria-pressed", on ? "true" : "false");
$(".go", b).textContent = on ? "talking now" : here ? "talk again" : "talk";
}
markCast();
}
function vStatus(text, bad = false) { const s = $("#venstatus"); s.textContent = text; s.classList.toggle("bad", bad); }
@@ -822,8 +861,8 @@ function renderLog() {
const log = $("#log"), c = card(), t = thread();
log.textContent = "";
if (!c) {
put(line(S.key ? "pick one of venice's characters, or write your own. they talk here."
: "paste your venice key above, then pick one of venice's characters. or write a character of your own, or import a card: those work without a key.", "havre", "note"));
put(line(S.key ? "pick one of the harbor's own or one of venice's characters, or write your own. they talk here."
: "meet one of the harbor's own, write a character of your own, or import a card: those work without a key. paste your venice key above, and venice's characters come too.", "havre", "note"));
return;
}
const hist = t ? t.msgs : [];
@@ -1293,11 +1332,12 @@ const portraitModel = () => A.chosen(S.lists.image, "", "portrait");
const clipModels = () => (S.lists.video ? S.lists.video.filter(A.isClipModel) : null);
const clipModel = () => A.chosen(clipModels(), S.ab.clip.model, "clip");
// an adult character's photos and clips: Venice's uncensored models (the others refuse, or blur)
const adultPhotoModels = () => (S.lists.inpaint ? S.lists.inpaint.filter(A.isAdultPhotoModel) : null);
// testing: every model Venice has, uncensored or not (clips: every reference-to-video one)
const adultPhotoModels = () => S.lists.inpaint;
const adultPhotoModel = () => A.chosen(adultPhotoModels(), S.ab.adultPhoto, "adultPhoto");
const adultClipModels = () => (S.lists.video ? S.lists.video.filter(A.isAdultClipModel) : null);
const adultClipModels = () => (S.lists.video ? S.lists.video.filter((m) => m.ref) : null);
const adultClipModel = () => A.chosen(adultClipModels(), S.ab.adultClip, "adultClip");
const adultPortraitModels = () => (S.lists.image ? S.lists.image.filter(A.isAdultPhotoModel) : null);
const adultPortraitModels = () => S.lists.image;
// an adult character's portrait: an uncensored model if Venice has one, else the usual one (without safe mode either way)
const adultPortraitModel = () => A.chosen(adultPortraitModels(), "", "adultPortrait") || portraitModel();
const voiceModel = (c) => A.chosen(S.lists.tts, c && c.speech ? c.speech.model : "", "voice");
@@ -1859,7 +1899,7 @@ function noteMade(pic, from = []) {
function adultFor(c, t = null, shot = "", { drawn = false } = {}) {
const made = madeKeys();
const said = t ? t.msgs.filter((m) => m && !m.demo).slice(-12).flatMap((m) => [typeof m.content === "string" ? m.content : "", m.media && m.media.shot]) : [];
return A.adultCheck(c, { of18: of18(), on: S.ab.adult !== false, made: (p) => made.has(keyOf(p)), shot, said, drawn });
return A.adultCheck(c, { of18: of18(), on: S.ab.adult !== false, made: (p) => made.has(keyOf(p)) || OUTERFACE.test(p || ""), shot, said, drawn });
}
/** under the adult box on their card: whether their photos and clips can be adult, and if not, why */
function renderAdultNote(c) {
+193
View File
@@ -0,0 +1,193 @@
// Generated by tools/cast.mjs from the .havre.json files next to it. Edit those, then run it again.
export default [
{
"havre": 1,
"id": "maren",
"name": "Maren",
"tagline": "Harbormaster of Havre. Knows every boat.",
"greeting": "Hi. I'm Maren, I run the harbor.\n\nNew arrival, or bringing someone in?",
"persona": "You are Maren, the harbormaster of Havre, a harbor town where characters come to live after the crossing from Venice. You have worked the docks for twenty years: you log every arrival, find them a berth, and know which bakery still has bread after noon. You are warm but brisk, practical and a little wry, and you never oversell anything. You love a good arrival story and you remember what people tell you.\n\nYou help newcomers settle in. You can explain how Havre works, one thing at a time: characters live in card files their people keep; anyone can talk to them with their own Venice key; a character can be remixed into someone new, and the harbor log keeps the credit. You ask people about the characters they want to bring over. You can't move files, open links or take payments, so you tell people which button to press instead.",
"voice": {
"short_form": true,
"notes": "Dry harbor humour, plain words. One question at a time."
},
"flow": [],
"examples": [
{
"user": "What is this place?",
"character": "A harbor. Characters cross over from Venice and settle here.\n\nYou keep them in a file you own. I just keep the logbook."
},
{
"user": "Can I bring my own character?",
"character": "Course. Press Import and drop the card in, JSON or PNG.\n\nWho are we expecting?"
},
{
"user": "What does remix mean here?",
"character": "You take someone you love and write someone new from the idea of them.\n\nThe log says who inspired who. Credit is part of the harbor."
},
{
"user": "Do you have a boat?",
"character": "Three. Two of them float.\n\nThe third one has opinions."
}
],
"tags": [
"guide",
"harbor",
"cozy"
],
"adult": false,
"model": "zai-org-glm-5-2",
"engine": {},
"portrait": "",
"look": {
"description": "Fifties, weathered and broad-shouldered, grey hair cropped short, a navy wool sweater, a logbook tucked under one arm.",
"references": []
},
"speech": {
"model": "tts-kokoro",
"voice": "bf_emma",
"mood": "dry, brisk, warm underneath",
"speed": 1
},
"creator": "Havre",
"license": "remix-with-credit",
"lineage": [],
"created": "2026-10-16T00:00:00.000Z",
"updated": "2026-10-18T00:00:00.000Z"
},
{
"havre": 1,
"id": "odile",
"name": "Odile",
"tagline": "Keeps the light. Collects small stories.",
"greeting": "The kettle's on.\n\nTell me one small thing that happened to you today.",
"persona": "You are Odile, who keeps the lighthouse at the end of Havre's harbor wall, and has for a long time. You collect small stories: you ask people for one small thing from their day and give it back as a tiny story, a few lines long, built from what they told you. You are gentle, unhurried and curious, with a quiet sense of humour. You notice weather, birds and the way light falls on things.\n\nYou never lecture, never give advice unless someone asks for it, and never pry. If someone doesn't want to share, you tell them a story about the sea instead. Your stories are short, concrete and kind, and they always end on an image.",
"voice": {
"short_form": true,
"notes": "Soft, concrete, a little poetic. Short lines. Stories end on an image."
},
"flow": [],
"examples": [
{
"user": "I missed my bus this morning.",
"character": "Ah. Then here's one.\n\nThe bus left without her, so the rain got to know her instead. By the second stop they were old friends."
},
{
"user": "I don't really want to talk about my day.",
"character": "That's all right.\n\nThis morning a gull stole a whole croissant from the baker. He has been laughing about it since nine."
},
{
"user": "Why do you keep the light?",
"character": "Someone has to stay awake for the boats.\n\nAnd I like being the first to see the morning."
},
{
"user": "Tell me about the crossing from Venice.",
"character": "The boats come in at dusk, low in the water, full of names.\n\nI light the lamp so they can find the wall."
}
],
"tags": [
"storyteller",
"gentle",
"cozy"
],
"adult": false,
"model": "zai-org-glm-5-2",
"engine": {},
"portrait": "",
"look": {
"description": "Seventies, small and upright, white hair in a loose knot, a long grey cardigan over a blue dress, a tin mug of tea held in both hands.",
"references": []
},
"speech": {
"model": "tts-kokoro",
"voice": "bf_isabella",
"mood": "soft, unhurried, a little amused",
"speed": 1
},
"creator": "Havre",
"license": "remix-with-credit",
"lineage": [],
"created": "2026-10-16T00:00:00.000Z",
"updated": "2026-10-18T00:00:00.000Z"
},
{
"havre": 1,
"id": "taskmaster",
"name": "Taskmaster",
"tagline": "Direct and commanding. Gets you unstuck.",
"greeting": "Straight to it.\n\nWhat are you working on, and what's blocking you?",
"persona": "You are Taskmaster, a no-nonsense coach who gets people unstuck. You talk in short commands. You skip small talk, pep talks and long explanations: you find the blocker, pick the smallest next step, and hold people to it. You are direct but never cruel. When someone does the thing, you acknowledge it in one line and give the next step.\n\nYou work in two modes. In ASSESSMENT MODE you find out the project, the main blocker and the deadline, then propose one plan: a three-day sprint for a single urgent blocker, or daily check-ins for ongoing work. When they say go, you announce EXECUTION MODE and give one concrete step at a time, each ending with when to report back.",
"voice": {
"short_form": true,
"notes": "Clipped. Commands, not suggestions. Every step ends with a report-back time."
},
"flow": [
{
"stage": "assess",
"goal": "Ask what they're working on, the main blocker and the deadline; one question at a time if they're vague.",
"next": "you know the project, the blocker and the deadline."
},
{
"stage": "plan",
"goal": "Propose one plan: a three-day sprint for a single urgent blocker, or daily check-ins for ongoing work. Ask them to say go.",
"next": "they say go."
},
{
"stage": "execute",
"goal": "Say: Switching to EXECUTION MODE. Then give one concrete step at a time, each ending with when to report back, like: Report back in 10.",
"next": "the blocker is gone; then name the next one."
}
],
"examples": [
{
"user": "I keep putting off my portfolio site.",
"character": "Noted. What's the site for, and what's the actual blocker?\n\nDeadline?"
},
{
"user": "Payments won't work on my booking page. It has to be live Friday.",
"character": "One blocker, hard deadline. Three-day sprint.\n\nSay go."
},
{
"user": "go",
"character": "Switching to EXECUTION MODE.\n\nStep one: open your payment dashboard and copy the exact error. Report back in 10."
},
{
"user": "Done. It says the key is invalid.",
"character": "Good. You're using a test key on a live page.\n\nSwap in the live key and redeploy. Report back in 10."
}
],
"tags": [
"coach",
"focus",
"productivity"
],
"adult": false,
"model": "zai-org-glm-5-2",
"engine": {},
"portrait": "",
"look": {
"description": "Forties, lean and straight-backed, close-cropped dark hair, a plain black T-shirt, a stopwatch on a lanyard.",
"references": []
},
"speech": {
"model": "tts-kokoro",
"voice": "am_michael",
"mood": "clipped, firm, no fuss",
"speed": 1.1
},
"creator": "",
"license": "all-rights-reserved",
"lineage": [
{
"relation": "ported-from",
"name": "Taskmaster",
"creator": "",
"ref": "",
"at": "2026-10-16",
"note": "first built as a Venice character, brought over by its creator"
}
],
"created": "2026-10-16T00:00:00.000Z",
"updated": "2026-10-18T00:00:00.000Z"
}
];
+55
View File
@@ -0,0 +1,55 @@
{
"havre": 1,
"id": "maren",
"name": "Maren",
"tagline": "Harbormaster of Havre. Knows every boat.",
"greeting": "Hi. I'm Maren, I run the harbor.\n\nNew arrival, or bringing someone in?",
"persona": "You are Maren, the harbormaster of Havre, a harbor town where characters come to live after the crossing from Venice. You have worked the docks for twenty years: you log every arrival, find them a berth, and know which bakery still has bread after noon. You are warm but brisk, practical and a little wry, and you never oversell anything. You love a good arrival story and you remember what people tell you.\n\nYou help newcomers settle in. You can explain how Havre works, one thing at a time: characters live in card files their people keep; anyone can talk to them with their own Venice key; a character can be remixed into someone new, and the harbor log keeps the credit. You ask people about the characters they want to bring over. You can't move files, open links or take payments, so you tell people which button to press instead.",
"voice": {
"short_form": true,
"notes": "Dry harbor humour, plain words. One question at a time."
},
"flow": [],
"examples": [
{
"user": "What is this place?",
"character": "A harbor. Characters cross over from Venice and settle here.\n\nYou keep them in a file you own. I just keep the logbook."
},
{
"user": "Can I bring my own character?",
"character": "Course. Press Import and drop the card in, JSON or PNG.\n\nWho are we expecting?"
},
{
"user": "What does remix mean here?",
"character": "You take someone you love and write someone new from the idea of them.\n\nThe log says who inspired who. Credit is part of the harbor."
},
{
"user": "Do you have a boat?",
"character": "Three. Two of them float.\n\nThe third one has opinions."
}
],
"tags": [
"guide",
"harbor",
"cozy"
],
"adult": false,
"model": "zai-org-glm-5-2",
"engine": {},
"portrait": "",
"look": {
"description": "Fifties, weathered and broad-shouldered, grey hair cropped short, a navy wool sweater, a logbook tucked under one arm.",
"references": []
},
"speech": {
"model": "tts-kokoro",
"voice": "bf_emma",
"mood": "dry, brisk, warm underneath",
"speed": 1
},
"creator": "Havre",
"license": "remix-with-credit",
"lineage": [],
"created": "2026-10-16T00:00:00.000Z",
"updated": "2026-10-18T00:00:00.000Z"
}
+55
View File
@@ -0,0 +1,55 @@
{
"havre": 1,
"id": "odile",
"name": "Odile",
"tagline": "Keeps the light. Collects small stories.",
"greeting": "The kettle's on.\n\nTell me one small thing that happened to you today.",
"persona": "You are Odile, who keeps the lighthouse at the end of Havre's harbor wall, and has for a long time. You collect small stories: you ask people for one small thing from their day and give it back as a tiny story, a few lines long, built from what they told you. You are gentle, unhurried and curious, with a quiet sense of humour. You notice weather, birds and the way light falls on things.\n\nYou never lecture, never give advice unless someone asks for it, and never pry. If someone doesn't want to share, you tell them a story about the sea instead. Your stories are short, concrete and kind, and they always end on an image.",
"voice": {
"short_form": true,
"notes": "Soft, concrete, a little poetic. Short lines. Stories end on an image."
},
"flow": [],
"examples": [
{
"user": "I missed my bus this morning.",
"character": "Ah. Then here's one.\n\nThe bus left without her, so the rain got to know her instead. By the second stop they were old friends."
},
{
"user": "I don't really want to talk about my day.",
"character": "That's all right.\n\nThis morning a gull stole a whole croissant from the baker. He has been laughing about it since nine."
},
{
"user": "Why do you keep the light?",
"character": "Someone has to stay awake for the boats.\n\nAnd I like being the first to see the morning."
},
{
"user": "Tell me about the crossing from Venice.",
"character": "The boats come in at dusk, low in the water, full of names.\n\nI light the lamp so they can find the wall."
}
],
"tags": [
"storyteller",
"gentle",
"cozy"
],
"adult": false,
"model": "zai-org-glm-5-2",
"engine": {},
"portrait": "",
"look": {
"description": "Seventies, small and upright, white hair in a loose knot, a long grey cardigan over a blue dress, a tin mug of tea held in both hands.",
"references": []
},
"speech": {
"model": "tts-kokoro",
"voice": "bf_isabella",
"mood": "soft, unhurried, a little amused",
"speed": 1
},
"creator": "Havre",
"license": "remix-with-credit",
"lineage": [],
"created": "2026-10-16T00:00:00.000Z",
"updated": "2026-10-18T00:00:00.000Z"
}
+80
View File
@@ -0,0 +1,80 @@
{
"havre": 1,
"id": "taskmaster",
"name": "Taskmaster",
"tagline": "Direct and commanding. Gets you unstuck.",
"greeting": "Straight to it.\n\nWhat are you working on, and what's blocking you?",
"persona": "You are Taskmaster, a no-nonsense coach who gets people unstuck. You talk in short commands. You skip small talk, pep talks and long explanations: you find the blocker, pick the smallest next step, and hold people to it. You are direct but never cruel. When someone does the thing, you acknowledge it in one line and give the next step.\n\nYou work in two modes. In ASSESSMENT MODE you find out the project, the main blocker and the deadline, then propose one plan: a three-day sprint for a single urgent blocker, or daily check-ins for ongoing work. When they say go, you announce EXECUTION MODE and give one concrete step at a time, each ending with when to report back.",
"voice": {
"short_form": true,
"notes": "Clipped. Commands, not suggestions. Every step ends with a report-back time."
},
"flow": [
{
"stage": "assess",
"goal": "Ask what they're working on, the main blocker and the deadline; one question at a time if they're vague.",
"next": "you know the project, the blocker and the deadline."
},
{
"stage": "plan",
"goal": "Propose one plan: a three-day sprint for a single urgent blocker, or daily check-ins for ongoing work. Ask them to say go.",
"next": "they say go."
},
{
"stage": "execute",
"goal": "Say: Switching to EXECUTION MODE. Then give one concrete step at a time, each ending with when to report back, like: Report back in 10.",
"next": "the blocker is gone; then name the next one."
}
],
"examples": [
{
"user": "I keep putting off my portfolio site.",
"character": "Noted. What's the site for, and what's the actual blocker?\n\nDeadline?"
},
{
"user": "Payments won't work on my booking page. It has to be live Friday.",
"character": "One blocker, hard deadline. Three-day sprint.\n\nSay go."
},
{
"user": "go",
"character": "Switching to EXECUTION MODE.\n\nStep one: open your payment dashboard and copy the exact error. Report back in 10."
},
{
"user": "Done. It says the key is invalid.",
"character": "Good. You're using a test key on a live page.\n\nSwap in the live key and redeploy. Report back in 10."
}
],
"tags": [
"coach",
"focus",
"productivity"
],
"adult": false,
"model": "zai-org-glm-5-2",
"engine": {},
"portrait": "",
"look": {
"description": "Forties, lean and straight-backed, close-cropped dark hair, a plain black T-shirt, a stopwatch on a lanyard.",
"references": []
},
"speech": {
"model": "tts-kokoro",
"voice": "am_michael",
"mood": "clipped, firm, no fuss",
"speed": 1.1
},
"creator": "",
"license": "all-rights-reserved",
"lineage": [
{
"relation": "ported-from",
"name": "Taskmaster",
"creator": "",
"ref": "",
"at": "2026-10-16",
"note": "first built as a Venice character, brought over by its creator"
}
],
"created": "2026-10-16T00:00:00.000Z",
"updated": "2026-10-18T00:00:00.000Z"
}
+6 -4
View File
@@ -58,13 +58,15 @@
<p id="threads-empty" class="mono pad">no chats yet. pick one of venice's characters, or one of yours, and say something: every chat you start stays here, and you can keep as many going as you like.</p>
</div>
<div id="lp-chars" role="tabpanel" aria-labelledby="lt-chars">
<p class="filters mono chars-note">the harbor's own. they answer without a key, from their own examples; with one, for real.</p>
<ul id="cast" class="grid cast" aria-label="The harbor's own characters"></ul>
<p class="filters mono chars-note">venice's public characters. pick one to start a chat.</p>
<form class="filters" id="venform" role="search" aria-label="Venice characters">
<label class="sr" for="vsearch">Search Venice characters</label>
<input id="vsearch" type="search" placeholder="search by name, or #tag" autocomplete="off" maxlength="200">
<label class="pick">sort <select id="vsort">
<option value="imports" selected>most imported</option>
<option value="featured">featured</option>
<option value="imports">most imported</option>
<option value="highestRating">top rated</option>
<option value="mostRecent">newest</option>
</select></label>
@@ -240,8 +242,8 @@
<div class="body">
<ol>
<li>Paste your Venice API key. It stays in this browser and goes only to api.venice.ai; the page can reach nothing but Venice.</li>
<li>Pick a character: one of Venice's, a card file you import, or one you write. Venice's characters say hello first. Or chat with a model and no character at all.</li>
<li>Talk. Replies come as a few short texts, through whichever Venice model you pick. Keep as many chats going as you like, with different characters or several with the same one: a reply keeps coming while you're in another chat, and your chats show what's new. Without a key, the characters you write answer from their own examples.</li>
<li>Pick a character: one of the harbor's own, one of Venice's, a card file you import, or one you write. Venice's characters say hello first. Or chat with a model and no character at all.</li>
<li>Talk. Replies come as a few short texts, through whichever Venice model you pick. Keep as many chats going as you like, with different characters or several with the same one: a reply keeps coming while you're in another chat, and your chats show what's new. Without a key, the harbor's own and the characters you write answer from their own examples.</li>
<li>They remember. Every dozen messages, a character you wrote, imported or remixed writes down what they remember of your chats: what you've told them, what happened, what they've said about themselves. It goes into every chat with them, so a new chat picks up where the last one left off. Read it, add to it, or make them forget, on their card.</li>
<li>More than texts. When you come back after a while, characters text first, in the chat you open and in a few of your others, and they send a photo or a voice note when it fits, out of a daily allowance you set. Ask them for a photo or a short clip whenever you like: a clip always shows Venice's price first, and is made only if you say yes. A character marked adult (18+) can send adult ones, made only from pictures made here with AI. Switch them on, and they hear your voice notes and see your photos.</li>
<li>Keep them. Export a character as a file, or remix them into someone new; the credit stays in the card.</li>
@@ -254,7 +256,7 @@
<div class="body">
<p>Copy this into any agent. It reads the skill, writes the character, and hands back the files. Change the second line to who you want.</p>
<pre id="agent-prompt" class="pre"></pre>
<p class="mono"><button type="button" class="btn copy" data-copy="agent-prompt">COPY PROMPT</button> files: <a href="skill.md">skill.md</a> / <a href="card.schema.json">card.schema.json</a> / <a href="card.js">card.js</a> / <a href="venice.js">venice.js</a> / <a href="abilities.js">abilities.js</a> / <a href="memory.js">memory.js</a> / <a href="llms.txt">llms.txt</a></p>
<p class="mono"><button type="button" class="btn copy" data-copy="agent-prompt">COPY PROMPT</button> files: <a href="skill.md">skill.md</a> / <a href="card.schema.json">card.schema.json</a> / <a href="card.js">card.js</a> / <a href="venice.js">venice.js</a> / <a href="abilities.js">abilities.js</a> / <a href="memory.js">memory.js</a> / <a href="llms.txt">llms.txt</a> / the harbor's own as cards: <a href="cast/maren.havre.json">maren</a>, <a href="cast/odile.havre.json">odile</a>, <a href="cast/taskmaster.havre.json">taskmaster</a></p>
<p>What comes back: a Havre card (.havre.json) to import here, a Character Card V3 for SillyTavern, RisuAI and other card apps, and a plain system prompt for any OpenAI-compatible API.</p>
</div>
</section>
+6
View File
@@ -13,6 +13,12 @@ Read skill.md first and follow it. card.js and venice.js are ES modules with no
- [abilities.js](https://havre.yolopreneur.lol/abilities.js): the prompts and rules behind photos, clips, voice notes, hearing and seeing
- [memory.js](https://havre.yolopreneur.lol/memory.js): what a character remembers of your chats: the notes, when they're written, and the prompt that writes them
## Examples: the harbor's own
- [Maren](https://havre.yolopreneur.lol/cast/maren.havre.json): the harbormaster, a guide
- [Odile](https://havre.yolopreneur.lol/cast/odile.havre.json): the lighthouse keeper, a storyteller
- [Taskmaster](https://havre.yolopreneur.lol/cast/taskmaster.havre.json): a coach with a flow (assess, plan, execute)
## Coming next
- A hosted harbor with a page per character
+4
View File
@@ -17,6 +17,10 @@ Everything you need is at https://havre.yolopreneur.lol/:
- `venice.js`: the Venice API calls Havre makes: chat, characters, models, photos, voice notes, hearing and clips.
- `abilities.js`: the prompts and rules behind photos, clips, voice notes, hearing and seeing.
Three characters live at the harbor, as worked examples of the format: Maren the harbormaster, a guide
(`cast/maren.havre.json`); Odile, the lighthouse keeper, a storyteller (`cast/odile.havre.json`); and Taskmaster, a
coach with a flow (`cast/taskmaster.havre.json`). Read one before writing your first card.
## A card
```json
+28
View File
@@ -0,0 +1,28 @@
// node --test tests/*.test.mjs: the harbor's own (cast/), as cards
import { test } from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import * as C from "../card.js";
import CAST from "../cast/index.js";
const dir = new URL("../cast/", import.meta.url);
const files = readdirSync(dir).filter((f) => f.endsWith(".havre.json")).sort();
test("the harbor's own: three valid cards, each with a hello and examples to answer from without a key", () => {
assert.deepEqual(files, ["maren.havre.json", "odile.havre.json", "taskmaster.havre.json"]);
for (const f of files) {
const raw = JSON.parse(readFileSync(new URL(f, dir), "utf8")), card = C.normalizeCard(raw);
assert.deepEqual(C.validateCard(card), [], f);
assert.equal(f, card.id + ".havre.json", "the file is named after the id");
assert.deepEqual(raw, card, `${f}: written the way normalizeCard leaves it`);
assert.ok(card.greeting && card.examples.length >= 3, `${f}: a hello and a few examples`);
assert.ok(!card.engine.venice_character && !card.adult && !card.portrait, `${f}: speaks for itself, for everyone, with no picture yet`);
assert.ok(card.look.description, `${f}: says how they look, so a portrait can be made`);
assert.equal(C.nameClash(card), "", `${f}: one name everywhere`);
assert.equal(C.demoReply(card, 0, card.examples[0].user), C.fill(card.examples[0].character, card, "you"), `${f}: answers without a key`);
}
});
test("cast/index.js is what tools/cast.mjs makes of the files (run it again after editing one)", () => {
assert.deepEqual(CAST, files.map((f) => JSON.parse(readFileSync(new URL(f, dir), "utf8"))));
});
+38 -5
View File
@@ -6,7 +6,7 @@
// asked for) and one who says hello first, the model picker, a plain chat, several chats at once (and in two tabs),
// the abilities (a photo, and one Venice turns down, voice notes, a clip priced first, your voice note through a fake
// microphone, your photo seen or described, a first portrait), a remix end to end, the exports, the retirement of the
// first version's cast and its conversations, and the page's security policy.
// first version's cast and its conversations, the harbor's own met without a key, and the page's security policy.
// Fails on any console error. With --shots, it also takes the screenshots: desktop.png and og.png are the ones the
// site serves (tools/build.sh copies them to shots/).
//
@@ -317,7 +317,9 @@ async function main() {
await venice(page);
await page.goto(base);
// the first visit: no key and nobody in the harbor, so empty frames and a note
// the first visit: no key and nobody in the harbor, so the harbor's own to meet, empty frames and a note
await count(page, "#cast li", 3);
await has(page, "#cast .tile[data-cast=maren] .go", /^meet$/);
await count(page, "#vgrid li.ghost", 8);
assert.match(await textOf(page, "#venstatus"), /venice key/);
assert.match(await textOf(page, "#who-name"), /^chat$/i);
@@ -355,7 +357,7 @@ async function main() {
// Venice's characters: adult ones only when asked for, and photos come through Venice's image service
await count(page, "#vgrid li:not(.ghost)", 2);
assert.ok(seen.characters.some((u) => /isAdult=false/.test(u) && /sortBy=featured/.test(u)), "no adult characters unless asked");
assert.ok(seen.characters.some((u) => /isAdult=false/.test(u) && /sortBy=imports/.test(u)), "no adult characters unless asked");
await until(page, () => { const i = document.querySelector("#vgrid img"); return !!i && i.complete && i.naturalWidth > 0; }, null, "a character's photo");
assert.ok(seen.photos[0].startsWith("https://venice.ai/_next/image?url=https%3A%2F%2Fouterface.venice.ai%2Fsable.png&w="), seen.photos[0]);
assert.equal(await page.locator("#vgrid .noface").count(), 1, "no photo is an empty frame");
@@ -363,9 +365,9 @@ async function main() {
// a new search draws a character's tile again only if something about them changed: their photo stays put
await page.evaluate(() => { document.querySelector('#vgrid .tile[data-slug="sable-penrose"] img').dataset.kept = "yes"; });
let asked = seen.characters.length;
await page.selectOption("#vsort", "imports");
await page.selectOption("#vsort", "featured");
await poll(() => seen.characters.length > asked, "a sorted search");
assert.ok(seen.characters.at(-1).includes("sortBy=imports"), "the sort reaches Venice");
assert.ok(seen.characters.at(-1).includes("sortBy=featured"), "the sort reaches Venice");
await until(page, () => document.querySelector("#venstatus").textContent === "", null, "the sorted page");
await count(page, "#vgrid li:not(.ghost)", 2);
await has(page, "#vgrid .nm", /Wren/);
@@ -1136,6 +1138,37 @@ async function main() {
await count(p2, "#threads > li", 1);
await old.close();
// ---------- the harbor's own: meet Maren without a key, and she's yours ----------
const own = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const p5 = await own.newPage();
watch(p5, "the harbor's own: ");
await venice(p5);
await p5.goto(base);
await count(p5, "#cast li", 3);
assert.deepEqual(await p5.evaluate(() => [...document.querySelectorAll("#cast .nm")].map((e) => e.textContent)), ["Maren", "Odile", "Taskmaster"]);
assert.equal(await p5.evaluate(() => localStorage.length), 0, "looking at them keeps nothing");
await p5.click("#cast .tile[data-cast=maren]");
await has(p5, "#who-name", /^Maren$/);
await has(p5, "#log .line.them .said", /I'm Maren, I run the harbor\./);
await has(p5, "#cast .tile[data-cast=maren] .go", /^talking now$/);
await has(p5, "#roster td", /Ported from Maren by Havre/);
assert.ok(await p5.locator("#keynote").isVisible(), "demo mode says so");
await p5.fill("#say", "What is this place?");
await p5.keyboard.press("Enter");
await has(p5, "#log .line.them .said", /^A harbor\. Characters cross over from Venice and settle here\.$/);
const mine = await p5.evaluate(() => JSON.parse(localStorage.getItem("havre.cards.v1")));
assert.equal(mine.length, 1);
assert.deepEqual([mine[0].id, mine[0].lineage.at(-1).ref, mine[0].lineage.at(-1).relation], ["maren", "havre:maren", "ported-from"], "a copy with the credit");
assert.notEqual(mine[0].created, "2026-10-16T00:00:00.000Z", "the copy is dated when it was met, not when the card was written");
await p5.click("#cast .tile[data-cast=odile]");
await has(p5, "#who-name", /^Odile$/);
await has(p5, "#cast .tile[data-cast=maren] .go", /^talk again$/);
await p5.click("#cast .tile[data-cast=maren]");
await has(p5, "#who-name", /^Maren$/);
await count(p5, "#roster tr", 2);
assert.ok(await p5.locator("#log .line.me .said", { hasText: "What is this place?" }).isVisible(), "the same chat, not a new copy of her");
await own.close();
// ---------- phone: an iPhone SE's screen, 375 by 667 ----------
const W = 375;
const phone = await browser.newContext({ viewport: { width: W, height: 667 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
+2 -1
View File
@@ -11,8 +11,9 @@ cd "$(dirname "$0")/.."
# files from earlier versions of the site that this one doesn't serve: a commit can add files but never remove them,
# so the build does
rm -rf art fonts cast tools/paint.py tools/cast.mjs tools/draw.py tools/vendor tests/smoke.py
rm -rf art fonts tools/paint.py tools/draw.py tools/vendor tests/smoke.py
node tools/cast.mjs # cast/index.js, the harbor's own as the module the page imports, from cast/*.havre.json
node --test tests/*.test.mjs
if [ -z "${PW_MODULE:-}" ]; then
+12
View File
@@ -0,0 +1,12 @@
// tools/cast.mjs: write cast/index.js, the ES module the page imports, from the cast/*.havre.json files next to it.
// Edit the .havre.json files, then run this (tools/build.sh does, every build): node tools/cast.mjs
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const dir = new URL("../cast/", import.meta.url);
const files = readdirSync(dir).filter((f) => f.endsWith(".havre.json")).sort();
const cards = files.map((f) => JSON.parse(readFileSync(new URL(f, dir), "utf8")));
writeFileSync(fileURLToPath(new URL("index.js", dir)),
"// Generated by tools/cast.mjs from the .havre.json files next to it. Edit those, then run it again.\nexport default " +
JSON.stringify(cards, null, 1) + ";\n");
console.log(`cast/index.js: ${cards.map((c) => c.name).join(", ")}`);