diff --git a/examples/apps/build.mjs b/examples/apps/build.mjs index b16c40f..3b3bd10 100644 --- a/examples/apps/build.mjs +++ b/examples/apps/build.mjs @@ -1,7 +1,7 @@ import { build, context } from "esbuild"; const watch = process.argv.includes("--watch"); const options = { - entryPoints: { home: "src/home.js", bank: "src/bank.js", chat: "src/chat.js", adgen: "src/adgen.js", imagegen: "src/imagegen.js", persona: "src/persona.js", cartridge: "src/cartridge.js", adpulse: "src/adpulse.js", adforge: "src/adforge.js", shelf: "src/shelf.js", studio: "src/studio.js", aplus: "src/aplus.js", natal: "src/natal.js", arcana: "src/arcana.js", redline: "src/redline.js", batch: "src/batch.js", take: "src/take.js", identity: "src/identity.js", reel: "src/reel.js", marquee: "src/marquee.js", huddle: "src/huddle.js", deck: "src/deck.js", dub: "src/dub.js", crest: "src/crest.js", arcade: "src/arcade.js", yearbook: "src/yearbook.js", toon: "src/toon.js", storybook: "src/storybook.js", anthem: "src/anthem.js", roast: "src/roast.js", emote: "src/emote.js", gist: "src/gist.js", rephrase: "src/rephrase.js", explainthis: "src/explainthis.js", translate: "src/translate.js", polish: "src/polish.js", extract: "src/extract.js", reply: "src/reply.js", unjargon: "src/unjargon.js", nameit: "src/nameit.js", actions: "src/actions.js", snap: "src/snap.js", recap: "src/recap.js", meetnotes: "src/meetnotes.js", inkling: "src/inkling.js", petrait: "src/petrait.js", rizz: "src/rizz.js", dreamlog: "src/dreamlog.js", roomify: "src/roomify.js", thumbs: "src/thumbs.js", meme: "src/meme.js", echo: "src/echo.js", regex: "src/regex.js", errslate: "src/errslate.js", commit: "src/commit.js", docstring: "src/docstring.js", shell: "src/shell.js", cron: "src/cron.js", steps: "src/steps.js", compare: "src/compare.js", formula: "src/formula.js", spellout: "src/spellout.js", clipfix: "src/clipfix.js", convert: "src/convert.js", pdftools: "src/pdftools.js", emboss: "src/emboss.js", qr: "src/qr.js", palette: "src/palette.js", canvas: "src/canvas.js", cut: "src/cut.js", hooks: "src/hooks.js", repurpose: "src/repurpose.js", caption: "src/caption.js", titles: "src/titles.js", outline: "src/outline.js", standup: "src/standup.js", objection: "src/objection.js", coldemail: "src/coldemail.js", resize: "src/resize.js", ideabrain: "src/ideabrain.js", ideafetch: "src/ideafetch.js", reachout: "src/reachout.js", os: "src/os/os.js" }, + entryPoints: { home: "src/home.js", bank: "src/bank.js", chat: "src/chat.js", adgen: "src/adgen.js", imagegen: "src/imagegen.js", persona: "src/persona.js", cartridge: "src/cartridge.js", adpulse: "src/adpulse.js", adforge: "src/adforge.js", shelf: "src/shelf.js", studio: "src/studio.js", aplus: "src/aplus.js", natal: "src/natal.js", arcana: "src/arcana.js", redline: "src/redline.js", batch: "src/batch.js", take: "src/take.js", identity: "src/identity.js", reel: "src/reel.js", marquee: "src/marquee.js", huddle: "src/huddle.js", deck: "src/deck.js", dub: "src/dub.js", crest: "src/crest.js", arcade: "src/arcade.js", yearbook: "src/yearbook.js", toon: "src/toon.js", storybook: "src/storybook.js", anthem: "src/anthem.js", roast: "src/roast.js", emote: "src/emote.js", gist: "src/gist.js", rephrase: "src/rephrase.js", explainthis: "src/explainthis.js", translate: "src/translate.js", polish: "src/polish.js", extract: "src/extract.js", reply: "src/reply.js", unjargon: "src/unjargon.js", nameit: "src/nameit.js", actions: "src/actions.js", snap: "src/snap.js", recap: "src/recap.js", meetnotes: "src/meetnotes.js", inkling: "src/inkling.js", petrait: "src/petrait.js", rizz: "src/rizz.js", dreamlog: "src/dreamlog.js", roomify: "src/roomify.js", thumbs: "src/thumbs.js", meme: "src/meme.js", echo: "src/echo.js", regex: "src/regex.js", errslate: "src/errslate.js", commit: "src/commit.js", docstring: "src/docstring.js", shell: "src/shell.js", cron: "src/cron.js", steps: "src/steps.js", compare: "src/compare.js", formula: "src/formula.js", spellout: "src/spellout.js", clipfix: "src/clipfix.js", convert: "src/convert.js", pdftools: "src/pdftools.js", emboss: "src/emboss.js", qr: "src/qr.js", palette: "src/palette.js", canvas: "src/canvas.js", cut: "src/cut.js", hooks: "src/hooks.js", repurpose: "src/repurpose.js", caption: "src/caption.js", titles: "src/titles.js", outline: "src/outline.js", standup: "src/standup.js", objection: "src/objection.js", coldemail: "src/coldemail.js", resize: "src/resize.js", ideabrain: "src/ideabrain.js", ideafetch: "src/ideafetch.js", reachout: "src/reachout.js", draft: "src/draft.js", os: "src/os/os.js" }, bundle: true, format: "esm", target: "chrome111", outdir: "dist", sourcemap: true, logLevel: "info", }; if (watch) { const ctx = await context(options); await ctx.watch(); console.error("[apps] watching…"); } diff --git a/examples/apps/draft.html b/examples/apps/draft.html new file mode 100644 index 0000000..6f79183 --- /dev/null +++ b/examples/apps/draft.html @@ -0,0 +1,126 @@ + + + + + + Draft — social posts on your Claude, steered piece by piece + + + + + + +
+ Draft +
+
+
+
+

Say it once. Steer it in place.

+

Topic and a format in — your own Claude drafts a few genuinely different posts, grounded in your brief. Then select any one and say what to change: only that piece re-runs, swapped in place. Runs on your Claude via Switchboard — the operator holds no key and never sees your data.

+
+
+
+ + + diff --git a/examples/apps/harness/provider.js b/examples/apps/harness/provider.js index b5b4bf2..d062783 100644 --- a/examples/apps/harness/provider.js +++ b/examples/apps/harness/provider.js @@ -422,6 +422,11 @@ // readout never renders. The pull route is also tightened to the pull prompt's own language. [function (lc, p) { return /you are adpulse|blunt, numbers-first|pre-computed aggregates|monthlyburn/i.test(p); }, function () { return adpulseDiagnosis(); }], [function (lc, p) { return /reply with only a csv|campaign-level performance for the last 30 days/i.test(p); }, function () { return "text:Campaign name,Amount spent (INR),Impressions,Clicks,CTR,CPC,Purchases,Purchase value,ROAS\nProspecting — Broad,42000,900000,4200,0.47,10,30,33600,0.8\nRetargeting — 7d,18000,210000,3800,1.81,4.7,180,75600,4.2\nLookalike 1%,26000,540000,3100,0.57,8.4,90,54600,2.1"; }], + // draft — the general content drafter. batch → {pieces:[{hook,body}]}, revise → {hook,body}. The + // revise route MUST be matched (it also says "return only a json object"); both sit before the + // generic json fallback so the select-and-say swap gets a real re-drafted body, not the fallback. + [function (lc, p) { return /you are revising one|current piece:/i.test(p); }, function (lc, p) { return { hook: "revised", body: "Revised for " + bname + " — " + optionCard(0).body }; }], + [function (lc, p) { return /you are drafting \d+ |"pieces"\s*:\s*\[/i.test(p); }, function (lc, p) { var m = p.match(/drafting (\d+)/i); var n = m ? Math.max(1, Number(m[1])) : 3; var out = []; for (var i = 0; i < n; i++) out.push({ hook: "angle " + (i + 1), body: optionCard(i).body }); return { pieces: out }; }], // batch (YC answers / video scripts) [function (lc, p) { return /y combinator application|founder video|prepping the videos|complete answer options/i.test(p); }, function () { return labelTextArray(3); }], // identity (5 sequential label/text stages) diff --git a/examples/apps/harness/runner.js b/examples/apps/harness/runner.js index 5b8b050..d454a05 100644 --- a/examples/apps/harness/runner.js +++ b/examples/apps/harness/runner.js @@ -20,6 +20,7 @@ const CFG = { take: { name: "Take", cat: "founder-stack", count: (d) => /the script/i.test(txt(d)) ? d.querySelectorAll("#view .opt").length || 1 : 0 }, identity: { name: "Identity", cat: "founder-stack", count: (d) => d.querySelectorAll("#view .q-card .opt, #view .opt").length }, batch: { name: "Batch", cat: "founder-stack", count: (d) => d.querySelectorAll("#view .q-card").length }, + draft: { name: "Draft", cat: "founder-stack", count: (d) => d.querySelectorAll("#view .q-card.piece").length }, bank: { name: "Bank", cat: "founder-stack", count: (d) => d.querySelectorAll("#brief-out .briefline").length || (d.getElementById("brief-sec") && !d.getElementById("brief-sec").hidden ? 1 : 0) }, redline: { name: "Redline", cat: "founder-stack", count: (d) => d.querySelectorAll("#side-body .dec").length }, // no crutch: AdPulse diagnoses its own representative month on connect (live pull first when a diff --git a/examples/apps/harness/serve.mjs b/examples/apps/harness/serve.mjs index ba96fc6..495efbe 100644 --- a/examples/apps/harness/serve.mjs +++ b/examples/apps/harness/serve.mjs @@ -96,6 +96,8 @@ const WRAPPS = [ { id: "coldemail", html: "coldemail.html", cat: "skill" }, // ideabrain "I have an idea" widget (2026-08) — idea-first, no cold-open; the runner types the idea line. { id: "ideabrain", html: "ideabrain.html", cat: "studio" }, + // draft — the general content drafter + select-and-say revise (2026-08); cold-opens from the lent brief. + { id: "draft", html: "draft.html", cat: "founder-stack" }, ]; const HARNESS_JSON = JSON.stringify(PROJECTS); diff --git a/examples/apps/src/core/arrange.core.js b/examples/apps/src/core/arrange.core.js new file mode 100644 index 0000000..62ddb20 --- /dev/null +++ b/examples/apps/src/core/arrange.core.js @@ -0,0 +1,100 @@ +// Arrange — the EDITOR capability. draft.core.js WRITES content; this one refuses to. It takes what a +// specific person actually said, in their own voice, and only makes it postable: cut filler, fix +// transcription, break into lines, reorder for flow. It never adds a sentence, never "improves" a word, +// never reaches for a hook or a closing line. Because a person's own plain words beat anything a model +// drafts — the founder's "just looking at a lot of stream text getting anxious for no reason" is better +// than any line the writer produced, and the whole job is to not wreck it. +// +// This exists because generate-from-a-brief (draft.core.js) produced content-shaped slop for personal +// posts. `arrange` is what should back notch select-and-say for a founder's own voice: you talk, it trims. +// +// `sb` is the same surface: sb.stream(params). PURE ESM, NO DOM. + +// ─────────────────────────── the editor rules (the whole point) ─────────────────────────── + +/** The editor contract. Every ALLOWED line is a cut/fix/reorder; every FORBIDDEN line is an act of + * writing. If the model is tempted to improve, the rule is: don't. */ +export const EDITOR_RULES = [ + "You are an EDITOR, not a writer. The text below is something a specific person actually said, in their own voice. Your ONLY job is to make it postable WITHOUT changing whose words they are.", + "ALLOWED: remove filler and repetition; fix transcription errors and typos; break it into short lines or a few short paragraphs; reorder for the way it reads best; drop a redundant clause.", + "FORBIDDEN: writing any new sentence; swapping their words for 'better' or cleverer ones; adding a hook, an opening line, a closing line, a call to action, a summary, a metaphor, or an aphorism; making it punchier. Do NOT try to make any line 'land'.", + "Keep their exact phrasing wherever you can — including casual, lowercase, unfinished-sounding style. If a bit is already good, leave it completely alone. When in doubt, cut rather than rewrite.", + "You are allowed to end up changing almost nothing. That is a success, not a failure. The best possible output is their words, lightly cut and ordered, and nothing else.", +].join("\n"); + +/** Per-format shaping — still edit-only. Just says how to break it up, never how to rewrite it. */ +export function arrangeShape(format) { + switch (String(format || "").toLowerCase()) { + case "x-thread": + return "Shape: an X thread. If it naturally breaks into beats, number them 1/ 2/ … and put one beat per part, ≤270 chars each — but only split their existing words; do not write connective tissue between beats."; + case "linkedin": + return "Shape: short paragraphs, one thought each. No hashtags, no sign-off."; + case "x-single": + return "Shape: one short post, ≤270 chars. If their words are longer, CUT down to the truest part — do not rewrite to fit."; + default: + return "Shape: short lines/paragraphs, whatever fits their words most plainly."; + } +} + +/** Build the arrange prompt. Pure. `steers` are EDITORIAL nudges only (e.g. 'shorter', 'cut the last + * line', 'keep it rawer') — never 'make it punchier' style rewrites; the rules above still bind. */ +export function buildArrangePrompt({ raw, format, steers }) { + return [ + EDITOR_RULES, + arrangeShape(format), + steers && steers.length ? `Editorial steer (still edit-only — cut/reorder, do not rewrite): ${steers.map((s) => `"${s}"`).join(" → ")}` : "", + "THEIR WORDS (arrange these — do not write new ones):", + String(raw || "").trim(), + "Output ONLY the arranged text. No preamble, no quotes around it, no notes, no JSON.", + ].filter(Boolean).join("\n\n"); +} + +/** Coerce the reply to plain text — strip any fences/labels the model adds. Never throws. */ +export function normalizeArranged(text) { + let t = String(text || "").replace(/```[a-z]*\n?/gi, "").trim(); + // drop a leading "Here's..." line if the model ignored the no-preamble instruction + t = t.replace(/^\s*(here'?s|arranged|output)\b[^\n]*\n+/i, "").trim(); + return { body: t }; +} + +// ─────────────────────────── the action ─────────────────────────── + +/** THE ACTION — arrange one block of the person's own words. input: { raw|body, format?, steers? }. + * Returns { body }. Pure model call, no side effects. */ +export async function arrange(input, sb) { + const raw = input?.raw ?? input?.body; + if (!raw || !String(raw).trim()) throw new Error("arrange needs { raw } — the person's own words to arrange."); + const prompt = buildArrangePrompt({ raw, format: input?.format, steers: input?.steers }); + let text = ""; + for await (const d of sb.stream({ prompt })) { + if (d.type === "text") text += d.text; + else if (d.type === "error") throw new Error(d.error?.message || "stream error"); + } + return normalizeArranged(text); +} + +// ─────────────────────────── manifest ─────────────────────────── +// Register in packages/switchboard-mcp/registry.mjs: import arrange + push onto MANIFESTS → +// wrapp__arrange__arrange. No connector grant — pure model call, tools:[]. +export const manifest = { + name: "arrange", + title: "Arrange", + origin: "https://arrange.thelastprompt.ai", + scope: { models: ["sonnet"], contextKinds: ["brand", "project"], tools: [] }, + actions: [ + { + name: "arrange", + summary: + "Turn a person's own raw words (a voice note, a rant, a rough dump) into a postable version WITHOUT rewriting them — cut filler, fix transcription, break into lines, reorder. Never adds a sentence or a hook. The editor, not the writer; use this instead of draft for a person's own voice.", + input: { + raw: "string — the person's own words to arrange. Required.", + format: "string? — x-thread, x-single, or linkedin (shapes the break-up only, never the wording).", + steers: "string[]? — editorial nudges only: 'shorter', 'cut the last line', 'keep it rawer'.", + }, + output: { body: "the arranged text — their words, cut and ordered" }, + run: arrange, + }, + ], +}; + +export default manifest; diff --git a/examples/apps/src/core/draft.core.js b/examples/apps/src/core/draft.core.js new file mode 100644 index 0000000..5877754 --- /dev/null +++ b/examples/apps/src/core/draft.core.js @@ -0,0 +1,273 @@ +// Draft — the GENERAL content-drafting capability the composition vision was missing. Every other core +// is single-purpose (batch = a YC application, reachout = one lead's sequence). This one is factored: +// given a BRIEF (voice/angle/audience — the kind of thing ideabrain.brief + autopilot.slate produce) and +// a FORMAT (x-thread, x-single, linkedin, ig-caption, ig-carousel …), draft N distinct pieces on the +// user's own Claude. It is the piece between "great strategy" and "posts in the queue". +// +// It ships TWO actions on purpose — one capability, two features: +// • draft(input, sb) — batch: N pieces for a format (the content pipeline's last stage). +// • revise(input, sb) — ONE piece + a steer, re-drafted in place (the primitive behind notch +// "select-and-say": select a block → your words become a steer → re-run JUST +// this piece → swap the result. Same `steers` contract reachout already uses). +// +// `sb` is the same capability subset the SDK's `relay` exposes: +// sb.stream(params) -> async-iterable (the model call) +// sb.context.active() -> Context|null (the brand/strategy to ground in) +// The harness / a test supplies a MOCK sb; the daemon supplies the gated one. Same functions, same +// output shape — that parity is the whole point (see reachout.core.js for the sibling pattern). +// +// HONESTY: never fabricate a fact, a metric, or a quote. If a number isn't in the brief/topic, write +// around it — do NOT invent one. Same anti-slop posture as reachout's HONESTY clause. +// +// PURE ESM, NO DOM: imports cleanly in Node (connector / test) and the browser bundle. + +// ─────────────────────────── the brief (the strategy to ground in) ─────────────────────────── + +/** Normalize a content brief from whatever the upstream produced — a lent brand/idea context, an + * ideabrain.brief ({productIdea, positioningHint, audience, vibe, …}), or an autopilot.slate voice/ + * angle pick, or a loose object. Every field optional; missing ones just drop out of the prompt. */ +export function normalizeBrief(ctx) { + if (!ctx) return null; + const d = ctx.data || ctx; + const str = (v) => String(v ?? "").trim(); + return { + name: str(ctx.name || d.name || d.company), + about: str(d.about || d.productIdea || d.oneLine || d.tagline), + positioning: str(d.positioning || d.positioningHint), + audience: str(d.audience), + voice: str(d.voice || d.vibe), // slate "Builder's Diary" text, or ideabrain vibe + angle: str(d.angle), // slate "Unfinished, on purpose" + }; +} + +// ─────────────────────────── the formats (per-channel shape guidance) ─────────────────────────── +// One place that knows what each format IS. Adding a channel = adding one entry here. + +export const FORMATS = { + "x-thread": { + label: "X thread", + guide: "An X (Twitter) thread, 4–7 tweets. Number them 1/ 2/ …; each ≤270 chars, one thought per tweet, tweets separated by a blank line. Tweet 1 opens on something concrete and specific — a moment, a detail, a plain admission — never a pitch or a product intro. Let it end when the thought is finished: no follow-me CTA, no question tacked on the end.", + }, + "x-single": { + label: "X post", + guide: "A single X (Twitter) post, ≤270 chars. One real thing, said plainly, the truest line first. No hashtags. Don't end on a question.", + }, + "linkedin": { + label: "LinkedIn post", + guide: "A LinkedIn post, 120–200 words. Short paragraphs, one line each. Open on a real first line, not a hook. Talk through the actual thing — a decision, what happened, what you think — like you're telling one person. End when you're done: no question to the reader, no 'building in public' sign-off, at most one hashtag if any.", + }, + "ig-caption": { + label: "Instagram caption", + guide: "An Instagram caption, 40–120 words. The first line is a real opening line (it shows before 'more'), not a hook. A few line breaks for air. A couple of hashtags only if they'd actually help someone find it.", + }, + "ig-carousel": { + label: "Instagram carousel", + guide: "An Instagram carousel, 5–7 slides. Body: one slide per line as 'Slide N — HEADLINE (≤8 words): sub-line (≤20 words)'. Slide 1 opens on something concrete; the last slide can point somewhere, but don't make it a hard CTA.", + }, +}; + +/** Format option cards — exactly one recommended (house doctrine, cf. reachout.SEQUENCE_OPTIONS). */ +export const FORMAT_OPTIONS = [ + { id: "x-thread", label: "X thread", recommended: true }, + { id: "x-single", label: "X post", recommended: false }, + { id: "linkedin", label: "LinkedIn post", recommended: false }, + { id: "ig-caption", label: "Instagram caption", recommended: false }, + { id: "ig-carousel", label: "Instagram carousel", recommended: false }, +]; + +/** Resolve a format id to its guide; unknown ids fall back to a plain-post guide so we never throw. */ +export function formatGuide(format) { + const f = FORMATS[String(format || "").toLowerCase()]; + return f ? f.guide : "A single short social post. One clear idea, strongest line first."; +} + +// ─────────────────────────── the prompts ─────────────────────────── + +/** The factual honesty clause (facts only — voice lives in VOICE below). */ +export const HONESTY = + "Never fabricate a fact, a metric, a quote, or a shared connection. If a specific number isn't in the brief or topic, write around it — don't invent one."; + +/** THE VOICE RULE — matters more than the format. The single failure mode this exists to kill is + * writing that sounds like an ANNOUNCEMENT / an ad / a keynote instead of one person talking. Every + * ban here is a real tell that reads as AI-marketing copy. */ +export const VOICE = [ + "VOICE (this matters more than the format): write like one specific person typing to another person. Not a brand, not a landing page, not a keynote. The worst possible failure is sounding like an ANNOUNCEMENT.", + "ONE idea per piece — this is the top rule. Pick the single thing this post is about and cut everything that isn't it. Do NOT cram the product, the story, and the thesis into one post. If a second point wants in, it's a different post, not another line. A thread stays on its one story start to finish.", + "NEVER announce or introduce the product. No 'I'm building…', no 'introducing', no 'I call them X' or revealing a coined name like it's a moment — if a coined term matters, drop it in passing as if the reader already knows it; never present it.", + "NO feature-bullet cadence — no strings of clipped fragments like 'No subscription. No middleman.' That is ad copy, not speech.", + "NO tidy quotable one-liners or aphorisms built to be screenshotted. Do not reach for a memorable line. If a good line happens on its own, fine.", + "NO rhetorical question at the end. No 'is it just me?', no 'what would you do?'. Just stop when the thought is done.", + "NO sign-off clichés — no 'building in public', 'still unfinished', 'more soon', 'let's go', 'excited to share'.", + "NO 'it's not X, it's Y' seesaw constructions. Go easy on em-dashes and balanced clauses.", + "KEEP the person's own plain words from the brief and topic — verbatim wherever you can. Do NOT upgrade their phrasing into cleverer words; plain and true beats polished. If they wrote 'getting anxious for no reason', keep that exact register — do not turn it into a metaphor.", + "Let it be a little unpolished. A sentence that runs on or trails off like real speech beats a balanced, written-sounding one. Understate; don't sell.", +].join("\n"); + +/** Ground block from a brief — shared by draft + revise so a re-drafted piece stays on-voice. Pure. */ +function briefBlock(brief) { + if (!brief) return "STRATEGY / BRIEF: none lent — write in a plain, honest, specific first-person voice."; + return [ + "STRATEGY / BRIEF (ground every piece in this — it's what makes the content theirs, not generic):", + brief.name ? `Who: ${brief.name}.` : "", + brief.about ? `About: ${brief.about}.` : "", + brief.positioning ? `Positioning: ${brief.positioning}.` : "", + brief.audience ? `Audience: ${brief.audience}.` : "", + brief.voice ? `Voice: ${brief.voice}.` : "", + brief.angle ? `Angle: ${brief.angle}.` : "", + ].filter(Boolean).join(" "); +} + +/** Build the BATCH prompt: N distinct pieces of one format, grounded in the brief + topic. Pure. */ +export function buildDraftPrompt({ brief, format, topic, n, steers }) { + const count = Math.max(1, Number(n) || 1); + return [ + `You are drafting ${count} ${FORMATS[format]?.label || "post"}${count === 1 ? "" : "s"} on the user's own Claude. Make each genuinely different — a different way in, a different thing to say; never ${count} rewrites of one idea.`, + VOICE, + briefBlock(brief), + `FORMAT: ${formatGuide(format)}`, + topic ? `WHAT THIS IS ABOUT: ${topic}` : `WHAT THIS IS ABOUT: nothing given — take the ${count} most specific, real angles straight from the brief.`, + steers && steers.length ? `Steering (apply the latest, it wins): ${steers.map((s) => `"${s}"`).join(" → ")}` : "", + HONESTY, + `Return ONLY a JSON object — no prose, no markdown fences — exactly: {"pieces":[{"hook":"…","body":"…"}]} with exactly ${count} entr${count === 1 ? "y" : "ies"}. "hook" is a ≤12-word internal label (never shown to a reader — do not write the body to match it); "body" is the full post as plain text with real line breaks.`, + ].filter(Boolean).join("\n\n"); +} + +/** Build the REVISE prompt: re-draft ONE existing piece with a steer, same format. Pure. This is the + * select-and-say primitive — the steer is the user's typed words against the selected block. */ +export function buildRevisePrompt({ brief, format, body, steers }) { + return [ + `You are revising ONE ${FORMATS[format]?.label || "post"} on the user's own Claude. Rewrite it applying the steer: keep what works, change what the steer asks for, keep the same format and roughly the same length unless the steer says otherwise.`, + VOICE, + briefBlock(brief), + `FORMAT: ${formatGuide(format)}`, + `CURRENT PIECE:\n${String(body || "").trim()}`, + steers && steers.length ? `STEER (what to change — this is the instruction): ${steers.map((s) => `"${s}"`).join(" → ")}` : "STEER: tighten it — cut anything that sounds written or salesy, keep the plainest true version.", + HONESTY, + `Return ONLY a JSON object — no prose, no markdown fences — exactly: {"hook":"…","body":"…"}. "body" is the full revised post as plain text.`, + ].filter(Boolean).join("\n\n"); +} + +// ─────────────────────────── lenient parsing ─────────────────────────── + +/** Pull the first JSON object out of a possibly-messy model reply (fences stripped). Null on failure. */ +function firstJsonObject(text) { + const t = String(text || "").replace(/```[a-z]*\n?/gi, "").trim(); + const s = t.indexOf("{"), e = t.lastIndexOf("}"); + if (s === -1 || e <= s) return null; + try { return JSON.parse(t.slice(s, e + 1)); } catch { return null; } +} + +/** Coerce a batch reply into exactly N pieces {format, hook, body}. Never throws; pads/truncates to N. */ +export function normalizeDraft(text, format, n) { + const count = Math.max(1, Number(n) || 1); + const obj = firstJsonObject(text); + let arr = Array.isArray(obj?.pieces) ? obj.pieces : (Array.isArray(obj) ? obj : null); + if (!arr) { + // salvage a bare array + const t = String(text || "").replace(/```[a-z]*\n?/gi, "").trim(); + const a = t.indexOf("["), b = t.lastIndexOf("]"); + if (a !== -1 && b > a) { try { const j = JSON.parse(t.slice(a, b + 1)); if (Array.isArray(j)) arr = j; } catch { /* ignore */ } } + } + if (!arr) arr = []; + const clean = arr.slice(0, count).map((x) => ({ + format, + hook: String(x?.hook ?? x?.title ?? "").trim().slice(0, 120), + body: String(x?.body ?? x?.text ?? "").trim(), + })); + while (clean.length < count) clean.push({ format, hook: "", body: "" }); + return { pieces: clean }; +} + +/** Coerce a revise reply into one piece {format, hook, body}. Never throws. */ +export function normalizeRevised(text, format) { + const obj = firstJsonObject(text) || {}; + return { + format, + hook: String(obj.hook ?? obj.title ?? "").trim().slice(0, 120), + body: String(obj.body ?? obj.text ?? String(text || "")).trim(), + }; +} + +// ─────────────────────────── the model call ─────────────────────────── + +async function runPrompt(sb, prompt) { + let text = ""; + for await (const d of sb.stream({ prompt })) { + if (d.type === "text") text += d.text; + else if (d.type === "error") throw new Error(d.error?.message || "stream error"); + } + return text; +} + +// ─────────────────────────── the actions ─────────────────────────── + +/** THE ACTION #1 — draft N distinct pieces of one format. Pure model call, no side effects. + * input: { format, topic?, n?, brief?, steers? }. Returns { format, pieces:[{format,hook,body}] }. */ +export async function draft(input, sb) { + const format = String(input?.format || "x-single").toLowerCase(); + const n = Math.max(1, Number(input?.n) || 3); + const brief = input?.brief !== undefined ? normalizeBrief(input.brief) + : normalizeBrief(await sb.context?.active?.().catch(() => null)); + const prompt = buildDraftPrompt({ brief, format, topic: input?.topic, n, steers: input?.steers }); + const text = await runPrompt(sb, prompt); + return { format, ...normalizeDraft(text, format, n) }; +} + +/** THE ACTION #2 — re-draft ONE piece with a steer (the select-and-say primitive). Pure model call. + * input: { piece:{format?, body}, steer? | steers?, brief? }. Returns { piece:{format,hook,body} }. */ +export async function revise(input, sb) { + const piece = input?.piece || {}; + const body = piece.body ?? input?.body; + if (!body) throw new Error("revise needs { piece: { body } } — the current text to re-draft."); + const format = String(piece.format || input?.format || "x-single").toLowerCase(); + const steers = input?.steers || (input?.steer ? [input.steer] : []); + const brief = input?.brief !== undefined ? normalizeBrief(input.brief) + : normalizeBrief(await sb.context?.active?.().catch(() => null)); + const prompt = buildRevisePrompt({ brief, format, body, steers }); + const text = await runPrompt(sb, prompt); + return { piece: normalizeRevised(text, format) }; +} + +// ─────────────────────────── the agent-facing manifest ─────────────────────────── +// Register EXACTLY like the others in packages/switchboard-mcp/registry.mjs: +// import draft from "…/draft.core.js"; → add `draft` to MANIFESTS. +// That one line renders it as MCP tools wrapp__draft__draft and wrapp__draft__revise, and (via the +// same run(input, sb) contract) makes it composable by the launcher's planner and the select-and-say +// edit layer. NO connector grant — drafting is a pure model call, tools:[]. +export const manifest = { + name: "draft", + title: "Draft", + origin: "https://draft.thelastprompt.ai", + scope: { models: ["sonnet"], contextKinds: ["brand", "project"], tools: [] }, + actions: [ + { + name: "draft", + summary: + "Draft N distinct social posts of one format (x-thread, x-single, linkedin, ig-caption, ig-carousel), grounded in a voice/angle/audience brief and an optional topic. The general content-drafting capability; runs on the user's own Claude, no side effects.", + input: { + format: "string — one of: x-thread, x-single, linkedin, ig-caption, ig-carousel. Default x-single.", + topic: "string? — the theme/hook for this batch. Omit to draw the strongest angles from the brief.", + n: "number? — how many distinct pieces to draft. Default 3.", + brief: "object? — { name, about, positioning, audience, voice, angle } (accepts an ideabrain.brief or autopilot.slate voice/angle). Falls back to the active context.", + steers: "string[]? — steering, e.g. 'punchier', 'lead with the stat', 'less salesy'.", + }, + output: { format: "the format drafted", pieces: "[{ format, hook, body }] — the drafted pieces" }, + run: draft, + }, + { + name: "revise", + summary: + "Re-draft ONE existing piece applying a steer, keeping its format — the primitive behind notch 'select-and-say': select a block, your typed words become the steer, only that piece re-runs. Pure model call.", + input: { + piece: "object — { format?, body } — the current piece to re-draft. Required (body).", + steer: "string? — the change to apply (or pass steers[]).", + steers: "string[]? — ordered steers, latest wins.", + brief: "object? — the same brief the piece was drafted from; falls back to the active context.", + }, + output: { piece: "{ format, hook, body } — the revised piece" }, + run: revise, + }, + ], +}; + +export default manifest; diff --git a/examples/apps/src/core/record.core.js b/examples/apps/src/core/record.core.js new file mode 100644 index 0000000..7c305d4 --- /dev/null +++ b/examples/apps/src/core/record.core.js @@ -0,0 +1,312 @@ +// Record — the capability that refuses to end a pipeline step with "you go do it." The content +// pipeline (ideabrain → draft → …) produces a WRITTEN post. The next real step is a human recording a +// video of it — the one irreducibly-human part: a face and a voice talking to camera. Everything AROUND +// that is not human work, so we do ALL of it: rewrite the post into spoken teleprompter lines, plan the +// shots and screen-captures, write the setup checklist — and then hand the founder a GUIDED teach-mode +// "guru" flow that walks them line-by-line through the take. They supply only their face and voice. +// +// This is the hard product principle made mechanical: a step that needs the human does the MAXIMUM for +// them and then LAUNCHES the guided part; it never stops at an instruction. Concretely the capability +// ships two features on one definition: +// • plan(input, sb) — turn a written post into a SPOKEN, camera-ready plan (one model call). +// • buildRecordGuide({…}) — a PURE builder that turns that plan into a teach-mode guide-run.json +// object: the "guru does every step" artifact the notch/CursorGuide +// runs. Each teleprompter line is a step whose `say` is spoken aloud on +// device and whose `text` tells the human what to do ("read this to +// camera"). The human just talks; the guide shows and says each line. +// +// `sb` is the same capability subset the SDK's `relay` exposes: +// sb.stream(params) -> async-iterable (the model call) +// sb.context.active() -> Context|null (the brand/project to ground tone in) +// The harness / a test supplies a MOCK sb; the daemon supplies the gated one. Same functions, same +// output shape — that parity is the whole point (see draft.core.js / reachout.core.js for the siblings). +// +// PURE ESM, NO DOM: imports cleanly in Node (connector / test) and the browser bundle. + +// ─────────────────────────── the brief (tone to ground in) ─────────────────────────── + +/** Normalize a lent brand/project context (or any loose object) to the few fields the prompt uses to + * keep the SPOKEN lines on-voice. Mirrors draft.normalizeBrief — every field optional. */ +export function normalizeBrief(ctx) { + if (!ctx) return null; + const d = ctx.data || ctx; + const str = (v) => String(v ?? "").trim(); + return { + name: str(ctx.name || d.name || d.company), + about: str(d.about || d.productIdea || d.oneLine || d.tagline), + voice: str(d.voice || d.vibe), + audience: str(d.audience), + }; +} + +// ─────────────────────────── the prompt ─────────────────────────── + +/** The factual honesty clause — same posture as draft/reachout: a spoken take can't invent facts. */ +export const HONESTY = + "Never add a fact, a number, a claim, or a name that isn't already in the post. Recording it out loud does not make it truer — if it wasn't written, don't say it."; + +/** THE VOICE RULE for a SPOKEN take. A written post read verbatim to camera sounds like reading. The + * single failure mode this kills is teleprompter lines that sound WRITTEN instead of like a person + * talking. Grounded in the same plain, un-announced spirit as draft.core.js's VOICE. */ +export const VOICE = [ + "VOICE: these lines are SPOKEN to a camera, not read off a page. Write how the person actually talks — contractions, short breaths, the plainest true version. If a written sentence would sound stiff out loud, loosen it; keep the meaning, lose the polish.", + "ONE breath per line. Segment the take so each teleprompter line is one short spoken beat a person can say in a single glance-and-look-back-up — roughly 4–9 seconds. Never a paragraph.", + "Open on the truest, most concrete thing — a moment, a plain admission — never 'Hey guys' or a channel intro or a product pitch. No 'welcome back', no 'in this video'.", + "NO sign-off cliché — no 'smash that like', no 'link in bio', no 'let me know in the comments'. Let it end when the thought is done.", + "Keep the person's own words from the post verbatim wherever they're already speakable. Don't upgrade their phrasing into cleverer words.", +].join("\n"); + +/** Ground block from a brief — keeps the spoken rewrite in the person's register. Pure. */ +function briefBlock(brief) { + if (!brief) return "TONE: none lent — keep it plain, first-person, spoken-aloud honest."; + return [ + "TONE (keep the spoken lines in this register — it's what makes the take theirs):", + brief.name ? `Who: ${brief.name}.` : "", + brief.about ? `About: ${brief.about}.` : "", + brief.voice ? `Voice: ${brief.voice}.` : "", + brief.audience ? `Audience: ${brief.audience}.` : "", + ].filter(Boolean).join(" "); +} + +/** Build the PLAN prompt: written post → spoken teleprompter + shot plan + setup checklist. Pure. */ +export function buildPlanPrompt({ brief, format, body, steers }) { + return [ + "You are Record. You turn a WRITTEN social post into a SPOKEN, camera-ready plan a person can record themselves talking through. You do the whole job around the human so all they have to do is talk.", + VOICE, + briefBlock(brief), + format ? `THE POST'S FORMAT: ${format} (context only — the take is a person talking to camera, not the text on screen).` : "", + `THE WRITTEN POST (rewrite THIS into spoken lines — same substance, spoken register):\n${String(body || "").trim()}`, + steers && steers.length ? `Steering (apply the latest, it wins): ${steers.map((s) => `"${s}"`).join(" → ")}` : "", + "Produce THREE things:", + "1) teleprompter: the post rewritten as an ordered list of short spoken lines (one breath each, ~4–9s), each with a rough `seconds` estimate. This is what scrolls on the teleprompter and is spoken aloud.", + "2) shots: a short shot plan — for each segment, the camera framing (e.g. 'medium, eyes to lens') and a `capture` note for when to screen-capture or cut to b-roll (or 'talking head' when it's just them). Keep it to a handful of shots, aligned to the take.", + "3) setup: a short setup checklist BEFORE recording — lighting, mic, framing, background. Plain imperative items.", + HONESTY, + 'Return ONLY a JSON object — no prose, no markdown fences — exactly: {"teleprompter":[{"line":"…","seconds":6}],"shots":[{"shot":"…","framing":"…","capture":"…"}],"setup":["…"]}. Lines are plain spoken text.', + ].filter(Boolean).join("\n\n"); +} + +// ─────────────────────────── lenient parsing ─────────────────────────── + +/** Pull the first JSON object out of a possibly-messy model reply (fences stripped). Null on failure. */ +function firstJsonObject(text) { + const t = String(text || "").replace(/```[a-z]*\n?/gi, "").trim(); + const s = t.indexOf("{"), e = t.lastIndexOf("}"); + if (s === -1 || e <= s) return null; + try { return JSON.parse(t.slice(s, e + 1)); } catch { return null; } +} + +/** Rough per-line seconds when the model omits one: ~0.4s per word, floored/capped to a sane beat. */ +function estimateSeconds(line) { + const words = String(line || "").trim().split(/\s+/).filter(Boolean).length; + return Math.max(2, Math.min(15, Math.round(words * 0.4))); +} + +/** Coerce a plan reply into { teleprompter, shots, setup, durationSec }. Never throws; salvages a lot. */ +export function normalizePlan(text) { + const obj = firstJsonObject(text) || {}; + + const tRaw = Array.isArray(obj.teleprompter) ? obj.teleprompter + : Array.isArray(obj.lines) ? obj.lines + : Array.isArray(obj.script) ? obj.script : []; + const teleprompter = tRaw + .map((x) => { + const line = typeof x === "string" ? x : String(x?.line ?? x?.text ?? "").trim(); + if (!line) return null; + const secs = Number(x?.seconds ?? x?.sec ?? x?.duration); + return { line, seconds: Number.isFinite(secs) && secs > 0 ? Math.round(secs) : estimateSeconds(line) }; + }) + .filter(Boolean); + + const sRaw = Array.isArray(obj.shots) ? obj.shots : []; + const shots = sRaw + .map((x, i) => ({ + shot: String(x?.shot ?? x?.name ?? `Shot ${i + 1}`).trim(), + framing: String(x?.framing ?? x?.frame ?? "").trim(), + capture: String(x?.capture ?? x?.broll ?? x?.bRoll ?? "talking head").trim() || "talking head", + })) + .filter((s) => s.shot || s.framing || s.capture); + + const setRaw = Array.isArray(obj.setup) ? obj.setup : Array.isArray(obj.checklist) ? obj.checklist : []; + const setup = setRaw.map((s) => String(typeof s === "string" ? s : s?.item ?? s?.text ?? "").trim()).filter(Boolean); + + const durationSec = teleprompter.reduce((a, t) => a + (t.seconds || 0), 0); + return { teleprompter, shots, setup, durationSec }; +} + +// ─────────────────────────── the model call ─────────────────────────── + +async function runPrompt(sb, prompt) { + let text = ""; + for await (const d of sb.stream({ prompt })) { + if (d.type === "text") text += d.text; + else if (d.type === "error") throw new Error(d.error?.message || "stream error"); + } + return text; +} + +// ─────────────────────────── the teach-mode guide builder (the "guru does every step" artifact) ─────────────────────────── + +/** slug a string into a stable, unique-able step-id fragment. */ +function slug(s, fallback) { + const out = String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24); + return out || fallback; +} + +/** THE PURE BUILDER — turn a plan into a teach-mode guide-run.json OBJECT that walks the recording: + * one setup step per checklist item, a camera-framing step, one teleprompter step PER spoken line + * (say = the line, text = "read this to camera"), and a final "review your take" step. No `sb`, no + * side effects — the DOM/connector/notch writes the returned object to ~/.relay/guide-run.json and the + * native CursorGuide.begin(...) runs it. `source:"God"`, `mode:"teach"`. + * + * This is where the principle lands: the human never gets told "now go record it." They get a guided + * flow that shows and SPEAKS every line; the only thing they do is talk. NEVER put a secret in `copy`. */ +export function buildRecordGuide({ piece, plan, project } = {}) { + const p = plan || {}; + const tele = Array.isArray(p.teleprompter) ? p.teleprompter.filter((t) => t && String(t.line || "").trim()) : []; + const setup = Array.isArray(p.setup) ? p.setup.filter((s) => String(s || "").trim()) : []; + const shots = Array.isArray(p.shots) ? p.shots : []; + const format = piece?.format ? String(piece.format) : ""; + const totalSec = tele.reduce((a, t) => a + (Number(t.seconds) || 0), 0) || p.durationSec || 0; + + const steps = []; + const used = new Set(); + const pushId = (base) => { + let id = base, n = 2; + while (used.has(id)) id = `${base}-${n++}`; + used.add(id); + return id; + }; + + // 1) setup checklist — one step each, spoken so the founder can prep hands-free. + setup.forEach((item, i) => { + steps.push({ + id: pushId(`setup-${slug(item, String(i + 1))}`), + text: item, + hint: "Ready when this is done.", + say: item, + }); + }); + + // 2) camera-framing step — from the first shot's framing if we have one. + const framing = shots.find((s) => s?.framing)?.framing || "Medium shot, eyes to the lens, phone at eye level."; + steps.push({ + id: pushId("frame"), + text: `Set your frame: ${framing}`, + hint: "You can see yourself, eyes to the lens.", + say: "Now let's frame the shot. Look right into the lens, not at yourself.", + }); + + // 3) one step PER teleprompter line — the guru speaks each line; the human reads it to camera. + // `say` = the exact line (spoken aloud on device), `text` = the instruction, `hold` = its seconds. + tele.forEach((t, i) => { + const line = String(t.line).trim(); + const secs = Number(t.seconds) || 0; + steps.push({ + id: pushId(`line-${i + 1}`), + text: "Read this to camera", + hint: `Line ${i + 1} of ${tele.length}${secs ? ` · ~${secs}s` : ""}`, + say: line, + copy: line, // non-secret: the spoken line itself, handy to paste into a teleprompter app. + ...(secs ? { hold: Math.round(secs * 1000) } : {}), + }); + }); + + // 4) review your take — the last step; nothing more is asked of them. + steps.push({ + id: pushId("review"), + text: "Review your take", + hint: "Watch it back once; re-record any line that felt off.", + say: "That's the whole take. Watch it back once, and if a line felt off, just do that one again.", + }); + + const title = piece?.hook + ? `Record: ${String(piece.hook).slice(0, 60)}` + : `Record your ${format || "post"}`; + + return { + mode: "teach", + title, + source: "God", + project: String(project || ""), + steps, + // non-schema metadata some runners surface; harmless to the ones that don't. + meta: { format, lines: tele.length, durationSec: totalSec }, + }; +} + +// ─────────────────────────── the actions ─────────────────────────── + +/** THE ACTION #1 — plan a take: a written post → a spoken, camera-ready plan. Pure model call. + * input: { piece:{format?, body}, brief?, steers? }. Returns { teleprompter, shots, setup, durationSec }. */ +export async function plan(input, sb) { + const piece = input?.piece || {}; + const body = piece.body ?? input?.body; + if (!body) throw new Error("plan needs { piece: { body } } — the written post to turn into a take."); + const format = piece.format || input?.format || ""; + const brief = input?.brief !== undefined ? normalizeBrief(input.brief) + : normalizeBrief(await sb.context?.active?.().catch(() => null)); + const prompt = buildPlanPrompt({ brief, format, body, steers: input?.steers }); + const text = await runPrompt(sb, prompt); + return normalizePlan(text); +} + +/** THE ACTION #2 — plan a take AND emit its teach-mode guide in one shot. This is the "do the max, then + * launch the guided part" action: the caller gets both the plan (to show) and the guide-run.json object + * (to write to ~/.relay/guide-run.json so CursorGuide runs it). Pure model call + pure build. + * input: { piece:{format?, hook?, body}, brief?, project?, steers? }. Returns { plan, guide }. */ +export async function guide(input, sb) { + const built = await plan(input, sb); + const guideObj = buildRecordGuide({ piece: input?.piece || {}, plan: built, project: input?.project }); + return { plan: built, guide: guideObj }; +} + +// ─────────────────────────── the agent-facing manifest ─────────────────────────── +// Register EXACTLY like the others in packages/switchboard-mcp/registry.mjs: +// import record from "…/record.core.js"; → add `record` to MANIFESTS. +// That renders it as MCP tools wrapp__record__plan and wrapp__record__guide, and (via run(input, sb)) +// makes it composable by the launcher's planner. NO connector grant — planning is a pure model call and +// the guide is a pure object the notch writes; tools:[]. +export const manifest = { + name: "record", + title: "Record", + origin: "https://record.thelastprompt.ai", + scope: { models: ["sonnet"], contextKinds: ["brand", "project"], tools: [] }, + actions: [ + { + name: "plan", + summary: + "Turn a WRITTEN post into a SPOKEN, camera-ready plan: teleprompter lines (one breath each, with rough seconds), a shot plan (framing + when to screen-capture / cut b-roll), and a setup checklist (lighting/mic/frame). Does the whole job around the human so all they do is talk. Runs on the user's own Claude; no side effects.", + input: { + piece: "object — { format?, body } — the written post to record. Required (body).", + brief: "object? — { name, about, voice, audience } to keep the spoken lines on-voice. Falls back to the active context.", + steers: "string[]? — steering, e.g. 'more casual', 'cut it to 30 seconds', 'lead with the admission'.", + }, + output: { + teleprompter: "[{ line, seconds }] — the post rewritten as spoken lines", + shots: "[{ shot, framing, capture }] — the shot plan", + setup: "string[] — the pre-record checklist", + durationSec: "number — total spoken seconds", + }, + run: plan, + }, + { + name: "guide", + summary: + "Plan the take AND emit its teach-mode guide-run.json object in one call — the 'do the max for them, then launch the guided part' action. Returns { plan, guide }; the guide walks the recording step-by-step (setup, framing, one teleprompter step per line where it SPEAKS the line while the human reads it to camera, then review). Write `guide` to ~/.relay/guide-run.json to run it. Pure model call + pure build; no side effects.", + input: { + piece: "object — { format?, hook?, body } — the written post to record. Required (body).", + brief: "object? — tone context; falls back to the active context.", + project: "string? — project label shown on the guide card.", + steers: "string[]? — steering for the spoken rewrite.", + }, + output: { + plan: "{ teleprompter, shots, setup, durationSec } — the same plan `plan` returns", + guide: "teach-mode guide-run.json object { mode:'teach', title, source:'God', project, steps[] }", + }, + run: guide, + }, + ], +}; + +export default manifest; diff --git a/examples/apps/src/draft.js b/examples/apps/src/draft.js new file mode 100644 index 0000000..3047557 --- /dev/null +++ b/examples/apps/src/draft.js @@ -0,0 +1,388 @@ +// DRAFT — the front-end of the notch "select-and-say" edit loop. Topic + a format + a count in, N +// distinct pieces out on the visitor's OWN Claude — then SELECT any piece, SAY a steer, and re-run +// JUST that piece in place. The valuable middle (the voice rule, the format guides, the batch/revise +// prompts, the lenient parsing) lives in src/core/draft.core.js — ONE definition, shared with the MCP +// connector and the launcher's compose layer. This file is TEMPLATE PLUMBING + the app: the block +// between here and "APP LOGIC" is proven idiom (distilled from batch.js / reachout.js) — keep it +// byte-identical; edit the CONFIG block and everything below. +// +// House doctrine (all five): context-first · single input · options with exactly ONE recommended · +// house design system (the shared kit carries it) · one-go pipeline the user can steer anywhere. +// +// SELECT-AND-SAY is the whole point. Each drafted piece is its own card; the steerRow under it is the +// per-piece "say". Typing a steer → revise({ piece, steer, brief }, relay) → the returned body +// replaces THAT piece's body in state, re-rendered in place and flagged re-drafted. steers[] accrue +// per piece, latest-wins — the same contract reachout/batch use. +import { whenRelayReady, mountConnect } from "@relay/sdk"; +import { optionCards, steerRow, researching } from "./kit/ui.js"; +// God's hand: expose Draft's primary action (draft N pieces of a format) as a page-tool the native God +// webview (or any WebMCP host) can DRIVE — reusing the same start() a click runs. +import { exposeToGod } from "./kit/webmcp.js"; +// Carried context: when the Switchboard OS / launcher opens Draft AT an item, seed the topic from it. +import { readOsContext } from "./os/os-context.js"; +import { FORMAT_OPTIONS, FORMATS, draft as draftPieces, revise as revisePiece } from "./core/draft.core.js"; + +// ==== CONFIG — every new wrapp edits this block ============================================= +const APP = { + id: "draft", + name: "Draft", + installUrl: "https://thelastprompt.ai/switchboard/", + scope: { + reason: "Draft — writes N distinct social posts (X, LinkedIn, Instagram) on your own Claude, grounded only in your brief, then re-runs any single piece from a steer (select-and-say)", + models: ["sonnet"], + tools: [], // pure model call — no connector grant (see draft.core.js) + }, + usesContext: "single", // a lent brand/idea context becomes the voice/angle brief +}; + +// ==== dom + string helpers ================================================================== +const $ = (id) => document.getElementById(id); +const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }; +const uid = () => Math.random().toString(36).slice(2, 9); +const msg = (e) => String(e?.message || e).slice(0, 200); +let toastT = null; +function toast(text, err) { + clearTimeout(toastT); + let t = document.querySelector(".toast"); + if (!t) { t = el("div", "toast"); document.body.append(t); } + t.className = "toast" + (err ? " err" : ""); t.textContent = text; + toastT = setTimeout(() => t.remove(), 3400); +} + +// carried context — the OS/launcher may open Draft AT an item; seed the topic from it. Safe no-op +// when absent (bad hash → null → ""). +const OS_CTX = readOsContext(); +function osCtxTitle() { + const c = OS_CTX; if (!c) return ""; + if (typeof c.artifact === "string") return c.artifact.slice(0, 200); + if (c.artifact && typeof c.artifact.title === "string") return c.artifact.title.slice(0, 200); + if (typeof c.term === "string") return c.term.slice(0, 200); + return ""; +} + +// ==== connect (standard chip + returning-user probe) ======================================== +let relay = null; +let notInstalled = false; +let brand = null; // the ONE lent context, when APP.usesContext === "single" +let wired = false; + +mountConnect($("chip-dock"), { + scope: APP.scope, + context: APP.usesContext, + installUrl: APP.installUrl, + onConnect: (r) => { relay = r; wire(r); void onReady(); }, + onDisconnect: () => { relay = null; render(); }, + onProjectChange: () => { void syncContext(); }, +}); +(async () => { + const r = await whenRelayReady(2000, { installUrl: APP.installUrl }); + if (r && "connect" in r) { const grant = await r.permissions().catch(() => null); if (grant) { relay = r; wire(r); void onReady(); return; } } + else if (r && r.installed === false) notInstalled = true; + render(); +})(); +function wire(r) { if (wired) return; wired = true; r.on("permissionsChanged", () => void syncContext()); } + +// onReady fires TWICE by design — mountConnect's onConnect AND the returning-user probe, whichever +// wins the race. Hydrating from storage on BOTH passes is a real (timing-dependent) bug: the second +// pass re-reads the run the first pass just saved, REPLACING the in-memory object a running pipeline +// still holds a reference to. Hydrate once. (Learned the hard way in batch.js.) +let hydrated = false; +async function onReady() { + await syncContext(); + if (!hydrated) { hydrated = true; await loadState(); } + render(); autostart(); +} + +// CONTEXT-FIRST: the moment a context is lent, the brief (voice/angle/audience) derives from it — see +// draft.core.normalizeBrief. Hardcoded samples are allowed ONLY pre-connect, visibly labeled. +async function syncContext() { + if (!relay) return; + if (APP.usesContext === "single") brand = await relay.context.active().catch(() => null); + render(); +} + +// ==== per-origin state (values are opaque STRINGS — store JSON; keys are filenames, no ":") ===== +let state = { run: null }; +async function loadState() { try { const raw = await relay.storage.get(APP.id + "-state"); if (raw) state = JSON.parse(raw); } catch { state = { run: null }; } } +async function saveState() { try { await relay.storage.set(APP.id + "-state", JSON.stringify(state)); } catch { /* non-fatal */ } } + +// ==== house UI atoms (steer chips + connect steps) ========================================== +const STEER_CHIPS = ["plainer words", "more concrete", "shorter", "different angle", "less salesy"]; + +function connectSteps() { + const card = el("div", "steps-card"); + const steps = el("div", "steps"); + const s1 = el("div"); s1.innerHTML = notInstalled + ? "1 · Install Switchboard (button, top-right)" + : "1 · Connect Switchboard (top-right) — lends this page your Claude"; + const s2 = el("div"); s2.innerHTML = "2 · Topic + a format — it drafts a few distinct pieces"; + const s3 = el("div"); s3.innerHTML = "3 · Select any piece, say what to change, it re-runs just that one"; + steps.append(s1, s2, s3); + card.append(steps); + return card; +} + +// ==== APP LOGIC ═════════════════════════════════════════════════════════════════════════════ +// A run = one topic × one format × N pieces. Each piece is independent and editable in place: its own +// card, its own steers[], its own re-draft. There is no "pick one winner" here (that's batch/reachout); +// every piece is a keeper you can steer. The format picker is the doctrine's options-with-one- +// recommended slate; the count is a small stepper; the topic is the single free-text input. + +const COUNT_OPTIONS = [1, 3, 5]; +let running = false; // a batch draft is in flight +let topicText = ""; // the single input +let formatChoice = (FORMAT_OPTIONS.find((f) => f.recommended) || FORMAT_OPTIONS[0]).id; +let countChoice = 3; + +// The brief handed to draft/revise. Passing the lent context object keeps draft AND every later revise +// grounded in the SAME voice; when nothing is lent we pass undefined so the core falls back to the +// active context itself (identical result, one fewer round-trip here). +function activeBrief() { return brand || undefined; } +function formatLabel(id) { return FORMATS[id]?.label || id; } + +// ---- the batch draft (the "one-go" pipeline) ---- +async function start(topic) { + if (!relay || running) return; + topic = String(topic || "").trim(); + const format = formatChoice; + const n = Math.max(1, Number(countChoice) || 3); + state.run = { id: uid(), topic, format, n, status: "", pieces: [] }; + await saveState(); render(); + await draftAll(); +} + +async function draftAll() { + const r = state.run; if (!r || !relay || running) return; + running = true; + r.status = `drafting ${r.n} ${formatLabel(r.format).toLowerCase()}${r.n === 1 ? "" : "s"}…`; + r.error = null; + render(); + try { + const out = await draftPieces({ format: r.format, topic: r.topic, n: r.n, brief: activeBrief() }, relay); + const pieces = (out?.pieces || []).filter((p) => (p.body || "").trim()); + if (!pieces.length) throw new Error("nothing came back — try again"); + r.pieces = pieces.map((p) => ({ + id: uid(), + format: p.format || r.format, + hook: p.hook || "", + body: p.body || "", + steers: [], + redrafted: false, + busy: false, + error: null, + })); + } catch (e) { + r.error = msg(e); + } + running = false; r.status = ""; + await saveState(); render(); +} + +// ---- SELECT-AND-SAY: the primitive. steerRow(piece).onSteer → here → revise() → swap body in place. +// 1. push the typed words onto THIS piece's steers[] (latest wins, same contract as batch/reachout) +// 2. revise({ piece:{format,body}, steers, brief }, relay) — re-runs ONLY this piece on your Claude +// 3. replace this piece's body/hook in state, flag it re-drafted, re-render (in place) +// A per-piece `busy` flag freezes only that card's steer controls, so steering one piece never blocks +// the others — you can nudge three pieces at once. +async function steerOne(id, steer) { + const r = state.run; if (!r || !relay) return; + const p = r.pieces.find((x) => x.id === id); if (!p || p.busy) return; + const s = String(steer || "").trim(); if (!s) return; + p.steers.push(s); + p.busy = true; p.error = null; + render(); + try { + const out = await revisePiece({ piece: { format: p.format, body: p.body }, steers: p.steers, brief: activeBrief() }, relay); + const next = out?.piece; + if (!next || !(next.body || "").trim()) throw new Error("no revision came back"); + p.body = next.body; + p.hook = next.hook || p.hook; + p.format = next.format || p.format; + p.redrafted = true; + } catch (e) { + p.error = msg(e); + toast(msg(e), true); + } + p.busy = false; + await saveState(); render(); +} + +function newRun() { state.run = null; topicText = ""; void saveState(); render(); } + +// ---- export (copy / download the whole batch as .md) ---- +function runMd() { + const r = state.run; + const L = [ + `# Draft — ${formatLabel(r.format)}${r.topic ? ` · ${r.topic.slice(0, 80)}` : ""}`, + "", + `> ${r.pieces.length} piece${r.pieces.length === 1 ? "" : "s"} drafted on your own Claude${brand ? `, grounded in “${brand.name}”` : ""} — nothing fabricated. Edit into your own voice before posting.`, + "", + ]; + r.pieces.forEach((p, i) => { + L.push(`## ${i + 1}. ${p.hook || formatLabel(p.format)}${p.redrafted ? " (re-drafted)" : ""}`, "", p.body || "_(empty)_", ""); + }); + L.push("---", "Built with Draft, on your own Claude."); + return L.join("\n"); +} +async function copyAll() { + try { await navigator.clipboard.writeText(runMd()); toast("Copied ✓"); } + catch { toast("Couldn't copy — download instead.", true); } +} +function download() { + const blob = new Blob([runMd()], { type: "text/markdown" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `draft-${state.run.format}.md`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 5000); +} +async function copyOne(p) { + try { await navigator.clipboard.writeText(p.body || ""); toast("Piece copied ✓"); } + catch { toast("Couldn't copy this piece.", true); } +} + +// ---- cold open (the demo IS the product running) ---- +function autostart() { + // A saved mid-draft status must not restore as a live spinner (the redline sanitize lesson). + if (state.run) { state.run.status = ""; render(); return; } + // CONTEXT-FIRST cold open: a lent brand context is enough to begin with ZERO input — the moment you + // connect, Draft is already writing a few pieces straight from your brief (topic left empty → + // draft.core draws the most specific real angles from the brief). No form, no button. + if (brand) { topicText = ""; void start(""); } +} + +// ==== render ================================================================================ +function render() { + const hero = $("hero"), view = $("view"); + const r = state.run; + hero.hidden = !!r; + view.textContent = ""; + + if (!relay) { view.append(connectSteps()); return; } + if (!r) { view.append(setupScreen()); return; } + + // ---- run bar ---- + const bar = el("div", "runbar"); + bar.append(el("span", "kicker", "the brief")); + bar.append(el("span", "run-input", `${formatLabel(r.format)}${r.topic ? " · " + r.topic : brand ? " · from " + brand.name : ""}`)); + bar.append(el("span", "grow")); + if (!running && r.pieces.length) { + const rd = el("button", "act", "↻ redraft all"); rd.onclick = () => void draftAll(); bar.append(rd); + const cp = el("button", "act", "copy .md"); cp.onclick = () => void copyAll(); bar.append(cp); + const dl = el("button", "act", "⬇ download"); dl.onclick = download; bar.append(dl); + } + const nu = el("button", "act", "× new"); nu.onclick = newRun; bar.append(nu); + view.append(bar); + + if (r.status) view.append(researching(r.status)); + if (r.error) { + view.append(el("div", "err", r.error)); + const t = el("button", "act", "try again"); t.onclick = () => void draftAll(); view.append(t); + return; + } + + for (const p of r.pieces) view.append(pieceCard(p)); +} + +function setupScreen() { + const box = el("div", "start"); + if (brand) box.append(el("div", "ctx", "Grounded in your context — " + brand.name)); + else box.append(el("div", "ctx", "Tip: lend your brand in the chip so the writing sounds like you.")); + + // single input — the topic + const f1 = el("div", "field"); + f1.append(el("span", "kicker", "what's it about? — one line (leave blank to draw from your brief)")); + const row = el("div", "bindrow"); + const ta = el("textarea"); ta.rows = 2; ta.value = topicText || osCtxTitle(); + ta.placeholder = "e.g. what shipping in public actually feels like"; + ta.addEventListener("input", () => { topicText = ta.value; }); + row.append(ta); + f1.append(row); + box.append(f1); + + // format picker — the doctrine's options-with-one-recommended slate (the kit atom) + const f2 = el("div", "field"); + f2.append(el("span", "kicker", "format")); + f2.append(optionCards({ + options: FORMAT_OPTIONS.map((f) => ({ id: f.id, label: f.label, recommended: f.recommended })), + chosenId: formatChoice, + onChoose: (o) => { formatChoice = o.id; render(); }, + chosenNote: "", + })); + box.append(f2); + + // count — a small stepper + const f3 = el("div", "field"); + f3.append(el("span", "kicker", "how many")); + const counts = el("div", "countrow"); + for (const c of COUNT_OPTIONS) { + const b = el("button", "count" + (c === countChoice ? " on" : ""), String(c)); + b.onclick = () => { countChoice = c; render(); }; + counts.append(b); + } + f3.append(counts); + box.append(f3); + + const btn = el("button", "primary", brand && !(topicText || "").trim() ? "Draft from " + brand.name + " ▸" : "Draft ▸"); + btn.style.marginTop = "18px"; + btn.onclick = () => void start(topicText); + box.append(btn); + box.append(el("div", "hint", "Drafts on your Claude · each piece distinct · select any one and say what to change")); + setTimeout(() => ta.focus(), 30); + return box; +} + +function pieceCard(p) { + const card = el("div", "q-card piece"); + const head = el("div", "phead"); + head.append(el("span", "q-num", formatLabel(p.format))); + if (p.redrafted) head.append(el("span", "redraft-chip", "re-drafted")); + head.append(el("span", "grow")); + const cp = el("button", "act tiny", "copy"); cp.onclick = () => void copyOne(p); head.append(cp); + card.append(head); + + if (p.hook) card.append(el("div", "phook", p.hook)); + card.append(el("div", "pbody", p.body)); + + if (p.error) card.append(el("div", "err", p.error)); + // the per-piece "say" — select this piece (it's already the one you're steering) and tell it what to + // change. Disabled while THIS piece is re-drafting; other pieces stay live. + card.append(steerRow({ + onSteer: (s) => void steerOne(p.id, s), + chips: STEER_CHIPS, + disabled: p.busy, + kicker: p.busy ? "re-drafting this piece…" : "not quite? say what to change", + placeholder: "e.g. cut the last line, keep it plainer…", + })); + return card; +} +render(); + +// ---- God's hand: one page-tool, driving the real pipeline ------------------------------------ +// `draft_pieces` runs the SAME start() a click runs — N distinct pieces of a format draft themselves +// live in the DOM — then returns the batch as markdown for God to read. Pure model call; nothing is +// published or sent. (The select-and-say revise stays a human act at the notch.) +exposeToGod({ + name: "draft_pieces", + description: "Draft N distinct social posts of one format (x-thread, x-single, linkedin, ig-caption, ig-carousel) from a topic, grounded in the lent brief. Fills the cards live and returns the pieces as markdown; nothing is published.", + inputSchema: { + topic: "string? — what the posts are about. Omit to draw the strongest angles from the brief.", + format: "string? — one of: x-thread, x-single, linkedin, ig-caption, ig-carousel. Default the recommended (x-thread).", + n: "number? — how many distinct pieces. Default 3.", + }, + execute: async ({ topic, format, n } = {}) => { + const waitFor = async (cond, ms) => { const t = Date.now(); while (!cond()) { if (Date.now() - t > ms) return false; await new Promise((r) => setTimeout(r, 80)); } return true; }; + if (!await waitFor(() => !!relay, 6000)) throw new Error("Draft isn't connected to Switchboard yet"); + if (format && FORMATS[String(format).toLowerCase()]) formatChoice = String(format).toLowerCase(); + if (n != null && Number(n) > 0) countChoice = Math.max(1, Number(n)); + const val = String(topic || "").trim(); + for (let attempt = 0; attempt < 3; attempt++) { + await waitFor(() => !running, 180000); // let any in-flight run finish before we take the wheel + await start(val); // draftAll — all pieces, awaited + await waitFor(() => !running, 180000); + const r = state.run || {}; + if ((r.pieces || []).some((x) => (x.body || "").trim())) return { pieces: runMd() }; + } + throw new Error("Draft stayed busy — try again"); + }, +}); diff --git a/examples/apps/wrapps/draft/switchboard.json b/examples/apps/wrapps/draft/switchboard.json new file mode 100644 index 0000000..dab01fa --- /dev/null +++ b/examples/apps/wrapps/draft/switchboard.json @@ -0,0 +1,46 @@ +{ + "id": "draft", + "name": "Draft", + "tagline": "A topic into a few distinct posts on your Claude — then steer any one in place.", + "category": "agent", + "author": "Switchboard", + "components": { + "workflows": [ + "draft/draft", + "draft/revise" + ], + "ui": { + "kind": "web", + "url": "http://localhost:5174/draft.html" + } + }, + "surfaces": [ + "browser", + "batch" + ], + "requires": [ + { + "kind": "daemon" + }, + { + "kind": "model", + "class": "cloud" + } + ], + "inside": [ + "N genuinely different posts for one format (X, LinkedIn, Instagram), grounded in your brief", + "Select any piece and say what to change — only that piece re-runs, swapped in place (select-and-say)", + "Pure model call on your own Claude — no connector, nothing published or sent" + ], + "tools": [ + { + "name": "draft_pieces", + "description": "Draft N distinct social posts of one format (x-thread, x-single, linkedin, ig-caption, ig-carousel) from a topic, grounded in the lent brief. Renders the cards live and returns the pieces as markdown. Pure model call — nothing is published.", + "inputSchema": { + "topic": "string? — what the posts are about. Omit to draw the strongest angles from the brief.", + "format": "string? — one of: x-thread, x-single, linkedin, ig-caption, ig-carousel. Default x-thread.", + "n": "number? — how many distinct pieces. Default 3." + } + } + ] +} diff --git a/packages/switchboard-mcp/registry.mjs b/packages/switchboard-mcp/registry.mjs index f486ad3..0567a21 100644 --- a/packages/switchboard-mcp/registry.mjs +++ b/packages/switchboard-mcp/registry.mjs @@ -15,12 +15,15 @@ import redline from "../../examples/apps/src/core/redline.core.js"; import autopilot from "../../examples/apps/src/core/autopilot.core.js"; import ideabrain from "../../examples/apps/src/core/ideabrain.core.js"; import reachout from "../../examples/apps/src/core/reachout.core.js"; +import draft from "../../examples/apps/src/core/draft.core.js"; +import record from "../../examples/apps/src/core/record.core.js"; +import arrange from "../../examples/apps/src/core/arrange.core.js"; /** @typedef {{ name:string, summary:string, input:object, output:object, run:(input:any, sb:any)=>Promise }} WrappAction */ /** @typedef {{ name:string, title?:string, origin:string, scope?:object, actions:WrappAction[] }} WrappManifest */ /** @type {WrappManifest[]} */ -export const MANIFESTS = [adpulse, batch, redline, autopilot, ideabrain, reachout]; +export const MANIFESTS = [adpulse, batch, redline, autopilot, ideabrain, reachout, draft, record, arrange]; /** MCP tool name for an action, namespaced so two wrapps can't collide: wrapp____. */ export const toolName = (wrapp, action) => `wrapp__${wrapp}__${action}`;