From 2c6c5b64ffd3b6ab9208b600f035e715b4097b75 Mon Sep 17 00:00:00 2001 From: Sunny Wu Date: Sat, 9 May 2026 22:52:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20company-context=20card=20?= =?UTF-8?q?=E2=80=94=20founder-confirmed=20snapshot=20of=20company,=20ICPs?= =?UTF-8?q?,=20GTM=20goals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a collapsible card on the home dashboard surfacing the agents' understanding of the company. Founder edits via Dialog or bootstraps via "Refresh from AI" (one-shot Sonnet synth from voice + hot leads). Live counts on GTM goals derive from existing pipeline tables — no snapshot history. - new singleton table `company_context` (json columns for icps + objectives) - `lib/state/{company-context,gtm-metrics}.ts` for load/save + counters - `lib/orchestrator/context-synth.ts` mirrors title.ts pattern, mock/no-key fallback - `app/api/context/{route,refresh/route}.ts` + mock mirror - card + edit dialog + hook in `lib/ui/` - Anvil seed in `scripts/seed-demo.ts` Personas don't yet read CompanyContext — that's a follow-up (thread it into WorkContext for Conductor + Writer few-shots once backend is ready). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 51 +- app/api/context/refresh/route.ts | 21 + app/api/context/route.ts | 57 ++ app/api/mock/context/route.ts | 23 + drizzle/migrations/0003_company_context.sql | 8 + lib/orchestrator/context-synth.ts | 215 ++++++++ lib/shared/schemas.ts | 41 ++ lib/state/company-context.ts | 28 + lib/state/gtm-metrics.ts | 56 ++ lib/state/schema.ts | 20 + lib/ui/components/company-context-card.tsx | 334 ++++++++++++ .../edit-company-context-dialog.tsx | 497 ++++++++++++++++++ lib/ui/hooks/use-company-context.ts | 138 +++++ scripts/seed-demo.ts | 99 +++- 14 files changed, 1577 insertions(+), 11 deletions(-) create mode 100644 app/api/context/refresh/route.ts create mode 100644 app/api/context/route.ts create mode 100644 app/api/mock/context/route.ts create mode 100644 drizzle/migrations/0003_company_context.sql create mode 100644 lib/orchestrator/context-synth.ts create mode 100644 lib/state/company-context.ts create mode 100644 lib/state/gtm-metrics.ts create mode 100644 lib/ui/components/company-context-card.tsx create mode 100644 lib/ui/components/edit-company-context-dialog.tsx create mode 100644 lib/ui/hooks/use-company-context.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0160f11..5a67fa5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,11 +51,12 @@ L1 Conductor query() — 1 SDK call, returns plan │ │ ├─ sales-mgr │ │ ├─ cs-mgr │ │ ├─ revops-mgr - │ │ └─ insight-mgr + │ │ ├─ insight-mgr + │ │ └─ content-mgr — defined but NOT yet exported from managers/index.ts │ ▼ L2 Specialist queries (separate query() calls, dispatched by L0) - │ 13 personas × Sonnet 4.6 mostly, Haiku for tagger + │ 13 shipping personas + 6 content personas (half-wired) × Sonnet 4.6 mostly, Haiku for tagger │ each with scoped Composio MCP via allowedTools │ ▼ @@ -68,7 +69,7 @@ L3 Composio MCP HTTP server — actions executed via Composio --- -## Personas (exactly 13 — DO NOT add Health Monitor; it was dropped per audit) +## Personas (5 departments — DO NOT add Health Monitor; it was dropped per audit) | Department | Specialists | |---|---| @@ -76,8 +77,15 @@ L3 Composio MCP HTTP server — actions executed via Composio | CS | activation | | RevOps | crm-logger, pipeline-reporter, slack-digest | | Insight | feedback-tagger, theme-synthesizer, linear-filer | +| Content (blogs pivot) | linkedin-researcher, x-researcher, reddit-researcher, synthesizer, blog-writer, blog-designer | -Plus Conductor (L1) and 4 Department Heads (L2) which exist as `AgentDefinition` objects nested inside the Conductor's `query()` call. +Plus Conductor (L1) and 5 Department Heads (L2) which exist as `AgentDefinition` objects nested inside the Conductor's `query()` call. + +> **Content department is half-wired — treat as not-yet-runnable in real mode.** +> - The 6 content `PersonaId`s and `Department = "content"` exist in `lib/shared/types.ts:38-45` and `lib/personas/registry.ts:233-280` (with permissive `z.unknown` passthrough output schemas). +> - `contentManager` is defined in `lib/orchestrator/managers/content.ts` but is **not exported** from `lib/orchestrator/managers/index.ts` — `managerAgents` and `MANAGER_AGENT_NAMES` still ship just the original 4. +> - The on-disk prompts are `content-researcher.md`, `content-writer.md`, `content-designer.md`, which **do not match** the registry's `PersonaId`s (`linkedin-researcher`, `x-researcher`, `reddit-researcher`, `synthesizer`, `blog-writer`, `blog-designer`). The content manager's prompt also only references the 3-name set. Loading any content persona via `systemPromptPath` will 404 until the names are reconciled. +> - Mock mode and the DAG renderer reference these — keep them callable from there. Don't wire content into the live Conductor without resolving the name mismatch first. --- @@ -174,17 +182,22 @@ If a parallel session needs a change to any of these, raise it with the human co ``` lib/orchestrator/conductor.ts -lib/orchestrator/managers/{index,sales,cs,revops,insight}.ts +lib/orchestrator/managers/{index,sales,cs,revops,insight,content}.ts lib/orchestrator/title.ts ← run-title generator (LLM, used by recent-runs UI) +lib/orchestrator/context-synth.ts ← one-shot LLM synth of CompanyContext from voice + leads lib/dispatch/execute.ts ← deterministic post-approval Composio dispatcher (no LLM) lib/dispatch/providers.ts ← artifactType × toolkit → Composio action map lib/state/workflows.ts lib/state/approvals.ts lib/state/work-context.ts ← WorkContext snapshot threaded into Conductor prompt +lib/state/company-context.ts ← singleton CompanyContext row per founder (load/save) +lib/state/gtm-metrics.ts ← live counts for GtmObjective targets (read-time, no snapshot) app/api/runs/route.ts app/api/runs/list/route.ts ← lists recent runs for the dashboard drawer app/api/approvals/[id]/route.ts app/api/approvals/bulk/route.ts ← bulk-resolve endpoint (one click approves all) +app/api/context/route.ts ← GET/PUT singleton CompanyContext +app/api/context/refresh/route.ts ← POST → returns a *proposed* CompanyContext (does not save) ``` **Session 2 owns (worktree `feat/personas`):** @@ -192,7 +205,9 @@ app/api/approvals/bulk/route.ts ← bulk-resolve endpoint (one click approves ``` lib/personas/registry.ts lib/personas/runtime.ts -lib/personas/prompts/*.md ← 13 stubs; non-tech teammate writes content +lib/personas/prompts/*.md ← persona system prompts; non-tech teammate writes content + (note: 13 shipping personas + 3 content stubs whose names + don't yet match the registry's content PersonaIds) lib/personas/researcher/fetch.ts ← Pattern B: deterministic TS fetches → pure-LLM synthesizer app/api/test-persona/route.ts ← dev-only HTTP entry point used by the persona e2e harness lib/tools/composio.ts @@ -217,8 +232,12 @@ app/api/composio/callback/route.ts app/api/connections/start/route.ts ← mints Composio Connect Link OAuth URL app/api/stream/mock-emit/ ← dev-only SSE injection endpoint app/api/mock/runs/ ← dev-only mock recent-runs feed (NEXT_PUBLIC_USE_MOCKS=1) +app/api/mock/context/route.ts ← dev-only mock CompanyContext for NEXT_PUBLIC_USE_MOCKS=1 lib/ui/components/*.tsx ← custom components (NOT components/ui/) +lib/ui/components/company-context-card.tsx ← dashboard card showing CompanyContext + GTM metrics +lib/ui/components/edit-company-context-dialog.tsx ← edit/refresh dialog (pre-fills from synth) lib/ui/hooks/*.ts +lib/ui/hooks/use-company-context.ts ← client hook; routes to /api/mock/context under NEXT_PUBLIC_USE_MOCKS lib/ui/persona-meta.ts ← display labels, icons, status colors for personas/nodes lib/realtime/bus.ts lib/realtime/events.ts @@ -280,6 +299,22 @@ Always swap mocks for real imports just before merging your branch to `main`. 17. **WorkContext threading:** `loadWorkContext()` snapshots leads + trial-signals from the local DB and formats a `summary` string injected into the Conductor prompt. Managers reason about item counts from this snapshot; Specialists receive denormalized `item: { fields }` splatted into each materialized task input at dispatch time. 18. **`POST /api/runs` is fire-and-forget.** Returns `{ workflowRunId }` with HTTP 202 immediately; the workflow runs detached. Always attach `.catch(markRunFailed)` to the detached promise — uncaught rejection kills the dev server. 19. **Pattern B: personas reason in pure LLM over local data.** The Researcher's deterministic Composio fetch → pure-LLM synth (`lib/personas/researcher/fetch.ts`) is the model: pre-fetch any external data deterministically in TypeScript, then feed it to the persona as text in the prompt. Do NOT give downstream personas (Qualifier, Strategist, Writer) MCP tool access for reasoning — they reason over the lead's denormalized fields and prior personas' outputs. Composio is an automation handoff fired by the post-approval dispatcher, not a tool the LLM picks mid-thought. +20. **CompanyContext synth proposes, never persists.** `lib/orchestrator/context-synth.ts` reads voice samples + recent qualified leads and returns a *proposed* `CompanyContext`. The dashboard's edit dialog opens pre-populated with that proposal — the founder confirms before `saveCompanyContext` writes. `POST /api/context/refresh` returns the proposal; only `PUT /api/context` writes. Don't add a code path that auto-saves the synth output. +21. **GTM objective counts are read-time, not snapshotted.** `lib/state/gtm-metrics.ts` exhaustively maps each `GtmMetric` to a Drizzle counter against existing tables (booked meetings, qualified leads, sent drafts). The `Record` shape forces the typechecker to flag missing counters when a new metric is added. There is no metrics history table — adding a metric without a clean DB source breaks this contract. + +--- + +## Company context (dashboard surface) + +A singleton `CompanyContext` row per `userId` stores the founder-confirmed snapshot of who the agents understand the company to be: `companyOverview`, `keyFacts`, `icps[]` (priority + industry + size + seniority), and `gtmObjectives[]` (a `GtmMetric` + target + optional `since` date). Schema in `lib/shared/types.ts:422-455`; persistence in `lib/state/company-context.ts`; migration `drizzle/migrations/0003_company_context.sql`. + +Lifecycle: +- **Read:** `GET /api/context` → `loadCompanyContext(userId)`. Joined with live counts from `lib/state/gtm-metrics.ts` for the dashboard card. +- **Write:** `PUT /api/context` → `saveCompanyContext(input)`. Founder-driven only. +- **Synth:** `POST /api/context/refresh` → `synthCompanyContext()` → returns a proposed `CompanyContext`. Does NOT persist; the edit dialog opens pre-populated and the founder confirms. +- **Mock:** `app/api/mock/context/route.ts` serves a fixture under `NEXT_PUBLIC_USE_MOCKS=1`; `useCompanyContext` routes there client-side. + +Threading into orchestration: managers don't currently read `CompanyContext` from the DAG planner — `WorkContext` (leads + trial-signals snapshot) is what the Conductor sees. Treat `CompanyContext` as a dashboard-facing surface for now. --- @@ -292,7 +327,9 @@ Always swap mocks for real imports just before merging your branch to `main`. | Claude Agent SDK over LangGraph/CrewAI | Native sub-agents, native MCP, fewer deps | | Composio MCP HTTP, not provider package | Documented integration path, no extra package | | Drop Health Monitor persona (13 not 14) | Overlapped with Activation, was P1+ anyway | -| Keep all 4 Department Heads | Visual depth in DAG sells the org-chart pitch | +| Keep all 4 original Department Heads | Visual depth in DAG sells the org-chart pitch | +| Add Content department (5th) for blogs pivot | Topic-shaped pipeline (researcher → writer → designer); ignores leads/trial-signals; post-approval Linear ticket is automatic | +| `CompanyContext` synth proposes, never auto-saves | Founder is the source of truth for company self-description; LLM seeds, founder confirms | | Static voice training, not cross-run learning | Hackathon scope; cross-run = P2 | | Prompted JSON over `structured_output` API | Simpler, works today, retry on parse fail | | Public repo from day 1 | Enables agentic install demo | diff --git a/app/api/context/refresh/route.ts b/app/api/context/refresh/route.ts new file mode 100644 index 0000000..a7daf01 --- /dev/null +++ b/app/api/context/refresh/route.ts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..ee9a0ee --- /dev/null +++ b/app/api/context/route.ts @@ -0,0 +1,57 @@ +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/context/route.ts b/app/api/mock/context/route.ts new file mode 100644 index 0000000..86bf4f7 --- /dev/null +++ b/app/api/mock/context/route.ts @@ -0,0 +1,23 @@ +/** + * 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/drizzle/migrations/0003_company_context.sql b/drizzle/migrations/0003_company_context.sql new file mode 100644 index 0000000..a7ca8c6 --- /dev/null +++ b/drizzle/migrations/0003_company_context.sql @@ -0,0 +1,8 @@ +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/lib/orchestrator/context-synth.ts b/lib/orchestrator/context-synth.ts new file mode 100644 index 0000000..7e54196 --- /dev/null +++ b/lib/orchestrator/context-synth.ts @@ -0,0 +1,215 @@ +/** + * 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/shared/schemas.ts b/lib/shared/schemas.ts index f02129e..0d7da20 100644 --- a/lib/shared/schemas.ts +++ b/lib/shared/schemas.ts @@ -409,6 +409,47 @@ export const ConnectionSchema = z.object({ connectedAt: z.date().nullable().optional(), }); +// ----- company context ----- + +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 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()), +}); + +/** Body shape for PUT /api/context — userId + updatedAt are server-side. */ +export const CompanyContextInputSchema = CompanyContextSchema.omit({ + userId: true, + updatedAt: true, +}); + // ----- API request schemas ----- export const RunWorkflowRequestSchema = z.object({ diff --git a/lib/state/company-context.ts b/lib/state/company-context.ts new file mode 100644 index 0000000..d667620 --- /dev/null +++ b/lib/state/company-context.ts @@ -0,0 +1,28 @@ +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/gtm-metrics.ts b/lib/state/gtm-metrics.ts new file mode 100644 index 0000000..6b242ad --- /dev/null +++ b/lib/state/gtm-metrics.ts @@ -0,0 +1,56 @@ +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 bb92637..e345844 100644 --- a/lib/state/schema.ts +++ b/lib/state/schema.ts @@ -10,6 +10,7 @@ */ import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; +import type { GtmObjective, ICPProfile } from "../shared/types"; // ----- helpers ----- @@ -332,6 +333,23 @@ 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; @@ -354,3 +372,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; diff --git a/lib/ui/components/company-context-card.tsx b/lib/ui/components/company-context-card.tsx new file mode 100644 index 0000000..bd3aae6 --- /dev/null +++ b/lib/ui/components/company-context-card.tsx @@ -0,0 +1,334 @@ +"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/edit-company-context-dialog.tsx b/lib/ui/components/edit-company-context-dialog.tsx new file mode 100644 index 0000000..2dadcb0 --- /dev/null +++ b/lib/ui/components/edit-company-context-dialog.tsx @@ -0,0 +1,497 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { + CompanyContextInputSchema, + GtmMetricSchema, + IcpPrioritySchema, +} from "@/lib/shared/schemas"; +import type { + CompanyContext, + GtmMetric, + GtmObjective, + ICPProfile, + IcpPriority, +} from "@/lib/shared/types"; + +const METRIC_LABEL: Record = { + demos_booked: "Demos booked", + qualified_hot_leads: "Qualified hot leads", + outreach_sent: "Outreach sent", +}; + +interface EditCompanyContextDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initial: CompanyContext | null; + onSave: ( + next: Omit, + ) => Promise; +} + +interface FormState { + companyOverview: string; + keyFacts: string[]; + icps: ICPProfile[]; + gtmObjectives: GtmObjective[]; +} + +function makeEmptyIcp(): ICPProfile { + return { + name: "", + priority: "warm", + description: "", + industry: [], + companySizeRange: "", + seniority: [], + }; +} + +// Year computed lazily so a long-lived tab proposes "Q1 of right now" +// rather than whenever the module loaded. +function makeEmptyObjective(): GtmObjective { + return { + metric: "demos_booked", + target: 50, + label: "Q1 demos booked", + since: new Date(new Date().getFullYear(), 0, 1).toISOString(), + }; +} + +function seedForm(initial: CompanyContext | null): FormState { + if (initial) { + return { + companyOverview: initial.companyOverview, + keyFacts: [...initial.keyFacts], + icps: initial.icps.map((i) => ({ + ...i, + industry: [...i.industry], + seniority: [...i.seniority], + })), + gtmObjectives: initial.gtmObjectives.map((o) => ({ ...o })), + }; + } + return { + companyOverview: "", + keyFacts: [], + icps: [makeEmptyIcp()], + gtmObjectives: [makeEmptyObjective()], + }; +} + +export function EditCompanyContextDialog({ + open, + onOpenChange, + initial, + onSave, +}: EditCompanyContextDialogProps) { + const [form, setForm] = useState(() => seedForm(initial)); + const [keyFactDraft, setKeyFactDraft] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setForm(seedForm(initial)); + setKeyFactDraft(""); + setError(null); + } + }, [open, initial]); + + const validation = useMemo(() => { + return CompanyContextInputSchema.safeParse({ + companyOverview: form.companyOverview, + keyFacts: form.keyFacts, + icps: form.icps, + gtmObjectives: form.gtmObjectives, + }); + }, [form]); + + const addKeyFact = () => { + const trimmed = keyFactDraft.trim(); + if (!trimmed) return; + setForm((f) => ({ ...f, keyFacts: [...f.keyFacts, trimmed] })); + setKeyFactDraft(""); + }; + + const removeKeyFact = (idx: number) => { + setForm((f) => ({ + ...f, + keyFacts: f.keyFacts.filter((_, i) => i !== idx), + })); + }; + + const updateIcp = (idx: number, patch: Partial) => { + setForm((f) => ({ + ...f, + icps: f.icps.map((icp, i) => (i === idx ? { ...icp, ...patch } : icp)), + })); + }; + + const addIcp = () => + setForm((f) => ({ ...f, icps: [...f.icps, makeEmptyIcp()] })); + + const removeIcp = (idx: number) => + setForm((f) => ({ + ...f, + icps: f.icps.filter((_, i) => i !== idx), + })); + + const updateObjective = (idx: number, patch: Partial) => { + setForm((f) => ({ + ...f, + gtmObjectives: f.gtmObjectives.map((o, i) => + i === idx ? { ...o, ...patch } : o, + ), + })); + }; + + const addObjective = () => + setForm((f) => ({ + ...f, + gtmObjectives: [...f.gtmObjectives, makeEmptyObjective()], + })); + + const removeObjective = (idx: number) => + setForm((f) => ({ + ...f, + gtmObjectives: f.gtmObjectives.filter((_, i) => i !== idx), + })); + + const handleSave = async () => { + if (!validation.success) { + setError( + validation.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("\n"), + ); + return; + } + setSaving(true); + setError(null); + try { + await onSave(validation.data); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }; + + return ( + + + + Company context + + Anchor your agents to who you are, who you target, and what + you're going after this period. + + + + + + Company + ICPs ({form.icps.length}) + + GTM goals ({form.gtmObjectives.length}) + + + + +
+ +