diff --git a/check_coolify_logs.js b/check_coolify_logs.js
new file mode 100644
index 0000000..f0df1c7
--- /dev/null
+++ b/check_coolify_logs.js
@@ -0,0 +1,36 @@
+const DFS_LOGIN = process.env.COOLIFY_API_TOKEN;
+
+async function checkDeployment() {
+ const url = `${process.env.COOLIFY_URL}/api/v1/deployments?resource_uuid=y4cscsc8s08c8808go0448s0&per_page=1`;
+ console.log(`Pinging Coolify API at ${url}...`);
+
+ const response = await fetch(url, {
+ headers: {
+ "Authorization": `Bearer ${DFS_LOGIN}`
+ }
+ });
+
+ const data = await response.json();
+ if (data && data.length > 0) {
+ const deploy = data[0];
+ console.log(`\nDeployment UUID: ${deploy.deployment_uuid}`);
+ console.log(`Status: ${deploy.status}`);
+ console.log(`Created At: ${deploy.created_at}`);
+
+ // Parse the logs array to see what it's currently doing
+ try {
+ const logs = JSON.parse(deploy.logs);
+ if (logs && logs.length > 0) {
+ console.log("\nLast 5 log lines from the build container:");
+ logs.slice(-5).forEach(log => {
+ console.log(`[${log.timestamp}] ${log.type}: ${log.output.substring(0, 100)}`);
+ });
+ }
+ } catch(e) {
+ console.log("Could not parse logs array.");
+ }
+ } else {
+ console.log("No deployments found.");
+ }
+}
+checkDeployment().catch(console.error);
diff --git a/market_data_assets/camp_market_sizes.csv b/market_data_assets/camp_market_sizes.csv
new file mode 100644
index 0000000..2acca62
--- /dev/null
+++ b/market_data_assets/camp_market_sizes.csv
@@ -0,0 +1,47 @@
+Category,Tier,Canada,United States,Total Market Size
+"day_care_center",1,8875,49542,58417
+"martial_arts_school",2,2763,22131,24894
+"dance_school",2,2785,18951,21736
+"after_school_program",1,1765,18992,20757
+"community_center",2,2510,12556,15066
+"summer_camp",1,1301,12109,13410
+"music_school",2,2381,11015,13396
+"recreation_center",1,1672,8824,10496
+"camp",1,1133,7801,8934
+"sports_complex",2,1160,7013,8173
+"youth_organization",1,576,7351,7927
+"art_school",2,857,5538,6395
+"gymnastics_center",2,323,4655,4978
+"boxing_gym",2,519,4171,4690
+"equestrian_facility",2,666,3625,4291
+"children_amusement_center",1,262,3271,3533
+"swimming_school",2,385,2531,2916
+"ballet_school",2,231,2290,2521
+"adventure_sports_center",2,448,2000,2448
+"tennis_club",2,264,2043,2307
+"boot_camp",1,206,1971,2177
+"athletic_club",2,213,1959,2172
+"drum_school",2,147,1593,1740
+"baseball_club",2,105,1596,1701
+"boat_club",2,97,1477,1574
+"basketball_club",2,200,1349,1549
+"childrens_club",1,185,1211,1396
+"drama_school",2,268,1100,1368
+"baby_swimming_school",2,159,1202,1361
+"archery_range",2,76,844,920
+"surf_school",2,62,755,817
+"sailing_school",2,121,583,704
+"aquatic_center",2,93,506,599
+"cooking_school",2,84,479,563
+"drawing_lessons",2,86,379,465
+"equestrian_club",2,73,380,453
+"aikido_school",2,44,348,392
+"bicycle_club",2,64,318,382
+"archery_club",2,57,254,311
+"chess_club",2,53,218,271
+"childrens_farm",1,61,191,252
+"canoe_and_kayak_club",2,64,157,221
+"badminton_club",2,97,82,179
+"english_language_camp",1,31,101,132
+"capoeira_school",2,11,92,103
+"riding_school",2,0,0,0
diff --git a/new-site/Beta Signup.html b/new-site/Beta Signup.html
new file mode 100644
index 0000000..c6d36d1
--- /dev/null
+++ b/new-site/Beta Signup.html
@@ -0,0 +1,167 @@
+
+
+
+
+
+ Vibn — Request an invite
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/new-site/app.jsx b/new-site/app.jsx
new file mode 100644
index 0000000..61e5d90
--- /dev/null
+++ b/new-site/app.jsx
@@ -0,0 +1,227 @@
+// App — composes the page. Includes the sticky nav, the success modal that
+// appears when the user submits the hero prompt, and the Tweaks panel.
+
+const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+ "accent": ["#ff6b47", "#ffae9a", "#9c3a1f"],
+ "heroVariant": "promise",
+ "showStopMarker": true,
+ "showLivePill": false
+}/*EDITMODE-END*/;
+
+const ACCENT_PRESETS = {
+ coral: ["#ff6b47", "#ffae9a", "#9c3a1f"], // warm coral (default)
+ amber: ["#ffb347", "#ffd9a3", "#9c6e1f"], // soft amber
+ lime: ["#9ee649", "#d2f3a6", "#3f7a1c"], // electric lime
+ violet: ["#b07cff", "#dabfff", "#5a2fa3"], // violet
+};
+
+function applyAccent(arr) {
+ // arr[0] is the hero color we map to var(--accent); compute soft + glow + fg.
+ const hero = arr[0];
+ const soft = `${hero}24`; // 14% alpha
+ const glow = `${hero}59`; // 35% alpha
+ const root = document.documentElement;
+ root.style.setProperty("--accent", hero);
+ root.style.setProperty("--accent-soft", soft);
+ root.style.setProperty("--accent-glow", glow);
+ // Foreground on accent: derive a dark-on-accent for primary buttons.
+ root.style.setProperty("--accent-fg", "#1a0f0a");
+}
+
+function App() {
+ const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+ const [scrolled, setScrolled] = React.useState(false);
+ const [showLaunch, setShowLaunch] = React.useState(null);
+
+ React.useEffect(() => {
+ applyAccent(t.accent);
+ }, [t.accent]);
+
+ React.useEffect(() => {
+ const onScroll = () => setScrolled(window.scrollY > 8);
+ onScroll();
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
+ const handleStart = (prompt) => {
+ setShowLaunch(prompt || "Build me a tool for my business.");
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {showLaunch !== null && (
+ setShowLaunch(null)} />
+ )}
+
+
+
+ setTweak("accent", v)}
+ />
+
+
+ setTweak("heroVariant", v)}
+ />
+ setTweak("showLivePill", v)}
+ />
+
+
+ setTweak("showStopMarker", v)}
+ />
+
+
+
+ {/* Tweak-driven CSS overrides */}
+
+ >
+ );
+}
+
+function Nav({ scrolled }) {
+ return (
+
+
+
+ );
+}
+
+// Modal that fires when the user submits the hero prompt. Reassures them their
+// vibe will be honored — playfully sells the rest of the flow.
+function LaunchModal({ prompt, onClose }) {
+ React.useEffect(() => {
+ const onKey = (e) => { if (e.key === "Escape") onClose(); };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ const [step, setStep] = React.useState(0);
+ React.useEffect(() => {
+ if (step >= 4) return undefined;
+ const t = setTimeout(() => setStep(step + 1), 700);
+ return () => clearTimeout(t);
+ }, [step]);
+
+ return (
+
+
+
+
e.stopPropagation()}>
+
✕
+
Vibn is on it
+
Keep vibing — we've got the rest.
+
"{prompt}"
+
+
+ {["Drafting the screens", "Setting up logins", "Saving your stuff", "Putting it online"].map((s, i) => (
+
+ {i < step ? (
+
+
+
+ ) : i === step ? (
+
+ ) : (
+
+
+
+ )}
+
{s}
+
+ ))}
+
+
+
No homework · No setup · No new tools to learn
+
+
+ );
+}
+
+ReactDOM.createRoot(document.getElementById("root")).render( );
diff --git a/new-site/audience.jsx b/new-site/audience.jsx
new file mode 100644
index 0000000..5d0f897
--- /dev/null
+++ b/new-site/audience.jsx
@@ -0,0 +1,177 @@
+// Who it's for — three audience cards, each with a Reddit-style customer quote
+// and Vibn's answer.
+
+const AUDIENCE = [
+ {
+ label: "Small business owners",
+ icon: "shop",
+ quote: "I'm paying $312/month for software that does 60% of what I need and zero of the rest.",
+ source: "u/coffeeshop_owner · r/smallbusiness",
+ answer: "Build the tool that actually fits your shop — exactly your workflow, no monthly fee bleed.",
+ },
+ {
+ label: "Freelancers building for clients",
+ icon: "spark",
+ quote: "My client wants a quote tool. I can mock the frontend in a day. The backend? Two weeks I don't have.",
+ source: "u/agency_of_one · r/freelance",
+ answer: "Deliver the whole thing — login, data, hosting — in the same chat where you built the screens.",
+ },
+ {
+ label: "Anyone with an idea",
+ icon: "spark2",
+ quote: "I built the homepage in an afternoon. Then the AI told me to 'just deploy it' and I cried.",
+ source: "u/first_time_builder · r/sideproject",
+ answer: "No deploys. No GitHub. No fear. The thing you described is online, with logins, ready for users.",
+ },
+];
+
+function Audience() {
+ return (
+
+
+
+
+
+
Who Vibn is for
+
+ People who have an idea — not a stack.
+
+
+ If you've ever felt this, Vibn was built for you.
+
+
+
+
+ {AUDIENCE.map((a) => (
+
+
+
{a.label}
+
+
+ "{a.quote}"
+
— {a.source}
+
+
+
+ Vibn
+ {a.answer}
+
+
+ ))}
+
+
+
+ );
+}
+
+function AudienceIcon({ name }) {
+ const p = { width: 20, height: 20, viewBox: "0 0 20 20", fill: "none",
+ stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
+ if (name === "shop") return (
+
+ );
+ if (name === "spark") return (
+
+ );
+ if (name === "spark2") return (
+
+ );
+ return null;
+}
+
+Object.assign(window, { Audience });
diff --git a/new-site/beta.jsx b/new-site/beta.jsx
new file mode 100644
index 0000000..5ff7808
--- /dev/null
+++ b/new-site/beta.jsx
@@ -0,0 +1,809 @@
+// Beta signup — invite request flow with submit/confirmed states.
+
+function Arrow({ size = 14 }) {
+ return (
+
+
+
+ );
+}
+
+function Glow({ color = "var(--accent-glow)", size = 700, opacity = 1, style = {} }) {
+ return (
+
+ );
+}
+
+const ROLES = [
+ { value: "smb", label: "Small business owner", hint: "I run a shop, salon, studio, café…" },
+ { value: "freelancer", label: "Freelancer / agency", hint: "I build tools for clients" },
+ { value: "ideaperson", label: "I just have an idea", hint: "First-time builder, no code" },
+];
+
+const SOURCES = ["Reddit", "Twitter / X", "TikTok", "YouTube", "A friend", "Google", "Something else"];
+
+const BENEFITS = [
+ {
+ icon: "lightning",
+ title: "First access",
+ body: "Skip the queue when public beta opens. You build before everyone else.",
+ },
+ {
+ icon: "gift",
+ title: "90 days of Pro, free",
+ body: "Full launch features — hosting, marketing, customer acquisition — on the house.",
+ },
+ {
+ icon: "chat",
+ title: "Direct line to the team",
+ body: "Private channel with the people building Vibn. Your feedback ships.",
+ },
+];
+
+function BetaApp() {
+ const [submitted, setSubmitted] = React.useState(false);
+ const [submitting, setSubmitting] = React.useState(false);
+ const [scrolled, setScrolled] = React.useState(false);
+ const [form, setForm] = React.useState({
+ email: "",
+ name: "",
+ build: "",
+ role: "smb",
+ source: "",
+ });
+
+ React.useEffect(() => {
+ const onScroll = () => setScrolled(window.scrollY > 8);
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
+ const update = (k, v) => setForm((f) => ({ ...f, [k]: v }));
+
+ const valid = /\S+@\S+\.\S+/.test(form.email) && form.build.trim().length > 4;
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!valid || submitting) return;
+ setSubmitting(true);
+ setTimeout(() => {
+ setSubmitting(false);
+ setSubmitted(true);
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }, 700);
+ };
+
+ // Stable "queue position" based on email — feels real, deterministic.
+ const queuePos = React.useMemo(() => {
+ let h = 7;
+ for (const c of form.email) h = (h * 31 + c.charCodeAt(0)) >>> 0;
+ return 2100 + (h % 900); // 2,100 – 2,999
+ }, [form.email]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {submitted ? (
+
+ ) : (
+ <>
+
+ Closed beta · invite-only
+
+ Be one of the first to vibe with Vibn .
+
+
+ We're letting in 50 new builders a week .
+ Tell us what you want to build — the most exciting ideas get the invite first.
+
+
+
+
+ >
+ )}
+
+ {/* What you get — shown on both states */}
+
+
+
What you get on the inside
+
+
+ {BENEFITS.map((b) => (
+
+
+
{b.title}
+
{b.body}
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
+
+function Field({ label, title, hint, required, children }) {
+ return (
+
+
+
{label}{required && * }
+
+
{title}
+ {hint &&
{hint}
}
+
+
+
{children}
+
+ );
+}
+
+function BenefitIcon({ name }) {
+ const p = { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none",
+ stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
+ if (name === "lightning") return ;
+ if (name === "gift") return ;
+ if (name === "chat") return ;
+ return null;
+}
+
+// ── Submitted state ─────────────────────────────────────────────────────────
+
+function Confirmed({ form, queuePos }) {
+ const [copied, setCopied] = React.useState(false);
+ // Fake-but-stable referral code
+ const ref = React.useMemo(() => {
+ const seed = form.email || form.name || "anon";
+ let h = 5;
+ for (const c of seed) h = (h * 33 + c.charCodeAt(0)) >>> 0;
+ return "v-" + h.toString(36).slice(0, 6);
+ }, [form.email, form.name]);
+ const link = typeof window !== "undefined" ? `${window.location.origin}/join?ref=${ref}` : `vibn.app/join?ref=${ref}`;
+
+ const copyLink = () => {
+ try { navigator.clipboard.writeText(link); } catch (e) { /* noop */ }
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1800);
+ };
+
+ // Compute a queue progress bar percentage — visual feedback only
+ const pct = Math.max(2, Math.min(98, 100 - (queuePos - 2100) / 9));
+
+ return (
+
+
+
+
You're on the list
+
+ {form.name ? <>Welcome, {form.name} .> : <>You're in line .>}
+
+
+ We got your invite request — keep an eye on {form.email} .
+
+
+
+
+
+
+
your spot in line
+
#{queuePos.toLocaleString()}
+
+
+
+
+
+ You should hear from us in ~{Math.ceil((queuePos - 50) / 50)} weeks . Don't want to wait?
+
+
+
+
+
+
Skip the line
+
Send 3 friends — jump to the front.
+
Each friend who joins via your link bumps you up 500 spots.
+
+
+
+ vibn.app/join?ref=
+ {ref}
+
+
+ {copied ? "Copied!" : "Copy link"}
+
+
+
+
+
+ {form.build && (
+
+
What we'll help you build first
+
"{form.build}"
+
+ )}
+
+ );
+}
+
+function ShareIcon({ name }) {
+ const p = { width: 14, height: 14, viewBox: "0 0 16 16", fill: "currentColor" };
+ if (name === "x") return ;
+ if (name === "reddit") return ;
+ if (name === "mail") return ;
+ return null;
+}
+
+// ── Styles ──────────────────────────────────────────────────────────────────
+
+function BetaStyle() {
+ return ;
+}
+
+ReactDOM.createRoot(document.getElementById("root")).render( );
diff --git a/new-site/closing.jsx b/new-site/closing.jsx
new file mode 100644
index 0000000..a01550d
--- /dev/null
+++ b/new-site/closing.jsx
@@ -0,0 +1,150 @@
+// Closing CTA + Footer.
+
+function Closing() {
+ return (
+
+
+
+
+
+
+
+
+ If you can describe it,
+ you can build it.
+
+
+ And you can keep building it — all the way to customers.
+ No new tools. No homework. No going back to the wall.
+
+
+
+
+
+ );
+}
+
+function Footer() {
+ return (
+
+
+
+
+
+
+ 🇨🇦 Built in Canada
+ ·
+ Your data stays safe
+ ·
+ No credit card to start
+
+
+
+
© 2026 Vibn Inc. · Made for makers, not engineers.
+
+
+
+
+ );
+}
+
+Object.assign(window, { Closing, Footer });
diff --git a/new-site/crossed.jsx b/new-site/crossed.jsx
new file mode 100644
index 0000000..295c6c1
--- /dev/null
+++ b/new-site/crossed.jsx
@@ -0,0 +1,134 @@
+// Crossed-out list — technical terms struck through, ending in "Your AI handles
+// all of it. You just keep building."
+
+const CROSSED_TERMS = [
+ "Databases",
+ "Auth providers",
+ "GitHub",
+ "Hosting",
+ "API keys",
+ "Environment variables",
+ "Deployment",
+ "Backend code",
+ "Servers",
+ "DNS records",
+ "SSL certificates",
+ "CORS errors",
+ "Webhooks",
+ "Build pipelines",
+ "package.json",
+ "npm install",
+];
+
+function CrossedOut() {
+ return (
+
+
+
+
+
+
What you don't have to learn
+
+ All the stuff that made you give up last time.
+
+
Forget every word on this list.
+
+
+
+ {CROSSED_TERMS.map((term, i) => (
+
+ {term}
+
+ ))}
+
+
+
+ Your AI handles all of it .
+
+ You just keep building.
+
+
+
+ );
+}
+
+Object.assign(window, { CrossedOut });
diff --git a/new-site/hero.jsx b/new-site/hero.jsx
new file mode 100644
index 0000000..aa5727f
--- /dev/null
+++ b/new-site/hero.jsx
@@ -0,0 +1,370 @@
+// Hero: the Reddit quote headline + prompt input.
+// Visitors can type into the prompt; cycling placeholders, suggestion chips, submit handler logs to console.
+
+const HERO_PLACEHOLDERS = [
+ "A booking site for my dog grooming business…",
+ "An invoice tracker for my freelance clients…",
+ "A members-only recipe site for my supper club…",
+ "A custom CRM for our 3-person real estate team…",
+ "A tip calculator app for our restaurant staff…",
+ "A waitlist site for my new ceramics studio…",
+];
+
+const HERO_CHIPS = [
+ "📋 Client intake form",
+ "📅 Booking site",
+ "🧾 Invoice tracker",
+ "🛒 Online store",
+ "📰 Email newsletter",
+];
+
+function Hero({ onStart, variant = "quote" }) {
+ const [text, setText] = React.useState("");
+ const [phIdx, setPhIdx] = React.useState(0);
+ const [phChars, setPhChars] = React.useState(0);
+ const [deleting, setDeleting] = React.useState(false);
+ const taRef = React.useRef(null);
+
+ // Type-on placeholder when textarea is empty.
+ React.useEffect(() => {
+ if (text.length > 0) return undefined;
+ const full = HERO_PLACEHOLDERS[phIdx];
+ const speed = deleting ? 18 : 38;
+ const t = setTimeout(() => {
+ if (!deleting) {
+ if (phChars < full.length) setPhChars(phChars + 1);
+ else setTimeout(() => setDeleting(true), 1700);
+ } else {
+ if (phChars > 0) setPhChars(phChars - 1);
+ else {
+ setDeleting(false);
+ setPhIdx((phIdx + 1) % HERO_PLACEHOLDERS.length);
+ }
+ }
+ }, speed);
+ return () => clearTimeout(t);
+ }, [text, phIdx, phChars, deleting]);
+
+ const placeholder = HERO_PLACEHOLDERS[phIdx].slice(0, phChars);
+
+ const submit = () => {
+ const value = text || HERO_PLACEHOLDERS[phIdx];
+ if (onStart) onStart(value);
+ };
+
+ const useChip = (chip) => {
+ const clean = chip.replace(/^[^\w]+/, "").trim();
+ setText(`Build me ${clean.toLowerCase()} for my business.`);
+ if (taRef.current) taRef.current.focus();
+ };
+
+ return (
+
+
+
+ {/* ambient glows behind hero */}
+
+
+
+
+
+
Live from minute one
+
+ {variant === "promise" ? (
+ <>
+
+ Keep vibing .
+ All the way to launch.
+
+
idea → live → marketed → customers
+
+ "I built my product, now what?" Vibn is the answer.
+ Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
+
+ >
+ ) : (
+ <>
+
+ " I built my product,
+ now what?"
+
+
posted 2 hours ago · r/SideProject
+
+ Keep vibing. All the way to launch.
+ Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
+
+ >
+ )}
+
+ {/* Prompt */}
+
+
+
+
+
setText(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) submit(); }}
+ placeholder=""
+ aria-label="Describe what you want to build"
+ />
+ {text.length === 0 && (
+ {placeholder}
+ )}
+
+
+
+
+ Screenshot
+
+
+ Voice
+
+
+ Templates
+
+
+
+ Start building
+
+
+
+
+
+ {/* Suggestion chips */}
+
+ {HERO_CHIPS.map((c) => (
+ useChip(c)}>{c}
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
+
+function PromptIcon({ name }) {
+ const props = { width: 13, height: 13, viewBox: "0 0 16 16", fill: "none",
+ stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
+ if (name === "paperclip") return (
+
+ );
+ if (name === "mic") return (
+
+ );
+ if (name === "grid") return (
+
+ );
+ return null;
+}
+
+Object.assign(window, { Hero });
diff --git a/new-site/index.html b/new-site/index.html
new file mode 100644
index 0000000..84a792e
--- /dev/null
+++ b/new-site/index.html
@@ -0,0 +1,219 @@
+
+
+
+
+
+ Vibn — Keep vibing. All the way to launch.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/new-site/journey.jsx b/new-site/journey.jsx
new file mode 100644
index 0000000..8e7aaa2
--- /dev/null
+++ b/new-site/journey.jsx
@@ -0,0 +1,333 @@
+// The Journey — 4 steps from idea → first 100 customers. A visual marker shows
+// where most tools stop. Each step shows a tiny "demo" snippet of what Vibn does.
+
+const JOURNEY_STEPS = [
+ {
+ num: "01",
+ title: "You describe it.",
+ sub: "The AI builds it.",
+ body: "Talk to it like you'd talk to a friend who codes. It builds the screens, the buttons, the logic — whatever your idea needs.",
+ demo: "describe",
+ },
+ {
+ num: "02",
+ title: "It goes live.",
+ sub: "The AI puts it online.",
+ body: "Logins, saving your stuff, hosting — handled. You get a live link from minute one. Share it. Show your friends. It just works.",
+ demo: "live",
+ },
+ {
+ num: "03",
+ title: "It gets seen.",
+ sub: "The AI markets it.",
+ body: "Posts, emails, social — written, scheduled, and shipped on autopilot. The tone matches your brand because you trained it talking to your AI.",
+ demo: "seen",
+ },
+ {
+ num: "04",
+ title: "It gets customers.",
+ sub: "Your first 100.",
+ body: "Through our Google partnership, Vibn helps the right people find your product when they're searching for what you built.",
+ demo: "customers",
+ },
+];
+
+function Journey() {
+ return (
+
+
+
+
+
+
The journey
+
+ From idea to first 100 customers.
+ In one chat.
+
+
+ Other tools take you to step two and wave goodbye. Vibn keeps building with you.
+
+
+
+
+ {/* "Where everyone else stops" marker, sits over the gap between cards 2 and 3 */}
+
+
+
↑ Where every other tool stops
+
+
+
+ {JOURNEY_STEPS.map((step, i) => (
+
= 2} />
+ ))}
+
+
+
+ One tool. One chat. From "wouldn't it be cool if…" to real customers paying you money.
+
+
+
+ );
+}
+
+function StepCard({ step, stopped }) {
+ return (
+
+
+
{step.num}
+
{step.title}
+
{step.sub}
+
{step.body}
+
+
+
+ );
+}
+
+function StepDemo({ demo }) {
+ if (demo === "describe") {
+ return (
+
+
YOU build a booking site for my dog grooming biz
+
VIBN on it — designing screens…
+
+ VIBN
+ ✓ booking flow ready
+
+
+ );
+ }
+ if (demo === "live") {
+ return (
+
+
+ VIBN
+ put it online
+
+
+
+ pawsandposh.vibn.app
+
+
✓ logins · ✓ saving · ✓ live
+
+ );
+ }
+ if (demo === "seen") {
+ return (
+
+
VIBN draft a launch post for Instagram + email blast
+
↳ scheduled for Tue 9:00 AM
+
↳ scheduled for Thu 6:00 PM
+
✓ 3 channels on autopilot
+
+ );
+ }
+ if (demo === "customers") {
+ return (
+
+
+ +47this week
+
+
+
+
+
+
+ found you via Google
+
+
✓ tracking toward 100
+
+ );
+ }
+ return null;
+}
+
+Object.assign(window, { Journey });
diff --git a/new-site/primitives.jsx b/new-site/primitives.jsx
new file mode 100644
index 0000000..01d9880
--- /dev/null
+++ b/new-site/primitives.jsx
@@ -0,0 +1,108 @@
+// Small shared primitives: logo, arrow icon, ambient glow, eyebrow, trust strip.
+
+// The "V_" mark — bold filled V + terminal-cursor underscore. Sized via the
+// outer .logo-mark; the SVG fills it. `stroke-linejoin="round"` + a thin
+// stroke on the filled paths softens the corners just enough.
+function LogoMark({ size = 26, blink = true }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function Logo({ size = 26 }) {
+ return (
+
+
+ vibn
+
+ );
+}
+
+function Arrow({ size = 14 }) {
+ return (
+
+
+
+ );
+}
+
+function Eyebrow({ children }) {
+ return {children}
;
+}
+
+// Soft radial glow blob for ambient backgrounds. Place absolutely positioned.
+function Glow({ color = "var(--accent-glow)", size = 700, opacity = 1, style = {} }) {
+ return (
+
+ );
+}
+
+function TrustStrip({ items }) {
+ return (
+
+ {items.map((item, i) => (
+
+ {i > 0 && · }
+ {item}
+
+ ))}
+
+ );
+}
+
+// A subtle gradient hairline used inside cards & frames.
+function Hairline({ vertical = false, style = {} }) {
+ return (
+
+ );
+}
+
+Object.assign(window, { Logo, LogoMark, Arrow, Eyebrow, Glow, TrustStrip, Hairline });
diff --git a/new-site/tweaks-panel.jsx b/new-site/tweaks-panel.jsx
new file mode 100644
index 0000000..79ccfe9
--- /dev/null
+++ b/new-site/tweaks-panel.jsx
@@ -0,0 +1,568 @@
+
+// tweaks-panel.jsx
+// Reusable Tweaks shell + form-control helpers.
+//
+// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
+// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
+// individual prototypes don't re-roll it. Ships a consistent set of controls so you
+// don't hand-draw , segmented radios, steppers, etc.
+//
+// Usage (in an HTML file that loads React + Babel):
+//
+// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+// "primaryColor": "#D97757",
+// "palette": ["#D97757", "#29261b", "#f6f4ef"],
+// "fontSize": 16,
+// "density": "regular",
+// "dark": false
+// }/*EDITMODE-END*/;
+//
+// function App() {
+// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+// return (
+//
+// Hello
+//
+//
+// setTweak('fontSize', v)} />
+// setTweak('density', v)} />
+//
+// setTweak('primaryColor', v)} />
+// setTweak('palette', v)} />
+// setTweak('dark', v)} />
+//
+//
+// );
+// }
+//
+// ─────────────────────────────────────────────────────────────────────────────
+
+const __TWEAKS_STYLE = `
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
+ transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
+ background:rgba(250,249,247,.78);color:#29261b;
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
+ overflow-y:auto;overflow-x:hidden;min-height:0;
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
+ .twk-body::-webkit-scrollbar{width:8px}
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
+ border:2px solid transparent;background-clip:content-box}
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
+ border:2px solid transparent;background-clip:content-box}
+ .twk-row{display:flex;flex-direction:column;gap:5px}
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
+ color:rgba(41,38,27,.72)}
+ .twk-lbl>span:first-child{font-weight:500}
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
+
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
+ color:rgba(41,38,27,.45);padding:10px 0 0}
+ .twk-sect:first-child{padding-top:0}
+
+ .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
+ select.twk-field{padding-right:22px;
+ background-image:url("data:image/svg+xml;utf8, ");
+ background-repeat:no-repeat;background-position:right 8px center}
+
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
+ width:14px;height:14px;border-radius:50%;background:#fff;
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
+ background:rgba(0,0,0,.06);user-select:none}
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
+ overflow-wrap:anywhere}
+
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
+ .twk-toggle[data-on="1"]{background:#34c759}
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
+
+ .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
+ user-select:none;padding-right:8px}
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
+ outline:none;color:inherit;-moz-appearance:textfield}
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
+ -webkit-appearance:none;margin:0}
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
+
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
+
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
+ background:transparent;flex-shrink:0}
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
+
+ .twk-chips{display:flex;gap:6px}
+ .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
+ padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
+ box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
+ transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
+ .twk-chip:hover{transform:translateY(-1px);
+ box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
+ .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
+ 0 2px 6px rgba(0,0,0,.15)}
+ .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
+ display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i:first-child{box-shadow:none}
+ .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
+ filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
+`;
+
+// ── useTweaks ───────────────────────────────────────────────────────────────
+// Single source of truth for tweak values. setTweak persists via the host
+// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
+function useTweaks(defaults) {
+ const [values, setValues] = React.useState(defaults);
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
+ // useState-style call doesn't write a "[object Object]" key into the persisted
+ // JSON block.
+ const setTweak = React.useCallback((keyOrEdits, val) => {
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
+ ? keyOrEdits : { [keyOrEdits]: val };
+ setValues((prev) => ({ ...prev, ...edits }));
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
+ // Same-window signal so in-page listeners (deck-stage rail thumbnails)
+ // can react — the parent message only reaches the host, not peers.
+ window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
+ }, []);
+ return [values, setTweak];
+}
+
+// ── TweaksPanel ─────────────────────────────────────────────────────────────
+// Floating shell. Registers the protocol listener BEFORE announcing
+// availability — if the announce ran first, the host's activate could land
+// before our handler exists and the toolbar toggle would silently no-op.
+// The close button posts __edit_mode_dismissed so the host's toolbar toggle
+// flips off in lockstep; the host echoes __deactivate_edit_mode back which
+// is what actually hides the panel.
+function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
+ const [open, setOpen] = React.useState(false);
+ const dragRef = React.useRef(null);
+ // Auto-inject a rail toggle when a is on the page. The
+ // toggle drives the deck's per-viewer _railVisible via window message;
+ // state is mirrored from the same localStorage key the deck reads so
+ // the control reflects reality across reloads. The mechanism is the
+ // message — authors who want custom placement can post it directly
+ // and pass noDeckControls to suppress this one.
+ const hasDeckStage = React.useMemo(
+ () => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
+ [],
+ );
+ // deck-stage enables its rail in connectedCallback, but this panel can
+ // mount before that element has upgraded. The initial read catches the
+ // common case; the listener covers mounting first. (Older deck-stage.js
+ // copies still wait for the host's __omelette_rail_enabled postMessage —
+ // same listener handles those.)
+ const [railEnabled, setRailEnabled] = React.useState(
+ () => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
+ );
+ React.useEffect(() => {
+ if (!hasDeckStage || railEnabled) return undefined;
+ const onMsg = (e) => {
+ if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
+ };
+ window.addEventListener('message', onMsg);
+ return () => window.removeEventListener('message', onMsg);
+ }, [hasDeckStage, railEnabled]);
+ const [railVisible, setRailVisible] = React.useState(() => {
+ try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
+ });
+ const toggleRail = (on) => {
+ setRailVisible(on);
+ window.postMessage({ type: '__deck_rail_visible', on }, '*');
+ };
+ const offsetRef = React.useRef({ x: 16, y: 16 });
+ const PAD = 16;
+
+ const clampToViewport = React.useCallback(() => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const w = panel.offsetWidth, h = panel.offsetHeight;
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
+ offsetRef.current = {
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
+ };
+ panel.style.right = offsetRef.current.x + 'px';
+ panel.style.bottom = offsetRef.current.y + 'px';
+ }, []);
+
+ React.useEffect(() => {
+ if (!open) return;
+ clampToViewport();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', clampToViewport);
+ return () => window.removeEventListener('resize', clampToViewport);
+ }
+ const ro = new ResizeObserver(clampToViewport);
+ ro.observe(document.documentElement);
+ return () => ro.disconnect();
+ }, [open, clampToViewport]);
+
+ React.useEffect(() => {
+ const onMsg = (e) => {
+ const t = e?.data?.type;
+ if (t === '__activate_edit_mode') setOpen(true);
+ else if (t === '__deactivate_edit_mode') setOpen(false);
+ };
+ window.addEventListener('message', onMsg);
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
+ return () => window.removeEventListener('message', onMsg);
+ }, []);
+
+ const dismiss = () => {
+ setOpen(false);
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
+ };
+
+ const onDragStart = (e) => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const r = panel.getBoundingClientRect();
+ const sx = e.clientX, sy = e.clientY;
+ const startRight = window.innerWidth - r.right;
+ const startBottom = window.innerHeight - r.bottom;
+ const move = (ev) => {
+ offsetRef.current = {
+ x: startRight - (ev.clientX - sx),
+ y: startBottom - (ev.clientY - sy),
+ };
+ clampToViewport();
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ };
+
+ if (!open) return null;
+ return (
+ <>
+
+
+
+ {title}
+ e.stopPropagation()}
+ onClick={dismiss}>✕
+
+
+ {children}
+ {hasDeckStage && railEnabled && !noDeckControls && (
+
+
+
+ )}
+
+
+ >
+ );
+}
+
+// ── Layout helpers ──────────────────────────────────────────────────────────
+
+function TweakSection({ label, children }) {
+ return (
+ <>
+ {label}
+ {children}
+ >
+ );
+}
+
+function TweakRow({ label, value, children, inline = false }) {
+ return (
+
+
+ {label}
+ {value != null && {value} }
+
+ {children}
+
+ );
+}
+
+// ── Controls ────────────────────────────────────────────────────────────────
+
+function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
+ return (
+
+ onChange(Number(e.target.value))} />
+
+ );
+}
+
+function TweakToggle({ label, value, onChange }) {
+ return (
+
+
{label}
+
onChange(!value)}>
+
+ );
+}
+
+function TweakRadio({ label, value, options, onChange }) {
+ const trackRef = React.useRef(null);
+ const [dragging, setDragging] = React.useState(false);
+ // The active value is read by pointer-move handlers attached for the lifetime
+ // of a drag — ref it so a stale closure doesn't fire onChange for every move.
+ const valueRef = React.useRef(value);
+ valueRef.current = value;
+
+ // Segments wrap mid-word once per-segment width runs out. The track is
+ // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
+ // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
+ // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
+ // back to a dropdown rather than wrap.
+ const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
+ const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
+ const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
+ if (!fitsAsSegments) {
+ // emits strings — map back to the original option value so the
+ // fallback stays type-preserving (numbers, booleans) like the segment path.
+ const resolve = (s) => {
+ const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
+ return m === undefined ? s : typeof m === 'object' ? m.value : m;
+ };
+ return onChange(resolve(s))} />;
+ }
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
+ const n = opts.length;
+
+ const segAt = (clientX) => {
+ const r = trackRef.current.getBoundingClientRect();
+ const inner = r.width - 4;
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
+ };
+
+ const onPointerDown = (e) => {
+ setDragging(true);
+ const v0 = segAt(e.clientX);
+ if (v0 !== valueRef.current) onChange(v0);
+ const move = (ev) => {
+ if (!trackRef.current) return;
+ const v = segAt(ev.clientX);
+ if (v !== valueRef.current) onChange(v);
+ };
+ const up = () => {
+ setDragging(false);
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+
+ return (
+
+
+
+ {opts.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ );
+}
+
+function TweakSelect({ label, value, options, onChange }) {
+ return (
+
+ onChange(e.target.value)}>
+ {options.map((o) => {
+ const v = typeof o === 'object' ? o.value : o;
+ const l = typeof o === 'object' ? o.label : o;
+ return {l} ;
+ })}
+
+
+ );
+}
+
+function TweakText({ label, value, placeholder, onChange }) {
+ return (
+
+ onChange(e.target.value)} />
+
+ );
+}
+
+function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
+ const clamp = (n) => {
+ if (min != null && n < min) return min;
+ if (max != null && n > max) return max;
+ return n;
+ };
+ const startRef = React.useRef({ x: 0, val: 0 });
+ const onScrubStart = (e) => {
+ e.preventDefault();
+ startRef.current = { x: e.clientX, val: value };
+ const decimals = (String(step).split('.')[1] || '').length;
+ const move = (ev) => {
+ const dx = ev.clientX - startRef.current.x;
+ const raw = startRef.current.val + dx * step;
+ const snapped = Math.round(raw / step) * step;
+ onChange(clamp(Number(snapped.toFixed(decimals))));
+ };
+ const up = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+ return (
+
+ {label}
+ onChange(clamp(Number(e.target.value)))} />
+ {unit && {unit} }
+
+ );
+}
+
+// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
+// read on both #111 and #fafafa without per-option configuration. Hex input
+// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
+function __twkIsLight(hex) {
+ const h = String(hex).replace('#', '');
+ const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
+ const n = parseInt(x.slice(0, 6), 16);
+ if (Number.isNaN(n)) return true;
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
+ return r * 299 + g * 587 + b * 114 > 148000;
+}
+
+const __TwkCheck = ({ light }) => (
+
+
+
+);
+
+// TweakColor — curated color/palette picker. Each option is either a single
+// hex string or an array of 1-5 hex strings; the card adapts — a lone color
+// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
+// rest stacked in a sharp column on the right. onChange emits the
+// option in the shape it was passed (string stays string, array stays array).
+// Without options it falls back to the native color input for back-compat.
+function TweakColor({ label, value, options, onChange }) {
+ if (!options || !options.length) {
+ return (
+
+
{label}
+
onChange(e.target.value)} />
+
+ );
+ }
+ // Native emits lowercase hex per the HTML spec, so
+ // compare case-insensitively. String() guards JSON.stringify(undefined),
+ // which returns the primitive undefined (no .toLowerCase).
+ const key = (o) => String(JSON.stringify(o)).toLowerCase();
+ const cur = key(value);
+ return (
+
+
+ {options.map((o, i) => {
+ const colors = Array.isArray(o) ? o : [o];
+ const [hero, ...rest] = colors;
+ const sup = rest.slice(0, 4);
+ const on = key(o) === cur;
+ return (
+ onChange(o)}>
+ {sup.length > 0 && (
+
+ {sup.map((c, j) => )}
+
+ )}
+ {on && <__TwkCheck light={__twkIsLight(hero)} />}
+
+ );
+ })}
+
+
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+ {label}
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});
diff --git a/new-site/wall.jsx b/new-site/wall.jsx
new file mode 100644
index 0000000..caab36e
--- /dev/null
+++ b/new-site/wall.jsx
@@ -0,0 +1,251 @@
+// The Wall — recreates the moment the vibe dies. Faux chat from a "generic" AI
+// coding tool that hands back a homework list. Ends on the punchline.
+
+function Wall() {
+ return (
+
+
+
+
+
+
The wall
+
+ Every other tool stops right here .
+
+
+ You built it. It works on your laptop. Then the chat hands you a list.
+
+
+
+
+
+
+
untitled-project · main
+
generic ai coder · chat
+
+
+
+
+
YOU
+
+
You · just now
+
okay it works!! how do i put this online so my customers can use it?
+
+
+
+
+
AI
+
+
Generic AI · just now
+
+ Great job 🎉 Your app is running locally. To take it live, you'll need to set a few things up first:
+
+
+ Sign up for Supabase and create a project for your database.↗ external
+ Configure authentication with Supabase Auth or Clerk — pick one.↗ external
+ Create a GitHub repo , commit your code, and push it.↗ external
+ Deploy to Vercel : connect repo, configure framework preset.↗ external
+ Add environment variables for your API keys and DB url in the Vercel dashboard.↗ external
+ Set up DNS for your custom domain and verify nameservers with your registrar.↗ external
+ Configure SSL / TLS certificates for HTTPS (or use Vercel's automatic provisioning).↗ external
+ Set up Stripe if you want to take payments, and configure webhooks.↗ external
+
+
↓ 23 more steps
+
+
+
+
+
+
+
+
+
+
+ And just like that — the vibe is gone.
+
+
+
+
+ );
+}
+
+Object.assign(window, { Wall });
diff --git a/patch_stripe_keys.js b/patch_stripe_keys.js
new file mode 100644
index 0000000..1d8c3ec
--- /dev/null
+++ b/patch_stripe_keys.js
@@ -0,0 +1,17 @@
+const fs = require('fs');
+
+const envPath = 'vibn-frontend/.env.local';
+
+let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
+
+if (!content.includes('STRIPE_CLIENT_ID')) {
+ content += `\nSTRIPE_CLIENT_ID=ca_UTuWw2qE8wFLNlWOL7T1v0H5GdB6BtDw\n`;
+ fs.writeFileSync(envPath, content);
+ console.log("✅ Successfully added STRIPE_CLIENT_ID to .env.local!");
+} else {
+ // Replace it just in case
+ content = content.replace(/STRIPE_CLIENT_ID=.*/, 'STRIPE_CLIENT_ID=ca_UTuWw2qE8wFLNlWOL7T1v0H5GdB6BtDw');
+ fs.writeFileSync(envPath, content);
+ console.log("✅ Successfully updated STRIPE_CLIENT_ID in .env.local!");
+}
+
diff --git a/vibn-agent-runner/src/agent-session-runner.ts b/vibn-agent-runner/src/agent-session-runner.ts
index 670fd03..cbae5b3 100644
--- a/vibn-agent-runner/src/agent-session-runner.ts
+++ b/vibn-agent-runner/src/agent-session-runner.ts
@@ -12,189 +12,243 @@
* - Calls vibn-frontend's PATCH /api/projects/[id]/agent/sessions/[sid]
*/
-import { execSync } from 'child_process';
-import { createLLM, toOAITools, LLMMessage } from './llm';
-import { AgentConfig } from './agents';
-import { executeTool, ToolContext } from './tools';
-import { resolvePrompt } from './prompts/loader';
-import { ingestSessionEvents } from './vibn-events-ingest';
+import { execSync } from "child_process";
+import { createLLM, toOAITools, LLMMessage } from "./llm";
+import { AgentConfig } from "./agents";
+import { executeTool, ToolContext } from "./tools";
+import { resolvePrompt } from "./prompts/loader";
+import { ingestSessionEvents } from "./vibn-events-ingest";
const MAX_TURNS = 60;
export interface OutputLine {
- ts: string;
- type: 'step' | 'stdout' | 'stderr' | 'info' | 'error' | 'done';
- text: string;
+ ts: string;
+ type: "step" | "stdout" | "stderr" | "info" | "error" | "done";
+ text: string;
}
export interface SessionRunOptions {
- sessionId: string;
- projectId: string;
- vibnApiUrl: string; // e.g. https://vibnai.com
- appPath: string; // relative path within repo, e.g. "apps/admin"
- repoRoot?: string; // absolute path to the git repo root (for auto-commit)
- isStopped: () => boolean;
- // Auto-approve: commit + push + deploy without user confirmation
- autoApprove?: boolean;
- giteaRepo?: string; // e.g. "mark/sportsy"
- coolifyAppUuid?: string;
- coolifyApiUrl?: string;
- coolifyApiToken?: string;
+ sessionId: string;
+ projectId: string;
+ vibnApiUrl: string; // e.g. https://vibnai.com
+ appPath: string; // relative path within repo, e.g. "apps/admin"
+ repoRoot?: string; // absolute path to the git repo root (for auto-commit)
+ isStopped: () => boolean;
+ // Auto-approve: commit + push + deploy without user confirmation
+ autoApprove?: boolean;
+ giteaRepo?: string; // e.g. "mark/sportsy"
+ coolifyAppUuid?: string;
+ coolifyApiUrl?: string;
+ coolifyApiToken?: string;
}
// ── VIBN DB bridge ────────────────────────────────────────────────────────────
async function patchSession(
- opts: SessionRunOptions,
- payload: {
- status?: string;
- outputLine?: OutputLine;
- changedFile?: { path: string; status: string };
- error?: string;
- }
+ opts: SessionRunOptions,
+ payload: {
+ status?: string;
+ outputLine?: OutputLine;
+ changedFile?: { path: string; status: string };
+ error?: string;
+ },
): Promise {
- const url = `${opts.vibnApiUrl}/api/projects/${opts.projectId}/agent/sessions/${opts.sessionId}`;
- try {
- await fetch(url, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json', 'x-agent-runner-secret': process.env.AGENT_RUNNER_SECRET ?? '' },
- body: JSON.stringify(payload),
- });
- } catch (err) {
- // Log but don't crash — output will be lost for this line but loop continues
- console.warn('[session-runner] PATCH failed:', err instanceof Error ? err.message : err);
- }
+ const url = `${opts.vibnApiUrl}/api/projects/${opts.projectId}/agent/sessions/${opts.sessionId}`;
+ try {
+ await fetch(url, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "x-agent-runner-secret": process.env.AGENT_RUNNER_SECRET ?? "",
+ },
+ body: JSON.stringify(payload),
+ });
+ } catch (err) {
+ // Log but don't crash — output will be lost for this line but loop continues
+ console.warn(
+ "[session-runner] PATCH failed:",
+ err instanceof Error ? err.message : err,
+ );
+ }
}
function now(): string {
- return new Date().toISOString();
+ return new Date().toISOString();
}
// ── File change tracking ──────────────────────────────────────────────────────
-const FILE_WRITE_TOOLS = new Set(['write_file', 'replace_in_file', 'create_file']);
+const FILE_WRITE_TOOLS = new Set([
+ "write_file",
+ "replace_in_file",
+ "create_file",
+]);
function extractChangedFile(
- toolName: string,
- args: Record,
- workspaceRoot: string,
- appPath: string
+ toolName: string,
+ args: Record,
+ workspaceRoot: string,
+ appPath: string,
): { path: string; status: string } | null {
- if (!FILE_WRITE_TOOLS.has(toolName)) return null;
- const rawPath = String(args.path ?? args.file_path ?? '');
- if (!rawPath) return null;
+ if (!FILE_WRITE_TOOLS.has(toolName)) return null;
+ const rawPath = String(args.path ?? args.file_path ?? "");
+ if (!rawPath) return null;
- // Make path relative to appPath for display
- const fullPrefix = `${workspaceRoot}/${appPath}/`;
- const appPrefix = `${appPath}/`;
- let displayPath = rawPath
- .replace(fullPrefix, '')
- .replace(appPrefix, '');
+ // Make path relative to appPath for display
+ const fullPrefix = `${workspaceRoot}/${appPath}/`;
+ const appPrefix = `${appPath}/`;
+ let displayPath = rawPath.replace(fullPrefix, "").replace(appPrefix, "");
- const fileStatus = toolName === 'write_file' ? 'added' : 'modified';
- return { path: displayPath, status: fileStatus };
+ const fileStatus = toolName === "write_file" ? "added" : "modified";
+ return { path: displayPath, status: fileStatus };
}
// ── Auto-commit helper ────────────────────────────────────────────────────────
async function autoCommitAndDeploy(
- opts: SessionRunOptions,
- task: string,
- emit: (line: OutputLine) => Promise
+ opts: SessionRunOptions,
+ task: string,
+ emit: (line: OutputLine) => Promise,
): Promise {
- const repoRoot = opts.repoRoot;
- if (!repoRoot || !opts.giteaRepo) {
- await emit({ ts: now(), type: 'info', text: 'Auto-approve skipped — no repo root available.' });
- return;
- }
+ const repoRoot = opts.repoRoot;
+ if (!repoRoot || !opts.giteaRepo) {
+ await emit({
+ ts: now(),
+ type: "info",
+ text: "Auto-approve skipped — no repo root available.",
+ });
+ return;
+ }
- const gitOpts = { cwd: repoRoot, stdio: 'pipe' as const };
- const giteaApiUrl = process.env.GITEA_API_URL || '';
- const giteaUsername = process.env.GITEA_USERNAME || 'agent';
- const giteaToken = process.env.GITEA_API_TOKEN || '';
+ const gitOpts = { cwd: repoRoot, stdio: "pipe" as const };
+ const giteaApiUrl = process.env.GITEA_API_URL || "";
+ const giteaUsername = process.env.GITEA_USERNAME || "agent";
+ const giteaToken = process.env.GITEA_API_TOKEN || "";
+ try {
try {
- try {
- execSync('git config user.email "agent@vibnai.com"', gitOpts);
- execSync('git config user.name "VIBN Agent"', gitOpts);
- } catch { /* already set */ }
-
- execSync('git add -A', gitOpts);
-
- const status = execSync('git status --porcelain', gitOpts).toString().trim();
- if (!status) {
- await emit({ ts: now(), type: 'info', text: '✓ No file changes to commit.' });
- await patchSession(opts, { status: 'approved' });
- return;
- }
-
- const commitMsg = `agent: ${task.slice(0, 72)}`;
- execSync(`git commit -m ${JSON.stringify(commitMsg)}`, gitOpts);
- await emit({ ts: now(), type: 'info', text: `✓ Committed: "${commitMsg}"` });
-
- const authedUrl = `${giteaApiUrl}/${opts.giteaRepo}.git`
- .replace('https://', `https://${giteaUsername}:${giteaToken}@`);
- execSync(`git push "${authedUrl}" HEAD:main`, gitOpts);
- await emit({ ts: now(), type: 'info', text: '✓ Pushed to Gitea.' });
-
- // Optional Coolify deploy
- let deployed = false;
- if (opts.coolifyApiUrl && opts.coolifyApiToken && opts.coolifyAppUuid) {
- try {
- const deployRes = await fetch(
- `${opts.coolifyApiUrl}/api/v1/applications/${opts.coolifyAppUuid}/start`,
- { method: 'POST', headers: { Authorization: `Bearer ${opts.coolifyApiToken}` } }
- );
- deployed = deployRes.ok;
- if (deployed) await emit({ ts: now(), type: 'info', text: '✓ Deployment triggered.' });
- } catch { /* best-effort */ }
- }
-
- await patchSession(opts, {
- status: 'approved',
- outputLine: {
- ts: now(), type: 'done',
- text: `✓ Auto-committed & ${deployed ? 'deployed' : 'pushed'}. No approval needed.`,
- },
- });
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- await emit({ ts: now(), type: 'error', text: `Auto-commit failed: ${msg}` });
- // Fall back to done so user can manually approve
- await patchSession(opts, { status: 'done' });
+ execSync('git config user.email "agent@vibnai.com"', gitOpts);
+ execSync('git config user.name "VIBN Agent"', gitOpts);
+ } catch {
+ /* already set */
}
+
+ execSync("git add -A", gitOpts);
+
+ const status = execSync("git status --porcelain", gitOpts)
+ .toString()
+ .trim();
+ if (!status) {
+ await emit({
+ ts: now(),
+ type: "info",
+ text: "✓ No file changes to commit.",
+ });
+ await patchSession(opts, { status: "approved" });
+ return;
+ }
+
+ const commitMsg = `agent: ${task.slice(0, 72)}`;
+ const msgFile = require("path").join(
+ opts.workspaceRoot,
+ ".git",
+ "COMMIT_EDITMSG",
+ );
+ require("fs").writeFileSync(msgFile, commitMsg, "utf8");
+ execSync("git commit -F .git/COMMIT_EDITMSG", gitOpts);
+ try {
+ require("fs").unlinkSync(msgFile);
+ } catch {}
+ await emit({
+ ts: now(),
+ type: "info",
+ text: `✓ Committed: "${commitMsg}"`,
+ });
+
+ const authedUrl = `${giteaApiUrl}/${opts.giteaRepo}.git`.replace(
+ "https://",
+ `https://${giteaUsername}:${giteaToken}@`,
+ );
+ execSync(`git push "${authedUrl}" HEAD:main`, gitOpts);
+ await emit({ ts: now(), type: "info", text: "✓ Pushed to Gitea." });
+
+ // Optional Coolify deploy
+ let deployed = false;
+ if (opts.coolifyApiUrl && opts.coolifyApiToken && opts.coolifyAppUuid) {
+ try {
+ const deployRes = await fetch(
+ `${opts.coolifyApiUrl}/api/v1/applications/${opts.coolifyAppUuid}/start`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${opts.coolifyApiToken}` },
+ },
+ );
+ deployed = deployRes.ok;
+ if (deployed)
+ await emit({
+ ts: now(),
+ type: "info",
+ text: "✓ Deployment triggered.",
+ });
+ } catch {
+ /* best-effort */
+ }
+ }
+
+ await patchSession(opts, {
+ status: "approved",
+ outputLine: {
+ ts: now(),
+ type: "done",
+ text: `✓ Auto-committed & ${deployed ? "deployed" : "pushed"}. No approval needed.`,
+ },
+ });
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ await emit({
+ ts: now(),
+ type: "error",
+ text: `Auto-commit failed: ${msg}`,
+ });
+ // Fall back to done so user can manually approve
+ await patchSession(opts, { status: "done" });
+ }
}
// ── Main streaming execution loop ─────────────────────────────────────────────
export async function runSessionAgent(
- config: AgentConfig,
- task: string,
- ctx: ToolContext,
- opts: SessionRunOptions
+ config: AgentConfig,
+ task: string,
+ ctx: ToolContext,
+ opts: SessionRunOptions,
): Promise {
- const llm = createLLM(config.model, { temperature: 0.2 });
- const oaiTools = toOAITools(config.tools);
+ const llm = createLLM(config.model, { temperature: 0.2 });
+ const oaiTools = toOAITools(config.tools);
- const emit = async (line: OutputLine) => {
- console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`);
- await Promise.all([
- patchSession(opts, { outputLine: line }),
- ingestSessionEvents(opts.vibnApiUrl, opts.projectId, opts.sessionId, [
- {
- type: `output.${line.type}`,
- payload: { text: line.text },
- ts: line.ts,
- },
- ]),
- ]);
- };
+ const emit = async (line: OutputLine) => {
+ console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`);
+ await Promise.all([
+ patchSession(opts, { outputLine: line }),
+ ingestSessionEvents(opts.vibnApiUrl, opts.projectId, opts.sessionId, [
+ {
+ type: `output.${line.type}`,
+ payload: { text: line.text },
+ ts: line.ts,
+ },
+ ]),
+ ]);
+ };
- await emit({ ts: now(), type: 'info', text: `Agent starting (${llm.modelId}) — working in ${opts.appPath}` });
+ await emit({
+ ts: now(),
+ type: "info",
+ text: `Agent starting (${llm.modelId}) — working in ${opts.appPath}`,
+ });
- // Scope the system prompt to the specific app within the monorepo
- const basePrompt = resolvePrompt(config.promptId);
- const scopedPrompt = `${basePrompt}
+ // Scope the system prompt to the specific app within the monorepo
+ const basePrompt = resolvePrompt(config.promptId);
+ const scopedPrompt = `${basePrompt}
## Active context
You are working inside the monorepo directory: ${opts.appPath}
@@ -203,135 +257,166 @@ When running commands, always cd into ${opts.appPath} first unless already there
Do NOT run git commit or git push — the platform handles committing after you finish.
`;
- const history: LLMMessage[] = [
- { role: 'user', content: task }
+ const history: LLMMessage[] = [{ role: "user", content: task }];
+
+ let turn = 0;
+ let finalText = "";
+ const trackedFiles = new Map(); // path → status
+
+ while (turn < MAX_TURNS) {
+ // Check for stop signal between turns
+ if (opts.isStopped()) {
+ await emit({ ts: now(), type: "info", text: "Stopped by user." });
+ await patchSession(opts, { status: "stopped" });
+ return;
+ }
+
+ turn++;
+ await emit({ ts: now(), type: "info", text: `Turn ${turn} — thinking…` });
+
+ const messages: LLMMessage[] = [
+ { role: "system", content: scopedPrompt },
+ ...history,
];
- let turn = 0;
- let finalText = '';
- const trackedFiles = new Map(); // path → status
-
- while (turn < MAX_TURNS) {
- // Check for stop signal between turns
- if (opts.isStopped()) {
- await emit({ ts: now(), type: 'info', text: 'Stopped by user.' });
- await patchSession(opts, { status: 'stopped' });
- return;
- }
-
- turn++;
- await emit({ ts: now(), type: 'info', text: `Turn ${turn} — thinking…` });
-
- const messages: LLMMessage[] = [
- { role: 'system', content: scopedPrompt },
- ...history
- ];
-
- let response: Awaited>;
- try {
- response = await llm.chat(messages, oaiTools, 8192);
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- await emit({ ts: now(), type: 'error', text: `LLM error: ${msg}` });
- await patchSession(opts, { status: 'failed', error: msg });
- return;
- }
-
- const assistantMsg: LLMMessage = {
- role: 'assistant',
- content: response.content,
- tool_calls: response.tool_calls.length > 0 ? response.tool_calls : undefined
- };
- history.push(assistantMsg);
-
- // Agent finished — no more tool calls
- if (response.tool_calls.length === 0) {
- finalText = response.content ?? 'Task complete.';
- break;
- }
-
- // Execute each tool call
- for (const tc of response.tool_calls) {
- if (opts.isStopped()) break;
-
- const fnName = tc.function.name;
- let fnArgs: Record = {};
- try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ }
-
- // Human-readable step label
- const stepLabel = buildStepLabel(fnName, fnArgs);
- await emit({ ts: now(), type: 'step', text: stepLabel });
-
- let result: unknown;
- try {
- result = await executeTool(fnName, fnArgs, ctx);
- } catch (err) {
- result = { error: err instanceof Error ? err.message : String(err) };
- }
-
- // Stream stdout/stderr if present
- if (result && typeof result === 'object') {
- const r = result as Record;
- if (r.stdout && String(r.stdout).trim()) {
- for (const line of String(r.stdout).split('\n').filter(Boolean).slice(0, 40)) {
- await emit({ ts: now(), type: 'stdout', text: line });
- }
- }
- if (r.stderr && String(r.stderr).trim()) {
- for (const line of String(r.stderr).split('\n').filter(Boolean).slice(0, 20)) {
- await emit({ ts: now(), type: 'stderr', text: line });
- }
- }
- if (r.error) {
- await emit({ ts: now(), type: 'error', text: String(r.error) });
- }
- }
-
- // Track file changes
- const changed = extractChangedFile(fnName, fnArgs, ctx.workspaceRoot, opts.appPath);
- if (changed && !trackedFiles.has(changed.path)) {
- trackedFiles.set(changed.path, changed.status);
- await patchSession(opts, { changedFile: changed });
- await emit({ ts: now(), type: 'info', text: `${changed.status === 'added' ? '+ Created' : '~ Modified'} ${changed.path}` });
- }
-
- history.push({
- role: 'tool',
- tool_call_id: tc.id,
- name: fnName,
- content: typeof result === 'string' ? result : JSON.stringify(result)
- });
- }
+ let response: Awaited>;
+ try {
+ response = await llm.chat(messages, oaiTools, 8192);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ await emit({ ts: now(), type: "error", text: `LLM error: ${msg}` });
+ await patchSession(opts, { status: "failed", error: msg });
+ return;
}
- if (turn >= MAX_TURNS && !finalText) {
- finalText = `Hit the ${MAX_TURNS}-turn limit. Stopping.`;
+ const assistantMsg: LLMMessage = {
+ role: "assistant",
+ content: response.content,
+ tool_calls:
+ response.tool_calls.length > 0 ? response.tool_calls : undefined,
+ };
+ history.push(assistantMsg);
+
+ // Agent finished — no more tool calls
+ if (response.tool_calls.length === 0) {
+ finalText = response.content ?? "Task complete.";
+ break;
}
- await emit({ ts: now(), type: 'done', text: finalText });
+ // Execute each tool call
+ for (const tc of response.tool_calls) {
+ if (opts.isStopped()) break;
- if (opts.autoApprove) {
- await autoCommitAndDeploy(opts, task, emit);
- } else {
- await patchSession(opts, {
- status: 'done',
- outputLine: { ts: now(), type: 'done', text: '✓ Complete — review changes and approve to commit.' },
+ const fnName = tc.function.name;
+ let fnArgs: Record = {};
+ try {
+ fnArgs = JSON.parse(tc.function.arguments || "{}");
+ } catch {
+ /* bad JSON */
+ }
+
+ // Human-readable step label
+ const stepLabel = buildStepLabel(fnName, fnArgs);
+ await emit({ ts: now(), type: "step", text: stepLabel });
+
+ let result: unknown;
+ try {
+ result = await executeTool(fnName, fnArgs, ctx);
+ } catch (err) {
+ result = { error: err instanceof Error ? err.message : String(err) };
+ }
+
+ // Stream stdout/stderr if present
+ if (result && typeof result === "object") {
+ const r = result as Record;
+ if (r.stdout && String(r.stdout).trim()) {
+ for (const line of String(r.stdout)
+ .split("\n")
+ .filter(Boolean)
+ .slice(0, 40)) {
+ await emit({ ts: now(), type: "stdout", text: line });
+ }
+ }
+ if (r.stderr && String(r.stderr).trim()) {
+ for (const line of String(r.stderr)
+ .split("\n")
+ .filter(Boolean)
+ .slice(0, 20)) {
+ await emit({ ts: now(), type: "stderr", text: line });
+ }
+ }
+ if (r.error) {
+ await emit({ ts: now(), type: "error", text: String(r.error) });
+ }
+ }
+
+ // Track file changes
+ const changed = extractChangedFile(
+ fnName,
+ fnArgs,
+ ctx.workspaceRoot,
+ opts.appPath,
+ );
+ if (changed && !trackedFiles.has(changed.path)) {
+ trackedFiles.set(changed.path, changed.status);
+ await patchSession(opts, { changedFile: changed });
+ await emit({
+ ts: now(),
+ type: "info",
+ text: `${changed.status === "added" ? "+ Created" : "~ Modified"} ${changed.path}`,
});
+ }
+
+ history.push({
+ role: "tool",
+ tool_call_id: tc.id,
+ name: fnName,
+ content: typeof result === "string" ? result : JSON.stringify(result),
+ });
}
+ }
+
+ if (turn >= MAX_TURNS && !finalText) {
+ finalText = `Hit the ${MAX_TURNS}-turn limit. Stopping.`;
+ }
+
+ await emit({ ts: now(), type: "done", text: finalText });
+
+ if (opts.autoApprove) {
+ await autoCommitAndDeploy(opts, task, emit);
+ } else {
+ await patchSession(opts, {
+ status: "done",
+ outputLine: {
+ ts: now(),
+ type: "done",
+ text: "✓ Complete — review changes and approve to commit.",
+ },
+ });
+ }
}
// ── Step label helpers ────────────────────────────────────────────────────────
function buildStepLabel(tool: string, args: Record): string {
- switch (tool) {
- case 'read_file': return `Read ${args.path ?? args.file_path}`;
- case 'write_file': return `Write ${args.path ?? args.file_path}`;
- case 'replace_in_file': return `Edit ${args.path ?? args.file_path}`;
- case 'list_directory': return `List ${args.path ?? '.'}`;
- case 'find_files': return `Find files: ${args.pattern}`;
- case 'search_code': return `Search: ${args.query}`;
- case 'execute_command': return `Run: ${String(args.command ?? '').slice(0, 80)}`;
- case 'git_commit_and_push': return `Git commit: "${args.message}"`;
- default: return `${tool}(${JSON.stringify(args).slice(0, 60)})`;
- }
+ switch (tool) {
+ case "read_file":
+ return `Read ${args.path ?? args.file_path}`;
+ case "write_file":
+ return `Write ${args.path ?? args.file_path}`;
+ case "replace_in_file":
+ return `Edit ${args.path ?? args.file_path}`;
+ case "list_directory":
+ return `List ${args.path ?? "."}`;
+ case "find_files":
+ return `Find files: ${args.pattern}`;
+ case "search_code":
+ return `Search: ${args.query}`;
+ case "execute_command":
+ return `Run: ${String(args.command ?? "").slice(0, 80)}`;
+ case "git_commit_and_push":
+ return `Git commit: "${args.message}"`;
+ default:
+ return `${tool}(${JSON.stringify(args).slice(0, 60)})`;
+ }
}
diff --git a/vibn-agent-runner/src/llm.ts b/vibn-agent-runner/src/llm.ts
index a62061e..0edd99d 100644
--- a/vibn-agent-runner/src/llm.ts
+++ b/vibn-agent-runner/src/llm.ts
@@ -1,7 +1,7 @@
-import { GoogleAuth } from 'google-auth-library';
-import { GoogleGenAI } from '@google/genai';
-import AnthropicVertex from '@anthropic-ai/vertex-sdk';
-import { v4 as uuidv4 } from 'uuid';
+import { GoogleAuth } from "google-auth-library";
+import { GoogleGenAI } from "@google/genai";
+import AnthropicVertex from "@anthropic-ai/vertex-sdk";
+import { v4 as uuidv4 } from "uuid";
// =============================================================================
// Unified LLM client — OpenAI-compatible message format throughout
@@ -22,46 +22,64 @@ import { v4 as uuidv4 } from 'uuid';
// ---------------------------------------------------------------------------
export interface LLMMessage {
- role: 'system' | 'user' | 'assistant' | 'tool';
- content: string | null;
- tool_calls?: LLMToolCall[];
- tool_call_id?: string; // set on role=tool messages
- name?: string; // function name on role=tool messages
+ role: "system" | "user" | "assistant" | "tool";
+ content: string | null;
+ tool_calls?: LLMToolCall[];
+ tool_call_id?: string; // set on role=tool messages
+ name?: string; // function name on role=tool messages
}
export interface LLMToolCall {
- id: string;
- type: 'function';
- function: {
- name: string;
- arguments: string; // JSON-encoded string
- };
+ id: string;
+ type: "function";
+ function: {
+ name: string;
+ arguments: string; // JSON-encoded string
+ };
}
export interface LLMTool {
- type: 'function';
- function: {
- name: string;
- description: string;
- parameters: Record;
- };
+ type: "function";
+ function: {
+ name: string;
+ description: string;
+ parameters: Record;
+ };
}
export interface LLMResponse {
- content: string | null;
- reasoning: string | null; // GLM-5 chain-of-thought
- tool_calls: LLMToolCall[];
- finish_reason: string;
- usage?: {
- prompt_tokens: number;
- completion_tokens: number;
- total_tokens: number;
- };
+ content: string | null;
+ reasoning: string | null; // GLM-5 chain-of-thought
+ tool_calls: LLMToolCall[];
+ finish_reason: string;
+ usage?: {
+ prompt_tokens: number;
+ completion_tokens: number;
+ total_tokens: number;
+ };
+}
+
+/**
+ * Strips DeepSeek-specific XML tags like and from content
+ * so it doesn't leak into the model's history and cause subsequent hallucinations.
+ */
+function stripModelMarkup(text: string | null | undefined): string | null {
+ if (!text) return null;
+ return (
+ text
+ .replace(/[\s\S]*?<\/tool_calls>/g, "")
+ .replace(/[\s\S]*?<\/think>/g, "")
+ .trim() || null
+ );
}
export interface LLMClient {
- modelId: string;
- chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens?: number): Promise;
+ modelId: string;
+ chat(
+ messages: LLMMessage[],
+ tools?: LLMTool[],
+ maxTokens?: number,
+ ): Promise;
}
// ---------------------------------------------------------------------------
@@ -69,7 +87,7 @@ export interface LLMClient {
// Used for: zai-org/glm-5-maas, anthropic/claude-sonnet-4-6, etc.
// ---------------------------------------------------------------------------
-let _cachedToken = '';
+let _cachedToken = "";
let _tokenExpiry = 0;
// Build GoogleAuth with explicit service account credentials when available.
@@ -77,113 +95,131 @@ let _tokenExpiry = 0;
// an env var since it contains no newlines or special shell characters.
// Falls back to the GCP metadata server (works on VMs with correct scopes).
function buildGoogleAuth(): GoogleAuth {
- const b64Key = process.env.GCP_SA_KEY_BASE64;
- if (b64Key) {
- try {
- const jsonStr = Buffer.from(b64Key, 'base64').toString('utf8');
- const credentials = JSON.parse(jsonStr);
- return new GoogleAuth({ credentials, scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
- } catch {
- console.warn('[llm] GCP_SA_KEY_BASE64 is set but failed to decode/parse — falling back to metadata server');
- }
+ const b64Key = process.env.GCP_SA_KEY_BASE64;
+ if (b64Key) {
+ try {
+ const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
+ const credentials = JSON.parse(jsonStr);
+ return new GoogleAuth({
+ credentials,
+ scopes: ["https://www.googleapis.com/auth/cloud-platform"],
+ });
+ } catch {
+ console.warn(
+ "[llm] GCP_SA_KEY_BASE64 is set but failed to decode/parse — falling back to metadata server",
+ );
}
- return new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
+ }
+ return new GoogleAuth({
+ scopes: ["https://www.googleapis.com/auth/cloud-platform"],
+ });
}
const _googleAuth = buildGoogleAuth();
async function getVertexToken(): Promise {
- const now = Date.now();
- if (_cachedToken && now < _tokenExpiry) return _cachedToken;
- const client = await _googleAuth.getClient();
- const tokenResponse = await client.getAccessToken();
- _cachedToken = tokenResponse.token!;
- _tokenExpiry = now + 55 * 60 * 1000; // tokens last 1hr, refresh at 55min
- return _cachedToken;
+ const now = Date.now();
+ if (_cachedToken && now < _tokenExpiry) return _cachedToken;
+ const client = await _googleAuth.getClient();
+ const tokenResponse = await client.getAccessToken();
+ _cachedToken = tokenResponse.token!;
+ _tokenExpiry = now + 55 * 60 * 1000; // tokens last 1hr, refresh at 55min
+ return _cachedToken;
}
export class VertexOpenAIClient implements LLMClient {
- modelId: string;
- private projectId: string;
- private region: string;
- private temperature: number;
+ modelId: string;
+ private projectId: string;
+ private region: string;
+ private temperature: number;
- constructor(modelId: string, opts?: { projectId?: string; region?: string; temperature?: number }) {
- this.modelId = modelId;
- this.projectId = opts?.projectId ?? process.env.GCP_PROJECT_ID ?? 'master-ai-484822';
- this.region = opts?.region ?? 'global';
- this.temperature = opts?.temperature ?? 0.3;
+ constructor(
+ modelId: string,
+ opts?: { projectId?: string; region?: string; temperature?: number },
+ ) {
+ this.modelId = modelId;
+ this.projectId =
+ opts?.projectId ?? process.env.GCP_PROJECT_ID ?? "master-ai-484822";
+ this.region = opts?.region ?? "global";
+ this.temperature = opts?.temperature ?? 0.3;
+ }
+
+ async chat(
+ messages: LLMMessage[],
+ tools?: LLMTool[],
+ maxTokens = 4096,
+ ): Promise {
+ const base =
+ this.region === "global"
+ ? "https://aiplatform.googleapis.com"
+ : `https://${this.region}-aiplatform.googleapis.com`;
+ const url = `${base}/v1/projects/${this.projectId}/locations/${this.region}/endpoints/openapi/chat/completions`;
+
+ const body: Record = {
+ model: this.modelId,
+ messages,
+ max_tokens: maxTokens,
+ temperature: this.temperature,
+ stream: false,
+ };
+
+ if (tools && tools.length > 0) {
+ body.tools = tools;
+ body.tool_choice = "auto";
}
- async chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens = 4096): Promise {
- const base = this.region === 'global'
- ? 'https://aiplatform.googleapis.com'
- : `https://${this.region}-aiplatform.googleapis.com`;
- const url = `${base}/v1/projects/${this.projectId}/locations/${this.region}/endpoints/openapi/chat/completions`;
+ // Retry with exponential backoff on 429 / 503 (rate limit / overload)
+ const MAX_RETRIES = 4;
+ const RETRY_STATUSES = new Set([429, 503]);
- const body: Record = {
- model: this.modelId,
- messages,
- max_tokens: maxTokens,
- temperature: this.temperature,
- stream: false
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
+ const token = await getVertexToken();
+ const res = await fetch(url, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (res.ok) {
+ const data = (await res.json()) as any;
+ const choice = data.choices?.[0];
+ const message = choice?.message ?? {};
+ return {
+ content: stripModelMarkup(message.content),
+ reasoning: stripModelMarkup(message.reasoning_content),
+ tool_calls: message.tool_calls ?? [],
+ finish_reason: choice?.finish_reason ?? "stop",
+ usage: data.usage,
};
+ }
- if (tools && tools.length > 0) {
- body.tools = tools;
- body.tool_choice = 'auto';
- }
+ const errText = await res.text();
- // Retry with exponential backoff on 429 / 503 (rate limit / overload)
- const MAX_RETRIES = 4;
- const RETRY_STATUSES = new Set([429, 503]);
+ // Force token refresh on 401
+ if (res.status === 401) _tokenExpiry = 0;
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
- const token = await getVertexToken();
- const res = await fetch(url, {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(body)
- });
+ if (RETRY_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
+ // Check for Retry-After header, otherwise use exponential backoff
+ const retryAfter = res.headers.get("retry-after");
+ const waitMs = retryAfter
+ ? Math.min(parseInt(retryAfter, 10) * 1000, 60_000)
+ : Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
+ console.warn(
+ `[llm] Vertex ${res.status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`,
+ );
+ await new Promise((r) => setTimeout(r, waitMs));
+ continue;
+ }
- if (res.ok) {
- const data = await res.json() as any;
- const choice = data.choices?.[0];
- const message = choice?.message ?? {};
- return {
- content: message.content ?? null,
- reasoning: message.reasoning_content ?? null,
- tool_calls: message.tool_calls ?? [],
- finish_reason: choice?.finish_reason ?? 'stop',
- usage: data.usage
- };
- }
-
- const errText = await res.text();
-
- // Force token refresh on 401
- if (res.status === 401) _tokenExpiry = 0;
-
- if (RETRY_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
- // Check for Retry-After header, otherwise use exponential backoff
- const retryAfter = res.headers.get('retry-after');
- const waitMs = retryAfter
- ? Math.min(parseInt(retryAfter, 10) * 1000, 60_000)
- : Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
- console.warn(`[llm] Vertex ${res.status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`);
- await new Promise(r => setTimeout(r, waitMs));
- continue;
- }
-
- throw new Error(`Vertex API ${res.status}: ${errText.slice(0, 400)}`);
- }
-
- // TypeScript requires an explicit throw after the loop (unreachable in practice)
- throw new Error('Vertex API: exceeded max retries');
+ throw new Error(`Vertex API ${res.status}: ${errText.slice(0, 400)}`);
}
+
+ // TypeScript requires an explicit throw after the loop (unreachable in practice)
+ throw new Error("Vertex API: exceeded max retries");
+ }
}
// ---------------------------------------------------------------------------
@@ -193,99 +229,116 @@ export class VertexOpenAIClient implements LLMClient {
// ---------------------------------------------------------------------------
export class GeminiClient implements LLMClient {
- modelId: string;
- private temperature: number;
+ modelId: string;
+ private temperature: number;
- constructor(modelId = 'gemini-2.5-flash', opts?: { temperature?: number }) {
- this.modelId = modelId;
- this.temperature = opts?.temperature ?? 0.2;
- }
+ constructor(modelId = "gemini-2.5-flash", opts?: { temperature?: number }) {
+ this.modelId = modelId;
+ this.temperature = opts?.temperature ?? 0.2;
+ }
- async chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens = 8192): Promise {
- const apiKey = process.env.GOOGLE_API_KEY;
- if (!apiKey) throw new Error('GOOGLE_API_KEY not set');
+ async chat(
+ messages: LLMMessage[],
+ tools?: LLMTool[],
+ maxTokens = 8192,
+ ): Promise {
+ const apiKey = process.env.GOOGLE_API_KEY;
+ if (!apiKey) throw new Error("GOOGLE_API_KEY not set");
- const genai = new GoogleGenAI({ apiKey });
+ const genai = new GoogleGenAI({ apiKey });
- const systemMsg = messages.find(m => m.role === 'system');
- const nonSystem = messages.filter(m => m.role !== 'system');
+ const systemMsg = messages.find((m) => m.role === "system");
+ const nonSystem = messages.filter((m) => m.role !== "system");
- const functionDeclarations = (tools ?? []).map(t => ({
- name: t.function.name,
- description: t.function.description,
- parameters: t.function.parameters as any
- }));
+ const functionDeclarations = (tools ?? []).map((t) => ({
+ name: t.function.name,
+ description: t.function.description,
+ parameters: t.function.parameters as any,
+ }));
- const response = await genai.models.generateContent({
- model: this.modelId,
- contents: toGeminiContents(nonSystem),
- config: {
- systemInstruction: systemMsg?.content ?? undefined,
- tools: functionDeclarations.length > 0 ? [{ functionDeclarations }] : undefined,
- temperature: this.temperature,
- maxOutputTokens: maxTokens
- }
- });
+ const response = await genai.models.generateContent({
+ model: this.modelId,
+ contents: toGeminiContents(nonSystem),
+ config: {
+ systemInstruction: systemMsg?.content ?? undefined,
+ tools:
+ functionDeclarations.length > 0
+ ? [{ functionDeclarations }]
+ : undefined,
+ temperature: this.temperature,
+ maxOutputTokens: maxTokens,
+ },
+ });
- const candidate = response.candidates?.[0];
- if (!candidate) throw new Error('No response from Gemini');
+ const candidate = response.candidates?.[0];
+ if (!candidate) throw new Error("No response from Gemini");
- const parts = candidate.content?.parts ?? [];
- const textContent = parts.filter(p => p.text).map(p => p.text).join('') || null;
- const fnCalls = parts.filter(p => p.functionCall);
+ const parts = candidate.content?.parts ?? [];
+ const textContent =
+ parts
+ .filter((p) => p.text)
+ .map((p) => p.text)
+ .join("") || null;
+ const fnCalls = parts.filter((p) => p.functionCall);
- const tool_calls: LLMToolCall[] = fnCalls.map(p => ({
- id: `call_${uuidv4().replace(/-/g, '').slice(0, 12)}`,
- type: 'function' as const,
- function: {
- name: p.functionCall!.name ?? '',
- arguments: JSON.stringify(p.functionCall!.args ?? {})
- }
- }));
+ const tool_calls: LLMToolCall[] = fnCalls.map((p) => ({
+ id: `call_${uuidv4().replace(/-/g, "").slice(0, 12)}`,
+ type: "function" as const,
+ function: {
+ name: p.functionCall!.name ?? "",
+ arguments: JSON.stringify(p.functionCall!.args ?? {}),
+ },
+ }));
- return {
- content: textContent,
- reasoning: null,
- tool_calls,
- finish_reason: fnCalls.length > 0 ? 'tool_calls' : 'stop'
- };
- }
+ return {
+ content: stripModelMarkup(textContent),
+ reasoning: null,
+ tool_calls,
+ finish_reason: fnCalls.length > 0 ? "tool_calls" : "stop",
+ };
+ }
}
/** Convert OpenAI message format → Gemini Content[] format */
function toGeminiContents(messages: LLMMessage[]): any[] {
- const contents: any[] = [];
- for (const msg of messages) {
- if (msg.role === 'assistant') {
- const parts: any[] = [];
- if (msg.content) parts.push({ text: msg.content });
- for (const tc of msg.tool_calls ?? []) {
- parts.push({
- functionCall: {
- name: tc.function.name,
- args: JSON.parse(tc.function.arguments || '{}')
- }
- });
- }
- contents.push({ role: 'model', parts });
- } else if (msg.role === 'tool') {
- // Parse content back — could be JSON or plain text
- let resultValue: unknown = msg.content;
- try { resultValue = JSON.parse(msg.content ?? 'null'); } catch { /* keep as string */ }
- contents.push({
- role: 'user',
- parts: [{
- functionResponse: {
- name: msg.name ?? 'tool',
- response: { result: resultValue }
- }
- }]
- });
- } else {
- contents.push({ role: 'user', parts: [{ text: msg.content ?? '' }] });
- }
+ const contents: any[] = [];
+ for (const msg of messages) {
+ if (msg.role === "assistant") {
+ const parts: any[] = [];
+ if (msg.content) parts.push({ text: msg.content });
+ for (const tc of msg.tool_calls ?? []) {
+ parts.push({
+ functionCall: {
+ name: tc.function.name,
+ args: JSON.parse(tc.function.arguments || "{}"),
+ },
+ });
+ }
+ contents.push({ role: "model", parts });
+ } else if (msg.role === "tool") {
+ // Parse content back — could be JSON or plain text
+ let resultValue: unknown = msg.content;
+ try {
+ resultValue = JSON.parse(msg.content ?? "null");
+ } catch {
+ /* keep as string */
+ }
+ contents.push({
+ role: "user",
+ parts: [
+ {
+ functionResponse: {
+ name: msg.name ?? "tool",
+ response: { result: resultValue },
+ },
+ },
+ ],
+ });
+ } else {
+ contents.push({ role: "user", parts: [{ text: msg.content ?? "" }] });
}
- return contents;
+ }
+ return contents;
}
// ---------------------------------------------------------------------------
@@ -295,147 +348,196 @@ function toGeminiContents(messages: LLMMessage[]): any[] {
// ---------------------------------------------------------------------------
export class AnthropicVertexClient implements LLMClient {
- modelId: string;
- private projectId: string;
- private region: string;
+ modelId: string;
+ private projectId: string;
+ private region: string;
- constructor(modelId: string, opts?: { projectId?: string; region?: string }) {
- // Strip the "anthropic/" prefix if present — the SDK uses bare model names
- this.modelId = modelId.startsWith('anthropic/') ? modelId.slice(10) : modelId;
- this.projectId = opts?.projectId ?? process.env.GCP_PROJECT_ID ?? 'master-ai-484822';
- this.region = opts?.region ?? process.env.CLAUDE_REGION ?? 'us-east5';
+ constructor(modelId: string, opts?: { projectId?: string; region?: string }) {
+ // Strip the "anthropic/" prefix if present — the SDK uses bare model names
+ this.modelId = modelId.startsWith("anthropic/")
+ ? modelId.slice(10)
+ : modelId;
+ this.projectId =
+ opts?.projectId ?? process.env.GCP_PROJECT_ID ?? "master-ai-484822";
+ this.region = opts?.region ?? process.env.CLAUDE_REGION ?? "us-east5";
+ }
+
+ private buildClient(): AnthropicVertex {
+ const b64Key = process.env.GCP_SA_KEY_BASE64;
+ if (b64Key) {
+ try {
+ const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
+ const credentials = JSON.parse(jsonStr);
+ return new AnthropicVertex({
+ projectId: this.projectId,
+ region: this.region,
+ googleAuth: new GoogleAuth({
+ credentials,
+ scopes: ["https://www.googleapis.com/auth/cloud-platform"],
+ }) as any,
+ });
+ } catch {
+ console.warn(
+ "[llm] AnthropicVertex: SA key decode failed, falling back to metadata server",
+ );
+ }
}
+ return new AnthropicVertex({
+ projectId: this.projectId,
+ region: this.region,
+ });
+ }
- private buildClient(): AnthropicVertex {
- const b64Key = process.env.GCP_SA_KEY_BASE64;
- if (b64Key) {
- try {
- const jsonStr = Buffer.from(b64Key, 'base64').toString('utf8');
- const credentials = JSON.parse(jsonStr);
- return new AnthropicVertex({
- projectId: this.projectId,
- region: this.region,
- googleAuth: new GoogleAuth({ credentials, scopes: ['https://www.googleapis.com/auth/cloud-platform'] }) as any,
- });
- } catch {
- console.warn('[llm] AnthropicVertex: SA key decode failed, falling back to metadata server');
- }
+ async chat(
+ messages: LLMMessage[],
+ tools?: LLMTool[],
+ maxTokens = 8192,
+ ): Promise {
+ const client = this.buildClient();
+
+ const system =
+ messages.find((m) => m.role === "system")?.content ?? undefined;
+ const nonSystem = messages.filter((m) => m.role !== "system");
+
+ // Convert OpenAI message format → Anthropic format
+ const anthropicMessages: any[] = nonSystem.map((m) => {
+ if (m.role === "assistant") {
+ const parts: any[] = [];
+ if (m.content) parts.push({ type: "text", text: m.content });
+ for (const tc of m.tool_calls ?? []) {
+ parts.push({
+ type: "tool_use",
+ id: tc.id,
+ name: tc.function.name,
+ input: JSON.parse(tc.function.arguments || "{}"),
+ });
}
- return new AnthropicVertex({ projectId: this.projectId, region: this.region });
- }
+ return {
+ role: "assistant",
+ content:
+ parts.length === 1 && parts[0].type === "text"
+ ? parts[0].text
+ : parts,
+ };
+ }
+ if (m.role === "tool") {
+ return {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: m.tool_call_id,
+ content: m.content ?? "",
+ },
+ ],
+ };
+ }
+ return { role: "user", content: m.content ?? "" };
+ });
- async chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens = 8192): Promise {
- const client = this.buildClient();
+ const anthropicTools = (tools ?? []).map((t) => ({
+ name: t.function.name,
+ description: t.function.description,
+ input_schema: t.function.parameters,
+ }));
- const system = messages.find(m => m.role === 'system')?.content ?? undefined;
- const nonSystem = messages.filter(m => m.role !== 'system');
+ const MAX_RETRIES = 4;
+ const RETRY_STATUSES = new Set([429, 503]);
- // Convert OpenAI message format → Anthropic format
- const anthropicMessages: any[] = nonSystem.map(m => {
- if (m.role === 'assistant') {
- const parts: any[] = [];
- if (m.content) parts.push({ type: 'text', text: m.content });
- for (const tc of m.tool_calls ?? []) {
- parts.push({
- type: 'tool_use',
- id: tc.id,
- name: tc.function.name,
- input: JSON.parse(tc.function.arguments || '{}'),
- });
- }
- return { role: 'assistant', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text : parts };
- }
- if (m.role === 'tool') {
- return {
- role: 'user',
- content: [{ type: 'tool_result', tool_use_id: m.tool_call_id, content: m.content ?? '' }],
- };
- }
- return { role: 'user', content: m.content ?? '' };
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ const response = await (client.messages.create as Function)({
+ model: this.modelId,
+ max_tokens: maxTokens,
+ system: system ?? undefined,
+ messages: anthropicMessages,
+ tools: anthropicTools.length > 0 ? anthropicTools : undefined,
});
- const anthropicTools = (tools ?? []).map(t => ({
- name: t.function.name,
- description: t.function.description,
- input_schema: t.function.parameters,
- }));
+ const textContent =
+ response.content
+ .filter((b: any) => b.type === "text")
+ .map((b: any) => b.text)
+ .join("") || null;
- const MAX_RETRIES = 4;
- const RETRY_STATUSES = new Set([429, 503]);
+ const tool_calls: LLMToolCall[] = response.content
+ .filter((b: any) => b.type === "tool_use")
+ .map((b: any) => ({
+ id: b.id,
+ type: "function" as const,
+ function: {
+ name: b.name,
+ arguments: JSON.stringify(b.input ?? {}),
+ },
+ }));
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
- try {
- const response = await (client.messages.create as Function)({
- model: this.modelId,
- max_tokens: maxTokens,
- system: system ?? undefined,
- messages: anthropicMessages,
- tools: anthropicTools.length > 0 ? anthropicTools : undefined,
- });
-
- const textContent = response.content
- .filter((b: any) => b.type === 'text')
- .map((b: any) => b.text)
- .join('') || null;
-
- const tool_calls: LLMToolCall[] = response.content
- .filter((b: any) => b.type === 'tool_use')
- .map((b: any) => ({
- id: b.id,
- type: 'function' as const,
- function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
- }));
-
- return {
- content: textContent,
- reasoning: null,
- tool_calls,
- finish_reason: response.stop_reason === 'tool_use' ? 'tool_calls' : 'stop',
- usage: response.usage
- ? { prompt_tokens: response.usage.input_tokens, completion_tokens: response.usage.output_tokens, total_tokens: response.usage.input_tokens + response.usage.output_tokens }
- : undefined,
- };
- } catch (err: any) {
- const status = err?.status ?? err?.statusCode ?? 0;
- if (RETRY_STATUSES.has(status) && attempt < MAX_RETRIES) {
- const waitMs = Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
- console.warn(`[llm] Anthropic Vertex ${status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`);
- await new Promise(r => setTimeout(r, waitMs));
- continue;
- }
- throw new Error(`Anthropic Vertex error: ${err?.message ?? String(err)}`);
- }
+ return {
+ content: stripModelMarkup(textContent),
+ reasoning: null,
+ tool_calls,
+ finish_reason:
+ response.stop_reason === "tool_use" ? "tool_calls" : "stop",
+ usage: response.usage
+ ? {
+ prompt_tokens: response.usage.input_tokens,
+ completion_tokens: response.usage.output_tokens,
+ total_tokens:
+ response.usage.input_tokens + response.usage.output_tokens,
+ }
+ : undefined,
+ };
+ } catch (err: any) {
+ const status = err?.status ?? err?.statusCode ?? 0;
+ if (RETRY_STATUSES.has(status) && attempt < MAX_RETRIES) {
+ const waitMs = Math.min(
+ 2 ** attempt * 2000 + Math.random() * 500,
+ 30_000,
+ );
+ console.warn(
+ `[llm] Anthropic Vertex ${status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`,
+ );
+ await new Promise((r) => setTimeout(r, waitMs));
+ continue;
}
- throw new Error('Anthropic Vertex: exceeded max retries');
+ throw new Error(
+ `Anthropic Vertex error: ${err?.message ?? String(err)}`,
+ );
+ }
}
+ throw new Error("Anthropic Vertex: exceeded max retries");
+ }
}
// ---------------------------------------------------------------------------
// Factory — createLLM(modelId | tier)
// ---------------------------------------------------------------------------
-export type ModelTier = 'A' | 'B' | 'C';
+export type ModelTier = "A" | "B" | "C";
const TIER_MODELS: Record = {
- A: process.env.TIER_A_MODEL ?? 'gemini-2.5-flash',
- B: process.env.TIER_B_MODEL ?? 'claude-sonnet-4-6',
- C: process.env.TIER_C_MODEL ?? 'claude-sonnet-4-6'
+ A: process.env.TIER_A_MODEL ?? "gemini-2.5-flash",
+ B: process.env.TIER_B_MODEL ?? "claude-sonnet-4-6",
+ C: process.env.TIER_C_MODEL ?? "claude-sonnet-4-6",
};
-export function createLLM(modelOrTier: string | ModelTier, opts?: { temperature?: number }): LLMClient {
- const modelId = (modelOrTier === 'A' || modelOrTier === 'B' || modelOrTier === 'C')
- ? TIER_MODELS[modelOrTier]
- : modelOrTier;
+export function createLLM(
+ modelOrTier: string | ModelTier,
+ opts?: { temperature?: number },
+): LLMClient {
+ const modelId =
+ modelOrTier === "A" || modelOrTier === "B" || modelOrTier === "C"
+ ? TIER_MODELS[modelOrTier]
+ : modelOrTier;
- if (modelId.startsWith('gemini-')) {
- return new GeminiClient(modelId, opts);
- }
+ if (modelId.startsWith("gemini-")) {
+ return new GeminiClient(modelId, opts);
+ }
- if (modelId.startsWith('anthropic/') || modelId.startsWith('claude-')) {
- return new AnthropicVertexClient(modelId);
- }
+ if (modelId.startsWith("anthropic/") || modelId.startsWith("claude-")) {
+ return new AnthropicVertexClient(modelId);
+ }
- return new VertexOpenAIClient(modelId, { temperature: opts?.temperature });
+ return new VertexOpenAIClient(modelId, { temperature: opts?.temperature });
}
// ---------------------------------------------------------------------------
@@ -443,14 +545,18 @@ export function createLLM(modelOrTier: string | ModelTier, opts?: { temperature?
// ---------------------------------------------------------------------------
export function toOAITools(
- tools: Array<{ name: string; description: string; parameters: Record }>
+ tools: Array<{
+ name: string;
+ description: string;
+ parameters: Record;
+ }>,
): LLMTool[] {
- return tools.map(t => ({
- type: 'function',
- function: {
- name: t.name,
- description: t.description,
- parameters: t.parameters
- }
- }));
+ return tools.map((t) => ({
+ type: "function",
+ function: {
+ name: t.name,
+ description: t.description,
+ parameters: t.parameters,
+ },
+ }));
}
diff --git a/vibn-agent-runner/src/tools/git-api.ts b/vibn-agent-runner/src/tools/git-api.ts
index 94b0155..eac649e 100644
--- a/vibn-agent-runner/src/tools/git-api.ts
+++ b/vibn-agent-runner/src/tools/git-api.ts
@@ -3,66 +3,100 @@
// Requires a GitPushConfig with Gitea credentials for authenticated push.
// =============================================================================
-import * as cp from 'child_process';
-import * as util from 'util';
-import { PROTECTED_GITEA_REPOS } from './security';
+import * as cp from "child_process";
+import * as util from "util";
+import { PROTECTED_GITEA_REPOS } from "./security";
const execAsync = util.promisify(cp.exec);
+import fs from "fs";
+import path from "path";
+
export interface GitPushConfig {
- apiUrl: string;
- apiToken: string;
- username: string;
+ apiUrl: string;
+ apiToken: string;
+ username: string;
}
export async function gitCommitAndPush(
- workspaceRoot: string,
- message: string,
- cfg: GitPushConfig
+ workspaceRoot: string,
+ message: string,
+ cfg: GitPushConfig,
): Promise {
- const cwd = workspaceRoot;
- const { apiUrl, apiToken, username } = cfg;
+ const cwd = workspaceRoot;
+ const { apiUrl, apiToken, username } = cfg;
+ try {
+ // Check remote URL before committing — block pushes to protected repos
+ let remoteCheck = "";
try {
- // Check remote URL before committing — block pushes to protected repos
- let remoteCheck = '';
- try {
- remoteCheck = (await execAsync('git remote get-url origin', { cwd })).stdout.trim();
- } catch { /* no remote yet */ }
-
- for (const protectedRepo of PROTECTED_GITEA_REPOS) {
- const repoPath = protectedRepo.replace('mark/', '');
- if (remoteCheck.includes(`/${repoPath}`) || remoteCheck.includes(`/${repoPath}.git`)) {
- return {
- error: `SECURITY: This workspace is linked to a protected Vibn platform repo (${protectedRepo}). ` +
- `Agents cannot push to platform repos. Only user project repos are writable.`,
- };
- }
- }
-
- await execAsync('git add -A', { cwd });
- await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd });
-
- // Strip any existing credentials from remote URL and re-inject cleanly
- let remoteUrl = '';
- try {
- remoteUrl = (await execAsync('git remote get-url origin', { cwd })).stdout.trim();
- } catch { /* no remote */ }
-
- const cleanUrl = remoteUrl.replace(/https:\/\/[^@]+@/, 'https://');
- const baseUrl = cleanUrl || apiUrl;
- const authedUrl = baseUrl.replace('https://', `https://${username}:${apiToken}@`);
-
- await execAsync(`git remote set-url origin "${authedUrl}"`, { cwd }).catch(async () => {
- await execAsync(`git remote add origin "${authedUrl}"`, { cwd });
- });
-
- const branch = (await execAsync('git rev-parse --abbrev-ref HEAD', { cwd })).stdout.trim();
- await execAsync(`git push -u origin "${branch}"`, { cwd, timeout: 60_000 });
-
- return { success: true, message, branch };
- } catch (err: any) {
- const cleaned = (err.message || '').replace(new RegExp(apiToken, 'g'), '***');
- return { error: `Git operation failed: ${cleaned}` };
+ remoteCheck = (
+ await execAsync("git remote get-url origin", { cwd })
+ ).stdout.trim();
+ } catch {
+ /* no remote yet */
}
+
+ for (const protectedRepo of PROTECTED_GITEA_REPOS) {
+ const repoPath = protectedRepo.replace("mark/", "");
+ if (
+ remoteCheck.includes(`/${repoPath}`) ||
+ remoteCheck.includes(`/${repoPath}.git`)
+ ) {
+ return {
+ error:
+ `SECURITY: This workspace is linked to a protected Vibn platform repo (${protectedRepo}). ` +
+ `Agents cannot push to platform repos. Only user project repos are writable.`,
+ };
+ }
+ }
+
+ // Write commit message to a temporary file to avoid shell injection
+ const msgFile = path.join(cwd, ".git", "COMMIT_EDITMSG");
+ fs.writeFileSync(msgFile, message, "utf8");
+
+ await execAsync("git add -A", { cwd });
+ await execAsync("git commit -F .git/COMMIT_EDITMSG", { cwd });
+ try {
+ fs.unlinkSync(msgFile);
+ } catch {
+ /* ignore */
+ }
+
+ // Strip any existing credentials from remote URL and re-inject cleanly
+ let remoteUrl = "";
+ try {
+ remoteUrl = (
+ await execAsync("git remote get-url origin", { cwd })
+ ).stdout.trim();
+ } catch {
+ /* no remote */
+ }
+
+ const cleanUrl = remoteUrl.replace(/https:\/\/[^@]+@/, "https://");
+ const baseUrl = cleanUrl || apiUrl;
+ const authedUrl = baseUrl.replace(
+ "https://",
+ `https://${username}:${apiToken}@`,
+ );
+
+ await execAsync(`git remote set-url origin "${authedUrl}"`, { cwd }).catch(
+ async () => {
+ await execAsync(`git remote add origin "${authedUrl}"`, { cwd });
+ },
+ );
+
+ const branch = (
+ await execAsync("git rev-parse --abbrev-ref HEAD", { cwd })
+ ).stdout.trim();
+ await execAsync(`git push -u origin "${branch}"`, { cwd, timeout: 60_000 });
+
+ return { success: true, message, branch };
+ } catch (err: any) {
+ const cleaned = (err.message || "").replace(
+ new RegExp(apiToken, "g"),
+ "***",
+ );
+ return { error: `Git operation failed: ${cleaned}` };
+ }
}
diff --git a/vibn-frontend/app/api/chat/route.ts b/vibn-frontend/app/api/chat/route.ts
index bd5c2be..b3fccc3 100644
--- a/vibn-frontend/app/api/chat/route.ts
+++ b/vibn-frontend/app/api/chat/route.ts
@@ -316,7 +316,13 @@ export async function POST(request: Request) {
const history: ChatMessage[] = rows.reverse().map((r: any) => {
const msg = r.data;
if (msg.role === "assistant" && msg.toolCalls?.length) {
- return { ...msg, toolCalls: undefined };
+ msg.toolCalls = undefined;
+ }
+ if (typeof msg.content === "string") {
+ msg.content = msg.content
+ .replace(/[\s\S]*?<\/tool_calls>/g, "")
+ .replace(/[\s\S]*?<\/think>/g, "")
+ .trim();
}
return msg;
});
diff --git a/vibn-frontend/app/api/projects/[projectId]/atlas-chat/route.ts b/vibn-frontend/app/api/projects/[projectId]/atlas-chat/route.ts
index 3a55a69..92ea5b9 100644
--- a/vibn-frontend/app/api/projects/[projectId]/atlas-chat/route.ts
+++ b/vibn-frontend/app/api/projects/[projectId]/atlas-chat/route.ts
@@ -1,13 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
-import {
- augmentAtlasMessage,
- parseContextRefs,
-} from "@/lib/chat-context-refs";
+import { augmentAtlasMessage, parseContextRefs } from "@/lib/chat-context-refs";
import { formatCreationKickoffForPrompt } from "@/lib/server/creation-kickoff-prompt";
-const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
+const AGENT_RUNNER_URL =
+ process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
const ALLOWED_SCOPES = new Set(["overview", "build"]);
@@ -16,8 +14,13 @@ function normalizeScope(raw: string | null | undefined): "overview" | "build" {
return ALLOWED_SCOPES.has(s) ? (s as "overview" | "build") : "overview";
}
-function runnerSessionId(projectId: string, scope: "overview" | "build"): string {
- return scope === "overview" ? `atlas_${projectId}` : `atlas_${projectId}__build`;
+function runnerSessionId(
+ projectId: string,
+ scope: "overview" | "build",
+): string {
+ return scope === "overview"
+ ? `atlas_${projectId}`
+ : `atlas_${projectId}__build`;
}
// ---------------------------------------------------------------------------
@@ -53,12 +56,15 @@ async function ensureLegacyConversationsTable() {
legacyTableChecked = true;
}
-async function loadAtlasHistory(projectId: string, scope: "overview" | "build"): Promise {
+async function loadAtlasHistory(
+ projectId: string,
+ scope: "overview" | "build",
+): Promise {
try {
await ensureThreadsTable();
const rows = await query<{ messages: any[] }>(
`SELECT messages FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`,
- [projectId, scope]
+ [projectId, scope],
);
if (rows.length > 0) {
const fromThreads = rows[0]?.messages;
@@ -68,7 +74,7 @@ async function loadAtlasHistory(projectId: string, scope: "overview" | "build"):
await ensureLegacyConversationsTable();
const leg = await query<{ messages: any[] }>(
`SELECT messages FROM atlas_conversations WHERE project_id = $1`,
- [projectId]
+ [projectId],
);
const legacyMsgs = leg[0]?.messages ?? [];
if (Array.isArray(legacyMsgs) && legacyMsgs.length > 0) {
@@ -82,7 +88,11 @@ async function loadAtlasHistory(projectId: string, scope: "overview" | "build"):
}
}
-async function saveAtlasHistory(projectId: string, scope: "overview" | "build", messages: any[]): Promise {
+async function saveAtlasHistory(
+ projectId: string,
+ scope: "overview" | "build",
+ messages: any[],
+): Promise {
try {
await ensureThreadsTable();
await query(
@@ -90,7 +100,7 @@ async function saveAtlasHistory(projectId: string, scope: "overview" | "build",
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (project_id, scope) DO UPDATE
SET messages = $3::jsonb, updated_at = NOW()`,
- [projectId, scope, JSON.stringify(messages)]
+ [projectId, scope, JSON.stringify(messages)],
);
} catch (e) {
console.error("[atlas-chat] Failed to save history:", e);
@@ -104,7 +114,7 @@ async function savePrd(projectId: string, prdContent: string): Promise {
SET data = data || jsonb_build_object('prd', $2::text, 'stage', 'architecture'),
updated_at = NOW()
WHERE id = $1`,
- [projectId, prdContent]
+ [projectId, prdContent],
);
console.log(`[atlas-chat] PRD saved for project ${projectId}`);
} catch (e) {
@@ -113,9 +123,14 @@ async function savePrd(projectId: string, prdContent: string): Promise {
}
/** Replace the latest user message content so DB/UI never show the internal ref prefix. */
-function scrubLastUserMessageContent(history: unknown[], cleanText: string): unknown[] {
+function scrubLastUserMessageContent(
+ history: unknown[],
+ cleanText: string,
+): unknown[] {
if (!Array.isArray(history) || history.length === 0) return history;
- const h = history.map(m => (m && typeof m === "object" ? { ...(m as object) } : m));
+ const h = history.map((m) =>
+ m && typeof m === "object" ? { ...(m as object) } : m,
+ );
for (let i = h.length - 1; i >= 0; i--) {
const m = h[i] as { role?: string; content?: string };
if (m?.role === "user" && typeof m.content === "string") {
@@ -132,7 +147,7 @@ function scrubLastUserMessageContent(history: unknown[], cleanText: string): unk
export async function GET(
req: NextRequest,
- { params }: { params: Promise<{ projectId: string }> }
+ { params }: { params: Promise<{ projectId: string }> },
) {
const session = await authSession();
if (!session?.user?.email) {
@@ -146,7 +161,10 @@ export async function GET(
// Filter to only user/assistant messages (no system prompts) for display
const messages = history
.filter((m: any) => m.role === "user" || m.role === "assistant")
- .map((m: any) => ({ role: m.role as "user" | "assistant", content: m.content as string }));
+ .map((m: any) => ({
+ role: m.role as "user" | "assistant",
+ content: m.content as string,
+ }));
return NextResponse.json({ messages });
}
@@ -157,7 +175,7 @@ export async function GET(
export async function POST(
req: NextRequest,
- { params }: { params: Promise<{ projectId: string }> }
+ { params }: { params: Promise<{ projectId: string }> },
) {
const session = await authSession();
if (!session?.user?.email) {
@@ -180,9 +198,19 @@ export async function POST(
// Strip tool_call / tool_response messages — replaying them across sessions
// causes Gemini to reject the request with a turn-ordering error.
const rawHistory = await loadAtlasHistory(projectId, scope);
- const history = rawHistory.filter((m: any) =>
- (m.role === "user" || m.role === "assistant") && m.content
- );
+ const history = rawHistory
+ .filter(
+ (m: any) => (m.role === "user" || m.role === "assistant") && m.content,
+ )
+ .map((m: any) => {
+ if (typeof m.content === "string") {
+ m.content = m.content
+ .replace(/[\s\S]*?<\/tool_calls>/g, "")
+ .replace(/[\s\S]*?<\/think>/g, "")
+ .trim();
+ }
+ return m;
+ });
// __init__ is a special internal trigger used only when there is no existing history.
// If history already exists, ignore the init request (conversation already started).
@@ -197,11 +225,13 @@ export async function POST(
try {
const rows = await query<{ data: Record }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
- [projectId]
+ [projectId],
);
const kb =
rows[0]?.data != null
- ? formatCreationKickoffForPrompt(rows[0].data as Record)
+ ? formatCreationKickoffForPrompt(
+ rows[0].data as Record,
+ )
: null;
if (kb) {
kickoffPrefix = `[Project kickoff from creation wizard]\n${kb}\n\n`;
@@ -236,7 +266,7 @@ export async function POST(
console.error("[atlas-chat] Agent runner error:", text);
return NextResponse.json(
{ error: "Vibn is unavailable. Please try again." },
- { status: 502 }
+ { status: 502 },
);
}
@@ -265,7 +295,7 @@ export async function POST(
console.error("[atlas-chat] Error:", err);
return NextResponse.json(
{ error: "Request timed out or failed. Please try again." },
- { status: 500 }
+ { status: 500 },
);
}
}
@@ -276,7 +306,7 @@ export async function POST(
export async function DELETE(
req: NextRequest,
- { params }: { params: Promise<{ projectId: string }> }
+ { params }: { params: Promise<{ projectId: string }> },
) {
const session = await authSession();
if (!session?.user?.email) {
@@ -288,21 +318,32 @@ export async function DELETE(
const sessionId = runnerSessionId(projectId, scope);
try {
- await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
- } catch { /* runner may be down */ }
+ await fetch(
+ `${AGENT_RUNNER_URL}/atlas/sessions/${encodeURIComponent(sessionId)}`,
+ { method: "DELETE" },
+ );
+ } catch {
+ /* runner may be down */
+ }
try {
await ensureThreadsTable();
await query(
`DELETE FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`,
- [projectId, scope]
+ [projectId, scope],
);
- } catch { /* table may not exist yet */ }
+ } catch {
+ /* table may not exist yet */
+ }
if (scope === "overview") {
try {
- await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]);
- } catch { /* legacy */ }
+ await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [
+ projectId,
+ ]);
+ } catch {
+ /* legacy */
+ }
}
return NextResponse.json({ cleared: true });
diff --git a/vibn-frontend/lib/ai/openai-compatible-chat.ts b/vibn-frontend/lib/ai/openai-compatible-chat.ts
index 303191b..9cf07e3 100644
--- a/vibn-frontend/lib/ai/openai-compatible-chat.ts
+++ b/vibn-frontend/lib/ai/openai-compatible-chat.ts
@@ -210,11 +210,18 @@ function parseAssistantMessage(message: Record | undefined): {
: typeof (message as { reasoning?: string })?.reasoning === "string"
? (message as { reasoning: string }).reasoning
: "";
+
+ const stripTags = (s: string) =>
+ s
+ .replace(/[\s\S]*?<\/tool_calls>/g, "")
+ .replace(/[\s\S]*?<\/think>/g, "")
+ .trim();
+
// DeepSeek separates thinking from speaking — during tool loops it
// often puts everything in reasoning_content and leaves content empty.
// When that happens, surface the reasoning as the user-visible text
// so the user isn't staring at silent tool pills.
- const text = rawText || thoughts;
+ const text = stripTags(rawText || thoughts);
const toolCalls: ToolCall[] = [];
const rawCalls = message?.tool_calls;
if (Array.isArray(rawCalls)) {
diff --git a/vibn-frontend/marketing/components/new-site/index.tsx b/vibn-frontend/marketing/components/new-site/index.tsx
index 8fca7e7..5556821 100644
--- a/vibn-frontend/marketing/components/new-site/index.tsx
+++ b/vibn-frontend/marketing/components/new-site/index.tsx
@@ -923,7 +923,7 @@ function Hero({ onStart, variant = "quote" }) {
.hero-attribution {
font-family: var(--font-mono);
font-size: 12px;
- color: var(--fg-faint);
+ color: var(--fg-mute);
letter-spacing: 0.04em;
margin-top: 6px;
display: inline-flex; align-items: center; gap: 8px;
@@ -1120,10 +1120,10 @@ function Hero({ onStart, variant = "quote" }) {
idea → live → marketed → customers
- "I built my product, now what?" Vibn is the answer.
+ Build it. Go Live. Find Users.
Your AI handles the technical stuff, puts your idea online, and
- helps you find your first customers.
+ helps you find customers. No extra tools. No headaches.
>
) : (