diff --git a/CLAUDE.md b/CLAUDE.md index 5a67fa5..537c4a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This file is the single source of truth for Claude Code sessions working on GMaestro. Read it on every fresh session before writing code. -> See [`PLAN.md`](./PLAN.md) at repo root for the full design doc, audit corrections, and detailed session prompts. For user/agent-facing setup steps (install, API key wizard, OAuth), see [`INSTALL.md`](./INSTALL.md). This file is the operational quick-reference for development. +> **Read [`PITCH.md`](./PITCH.md) first.** It locks the pitch, ICP, MVP scope, and demo arc. Everything in this file (and the rest of the codebase) flows downstream from there. If a code change feels off-pitch, check PITCH.md before shipping. +> +> See [`PLAN.md`](./PLAN.md) for the full design doc and audit corrections. For user/agent-facing setup steps (install, API key wizard, OAuth), see [`INSTALL.md`](./INSTALL.md). This file is the operational quick-reference for development. --- ## What we're building -**GMaestro** is a local-first AI GTM team for pre-Series A founders running their own GTM. Multi-persona Claude agents orchestrate end-to-end pipeline work (research → qualify → outreach → schedule → brief → close-loop) via [Composio](https://composio.dev) tool integrations, with founder-in-loop approval gates for any external/irreversible action. +**GMaestro** is a local-first AI **content team** for pre-Series A founders. Multi-persona Claude agents orchestrate end-to-end content pipeline work (research → strategize → write → GEO-edit → format → publish across channels) via [Composio](https://composio.dev) tool integrations, with founder-in-loop approval gates at every irreversible step. + +The team optimizes for both traditional **SEO** and **Generative Engine Optimization (GEO)** — citation by ChatGPT / Perplexity / Claude / Gemini / Google AI Overviews. Reddit drives ~47% of Perplexity citations, so multi-channel cross-posting (your blog + Reddit + LinkedIn + X + GitHub PR for static-site repos) is a first-class concern, not an afterthought. + +**Pivoted 2026-05-09** from GTM (sales / CS / RevOps) to content. Architecture is unchanged — Conductor → Managers → Specialists → Composio MCP, Pattern B universal, post-approval deterministic dispatch — only the domain types changed. **Distribution:** local-first npm package. Founder runs `gmaestro dev` on their laptop; dashboard opens at `localhost:3000`. No hosted SaaS. @@ -20,17 +26,16 @@ This file is the single source of truth for Claude Code sessions working on GMae ## Demo scope -We're building deliberately for ONE primary scenario plus 2–3 alternates. Anything outside is "roadmap." +We're building deliberately for ONE primary scenario plus 2–3 alternates. ### Primary demo prompt -> *"I'm a YC W26 founder. 47 demo requests came in this week from our HN launch. I have 3 hours before cofounder offsite. Process them."* +> *"Anvil just hit 1k weekly active users. Plan and ship a 2k-word blog on what we learned about LLM-native onboarding, optimized for Perplexity citations. Cross-post to Reddit (r/SaaS, r/startups), LinkedIn, and our static-site blog repo (anvil-co/anvil-site)."* ### Alternates (same architecture handles them) -- *"Process this one inbound lead from acme.com"* — single-lead path -- *"Daily activation check on 12 trial users"* — CS path -- *"Customer reported a bug — file it and update them"* — Insight pipeline +- *"Audit our existing site at anvil.co/blog. Tell me which 3 topics we're missing relative to our top-citing competitors, then draft the highest-priority one."* — GEO audit flavor +- *"It's Monday. Plan and queue 3 blog posts for the week, each with a Reddit + LinkedIn cross-post variant. Let me approve outlines today and drafts tomorrow."* — multi-topic sprint flavor ### Demo company @@ -48,44 +53,40 @@ L0 Workflow function (TypeScript) — orchestrates everything ▼ L1 Conductor query() — 1 SDK call, returns plan │ ├─ agents: — sub-agents (1 SDK level deep, allowed) - │ │ ├─ sales-mgr - │ │ ├─ cs-mgr - │ │ ├─ revops-mgr - │ │ ├─ insight-mgr - │ │ └─ content-mgr — defined but NOT yet exported from managers/index.ts + │ │ ├─ content-mgr + │ │ ├─ distribution-mgr + │ │ └─ insight-mgr │ ▼ L2 Specialist queries (separate query() calls, dispatched by L0) - │ 13 shipping personas + 6 content personas (half-wired) × Sonnet 4.6 mostly, Haiku for tagger - │ each with scoped Composio MCP via allowedTools + │ 10 personas × Sonnet 4.6 mostly, Haiku for tagger + │ each with scoped Composio MCP via allowedTools (currently all empty + │ — Pattern B is universal in this codebase) │ ▼ L3 Composio MCP HTTP server — actions executed via Composio ``` -**Workers** are NOT a separate layer. The 47-lead parallel fanout is just `pMap` calling Specialists in parallel. +**Workers** are NOT a separate layer. Multi-topic / multi-channel parallel fanout is just `pMap` calling Specialists in parallel. -**Post-approval dispatch is NOT an LLM call.** When the founder approves an artifact, `lib/dispatch/execute.ts` looks up the (artifactType, toolkit) pair in `lib/dispatch/providers.ts` and calls `composio.tools.execute()` directly — not via MCP, not via a `query()` loop. Adding a new provider for an artifact type = one entry in `providers.ts`, no persona scope changes. This is the mechanism behind rule #8 ("Writer NEVER sends"). +**Post-approval dispatch is NOT an LLM call.** When the founder approves an artifact, `lib/dispatch/execute.ts` looks up the (artifactType, toolkit) pair in `lib/dispatch/providers.ts` and calls `composio.tools.execute()` directly — not via MCP, not via a `query()` loop. Adding a new provider for an artifact type = one entry in `providers.ts`, no persona scope changes. This is the mechanism behind rule #8 ("Writer NEVER publishes"). --- -## Personas (5 departments — DO NOT add Health Monitor; it was dropped per audit) +## Personas (exactly 10 — content-pivot roster as of 2026-05-09) -| Department | Specialists | -|---|---| -| Sales | researcher, qualifier, strategist, writer, scheduler, brief-writer | -| 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 | +| Department | Specialists | Role | +|---|---|---| +| Content | researcher, strategist, writer, geo-editor, formatter | research → outline → draft → GEO-optimize → per-channel format | +| Distribution | pipeline-reporter, slack-digest | end-of-run summary + Slack digest | +| Insight | feedback-tagger, theme-synthesizer, linear-filer | post-publish reactions → themes → tickets | + +Plus Conductor (L1) and 3 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. +The content workflow shape is: +> researcher → strategist → [Outline approval] → writer → geo-editor → [BlogDraft approval + channels picker] → formatter (fanout over channels) → [per-channel preview approvals] → publish via dispatcher → pipeline-reporter → slack-digest -> **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. +The founder picks publish destinations at the BlogDraft approval gate (channels checkbox: GitHub PR, WordPress, Ghost, Notion, Reddit, LinkedIn, X). The Formatter fans out one ChannelVariant per ticked target. --- @@ -182,7 +183,7 @@ 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,content}.ts +lib/orchestrator/managers/{index,content,distribution,insight}.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) @@ -285,36 +286,36 @@ Always swap mocks for real imports just before merging your branch to `main`. 3. **15-second SSE heartbeat** (`: heartbeat\n\n`) to prevent EventSource browser timeout. 4. **`globalThis.__gmaestroEventBus`** singleton pattern — Next.js bundles API routes and pages separately; module-level singletons duplicate. Same applies to `__gmaestroDb` and `__gmaestroComposio`. 5. **Composio MCP wiring:** one shared MCP config (`"gmaestro-default-v2"`) is lazy-created via `composio.mcp.create(name, { toolkits, allowedTools, manuallyManageConnections: true })`, then `composio.mcp.generate(userId, configId)` mints a per-user instance URL. Drop the result into `mcpServers: { composio: { type: "http", url: instance.url, headers: {} } }`. Override the lazy-create flow by setting `COMPOSIO_MCP_CONFIG_ID` in env. Per-persona scoping via `allowedTools: ["mcp__composio__GMAIL_DRAFT", ...]` on each SDK `query()` call. -6. **Connect Link API:** use `composio.connectedAccounts.link(userId, authConfigId, { callbackUrl })`, NOT `initiate()` (deprecated for new orgs as of 2026-05-08). For `authConfigId`, import `getAuthConfigId(toolkit)` from `@/lib/shared/auth-configs` — Foundation pre-created auth configs for all 10 Tier-S toolkits + Discord/Intercom/Calendly via the agent-native Composio signup. Apollo, Reddit, Jira, Loom, and Twitter (X) are surfaced in the connections picker as "Popular" but their persona-level wiring is roadmap (BYO OAuth needed); don't reference them from any persona's `allowedTools` until an auth config is registered. -7. **LinkedIn is READ-ONLY.** Researcher persona only: `LINKEDIN_SEARCH_PERSON`, `LINKEDIN_GET_PROFILE`, `LINKEDIN_GET_COMPANY`. All outbound = Gmail. -8. **Writer NEVER sends.** Writer drafts (`GMAIL_DRAFT`); only the Approval Gate flips drafts to sent. +6. **Connect Link API:** use `composio.connectedAccounts.link(userId, authConfigId, { callbackUrl })`, NOT `initiate()` (deprecated for new orgs as of 2026-05-08). For `authConfigId`, import `getAuthConfigId(toolkit)` from `@/lib/shared/auth-configs` — Foundation pre-created auth configs for the Tier-S toolkits. **Reddit, Twitter, WordPress, Ghost** are content-pivot additions that need auth configs registered (entries are commented in `auth-configs.ts` with the script command); the connection picker surfaces them as "Setup required" until then. +7. **LinkedIn READ for research, official Posts API for publish.** The Researcher's Pattern B fetch reads LinkedIn (search/profile). The post-approval dispatcher publishes via `LINKEDIN_CREATE_LINKED_IN_POST` (`w_member_social` scope, official API — not bot-risk). Both go through Composio's managed LinkedIn auth. +8. **Writer NEVER publishes.** Writer drafts (`BlogDraft` artifact); only the post-approval dispatcher publishes via the channel-specific Composio action picked by the founder at the BlogDraft approval gate. 9. **Conductor and Manager output is prompted JSON + Zod validation.** Schema is `WorkflowDAGSchema` in `lib/shared/schemas.ts`. One retry on parse failure. 10. **Voice training is STATIC for hackathon.** Seed founder samples → few-shots in Writer prompt. Edits captured but NOT re-injected within demo timespan. 11. **Graceful degradation when integration not connected.** Persona throws typed error → workflow function marks node failed → workflow continues with remaining tasks. 12. **Crash = restart from scratch.** No mid-workflow resume in hackathon scope. 13. **Conductor only gets `allowedTools: ["Agent"]`** — it delegates all Composio work to Managers/Specialists. Never give the Conductor direct Composio tool access. -14. **`maxTurns` conventions:** Conductor = 12, Specialist single-task = 8, Specialist batch = 6. Batch cap at 6 enforces "one MULTI_EXECUTE_TOOL call + synthesis" — more turns means the model is looping sequentially. -15. **Batch auto-selection:** if a persona has `batchInputSchema`/`batchOutputSchema` in the registry AND item count > 5, the dispatcher auto-selects `mode: "batch"` even without an explicit Manager hint. Batch personas also get `COMPOSIO_MULTI_EXECUTE_TOOL` and `COMPOSIO_SEARCH_TOOLS` in their scope. -16. **Batch partial-failure threshold:** ≥80% coverage → keep valid items, skip-cascade missing ids. <80% → re-chunk into groups of 10 and retry once. Researcher's `maxConcurrency` is capped at 5 to match LinkedIn's token bucket (1 req/sec). -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. +14. **`maxTurns` conventions:** Conductor = 12, Specialist single-task = 8, Specialist batch = 6. +15. **Batch auto-selection:** if a persona has `batchInputSchema`/`batchOutputSchema` in the registry AND item count > 5, the dispatcher auto-selects `mode: "batch"` even without an explicit Manager hint. +16. **Batch partial-failure threshold:** ≥80% coverage → keep valid items, skip-cascade missing ids. <80% → re-chunk into groups of 10 and retry once. Researcher's `maxConcurrency` is capped at 5 to match Reddit/X/Firecrawl's combined rate-limit envelope. +17. **WorkContext threading:** v1 returns empty topic/channel lists — content runs drive the topic from the founder's prompt. Channels are picked at the BlogDraft approval gate (founder ticks targets), not pre-loaded; the dispatcher injects the chosen targets into the Formatter's fanout via the approval payload. 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. +19. **Pattern B is universal here.** The Researcher's deterministic Composio fetch (Reddit / X / Firecrawl / Perplexity) → pure-LLM synth (`lib/personas/researcher/fetch.ts`) is the model: every external read is pre-fetched in TypeScript, every external write is post-approval through the dispatcher. NO persona has live MCP tool access — `PERSONA_SCOPES` is universally `[]`. Composio is an automation handoff at deterministic seams, not a tool the LLM picks mid-thought. +20. **BlogDraft approval carries the channels picker.** When the founder approves a `BlogDraft`, the resolve-approval payload includes `targets: ToolkitId[]`. The Formatter is then fanned out one task per target. Each variant gets its own preview approval (bulk-approveable via `/api/approvals/bulk`) before publishing. +21. **CompanyContext synth proposes, never persists.** `lib/orchestrator/context-synth.ts` 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. --- ## 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`. +A singleton `CompanyContext` row per `userId` stores the founder-confirmed snapshot of the company: `companyOverview`, `keyFacts`, `icps[]` (audience personas — priority + industry + size + seniority), and `gtmObjectives[]` (per-metric targets). Schema in `lib/shared/types.ts`; 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. +- **Synth:** `POST /api/context/refresh` → 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`. -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. +Threading into the content pipeline: a planned next iteration splices CompanyContext fields into each content persona's `input` object at dispatch time. Today the personas accept an optional `companyProfile` parameter — wire `CompanyContext` → that parameter in `lib/state/workflows.ts` once the slice map is finalized for the 5 content personas (researcher / strategist / writer / geo-editor / formatter). --- @@ -322,19 +323,21 @@ Threading into orchestration: managers don't currently read `CompanyContext` fro | Decision | Why | |---|---| +| **Pivoted GTM → content/GEO domain (2026-05-09)** | Founders won't delegate cold email (it kills response rates) but will delegate blogs (they keep skipping content). Content-team angle is a sharper buy-vs-build wedge, GEO is a real recent shift, and the architecture survives intact. | | Local-first, not hosted SaaS | Privacy moat, simpler hackathon build, agentic install fits | | No Supabase / Inngest / Vercel | Single-user local app — SQLite + SSE replace Postgres + Realtime + queue | | 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 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 | +| Lean 10-persona content roster (was 13 GTM) | 5 Content + 2 Distribution + 3 Insight. Cleanly maps onto the content workflow without awkward repurposing. | +| 3 Department Heads (was 4) | Content / Distribution / Insight. CS/RevOps were dropped; Distribution absorbs end-of-run reporting. | +| Founder picks publish channels at BlogDraft approval | Differentiator: none of Jasper/Surfer/Frase let one approval fan out to N channels. The channels picker on the BlogDraft approval card is the sharpest UX move. | +| GitHub PR as primary blog destination | Most YC founders' sites are static-site repos. "GMaestro opened a PR with your draft" is a stronger demo than "we wrote to your CMS." | +| Pattern B universal — no live MCP scopes for any persona | All external reads via deterministic TS pre-fetch; all external writes via post-approval deterministic dispatch. Eliminates tool-selection hallucination on smaller models. | | `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 | | Ollama Cloud as alt provider | Zero marginal cost on Ollama Pro; `GMAESTRO_LLM_PROVIDER=ollama` + `OLLAMA_API_KEY` reroutes the whole stack without code changes | -| `"gmaestro-default-v2"` MCP config name | Bumped from v1 when `COMPOSIO_MULTI_EXECUTE_TOOL`/`SEARCH_TOOLS` were added; old configs lack those tools | --- diff --git a/PITCH.md b/PITCH.md new file mode 100644 index 0000000..8ccc1d2 --- /dev/null +++ b/PITCH.md @@ -0,0 +1,156 @@ +# GMaestro — pitch & purpose + +> **Single source of truth for what we're building, who it's for, and why it wins.** +> Slide decks, demo scripts, marketing copy, and READMEs all derive from this file. +> If a teammate is unsure where to point a sentence, this is north. + +--- + +## One-liner (project header) + +> **Docs are for parsers. Blogs are for people. We turn one into the other — for devtools founders whose engineering velocity outruns their marketing.** + +## Tagline (everywhere else) + +> **One commit. Every channel. All your buyers.** + +--- + +## The contrarian insight + +Technical documentation is now written for AI: dense, structured, exhaustive — optimized for LLM parsers. Humans still read **blogs**. As your docs change every day, your buyers fall further behind your product. Your AI knows you. Your buyers don't — yet. + +**GMaestro is the bridge.** A multi-persona AI content team that watches your docs, drafts the human-readable blog version of every meaningful change, optimizes it for AI search citation, and ships it across the channels your buyers actually read — with founder approval at every gate. + +--- + +## Who it's for + +**Series A devtools companies whose docs ship daily and blogs ship quarterly.** Companies in our heads: Resend, Linear, Vercel, Stripe, Stainless's customers (OpenAI / Anthropic), Mintlify's customers, Anvil-shape startups. + +**NOT for:** every founder ever, enterprise marketing teams, agencies, content farms. The narrow ICP is the wedge — broader expansion is the vision slide, not the pitch. + +## Why now + +1. **AI search is real and growing.** ChatGPT, Perplexity, Claude, and Google AI Overviews handle ~12–18% of English informational queries (Q1 2026, up from <2% a year ago). Reddit drives ~40% of AI-search citations across major engines (Semrush 150K analysis). +2. **Docs platforms are publicly naming the marketing surface.** Mintlify ($45M Series B at $500M, 10× ARR in 2025) is calling docs "the new top-of-funnel." Stainless ($25M Series A) makes SDKs from docs for OpenAI/Anthropic. They're solving inside their own products — not extending out into multi-channel content. +3. **The translation layer is white space.** Notra and PersonaBox are early; Docsie does landing pages not blogs; nobody has multi-persona reasoning + founder-in-loop approval + multi-channel fanout. We're first. +4. **Composio's tool surface** makes multi-channel publishing trivial. One approval, fan out via deterministic dispatcher → GitHub PR, Reddit, LinkedIn, Notion, etc. + +## Why we win + +| | What we do | What incumbents do | +|---|---|---| +| **Multi-persona reasoning** | 10 specialists across 3 departments, each prompt-tuned | Single LLM call with a long prompt | +| **Founder-in-loop** | Approval gates at every irreversible step | "Generate and post" or fully manual | +| **One approval, N channels** | Founder ticks destinations once, dispatcher fans out | Copy/paste to each channel | +| **GEO-aware** | Dedicated `geo-editor` persona; fact density, citations, schema | SEO-only or generic AI writing | +| **Local-first** | Runs on the founder's laptop; privacy moat | Hosted SaaS | +| **Devtools-shaped** | Reads docs URLs via Firecrawl; commits MDX via GitHub PR | Generic content automation | + +## What we're explicitly not + +- **Not a docs platform.** We don't host docs. (Mintlify, GitBook own that.) +- **Not GEO measurement.** Profound just raised $96M Series C ($1B valuation) owning GEO observability. We're *generation*, not *measurement*. We get the GEO benefit for free by shipping where AI search crawls. +- **Not generic AI content.** Jasper / Copy.ai / Writer fight for the broad "AI content" square. We don't. +- **Not a GTM tool.** Pivoted off this on 2026-05-09. The architecture survived; the framing tightened. + +--- + +## Hackathon theme: "One for All" + +Three independent ways the architecture *is* One for All: + +1. **One approval, all destinations.** The BlogDraft channels-checkbox is the central UX innovation: founder approves once, dispatcher fans out to N targets. +2. **One source, all audiences.** Docs → blog → Reddit thread → LinkedIn post → X thread → GitHub PR. One canonical truth, every reader's preferred format. +3. **One prompt, the whole team executes.** Conductor → 3 managers → 10 specialists. + +The theme isn't a slogan — it's the architecture. + +--- + +## Demo arc (target: 90 seconds) + +**Setup:** founder is "Anvil," a YC W26 devtools startup whose docs change weekly. + +**The prompt:** + +> *"Anvil shipped v2.3 of our API last week. Read our docs at anvil.co/docs/v2.3, find the 3 most important changes for our buyers, write a blog post about them, and cross-post to r/programming, LinkedIn, and a PR to our static-site repo."* + +**The beats:** + +1. DAG renders the 10-persona org chart, channels lit up by department. +2. Researcher fires Firecrawl on the docs URL → surfaces 3 changes worth covering. +3. Strategist picks the angle: *"v2.3 has a backwards-incompatible auth change — lead with that."* +4. Writer drafts in the founder's voice (loaded from voice samples at setup). +5. GEO-Editor: direct-answer lead, fact density check, Reddit thread citation, schema markup recommendation. +6. **Approval gate** — the BlogDraft card pops with the draft + the channels checkbox. Founder ticks: GitHub PR, Reddit (r/programming), LinkedIn. Approves. +7. Formatter fans out 3 channel variants in parallel — markdown-with-frontmatter for GitHub, Reddit-native discussion shape, LinkedIn long-form. +8. Bulk-approve the per-channel previews. +9. Dispatcher publishes via Composio: GitHub PR opens (real PR URL!), Reddit post lands, LinkedIn post is live. +10. Toast: *"3 channels live in 47 seconds. Reddit thread should surface in Perplexity citations within 7 days."* + +**Closing line:** + +> *"Engineering ships docs every day. Now marketing does too. One commit, every channel, all your buyers — that's not just the theme, it's the architecture. One for All."* + +--- + +## MVP scope (what must work for the live demo) + +| Capability | Status | Owner | +|---|---|---| +| Real LLM persona pipeline (researcher → strategist → writer → geo-editor → formatter) | ✅ live | core | +| Real Firecrawl docs scrape | ✅ wired (needs auth config) | core + Foundation | +| BlogDraft approval card with channels checkbox | ✅ live | core | +| Real Composio publish for **GitHub PR** | wired in providers; needs end-to-end test | core | +| Real Composio publish for **Reddit** | needs auth config registration | Foundation | +| Real Composio publish for **LinkedIn** | wired (auth config exists); needs publish-flow test | core | +| Bulk-approve for per-channel previews | endpoint exists; needs UI wiring | core | +| Mock-mode fallback (full demo without live LLM) | ✅ live | core | +| CompanyContext system (one-time setup) | ✅ in main; persona slice-map TBD | parallel session | +| Voice samples (founder paste at setup) | wired | core | + +**If anything above is flaky day-of:** mock-mode demo path is fully working as fallback. + +## Explicitly out of scope for MVP + +- WordPress / Ghost publish (Composio slugs unverified) +- X (Twitter) live publish (requires BYO Twitter dev creds) +- Cross-run voice learning +- Slack alt-chat surface (mention as "capability," don't demo) +- Analytics / citation tracking loop +- General GTM features (sales, CRM, scheduling) — deliberately killed in the pivot +- Multi-topic sprint demo (single-blog flow only) + +## North-star metrics + +- **Demo:** *"3+ channels live in <60 seconds from a docs URL."* If we hit that, we win. +- **Product:** *"Every doc commit auto-becomes the blog post you didn't write."* + +--- + +## Decision log (why this framing won) + +- **Locked in 2026-05-10** after debating GEO-only / blog-tool / GMF / docs-→-blogs. +- Research-driven: Anthropic-judged hackathons reward narrow + theatrical demos over broad pitches (the *lawyer* beat 500 devs at Cerebral Valley with permit-processing). Profound's $1B Series C owns the GEO narrative — we don't try to out-pitch a unicorn. Jasper/Copy.ai/Writer own the broad "AI content for founders" square — we don't fight there. +- White space: Notra/PersonaBox/Docsie are adjacent; nobody has multi-persona + multi-channel + founder-in-loop. Mintlify and GitBook publicly naming the gap = category being established. +- Devtools is the highest-WTP B2B niche. $4–12K/mo agency budgets at Series A devtools companies prove the buyer pays. +- "One for All" theme literally restates the product: one input, all the channels. + +## Pivot history + +- **2026-05-08** — initial pitch: AI GTM team for pre-Series A founders (sales / CS / RevOps). +- **2026-05-09** — pivoted to AI content team (blog / GEO / multi-channel). Architecture survived; domain types swapped. +- **2026-05-10** — narrowed to **devtools docs → multi-channel content**. Same architecture, sharper positioning. **This is locked.** + +--- + +## Where each teammate goes from here + +- **Pitch deck:** lift hero / tagline / theme tie-in / demo arc verbatim. Don't paraphrase. +- **Demo script:** the 10 beats above. Single founder prompt. 90 seconds. +- **Marketing copy / project page:** start from the one-liner. Use Mintlify/Stainless funding as social proof. +- **Engineering (this branch):** finish the real-LLM publish path for GitHub PR + Reddit + LinkedIn. Verify Firecrawl docs scrape end-to-end. Bulk-approve UI for per-channel previews. + +If you change the pitch, change this file first. Everything else flows from here. diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index e51d9f0..9a245ba 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -3,19 +3,19 @@ import { Suspense, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { CompanyContextCard } from "@/lib/ui/components/company-context-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"; +import { RunInputForm } from "@/lib/ui/components/run-input-form"; const Hero = (

GMaestro{" "} - - GStack for blogs + - your AI content team

- You → Conductor → research, synthesis, write, design.{" "} - Your B2B blog pipeline, on autopilot. + You → Conductor → 3 managers → 10 specialists across blog, GEO, and + multi-channel distribution. Founder-in-loop, multi-channel, GEO-aware.

); @@ -38,7 +38,7 @@ export default function DashboardPage() {
- +
diff --git a/app/api/runs/route.ts b/app/api/runs/route.ts index e35b36a..9f1025a 100644 --- a/app/api/runs/route.ts +++ b/app/api/runs/route.ts @@ -81,26 +81,37 @@ export async function POST(request: Request) { const founderId = process.env.GMAESTRO_USER_ID ?? "default"; - // 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 - // instead of producing a no-op run that hangs in "running" forever. - try { - await ensureLeadsForPromptEmails(parsed.data.prompt); - } catch (err) { - console.error("[api/runs] ensureLeadsForPromptEmails failed:", err); - return NextResponse.json( - { error: "Failed to materialize leads from prompt" }, - { status: 500 }, - ); + // Build a prompt-shaped string for the run row (drives the recent-runs UI + // + the Conductor's prompt input). For the new 3-input form, we synthesize + // a structured prompt the Conductor can reason about. For legacy callers + // sending a freeform `prompt`, pass it through. + const { companyUrl, docsUrl, destination, prompt: legacyPrompt } = parsed.data; + const promptString = legacyPrompt ?? buildStructuredPrompt({ + companyUrl: companyUrl!, + docsUrl: docsUrl!, + destination: destination!, + }); + + // Materialize leads for any emails in legacy prompts BEFORE WorkContext + // is built. New 3-input flow has no email parsing. + if (legacyPrompt) { + try { + await ensureLeadsForPromptEmails(legacyPrompt); + } catch (err) { + console.error("[api/runs] ensureLeadsForPromptEmails failed:", err); + return NextResponse.json( + { error: "Failed to materialize leads from prompt" }, + { status: 500 }, + ); + } } - const workflowRunId = await createRun(parsed.data.prompt); + const workflowRunId = await createRun(promptString); // Fire-and-forget: the workflow runs for minutes; the route returns the id // immediately. The .catch is non-negotiable — without it, an unhandled // rejection in detached land kills the dev server with no DB trace. - void runWorkflow(workflowRunId, parsed.data.prompt, founderId).catch( + void runWorkflow(workflowRunId, promptString, founderId).catch( async (err) => { try { await markRunFailed(workflowRunId, err); @@ -119,3 +130,26 @@ export async function POST(request: Request) { return NextResponse.json({ workflowRunId }, { status: 202 }); } + +/** + * Synthesize a Conductor-readable prompt from the structured 3-input payload. + * The Conductor doesn't need to know the inputs are structured — it just sees + * a clear instruction with the URLs + destination baked in. + */ +function buildStructuredPrompt(input: { + companyUrl: string; + docsUrl: string; + destination: "blog-html" | "reddit" | "x-thread"; +}): string { + const destinationLabel = { + "blog-html": "a blog post (~1,000 words)", + reddit: "a Reddit thread (~250 words)", + "x-thread": "an X thread (5–10 tweets)", + }[input.destination]; + return [ + `Write ${destinationLabel} for the company at ${input.companyUrl}.`, + `The blog is about the technical content at ${input.docsUrl}.`, + `Match the company's existing voice (extracted from their blog automatically).`, + `Destination: ${input.destination}.`, + ].join(" "); +} diff --git a/app/api/test-persona/route.ts b/app/api/test-persona/route.ts index 138c98d..afa7a6a 100644 --- a/app/api/test-persona/route.ts +++ b/app/api/test-persona/route.ts @@ -75,11 +75,30 @@ export async function POST(request: Request) { // Pattern B: researcher needs the Composio fetch bundle pre-baked into // its input (the workflow dispatcher does this; we replicate here). if (personaId === "researcher") { - const item = (input.item as Record | undefined) ?? {}; + const topic = + typeof input.topic === "string" + ? input.topic + : typeof (input.item as { topic?: string } | undefined)?.topic === "string" + ? ((input.item as { topic: string }).topic) + : ""; + const companyProfileRaw = input.companyProfile ?? (input.item as { companyProfile?: unknown } | undefined)?.companyProfile; + const companyProfile = + companyProfileRaw && typeof companyProfileRaw === "object" && !Array.isArray(companyProfileRaw) + ? (companyProfileRaw as Record) + : {}; + const companyName = + typeof companyProfile.companyName === "string" + ? companyProfile.companyName + : undefined; + const competitorUrls = Array.isArray(companyProfile.competitors) + ? (companyProfile.competitors as unknown[]).filter( + (u): u is string => typeof u === "string", + ) + : undefined; const bundle = await fetchResearcherBundle(userId, { - email: typeof item.email === "string" ? item.email : undefined, - name: typeof item.name === "string" ? item.name : undefined, - company: typeof item.company === "string" ? item.company : undefined, + topic, + companyName, + competitorUrls, }); finalInput = { ...input, fetchBundle: bundle }; } diff --git a/lib/dispatch/execute.ts b/lib/dispatch/execute.ts index bd5b2dd..720eeff 100644 --- a/lib/dispatch/execute.ts +++ b/lib/dispatch/execute.ts @@ -139,15 +139,12 @@ function mergeFounderEdits( } async function stampArtifactSent(approval: ApprovalRequest): Promise { - if (approval.artifactType === "OutreachDraft") { - await db - .update(schema.outreachDrafts) - .set({ sentAt: new Date(), approvalStatus: "approved" }) - .where(eq(schema.outreachDrafts.id, approval.artifactId)); - return; - } - // Other artifact tables don't yet have a "sentAt" or analogue — extend as - // BookedMeeting/ActivationNudge dispatch lands. + // Content-domain artifacts (TopicResearchBrief, ContentOutline, BlogDraft, + // ChannelVariant, PublishedArtifact) don't have dedicated tables yet — + // the approval_requests row carries the full artifact in its proposed_action + // column. Sent-state is implicit (the dispatcher succeeded). Wire dedicated + // tables here when historical artifact pages need them. + void approval; } function isAuthFailure(message: string): boolean { diff --git a/lib/dispatch/providers.ts b/lib/dispatch/providers.ts index eda847d..d1f6614 100644 --- a/lib/dispatch/providers.ts +++ b/lib/dispatch/providers.ts @@ -4,11 +4,15 @@ * - the approval card's provider picker UI (which providers does the founder * have a choice between for THIS artifact?) * - the post-approval dispatcher (which Composio action do we call when the - * founder picks "gmail"?) + * founder picks "github"?) + * + * For BlogDraft approvals, the founder picks N targets via the channels + * checkbox — one approval, fans out to N publishes. For ChannelVariant + * approvals (per-channel previews), there's exactly one provider per variant + * (the channel is baked in). * * Adding a new provider for an artifact type = one entry here. No persona - * changes, no LLM-side scope changes. The LLM never sees these — the - * dispatcher uses them deterministically after founder approval. + * changes, no LLM-side scope changes. */ export interface ProviderAction { @@ -20,9 +24,9 @@ export interface ProviderAction { label: string; /** * Build the Composio action arguments from the approval's `proposed_action`. - * The approval row carries the persona's full typed output (writer's draft, - * scheduler's meeting, etc.) plus optional `_leadContext` / `_upstreamOutputs` - * card metadata — this fn pulls out just the fields the action needs. + * The approval row carries the persona's full typed output (formatter's + * ChannelVariant, etc.) plus optional card metadata — this fn pulls out + * just the fields the action needs. */ buildArgs: (proposed: Record) => Record; } @@ -31,84 +35,178 @@ function asString(v: unknown): string | undefined { return typeof v === "string" ? v : undefined; } +function asObject(v: unknown): Record { + return v && typeof v === "object" ? (v as Record) : {}; +} + /** * Per-artifact provider catalog. Order = preference (first connected provider - * is auto-selected if only one match). Add new providers here and the picker - * picks them up automatically. + * is auto-selected if only one match). */ export const PROVIDERS_BY_ARTIFACT: Record = { - OutreachDraft: [ + /** + * BlogDraft approvals carry `targets: ToolkitId[]` set by the founder via + * the channels picker. The dispatcher fans out one publish per target by + * looking up the matching ChannelVariant entry below — there's no single + * "BlogDraft provider" call. We expose all 7 target toolkits here so the + * approval card can render the channels picker; the dispatcher itself + * doesn't invoke these directly for BlogDraft (it routes through Formatter + * + ChannelVariant approvals). + */ + BlogDraft: [], + + /** + * ChannelVariant — one provider per target. The dispatcher reads the + * variant's `target` field to pick the right provider. + * + * For each: `metadata` is the per-channel structure the Formatter persona + * produced; the buildArgs fn extracts the Composio-action-specific args. + */ + ChannelVariant: [ { - toolkit: "gmail", - action: "GMAIL_SEND_EMAIL", - label: "Gmail", - buildArgs: (p) => ({ - recipient_email: asString(p.to) ?? asString(p.recipient_email), - subject: asString(p.subject) ?? "", - body: asString(p.body) ?? "", - }), + toolkit: "github", + action: "GITHUB_CREATE_PULL_REQUEST", + label: "GitHub PR", + buildArgs: (p) => { + const metadata = asObject(p.metadata); + // GitHub publish is a 2-step flow (commit file then open PR). + // The dispatcher recognizes target=github and runs both + // `GITHUB_COMMIT_MULTIPLE_FILES` then `GITHUB_CREATE_PULL_REQUEST`. + // We only need the args for the PR step here — commit args are + // derived from the variant's `content` (markdown body) and metadata.path. + const repo = asString(metadata.repo) ?? "anvil-co/anvil-site"; + const [owner, repoName] = repo.split("/"); + return { + owner, + repo: repoName, + title: asString(metadata.prTitle) ?? "Add post", + head: asString(metadata.branch) ?? "content/new-post", + base: "main", + body: asString(metadata.prBody) ?? asString(p.content) ?? "", + // The actual file commit happens in the dispatcher pre-step using + // GITHUB_COMMIT_MULTIPLE_FILES with: { owner, repo, branch, path, + // content: variant.content }. + _commitFile: { + path: asString(metadata.path) ?? "content/blog/post.mdx", + content: asString(p.content) ?? "", + }, + }; + }, }, { - toolkit: "outlook", - action: "OUTLOOK_SEND_EMAIL", - label: "Outlook", - buildArgs: (p) => ({ - to_email: asString(p.to) ?? asString(p.recipient_email), - subject: asString(p.subject) ?? "", - body: asString(p.body) ?? "", - }), + toolkit: "wordpress", + action: "WORDPRESS_CREATE_POST", // TBD — verify slug via _probe-mcp-tools.ts + label: "WordPress", + buildArgs: (p) => { + const metadata = asObject(p.metadata); + return { + title: asString(metadata.title) ?? "", + content: asString(p.content) ?? "", + slug: asString(metadata.slug) ?? "", + excerpt: asString(metadata.excerpt) ?? "", + status: asString(metadata.status) ?? "draft", + categories: Array.isArray(metadata.categories) ? metadata.categories : [], + tags: Array.isArray(metadata.tags) ? metadata.tags : [], + }; + }, }, - ], - CustomDeal: [ { - toolkit: "googlecalendar", - action: "GOOGLECALENDAR_CREATE_EVENT", - label: "Google Calendar", - buildArgs: (p) => ({ - summary: asString(p.title) ?? asString(p.subject) ?? "Meeting", - start_datetime: asString(p.startsAt), - end_datetime: asString(p.endsAt), - attendees: Array.isArray(p.attendees) ? p.attendees : [], - description: asString(p.description) ?? "", - }), + toolkit: "ghost", + action: "GHOST_CREATE_POST", // TBD — verify slug via _probe-mcp-tools.ts + label: "Ghost", + buildArgs: (p) => { + const metadata = asObject(p.metadata); + return { + title: asString(metadata.title) ?? "", + html: asString(p.content) ?? "", + slug: asString(metadata.slug) ?? "", + excerpt: asString(metadata.excerpt) ?? "", + status: asString(metadata.status) ?? "draft", + tags: Array.isArray(metadata.tags) ? metadata.tags : [], + }; + }, }, - ], - ActivationNudge: [ { - toolkit: "gmail", - action: "GMAIL_SEND_EMAIL", - label: "Gmail", - buildArgs: (p) => ({ - recipient_email: asString(p.to) ?? asString(p.recipient_email), - subject: asString(p.subject) ?? "", - body: asString(p.body) ?? "", - }), + toolkit: "notion", + action: "NOTION_INSERT_ROW_DATABASE", + label: "Notion", + buildArgs: (p) => { + const metadata = asObject(p.metadata); + // Variant content is a JSON-stringified array of Notion block objects. + // Notion's row-insert action takes properties + children blocks. + let children: unknown[] = []; + try { + children = JSON.parse(asString(p.content) ?? "[]"); + } catch { + children = []; + } + return { + database_id: asString(metadata.databaseId) ?? "", + properties: metadata.properties ?? {}, + children, + }; + }, }, { - toolkit: "intercom", - action: "INTERCOM_REPLY_TO_CONVERSATION", - label: "Intercom", - buildArgs: (p) => ({ - conversation_id: asString(p.conversationId), - message_body: asString(p.body) ?? "", - }), + toolkit: "reddit", + action: "REDDIT_CREATE_REDDIT_POST", + label: "Reddit", + buildArgs: (p) => { + const metadata = asObject(p.metadata); + return { + subreddit: asString(metadata.subreddit) ?? "test", + kind: asString(metadata.kind) ?? "self", + title: asString(metadata.title) ?? "", + text: asString(p.content) ?? "", + ...(metadata.flair ? { flair_text: asString(metadata.flair) } : {}), + }; + }, + }, + { + toolkit: "linkedin", + action: "LINKEDIN_CREATE_LINKED_IN_POST", + label: "LinkedIn", + buildArgs: (p) => { + const metadata = asObject(p.metadata); + return { + commentary: asString(p.content) ?? "", + visibility: asString(metadata.visibility) ?? "PUBLIC", + }; + }, }, - ], - CRMUpdate: [ { - toolkit: "hubspot", - action: "HUBSPOT_CREATE_CONTACT", - label: "HubSpot", - buildArgs: (p) => ({ - properties: p.properties ?? { - email: asString(p.email), - firstname: asString(p.firstName), - lastname: asString(p.lastName), - company: asString(p.company), - }, - }), + toolkit: "twitter", + action: "TWITTER_CREATION_OF_A_POST", + label: "X (Twitter)", + buildArgs: (p) => { + const metadata = asObject(p.metadata); + // For threads, the content is newline-`---`-separated tweets. + // The dispatcher chains them via reply_in_reply_to_tweet_id. + const isThread = asString(metadata.kind) === "thread"; + const content = asString(p.content) ?? ""; + if (isThread) { + const tweets = content + .split(/\n---\n/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); + return { + text: tweets[0] ?? "", + _threadRest: tweets.slice(1), + }; + } + return { text: content }; + }, }, ], + + /** + * Lower-priority artifacts — TopicResearchBrief / ContentOutline approvals + * are typically resolved without a Composio call (they're internal gates). + * Empty provider list = no provider picker, just approve/reject. + */ + TopicResearchBrief: [], + ContentOutline: [], + PublishedArtifact: [], }; export function getProvidersForArtifact( diff --git a/lib/orchestrator/conductor.ts b/lib/orchestrator/conductor.ts index b06013f..6063660 100644 --- a/lib/orchestrator/conductor.ts +++ b/lib/orchestrator/conductor.ts @@ -8,15 +8,15 @@ import { makeMockMcpConfig, makeMockWorkflowDAG } from "@/lib/shared/mocks"; import type { WorkContext } from "@/lib/state/work-context"; import { managerAgents, MANAGER_AGENT_NAMES } from "./managers"; -export const CONDUCTOR_SYSTEM_PROMPT = `You are the Conductor of GMaestro, an AI GTM team for a YC W26 founder. +export const CONDUCTOR_SYSTEM_PROMPT = `You are the Conductor of GMaestro, an AI content team for a pre-Series A founder. The team optimizes for both traditional SEO and Generative Engine Optimization (GEO — citation by ChatGPT / Perplexity / Claude / Gemini / Google AI Overviews). -You have four department-head sub-agents you can invoke via the Agent tool: +You have three department-head sub-agents you can invoke via the Agent tool: - ${MANAGER_AGENT_NAMES.join(", ")} Each manager owns a fixed roster of specialists. Your job: -1. Read the founder's objective. -2. Read the AVAILABLE WORK ITEMS section — these are the rows in the founder's local store the team can act on (leads, trial signals, etc.). The dashboard is the source of truth for these. Do not invent items that are not listed. -3. Decide which department(s) should be involved given the available items. +1. Read the founder's objective. The objective typically names a topic (or asks to plan multiple) and may specify channels, audience, voice constraints. +2. Read the AVAILABLE WORK ITEMS section if present — multi-topic sprints expose a "topics" collection. Single-blog runs typically have no work items; the topic is in the objective itself. +3. Decide which department(s) should be involved. 4. Invoke the relevant managers (in parallel when independent) using the Agent tool. Each manager will return a JSON array of specialist tasks for its department, possibly using the FANOUT TEMPLATE pattern (see below). 5. Concatenate every manager's task array into a single flat array. 6. Output ONE final JSON object — and nothing else — matching this schema: @@ -25,33 +25,38 @@ Each manager owns a fixed roster of specialists. Your job: "tasks": [ { "id": string, // unique across the whole DAG - "specialistId": "researcher" | "qualifier" | "strategist" | "writer" | "scheduler" | "brief-writer" - | "activation" - | "crm-logger" | "pipeline-reporter" | "slack-digest" + "specialistId": "researcher" | "strategist" | "writer" | "geo-editor" | "formatter" + | "pipeline-reporter" | "slack-digest" | "feedback-tagger" | "theme-synthesizer" | "linear-filer", "input": object, // values may contain the literal token "\${each}" when fanoutOver is set "dependsOn"?: string[], // ids of upstream tasks in this same DAG "passOutput"?: string[], // whitelist of output keys to expose to downstream tasks (default: expose all) "triggerRule"?: "all_success" | "all_done", // default "all_success"; use "all_done" for tasks that should run even if upstream failed (e.g. a final summary) - "fanoutOver"?: "leads" | "trial-signals", // if set, system materializes one task per item in the named collection - "mode"?: "batch" | "fanout" // batch = ONE LLM call processes all items via COMPOSIO_MULTI_EXECUTE_TOOL (~30× faster); fanout = N LLM calls, one per item. Default batch for read/synth personas, fanout for write-with-approval. - }, - ... + "fanoutOver"?: "topics" | "channels", // if set, system materializes one task per item in the named collection + "mode"?: "batch" | "fanout" // batch = ONE LLM call processes all items (~30× faster); fanout = N LLM calls. Default batch for read/synth personas, fanout for write-with-approval. + } ], "edges"?: [ { "from": string, "to": string, "artifactType": string } ] } +CONTENT WORKFLOW SHAPE — typical single-blog run: + researcher → strategist → [Outline approval] → writer → geo-editor → [BlogDraft approval + channels picker] → formatter (fanout over channels) → [per-channel preview approvals] → publish via dispatcher → pipeline-reporter → slack-digest + FANOUT TEMPLATES — read carefully: - A task with "fanoutOver" is a TEMPLATE the system expands into one materialized task per source item. -- Use the literal token "\${each}" inside the input wherever the per-item id should land (e.g. { "leadId": "\${each}" }). -- Within a single fanout chain (e.g. researcher -> qualifier -> writer all with fanoutOver: "leads"), dependsOn references stay as the SHORT template id ("researcher", not "researcher-1"). The system rewires each instance correctly. -- A non-fanout task (e.g. "slack-digest") that depends on a fanout template ("crm-logger") will wait for ALL N instances of that template to complete. -- Downstream tasks read upstream outputs via previousOutputs... Use passOutput on the upstream task to whitelist which output keys flow through (default: all). +- Use the literal token "\${each}" inside the input wherever the per-item id should land (e.g. { "topic": "\${each}" } or { "target": "\${each}" }). +- Within a single fanout chain, dependsOn references stay as the SHORT template id ("writer", not "writer-1"). The system rewires each instance correctly. +- A non-fanout task (e.g. "slack-digest") that depends on a fanout template ("formatter") will wait for ALL N instances of that template to complete. +- Downstream tasks read upstream outputs via previousOutputs... Use passOutput on the upstream task to whitelist which output keys flow through. + +CHANNELS FANOUT — special case: +- The "channels" fanoutOver source is set by the founder at BlogDraft approval time (they tick which destinations to publish to). The orchestrator materializes one formatter task per ticked target. +- The formatter task input MUST include "target": "\${each}". STRICT OUTPUT RULES: - Return ONLY the JSON object. No prose, no markdown code fences, no commentary before or after. -- The "tasks" array must be flat — no nesting per department. Each task must use one of the 13 specialist ids above. -- Cross-department dependencies are allowed (e.g. crm-logger depends on writer). Use the task ids returned by the managers. +- The "tasks" array must be flat — no nesting per department. Each task must use one of the 10 specialist ids above. +- Cross-department dependencies are allowed (e.g. pipeline-reporter depends on formatter). Use the task ids returned by the managers. - If an objective involves no work for a department, simply do not invoke that manager. `; diff --git a/lib/orchestrator/managers/content.ts b/lib/orchestrator/managers/content.ts new file mode 100644 index 0000000..7d75b4c --- /dev/null +++ b/lib/orchestrator/managers/content.ts @@ -0,0 +1,84 @@ +import "server-only"; +import type { AgentDefinition } from "@anthropic-ai/claude-agent-sdk"; + +export const CONTENT_MANAGER_AGENT_NAME = "content-mgr" as const; + +export const contentManager: AgentDefinition = { + description: + "Content department head. Decomposes a content/blog/GEO objective into specialist tasks across researcher, strategist, writer, geo-editor, and formatter.", + model: "claude-opus-4-7", + mcpServers: ["composio"], + tools: [], + prompt: `You are the Content Department Head at GMaestro, an AI content team for a pre-Series A founder optimizing for both traditional SEO and Generative Engine Optimization (GEO — citation by ChatGPT / Perplexity / Claude / Gemini / Google AI Overviews). + +Your job is to decompose the founder's content objective into a list of specialist tasks. You manage exactly five specialists, each with a fixed role: + +- "researcher" — given a topic seed, runs Pattern B fetch (Reddit / X / Firecrawl / Perplexity) and synthesizes a TopicResearchBrief with candidates + competitor scan + citation footprint. +- "strategist" — turns the approved topic + research brief into a ContentOutline (thesis, sections, target keywords, GEO signals). +- "writer" — turns the approved outline into a BlogDraft (long-form markdown, in the founder's voice). +- "geo-editor" — applies GEO/SEO signals to the draft (direct-answer lead, fact density, citation density, schema markup recs, expert-quote hooks). +- "formatter" — given an APPROVED draft + a SINGLE target channel, emits a channel-native ChannelVariant (MDX for GitHub, HTML for WordPress/Ghost, Notion blocks, Reddit-native, LinkedIn carousel/text, X tweet/thread). + +If the objective doesn't involve a stage above, omit it. + +OUTPUT FORMAT — strict. Output ONLY a JSON array of tasks, nothing else. No prose, no markdown fences. + +Each task object: +{ + "id": string, // unique within the array + "specialistId": "researcher" | "strategist" | "writer" | "geo-editor" | "formatter", + "input": object, // values may contain "\${each}" when fanoutOver is set + "dependsOn"?: string[], + "passOutput"?: string[], // whitelist of output fields to expose downstream (default: expose all) + "triggerRule"?: "all_success" | "all_done", // default "all_success" + "fanoutOver"?: "topics" | "channels" // expand into one task per item +} + +PATTERN — single-blog from a topic prompt (the common case): +[ + { "id": "researcher", "specialistId": "researcher", "input": { "topic": "" }, "passOutput": ["recommendedTopic", "candidates", "competitorScan", "citationFootprint"] }, + { "id": "strategist", "specialistId": "strategist", "input": { "topic": "" }, "dependsOn": ["researcher"], "passOutput": ["title", "thesis", "sections", "targetKeywords", "geoSignals"] }, + { "id": "writer", "specialistId": "writer", "input": { "topic": "" }, "dependsOn": ["strategist"], "passOutput": ["id", "title", "slug", "excerpt", "bodyMarkdown", "tags", "citations"] }, + { "id": "geo-editor", "specialistId": "geo-editor", "input": {}, "dependsOn": ["writer"], "passOutput": ["id", "title", "slug", "excerpt", "bodyMarkdown", "tags", "citations", "geoNotes", "factDensityRatio"] }, + { "id": "formatter", "specialistId": "formatter", "input": { "target": "\${each}" }, "fanoutOver": "channels", "dependsOn": ["geo-editor"], "passOutput": ["id", "blogDraftId", "target", "content", "metadata"] } +] + +PATTERN — multi-topic sprint (when the founder asks for N blogs): +[ + { "id": "researcher", "specialistId": "researcher", "input": { "topic": "\${each}" }, "fanoutOver": "topics", "mode": "batch", "passOutput": ["recommendedTopic", "candidates"] }, + { "id": "strategist", "specialistId": "strategist", "input": { "topic": "\${each}" }, "fanoutOver": "topics", "mode": "batch", "dependsOn": ["researcher"], "passOutput": ["title", "thesis", "sections"] }, + { "id": "writer", "specialistId": "writer", "input": { "topic": "\${each}" }, "fanoutOver": "topics", "mode": "fanout", "dependsOn": ["strategist"], "passOutput": ["id", "title", "slug", "excerpt", "bodyMarkdown"] }, + { "id": "geo-editor", "specialistId": "geo-editor", "input": { "topic": "\${each}" }, "fanoutOver": "topics", "mode": "fanout", "dependsOn": ["writer"], "passOutput": ["id", "bodyMarkdown", "geoNotes"] } +] +(formatter fanout over channels is added separately AFTER the founder approves each draft and ticks targets.) + +CASCADE BEHAVIOR — writer / geo-editor / formatter use the default +triggerRule: "all_success" so they SKIP cleanly when an upstream +persona fails. Previously these used "all_done" for "demo robustness" +(produce a draft even if research failed) but it backfired: when +researcher couldn't reach Firecrawl, writer ran on empty input, +produced "[MISSING UPSTREAM DATA]" placeholders, and geo-editor + +formatter cascaded garbage downstream — burning 5+ minutes per +persona on garbage. The approval-gate guard already catches the +empty-output case at the workflow level, so silent cascade isn't +needed for safety. + +MODE SELECTION: +- "batch" mode: ONE LLM call processes all N items. Use for researcher + strategist when fanning out over multiple topics — cross-topic reasoning lets them avoid duplicating angles. +- "fanout" mode: N parallel LLM calls. Use for writer (per-blog voice consistency), geo-editor (per-blog signal optimization), and formatter (each variant is an independent voice exercise per channel). + +Default to "batch" for researcher / strategist when fanoutOver is "topics" and item count > 5. Always "fanout" for writer / geo-editor / formatter. + +FORMATTER FANOUT — special case: +- The formatter ALWAYS uses fanoutOver: "channels". The "channels" source list is set by the founder at BlogDraft approval time (they tick which destinations to publish to). The orchestrator materializes one formatter task per ticked target. +- The formatter task input MUST include "target": "\${each}" so each materialized instance gets its target. + +Rules: +- Use only the five specialist ids above. Do not invent new ones. +- For fanout, use SHORT ids ("writer" not "writer-1") and the literal "\${each}" token in input fields that should hold the per-item id. The system appends "__" to the id and substitutes "\${each}". +- Within a fanout chain, dependsOn references stay as the SHORT template id; the system rewires per-instance. +- Use passOutput on tasks whose outputs are needed downstream — keep the whitelist tight. +- triggerRule "all_done" is reserved for END-OF-RUN report personas that should fire regardless of upstream success (pipeline-reporter, slack-digest — handled by the distribution-mgr, not this manager). +- If you have no work for the content department, return []. +`, +}; diff --git a/lib/orchestrator/managers/cs.ts b/lib/orchestrator/managers/cs.ts deleted file mode 100644 index c08796a..0000000 --- a/lib/orchestrator/managers/cs.ts +++ /dev/null @@ -1,45 +0,0 @@ -import "server-only"; -import type { AgentDefinition } from "@anthropic-ai/claude-agent-sdk"; - -export const CS_MANAGER_AGENT_NAME = "cs-mgr" as const; - -export const csManager: AgentDefinition = { - description: - "Customer Success department head. Decomposes a CS objective into activation tasks for trial users.", - model: "claude-opus-4-7", - mcpServers: ["composio"], - tools: ["mcp__composio__STRIPE_LIST_CUSTOMERS"], - prompt: `You are the Customer Success Department Head at GMaestro. - -You manage exactly one specialist: -- "activation" — given a TrialSignal (Stripe + product usage), drafts an in-app or email nudge to unstick a stalled trial user. - -OUTPUT FORMAT — strict. Output ONLY a JSON array of tasks, nothing else. No prose, no markdown fences. - -Each task: -{ - "id": string, // SHORT id like "activation" for fanout templates; the system appends per-item suffix - "specialistId": "activation", - "input": object, // values may contain "\${each}" when fanoutOver is set - "dependsOn"?: string[], - "passOutput"?: string[], - "triggerRule"?: "all_success" | "all_done", - "fanoutOver"?: "trial-signals" -} - -PATTERN — fanout over all stalled trial users: -[ - { "id": "activation", "specialistId": "activation", "input": { "trialSignalId": "\${each}" }, "fanoutOver": "trial-signals", "passOutput": ["id", "subject", "channel"] } -] - -PATTERN — single trial user: -[ - { "id": "activation-1", "specialistId": "activation", "input": { "trialSignalId": "" } } -] - -Rules: -- Only use specialistId "activation". -- Prefer fanoutOver "trial-signals" when the objective targets multiple stalled users. -- If no CS work is required, return []. -`, -}; diff --git a/lib/orchestrator/managers/distribution.ts b/lib/orchestrator/managers/distribution.ts new file mode 100644 index 0000000..d5ab517 --- /dev/null +++ b/lib/orchestrator/managers/distribution.ts @@ -0,0 +1,45 @@ +import "server-only"; +import type { AgentDefinition } from "@anthropic-ai/claude-agent-sdk"; + +export const DISTRIBUTION_MANAGER_AGENT_NAME = "distribution-mgr" as const; + +export const distributionManager: AgentDefinition = { + description: + "Distribution department head. After publishing, summarizes the run via pipeline-reporter and posts an end-of-run digest via slack-digest.", + model: "claude-opus-4-7", + mcpServers: ["composio"], + tools: [], + prompt: `You are the Distribution Department Head at GMaestro. + +You manage exactly two specialists: +- "pipeline-reporter" — produces a structured content-pipeline summary (post title, words, channels published, GEO signals applied, failures). +- "slack-digest" — produces a Slack-flavored short digest the dashboard's post-approval handler posts to the founder's #content channel. + +OUTPUT FORMAT — strict. Output ONLY a JSON array of tasks, nothing else. No prose, no markdown fences. + +Each task: +{ + "id": string, + "specialistId": "pipeline-reporter" | "slack-digest", + "input": object, + "dependsOn"?: string[], + "passOutput"?: string[], + "triggerRule"?: "all_success" | "all_done" +} + +PATTERN — typical end-of-run distribution: +[ + { "id": "pipeline-reporter", "specialistId": "pipeline-reporter", "input": {}, "dependsOn": ["formatter"], "triggerRule": "all_done", "passOutput": ["summary", "metrics"] }, + { "id": "slack-digest", "specialistId": "slack-digest", "input": {}, "dependsOn": ["pipeline-reporter"], "triggerRule": "all_done" } +] + +Note on cross-department deps: the content manager will typically emit a fanout chain for "formatter". When you reference "formatter" in dependsOn, the system understands you mean ALL formatter instances. + +Rules: +- Use only the two specialist ids above. +- Both are single-instance (no fanout). +- Use triggerRule "all_done" so the digest still runs even if some upstream chains failed (the digest reports the failures honestly). +- pipeline-reporter typically runs before slack-digest so the digest can reference its summary. +- If no Distribution work is required, return []. +`, +}; diff --git a/lib/orchestrator/managers/index.ts b/lib/orchestrator/managers/index.ts index 850d04e..fa60cc4 100644 --- a/lib/orchestrator/managers/index.ts +++ b/lib/orchestrator/managers/index.ts @@ -1,31 +1,32 @@ import "server-only"; import type { AgentDefinition } from "@anthropic-ai/claude-agent-sdk"; -import { csManager, CS_MANAGER_AGENT_NAME } from "./cs"; +import { + contentManager, + CONTENT_MANAGER_AGENT_NAME, +} from "./content"; +import { + distributionManager, + DISTRIBUTION_MANAGER_AGENT_NAME, +} from "./distribution"; import { insightManager, INSIGHT_MANAGER_AGENT_NAME } from "./insight"; -import { revopsManager, REVOPS_MANAGER_AGENT_NAME } from "./revops"; -import { salesManager, SALES_MANAGER_AGENT_NAME } from "./sales"; export { - csManager, + contentManager, + distributionManager, insightManager, - revopsManager, - salesManager, - CS_MANAGER_AGENT_NAME, + CONTENT_MANAGER_AGENT_NAME, + DISTRIBUTION_MANAGER_AGENT_NAME, INSIGHT_MANAGER_AGENT_NAME, - REVOPS_MANAGER_AGENT_NAME, - SALES_MANAGER_AGENT_NAME, }; export const managerAgents: Record = { - [SALES_MANAGER_AGENT_NAME]: salesManager, - [CS_MANAGER_AGENT_NAME]: csManager, - [REVOPS_MANAGER_AGENT_NAME]: revopsManager, + [CONTENT_MANAGER_AGENT_NAME]: contentManager, + [DISTRIBUTION_MANAGER_AGENT_NAME]: distributionManager, [INSIGHT_MANAGER_AGENT_NAME]: insightManager, }; export const MANAGER_AGENT_NAMES = [ - SALES_MANAGER_AGENT_NAME, - CS_MANAGER_AGENT_NAME, - REVOPS_MANAGER_AGENT_NAME, + CONTENT_MANAGER_AGENT_NAME, + DISTRIBUTION_MANAGER_AGENT_NAME, INSIGHT_MANAGER_AGENT_NAME, ] as const; diff --git a/lib/orchestrator/managers/insight.ts b/lib/orchestrator/managers/insight.ts index 207ca1b..932ce02 100644 --- a/lib/orchestrator/managers/insight.ts +++ b/lib/orchestrator/managers/insight.ts @@ -5,22 +5,22 @@ export const INSIGHT_MANAGER_AGENT_NAME = "insight-mgr" as const; export const insightManager: AgentDefinition = { description: - "Insight department head. Decomposes a customer-feedback objective into tagging, theme synthesis, and Linear issue filing.", + "Insight department head. Decomposes a content-feedback objective into post-publish tagging, theme synthesis, and Linear task filing.", model: "claude-opus-4-7", mcpServers: ["composio"], - tools: ["mcp__composio__LINEAR_CREATE_ISSUE"], + tools: [], prompt: `You are the Insight Department Head at GMaestro. You manage exactly three specialists: -- "feedback-tagger" — given raw customer feedback, classifies it (bug / feature / churn-signal / praise) — read-only tagging. -- "theme-synthesizer" — clusters tagged feedback into 3–5 themes and writes a Notion page. -- "linear-filer" — files actionable themes as Linear issues (or GitHub issues). +- "feedback-tagger" — given a single post-publish signal (Reddit comment, LinkedIn reaction, X reply, blog comment, analytics anomaly), tags themes + sentiment. +- "theme-synthesizer" — clusters tagged signals into 3-5 themes and produces a backlog the founder can scan. +- "linear-filer" — files actionable themes (topic gaps, quality issues, follow-up requests) as Linear tasks. OUTPUT FORMAT — strict. Output ONLY a JSON array of tasks, nothing else. No prose, no markdown fences. Each task: { - "id": string, // e.g. "feedback-tagger-1" + "id": string, "specialistId": "feedback-tagger" | "theme-synthesizer" | "linear-filer", "input": object, "dependsOn"?: string[] diff --git a/lib/orchestrator/managers/revops.ts b/lib/orchestrator/managers/revops.ts deleted file mode 100644 index c001905..0000000 --- a/lib/orchestrator/managers/revops.ts +++ /dev/null @@ -1,48 +0,0 @@ -import "server-only"; -import type { AgentDefinition } from "@anthropic-ai/claude-agent-sdk"; - -export const REVOPS_MANAGER_AGENT_NAME = "revops-mgr" as const; - -export const revopsManager: AgentDefinition = { - description: - "Revenue Operations department head. Decomposes a RevOps objective into CRM logging, pipeline reporting, and Slack-digest tasks.", - model: "claude-opus-4-7", - mcpServers: ["composio"], - tools: ["mcp__composio__SLACK_POST_MESSAGE"], - prompt: `You are the Revenue Operations Department Head at GMaestro. - -You manage exactly three specialists: -- "crm-logger" — writes lead/deal updates into HubSpot or Google Sheets. -- "pipeline-reporter" — produces a structured pipeline summary from CRM data. -- "slack-digest" — posts an end-of-run summary to a Slack channel. - -OUTPUT FORMAT — strict. Output ONLY a JSON array of tasks, nothing else. No prose, no markdown fences. - -Each task: -{ - "id": string, - "specialistId": "crm-logger" | "pipeline-reporter" | "slack-digest", - "input": object, - "dependsOn"?: string[], - "passOutput"?: string[], - "triggerRule"?: "all_success" | "all_done", - "fanoutOver"?: "leads" | "trial-signals" -} - -PATTERN — log every processed lead, then post one summary digest: -[ - { "id": "crm-logger", "specialistId": "crm-logger", "input": { "leadId": "\${each}" }, "fanoutOver": "leads", "mode": "batch", "dependsOn": ["writer"], "passOutput": ["crmContactId"], "triggerRule": "all_done" }, - { "id": "slack-digest", "specialistId": "slack-digest", "input": {}, "dependsOn": ["crm-logger"], "triggerRule": "all_done" } -] - -MODE — crm-logger should ALWAYS run in "batch" mode when fanoutOver is set. One LLM call writes all N HubSpot updates via COMPOSIO_MULTI_EXECUTE_TOOL — vastly faster than 47 separate sessions. slack-digest is a single task (no fanout). - -Note on cross-department deps: the sales manager will typically emit a fanout chain for "writer", "scheduler" etc. When you reference "writer" in dependsOn, the system understands you mean ALL writer instances (when crm-logger is also fanned out per-lead, the system pairs them by item id). - -Rules: -- crm-logger should typically be fanned out per-lead (use fanoutOver: "leads") with dependsOn: ["writer"]. -- slack-digest is the final task — single instance, depends on crm-logger, triggerRule "all_done" so it runs even if some chains failed. -- pipeline-reporter is single instance, no fanout, depends on crm-logger. -- If no RevOps work is required, return []. -`, -}; diff --git a/lib/orchestrator/managers/sales.ts b/lib/orchestrator/managers/sales.ts deleted file mode 100644 index 168596f..0000000 --- a/lib/orchestrator/managers/sales.ts +++ /dev/null @@ -1,83 +0,0 @@ -import "server-only"; -import type { AgentDefinition } from "@anthropic-ai/claude-agent-sdk"; - -export const SALES_MANAGER_AGENT_NAME = "sales-mgr" as const; - -export const salesManager: AgentDefinition = { - description: - "Sales department head. Decomposes a sales objective into specialist tasks across researcher, qualifier, strategist, writer, scheduler, and brief-writer.", - model: "claude-opus-4-7", - mcpServers: ["composio"], - tools: ["mcp__composio__HUBSPOT_SEARCH_CONTACTS"], - prompt: `You are the Sales Department Head at GMaestro, an AI GTM team for a YC W26 founder. - -Your job is to decompose the founder's sales objective into a list of specialist tasks for the sales team. You manage exactly six specialists, each with a fixed role: - -- "researcher" — enriches a Lead into an EnrichedLead via LinkedIn / Apollo / GitHub. -- "qualifier" — turns an EnrichedLead into a QualifiedLead with tier + scores. -- "strategist" — turns a QualifiedLead into an OutreachStrategy (read-only). -- "writer" — turns an OutreachStrategy into an OutreachDraft (drafts only, NEVER sends). -- "scheduler" — books a call (writes to calendar, sends invite-only emails). -- "brief-writer" — produces a PrepBrief in Notion before a call. - -If the objective doesn't involve a stage above, omit it. - -OUTPUT FORMAT — strict. Output ONLY a JSON array of tasks, nothing else. No prose, no markdown fences. - -Each task object: -{ - "id": string, // unique within the array — for fanout templates use a SHORT id like "researcher" (system appends per-item suffix) - "specialistId": "researcher" | "qualifier" | "strategist" | "writer" | "scheduler" | "brief-writer", - "input": object, // values may contain "\${each}" when fanoutOver is set - "dependsOn"?: string[], // ids of upstream tasks (use the SHORT template id within a fanout chain) - "passOutput"?: string[], // whitelist of output fields to expose to downstream tasks (default: expose all) - "triggerRule"?: "all_success" | "all_done", // default "all_success" - "fanoutOver"?: "leads" | "trial-signals" // if set, system materializes one task per item in the named source -} - -PATTERN — multi-lead fanout (the common case for "process N leads"): -[ - { "id": "researcher", "specialistId": "researcher", "input": { "leadId": "\${each}" }, "fanoutOver": "leads", "mode": "batch", "passOutput": ["leadId", "personRole", "companyIndustry"] }, - { "id": "qualifier", "specialistId": "qualifier", "input": { "leadId": "\${each}" }, "fanoutOver": "leads", "mode": "batch", "dependsOn": ["researcher"], "passOutput": ["leadId", "tier", "fitScore", "recommendedAction"] }, - { "id": "strategist", "specialistId": "strategist", "input": { "leadId": "\${each}" }, "fanoutOver": "leads", "mode": "batch", "dependsOn": ["qualifier"], "passOutput": ["leadId", "tier", "angle", "callToAction"] }, - { "id": "writer", "specialistId": "writer", "input": { "leadId": "\${each}" }, "fanoutOver": "leads", "mode": "fanout", "dependsOn": ["strategist"], "triggerRule": "all_done", "passOutput": ["id", "subject", "body", "channel"] }, - { "id": "scheduler", "specialistId": "scheduler", "input": { "leadId": "\${each}" }, "fanoutOver": "leads", "mode": "fanout", "dependsOn": ["writer"], "triggerRule": "all_done", "passOutput": ["id", "startsAt", "meetingLink"] }, - { "id": "brief-writer", "specialistId": "brief-writer", "input": { "leadId": "\${each}" }, "fanoutOver": "leads", "mode": "fanout", "dependsOn": ["scheduler"], "triggerRule": "all_done" } -] - -DEMO ROBUSTNESS — writer/scheduler/brief-writer all use triggerRule: "all_done" -so the founder gets at least a draft per lead even when researcher (LinkedIn) -or qualifier (HubSpot) failed because the integration isn't connected. The -writer falls back to lead.item.{email,name,company} for basic personalization. -Once LinkedIn/HubSpot are connected, the upstream stages succeed and the -drafts get richer automatically. - -MODE SELECTION — read carefully: -- "batch" mode: ONE LLM call processes all N items via Composio's COMPOSIO_MULTI_EXECUTE_TOOL. - Use for read/synth stages where each item is processed independently with no per-item human approval: - researcher (enrichment), qualifier (scoring), strategist (corpus-level playbook). - ~30× faster than fanout for N>10. Cross-lead reasoning (dedup, clustering) becomes possible. -- "fanout" mode: N parallel LLM calls. One per item. - Use ONLY where each item demands per-item human-in-loop approval or per-item voice personalization: - writer (per-draft approval gate is the demo's emotional beat), - scheduler (per-meeting calendar action), - brief-writer (per-meeting Notion brief). - -Default to "batch" for researcher / qualifier / strategist when fanoutOver is "leads" and item count > 5. - -PATTERN — single inbound lead (no fanout, when only one lead matters): -[ - { "id": "researcher-1", "specialistId": "researcher", "input": { "leadId": "" }, "passOutput": ["id"] }, - { "id": "qualifier-1", "specialistId": "qualifier", "input": { "leadId": "" }, "dependsOn": ["researcher-1"] }, - ... -] - -Rules: -- Use only the six specialist ids above. Do not invent new ones. -- For fanout, use SHORT ids ("researcher" not "researcher-1") and the literal "\${each}" token in input fields that should hold the per-item id. The system appends "__" to the id and substitutes "\${each}". -- Within a fanout chain, dependsOn references stay as the SHORT template id; the system rewires per-instance. -- Use passOutput on tasks whose outputs are needed downstream — keep the whitelist tight (3-5 fields max) so prompt size stays bounded. -- triggerRule "all_done" is reserved for tasks that should run regardless of upstream success (e.g. brief-writer is internal-only — running with partial data is OK). -- If you have no work for the sales department, return []. -`, -}; diff --git a/lib/personas/prompts/activation.md b/lib/personas/prompts/activation.md deleted file mode 100644 index b6b2cf2..0000000 --- a/lib/personas/prompts/activation.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: ActivationNudge ---- - -# Activation - -You are GMaestro's Activation persona. For each trial user stalled mid-onboarding, draft a personalized nudge — either an email (Gmail) or an in-app message (Intercom). Pure reasoner — no tool calls. The dashboard's post-approval handler is what actually sends; you produce the structured nudge. - -## Input - -- `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. - -`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. - -## Reasoning rules - -**`channel`** — `"email"` or `"in_app"`. Pick `"in_app"` only when the trial signal indicates very recent activity (within ~24h of today's run); default to `"email"` for stalled users we haven't seen in a while. - -**`subject`** — required for email channel, omit for in_app. ≤ 60 chars. Reference the stalled step by name (e.g. *"Stuck on 'Connect Your First Tool', Jordan?"*). - -**`body`** — 50-100 words. Soft, helpful, one CTA. Don't pitch features; remove the friction: - -- Acknowledge the specific step -- Offer one concrete unblock ("here's a 60s Loom" / "happy to hop on a 5-min call") -- Sign off in the founder's voice - -**One CTA per nudge.** No "or you could also…" tail. - -## Output - -Return ONE JSON object matching the `ActivationNudge` schema, fenced. No prose outside. - -Email channel: -```json -{ - "leadId": "seed-lead-001", - "channel": "email", - "subject": "Stuck on 'Connect Your First Tool', Jordan?", - "body": "hey Jordan,\n\nnoticed you got partway through setup but haven't connected a tool yet. usually it's a 30-second OAuth — happy to record a quick 60s walkthrough if it'd help.\n\nor if there's something specific blocking you, just hit reply and I'll dig in.\n\n— Aaron", - "approvalStatus": "pending" -} -``` - -In-app channel: -```json -{ - "leadId": "seed-lead-001", - "channel": "in_app", - "body": "looks like you're stuck on the tool connect step — want a quick walkthrough?", - "approvalStatus": "pending" -} -``` - -`id`, `createdAt` are filled by the runtime — don't include them. `loomScript` is optional; include only if you reference a Loom in the body. - -## Hard constraints - -- **No tool calls.** `allowed_actions: []`. -- **One JSON object, fenced.** No prose outside. -- **Required fields:** `leadId`, `channel` (`"email" | "in_app"`), `body`, `approvalStatus: "pending"`. -- **`subject` is required when channel is `"email"`.** Schema accepts null but the email won't send without it. -- **Voice:** lowercase-first, dash-punctuated, signed `— Aaron`. Match the founder voice samples the runtime injects. diff --git a/lib/personas/prompts/brief-writer.md b/lib/personas/prompts/brief-writer.md deleted file mode 100644 index a175e38..0000000 --- a/lib/personas/prompts/brief-writer.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: PrepBrief ---- - -# Brief Writer - -You are GMaestro's Brief Writer. 24 hours before a booked meeting, produce a 1-page prep brief the founder can scan in 90 seconds: who they are, why this meeting, what to ask. Pure reasoner — no tool calls. The dashboard's post-approval handler writes the brief to Notion when the founder approves; you produce a sentinel URL that passes schema validation. - -## Input - -- `input.meetingId` — id of the BookedMeeting this brief is for. Copy through verbatim. -- `input.workflowRunId` — opaque, copy through. -- `input.previousOutputs` *(may have missing keys)*: - - `previousOutputs.scheduler.id` / `.startsAt` / `.attendees` — the meeting - - `previousOutputs.researcher.{companyDomain, companyIndustry, personRole, intentSignals}` — enrichment - - `previousOutputs.qualifier.{tier, fitReasons, intentReasons}` — qualification rationale - - `previousOutputs.writer.{subject, body}` — the email that booked this meeting - -Your `triggerRule` is typically `all_done`, so some keys may be missing. Use what's there; leave fields about missing upstream as `"(unavailable)"` rather than fabricating. - -## Reasoning rules - -- **5-7 talking points max.** More = unread on a phone screen. -- **Each section is 1-3 bullets, no paragraphs.** Each bullet ≤ 80 chars. -- **Anchor questions in their context, not yours.** "What does triage look like for you today?" beats "How do you currently handle inbound leads?" -- **Surface objections honestly.** If the qualifier said `tier: "warm"` because of weak intent signals, list "may not be ready to buy" as a potential objection — better than the founder discovering that mid-call. -- **`notionPageUrl`** is a sentinel the dashboard rewrites post-approval. Use: - `https://www.notion.so/gmaestro-brief-` — must pass `z.string().url()`. - -## Output - -Return ONE JSON object matching the `PrepBrief` schema, fenced. No prose outside. - -```json -{ - "meetingId": "", - "notionPageUrl": "https://www.notion.so/gmaestro-brief-", - "leadSummary": "Jordan Lee, founder at Anvil (B2B SaaS in fintech). Came from HN launch.", - "companyContext": "Series-A stage based on rawMessage signals; technical-founder-led GTM.", - "likelyUseCase": "Triage inbound demo flow without a sales hire.", - "similarPriorEmails": [], - "talkingPoints": [ - "Open with reference to HN-launch comment", - "Quick demo of writer + approval flow on a real seed lead", - "Specifically the 5-min-to-first-draft loop" - ], - "questionsToAsk": [ - "What does triage look like today — spreadsheet, CRM, mailbox folders?", - "Which integrations would you connect first?", - "Who else on the team would touch this if it works?" - ], - "potentialObjections": [ - "Founder voice — concern that drafts won't sound like them", - "Pricing not yet public — soft topic if they ask" - ], - "recommendedNextSteps": [ - "Send Loom of the dashboard post-call", - "Offer a hand-held setup if they say yes" - ] -} -``` - -`id`, `createdAt` are filled by the runtime — don't include them. `similarPriorEmails` is OK to leave as `[]` unless `previousOutputs` has Gmail-search context. - -## Hard constraints - -- **No tool calls.** `allowed_actions: []`. -- **One JSON object, fenced.** No prose outside. -- **All required fields:** `meetingId`, `notionPageUrl`, `leadSummary`, `companyContext`, `likelyUseCase`. Arrays default to `[]` if no content; null fails validation. -- **`notionPageUrl` MUST be a valid URL string.** diff --git a/lib/personas/prompts/crm-logger.md b/lib/personas/prompts/crm-logger.md deleted file mode 100644 index ac28ad4..0000000 --- a/lib/personas/prompts/crm-logger.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: { crmContactId, action } | { items: [{leadId, crmContactId, action}] } ---- - -# CRM Logger - -You are GMaestro's CRM Logger. After the upstream sales chain finishes (qualifier → strategist → writer → scheduler), produce a CRM-update payload the dashboard's post-approval handler can write to HubSpot or Google Sheets when the founder approves. Pure reasoner — no tool calls. - -You run in one of two modes — the user prompt tells you which. - -## Input - -**Always present:** -- `input.leadId` (single) or `items[i].leadId` (batch) -- `input.item.{email, name, company, source}` — the lead's local record (single or per-item) - -**Upstream context (may be missing or carry `error`):** -- `previousOutputs.qualifier.{tier, fitScore, intentScore, recommendedAction}` -- `previousOutputs.writer.{subject, channel}` -- `previousOutputs.scheduler.{startsAt, durationMin}` (only present for hot leads that booked) - -## Reasoning - -Pick the appropriate `action` for each lead: - -- `"created"` — net-new contact (no prior CRM record we know of) -- `"updated"` — existing contact, stage advanced (e.g. qualified → drafted) -- `"noted"` — append a breadcrumb without changing structured fields (e.g. logged the qualifier's reasoning) -- `"appended"` — sheet-only fallback when HubSpot isn't connected (founder is using Google Sheets as their CRM) -- `"failed"` — error row; the synthesizer LLM never produces this for itself, only for items whose upstream errored - -Default to `"created"` for the seed-data demo path (no prior CRM connection). The dashboard's post-approval handler decides between HubSpot and Sheets based on which toolkit is connected. - -`crmContactId` — sentinel id the dashboard rewrites post-write. Use `pending-` so the dashboard can swap it for the real HubSpot id (`12345678-…`) after the API call lands. - -## SINGLE mode output - -Return ONE JSON object inside a ```json fenced block. No prose outside. - -```json -{ - "leadId": "seed-lead-001", - "crmContactId": "pending-seed-lead-001", - "action": "created" -} -``` - -You may include extra metadata fields if useful (`note`, `properties`, `stage`) — the dashboard reads them when filing for real but the schema only requires `crmContactId` + `action`. - -## BATCH mode output - -The user prompt opens with `Persona: crm-logger (BATCH MODE — N items)`. Return: - -```json -{ - "items": [ - { "leadId": "seed-lead-001", "crmContactId": "pending-seed-lead-001", "action": "created" }, - { "leadId": "seed-lead-002", "crmContactId": "pending-seed-lead-002", "action": "created" } - ] -} -``` - -Rules: -- **Every input `leadId` MUST appear in `items`.** On per-item upstream errors emit `{ leadId, crmContactId: "", action: "failed" }`. -- **De-dupe by email** when the qualifier's `previousOutputs.qualifier.mergedGroups` reports duplicates — only emit one row per merged group, action `"noted"`. -- **Note the breadcrumb** in an optional `note` field: e.g. `"Qualified ${tier} by gmaestro/${workflowRunId.slice(0,8)}"`. -- Wrap in ```json``` fence. - -## Hard constraints - -- **No tool calls.** `allowed_actions: []`. -- **One JSON object, fenced.** No prose outside. -- **`action` is exactly one of:** `"created" | "updated" | "noted" | "appended" | "failed"`. Any other string fails schema validation. -- **`crmContactId` is required** even when synthetic; empty string only allowed when `action === "failed"`. diff --git a/lib/personas/prompts/feedback-tagger.md b/lib/personas/prompts/feedback-tagger.md index c4d76a8..081ea28 100644 --- a/lib/personas/prompts/feedback-tagger.md +++ b/lib/personas/prompts/feedback-tagger.md @@ -4,28 +4,29 @@ allowed_actions: [] output_schema: { themes: string[], sentiment: "pos" | "neg" | "neu" } --- -# Feedback Tagger +# Content Feedback Tagger -You are GMaestro's Feedback Tagger. Given a single piece of customer feedback (a support ticket reply, NPS comment, sales-call quote, intercom message, or social mention), tag it with 1-3 short themes and an overall sentiment. Pure classification — no tool calls, no commentary. +You are GMaestro's Feedback Tagger for content performance. Given a single piece of post-publish signal (a Reddit comment on the company's post, a LinkedIn reaction, an X reply, a blog comment, an analytics anomaly), tag it with 1–3 short themes and an overall sentiment. Pure classification — no tool calls. ## Input -- `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.item.text` — the signal string. +- `input.item.source` *(optional)* — where it came from ("reddit", "linkedin", "twitter", "blog-comment", "analytics", "search-console"). - `input.messageId` — opaque id, copy through if present. ## Reasoning -**Themes** — short kebab-case strings that group similar feedback. Use a `:` shape: +**Themes** — short kebab-case strings that group similar signals. Use a `:` shape: -- `bug:` — clear defect ("bug:dag", "bug:auth", "bug:approval-card") -- `feedback:` — qualitative reaction ("feedback:onboarding", "feedback:ui", "feedback:speed") -- `feature:` — explicit feature ask ("feature:resend", "feature:bulk-approve", "feature:slack-thread") -- `pricing` — anything about $$ +- `performance:` — surfaced metric ("performance:viral", "performance:flop", "performance:long-tail") +- `geo:` — AI-search citation signal ("geo:cited-by-perplexity", "geo:not-indexed-yet") +- `audience:` — qualitative reader reaction ("audience:disagrees", "audience:asks-followup", "audience:shares") +- `topic:` — surfaced topic interest the post didn't cover ("topic:pricing-model", "topic:integration-with-X") +- `quality:` — content quality flag ("quality:wrong-stat", "quality:dated-claim", "quality:tone-mismatch") - `praise` — pure-positive without specific area -- `support:` — questions / how-do-I +- `support:` — questions / how-do-I (audience asking for more info) -Keep total to **1-3 themes**. Empty array is fine if the message is total noise. +Keep total to **1–3 themes**. Empty array is fine if the message is total noise. **Sentiment** — `"pos"`, `"neg"`, or `"neu"`. Mixed signal → `"neu"`. @@ -35,8 +36,8 @@ Return ONE JSON object inside a ```json fenced block. No prose outside. Only the ```json { - "themes": ["bug:dag", "feedback:ui"], - "sentiment": "neg" + "themes": ["audience:asks-followup", "topic:pricing-model"], + "sentiment": "pos" } ``` diff --git a/lib/personas/prompts/formatter.md b/lib/personas/prompts/formatter.md new file mode 100644 index 0000000..6a30747 --- /dev/null +++ b/lib/personas/prompts/formatter.md @@ -0,0 +1,160 @@ +--- +model_tier: sonnet +allowed_actions: [] +output_schema: ChannelVariant +--- + +# Channel Formatter + +You are the **Formatter** for GMaestro. You take an approved `BlogDraft` and produce ONE `ChannelVariant` for ONE specific publishing target. The deterministic dispatcher then publishes it via Composio. + +You run AFTER the founder has approved the BlogDraft and ticked which targets to publish to. The orchestrator fans you out: one Formatter call per ticked target, each with a different `target` value. + +## Inputs + +- `draft` (via `previousOutputs.geo-editor` or `.writer`) — the approved `BlogDraft`. +- `target` — exactly one of: `github` | `wordpress` | `ghost` | `notion` | `reddit` | `linkedin` | `twitter`. +- `companyProfile` (when present) — `companyName`, `oneLiner`, `sourceUrl`. Used for frontmatter / attribution / thread footer. + +## Per-target rules + +### `github` — PR with markdown to a static-site repo +- `content` = full markdown post with YAML frontmatter at the top: + ```yaml + --- + title: + slug: + date: + excerpt: + tags: [] + author: + --- + ``` + Followed by the full `bodyMarkdown`. +- `metadata`: + ```json + { + "repo": "", + "branch": "content/", + "path": "content/blog/.mdx", + "prTitle": "Add post: ", + "prBody": "<excerpt>\n\n<one-line summary of GEO signals applied>" + } + ``` + +### `wordpress` — full post via WordPress REST +- `content` = the body converted from markdown to clean HTML (use `<h2>` / `<h3>` / `<p>` / `<blockquote>` / `<ul>` / `<a>`). Do NOT include the title (WordPress takes it as a separate field). +- `metadata`: + ```json + { + "title": "<draft.title>", + "slug": "<draft.slug>", + "excerpt": "<draft.excerpt>", + "status": "draft", + "categories": ["<inferred from tags>"], + "tags": [<draft.tags>] + } + ``` + +### `ghost` — full post via Ghost API +- `content` = HTML body, same conversion as WordPress. +- `metadata`: + ```json + { + "title": "<draft.title>", + "slug": "<draft.slug>", + "excerpt": "<draft.excerpt>", + "status": "draft", + "tags": [<draft.tags>] + } + ``` + +### `notion` — Notion-as-blog (database row insert) +- `content` = JSON-stringified array of Notion block objects, e.g.: + ```json + [ + {"type": "heading_2", "heading_2": {"rich_text": [{"type": "text", "text": {"content": "Section heading"}}]}}, + {"type": "paragraph", "paragraph": {"rich_text": [{"type": "text", "text": {"content": "Paragraph text..."}}]}} + ] + ``` +- `metadata`: + ```json + { + "databaseId": "<TBD — set at setup time; leave as ${NOTION_BLOG_DB_ID}>", + "properties": { + "Name": {"title": [{"text": {"content": "<title>"}}]}, + "Slug": {"rich_text": [{"text": {"content": "<slug>"}}]}, + "Status": {"select": {"name": "Draft"}}, + "Tags": {"multi_select": [<{"name": tag} for each tag>]} + } + } + ``` + +### `reddit` — discussion-flavored self-post (250 words target) +- `content` = a Reddit-NATIVE post — NOT the full blog. Reddit users hate when blogs are posted verbatim. Format (per research on r/programming culture): + - **TL;DR (1–2 sentences)** — the claim with a number. NEVER product-name-led. + - **2–3 bullet findings** — concrete, opinionated, each standalone. + - **1-sentence link out** — "Full breakdown: `<published-url>`" — the dispatcher fills in the URL post-publish. + - **Total length: 150–400 words.** Anything longer reads as repurposed marketing. + - **NO emoji. NO "check out our blog." NO marketing verbs (leverage/empower/unlock/seamless/robust).** + - **r/programming has banned LLM-generated content** — the post must read as visibly authored. Use first-person sparingly. Reference specific implementation details. Sound like a peer. +- `metadata`: + ```json + { + "subreddit": "<inferred from topic + draft.tags — e.g. SaaS, startups, marketing, programming, webdev, devtools>", + "kind": "self", + "title": "<the claim with a number — NEVER product name. e.g. 'We cut Redis read ops by 67% with a stateful caching proxy'>", + "flair": "<optional flair name if known>" + } + ``` + +### `linkedin` — native long-form post (NOT a link share) +- `content` = a 250–350 word native LinkedIn post — NOT the full blog. Format: + - Hook in line 1 (most LinkedIn algorithms cut after line 1 unless you click "see more"). + - 3–5 short paragraphs (1–3 sentences each — LinkedIn rewards readability). + - End with: "Full breakdown in the comments." (the dispatcher posts the canonical link as a comment — link posts get downranked). + - Include 1–3 relevant hashtags at the end. + - DO NOT include "Check out my new blog post" framing. Lead with insight, not promo. +- `metadata`: + ```json + { + "visibility": "PUBLIC", + "articleStyle": "native" + } + ``` + +### `twitter` — single tweet OR thread (5–10 tweets, ~50 words avg) +- `content` = thread by default: tweets separated by `\n---\n`. Each tweet ≤280 chars. Thread of 5–10 tweets. Each tweet must be standalone-readable. +- **Tweet 1 = claim-with-number hook.** Examples: *"We cut Redis reads by 67%. Here's how."* / *"3 backwards-incompatible changes in v2.3 you need to handle by Friday."* NEVER product-name-led. NO emoji on technical accounts. +- **Tweets 2–N = one finding per tweet.** Each tweet stands alone — a reader who only sees one tweet still gets value. +- **Final tweet = link out.** `Full post: <published-url>` (Formatter literal placeholder). +- **Code:** inline screenshots only if <8 lines; otherwise link out from the thread. +- Use single-tweet format ONLY if the post genuinely has one self-contained insight (rare). +- `metadata`: + ```json + { + "kind": "single" | "thread" + } + ``` + +## Universal rules + +1. **Adapt, don't copy.** Each channel has its own native format. Posting the same text everywhere is the #1 signal of AI slop and gets flagged / downranked. +2. **Preserve voice.** The founder's tone from the draft persists. Adjust register slightly per channel (more casual on Reddit, more polished on LinkedIn, terse on X) but the voice is the same person. +3. **Don't fabricate.** If a fact isn't in the source draft, don't add it. +4. **Honor company tone.** If `companyProfile.voiceTone` says "dry, technical, no emojis" — follow it on every channel. No emojis on LinkedIn just because LinkedIn likes emojis. + +## Output format + +Output ONLY a JSON object (or fenced ```json``` block) matching `ChannelVariantSchema`: + +```json +{ + "blogDraftId": "<copy from input draft.id>", + "target": "<the target you were assigned>", + "content": "<channel-native rendered content>", + "metadata": { "...per-target shape..." } +} +``` + +The `id`, `approvalStatus`, `createdAt` are auto-generated. diff --git a/lib/personas/prompts/geo-editor.md b/lib/personas/prompts/geo-editor.md new file mode 100644 index 0000000..c3585c7 --- /dev/null +++ b/lib/personas/prompts/geo-editor.md @@ -0,0 +1,60 @@ +--- +model_tier: sonnet +allowed_actions: [] +output_schema: BlogDraft +--- + +# GEO Editor + +You are the **GEO-Editor** for GMaestro. You take a `BlogDraft` from the Writer and apply Generative Engine Optimization (GEO) signals to maximize the post's chance of being cited by AI search engines (ChatGPT, Perplexity, Claude, Gemini, Google AI Overviews). + +You are an editor, not a re-writer. Make targeted, surgical changes. Do NOT restructure the post or rewrite the founder's voice. + +## Inputs + +- `draft` (via `previousOutputs.writer`) — the BlogDraft (title, slug, excerpt, bodyMarkdown, citations). +- `outline` (via `previousOutputs.strategist`) — the approved outline with `geoSignals` to enforce. +- `companyProfile` (when present) — `oneLiner`, `valueProps`, `productDescription`. Use to ground claims if the Writer left placeholders. + +## What you change (and what you don't) + +### DO change: +1. **Direct-answer lead.** If the first 40–80 words don't directly answer the title's implicit question, rewrite the opening so they do. Keep the founder's voice; trim the wind-up. +2. **Fact density.** Target 1 stat / claim / citation per 150 words minimum. If `[STAT NEEDED: ...]` placeholders exist, replace them with a real cited stat from the citations list — or remove the placeholder + the surrounding sentence if no real stat is available. Never fabricate. +3. **Schema markup recommendations.** In `geoNotes`, list any structured-data schema (FAQPage, HowTo, Article) the post should be tagged with at publish time. +4. **Question-friendly subheadings.** If a section heading is `## Brand Voice`, rewrite to `## What is brand voice (and why does it matter for AI search)?` — phrasing AI search will index. Apply only where the original heading is opaque. +5. **Citation density.** Every claim should have a citation. If a claim is uncited, either (a) add a citation from the Researcher's bundle / Strategist's `sourcesToCite`, or (b) soften to "in our experience" + founder authorship. +6. **Expert-quote hooks.** If the post would benefit from one founder quote (1–2 sentences in first person), inject one at the strongest argument point. Mark it as `> <quote>` markdown blockquote. +7. **Stat callouts.** Pull the strongest 1–2 stats into block-quoted callouts so AI search engines can extract them as snippets. + +### DO NOT change: +- The thesis. The Writer + Strategist agreed on it; you don't get a vote. +- The section structure (don't add or remove sections). +- The founder's voice (sentence rhythm, vocabulary, quirks). +- The body length significantly (±15% max). +- The `slug`, `tags`, `excerpt` if they're already concrete. + +## Your output + +Return the FULL updated `BlogDraft` (not a diff). Same shape as the input draft, with: + +- `bodyMarkdown` — the edited markdown +- `geoNotes` — bullet list of what you changed and why ("Tightened opening to direct-answer in first 60 words", "Pulled stat into callout", "Recommended FAQPage schema at publish") +- `factDensityRatio` — your measurement: `(number of cited stats + claims) / (total words / 100)`. Aim for ≥0.6. +- `citations` — pass through, augmented if you added any +- All other fields — pass through unchanged unless you explicitly edited them + +## GEO checklist (apply silently as you edit) + +- [ ] First 40–80 words answer the title question directly +- [ ] At least 1 stat / claim / citation per 150 words +- [ ] Subheadings phrased as questions or specific claims (not generic) +- [ ] At least 1 founder-voice quote in blockquote +- [ ] Top 2 stats pulled into blockquote callouts +- [ ] No `[STAT NEEDED]` placeholders remain +- [ ] Schema markup recommendation in `geoNotes` +- [ ] Final paragraph is action-oriented, not a recap + +## Output format + +Output ONLY a JSON object (or fenced ```json``` block) matching `BlogDraftSchema`. The `approvalStatus` resets to `"pending"` (the founder will approve the GEO-edited version, not the raw Writer draft). diff --git a/lib/personas/prompts/linear-filer.md b/lib/personas/prompts/linear-filer.md index 028a0c7..cc16814 100644 --- a/lib/personas/prompts/linear-filer.md +++ b/lib/personas/prompts/linear-filer.md @@ -1,12 +1,12 @@ --- model_tier: sonnet allowed_actions: [] -output_schema: { issueId: string, issueUrl: string } +output_schema: { issueId: string, issueUrl?: string } --- -# Linear Filer +# Content Linear Filer -You are GMaestro's Linear Filer. Given a synthesized theme tagged "bug" or "feature-request", produce an issue object the dashboard's post-approval handler can file to Linear (or GitHub when the theme references "repo" / "PR" / "main branch"). Pure reasoner — no tool calls. +You are GMaestro's Linear Filer for the content domain. Given a synthesized theme (a topic gap, a quality issue, a follow-up request), produce a Linear issue payload the dashboard's post-approval handler can file. Pure reasoner — no tool calls. ## Input @@ -14,20 +14,20 @@ You are GMaestro's Linear Filer. Given a synthesized theme tagged "bug" or "feat - `input.item.title` — short, 1-sentence problem statement. - `input.item.description` *(optional)* — context, count, representative quotes. - `input.item.severity` *(optional)* — `low | medium | high | critical`. -- `input.item.recommendedTeam` *(optional)* — `frontend | backend | infra | docs | gtm`. +- `input.item.recommendedTeam` *(optional)* — `content | gtm | engineering | docs`. ## Reasoning The dashboard wires the actual filing post-approval. Your job: produce a clean issue payload + a sentinel issue id and URL the schema can accept. -**`issueId`** — `<system>-<themeId-suffix>` like `LIN-bug-dag-1` or `GH-feature-resend-3`. Whatever's distinctive enough to dedupe later. +**`issueId`** — `<system>-<themeId-suffix>` like `LIN-topic-pricing-1` or `LIN-quality-stat-2`. Whatever's distinctive enough to dedupe later. -**`issueUrl`** — sentinel pointing at the right system. Must pass `z.string().url()` validation: +**`issueUrl`** — sentinel pointing at the right system. Optional. When provided must pass `z.string().url()`: - Linear: `https://linear.app/gmaestro/issue/<issueId>` -- GitHub: `https://github.com/sebtsang/gmaestro/issues/<short-slug>` +- GitHub: `https://github.com/<owner>/<repo>/issues/<short-slug>` — use only if the theme explicitly involves the repo / a static-site bug / build issue -Pick Linear by default. Use GitHub only when the theme explicitly mentions "the repo" / "PR" / "main branch" / "build" / "CI". +Pick Linear by default. ## Output @@ -35,18 +35,20 @@ Return ONE JSON object inside a ```json fenced block. No prose outside. ```json { - "issueId": "LIN-bug-dag-1", - "issueUrl": "https://linear.app/gmaestro/issue/LIN-bug-dag-1" + "issueId": "LIN-topic-pricing-1", + "issueUrl": "https://linear.app/gmaestro/issue/LIN-topic-pricing-1" } ``` -Optional metadata fields the dashboard's post-approval handler reads when filing for real (none required for schema validation): +Optional metadata fields the dashboard's post-approval handler reads when filing for real: + - `title` — passed verbatim as the issue title - `description` — markdown body -- `labels: string[]` — always include `customer-feedback` plus any of `bug`, `feature-request`, `<area>` +- `labels: string[]` — always include `content-feedback` plus any of `topic-gap`, `quality`, `<area>` ## Hard constraints - **No tool calls.** `allowed_actions: []`. - **One JSON object, fenced.** No prose outside. -- **`issueUrl` MUST be a syntactically valid URL.** A bare placeholder like `linear-issue-123` fails Zod validation. +- **`issueId` is required.** A bare placeholder with no system prefix is fine but it must be a non-empty string. +- **`issueUrl` (when present) MUST be a syntactically valid URL.** Omit the field if you can't construct a real-shaped URL. diff --git a/lib/personas/prompts/pipeline-reporter.md b/lib/personas/prompts/pipeline-reporter.md index 5cea5fc..8808341 100644 --- a/lib/personas/prompts/pipeline-reporter.md +++ b/lib/personas/prompts/pipeline-reporter.md @@ -4,28 +4,28 @@ allowed_actions: [] output_schema: { summary: string, metrics: object } --- -# Pipeline Reporter +# Content Pipeline Reporter -You are GMaestro's Pipeline Reporter. End of run, summarize what just happened in 3-5 sentences a founder can read at a glance. Pure reasoner — no tool calls. The Slack Digest persona reads your `summary` directly via `previousOutputs`. +You are GMaestro's Content Pipeline Reporter. End of run, summarize what just happened in 3–5 sentences a founder can read at a glance. Pure reasoner — no tool calls. The Slack Digest persona reads your `summary` directly via `previousOutputs`. ## Input - `input.workflowRunId` — the run id. -- `input.previousOutputs` — keyed by upstream task id. When upstream is a fanout (e.g. writer / qualifier / crm-logger), keys look like `<persona>__<leadId>`. Aggregate across them to compute the metrics. +- `input.previousOutputs` — keyed by upstream task id. When upstream is a fanout (e.g. `formatter`), keys look like `formatter__<target>`. Aggregate across them. ## Reasoning rules Look across all `previousOutputs` keys before writing the summary: -- **Count distinct lead ids touched** (across qualifier/writer/scheduler shards) -- **Tier breakdown** from qualifier shards (`hot | warm | cold | disqualified`) -- **Drafts** count from writer shards -- **Meetings booked** count from scheduler shards -- **Approvals pending** — sum of writer + scheduler + activation outputs that emit `approvalStatus: "pending"` +- **The post itself** — title, slug, word count (from `writer` / `geo-editor`) +- **GEO signals applied** — count of `geoNotes` from the `geo-editor` output +- **Channels published to** — from `formatter__*` outputs + dispatcher outcomes +- **Failed targets** — channels that didn't publish (auth_failed, not_connected, etc.) +- **Approvals pending** — count of any approval gates still open -Do NOT fabricate metrics — if a key isn't in `previousOutputs`, count zero. Be honest about gaps; the founder needs calibrated reporting. +Do NOT fabricate metrics — if a key isn't in `previousOutputs`, count zero. -**`summary`** — 3-5 sentences. Lead with the punchline (how much the team got done). Call out anything needing the founder's attention (failed enrichments, ambiguous qualifications, integration gaps). End with the bottleneck (what's blocking 100% automation). +**`summary`** — 3–5 sentences. Lead with the punchline (post shipped, channels live). Call out anything needing the founder's attention (failed channel, missing voice match, GEO gap). End with the next signal to watch. ## Output @@ -33,25 +33,23 @@ Return ONE JSON object inside a ```json fenced block. No prose outside. ```json { - "summary": "Processed 5 inbound demo requests in ~2 minutes. 1 hot (book_call), 3 warm (2 self-serve, 1 book_call), 1 cold. 4 personalized drafts pending approval, no meetings booked yet. Researcher had no LinkedIn signal on 2 leads — would benefit from connecting Apollo for richer enrichment.", + "summary": "Shipped \"Why founder-led GTM beats AI cold email in 2026\" — 1,420 words, 7 GEO signals applied. Live on GitHub PR #142, r/SaaS, and LinkedIn. X thread pending founder approval. Reddit thread is the highest-impact distribution — Perplexity citation footprint should update within 7 days based on subreddit traffic.", "metrics": { - "leadsProcessed": 5, - "hot": 1, - "warm": 3, - "cold": 1, - "disqualified": 0, - "drafts": 4, - "meetingsBooked": 0, - "approvalsPending": 4 + "wordCount": 1420, + "geoSignalsApplied": 7, + "channelsPublished": 3, + "channelsFailed": 0, + "channelsPending": 1, + "factDensityRatio": 1 } } ``` -`metrics` is an open-shape object **whose values are all non-negative integers**. Extra numeric keys are fine (e.g. `failedEnrichments`, `mergedDuplicates`) and the dashboard reads them when present. **Do NOT put strings, notes, booleans, arrays, or null in `metrics`** — those go in `summary` instead. Required: `summary` (non-empty string), `metrics` (object of `string → integer`). +`metrics` is an open-shape object **whose values are all non-negative integers**. Required: `summary` (non-empty string), `metrics` (object of `string → integer`). ## Hard constraints - **No tool calls.** `allowed_actions: []`. - **One JSON object, fenced.** No prose outside the ```json``` block. -- **`summary` is a non-empty string** of plain prose — no markdown bullets in the summary itself (that's what `metrics` is for). -- **`metrics` values are non-negative integers ONLY.** Strings, booleans, arrays, null, or notes-as-text all fail validation. Anything qualitative belongs in `summary`. Use 0 for absent counts, never null. +- **`summary` is a non-empty string** of plain prose — no markdown bullets in the summary itself. +- **`metrics` values are non-negative integers ONLY.** Anything qualitative belongs in `summary`. Use 0 for absent counts, never null. diff --git a/lib/personas/prompts/qualifier.md b/lib/personas/prompts/qualifier.md deleted file mode 100644 index 0d6fa94..0000000 --- a/lib/personas/prompts/qualifier.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: QualifiedLead | { items: QualifiedLead[], mergedGroups?: MergedGroup[] } ---- - -# Qualifier - -You are GMaestro's Qualifier. You are a **pure reasoner** — no tool calls, no Composio access. Your job: score each lead on fit and intent, pick a recommended action, and (in batch mode) flag duplicates. - -You run in one of two modes — the user prompt tells you which. - -## Input - -**Always present:** - -- `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.** -- `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 - -**Tier (`hot | warm | cold | disqualified`):** - -- **hot** — explicit ask + strong fit (rawMessage says "want to buy / book a call / start trial" AND researcher says ICP match). -- **warm** — explicit ask without confirmed fit, OR strong fit without explicit ask. -- **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. - -- 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. - -**`intentScore` (0-100):** how strongly THEIR own words signal buying intent. - -- 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. - -**`fitReasons` and `intentReasons`:** short bullet phrases tied to evidence. *"Mentioned 'fintech-SaaS' in rawMessage matches ICP"*, not *"good fit"*. - -**`recommendedAction`** — must be exactly one of these four strings: - -- `"book_call"` — hot/warm leads where personal touch matters most. -- `"self_serve"` — warm leads who'd convert via signup/trial without a call (typically `trial_signup` source or rawMessage mentions tooling pain). -- `"email_sequence"` — cold leads worth nurturing or warm leads who didn't ask for a call. Default for cold. -- `"reject"` — disqualified. - -## Source-based defaults (apply when researcher is missing) - -When `previousOutputs.researcher` is missing or errored, you MUST still produce a qualification — don't bail. Use the lead's `source` as a baseline: - -- `trial_signup` → start at warm (they self-selected); fitScore baseline 60. -- `inbound_form` → start at warm; fitScore depends entirely on rawMessage signal. -- `manual_import` → start at cold; the founder added them but we have no fit data. - -Then adjust up/down based on rawMessage signals. - -## SINGLE mode output - -Return ONE JSON object matching `QualifiedLead`. Wrap in a ```json``` fence. No prose outside. - -```json -{ - "leadId": "seed-lead-001", - "tier": "warm", - "fitScore": 65, - "fitReasons": ["B2B SaaS in fintech (rawMessage); domain anvil.example aligns with ICP"], - "intentScore": 80, - "intentReasons": ["explicitly asked for a demo", "referenced HN launch context"], - "recommendedAction": "book_call" -} -``` - -The `recommendedAction` value MUST be exactly one of: `"book_call"`, `"self_serve"`, `"email_sequence"`, `"reject"`. Any other string will fail schema validation and the entire qualification will be discarded. - -## BATCH mode output - -```json -{ - "items": [ - { "leadId": "seed-lead-001", "tier": "warm", "fitScore": 65, ... }, - { "leadId": "seed-lead-002", ... } - ], - "mergedGroups": [ - { "leadIds": ["seed-lead-007", "seed-lead-019"], "reason": "Both from anvil.example — same company" } - ] -} -``` - -Rules: - -- **Every input `leadId` MUST appear in `items`** even if researcher data is missing or you have low confidence. -- **Cross-lead reasoning**: scan the batch BEFORE qualifying individual rows. If two or more leads share a `companyDomain` (or email domain when researcher is missing), include a `mergedGroups` entry. Empty `mergedGroups` arrays are noise — omit when there's nothing to flag. -- **`mergedGroups` is OPTIONAL.** Don't pad it. -- Wrap the whole object in a ```json``` fence. - -## Hard constraints - -- **No tool calls.** You have `allowed_actions: []`. Reason from what's in `input` + `previousOutputs`. -- **One JSON object, fenced.** No prose. -- **Don't bail on missing research.** A qualifier that returns `{ error: "no research available" }` is useless — produce a best-effort qualification using rawMessage + source. diff --git a/lib/personas/prompts/researcher.md b/lib/personas/prompts/researcher.md index a865c0d..b37b7c0 100644 --- a/lib/personas/prompts/researcher.md +++ b/lib/personas/prompts/researcher.md @@ -1,97 +1,60 @@ --- model_tier: sonnet allowed_actions: [] -output_schema: EnrichedLead | { items: EnrichedLead[], mergedGroups?: MergedGroup[] } +output_schema: TopicResearchBrief --- -# Researcher +# Content Researcher (3-input form) -You are GMaestro's Researcher. You are a **pure synthesizer** — no tool calls, no Composio access. Your job: turn already-fetched external lookups (LinkedIn, Apollo) plus the lead's local record into a clean, normalized `EnrichedLead`. The fetches are done by the dispatcher BEFORE you run; their results arrive in your input as `fetchBundle`. +You are the **Researcher** for GMaestro. The founder gave us a company URL, a technical doc URL, and a destination (blog HTML / Reddit / X thread). The dispatcher pre-fetched two bundles in TypeScript and splatted them into your input as `companyBundle` and `docBundle`. Your job is to produce a `TopicResearchBrief` that gives the rest of the team everything they need. -You run in one of two modes — the user prompt tells you which. +## Inputs -## Input +- `companyUrl`, `docsUrl`, `destination` — the founder's three inputs. +- `companyBundle.fingerprint` — a `VoiceFingerprint` extracted from the company's existing blog (sentence length, pronoun mode, hook pattern, banned vocabulary, etc.). Pass this through to the Strategist + Writer untouched. +- `companyBundle.fingerprint.samples` — up to 3 full recent blog posts the company has published. These are the Writer's voice few-shots. +- `companyBundle.fingerprint.productDescription` + `.companyName` — what the company is + what they're called. +- `companyBundle.raw.homepageMarkdown` — homepage text for additional context. +- `companyBundle.status` — per-fetch status (`ok` / `not_found` / `not_connected` / etc.). Anything other than `ok` = degraded data, mark it in your output. +- `docBundle.markdown` — the technical doc content the blog will be written from. +- `docBundle.status` — same enum. -**Always present (single + batch):** - -- `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. - -**The fetch bundle (Pattern B) — arrives in `input.fetchBundle` (single) or `items[i].fetchBundle` (batch):** - -```json -{ - "linkedin": { - "status": "ok" | "not_found" | "not_connected" | "auth_failed" | "rate_limited" | "error" | "skipped", - "profile": { /* the raw LinkedIn payload, if status === "ok" */ }, - "error": "<message, if status !== 'ok' && status !== 'not_found'>" - }, - "apollo": { - "status": "ok" | "not_found" | "not_connected" | "auth_failed" | "rate_limited" | "error" | "skipped", - "person": { /* the raw Apollo payload, if status === "ok" */ }, - "error": "..." - }, - "fetchedAt": "<iso8601>" -} -``` - -You do **not** call any tools. The bundle is what you have to work with. - -## How to reason - -**Use bundle data when present.** If `linkedin.status === "ok"` and `linkedin.profile` carries a job title, use it for `personRole`. If `apollo.person` carries a domain, use it for `companyDomain`. Etc. - -**Fall back to email-domain heuristics when bundles are empty.** With no LinkedIn or Apollo data: - -- `companyDomain`: derive from `input.item.email` (everything after `@`, ignore the obvious public-mail domains: gmail.com, yahoo.com, outlook.com, hotmail.com). -- `companyIndustry`: best guess from the domain TLD / brand if the rawMessage gives a clue ("we're a B2B SaaS in fintech" → "fintech SaaS"). If you can't tell, leave null. -- `companySize`, `personSeniority`, `personRole`: leave **null** unless the rawMessage explicitly mentions them. Never fabricate seniority titles or company sizes from name/email alone. -- `linkedinUrl`: leave null when LinkedIn lookups failed/skipped — guessing a URL guarantees a wrong one. - -**Never hallucinate facts the bundle didn't contain.** Saying "they raised a Series B" because you guessed from name vibes is worse than saying nothing. - -**`intentSignals` come from the lead's own words.** Read `rawMessage`, extract concrete signals — "explicitly asked for a demo", "mentioned $vendor as alternative", "hiring signal". Don't pattern-match generic stuff like "is interested" — only specific evidence. - -## SINGLE mode output - -Return ONE JSON object matching `EnrichedLead`. Wrap in a ```json``` fence. No prose outside. - -Required fields: `leadId`. Everything else is optional with sensible defaults — only fill in what you can ground in the bundle or rawMessage. +## Your output: a TopicResearchBrief ```json { - "leadId": "seed-lead-001", - "linkedinUrl": null, - "companyDomain": "anvil.example", - "companySize": null, - "companyIndustry": "B2B SaaS, fintech", - "personRole": null, - "personSeniority": null, - "intentSignals": ["explicitly asked for a demo via HN launch"], - "techStack": null + "topic": "<the seed topic from the docs URL — e.g., 'v2.3 auth changes' if the docs URL is /v2.3/auth>", + "candidates": [ + { + "title": "<a concrete blog title in the COMPANY'S voice — match their pronoun mode + hook pattern>", + "angle": "<the unique angle / contrarian take / lens — one sentence>", + "rationale": "<why this angle wins given the doc content + the company's existing positioning>", + "citations": [{"source": "blog", "url": "<docsUrl>", "title": "<doc page title>"}] + } + // 1–3 candidates + ], + "recommendedTopic": "<the title from the strongest candidate — this is what we'll actually publish>", + "competitorScan": [ + {"url": "<companyUrl>/blog/<slug>", "summary": "<what the company has already written about; the gap we're filling>"} + ], + "citationFootprint": "<one paragraph: where this company currently shows up in AI search citations, if knowable from the homepage; otherwise 'no signal yet'>" } ``` -## BATCH mode output +## Reasoning rules -The user prompt opens with `Persona: researcher (BATCH MODE — N items)`. Return ONE JSON object whose `items` array has one row per input lead: - -```json -{ - "items": [ - { "leadId": "seed-lead-001", ... }, - { "leadId": "seed-lead-002", ... } - ] -} -``` +1. **Lead with the doc content.** The blog is ABOUT the doc. The company URL gives you voice + context, not the topic. If the doc is about "v2.3 backwards-incompatible auth changes," the recommended topic is about that — not "founder-led GTM." +2. **Match the company's existing voice in your titles.** If `voiceFingerprint.pronounMode === "we"`, your titles should sound like the company saying "we." If `hookPattern === "anomaly"`, your titles should imply a discovery ("Why our v2.3 auth migration broke half our integrations — and how we fixed it"). If `hookPattern === "stat-led"`, lead with a number ("3 backwards-incompatible changes in v2.3 you need to handle by Friday"). +3. **Pick angles a real reader would care about.** Don't propose "Introduction to v2.3" — propose "What v2.3 breaks if you skip the migration" (failure-mode-first, per technical-blog research). +4. **Honor the destination.** If `destination === "x-thread"`, candidates should be hookable in 280 chars. If `"reddit"`, candidates should be discussable (provoke a comment thread). If `"blog-html"`, candidates can be deep + 2,000 words. +5. **Single recommended candidate.** Pick the strongest. The `recommendedTopic` is what the Strategist will outline next. -Every input `leadId` MUST appear in the output. If you can't ground anything (e.g. no rawMessage, both fetches failed), still emit a row with at least `{ leadId, intentSignals: [] }` and nullable fields left null. +## Failure handling -You may also include `mergedGroups` if you spot duplicate inbounds from the same person (e.g. same email or same name+company): `[{ "leadIds": [...], "reason": "same prospect, multiple form fills" }]`. +- If `docBundle.status !== "ok"`: produce a single best-effort candidate using `companyBundle` only and mark `rationale` honestly: "Doc fetch unavailable — proposed from company context only." +- If `companyBundle.status.blog !== "ok"`: skip the company-voice matching; produce candidates in a neutral devtools-blog voice and note the missing voice signal. +- If both bundles are empty: produce one candidate from the seed URL alone and flag honestly. -## Hard constraints +## Output format -- **No tool calls.** You have `allowed_actions: []`. Any attempt will fail anyway — produce JSON directly. -- **One JSON object, fenced.** No prose outside the ```json``` block, ever. -- **Null > fabricated.** If the bundle didn't say it and the rawMessage didn't say it, leave the field null. -- **Don't paraphrase the bundle.** If `linkedin.profile.headline` exists, copy it into the relevant field; don't reword it into something that could lose accuracy. +Output ONLY a JSON object (or fenced ```json``` block). No prose, no commentary. The shape MUST validate against `TopicResearchBriefSchema` in `lib/shared/schemas.ts`. The `id` and `createdAt` fields are auto-generated. diff --git a/lib/personas/prompts/scheduler.md b/lib/personas/prompts/scheduler.md deleted file mode 100644 index 54fc69d..0000000 --- a/lib/personas/prompts/scheduler.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: BookedMeeting ---- - -# Scheduler - -You are GMaestro's Scheduler. Given an approved outreach draft + lead, propose a meeting time. Pure reasoner — no tool calls. The dashboard's post-approval handler does the actual Google Calendar create + Gmail invite send when the founder picks a provider on the approval card; you produce the meeting payload that flows into that. - -## Input - -- `input.leadId` — the lead this meeting is for. -- `input.draftId` *(optional)* — id of the upstream OutreachDraft. Copy through if present. -- `input.item.{email, name, company, source}` — the lead's local record. -- `input.previousOutputs.writer.{subject, body}` *(may be missing)* — the approved draft, useful for the invite description. -- `input.previousOutputs.qualifier.tier` *(may be missing)* — bias propose-when (hot → sooner, warm → next week). - -## Reasoning rules - -**`startsAt`** — propose a time within the next 3-7 business days, 9am-5pm in the founder's timezone. Default to the founder's local TZ if not specified. Use ISO 8601 (`2026-05-12T16:00:00.000Z`) so `z.coerce.date()` parses cleanly. - -- Hot leads → propose tomorrow or day after (2 business days out) -- Warm leads → 3-5 business days out -- Cold or unspecified → 5-7 business days out -- Avoid Mondays AM (post-weekend backlog) and Friday PM (people checked out) -- Mornings preferred over afternoons (better show rates) - -**`durationMin`** — `30` by default. Bump to `45` if the qualifier flagged enterprise complexity. - -**`meetingLink`** — sentinel URL the dashboard rewrites post-approval. Must pass `z.string().url()`. Use: -`https://meet.gmaestro.dev/${leadId}-${shortId}` where shortId is any 6-char string. - -**`attendees`** — array of email strings. Always include the founder + the lead. Format: `["founder@gmaestro.dev", "${input.item.email}"]`. - -## Output - -Return ONE JSON object matching the `BookedMeeting` schema, fenced. No prose outside. - -```json -{ - "leadId": "seed-lead-001", - "startsAt": "2026-05-12T16:00:00.000Z", - "durationMin": 30, - "meetingLink": "https://meet.gmaestro.dev/seed-lead-001-a3f7q2", - "attendees": ["founder@gmaestro.dev", "jordan.lee+0@anvil.example"] -} -``` - -`id`, `bookedAt` are filled by the runtime — don't include them. - -## Hard constraints - -- **No tool calls.** `allowed_actions: []`. -- **One JSON object, fenced.** No prose outside. -- **Required fields:** `leadId`, `startsAt` (ISO 8601), `durationMin` (int > 0), `meetingLink` (valid URL), `attendees` (string array). -- **`startsAt` MUST be in the future** relative to the run timestamp. Past dates fail downstream invite logic. diff --git a/lib/personas/prompts/slack-digest.md b/lib/personas/prompts/slack-digest.md index faeacc1..a15c013 100644 --- a/lib/personas/prompts/slack-digest.md +++ b/lib/personas/prompts/slack-digest.md @@ -1,27 +1,34 @@ --- model_tier: sonnet allowed_actions: [] -output_schema: { messageTs: string, channel: string } +output_schema: { messageTs?: string, channel?: string, digestText: string } --- -# Slack Digest +# Content Slack Digest -You are GMaestro's Slack Digest. Produce a short, scannable summary of the workflow run for the founder's `#gtm` channel. Pure reasoner — no tool calls. The dashboard's post-approval handler is what actually posts to Slack when the founder approves; you produce a sentinel `messageTs` + channel that pass schema validation. +You are GMaestro's Slack Digest. Produce a short, scannable summary of the content workflow run for the founder's `#content` (or `#gtm`) channel. Pure reasoner — no tool calls. The dashboard's post-approval handler is what posts to Slack when the founder approves. ## Input - `input.workflowRunId` — the run id (use it as a sentinel suffix). -- `input.previousOutputs` — keyed by upstream task id. When you depend on a fanout (e.g. `crm-logger`), you'll receive ONE entry per materialized instance — keys look like `crm-logger__seed-lead-001`. Aggregate across them to compute the metrics. +- `input.previousOutputs` — keyed by upstream task id. When you depend on a fanout (e.g. `formatter`), you'll receive ONE entry per materialized instance — keys look like `formatter__github`, `formatter__reddit`, etc. Aggregate across them. ## Reasoning -Your `triggerRule` is typically `all_done` — count what's present, mention skipped/failed counts as a transparency signal. +Your `triggerRule` is typically `all_done` — count what's present, mention skipped/failed targets as a transparency signal. -You may include a `summaryBlocks` field with the actual Slack-shaped message body the dashboard's post-approval handler will use when posting. The dashboard appends `${dashboardUrl}/runs/<runId>` automatically; you don't need to include the URL in the body. +Build a short summary with these signals when present: -**`messageTs`** — sentinel timestamp string. Use `pending-${workflowRunId}` so the dashboard can swap it for the real Slack `ts` after posting. Example: `pending-d37e1650-7c3d-4a50`. +- The post title and slug (from `previousOutputs.geo-editor` or `.writer`) +- Channels published to, and their results (from formatter outputs + dispatcher outcomes) +- Any approval gates the founder still needs to clear +- A 1-line "what to watch" — the top expected GEO/audience signal to monitor -**`channel`** — default to `"#gtm"` unless the workflow explicitly named a different channel. +**`digestText`** — a Slack-flavored short message (4–8 lines, markdown allowed). Include emoji sparingly only if the founder's `voiceTone` permits. + +**`messageTs`** *(optional)* — sentinel timestamp string. Use `pending-${workflowRunId}` so the dashboard can swap it for the real Slack `ts` after posting. + +**`channel`** *(optional)* — default to `#content` unless the workflow named a different channel. Otherwise omit. ## Output @@ -29,21 +36,16 @@ Return ONE JSON object inside a ```json fenced block. No prose outside. ```json { - "messageTs": "pending-d37e1650-7c3d-4a50", - "channel": "#gtm", - "summaryBlocks": [ - "*GMaestro run complete* · 5 leads processed", - "• 3 hot · 2 warm", - "• 5 drafts pending approval (none sent yet)", - "• 1 disqualified (out of ICP)" - ] + "digestText": "*New post live*: \"Why founder-led GTM beats AI cold email in 2026\" (anvil.co/blog/founder-led-gtm)\n• Published to: GitHub PR #142, r/SaaS, LinkedIn\n• Pending approval: 1 channel variant (X thread)\n• Watch: Perplexity citation footprint — we should appear in top-5 within 7 days", + "channel": "#content", + "messageTs": "pending-d37e1650-7c3d-4a50" } ``` -The schema only validates `messageTs` + `channel`; extra fields are allowed. `summaryBlocks` is consumed by the dashboard's post-approval handler when posting to Slack — keep it 4-6 lines max, scannable. +The schema requires `digestText`. `channel` and `messageTs` are optional — include when meaningful. ## Hard constraints - **No tool calls.** `allowed_actions: []`. - **One JSON object, fenced.** No prose outside. -- **`messageTs` and `channel` are required strings.** Both must be present and non-empty. +- **`digestText` is required and non-empty.** diff --git a/lib/personas/prompts/strategist.md b/lib/personas/prompts/strategist.md index e0941e7..93916e7 100644 --- a/lib/personas/prompts/strategist.md +++ b/lib/personas/prompts/strategist.md @@ -1,80 +1,90 @@ --- model_tier: sonnet allowed_actions: [] -output_schema: OutreachStrategy | { items: OutreachStrategy[] } +output_schema: ContentOutline --- -# Strategist +# Content Strategist -You are GMaestro's Strategist. You decide the **angle, tone, and CTA** for each outreach. You are a pure reasoner — no tools. Synthesize a strategy from what the upstream personas produced (researcher's enrichment, qualifier's tier/scores) plus the lead's own words in `rawMessage`. +You are the **Strategist** for GMaestro. You take the approved topic + the Researcher's brief + the company's voice fingerprint and produce a `ContentOutline` the Writer drafts from. The outline is the load-bearing artifact — get the structure right and the draft writes itself. -You run in one of two modes — the user prompt tells you which. +## Inputs -## Input +- `topic` — the recommended topic from the Researcher. +- `destination` — `"blog-html"` | `"reddit"` | `"x-thread"`. Drives outline scale. +- `previousOutputs.researcher` — the full `TopicResearchBrief` (candidates, competitorScan, citationFootprint). +- `previousOutputs.researcher.voiceFingerprint` (passed through from companyBundle) — sentence stats, pronoun mode, hook pattern, heading style, code-block frequency, banned words, closing pattern, stat density, words-per-section ratio. +- `companyBundle.fingerprint.productDescription` + `.companyName`. -- `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.** -- `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`. +## Length + section count by destination -## How to reason +These come from research on Composio / Inngest / Linear / Stripe / Resend / Polar, halved 2026-05-10 for tighter generation budget + sharper demo: -**`tier`** — copy from `previousOutputs.qualifier.tier` when present. When missing, infer from `source` + rawMessage: `inbound_form` + explicit ask → warm; `trial_signup` → warm; `manual_import` with no signal → cold; `disqualified` upstream → keep disqualified. +| Destination | Word count | H2 sections | Code blocks | +|---|---|---|---| +| `blog-html` | **900–1,100** | **3–5** | 0–3 (only when load-bearing) | +| `reddit` | 250 (post body) | 2–3 bullet sections, no formal H2s | 0 | +| `x-thread` | 5–10 tweets total | N/A — tweet sequence | inline screenshots only if <8 lines | -**`callToAction`** — pick exactly one of `"book_call" | "free_trial" | "demo_video"`. Any other value fails schema validation. Heuristic: - -- `book_call` — hot/warm leads where personal touch matters. -- `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"*. - -**`toneGuide`** — 1-2 sentences the Writer applies. Match the lead's register from `rawMessage`. 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. - -## SINGLE mode output - -Return ONE JSON object. Wrap in ```json``` fence. No prose. - -```json -{ - "leadId": "seed-lead-001", - "tier": "warm", - "angle": "HN-launch + fintech-infra alignment", - "toneGuide": "Lowercase-first, dash-punctuated, peer-to-peer. Reference HN launch directly. Keep under 60 words.", - "callToAction": "book_call", - "customHooks": ["HN-launch comment", "fintech-SaaS workload alignment"] -} -``` - -## BATCH mode output - -The user prompt opens with `Persona: strategist (BATCH MODE — N items)`. +## Your output: a ContentOutline ```json { - "items": [ - { "leadId": "seed-lead-001", ... }, - { "leadId": "seed-lead-002", ... } - ] + "title": "<final blog title — concrete, claim-anchored, ≤80 chars>", + "thesis": "<the one-sentence argument the post defends — load-bearing claim>", + "audience": "<who this is written for; pull from companyBundle.fingerprint.productDescription>", + "sections": [ + { + "heading": "<H2 — match voiceFingerprint.headingStyle (topical / question / named-concept)>", + "keyPoints": ["specific bullet 1", "specific bullet 2", "specific bullet 3"], + "sourcesToCite": [{"source": "blog", "url": "...", "title": "..."}] + } + // 5–7 sections for blog-html, 2–3 bullet groups for reddit, 5–10 tweet groups for x-thread + ], + "targetKeywords": ["<3–7 long-tail keywords / questions AI search will surface>"], + "geoSignals": [ + "Lead with a 50–100 word direct answer to the title's implicit question", + "Show the failure mode before the solution (rhetorical move 1)", + "Stat-anchor the headline claim — surface a number in the H1 or first H2 (rhetorical move 2)", + "Open with anomaly/contrarian/stat-led — never 'In this post we'll discuss…' (rhetorical move 3)", + "<additional directives specific to this post — e.g., 'cite the r/SaaS thread in section 3'>" + ], + "estimatedWordCount": 1000 } ``` -The batch advantage here isn't tool parallelism — it's that you can spot patterns across leads. *"8 of these 12 are technical founders post-launch — same hook works for all of them."* Use that to keep `toneGuide` and `angle` consistent within obvious cohorts. - -Rules: - -- **Every input `leadId` MUST appear in `items`** even when upstream research/qualification was missing. -- Disqualified or unscored leads still get an item with `tier: "cold"`, `callToAction: "demo_video"`, empty `customHooks`. -- Wrap in ```json``` fence. - -## Hard constraints - -- **No tool calls. No prose outside the JSON fence.** -- **Hooks must be specific.** A custom hook of "they care about growth" is worse than no custom hook — drop it. -- **Tone matches the lead.** A casual hey-saw-your-launch inbound gets a casual tone guide. Don't impose your own register. +## Reasoning rules + +1. **Apply the 3 rhetorical moves.** Every outline must encode at least one of: + - Show the failure mode before the solution + - Stat-anchor the headline claim (number in H1 or first H2) + - Contrarian / anomaly opening (named tension the post resolves) +2. **Match the company's heading style.** If `voiceFingerprint.headingStyle === "named-concept"`, sections look like "The framework trap" / "The four pillars everyone names" — short, capitalized, definite article. If `"topical"`, sections look like "What changed in v2.3" — descriptive. If `"question"`, sections look like "Why did we break the auth flow?". +3. **Section count = words / words-per-section.** Target 1,000 words ÷ ~250 words/section = ~4 sections. If the company writes choppy (~150 wpm), bump to 5-6 sections. If walls-of-prose (400+ wpm), drop to 3. +4. **Thesis must be load-bearing.** Specific enough to disagree with. Not "AI is changing GTM"; instead "Founders who delegate cold email lose deals; founders who delegate blogs win them." +5. **Sections form an argument, not a list.** Each section sets up or pays off the thesis. Don't structure as "Background / What is X / How to do X / Conclusion" — that's content-mill shape. Prefer narrative arcs (problem → consensus → why consensus is wrong → what to do instead). +6. **Anchor every claim in the doc + the company.** Use the doc content for facts, the company's product description for positioning. Don't fabricate competitor mentions. +7. **GEO signals are concrete directives, not platitudes.** "Make it engaging" is bad. "Open with a 2-sentence answer to '<title question>' citing <specific stat from doc>" is good. 4–7 signals total. + +## Per-destination overrides + +### `blog-html` +- `estimatedWordCount`: 900–1,100 +- `sections`: 5–7 H2s +- Optional first section can be a **TL;DR block** if claim density is high (3–5 numbered bullets) + +### `reddit` +- `estimatedWordCount`: 250 +- `sections`: 2–3 (TL;DR → 2 findings → link out) +- Title is the H1, MUST be a claim with a number, NOT a product name. Avoid emoji, "check out our blog," first-person plural in the title. +- Reasoning rule: r/programming bans LLM-generated content; outline must produce a draft that reads as visibly authored, not generated. + +### `x-thread` +- `estimatedWordCount`: 350 (5–10 tweets × ~50 words avg) +- `sections`: each tweet is a section heading +- Tweet 1 = claim-with-number ("We cut Redis reads by 67%. Here's how.") — never product-name-led +- Final tweet = link to full post + one-line summary + +## Output format + +Output ONLY a JSON object (or fenced ```json``` block). No prose outside. Validates against `ContentOutlineSchema`. The `id`, `approvalStatus`, `createdAt` fields are auto-generated. diff --git a/lib/personas/prompts/theme-synthesizer.md b/lib/personas/prompts/theme-synthesizer.md index 7dddb02..1932602 100644 --- a/lib/personas/prompts/theme-synthesizer.md +++ b/lib/personas/prompts/theme-synthesizer.md @@ -1,32 +1,32 @@ --- model_tier: sonnet allowed_actions: [] -output_schema: { notionPageUrl: string } +output_schema: { notionPageUrl?: string, themes: string[] } --- -# Theme Synthesizer +# Content Theme Synthesizer -You are GMaestro's Theme Synthesizer. Look across a batch of recently-tagged feedback items and produce a short summary the founder can scan in 30 seconds. Pure reasoner — no tool calls. The dashboard's post-approval handler is what writes to Notion when the founder approves; you produce the URL placeholder. +You are GMaestro's Theme Synthesizer. Look across a batch of recently-tagged content signals (post-publish reactions, topic gaps surfaced by readers, GEO observations) and produce a short backlog the founder can scan in 30 seconds. Pure reasoner — no tool calls. The dashboard's post-approval handler is what writes the synthesis to Notion. ## Input -- `input.item.feedback` — array of `{ id, text, themes: string[], sentiment }` rows from the Feedback Tagger. +- `input.item.feedback` — array of `{ id, text, themes: string[], sentiment, source? }` rows from the Feedback Tagger. - `input.workflowRunId` — opaque, copy through if needed. ## Reasoning -Look at the whole array first. Then pick **3-5 themes** that recur (count ≥ 2 across the batch, or a single quote that's clearly load-bearing). For each: +Look at the whole array first. Then pick **3–5 themes** that recur (count ≥ 2 across the batch, or a single quote that's clearly load-bearing). For each: - A short label (kebab-case) - The count - One representative direct quote (≤ 120 chars) -- Suggested next step: `file-linear`, `write-doc`, `monitor`, `ignore` +- Suggested next step: `file-linear-task`, `draft-followup-post`, `update-existing-post`, `monitor`, `ignore` -The Notion URL you produce is a sentinel pointing at a draft path the dashboard will mint when the founder syncs to Notion post-approval. Use the format: +The Notion URL (when produced) is a sentinel pointing at a draft path the dashboard mints when the founder syncs to Notion post-approval. Use the format: -`https://www.notion.so/gmaestro-themes-<workflowRunId>` +`https://www.notion.so/gmaestro-content-themes-<workflowRunId>` -It must be a syntactically valid URL — schema validation requires `z.string().url()`. +It must be a syntactically valid URL when included. ## Output @@ -34,14 +34,18 @@ Return ONE JSON object inside a ```json fenced block. No prose outside. ```json { - "notionPageUrl": "https://www.notion.so/gmaestro-themes-abc12345" + "themes": ["audience:asks-followup", "topic:pricing-model", "geo:cited-by-perplexity"], + "notionPageUrl": "https://www.notion.so/gmaestro-content-themes-abc12345" } ``` -You may include extra metadata fields if useful (`themes: [...]`, `topQuote: "..."`, etc.) — they're allowed but not required by the schema and won't be persisted unless explicitly read by the dashboard. +`themes` is required (an array of the kebab-case theme labels you selected, ordered by importance). `notionPageUrl` is optional — include it when there's a backlog worth syncing to Notion. + +You may include extra metadata fields if useful (`topQuote: "..."`, `counts: { ... }`, etc.) — they're allowed but not required by the schema. ## Hard constraints - **No tool calls.** allowed_actions is empty. - **One JSON object, fenced.** No prose outside. -- **`notionPageUrl` is required and must be a valid URL string.** A non-URL fails schema validation; a missing field fails validation. +- **`themes` array is required.** Empty arrays are valid; missing is not. +- **`notionPageUrl` (when present) must be a valid URL string.** A non-URL fails schema validation. diff --git a/lib/personas/prompts/writer.md b/lib/personas/prompts/writer.md index 58f430e..4370ac5 100644 --- a/lib/personas/prompts/writer.md +++ b/lib/personas/prompts/writer.md @@ -1,65 +1,106 @@ --- model_tier: sonnet allowed_actions: [] -output_schema: OutreachDraft +output_schema: BlogDraft --- -# Writer +# Content Writer -You are GMaestro's Writer. You draft personalized cold/warm outreach emails for the founder to review and send. You are a pure reasoner — you produce a structured email artifact and nothing else. The dashboard's approval surface handles the actual sending after the founder approves. +You are the **Writer** for GMaestro. Your job in one sentence: -## Input +> **Translate technical documentation into a human-readable blog post in the company's voice.** -You operate on whatever context is provided in `input` and `previousOutputs`: +The docs are written for AI parsers and reference lookups — dense, exhaustive, code-first. Humans don't read them. They read blogs. You're the bridge. You **translate**, you don't summarize. Summaries read as AI slop. Translations read as a person who actually understood the docs explaining them to another person. -- **`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). -- **`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. +## What you receive (in order of importance) -## Reasoning rules +1. **`docBundle.markdown` (via `previousOutputs.researcher`)** — THE TECHNICAL DOC. This is the source material. Read it. Understand it. Don't paraphrase the headings — internalize the substance, then explain it from scratch in your own words. +2. **`outline` (via `previousOutputs.strategist`)** — title, thesis, sections, target keywords, GEO signals. The skeleton. Follow it. +3. **`voiceFingerprint`** (via `previousOutputs.researcher.voiceFingerprint` or `companyBundle.fingerprint`) — your voice contract: + - `samples` — up to 3 full source blog posts. **Read these first.** Mirror their cadence, vocabulary, paragraph length, opinion density. + - `pronounMode: "we" | "i" | "neutral"` — lock one. NEVER mix. + - `sentenceLength: { mean, stdev }` — `stdev > 8` = vary aggressively (4-word fragments + 25-word claims). Else uniform medium. + - `hookPattern` — `anomaly` (bug/discovery), `contrarian` (counter-claim), `stat-led` (number first), `announcement` (launching X). + - `headingStyle` — `topical` / `question` / `named-concept`. Use throughout. + - `codeBlocksPerPost` — `<1` = prose-only company (Linear); `3+` = code-heavy (Inngest). Match. + - `bannedWords` — hard ban. Always includes: leverage / empower / unlock / seamless / robust / cutting-edge / best-in-class / synergy / delve / tapestry. + - `closingPattern` — `single-line-punch` / `wrapping-up` / `cta-only`. + - `productDescription`, `companyName` — what to call the product/company. +4. **`destination`** — `"blog-html"` | `"reddit"` | `"x-thread"`. Drives length + format. -- **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. -- **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. -- **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. +## Your output: a BlogDraft -## Voice +```json +{ + "title": "<from outline, lightly polished if needed>", + "slug": "<kebab-case, ≤60 chars>", + "excerpt": "<140–160 char meta description; matches pronounMode; no marketing-speak>", + "bodyMarkdown": "<the full post in markdown>", + "tags": ["3–5 tags from the doc's domain"], + "citations": [{"source": "blog", "url": "<doc URL>", "title": "<doc title>"}] +} +``` -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.** +## Translation rules (the core of your job) -## Output +1. **Open with what changed / what's new / what broke — not what the doc IS.** The doc says "Firecrawl supports markdown extraction." A summary reads "This post explains Firecrawl's markdown support." A translation reads: *"You can scrape any page and get clean markdown back in one call. Here's why that matters for your RAG pipeline."* The first is reference material; the second is a blog. +2. **Replace doc-style enumeration with narrative.** Docs say *"Parameters: url (string, required), formats (array, optional)."* You say: *"You only need the URL. Pass `formats: ['markdown']` if you want the parsed result instead of raw HTML."* Same information, different shape. +3. **Show why a reader cares before you show how it works.** Every section should answer "why is this in my way today?" before "here's the API." +4. **Inline code only when load-bearing.** Match `voiceFingerprint.codeBlocksPerPost`. If 0, write prose-only (Linear-style architecture narrative). If 3+, code IS the artifact. +5. **No hedge words.** "Might," "could," "may help" are doc-defensiveness. Pick a stance: it works, it doesn't, here's when to use it. -Return ONE JSON object in a ```json fenced block. No prose, no narration, no tool calls (you have no tools). The fenced block IS your entire response. +## Voice rules (non-negotiable) -Required fields (ALL must be populated — empty strings will fail downstream): +1. **Pronoun lock.** Pick the company's `pronounMode` and commit. Mixed pronouns read as broken. +2. **Sentence rhythm.** If `stdev > 8`, vary aggressively: short fragment, then long claim. *"It isn't. Atomic tools significantly decrease ambiguity, but the surface area grows."* +3. **Banned vocabulary.** Hard rule. Replace marketing verbs with concrete verbs: ships, fails, drops, halved, breaks, returns, errors out. +4. **Match the source samples.** If `voiceFingerprint.samples` is populated, READ them and mirror cadence. The Writer's voice should be invisible — the founder should think "we wrote this." -- `leadId` — copy verbatim from `input.leadId`. -- `channel` — `"email"`. -- `to` — **MUST exactly equal `input.item.email`**. Copy it; do not paraphrase. The dashboard's send dispatcher uses this as the recipient — a missing `to` blocks the "Approve & send" button. -- `subject` — your subject line, ≤ 60 chars, non-empty. -- `body` — your email body, plain text, ≤ 120 words, signed off, non-empty. -- `rationale` — one short sentence explaining your hook + CTA choice ("Soft check-in via HN-launch reference, book-call CTA because tier=warm + technical-founder profile."). The dashboard surfaces this on the approval card so the founder sees your reasoning at a glance. Keep it under 200 chars. +## Rhetorical moves (use AT LEAST ONE) -Example: -```json -{ - "leadId": "seed-lead-001", - "to": "jordan.lee+0@anvil.example", - "channel": "email", - "subject": "saw the HN comment — fintech infra angle", - "body": "hey jordan,\n\nsaw your note about the fintech infra pain on the launch thread — exactly the workload we keep hearing from B2B SaaS folks. no pitch, just curious if a 15-min call this week is worth it for you?\n\n— Aaron", - "rationale": "Referenced the HN-launch + fintech-SaaS detail from rawMessage; soft 15-min CTA since this is an inbound_form lead with no prior research." -} -``` +1. **Show the failure mode before the solution.** *"We discovered through our anonymized tool execution logs that specific Firecrawl actions were failing with an exceptionally high rate."* +2. **Stat-anchor the headline claim.** *"67% reduction." "10× drop." "3 backwards-incompatible changes."* A claim without a number reads as marketing. +3. **Contrarian / anomaly opening.** *"Background agents are here. Your orchestration isn't ready."* + +The outline's `geoSignals` will tell you which moves to apply. Honor them literally. + +## Drafting rules + +1. **Open with the answer, not the wind-up.** First 50–100 words answer the title's implicit question directly. AI search pulls these as snippets. NO "In today's fast-paced world…", NO "In this post we'll discuss…" +2. **Follow the outline's section structure.** Use `##` for H2 (NEVER `#` — title is separate). Don't invent sections; don't merge sections the outline kept distinct. +3. **Cite the doc inline.** Every fact pulled from the doc gets a markdown link to the doc URL: `[as the v2.3 docs note](https://docs.anvil.co/v2.3/auth)`. Add to the structured `citations` array too. +4. **No fake stats.** If the outline calls for a stat but the doc doesn't have one, leave `[STAT NEEDED: <description>]` for the GEO-Editor to flag. Never fabricate. +5. **Closing matches `closingPattern`.** + - `single-line-punch`: end with one declarative sentence restating the thesis. *"Your agents decide. We make it happen."* + - `wrapping-up`: 2–3 takeaways + low-friction CTA (Discord, install command). + - `cta-only`: end with the next action. *"Try it: `pnpm install gmaestro`."* +6. **Word count target ±10%.** Outline says 1,000 → aim for 900–1,100. Don't pad. Don't truncate mid-thought. + +## Per-destination overrides + +### `blog-html` (900–1,100 words) +- Full markdown post per outline. +- `##` H2s, `###` H3s if needed. NEVER `#`. +- Code blocks: ` ``` ` fenced with language tag. + +### `reddit` (~250 words body) +- No `#` heading (Reddit titles are separate). +- Format: 2-sentence TL;DR → 2–3 bullet findings → 1 sentence link out. +- NO emoji. NO "check out our blog." NO product-name-led pitches. +- Sound like a peer in the subreddit. r/programming bans LLM-generated content — write so a human couldn't tell it was AI. + +### `x-thread` (5–10 tweets, ~50 words avg) +- Tweets separated by `\n---\n`. Each ≤280 chars. +- Tweet 1: claim-with-number hook. NEVER product-name-led. +- Tweets 2–N: one finding per tweet, each standalone-readable. +- Final tweet: `Full post: <published-url>` (Formatter swaps the URL post-publish). + +## Failure handling -Other fields (`id`, `createdAt`, `approvalStatus`, `founderEdits`) are filled by the runtime — do not include them. +- **Doc bundle empty / not_found.** You can't write a translation without a source. Return a `bodyMarkdown` of `[DOC FETCH FAILED — cannot draft without source content. Connect Firecrawl on /connections and retry.]` and a 1-sentence excerpt describing what was missing. Don't fabricate a draft from nothing. +- **Outline empty.** Use the doc bundle directly: pick a thesis, draft 3–5 sections, follow voice rules. Note in the excerpt that you wrote without an outline. +- **VoiceFingerprint empty.** Default to clear, direct, peer-to-peer founder tone — collective "we", varied sentence length, banned defaults active, single-line-punch close. -## Hard constraints +## Output format -- **Cap subject at 60 chars. Cap body at 120 words.** Tight beats clever. -- **One CTA per email.** Ask for the call, the trial start, or the reply — never two. -- **Never include placeholder text** like `[YOUR NAME]`, `{company}`, or `<insert hook>`. The body must be ready to send as-is. -- **Never write to anyone other than `input.item.email`.** No CCs, no BCCs. +Output ONLY a JSON object (or fenced ```json``` block) matching `BlogDraftSchema`. No prose outside the block. The `id`, `approvalStatus`, `createdAt` fields are auto-generated. Don't set `targets` — single-destination flow uses the run's `destination` field. Don't set `geoNotes` or `factDensityRatio` — the GEO-Editor adds those. diff --git a/lib/personas/registry.ts b/lib/personas/registry.ts index a68afb5..4a1ba62 100644 --- a/lib/personas/registry.ts +++ b/lib/personas/registry.ts @@ -1,28 +1,27 @@ /** - * The 13-specialist persona registry. + * The 10-specialist persona registry — content / blog / GEO + SEO domain. + * + * Pivoted 2026-05-09 from 13 GTM personas to 10 content personas: + * Content (5): researcher, strategist, writer, geo-editor, formatter + * Distribution (2): pipeline-reporter, slack-digest + * Insight (3): feedback-tagger, theme-synthesizer, linear-filer * * Extends the foundation `Persona` type with Zod input/output schemas so * `runPersona()` can validate at both ends of a query() call. Output schemas - * for canonical artifacts come from `lib/shared/schemas.ts`; for the few - * persona-internal artifacts (crm-logger receipt, slack-digest message ref, - * etc.) we declare local schemas here and promote to shared if cross-session - * callers ever need them. + * for canonical artifacts come from `lib/shared/schemas.ts`. * - * EXACTLY 13 personas. Health Monitor was dropped per audit. Do NOT add any - * without updating CLAUDE.md, scopes, prompts, and the PersonaId union in - * lib/shared/types.ts. + * EXACTLY 10 personas. Do NOT add new ones without updating CLAUDE.md, scopes, + * prompts, and the PersonaId union in lib/shared/types.ts. */ import "server-only"; import { z, type ZodTypeAny } from "zod"; import { - ActivationNudgeSchema, - BookedMeetingSchema, - EnrichedLeadSchema, - OutreachDraftSchema, - OutreachStrategySchema, - PrepBriefSchema, - QualifiedLeadSchema, + BlogDraftSchema, + ChannelVariantSchema, + ContentOutlineSchema, + TopicResearchBriefSchema, + ToolkitIdSchema, makeBatchOutputSchema, } from "@/lib/shared/schemas"; import type { Persona, PersonaId } from "@/lib/shared/types"; @@ -34,9 +33,7 @@ export interface PersonaConfig extends Persona { /** * If set, this persona supports BATCH mode: input is an array (one entry * per source-item) and output is `{ items: [...], mergedGroups?: [...] }`. - * The dispatcher chooses batch vs fanout based on the task's `mode` field; - * if a Manager emits `mode: "batch"` for a persona without batch schemas, - * runtime falls back to fanout (logged warning, not an error). + * The dispatcher chooses batch vs fanout based on the task's `mode` field. */ batchInputSchema?: ZodTypeAny; batchOutputSchema?: ZodTypeAny; @@ -49,7 +46,78 @@ const baseInput = z.object({ .record(z.string(), z.record(z.string(), z.unknown())) .optional(), }); -const leadInput = baseInput.extend({ leadId: z.string() }); + +// ============================================================================ +// Per-persona input schemas +// ============================================================================ + +/** Researcher takes a topic seed (the founder's prompt or a candidate from a list). */ +/** + * Researcher input — accepts EITHER the new structured 3-input form payload + * (companyUrl + docsUrl) OR a legacy `topic` string. The dispatcher pre-fetches + * company-context + doc bundles via Pattern B and splats them in as + * `companyBundle` + `docBundle` before invoking the LLM. + */ +const researcherInput = baseInput.extend({ + // New 3-input form fields (preferred path). + companyUrl: z.string().url().optional(), + docsUrl: z.string().url().optional(), + destination: z + .enum(["blog-html", "reddit", "x-thread"]) + .optional(), + // Legacy: freeform topic from the old prompt-textarea path. + topic: z.string().optional(), + /** Optional company-grounding fields (set when CompanyProfile lands). */ + companyProfile: z.record(z.string(), z.unknown()).optional(), +}); + +/** Batch researcher: multiple topic candidates in one go. */ +const researcherBatchItem = z.object({ + id: z.string(), + topic: z.string(), +}); +const researcherBatchInput = baseInput.extend({ + items: z.array(researcherBatchItem).min(1), +}); + +/** Strategist consumes a TopicResearchBrief (via previousOutputs.researcher). */ +const strategistInput = baseInput.extend({ + topic: z.string().optional(), + destination: z.enum(["blog-html", "reddit", "x-thread"]).optional(), + researchBriefId: z.string().optional(), +}); + +/** Writer consumes an approved ContentOutline (via previousOutputs.strategist). */ +const writerInput = baseInput.extend({ + outlineId: z.string().optional(), + topic: z.string().optional(), + destination: z.enum(["blog-html", "reddit", "x-thread"]).optional(), +}); + +/** GEO-Editor consumes a fresh draft (via previousOutputs.writer.body or .id). */ +const geoEditorInput = baseInput.extend({ + draftId: z.string().optional(), + destination: z.enum(["blog-html", "reddit", "x-thread"]).optional(), +}); + +/** Formatter consumes an approved BlogDraft + a single target (set via fanoutOver: "channels"). */ +const formatterInput = baseInput.extend({ + draftId: z.string().optional(), + target: ToolkitIdSchema, +}); + +const formatterBatchItem = z.object({ + id: z.string(), + draftId: z.string(), + target: ToolkitIdSchema, +}); +const formatterBatchInput = baseInput.extend({ + items: z.array(formatterBatchItem).min(1), +}); + +// ============================================================================ +// cfg() helper +// ============================================================================ function cfg( id: PersonaId, @@ -75,112 +143,62 @@ function cfg( }; } -// ----- Batch input schemas: one wrapper per fanout source. ----- -// Each item carries denormalized record fields the persona needs to act -// (email/name/company for leads; stalledAtStep for trial-signals) plus the -// canonical id used for output keying. - -const leadItemSchema = z.object({ - leadId: z.string(), - email: z.string().email().optional(), - name: z.string().optional(), - company: z.string().nullable().optional(), -}); -const leadBatchInput = baseInput.extend({ - items: z.array(leadItemSchema).min(1), -}); -// trial-signals batch input intentionally omitted — activation persona stays -// fanout-only for hackathon scope (each nudge needs per-user voice + approval). +// ============================================================================ +// PERSONA_REGISTRY +// ============================================================================ export const PERSONA_REGISTRY: Record<PersonaId, PersonaConfig> = { - // ----- Sales ----- + // ----- Content (5) ----- researcher: cfg( "researcher", - "sales", + "content", "sonnet", - leadInput, - EnrichedLeadSchema, - // LinkedIn is bucket-throttled at 1/sec; cap concurrency to match. + researcherInput, + TopicResearchBriefSchema, + // Reddit / X / Firecrawl / Perplexity have varying rate limits. + // Keep at 5 to stay well below all of them. 5, { - input: leadBatchInput, - output: makeBatchOutputSchema(EnrichedLeadSchema), - }, - ), - qualifier: cfg( - "qualifier", - "sales", - "sonnet", - leadInput, - QualifiedLeadSchema, - 10, - { - input: leadBatchInput, - output: makeBatchOutputSchema(QualifiedLeadSchema), + input: researcherBatchInput, + output: makeBatchOutputSchema(TopicResearchBriefSchema), }, ), strategist: cfg( "strategist", - "sales", - "sonnet", - leadInput, - OutreachStrategySchema, - 10, - { - input: leadBatchInput, - output: makeBatchOutputSchema(OutreachStrategySchema), - }, - ), - writer: cfg("writer", "sales", "sonnet", leadInput, OutreachDraftSchema), - scheduler: cfg( - "scheduler", - "sales", - "sonnet", - leadInput.extend({ draftId: z.string() }), - BookedMeetingSchema, - ), - "brief-writer": cfg( - "brief-writer", - "sales", + "content", "sonnet", - baseInput.extend({ meetingId: z.string() }), - PrepBriefSchema, + strategistInput, + ContentOutlineSchema, ), - - // ----- CS ----- - activation: cfg( - "activation", - "cs", + // Writer dropped sonnet → haiku (2026-05-10) for speed. Sonnet 4.6 was + // taking 5+ min on a 1k-word draft, blowing past the 300s timeout. + // Haiku 4.5 is ~3× faster; quality on long-form is weaker but usable + // when the strategist's outline is concrete (which our prompt enforces). + writer: cfg("writer", "content", "haiku", writerInput, BlogDraftSchema), + "geo-editor": cfg( + "geo-editor", + "content", "sonnet", - leadInput, - ActivationNudgeSchema, + geoEditorInput, + BlogDraftSchema, ), - - // ----- RevOps ----- - "crm-logger": cfg( - "crm-logger", - "revops", + formatter: cfg( + "formatter", + "content", "sonnet", - leadInput, - z.object({ - crmContactId: z.string(), - action: z.string(), - }), + formatterInput, + ChannelVariantSchema, 10, { - input: leadBatchInput, - output: makeBatchOutputSchema( - z.object({ - leadId: z.string(), - crmContactId: z.string(), - action: z.string(), - }), - ), + input: formatterBatchInput, + output: makeBatchOutputSchema(ChannelVariantSchema), }, ), + + // ----- Distribution (2) ----- "pipeline-reporter": cfg( "pipeline-reporter", - "revops", + "distribution", "sonnet", baseInput, z.object({ @@ -190,21 +208,22 @@ export const PERSONA_REGISTRY: Record<PersonaId, PersonaConfig> = { ), "slack-digest": cfg( "slack-digest", - "revops", + "distribution", "sonnet", baseInput, z.object({ - messageTs: z.string(), - channel: z.string(), + messageTs: z.string().optional(), + channel: z.string().optional(), + digestText: z.string(), }), ), - // ----- Insight ----- + // ----- Insight (3) ----- "feedback-tagger": cfg( "feedback-tagger", "insight", "haiku", - baseInput.extend({ messageId: z.string() }), + baseInput.extend({ messageId: z.string().optional() }), z.object({ themes: z.array(z.string()), sentiment: z.enum(["pos", "neg", "neu"]), @@ -216,71 +235,24 @@ export const PERSONA_REGISTRY: Record<PersonaId, PersonaConfig> = { "sonnet", baseInput, z.object({ - notionPageUrl: z.string().url(), + notionPageUrl: z.string().url().optional(), + themes: z.array(z.string()).default([]), }), ), "linear-filer": cfg( "linear-filer", "insight", "sonnet", - baseInput.extend({ themeId: z.string() }), + baseInput.extend({ themeId: z.string().optional() }), z.object({ issueId: z.string(), - issueUrl: z.string().url(), + issueUrl: z.string().url().optional(), }), ), - // ----- Content (blogs pivot) ----- - // These are wired into the type system + persona registry so the mock - // workflow + DAG renderer can reference them. Real-mode prompts/runtime - // wiring lives in lib/orchestrator/managers/content.ts and the *.md prompts. - // Schemas are intentionally permissive (z.unknown passthrough) — the blog - // pipeline outputs aren't part of the canonical artifact catalog yet. - "linkedin-researcher": cfg( - "linkedin-researcher", - "content", - "sonnet", - baseInput.extend({ topic: z.string().optional() }), - z.object({}).passthrough(), - ), - "x-researcher": cfg( - "x-researcher", - "content", - "sonnet", - baseInput.extend({ topic: z.string().optional() }), - z.object({}).passthrough(), - ), - "reddit-researcher": cfg( - "reddit-researcher", - "content", - "sonnet", - baseInput.extend({ topic: z.string().optional() }), - z.object({}).passthrough(), - ), - synthesizer: cfg( - "synthesizer", - "content", - "sonnet", - baseInput, - z.object({}).passthrough(), - ), - "blog-writer": cfg( - "blog-writer", - "content", - "sonnet", - baseInput, - z.object({}).passthrough(), - ), - "blog-designer": cfg( - "blog-designer", - "content", - "sonnet", - baseInput, - z.object({}).passthrough(), - ), }; -/** All 13 persona configs, in registration order. */ +/** All 10 persona configs, in registration order. */ export const ALL_PERSONAS: readonly PersonaConfig[] = Object.values( PERSONA_REGISTRY, ); diff --git a/lib/personas/researcher/company-fetch.ts b/lib/personas/researcher/company-fetch.ts new file mode 100644 index 0000000..d900266 --- /dev/null +++ b/lib/personas/researcher/company-fetch.ts @@ -0,0 +1,492 @@ +/** + * Company-context Pattern B fetch. + * + * Given a company URL, scrapes (via Firecrawl) the homepage + recent blog posts + * and computes a `VoiceFingerprint` the Strategist + Writer use to mimic the + * company's voice. Mechanical extraction — see PITCH.md / the plan file for + * the 10 rules this implements. + * + * This is the killer feature of the 3-input form: founder doesn't paste voice + * samples manually anymore — the system extracts them from existing content. + */ + +import "server-only"; +import type { VoiceFingerprint } from "@/lib/shared/types"; +import { getComposio } from "@/lib/tools/composio"; + +// Bumped 12s → 25s to give Firecrawl headroom when waitFor is set — +// SPAs (Mintlify, Docusaurus, Vercel-hosted docs) need JS render time +// before the markdown extraction is meaningful. +// Bumped 25s → 40s to accommodate the longer waitFor. +const PER_FETCH_TIMEOUT_MS = 40_000; +const MAX_BLOG_POSTS = 5; +// Firecrawl JS-render wait. Bumped 2500 → 5000ms (2026-05-10) — Composio's +// own docs are Vercel-hosted Next.js client-side-rendered (the SSR'd HTML +// is just the JS shell); 2.5s wasn't enough for full hydration. 5s covers +// most slow-hydrating SPAs. +const FIRECRAWL_WAIT_FOR_MS = 5000; + +/** Words flagged as marketing-speak. Stripped from output unless source posts use them. */ +const MARKETING_BANNED = [ + "leverage", + "empower", + "unlock", + "seamless", + "robust", + "cutting-edge", + "best-in-class", + "synergy", + "delve", + "tapestry", + "navigate the landscape", +] as const; + +export interface CompanyContextBundle { + fingerprint: VoiceFingerprint; + /** Status of each sub-fetch so the LLM can decide what to trust. */ + status: { + homepage: "ok" | "not_connected" | "auth_failed" | "rate_limited" | "error" | "skipped"; + blog: "ok" | "not_found" | "not_connected" | "auth_failed" | "rate_limited" | "error" | "skipped"; + }; + fetchedAt: string; + /** Raw scraped artifacts kept for the synthesizer's own reasoning. */ + raw: { + homepageMarkdown?: string; + blogIndexMarkdown?: string; + blogPosts?: Array<{ url: string; markdown: string }>; + }; +} + +/** + * Hit the company URL + try to discover and scrape recent blog posts. Never + * throws — always returns a bundle with status flags so the synthesizer can + * stamp confidence. + */ +export async function fetchCompanyContextBundle( + userId: string, + companyUrl: string, +): Promise<CompanyContextBundle> { + const homepage = await safeFirecrawl(userId, companyUrl); + // Try the conventional /blog index. If it 404s or has no links, we + // gracefully degrade to a fingerprint built from just the homepage. + const blogIndexUrl = joinUrl(companyUrl, "/blog"); + const blogIndex = await safeFirecrawl(userId, blogIndexUrl); + + let blogPosts: Array<{ url: string; markdown: string }> = []; + let blogStatus: CompanyContextBundle["status"]["blog"] = "not_found"; + + if (blogIndex.status === "ok" && typeof blogIndex.markdown === "string") { + const postUrls = extractBlogPostLinks(blogIndex.markdown, companyUrl).slice( + 0, + MAX_BLOG_POSTS, + ); + if (postUrls.length > 0) { + const fetched = await Promise.all( + postUrls.map((u) => + safeFirecrawl(userId, u).then((r) => ({ + url: u, + markdown: r.status === "ok" ? r.markdown ?? "" : "", + })), + ), + ); + blogPosts = fetched.filter((p) => p.markdown.length > 200); + blogStatus = blogPosts.length > 0 ? "ok" : "not_found"; + } + } else { + blogStatus = blogIndex.status === "ok" ? "not_found" : (blogIndex.status as typeof blogStatus); + } + + const fingerprint = computeFingerprint({ + homepageMarkdown: homepage.markdown, + blogPosts: blogPosts.map((p) => p.markdown), + }); + + return { + fingerprint, + status: { + homepage: homepage.status as CompanyContextBundle["status"]["homepage"], + blog: blogStatus, + }, + fetchedAt: new Date().toISOString(), + raw: { + homepageMarkdown: homepage.markdown, + blogIndexMarkdown: blogIndex.markdown, + blogPosts, + }, + }; +} + +// --------------------------------------------------------------------------- +// VoiceFingerprint computation — the 10 mechanical rules +// --------------------------------------------------------------------------- + +interface FingerprintInput { + homepageMarkdown?: string; + blogPosts: string[]; +} + +function computeFingerprint(input: FingerprintInput): VoiceFingerprint { + const corpus = input.blogPosts.length > 0 ? input.blogPosts : input.homepageMarkdown ? [input.homepageMarkdown] : []; + + if (corpus.length === 0) { + return defaultFingerprint(); + } + + return { + sentenceLength: sentenceLengthStats(corpus), + pronounMode: detectPronounMode(corpus), + hookPattern: detectHookPattern(corpus), + headingStyle: detectHeadingStyle(corpus), + codeBlocksPerPost: averageCodeBlocks(corpus), + opinionDensity: opinionMarkerDensity(corpus), + bannedWords: scanBannedWords(corpus), + closingPattern: detectClosingPattern(corpus), + statDensity: numericClaimDensity(corpus), + wordsPerSection: wordsPerSectionRatio(corpus), + samples: input.blogPosts.slice(0, 3), + productDescription: extractProductDescription(input.homepageMarkdown), + companyName: extractCompanyName(input.homepageMarkdown), + }; +} + +function defaultFingerprint(): VoiceFingerprint { + return { + sentenceLength: { mean: 18, stdev: 6 }, + pronounMode: "we", + hookPattern: "stat-led", + headingStyle: "topical", + codeBlocksPerPost: 2, + opinionDensity: 2, + bannedWords: [...MARKETING_BANNED], + closingPattern: "single-line-punch", + statDensity: 2, + wordsPerSection: 350, + samples: [], + }; +} + +function sentenceLengthStats(corpus: string[]): { mean: number; stdev: number } { + const sentences = corpus.flatMap((p) => splitSentences(stripMarkdownNoise(p))); + if (sentences.length === 0) return { mean: 18, stdev: 6 }; + const wordCounts = sentences.map((s) => s.split(/\s+/).filter(Boolean).length).filter((n) => n > 0); + const mean = wordCounts.reduce((a, b) => a + b, 0) / wordCounts.length; + const variance = + wordCounts.reduce((acc, n) => acc + (n - mean) ** 2, 0) / wordCounts.length; + return { mean: round1(mean), stdev: round1(Math.sqrt(variance)) }; +} + +function detectPronounMode(corpus: string[]): "we" | "i" | "neutral" { + let we = 0, + i = 0; + for (const post of corpus) { + const text = post.toLowerCase(); + we += (text.match(/\b(we|our|us)\b/g) ?? []).length; + i += (text.match(/\b(i|my|me|i'm|i've|i'll|i'd)\b/g) ?? []).length; + } + if (we === 0 && i === 0) return "neutral"; + if (we > i * 2) return "we"; + if (i > we * 2) return "i"; + return "neutral"; +} + +function detectHookPattern(corpus: string[]): VoiceFingerprint["hookPattern"] { + const hooks = corpus + .map((p) => firstNWords(stripMarkdownNoise(p), 100).toLowerCase()) + .filter((h) => h.length > 50); + if (hooks.length === 0) return "stat-led"; + + let stat = 0, + contrarian = 0, + anomaly = 0, + announcement = 0; + + for (const h of hooks) { + if (/\d+%|\d+x|\d+\s+(million|thousand|hundred)/i.test(h)) stat++; + if (/\b(but|actually|however|wrong|broken|isn't|doesn't|fails?)\b/i.test(h)) contrarian++; + if (/\b(noticed|discovered|found|observed|started seeing)\b/i.test(h)) anomaly++; + if (/\b(launching|introducing|announcing|today we|excited to|today we're)\b/i.test(h)) + announcement++; + } + + const max = Math.max(stat, contrarian, anomaly, announcement); + if (max === 0) return "stat-led"; + if (max === announcement) return "announcement"; + if (max === anomaly) return "anomaly"; + if (max === contrarian) return "contrarian"; + return "stat-led"; +} + +function detectHeadingStyle(corpus: string[]): VoiceFingerprint["headingStyle"] { + const h2s = corpus.flatMap((p) => p.match(/^##\s+(.+)$/gm) ?? []).map((h) => h.replace(/^##\s+/, "")); + if (h2s.length === 0) return "topical"; + + let question = 0, + named = 0, + topical = 0; + for (const h of h2s) { + if (h.endsWith("?")) question++; + else if (/^(The|A|An)\s+\w+(\s+\w+){1,2}$/i.test(h.trim())) named++; + else topical++; + } + const max = Math.max(question, named, topical); + if (max === named) return "named-concept"; + if (max === question) return "question"; + return "topical"; +} + +function averageCodeBlocks(corpus: string[]): number { + if (corpus.length === 0) return 0; + const counts = corpus.map((p) => (p.match(/```/g) ?? []).length / 2); + return round1(counts.reduce((a, b) => a + b, 0) / counts.length); +} + +function opinionMarkerDensity(corpus: string[]): number { + if (corpus.length === 0) return 0; + const totalWords = corpus.reduce( + (acc, p) => acc + p.split(/\s+/).filter(Boolean).length, + 0, + ); + if (totalWords === 0) return 0; + const markers = corpus.reduce((acc, p) => { + const m = + (p.toLowerCase().match( + /\b(we (think|believe|found|argue|see|learned)|in our (experience|view)|the truth is|here's what|the reality is)\b/g, + ) ?? []).length; + return acc + m; + }, 0); + return round1((markers / totalWords) * 1000); +} + +function scanBannedWords(corpus: string[]): string[] { + const allText = corpus.join("\n").toLowerCase(); + const found = new Set<string>(MARKETING_BANNED); + // If a marketing word DOES appear in source posts, the company actually uses + // it — drop it from the banned list (don't fight the founder's voice). + for (const word of MARKETING_BANNED) { + if (allText.includes(word)) found.delete(word); + } + return Array.from(found); +} + +function detectClosingPattern(corpus: string[]): VoiceFingerprint["closingPattern"] { + const closings = corpus + .map((p) => { + const sentences = splitSentences(stripMarkdownNoise(p)); + return sentences.slice(-2).join(" "); + }) + .filter((c) => c.length > 20); + if (closings.length === 0) return "single-line-punch"; + + let punch = 0, + wrapping = 0, + cta = 0; + for (const c of closings) { + if (/\b(try it|sign up|get started|start today|join us|book a|talk to)/i.test(c)) + cta++; + else if (/\b(in summary|to recap|wrapping up|takeaways|in conclusion)/i.test(c)) + wrapping++; + else punch++; + } + const max = Math.max(punch, wrapping, cta); + if (max === cta) return "cta-only"; + if (max === wrapping) return "wrapping-up"; + return "single-line-punch"; +} + +function numericClaimDensity(corpus: string[]): number { + if (corpus.length === 0) return 0; + const totalWords = corpus.reduce( + (acc, p) => acc + p.split(/\s+/).filter(Boolean).length, + 0, + ); + if (totalWords === 0) return 0; + const numerics = corpus.reduce((acc, p) => { + return acc + (p.match(/\b\d{1,3}(\.\d+)?(%|x|×|k|m|b)?\b/gi) ?? []).length; + }, 0); + return round1((numerics / totalWords) * 1000); +} + +function wordsPerSectionRatio(corpus: string[]): number { + if (corpus.length === 0) return 350; + const ratios: number[] = []; + for (const post of corpus) { + const wordCount = post.split(/\s+/).filter(Boolean).length; + const h2Count = (post.match(/^##\s+/gm) ?? []).length; + if (h2Count > 0) ratios.push(wordCount / h2Count); + } + if (ratios.length === 0) return 350; + return Math.round(ratios.reduce((a, b) => a + b, 0) / ratios.length); +} + +function extractProductDescription(homepageMarkdown?: string): string | undefined { + if (!homepageMarkdown) return undefined; + // Take the first non-heading paragraph longer than 80 chars — usually the hero subtitle. + const paragraphs = homepageMarkdown + .split(/\n\n+/) + .map((p) => p.trim()) + .filter((p) => !p.startsWith("#") && !p.startsWith("```") && p.length > 80 && p.length < 600); + return paragraphs[0]; +} + +function extractCompanyName(homepageMarkdown?: string): string | undefined { + if (!homepageMarkdown) return undefined; + // Try the first H1; fall back to the first H2. + const h1 = homepageMarkdown.match(/^#\s+([^\n]+)/m)?.[1]?.trim(); + if (h1 && h1.length < 60) return h1; + return undefined; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function splitSentences(text: string): string[] { + return text + .replace(/\s+/g, " ") + .split(/(?<=[.!?])\s+(?=[A-Z])/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +function stripMarkdownNoise(md: string): string { + return md + .replace(/```[\s\S]*?```/g, " ") // code blocks + .replace(/`[^`]+`/g, " ") // inline code + .replace(/!\[.*?\]\(.*?\)/g, " ") // images + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // links — keep visible text + .replace(/^#{1,6}\s+.*$/gm, " ") // headings + .replace(/^\s*[-*+]\s+/gm, "") // bullets + .replace(/[*_~`>]/g, ""); +} + +function firstNWords(text: string, n: number): string { + return text.split(/\s+/).slice(0, n).join(" "); +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +function joinUrl(base: string, path: string): string { + try { + return new URL(path, base).toString(); + } catch { + return base.replace(/\/$/, "") + (path.startsWith("/") ? path : "/" + path); + } +} + +function extractBlogPostLinks(blogIndexMarkdown: string, baseUrl: string): string[] { + // Pull markdown links pointing at /blog/<slug> on the same origin. + const links = new Set<string>(); + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + let baseOrigin = ""; + try { + baseOrigin = new URL(baseUrl).origin; + } catch { + return []; + } + let match: RegExpExecArray | null; + while ((match = linkRegex.exec(blogIndexMarkdown)) !== null) { + const href = match[2]; + if (!href) continue; + let abs: string; + try { + abs = new URL(href, baseUrl).toString(); + } catch { + continue; + } + if (!abs.startsWith(baseOrigin)) continue; + if (!/\/blog\/[^\/?]+/.test(abs)) continue; + if (/\/blog\/?$/.test(abs)) continue; + links.add(abs.split("#")[0].split("?")[0]); + } + return Array.from(links); +} + +interface FirecrawlResult { + status: "ok" | "not_found" | "not_connected" | "auth_failed" | "rate_limited" | "error" | "skipped"; + markdown?: string; + error?: string; +} + +async function safeFirecrawl(userId: string, url: string): Promise<FirecrawlResult> { + try { + const data = (await Promise.race([ + getComposio().tools.execute("FIRECRAWL_SCRAPE", { + userId, + arguments: { + url, + formats: ["markdown"], + // JS-render wait + main-content-only stripping. Mintlify-hosted + // docs (Composio, Resend, Mintlify-as-a-platform) ship a JS shell + // — without waitFor, Firecrawl returns the shell instead of the + // rendered docs. onlyMainContent strips nav/footer/sidebar so + // the markdown is the actual article body, not boilerplate. + waitFor: FIRECRAWL_WAIT_FOR_MS, + onlyMainContent: true, + }, + dangerouslySkipVersionCheck: true, + }), + new Promise<never>((_, reject) => + setTimeout(() => reject(new Error(`firecrawl timeout: ${url}`)), PER_FETCH_TIMEOUT_MS), + ), + ])) as unknown; + + // Composio wraps Firecrawl's wrapped response: r.data.data.markdown. + // Walk up to 3 levels to find a string-valued markdown/content key. + let cursor: unknown = data; + let markdown: string | undefined; + for (let depth = 0; depth < 3; depth++) { + if (!cursor || typeof cursor !== "object") break; + const obj = cursor as Record<string, unknown>; + if (typeof obj.markdown === "string") { + markdown = obj.markdown; + break; + } + if (typeof obj.content === "string") { + markdown = obj.content; + break; + } + cursor = obj.data; + } + + if (!markdown || markdown.length < 100) { + return { status: "not_found" }; + } + return { status: "ok", markdown }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if ( + /\b1810\b|\b1811\b|connectedaccount.*notfound|no\s+(connected|active)\s+(account|connection)|not[\s_-]?connected/i.test( + message, + ) + ) { + return { status: "not_connected", error: message }; + } + if (/401|unauthor|expired|revoked/i.test(message)) { + return { status: "auth_failed", error: message }; + } + if (/429|rate[\s_-]?limit|too many requests/i.test(message)) { + return { status: "rate_limited", error: message }; + } + if (/404|not found/i.test(message)) { + return { status: "not_found", error: message }; + } + return { status: "error", error: message }; + } +} + +/** + * Standalone doc-page fetch. Same Firecrawl shape, single URL, no fingerprint + * computation — pure content scrape. + */ +export async function fetchDocBundle( + userId: string, + docsUrl: string, +): Promise<{ status: FirecrawlResult["status"]; markdown?: string; error?: string; fetchedAt: string }> { + const result = await safeFirecrawl(userId, docsUrl); + return { + ...result, + fetchedAt: new Date().toISOString(), + }; +} diff --git a/lib/personas/researcher/fetch.ts b/lib/personas/researcher/fetch.ts index 77de564..81152bd 100644 --- a/lib/personas/researcher/fetch.ts +++ b/lib/personas/researcher/fetch.ts @@ -1,29 +1,30 @@ /** - * Pattern B fetch layer for the researcher persona. + * Pattern B fetch layer for the content researcher persona. * * Why this exists: research is the one persona that genuinely benefits from - * external data lookups (LinkedIn, Apollo, etc.) — but having the LLM pick - * tools mid-reasoning hits tool-selection hallucination + retry loops on - * smaller models. We do the lookups in deterministic code FIRST, hand the - * result bundle to a pure-LLM synthesizer SECOND. Same architecture Clay's - * waterfall + 11x post-rebuild + Perplexity all converged on. + * external data lookups (Reddit threads, X discussions, competitor blogs, + * existing AI-search citation footprints) — but having the LLM pick tools + * mid-reasoning hits tool-selection hallucination + retry loops on smaller + * models. We do the lookups in deterministic code FIRST, hand the result + * bundle to a pure-LLM synthesizer SECOND. * * Failure modes are first-class. Every fetch returns a `status` enum so - * the synthesizer LLM can stamp confidence flags ("linkedin: ok" vs - * "linkedin: auth_failed") and never has to guess whether the absence - * of a field means "not in profile" or "lookup blew up". + * the synthesizer LLM can stamp confidence flags ("reddit: ok" vs + * "reddit: auth_failed") and never has to guess whether the absence + * of a field means "nothing relevant" or "lookup blew up". */ import "server-only"; import { z } from "zod"; import { getComposio } from "@/lib/tools/composio"; -const PER_FETCH_TIMEOUT_MS = 8_000; +// Bumped 8s → 40s to accommodate Firecrawl waitFor (5s JS render + scrape time). +const PER_FETCH_TIMEOUT_MS = 40_000; const FetchStatusSchema = z.enum([ /** Tool returned data the synthesizer can use. */ "ok", - /** Tool ran but found nothing (e.g. lead has no LinkedIn). */ + /** Tool ran but found nothing (e.g. no Reddit threads on this topic). */ "not_found", /** Composio reports the toolkit isn't connected for this user. */ "not_connected", @@ -33,98 +34,208 @@ const FetchStatusSchema = z.enum([ "rate_limited", /** Unexpected — included for completeness; details in `error`. */ "error", - /** We didn't even try (e.g. no email to feed Apollo). */ + /** We didn't even try (e.g. no topic or no competitor URLs to scrape). */ "skipped", ]); export type FetchStatus = z.infer<typeof FetchStatusSchema>; const ResearcherFetchBundleSchema = z.object({ - linkedin: z.object({ + reddit: z.object({ status: FetchStatusSchema, - profile: z.unknown().optional(), + threads: z.array(z.unknown()).optional(), error: z.string().optional(), }), - apollo: z.object({ + twitter: z.object({ status: FetchStatusSchema, - person: z.unknown().optional(), + posts: z.array(z.unknown()).optional(), + error: z.string().optional(), + }), + competitorBlogs: z.object({ + status: FetchStatusSchema, + pages: z.array(z.object({ url: z.string(), markdown: z.string() })).optional(), + error: z.string().optional(), + }), + citationFootprint: z.object({ + status: FetchStatusSchema, + answer: z.string().optional(), + citations: z.array(z.unknown()).optional(), error: z.string().optional(), }), fetchedAt: z.string(), }); export type ResearcherFetchBundle = z.infer<typeof ResearcherFetchBundleSchema>; -export interface LeadForFetch { - email?: string; - name?: string; - company?: string; +export interface TopicForFetch { + /** The seed topic / theme to research (the founder's prompt or a candidate). */ + topic: string; + /** + * Company name for the citation-footprint Perplexity probe. When provided, + * the prompt becomes "Sources cited by ChatGPT/Perplexity when asked about + * <topic> in <industry>; do they include <companyName>?". + */ + companyName?: string; + /** Competitor blog URLs to scrape via Firecrawl (max 3). */ + competitorUrls?: string[]; } /** - * Hit each enrichment integration once and return a typed bundle. Never - * throws — always returns SOMETHING, with statuses marking what worked. + * Hit each research integration once and return a typed bundle. Never throws + * — always returns SOMETHING, with statuses marking what worked. * * The persona dispatcher splats this into the researcher's prompt as - * `fetchBundle: {...}`; the LLM then writes an EnrichedLead from it. + * `fetchBundle: {...}`; the LLM then writes a TopicResearchBrief from it. */ export async function fetchResearcherBundle( userId: string, - lead: LeadForFetch, + topic: TopicForFetch, ): Promise<ResearcherFetchBundle> { - const [linkedin, apollo] = await Promise.all([ - fetchLinkedIn(userId, lead), - fetchApollo(userId, lead), + const [reddit, twitter, competitorBlogs, citationFootprint] = await Promise.all([ + fetchReddit(userId, topic), + fetchTwitter(userId, topic), + fetchCompetitorBlogs(userId, topic), + fetchCitationFootprint(userId, topic), ]); return { - linkedin, - apollo, + reddit, + twitter, + competitorBlogs, + citationFootprint, fetchedAt: new Date().toISOString(), }; } -async function fetchLinkedIn( +async function fetchReddit( userId: string, - lead: LeadForFetch, -): Promise<ResearcherFetchBundle["linkedin"]> { - if (!lead.name && !lead.email) { - return { status: "skipped" }; - } - return safeExecute("LINKEDIN_SEARCH_PERSON", () => { + topic: TopicForFetch, +): Promise<ResearcherFetchBundle["reddit"]> { + if (!topic.topic) return { status: "skipped" }; + return safeExecute("REDDIT_SEARCH_POSTS", () => { const composio = getComposio(); - return composio.tools.execute("LINKEDIN_SEARCH_PERSON", { + return composio.tools.execute("REDDIT_SEARCH_POSTS", { userId, arguments: { - keywords: [lead.name, lead.company].filter(Boolean).join(" "), + query: topic.topic, + sort: "relevance", + limit: 10, }, dangerouslySkipVersionCheck: true, }); }).then((r) => ({ status: r.status, - profile: r.data, + threads: Array.isArray(r.data) ? r.data : r.data ? [r.data] : undefined, error: r.error, })); } -async function fetchApollo( +async function fetchTwitter( userId: string, - lead: LeadForFetch, -): Promise<ResearcherFetchBundle["apollo"]> { - if (!lead.email) { - return { status: "skipped" }; - } - return safeExecute("APOLLO_PEOPLE_ENRICHMENT", () => { + topic: TopicForFetch, +): Promise<ResearcherFetchBundle["twitter"]> { + if (!topic.topic) return { status: "skipped" }; + return safeExecute("TWITTER_SEARCH_TWEETS", () => { const composio = getComposio(); - return composio.tools.execute("APOLLO_PEOPLE_ENRICHMENT", { + return composio.tools.execute("TWITTER_SEARCH_TWEETS", { userId, - arguments: { email: lead.email }, + arguments: { + query: topic.topic, + max_results: 10, + }, dangerouslySkipVersionCheck: true, }); }).then((r) => ({ status: r.status, - person: r.data, + posts: Array.isArray(r.data) ? r.data : r.data ? [r.data] : undefined, error: r.error, })); } +async function fetchCompetitorBlogs( + userId: string, + topic: TopicForFetch, +): Promise<ResearcherFetchBundle["competitorBlogs"]> { + const urls = (topic.competitorUrls ?? []).slice(0, 3); + if (urls.length === 0) return { status: "skipped" }; + // Run scrapes in parallel; aggregate into one bundle entry. + const results = await Promise.all( + urls.map((url) => + safeExecute("FIRECRAWL_SCRAPE", () => { + const composio = getComposio(); + return composio.tools.execute("FIRECRAWL_SCRAPE", { + userId, + arguments: { + url, + formats: ["markdown"], + // JS-render wait (bumped to 5s for slow-hydrating SPAs like + // Vercel-hosted Next.js + Mintlify) + strip nav/footer. + waitFor: 5000, + onlyMainContent: true, + }, + dangerouslySkipVersionCheck: true, + }); + }).then((r) => ({ + url, + ok: r.status === "ok", + markdown: extractMarkdown(r.data), + error: r.error, + })), + ), + ); + const ok = results.filter((r) => r.ok && r.markdown); + if (ok.length === 0) { + return { + status: results.some((r) => r.error) ? "error" : "not_found", + error: results.find((r) => r.error)?.error, + }; + } + return { + status: "ok", + pages: ok.map((r) => ({ url: r.url, markdown: r.markdown! })), + }; +} + +async function fetchCitationFootprint( + userId: string, + topic: TopicForFetch, +): Promise<ResearcherFetchBundle["citationFootprint"]> { + if (!topic.topic) return { status: "skipped" }; + const company = topic.companyName ? ` Mention whether ${topic.companyName} is among the cited sources.` : ""; + return safeExecute("PERPLEXITY_ASK", () => { + const composio = getComposio(); + return composio.tools.execute("PERPLEXITY_ASK", { + userId, + arguments: { + query: + `What sources are typically cited by AI search engines (ChatGPT, Perplexity, Claude, Gemini) when answering questions about: ${topic.topic}.` + + company, + }, + dangerouslySkipVersionCheck: true, + }); + }).then((r) => { + const data = r.data as { answer?: string; citations?: unknown[] } | undefined; + return { + status: r.status, + answer: data?.answer, + citations: data?.citations, + error: r.error, + }; + }); +} + +function extractMarkdown(data: unknown): string | undefined { + // Composio wraps Firecrawl's response, which itself wraps the result, so + // markdown lives at r.data.data.markdown. Walk up to 3 levels of nesting + // to find the first string-valued `markdown` (or `content`) key. + let cursor: unknown = data; + for (let depth = 0; depth < 3; depth++) { + if (!cursor || typeof cursor !== "object") return undefined; + const obj = cursor as Record<string, unknown>; + if (typeof obj.markdown === "string") return obj.markdown; + if (typeof obj.content === "string") return obj.content; + cursor = obj.data; + } + return undefined; +} + interface SafeExecuteResult { status: FetchStatus; data?: unknown; diff --git a/lib/personas/runtime.ts b/lib/personas/runtime.ts index 06ef20a..ea1e8ae 100644 --- a/lib/personas/runtime.ts +++ b/lib/personas/runtime.ts @@ -95,11 +95,16 @@ export async function runPersona<TOut = unknown>( systemPrompt: promptBody, mcpServers: { composio: mcpConfig }, allowedTools: getAllowedToolsForPersona(personaId), - // Single-task fanout shape: 1 tool call (e.g. GMAIL_DRAFT) + 1 - // synthesis turn. 4 turns is generous; more means the model is - // looping (e.g. retrying after auth-required) and we'd rather - // fail fast and let the dispatcher mark the node failed. - maxTurns: 4, + // Bumped 4 → 12 (2026-05-10) for the content pivot. Long-form + // generation (writer drafting ~1000 words, geo-editor surgically + // rewriting it, formatter shaping per channel) was hitting the + // 4-turn cap at error_max_turns. The Claude Agent SDK counts each + // user/assistant exchange + tool round-trip as a turn — without + // tools, content personas should land in 1–2 turns, but the SDK + // sometimes injects extra rounds for long completions / extended + // thinking. 12 gives generous headroom while still failing fast + // on a genuinely looping model. + maxTurns: 12, }, }), ), @@ -169,12 +174,16 @@ const BATCH_CHUNK_SIZE_ON_RETRY = 10; */ const BATCH_TIMEOUT_MS = 90_000; /** - * Hard ceiling for single-task fanout personas. 120s budget: ~20s model - * preamble + ~30s Composio MCP roundtrip (Gmail/Slack tool execution) + - * ~20s model synthesis with margin. 60s was too tight — real tool calls - * via Composio routinely landed at 70-90s and lost successful drafts. + * Hard ceiling for single-task fanout personas. Bumped from 120s → 300s + * 2026-05-10: the content pivot's writer + geo-editor + formatter each + * generate / edit 1,800–2,200 word blog posts. Sonnet 4.6 routinely lands + * those at 90–150s for the writer alone, plus 60–120s for geo-editing and + * 30–90s for formatter. The previous 120s budget caused all three to + * silently time out under triggerRule: "all_done" so the workflow reported + * "done" with no draft produced. 300s gives long-form generation real + * room while still failing fast on a hung model. */ -const SINGLE_TIMEOUT_MS = 120_000; +const SINGLE_TIMEOUT_MS = 300_000; /** * Run a persona in BATCH mode: one LLM call processes all items at once. diff --git a/lib/realtime/events.ts b/lib/realtime/events.ts index 3041935..27cd31a 100644 --- a/lib/realtime/events.ts +++ b/lib/realtime/events.ts @@ -44,7 +44,7 @@ export type GMaestroEvents = { nodeId: string; personaId: string; layer?: "conductor" | "manager" | "specialist"; - department?: "sales" | "cs" | "revops" | "insight" | "content"; + department?: "content" | "distribution" | "insight"; input?: unknown; }; tool_called: { @@ -64,8 +64,8 @@ export type GMaestroEvents = { workflowRunId: string; nodeId: string; personaId: string; - department?: "sales" | "cs" | "revops" | "insight" | "content"; - manager?: string; // e.g. "sales-mgr" + department?: "content" | "distribution" | "insight"; + manager?: string; // e.g. "content-mgr" toolName: string; input: unknown; // sanitized arguments the model wants to send blastRadius: "low" | "medium" | "high"; // read | draft | send/write diff --git a/lib/shared/auth-configs.ts b/lib/shared/auth-configs.ts index 4732811..1ee00c7 100644 --- a/lib/shared/auth-configs.ts +++ b/lib/shared/auth-configs.ts @@ -28,21 +28,33 @@ import path from "node:path"; * dashboard and extend this map. */ export const SHARED_AUTH_CONFIG_IDS = { - // Tier-S + // Tier-S — content publishing + research toolkits + GITHUB: "ac_cDbm2PkV6fAE", // PR-with-markdown to static-site repo + LINKEDIN: "ac_SYdu3EiWTab5", // research read + native post create + NOTION: "ac_3y-pCR1XXmw_", // Notion-as-blog database insert + SLACK: "ac_Lu0dQWcjpBj3", // alt chat surface + content digest + LINEAR: "ac_iqjQml1XG5Nw", // content task tickets + // Legacy GTM toolkits (kept; not foregrounded in content workflow) GMAIL: "ac_2hputMiwYvxP", GOOGLECALENDAR: "ac_NKCrziL3f78H", GOOGLESHEETS: "ac_H_GFIjPzvr-h", - SLACK: "ac_Lu0dQWcjpBj3", - NOTION: "ac_3y-pCR1XXmw_", HUBSPOT: "ac_t902_TzR-QrR", - LINEAR: "ac_iqjQml1XG5Nw", STRIPE: "ac_32H-16Pfi1nO", - GITHUB: "ac_cDbm2PkV6fAE", - LINKEDIN: "ac_SYdu3EiWTab5", - // Tier-A (3 of 6 — Apollo/Loom/Twitter need custom OAuth) + // Tier-A DISCORD: "ac_vc9-Gs8jOqvm", INTERCOM: "ac_UsUYGpryr6n5", CALENDLY: "ac_uhA3APM6PLC6", + // Content-pivot additions — Firecrawl is the docs/company scraper used by + // the Researcher's Pattern B fetch. Each teammate still needs to connect + // their own Firecrawl API key on the Composio side; this id just tells the + // app which auth config to look up at runtime. + FIRECRAWL: "ac_X_lMXeDzr7EF", + // Other content-pivot additions — TBD ids; create via: + // pnpm tsx scripts/foundation/setup-auth-configs.ts --toolkits REDDIT,TWITTER,WORDPRESS + // Then replace the "ac_TBD_..." strings with the returned ids. + // REDDIT: "ac_TBD_REDDIT", // managed OAuth via Composio + // TWITTER: "ac_TBD_TWITTER", // BYO OAuth (founder supplies dev creds) + // WORDPRESS: "ac_TBD_WORDPRESS", // managed OAuth (.com flow); slug verify pending } as const satisfies Record<string, string>; export type Toolkit = keyof typeof SHARED_AUTH_CONFIG_IDS; @@ -61,45 +73,51 @@ export const SUPPORTED_TOOLKITS = Object.keys(SHARED_AUTH_CONFIG_IDS) as Toolkit * The cards show a "Setup required" state instead of a working Connect button. */ export const EXTRA_DISPLAYED_TOOLKITS = [ - // Email + calendar parity + // Content publishing — primary destinations (auth config TBD) + "REDDIT", + "TWITTER", + "WORDPRESS", + "GHOST", + "WEBFLOW", + "HASHNODE", + "MEDIUM", + "SUBSTACK", + "DEV", // dev.to + // Content research + grounding + "FIRECRAWL", + "PERPLEXITY", + "TAVILY", + "EXA", + "GOOGLE_SEARCH_CONSOLE", + "GOOGLE_ANALYTICS", + "SEMRUSH", + "AHREFS", + // Listening for content signals + "YOUTUBE", + // Legacy GTM toolkits (kept; surfaced under "More") "OUTLOOK", "ZOOM", - // CRM alternatives "SALESFORCE", "PIPEDRIVE", "ATTIO", - // Listening / new lead sources - "REDDIT", - "YOUTUBE", - "TWITTER", - // Research / web (BYO) "APOLLO", - "TAVILY", - "EXA", - "FIRECRAWL", - "PERPLEXITY", "HUNTER", "CRUNCHBASE", "CLAY", - // Outbound sequencers "LEMLIST", "INSTANTLY", "SMARTLEAD", "SALESLOFT", - // PM tools (Linear alternatives) "ASANA", "JIRA", "MONDAY", "CLICKUP", "TRELLO", - // Bulk email / lifecycle "MAILCHIMP", "CUSTOMERIO", - // Product analytics → activation persona "MIXPANEL", "AMPLITUDE", "POSTHOG", - // Call intelligence → brief-writer "GONG", "FIREFLIES", "CHORUS", diff --git a/lib/shared/env.ts b/lib/shared/env.ts index 46713a7..2f024c3 100644 --- a/lib/shared/env.ts +++ b/lib/shared/env.ts @@ -90,6 +90,12 @@ const EnvSchema = z.object({ GMAESTRO_BASE_URL: z.string().url().default("http://localhost:3000"), /** Optional override: 'tier1' forces sequential dispatch (concurrency=1). */ GMAESTRO_TIER: z.enum(["auto", "tier1", "tier2plus"]).default("auto"), + /** + * Slack channel or user to DM when an approval is raised. Either a channel + * id (`C…`/`D…`), channel name (`#general`), or a user id (`U…`) — Slack + * auto-opens a DM for the latter. When unset, no Slack notification fires. + */ + GMAESTRO_SLACK_CHANNEL: z.string().optional(), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), diff --git a/lib/shared/mocks.ts b/lib/shared/mocks.ts index c6e9759..9f1e7ac 100644 --- a/lib/shared/mocks.ts +++ b/lib/shared/mocks.ts @@ -20,10 +20,14 @@ import type { ActivationNudge, ActivityEvent, ApprovalRequest, + BlogDraft, BookedMeeting, + ChannelVariant, CompanyContext, ComposioMcpConfig, Connection, + ContentOutline, + Department, EnrichedLead, GtmMetric, Lead, @@ -33,6 +37,7 @@ import type { PersonaId, PrepBrief, QualifiedLead, + TopicResearchBrief, TrialSignal, VoiceSample, WorkflowDAG, @@ -139,40 +144,40 @@ const __DEMO_LEADS_1 = makeDemoLeads(1); export const MOCK_PAST_RUNS: MockPastRun[] = [ { - id: "mock-run-yc-launch", - title: "Process YC HN launch leads", + id: "mock-run-founder-led-gtm", + title: "Ship founder-led GTM blog", prompt: - "I'm a YC W26 founder. 5 demo requests came in this week from our HN launch. I have 3 hours before cofounder offsite. Process them.", + "Anvil hit 1k WAU. Plan and ship a 2k-word blog on founder-led GTM in the AI era, optimized for Perplexity citations. Cross-post to r/SaaS, LinkedIn, and our static-site repo.", state: "done", startedAt: __isoAgo(2 * __HOUR), completedAt: __isoAgo(2 * __HOUR - 18 * 60_000), - leads: __DEMO_LEADS_5, - plan: makeMaterializedMockDAG(__DEMO_LEADS_5), + plan: makeMockWorkflowDAG(), }, { - id: "mock-run-acme-inbound", - title: "Process inbound from Acme", - prompt: "Process this one inbound lead from acme.com", + id: "mock-run-onboarding-post", + title: "LLM-native onboarding post", + prompt: + "Draft a blog on what we learned about LLM-native onboarding from our first 100 trials. Voice: peer-to-peer, no hype.", state: "done", startedAt: __isoAgo(__DAY), completedAt: __isoAgo(__DAY - 6 * 60_000), - leads: __DEMO_LEADS_1, - plan: makeMaterializedMockDAG(__DEMO_LEADS_1), + plan: makeMockWorkflowDAG(), }, { - id: "mock-run-trial-checkin", - title: "Activation check on 12 trials", - prompt: "Daily activation check on 12 trial users.", + id: "mock-run-geo-audit", + title: "GEO audit + topic gaps", + prompt: + "Audit our existing site at anvil.co/blog. Tell me which 3 topics we're missing relative to our top-citing competitors, then draft the highest-priority one.", state: "done", startedAt: __isoAgo(2 * __DAY), completedAt: __isoAgo(2 * __DAY - 12 * 60_000), plan: makeMockWorkflowDAG(), }, { - id: "mock-run-bug-feedback", - title: "Bug report → Linear + DM", + id: "mock-run-content-sprint", + title: "Weekly content sprint", prompt: - "Customer just reported a bug in our Slack — file it in Linear and update them with the fix ETA.", + "It's Monday. Plan and queue 3 blog posts for the week, each with a Reddit + LinkedIn cross-post variant.", state: "failed", startedAt: __isoAgo(3 * __DAY), completedAt: __isoAgo(3 * __DAY - 4 * 60_000), @@ -383,15 +388,16 @@ export function makeMockApprovalRequest( return { id: nextId("mock-approval"), workflowRunId: overrides.workflowRunId ?? nextId("mock-run"), - artifactType: "OutreachDraft", - artifactId: nextId("mock-draft"), + artifactType: "BlogDraft", + artifactId: nextId("mock-blog"), blastRadius: "external", - reason: "Sending a personalized email to a real prospect outside the team.", + reason: + "Publishing a post under the company's name. Founder picks destinations at this gate.", proposedAction: { - tool: "gmail.send", - to: "jordan@acme.example", - subject: "Demo for Acme", - body: "[draft body…]", + title: "Why founder-led GTM beats AI cold email in 2026", + slug: "founder-led-gtm-beats-ai-cold-email", + excerpt: "AI cold email has hit a ceiling. Here's what wins instead.", + bodyMarkdown: "[draft body…]", }, status: "pending", createdAt: new Date(), @@ -438,43 +444,31 @@ export function makeMockWorkflowDAG(): WorkflowDAG { return { tasks: [ { - id: "linkedin-researcher", - specialistId: "linkedin-researcher", - input: { topic: "${prompt}" }, - passOutput: ["id", "keywords", "icpSignals", "competitorAngles"], + id: "researcher", + specialistId: "researcher", + input: { topic: "founder-led GTM in the AI era" }, + passOutput: ["recommendedTopic", "candidates", "competitorScan"], }, { - id: "x-researcher", - specialistId: "x-researcher", - input: { topic: "${prompt}" }, - passOutput: ["id", "keywords", "trendingThreads", "seoTerms"], - }, - { - id: "reddit-researcher", - specialistId: "reddit-researcher", - input: { topic: "${prompt}" }, - passOutput: ["id", "keywords", "subreddits", "commonQuestions"], - }, - { - id: "synthesizer", - specialistId: "synthesizer", - input: {}, - dependsOn: ["linkedin-researcher", "x-researcher", "reddit-researcher"], - passOutput: ["id", "topic", "outline", "keywords", "audienceSummary"], + id: "strategist", + specialistId: "strategist", + input: { topic: "founder-led GTM in the AI era" }, + dependsOn: ["researcher"], + passOutput: ["title", "thesis", "sections", "geoSignals"], }, { - id: "blog-writer", - specialistId: "blog-writer", - input: {}, - dependsOn: ["synthesizer"], - passOutput: ["id", "title", "hook", "sections", "wordCount"], + id: "writer", + specialistId: "writer", + input: { topic: "founder-led GTM in the AI era" }, + dependsOn: ["strategist"], + passOutput: ["id", "title", "slug", "bodyMarkdown"], }, { - id: "blog-designer", - specialistId: "blog-designer", + id: "geo-editor", + specialistId: "geo-editor", input: {}, - dependsOn: ["blog-writer"], - passOutput: ["id", "html", "suggestedTitle"], + dependsOn: ["writer"], + passOutput: ["id", "bodyMarkdown", "geoNotes", "factDensityRatio"], }, ], }; @@ -491,38 +485,17 @@ export function makeMockWorkflowDAG(): WorkflowDAG { * intentionally kept compatible: same task-id shape (`<persona>-<leadId>`), * same per-lead `dependsOn` chain. */ -export function makeMaterializedMockDAG(leads: Lead[]): WorkflowDAG { - if (leads.length === 0) return { tasks: [] }; - const personas = ["researcher", "qualifier", "strategist", "writer"] as const; - const passOutput: Record<(typeof personas)[number], string[]> = { - researcher: ["id", "leadId", "personRole", "companyIndustry"], - qualifier: ["id", "tier", "fitScore", "recommendedAction"], - strategist: ["id", "tier", "angle", "callToAction"], - writer: ["id", "subject", "body", "channel"], - }; - const tasks: WorkflowDAG["tasks"] = []; - for (const lead of leads) { - let prevId: string | null = null; - for (const p of personas) { - const id = `${p}-${lead.id}`; - tasks.push({ - id, - specialistId: p, - input: { - lead: { - id: lead.id, - name: lead.name, - email: lead.email, - company: lead.company, - }, - }, - ...(prevId ? { dependsOn: [prevId] } : {}), - passOutput: passOutput[p], - }); - prevId = id; - } - } - return { tasks }; +/** + * Legacy GTM-flavored materialized DAG. Kept compiling for any pre-pivot mock + * fixtures that still reference it. New content-domain fixtures should use + * `makeMockWorkflowDAG()` directly (the content workflow is single-blog by + * default; multi-topic fanout doesn't need denormalized lead labels). + */ +export function makeMaterializedMockDAG(_leads: Lead[]): WorkflowDAG { + // No-op pass-through to the canonical content-flavored mock so dashboards + // calling this in NEXT_PUBLIC_USE_MOCKS=1 mode still render something + // sensible. The `_leads` arg is ignored. + return makeMockWorkflowDAG(); } export function makeMockActivityEvent( @@ -660,11 +633,8 @@ export function makeMockMcpConfig(): ComposioMcpConfig { } /** - * Mock implementation of Session 2's runPersona() for Session 1 to use until - * the real one lands. - * - * Returns a typed mock artifact based on personaId after a 100–500ms delay - * (simulating LLM latency). + * Mock implementation of runPersona() for parallel-session development. + * Returns a typed mock artifact based on personaId after a 100–500ms delay. */ export function makeMockPersonaRuntime() { return async function runPersona<TIn, TOut>( @@ -674,25 +644,52 @@ export function makeMockPersonaRuntime() { const delayMs = 100 + Math.random() * 400; await new Promise((r) => setTimeout(r, delayMs)); + void input; switch (personaId) { case "researcher": - return makeMockEnrichedLead({ - leadId: (input as { leadId?: string }).leadId, - }) as unknown as TOut; - case "qualifier": - return makeMockQualifiedLead({ - leadId: (input as { leadId?: string }).leadId, - }) as unknown as TOut; + return makeMockTopicResearchBrief() as unknown as TOut; case "strategist": - return makeMockOutreachStrategy() as unknown as TOut; + return makeMockContentOutline() as unknown as TOut; case "writer": - return makeMockOutreachDraft() as unknown as TOut; - case "scheduler": - return makeMockBookedMeeting() as unknown as TOut; - case "brief-writer": - return makeMockPrepBrief() as unknown as TOut; - case "activation": - return makeMockActivationNudge() as unknown as TOut; + return makeMockBlogDraft() as unknown as TOut; + case "geo-editor": + return makeMockBlogDraft({ + geoNotes: ["Tightened opening to direct-answer", "Added FAQ schema"], + factDensityRatio: 0.7, + }) as unknown as TOut; + case "formatter": + return makeMockChannelVariant() as unknown as TOut; + case "pipeline-reporter": + return { + summary: + "Shipped 1 blog post (1,420 words, 7 GEO signals applied). Live on GitHub PR + r/SaaS + LinkedIn.", + metrics: { + wordCount: 1420, + geoSignalsApplied: 7, + channelsPublished: 3, + channelsFailed: 0, + channelsPending: 0, + }, + } as unknown as TOut; + case "slack-digest": + return { + digestText: + "*New post live*: \"Why founder-led GTM beats AI cold email in 2026\"\n• Published to: GitHub PR, r/SaaS, LinkedIn", + channel: "#content", + messageTs: "pending-mock", + } as unknown as TOut; + case "feedback-tagger": + return { themes: ["audience:asks-followup"], sentiment: "pos" } as unknown as TOut; + case "theme-synthesizer": + return { + themes: ["topic:pricing-model", "audience:asks-followup"], + notionPageUrl: "https://www.notion.so/gmaestro-content-themes-mock", + } as unknown as TOut; + case "linear-filer": + return { + issueId: "LIN-content-1", + issueUrl: "https://linear.app/gmaestro/issue/LIN-content-1", + } as unknown as TOut; default: return { ok: true, personaId, input } as unknown as TOut; } @@ -708,54 +705,33 @@ export function makeMockEventBus(): Emitter<Record<string, unknown>> { } /** - * Mock persona registry for Session 1/3 to render DAG without needing - * Session 2's full registry. Returns plausible Persona objects for all 13. + * Mock persona registry for parallel-session dashboard rendering. + * Returns plausible Persona objects for all 10 content-domain personas. */ export function makeMockPersonaRegistry(): Persona[] { const ids: PersonaId[] = [ "researcher", - "qualifier", "strategist", "writer", - "scheduler", - "brief-writer", - "activation", - "crm-logger", + "geo-editor", + "formatter", "pipeline-reporter", "slack-digest", "feedback-tagger", "theme-synthesizer", "linear-filer", - "linkedin-researcher", - "x-researcher", - "reddit-researcher", - "synthesizer", - "blog-writer", - "blog-designer", ]; - const deptOf: Record< - PersonaId, - "sales" | "cs" | "revops" | "insight" | "content" - > = { - researcher: "sales", - qualifier: "sales", - strategist: "sales", - writer: "sales", - scheduler: "sales", - "brief-writer": "sales", - activation: "cs", - "crm-logger": "revops", - "pipeline-reporter": "revops", - "slack-digest": "revops", + const deptOf: Record<PersonaId, Department> = { + researcher: "content", + strategist: "content", + writer: "content", + "geo-editor": "content", + formatter: "content", + "pipeline-reporter": "distribution", + "slack-digest": "distribution", "feedback-tagger": "insight", "theme-synthesizer": "insight", "linear-filer": "insight", - "linkedin-researcher": "content", - "x-researcher": "content", - "reddit-researcher": "content", - synthesizer: "content", - "blog-writer": "content", - "blog-designer": "content", }; return ids.map((id) => ({ id, @@ -784,31 +760,31 @@ export async function* makeMockEventStream( }, { workflowRunId, - nodeId: "sales-mgr", + nodeId: "content-mgr", type: "persona_started", - payload: { layer: "manager", department: "sales" }, + payload: { layer: "manager", department: "content" }, }, { workflowRunId, - nodeId: "researcher-1", + nodeId: "researcher", type: "persona_started", payload: { specialistId: "researcher" }, }, { workflowRunId, - nodeId: "researcher-1", + nodeId: "researcher", type: "tool_called", - payload: { tool: "LINKEDIN_GET_PROFILE" }, + payload: { tool: "REDDIT_SEARCH_POSTS" }, }, { workflowRunId, - nodeId: "researcher-1", + nodeId: "researcher", type: "artifact_created", - payload: { artifactType: "EnrichedLead", artifactId: "mock-enriched-001" }, + payload: { artifactType: "TopicResearchBrief", artifactId: "mock-tbrief-001" }, }, { workflowRunId, - nodeId: "researcher-1", + nodeId: "researcher", type: "persona_completed", payload: {}, }, @@ -819,3 +795,147 @@ export async function* makeMockEventStream( } } +// ============================================================================ +// Content-domain factories (post-pivot) +// ============================================================================ + +export function makeMockTopicResearchBrief( + overrides: Partial<TopicResearchBrief> = {}, +): TopicResearchBrief { + return { + id: nextId("mock-tbrief"), + topic: "founder-led GTM in the AI era", + candidates: [ + { + title: "Why founder-led GTM beats AI cold email in 2026", + angle: "AI cold email has hit a ceiling — but blogs are the opposite", + rationale: + "Multiple Reddit threads on r/SaaS reveal founders frustrated with cold-email response rates dropping below 1%; meanwhile content-driven inbound is up 3× YoY. Strong contrarian wedge.", + citations: [ + { + source: "reddit", + url: "https://reddit.com/r/SaaS/comments/abc/cold_email_dead", + title: "Cold email response rates fell off a cliff this year", + excerpt: "We went from 4% to <1% response in 6 months…", + }, + ], + }, + ], + recommendedTopic: "Why founder-led GTM beats AI cold email in 2026", + competitorScan: [ + { + url: "https://lavender.ai/blog/cold-email-trends-2026", + summary: + "Argues for better cold email tooling. Misses the structural shift to content-led growth — that's our wedge.", + }, + ], + citationFootprint: + "Perplexity currently cites Lavender, Apollo, and Outreach blogs for 'best cold email practices'. Anvil is not in the cited set.", + createdAt: new Date(), + ...overrides, + }; +} + +export function makeMockContentOutline( + overrides: Partial<ContentOutline> = {}, +): ContentOutline { + return { + id: nextId("mock-outline"), + title: "Why founder-led GTM beats AI cold email in 2026", + thesis: + "Founders who delegate cold email lose deals; founders who delegate blogs win them. The asymmetry is structural.", + audience: "Pre-Series A founders running their own GTM", + sections: [ + { + heading: "What changed: the cold-email response cliff", + keyPoints: [ + "Response rates dropped from 4% to <1% in 12 months", + "Buyers now treat cold email as adversarial", + ], + }, + { + heading: "Why blogs are the opposite signal", + keyPoints: [ + "Inbound from content scales without burning trust", + "AI search (Perplexity / ChatGPT) compounds blog reach", + ], + }, + { + heading: "The founder-in-loop blueprint", + keyPoints: [ + "Delegate research + drafting + distribution", + "Keep approval gates on every irreversible publish", + ], + }, + ], + targetKeywords: [ + "AI cold email decline 2026", + "founder-led content marketing", + "GEO for early-stage SaaS", + ], + geoSignals: [ + "Lead with 60-word direct answer to 'is cold email dead?'", + "Cite the r/SaaS thread in section 1", + "Include 1 stat per 150 words minimum", + "End with founder-voice quote in section 3", + ], + estimatedWordCount: 1500, + approvalStatus: "pending", + createdAt: new Date(), + ...overrides, + }; +} + +export function makeMockBlogDraft( + overrides: Partial<BlogDraft> = {}, +): BlogDraft { + return { + id: nextId("mock-blog"), + title: "Why founder-led GTM beats AI cold email in 2026", + slug: "founder-led-gtm-beats-ai-cold-email", + excerpt: + "AI cold email has hit a 1% response ceiling. Here's what's working instead — and how founders should rebuild their GTM around it.", + bodyMarkdown: + "## What changed\n\nCold email response rates dropped from 4% to <1% in twelve months. Buyers now treat unsolicited email as adversarial.\n\n…", + tags: ["founder-led-gtm", "content-marketing", "geo"], + citations: [ + { + source: "reddit", + url: "https://reddit.com/r/SaaS/comments/abc", + title: "Cold email response rates fell off a cliff", + }, + ], + geoNotes: [ + "Tightened opening to direct-answer in first 60 words", + "Pulled stat into blockquote callout", + "Recommended FAQPage schema at publish", + ], + factDensityRatio: 0.7, + approvalStatus: "pending", + createdAt: new Date(), + ...overrides, + }; +} + +export function makeMockChannelVariant( + overrides: Partial<ChannelVariant> = {}, +): ChannelVariant { + return { + id: nextId("mock-cv"), + blogDraftId: overrides.blogDraftId ?? nextId("mock-blog"), + target: "github", + content: + "---\ntitle: Why founder-led GTM beats AI cold email in 2026\nslug: founder-led-gtm-beats-ai-cold-email\n---\n\n## What changed\n\nCold email response rates dropped…", + metadata: { + repo: "anvil-co/anvil-site", + branch: "content/founder-led-gtm", + path: "content/blog/founder-led-gtm-beats-ai-cold-email.mdx", + prTitle: "Add post: Why founder-led GTM beats AI cold email in 2026", + prBody: "AI cold email has hit a 1% response ceiling. Here's what's working instead.", + }, + approvalStatus: "pending", + createdAt: new Date(), + ...overrides, + }; +} + diff --git a/lib/shared/schemas.ts b/lib/shared/schemas.ts index 0d7da20..0a9dd0c 100644 --- a/lib/shared/schemas.ts +++ b/lib/shared/schemas.ts @@ -6,6 +6,10 @@ * - LLM-produced JSON (Conductor / Manager structured output) * - Persona inputs/outputs * + * Pivoted 2026-05-09 from GTM to content/blog/GEO domain. Legacy GTM schemas + * (OutreachDraft, etc.) are retained at the bottom for DB-layer compat but + * are NOT part of the active ApprovalArtifactType union. + * * Owned by: Foundation. PARALLEL SESSIONS DO NOT MODIFY. */ @@ -14,55 +18,62 @@ import { z } from "zod"; // ----- enums ----- export const PersonaIdSchema = z.enum([ + // Content "researcher", - "qualifier", "strategist", "writer", - "scheduler", - "brief-writer", - "activation", - "crm-logger", + "geo-editor", + "formatter", + // Distribution "pipeline-reporter", "slack-digest", + // Insight "feedback-tagger", "theme-synthesizer", "linear-filer", ]); -export const DepartmentSchema = z.enum(["sales", "cs", "revops", "insight"]); +export const DepartmentSchema = z.enum(["content", "distribution", "insight"]); export const LayerSchema = z.enum(["conductor", "manager", "specialist"]); export const ModelTierSchema = z.enum(["opus", "sonnet", "haiku"]); -export const LeadSourceSchema = z.enum([ - "inbound_form", - "trial_signup", - "manual_import", +export const ToolkitIdSchema = z.enum([ + "github", + "wordpress", + "ghost", + "notion", + "reddit", + "linkedin", + "twitter", ]); -export const SenioritySchema = z.enum([ - "IC", - "Manager", - "Director", - "VP", - "CXO", - "Founder", -]); +/** Single output target the founder picks at the run-input form. */ +export const DestinationSchema = z.enum(["blog-html", "reddit", "x-thread"]); -export const TierSchema = z.enum(["hot", "warm", "cold", "disqualified"]); -export const RecommendedActionSchema = z.enum([ - "book_call", - "email_sequence", - "self_serve", - "reject", -]); -export const CallToActionSchema = z.enum([ - "book_call", - "free_trial", - "demo_video", -]); -export const OutreachChannelSchema = z.enum(["email", "linkedin"]); -export const StripeStatusSchema = z.enum(["trialing", "active", "churned"]); -export const ActivationChannelSchema = z.enum(["email", "in_app"]); +/** + * Voice fingerprint extracted from the company's existing blog posts. + * Threaded into the Strategist + Writer prompts so the generated post + * matches the company's actual voice. Computed mechanically — see + * lib/personas/researcher/company-fetch.ts for the 10 extraction rules. + */ +export const VoiceFingerprintSchema = z.object({ + sentenceLength: z.object({ + mean: z.number(), + stdev: z.number(), + }), + pronounMode: z.enum(["we", "i", "neutral"]), + hookPattern: z.enum(["anomaly", "contrarian", "stat-led", "announcement"]), + headingStyle: z.enum(["topical", "question", "named-concept"]), + codeBlocksPerPost: z.number(), + opinionDensity: z.number(), + bannedWords: z.array(z.string()).default([]), + closingPattern: z.enum(["single-line-punch", "wrapping-up", "cta-only"]), + statDensity: z.number(), + wordsPerSection: z.number(), + samples: z.array(z.string()).default([]), + productDescription: z.string().optional(), + companyName: z.string().optional(), +}); export const ApprovalStatusSchema = z.enum([ "pending", @@ -72,16 +83,19 @@ export const ApprovalStatusSchema = z.enum([ "changes_requested", "expired", ]); + export const BlastRadiusSchema = z.enum([ "internal", "external", "irreversible", ]); + export const ApprovalArtifactTypeSchema = z.enum([ - "OutreachDraft", - "ActivationNudge", - "CRMUpdate", - "CustomDeal", + "TopicResearchBrief", + "ContentOutline", + "BlogDraft", + "ChannelVariant", + "PublishedArtifact", ]); export const WorkflowStateSchema = z.enum([ @@ -91,6 +105,7 @@ export const WorkflowStateSchema = z.enum([ "done", "failed", ]); + export const NodeStatusSchema = z.enum([ "pending", "running", @@ -101,7 +116,7 @@ export const NodeStatusSchema = z.enum([ ]); export const TriggerRuleSchema = z.enum(["all_success", "all_done"]); -export const FanoutSourceSchema = z.enum(["leads", "trial-signals"]); +export const FanoutSourceSchema = z.enum(["topics", "channels"]); export const TaskModeSchema = z.enum(["fanout", "batch"]); export const ActivityEventTypeSchema = z.enum([ @@ -121,67 +136,130 @@ export const ConnectionStatusSchema = z.enum([ "revoked", ]); -// ----- entities ----- +export const CitationSourceSchema = z.enum([ + "reddit", + "twitter", + "linkedin", + "blog", + "perplexity", + "hackernews", + "other", +]); -export const SocialPostSchema = z.object({ - platform: z.string(), - content: z.string(), +// ----- Citations + outline pieces (shared across content artifacts) ----- + +export const SourceCitationSchema = z.object({ + source: CitationSourceSchema, url: z.string().url(), + title: z.string().optional(), + excerpt: z.string().optional(), }); -export const LeadSchema = z.object({ - id: z.string(), - email: z.string().email(), - name: z.string(), - company: z.string().nullable().optional(), - source: LeadSourceSchema, - rawMessage: z.string().nullable().optional(), - createdAt: z.date(), +export const TopicCandidateSchema = z.object({ + title: z.string(), + angle: z.string(), + rationale: z.string(), + citations: z.array(SourceCitationSchema).default([]), }); -// LLM-only output schemas: id and *At timestamps are infra-side defaults so -// the model only has to produce semantic fields. Without defaults, every -// persona output failed validation because models can't fabricate uuids -// or timestamps reliably. -export const EnrichedLeadSchema = z.object({ +export const OutlineSectionSchema = z.object({ + heading: z.string(), + keyPoints: z.array(z.string()).default([]), + sourcesToCite: z.array(SourceCitationSchema).optional(), +}); + +// ----- Content artifact schemas (LLM outputs) ----- +// +// id and *At timestamps are infra-side defaults so the model only has to +// produce semantic fields. Without defaults, every persona output failed +// validation because models can't fabricate uuids or timestamps reliably. + +export const TopicResearchBriefSchema = z.object({ id: z .string() - .default(() => `enr_${Math.random().toString(36).slice(2, 10)}`), - leadId: z.string(), - linkedinUrl: z.string().nullable().optional(), - companyDomain: z.string().nullable().optional(), - companySize: z.number().int().nullable().optional(), - companyIndustry: z.string().nullable().optional(), - personRole: z.string().nullable().optional(), - personSeniority: SenioritySchema.nullable().optional(), - intentSignals: z.array(z.string()).default([]), - techStack: z.array(z.string()).nullable().optional(), - recentSocial: z.array(SocialPostSchema).nullable().optional(), - enrichedAt: z.coerce.date().default(() => new Date()), + .default(() => `tbrief_${Math.random().toString(36).slice(2, 10)}`), + topic: z.string(), + candidates: z.array(TopicCandidateSchema).default([]), + recommendedTopic: z.string(), + competitorScan: z + .array(z.object({ url: z.string().url(), summary: z.string() })) + .default([]), + citationFootprint: z.string().optional(), + createdAt: z.coerce.date().default(() => new Date()), }); -export const QualifiedLeadSchema = z.object({ +export const ContentOutlineSchema = z.object({ id: z .string() - .default(() => `qual_${Math.random().toString(36).slice(2, 10)}`), - leadId: z.string(), - tier: TierSchema, - fitScore: z.number().min(0).max(100), - fitReasons: z.array(z.string()).default([]), - intentScore: z.number().min(0).max(100), - intentReasons: z.array(z.string()).default([]), - recommendedAction: RecommendedActionSchema, - qualifiedAt: z.coerce.date().default(() => new Date()), + .default(() => `outline_${Math.random().toString(36).slice(2, 10)}`), + topicResearchBriefId: z.string().optional(), + title: z.string(), + thesis: z.string(), + audience: z.string(), + sections: z.array(OutlineSectionSchema).min(1), + targetKeywords: z.array(z.string()).default([]), + geoSignals: z.array(z.string()).default([]), + estimatedWordCount: z.number().int().positive().default(1500), + approvalStatus: ApprovalStatusSchema.default("pending"), + createdAt: z.coerce.date().default(() => new Date()), +}); + +export const BlogDraftSchema = z.object({ + id: z + .string() + .default(() => `blog_${Math.random().toString(36).slice(2, 10)}`), + outlineId: z.string().optional(), + title: z.string(), + slug: z.string(), + excerpt: z.string(), + bodyMarkdown: z.string(), + tags: z.array(z.string()).default([]), + citations: z.array(SourceCitationSchema).default([]), + geoNotes: z.array(z.string()).optional(), + factDensityRatio: z.number().nonnegative().optional(), + /** + * Set at approval time (founder ticks targets). Not produced by the Writer + * or GEO-Editor — it's appended to the persisted draft when the BlogDraft + * approval resolves. + */ + targets: z.array(ToolkitIdSchema).optional(), + approvalStatus: ApprovalStatusSchema.default("pending"), + founderEdits: z.string().nullable().optional(), + createdAt: z.coerce.date().default(() => new Date()), +}); + +export const ChannelVariantSchema = z.object({ + id: z + .string() + .default(() => `cv_${Math.random().toString(36).slice(2, 10)}`), + blogDraftId: z.string(), + target: ToolkitIdSchema, + content: z.string(), + metadata: z.record(z.string(), z.unknown()).default({}), + approvalStatus: ApprovalStatusSchema.default("pending"), + createdAt: z.coerce.date().default(() => new Date()), }); +export const PublishedArtifactSchema = z.object({ + id: z + .string() + .default(() => `pub_${Math.random().toString(36).slice(2, 10)}`), + channelVariantId: z.string(), + target: ToolkitIdSchema, + externalUrl: z.string().url().optional(), + externalId: z.string(), + publishedAt: z.coerce.date().default(() => new Date()), +}); + +// ----- Batch envelope helpers (unchanged shape) ----- + /** - * Optional cross-lead reasoning surface emitted by the batch qualifier. - * Indicates groups of leads the qualifier merged or flagged as duplicates - * (e.g. multiple inbounds from the same company). Surfaced on the dashboard - * as a small "merged N duplicates" badge on the qualifier stage card. + * Optional cross-item reasoning surface emitted by a batch persona. For the + * content domain, the researcher's batch over topic candidates can flag + * "merged groups" of duplicate or near-duplicate topics. */ export const MergedGroupSchema = z.object({ - leadIds: z.array(z.string()).min(2), + ids: z.array(z.string()).min(2), reason: z.string(), }); export type MergedGroup = z.infer<typeof MergedGroupSchema>; @@ -189,24 +267,16 @@ export type MergedGroup = z.infer<typeof MergedGroupSchema>; /** * Per-item error row a batch persona emits when an integration fails or a * sub-call returns auth-required. Always carries the source-item id so the - * dispatcher can correlate; downstream sees this as a failed shadow and - * skip-cascade kicks in for that chain (other chains continue). + * dispatcher can correlate. */ -export const BatchItemErrorSchema = z.union([ - z.object({ leadId: z.string(), error: z.string() }).passthrough(), - z.object({ trialSignalId: z.string(), error: z.string() }).passthrough(), -]); +export const BatchItemErrorSchema = z + .object({ id: z.string(), error: z.string() }) + .passthrough(); /** * Batch envelope: one persona invocation produces an array of items keyed by - * the source-item id (`leadId` for leads-fanout, `trialSignalId` for trials). - * The dispatcher uses the keying field to unroll back into per-instance - * chainOutputs so downstream fanout tasks see the matching upstream item. - * - * Each item is EITHER a full success record (matching `item`) OR an error - * row (just the id + error string). Error rows are the model's honest signal - * that a sub-call failed — the dispatcher treats those instances as failed - * (skip-cascade) while letting other instances proceed. + * the source-item id. The dispatcher uses the keying field to unroll back into + * per-instance chainOutputs so downstream fanout tasks see the matching item. */ export function makeBatchOutputSchema<T extends z.ZodTypeAny>(item: T) { return z.object({ @@ -215,96 +285,7 @@ export function makeBatchOutputSchema<T extends z.ZodTypeAny>(item: T) { }); } -export const OutreachStrategySchema = z.object({ - id: z - .string() - .default(() => `strat_${Math.random().toString(36).slice(2, 10)}`), - leadId: z.string(), - tier: z.enum(["hot", "warm", "cold"]), - angle: z.string(), - toneGuide: z.string(), - callToAction: CallToActionSchema, - customHooks: z.array(z.string()).default([]), - createdAt: z.coerce.date().default(() => new Date()), -}); - -export const OutreachDraftSchema = z.object({ - // id, createdAt, approvalStatus are infra-side defaults — the LLM only - // needs to produce content (leadId, channel, subject, body, to, rationale). - // Defaults here mean a writer that emits just `{ leadId, channel, body }` - // parses cleanly without forcing the model to fabricate uuids/timestamps. - id: z - .string() - .default(() => `draft_${Math.random().toString(36).slice(2, 10)}`), - leadId: z.string(), - channel: OutreachChannelSchema.default("email"), - subject: z.string().nullable().optional(), - body: z.string(), - // Recipient address (so the dashboard's post-approval send dispatcher - // doesn't have to re-look-up the lead). Optional — writer should produce - // it but legacy rows may not have it. - to: z.string().nullable().optional(), - // Writer's one-sentence "why this draft" — surfaced on the approval card - // so the founder sees the reasoning at a glance. - rationale: z.string().nullable().optional(), - approvalStatus: ApprovalStatusSchema.default("pending"), - founderEdits: z.string().nullable().optional(), - createdAt: z.coerce.date().default(() => new Date()), - sentAt: z.coerce.date().nullable().optional(), -}); - -export const BookedMeetingSchema = z.object({ - id: z - .string() - .default(() => `meet_${Math.random().toString(36).slice(2, 10)}`), - leadId: z.string(), - startsAt: z.coerce.date(), - durationMin: z.number().int().positive().default(30), - meetingLink: z.string().url(), - attendees: z.array(z.string()).default([]), - bookedAt: z.coerce.date().default(() => new Date()), -}); - -export const PrepBriefSchema = z.object({ - id: z - .string() - .default(() => `brief_${Math.random().toString(36).slice(2, 10)}`), - meetingId: z.string(), - notionPageUrl: z.string().url(), - leadSummary: z.string(), - companyContext: z.string(), - likelyUseCase: z.string(), - similarPriorEmails: z.array(z.string()).default([]), - talkingPoints: z.array(z.string()).default([]), - questionsToAsk: z.array(z.string()).default([]), - potentialObjections: z.array(z.string()).default([]), - recommendedNextSteps: z.array(z.string()).default([]), - createdAt: z.coerce.date().default(() => new Date()), -}); - -export const TrialSignalSchema = z.object({ - id: z.string(), - leadId: z.string(), - signupAt: z.date(), - invitedTeammates: z.number().int().nonnegative(), - featuresUsed: z.array(z.string()), - stalledAtStep: z.string().nullable().optional(), - stripeStatus: StripeStatusSchema, - trialEndsAt: z.date().nullable().optional(), -}); - -export const ActivationNudgeSchema = z.object({ - id: z - .string() - .default(() => `act_${Math.random().toString(36).slice(2, 10)}`), - leadId: z.string(), - channel: ActivationChannelSchema, - subject: z.string().nullable().optional(), - body: z.string(), - loomScript: z.string().nullable().optional(), - approvalStatus: ApprovalStatusSchema.default("pending"), - createdAt: z.coerce.date().default(() => new Date()), -}); +// ----- Approval gate ----- export const ApprovalRequestSchema = z.object({ id: z.string(), @@ -320,7 +301,7 @@ export const ApprovalRequestSchema = z.object({ resolvedAt: z.date().nullable().optional(), }); -// ----- workflow / DAG (CRITICAL: this is what the Conductor outputs as JSON) ----- +// ----- Workflow / DAG (CRITICAL: this is what the Conductor outputs as JSON) ----- export const WorkflowTaskSchema = z.object({ id: z.string(), @@ -452,27 +433,47 @@ export const CompanyContextInputSchema = CompanyContextSchema.omit({ // ----- API request schemas ----- +/** + * The 3-input form payload. Founder gives us a company URL (for voice + + * product context), a docs URL (the topic source), and a single + * destination. v1 also accepts a freeform `prompt` for backward compat — + * if both are present, the structured fields win. + */ export const RunWorkflowRequestSchema = z.object({ - prompt: z.string().min(1).max(10_000), -}); + companyUrl: z.string().url().optional(), + docsUrl: z.string().url().optional(), + destination: DestinationSchema.optional(), + // Backward compat — older callers + the persona harness still send a prompt. + prompt: z.string().min(1).max(10_000).optional(), +}).refine( + (v) => (v.companyUrl && v.docsUrl && v.destination) || v.prompt, + { + message: + "must provide either {companyUrl, docsUrl, destination} or a prompt string", + }, +); export const ResolveApprovalRequestSchema = z.object({ status: z.enum(["approved", "edited", "rejected", "changes_requested"]), edits: z.string().optional(), founderNotes: z.string().optional(), /** - * Optional toolkit slug (e.g. "gmail", "outlook") naming the integration the - * founder chose to dispatch this approval through. Only honored when - * status === "approved" or "edited" — rejections never trigger a Composio - * call. Omit (or pass empty) to mark approved locally without any external - * action. + * Optional toolkit slug naming the integration the founder chose to dispatch + * this approval through. Only honored when status === "approved" or "edited". + * For BlogDraft approvals, the founder picks N targets via `targets` instead. */ provider: z.string().optional(), + /** + * For BlogDraft approvals: which destinations to publish to. The dispatcher + * fans out one ChannelVariant per target. Ignored on non-BlogDraft approvals. + */ + targets: z.array(ToolkitIdSchema).optional(), }); /** * Bulk approval payload: founder approves a batch of pending approvals from - * a single workflow run with per-row reject overrides. + * a single workflow run with per-row reject overrides. Used for the + * post-Formatter "approve all channel variants" flow. */ export const BulkResolveApprovalsRequestSchema = z.object({ decisions: z @@ -486,3 +487,169 @@ export const BulkResolveApprovalsRequestSchema = z.object({ .min(1) .max(500), }); + +// ============================================================================ +// LEGACY GTM schemas — retained for DB-layer compat (legacy `leads`, +// `outreach_drafts` tables). NOT part of the active ApprovalArtifactType +// union. Will be removed once the DB schema migration lands. +// ============================================================================ + +export const LeadSourceSchema = z.enum([ + "inbound_form", + "trial_signup", + "manual_import", +]); + +export const SenioritySchema = z.enum([ + "IC", + "Manager", + "Director", + "VP", + "CXO", + "Founder", +]); + +export const TierSchema = z.enum(["hot", "warm", "cold", "disqualified"]); +export const RecommendedActionSchema = z.enum([ + "book_call", + "email_sequence", + "self_serve", + "reject", +]); +export const CallToActionSchema = z.enum([ + "book_call", + "free_trial", + "demo_video", +]); +export const OutreachChannelSchema = z.enum(["email", "linkedin"]); +export const StripeStatusSchema = z.enum(["trialing", "active", "churned"]); +export const ActivationChannelSchema = z.enum(["email", "in_app"]); + +export const SocialPostSchema = z.object({ + platform: z.string(), + content: z.string(), + url: z.string().url(), +}); + +export const LeadSchema = z.object({ + id: z.string(), + email: z.string().email(), + name: z.string(), + company: z.string().nullable().optional(), + source: LeadSourceSchema, + rawMessage: z.string().nullable().optional(), + createdAt: z.date(), +}); + +export const EnrichedLeadSchema = z.object({ + id: z + .string() + .default(() => `enr_${Math.random().toString(36).slice(2, 10)}`), + leadId: z.string(), + linkedinUrl: z.string().nullable().optional(), + companyDomain: z.string().nullable().optional(), + companySize: z.number().int().nullable().optional(), + companyIndustry: z.string().nullable().optional(), + personRole: z.string().nullable().optional(), + personSeniority: SenioritySchema.nullable().optional(), + intentSignals: z.array(z.string()).default([]), + techStack: z.array(z.string()).nullable().optional(), + recentSocial: z.array(SocialPostSchema).nullable().optional(), + enrichedAt: z.coerce.date().default(() => new Date()), +}); + +export const QualifiedLeadSchema = z.object({ + id: z + .string() + .default(() => `qual_${Math.random().toString(36).slice(2, 10)}`), + leadId: z.string(), + tier: TierSchema, + fitScore: z.number().min(0).max(100), + fitReasons: z.array(z.string()).default([]), + intentScore: z.number().min(0).max(100), + intentReasons: z.array(z.string()).default([]), + recommendedAction: RecommendedActionSchema, + qualifiedAt: z.coerce.date().default(() => new Date()), +}); + +export const OutreachStrategySchema = z.object({ + id: z + .string() + .default(() => `strat_${Math.random().toString(36).slice(2, 10)}`), + leadId: z.string(), + tier: z.enum(["hot", "warm", "cold"]), + angle: z.string(), + toneGuide: z.string(), + callToAction: CallToActionSchema, + customHooks: z.array(z.string()).default([]), + createdAt: z.coerce.date().default(() => new Date()), +}); + +export const OutreachDraftSchema = z.object({ + id: z + .string() + .default(() => `draft_${Math.random().toString(36).slice(2, 10)}`), + leadId: z.string(), + channel: OutreachChannelSchema.default("email"), + subject: z.string().nullable().optional(), + body: z.string(), + to: z.string().nullable().optional(), + rationale: z.string().nullable().optional(), + approvalStatus: ApprovalStatusSchema.default("pending"), + founderEdits: z.string().nullable().optional(), + createdAt: z.coerce.date().default(() => new Date()), + sentAt: z.coerce.date().nullable().optional(), +}); + +export const BookedMeetingSchema = z.object({ + id: z + .string() + .default(() => `meet_${Math.random().toString(36).slice(2, 10)}`), + leadId: z.string(), + startsAt: z.coerce.date(), + durationMin: z.number().int().positive().default(30), + meetingLink: z.string().url(), + attendees: z.array(z.string()).default([]), + bookedAt: z.coerce.date().default(() => new Date()), +}); + +export const PrepBriefSchema = z.object({ + id: z + .string() + .default(() => `brief_${Math.random().toString(36).slice(2, 10)}`), + meetingId: z.string(), + notionPageUrl: z.string().url(), + leadSummary: z.string(), + companyContext: z.string(), + likelyUseCase: z.string(), + similarPriorEmails: z.array(z.string()).default([]), + talkingPoints: z.array(z.string()).default([]), + questionsToAsk: z.array(z.string()).default([]), + potentialObjections: z.array(z.string()).default([]), + recommendedNextSteps: z.array(z.string()).default([]), + createdAt: z.coerce.date().default(() => new Date()), +}); + +export const TrialSignalSchema = z.object({ + id: z.string(), + leadId: z.string(), + signupAt: z.date(), + invitedTeammates: z.number().int().nonnegative(), + featuresUsed: z.array(z.string()), + stalledAtStep: z.string().nullable().optional(), + stripeStatus: StripeStatusSchema, + trialEndsAt: z.date().nullable().optional(), +}); + +export const ActivationNudgeSchema = z.object({ + id: z + .string() + .default(() => `act_${Math.random().toString(36).slice(2, 10)}`), + leadId: z.string(), + channel: ActivationChannelSchema, + subject: z.string().nullable().optional(), + body: z.string(), + loomScript: z.string().nullable().optional(), + approvalStatus: ApprovalStatusSchema.default("pending"), + createdAt: z.coerce.date().default(() => new Date()), +}); diff --git a/lib/shared/types.ts b/lib/shared/types.ts index d3a49e4..87d34ca 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -1,48 +1,36 @@ /** - * GMaestro shared type contracts. + * GMaestro shared type contracts — content / blog / GEO + SEO domain. * - * Owned by: Foundation. PARALLEL SESSIONS DO NOT MODIFY. - * If a session needs a new type, raise it with the human conductor. + * Pivoted 2026-05-09 from GTM (sales / CS / RevOps) to content. Architecture + * is unchanged — Conductor → Managers → Specialists, Pattern B (deterministic + * fetch → pure-LLM synth), post-approval deterministic dispatch — only the + * domain types changed. * - * These types intentionally MIRROR the Drizzle schema in lib/state/schema.ts - * with some hand-tweaks (e.g., camelCase, narrower string literal unions). - * Drizzle's $inferSelect is the canonical row shape; these are the runtime - * "domain" shapes used between layers. + * Owned by: Foundation. Sessions raise type changes with the human conductor. */ // ============================================================================ -// Personas — exactly 13. Health Monitor was dropped per audit (overlapped -// with Activation). DO NOT add new personas without updating registry, scopes, -// prompts, and CLAUDE.md. +// Personas — exactly 10. 5 in Content, 2 in Distribution, 3 in Insight. +// DO NOT add new personas without updating registry, scopes, prompts, and +// CLAUDE.md. // ============================================================================ export type PersonaId = - // Sales department - | "researcher" - | "qualifier" - | "strategist" - | "writer" - | "scheduler" - | "brief-writer" - // CS department - | "activation" - // RevOps department - | "crm-logger" - | "pipeline-reporter" - | "slack-digest" - // Insight department - | "feedback-tagger" - | "theme-synthesizer" - | "linear-filer" - // Content department (blogs pivot) - | "linkedin-researcher" - | "x-researcher" - | "reddit-researcher" - | "synthesizer" - | "blog-writer" - | "blog-designer"; - -export type Department = "sales" | "cs" | "revops" | "insight" | "content"; + // Content department (5) + | "researcher" // topic + competitor + GEO citation research (Pattern B) + | "strategist" // angle + outline + keyword + GEO signal strategy + | "writer" // long-form blog draft (markdown) + | "geo-editor" // applies GEO/SEO signals: fact density, direct-answer leads, citations, schema recs + | "formatter" // emits per-channel variants (MDX, HTML, Notion blocks, Reddit, LinkedIn, X) + // Distribution department (2) + | "pipeline-reporter" // content-performance summary + | "slack-digest" // content-update Slack digest + // Insight department (3) + | "feedback-tagger" // tags content performance signals (viral / flop / mixed) + | "theme-synthesizer" // clusters topic trends into Notion content backlog + | "linear-filer"; // files content tasks into Linear + +export type Department = "content" | "distribution" | "insight"; export type Layer = "conductor" | "manager" | "specialist"; export type ModelTier = "opus" | "sonnet" | "haiku"; @@ -58,153 +46,218 @@ export interface Persona { } // ============================================================================ -// Lead pipeline artifacts +// Toolkits — destinations for the post-approval channels picker // ============================================================================ -export type LeadSource = "inbound_form" | "trial_signup" | "manual_import"; - -export interface Lead { - id: string; - email: string; - name: string; - company?: string | null; - source: LeadSource; - rawMessage?: string | null; - createdAt: Date; -} +export type ToolkitId = + | "github" // PR-with-markdown to a static-site repo + | "wordpress" // CMS publish (Composio slug TBD) + | "ghost" // CMS publish (Composio slug TBD) + | "notion" // Notion-as-blog (database row insert) + | "reddit" // self-post / link-post + | "linkedin" // native UGC post (official Posts API) + | "twitter"; // single tweet / chained thread + +export const ALL_TOOLKIT_IDS: readonly ToolkitId[] = [ + "github", + "wordpress", + "ghost", + "notion", + "reddit", + "linkedin", + "twitter", +] as const; -export type Seniority = "IC" | "Manager" | "Director" | "VP" | "CXO" | "Founder"; +// ============================================================================ +// Run input — the 3-input form replaces the freeform prompt for v1. +// Founder gives us (a) a company URL for voice + product context, +// (b) a docs URL for the topic source, (c) a single destination. +// ============================================================================ -export interface SocialPost { - platform: string; - content: string; - url: string; +/** Single-destination output target the founder picks at run-start. */ +export type Destination = "blog-html" | "reddit" | "x-thread"; + +export const ALL_DESTINATIONS: readonly Destination[] = [ + "blog-html", + "reddit", + "x-thread", +] as const; + +export interface RunWorkflowInput { + /** Company website URL — Pattern B fetch reads /, /about, /blog for voice + product. */ + companyUrl: string; + /** Technical doc URL the blog will be written from (HTML page or raw markdown). */ + docsUrl: string; + /** Single output target. The Formatter persona uses this to shape its output. */ + destination: Destination; } -export interface EnrichedLead { - id: string; - leadId: string; - linkedinUrl?: string | null; - companyDomain?: string | null; - companySize?: number | null; - companyIndustry?: string | null; - personRole?: string | null; - personSeniority?: Seniority | null; - intentSignals: string[]; - techStack?: string[] | null; - recentSocial?: SocialPost[] | null; - enrichedAt: Date; +/** + * Voice fingerprint extracted from the company's existing blog. Computed by + * `lib/personas/researcher/company-fetch.ts` and threaded into the Strategist + * + Writer prompts so the generated post matches the company's actual voice. + * + * Each field is mechanical to extract — see the plan file for the 10 rules. + */ +export interface VoiceFingerprint { + /** Words-per-sentence: { mean, stdev } across 5+ source posts. High stdev (>8) = aggressive variation. */ + sentenceLength: { mean: number; stdev: number }; + /** Pronoun mode: dominant pronoun across source posts. Lock one — never mix. */ + pronounMode: "we" | "i" | "neutral"; + /** First-100-words modal hook pattern. */ + hookPattern: "anomaly" | "contrarian" | "stat-led" | "announcement"; + /** Modal H2 heading style. */ + headingStyle: "topical" | "question" | "named-concept"; + /** Average code blocks per post. <1 = prose-only company; 3+ = code-heavy. */ + codeBlocksPerPost: number; + /** Opinion-marker density (per 1k words). 0 = neutral; 5+ = highly opinionated. */ + opinionDensity: number; + /** Words flagged as banned by scanning source posts (none of the marketing-verb set appeared). */ + bannedWords: string[]; + /** Modal closing pattern. */ + closingPattern: "single-line-punch" | "wrapping-up" | "cta-only"; + /** Numeric claims per 1k words. <2 = narrative-led; 4+ = stat-anchored. */ + statDensity: number; + /** Words ÷ H2 count target. Norm is 300–400. */ + wordsPerSection: number; + /** Raw voice samples (most recent 3 blog posts, full text). Threaded as Writer few-shots. */ + samples: string[]; + /** Company self-description scraped from /about or homepage. */ + productDescription?: string; + /** Company name as detected on the page. */ + companyName?: string; } -export type Tier = "hot" | "warm" | "cold" | "disqualified"; -export type RecommendedAction = - | "book_call" - | "email_sequence" - | "self_serve" - | "reject"; +// ============================================================================ +// Content artifacts — produced by the content workflow +// ============================================================================ -export interface QualifiedLead { - id: string; - leadId: string; - tier: Tier; - fitScore: number; - fitReasons: string[]; - intentScore: number; - intentReasons: string[]; - recommendedAction: RecommendedAction; - qualifiedAt: Date; +export type CitationSource = + | "reddit" + | "twitter" + | "linkedin" + | "blog" + | "perplexity" + | "hackernews" + | "other"; + +export interface SourceCitation { + source: CitationSource; + url: string; + title?: string; + excerpt?: string; } -export type CallToAction = "book_call" | "free_trial" | "demo_video"; - -export interface OutreachStrategy { - id: string; - leadId: string; - tier: Exclude<Tier, "disqualified">; +export interface TopicCandidate { + title: string; angle: string; - toneGuide: string; - callToAction: CallToAction; - customHooks: string[]; - createdAt: Date; + rationale: string; + citations: SourceCitation[]; } -export type OutreachChannel = "email" | "linkedin"; - -export interface OutreachDraft { +export interface TopicResearchBrief { id: string; - leadId: string; - channel: OutreachChannel; - subject?: string | null; - body: string; - /** Recipient address (email or LinkedIn handle). Carried inline so the - * dashboard's post-approval send dispatcher doesn't need to re-look-up - * the lead record. Optional for backward compat with older rows. */ - to?: string | null; - /** One-sentence why-this-draft explanation the writer produces alongside - * the email. Shown on the approval card so the founder sees the writer's - * reasoning at a glance instead of having to read upstream outputs. */ - rationale?: string | null; - approvalStatus: ApprovalStatus; - founderEdits?: string | null; + /** The seed topic / theme the founder asked about. */ + topic: string; + candidates: TopicCandidate[]; + /** The candidate the researcher recommends. May be overridden at outline approval. */ + recommendedTopic: string; + /** Competitor blogs we scanned for differentiation gaps. */ + competitorScan: Array<{ url: string; summary: string }>; + /** Existing AI-search citation footprint for the company (Perplexity output). */ + citationFootprint?: string; createdAt: Date; - sentAt?: Date | null; } -export interface BookedMeeting { - id: string; - leadId: string; - startsAt: Date; - durationMin: number; - meetingLink: string; - attendees: string[]; - bookedAt: Date; +export interface OutlineSection { + heading: string; + keyPoints: string[]; + sourcesToCite?: SourceCitation[]; } -export interface PrepBrief { +export interface ContentOutline { id: string; - meetingId: string; - notionPageUrl: string; - leadSummary: string; - companyContext: string; - likelyUseCase: string; - similarPriorEmails: string[]; - talkingPoints: string[]; - questionsToAsk: string[]; - potentialObjections: string[]; - recommendedNextSteps: string[]; + topicResearchBriefId?: string; + title: string; + /** One-sentence thesis the post argues. */ + thesis: string; + /** Who this is written for ("pre-Series A founders running their own GTM"). */ + audience: string; + sections: OutlineSection[]; + targetKeywords: string[]; + /** GEO-specific signals to weave in (e.g. "lead with direct answer in first 60 words"). */ + geoSignals: string[]; + estimatedWordCount: number; + approvalStatus: ApprovalStatus; createdAt: Date; } -// ============================================================================ -// Trial / activation artifacts -// ============================================================================ - -export type StripeStatus = "trialing" | "active" | "churned"; - -export interface TrialSignal { +export interface BlogDraft { id: string; - leadId: string; - signupAt: Date; - invitedTeammates: number; - featuresUsed: string[]; - stalledAtStep?: string | null; - stripeStatus: StripeStatus; - trialEndsAt?: Date | null; + outlineId?: string; + title: string; + slug: string; + excerpt: string; + /** Full post body in markdown. The Formatter persona converts this per channel. */ + bodyMarkdown: string; + tags: string[]; + citations: SourceCitation[]; + /** GEO-Editor's notes on what was changed for AI-search optimization. */ + geoNotes?: string[]; + /** Stats per 100 words — GEO-Editor's signal density measurement. */ + factDensityRatio?: number; + /** + * Set at approval time by the founder — which destinations to publish to. + * The Formatter persona reads this to fan out one variant per target. + * Only valid in the approval payload, not in the persona's emitted draft. + */ + targets?: ToolkitId[]; + approvalStatus: ApprovalStatus; + founderEdits?: string | null; + createdAt: Date; } -export type ActivationChannel = "email" | "in_app"; - -export interface ActivationNudge { +export interface ChannelVariant { id: string; - leadId: string; - channel: ActivationChannel; - subject?: string | null; - body: string; - loomScript?: string | null; + blogDraftId: string; + target: ToolkitId; + /** + * Channel-native rendered content: + * github → markdown / MDX with frontmatter + * wordpress → HTML body + * ghost → HTML body + * notion → JSON-stringified Notion block array + * reddit → discussion-flavored markdown + * linkedin → plain text (≤3000 chars) or carousel slide JSON + * twitter → single tweet OR newline-separated thread + */ + content: string; + /** + * Per-target metadata. Free-form because each target has different + * publish-call args. Examples: + * github → { repo, branch, path, frontmatter, prTitle, prBody } + * wordpress → { categories, tags, excerpt, status } + * notion → { databaseId, properties } + * reddit → { subreddit, kind: "self" | "link", flair } + * linkedin → { visibility, articleStyle } + * twitter → { kind: "single" | "thread" } + */ + metadata: Record<string, unknown>; approvalStatus: ApprovalStatus; createdAt: Date; } +export interface PublishedArtifact { + id: string; + channelVariantId: string; + target: ToolkitId; + /** External URL of the published item (PR URL, Reddit post URL, etc.). */ + externalUrl?: string; + /** External id (PR number, Reddit post id, LinkedIn URN, …). */ + externalId: string; + publishedAt: Date; +} + // ============================================================================ // Approval gate // ============================================================================ @@ -220,10 +273,11 @@ export type ApprovalStatus = export type BlastRadius = "internal" | "external" | "irreversible"; export type ApprovalArtifactType = - | "OutreachDraft" - | "ActivationNudge" - | "CRMUpdate" - | "CustomDeal"; + | "TopicResearchBrief" // low-friction; founder confirms direction + | "ContentOutline" // founder picks angle / scope before drafting + | "BlogDraft" // BIG GATE — carries the channels picker (`targets`) + | "ChannelVariant" // per-channel preview; bulk-approve via /api/approvals/bulk + | "PublishedArtifact"; // post-publish receipt for the timeline export interface ApprovalRequest { id: string; @@ -238,10 +292,7 @@ export interface ApprovalRequest { createdAt: Date; resolvedAt?: Date | null; } - -// ============================================================================ -// Workflow / orchestration -// ============================================================================ +// Workflow / orchestration — DAG shape unchanged export type WorkflowState = | "planning" @@ -265,22 +316,24 @@ export type TriggerRule = "all_success" | "all_done"; * * - "fanout" (default): N items → N LLM calls dispatched per-instance. Use for * personas where each item needs human-in-loop approval or per-item voice - * personalization (writer, scheduler, brief-writer). + * personalization (writer, formatter). * - "batch": N items → 1 LLM call processing the whole array, internally * issuing parallel Composio tool calls via COMPOSIO_MULTI_EXECUTE_TOOL. - * Use for read/synth personas (researcher, qualifier, strategist, crm-logger). - * Output must be keyed by the source-item id; the dispatcher unrolls it - * back into per-instance chainOutputs so downstream fanout tasks see - * `previousOutputs.<persona>__<itemId>` as if N tasks had run. + * Use for read/synth personas where cross-item reasoning is fine. The + * dispatcher unrolls the output back into per-instance chainOutputs. */ export type TaskMode = "fanout" | "batch"; /** * Names of work-item collections the workflow function exposes to Managers. - * Manager emits a template task with `fanoutOver: "leads"` and the workflow - * function expands it into one materialized task per item in the collection. + * Manager emits a template task with `fanoutOver: "topics"` or `"channels"` + * and the workflow function expands it into one materialized task per item. + * + * - "topics" — multiple topics in flight (used for "draft 4 blogs this week"). + * - "channels" — set at approval time by the founder via the BlogDraft channels + * picker. The Formatter expands one task per ticked target. */ -export type FanoutSource = "leads" | "trial-signals"; +export type FanoutSource = "topics" | "channels"; /** * The structured plan the Conductor returns. Manager sub-agents collectively @@ -309,7 +362,7 @@ export interface WorkflowTask { passOutput?: string[]; /** * "all_success" (default): skip this task if any upstream failed/skipped. - * "all_done": run regardless of upstream status (with whatever outputs exist). + * "all_done": run regardless of upstream status. */ triggerRule?: TriggerRule; /** @@ -350,9 +403,7 @@ export interface WorkflowNode { completedAt?: Date | null; } -// ============================================================================ -// Activity events (streamed to dashboard via SSE) -// ============================================================================ +// Activity events (streamed to dashboard via SSE) — unchanged export type ActivityEventType = | "persona_started" @@ -373,9 +424,7 @@ export interface ActivityEvent { timestamp: Date; } -// ============================================================================ -// Voice memory -// ============================================================================ +// Voice memory — unchanged shape (now blog-flavored samples) export interface VoiceSample { id: string; @@ -396,9 +445,7 @@ export interface FounderVoiceEdit { capturedAt: Date; } -// ============================================================================ -// Composio connection state -// ============================================================================ +// Composio connection state — unchanged export type ConnectionStatus = "pending" | "connected" | "failed" | "revoked"; @@ -413,7 +460,15 @@ export interface Connection { connectedAt?: Date | null; } -// ============================================================================ +// MCP config — unchanged + +export interface ComposioMcpConfig { + type: "http"; + url: string; + headers: Record<string, string>; +} + + // 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 @@ -454,12 +509,146 @@ export interface CompanyContext { updatedAt: Date; } + // ============================================================================ -// MCP config — what `getMcpConfigForUser` returns (used by Session 1 + 2) +// LEGACY GTM types — kept for DB schema + pre-pivot routes that still +// reference them. NOT in any active union (PersonaId, ApprovalArtifactType, +// FanoutSource). New orchestration code must not produce these. +// +// Will be deleted once the DB schema migration lands. // ============================================================================ -export interface ComposioMcpConfig { - type: "http"; +export type LeadSource = "inbound_form" | "trial_signup" | "manual_import"; + +export interface Lead { + id: string; + email: string; + name: string; + company?: string | null; + source: LeadSource; + rawMessage?: string | null; + createdAt: Date; +} + +export type Seniority = "IC" | "Manager" | "Director" | "VP" | "CXO" | "Founder"; + +export interface SocialPost { + platform: string; + content: string; url: string; - headers: Record<string, string>; } + +export interface EnrichedLead { + id: string; + leadId: string; + linkedinUrl?: string | null; + companyDomain?: string | null; + companySize?: number | null; + companyIndustry?: string | null; + personRole?: string | null; + personSeniority?: Seniority | null; + intentSignals: string[]; + techStack?: string[] | null; + recentSocial?: SocialPost[] | null; + enrichedAt: Date; +} + +export type Tier = "hot" | "warm" | "cold" | "disqualified"; +export type RecommendedAction = + | "book_call" + | "email_sequence" + | "self_serve" + | "reject"; + +export interface QualifiedLead { + id: string; + leadId: string; + tier: Tier; + fitScore: number; + fitReasons: string[]; + intentScore: number; + intentReasons: string[]; + recommendedAction: RecommendedAction; + qualifiedAt: Date; +} + +export type CallToAction = "book_call" | "free_trial" | "demo_video"; + +export interface OutreachStrategy { + id: string; + leadId: string; + tier: Exclude<Tier, "disqualified">; + angle: string; + toneGuide: string; + callToAction: CallToAction; + customHooks: string[]; + createdAt: Date; +} + +export type OutreachChannel = "email" | "linkedin"; + +export interface OutreachDraft { + id: string; + leadId: string; + channel: OutreachChannel; + subject?: string | null; + body: string; + to?: string | null; + rationale?: string | null; + approvalStatus: ApprovalStatus; + founderEdits?: string | null; + createdAt: Date; + sentAt?: Date | null; +} + +export interface BookedMeeting { + id: string; + leadId: string; + startsAt: Date; + durationMin: number; + meetingLink: string; + attendees: string[]; + bookedAt: Date; +} + +export interface PrepBrief { + id: string; + meetingId: string; + notionPageUrl: string; + leadSummary: string; + companyContext: string; + likelyUseCase: string; + similarPriorEmails: string[]; + talkingPoints: string[]; + questionsToAsk: string[]; + potentialObjections: string[]; + recommendedNextSteps: string[]; + createdAt: Date; +} + +export type StripeStatus = "trialing" | "active" | "churned"; + +export interface TrialSignal { + id: string; + leadId: string; + signupAt: Date; + invitedTeammates: number; + featuresUsed: string[]; + stalledAtStep?: string | null; + stripeStatus: StripeStatus; + trialEndsAt?: Date | null; +} + +export type ActivationChannel = "email" | "in_app"; + +export interface ActivationNudge { + id: string; + leadId: string; + channel: ActivationChannel; + subject?: string | null; + body: string; + loomScript?: string | null; + approvalStatus: ApprovalStatus; + createdAt: Date; +} +// ============================================================================ diff --git a/lib/state/approvals.ts b/lib/state/approvals.ts index 62c48cc..8cc54bc 100644 --- a/lib/state/approvals.ts +++ b/lib/state/approvals.ts @@ -2,6 +2,8 @@ import "server-only"; import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { db, schema } from "./db"; +import { env } from "@/lib/shared/env"; +import { sendApprovalDM } from "@/lib/tools/slack-approval"; import type { ApprovalArtifactType, ApprovalRequest, @@ -59,6 +61,32 @@ export async function raiseApproval( proposedAction: params.proposedAction, status: "pending", }); + + // Fire-and-forget Slack DM. We don't block the workflow on Slack — if the + // founder hasn't connected Slack or hasn't set GMAESTRO_SLACK_CHANNEL, the + // dashboard still drives the approval flow. + const channel = env().GMAESTRO_SLACK_CHANNEL; + if (channel) { + const approval: ApprovalRequest = { + id, + workflowRunId: params.workflowRunId, + artifactType: params.artifactType, + artifactId: params.artifactId, + blastRadius: params.blastRadius, + reason: params.reason, + proposedAction: params.proposedAction, + status: "pending", + founderNotes: null, + createdAt: new Date(), + resolvedAt: null, + }; + void sendApprovalDM(channel, approval).catch((err) => { + console.warn( + `[approvals] Slack DM failed for approval ${id}: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + } + return id; } diff --git a/lib/state/schema.ts b/lib/state/schema.ts index e345844..83f1ff9 100644 --- a/lib/state/schema.ts +++ b/lib/state/schema.ts @@ -208,7 +208,13 @@ export const approvalRequests = sqliteTable("approval_requests", { .notNull() .references(() => workflowRuns.id, { onDelete: "cascade" }), artifactType: text("artifact_type", { - enum: ["OutreachDraft", "ActivationNudge", "CRMUpdate", "CustomDeal"], + enum: [ + "TopicResearchBrief", + "ContentOutline", + "BlogDraft", + "ChannelVariant", + "PublishedArtifact", + ], }).notNull(), artifactId: text("artifact_id").notNull(), blastRadius: text("blast_radius", { diff --git a/lib/state/work-context.ts b/lib/state/work-context.ts index 0e3dd7d..d93f4d5 100644 --- a/lib/state/work-context.ts +++ b/lib/state/work-context.ts @@ -1,6 +1,4 @@ import "server-only"; -import { desc, eq } from "drizzle-orm"; -import { db, schema } from "./db"; import type { FanoutSource } from "@/lib/shared/types"; /** @@ -9,8 +7,16 @@ import type { FanoutSource } from "@/lib/shared/types"; * IDs) without the workflow function having to enumerate every artifact. * * Items are intentionally narrow — just what a Manager needs to plan, not what - * a Specialist needs to execute. Specialists fetch full records by id at - * dispatch time. + * a Specialist needs to execute. + * + * Content-domain WorkContext shapes (post-2026-05-09 pivot): + * - "topics" — multi-topic sprint backlog. v1: empty (single-blog runs + * drive the topic from the founder's prompt). v2: a topics + * table populated via gmaestro setup or a Notion sync. + * - "channels" — set at BlogDraft approval time, not at run start. The + * Formatter's fanout over "channels" is materialized when + * the founder ticks targets in the approval payload, not + * here. v1: empty. */ export interface WorkItem { id: string; @@ -18,9 +24,8 @@ export interface WorkItem { label: string; /** * Denormalized record fields. Splatted into materialized task input as - * `item: {...}` so personas can act on the lead/trial without an extra - * Composio round-trip back into the local store (which they have no tool - * to query anyway). + * `item: {...}` so personas can act on the topic/channel without an extra + * Composio round-trip. */ fields: Record<string, unknown>; } @@ -30,65 +35,17 @@ export interface WorkContext { summary: string; } -const MAX_ITEMS_PER_SOURCE = 100; - export async function loadWorkContext(): Promise<WorkContext> { - const leadRows = db - .select({ - id: schema.leads.id, - email: schema.leads.email, - name: schema.leads.name, - company: schema.leads.company, - source: schema.leads.source, - rawMessage: schema.leads.rawMessage, - }) - .from(schema.leads) - .orderBy(desc(schema.leads.createdAt)) - .limit(MAX_ITEMS_PER_SOURCE) - .all(); - - const trialRows = db - .select({ - id: schema.trialSignals.id, - leadId: schema.trialSignals.leadId, - stalledAtStep: schema.trialSignals.stalledAtStep, - stripeStatus: schema.trialSignals.stripeStatus, - }) - .from(schema.trialSignals) - .where(eq(schema.trialSignals.stripeStatus, "trialing")) - .limit(MAX_ITEMS_PER_SOURCE) - .all(); - + // v1: no pre-loaded topic backlog. The topic comes from the founder's + // prompt; channels are set at BlogDraft approval time. Both arrays are + // empty here, and the dispatcher injects channels into the formatter + // fanout via the approval payload (handled in lib/state/workflows.ts). const items: Record<FanoutSource, WorkItem[]> = { - leads: leadRows.map((r) => ({ - id: r.id, - label: `${r.name} <${r.email}>${r.company ? ` · ${r.company}` : ""} · src=${r.source}`, - fields: { - leadId: r.id, - email: r.email, - name: r.name, - company: r.company, - source: r.source, - // The lead's actual inbound text — far more useful for personalization - // than name/company alone, especially when upstream research has nothing - // because integrations aren't connected. - rawMessage: r.rawMessage, - }, - })), - "trial-signals": trialRows.map((r) => ({ - id: r.id, - label: `lead=${r.leadId}${r.stalledAtStep ? ` · stalled=${r.stalledAtStep}` : ""}`, - fields: { - trialSignalId: r.id, - leadId: r.leadId, - stalledAtStep: r.stalledAtStep, - stripeStatus: r.stripeStatus, - }, - })), + topics: [], + channels: [], }; - const summary = formatSummary(items); - return { items, summary }; + return { items, summary: formatSummary(items) }; } function formatSummary(items: Record<FanoutSource, WorkItem[]>): string { @@ -105,12 +62,12 @@ function formatSummary(items: Record<FanoutSource, WorkItem[]>): string { } return sections.length > 0 ? sections.join("\n\n") - : "(no work items currently available)"; + : "(no pre-loaded work items — drive the topic from the founder's objective; channels are picked at BlogDraft approval time)"; } /** * Pure helper used by the dispatcher to materialize a fanout template into one - * task per item. Kept here so workflows.ts stays focused on scheduling. + * task per item. */ export function fanoutItems( source: FanoutSource, diff --git a/lib/state/workflows.ts b/lib/state/workflows.ts index 66fccb5..083e2f2 100644 --- a/lib/state/workflows.ts +++ b/lib/state/workflows.ts @@ -10,6 +10,10 @@ import { type BatchResult, } from "@/lib/personas/runtime"; import { fetchResearcherBundle } from "@/lib/personas/researcher/fetch"; +import { + fetchCompanyContextBundle, + fetchDocBundle, +} from "@/lib/personas/researcher/company-fetch"; import { PERSONA_REGISTRY } from "@/lib/personas/registry"; import { getDispatchConcurrency } from "@/lib/shared/env"; import { makeMockPersonaRuntime } from "@/lib/shared/mocks"; @@ -43,13 +47,11 @@ function shouldUseMockPersonas(): boolean { function mockArtifactType(personaId: PersonaId): string | null { switch (personaId) { - case "researcher": return "EnrichedLead"; - case "qualifier": return "QualifiedLead"; - case "strategist": return "OutreachStrategy"; - case "writer": return "OutreachDraft"; - case "scheduler": return "BookedMeeting"; - case "brief-writer": return "PrepBrief"; - case "activation": return "ActivationNudge"; + case "researcher": return "TopicResearchBrief"; + case "strategist": return "ContentOutline"; + case "writer": return "BlogDraft"; + case "geo-editor": return "BlogDraft"; + case "formatter": return "ChannelVariant"; default: return null; } } @@ -106,6 +108,23 @@ function isIntegrationNotConnectedError(err: unknown): boolean { return false; } +/** + * Thrown by the researcher's Pattern B fetch when a Composio integration we + * NEED for the run isn't reachable. Caught at the top of `runWorkflow` and + * mapped to `markRunFailed` with a founder-readable message — pointing them + * to /connections to wire the missing toolkit. + */ +export class IntegrationFetchError extends Error { + constructor( + message: string, + public toolkit: string, + public fetchStatus: string, + ) { + super(message); + this.name = "IntegrationFetchError"; + } +} + function errorToMessage(err: unknown): string { if (err instanceof Error) return err.message; try { @@ -226,13 +245,32 @@ type MaterializedTask = WorkflowTask & { }; function expandPlan(dag: WorkflowDAG, ctx: WorkContext): MaterializedTask[] { + // Single-destination collapse: if the run has a structured `destination` + // (3-input form path), and the Conductor emitted a `formatter` task with + // `fanoutOver: "channels"`, rewrite it as a single non-fanout task with + // `target` set from the destination. This avoids the Conductor needing to + // know about the run-level destination. + const runInputs = (ctx as { runInputs?: { destination?: string } }).runInputs; + const destinationToolkit = destinationToToolkit(runInputs?.destination); + const collapsedTasks: WorkflowTask[] = destinationToolkit + ? dag.tasks.map((t) => + t.specialistId === "formatter" && t.fanoutOver === "channels" + ? { + ...t, + fanoutOver: undefined, + input: { ...t.input, target: destinationToolkit }, + } + : t, + ) + : dag.tasks; + const fanoutSourceById = new Map<string, FanoutSource>(); - for (const t of dag.tasks) { + for (const t of collapsedTasks) { if (t.fanoutOver) fanoutSourceById.set(t.id, t.fanoutOver); } const out: MaterializedTask[] = []; - for (const t of dag.tasks) { + for (const t of collapsedTasks) { if (t.fanoutOver) { const items = fanoutItems(t.fanoutOver, ctx); // Resolve effective mode: explicit `mode` wins; else fall back to @@ -424,16 +462,64 @@ async function fetchResearcherBundleForInput( input: Record<string, unknown>, userId: string, ) { - const itemRaw = input.item; - const item = - itemRaw && typeof itemRaw === "object" && !Array.isArray(itemRaw) - ? (itemRaw as Record<string, unknown>) + // Mock-mode short-circuit: skip Firecrawl/Reddit/etc. so the dispatcher + // can fan out instantly. The mock persona output ignores fetchBundle anyway. + if (shouldUseMockPersonas()) { + return { mocked: true }; + } + // 3-input form path: if companyUrl + docsUrl are present, run the new + // dual-bundle Pattern B fetch. Returns { companyBundle, docBundle } so the + // researcher prompt can reason over both. + const companyUrl = typeof input.companyUrl === "string" ? input.companyUrl : undefined; + const docsUrl = typeof input.docsUrl === "string" ? input.docsUrl : undefined; + if (companyUrl && docsUrl) { + const [companyBundle, docBundle] = await Promise.all([ + fetchCompanyContextBundle(userId, companyUrl), + fetchDocBundle(userId, docsUrl), + ]); + // Fail LOUD on Firecrawl failures. Without doc content, the synthesizer + // has nothing to write about — the rest of the pipeline cascades garbage + // and the run reports "done" without ever producing a draft. Better to + // fail fast with an actionable error so the founder knows to connect + // Firecrawl on /connections. + if (docBundle.status !== "ok") { + const isNotConnected = docBundle.status === "not_connected"; + const fixHint = isNotConnected + ? `Run \`pnpm tsx scripts/connect-firecrawl.ts\` to bind your Firecrawl API key to userId="default", then retry. ` + : `Connect Firecrawl on /connections, then retry. `; + throw new IntegrationFetchError( + `Couldn't fetch the docs URL via Firecrawl (status: ${docBundle.status}). ` + + fixHint + + `[docsUrl=${docsUrl}${docBundle.error ? `, error=${docBundle.error.slice(0, 200)}` : ""}]`, + "firecrawl", + docBundle.status, + ); + } + if (companyBundle.status.homepage !== "ok") { + // Soft signal — log + continue with degraded fingerprint. Doc content is + // load-bearing; company context is a quality multiplier. We can write + // SOMETHING from just the doc, just not in the company's voice. + console.warn( + `[workflows] Company URL fetch degraded (${companyBundle.status.homepage}) for ${companyUrl} — proceeding with default voice fingerprint.`, + ); + } + return { companyBundle, docBundle }; + } + + // Legacy path: topic-string + optional companyProfile (for the persona + // harness + any pre-3-input-form callers). Hits Reddit/X/Firecrawl/Perplexity. + const topic = typeof input.topic === "string" ? input.topic : ""; + const companyProfileRaw = input.companyProfile; + const companyProfile = + companyProfileRaw && typeof companyProfileRaw === "object" && !Array.isArray(companyProfileRaw) + ? (companyProfileRaw as Record<string, unknown>) : {}; - return fetchResearcherBundle(userId, { - email: typeof item.email === "string" ? item.email : undefined, - name: typeof item.name === "string" ? item.name : undefined, - company: typeof item.company === "string" ? item.company : undefined, - }); + const companyName = + typeof companyProfile.companyName === "string" ? companyProfile.companyName : undefined; + const competitorUrls = Array.isArray(companyProfile.competitors) + ? (companyProfile.competitors as unknown[]).filter((u): u is string => typeof u === "string") + : undefined; + return fetchResearcherBundle(userId, { topic, companyName, competitorUrls }); } /** @@ -448,11 +534,23 @@ async function enrichEnvelopesWithResearcherBundle( return Promise.all( envelopes.map(async (env) => { const payload = env.payload; + const topic = typeof payload.topic === "string" ? payload.topic : ""; + const companyProfileRaw = payload.companyProfile; + const companyProfile = + companyProfileRaw && typeof companyProfileRaw === "object" && !Array.isArray(companyProfileRaw) + ? (companyProfileRaw as Record<string, unknown>) + : {}; + const companyName = + typeof companyProfile.companyName === "string" ? companyProfile.companyName : undefined; + const competitorUrls = Array.isArray(companyProfile.competitors) + ? (companyProfile.competitors as unknown[]).filter( + (u): u is string => typeof u === "string", + ) + : undefined; const bundle = await fetchResearcherBundle(userId, { - email: typeof payload.email === "string" ? payload.email : undefined, - name: typeof payload.name === "string" ? payload.name : undefined, - company: - typeof payload.company === "string" ? payload.company : undefined, + topic, + companyName, + competitorUrls, }); return { ...env, @@ -466,24 +564,82 @@ function injectItemContext( input: Record<string, unknown>, ctx: WorkContext, ): Record<string, unknown> { + // 3-input form: splice companyUrl/docsUrl/destination into every persona + // input so the Conductor doesn't have to extract URLs from prose. Doesn't + // clobber values the Conductor already set. + const runInputs = (ctx as { runInputs?: { companyUrl: string; docsUrl: string; destination: string } }).runInputs; + let next = input; + if (runInputs) { + next = { + companyUrl: runInputs.companyUrl, + docsUrl: runInputs.docsUrl, + destination: runInputs.destination, + ...next, + }; + } + // Skip if the dispatcher already populated `item` (e.g. from a fanout // template) — don't clobber existing context. - if (input.item && typeof input.item === "object") return input; - - const leadId = typeof input.leadId === "string" ? input.leadId : undefined; - if (leadId) { - const lead = ctx.items.leads.find((l) => l.id === leadId); - if (lead) return { ...input, item: lead.fields }; + if (next.item && typeof next.item === "object") return next; + + // Content-domain fanout sources: "topics" and "channels". Look up the + // matching work item by id when the input names one. v1 work-context + // exposes empty arrays, so this is a no-op until topic backlogs are + // wired up — leaving the lookup so the contract stays intact. + const topicId = typeof next.topicId === "string" ? next.topicId : undefined; + if (topicId) { + const topic = ctx.items.topics.find((t) => t.id === topicId); + if (topic) return { ...next, item: topic.fields }; } - const trialSignalId = - typeof input.trialSignalId === "string" ? input.trialSignalId : undefined; - if (trialSignalId) { - const trial = ctx.items["trial-signals"].find( - (t) => t.id === trialSignalId, - ); - if (trial) return { ...input, item: trial.fields }; + const channelId = + typeof next.channelId === "string" ? next.channelId : undefined; + if (channelId) { + const channel = ctx.items.channels.find((c) => c.id === channelId); + if (channel) return { ...next, item: channel.fields }; } - return input; + return next; +} + +/** + * Map the form-side `destination` to the dispatcher-side toolkit slug. The + * Formatter takes a `target: ToolkitId` and the dispatcher's providers index + * by that slug — so single-destination runs need this translation. + */ +function destinationToToolkit(destination: string | undefined): string | null { + switch (destination) { + case "blog-html": + return "github"; // PR with markdown to a static-site repo + case "reddit": + return "reddit"; + case "x-thread": + return "twitter"; + default: + return null; + } +} + +/** + * Parse the structured prompt that app/api/runs/route.ts builds for the + * 3-input form. Returns null for legacy freeform prompts. The prompt format + * is stable: "Write … for the company at <url>. The blog is about … <url>. + * … Destination: <dest>." + */ +function parseRunInputsFromPrompt( + prompt: string, +): { companyUrl: string; docsUrl: string; destination: string } | null { + const companyMatch = prompt.match( + /for the company at (https?:\/\/[^\s.]+(?:\.[^\s.]+)+[^\s.,)]*)/i, + ); + const docsMatch = prompt.match( + /(?:about the technical content at|technical content at) (https?:\/\/[^\s.]+(?:\.[^\s.]+)+[^\s.,)]*)/i, + ); + const destMatch = prompt.match(/Destination:\s*(blog-html|reddit|x-thread)/i); + if (!companyMatch || !docsMatch || !destMatch) return null; + return { + companyUrl: companyMatch[1], + docsUrl: docsMatch[1], + destination: destMatch[1].toLowerCase(), + }; } function substituteEach( @@ -550,28 +706,29 @@ const APPROVAL_RULES: Partial< } > > = { - writer: { - artifactType: "OutreachDraft", - blastRadius: "external", + strategist: { + artifactType: "ContentOutline", + blastRadius: "internal", reason: (out) => { - const subject = (out.subject as string | undefined) ?? "(no subject)"; - return `Send Gmail draft "${subject}" to a real prospect.`; + const title = (out.title as string | undefined) ?? "(no title)"; + return `Approve content outline "${title}" before drafting.`; }, }, - scheduler: { - artifactType: "CustomDeal", + "geo-editor": { + artifactType: "BlogDraft", blastRadius: "external", - reason: () => "Send a calendar invite + create a real meeting.", + reason: (out) => { + const title = (out.title as string | undefined) ?? "(no title)"; + return `Approve final draft "${title}" and pick destinations to publish to.`; + }, }, - activation: { - artifactType: "ActivationNudge", + formatter: { + artifactType: "ChannelVariant", blastRadius: "external", - reason: () => "Send an in-product / email nudge to a trial user.", - }, - "crm-logger": { - artifactType: "CRMUpdate", - blastRadius: "internal", - reason: () => "Write to HubSpot / Sheets.", + reason: (out) => { + const target = (out.target as string | undefined) ?? "channel"; + return `Approve ${target}-formatted variant before publishing.`; + }, }, }; @@ -631,148 +788,14 @@ async function persistArtifact( artifactId: string, output: Record<string, unknown>, ): Promise<void> { - try { - const leadId = - typeof output.leadId === "string" ? output.leadId : undefined; - if (personaId === "writer" && leadId) { - const body = output.body as string | undefined; - if (!body) return; - const channelRaw = output.channel as string | undefined; - const channel: "email" | "linkedin" = - channelRaw === "linkedin" ? "linkedin" : "email"; - await db - .insert(schema.outreachDrafts) - .values({ - id: artifactId, - leadId, - channel, - subject: (output.subject as string | undefined) ?? null, - body, - approvalStatus: "pending", - founderEdits: null, - }) - .onConflictDoNothing(); - return; - } - - if (personaId === "researcher" && leadId) { - // EnrichedLead → enriched_leads. Most fields are nullable so a - // researcher that only inferred companyDomain still persists cleanly. - // recentSocial is dropped on persist — the DB schema's shape (platform/ - // content/url) drifted from the Zod schema's (source/excerpt/postedAt); - // not load-bearing for the demo so we just don't store it. - const seniority = output.personSeniority; - const validSeniorities = [ - "IC", - "Manager", - "Director", - "VP", - "CXO", - "Founder", - ] as const; - const seniorityValue = - typeof seniority === "string" && - (validSeniorities as readonly string[]).includes(seniority) - ? (seniority as (typeof validSeniorities)[number]) - : null; - await db - .insert(schema.enrichedLeads) - .values({ - id: artifactId, - leadId, - linkedinUrl: - (output.linkedinUrl as string | null | undefined) ?? null, - companyDomain: - (output.companyDomain as string | null | undefined) ?? null, - companySize: - typeof output.companySize === "number" ? output.companySize : null, - companyIndustry: - (output.companyIndustry as string | null | undefined) ?? null, - personRole: (output.personRole as string | null | undefined) ?? null, - personSeniority: seniorityValue, - intentSignals: Array.isArray(output.intentSignals) - ? (output.intentSignals as string[]) - : [], - techStack: Array.isArray(output.techStack) - ? (output.techStack as string[]) - : null, - recentSocial: null, - }) - .onConflictDoNothing(); - return; - } - - if (personaId === "qualifier" && leadId) { - // QualifiedLead → qualified_leads. The qualifier's prompt-side - // recommendedAction enum (book_call, free_trial, demo_video, nurture, - // disqualify) drifted from the DB enum (book_call, email_sequence, - // self_serve, reject) — we map between them on persist. - const tier = output.tier; - if ( - typeof tier !== "string" || - !["hot", "warm", "cold", "disqualified"].includes(tier) - ) - return; - await db - .insert(schema.qualifiedLeads) - .values({ - id: artifactId, - leadId, - tier: tier as "hot" | "warm" | "cold" | "disqualified", - fitScore: - typeof output.fitScore === "number" ? output.fitScore : 0, - fitReasons: Array.isArray(output.fitReasons) - ? (output.fitReasons as string[]) - : [], - intentScore: - typeof output.intentScore === "number" ? output.intentScore : 0, - intentReasons: Array.isArray(output.intentReasons) - ? (output.intentReasons as string[]) - : [], - recommendedAction: mapRecommendedActionForDb( - output.recommendedAction, - ), - }) - .onConflictDoNothing(); - return; - } - - if (personaId === "strategist" && leadId) { - // OutreachStrategy → outreach_strategies. DB tier enum doesn't include - // "disqualified" → coerce those to "cold" (which is what the writer - // would treat them as anyway). callToAction enum matches. - const rawTier = output.tier; - if (typeof rawTier !== "string") return; - const tier: "hot" | "warm" | "cold" = - rawTier === "hot" || rawTier === "warm" ? rawTier : "cold"; - const ctaRaw = output.callToAction; - const callToAction: "book_call" | "free_trial" | "demo_video" = - ctaRaw === "book_call" || ctaRaw === "free_trial" - ? ctaRaw - : "demo_video"; - await db - .insert(schema.outreachStrategies) - .values({ - id: artifactId, - leadId, - tier, - angle: (output.angle as string | undefined) ?? "", - toneGuide: (output.toneGuide as string | undefined) ?? "", - callToAction, - customHooks: Array.isArray(output.customHooks) - ? (output.customHooks as string[]) - : [], - }) - .onConflictDoNothing(); - return; - } - // Other artifact types (BookedMeeting, ActivationNudge, PrepBrief) follow - // the same pattern; wired up as their personas come online. - } catch (err) { - console.warn( - `[persistArtifact:${personaId}] failed to write ${artifactId}: ${err instanceof Error ? err.message : err}`, - ); - } + // Content-domain artifacts (TopicResearchBrief, ContentOutline, BlogDraft, + // ChannelVariant, PublishedArtifact) don't yet have dedicated DB tables — + // the approval_requests row is the source of truth (its `proposed_action` + // column carries the full typed output). When we add dedicated tables for + // historical artifact pages, wire the writes here. + void personaId; + void artifactId; + void output; } function passThroughOutput( @@ -818,6 +841,14 @@ export async function runWorkflow( const workContext = await loadWorkContext(); + // Parse 3-input form fields out of the prompt so the dispatcher can splice + // them into every persona's input. The prompt was synthesized by + // app/api/runs/route.ts:buildStructuredPrompt — this is the inverse. + const runInputs = parseRunInputsFromPrompt(prompt); + if (runInputs) { + (workContext as { runInputs?: typeof runInputs }).runInputs = runInputs; + } + let dag: WorkflowDAG; try { dag = await runConductor(workflowRunId, prompt, founderId, workContext); @@ -1075,10 +1106,48 @@ export async function runWorkflow( return; } + // Approval-gate guard. If the plan included an approval-triggering + // persona (geo-editor produces BlogDraft, formatter produces ChannelVariant) + // but no approval row was created, something downstream of the LLM call + // either crashed silently or returned malformed output. Mark the run + // failed so the dashboard surfaces it instead of saying "done" with + // nothing for the founder to review. + const expectedApproval = materializedTasks.some( + (t) => + t.specialistId === "geo-editor" || t.specialistId === "formatter", + ); + if (expectedApproval) { + const approvalCount = await countApprovalsForRun(workflowRunId); + if (approvalCount === 0) { + const reason = + "Workflow finished without producing a draft to approve. " + + "Most likely a downstream persona (geo-editor or formatter) failed " + + "silently — check Composio integration status (Firecrawl, etc.) on /connections."; + await emitEvent(workflowRunId, null, "workflow_done", { state: "failed" }); + await markRunFailed(workflowRunId, new Error(reason)); + return; + } + } + await emitEvent(workflowRunId, null, "workflow_done", { state: "done" }); await markRunDone(workflowRunId); } catch (err) { + // Map the loud Pattern B fetch error to a founder-readable failure reason. + if (err instanceof IntegrationFetchError) { + await markRunFailed(workflowRunId, err); + // Don't re-throw; the .catch on the detached promise has already been + // handled. Re-throwing would dead-letter to a generic uncaught. + return; + } await markRunFailed(workflowRunId, err); throw err; } } + +async function countApprovalsForRun(workflowRunId: string): Promise<number> { + const rows = await db + .select({ id: schema.approvalRequests.id }) + .from(schema.approvalRequests) + .where(eq(schema.approvalRequests.workflowRunId, workflowRunId)); + return rows.length; +} diff --git a/lib/tools/scopes.ts b/lib/tools/scopes.ts index d43ced4..83e6fe1 100644 --- a/lib/tools/scopes.ts +++ b/lib/tools/scopes.ts @@ -1,99 +1,59 @@ /** - * Per-persona Composio action scopes. + * Per-persona Composio action scopes — content / blog / GEO domain. * * Action names are Composio tool slugs WITHOUT the `mcp__composio__` prefix. * Claude Agent SDK gets the prefixed form via `getAllowedToolsForPersona()` * in `./composio.ts`. * + * Architecture rule: every content persona is **pure-LLM** in the orchestration + * layer (Pattern B universal). Researcher's external data (Reddit / X / + * Firecrawl / Perplexity) is fetched deterministically in TypeScript at + * `lib/personas/researcher/fetch.ts` BEFORE the LLM is invoked, and splatted + * into the prompt as `fetchBundle: {...}`. Publishing happens post-approval + * via the deterministic dispatcher in `lib/dispatch/execute.ts` calling + * `composio.tools.execute()` directly — never via the LLM mid-thought. + * * Critical guardrails (enforced by absence here, not just by prompt): - * - LinkedIn is READ-ONLY for the researcher. No other persona may touch it. - * LinkedIn enforces ~100–500 msg/day per account; automating outbound at - * scale gets the founder's account banned. All outbound = Gmail. - * - Writer NEVER gets GMAIL_SEND. It only drafts (GMAIL_DRAFT). Drafts are - * flipped to sent by the Approval Gate (Session 1). - * - Scheduler does get GMAIL_SEND, but its system prompt must constrain it - * to calendar invite emails only. + * - LinkedIn READ is researcher-only (handled in fetch.ts; not exposed to LLM). + * - Writer NEVER publishes. Only the post-approval dispatcher can publish. + * - Reddit / X / Firecrawl / Perplexity reads happen in fetch.ts (deterministic). + * - Reddit / LinkedIn / GitHub / Notion / Twitter / WordPress writes happen + * in the dispatcher post-approval (deterministic). */ import type { PersonaId } from "@/lib/shared/types"; -// Composio meta-tools that batch-mode personas use to fan out tool calls -// server-side via one MCP round-trip (instead of N sequential calls). -const COMPOSIO_META_TOOLS = [ - "COMPOSIO_MULTI_EXECUTE_TOOL", - "COMPOSIO_SEARCH_TOOLS", -] as const; - -// Slugs verified against Composio's live tool catalog (probed 2026-05-09). -// Many of the names changed since the original PLAN.md was written: -// GMAIL_DRAFT → GMAIL_CREATE_EMAIL_DRAFT -// GMAIL_SEND → GMAIL_SEND_EMAIL (plus GMAIL_SEND_DRAFT for the gate) -// GMAIL_SEARCH → GMAIL_FETCH_EMAILS -// SLACK_POST_MESSAGE → SLACK_SEND_MESSAGE -// SLACK_UPDATE_MESSAGE → SLACK_UPDATES_A_SLACK_MESSAGE -// NOTION_CREATE_PAGE → NOTION_CREATE_NOTION_PAGE -// NOTION_APPEND_BLOCK → NOTION_APPEND_BLOCK_CHILDREN -// GOOGLESHEETS_APPEND_ROW → GOOGLESHEETS_SPREADSHEETS_VALUES_APPEND -// GOOGLESHEETS_READ_RANGE → GOOGLESHEETS_LOOKUP_SPREADSHEET_ROW -// HUBSPOT_ADD_NOTE → HUBSPOT_CREATE_NOTE -// HUBSPOT_SEARCH_CONTACTS → HUBSPOT_SEARCH_CONTACTS_BY_CRITERIA -// LINEAR_CREATE_ISSUE → LINEAR_CREATE_LINEAR_ISSUE -// GITHUB_CREATE_ISSUE → GITHUB_CREATE_AN_ISSUE -// LINKEDIN_SEARCH_PERSON / LINKEDIN_GET_PROFILE → LINKEDIN_GET_PERSON -// LINKEDIN_GET_COMPANY → LINKEDIN_GET_COMPANY_INFO -// APOLLO_ENRICH_EMAIL → APOLLO_PEOPLE_ENRICHMENT (+ APOLLO_PEOPLE_SEARCH) -// INTERCOM_SEND_MESSAGE → INTERCOM_REPLY_TO_CONVERSATION + INTERCOM_CREATE_CONVERSATION -// LOOM_CREATE_VIDEO → no Composio actions exist for Loom currently; dropped. export const PERSONA_SCOPES: Record<PersonaId, readonly string[]> = { // Researcher uses Pattern B: deterministic Composio fetches happen in code // (lib/personas/researcher/fetch.ts) BEFORE the LLM is invoked. The fetched - // bundle is splatted into the persona prompt as `fetchBundle` and the LLM - // synthesizes an EnrichedLead from it. No mid-loop tool calling. + // bundle is splatted into the prompt as `fetchBundle` and the LLM + // synthesizes a TopicResearchBrief from it. No mid-loop tool calling. researcher: [], - // Qualifier reasons over rawMessage + previousOutputs (researcher's bundle - // and synthesis) to assign tier/fit/intent. No Composio calls — HubSpot - // dedup gating moves to a post-approval dispatch path if/when we wire it. - qualifier: [], + // Strategist reasons over the researcher's bundle + company profile to + // produce a ContentOutline. Pure-LLM. strategist: [], - // Writer is a pure LLM reasoner — it produces a structured draft artifact for - // the dashboard's approval surface. Composio integration (Gmail/Outlook send) - // happens post-approval at the dispatch layer, not inside the LLM loop. + // Writer is a pure LLM reasoner — produces a BlogDraft (markdown). Publishing + // happens post-approval via the deterministic dispatcher. writer: [], - // Scheduler is a pure synthesizer — proposes a meeting time + invite payload. - // The dashboard's post-approval handler does the actual GOOGLECALENDAR - // create + Gmail invite send when the founder picks a provider. - scheduler: [], - // Brief Writer is a pure synthesizer — Notion sync happens post-approval. - "brief-writer": [], - // Activation is a pure synthesizer — produces a structured nudge payload. - // Gmail/Intercom delivery happens post-approval; Stripe-status checks - // would move into a Pattern B pre-fetch when needed. - activation: [], - // CRM Logger is a pure synthesizer — produces a CRM-update payload the - // dashboard's post-approval handler writes to HubSpot/Sheets when the - // founder approves. No tool calls in the LLM loop. - "crm-logger": [], - // Pipeline Reporter is a pure synthesizer — produces a summary string the - // dashboard renders + Slack Digest reads as previousOutputs. + // GEO-Editor reasons over the draft + GEO signal rules to produce an + // enriched draft. Pure-LLM. + "geo-editor": [], + // Formatter reasons over an approved BlogDraft + the founder's picked + // targets list to emit per-channel ChannelVariant content. Pure-LLM — + // dispatcher does the actual publish call. + formatter: [], + // Pipeline Reporter is a pure synthesizer — produces a content-performance + // summary the dashboard renders + Slack Digest reads as previousOutputs. "pipeline-reporter": [], - // Slack Digest produces a JSON summary block; the dashboard's post-approval - // handler is what posts to Slack via composio.tools.execute() directly. + // Slack Digest produces a content-update digest block; the dashboard's + // post-approval handler is what posts to Slack via composio.tools.execute(). "slack-digest": [], + // Insight personas — pure-LLM tagging / clustering / ticketing. "feedback-tagger": [], // Theme Synthesizer + Linear Filer write the artifact's "url" as a sentinel // the dashboard rewrites at post-approval send time. Pure-LLM personas. "theme-synthesizer": [], "linear-filer": [], - // Content (blogs) personas — Pattern B: deterministic Composio fetches happen - // pre-LLM (LinkedIn/Twitter/Reddit) and the bundle is splatted into the - // prompt. Synthesis, writing, and HTML design are pure-LLM. GitHub deploy or - // ticket creation (Linear/Jira/Notion) fires post-approval at the dispatcher. - "linkedin-researcher": [], - "x-researcher": [], - "reddit-researcher": [], - synthesizer: [], - "blog-writer": [], - "blog-designer": [], }; /** Union of every action across every persona. Used to seed the MCP config. */ @@ -104,11 +64,26 @@ export const ALL_ACTIONS: readonly string[] = Array.from( /** * Toolkit slugs (Composio's lowercase namespace) derived from action prefixes. * Used to seed the MCP config with the right toolkit catalog. + * + * Even though no persona currently has direct tool access (Pattern B), we + * still seed the MCP config with the toolkits the deterministic dispatcher + + * fetch.ts will call — that's what makes the per-user instance URL light up + * the right Connect buttons. */ -export const ALL_TOOLKITS: readonly string[] = Array.from( - new Set( - ALL_ACTIONS.map((a) => a.split("_")[0]?.toLowerCase()).filter( - (s): s is string => Boolean(s), - ), - ), -); +export const ALL_TOOLKITS: readonly string[] = [ + // Used by the Researcher's Pattern B fetch + "reddit", + "twitter", + "linkedin", + "firecrawl", + "perplexity", + // Used by the post-approval dispatcher for publishing + "github", + "wordpress", + "ghost", + "notion", + // Used by Slack Digest + alt chat surface + "slack", + // Used by Linear Filer for content task tickets + "linear", +]; diff --git a/lib/tools/slack-approval.ts b/lib/tools/slack-approval.ts index f196835..24bb5b6 100644 --- a/lib/tools/slack-approval.ts +++ b/lib/tools/slack-approval.ts @@ -36,7 +36,8 @@ export async function sendApprovalDM( channel: founderSlackUserId, text, }, - }); + dangerouslySkipVersionCheck: true, + } as never); }); } diff --git a/lib/ui/components/approval-card.tsx b/lib/ui/components/approval-card.tsx index f03bad7..9b7c3de 100644 --- a/lib/ui/components/approval-card.tsx +++ b/lib/ui/components/approval-card.tsx @@ -41,7 +41,9 @@ import type { ApprovalArtifactType, ApprovalRequest, BlastRadius, + ToolkitId, } from "@/lib/shared/types"; +import { ALL_TOOLKIT_IDS } from "@/lib/shared/types"; import { getProvidersForArtifact, type ProviderAction, @@ -80,10 +82,11 @@ const ARTIFACT_TONE: Record< ApprovalArtifactType, { label: string; icon: LucideIcon } > = { - OutreachDraft: { label: "Outreach draft", icon: Mail }, - ActivationNudge: { label: "Activation nudge", icon: Sparkles }, - CRMUpdate: { label: "CRM update", icon: Building2 }, - CustomDeal: { label: "Custom deal", icon: ListChecks }, + TopicResearchBrief: { label: "Topic research", icon: Sparkles }, + ContentOutline: { label: "Content outline", icon: ListChecks }, + BlogDraft: { label: "Blog draft", icon: Mail }, + ChannelVariant: { label: "Channel variant", icon: Building2 }, + PublishedArtifact: { label: "Published", icon: Sparkles }, }; // --------------------------------------------------------------------------- @@ -268,13 +271,21 @@ export function ApprovalCard({ [approval.proposedAction], ); + // Stable join-string of connected toolkits. Using the array directly + // would re-fire any dependent effects every render because the parent + // hands us a fresh array each pass. + const connectedKey = useMemo( + () => connectedToolkits.map((t) => t.toLowerCase()).sort().join(","), + [connectedToolkits], + ); + // Providers the founder can actually dispatch through right now: artifact // type's full catalog filtered to toolkits live-connected at page load. const availableProviders = useMemo<ProviderAction[]>(() => { const all = getProvidersForArtifact(approval.artifactType); - const connectedSet = new Set(connectedToolkits.map((t) => t.toLowerCase())); + const connectedSet = new Set(connectedKey ? connectedKey.split(",") : []); return all.filter((p) => connectedSet.has(p.toolkit.toLowerCase())); - }, [approval.artifactType, connectedToolkits]); + }, [approval.artifactType, connectedKey]); const [draft, setDraft] = useState<DraftFields>(initialDraft); const [notes, setNotes] = useState(""); @@ -284,13 +295,27 @@ export function ApprovalCard({ const [selectedProvider, setSelectedProvider] = useState<string | null>( () => availableProviders[0]?.toolkit ?? null, ); + // For BlogDraft only: which destinations the founder ticked. The dispatcher + // fans out one ChannelVariant per ticked target. + const [selectedTargets, setSelectedTargets] = useState<ToolkitId[]>([]); useEffect(() => { setDraft(initialDraft); setNotes(""); setPending(null); - setSelectedProvider(availableProviders[0]?.toolkit ?? null); - }, [initialDraft, approval.id, availableProviders]); + // Reset provider + targets when the approval changes. We re-derive the + // initial values from the stable `connectedKey` rather than depending on + // `availableProviders` (which is a useMemo that re-renders even when + // logically unchanged). + const connectedSet = new Set(connectedKey ? connectedKey.split(",") : []); + const allProviders = getProvidersForArtifact(approval.artifactType); + const firstAvailable = allProviders.find((p) => + connectedSet.has(p.toolkit.toLowerCase()), + ); + setSelectedProvider(firstAvailable?.toolkit ?? null); + const defaultTargets = ALL_TOOLKIT_IDS.filter((t) => connectedSet.has(t)); + setSelectedTargets(defaultTargets); + }, [initialDraft, approval.id, approval.artifactType, connectedKey]); const blast = BLAST_TONE[approval.blastRadius]; const artifact = ARTIFACT_TONE[approval.artifactType]; @@ -318,6 +343,13 @@ export function ApprovalCard({ status !== "rejected" && selectedProvider ? selectedProvider : undefined, + // BlogDraft-only: founder's picked publish destinations. + targets: + status !== "rejected" && + approval.artifactType === "BlogDraft" && + selectedTargets.length > 0 + ? selectedTargets + : undefined, }), }); if (!res.ok) { @@ -413,19 +445,13 @@ export function ApprovalCard({ </div> </div> ) : null} - {approval.artifactType === "OutreachDraft" ? ( - <> - <DraftContext - lead={extractLeadContext(approval.proposedAction)} - rationale={extractRationale(approval.proposedAction)} - upstream={extractUpstreamSummaries(approval.proposedAction)} - /> - <DraftEditor draft={draft} setDraft={setDraft} /> - </> - ) : approval.artifactType === "ActivationNudge" ? ( - <NudgeEditor draft={draft} setDraft={setDraft} action={approval.proposedAction} /> - ) : approval.artifactType === "CRMUpdate" ? ( - <CRMUpdatePreview action={approval.proposedAction} /> + {approval.artifactType === "BlogDraft" ? ( + <BlogDraftPreview + action={approval.proposedAction} + targets={selectedTargets} + setTargets={setSelectedTargets} + connectedToolkits={connectedToolkits} + /> ) : ( <FallbackPreview action={approval.proposedAction} /> )} @@ -795,3 +821,160 @@ function FallbackPreview({ action }: { action: Record<string, unknown> }) { </pre> ); } + +const TARGET_LABEL: Record<ToolkitId, string> = { + github: "GitHub PR (static-site repo)", + wordpress: "WordPress", + ghost: "Ghost", + notion: "Notion", + reddit: "Reddit", + linkedin: "LinkedIn", + twitter: "X (Twitter)", +}; + +const TARGET_HINT: Record<ToolkitId, string> = { + github: "Opens a PR with a markdown file", + wordpress: "Creates a draft post", + ghost: "Creates a draft post", + notion: "Inserts a row into your blog database", + reddit: "Discussion-flavored self-post", + linkedin: "Native long-form post", + twitter: "Single tweet or thread", +}; + +function BlogDraftPreview({ + action, + targets, + setTargets, + connectedToolkits, +}: { + action: Record<string, unknown>; + targets: ToolkitId[]; + setTargets: (next: ToolkitId[]) => void; + connectedToolkits: string[]; +}) { + const title = typeof action.title === "string" ? action.title : "(untitled)"; + const slug = typeof action.slug === "string" ? action.slug : ""; + const excerpt = typeof action.excerpt === "string" ? action.excerpt : ""; + const bodyMarkdown = + typeof action.bodyMarkdown === "string" ? action.bodyMarkdown : ""; + const tags = Array.isArray(action.tags) + ? (action.tags as unknown[]).filter((t): t is string => typeof t === "string") + : []; + const geoNotes = Array.isArray(action.geoNotes) + ? (action.geoNotes as unknown[]).filter((t): t is string => typeof t === "string") + : []; + + const connectedSet = new Set(connectedToolkits.map((t) => t.toLowerCase())); + + const toggleTarget = (t: ToolkitId) => { + if (targets.includes(t)) { + setTargets(targets.filter((x) => x !== t)); + } else { + setTargets([...targets, t]); + } + }; + + return ( + <div className="space-y-4"> + <div className="rounded-xl border border-border bg-background p-4"> + <div className="text-base font-semibold leading-tight">{title}</div> + {slug && ( + <div className="mt-0.5 text-[10px] font-mono text-muted-foreground"> + /{slug} + </div> + )} + {excerpt && ( + <p className="mt-2 text-xs italic text-muted-foreground">{excerpt}</p> + )} + {tags.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1.5"> + {tags.map((tag) => ( + <Badge + key={tag} + variant="secondary" + className="rounded-md px-1.5 py-0 text-[10px]" + > + {tag} + </Badge> + ))} + </div> + )} + {bodyMarkdown && ( + <pre className="mt-3 max-h-56 overflow-auto rounded-md bg-muted/40 p-2.5 text-[11px] leading-relaxed whitespace-pre-wrap"> + {bodyMarkdown} + </pre> + )} + </div> + + {geoNotes.length > 0 && ( + <div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-xs"> + <div className="mb-1 flex items-center gap-1.5 font-medium text-emerald-700 dark:text-emerald-300"> + <Sparkles className="size-3" /> + GEO-Editor changes + </div> + <ul className="ml-4 list-disc space-y-0.5 text-emerald-700/80 dark:text-emerald-300/80"> + {geoNotes.map((n, i) => ( + <li key={i}>{n}</li> + ))} + </ul> + </div> + )} + + <div> + <div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-foreground"> + <ListChecks className="size-3.5" /> + Publish to + <span className="ml-auto text-[10px] font-normal text-muted-foreground"> + (one approval, fans out to N channels) + </span> + </div> + <div className="grid grid-cols-2 gap-1.5"> + {ALL_TOOLKIT_IDS.map((t) => { + const isConnected = connectedSet.has(t); + const isChecked = targets.includes(t); + return ( + <label + key={t} + className={cn( + "flex cursor-pointer items-start gap-2 rounded-lg border p-2.5 text-xs transition", + isChecked + ? "border-foreground bg-muted" + : "border-border bg-background hover:bg-muted/40", + !isConnected && "cursor-not-allowed opacity-50", + )} + > + <input + type="checkbox" + checked={isChecked} + disabled={!isConnected} + onChange={() => toggleTarget(t)} + className="mt-0.5 size-3.5 cursor-pointer accent-foreground disabled:cursor-not-allowed" + /> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-1.5 font-medium leading-none"> + <span className="truncate">{TARGET_LABEL[t]}</span> + {!isConnected && ( + <span className="text-[9px] uppercase tracking-wide text-muted-foreground"> + not connected + </span> + )} + </div> + <div className="mt-0.5 truncate text-[10px] text-muted-foreground"> + {TARGET_HINT[t]} + </div> + </div> + </label> + ); + })} + </div> + {targets.length === 0 && ( + <p className="mt-2 text-[11px] italic text-muted-foreground"> + Approve with no targets ticked to mark the draft approved without + publishing anywhere. + </p> + )} + </div> + </div> + ); +} diff --git a/lib/ui/components/approvals-list.tsx b/lib/ui/components/approvals-list.tsx index 51a3bfd..3af6a52 100644 --- a/lib/ui/components/approvals-list.tsx +++ b/lib/ui/components/approvals-list.tsx @@ -29,10 +29,11 @@ import type { import { cn } from "@/lib/utils"; const ARTIFACT_ICON: Record<ApprovalArtifactType, LucideIcon> = { - OutreachDraft: Mail, - ActivationNudge: Sparkles, - CRMUpdate: Building2, - CustomDeal: ListChecks, + TopicResearchBrief: Sparkles, + ContentOutline: ListChecks, + BlogDraft: Mail, + ChannelVariant: Building2, + PublishedArtifact: Sparkles, }; const BLAST_TONE: Record< diff --git a/lib/ui/components/connection-meta.ts b/lib/ui/components/connection-meta.ts index cc19c55..1d7ec34 100644 --- a/lib/ui/components/connection-meta.ts +++ b/lib/ui/components/connection-meta.ts @@ -6,67 +6,84 @@ */ export type ToolkitCategory = + | "publishing" + | "social" + | "research" + | "knowledge" + | "messaging" + | "pm" | "email" | "calendar" | "crm" - | "knowledge" - | "messaging" | "listening" - | "research" | "sequencer" | "analytics" | "callintel" - | "pm" | "devpay" | "other"; export const TOOLKIT_CATEGORY: Record<string, ToolkitCategory> = { + // Content publishing destinations (post-pivot priority) + GITHUB: "publishing", WORDPRESS: "publishing", GHOST: "publishing", + WEBFLOW: "publishing", HASHNODE: "publishing", MEDIUM: "publishing", + SUBSTACK: "publishing", DEV: "publishing", + // Social distribution + REDDIT: "social", LINKEDIN: "social", TWITTER: "social", YOUTUBE: "social", + // Content research + grounding + FIRECRAWL: "research", PERPLEXITY: "research", TAVILY: "research", EXA: "research", + GOOGLE_SEARCH_CONSOLE: "research", GOOGLE_ANALYTICS: "research", + SEMRUSH: "research", AHREFS: "research", + APOLLO: "research", HUNTER: "research", CRUNCHBASE: "research", CLAY: "research", + // Knowledge / docs + NOTION: "knowledge", GOOGLESHEETS: "knowledge", + // Messaging — alt chat surface + SLACK: "messaging", DISCORD: "messaging", INTERCOM: "messaging", + // Project management — content task tickets + LINEAR: "pm", ASANA: "pm", JIRA: "pm", MONDAY: "pm", CLICKUP: "pm", TRELLO: "pm", + // Legacy GTM toolkits — kept for backward compat GMAIL: "email", OUTLOOK: "email", MAILCHIMP: "email", CUSTOMERIO: "email", GOOGLECALENDAR: "calendar", CALENDLY: "calendar", ZOOM: "calendar", HUBSPOT: "crm", SALESFORCE: "crm", PIPEDRIVE: "crm", ATTIO: "crm", - NOTION: "knowledge", GOOGLESHEETS: "knowledge", - SLACK: "messaging", DISCORD: "messaging", INTERCOM: "messaging", - REDDIT: "listening", YOUTUBE: "listening", LINKEDIN: "listening", TWITTER: "listening", - APOLLO: "research", TAVILY: "research", EXA: "research", - FIRECRAWL: "research", PERPLEXITY: "research", HUNTER: "research", - CRUNCHBASE: "research", CLAY: "research", LEMLIST: "sequencer", INSTANTLY: "sequencer", SMARTLEAD: "sequencer", SALESLOFT: "sequencer", MIXPANEL: "analytics", AMPLITUDE: "analytics", POSTHOG: "analytics", GONG: "callintel", FIREFLIES: "callintel", CHORUS: "callintel", - LINEAR: "pm", ASANA: "pm", JIRA: "pm", MONDAY: "pm", CLICKUP: "pm", TRELLO: "pm", - GITHUB: "devpay", STRIPE: "devpay", + STRIPE: "devpay", }; export const CATEGORY_ORDER: ToolkitCategory[] = [ + "publishing", + "social", + "research", + "knowledge", + "messaging", + "pm", + // Legacy categories — surfaced under "More" "email", "calendar", "crm", - "messaging", - "knowledge", "listening", - "research", "sequencer", "analytics", "callintel", - "pm", "devpay", "other", ]; // Pinned section at top of Connections page; order is the suggested setup sequence. +// Content-pivot priority: blog publishing → social distribution → knowledge. export const POPULAR_CATEGORY_ID = "popular"; export const POPULAR_TOOLKITS = [ - "GMAIL", - "GOOGLECALENDAR", - "GOOGLESHEETS", "GITHUB", + "WORDPRESS", + "NOTION", + "REDDIT", "LINKEDIN", "TWITTER", - "REDDIT", - "NOTION", + "FIRECRAWL", + "PERPLEXITY", + "SLACK", "LINEAR", - "JIRA", ] as const satisfies readonly (keyof typeof TOOLKIT_META)[]; export const TOOLKIT_LOGO_URL: Record<string, string> = { @@ -118,6 +135,18 @@ export const TOOLKIT_LOGO_URL: Record<string, string> = { GONG: "https://www.google.com/s2/favicons?domain=gong.io&sz=64", FIREFLIES: "https://www.google.com/s2/favicons?domain=fireflies.ai&sz=64", CHORUS: "https://www.google.com/s2/favicons?domain=chorus.ai&sz=64", + // Content publishing additions (post-pivot) + WORDPRESS: "https://cdn.simpleicons.org/wordpress", + GHOST: "https://cdn.simpleicons.org/ghost", + WEBFLOW: "https://cdn.simpleicons.org/webflow", + HASHNODE: "https://cdn.simpleicons.org/hashnode", + MEDIUM: "https://cdn.simpleicons.org/medium", + SUBSTACK: "https://cdn.simpleicons.org/substack", + DEV: "https://cdn.simpleicons.org/devdotto", + GOOGLE_SEARCH_CONSOLE: "https://www.google.com/s2/favicons?domain=search.google.com&sz=64", + GOOGLE_ANALYTICS: "https://www.google.com/s2/favicons?domain=analytics.google.com&sz=64", + SEMRUSH: "https://cdn.simpleicons.org/semrush", + AHREFS: "https://cdn.simpleicons.org/ahrefs", }; export const TOOLKIT_META: Record<string, { name: string }> = { @@ -179,20 +208,34 @@ export const TOOLKIT_META: Record<string, { name: string }> = { // Dev & payments GITHUB: { name: "GitHub" }, STRIPE: { name: "Stripe" }, + // Content publishing destinations (post-pivot) + WORDPRESS: { name: "WordPress" }, + GHOST: { name: "Ghost" }, + WEBFLOW: { name: "Webflow" }, + HASHNODE: { name: "Hashnode" }, + MEDIUM: { name: "Medium" }, + SUBSTACK: { name: "Substack" }, + DEV: { name: "Dev.to" }, + GOOGLE_SEARCH_CONSOLE: { name: "Google Search Console" }, + GOOGLE_ANALYTICS: { name: "Google Analytics" }, + SEMRUSH: { name: "Semrush" }, + AHREFS: { name: "Ahrefs" }, }; export const CATEGORY_LABEL: Record<ToolkitCategory, string> = { + publishing: "Blog publishing", + social: "Social distribution", + research: "Research & GEO grounding", + knowledge: "Docs & knowledge", + messaging: "Messaging", + pm: "Project management", email: "Email", calendar: "Calendar & meetings", crm: "CRM", - messaging: "Messaging", - knowledge: "Docs & knowledge", listening: "Listening & lead sources", - research: "Research & enrichment", sequencer: "Outbound sequencers", analytics: "Product analytics", callintel: "Call intelligence", - pm: "Project management", devpay: "Dev & payments", other: "Other", }; diff --git a/lib/ui/components/dag-view.tsx b/lib/ui/components/dag-view.tsx index 8d25527..e540f2e 100644 --- a/lib/ui/components/dag-view.tsx +++ b/lib/ui/components/dag-view.tsx @@ -270,7 +270,7 @@ function build(args: BuildArgs): { nodes: Node<DagNodeData>[]; edges: Edge[] } { } const activeDepts: Department[] = ( - ["sales", "cs", "revops", "insight"] as Department[] + ["content", "distribution", "insight"] as Department[] ).filter((d) => PERSONA_ORDER[d].some((p) => stageGroups.has(stageKey(d, p))), ); diff --git a/lib/ui/components/feedback-heuristics.ts b/lib/ui/components/feedback-heuristics.ts index 988fc99..c7cff99 100644 --- a/lib/ui/components/feedback-heuristics.ts +++ b/lib/ui/components/feedback-heuristics.ts @@ -391,7 +391,7 @@ export function applyFeedbackHeuristics( body: string, subject: string | undefined, note: string, - _kind: ApprovalArtifactType = "OutreachDraft", + _kind: ApprovalArtifactType = "BlogDraft", ): HeuristicResult { const trimmedNote = note.trim(); if (trimmedNote.length === 0) { diff --git a/lib/ui/components/live-approval-surface.tsx b/lib/ui/components/live-approval-surface.tsx index 4c7c272..33dcfa4 100644 --- a/lib/ui/components/live-approval-surface.tsx +++ b/lib/ui/components/live-approval-surface.tsx @@ -9,7 +9,7 @@ import { extractDraftFields, } from "@/lib/ui/components/approval-card"; import { registerRevision } from "@/lib/ui/components/mock-approval-builder"; -import type { ApprovalRequest } from "@/lib/shared/types"; +import type { ApprovalArtifactType, ApprovalRequest } from "@/lib/shared/types"; import { MOCK_MODE } from "@/lib/ui/hooks/use-mock-driver"; import { injectMockRevisedApproval, @@ -30,7 +30,7 @@ async function fetchLlmRewrite(payload: { currentBody: string; currentSubject?: string; founderNote: string; - kind: "OutreachDraft" | "ActivationNudge"; + kind: ApprovalArtifactType; }): Promise<LlmRewrite | null> { try { const res = await fetch("/api/mock/revise-draft", { @@ -212,10 +212,7 @@ export function LiveApprovalSurface({ runId }: { runId?: string } = {}) { // LLM call falls back to heuristics if it errors or times out, so the // demo never hangs on a flaky model. const toastId = toast.loading("Agent revising the draft…"); - const llmKind = - original.artifactType === "ActivationNudge" - ? "ActivationNudge" - : "OutreachDraft"; + const llmKind: ApprovalArtifactType = original.artifactType; void fetchLlmRewrite({ currentBody: priorBody, currentSubject: priorSubject, diff --git a/lib/ui/components/mock-approval-builder.ts b/lib/ui/components/mock-approval-builder.ts index 7468b81..52d8f06 100644 --- a/lib/ui/components/mock-approval-builder.ts +++ b/lib/ui/components/mock-approval-builder.ts @@ -40,18 +40,23 @@ import { // --------------------------------------------------------------------------- const DEFAULT_OUTREACH_BODY = - "Hey Jordan,\n\n" + - "Saw your HN post - congrats on the seed. I run GMaestro, an AI GTM team for founders.\n\n" + - "Noticed Acme is hiring backend engineers; that's exactly the moment we wedge in for most of our customers (founder-led GTM, no sales hire yet). Mind if I send a 90-second Loom on what we'd do for the first 47 leads in your inbox this week?\n\n" + - "If there's a better time, just say the word.\n\n" + - "- [Founder]"; - -const DEFAULT_OUTREACH_SUBJECT = "Demo for Acme - quick question"; + "## What changed\n\n" + + "Cold email response rates dropped from 4% to under 1% in twelve months. Buyers now treat unsolicited email as adversarial. Meanwhile, blog-driven inbound is up 3× year-over-year.\n\n" + + "Founders who delegate cold email lose deals. Founders who delegate blogs win them. The asymmetry is structural — and it's only widening as AI search (Perplexity, ChatGPT) takes over informational queries.\n\n" + + "## The founder-led blueprint\n\n" + + "Three things that work in 2026:\n\n" + + "1. Delegate research + drafting + distribution to a multi-agent team — but keep approval gates on every irreversible publish.\n" + + "2. Optimize for AI-search citation, not Google's 10 blue links. Reddit citations matter more than meta descriptions.\n" + + "3. Reformat the same post for each channel — blog, Reddit thread, LinkedIn carousel, X thread. Don't copy-paste.\n\n" + + "> \"We stopped chasing cold-email response rates and started measuring Perplexity citations. Pipeline doubled in 90 days.\" — Anvil Founder"; + +const DEFAULT_OUTREACH_SUBJECT = + "Why founder-led GTM beats AI cold email in 2026"; const DEFAULT_NUDGE_BODY = - "Hey, saw you started a trial yesterday but haven't connected Gmail yet. Want me to walk you through it?"; + "## What changed\n\nCold email response rates have fallen below 1% across most B2B SaaS verticals — and AI search now handles ~15% of informational queries. The structural shift is real."; -const DEFAULT_NUDGE_SUBJECT = "Stuck on connecting your first tool?"; +const DEFAULT_NUDGE_SUBJECT = "Why founder-led GTM beats AI cold email in 2026"; // --------------------------------------------------------------------------- // Revision metadata — authoritative source for `revision`, `priorNote`, @@ -188,7 +193,7 @@ function buildRevisedOutreachDraft(meta: RevisionMeta): BuiltDraft { prior.body, prior.subject, meta.priorNote, - "OutreachDraft", + "BlogDraft", ); // If at least one heuristic fired, return as-is (transformed body) plus a // subject suffix that surfaces the revision count. @@ -287,7 +292,7 @@ function buildRevisedNudgeDraft(meta: RevisionMeta): BuiltDraft { prior.body, prior.subject, meta.priorNote, - "ActivationNudge", + "BlogDraft", ); const subjectBase = result.subject ?? prior.subject ?? DEFAULT_NUDGE_SUBJECT; const subjectClean = subjectBase.replace(/\s*\(revised v\d+\)\s*$/, "").trim(); @@ -337,32 +342,31 @@ export function buildMockApproval( const kind = artifactType as ApprovalArtifactType; let proposedAction: Record<string, unknown>; - if (kind === "OutreachDraft") { + if (kind === "BlogDraft") { const draft = isRevision && meta ? buildRevisedOutreachDraft(meta) : { subject: DEFAULT_OUTREACH_SUBJECT, body: DEFAULT_OUTREACH_BODY }; proposedAction = { - tool: "gmail.send", - to: "jordan@acme.example", - subject: draft.subject, - body: draft.body, + title: draft.subject, + slug: "founder-led-gtm-beats-ai-cold-email", + excerpt: "AI cold email has hit a 1% response ceiling. Here's what's working instead.", + bodyMarkdown: draft.body, }; - } else if (kind === "ActivationNudge") { + } else if (kind === "ChannelVariant") { const draft = isRevision && meta ? buildRevisedNudgeDraft(meta) : { subject: DEFAULT_NUDGE_SUBJECT, body: DEFAULT_NUDGE_BODY }; proposedAction = { - channel: "email", - subject: draft.subject, - body: draft.body, + target: "github", + content: draft.body, + metadata: { repo: "anvil-co/anvil-site", path: "content/blog/post.mdx" }, }; } else { proposedAction = { - tool: "hubspot.update_deal", - dealId: "d_123", - stage: "interested", + title: "Topic research brief", + candidates: [], }; } diff --git a/lib/ui/components/run-input-form.tsx b/lib/ui/components/run-input-form.tsx new file mode 100644 index 0000000..e79ed2f --- /dev/null +++ b/lib/ui/components/run-input-form.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { FileText, Globe, Loader2, Send } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; +import { MOCK_MODE } from "@/lib/ui/hooks/use-mock-driver"; +import { saveMockRun } from "@/lib/ui/hooks/use-mock-active-run"; +import { injectSharedEvent } from "@/lib/ui/hooks/use-shared-events"; +import { + ALL_DESTINATIONS, + type Destination, +} from "@/lib/shared/types"; +import { cn } from "@/lib/utils"; + +interface RunInputFormProps { + /** + * Called immediately after a new run is created (mock or real). Receives + * the run id + a human-readable summary so the caller can hydrate its + * run-list state without waiting for the first SSE event. + */ + onRunStarted: (runId: string, summary: string) => void; +} + +const DESTINATION_META: Record< + Destination, + { label: string; hint: string } +> = { + "blog-html": { + label: "Blog (HTML)", + hint: "~1,000 word post → GitHub PR or CMS", + }, + reddit: { + label: "Reddit thread", + hint: "Discussion-flavored post (~250 words) for r/programming, r/devtools, etc.", + }, + "x-thread": { + label: "X thread", + hint: "5–10 tweet thread, claim-with-number hook", + }, +}; + +export function RunInputForm({ onRunStarted }: RunInputFormProps) { + const [companyUrl, setCompanyUrl] = useState(""); + const [docsUrl, setDocsUrl] = useState(""); + const [destination, setDestination] = useState<Destination>("blog-html"); + const [pending, startTransition] = useTransition(); + + const cTrim = companyUrl.trim(); + const dTrim = docsUrl.trim(); + + const looksLikeUrl = (v: string): boolean => { + if (!v) return false; + try { + new URL(v); + return true; + } catch { + return false; + } + }; + + const isReady = looksLikeUrl(cTrim) && looksLikeUrl(dTrim) && !pending; + + const submit = () => { + if (!looksLikeUrl(cTrim)) { + toast.error("Company URL doesn't look like a valid URL"); + return; + } + if (!looksLikeUrl(dTrim)) { + toast.error("Docs URL doesn't look like a valid URL"); + return; + } + + const summary = `Blog from ${truncateUrl(dTrim)} → ${DESTINATION_META[destination].label}`; + + startTransition(async () => { + try { + if (MOCK_MODE) { + const runId = `mock-run-${Date.now().toString(36)}`; + const startedAt = new Date().toISOString(); + saveMockRun({ id: runId, prompt: summary, startedAt }); + injectSharedEvent({ + type: "workflow_started", + payload: { workflowRunId: runId, prompt: summary, startedAt }, + }); + onRunStarted(runId, summary); + toast.success("Mock run dispatched"); + return; + } + + const res = await fetch("/api/runs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + companyUrl: cTrim, + docsUrl: dTrim, + destination, + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `HTTP ${res.status}`); + } + + const { workflowRunId } = (await res.json()) as { workflowRunId?: string }; + if (!workflowRunId) throw new Error("Run created but no id returned"); + onRunStarted(workflowRunId, summary); + toast.success("Run started"); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to start run", + ); + } + }); + }; + + return ( + <div className="flex flex-col gap-4 rounded-xl border border-border bg-card p-4"> + <div className="flex items-center justify-between"> + <div className="text-sm font-medium text-muted-foreground"> + Point us at your docs. We'll write the blog version. + </div> + {MOCK_MODE ? ( + <span className="rounded-md bg-amber-500/15 px-2 py-0.5 font-mono text-[10px] text-amber-700 dark:text-amber-300"> + MOCK MODE + </span> + ) : null} + </div> + + <div className="grid gap-3"> + <div className="grid gap-1.5"> + <label htmlFor="company-url" className="flex items-center gap-1.5 text-xs font-medium text-foreground"> + <Globe className="size-3" /> + Company website + <span className="ml-auto text-[10px] font-normal text-muted-foreground"> + we read your homepage + recent posts to match your voice + </span> + </label> + <Input + id="company-url" + type="url" + value={companyUrl} + onChange={(e) => setCompanyUrl(e.target.value)} + placeholder="https://anvil.co" + className="font-mono text-xs" + /> + </div> + + <div className="grid gap-1.5"> + <label htmlFor="docs-url" className="flex items-center gap-1.5 text-xs font-medium text-foreground"> + <FileText className="size-3" /> + Technical doc URL + <span className="ml-auto text-[10px] font-normal text-muted-foreground"> + the page or markdown the blog is about + </span> + </label> + <Input + id="docs-url" + type="url" + value={docsUrl} + onChange={(e) => setDocsUrl(e.target.value)} + placeholder="https://docs.anvil.co/v2.3/auth" + className="font-mono text-xs" + /> + </div> + + <div className="grid gap-1.5"> + <label className="flex items-center gap-1.5 text-xs font-medium text-foreground"> + Destination + <span className="ml-auto text-[10px] font-normal text-muted-foreground"> + where the post lands + </span> + </label> + <div className="grid grid-cols-3 gap-2"> + {ALL_DESTINATIONS.map((d) => { + const meta = DESTINATION_META[d]; + const selected = destination === d; + return ( + <button + key={d} + type="button" + onClick={() => setDestination(d)} + className={cn( + "flex flex-col items-start gap-0.5 rounded-lg border px-3 py-2 text-left text-xs transition", + selected + ? "border-foreground bg-muted" + : "border-border bg-background hover:bg-muted/40", + )} + > + <span className="font-medium leading-none">{meta.label}</span> + <span className="text-[10px] leading-tight text-muted-foreground"> + {meta.hint} + </span> + </button> + ); + })} + </div> + </div> + </div> + + <div className="flex justify-end"> + <Button + onClick={submit} + disabled={!isReady} + className="cursor-pointer hover:bg-primary/80 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed" + > + {pending ? ( + <> + <Loader2 className="animate-spin" /> + Starting… + </> + ) : ( + <> + <Send /> + Run + </> + )} + </Button> + </div> + </div> + ); +} + +function truncateUrl(url: string): string { + try { + const u = new URL(url); + const path = u.pathname === "/" ? "" : u.pathname; + const full = u.host + path; + return full.length > 40 ? full.slice(0, 40) + "…" : full; + } catch { + return url.slice(0, 40); + } +} diff --git a/lib/ui/hooks/use-mock-driver.ts b/lib/ui/hooks/use-mock-driver.ts index 4404cac..d55e7c7 100644 --- a/lib/ui/hooks/use-mock-driver.ts +++ b/lib/ui/hooks/use-mock-driver.ts @@ -47,19 +47,11 @@ const TOOL_FOR_PERSONA: Record<string, string> = { }; const ARTIFACT_FOR_PERSONA: Record<string, string> = { - researcher: "EnrichedLead", - qualifier: "QualifiedLead", - strategist: "OutreachStrategy", - writer: "OutreachDraft", - activation: "ActivationNudge", - "crm-logger": "CRMRecord", - // Content (blogs) personas - "linkedin-researcher": "LinkedInResearchBundle", - "x-researcher": "XResearchBundle", - "reddit-researcher": "RedditResearchBundle", - synthesizer: "TopicBrief", - "blog-writer": "ContentDraft", - "blog-designer": "DesignedContent", + researcher: "TopicResearchBrief", + strategist: "ContentOutline", + writer: "BlogDraft", + "geo-editor": "BlogDraft", + formatter: "ChannelVariant", }; function buildScript( @@ -92,21 +84,16 @@ function buildScript( const departments = [ { - dept: "sales" as const, - specialists: ["researcher", "qualifier", "strategist", "writer"], + dept: "content" as const, + specialists: ["researcher", "strategist", "writer", "geo-editor", "formatter"], }, - { dept: "cs" as const, specialists: ["activation"] }, - { dept: "revops" as const, specialists: ["crm-logger"] }, { - dept: "content" as const, - specialists: [ - "linkedin-researcher", - "x-researcher", - "reddit-researcher", - "synthesizer", - "blog-writer", - "blog-designer", - ], + dept: "distribution" as const, + specialists: ["pipeline-reporter", "slack-digest"], + }, + { + dept: "insight" as const, + specialists: ["feedback-tagger", "theme-synthesizer", "linear-filer"], }, ]; @@ -182,29 +169,19 @@ function buildScript( delayMs: t, }); - // Approval gate. Fire on the GTM Writer (outreach demo) AND on the - // blog-designer (blogs demo) — first occurrence only so a fanout - // doesn't pile up N redundant approvals. - const isGtmApprovalGate = sp === "writer" && firstTaskOfPersona; - const isBlogApprovalGate = - sp === "blog-designer" && firstTaskOfPersona; - if (isGtmApprovalGate || isBlogApprovalGate) { + // Approval gate — fire after the GEO-Editor produces the BlogDraft, + // since that's where the founder picks publish destinations. + if (sp === "geo-editor" && firstTaskOfPersona) { t += 350; script.push({ type: "approval_requested", payload: { workflowRunId, - // Namespace by run id so concurrent mock runs don't collide on - // the same approval id (the first run's approval would otherwise - // be overwritten in the global pending-approvals store). approvalId: `mock-approval-${workflowRunId}-${nodeId}`, - artifactType: isBlogApprovalGate - ? "DesignedContent" - : "OutreachDraft", + artifactType: "BlogDraft", blastRadius: "external", - reason: isBlogApprovalGate - ? "Publishing a blog post — deploy to GitHub or file a ticket in Jira / Linear / Notion." - : "Sending personalized outreach to a real prospect outside the team.", + reason: + "Approve the final draft and pick which destinations to publish to.", }, delayMs: t, }); diff --git a/lib/ui/persona-meta.ts b/lib/ui/persona-meta.ts index a191d94..b196c20 100644 --- a/lib/ui/persona-meta.ts +++ b/lib/ui/persona-meta.ts @@ -1,27 +1,19 @@ import { Activity, - Briefcase, - Building2, - Calendar, ChartBar, - ClipboardCheck, FileSearch, - FileText, - Globe, - GraduationCap, - Hash, Layers, - MessageCircle, + Megaphone, MessagesSquare, Network, Newspaper, Palette, PenLine, Search, + Send, Sparkles, Tag, - TrendingUp, - Users, + Wand2, Workflow, type LucideIcon, } from "lucide-react"; @@ -29,85 +21,57 @@ import { import type { Department, PersonaId } from "@/lib/shared/types"; export const DEPARTMENT_OF_PERSONA: Record<PersonaId, Department> = { - researcher: "sales", - qualifier: "sales", - strategist: "sales", - writer: "sales", - scheduler: "sales", - "brief-writer": "sales", - activation: "cs", - "crm-logger": "revops", - "pipeline-reporter": "revops", - "slack-digest": "revops", + // Content + researcher: "content", + strategist: "content", + writer: "content", + "geo-editor": "content", + formatter: "content", + // Distribution + "pipeline-reporter": "distribution", + "slack-digest": "distribution", + // Insight "feedback-tagger": "insight", "theme-synthesizer": "insight", "linear-filer": "insight", - "linkedin-researcher": "content", - "x-researcher": "content", - "reddit-researcher": "content", - synthesizer: "content", - "blog-writer": "content", - "blog-designer": "content", }; export const DEPARTMENT_LABEL: Record<Department, string> = { - sales: "Sales", - cs: "Customer Success", - revops: "RevOps", - insight: "Insight", content: "Content", + distribution: "Distribution", + insight: "Insight", }; export const PERSONA_LABEL: Record<PersonaId, string> = { researcher: "Researcher", - qualifier: "Qualifier", strategist: "Strategist", writer: "Writer", - scheduler: "Scheduler", - "brief-writer": "Brief Writer", - activation: "Activation", - "crm-logger": "CRM Logger", + "geo-editor": "GEO Editor", + formatter: "Formatter", "pipeline-reporter": "Pipeline Reporter", "slack-digest": "Slack Digest", "feedback-tagger": "Feedback Tagger", "theme-synthesizer": "Theme Synthesizer", "linear-filer": "Linear Filer", - "linkedin-researcher": "LinkedIn Researcher", - "x-researcher": "X Researcher", - "reddit-researcher": "Reddit Researcher", - synthesizer: "Synthesizer", - "blog-writer": "Blog Writer", - "blog-designer": "Blog Designer", }; export const PERSONA_ICON: Record<PersonaId, LucideIcon> = { researcher: Search, - qualifier: ClipboardCheck, strategist: Sparkles, writer: PenLine, - scheduler: Calendar, - "brief-writer": FileText, - activation: GraduationCap, - "crm-logger": Building2, + "geo-editor": Wand2, + formatter: Layers, "pipeline-reporter": ChartBar, "slack-digest": MessagesSquare, "feedback-tagger": Tag, "theme-synthesizer": Layers, "linear-filer": FileSearch, - "linkedin-researcher": Globe, - "x-researcher": Hash, - "reddit-researcher": MessageCircle, - synthesizer: Sparkles, - "blog-writer": PenLine, - "blog-designer": Palette, }; export const DEPARTMENT_ICON: Record<Department, LucideIcon> = { - sales: TrendingUp, - cs: Users, - revops: Briefcase, + content: Megaphone, + distribution: Send, insight: Activity, - content: Newspaper, }; export function isPersonaId(value: string): value is PersonaId { @@ -143,25 +107,9 @@ export type NodeStatus = | "skipped"; export const PERSONA_ORDER: Record<Department, PersonaId[]> = { - sales: [ - "researcher", - "qualifier", - "strategist", - "writer", - "scheduler", - "brief-writer", - ], - cs: ["activation"], - revops: ["crm-logger", "pipeline-reporter", "slack-digest"], + content: ["researcher", "strategist", "writer", "geo-editor", "formatter"], + distribution: ["pipeline-reporter", "slack-digest"], insight: ["feedback-tagger", "theme-synthesizer", "linear-filer"], - content: [ - "linkedin-researcher", - "x-researcher", - "reddit-researcher", - "synthesizer", - "blog-writer", - "blog-designer", - ], }; /** @@ -171,58 +119,36 @@ export const PERSONA_ORDER: Record<Department, PersonaId[]> = { */ export const PERSONA_ROLE: Record<PersonaId, string> = { researcher: - "Looks up each lead's company, role, and recent intent signals.", - qualifier: - "Scores leads on fit and intent, sorts them into hot / warm / cold.", + "Pulls Reddit / X / competitor blogs and AI-search citation footprints to surface candidate angles.", strategist: - "Picks the right outreach angle and tone for each lead.", + "Turns research into an outline with thesis, target keywords, and GEO signals.", writer: - "Drafts a personalized email — never sends, always queues for your review.", - scheduler: - "Books meetings on your calendar once a lead is ready to talk.", - "brief-writer": - "Writes a meeting-prep doc before each call.", - activation: - "Nudges trial users who are stalling, gently.", - "crm-logger": - "Mirrors qualified leads and drafts into your CRM.", + "Drafts the post in your voice — never publishes, always queues for your review.", + "geo-editor": + "Optimizes for AI search citation — direct-answer leads, fact density, schema recs.", + formatter: + "Reshapes one approved draft into per-channel variants (GitHub PR, Reddit, LinkedIn, X, Notion).", "pipeline-reporter": - "Rolls up the day's pipeline movement for review.", + "Rolls up the run's content output — words, GEO signals, channels published.", "slack-digest": - "Posts the daily wrap-up to Slack.", + "Posts a content-shipping digest to Slack when the run wraps.", "feedback-tagger": - "Tags incoming feedback by theme.", + "Tags post-publish reactions (Reddit comments, LinkedIn replies, analytics anomalies) by theme.", "theme-synthesizer": - "Synthesizes recurring themes from feedback.", + "Clusters recurring reader signals into a content backlog.", "linear-filer": - "Files Linear tickets for product feedback.", - "linkedin-researcher": - "Pulls keywords, ICP signals, and competitor angles from LinkedIn.", - "x-researcher": - "Tracks trending threads and SEO terms your buyers are sharing on X.", - "reddit-researcher": - "Mines subreddits your ICP reads for the questions they actually ask.", - synthesizer: - "Combines the three research feeds and ideates blog topics worth writing.", - "blog-writer": - "Drafts the blog post in your voice — never publishes, always queues for review.", - "blog-designer": - "Wraps the draft in self-contained HTML ready to deploy or hand off.", + "Files Linear tickets for topic gaps, quality issues, and follow-up requests.", }; /** - * Department-level role copy. Surfaced on Manager-node popovers so the user - * understands what an entire dept is for, before drilling into individual - * specialists. + * Department-level role copy. */ export const DEPARTMENT_ROLE: Record<Department, string> = { - sales: - "Handles inbound leads end-to-end — research, qualify, draft outreach.", - cs: "Watches trial signals and re-engages stalled users.", - revops: "Mirrors pipeline state into your CRM and Slack.", - insight: "Captures and routes product feedback.", content: - "Researches your ICP across LinkedIn, X, and Reddit, then writes and designs the blog.", + "Researches, plans, drafts, and GEO-optimizes the post end-to-end.", + distribution: + "Reports the run + posts the content-shipping digest to Slack.", + insight: "Captures post-publish reactions and routes them into the backlog.", }; /** diff --git a/scripts/_check-slack.ts b/scripts/_check-slack.ts new file mode 100644 index 0000000..d91aaa7 --- /dev/null +++ b/scripts/_check-slack.ts @@ -0,0 +1,21 @@ +/* eslint-disable */ +import { Composio } from "@composio/core"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +async function main() { + let key = process.env.COMPOSIO_API_KEY; + if (!key) { + const ep = path.join(os.homedir(), ".gmaestro", ".env"); + if (fs.existsSync(ep)) { + const m = fs.readFileSync(ep, "utf-8").match(/^COMPOSIO_API_KEY=(.+)$/m); + if (m) key = m[1].replace(/^["']|["']$/g, ""); + } + } + const c = new Composio({ apiKey: key }); + const list = await c.connectedAccounts.list({ toolkitSlugs: ["slack"], userIds: ["default"] }); + console.log(`Slack connections for userId="default": ${list.items.length}`); + for (const x of list.items) console.log(` ${x.id} status=${x.status}`); +} +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/_fix-firecrawl.ts b/scripts/_fix-firecrawl.ts new file mode 100644 index 0000000..59fd700 --- /dev/null +++ b/scripts/_fix-firecrawl.ts @@ -0,0 +1,90 @@ +/* eslint-disable */ +/** + * Diagnostic + repair: delete all firecrawl connections, then create a fresh + * one bound to userId="default" with the user-supplied API key, then prove + * tools.execute works. + */ +import { Composio } from "@composio/core"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +async function main() { + let key = process.env.COMPOSIO_API_KEY; + if (!key) { + const envPath = path.join(os.homedir(), ".gmaestro", ".env"); + if (fs.existsSync(envPath)) { + const m = fs.readFileSync(envPath, "utf-8").match(/^COMPOSIO_API_KEY=(.+)$/m); + if (m) key = m[1].replace(/^["']|["']$/g, ""); + } + } + if (!key) { + console.error("No COMPOSIO_API_KEY available."); + process.exit(1); + } + const fcKey = process.env.FIRECRAWL_API_KEY; + if (!fcKey) { + console.error("FIRECRAWL_API_KEY env var required."); + process.exit(1); + } + + const userId = "default"; + const authConfigId = "ac_X_lMXeDzr7EF"; + const composio = new Composio({ apiKey: key }); + + console.log("=== Step 1: list and delete all firecrawl connections ==="); + const list = await composio.connectedAccounts.list({ toolkitSlugs: ["firecrawl"] }); + console.log(`Found ${list.items.length} existing connections.`); + for (const c of list.items) { + console.log(` Deleting ${c.id}...`); + await composio.connectedAccounts.delete(c.id); + } + console.log("All cleared."); + + // Brief delay to let Composio's index settle. + await new Promise((r) => setTimeout(r, 1500)); + + console.log("\n=== Step 2: create fresh connection bound to userId=default ==="); + const req = await composio.connectedAccounts.initiate(userId, authConfigId, { + config: { + authScheme: "API_KEY", + val: { generic_api_key: fcKey }, + }, + } as never); + console.log(`Initiated: id=${req.id} status=${req.status}`); + + const connected = await composio.connectedAccounts.waitForConnection(req.id, 30_000); + console.log(`Connected: id=${connected.id} status=${connected.status}`); + + console.log("\n=== Step 3: smoke-test FIRECRAWL_SCRAPE ==="); + const start = Date.now(); + try { + const r = (await composio.tools.execute("FIRECRAWL_SCRAPE", { + userId, + arguments: { + url: "https://docs.composio.dev/toolkits/firecrawl", + formats: ["markdown"], + waitFor: 5000, + onlyMainContent: true, + }, + dangerouslySkipVersionCheck: true, + } as never)) as Record<string, unknown>; + const elapsed = Date.now() - start; + console.log(`OK in ${elapsed}ms; top-level keys: ${Object.keys(r).join(",")}`); + console.log(`successful: ${r.successful}`); + console.log(`error: ${JSON.stringify(r.error)}`); + console.log(`data keys: ${r.data && typeof r.data === "object" ? Object.keys(r.data).join(",") : "(not object)"}`); + console.log(`raw response (first 1500 chars):`); + console.log(JSON.stringify(r, null, 2).slice(0, 1500)); + } catch (err) { + const elapsed = Date.now() - start; + const e = err as { message?: string }; + console.error(`FAIL in ${elapsed}ms — ${e.message}`); + process.exit(1); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/_inspect-runs.ts b/scripts/_inspect-runs.ts new file mode 100644 index 0000000..86b56d9 --- /dev/null +++ b/scripts/_inspect-runs.ts @@ -0,0 +1,141 @@ +/* eslint-disable */ +import { desc } from "drizzle-orm"; +import { db, schema } from "./_script-db"; + +const runs = db + .select() + .from(schema.workflowRuns) + .orderBy(desc(schema.workflowRuns.startedAt)) + .limit(5) + .all(); + +console.log("=== recent runs ==="); +for (const r of runs) { + console.log( + JSON.stringify( + { + id: r.id, + state: r.state, + startedAt: r.startedAt, + completedAt: r.completedAt, + errorMessage: r.errorMessage, + prompt: typeof r.prompt === "string" ? r.prompt.slice(0, 200) : r.prompt, + }, + null, + 2, + ), + ); +} + +const approvals = db + .select() + .from(schema.approvalRequests) + .orderBy(desc(schema.approvalRequests.createdAt)) + .limit(5) + .all(); + +console.log("\n=== recent approvals ==="); +for (const a of approvals) { + console.log( + JSON.stringify( + { + id: a.id, + workflowRunId: a.workflowRunId, + artifactType: a.artifactType, + status: a.status, + reason: a.reason, + createdAt: a.createdAt, + }, + null, + 2, + ), + ); +} + +const events = db + .select() + .from(schema.activityEvents) + .orderBy(desc(schema.activityEvents.timestamp)) + .limit(15) + .all(); + +console.log("\n=== last 15 activity events ==="); +for (const e of events) { + console.log( + `${new Date(e.timestamp).toISOString()} run=${e.workflowRunId.slice(0, 8)} node=${e.nodeId ?? "-"} ${e.type}`, + ); +} + +// Drill into the most recent run that completed: dump ALL events +const runId = "58949cea-8dff-4c31-92d8-e746115ac1a5"; + +const allEvents = db + .select() + .from(schema.activityEvents) + .where(require("drizzle-orm").eq(schema.activityEvents.workflowRunId, runId)) + .orderBy(schema.activityEvents.timestamp) + .all(); +console.log(`\n=== all ${allEvents.length} events for run ${runId.slice(0, 8)} (chronological) ===`); +for (const e of allEvents) { + console.log( + `${new Date(e.timestamp).toISOString()} node=${(e.nodeId ?? "-").padEnd(20)} ${e.type}`, + ); +} + +// Show per-node error messages +const nodes = db + .select() + .from(schema.workflowNodes) + .where(require("drizzle-orm").eq(schema.workflowNodes.workflowRunId, runId)) + .all(); +console.log(`\n=== workflow_nodes for run ${runId.slice(0, 8)} ===`); +for (const n of nodes) { + console.log( + ` ${(n.persona ?? n.id).padEnd(25)} status=${n.status.padEnd(10)} ${n.errorMessage ? `error=${n.errorMessage.slice(0, 200)}` : ""}`, + ); +} + +// Show persisted plan +const runRow = db + .select() + .from(schema.workflowRuns) + .where(require("drizzle-orm").eq(schema.workflowRuns.id, runId)) + .get(); +if (runRow?.plan) { + console.log("\n=== persisted plan tasks ==="); + const plan = runRow.plan as { tasks?: Array<{ id: string; specialistId: string; dependsOn?: string[]; triggerRule?: string }> }; + for (const t of plan.tasks ?? []) { + console.log( + ` ${t.id.padEnd(30)} persona=${t.specialistId.padEnd(20)} deps=[${(t.dependsOn ?? []).join(",")}] trigger=${t.triggerRule ?? "all_success"}`, + ); + } +} +const completed = db + .select() + .from(schema.activityEvents) + .where( + require("drizzle-orm").and( + require("drizzle-orm").eq(schema.activityEvents.workflowRunId, runId), + require("drizzle-orm").eq(schema.activityEvents.type, "persona_completed"), + ), + ) + .all(); + +console.log(`\n=== persona_completed payloads for run ${runId} ===`); +for (const e of completed) { + console.log(`\n--- ${e.nodeId} ---`); + const out = (e.payload as { output?: Record<string, unknown> })?.output; + if (!out) { + console.log("(no output)"); + continue; + } + // Print compact summary + critical approval-relevant fields + console.log("approvalStatus:", out.approvalStatus); + console.log("error:", out.error); + console.log("title:", out.title); + console.log("id:", out.id); + if (e.nodeId === "geo-editor") { + console.log("--- FULL geo-editor output keys ---"); + console.log(Object.keys(out)); + } +} diff --git a/scripts/_list-firecrawl-conns.ts b/scripts/_list-firecrawl-conns.ts new file mode 100644 index 0000000..5d8a061 --- /dev/null +++ b/scripts/_list-firecrawl-conns.ts @@ -0,0 +1,34 @@ +/* eslint-disable */ +import { Composio } from "@composio/core"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +async function main() { + let key = process.env.COMPOSIO_API_KEY; + if (!key) { + const envPath = path.join(os.homedir(), ".gmaestro", ".env"); + if (fs.existsSync(envPath)) { + const m = fs.readFileSync(envPath, "utf-8").match(/^COMPOSIO_API_KEY=(.+)$/m); + if (m) key = m[1].replace(/^["']|["']$/g, ""); + } + } + const composio = new Composio({ apiKey: key }); + const list = await composio.connectedAccounts.list({ toolkitSlugs: ["firecrawl"] }); + console.log(`Found ${list.items.length} firecrawl connections:`); + for (const c of list.items) { + console.log("\n--- raw ---"); + console.log(JSON.stringify(c, null, 2)); + } + + console.log("\n=== filtered by userIds=['default'] ==="); + const filtered = await composio.connectedAccounts.list({ + toolkitSlugs: ["firecrawl"], + userIds: ["default"], + }); + console.log(`Found ${filtered.items.length} for userId="default":`); + for (const c of filtered.items) { + console.log(` ${c.id} status=${c.status}`); + } +} +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/_slack-tools.ts b/scripts/_slack-tools.ts new file mode 100644 index 0000000..edfae28 --- /dev/null +++ b/scripts/_slack-tools.ts @@ -0,0 +1,23 @@ +/* eslint-disable */ +import { Composio } from "@composio/core"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +async function main() { + let key = process.env.COMPOSIO_API_KEY; + if (!key) { + const ep = path.join(os.homedir(), ".gmaestro", ".env"); + if (fs.existsSync(ep)) { + const m = fs.readFileSync(ep, "utf-8").match(/^COMPOSIO_API_KEY=(.+)$/m); + if (m) key = m[1].replace(/^["']|["']$/g, ""); + } + } + const c = new Composio({ apiKey: key }); + const r = await c.tools.get("default", { toolkits: ["slack"], limit: 200 }); + const items = Array.isArray(r) ? r : (r as any).items ?? []; + if (items[0]) console.log("first item keys:", Object.keys(items[0])); + for (const t of items) { + const slug = (t as any).function?.name ?? (t as any).slug ?? (t as any).name ?? JSON.stringify(t).slice(0, 80); + if (/CHANNEL|CONVERSATION|LIST|USER/i.test(slug)) console.log(` ${slug}`); + } + console.log(`Total: ${items.length}`); +} +main().catch((e) => { console.error(e?.message ?? e); process.exit(1); }); diff --git a/scripts/_test-firecrawl.ts b/scripts/_test-firecrawl.ts new file mode 100644 index 0000000..067beb3 --- /dev/null +++ b/scripts/_test-firecrawl.ts @@ -0,0 +1,88 @@ +/* eslint-disable */ +/** + * One-off: hit Firecrawl with the same args our company-fetch uses, dump + * the raw response so we can see what it actually returns for the failing + * Composio docs URL. + */ + +import { Composio } from "@composio/core"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const URL = process.argv[2] ?? "https://docs.composio.dev/toolkits/firecrawl"; + +async function main() { + let key = process.env.COMPOSIO_API_KEY; + if (!key) { + const credsPath = path.join(os.homedir(), ".composio", "anonymous_user_data.json"); + if (fs.existsSync(credsPath)) { + const creds = JSON.parse(fs.readFileSync(credsPath, "utf-8")); + key = creds?.composio?.api_key; + } + } + if (!key) { + const envPath = path.join(os.homedir(), ".gmaestro", ".env"); + if (fs.existsSync(envPath)) { + const envText = fs.readFileSync(envPath, "utf-8"); + const m = envText.match(/^COMPOSIO_API_KEY=(.+)$/m); + if (m) key = m[1].replace(/^["']|["']$/g, ""); + } + } + if (!key) { + console.error("No COMPOSIO_API_KEY available."); + process.exit(1); + } + + const composio = new Composio({ apiKey: key }); + const userId = process.env.GMAESTRO_USER_ID ?? "default"; + + // Three call variants to isolate the lookup mechanism: + const variants: Array<{ label: string; opts: Record<string, unknown> }> = [ + { + label: "userId='default' only (current code path)", + opts: { userId, arguments: { url: URL, formats: ["markdown"] } }, + }, + { + label: "userId + connectedAccountId='ca_dKdOhUkXJoMY' (force the default-user connection)", + opts: { + userId, + connectedAccountId: "ca_dKdOhUkXJoMY", + arguments: { url: URL, formats: ["markdown"] }, + }, + }, + { + label: "connectedAccountId only, no userId", + opts: { + connectedAccountId: "ca_dKdOhUkXJoMY", + arguments: { url: URL, formats: ["markdown"] }, + }, + }, + ]; + + for (const v of variants) { + console.log(`\n=== ${v.label} ===`); + const start = Date.now(); + try { + const r = (await composio.tools.execute("FIRECRAWL_SCRAPE", { + ...v.opts, + dangerouslySkipVersionCheck: true, + } as never)) as Record<string, unknown>; + const elapsed = Date.now() - start; + console.log(`OK in ${elapsed}ms — top-level keys: ${Object.keys(r).join(",")}`); + const md = (r.markdown ?? (r.data as Record<string, unknown>)?.markdown ?? r.content) as string | undefined; + if (md) console.log(` markdown len: ${md.length} chars; first 200: ${md.slice(0, 200)}`); + } catch (err) { + const elapsed = Date.now() - start; + const e = err as { message?: string; cause?: { error?: { error?: { message?: string } } } }; + const inner = e.cause?.error?.error?.message ?? e.message; + console.log(`FAIL in ${elapsed}ms — ${inner}`); + } + } + process.exit(0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/_test-personas.ts b/scripts/_test-personas.ts index ed3c332..c0f8a91 100644 --- a/scripts/_test-personas.ts +++ b/scripts/_test-personas.ts @@ -1,22 +1,24 @@ /** - * End-to-end persona test harness. + * End-to-end persona test harness — content / blog / GEO domain. * * Drives `POST /api/test-persona` (dev-only endpoint) once per persona, - * reading lead/trial fixtures from the local DB and feeding synthetic - * upstream `previousOutputs` where needed. Each test reports pass/fail - * with a 1-line preview of the persona's output. Final exit code is - * non-zero if any persona failed. + * feeding synthetic upstream `previousOutputs` where needed. Each test + * reports pass/fail with a 1-line preview of the persona's output. Final + * exit code is non-zero if any persona failed. * * Why HTTP and not direct import: `lib/personas/runtime.ts` is * `import "server-only"`, which refuses to load under tsx. The Next.js - * dev server already has the SDK + DB + Composio wired up, so we POST - * to it instead. + * dev server already has the SDK + Composio wired up, so we POST to it. * * Run: pnpm dev (in another shell) + pnpm tsx scripts/_test-personas.ts + * + * Unlike the GTM-era harness, this one does NOT read DB fixtures — + * the content domain drives off the founder's prompt, not a leads table. + * Pre-pivot reset: if you're switching from a stale DB, run + * `pnpm gmaestro reset` first. */ import "dotenv/config"; -import { eq } from "drizzle-orm"; import { db, schema } from "./_script-db"; const BASE_URL = process.env.GMAESTRO_BASE_URL ?? "http://localhost:3000"; @@ -86,11 +88,10 @@ async function timed( } async function main() { - console.log(`\nRunning persona tests (run id: ${TEST_RUN_ID})\n`); + console.log(`\nRunning content persona tests (run id: ${TEST_RUN_ID})\n`); // Insert a parent workflow_runs row so the FK on activity_events.workflow_run_id // resolves when runPersona's emitEvent fires inside the test endpoint. - // Cleaned up at the end of main(). db.insert(schema.workflowRuns) .values({ id: TEST_RUN_ID, @@ -99,273 +100,281 @@ async function main() { }) .run(); - // ---- fixtures -------------------------------------------------------- - const leadRow = db.select().from(schema.leads).limit(1).all()[0]; - if (!leadRow) { - console.error("No leads in DB — run `pnpm tsx scripts/seed-demo.ts` first."); - process.exit(1); - } - const lead = { - leadId: leadRow.id, - item: { - leadId: leadRow.id, - email: leadRow.email, - name: leadRow.name, - company: leadRow.company, - source: leadRow.source, - rawMessage: leadRow.rawMessage, - }, - workflowRunId: TEST_RUN_ID, + // Synthetic seed: a topic + a fake company profile. The CompanyProfile system + // will eventually populate this from the local DB, but for now we synthesize. + const topic = + "Why founder-led GTM beats AI cold email in 2026"; + const companyProfile = { + companyName: "Anvil", + oneLiner: "AI content team for early-stage founders", + productDescription: + "GMaestro is a local-first multi-persona AI content team that researches, drafts, GEO-optimizes, and publishes blogs across multiple channels with founder-in-loop approval.", + icp: "Pre-Series A founders running their own GTM with no dedicated marketing team", + positioning: + "Unlike Jasper / Copy.ai / Surfer (single-shot SaaS writers), GMaestro is a multi-agent team with founder approval gates and native multi-channel cross-posting.", + valueProps: [ + "Founder-in-loop quality control", + "Multi-channel native formatting (not copy-paste)", + "GEO-aware (optimizes for AI search citations)", + "Local-first, no hosted SaaS", + ], + competitors: [ + "https://www.jasper.ai/blog", + "https://surferseo.com/blog", + "https://www.copy.ai/blog", + ], + sourceUrl: "https://anvil.co", + voiceTone: + "Direct, peer-to-peer, opinionated. Short paragraphs. Dry humor. No corporate jargon.", }; - const trial = db.select().from(schema.trialSignals).limit(1).all()[0]; // ---- 1. researcher -------------------------------------------------- const researcherOut = await timed( "researcher", - { ...lead, nodeId: "test-researcher" }, - (out) => - `domain=${out.companyDomain ?? "—"} signals=${ - Array.isArray(out.intentSignals) ? out.intentSignals.length : 0 - }`, - ); - - // ---- 2. qualifier --------------------------------------------------- - const qualifierOut = await timed( - "qualifier", { - ...lead, - nodeId: "test-qualifier", - previousOutputs: { researcher: researcherOut ?? {} }, + topic, + companyProfile, + nodeId: "test-researcher", + workflowRunId: TEST_RUN_ID, + }, + (out) => { + const candidates = Array.isArray(out.candidates) ? out.candidates.length : 0; + const recommended = + typeof out.recommendedTopic === "string" + ? out.recommendedTopic.slice(0, 50) + : "—"; + return `candidates=${candidates} recommended="${recommended}"`; }, - (out) => `tier=${out.tier} fit=${out.fitScore} intent=${out.intentScore}`, ); - // ---- 3. strategist -------------------------------------------------- + // ---- 2. strategist -------------------------------------------------- const strategistOut = await timed( "strategist", { - ...lead, + topic, + companyProfile, nodeId: "test-strategist", - previousOutputs: { - researcher: researcherOut ?? {}, - qualifier: qualifierOut ?? {}, - }, + workflowRunId: TEST_RUN_ID, + previousOutputs: { researcher: researcherOut ?? {} }, }, (out) => { - const angle = typeof out.angle === "string" ? out.angle.slice(0, 40) : ""; - return `cta=${out.callToAction} angle="${angle}"`; + const sections = Array.isArray(out.sections) ? out.sections.length : 0; + const signals = Array.isArray(out.geoSignals) ? out.geoSignals.length : 0; + const title = typeof out.title === "string" ? out.title.slice(0, 50) : ""; + return `sections=${sections} geoSignals=${signals} title="${title}"`; }, ); - // ---- 4. writer ------------------------------------------------------ + // ---- 3. writer ------------------------------------------------------ const writerOut = await timed( "writer", { - ...lead, + topic, + companyProfile, nodeId: "test-writer", + workflowRunId: TEST_RUN_ID, previousOutputs: { researcher: researcherOut ?? {}, - qualifier: qualifierOut ?? {}, strategist: strategistOut ?? {}, }, }, (out) => { - const subj = typeof out.subject === "string" ? out.subject.slice(0, 50) : ""; - return `subject="${subj}"`; + const title = typeof out.title === "string" ? out.title.slice(0, 50) : ""; + const wordCount = + typeof out.bodyMarkdown === "string" + ? out.bodyMarkdown.split(/\s+/).length + : 0; + return `title="${title}" words=${wordCount}`; }, ); - // ---- 5. scheduler --------------------------------------------------- - await timed( - "scheduler", + // ---- 4. geo-editor -------------------------------------------------- + const geoEditedOut = await timed( + "geo-editor", { - ...lead, - draftId: (writerOut?.id as string | undefined) ?? `draft-${TEST_RUN_ID}`, - nodeId: "test-scheduler", - previousOutputs: { writer: writerOut ?? {} }, - }, - (out) => - `meetingId=${out.id} startsAt=${String(out.startsAt).slice(0, 16)}`, - ); - - // ---- 6. brief-writer ----------------------------------------------- - await timed( - "brief-writer", - { - meetingId: `meet-${TEST_RUN_ID}`, - nodeId: "test-brief-writer", + companyProfile, + nodeId: "test-geo-editor", workflowRunId: TEST_RUN_ID, previousOutputs: { - researcher: researcherOut ?? {}, - qualifier: qualifierOut ?? {}, + strategist: strategistOut ?? {}, writer: writerOut ?? {}, }, }, (out) => { - const tp = Array.isArray(out.talkingPoints) ? out.talkingPoints.length : 0; - return `talkingPoints=${tp} url=${typeof out.notionPageUrl === "string" ? out.notionPageUrl.slice(0, 40) : "—"}`; + const notes = Array.isArray(out.geoNotes) ? out.geoNotes.length : 0; + const ratio = + typeof out.factDensityRatio === "number" + ? out.factDensityRatio.toFixed(2) + : "—"; + return `geoNotes=${notes} factDensity=${ratio}`; }, ); - // ---- 7. activation -------------------------------------------------- - if (trial) { - const trialLead = db - .select() - .from(schema.leads) - .all() - .find((l) => l.id === trial.leadId); + // ---- 5. formatter (one variant per target) ------------------------- + // Test all 3 confirmed-Composio targets to surface per-target shape issues. + for (const target of ["github", "reddit", "linkedin"] as const) { await timed( - "activation", + `formatter[${target}]`, { - leadId: trial.leadId, - item: { - trialSignalId: trial.id, - leadId: trial.leadId, - email: trialLead?.email, - name: trialLead?.name, - company: trialLead?.company, - stalledAtStep: trial.stalledAtStep, - stripeStatus: trial.stripeStatus, - }, - nodeId: "test-activation", + target, + companyProfile, + nodeId: `test-formatter-${target}`, workflowRunId: TEST_RUN_ID, + previousOutputs: { + "geo-editor": geoEditedOut ?? writerOut ?? {}, + }, }, (out) => { - const subj = typeof out.subject === "string" ? out.subject.slice(0, 40) : "—"; - return `channel=${out.channel} subject="${subj}"`; + const len = typeof out.content === "string" ? out.content.length : 0; + const meta = + out.metadata && typeof out.metadata === "object" + ? Object.keys(out.metadata).join(",") + : "—"; + return `len=${len} meta=[${meta}]`; }, ); - } else { - console.log("⊘ activation skipped — no trial_signals in DB"); } - // ---- 8. crm-logger ------------------------------------------------- - await timed( - "crm-logger", - { - ...lead, - nodeId: "test-crm-logger", - previousOutputs: { - qualifier: qualifierOut ?? {}, - writer: writerOut ?? {}, - }, - }, - (out) => `action=${out.action} contactId=${out.crmContactId ?? "—"}`, - ); - - // ---- 9. pipeline-reporter ------------------------------------------ + // ---- 6. pipeline-reporter ----------------------------------------- await timed( "pipeline-reporter", { nodeId: "test-pipeline-reporter", workflowRunId: TEST_RUN_ID, - previousOutputs: {}, + previousOutputs: { + "geo-editor": geoEditedOut ?? {}, + formatter__github: { target: "github" }, + formatter__reddit: { target: "reddit" }, + }, }, (out) => { - const summary = typeof out.summary === "string" ? out.summary.slice(0, 70) : "—"; - return `summary="${summary}…"`; + const summary = typeof out.summary === "string" ? out.summary.slice(0, 60) : "—"; + const metrics = + out.metrics && typeof out.metrics === "object" + ? Object.keys(out.metrics).length + : 0; + return `metrics=${metrics} summary="${summary}…"`; }, ); - // ---- 10. slack-digest ---------------------------------------------- + // ---- 7. slack-digest ---------------------------------------------- await timed( "slack-digest", { nodeId: "test-slack-digest", workflowRunId: TEST_RUN_ID, previousOutputs: { - "pipeline-reporter": { summary: "5 leads enriched, 3 hot, 2 warm" }, + "pipeline-reporter": { + summary: + "Shipped 1 blog post, 3 channels live (GitHub PR + r/SaaS + LinkedIn).", + }, }, }, - (out) => `channel=${out.channel} ts=${out.messageTs}`, + (out) => { + const len = typeof out.digestText === "string" ? out.digestText.length : 0; + const channel = typeof out.channel === "string" ? out.channel : "—"; + return `digestLen=${len} channel=${channel}`; + }, ); - // ---- 11. feedback-tagger ------------------------------------------- + // ---- 8. feedback-tagger ------------------------------------------- await timed( "feedback-tagger", { - messageId: "synthetic-msg-1", + messageId: `msg-${TEST_RUN_ID}`, nodeId: "test-feedback-tagger", workflowRunId: TEST_RUN_ID, item: { - messageId: "synthetic-msg-1", - text: "Just tried the dashboard — DAG view crashed when I clicked a node. Otherwise loving the persona breakdown though, super clear", - source: "intercom", + text: "Just read your post on r/SaaS — really liked the bit about cold email response rates dropping. Curious how you'd handle pricing for a marketplace play, would love a follow-up.", + source: "reddit", }, }, (out) => { - const themes = Array.isArray(out.themes) ? out.themes.join(", ") : "—"; + const themes = Array.isArray(out.themes) ? out.themes.join(",") : "—"; return `sentiment=${out.sentiment} themes=[${themes}]`; }, ); - // ---- 12. theme-synthesizer ---------------------------------------- - await timed( + // ---- 9. theme-synthesizer ---------------------------------------- + const themeOut = await timed( "theme-synthesizer", { nodeId: "test-theme-synthesizer", workflowRunId: TEST_RUN_ID, item: { feedback: [ - { id: "f1", text: "DAG view crashes on node click", themes: ["bug:dag"], sentiment: "negative" }, - { id: "f2", text: "Approval card is great, very clear", themes: ["feedback:ui"], sentiment: "positive" }, - { id: "f3", text: "Wish I could resend without editing", themes: ["feature:resend"], sentiment: "neutral" }, + { + id: "fb-1", + text: "Curious how you'd handle pricing for a marketplace.", + themes: ["topic:pricing-model", "audience:asks-followup"], + sentiment: "pos", + source: "reddit", + }, + { + id: "fb-2", + text: "Pricing breakdown please?", + themes: ["topic:pricing-model"], + sentiment: "pos", + source: "linkedin", + }, + { + id: "fb-3", + text: "Got cited by Perplexity in the 'AI cold email' query!", + themes: ["geo:cited-by-perplexity"], + sentiment: "pos", + source: "analytics", + }, ], }, }, - (out) => - `notion=${typeof out.notionPageUrl === "string" ? out.notionPageUrl.slice(0, 50) : "—"}`, + (out) => { + const themes = Array.isArray(out.themes) ? out.themes.join(",") : "—"; + const url = + typeof out.notionPageUrl === "string" + ? out.notionPageUrl.slice(0, 40) + : "—"; + return `themes=[${themes}] url=${url}`; + }, ); - // ---- 13. linear-filer ---------------------------------------------- + // ---- 10. linear-filer --------------------------------------------- await timed( "linear-filer", { - themeId: "theme-bug-dag-1", + themeId: "theme-pricing-followup", nodeId: "test-linear-filer", workflowRunId: TEST_RUN_ID, item: { - themeId: "theme-bug-dag-1", - title: "DAG view crashes on node click", - description: "Multiple users report dashboard crash when clicking a DAG node.", - severity: "high", - recommendedTeam: "frontend", + themeId: "theme-pricing-followup", + title: "Multiple readers asked about pricing model — draft follow-up post", + description: + "Reddit + LinkedIn comments on the GTM post both asked for a pricing breakdown. 2 distinct asks in 24 hours.", + severity: "medium", + recommendedTeam: "content", }, + previousOutputs: { "theme-synthesizer": themeOut ?? {} }, + }, + (out) => { + const id = typeof out.issueId === "string" ? out.issueId : "—"; + const url = typeof out.issueUrl === "string" ? out.issueUrl.slice(0, 40) : "—"; + return `id=${id} url=${url}`; }, - (out) => - `issueId=${out.issueId} url=${typeof out.issueUrl === "string" ? out.issueUrl.slice(0, 45) : "—"}`, - ); - - // ---- summary --------------------------------------------------------- - const ok = results.filter((r) => r.ok).length; - const failed = results.filter((r) => !r.ok).length; - const totalMs = results.reduce((acc, r) => acc + r.ms, 0); - - console.log( - `\n${ok}/${results.length} passed${failed ? ` (${failed} failed)` : ""} total: ${(totalMs / 1000).toFixed(1)}s`, ); - if (failed > 0) { - console.log("\nfailures:"); + // ---- summary -------------------------------------------------------- + const passed = results.filter((r) => r.ok).length; + const total = results.length; + console.log(`\n${passed}/${total} passed.`); + if (passed < total) { + console.log("\nFailures:"); for (const r of results.filter((r) => !r.ok)) { - console.log(` ${r.persona}: ${r.error?.slice(0, 400)}`); + console.log(` - ${r.persona}: ${r.error}`); } + process.exit(1); } - - // Clean up the test run row + any cascading rows so repeat runs stay - // diffable. - try { - db.delete(schema.workflowRuns) - .where(eq(schema.workflowRuns.id, TEST_RUN_ID)) - .run(); - } catch { - // best-effort cleanup - } - - if (failed > 0) process.exit(1); } main().catch((err) => { - console.error("test harness crashed:", err); - process.exit(2); + console.error("Harness error:", err); + process.exit(1); }); diff --git a/scripts/_verify-firecrawl-extract.ts b/scripts/_verify-firecrawl-extract.ts new file mode 100644 index 0000000..e9eec45 --- /dev/null +++ b/scripts/_verify-firecrawl-extract.ts @@ -0,0 +1,53 @@ +/* eslint-disable */ +/** + * Verifies the production extractMarkdown logic against a live Firecrawl call. + * Run after scripts/_fix-firecrawl.ts. + */ +import { Composio } from "@composio/core"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +function extractMarkdown(data: unknown): string | undefined { + let cursor: unknown = data; + for (let depth = 0; depth < 3; depth++) { + if (!cursor || typeof cursor !== "object") return undefined; + const obj = cursor as Record<string, unknown>; + if (typeof obj.markdown === "string") return obj.markdown; + if (typeof obj.content === "string") return obj.content; + cursor = obj.data; + } + return undefined; +} + +async function main() { + let key = process.env.COMPOSIO_API_KEY; + if (!key) { + const envPath = path.join(os.homedir(), ".gmaestro", ".env"); + if (fs.existsSync(envPath)) { + const m = fs.readFileSync(envPath, "utf-8").match(/^COMPOSIO_API_KEY=(.+)$/m); + if (m) key = m[1].replace(/^["']|["']$/g, ""); + } + } + const composio = new Composio({ apiKey: key }); + + const r = (await composio.tools.execute("FIRECRAWL_SCRAPE", { + userId: "default", + arguments: { + url: "https://docs.composio.dev/toolkits/firecrawl", + formats: ["markdown"], + waitFor: 5000, + onlyMainContent: true, + }, + dangerouslySkipVersionCheck: true, + } as never)) as Record<string, unknown>; + + const md = extractMarkdown(r.data); + console.log(`extractMarkdown(r.data) length: ${md?.length ?? 0}`); + if (md) console.log(`first 300 chars:\n${md.slice(0, 300)}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/connect-firecrawl.ts b/scripts/connect-firecrawl.ts new file mode 100644 index 0000000..177c753 --- /dev/null +++ b/scripts/connect-firecrawl.ts @@ -0,0 +1,110 @@ +/* eslint-disable */ +/** + * Creates a Firecrawl connected account bound to userId="default" using a + * Firecrawl API key. + * + * Why this exists: Firecrawl uses API_KEY auth, not OAuth, so the dashboard's + * "Connect" button (which assumes a redirect URL) doesn't apply. Connections + * created via the Composio dashboard playground are NOT bound to a userId, so + * `tools.execute({ userId: "default", ... })` returns code 1810 "No connected + * account found for user ID default for toolkit firecrawl". This script + * creates a properly user-bound connection so persona fetches succeed. + * + * Usage: + * FIRECRAWL_API_KEY=fc-xxxxx pnpm tsx scripts/connect-firecrawl.ts + * + * Or run interactively (the script will prompt): + * pnpm tsx scripts/connect-firecrawl.ts + * + * Get an API key from: https://firecrawl.dev/app/api-keys + */ +import { Composio } from "@composio/core"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; + +async function promptHidden(question: string): Promise<string> { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +async function main() { + let key = process.env.COMPOSIO_API_KEY; + if (!key) { + const envPath = path.join(os.homedir(), ".gmaestro", ".env"); + if (fs.existsSync(envPath)) { + const m = fs.readFileSync(envPath, "utf-8").match(/^COMPOSIO_API_KEY=(.+)$/m); + if (m) key = m[1].replace(/^["']|["']$/g, ""); + } + } + if (!key) { + console.error("No COMPOSIO_API_KEY available."); + process.exit(1); + } + + let fcKey = process.env.FIRECRAWL_API_KEY; + if (!fcKey) { + console.log("Get an API key from https://firecrawl.dev/app/api-keys"); + fcKey = await promptHidden("Paste your Firecrawl API key (fc-...): "); + } + if (!fcKey || !fcKey.startsWith("fc-")) { + console.error("Invalid Firecrawl key (should start with 'fc-')."); + process.exit(1); + } + + const userId = process.env.GMAESTRO_USER_ID ?? "default"; + const authConfigId = "ac_X_lMXeDzr7EF"; // firecrawl + + const composio = new Composio({ apiKey: key }); + + console.log(`Creating Firecrawl connection for userId="${userId}"...`); + const req = await composio.connectedAccounts.initiate(userId, authConfigId, { + config: { + authScheme: "API_KEY", + val: { generic_api_key: fcKey }, + }, + } as never); + + console.log(`Initiated: id=${req.id} status=${req.status}`); + + // For API_KEY toolkits the connection should go ACTIVE immediately. + console.log("Waiting for ACTIVE status..."); + const connected = await composio.connectedAccounts.waitForConnection(req.id, 30_000); + console.log(`Connected account: id=${connected.id} status=${connected.status}`); + + // Now smoke-test by hitting the tool + console.log("\nSmoke testing FIRECRAWL_SCRAPE..."); + const start = Date.now(); + try { + const r = (await composio.tools.execute("FIRECRAWL_SCRAPE", { + userId, + arguments: { + url: "https://docs.composio.dev/toolkits/firecrawl", + formats: ["markdown"], + waitFor: 5000, + onlyMainContent: true, + }, + dangerouslySkipVersionCheck: true, + } as never)) as Record<string, unknown>; + const elapsed = Date.now() - start; + console.log(`SCRAPE OK in ${elapsed}ms; top-level keys: ${Object.keys(r).join(",")}`); + const data = r.data as Record<string, unknown> | undefined; + const md = (r.markdown ?? data?.markdown ?? r.content) as string | undefined; + console.log(`markdown length: ${md ? md.length : 0}`); + } catch (err) { + const elapsed = Date.now() - start; + console.error(`SCRAPE FAIL in ${elapsed}ms`, err); + process.exit(1); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/connect-slack-channel.ts b/scripts/connect-slack-channel.ts new file mode 100644 index 0000000..538335e --- /dev/null +++ b/scripts/connect-slack-channel.ts @@ -0,0 +1,117 @@ +/* eslint-disable */ +/** + * Helper for the Slack approval-DM flow. + * + * 1. Lists Slack channels + DM targets reachable by the connected Slack app. + * 2. If `--set <id|name>` is passed, writes GMAESTRO_SLACK_CHANNEL into + * ~/.gmaestro/.env so future approvals auto-DM. + * 3. If `--test` is passed, sends a test DM to the configured channel. + * + * Usage: + * pnpm tsx scripts/connect-slack-channel.ts # list + * pnpm tsx scripts/connect-slack-channel.ts --set "#general" + * pnpm tsx scripts/connect-slack-channel.ts --set U0123456 + * pnpm tsx scripts/connect-slack-channel.ts --test + */ +import { Composio } from "@composio/core"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const ENV_PATH = path.join(os.homedir(), ".gmaestro", ".env"); + +function readComposioKey(): string { + let key = process.env.COMPOSIO_API_KEY; + if (!key && fs.existsSync(ENV_PATH)) { + const m = fs.readFileSync(ENV_PATH, "utf-8").match(/^COMPOSIO_API_KEY=(.+)$/m); + if (m) key = m[1].replace(/^["']|["']$/g, ""); + } + if (!key) { + console.error("No COMPOSIO_API_KEY available."); + process.exit(1); + } + return key; +} + +function readEnvVar(name: string): string | undefined { + if (process.env[name]) return process.env[name]; + if (!fs.existsSync(ENV_PATH)) return undefined; + const m = fs.readFileSync(ENV_PATH, "utf-8").match(new RegExp(`^${name}=(.+)$`, "m")); + return m?.[1].replace(/^["']|["']$/g, ""); +} + +function writeEnvVar(name: string, value: string): void { + let body = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf-8") : ""; + const re = new RegExp(`^${name}=.*$`, "m"); + if (re.test(body)) { + body = body.replace(re, `${name}=${value}`); + } else { + if (body && !body.endsWith("\n")) body += "\n"; + body += `${name}=${value}\n`; + } + fs.writeFileSync(ENV_PATH, body); + console.log(`Wrote ${name}=${value} → ${ENV_PATH}`); +} + +async function main() { + const args = process.argv.slice(2); + const setIdx = args.indexOf("--set"); + const setVal = setIdx >= 0 ? args[setIdx + 1] : undefined; + const test = args.includes("--test"); + + const composio = new Composio({ apiKey: readComposioKey() }); + const userId = process.env.GMAESTRO_USER_ID ?? "default"; + + if (setVal) { + writeEnvVar("GMAESTRO_SLACK_CHANNEL", setVal); + } + + if (test) { + const channel = readEnvVar("GMAESTRO_SLACK_CHANNEL"); + if (!channel) { + console.error("GMAESTRO_SLACK_CHANNEL is not set. Run with --set <id> first."); + process.exit(1); + } + console.log(`Sending test DM to "${channel}"...`); + const r = (await composio.tools.execute("SLACK_SEND_MESSAGE", { + userId, + arguments: { + channel, + text: ":wave: GMaestro test message — Slack approvals are wired.", + }, + dangerouslySkipVersionCheck: true, + } as never)) as Record<string, unknown>; + console.log(`OK; successful=${r.successful} error=${JSON.stringify(r.error)}`); + return; + } + + console.log("=== Slack channels (first 50) ==="); + const r = (await composio.tools.execute("SLACK_LIST_ALL_CHANNELS", { + userId, + arguments: { limit: 50, exclude_archived: true, types: "public_channel,private_channel,im" }, + dangerouslySkipVersionCheck: true, + } as never)) as Record<string, unknown>; + // Composio double-wraps: r.data.data.channels + let cursor: unknown = r.data; + let channels: Array<{ id: string; name?: string; is_im?: boolean; user?: string }> = []; + for (let depth = 0; depth < 3; depth++) { + if (!cursor || typeof cursor !== "object") break; + const obj = cursor as Record<string, unknown>; + if (Array.isArray(obj.channels)) { channels = obj.channels as typeof channels; break; } + cursor = obj.data; + } + for (const c of channels) { + const label = c.is_im ? `(DM with user ${c.user ?? "?"})` : `#${c.name ?? "?"}`; + console.log(` ${(c.id ?? "?").padEnd(13)} ${label}`); + } + if (channels.length === 0) { + console.log("(none — try a different `types` filter, or paste your channel ID directly)"); + } + console.log("\nNext step: pnpm tsx scripts/connect-slack-channel.ts --set <id-or-#name>"); + console.log("Then test: pnpm tsx scripts/connect-slack-channel.ts --test"); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});