diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index e51d9f0..b330273 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -2,7 +2,7 @@ import { Suspense, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { CompanyContextCard } from "@/lib/ui/components/company-context-card"; +import { CompanyProfileCard } from "@/lib/ui/components/company-profile-card"; import { PromptInput } from "@/lib/ui/components/prompt-input"; import { RecentRunsList } from "@/lib/ui/components/recent-runs-list"; import { ResumePill } from "@/lib/ui/components/resume-pill"; @@ -36,7 +36,7 @@ export default function DashboardPage() { {Hero}
- +
diff --git a/app/(dashboard)/settings/company/page.tsx b/app/(dashboard)/settings/company/page.tsx new file mode 100644 index 0000000..b231c18 --- /dev/null +++ b/app/(dashboard)/settings/company/page.tsx @@ -0,0 +1,33 @@ +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); + // First-time setup: the wizard captures GMAESTRO_COMPANY_URL so the founder + // doesn't have to re-type it here. Only used as a fallback when the saved + // profile has no sourceUrl yet. + const setupUrl = process.env.GMAESTRO_COMPANY_URL?.trim() || null; + + 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/context/refresh/route.ts b/app/api/context/refresh/route.ts deleted file mode 100644 index a7daf01..0000000 --- a/app/api/context/refresh/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextResponse } from "next/server"; -import { synthesizeCompanyContext } from "@/lib/orchestrator/context-synth"; -import { env } from "@/lib/shared/env"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function POST() { - try { - const proposed = await synthesizeCompanyContext(env().GMAESTRO_USER_ID); - return NextResponse.json({ - proposed: { ...proposed, updatedAt: proposed.updatedAt.toISOString() }, - }); - } catch (err) { - console.error("[api/context/refresh] synth failed:", err); - return NextResponse.json( - { error: "AI synthesis failed", message: (err as Error).message }, - { status: 500 }, - ); - } -} diff --git a/app/api/context/route.ts b/app/api/context/route.ts deleted file mode 100644 index ee9a0ee..0000000 --- a/app/api/context/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NextResponse } from "next/server"; -import { env } from "@/lib/shared/env"; -import { CompanyContextInputSchema } from "@/lib/shared/schemas"; -import type { CompanyContext, GtmMetric } from "@/lib/shared/types"; -import { - loadCompanyContext, - saveCompanyContext, -} from "@/lib/state/company-context"; -import { countAllMetrics } from "@/lib/state/gtm-metrics"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -interface ContextResponse { - context: (Omit & { updatedAt: string }) | null; - liveCounts: Record; -} - -function payload(context: CompanyContext | null): ContextResponse { - const liveCounts = countAllMetrics( - context?.gtmObjectives.map((o) => ({ metric: o.metric, since: o.since })) ?? - [], - ); - return { - context: context - ? { ...context, updatedAt: context.updatedAt.toISOString() } - : null, - liveCounts, - }; -} - -export function GET() { - return NextResponse.json(payload(loadCompanyContext(env().GMAESTRO_USER_ID))); -} - -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 = CompanyContextInputSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: "Invalid request", issues: parsed.error.issues }, - { status: 400 }, - ); - } - - const saved = saveCompanyContext({ - ...parsed.data, - userId: env().GMAESTRO_USER_ID, - }); - return NextResponse.json(payload(saved)); -} diff --git a/app/api/mock/company-profile/route.ts b/app/api/mock/company-profile/route.ts new file mode 100644 index 0000000..e85080b --- /dev/null +++ b/app/api/mock/company-profile/route.ts @@ -0,0 +1,15 @@ +/** + * GET /api/mock/company-profile — fixture for `NEXT_PUBLIC_USE_MOCKS=1`. + * Mirrors the live route's response shape; source of truth is + * `makeMockCompanyProfile()` in `lib/shared/mocks.ts`. + */ + +import { NextResponse } from "next/server"; +import { makeMockCompanyProfile } from "@/lib/shared/mocks"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET() { + return NextResponse.json({ profile: makeMockCompanyProfile() }); +} diff --git a/app/api/mock/context/route.ts b/app/api/mock/context/route.ts deleted file mode 100644 index 86bf4f7..0000000 --- a/app/api/mock/context/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * GET /api/mock/context — fixture for `NEXT_PUBLIC_USE_MOCKS=1` demos. - * - * Mirrors the live route's response shape. Source of truth is - * `makeMockCompanyContext()` in `lib/shared/mocks.ts`. - */ - -import { NextResponse } from "next/server"; -import { - makeMockCompanyContext, - makeMockGtmLiveCounts, -} from "@/lib/shared/mocks"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export function GET() { - const context = makeMockCompanyContext(); - return NextResponse.json({ - context: { ...context, updatedAt: context.updatedAt.toISOString() }, - liveCounts: makeMockGtmLiveCounts(), - }); -} 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_context.sql b/drizzle/migrations/0003_company_context.sql deleted file mode 100644 index a7ca8c6..0000000 --- a/drizzle/migrations/0003_company_context.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE `company_context` ( - `user_id` text PRIMARY KEY NOT NULL, - `company_overview` text NOT NULL, - `key_facts` text NOT NULL, - `icps` text NOT NULL, - `gtm_objectives` text NOT NULL, - `updated_at` integer NOT NULL -); 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/orchestrator/context-synth.ts b/lib/orchestrator/context-synth.ts deleted file mode 100644 index 7e54196..0000000 --- a/lib/orchestrator/context-synth.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Returns a *proposed* CompanyContext — never saves. The founder reviews - * and saves via PUT /api/context. - */ - -import "server-only"; -import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; -import { extractJson } from "@/lib/shared/extract-json"; -import { getModelForTier } from "@/lib/shared/models"; -import { CompanyContextSchema } from "@/lib/shared/schemas"; -import type { CompanyContext } from "@/lib/shared/types"; -import { listLeads } from "@/lib/state/leads"; -import { getVoiceSamples } from "@/lib/state/voice"; -import { loadCompanyContext } from "@/lib/state/company-context"; - -const SYNTH_TIMEOUT_MS = 30_000; -const VOICE_SAMPLE_LIMIT = 8; -const HOT_LEAD_LIMIT = 20; - -const SYSTEM_PROMPT = `You analyze a founder's voice samples and recent hot leads to synthesize a CompanyContext snapshot for a GTM (go-to-market) AI tool. - -Return a single JSON object (no prose, no fences) with this shape: - -{ - "companyOverview": "1–2 sentence description of what the founder's company does", - "keyFacts": ["short bullet 1", "short bullet 2", ...], - "icps": [ - { - "name": "ICP label", - "priority": "hot" | "warm" | "cold", - "description": "1 sentence", - "industry": ["industry tags"], - "companySizeRange": "5-30", - "seniority": ["Founder", "CEO"] - } - ], - "gtmObjectives": [ - { - "metric": "demos_booked" | "qualified_hot_leads" | "outreach_sent", - "target": , - "label": "human-readable label", - "since": "ISO datetime, e.g. 2026-01-01T00:00:00Z" - } - ] -} - -Rules: -- Identify 2–4 distinct ICPs, ranked by priority. The "hot" priority should reflect the founder's most-pursued segment based on the leads they're actually working with. -- Identify 2–3 GTM objectives. Pick reasonable quarterly targets if the data doesn't suggest specific numbers. -- Use only the three metric values shown above. -- Be specific and concrete. Avoid generic placeholders like "B2B companies" — extract real industry signals from the data. -- If the data is sparse, lean on the existing context (if provided) and make conservative refinements. -- Output JSON only. No markdown fences, no explanatory text.`; - -interface SynthInputs { - existing: CompanyContext | null; - voiceSamples: Array<{ category: string; body: string; context?: string | null }>; - hotLeads: Array<{ name: string; company?: string | null; rawMessage?: string | null }>; -} - -function buildUserPrompt(userId: string, inputs: SynthInputs): string { - const lines: string[] = []; - lines.push(`Founder userId: ${userId}`); - lines.push(""); - - if (inputs.existing) { - lines.push("## Existing CompanyContext (refine, don't replace blindly)"); - lines.push(JSON.stringify(inputs.existing, null, 2)); - lines.push(""); - } - - if (inputs.voiceSamples.length > 0) { - lines.push( - `## Voice samples (${inputs.voiceSamples.length}, most recent first)`, - ); - for (const v of inputs.voiceSamples.slice(0, VOICE_SAMPLE_LIMIT)) { - lines.push( - `- [${v.category}]${v.context ? ` (${v.context})` : ""}: ${v.body.slice(0, 280)}`, - ); - } - lines.push(""); - } - - if (inputs.hotLeads.length > 0) { - lines.push(`## Recent hot leads (${inputs.hotLeads.length})`); - for (const l of inputs.hotLeads.slice(0, HOT_LEAD_LIMIT)) { - lines.push( - `- ${l.name}${l.company ? ` @ ${l.company}` : ""}${l.rawMessage ? ` — ${l.rawMessage.slice(0, 200)}` : ""}`, - ); - } - lines.push(""); - } - - if ( - !inputs.existing && - inputs.voiceSamples.length === 0 && - inputs.hotLeads.length === 0 - ) { - lines.push( - "No prior data. Produce a sensible default for a YC-style early-stage B2B SaaS founder.", - ); - } - - lines.push("Return the CompanyContext JSON now."); - return lines.join("\n"); -} - -async function callSynthModel(userPrompt: string): Promise { - const options: Options = { - model: getModelForTier("sonnet"), - systemPrompt: SYSTEM_PROMPT, - allowedTools: [], - maxTurns: 1, - permissionMode: "bypassPermissions", - }; - - const collect = (async () => { - const stream = query({ prompt: userPrompt, options }); - let text = ""; - for await (const message of stream) { - if (message.type === "assistant" && message.message?.content) { - for (const block of message.message.content) { - if (block.type === "text") text += block.text; - } - } - if (message.type === "result") break; - } - return text; - })(); - - return Promise.race([ - collect, - new Promise((_, reject) => - setTimeout( - () => reject(new Error("context-synth timeout")), - SYNTH_TIMEOUT_MS, - ), - ), - ]); -} - -function tryParse( - raw: string, - userId: string, -): - | { ok: true; value: CompanyContext } - | { ok: false; reason: string } { - let json: unknown; - try { - json = extractJson(raw); - } catch (err) { - return { ok: false, reason: `extract-json failed: ${(err as Error).message}` }; - } - const candidate = { - ...(json as Record), - userId, - updatedAt: new Date(), - }; - const parsed = CompanyContextSchema.safeParse(candidate); - if (!parsed.success) { - return { - ok: false, - reason: `zod failed: ${parsed.error.issues.map((i) => `${i.path.join(".")}:${i.message}`).join("; ")}`, - }; - } - return { ok: true, value: parsed.data }; -} - -export async function synthesizeCompanyContext( - userId: string, -): Promise { - const existing = loadCompanyContext(userId); - - // Mock mode and unkeyed installs return the existing context (or a - // sensible default) instead of 500ing — matches title.ts. - if ( - process.env.GMAESTRO_MOCK_CONDUCTOR === "1" || - !process.env.ANTHROPIC_API_KEY - ) { - if (existing) return { ...existing, updatedAt: new Date() }; - const { makeMockCompanyContext } = await import("@/lib/shared/mocks"); - return { ...makeMockCompanyContext(), userId }; - } - - const voiceSamples = getVoiceSamples(userId); - const hotLeads = listLeads({ tier: "hot", limit: HOT_LEAD_LIMIT }); - - const inputs: SynthInputs = { - existing, - voiceSamples: voiceSamples.map((v) => ({ - category: v.category, - body: v.body, - context: v.context, - })), - hotLeads: hotLeads.map((l) => ({ - name: l.name, - company: l.company, - rawMessage: l.rawMessage, - })), - }; - - const userPrompt = buildUserPrompt(userId, inputs); - const raw = await callSynthModel(userPrompt); - - const first = tryParse(raw, userId); - if (first.ok) return first.value; - - // Retry feeds the parse error back so the model can self-correct (rule #9). - const retryPrompt = `${userPrompt}\n\nYour previous response failed validation: ${first.reason}\n\nReturn corrected JSON only.`; - const retryRaw = await callSynthModel(retryPrompt); - const second = tryParse(retryRaw, userId); - if (second.ok) return second.value; - - throw new Error(`context-synth failed after retry: ${second.reason}`); -} 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 a68afb5..a47f3eb 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/company-profile-meta.ts b/lib/shared/company-profile-meta.ts new file mode 100644 index 0000000..aebff6a --- /dev/null +++ b/lib/shared/company-profile-meta.ts @@ -0,0 +1,323 @@ +/** + * Canonical metadata for the founder's company profile. + * + * Single source of truth for: + * - Field order (required first, optional after) — used by the settings form + * and the dashboard card to render sections in the same sequence. + * - Human labels (long + short) — short labels are what appears in + * "Still missing: company name, ICP." style sentences; long labels head + * form rows + card sections. + * - Length caps + array caps — kept in lockstep with `CompanyProfileUpdateSchema` + * in `lib/shared/schemas.ts`, so client-side counters stop founders from + * submitting payloads that the server will 400. + * - Per-persona slice map (`COMPANY_PROFILE_SLICES`) — the dispatcher in + * `lib/state/workflows.ts` re-imports this so persona attribution stays in + * sync with what each persona actually receives in `input.companyProfile`. + * + * Anything that touches a profile field — form, card, error toast, persona + * dispatch — should read from here. Don't fork. + */ + +import type { CompanyProfile, PersonaId } from "./types"; + +/** + * Field-form view of CompanyProfile — only the columns the founder edits. + * Excludes infrastructure columns (`userId`, `createdAt`, `updatedAt`). + */ +export type CompanyProfileFieldKey = Exclude< + keyof CompanyProfile, + "userId" | "createdAt" | "updatedAt" +>; + +/** + * 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, Scheduler) don't need company copy, so their token budgets + * stay lean. + * + * Keep this aligned with the prompt files under `lib/personas/prompts/` — + * each prompt's "Company context" section reads the fields named here. + */ +export 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"], + // ----- Content (blogs pivot) ----- + // Researchers fetch external signal grounded in what we sell + who we sell to. + "linkedin-researcher": [ + "companyName", + "oneLiner", + "productDescription", + "icp", + "competitors", + ], + "x-researcher": [ + "companyName", + "oneLiner", + "productDescription", + "competitors", + ], + "reddit-researcher": [ + "companyName", + "oneLiner", + "productDescription", + "icp", + ], + // Synthesizer picks the angle from the three research bundles — needs + // positioning + voice direction so the outline sounds like ours. + synthesizer: [ + "companyName", + "oneLiner", + "productDescription", + "icp", + "positioning", + "voiceTone", + ], + // Blog-writer is the voice-critical persona — full slice except sourceUrl. + "blog-writer": [ + "companyName", + "oneLiner", + "productDescription", + "positioning", + "voiceTone", + "valueProps", + "competitors", + ], + // Designer just needs the brand name for attribution. Logo/colors are + // future work (no field on the profile yet). + "blog-designer": ["companyName", "oneLiner"], + // Scheduler, CRM Logger, Pipeline Reporter, Slack Digest, Linear Filer: + // operational personas. They don't reason about customers — they execute. +}; + +/** + * Inverted slice map: which personas read each field. Derived once at module + * load — exported for the form/card to render "Read by X, Y, Z" attribution + * without re-deriving on every render. + */ +export const PERSONAS_BY_FIELD: Readonly< + Record> +> = (() => { + // Pre-populate every key with an empty array so callers can index any + // field without optional chaining (sourceUrl has no readers but still + // needs to be safe to look up). + const out: Record = {}; + for (const key of [ + "companyName", + "oneLiner", + "productDescription", + "icp", + "positioning", + "voiceTone", + "valueProps", + "competitors", + "sourceUrl", + ] satisfies CompanyProfileFieldKey[]) { + out[key] = []; + } + for (const [personaId, fields] of Object.entries(COMPANY_PROFILE_SLICES) as Array< + [PersonaId, ReadonlyArray] + >) { + if (!fields) continue; + for (const field of fields) { + out[field].push(personaId); + } + } + return out as Record>; +})(); + +/** + * UI rendering kind — drives which input control the form picks and how + * the dashboard card renders the value. + */ +export type CompanyProfileFieldKind = + | "text" + | "textarea" + | "url" + | "tagsByLine" + | "tagsByComma"; + +export interface CompanyProfileFieldMeta { + key: CompanyProfileFieldKey; + /** Long label for form rows + card section headers. */ + label: string; + /** Lower-case label for inline error sentences ("Still missing: X."). */ + shortLabel: string; + /** Description / hint text shown under the form label. */ + description?: string; + placeholder?: string; + required: boolean; + kind: CompanyProfileFieldKind; + /** Hard cap from `CompanyProfileUpdateSchema`. Keep in sync. */ + maxLength?: number; + /** Tag-list caps from `CompanyProfileUpdateSchema`. Keep in sync. */ + maxItems?: number; + maxItemLength?: number; +} + +/** + * Canonical render order. Required fields first (so a founder filling the + * form top-to-bottom hits everything they need before reaching optionals), + * then optionals grouped by topic (positioning + voice → wording-shaping; + * value props + competitors → angle inputs; sourceUrl → housekeeping). + * + * Settings form, dashboard card, and partial-save toast all walk this order. + */ +export const COMPANY_PROFILE_FIELD_ORDER: ReadonlyArray = [ + "companyName", + "oneLiner", + "productDescription", + "icp", + "positioning", + "voiceTone", + "valueProps", + "competitors", + "sourceUrl", +]; + +/** + * The subset that workflow runs require to be non-empty before dispatch. + * Mirrored in `app/api/runs/route.ts`'s 409 path. Order matches the form so + * "missing X, Y" reads top-to-bottom. + */ +export const REQUIRED_COMPANY_PROFILE_FIELDS: ReadonlyArray = + ["companyName", "oneLiner", "productDescription", "icp"]; + +export const COMPANY_PROFILE_FIELDS: Record< + CompanyProfileFieldKey, + CompanyProfileFieldMeta +> = { + companyName: { + key: "companyName", + label: "Company name", + shortLabel: "company name", + placeholder: "Anvil", + required: true, + kind: "text", + maxLength: 140, + }, + oneLiner: { + key: "oneLiner", + label: "One-liner", + shortLabel: "one-liner", + description: + "≤140 chars. The plain-English summary you'd give in an elevator.", + placeholder: + "Analytics for hardware engineers shipping firmware-heavy products.", + required: true, + kind: "text", + maxLength: 140, + }, + productDescription: { + key: "productDescription", + label: "What the product does", + shortLabel: "product description", + description: + "Markdown ok. What it actually does, who it's for, key capabilities.", + placeholder: + "Anvil records every firmware build's runtime telemetry and lets engineers diff two builds in 90 seconds…", + required: true, + kind: "textarea", + maxLength: 4000, + }, + icp: { + key: "icp", + label: "ICP (ideal customer)", + shortLabel: "ICP", + description: "Markdown ok. Who buys, what makes them a fit.", + placeholder: + "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.", + required: true, + kind: "textarea", + maxLength: 2000, + }, + positioning: { + key: "positioning", + label: "Positioning", + shortLabel: "positioning", + description: "“We are X for Y, unlike Z.”", + placeholder: + "We're the only telemetry tool that diffs runtime behavior between firmware builds — Datadog and Sentry both stop at the cloud edge.", + required: false, + kind: "textarea", + maxLength: 2000, + }, + voiceTone: { + key: "voiceTone", + label: "Voice / tone", + shortLabel: "voice / tone", + description: "How you sound in writing.", + placeholder: + "Direct, technically literate, no hype words. Lowercase-first headers. We sign off as the founder, not 'the team'.", + required: false, + kind: "textarea", + maxLength: 1000, + }, + valueProps: { + key: "valueProps", + label: "Value props", + shortLabel: "value props", + description: "One per line. Up to 8.", + placeholder: + "90-second build-vs-build runtime diff\nNo SDK install on devices in the field\nWorks alongside existing Datadog/Sentry pipes", + required: false, + kind: "tagsByLine", + maxItems: 8, + maxItemLength: 140, + }, + competitors: { + key: "competitors", + label: "Competitors", + shortLabel: "competitors", + description: "Comma-separated. Names of products you're commonly compared against.", + placeholder: "Datadog, Sentry, New Relic", + required: false, + kind: "tagsByComma", + maxItems: 8, + maxItemLength: 140, + }, + sourceUrl: { + key: "sourceUrl", + label: "Website URL (source)", + shortLabel: "website URL", + description: + "Stored alongside the profile so 'Auto-fill from website' has a default next time.", + placeholder: "https://yourcompany.com", + required: false, + kind: "url", + }, +}; + +/** + * Convenience: the "still missing" sentence the dashboard card and partial- + * save toast both produce. Keeps wording consistent. + */ +export function formatMissingFieldsList( + fields: ReadonlyArray, +): string { + return fields.map((f) => COMPANY_PROFILE_FIELDS[f].shortLabel).join(", "); +} diff --git a/lib/shared/mocks.ts b/lib/shared/mocks.ts index c6e9759..39061fc 100644 --- a/lib/shared/mocks.ts +++ b/lib/shared/mocks.ts @@ -21,11 +21,10 @@ import type { ActivityEvent, ApprovalRequest, BookedMeeting, - CompanyContext, + CompanyProfile, ComposioMcpConfig, Connection, EnrichedLead, - GtmMetric, Lead, OutreachDraft, OutreachStrategy, @@ -543,6 +542,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 { @@ -558,80 +585,6 @@ export function makeMockVoiceSample( }; } -export function makeMockCompanyContext( - overrides: Partial = {}, -): CompanyContext { - return { - userId: "default", - companyOverview: - "Anvil — YC W26 devtools startup, ~24 employees, US-based, recently fundraised.", - keyFacts: [ - "YC W26", - "Devtools", - "~24 employees", - "US-based", - "Recent Series Seed", - ], - icps: [ - { - name: "B2B SaaS pre-Series A", - priority: "hot", - description: "Founder-led GTM, no AE/SDR yet, hiring engineers", - industry: ["B2B SaaS"], - companySizeRange: "5-30", - seniority: ["Founder", "CEO"], - }, - { - name: "Technical founder, recent fundraise", - priority: "warm", - description: "Just raised seed, ramping outbound", - industry: ["B2B SaaS", "Devtools"], - companySizeRange: "10-50", - seniority: ["Founder", "CTO"], - }, - { - name: "PLG SaaS with stalled trials", - priority: "warm", - description: - "Self-serve product, low activation rate, looking for nudge automation", - industry: ["B2B SaaS"], - companySizeRange: "20-100", - seniority: ["Founder", "VP"], - }, - ], - gtmObjectives: [ - { - metric: "demos_booked", - target: 50, - label: "Q1 demos booked", - since: "2026-01-01T00:00:00Z", - }, - { - metric: "qualified_hot_leads", - target: 100, - label: "Q1 hot leads", - since: "2026-01-01T00:00:00Z", - }, - { - metric: "outreach_sent", - target: 200, - label: "Q1 outreach sent", - since: "2026-01-01T00:00:00Z", - }, - ], - updatedAt: new Date(), - ...overrides, - }; -} - -export function makeMockGtmLiveCounts(): Record { - return { - demos_booked: 12, - qualified_hot_leads: 38, - outreach_sent: 87, - }; -} - export function makeMockConnection( overrides: Partial = {}, ): Connection { diff --git a/lib/shared/schemas.ts b/lib/shared/schemas.ts index 0d7da20..724fa77 100644 --- a/lib/shared/schemas.ts +++ b/lib/shared/schemas.ts @@ -409,45 +409,54 @@ export const ConnectionSchema = z.object({ connectedAt: z.date().nullable().optional(), }); -// ----- company context ----- +// ----- company profile ----- -export const IcpPrioritySchema = z.enum(["hot", "warm", "cold"]); - -export const ICPProfileSchema = z.object({ - name: z.string().min(1), - priority: IcpPrioritySchema, - description: z.string(), - industry: z.array(z.string()).default([]), - companySizeRange: z.string().optional(), - seniority: z.array(z.string()).default([]), -}); - -export const GtmMetricSchema = z.enum([ - "demos_booked", - "qualified_hot_leads", - "outreach_sent", -]); - -export const GtmObjectiveSchema = z.object({ - metric: GtmMetricSchema, - target: z.number().int().positive(), - label: z.string().min(1), - since: z.string().datetime().optional(), +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(), }); -export const CompanyContextSchema = z.object({ - userId: z.string(), - companyOverview: z.string(), - keyFacts: z.array(z.string()).default([]), - icps: z.array(ICPProfileSchema).default([]), - gtmObjectives: z.array(GtmObjectiveSchema).default([]), - updatedAt: z.coerce.date().default(() => new 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 shape for PUT /api/context — userId + updatedAt are server-side. */ -export const CompanyContextInputSchema = CompanyContextSchema.omit({ - userId: true, - updatedAt: true, +/** + * 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 ----- diff --git a/lib/shared/types.ts b/lib/shared/types.ts index d3a49e4..37ce80b 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -396,6 +396,33 @@ 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; +} + +/** + * Re-exported from `company-profile-meta` for legacy callers. New code should + * import from there directly — that's where the rest of the profile metadata + * (labels, length caps, persona slices) lives. + */ +export { REQUIRED_COMPANY_PROFILE_FIELDS } from "./company-profile-meta"; + // ============================================================================ // Composio connection state // ============================================================================ @@ -413,47 +440,6 @@ export interface Connection { connectedAt?: Date | null; } -// ============================================================================ -// Company context — singleton row per founder, surfaces on the dashboard. -// Founder edits via the dialog OR LLM synthesis proposes updates the founder -// reviews/saves. Live counts on objectives are computed at read time from -// existing tables (no snapshot history). -// ============================================================================ - -export type IcpPriority = "hot" | "warm" | "cold"; - -export interface ICPProfile { - name: string; - priority: IcpPriority; - description: string; - industry: string[]; - companySizeRange?: string; - seniority: string[]; -} - -/** Metrics with a clean DB source. Add new entries only when a counter exists. */ -export type GtmMetric = - | "demos_booked" - | "qualified_hot_leads" - | "outreach_sent"; - -export interface GtmObjective { - metric: GtmMetric; - target: number; - label: string; - /** ISO date string. Live count filters >= this. Omit for all-time. */ - since?: string; -} - -export interface CompanyContext { - userId: string; - companyOverview: string; - keyFacts: string[]; - icps: ICPProfile[]; - gtmObjectives: GtmObjective[]; - updatedAt: Date; -} - // ============================================================================ // MCP config — what `getMcpConfigForUser` returns (used by Session 1 + 2) // ============================================================================ diff --git a/lib/state/company-context.ts b/lib/state/company-context.ts deleted file mode 100644 index d667620..0000000 --- a/lib/state/company-context.ts +++ /dev/null @@ -1,28 +0,0 @@ -import "server-only"; -import { eq } from "drizzle-orm"; -import { db, schema } from "@/lib/state/db"; -import { CompanyContextSchema } from "@/lib/shared/schemas"; -import type { CompanyContext } from "@/lib/shared/types"; - -export function loadCompanyContext(userId: string): CompanyContext | null { - const row = db - .select() - .from(schema.companyContext) - .where(eq(schema.companyContext.userId, userId)) - .get(); - return row ? CompanyContextSchema.parse(row) : null; -} - -export function saveCompanyContext( - input: Omit, -): CompanyContext { - const row = { ...input, updatedAt: new Date() }; - db.insert(schema.companyContext) - .values(row) - .onConflictDoUpdate({ - target: schema.companyContext.userId, - set: row, - }) - .run(); - return CompanyContextSchema.parse(row); -} 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/gtm-metrics.ts b/lib/state/gtm-metrics.ts deleted file mode 100644 index 6b242ad..0000000 --- a/lib/state/gtm-metrics.ts +++ /dev/null @@ -1,56 +0,0 @@ -import "server-only"; -import { and, count, eq, gte, isNotNull, type SQL, type Table } from "drizzle-orm"; -import { db, schema } from "@/lib/state/db"; -import type { GtmMetric } from "@/lib/shared/types"; - -/** Zero-init counts for every metric. Exhaustiveness keeps it in sync with `GtmMetric`. */ -export const ZERO_GTM_COUNTS: Record = { - demos_booked: 0, - qualified_hot_leads: 0, - outreach_sent: 0, -}; - -// Adding a metric: extend GtmMetric AND add a counter here. The Record's -// exhaustiveness check enforces both halves stay in sync. -const METRIC_COUNTERS: Record number> = { - demos_booked: (since) => - countWhere( - schema.bookedMeetings, - since ? gte(schema.bookedMeetings.bookedAt, since) : undefined, - ), - qualified_hot_leads: (since) => - countWhere( - schema.qualifiedLeads, - and( - eq(schema.qualifiedLeads.tier, "hot"), - since ? gte(schema.qualifiedLeads.qualifiedAt, since) : undefined, - ), - ), - outreach_sent: (since) => - countWhere( - schema.outreachDrafts, - and( - isNotNull(schema.outreachDrafts.sentAt), - since ? gte(schema.outreachDrafts.sentAt, since) : undefined, - ), - ), -}; - -function countWhere(table: Table, where: SQL | undefined): number { - const row = db.select({ n: count() }).from(table).where(where).get(); - return row?.n ?? 0; -} - -export function metricToCount(metric: GtmMetric, since?: Date): number { - return METRIC_COUNTERS[metric](since); -} - -export function countAllMetrics( - metrics: Array<{ metric: GtmMetric; since?: string }>, -): Record { - const out: Record = { ...ZERO_GTM_COUNTS }; - for (const m of metrics) { - out[m.metric] = metricToCount(m.metric, m.since ? new Date(m.since) : undefined); - } - return out; -} diff --git a/lib/state/schema.ts b/lib/state/schema.ts index e345844..c435df7 100644 --- a/lib/state/schema.ts +++ b/lib/state/schema.ts @@ -10,7 +10,6 @@ */ import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; -import type { GtmObjective, ICPProfile } from "../shared/types"; // ----- helpers ----- @@ -310,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", { @@ -333,23 +360,6 @@ export const founderVoiceEdits = sqliteTable("founder_voice_edits", { capturedAt: ts("captured_at"), }); -// ----- company context (singleton per founder) ----- - -export const companyContext = sqliteTable("company_context", { - userId: text("user_id").primaryKey(), - companyOverview: text("company_overview").notNull(), - keyFacts: text("key_facts", { mode: "json" }) - .notNull() - .$type(), - icps: text("icps", { mode: "json" }) - .notNull() - .$type(), - gtmObjectives: text("gtm_objectives", { mode: "json" }) - .notNull() - .$type(), - updatedAt: ts("updated_at"), -}); - // ----- type exports for downstream code ----- export type Lead = typeof leads.$inferSelect; @@ -372,5 +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 CompanyContextRow = typeof companyContext.$inferSelect; -export type CompanyContextInsert = typeof companyContext.$inferInsert; +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..f981f71 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,30 @@ import type { } from "@/lib/shared/types"; import { db, schema } from "./db"; +// Slice map lives in `lib/shared/company-profile-meta.ts` so the dashboard +// can derive "Read by …" attribution from the same source of truth that +// dispatch reads. Don't fork. +import { COMPANY_PROFILE_SLICES } from "@/lib/shared/company-profile-meta"; + +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 +935,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 +950,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 +1029,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-context-card.tsx b/lib/ui/components/company-context-card.tsx deleted file mode 100644 index bd3aae6..0000000 --- a/lib/ui/components/company-context-card.tsx +++ /dev/null @@ -1,334 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { - Building2, - ChevronDown, - Loader2, - PencilLine, - Sparkles, - Target, - TrendingUp, - Users, -} from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardFooter } from "@/components/ui/card"; -import { IcpPrioritySchema } from "@/lib/shared/schemas"; -import type { - CompanyContext, - GtmObjective, - IcpPriority, -} from "@/lib/shared/types"; -import { cn } from "@/lib/utils"; -import { EditCompanyContextDialog } from "@/lib/ui/components/edit-company-context-dialog"; -import { useCompanyContext, type LiveCounts } from "@/lib/ui/hooks/use-company-context"; - -const PRIORITY_TONE: Record = { - hot: "bg-rose-500/15 text-rose-700 dark:bg-rose-400/15 dark:text-rose-300 border-rose-500/30", - warm: "bg-amber-500/15 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300 border-amber-500/30", - cold: "bg-slate-500/15 text-slate-700 dark:bg-slate-400/15 dark:text-slate-300 border-slate-500/30", -}; - -const PRIORITY_ORDER = Object.fromEntries( - IcpPrioritySchema.options.map((p, i) => [p, i]), -) as Record; - -function summarize(context: CompanyContext): string { - const company = context.companyOverview.split(/[—.]/)[0]?.trim() ?? "Company"; - const icpCount = context.icps.length; - const topGoal = context.gtmObjectives[0]; - const goalChip = topGoal - ? ` · ${topGoal.target} ${topGoal.label.replace(/^Q\d\s+/i, "")}` - : ""; - const company72 = company.length > 72 ? `${company.slice(0, 69)}…` : company; - return `${company72} · ${icpCount} ICP${icpCount === 1 ? "" : "s"}${goalChip}`; -} - -export function CompanyContextCard() { - const { context, liveCounts, loading, refreshing, save, refresh } = - useCompanyContext(); - const [open, setOpen] = useState(false); - const [editorOpen, setEditorOpen] = useState(false); - const [editorSeed, setEditorSeed] = useState(null); - - const sortedIcps = useMemo( - () => - context - ? [...context.icps].sort( - (a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority], - ) - : [], - [context], - ); - - if (loading) return null; - - const empty = !context; - - const handleEdit = () => { - setEditorSeed(context); - setEditorOpen(true); - }; - - const handleRefresh = async () => { - const proposed = await refresh(); - if (!proposed) return; - setEditorSeed(proposed); - setEditorOpen(true); - }; - - return ( - <> - - - - {open && ( - <> -
- - {empty ? ( - - ) : ( - <> - - - - - )} - - - - {context - ? `Updated ${context.updatedAt.toLocaleString()}` - : "Set up so agents stay aligned"} - -
- - -
-
- - )} - - - { - await save(next); - setEditorOpen(false); - }} - /> - - ); -} - -function EmptyState({ onEdit }: { onEdit: () => void }) { - return ( -
-

- Tell your agents who you are and what you're going after. They'll keep - their reasoning anchored to your ICP and quarterly goals. -

-
- -
-
- ); -} - -function CompanySection({ context }: { context: CompanyContext }) { - return ( -
- -

- {context.companyOverview} -

- {context.keyFacts.length > 0 && ( -
- {context.keyFacts.map((fact) => ( - - {fact} - - ))} -
- )} -
- ); -} - -function IcpsSection({ icps }: { icps: CompanyContext["icps"] }) { - return ( -
- - {icps.length === 0 ? ( -

No ICPs defined yet.

- ) : ( -
    - {icps.map((icp) => ( -
  • -
    - - {icp.priority} - - {icp.name} -
    -

    - {icp.description} -

    -
    - {icp.companySizeRange && ( - - {icp.companySizeRange} - - )} - {icp.industry.map((ind) => ( - - {ind} - - ))} - {icp.seniority.map((s) => ( - - {s} - - ))} -
    -
  • - ))} -
- )} -
- ); -} - -function ObjectivesSection({ - objectives, - counts, -}: { - objectives: GtmObjective[]; - counts: LiveCounts; -}) { - return ( -
- - {objectives.length === 0 ? ( -

No goals set.

- ) : ( -
    - {objectives.map((obj) => { - const count = counts[obj.metric] ?? 0; - const pct = Math.min(100, Math.round((count / obj.target) * 100)); - const onTrack = count >= obj.target; - return ( -
  • -
    - - - {obj.label} - - - - {count} - - / {obj.target} - -
    -
    -
    -
    -
  • - ); - })} -
- )} -
- ); -} - -function SectionHeader({ - icon: Icon, - label, -}: { - icon: React.ComponentType<{ className?: string }>; - label: string; -}) { - return ( -
- - {label} -
- ); -} diff --git a/lib/ui/components/company-profile-card.tsx b/lib/ui/components/company-profile-card.tsx new file mode 100644 index 0000000..69b9a0b --- /dev/null +++ b/lib/ui/components/company-profile-card.tsx @@ -0,0 +1,308 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { + AlertCircle, + Building2, + ChevronDown, + CircleCheck, + ExternalLink, + Globe2, + PencilLine, + Quote, + Sparkles, + Target, + Users, + type LucideIcon, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { + COMPANY_PROFILE_FIELDS, + COMPANY_PROFILE_FIELD_ORDER, + PERSONAS_BY_FIELD, + formatMissingFieldsList, + type CompanyProfileFieldKey, +} from "@/lib/shared/company-profile-meta"; +import type { CompanyProfile, PersonaId } from "@/lib/shared/types"; +import { PERSONA_LABEL } from "@/lib/ui/persona-meta"; +import { cn } from "@/lib/utils"; +import { + isProfileComplete, + missingRequiredFields, + useCompanyProfile, +} from "@/lib/ui/hooks/use-company-profile"; + +const SETTINGS_HREF = "/settings/company"; + +const FIELD_ICON: Partial> = { + oneLiner: Quote, + productDescription: Sparkles, + icp: Users, + positioning: Target, + voiceTone: Quote, + valueProps: Sparkles, + competitors: Target, + sourceUrl: Globe2, +}; + +export function CompanyProfileCard() { + const { profile, loading } = useCompanyProfile(); + const [open, setOpen] = useState(false); + + if (loading) return null; + + const complete = isProfileComplete(profile); + const missing = missingRequiredFields(profile); + const summary = profile?.companyName + ? `${profile.companyName}${profile.oneLiner ? ` — ${profile.oneLiner}` : ""}` + : "Set up your company profile"; + const truncatedSummary = + summary.length > 96 ? `${summary.slice(0, 93)}…` : summary; + + return ( + + + + {open && ( + <> +
+ + {!complete && ( + + )} + {profile ? ( + + ) : ( +

+ Tell your agents who you are and who you sell to. They'll + anchor every reasoning step to it. +

+ )} +
+ + + {profile?.updatedAt + ? `Updated ${profile.updatedAt.toLocaleString()}` + : "Not configured"} + + + + {complete ? "Edit in settings" : "Open settings"} + + + + )} + + ); +} + +function CompletenessBadge({ + complete, + missing, +}: { + complete: boolean; + missing: number; +}) { + if (complete) { + return ( + + + complete + + ); + } + return ( + + + {missing > 0 ? `${missing} missing` : "incomplete"} + + ); +} + +function IncompleteBanner({ missing }: { missing: CompanyProfileFieldKey[] }) { + return ( +
+ +
+ + Workflow runs are blocked until your profile is filled. + + {missing.length > 0 && ( + + Still missing:{" "} + {formatMissingFieldsList(missing)}. + + )} + + Personas need this to score leads, pick angles, and draft in your voice. + +
+
+ ); +} + +function ProfileBody({ profile }: { profile: CompanyProfile }) { + // Render in canonical order. Skip companyName (it's the chip header) and + // any optional field that's empty. + const sections = COMPANY_PROFILE_FIELD_ORDER.filter((k) => k !== "companyName") + .map((key) => ({ key, value: profile[key] })) + .filter(({ value }) => !isValueEmpty(value)); + + return ( +
+ {sections.map(({ key, value }) => { + const meta = COMPANY_PROFILE_FIELDS[key]; + const Icon = FIELD_ICON[key] ?? Sparkles; + const personas = PERSONAS_BY_FIELD[key]; + return ( +
+ {renderValue(key, value)} +
+ ); + })} +
+ ); +} + +function isValueEmpty(value: unknown): boolean { + if (value == null) return true; + if (typeof value === "string") return value.trim().length === 0; + if (Array.isArray(value)) return value.length === 0; + return false; +} + +function renderValue( + key: CompanyProfileFieldKey, + value: CompanyProfile[CompanyProfileFieldKey], +) { + if (key === "valueProps" && Array.isArray(value)) { + return ( +
    + {value.map((vp, i) => ( +
  • {vp}
  • + ))} +
+ ); + } + if (key === "competitors" && Array.isArray(value)) { + return ( +
+ {value.map((c) => ( + + {c} + + ))} +
+ ); + } + if (key === "sourceUrl" && typeof value === "string") { + return ( + + {value} + + + ); + } + if (typeof value === "string") { + return ; + } + return null; +} + +function Section({ + icon: Icon, + label, + personas, + children, +}: { + icon: LucideIcon; + label: string; + personas: ReadonlyArray | undefined; + children: React.ReactNode; +}) { + return ( +
+
+ + {label} + {personas && personas.length > 0 && ( + + read by + {personas.slice(0, 3).map((p) => ( + + {PERSONA_LABEL[p]} + + )).reduce((acc, el, i, arr) => { + acc.push(el); + if (i < arr.length - 1) acc.push(·); + return acc; + }, [])} + {personas.length > 3 && ( + +{personas.length - 3} + )} + + )} +
+ {children} +
+ ); +} + +function Clamped({ text }: { text: string }) { + // Long fields (productDescription/icp/positioning can be ~2-4k chars) — + // tap to toggle; default-clamped so the card stays scannable. + const [expanded, setExpanded] = useState(false); + const isLong = text.length > 280; + if (!isLong) { + return

{text}

; + } + return ( +
+

+ {text} +

+ +
+ ); +} diff --git a/lib/ui/components/company-profile-form.tsx b/lib/ui/components/company-profile-form.tsx new file mode 100644 index 0000000..8b94ccb --- /dev/null +++ b/lib/ui/components/company-profile-form.tsx @@ -0,0 +1,421 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Globe2, Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + COMPANY_PROFILE_FIELDS, + COMPANY_PROFILE_FIELD_ORDER, + PERSONAS_BY_FIELD, + REQUIRED_COMPANY_PROFILE_FIELDS, + formatMissingFieldsList, + type CompanyProfileFieldKey, + type CompanyProfileFieldMeta, +} from "@/lib/shared/company-profile-meta"; +import type { CompanyProfile, PersonaId } from "@/lib/shared/types"; +import { PERSONA_LABEL } from "@/lib/ui/persona-meta"; +import { cn } from "@/lib/utils"; + +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; +} + +type FormState = Record; + +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 parseTagsByLine(s: string): string[] { + return s.split("\n").map((x) => x.trim()).filter((x) => x.length > 0); +} + +function parseTagsByComma(s: string): string[] { + return s.split(",").map((x) => x.trim()).filter((x) => x.length > 0); +} + +function tagsForField(meta: CompanyProfileFieldMeta, raw: string): string[] { + return meta.kind === "tagsByLine" ? parseTagsByLine(raw) : parseTagsByComma(raw); +} + +interface FieldValidation { + /** Issues to display under the field. Empty means valid. */ + issues: string[]; + /** Length / count to show in counters when relevant. */ + count?: number; +} + +function validateField(meta: CompanyProfileFieldMeta, raw: string): FieldValidation { + const issues: string[] = []; + if (meta.kind === "tagsByLine" || meta.kind === "tagsByComma") { + const tags = tagsForField(meta, raw); + if (meta.maxItems != null && tags.length > meta.maxItems) { + issues.push(`Up to ${meta.maxItems} items (you have ${tags.length}).`); + } + if (meta.maxItemLength != null) { + const tooLong = tags.filter((t) => t.length > meta.maxItemLength!); + if (tooLong.length > 0) { + issues.push( + `${tooLong.length === 1 ? "One item exceeds" : `${tooLong.length} items exceed`} ${meta.maxItemLength} chars.`, + ); + } + } + return { issues, count: tags.length }; + } + if (meta.maxLength != null && raw.length > meta.maxLength) { + issues.push(`Over ${meta.maxLength}-char limit (${raw.length}).`); + } + return { issues, count: raw.length }; +} + +function formToPayload(form: FormState) { + const trim = (s: string) => (s.trim().length === 0 ? null : s.trim()); + const tagsLine = (s: string) => { + const arr = parseTagsByLine(s); + return arr.length > 0 ? arr : null; + }; + const tagsComma = (s: string) => { + const arr = parseTagsByComma(s); + return arr.length > 0 ? arr : null; + }; + 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: tagsLine(form.valueProps), + competitors: tagsComma(form.competitors), + sourceUrl: trim(form.sourceUrl), + }; +} + +function isFieldEmpty(meta: CompanyProfileFieldMeta, raw: string): boolean { + if (meta.kind === "tagsByLine" || meta.kind === "tagsByComma") { + return tagsForField(meta, raw).length === 0; + } + return raw.trim().length === 0; +} + +interface CompanyProfileFormProps { + initialProfile: CompanyProfile | null; + /** URL captured by the setup wizard. Used as the scrape default when the + * saved profile has no sourceUrl yet. */ + setupUrl?: string | null; +} + +export function CompanyProfileForm({ + initialProfile, + setupUrl, +}: CompanyProfileFormProps) { + const [form, setForm] = useState(profileToForm(initialProfile)); + const [scrapeUrl, setScrapeUrl] = useState( + initialProfile?.sourceUrl ?? setupUrl ?? "", + ); + const [isScraping, setIsScraping] = useState(false); + const [savePending, startSave] = useTransition(); + + const set = (key: CompanyProfileFieldKey) => (value: string) => + setForm((prev) => ({ ...prev, [key]: value })); + + const missingRequired = REQUIRED_COMPANY_PROFILE_FIELDS.filter((k) => + isFieldEmpty(COMPANY_PROFILE_FIELDS[k], form[k]), + ); + const hasValidationIssues = COMPANY_PROFILE_FIELD_ORDER.some( + (k) => validateField(COMPANY_PROFILE_FIELDS[k], form[k]).issues.length > 0, + ); + + 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}`); + } + if (missingRequired.length === 0) { + toast.success("Profile saved. Workflows can now run."); + } else { + toast.success( + `Saved. Still missing: ${formatMissingFieldsList(missingRequired)}.`, + ); + } + } 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:{" "} + + {formatMissingFieldsList(missingRequired)} + + . +
+ ) : ( +
+ 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} + /> + +
+
+ + {COMPANY_PROFILE_FIELD_ORDER.map((key) => { + const meta = COMPANY_PROFILE_FIELDS[key]; + const value = form[key]; + const validation = validateField(meta, value); + const empty = isFieldEmpty(meta, value); + const personas = PERSONAS_BY_FIELD[key] ?? []; + return ( + + ); + })} + +
+ {hasValidationIssues && ( + + Fix the highlighted fields before saving. + + )} + +
+
+ ); +} + +interface FieldRowProps { + meta: CompanyProfileFieldMeta; + value: string; + onChange: (next: string) => void; + validation: FieldValidation; + empty: boolean; + personas: ReadonlyArray; +} + +function FieldRow({ + meta, + value, + onChange, + validation, + empty, + personas, +}: FieldRowProps) { + const hasIssues = validation.issues.length > 0; + const counterText = formatCounter(meta, validation); + + return ( +
+
+ + {meta.required && ( + + required + + )} + {personas.length > 0 && ( + + Read by + {personas.slice(0, 4).map((p) => ( + + {PERSONA_LABEL[p]} + + ))} + {personas.length > 4 && ( + + +{personas.length - 4} + + )} + + )} +
+ {meta.description && ( +

{meta.description}

+ )} + {meta.kind === "textarea" || meta.kind === "tagsByLine" ? ( +