From 30a35d89362c2c082dd40f8d11dd8c0baec02551 Mon Sep 17 00:00:00 2001 From: Sebastian Tsang Date: Sat, 9 May 2026 23:00:55 -0400 Subject: [PATCH 1/4] feat(company-context): require founder profile before workflow runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CompanyProfile record (one row per founder) that grounds every persona reasoning about a customer. Until now Qualifier scored fitScore against an unknown ICP, Strategist picked angles without positioning, and Writer drafted about a product the LLM had to guess at — the only way to convey company info was stuffing it into the prompt textarea on every run. - New /settings/company page with a structured form (companyName, oneLiner, productDescription, ICP, positioning, voiceTone, valueProps, competitors, sourceUrl) plus an "Auto-fill from website" button that scrapes /, /about, /pricing, /product, /features via cheerio + turndown and runs an LLM drafter to populate the form for review. - Workflow-start guard: POST /api/runs returns 409 with missingFields + settingsUrl until companyName, oneLiner, productDescription, and icp are non-empty. Frontend toasts with an "Open settings" action. - Selective per-persona threading via pickCompanyProfileSlice — only the fields each persona's prompt actually references are spliced in. Operational personas (CRM Logger, Slack Digest, Pipeline Reporter, Linear Filer, Scheduler) get nothing, so their token budgets stay lean. - WorkContext.summary now leads with the founder's company so the Conductor + Managers reason about tasks already grounded in who the company is — no Conductor code changes needed. - 8 persona prompts updated to reference input.companyProfile.* with concrete grounding instructions (e.g. Qualifier scores against companyProfile.icp, Writer pairs companyProfile.voiceTone with the existing voice samples). - Setup wizard collects an optional homepage URL and points the founder at /settings/company after install. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(dashboard)/settings/company/page.tsx | 29 ++ app/api/company-profile/route.ts | 38 ++ app/api/company-profile/scrape/route.ts | 75 ++++ app/api/runs/route.ts | 28 ++ bin/gmaestro.ts | 18 + drizzle/migrations/0003_company_profile.sql | 14 + lib/ingest/draft-profile.ts | 143 +++++++ lib/ingest/scrape.ts | 271 ++++++++++++++ lib/personas/prompts/activation.md | 1 + lib/personas/prompts/brief-writer.md | 1 + lib/personas/prompts/feedback-tagger.md | 1 + lib/personas/prompts/qualifier.md | 23 +- lib/personas/prompts/researcher.md | 1 + lib/personas/prompts/strategist.md | 7 +- lib/personas/prompts/theme-synthesizer.md | 1 + lib/personas/prompts/writer.md | 8 +- lib/personas/registry.ts | 4 + lib/shared/mocks.ts | 29 ++ lib/shared/schemas.ts | 50 +++ lib/shared/types.ts | 33 ++ lib/state/company-profile.ts | 148 ++++++++ lib/state/schema.ts | 30 ++ lib/state/work-context.ts | 48 ++- lib/state/workflows.ts | 72 ++++ lib/ui/components/company-profile-form.tsx | 394 ++++++++++++++++++++ lib/ui/components/prompt-input.tsx | 23 ++ lib/ui/components/top-nav.tsx | 3 +- package.json | 3 + pnpm-lock.yaml | 236 ++++++++++++ 29 files changed, 1705 insertions(+), 27 deletions(-) create mode 100644 app/(dashboard)/settings/company/page.tsx create mode 100644 app/api/company-profile/route.ts create mode 100644 app/api/company-profile/scrape/route.ts create mode 100644 drizzle/migrations/0003_company_profile.sql create mode 100644 lib/ingest/draft-profile.ts create mode 100644 lib/ingest/scrape.ts create mode 100644 lib/state/company-profile.ts create mode 100644 lib/ui/components/company-profile-form.tsx diff --git a/app/(dashboard)/settings/company/page.tsx b/app/(dashboard)/settings/company/page.tsx new file mode 100644 index 0000000..674ddfc --- /dev/null +++ b/app/(dashboard)/settings/company/page.tsx @@ -0,0 +1,29 @@ +import { CompanyProfileForm } from "@/lib/ui/components/company-profile-form"; +import { getCompanyProfile } from "@/lib/state/company-profile"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const USER_ID = process.env.GMAESTRO_USER_ID ?? "default"; + +export default async function CompanyProfilePage() { + const profile = getCompanyProfile(USER_ID); + + return ( +
+
+

Company profile

+

+ Grounds every persona that reasons about your customers — Qualifier scores + against your ICP, Strategist picks angles from your positioning, Writer drafts + in your voice. +

+

+ Filling the four required fields is mandatory before workflow runs can dispatch. +

+
+ + +
+ ); +} diff --git a/app/api/company-profile/route.ts b/app/api/company-profile/route.ts new file mode 100644 index 0000000..20582e6 --- /dev/null +++ b/app/api/company-profile/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { CompanyProfileUpdateSchema } from "@/lib/shared/schemas"; +import { + getCompanyProfile, + upsertCompanyProfile, +} from "@/lib/state/company-profile"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function getFounderId(): string { + return process.env.GMAESTRO_USER_ID ?? "default"; +} + +export async function GET() { + const profile = getCompanyProfile(getFounderId()); + return NextResponse.json({ profile }); +} + +export async function PUT(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = CompanyProfileUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", issues: parsed.error.issues }, + { status: 400 }, + ); + } + + const profile = upsertCompanyProfile(getFounderId(), parsed.data); + return NextResponse.json({ profile }); +} diff --git a/app/api/company-profile/scrape/route.ts b/app/api/company-profile/scrape/route.ts new file mode 100644 index 0000000..3787c48 --- /dev/null +++ b/app/api/company-profile/scrape/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { CompanyProfileScrapeRequestSchema } from "@/lib/shared/schemas"; +import { draftProfileFromScrape } from "@/lib/ingest/draft-profile"; +import { scrapeCompanySite } from "@/lib/ingest/scrape"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Synchronous scrape + draft. The whole call fits inside the LLM's 60s + * budget plus the scraper's 15s budget — no need for the fire-and-forget + * shape that `/api/runs` uses for multi-minute workflows. + */ +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = CompanyProfileScrapeRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", issues: parsed.error.issues }, + { status: 400 }, + ); + } + + let bundle; + try { + bundle = await scrapeCompanySite(parsed.data.url); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Scrape failed" }, + { status: 502 }, + ); + } + + const okCount = bundle.pages.filter((p) => p.status === "ok").length; + if (okCount === 0) { + return NextResponse.json( + { + error: + "couldn't fetch any usable pages from that URL. The site may require JavaScript or blocks scrapers — fill the form manually instead.", + bundle, + }, + { status: 422 }, + ); + } + + let draft; + try { + draft = await draftProfileFromScrape(bundle); + } catch (err) { + return NextResponse.json( + { + error: + err instanceof Error + ? `LLM drafter failed: ${err.message}` + : "LLM drafter failed", + }, + { status: 502 }, + ); + } + + return NextResponse.json({ + draft: { ...draft, sourceUrl: parsed.data.url }, + bundle: { + origin: bundle.origin, + okCount, + attempted: bundle.pages.length, + }, + }); +} diff --git a/app/api/runs/route.ts b/app/api/runs/route.ts index e35b36a..610d395 100644 --- a/app/api/runs/route.ts +++ b/app/api/runs/route.ts @@ -2,6 +2,11 @@ import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { RunWorkflowRequestSchema } from "@/lib/shared/schemas"; +import { REQUIRED_COMPANY_PROFILE_FIELDS } from "@/lib/shared/types"; +import { + getCompanyProfile, + isCompanyProfileComplete, +} from "@/lib/state/company-profile"; import { db, schema } from "@/lib/state/db"; import { createRun, markRunFailed, runWorkflow } from "@/lib/state/workflows"; @@ -81,6 +86,29 @@ export async function POST(request: Request) { const founderId = process.env.GMAESTRO_USER_ID ?? "default"; + // Workflow-start guard. The personas downstream reason about the founder's + // ICP / positioning / product description; without those filled, every + // persona below the Conductor is flying blind. Reject the run with a 409 + // and tell the frontend where to send the founder. + const profile = getCompanyProfile(founderId); + if (!isCompanyProfileComplete(profile)) { + const missing = REQUIRED_COMPANY_PROFILE_FIELDS.filter((f) => { + const v = profile?.[f] as unknown; + if (typeof v !== "string") return true; + return v.trim().length === 0; + }); + return NextResponse.json( + { + error: "company_profile_required", + message: + "Workflow runs are blocked until your company profile is filled. The personas need to know what the company does, who you sell to, and how you position before they can reason about leads.", + missingFields: missing, + settingsUrl: "/settings/company", + }, + { status: 409 }, + ); + } + // Materialize leads for any emails the founder named in the prompt BEFORE // we build WorkContext (which the Conductor reads). Done in the request // path, not the detached workflow, so a DB failure surfaces as a clean 500 diff --git a/bin/gmaestro.ts b/bin/gmaestro.ts index c026501..da9b6ed 100644 --- a/bin/gmaestro.ts +++ b/bin/gmaestro.ts @@ -129,6 +129,12 @@ program existing.GMAESTRO_USER_ID ?? (await input({ message: "Founder user id", default: "default" })); + const companyUrl = await input({ + message: + "Your company's homepage URL (we'll auto-fill your profile from it; press Enter to skip)", + default: "", + }); + const next: EnvMap = { ...existing, ANTHROPIC_API_KEY: anthropicKey || existing.ANTHROPIC_API_KEY || "", @@ -137,6 +143,9 @@ program GMAESTRO_BASE_URL: existing.GMAESTRO_BASE_URL ?? "http://localhost:3000", GMAESTRO_TIER: existing.GMAESTRO_TIER ?? "auto", }; + if (companyUrl.trim().length > 0) { + next.GMAESTRO_COMPANY_URL = companyUrl.trim(); + } writeEnv(next); console.log(`✔ wrote ${ENV_PATH}`); @@ -156,6 +165,15 @@ program } console.log("\nNext: pnpm gmaestro dev"); + if (companyUrl.trim().length > 0) { + console.log( + " Open http://localhost:3000/settings/company — click \"Auto-fill\" to draft your profile from the URL you provided, then review/save.", + ); + } else { + console.log( + " Open http://localhost:3000/settings/company first — workflow runs are blocked until your company profile is filled.", + ); + } console.log( "Note: parallel persona fanout works best on Anthropic Tier 2+ ($40 cumulative spend).", ); diff --git a/drizzle/migrations/0003_company_profile.sql b/drizzle/migrations/0003_company_profile.sql new file mode 100644 index 0000000..97f6085 --- /dev/null +++ b/drizzle/migrations/0003_company_profile.sql @@ -0,0 +1,14 @@ +CREATE TABLE `company_profiles` ( + `user_id` text PRIMARY KEY NOT NULL, + `company_name` text, + `one_liner` text, + `product_description` text, + `icp` text, + `positioning` text, + `voice_tone` text, + `value_props` text, + `competitors` text, + `source_url` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); diff --git a/lib/ingest/draft-profile.ts b/lib/ingest/draft-profile.ts new file mode 100644 index 0000000..db2e53f --- /dev/null +++ b/lib/ingest/draft-profile.ts @@ -0,0 +1,143 @@ +/** + * LLM drafter: scrape bundle → Partial. + * + * Pure synthesizer — no tools. The scraper in `scrape.ts` already pulled + * a few well-known pages; this just asks an LLM to extract the structured + * fields the founder will then review and edit. Field shape mirrors the + * `CompanyProfileUpdateSchema` so the response slots straight into the + * dashboard form's defaultValues. + * + * Same Pattern B used by the Researcher persona: deterministic fetch first, + * pure-LLM synthesis second. + */ + +import "server-only"; +import { z } from "zod"; +import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { extractJson } from "@/lib/shared/extract-json"; +import { getModelForTier } from "@/lib/shared/models"; +import { + formatScrapeBundleForPrompt, + type ScrapeBundle, +} from "./scrape"; + +const DRAFT_TIMEOUT_MS = 60_000; + +/** + * What we ask the LLM for. Looser than `CompanyProfileUpdateSchema` + * (every field optional, lenient string lengths) so a model that names + * "the company" something tangential still produces SOMETHING. The PUT + * route enforces the strict caps when the founder saves. + */ +const DraftedProfileSchema = z.object({ + companyName: z.string().nullable().optional(), + oneLiner: z.string().nullable().optional(), + productDescription: z.string().nullable().optional(), + icp: z.string().nullable().optional(), + positioning: z.string().nullable().optional(), + voiceTone: z.string().nullable().optional(), + valueProps: z.array(z.string()).nullable().optional(), + competitors: z.array(z.string()).nullable().optional(), +}); + +export type DraftedProfile = z.infer; + +const DRAFTER_SYSTEM_PROMPT = `You are GMaestro's company-profile extractor. You read scraped marketing pages from a company's website and produce a structured profile the founder will review and edit before it grounds the company's own GTM agents. + +Your job is to ground each field in EVIDENCE from the scrape. If a field has no evidence, leave it null — don't fabricate. Founders edit before saving, so a sparse correct draft is more useful than a confident wrong one. + +Output is a single JSON object — no prose, no markdown fence — with these keys (every key OPTIONAL; emit only those you can ground): + +{ + "companyName": string | null, // exact name as it appears (no Inc., LLC unless they use it) + "oneLiner": string | null, // ≤140 chars, the company's own framing of what they do + "productDescription": string | null, // ≤2000 chars, plain markdown — what the product actually does, who it's for, key capabilities + "icp": string | null, // ≤1000 chars, ideal customer profile — industry, size, role, situation. Inferred from who the marketing copy talks to + "positioning": string | null, // ≤1000 chars, "we are X for Y, unlike Z". Look for vs-competitor pages or pricing comparisons + "voiceTone": string | null, // ≤500 chars, describe the marketing voice: register, pacing, signature phrases. Concrete, not "professional and friendly" + "valueProps": string[] | null, // 3-5 short phrases, each ≤140 chars. The bullet-points the company itself emphasizes + "competitors": string[] | null // names of competitors mentioned by the site itself (in vs- pages, comparison tables). Don't guess +} + +Hard rules: +- ONE JSON object, no fence, no prose. Direct parse must succeed. +- Null > fabricated. A field you can't ground stays null. +- Write what the SITE says, not what's globally true. The founder edits afterwards. +- For \`icp\`, infer from who the copy talks to ("for engineering teams shipping multi-tenant apps"), not from your own knowledge of the space. +- For \`voiceTone\`, describe the actual writing style on the page ("clipped sentences, technical jargon, lowercase headings") — don't prescribe what they should be. +- Skip pages with status !== "ok" — they have no usable content. +`; + +export async function draftProfileFromScrape( + bundle: ScrapeBundle, +): Promise { + const okPages = bundle.pages.filter((p) => p.status === "ok"); + if (okPages.length === 0) { + return {}; + } + + const userPrompt = `Scraped bundle (${okPages.length}/${bundle.pages.length} pages succeeded): + +${formatScrapeBundleForPrompt(bundle)} + +Produce the JSON profile now. Direct parse only — no fence, no prose.`; + + const options: Options = { + model: getModelForTier("sonnet"), + systemPrompt: DRAFTER_SYSTEM_PROMPT, + mcpServers: {}, + allowedTools: [], + maxTurns: 2, + }; + + const raw = await Promise.race([ + collectFinalResult(userPrompt, options), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `profile drafter exceeded ${DRAFT_TIMEOUT_MS / 1000}s`, + ), + ), + DRAFT_TIMEOUT_MS, + ), + ), + ]); + + let parsed: unknown; + try { + parsed = extractJson(raw); + } catch (err) { + console.warn( + `[draft-profile] parse failed, returning empty draft: ${err instanceof Error ? err.message : err}`, + ); + return {}; + } + + const result = DraftedProfileSchema.safeParse(parsed); + if (!result.success) { + console.warn( + `[draft-profile] schema validation failed, returning empty draft: ${result.error.message}`, + ); + return {}; + } + + return result.data; +} + +async function collectFinalResult( + prompt: string, + options: Options, +): Promise { + const stream = query({ prompt, options }); + for await (const message of stream) { + if (message.type === "result") { + if (message.subtype !== "success") { + throw new Error(`drafter query failed: ${message.subtype}`); + } + return message.result; + } + } + throw new Error("drafter stream ended without a result message"); +} diff --git a/lib/ingest/scrape.ts b/lib/ingest/scrape.ts new file mode 100644 index 0000000..1598f8a --- /dev/null +++ b/lib/ingest/scrape.ts @@ -0,0 +1,271 @@ +/** + * Static-HTML scraper for the company-profile auto-fill flow. + * + * Pattern B mirror of `lib/personas/researcher/fetch.ts`: deterministic + * fetches happen here in code; the LLM only synthesizes. Each fetch is + * wrapped in a timeout + status-enum so the drafter prompt can stamp + * confidence ("homepage: ok" vs "homepage: error") instead of guessing + * whether a missing field means "not on the page" or "lookup failed". + * + * No headless browser — SPA-only sites degrade to manual form entry, + * which is acceptable for the demo. + */ + +import "server-only"; +import { load as loadHtml } from "cheerio"; +import TurndownService from "turndown"; + +const PER_FETCH_TIMEOUT_MS = 8_000; +const TOTAL_FETCH_BUDGET_MS = 15_000; + +/** Paths probed alongside the homepage. Each is best-effort — 404s are normal. */ +const WELL_KNOWN_PATHS = ["/", "/about", "/pricing", "/product", "/features"]; + +/** Hard cap on per-page markdown so the drafter prompt stays bounded. */ +const MAX_MARKDOWN_PER_PAGE = 6_000; + +export type PageFetchStatus = + | "ok" + | "not_found" + | "redirected_off_origin" + | "blocked" + | "timeout" + | "error" + | "skipped"; + +export interface PageFetch { + path: string; + url: string; + status: PageFetchStatus; + /** Cleaned markdown of the page's main text. Empty string when status !== "ok". */ + markdown: string; + title: string | null; + description: string | null; + error?: string; +} + +export interface ScrapeBundle { + origin: string; + pages: PageFetch[]; + fetchedAt: string; +} + +/** + * Fetch a small, well-known set of pages from the given URL's origin and + * convert each to markdown. Always returns a bundle — failures are recorded + * inline rather than thrown, so the drafter can reason about partial data. + */ +export async function scrapeCompanySite(rawUrl: string): Promise { + let origin: string; + try { + origin = new URL(rawUrl).origin; + } catch { + throw new Error(`invalid URL: ${rawUrl}`); + } + + const deadline = Date.now() + TOTAL_FETCH_BUDGET_MS; + const pages = await Promise.all( + WELL_KNOWN_PATHS.map((p) => fetchAndConvert(origin, p, deadline)), + ); + + return { + origin, + pages, + fetchedAt: new Date().toISOString(), + }; +} + +async function fetchAndConvert( + origin: string, + path: string, + deadline: number, +): Promise { + const url = `${origin}${path}`; + if (Date.now() > deadline) { + return { + path, + url, + status: "skipped", + markdown: "", + title: null, + description: null, + error: "total fetch budget exhausted", + }; + } + try { + const res = await fetchWithTimeout(url, PER_FETCH_TIMEOUT_MS); + if (res.redirectedOffOrigin) { + return { + path, + url, + status: "redirected_off_origin", + markdown: "", + title: null, + description: null, + }; + } + if (res.status === 404) { + return { + path, + url, + status: "not_found", + markdown: "", + title: null, + description: null, + }; + } + if (res.status === 403 || res.status === 429) { + return { + path, + url, + status: "blocked", + markdown: "", + title: null, + description: null, + error: `HTTP ${res.status}`, + }; + } + if (res.status >= 400) { + return { + path, + url, + status: "error", + markdown: "", + title: null, + description: null, + error: `HTTP ${res.status}`, + }; + } + const { markdown, title, description } = htmlToMarkdown(res.body); + return { + path, + url, + status: "ok", + markdown: markdown.slice(0, MAX_MARKDOWN_PER_PAGE), + title, + description, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const isTimeout = /timed out/i.test(message); + return { + path, + url, + status: isTimeout ? "timeout" : "error", + markdown: "", + title: null, + description: null, + error: message, + }; + } +} + +interface FetchResult { + status: number; + body: string; + redirectedOffOrigin: boolean; +} + +async function fetchWithTimeout( + url: string, + timeoutMs: number, +): Promise { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + redirect: "follow", + signal: ac.signal, + headers: { + // Some sites block bare `node-fetch`-style UAs. Identify as a normal + // browser so well-behaved CDNs don't 403 us out. + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 GMaestroBot/0.1", + Accept: "text/html,application/xhtml+xml", + }, + }); + const finalUrl = new URL(res.url); + const requested = new URL(url); + const redirectedOffOrigin = finalUrl.origin !== requested.origin; + const body = redirectedOffOrigin ? "" : await res.text(); + return { status: res.status, body, redirectedOffOrigin }; + } catch (err) { + if (ac.signal.aborted) throw new Error(`fetch timed out after ${timeoutMs}ms`); + throw err; + } finally { + clearTimeout(timer); + } +} + +interface HtmlConversion { + markdown: string; + title: string | null; + description: string | null; +} + +function htmlToMarkdown(html: string): HtmlConversion { + const $ = loadHtml(html); + + // Strip noise that bloats the markdown without adding signal. + $("script, style, noscript, iframe, svg, footer, nav, form").remove(); + $("[role='navigation'], [role='banner'], [aria-hidden='true']").remove(); + + const title = ($("title").first().text() || "").trim() || null; + const description = + ($('meta[name="description"]').attr("content") || "").trim() || + ($('meta[property="og:description"]').attr("content") || "").trim() || + null; + + // Prefer
/
when present — landing pages without those + // structural tags fall back to . + const $main = $("main").first(); + const $article = $("article").first(); + const $root = + $main.length > 0 ? $main : $article.length > 0 ? $article : $("body"); + + const turndown = new TurndownService({ + headingStyle: "atx", + bulletListMarker: "-", + codeBlockStyle: "fenced", + }); + // Drop image markdown — alt-text rarely helps the drafter and can carry + // raw filenames that confuse the model. + turndown.addRule("dropImages", { + filter: ["img"], + replacement: () => "", + }); + + const rawHtml = $.html($root); + const md = turndown + .turndown(rawHtml) + // Collapse 3+ newlines to 2; turndown can leave gaps when nested + //
wrappers were stripped. + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return { markdown: md, title, description }; +} + +/** + * Compact text representation of the bundle for inclusion in the drafter + * prompt. Per-page sections are clearly labeled with their status so the + * LLM can reason about partial data. + */ +export function formatScrapeBundleForPrompt(bundle: ScrapeBundle): string { + const sections: string[] = [`SCRAPED ORIGIN: ${bundle.origin}`]; + for (const page of bundle.pages) { + const header = `### ${page.path} (status=${page.status})`; + if (page.status !== "ok") { + sections.push(`${header}\n(no content${page.error ? ` — ${page.error}` : ""})`); + continue; + } + const meta: string[] = []; + if (page.title) meta.push(`title: ${page.title}`); + if (page.description) meta.push(`meta-description: ${page.description}`); + sections.push( + `${header}\n${meta.join("\n")}${meta.length ? "\n\n" : ""}${page.markdown}`, + ); + } + return sections.join("\n\n---\n\n"); +} diff --git a/lib/personas/prompts/activation.md b/lib/personas/prompts/activation.md index b6b2cf2..80302e9 100644 --- a/lib/personas/prompts/activation.md +++ b/lib/personas/prompts/activation.md @@ -12,6 +12,7 @@ You are GMaestro's Activation persona. For each trial user stalled mid-onboardin - `input.leadId` — id of the lead behind this trial signal. - `input.item.{trialSignalId, leadId, email, name, company, stalledAtStep, stripeStatus}` — the trial signal record + denormalized lead fields. +- `input.companyProfile.{companyName, oneLiner, productDescription, voiceTone}` — **the founder's own company**. `productDescription` tells you what the user is mid-trial of, so the unblock you offer can name the actual feature ("the GitHub-Linear sync step") instead of a generic "the connect step". `voiceTone` shapes how you sound. `stripeStatus` is one of `"trialing" | "active" | "churned"`. If churned, still produce a nudge but mark `channel: "email"` and use a softer CTA — the dashboard may decide not to send. diff --git a/lib/personas/prompts/brief-writer.md b/lib/personas/prompts/brief-writer.md index a175e38..4fe727a 100644 --- a/lib/personas/prompts/brief-writer.md +++ b/lib/personas/prompts/brief-writer.md @@ -12,6 +12,7 @@ You are GMaestro's Brief Writer. 24 hours before a booked meeting, produce a 1-p - `input.meetingId` — id of the BookedMeeting this brief is for. Copy through verbatim. - `input.workflowRunId` — opaque, copy through. +- `input.companyProfile.{companyName, oneLiner, productDescription, icp, positioning, valueProps, competitors, voiceTone}` — **the founder's own company**. Use for `companyContext` framing ("we're $oneLiner — this lead is a $fit-vs-icp"), for grounding `talkingPoints` in `valueProps`, and for naming `potentialObjections` (e.g. "they may already use one of our `competitors`"). - `input.previousOutputs` *(may have missing keys)*: - `previousOutputs.scheduler.id` / `.startsAt` / `.attendees` — the meeting - `previousOutputs.researcher.{companyDomain, companyIndustry, personRole, intentSignals}` — enrichment diff --git a/lib/personas/prompts/feedback-tagger.md b/lib/personas/prompts/feedback-tagger.md index c4d76a8..050a39c 100644 --- a/lib/personas/prompts/feedback-tagger.md +++ b/lib/personas/prompts/feedback-tagger.md @@ -13,6 +13,7 @@ You are GMaestro's Feedback Tagger. Given a single piece of customer feedback (a - `input.item.text` — the feedback string (or whatever it's named — also accept `input.text`). - `input.item.source` *(optional)* — where it came from ("intercom", "nps", "twitter", "slack", "support", "sales-call"). - `input.messageId` — opaque id, copy through if present. +- `input.companyProfile.{companyName, productDescription}` — the founder's own product. Use this to disambiguate `bug:` tags — only the areas your product actually has should appear (e.g. don't tag `bug:auth` if `productDescription` doesn't mention auth as a feature). ## Reasoning diff --git a/lib/personas/prompts/qualifier.md b/lib/personas/prompts/qualifier.md index 0d6fa94..8cb89a9 100644 --- a/lib/personas/prompts/qualifier.md +++ b/lib/personas/prompts/qualifier.md @@ -16,6 +16,7 @@ You run in one of two modes — the user prompt tells you which. - `input.leadId` (single) or `items[i].leadId` (batch). - `input.item.{email, name, company, source, rawMessage}` — the lead's local record. **`source` is one of `inbound_form`, `trial_signup`, `manual_import`** — different sources carry different baseline intent. **`rawMessage` is the lead's actual inbound text — your most reliable intent signal.** +- `input.companyProfile.{companyName, oneLiner, productDescription, icp}` — **the founder's own company**. `icp` is the ICP definition you score against — read it before you score anyone. `productDescription` tells you what we sell so you can spot relevant intent vs irrelevant. Always present (a workflow run is blocked until these are filled). - `previousOutputs.researcher` *(may be missing or carry an `error` field)* — the researcher's `EnrichedLead` for this lead. Fields you can use: `companyDomain`, `companyIndustry`, `companySize`, `personRole`, `personSeniority`, `intentSignals`, `techStack`. ## How to reason @@ -27,19 +28,21 @@ You run in one of two modes — the user prompt tells you which. - **cold** — neither explicit ask nor confirmed fit, but no disqualifying signal. Default for leads where you have almost nothing to go on. - **disqualified** — clear disqualifier (consumer use case, agency, student, competitor, no email match). -**`fitScore` (0-100):** how closely they match the ICP. Anchor it to evidence — don't pick a number just because it feels right. +**`fitScore` (0-100):** how closely they match `input.companyProfile.icp`. Anchor it to evidence in the ICP definition — don't pick a number just because it feels right. If the ICP says "Series A+ B2B SaaS" and the lead is a solo consumer-app founder, that's a clear miss; if the ICP says "engineering teams shipping firmware-heavy products" and the rawMessage mentions hardware, that's a strong match. -- 80-100: domain matches ICP exactly + role/seniority signals support it. -- 50-79: partial match (right industry, missing role data). -- 20-49: tangential (mentions adjacent space). -- 0-19: clear miss. +- 80-100: clear match against the ICP's named industry/size/role criteria. +- 50-79: partial match (one ICP criterion confirmed, others unclear). +- 20-49: tangential (the lead mentions adjacent space but doesn't fit the ICP's core criteria). +- 0-19: clear miss against the ICP definition. -**`intentScore` (0-100):** how strongly THEIR own words signal buying intent. +If `companyProfile.icp` is missing fields, fall back to general "ideal-customer signals" reasoning, but say so in `fitReasons` (e.g. "ICP unspecified — scoring on general B2B-SaaS heuristics"). -- 80-100: explicit ask ("want a demo", "ready to start", "give us pricing"). -- 50-79: meaningful interest ("evaluating", "curious about", "we have N leads we struggle with"). -- 20-49: passing curiosity ("saw your launch, cool"). -- 0-19: no signal. +**`intentScore` (0-100):** how strongly THEIR own words signal buying intent for what `input.companyProfile.productDescription` actually does. + +- 80-100: explicit ask, AND it's an ask FOR our actual product ("want a demo of [our thing]", "ready to start a trial"). +- 50-79: meaningful interest aligned with our product ("evaluating tools that do X", where X matches our description). +- 20-49: passing curiosity, OR interest in something adjacent but not our core product. +- 0-19: no signal, OR signal for a different product entirely. **`fitReasons` and `intentReasons`:** short bullet phrases tied to evidence. *"Mentioned 'fintech-SaaS' in rawMessage matches ICP"*, not *"good fit"*. diff --git a/lib/personas/prompts/researcher.md b/lib/personas/prompts/researcher.md index a865c0d..22b8f3c 100644 --- a/lib/personas/prompts/researcher.md +++ b/lib/personas/prompts/researcher.md @@ -16,6 +16,7 @@ You run in one of two modes — the user prompt tells you which. - `input.leadId` (single) or each `items[i].leadId` (batch) — the lead's local id. - `input.item.{email, name, company, source, rawMessage}` (single) or each `items[i].{...}` (batch) — the lead's local record. +- `input.companyProfile.{companyName, productDescription}` — the founder's own company. Useful background for `intentSignals` reasoning ("explicitly mentioned a competitor" only makes sense if you know what we sell) and for grounding `techStack` inferences against what our product actually integrates with. **The fetch bundle (Pattern B) — arrives in `input.fetchBundle` (single) or `items[i].fetchBundle` (batch):** diff --git a/lib/personas/prompts/strategist.md b/lib/personas/prompts/strategist.md index e0941e7..38e9967 100644 --- a/lib/personas/prompts/strategist.md +++ b/lib/personas/prompts/strategist.md @@ -14,6 +14,7 @@ You run in one of two modes — the user prompt tells you which. - `input.leadId` (single) or `items[i].leadId` (batch). - `input.item.{email, name, company, source, rawMessage}` — the lead's local record. **`rawMessage` is the lead's own framing — your hook should ground in it when present.** +- `input.companyProfile.{companyName, oneLiner, positioning, valueProps, competitors, voiceTone}` — **the founder's own company**. `positioning` is "we are X for Y, unlike Z" — use it to pick angles that lean into what makes us different. `valueProps` is the bullet list to draw `customHooks` from. `competitors` tells you what alternatives the lead might be evaluating us against. `voiceTone` shapes the `toneGuide` you write. - `previousOutputs.researcher` *(may be missing or `error`)* — `EnrichedLead` fields like `companyIndustry`, `personRole`, `intentSignals`, `techStack`. - `previousOutputs.qualifier` *(may be missing or `error`)* — `QualifiedLead` fields like `tier`, `fitScore`, `intentSignals`, `recommendedAction`. @@ -27,15 +28,15 @@ You run in one of two modes — the user prompt tells you which. - `free_trial` — warm leads who'd convert by self-serve (mentioned tooling pain, explicit "just want to try"). - `demo_video` — cold or disqualified or low-confidence leads where a low-friction async asset keeps the door open without pressure. -**`angle`** — one short phrase (≤ 60 chars) naming the email's hook. *"HN-launch + fintech-SaaS workload alignment"*, not *"Reach out to discuss product fit"*. +**`angle`** — one short phrase (≤ 60 chars) naming the email's hook. *"HN-launch + fintech-SaaS workload alignment"*, not *"Reach out to discuss product fit"*. When `companyProfile.positioning` mentions a specific differentiator the lead's `rawMessage` corroborates, lead with that. -**`toneGuide`** — 1-2 sentences the Writer applies. Match the lead's register from `rawMessage`. Examples: +**`toneGuide`** — 1-2 sentences the Writer applies. Match the lead's register from `rawMessage`, AND honor `companyProfile.voiceTone` when present (the founder has already specified how they sound). Examples: - Casual rawMessage ("hey saw your launch") → `"Lowercase-first, dash-punctuated, peer-to-peer. Keep it under 60 words."` - Formal rawMessage ("Looking forward to evaluating your platform") → `"Measured and respectful, no slang. Lead with respect for their evaluation process."` - Technical rawMessage ("we're a B2B SaaS in fintech") → `"Direct, technically literate, name-the-stack. No marketing fluff."` -**`customHooks`** — 1-3 short phrases referencing SPECIFIC evidence from rawMessage / researcher. Not "they care about productivity"; instead "mentioned 80 inbound leads/week struggling to triage" or "Series B fintech (researcher)". Empty array is fine when nothing's grounded. +**`customHooks`** — 1-3 short phrases referencing SPECIFIC evidence from rawMessage / researcher / `companyProfile.valueProps`. Not "they care about productivity"; instead "mentioned 80 inbound leads/week struggling to triage" or "Series B fintech (researcher)" or one of our `valueProps` framed in their language. Empty array is fine when nothing's grounded. ## SINGLE mode output diff --git a/lib/personas/prompts/theme-synthesizer.md b/lib/personas/prompts/theme-synthesizer.md index 7dddb02..950b80e 100644 --- a/lib/personas/prompts/theme-synthesizer.md +++ b/lib/personas/prompts/theme-synthesizer.md @@ -12,6 +12,7 @@ You are GMaestro's Theme Synthesizer. Look across a batch of recently-tagged fee - `input.item.feedback` — array of `{ id, text, themes: string[], sentiment }` rows from the Feedback Tagger. - `input.workflowRunId` — opaque, copy through if needed. +- `input.companyProfile.{companyName, productDescription}` — the founder's own product. When a recurring theme touches a specific product area, ground the suggested next step in what `productDescription` says that area does. ## Reasoning diff --git a/lib/personas/prompts/writer.md b/lib/personas/prompts/writer.md index 58f430e..943d82c 100644 --- a/lib/personas/prompts/writer.md +++ b/lib/personas/prompts/writer.md @@ -14,6 +14,7 @@ You operate on whatever context is provided in `input` and `previousOutputs`: - **`input.leadId`** — the lead's local id (e.g. `seed-lead-001`). - **`input.item`** — the lead's record from the founder's local store. Always carries `email`, `name`, `company`, `source` (one of `inbound_form`, `trial_signup`, `manual_import`), and `rawMessage` (the lead's actual inbound text — usually the most useful single field for personalization). +- **`input.companyProfile.{companyName, oneLiner, productDescription, voiceTone}`** — **the founder's own company**. `oneLiner` and `productDescription` tell you what we sell so you can frame the hook around it without inventing. `voiceTone` complements the few-shot voice samples — concrete tone guidance the founder has written down (e.g. "lowercase-first, no hype words, signs as the founder"). Always present. - **`previousOutputs.researcher`** *(may be missing or carry `error`)* — fields like `companyDomain`, `companyIndustry`, `personRole`, `personSeniority`. Use any present. - **`previousOutputs.qualifier`** *(may be missing or carry `error`)* — fields like `tier` (`hot|warm|cold`), `fitScore`, `intentSignals`, `disqualifyReasons`. Use any present. - **`previousOutputs.strategist`** *(may be missing or carry `error`)* — fields like `tier`, `angle`, `toneGuide`, `callToAction`, `customHooks`. Use any present. @@ -21,14 +22,15 @@ You operate on whatever context is provided in `input` and `previousOutputs`: ## Reasoning rules - **Personalize using `input.item.rawMessage` first.** That's the lead's own words about why they reached out. Reference something specific from it (a phrase, a problem they named, the source) — not just their name and company. +- **Ground the pitch in `input.companyProfile.productDescription` / `oneLiner`.** Don't invent what your product does. If you're naming a capability, it has to come from `productDescription`. - **If upstream personas produced findings, weave them in.** A strategist's `customHooks[0]` becomes your hook; a qualifier's `tier` informs your tone (hot = direct ask, warm = soft check-in, cold = curiosity hook). -- **If upstream personas are missing or errored, reason from `input.item` alone.** Don't fabricate qualification — write the email you'd write knowing only what the lead said in their inbound. Default to `tier: "warm"` and a soft CTA ("worth 15 min next week?") when no strategy is available. -- **Match the lead's register.** A casual "hey saw your launch" inbound gets a casual reply. A "Looking forward to evaluating your platform" inbound gets a more measured reply. +- **If upstream personas are missing or errored, reason from `input.item` and `input.companyProfile` alone.** Don't fabricate qualification — write the email you'd write knowing only what the lead said in their inbound and what your own company does. Default to `tier: "warm"` and a soft CTA ("worth 15 min next week?") when no strategy is available. +- **Match the lead's register, and honor `companyProfile.voiceTone`.** A casual "hey saw your launch" inbound gets a casual reply. A "Looking forward to evaluating your platform" inbound gets a more measured reply. When `voiceTone` is set (e.g. "lowercase-first, no hype words"), apply it on top of the lead-matching register — never override it. - **Never invent facts about the lead's company that aren't in input.** No "I see you raised a Series B" unless `previousOutputs.researcher.fundingStage` says so. No "I noticed your Q4 numbers" ever. ## Voice -The runtime injects 0–3 founder voice samples into your context as few-shots ahead of this prompt. If zero samples are present, default to: warm, brief, lowercase-first, dash-punctuated, signed `— Aaron`. **Never invent a voice — match what's given.** +The runtime injects 0–3 founder voice samples into your context as few-shots ahead of this prompt. Combine those samples with `input.companyProfile.voiceTone` (when present) for explicit tone rules the founder has written down. If both are absent, default to: warm, brief, lowercase-first, dash-punctuated, signed `— Aaron`. **Never invent a voice — match what's given.** ## Output diff --git a/lib/personas/registry.ts b/lib/personas/registry.ts index c77a64e..22c1c19 100644 --- a/lib/personas/registry.ts +++ b/lib/personas/registry.ts @@ -48,6 +48,10 @@ const baseInput = z.object({ previousOutputs: z .record(z.string(), z.record(z.string(), z.unknown())) .optional(), + // Founder's company profile slice — see `pickCompanyProfileSlice` in + // `lib/state/workflows.ts`. The dispatcher only includes the keys that + // each persona's prompt actually references, so the shape is loose. + companyProfile: z.record(z.string(), z.unknown()).optional(), }); const leadInput = baseInput.extend({ leadId: z.string() }); diff --git a/lib/shared/mocks.ts b/lib/shared/mocks.ts index da95470..bff5b6b 100644 --- a/lib/shared/mocks.ts +++ b/lib/shared/mocks.ts @@ -21,6 +21,7 @@ import type { ActivityEvent, ApprovalRequest, BookedMeeting, + CompanyProfile, ComposioMcpConfig, Connection, EnrichedLead, @@ -526,6 +527,34 @@ export function makeMockActivityEvent( // Voice / connection factories // ============================================================================ +export function makeMockCompanyProfile( + overrides: Partial = {}, +): CompanyProfile { + return { + userId: "default", + companyName: "Anvil", + oneLiner: "Analytics for hardware engineers shipping firmware-heavy products.", + productDescription: + "Anvil records every firmware build's runtime telemetry and lets engineers diff two builds in 90 seconds. Works alongside existing Datadog/Sentry pipes — no SDK install on devices in the field. Built for hardware companies 20–500 employees shipping connected products with multiple production builds per week.", + icp: + "Hardware companies 20–500 employees shipping connected products. Buyer is the firmware-engineering lead or VP Engineering. Strong fit signals: previous incidents traced to firmware regressions, multiple production builds per week, customer-reported bugs that take >2 days to repro.", + positioning: + "We're the only telemetry tool that diffs runtime behavior between firmware builds — Datadog and Sentry both stop at the cloud edge.", + voiceTone: + "Direct, technically literate, no hype words. Lowercase-first headers. We sign off as the founder, not 'the team'.", + valueProps: [ + "90-second build-vs-build runtime diff", + "No SDK install on devices in the field", + "Works alongside existing Datadog/Sentry pipes", + ], + competitors: ["Datadog", "Sentry", "New Relic"], + sourceUrl: "https://anvil.example", + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + export function makeMockVoiceSample( overrides: Partial = {}, ): VoiceSample { diff --git a/lib/shared/schemas.ts b/lib/shared/schemas.ts index f02129e..724fa77 100644 --- a/lib/shared/schemas.ts +++ b/lib/shared/schemas.ts @@ -409,6 +409,56 @@ export const ConnectionSchema = z.object({ connectedAt: z.date().nullable().optional(), }); +// ----- company profile ----- + +export const CompanyProfileSchema = z.object({ + userId: z.string(), + companyName: z.string().nullable(), + oneLiner: z.string().nullable(), + productDescription: z.string().nullable(), + icp: z.string().nullable(), + positioning: z.string().nullable(), + voiceTone: z.string().nullable(), + valueProps: z.array(z.string()).nullable(), + competitors: z.array(z.string()).nullable(), + sourceUrl: z.string().nullable(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +/** + * What the PUT /api/company-profile route accepts. Anything missing is left + * unchanged; passing `null` clears a field. Soft caps mirror the form's + * help text — over-long values are still accepted but get truncated. + */ +export const CompanyProfileUpdateSchema = z.object({ + companyName: z.string().max(140).nullable().optional(), + oneLiner: z.string().max(140).nullable().optional(), + productDescription: z.string().max(4000).nullable().optional(), + icp: z.string().max(2000).nullable().optional(), + positioning: z.string().max(2000).nullable().optional(), + voiceTone: z.string().max(1000).nullable().optional(), + valueProps: z.array(z.string().max(140)).max(8).nullable().optional(), + competitors: z.array(z.string().max(140)).max(8).nullable().optional(), + sourceUrl: z + .string() + .url() + .or(z.literal("")) + .nullable() + .optional() + .transform((v) => (v === "" ? null : v)), +}); + +/** + * Body for POST /api/company-profile/scrape. The route fetches a few + * well-known paths and asks an LLM to draft each profile field — the + * response shape is `Partial` so the form can + * splice it on top of whatever the founder already typed. + */ +export const CompanyProfileScrapeRequestSchema = z.object({ + url: z.string().url(), +}); + // ----- API request schemas ----- export const RunWorkflowRequestSchema = z.object({ diff --git a/lib/shared/types.ts b/lib/shared/types.ts index 670b8d8..cd148b8 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -389,6 +389,39 @@ export interface FounderVoiceEdit { capturedAt: Date; } +// ============================================================================ +// Company profile — single founder-vetted record grounding every persona +// that reasons about the customer (qualifier, strategist, writer, …). +// ============================================================================ + +export interface CompanyProfile { + userId: string; + companyName: string | null; + oneLiner: string | null; + productDescription: string | null; + icp: string | null; + positioning: string | null; + voiceTone: string | null; + valueProps: string[] | null; + competitors: string[] | null; + sourceUrl: string | null; + createdAt: Date; + updatedAt: Date; +} + +/** + * The four fields a workflow run requires to be non-empty before the + * Conductor is allowed to dispatch — without these, every persona below + * is reasoning blind. Mirrored in the workflow-start guard at + * `app/api/runs/route.ts` and the dashboard's profile-incomplete banner. + */ +export const REQUIRED_COMPANY_PROFILE_FIELDS = [ + "companyName", + "oneLiner", + "productDescription", + "icp", +] as const; + // ============================================================================ // Composio connection state // ============================================================================ diff --git a/lib/state/company-profile.ts b/lib/state/company-profile.ts new file mode 100644 index 0000000..ea3552c --- /dev/null +++ b/lib/state/company-profile.ts @@ -0,0 +1,148 @@ +/** + * Company-profile reads/writes. + * + * One row per founder (`userId` is the PK). The profile grounds every + * persona that reasons about a customer or message — without it, the + * Qualifier is scoring against an unknown ICP and the Writer is drafting + * about a product the LLM had to guess at. + * + * Filling the profile is REQUIRED before a workflow run is allowed to + * dispatch — the guard lives in `app/api/runs/route.ts` and uses + * {@link isCompanyProfileComplete} to decide. Fields are nullable so a + * partial profile (e.g. an LLM draft mid-edit) can still be saved. + */ + +import "server-only"; +import { eq } from "drizzle-orm"; +import { db, schema } from "@/lib/state/db"; +import { CompanyProfileSchema } from "@/lib/shared/schemas"; +import { + REQUIRED_COMPANY_PROFILE_FIELDS, + type CompanyProfile, +} from "@/lib/shared/types"; + +export function getCompanyProfile(userId: string): CompanyProfile | null { + const row = db + .select() + .from(schema.companyProfiles) + .where(eq(schema.companyProfiles.userId, userId)) + .get(); + if (!row) return null; + return CompanyProfileSchema.parse(row); +} + +export type CompanyProfileUpsert = { + companyName?: string | null; + oneLiner?: string | null; + productDescription?: string | null; + icp?: string | null; + positioning?: string | null; + voiceTone?: string | null; + valueProps?: string[] | null; + competitors?: string[] | null; + sourceUrl?: string | null; +}; + +/** + * Insert-or-update the founder's profile. Only fields present in `patch` + * are touched — undefined keys are left as-is; null clears a field. + * + * Returns the post-write row so callers can hand it straight to a Zod + * validator + UI without a follow-up read. + */ +export function upsertCompanyProfile( + userId: string, + patch: CompanyProfileUpsert, +): CompanyProfile { + const now = new Date(); + const existing = db + .select() + .from(schema.companyProfiles) + .where(eq(schema.companyProfiles.userId, userId)) + .get(); + + if (!existing) { + db.insert(schema.companyProfiles) + .values({ + userId, + companyName: patch.companyName ?? null, + oneLiner: patch.oneLiner ?? null, + productDescription: patch.productDescription ?? null, + icp: patch.icp ?? null, + positioning: patch.positioning ?? null, + voiceTone: patch.voiceTone ?? null, + valueProps: patch.valueProps ?? null, + competitors: patch.competitors ?? null, + sourceUrl: patch.sourceUrl ?? null, + createdAt: now, + updatedAt: now, + }) + .run(); + } else { + const next: Record = { updatedAt: now }; + if (patch.companyName !== undefined) next.companyName = patch.companyName; + if (patch.oneLiner !== undefined) next.oneLiner = patch.oneLiner; + if (patch.productDescription !== undefined) + next.productDescription = patch.productDescription; + if (patch.icp !== undefined) next.icp = patch.icp; + if (patch.positioning !== undefined) next.positioning = patch.positioning; + if (patch.voiceTone !== undefined) next.voiceTone = patch.voiceTone; + if (patch.valueProps !== undefined) next.valueProps = patch.valueProps; + if (patch.competitors !== undefined) next.competitors = patch.competitors; + if (patch.sourceUrl !== undefined) next.sourceUrl = patch.sourceUrl; + db.update(schema.companyProfiles) + .set(next) + .where(eq(schema.companyProfiles.userId, userId)) + .run(); + } + + return CompanyProfileSchema.parse( + db + .select() + .from(schema.companyProfiles) + .where(eq(schema.companyProfiles.userId, userId)) + .get(), + ); +} + +/** + * True iff every required field on the profile is present and non-empty. + * Used by the workflow-start guard — runs are refused until this passes. + * + * Optional fields (positioning, voiceTone, valueProps, competitors, sourceUrl) + * are not checked here; personas degrade gracefully when they're missing. + */ +export function isCompanyProfileComplete( + profile: CompanyProfile | null, +): profile is CompanyProfile { + if (!profile) return false; + for (const field of REQUIRED_COMPANY_PROFILE_FIELDS) { + const v = profile[field]; + if (typeof v !== "string" || v.trim().length === 0) return false; + } + return true; +} + +/** + * Compact summary block used when threading the profile into prompts. + * Always returns SOMETHING — even an empty profile produces "(no company + * profile filled)" — so persona prompts can rely on the field's presence. + */ +export function formatCompanyProfileForPrompt( + profile: CompanyProfile | null, +): string { + if (!profile) return "(no company profile filled)"; + const lines: string[] = []; + if (profile.companyName) lines.push(`COMPANY: ${profile.companyName}`); + if (profile.oneLiner) lines.push(`ONE-LINER: ${profile.oneLiner}`); + if (profile.productDescription) + lines.push(`PRODUCT:\n${profile.productDescription}`); + if (profile.icp) lines.push(`ICP:\n${profile.icp}`); + if (profile.positioning) lines.push(`POSITIONING:\n${profile.positioning}`); + if (profile.voiceTone) lines.push(`VOICE / TONE:\n${profile.voiceTone}`); + if (profile.valueProps?.length) + lines.push(`VALUE PROPS:\n- ${profile.valueProps.join("\n- ")}`); + if (profile.competitors?.length) + lines.push(`COMPETITORS: ${profile.competitors.join(", ")}`); + return lines.length > 0 ? lines.join("\n\n") : "(no company profile filled)"; +} diff --git a/lib/state/schema.ts b/lib/state/schema.ts index bb92637..c435df7 100644 --- a/lib/state/schema.ts +++ b/lib/state/schema.ts @@ -309,6 +309,34 @@ export const activityEvents = sqliteTable("activity_events", { timestamp: ts("timestamp"), }); +// ----- company profile ----- +// +// One row per founder (`userId` is the PK). Grounds every persona that +// reasons about a customer or message — qualifier/strategist/writer/etc. +// reference these fields so they aren't inferring ICP/positioning/voice +// from the lead's signals alone. +// +// Fields except `userId` + timestamps are nullable so a partial profile +// (e.g. an LLM draft from a website scrape) can be saved before the +// founder finishes editing. The workflow-start guard in /api/runs checks +// the four "required for grounding" fields are non-empty before letting +// a run proceed. + +export const companyProfiles = sqliteTable("company_profiles", { + userId: text("user_id").primaryKey(), + companyName: text("company_name"), + oneLiner: text("one_liner"), + productDescription: text("product_description"), + icp: text("icp"), + positioning: text("positioning"), + voiceTone: text("voice_tone"), + valueProps: text("value_props", { mode: "json" }).$type(), + competitors: text("competitors", { mode: "json" }).$type(), + sourceUrl: text("source_url"), + createdAt: ts("created_at"), + updatedAt: ts("updated_at"), +}); + // ----- voice memory ----- export const voiceSamples = sqliteTable("voice_samples", { @@ -354,3 +382,5 @@ export type WorkflowNode = typeof workflowNodes.$inferSelect; export type ActivityEvent = typeof activityEvents.$inferSelect; export type VoiceSample = typeof voiceSamples.$inferSelect; export type FounderVoiceEdit = typeof founderVoiceEdits.$inferSelect; +export type CompanyProfile = typeof companyProfiles.$inferSelect; +export type CompanyProfileInsert = typeof companyProfiles.$inferInsert; diff --git a/lib/state/work-context.ts b/lib/state/work-context.ts index 0e3dd7d..ef5af46 100644 --- a/lib/state/work-context.ts +++ b/lib/state/work-context.ts @@ -1,7 +1,11 @@ import "server-only"; import { desc, eq } from "drizzle-orm"; import { db, schema } from "./db"; -import type { FanoutSource } from "@/lib/shared/types"; +import { + formatCompanyProfileForPrompt, + getCompanyProfile, +} from "./company-profile"; +import type { CompanyProfile, FanoutSource } from "@/lib/shared/types"; /** * Lightweight snapshot of the founder's available work surfaces, threaded into @@ -27,12 +31,21 @@ export interface WorkItem { export interface WorkContext { items: Record; + /** + * Company profile snapshot — the founder-vetted record that grounds every + * persona reasoning about a customer or message. May be null only if the + * workflow-start guard was bypassed (e.g. an internal caller); production + * runs always have this populated. + */ + companyProfile: CompanyProfile | null; summary: string; } const MAX_ITEMS_PER_SOURCE = 100; -export async function loadWorkContext(): Promise { +export async function loadWorkContext( + userId: string = process.env.GMAESTRO_USER_ID ?? "default", +): Promise { const leadRows = db .select({ id: schema.leads.id, @@ -87,25 +100,40 @@ export async function loadWorkContext(): Promise { })), }; - const summary = formatSummary(items); - return { items, summary }; + const companyProfile = getCompanyProfile(userId); + const summary = formatSummary(items, companyProfile); + return { items, companyProfile, summary }; } -function formatSummary(items: Record): string { - const sections: string[] = []; +function formatSummary( + items: Record, + companyProfile: CompanyProfile | null, +): string { + // Lead with the founder's company so the Conductor + Managers reason about + // tasks already grounded in who the company is and who they sell to. Without + // this, the planner only sees "process these leads" and has to infer the + // bar for "good fit" from the prompt + lead text alone. + const sections: string[] = [ + "FOUNDER'S COMPANY:\n" + formatCompanyProfileForPrompt(companyProfile), + ]; + + const itemSections: string[] = []; for (const [source, list] of Object.entries(items) as Array< [FanoutSource, WorkItem[]] >) { if (list.length === 0) continue; const sample = list.slice(0, 3).map((i) => ` - ${i.id}: ${i.label}`); - sections.push( + itemSections.push( `${source} (count=${list.length})\n${sample.join("\n")}` + (list.length > 3 ? `\n ... and ${list.length - 3} more` : ""), ); } - return sections.length > 0 - ? sections.join("\n\n") - : "(no work items currently available)"; + sections.push( + itemSections.length > 0 + ? itemSections.join("\n\n") + : "(no work items currently available)", + ); + return sections.join("\n\n"); } /** diff --git a/lib/state/workflows.ts b/lib/state/workflows.ts index 66fccb5..90febc3 100644 --- a/lib/state/workflows.ts +++ b/lib/state/workflows.ts @@ -23,6 +23,7 @@ import { raiseApproval } from "./approvals"; import type { ApprovalArtifactType, BlastRadius, + CompanyProfile, FanoutSource, PersonaId, TaskMode, @@ -31,6 +32,67 @@ import type { } from "@/lib/shared/types"; import { db, schema } from "./db"; +/** + * Per-persona slice of the founder's company profile that gets spliced + * into each Specialist's input as `companyProfile: {...}`. Selective + * rather than blanket — operational personas (Slack Digest, CRM Logger, + * Pipeline Reporter, Linear Filer) don't need company copy in their + * inputs, so we don't bloat their token budgets with it. + * + * The fields a persona receives match what its prompt's "Company context" + * section expects to read — keep this table aligned with the prompt files + * under `lib/personas/prompts/`. + */ +const COMPANY_PROFILE_SLICES: Partial< + Record> +> = { + researcher: ["companyName", "productDescription"], + qualifier: ["companyName", "oneLiner", "productDescription", "icp"], + strategist: [ + "companyName", + "oneLiner", + "positioning", + "valueProps", + "competitors", + "voiceTone", + ], + writer: ["companyName", "oneLiner", "productDescription", "voiceTone"], + "brief-writer": [ + "companyName", + "oneLiner", + "productDescription", + "icp", + "positioning", + "valueProps", + "competitors", + "voiceTone", + ], + activation: ["companyName", "oneLiner", "productDescription", "voiceTone"], + "feedback-tagger": ["companyName", "productDescription"], + "theme-synthesizer": ["companyName", "productDescription"], + // Scheduler, CRM Logger, Pipeline Reporter, Slack Digest, Linear Filer: + // operational personas. They don't reason about customers — they execute. +}; + +function pickCompanyProfileSlice( + personaId: PersonaId, + profile: CompanyProfile | null, +): Partial | undefined { + if (!profile) return undefined; + const fields = COMPANY_PROFILE_SLICES[personaId]; + if (!fields) return undefined; + const slice: Partial = {}; + for (const f of fields) { + const value = profile[f]; + // Only include non-empty values — keeps the JSON tight for the LLM. + if (value === null || value === undefined) continue; + if (typeof value === "string" && value.trim().length === 0) continue; + if (Array.isArray(value) && value.length === 0) continue; + (slice as Record)[f] = value; + } + return Object.keys(slice).length > 0 ? slice : undefined; +} + const mockPersonaImpl = makeMockPersonaRuntime(); function shouldUseMockPersonas(): boolean { @@ -910,6 +972,10 @@ export async function runWorkflow( (t as MaterializedTask).batchGroup === task.batchGroup && t.specialistId === task.specialistId, ); + const profileSlice = pickCompanyProfileSlice( + task.specialistId, + workContext.companyProfile, + ); const envelopes: BatchItemEnvelope[] = groupTasks.map((t) => { const id = extractItemIdFromTaskId(t.id); const itemFields = @@ -921,6 +987,7 @@ export async function runWorkflow( // Per-item upstream context for batch members. Same shape as // single-task path so prompts can read previousOutputs.. previousOutputs, + ...(profileSlice ? { companyProfile: profileSlice } : {}), }, }; }); @@ -999,11 +1066,16 @@ export async function runWorkflow( try { // Pattern B for researcher (single mode): fetch external bundle in // code BEFORE invoking the LLM, splat it into input.fetchBundle. + const profileSlice = pickCompanyProfileSlice( + task.specialistId, + workContext.companyProfile, + ); const baseInput: Record = { ...task.input, previousOutputs, workflowRunId, nodeId: task.id, + ...(profileSlice ? { companyProfile: profileSlice } : {}), }; const inputForRun = task.specialistId === "researcher" diff --git a/lib/ui/components/company-profile-form.tsx b/lib/ui/components/company-profile-form.tsx new file mode 100644 index 0000000..60ef87c --- /dev/null +++ b/lib/ui/components/company-profile-form.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Globe2, Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import type { CompanyProfile } from "@/lib/shared/types"; +import { REQUIRED_COMPANY_PROFILE_FIELDS } from "@/lib/shared/types"; + +interface ScrapeDraft { + companyName?: string | null; + oneLiner?: string | null; + productDescription?: string | null; + icp?: string | null; + positioning?: string | null; + voiceTone?: string | null; + valueProps?: string[] | null; + competitors?: string[] | null; + sourceUrl?: string | null; +} + +interface FormState { + companyName: string; + oneLiner: string; + productDescription: string; + icp: string; + positioning: string; + voiceTone: string; + valueProps: string; + competitors: string; + sourceUrl: string; +} + +function profileToForm(profile: CompanyProfile | null): FormState { + return { + companyName: profile?.companyName ?? "", + oneLiner: profile?.oneLiner ?? "", + productDescription: profile?.productDescription ?? "", + icp: profile?.icp ?? "", + positioning: profile?.positioning ?? "", + voiceTone: profile?.voiceTone ?? "", + valueProps: (profile?.valueProps ?? []).join("\n"), + competitors: (profile?.competitors ?? []).join(", "), + sourceUrl: profile?.sourceUrl ?? "", + }; +} + +function formToPayload(form: FormState) { + const trim = (s: string) => (s.trim().length === 0 ? null : s.trim()); + const lines = (s: string) => + s + .split("\n") + .map((x) => x.trim()) + .filter((x) => x.length > 0); + const csv = (s: string) => + s + .split(",") + .map((x) => x.trim()) + .filter((x) => x.length > 0); + return { + companyName: trim(form.companyName), + oneLiner: trim(form.oneLiner), + productDescription: trim(form.productDescription), + icp: trim(form.icp), + positioning: trim(form.positioning), + voiceTone: trim(form.voiceTone), + valueProps: lines(form.valueProps).length > 0 ? lines(form.valueProps) : null, + competitors: csv(form.competitors).length > 0 ? csv(form.competitors) : null, + sourceUrl: trim(form.sourceUrl), + }; +} + +interface CompanyProfileFormProps { + initialProfile: CompanyProfile | null; +} + +export function CompanyProfileForm({ initialProfile }: CompanyProfileFormProps) { + const [form, setForm] = useState(profileToForm(initialProfile)); + const [scrapeUrl, setScrapeUrl] = useState(initialProfile?.sourceUrl ?? ""); + const [isScraping, setIsScraping] = useState(false); + const [savePending, startSave] = useTransition(); + + const set = (key: keyof FormState) => (value: string) => + setForm((prev) => ({ ...prev, [key]: value })); + + const isFieldEmpty = (key: keyof CompanyProfile) => { + if (key === "valueProps") return form.valueProps.trim().length === 0; + if (key === "competitors") return form.competitors.trim().length === 0; + const v = form[key as keyof FormState]; + return typeof v === "string" && v.trim().length === 0; + }; + + const missingRequired = REQUIRED_COMPANY_PROFILE_FIELDS.filter((k) => + isFieldEmpty(k), + ); + + const handleScrape = async () => { + const url = scrapeUrl.trim(); + if (!url) { + toast.error("Enter a URL first"); + return; + } + setIsScraping(true); + try { + const res = await fetch("/api/company-profile/scrape", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(data.error || `HTTP ${res.status}`); + } + const { draft } = (await res.json()) as { draft: ScrapeDraft }; + setForm((prev) => ({ + companyName: draft.companyName ?? prev.companyName, + oneLiner: draft.oneLiner ?? prev.oneLiner, + productDescription: draft.productDescription ?? prev.productDescription, + icp: draft.icp ?? prev.icp, + positioning: draft.positioning ?? prev.positioning, + voiceTone: draft.voiceTone ?? prev.voiceTone, + valueProps: draft.valueProps?.length + ? draft.valueProps.join("\n") + : prev.valueProps, + competitors: draft.competitors?.length + ? draft.competitors.join(", ") + : prev.competitors, + sourceUrl: draft.sourceUrl ?? prev.sourceUrl ?? url, + })); + toast.success("Auto-filled from website. Review and save."); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Auto-fill failed", + ); + } finally { + setIsScraping(false); + } + }; + + const handleSave = () => { + startSave(async () => { + try { + const res = await fetch("/api/company-profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formToPayload(form)), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(data.error || `HTTP ${res.status}`); + } + toast.success( + missingRequired.length === 0 + ? "Profile saved. Workflows can now run." + : `Saved (${missingRequired.length} required field${missingRequired.length === 1 ? "" : "s"} still empty).`, + ); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Save failed"); + } + }); + }; + + return ( +
+ {missingRequired.length > 0 ? ( +
+ Profile incomplete.{" "} + Workflow runs are blocked until these fields are filled:{" "} + {missingRequired.join(", ")}. +
+ ) : ( +
+ Profile complete — workflow runs will reference these fields. +
+ )} + +
+
+ + Auto-fill from website +
+

+ Paste your homepage URL — we’ll fetch a few pages and have an LLM draft each + field for you to review. Manual entry below works too. +

+
+ setScrapeUrl(e.target.value)} + className="max-w-md" + disabled={isScraping} + /> + +
+
+ + + set("companyName")(e.target.value)} + /> + + + + set("oneLiner")(e.target.value)} + maxLength={140} + /> + + + +