Havre: workflows.js, the harbor's named workflows and their doors (portraits, new), with tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
This commit is contained in:
1 parent
bec574282a
commit
ba964f473d
2 files changed
+184
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as W from "../workflows.js";
|
||||
import { validateCard } from "../card.js";
|
||||
|
||||
test("every workflow has a command, doors and a usage line", () => {
|
||||
for (const w of W.WORKFLOWS) {
|
||||
assert.ok(w.id && w.command && w.title && w.about && w.usage, w.id);
|
||||
assert.ok(w.doors.length >= 1, w.id);
|
||||
assert.equal(W.byId(w.id), w);
|
||||
assert.equal(W.byCommand(w.command), w);
|
||||
}
|
||||
});
|
||||
|
||||
test("a slash command at the start of the chat box is a workflow; anything else is words", () => {
|
||||
assert.deepEqual(W.parseCommand("/portraits"), { id: "portraits", arg: "" });
|
||||
assert.deepEqual(W.parseCommand(" /Portraits "), { id: "portraits", arg: "" });
|
||||
assert.deepEqual(W.parseCommand("/new a lighthouse keeper who\nwrites letters"), { id: "new", arg: "a lighthouse keeper who\nwrites letters" });
|
||||
assert.deepEqual(W.parseCommand("/"), { id: "help", arg: "" });
|
||||
assert.deepEqual(W.parseCommand("/help"), { id: "help", arg: "" });
|
||||
assert.deepEqual(W.parseCommand("/dance"), { id: "unknown", arg: "dance" });
|
||||
assert.equal(W.parseCommand("tell me about /new"), null);
|
||||
assert.equal(W.parseCommand("http://x/y"), null);
|
||||
assert.equal(W.parseCommand(""), null);
|
||||
});
|
||||
|
||||
test("/help lists the workflows with a chat door, one line each", () => {
|
||||
const lines = W.helpLines();
|
||||
assert.equal(lines.length, W.WORKFLOWS.filter((w) => w.doors.includes("chat")).length);
|
||||
assert.ok(lines.every((l) => l.startsWith("/") && l.includes(" — ")));
|
||||
});
|
||||
|
||||
test("three directions come back cleaned, titled, and no more than three", () => {
|
||||
const d = W.normalizeDirections({ directions: [
|
||||
{ title: " Harbor dawn ", look: " Thirty, salt-grey hair,\n a coat too big. " },
|
||||
{ title: "", look: "Forty, sharp bob, a red scarf." },
|
||||
{ title: "x", look: "" },
|
||||
{ title: "Four", look: "ok" }, { title: "Five", look: "ok" },
|
||||
] });
|
||||
assert.equal(d.length, 3);
|
||||
assert.deepEqual(d[0], { title: "Harbor dawn", look: "Thirty, salt-grey hair, a coat too big." });
|
||||
assert.equal(d[1].title, "look 2");
|
||||
assert.equal(d[2].title, "Four");
|
||||
assert.deepEqual(W.normalizeDirections({}), []);
|
||||
assert.deepEqual(W.normalizeDirections(null), []);
|
||||
});
|
||||
|
||||
test("the directions ask carries the card and what's known of how they look", () => {
|
||||
const m = W.directionsMessages({ name: "Odile", tagline: "keeps the light", persona: "You are Odile.", look: { description: "tall, grey coat" } });
|
||||
assert.equal(m[0].role, "system");
|
||||
assert.match(m[1].content, /Odile/);
|
||||
assert.match(m[1].content, /How they look so far: tall, grey coat/);
|
||||
assert.match(W.directionsMessages({ name: "X", look: {} })[1].content, /invent them/);
|
||||
});
|
||||
|
||||
test("a card from an idea is a valid Havre card with how they look on it", () => {
|
||||
const o = { name: "Marguerite Vale", tagline: "the harbor's last cartographer", greeting: "Oh. You again.\n\nSit.", persona: "You are Marguerite, a cartographer who maps what the sea takes back.",
|
||||
examples: [{ user: "hi", character: "hm." }], tags: ["maps", "sea"], look: "Fifties, ink-stained fingers, a coat with too many pockets." };
|
||||
const c = W.cardFromIdea(o, { model: "zai-org-glm-5-2" });
|
||||
assert.deepEqual(validateCard(c), []);
|
||||
assert.equal(c.name, "Marguerite Vale");
|
||||
assert.equal(c.look.description, "Fifties, ink-stained fingers, a coat with too many pockets.");
|
||||
assert.equal(c.model, "zai-org-glm-5-2");
|
||||
assert.equal(c.lineage.length, 0);
|
||||
assert.equal(c.voice.short_form, true);
|
||||
});
|
||||
|
||||
test("the idea schema is the writer's schema plus a look", () => {
|
||||
assert.ok(W.IDEA_SCHEMA.required.includes("look"));
|
||||
assert.ok(W.IDEA_SCHEMA.required.includes("persona"));
|
||||
const m = W.ideaMessages("a shy rapper", "Lone");
|
||||
assert.match(m[1].content, /"idea": "a shy rapper"/);
|
||||
assert.match(m[1].content, /"name": "Lone"/);
|
||||
assert.doesNotMatch(W.ideaMessages("x")[1].content, /"name"/);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// workflows.js: the things Havre does as named workflows, and the doors into each. Pure functions, no DOM: the page
|
||||
// imports this module, and so do the tests.
|
||||
//
|
||||
// A workflow is one named thing the harbor can do, with what it takes, what it gives, and the doors it can be
|
||||
// started from. The same workflow runs the same way whichever door opens it:
|
||||
//
|
||||
// chat a slash command typed in the chat box (/portraits, /new a lighthouse keeper who…)
|
||||
// sheet a link on the character's card
|
||||
// harbor the new-character form
|
||||
//
|
||||
// The page holds the hands that run them (app.js, runWorkflow); this file holds what they are, what the models are
|
||||
// asked, and how to read what comes back. More doors (a timer, a hook) are more entries in `doors`, nothing else.
|
||||
|
||||
import { normalizeCard, cardFromWrite, WRITE_SCHEMA } from "./card.js";
|
||||
|
||||
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 : []);
|
||||
|
||||
export const WORKFLOWS = [
|
||||
{
|
||||
id: "portraits", command: "portraits", title: "three portraits", takes: "card", gives: "portrait", doors: ["sheet", "chat"],
|
||||
about: "three ways they could look, drawn from their card; pick one as their portrait",
|
||||
usage: "/portraits",
|
||||
},
|
||||
{
|
||||
id: "new", command: "new", title: "write someone", takes: "idea", gives: "card", doors: ["harbor", "chat"],
|
||||
about: "a new character from an idea in a line or two, written as a card of your own",
|
||||
usage: "/new <who they are, in a line>",
|
||||
},
|
||||
];
|
||||
|
||||
export const byId = (id) => WORKFLOWS.find((w) => w.id === id) || null;
|
||||
export const byCommand = (cmd) => WORKFLOWS.find((w) => w.command === cmd) || null;
|
||||
|
||||
/**
|
||||
* A slash command typed in the chat: { id, arg } for a workflow, { id: "help" } for /help or a bare "/", null for
|
||||
* anything else (so "/" in the middle of a sentence, or a path, is just words).
|
||||
*/
|
||||
export function parseCommand(text) {
|
||||
const m = /^\/([a-z]*)(?:\s+([\s\S]*))?$/i.exec(str(text, 4000).trim());
|
||||
if (!m) return null;
|
||||
const cmd = m[1].toLowerCase(), arg = str(m[2] || "", 2000).trim();
|
||||
if (!cmd || cmd === "help" || cmd === "?") return { id: "help", arg: "" };
|
||||
const w = byCommand(cmd);
|
||||
return w ? { id: w.id, arg } : { id: "unknown", arg: cmd };
|
||||
}
|
||||
|
||||
/** what /help says: one line per workflow that has a chat door */
|
||||
export function helpLines() {
|
||||
return WORKFLOWS.filter((w) => w.doors.includes("chat")).map((w) => `${w.usage} — ${w.about}`);
|
||||
}
|
||||
|
||||
// ---------- portraits: three ways they could look ----------
|
||||
|
||||
export const DIRECTIONS_SYSTEM = "You describe how an original character looks, for portraits of them. Give three " +
|
||||
"different directions, each one paragraph of at most 45 words: their apparent age (an adult), face, hair, build and " +
|
||||
"clothes, and one detail people remember. The three differ in mood, setting or style, not in who they are. Invent " +
|
||||
"what isn't given. Never describe a real person. Return one JSON object: directions, an array of three objects with " +
|
||||
"title (at most 4 words) and look (the paragraph).";
|
||||
|
||||
export const DIRECTIONS_SCHEMA = {
|
||||
type: "object", additionalProperties: false, required: ["directions"],
|
||||
properties: { directions: { type: "array", items: { type: "object", additionalProperties: false, required: ["title", "look"],
|
||||
properties: { title: { type: "string" }, look: { type: "string" } } } } },
|
||||
};
|
||||
|
||||
export function directionsMessages(card) {
|
||||
const about = [card.tagline, card.look && card.look.description && "How they look so far: " + clip(card.look.description.replace(/\s+/g, " "), 400),
|
||||
card.persona && clip(card.persona.replace(/\s+/g, " "), 700)].filter(Boolean).join("\n");
|
||||
return [{ role: "system", content: DIRECTIONS_SYSTEM },
|
||||
{ role: "user", content: `The character: ${card.name}\n${about || "Nothing else is known: invent them."}` }];
|
||||
}
|
||||
|
||||
/** the three directions, cleaned: each a title and a look; fewer if the model sent fewer, none if it sent none */
|
||||
export function normalizeDirections(o, n = 3) {
|
||||
const list = arr(o && o.directions).map((d) => ({ title: clip(str(d && d.title, 200).replace(/\s+/g, " ").trim(), 40),
|
||||
look: str(d && d.look, 2000).replace(/\s+/g, " ").trim() })).filter((d) => d.look);
|
||||
return list.slice(0, n).map((d, i) => ({ ...d, title: d.title || `look ${i + 1}` }));
|
||||
}
|
||||
|
||||
// ---------- new: someone from an idea ----------
|
||||
|
||||
export const IDEA_SYSTEM = "You are a character writer. From an idea in a line or two you write a brand-new, original " +
|
||||
"character for a chat app, in your own words. The character is an adult, 18 or older. If the idea names them, keep " +
|
||||
"that name; otherwise invent one. Return one JSON object: name, tagline (at most 8 words), greeting (one to three " +
|
||||
"short texts separated by a blank line), persona (a system prompt in the second person, 120 to 250 words: who they " +
|
||||
"are, how they talk, what they want, what they won't do), examples (three objects with user and character, the " +
|
||||
"character's replies short, like texts), tags (at most five lowercase words), look (how they look, one paragraph of " +
|
||||
"at most 45 words: apparent age, face, hair, build, clothes, one detail people remember; never a real person).";
|
||||
|
||||
export const IDEA_SCHEMA = {
|
||||
...WRITE_SCHEMA,
|
||||
required: [...WRITE_SCHEMA.required, "look"],
|
||||
properties: { ...WRITE_SCHEMA.properties, look: { type: "string" } },
|
||||
};
|
||||
|
||||
export function ideaMessages(idea, name = "") {
|
||||
const ask = { idea: str(idea, 2000).trim(), ...(str(name, 80).trim() ? { name: str(name, 80).trim() } : {}) };
|
||||
return [{ role: "system", content: IDEA_SYSTEM },
|
||||
{ role: "user", content: "Write a character from this idea.\n\n" + JSON.stringify(ask, null, 1) }];
|
||||
}
|
||||
|
||||
/** the new card from what the writer sent back: a remix-shaped card (card.js cardFromWrite), plus how they look */
|
||||
export function cardFromIdea(o, { model } = {}) {
|
||||
const base = cardFromWrite(o || {}, { model });
|
||||
const look = str(o && o.look, 2000).replace(/\s+/g, " ").trim();
|
||||
return normalizeCard({ ...base, look: { ...base.look, description: look } });
|
||||
}
|
||||
Reference in new issue
Block a user