Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { CompanyContextCard } from "@/lib/ui/components/company-context-card";
import { CompanyProfileCard } from "@/lib/ui/components/company-profile-card";
import { PromptInput } from "@/lib/ui/components/prompt-input";
import { RecentRunsList } from "@/lib/ui/components/recent-runs-list";
import { ResumePill } from "@/lib/ui/components/resume-pill";
Expand Down Expand Up @@ -36,7 +36,7 @@ export default function DashboardPage() {
{Hero}
<div className="flex flex-col items-start gap-3">
<ResumePill />
<CompanyContextCard />
<CompanyProfileCard />
<div className="w-full">
<PromptInput onRunStarted={handleRunStarted} />
</div>
Expand Down
33 changes: 33 additions & 0 deletions app/(dashboard)/settings/company/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CompanyProfileForm } from "@/lib/ui/components/company-profile-form";
import { getCompanyProfile } from "@/lib/state/company-profile";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

const USER_ID = process.env.GMAESTRO_USER_ID ?? "default";

export default async function CompanyProfilePage() {
const profile = getCompanyProfile(USER_ID);
// First-time setup: the wizard captures GMAESTRO_COMPANY_URL so the founder
// doesn't have to re-type it here. Only used as a fallback when the saved
// profile has no sourceUrl yet.
const setupUrl = process.env.GMAESTRO_COMPANY_URL?.trim() || null;

return (
<div className="grid gap-6">
<header>
<h1 className="text-base font-semibold">Company profile</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Grounds every persona that reasons about your customers — Qualifier scores
against your ICP, Strategist picks angles from your positioning, Writer drafts
in your voice.
</p>
<p className="text-sm italic text-muted-foreground">
Filling the four required fields is mandatory before workflow runs can dispatch.
</p>
</header>

<CompanyProfileForm initialProfile={profile} setupUrl={setupUrl} />
</div>
);
}
38 changes: 38 additions & 0 deletions app/api/company-profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { CompanyProfileUpdateSchema } from "@/lib/shared/schemas";
import {
getCompanyProfile,
upsertCompanyProfile,
} from "@/lib/state/company-profile";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

function getFounderId(): string {
return process.env.GMAESTRO_USER_ID ?? "default";
}

export async function GET() {
const profile = getCompanyProfile(getFounderId());
return NextResponse.json({ profile });
}

export async function PUT(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const parsed = CompanyProfileUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", issues: parsed.error.issues },
{ status: 400 },
);
}

const profile = upsertCompanyProfile(getFounderId(), parsed.data);
return NextResponse.json({ profile });
}
75 changes: 75 additions & 0 deletions app/api/company-profile/scrape/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { CompanyProfileScrapeRequestSchema } from "@/lib/shared/schemas";
import { draftProfileFromScrape } from "@/lib/ingest/draft-profile";
import { scrapeCompanySite } from "@/lib/ingest/scrape";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* Synchronous scrape + draft. The whole call fits inside the LLM's 60s
* budget plus the scraper's 15s budget — no need for the fire-and-forget
* shape that `/api/runs` uses for multi-minute workflows.
*/
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const parsed = CompanyProfileScrapeRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", issues: parsed.error.issues },
{ status: 400 },
);
}

let bundle;
try {
bundle = await scrapeCompanySite(parsed.data.url);
} catch (err) {
Comment on lines +22 to +33
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Scrape failed" },
{ status: 502 },
);
}

const okCount = bundle.pages.filter((p) => p.status === "ok").length;
if (okCount === 0) {
return NextResponse.json(
{
error:
"couldn't fetch any usable pages from that URL. The site may require JavaScript or blocks scrapers — fill the form manually instead.",
bundle,
},
{ status: 422 },
);
}

let draft;
try {
draft = await draftProfileFromScrape(bundle);
} catch (err) {
return NextResponse.json(
{
error:
err instanceof Error
? `LLM drafter failed: ${err.message}`
: "LLM drafter failed",
},
{ status: 502 },
);
}

return NextResponse.json({
draft: { ...draft, sourceUrl: parsed.data.url },
bundle: {
origin: bundle.origin,
okCount,
attempted: bundle.pages.length,
},
});
}
21 changes: 0 additions & 21 deletions app/api/context/refresh/route.ts

This file was deleted.

57 changes: 0 additions & 57 deletions app/api/context/route.ts

This file was deleted.

15 changes: 15 additions & 0 deletions app/api/mock/company-profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* GET /api/mock/company-profile — fixture for `NEXT_PUBLIC_USE_MOCKS=1`.
* Mirrors the live route's response shape; source of truth is
* `makeMockCompanyProfile()` in `lib/shared/mocks.ts`.
*/

import { NextResponse } from "next/server";
import { makeMockCompanyProfile } from "@/lib/shared/mocks";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export function GET() {
return NextResponse.json({ profile: makeMockCompanyProfile() });
}
23 changes: 0 additions & 23 deletions app/api/mock/context/route.ts

This file was deleted.

28 changes: 28 additions & 0 deletions app/api/runs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { RunWorkflowRequestSchema } from "@/lib/shared/schemas";
import { REQUIRED_COMPANY_PROFILE_FIELDS } from "@/lib/shared/types";
import {
getCompanyProfile,
isCompanyProfileComplete,
} from "@/lib/state/company-profile";
import { db, schema } from "@/lib/state/db";
import { createRun, markRunFailed, runWorkflow } from "@/lib/state/workflows";

Expand Down Expand Up @@ -81,6 +86,29 @@ export async function POST(request: Request) {

const founderId = process.env.GMAESTRO_USER_ID ?? "default";

// Workflow-start guard. The personas downstream reason about the founder's
// ICP / positioning / product description; without those filled, every
// persona below the Conductor is flying blind. Reject the run with a 409
// and tell the frontend where to send the founder.
const profile = getCompanyProfile(founderId);
if (!isCompanyProfileComplete(profile)) {
const missing = REQUIRED_COMPANY_PROFILE_FIELDS.filter((f) => {
const v = profile?.[f] as unknown;
if (typeof v !== "string") return true;
return v.trim().length === 0;
});
return NextResponse.json(
{
error: "company_profile_required",
message:
"Workflow runs are blocked until your company profile is filled. The personas need to know what the company does, who you sell to, and how you position before they can reason about leads.",
missingFields: missing,
settingsUrl: "/settings/company",
},
{ status: 409 },
);
}

// Materialize leads for any emails the founder named in the prompt BEFORE
// we build WorkContext (which the Conductor reads). Done in the request
// path, not the detached workflow, so a DB failure surfaces as a clean 500
Expand Down
18 changes: 18 additions & 0 deletions bin/gmaestro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ program
existing.GMAESTRO_USER_ID ??
(await input({ message: "Founder user id", default: "default" }));

const companyUrl = await input({
message:
"Your company's homepage URL (we'll auto-fill your profile from it; press Enter to skip)",
default: "",
});

const next: EnvMap = {
...existing,
ANTHROPIC_API_KEY: anthropicKey || existing.ANTHROPIC_API_KEY || "",
Expand All @@ -137,6 +143,9 @@ program
GMAESTRO_BASE_URL: existing.GMAESTRO_BASE_URL ?? "http://localhost:3000",
GMAESTRO_TIER: existing.GMAESTRO_TIER ?? "auto",
};
if (companyUrl.trim().length > 0) {
next.GMAESTRO_COMPANY_URL = companyUrl.trim();
}
writeEnv(next);
console.log(`✔ wrote ${ENV_PATH}`);

Expand All @@ -156,6 +165,15 @@ program
}

console.log("\nNext: pnpm gmaestro dev");
if (companyUrl.trim().length > 0) {
console.log(
" Open http://localhost:3000/settings/company — click \"Auto-fill\" to draft your profile from the URL you provided, then review/save.",
);
} else {
console.log(
" Open http://localhost:3000/settings/company first — workflow runs are blocked until your company profile is filled.",
);
}
console.log(
"Note: parallel persona fanout works best on Anthropic Tier 2+ ($40 cumulative spend).",
);
Expand Down
8 changes: 0 additions & 8 deletions drizzle/migrations/0003_company_context.sql

This file was deleted.

14 changes: 14 additions & 0 deletions drizzle/migrations/0003_company_profile.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE `company_profiles` (
`user_id` text PRIMARY KEY NOT NULL,
`company_name` text,
`one_liner` text,
`product_description` text,
`icp` text,
`positioning` text,
`voice_tone` text,
`value_props` text,
`competitors` text,
`source_url` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
Loading
Loading