feat: SAM — in-app SEO agent with full MCP toolset (#322)

This commit is contained in:
Ben Senescu 2026-07-03 18:48:18 -04:00 committed by GitHub
parent 7ed574c408
commit 41d37792e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 9020 additions and 536 deletions

View File

@ -0,0 +1,22 @@
CREATE TABLE "sam_project_memory" (
"project_id" text NOT NULL,
"label" text NOT NULL,
"content" text NOT NULL,
"updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL,
CONSTRAINT "sam_project_memory_project_id_label_pk" PRIMARY KEY("project_id","label")
);
--> statement-breakpoint
CREATE TABLE "sam_sessions" (
"id" text PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"user_id" text NOT NULL,
"title" text DEFAULT 'New chat' NOT NULL,
"created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL,
"updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL,
"archived_at" text
);
--> statement-breakpoint
ALTER TABLE "sam_project_memory" ADD CONSTRAINT "sam_project_memory_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sam_sessions" ADD CONSTRAINT "sam_sessions_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sam_sessions" ADD CONSTRAINT "sam_sessions_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "sam_sessions_project_updated_idx" ON "sam_sessions" USING btree ("project_id","updated_at");

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,13 @@
"when": 1782851683039,
"tag": "0004_dashing_betty_ross",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1783117905771,
"tag": "0005_talented_wild_pack",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,22 @@
CREATE TABLE `sam_project_memory` (
`project_id` text NOT NULL,
`label` text NOT NULL,
`content` text NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
PRIMARY KEY(`project_id`, `label`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `sam_sessions` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`user_id` text NOT NULL,
`title` text DEFAULT 'New chat' NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
`archived_at` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `sam_sessions_project_updated_idx` ON `sam_sessions` (`project_id`,`updated_at`);

File diff suppressed because it is too large Load Diff

View File

@ -197,6 +197,13 @@
"when": 1782851682143,
"tag": "0027_reflective_molten_man",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1783117904905,
"tag": "0028_empty_beyonder",
"breakpoints": true
}
]
}

View File

@ -65,8 +65,9 @@
}
},
"dependencies": {
"@ai-sdk/react": "^3.0.201",
"@ai-sdk/react": "^3.0.211",
"@cloudflare/ai-chat": "^0.8.4",
"@cloudflare/think": "0.12.1",
"@cloudflare/workers-oauth-provider": "^0.4.0",
"@every-app/sdk": "^0.1.14",
"@modelcontextprotocol/sdk": "1.29.0",
@ -78,7 +79,7 @@
"@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-start": "^1.167.16",
"@tanstack/react-table": "^8.21.3",
"agents": "0.15.0",
"agents": "0.17.3",
"ai": "^6.0.199",
"autumn-js": "^1.1.7",
"better-auth": "^1.5.5",

1001
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@ type Props = {
*
* OpenSEO doesn't ship `@tailwindcss/typography`, so `prose` classes are
* no-ops every block element is styled here instead. Tables use daisyUI's
* `table table-xs` so model- and strategy-generated tables stay readable.
* `table table-sm` so model- and strategy-generated tables stay readable.
*
* Anchor URLs are sanitized to http(s) only LLMs can be coaxed into
* emitting `javascript:` payloads.
@ -122,7 +122,7 @@ export const MARKDOWN_COMPONENTS = {
),
table: ({ children }: { children?: ReactNode }) => (
<div className="my-3 overflow-x-auto">
<table className="table table-xs border border-base-300">
<table className="table table-sm border border-base-300">
{children}
</table>
</div>

View File

@ -1,10 +1,12 @@
import { Link } from "@tanstack/react-router";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import type { LinkOptions } from "@tanstack/react-router";
import type { ComponentType } from "react";
import { useEffect, useState, type ComponentType } from "react";
import {
CircleHelp,
CreditCard,
LayoutGrid,
LogOut,
MessageCircle,
Settings,
User,
X,
@ -14,6 +16,7 @@ import {
getProjectNavGroups,
} from "@/client/navigation/items";
import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher";
import { SamSidebarPanel } from "@/client/features/sam/SamSidebarPanel";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { closeDropdown } from "@/client/lib/dropdown";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
@ -72,6 +75,33 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
...(projectId ? getProjectNavGroups(projectId) : []),
connectNavGroup,
];
const navigate = useNavigate();
const location = useLocation();
const onSamRoute = location.pathname.includes("/sam");
// PostHog-style sidebar tabs: Browse shows the regular nav, Chat shows the
// SAM chat history. The tab is view state (switching to Browse leaves the
// conversation open in the content panel), but the route wins: landing on
// /sam selects Chat, navigating anywhere else flips back to Browse.
const [view, setView] = useState<"browse" | "chat">(
onSamRoute ? "chat" : "browse",
);
useEffect(() => {
setView(onSamRoute ? "chat" : "browse");
}, [onSamRoute]);
const openChat = () => {
setView("chat");
if (!projectId) return;
if (!onSamRoute) {
void navigate({
to: "/p/$projectId/sam",
params: { projectId },
search: {},
});
onNavigate?.();
}
};
return (
<div className="flex h-full w-60 flex-col bg-base-200">
@ -102,6 +132,30 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
/>
</div>
{projectId ? (
// Same underline tab idiom as the in-page tab strips (e.g. Domain
// Overview's Top Keywords / Top Pages).
<div className="px-3 pb-1">
<div role="tablist" className="tabs tabs-border w-full">
<SidebarViewTab
icon={LayoutGrid}
label="Browse"
active={view === "browse"}
onClick={() => setView("browse")}
/>
<SidebarViewTab
icon={MessageCircle}
label="Chat"
active={view === "chat"}
onClick={openChat}
/>
</div>
</div>
) : null}
{view === "chat" && projectId ? (
<SamSidebarPanel projectId={projectId} onNavigate={onNavigate} />
) : (
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{navGroups.map((group) => (
<div key={group.label} className="mb-1">
@ -123,12 +177,38 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
</div>
))}
</nav>
)}
<SidebarFooter onNavigate={onNavigate} />
</div>
);
}
function SidebarViewTab({
icon: Icon,
label,
active,
onClick,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`tab flex-1 gap-1.5 ${active ? "tab-active" : ""}`}
>
<Icon className="size-4" />
{label}
</button>
);
}
function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode();

View File

@ -0,0 +1,335 @@
import { type UIMessage } from "ai";
import { useState } from "react";
import {
AlertTriangle,
Check,
ChevronRight,
Copy,
Loader2,
Pencil,
Undo2,
} from "lucide-react";
import { Markdown } from "@/client/components/Markdown";
// Shared rendering for the chat agents (onboarding + SAM). The chats differ
// only in which tools are available and how tool names become labels
// (resolveToolLabel) plus which message actions their server supports
// (onUndo/onEdit); the UI itself is identical and lives here.
export type ToolLabel = { running: string; done: string };
// Maps a UIMessage tool part type (e.g. "tool-get_serp_results") to its label,
// or null to hide the badge entirely (onboarding hides tools it hasn't curated).
export type ResolveToolLabel = (partType: string) => ToolLabel | null;
// Turn a tool part type ("tool-get_serp_results") into a readable label
// ("Get serp results"). Used for chats that expose too many tools to curate a
// per-tool label map by hand.
export function humanizeToolLabel(partType: string): ToolLabel {
const name = partType.replace(/^tool-/, "").replace(/_/g, " ");
const label = name.charAt(0).toUpperCase() + name.slice(1);
return { running: label, done: label };
}
// Whether an assistant message already shows something — visible text, reasoning,
// or a tool badge. Used to decide when the standalone typing indicator is still
// needed: a running tool badge already reads as progress, so the dots would
// double up.
export function messageHasVisibleContent(message: UIMessage): boolean {
return message.parts.some(
(part) =>
(part.type === "text" && part.text.trim().length > 0) ||
(part.type === "reasoning" && part.text.trim().length > 0) ||
part.type.startsWith("tool-"),
);
}
// Plain text of a message for the clipboard: its visible text parts only (no
// reasoning traces, no tool payloads).
function messageText(message: UIMessage): string {
return message.parts
.filter(
(part): part is Extract<typeof part, { type: "text" }> =>
part.type === "text",
)
.map((part) => part.text)
.join("\n")
.trim();
}
function CopyButton({ message }: { message: UIMessage }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
aria-label="Copy message"
title="Copy"
className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
onClick={() => {
void navigator.clipboard.writeText(messageText(message));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
);
}
// Hover action bar under a message: copy for every message, undo/edit for user
// messages when the chat wires up the handlers (rewinding needs server support,
// so chats opt in per handler).
function MessageActions({
message,
onUndo,
onStartEdit,
}: {
message: UIMessage;
onUndo?: () => void;
onStartEdit?: () => void;
}) {
return (
<div
className={`flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 ${
message.role === "user" ? "justify-end" : ""
}`}
>
<CopyButton message={message} />
{onStartEdit ? (
<button
type="button"
aria-label="Edit message"
title="Edit and resend"
className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
onClick={onStartEdit}
>
<Pencil className="size-3.5" />
</button>
) : null}
{onUndo ? (
<button
type="button"
aria-label="Undo from this message"
title="Undo — remove this message and everything after it"
className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
onClick={onUndo}
>
<Undo2 className="size-3.5" />
</button>
) : null}
</div>
);
}
// Collapsible "thinking" block for the model's reasoning stream. Collapsed by
// default so the chain-of-thought doesn't bury the answer; while it's still
// streaming it doubles as the progress indicator ("Thinking…" + spinner).
function ReasoningBlock({
part,
live,
}: {
part: Extract<UIMessage["parts"][number], { type: "reasoning" }>;
live: boolean;
}) {
const [expanded, setExpanded] = useState(false);
// Persisted parts can keep a stale state:"streaming" (interrupted or
// multi-segment turns), so only trust it while the message is actually
// being generated — otherwise finished replies show hanging spinners.
const isStreaming = live && part.state === "streaming";
return (
<div className="text-base-content/60">
<button
type="button"
onClick={() => setExpanded((open) => !open)}
className="inline-flex items-center gap-1.5 text-xs hover:text-base-content/80"
>
{isStreaming ? (
<Loader2 className="size-3 animate-spin" />
) : (
<ChevronRight
className={`size-3 transition-transform ${expanded ? "rotate-90" : ""}`}
/>
)}
<span>{isStreaming ? "Thinking…" : "Thought process"}</span>
</button>
{expanded ? (
<div className="mt-1.5 whitespace-pre-wrap border-l-2 border-base-300 pl-3 text-xs text-base-content/50">
{part.text}
</div>
) : null}
</div>
);
}
// A small inline badge for one tool call, rendered in document order inside the
// assistant bubble so the sequence of work stays visible after it completes.
function ToolBadge({
part,
live,
resolveToolLabel,
}: {
part: UIMessage["parts"][number];
live: boolean;
resolveToolLabel: ResolveToolLabel;
}) {
const labels = resolveToolLabel(part.type);
if (!labels) return null;
const state = "state" in part ? part.state : undefined;
const isDone = state === "output-available";
// A "running" part in a message that is no longer being generated never
// finished — the turn was interrupted. Show it as failed, not spinning.
const isError = state === "output-error" || (!isDone && !live);
const isRunning = !isError && !isDone;
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ${
isError ? "bg-error/10 text-error" : "bg-base-200 text-base-content/70"
}`}
>
{isRunning ? (
<Loader2 className="size-3 animate-spin" />
) : isError ? (
<AlertTriangle className="size-3" />
) : (
<Check className="size-3" />
)}
<span>{isRunning ? `${labels.running}` : labels.done}</span>
</span>
);
}
/**
* One chat message bubble. User turns render as a right-aligned bubble;
* assistant turns render each part (reasoning, markdown text, tool badges) in
* document order, flush with the column. `resolveToolLabel` maps tool part
* types to labels.
*
* Every settled message gets a hover copy button. User messages additionally
* get undo (rewind the conversation to before this message) and edit (rewind,
* then resend the edited text) when the chat passes the handlers both need
* server support, so chats opt in.
*/
export function ChatMessage({
message,
resolveToolLabel,
streaming,
onUndo,
onEdit,
}: {
message: UIMessage;
resolveToolLabel: ResolveToolLabel;
/** True while this message is still being generated: reasoning spinners
* stay live and the hover actions (copy) are held back until it settles. */
streaming?: boolean;
onUndo?: () => void;
onEdit?: (newText: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
if (message.role === "user") {
if (editing && onEdit) {
const submit = () => {
const text = draft.trim();
setEditing(false);
if (text && text !== messageText(message)) onEdit(text);
};
return (
<div className="flex flex-col items-end gap-1.5 pl-8 sm:pl-16">
<textarea
className="textarea textarea-bordered w-full max-w-xl text-sm"
rows={Math.min(6, Math.max(2, draft.split("\n").length))}
value={draft}
autoFocus
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
if (event.key === "Escape") setEditing(false);
}}
/>
<div className="flex gap-1.5">
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => setEditing(false)}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary btn-xs"
onClick={submit}
>
Save & resend
</button>
</div>
</div>
);
}
return (
<div className="group flex flex-col gap-1">
<div className="flex justify-end pl-8 sm:pl-16">
<div className="rounded-box rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-content">
{message.parts.map((part, index) =>
part.type === "text" ? (
<span key={index} className="whitespace-pre-wrap">
{part.text}
</span>
) : null,
)}
</div>
</div>
<MessageActions
message={message}
onUndo={onUndo}
onStartEdit={
onEdit
? () => {
setDraft(messageText(message));
setEditing(true);
}
: undefined
}
/>
</div>
);
}
return (
<div className="group flex flex-col gap-1">
<div className="min-w-0 space-y-2 text-sm">
{message.parts.map((part, index) => {
if (part.type === "reasoning") {
return part.text.trim() ? (
<ReasoningBlock
key={index}
part={part}
live={Boolean(streaming)}
/>
) : null;
}
if (part.type === "text") {
return part.text.trim() ? (
<Markdown key={index}>{part.text}</Markdown>
) : null;
}
if (part.type.startsWith("tool-")) {
return (
<ToolBadge
key={index}
part={part}
live={Boolean(streaming)}
resolveToolLabel={resolveToolLabel}
/>
);
}
return null;
})}
</div>
{streaming ? null : <MessageActions message={message} />}
</div>
);
}

View File

@ -1,16 +1,12 @@
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { type UIMessage } from "ai";
import { useCustomer } from "autumn-js/react";
import { useEffect, useRef, useState } from "react";
import {
Sparkles,
Loader2,
Check,
AlertTriangle,
ChevronRight,
} from "lucide-react";
import { Markdown } from "@/client/components/Markdown";
ChatMessage,
messageHasVisibleContent,
type ResolveToolLabel,
} from "@/client/components/chat/ChatMessage";
import { captureClientEvent } from "@/client/lib/posthog";
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
@ -22,53 +18,6 @@ import {
WelcomeMessage,
} from "./OnboardingChatParts";
// Whether an assistant message already shows something — visible text or a tool
// badge. Used to decide when the standalone typing indicator is still needed: a
// running tool badge already reads as progress, so the dots would double up.
function messageHasVisibleContent(message: UIMessage): boolean {
return message.parts.some(
(part) =>
(part.type === "text" && part.text.trim().length > 0) ||
(part.type === "reasoning" && part.text.trim().length > 0) ||
part.type.startsWith("tool-"),
);
}
// Collapsible "thinking" block for the model's reasoning stream. Collapsed by
// default so the chain-of-thought doesn't bury the answer; while it's still
// streaming it doubles as the progress indicator ("Thinking…" + spinner).
function ReasoningBlock({
part,
}: {
part: Extract<UIMessage["parts"][number], { type: "reasoning" }>;
}) {
const [expanded, setExpanded] = useState(false);
const isStreaming = part.state === "streaming";
return (
<div className="text-base-content/60">
<button
type="button"
onClick={() => setExpanded((open) => !open)}
className="inline-flex items-center gap-1.5 text-xs hover:text-base-content/80"
>
{isStreaming ? (
<Loader2 className="size-3 animate-spin" />
) : (
<ChevronRight
className={`size-3 transition-transform ${expanded ? "rotate-90" : ""}`}
/>
)}
<span>{isStreaming ? "Thinking…" : "Thought process"}</span>
</button>
{expanded ? (
<div className="mt-1.5 whitespace-pre-wrap border-l-2 border-base-300 pl-3 text-xs text-base-content/50">
{part.text}
</div>
) : null}
</div>
);
}
// Friendly labels for each tool Sam can run, so the chat shows what it's doing
// rather than going silent while it gathers site data. `running` shows while the
// call is in flight; `done` stays as a persistent badge once it finishes.
@ -104,78 +53,10 @@ const TOOL_LABELS: Record<string, { running: string; done: string }> = {
},
};
// A small inline badge for one tool call, rendered in document order inside the
// assistant bubble so the sequence of work stays visible after it completes.
function ToolBadge({ part }: { part: UIMessage["parts"][number] }) {
const labels = TOOL_LABELS[part.type];
if (!labels) return null;
const state = "state" in part ? part.state : undefined;
const isError = state === "output-error";
const isDone = state === "output-available";
const isRunning = !isError && !isDone;
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ${
isError ? "bg-error/10 text-error" : "bg-base-200 text-base-content/70"
}`}
>
{isRunning ? (
<Loader2 className="size-3 animate-spin" />
) : isError ? (
<AlertTriangle className="size-3" />
) : (
<Check className="size-3" />
)}
<span>{isRunning ? `${labels.running}` : labels.done}</span>
</span>
);
}
function ChatBubble({ message }: { message: UIMessage }) {
const isUser = message.role === "user";
if (isUser) {
return (
<div className="flex justify-end pl-8 sm:pl-16">
<div className="rounded-box rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-content">
{message.parts.map((part, index) =>
part.type === "text" ? (
<span key={index} className="whitespace-pre-wrap">
{part.text}
</span>
) : null,
)}
</div>
</div>
);
}
return (
<div className="flex gap-3">
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="size-4" />
</div>
<div className="min-w-0 flex-1 space-y-2 pt-0.5 text-sm">
{message.parts.map((part, index) => {
if (part.type === "reasoning") {
return part.text.trim() ? (
<ReasoningBlock key={index} part={part} />
) : null;
}
if (part.type === "text") {
return part.text.trim() ? (
<Markdown key={index}>{part.text}</Markdown>
) : null;
}
if (part.type.startsWith("tool-")) {
return <ToolBadge key={index} part={part} />;
}
return null;
})}
</div>
</div>
);
}
// Onboarding curates a label per tool and hides any tool it hasn't named, so
// the pre-paywall preview only shows the handful it means to surface.
const resolveToolLabel: ResolveToolLabel = (partType) =>
TOOL_LABELS[partType] ?? null;
const SUGGESTED_QUESTIONS = [
"How will OpenSEO help me get more traffic?",
@ -300,37 +181,36 @@ export function OnboardingChatConversation({
onUpgrade={() => void startCheckout()}
/>
{messages.map((message) => (
<ChatBubble key={message.id} message={message} />
{messages.map((message, index) => (
<ChatMessage
key={message.id}
message={message}
resolveToolLabel={resolveToolLabel}
streaming={
isBusy &&
index === messages.length - 1 &&
message.role === "assistant"
}
/>
))}
{showTyping ? (
<div className="flex gap-3">
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="size-4" />
</div>
<div className="flex items-center gap-2 pt-2 text-base-content/40">
<div className="flex items-center gap-2 pt-1 text-base-content/40">
<span className="flex items-center gap-1.5">
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-current" />
</span>
</div>
</div>
) : null}
{status === "error" ? (
<div className="flex gap-3">
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-error/10 text-error">
<Sparkles className="size-4" />
</div>
<p className="pt-1 text-sm text-error">
<p className="text-sm text-error">
{/* Billing gates (free-question cap / out-of-credits) come
back as normal assistant messages now, so this only covers
genuine failures. */}
Something went wrong. Please refresh and try again.
</p>
</div>
) : null}
{showSuggestions ? (

View File

@ -252,9 +252,11 @@ export function ChatGate({
export function ChatComposer({
busy,
onSend,
placeholder = "Ask Sam about your strategy or OpenSEO…",
}: {
busy: boolean;
onSend: (text: string) => void;
placeholder?: string;
}) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -298,7 +300,7 @@ export function ChatComposer({
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKey}
rows={1}
placeholder="Ask Sam about your strategy or OpenSEO…"
placeholder={placeholder}
className="max-h-40 flex-1 resize-none border-0 bg-transparent px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-base-content/50 focus:outline-none"
/>
<button

View File

@ -0,0 +1,117 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Suspense, useCallback, useEffect } from "react";
import { Loader2, Plus, Wrench } from "lucide-react";
import { createSamSession } from "@/serverFunctions/sam";
import {
invalidateSamSessions,
samSessionsQueryOptions,
} from "@/client/features/sam/samQueries";
import { SamConversation } from "./SamConversation";
/**
* The SAM route's content: the active conversation, full-width. The chat
* history list lives in the app sidebar's Chat tab (SamSidebarPanel); this
* component only auto-selects the most recent session on landing and shows the
* start-a-chat empty state when the project has none.
*/
export function SamChat({
projectId,
activeSessionId,
}: {
projectId: string;
activeSessionId: string | undefined;
}) {
const navigate = useNavigate();
const sessionsQuery = useQuery(samSessionsQueryOptions(projectId));
const sessions = sessionsQuery.data ?? [];
const goToSession = useCallback(
(sessionId: string) =>
void navigate({
to: "/p/$projectId/sam",
params: { projectId },
search: { s: sessionId },
replace: true,
}),
[navigate, projectId],
);
const createSession = useMutation({
mutationFn: () => createSamSession({ data: { projectId } }),
onSuccess: ({ id }) => {
invalidateSamSessions(projectId);
goToSession(id);
},
});
// Default to the most recent session once they load; if none exist, leave the
// empty state so the user can start one explicitly.
const firstSessionId = sessions[0]?.id;
useEffect(() => {
if (activeSessionId || !firstSessionId) return;
goToSession(firstSessionId);
}, [activeSessionId, firstSessionId, goToSession]);
if (activeSessionId) {
return (
<div className="flex h-full min-h-0">
{/* useAgentChat suspends while it fetches the session's history; this
boundary keeps that suspension inside the chat panel instead of
letting it bubble up and swap out the whole shell which read as
a full page refresh on every session switch. */}
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-5 animate-spin text-base-content/40" />
</div>
}
>
<SamConversation
key={activeSessionId}
projectId={projectId}
sessionId={activeSessionId}
/>
</Suspense>
</div>
);
}
if (sessionsQuery.isLoading) {
// Sessions are still loading; the auto-select effect will redirect into
// the most recent one. Show a loader instead of flashing the empty state.
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-base-content/40" />
</div>
);
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Wrench className="size-6" />
</div>
<div className="space-y-1">
<p className="text-lg font-medium">What should we work on?</p>
<p className="max-w-sm text-sm text-base-content/60">
SAM is your in-app SEO agent with access to every OpenSEO research
tool. Start a chat to get going.
</p>
</div>
<button
type="button"
className="btn btn-primary btn-sm gap-1"
disabled={createSession.isPending}
onClick={() => createSession.mutate()}
>
{createSession.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
New chat
</button>
</div>
);
}

View File

@ -0,0 +1,188 @@
import { useAgent } from "agents/react";
// Think speaks the same chat protocol as @cloudflare/ai-chat, but its hook
// variant skips the client->server transcript sync Think doesn't support.
import { useAgentChat } from "@cloudflare/think/react";
import { useEffect, useRef } from "react";
import { ChatComposer } from "@/client/features/onboarding/OnboardingChatParts";
import { invalidateSamSessions } from "@/client/features/sam/samQueries";
import {
ChatMessage,
humanizeToolLabel,
messageHasVisibleContent,
} from "@/client/components/chat/ChatMessage";
const SUGGESTIONS = [
"What keywords should I focus on next?",
"Who are my top SERP competitors?",
"How is my Search Console traffic trending?",
"Find quick-win keywords I already rank for",
];
export function SamConversation({
projectId,
sessionId,
}: {
projectId: string;
sessionId: string;
}) {
// The conversation lives in the SamChatAgent Durable Object, keyed by the
// session id. The WebSocket is authorized in the Worker (src/server.ts) before
// it reaches the DO; billing gates come back as normal assistant messages.
const agent = useAgent({ agent: "sam-chat", name: sessionId });
const { messages, sendMessage, setMessages, clearHistory, status } =
useAgentChat({ agent });
const isBusy = status === "submitted" || status === "streaming";
const sendText = (text: string) => void sendMessage({ text });
// Rewind the server-side conversation to before `messageId`: the DO aborts
// any in-flight turn, then deletes the message and everything after it. Sync
// the local view from the server afterwards rather than slicing locally —
// an aborted turn may have persisted (or removed) more than we can see, and
// on Think setMessages is local-only, so this is a pure view update.
const rewindTo = async (messageId: string) => {
const response = await fetch(`/agents/sam-chat/${sessionId}/rewind`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messageId }),
});
if (!response.ok) return false;
const fresh = await fetch(
`/agents/sam-chat/${sessionId}/get-messages`,
).then((res) => (res.ok ? res.json() : null));
if (Array.isArray(fresh)) setMessages(fresh);
return true;
};
const undoFrom = (messageId: string) => void rewindTo(messageId);
const editAndResend = async (messageId: string, newText: string) => {
if (await rewindTo(messageId)) void sendMessage({ text: newText });
};
// The DO names the session from its first message during the turn, so refresh
// the side-panel once the turn settles (busy -> idle) to pick up the title.
const wasBusyRef = useRef(false);
useEffect(() => {
if (isBusy) {
wasBusyRef.current = true;
return;
}
if (wasBusyRef.current) {
wasBusyRef.current = false;
invalidateSamSessions(projectId);
}
}, [isBusy, projectId]);
// Pin to the bottom while the user follows along.
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, status]);
const lastMessage = messages[messages.length - 1];
const showTyping =
isBusy &&
(lastMessage?.role !== "assistant" ||
!messageHasVisibleContent(lastMessage));
const showSuggestions = messages.length === 0 && !isBusy;
return (
<div className="relative flex min-w-0 flex-1 flex-col">
{import.meta.env.DEV ? (
// Dev-only escape hatch: wipes this session's persisted transcript on
// the server (Think's cf_agent_chat_clear), for testing fresh-session
// behavior without creating a new chat.
<button
type="button"
className="btn btn-ghost btn-xs absolute right-3 top-2 z-10 text-base-content/40"
onClick={() => clearHistory()}
>
Clear history (dev)
</button>
) : null}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-6">
<div className="mx-auto max-w-2xl space-y-6">
{messages.length === 0 ? (
<div className="space-y-2 text-sm text-base-content/80">
<p>
Hey, Im SAM your in-app SEO agent. I can research keywords,
size up competitors, read your SERPs, backlinks, rank tracking
and Search Console, and turn it into next steps for this
project.
</p>
<p>Ask me anything, or start with one of these:</p>
</div>
) : null}
{messages.map((message, index) => (
<ChatMessage
key={message.id}
message={message}
// SAM exposes the full MCP tool surface (~19 tools), too many to
// hand-label, so tool names are humanized generically rather
// than kept in a curated label map.
resolveToolLabel={humanizeToolLabel}
streaming={
isBusy &&
index === messages.length - 1 &&
message.role === "assistant"
}
onUndo={
// Allowed even mid-turn: rewind aborts the in-flight turn
// server-side, so undo doubles as "stop and take it back".
message.role === "user" ? () => undoFrom(message.id) : undefined
}
onEdit={
message.role === "user"
? (newText) => void editAndResend(message.id, newText)
: undefined
}
/>
))}
{showTyping ? (
<div className="flex items-center gap-2 pt-1 text-base-content/40">
<span className="flex items-center gap-1.5">
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-current" />
</span>
</div>
) : null}
{status === "error" ? (
<p className="text-sm text-error">
Something went wrong. Please try again.
</p>
) : null}
{showSuggestions ? (
<div className="flex flex-wrap gap-2">
{SUGGESTIONS.map((question) => (
<button
key={question}
type="button"
className="rounded-full border border-base-300 bg-base-100 px-3 py-1.5 text-xs font-medium text-base-content/70 transition-colors hover:border-primary/50 hover:text-base-content"
onClick={() => sendText(question)}
>
{question}
</button>
))}
</div>
) : null}
</div>
</div>
<div className="flex-shrink-0 border-t border-base-300 px-5 py-3">
<div className="mx-auto w-full max-w-2xl">
<ChatComposer
busy={isBusy}
onSend={sendText}
placeholder="Ask SAM to research, analyze, or track anything…"
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,178 @@
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { Archive, Loader2, Plus, X } from "lucide-react";
import { archiveSamSession, createSamSession } from "@/serverFunctions/sam";
import {
invalidateSamSessions,
samSessionsQueryOptions,
} from "@/client/features/sam/samQueries";
const BETA_NOTICE_DISMISSED_KEY = "sam-beta-notice-dismissed";
// Beta framing + the MCP power-path nudge, pinned to the bottom of the Chat
// tab. Dismissible per browser; localStorage is read in an effect so SSR and
// the first client render stay identical (same pattern as AppShell).
function BetaNotice() {
const [dismissed, setDismissed] = useState(true);
useEffect(() => {
setDismissed(localStorage.getItem(BETA_NOTICE_DISMISSED_KEY) === "1");
}, []);
if (dismissed) return null;
return (
<div className="mx-2 mb-2 rounded-lg border border-base-300 bg-base-100 p-3">
<div className="flex items-center justify-between">
<span className="badge badge-primary badge-sm">Beta</span>
<button
type="button"
aria-label="Dismiss"
className="btn btn-ghost btn-xs btn-square text-base-content/40"
onClick={() => {
localStorage.setItem(BETA_NOTICE_DISMISSED_KEY, "1");
setDismissed(true);
}}
>
<X className="size-3.5" />
</button>
</div>
<p className="mt-1.5 text-xs text-base-content/70">
For more powerful AI workflows, use the OpenSEO MCP with your own agent
like Claude Code or Hermes.
</p>
<Link to="/ai" className="link link-primary mt-1.5 inline-block text-xs">
Set up the MCP
</Link>
</div>
);
}
// Compact age label for the session list (PostHog-style "3h" / "12d").
// Timestamps come back as UTC from both backends: D1 as "YYYY-MM-DD HH:MM:SS"
// (no zone marker), Postgres as ISO-8601 with a trailing Z.
function ageLabel(timestamp: string): string {
const iso = timestamp.includes("T") ? timestamp : `${timestamp}Z`;
const then = new Date(iso.replace(" ", "T")).getTime();
if (Number.isNaN(then)) return "";
const minutes = Math.max(0, Math.floor((Date.now() - then) / 60_000));
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
/**
* The sidebar's Chat tab: the active project's chat history plus a new-chat
* button. Selecting (or creating) a session navigates to the SAM route; the
* conversation itself renders in the main content panel.
*/
export function SamSidebarPanel({
projectId,
onNavigate,
}: {
projectId: string;
onNavigate?: () => void;
}) {
const navigate = useNavigate();
const location = useLocation();
const activeSessionId = (location.search as { s?: string }).s;
const sessionsQuery = useQuery(samSessionsQueryOptions(projectId));
const sessions = sessionsQuery.data ?? [];
const goToSession = (sessionId: string | undefined) => {
void navigate({
to: "/p/$projectId/sam",
params: { projectId },
search: sessionId ? { s: sessionId } : {},
});
onNavigate?.();
};
const createSession = useMutation({
mutationFn: () => createSamSession({ data: { projectId } }),
onSuccess: ({ id }) => {
invalidateSamSessions(projectId);
goToSession(id);
},
});
const archiveSession = useMutation({
mutationFn: (sessionId: string) =>
archiveSamSession({ data: { sessionId } }),
onSuccess: (_result, sessionId) => {
invalidateSamSessions(projectId);
if (sessionId === activeSessionId) {
goToSession(sessions.find((s) => s.id !== sessionId)?.id);
}
},
});
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="px-2 pb-1">
{/* Ghost row styled like a list item so the sidebar header doesn't
stack three heavy full-width controls. */}
<button
type="button"
className="btn btn-ghost btn-sm btn-block justify-start gap-2 font-normal text-base-content/70 hover:text-base-content"
disabled={createSession.isPending}
onClick={() => createSession.mutate()}
>
{createSession.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
New chat
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1">
{sessionsQuery.isLoading ? (
<div className="flex justify-center py-6 text-base-content/50">
<Loader2 className="size-4 animate-spin" />
</div>
) : sessions.length === 0 ? (
<p className="px-2 py-6 text-center text-xs text-base-content/50">
No chats yet. Start a new one.
</p>
) : (
sessions.map((session) => {
const isActive = session.id === activeSessionId;
return (
<div
key={session.id}
className={`group flex items-center gap-1 rounded-md px-1 ${
isActive ? "bg-base-300/50" : "hover:bg-base-300/40"
}`}
>
<button
type="button"
onClick={() => goToSession(session.id)}
className="min-w-0 flex-1 truncate px-2 py-1.5 text-left text-sm text-base-content/80"
>
{session.title}
</button>
<span className="shrink-0 text-xs text-base-content/40 group-hover:hidden">
{ageLabel(session.updatedAt)}
</span>
<button
type="button"
aria-label="Archive chat"
className="btn btn-ghost btn-xs btn-square hidden group-hover:inline-flex"
disabled={archiveSession.isPending}
onClick={() => archiveSession.mutate(session.id)}
>
<Archive className="size-3.5 text-base-content/50" />
</button>
</div>
);
})
)}
</div>
<BetaNotice />
</div>
);
}

View File

@ -0,0 +1,13 @@
import { queryOptions } from "@tanstack/react-query";
import { queryClient } from "@/client/tanstack-db";
import { listSamSessions } from "@/serverFunctions/sam";
export const samSessionsQueryOptions = (projectId: string) =>
queryOptions({
queryKey: ["samSessions", projectId],
queryFn: () => listSamSessions({ data: { projectId } }),
});
export function invalidateSamSessions(projectId: string) {
void queryClient.invalidateQueries({ queryKey: ["samSessions", projectId] });
}

View File

@ -2,6 +2,7 @@
// which is the provider-aware barrel) so the D1 client always binds to the
// SQLite tables regardless of DATABASE_PROVIDER.
export * from "../app.schema";
export * from "../sam.schema";
export * from "../better-auth-schema";
export * from "../billing.schema";
export * from "../gsc.schema";

View File

@ -65,6 +65,13 @@ export async function withPgClient<T>(fn: () => Promise<T>): Promise<T> {
if (getDatabaseProvider() !== "postgres") {
return fn();
}
// Reentrant: nested scopes (e.g. a DO hook calling helpers that defensively
// scope themselves) reuse the ambient client instead of opening another
// connection. Workflow steps are unaffected — ALS never crosses step.do, so
// each step's own wrap still creates its client.
if (pgClientStore.getStore()) {
return fn();
}
const sql = postgres(getPostgresConnectionString(), {
max: 1,
fetch_types: false,

54
src/db/pg/sam.schema.ts Normal file
View File

@ -0,0 +1,54 @@
import { sql } from "drizzle-orm";
import { index, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import { user } from "./better-auth-schema";
import { projects } from "./app.schema";
// See src/db/pg/app.schema.ts for why timestamps are ISO-8601 UTC text.
const isoNow = sql`to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
// One row per SAM chat session. The conversation history itself lives in the
// SamChatAgent Durable Object's SQLite (keyed by this id); this table is the
// listable registry the session side-panel reads from, and the project/user
// scoping the Worker authorizes a connection against before it reaches the DO.
// Normalized on purpose: org and user email are derived from the project and
// user rows at read time, never snapshotted here.
export const samSessions = pgTable(
"sam_sessions",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
title: text("title").notNull().default("New chat"),
createdAt: text("created_at").notNull().default(isoNow),
updatedAt: text("updated_at").notNull().default(isoNow),
// Soft-delete marker: null = active. Archived sessions disappear from the
// list but keep their registry row and DO transcript for a future unarchive.
archivedAt: text("archived_at"),
},
(table) => [
// The side-panel lists a project's sessions newest-first.
index("sam_sessions_project_updated_idx").on(
table.projectId,
table.updatedAt,
),
],
);
// See src/db/sam.schema.ts for the role of this table (shared SAM context
// blocks per project).
export const samProjectMemory = pgTable(
"sam_project_memory",
{
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
label: text("label").notNull(),
content: text("content").notNull(),
updatedAt: text("updated_at").notNull().default(isoNow),
},
(table) => [primaryKey({ columns: [table.projectId, table.label] })],
);

View File

@ -1,4 +1,5 @@
export * from "./app.schema";
export * from "./sam.schema";
export * from "./better-auth-schema";
export * from "./billing.schema";
export * from "./gsc.schema";

61
src/db/sam.schema.ts Normal file
View File

@ -0,0 +1,61 @@
import { sqliteTable, text, index, primaryKey } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { user } from "./better-auth-schema";
import { projects } from "./app.schema";
// One row per SAM chat session. The conversation history itself lives in the
// SamChatAgent Durable Object's SQLite (keyed by this id); this table is the
// listable registry the session side-panel reads from, and the project/user
// scoping the Worker authorizes a connection against before it reaches the DO.
// Normalized on purpose: org and user email are derived from the project and
// user rows at read time, never snapshotted here.
export const samSessions = sqliteTable(
"sam_sessions",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
title: text("title").notNull().default("New chat"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft-delete marker: null = active. Archived sessions disappear from the
// list but keep their registry row and DO transcript for a future unarchive.
archivedAt: text("archived_at"),
},
(table) => [
// The side-panel lists a project's sessions newest-first.
index("sam_sessions_project_updated_idx").on(
table.projectId,
table.updatedAt,
),
],
);
// SAM's persistent project memory: one row per (project, context-block label).
// The SamChatAgent DO surfaces these rows to the model as writable context
// blocks ("memory", "research_log"), so every chat session in a project reads
// and writes the same memory. Lives in the app DB rather than DO storage so it
// is shared across the per-session DOs and stays queryable by the Worker (for
// a future settings/inspection UI).
export const samProjectMemory = sqliteTable(
"sam_project_memory",
{
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
label: text("label").notNull(),
content: text("content").notNull(),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [primaryKey({ columns: [table.projectId, table.label] })],
);

View File

@ -5,11 +5,13 @@ import { getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core"
import { getTableConfig as getPgTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import * as sqliteApp from "./app.schema";
import * as sqliteSam from "./sam.schema";
import * as sqliteAuth from "./better-auth-schema";
import * as sqliteBilling from "./billing.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteReddit from "./reddit-attribution.schema";
import * as pgApp from "./pg/app.schema";
import * as pgSam from "./pg/sam.schema";
import * as pgAuth from "./pg/better-auth-schema";
import * as pgBilling from "./pg/billing.schema";
import * as pgGsc from "./pg/gsc.schema";
@ -131,11 +133,12 @@ function foreignKeys(table: Table, dialect: Dialect): string[] {
const sqliteAppTables = tablesFrom(
sqliteApp,
sqliteSam,
sqliteBilling,
sqliteGsc,
sqliteReddit,
);
const pgAppTables = tablesFrom(pgApp, pgBilling, pgGsc, pgReddit);
const pgAppTables = tablesFrom(pgApp, pgSam, pgBilling, pgGsc, pgReddit);
const sqliteAuthTables = tablesFrom(sqliteAuth);
const pgAuthTables = tablesFrom(pgAuth);

View File

@ -1,10 +1,12 @@
import { getDatabaseProvider } from "./provider";
import * as sqliteApp from "./app.schema";
import * as sqliteSam from "./sam.schema";
import * as sqliteAuth from "./better-auth-schema";
import * as sqliteBilling from "./billing.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteReddit from "./reddit-attribution.schema";
import * as pgApp from "./pg/app.schema";
import * as pgSam from "./pg/sam.schema";
import * as pgAuth from "./pg/better-auth-schema";
import * as pgBilling from "./pg/billing.schema";
import * as pgGsc from "./pg/gsc.schema";
@ -21,6 +23,7 @@ import * as pgReddit from "./pg/reddit-attribution.schema";
// schema is the one structural artifact NOT regenerated by `db:generate`, so the
// parity test is its drift guard.
type AppSchema = typeof sqliteApp &
typeof sqliteSam &
typeof sqliteAuth &
typeof sqliteBilling &
typeof sqliteGsc &
@ -28,9 +31,10 @@ type AppSchema = typeof sqliteApp &
const runtimeSchema =
getDatabaseProvider() === "postgres"
? { ...pgApp, ...pgAuth, ...pgBilling, ...pgGsc, ...pgReddit }
? { ...pgApp, ...pgSam, ...pgAuth, ...pgBilling, ...pgGsc, ...pgReddit }
: {
...sqliteApp,
...sqliteSam,
...sqliteAuth,
...sqliteBilling,
...sqliteGsc,
@ -54,6 +58,8 @@ export const {
audits,
auditPages,
auditLighthouseResults,
samSessions,
samProjectMemory,
user,
session,
account,

5
src/env.d.ts vendored
View File

@ -9,6 +9,9 @@ declare namespace Cloudflare {
// Durable Object backing the onboarding strategy chat (see wrangler.jsonc).
ONBOARDING_CHAT: DurableObjectNamespace;
// Durable Object backing the SAM in-app agent (see wrangler.jsonc).
SAM_CHAT: DurableObjectNamespace;
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
BYPASS_EMAIL_VERIFICATION?: string;
TEAM_DOMAIN?: string;
@ -38,7 +41,7 @@ declare namespace Cloudflare {
// DataForSEO API Basic auth value (base64 of login:password)
DATAFORSEO_API_KEY: string;
// OpenRouter API key for the onboarding chat.
// OpenRouter API key for the in-app chat agents (onboarding + SAM).
OPENROUTER_API_KEY?: string;
// Optional OpenRouter model slug override (defaults in openrouter.ts).
OPENROUTER_MODEL?: string;

View File

@ -38,6 +38,7 @@ import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/
import { Route as ProjectPProjectIdSettingsRouteImport } from './routes/_project/p/$projectId/settings'
import { Route as ProjectPProjectIdSearchPerformanceRouteImport } from './routes/_project/p/$projectId/search-performance'
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
import { Route as ProjectPProjectIdSamRouteImport } from './routes/_project/p/$projectId/sam'
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
@ -197,6 +198,11 @@ const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
path: '/saved',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdSamRoute = ProjectPProjectIdSamRouteImport.update({
id: '/sam',
path: '/sam',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdRankTrackingRoute =
ProjectPProjectIdRankTrackingRouteImport.update({
id: '/rank-tracking',
@ -290,6 +296,7 @@ export interface FileRoutesByFullPath {
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
@ -325,6 +332,7 @@ export interface FileRoutesByTo {
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
@ -368,6 +376,7 @@ export interface FileRoutesById {
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/_project/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/_project/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
@ -408,6 +417,7 @@ export interface FileRouteTypes {
| '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer'
| '/p/$projectId/rank-tracking'
| '/p/$projectId/sam'
| '/p/$projectId/saved'
| '/p/$projectId/search-performance'
| '/p/$projectId/settings'
@ -443,6 +453,7 @@ export interface FileRouteTypes {
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer'
| '/p/$projectId/sam'
| '/p/$projectId/saved'
| '/p/$projectId/search-performance'
| '/p/$projectId/settings'
@ -485,6 +496,7 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/keywords'
| '/_project/p/$projectId/prompt-explorer'
| '/_project/p/$projectId/rank-tracking'
| '/_project/p/$projectId/sam'
| '/_project/p/$projectId/saved'
| '/_project/p/$projectId/search-performance'
| '/_project/p/$projectId/settings'
@ -715,6 +727,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdSavedRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
'/_project/p/$projectId/sam': {
id: '/_project/p/$projectId/sam'
path: '/sam'
fullPath: '/p/$projectId/sam'
preLoaderRoute: typeof ProjectPProjectIdSamRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
'/_project/p/$projectId/rank-tracking': {
id: '/_project/p/$projectId/rank-tracking'
path: '/rank-tracking'
@ -862,6 +881,7 @@ interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
ProjectPProjectIdSamRoute: typeof ProjectPProjectIdSamRoute
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
ProjectPProjectIdSearchPerformanceRoute: typeof ProjectPProjectIdSearchPerformanceRoute
ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRoute
@ -878,6 +898,7 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
ProjectPProjectIdRankTrackingRoute:
ProjectPProjectIdRankTrackingRouteWithChildren,
ProjectPProjectIdSamRoute: ProjectPProjectIdSamRoute,
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
ProjectPProjectIdSearchPerformanceRoute:
ProjectPProjectIdSearchPerformanceRoute,

View File

@ -1,9 +1,10 @@
import {
Outlet,
createFileRoute,
redirect,
useLocation,
useNavigate,
} from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { setLastProjectId } from "@/client/lib/active-project";
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
@ -18,31 +19,50 @@ import {
import { getProjectAccess } from "@/serverFunctions/projects";
export const Route = createFileRoute("/_project/p/$projectId")({
beforeLoad: async ({ location, params }) => {
try {
await getProjectAccess({ data: { projectId: params.projectId } });
} catch (error) {
// Everything under this subtree fetches its data client-side with
// react-query, so SSR would only render empty chrome.
ssr: false,
component: ProjectLayout,
});
// Redirect-only guard, deliberately NOT a blocking beforeLoad: the shell
// renders immediately while the access check runs in the background, and the
// browser only gets bounced if it lands on a project it can't see (stale
// last-project id, foreign URL). Real authorization is enforced on every data
// call; nothing sensitive renders from this check.
function useProjectAccessRedirect(projectId: string) {
const navigate = useNavigate();
const access = useQuery({
queryKey: ["projectAccess", projectId],
queryFn: () => getProjectAccess({ data: { projectId } }),
// A failed check redirects away — retrying would just delay it.
retry: false,
// One check per project per tab; a revoked project still dead-ends at
// every data call, so there's nothing to re-validate here.
staleTime: Infinity,
});
const error = access.error;
useEffect(() => {
if (!error) return;
if (getErrorCode(error) === "UNAUTHENTICATED") {
throw redirect({
void navigate({
to: "/sign-in",
search: getSignInSearch(
getCurrentAuthRedirectFromHref(location.href),
getCurrentAuthRedirectFromHref(window.location.href),
),
replace: true,
});
return;
}
throw redirect({ to: "/", replace: true });
void navigate({ to: "/", replace: true });
}, [error, navigate]);
}
},
pendingComponent: ProjectRoutePending,
component: ProjectLayout,
});
function ProjectLayout() {
const { projectId } = Route.useParams();
const authGate = useHostedAuthRouteGuard();
useOnboardingRedirect();
useProjectAccessRedirect(projectId);
// Remember this as the last-visited project for the landing redirect.
// Settings is excluded: editing another project's settings is
@ -69,11 +89,3 @@ function ProjectLayout() {
</AuthenticatedAppLayout>
);
}
function ProjectRoutePending() {
return (
<div className="flex h-full items-center justify-center">
<span className="loading loading-spinner loading-md" />
</div>
);
}

View File

@ -0,0 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { SamChat } from "@/client/features/sam/SamChat";
const samSearchSchema = z.object({
// Active session id. Omitted until a session is selected/created.
s: z.string().optional(),
});
export const Route = createFileRoute("/_project/p/$projectId/sam")({
validateSearch: samSearchSchema,
component: SamRoute,
});
function SamRoute() {
const { projectId } = Route.useParams();
const { s } = Route.useSearch();
return <SamChat projectId={projectId} activeSessionId={s} />;
}

View File

@ -5,6 +5,7 @@ import {
import { routeAgentRequest } from "agents";
import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { SamSessionRepository } from "@/server/features/sam/SamSessionRepository";
import { runScheduledRankChecks } from "@/server/features/rank-tracking/services/scheduledRankChecks";
import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
@ -56,16 +57,64 @@ async function authorizeOnboardingChat(
return undefined;
}
// Route /agents/* to the onboarding chat DO. Auth happens here (both the WS
// upgrade and any HTTP message-history fetch), keeping it off the OAuth wrapper
// and TanStack route guard below.
async function routeOnboardingChatAgent(
// Authorize a SAM agent connection in the Worker, before it reaches the Durable
// Object. The DO instance name is the sessionId (set client-side); we resolve
// the session here and authorize the caller against the session's project via
// the same canonical project-access check the rest of the app uses, so the DO
// can trust its `name` and derive org/project/user from the session row.
async function authorizeSamChat(
request: Request,
env: Env,
): Promise<Response> {
sessionId: string,
): Promise<Response | undefined> {
let context;
try {
context = await resolveUserContextFromHeaders(request.headers);
} catch {
return new Response("Unauthorized", { status: 401 });
}
const session = await SamSessionRepository.getActiveSession(sessionId);
const project = session
? await ProjectRepository.getProjectForOrganization(
session.projectId,
context.organizationId,
)
: null;
if (!session || !project) {
return new Response("Forbidden", { status: 403 });
}
// Same as onboarding above: make sure the Autumn customer (and its default
// free-plan credits) exists before the DO's balance gate runs, or a brand-new
// org's first message hits a false "out of credits".
if (await isHostedServerAuthMode()) {
await getOrCreateOrganizationCustomer(context);
}
return undefined;
}
// Both chat DOs live behind /agents/*. Dispatch on the DO binding partyserver
// resolved for the request (rather than re-parsing the path), and fail closed
// on anything unrecognized.
function authorizeChatAgent(
request: Request,
lobby: { className: string; name: string },
): Promise<Response | undefined> | Response {
switch (lobby.className) {
case "SAM_CHAT":
return authorizeSamChat(request, lobby.name);
case "ONBOARDING_CHAT":
return authorizeOnboardingChat(request, lobby.name);
default:
return new Response("Forbidden", { status: 403 });
}
}
// Route /agents/* to the onboarding and SAM chat DOs. Auth happens here (both
// the WS upgrade and any HTTP message-history fetch), keeping it off the OAuth
// wrapper and TanStack route guard below.
async function routeChatAgents(request: Request, env: Env): Promise<Response> {
const response = await routeAgentRequest(request, env, {
onBeforeConnect: (req, lobby) => authorizeOnboardingChat(req, lobby.name),
onBeforeRequest: (req, lobby) => authorizeOnboardingChat(req, lobby.name),
onBeforeConnect: (req, lobby) => authorizeChatAgent(req, lobby),
onBeforeRequest: (req, lobby) => authorizeChatAgent(req, lobby),
});
return response ?? new Response("Not found", { status: 404 });
}
@ -90,7 +139,7 @@ function handleFetch(
const pathname = new URL(publicRequest.url).pathname;
if (pathname.startsWith("/agents/")) {
return routeOnboardingChatAgent(publicRequest, env);
return routeChatAgents(publicRequest, env);
}
if (isHostedAuthMode(authMode)) {
@ -120,6 +169,8 @@ export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
export { RankCheckWorkflow } from "./server/workflows/RankCheckWorkflow";
// Durable Object class for the onboarding strategy chat (Agents SDK).
export { OnboardingChatAgent } from "./server/features/onboarding/OnboardingChatAgent";
// Durable Object class for the SAM in-app agent (Agents SDK).
export { SamChatAgent } from "./server/features/sam/SamChatAgent";
export default {
fetch,

View File

@ -1,18 +1,19 @@
import { AIChatAgent } from "@cloudflare/ai-chat";
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
stepCountIs,
streamText,
type StreamTextOnFinishCallback,
type ToolSet,
} from "ai";
import type { OnChatMessageOptions } from "@cloudflare/ai-chat";
import { z } from "zod";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { buildOnboardingTools } from "@/server/features/onboarding/onboardingChatTools";
import { getOnboardingModel } from "@/server/lib/openrouter";
import { getChatAgentModel } from "@/server/lib/openrouter";
import {
openRouterCostUsd,
staticAssistantResponse,
} from "@/server/lib/chatAgent";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import {
customerHasManagedAccess,
@ -22,17 +23,6 @@ import {
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
import openSeoFactSheet from "@/server/features/onboarding/openseo-fact-sheet.md?raw";
// OpenRouter (with usage accounting on) reports the real USD cost of each
// response under providerMetadata.openrouter.usage.cost.
const openRouterUsageSchema = z.object({
openrouter: z.object({ usage: z.object({ cost: z.number() }) }),
});
function openRouterCostUsd(providerMetadata: unknown): number {
const parsed = openRouterUsageSchema.safeParse(providerMetadata);
return parsed.success ? parsed.data.openrouter.usage.cost : 0;
}
function buildSystemPrompt(domain: string | null): string {
return [
"You are Sam, the SEO onboarding agent inside OpenSEO. Introduce yourself as Sam if the user asks who you are.",
@ -69,21 +59,6 @@ function buildSystemPrompt(domain: string | null): string {
].join("\n\n");
}
// A non-LLM assistant turn streamed back over the chat protocol. Used to surface
// billing gates ("Subscribe to continue") without spending an LLM call — the
// client renders it as a normal message from Sam.
function staticAssistantResponse(text: string): Response {
const stream = createUIMessageStream({
execute: ({ writer }) => {
const id = crypto.randomUUID();
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: text });
writer.write({ type: "text-end", id });
},
});
return createUIMessageStreamResponse({ stream });
}
/**
* Durable Object backing the onboarding strategy chat. The conversation is
* persisted automatically in the DO's SQLite (`this.messages`), so it survives
@ -153,7 +128,7 @@ export class OnboardingChatAgent extends AIChatAgent {
monthlyCreditsRemaining = monthlyRemaining;
}
const model = await getOnboardingModel();
const model = await getChatAgentModel();
const result = streamText({
model,

View File

@ -1,11 +1,7 @@
import { tool, type ToolSet } from "ai";
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import {
MAX_PAGES,
readPages,
readSite,
} from "@/server/features/onboarding/scrape";
import { MAX_PAGES, readPages, readSite } from "@/server/lib/scrape";
import { DomainService } from "@/server/features/domain/services/DomainService";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { createDataforseoClient } from "@/server/lib/dataforseo";

View File

@ -0,0 +1,364 @@
import { Think } from "@cloudflare/think";
import type {
ChatResponseResult,
Session,
StepContext,
TurnConfig,
TurnContext,
} from "@cloudflare/think";
import { clearChatTerminal } from "agents/chat";
import type { UIMessage } from "ai";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, withPgClient } from "@/db";
import { user } from "@/db/schema";
import { openRouterCostUsd } from "@/server/lib/chatAgent";
import { SamSessionRepository } from "@/server/features/sam/SamSessionRepository";
import { SamProjectMemoryRepository } from "@/server/features/sam/SamProjectMemoryRepository";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { buildSamMcpTools } from "@/server/features/sam/samChatTools";
import { buildSamSystemPrompt } from "@/server/features/sam/samSystemPrompt";
import { buildChatAgentModel } from "@/server/lib/openrouter";
import {
getEnvValueSync,
isHostedServerAuthMode,
} from "@/server/lib/runtime-env";
import {
getUsageCreditsRemaining,
trackUsageCreditSpend,
} from "@/server/billing/subscription";
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { MCP_SCOPE } from "@/lib/oauth-resource";
import { buildFirstPartyMcpAuthContext } from "@/server/mcp/context";
// SAM's writable context blocks, backed by sam_project_memory rows shared by
// every chat session in the project.
const MEMORY_BLOCK = "memory";
const RESEARCH_LOG_BLOCK = "research_log";
const PUBLIC_ORIGIN_KEY = "sam-public-origin";
// Derive a short session title from the first user message.
function deriveTitle(text: string): string {
const trimmed = text.replace(/\s+/g, " ").trim();
if (!trimmed) return "New chat";
return trimmed.length > 60 ? `${trimmed.slice(0, 57)}` : trimmed;
}
function firstUserText(messages: UIMessage[]): string {
const firstUser = messages.find((message) => message.role === "user");
const textPart = firstUser?.parts.find((part) => part.type === "text");
return textPart?.text ?? "";
}
type SamContext = {
row: NonNullable<
Awaited<ReturnType<typeof SamSessionRepository.getSessionById>>
>;
project: NonNullable<
Awaited<ReturnType<typeof ProjectRepository.getProjectById>>
>;
// The session row is normalized (project/user ids only); the creating
// user's current email is resolved here for the billing/MCP auth context.
userEmail: string;
};
/**
* Durable Object backing the SAM in-app agent, built on Think. One DO per chat
* session (Think hosts one conversation per instance); the DO instance name IS
* the session id, set by the client (`useAgent({ name: sessionId })`) and
* authorized in the Worker (`onBeforeConnect`) before any connection reaches
* here so the DO trusts that its caller may act on `this.name` and derives
* project/user from the sam_sessions row (and the org from the project).
*
* Think owns the agentic loop (streaming, persistence, compaction-ready
* history, context blocks); this subclass contributes the model, the MCP
* toolset, the billing gate/metering, and project-scoped memory: the "memory"
* and "research_log" context blocks are backed by sam_project_memory rows in
* the app DB, so every session in a project shares them.
*/
export class SamChatAgent extends Think {
// Session row + project, resolved once per DO lifetime (the binding is
// immutable). Null until a turn/provider needs it — and left null when the
// registry row is gone, which beforeTurn turns into a polite refusal.
private samContext: SamContext | null = null;
// Per-turn billing state: beforeTurn arms it (non-null = hosted mode, meter
// this turn), onStepFinish accumulates the OpenRouter cost, onChatResponse
// meters the spend.
private turnCostUsd = 0;
private turnMonthlyRemaining: number | null = null;
// Record the app origin for the deep links tools attach to responses,
// derived from the requests this DO serves instead of env config. DO storage
// (not an instance field) because the DO hibernates: a turn can arrive as a
// WS message on a wake-up where fetch() never ran. Reads are served from
// workerd's in-process cache and unchanged puts are deduped, so this costs
// nothing per turn.
async fetch(request: Request): Promise<Response> {
await this.ctx.storage.put(PUBLIC_ORIGIN_KEY, getPublicOrigin(request));
return super.fetch(request);
}
getModel() {
const apiKey = getEnvValueSync(this.env, "OPENROUTER_API_KEY");
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is required for the SAM agent");
}
return buildChatAgentModel(
apiKey,
getEnvValueSync(this.env, "OPENROUTER_MODEL"),
);
}
configureSession(session: Session): Session {
return session
.withContext("soul", {
provider: { get: () => this.buildSoulPrompt() },
})
.withContext(MEMORY_BLOCK, {
description:
"Durable facts about this project: business, positioning, goals, target market, competitors, settled strategy decisions. Rewrite to fold in anything that should survive this chat.",
maxTokens: 2000,
provider: this.projectBlockProvider(MEMORY_BLOCK),
})
.withContext(RESEARCH_LOG_BLOCK, {
description:
'Dated one-line log of completed research, newest first: "YYYY-MM-DD — <what>: <inputs>. Verdict: <conclusion>". Append when you finish a research arc.',
maxTokens: 2000,
provider: this.projectBlockProvider(RESEARCH_LOG_BLOCK),
});
}
private async loadSamContext(): Promise<SamContext | null> {
if (this.samContext) return this.samContext;
const row = await SamSessionRepository.getSessionById(this.name);
if (!row) return null;
const project = await ProjectRepository.getProjectById(row.projectId);
if (!project) return null;
const [creator] = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, row.userId))
.limit(1);
if (!creator) return null;
this.samContext = { row, project, userEmail: creator.email };
return this.samContext;
}
// The read-only identity block. Runs through the context-block pipeline like
// the writable blocks, so it re-renders (fresh project row, intake mode
// on/off) whenever the prompt is refreshed.
private buildSoulPrompt(): Promise<string> {
return withPgClient(async () => {
const ctx = await this.loadSamContext();
if (!ctx) {
return "You are SAM, the SEO agent inside OpenSEO. This chat session no longer exists; tell the user to start a new chat.";
}
const memory = await SamProjectMemoryRepository.getBlock(
ctx.project.id,
MEMORY_BLOCK,
);
return buildSamSystemPrompt(
{
projectId: ctx.project.id,
projectName: ctx.project.name,
domain: ctx.project.domain,
locationCode: ctx.project.locationCode,
languageCode: ctx.project.languageCode,
},
{ memoryIsEmpty: !memory?.trim() },
);
});
}
// Bridge a context block to its sam_project_memory row. Each get/set scopes
// its own Postgres client: providers are invoked from Think's internals, so
// no ambient withPgClient scope can be assumed (no-op in D1 mode).
private projectBlockProvider(label: string) {
return {
get: (): Promise<string | null> =>
withPgClient(async () => {
const ctx = await this.loadSamContext();
if (!ctx) return null;
return SamProjectMemoryRepository.getBlock(ctx.project.id, label);
}),
set: (content: string): Promise<void> =>
withPgClient(async () => {
const ctx = await this.loadSamContext();
if (!ctx) return;
await SamProjectMemoryRepository.setBlock(
ctx.project.id,
label,
content,
);
}),
};
}
// Gates reshape the turn: no tools, a tiny budget, a system prompt that
// pins the exact reply, and no history — so the (unmetered) LLM call a
// refusal still makes costs a constant few hundred tokens even when users
// script them. Think's no-model path (deliverNotice + cancelAllChats)
// would make refusals free but hasn't been validated against the chat UI's
// rendering of an aborted turn; swap it in only after checking that.
private refusalTurn(text: string): TurnConfig {
return {
system: `Reply with exactly the following message and nothing else: ${text}`,
messages: [{ role: "user", content: "Acknowledge." }],
activeTools: [],
maxSteps: 1,
maxOutputTokens: 200,
maxRetries: 0,
};
}
async beforeTurn(_ctx: TurnContext): Promise<TurnConfig> {
this.turnCostUsd = 0;
this.turnMonthlyRemaining = null;
return withPgClient(async (): Promise<TurnConfig> => {
const ctx = await this.loadSamContext();
if (!ctx) {
return this.refusalTurn(
"I couldn't find this chat session. Please start a new one.",
);
}
// Gate every turn on credits in hosted mode: SAM is open to every plan
// (including free), and LLM tokens plus DataForSEO tool calls all draw
// down the org's credit balance. Self-hosted brings its own provider
// keys and has no Autumn balance, so it's ungated.
const { organizationId } = ctx.project;
if (await isHostedServerAuthMode()) {
const { monthlyRemaining, topupRemaining } =
await getUsageCreditsRemaining(organizationId);
if (monthlyRemaining + topupRemaining <= 0) {
return this.refusalTurn(
"You're out of credits. Top up to keep using SAM.",
);
}
this.turnMonthlyRemaining = monthlyRemaining;
}
const baseUrl =
(await this.ctx.storage.get<string>(PUBLIC_ORIGIN_KEY)) ??
"https://app.openseo.so";
const authContext = buildFirstPartyMcpAuthContext({
userId: ctx.row.userId,
userEmail: ctx.userEmail,
organizationId,
baseUrl,
scopes: [MCP_SCOPE],
});
return {
tools: buildSamMcpTools(authContext, {
id: ctx.project.id,
domain: ctx.project.domain,
}),
// SAM is meant to run complex multi-step work in one turn (site-read
// intake plus a full research chain, multi-competitor sweeps), so give
// it generous headroom — cost is bounded by per-step metering and the
// model stopping on its own, not by this cap.
maxSteps: 48,
maxOutputTokens: 6000,
};
});
}
onStepFinish(ctx: StepContext): void {
this.turnCostUsd += openRouterCostUsd(ctx.providerMetadata);
}
async onChatResponse(result: ChatResponseResult): Promise<void> {
await withPgClient(async () => {
const ctx = await this.loadSamContext();
if (!ctx) return;
if (this.turnMonthlyRemaining !== null) {
await trackUsageCreditSpend({
customer: {
userId: ctx.row.userId,
userEmail: ctx.userEmail,
organizationId: ctx.project.organizationId,
projectId: ctx.project.id,
},
customerId: ctx.project.organizationId,
creditFeature: "agent",
costUsd: this.turnCostUsd,
monthlyRemaining: this.turnMonthlyRemaining,
properties: { provider: "openrouter" },
});
}
// Name the session from its first message so the side-panel is readable.
if (ctx.row.title === "New chat") {
const title = deriveTitle(firstUserText(this.messages));
if (title !== "New chat") {
await SamSessionRepository.setTitle(ctx.row.id, title);
ctx.row.title = title;
}
} else {
await SamSessionRepository.touch(ctx.row.id);
}
});
// Re-pull the shared blocks so memory written by ANOTHER session's DO
// lands here by the next turn (this DO's own set_context writes are
// already live). One withPgClient scope covers all three providers (their
// own defensive scopes reuse it). Best-effort — never fail the response.
if (result.status === "completed") {
await withPgClient(() => this.session.refreshSystemPrompt()).catch(
(error: unknown) => {
console.error("[sam] context refresh failed", error);
},
);
}
}
onChatError(error: unknown): void {
console.error("[sam] chat turn error", error);
}
// POST .../rewind {messageId}: delete that message and everything after it on
// the active branch. Backs the client's undo (rewind past a user message) and
// edit (rewind, then resend the edited text). Authorized in the Worker like
// every other HTTP request to this DO. Think's own onRequest wrapper handles
// /get-messages before delegating here.
async onRequest(request: Request): Promise<Response> {
if (
request.method === "POST" &&
new URL(request.url).pathname.endsWith("/rewind")
) {
const body = z
.object({ messageId: z.string().min(1) })
.safeParse(await request.json().catch(() => null));
if (!body.success) {
return Response.json({ error: "messageId required" }, { status: 400 });
}
const { messageId } = body.data;
// A rewind can race an in-flight turn (the user undoes while the agent
// is still working, e.g. after the stream stalled client-side). Abort
// the turn and wait for it to settle BEFORE deleting, or its still-
// running loop keeps streaming chunks and persists a fresh assistant
// message right after the delete — an orphaned reply to nothing.
this.cancelAllChats();
await this.waitUntilStable({ timeout: 5000 });
const index = this.messages.findIndex(
(message) => message.id === messageId,
);
if (index === -1) {
return Response.json({ error: "message not found" }, { status: 404 });
}
const ids = this.messages.slice(index).map((message) => message.id);
await this.session.deleteMessages(ids);
// Drop the stored how-the-last-turn-ended record too. It exists so a
// reconnecting client can learn the last turn errored — but that turn
// was just undone, and leaving it makes every future connection replay
// a "Something went wrong" for a message that no longer exists.
await clearChatTerminal(this.ctx.storage);
return Response.json({ ok: true });
}
return super.onRequest(request);
}
}

View File

@ -0,0 +1,44 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { samProjectMemory } from "@/db/schema";
// Backing store for SAM's writable context blocks ("memory", "research_log").
// One row per (project, label); every chat session DO in a project reads and
// writes the same rows, which is what makes the memory project-scoped instead
// of per-conversation.
async function getBlock(
projectId: string,
label: string,
): Promise<string | null> {
const [row] = await db
.select({ content: samProjectMemory.content })
.from(samProjectMemory)
.where(
and(
eq(samProjectMemory.projectId, projectId),
eq(samProjectMemory.label, label),
),
)
.limit(1);
return row?.content ?? null;
}
async function setBlock(
projectId: string,
label: string,
content: string,
): Promise<void> {
await db
.insert(samProjectMemory)
.values({ projectId, label, content })
.onConflictDoUpdate({
target: [samProjectMemory.projectId, samProjectMemory.label],
set: { content, updatedAt: new Date().toISOString() },
});
}
export const SamProjectMemoryRepository = {
getBlock,
setBlock,
} as const;

View File

@ -0,0 +1,98 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import { samSessions } from "@/db/schema";
type CreateSamSessionInput = {
projectId: string;
userId: string;
};
async function createSession(input: CreateSamSessionInput) {
const id = crypto.randomUUID();
const [row] = await db
.insert(samSessions)
.values({
id,
projectId: input.projectId,
userId: input.userId,
})
.returning();
return row;
}
// Callers must have already authorized the project (requireProjectContext).
async function listSessionsForProject(projectId: string) {
return db
.select({
id: samSessions.id,
title: samSessions.title,
createdAt: samSessions.createdAt,
updatedAt: samSessions.updatedAt,
})
.from(samSessions)
.where(
and(eq(samSessions.projectId, projectId), isNull(samSessions.archivedAt)),
)
.orderBy(desc(samSessions.updatedAt), desc(samSessions.id));
}
// Look up a session by id alone (no scoping). Only for the SamChatAgent
// Durable Object, whose connections are authorized in the Worker before they
// reach the DO; the DO derives its project/user (and, via the project, its
// org) from this row.
async function getSessionById(id: string) {
const [row] = await db
.select()
.from(samSessions)
.where(eq(samSessions.id, id))
.limit(1);
return row ?? null;
}
// Excludes archived sessions so callers treat them like deleted ones
// (connection refused / not archivable) even though the row and DO transcript
// are kept. Does NOT authorize: callers must check the caller's access to
// row.projectId via the canonical project-access path
// (ProjectRepository.getProjectForOrganization) before acting on the session.
async function getActiveSession(id: string) {
const [row] = await db
.select()
.from(samSessions)
.where(and(eq(samSessions.id, id), isNull(samSessions.archivedAt)))
.limit(1);
return row ?? null;
}
// Set the title from the first user message and bump updatedAt so the session
// sorts to the top of the side-panel. Called by the DO on the first turn.
async function setTitle(id: string, title: string) {
await db
.update(samSessions)
.set({ title, updatedAt: new Date().toISOString() })
.where(eq(samSessions.id, id));
}
async function touch(id: string) {
await db
.update(samSessions)
.set({ updatedAt: new Date().toISOString() })
.where(eq(samSessions.id, id));
}
// Callers must have already authorized the session's project.
async function archiveSession(id: string) {
await db
.update(samSessions)
.set({ archivedAt: new Date().toISOString() })
.where(eq(samSessions.id, id));
}
export const SamSessionRepository = {
createSession,
listSessionsForProject,
getSessionById,
getActiveSession,
setTitle,
touch,
archiveSession,
} as const;

View File

@ -0,0 +1,268 @@
import { tool, type Tool, type ToolSet } from "ai";
import { z, type ZodRawShape } from "zod";
import { withPgClient } from "@/db";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
createWorkersOAuthMcpProps,
type McpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile";
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results";
import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
import {
findSerpCompetitorsTool,
getGoogleBusinessQuestionsTool,
getKeywordMetricsTool,
getLocalSerpResultsTool,
getRankedKeywordsTool,
searchLocalBusinessesTool,
} from "@/server/mcp/tools/dataforseo-research-tools";
import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords";
import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords";
import {
getSearchConsolePerformanceTool,
inspectUrlsTool,
} from "@/server/mcp/tools/search-console-tools";
import { whoamiTool } from "@/server/mcp/tools/whoami";
import { discoverSiteUrls, readPages, readSite } from "@/server/lib/scrape";
import openSeoFactSheet from "@/server/features/onboarding/openseo-fact-sheet.md?raw";
// SAM reads more of a site than the onboarding preview: enough pages to work
// out what a business does, sells, and positions against on its own.
const SAM_MAX_SCRAPE_PAGES = 10;
const SAM_MAX_MAPPED_URLS = 60;
// Shape of the MCP tool objects exported from src/server/mcp/tools/*. SAM reuses
// the exact same definitions the MCP server registers, so the in-app agent and
// the MCP server can never drift in what a tool does or how it bills.
type McpToolDefinition<Shape extends ZodRawShape> = {
config: { description: string; inputSchema: Shape };
handler: (
args: z.infer<z.ZodObject<Shape>>,
extra: ToolExtra,
) => Promise<CallToolResult>;
};
// Flatten an MCP CallToolResult into a plain value for the model: the handler's
// human-readable text summary plus the structured data it returned.
function toModelOutput(result: CallToolResult): unknown {
const summary = (result.content ?? [])
.filter(
(part): part is { type: "text"; text: string } => part.type === "text",
)
.map((part) => part.text)
.join("\n");
return result.structuredContent
? { summary, data: result.structuredContent }
: { summary };
}
// Adapt one MCP tool into an AI SDK tool. The MCP handler reads auth from `extra`
// (via requireMcpToolAuthContext) and self-gates project access against the org,
// so SAM gets identical scoping and metering for free.
//
// SAM always runs inside one project (the session row), so we bind that project
// server-side: any tool with a `projectId` input has it stripped from the schema
// the model sees and injected at call time. The model never has to know or pass
// the id, can't target another project, and can't hallucinate a wrong one.
function adaptMcpTool<Shape extends ZodRawShape>(
def: McpToolDefinition<Shape>,
extra: ToolExtra,
projectId: string,
): Tool {
const { projectId: _projectIdSchema, ...modelShape } = def.config.inputSchema;
const bindsProject = "projectId" in def.config.inputSchema;
return tool({
description: def.config.description,
inputSchema: z.object(bindsProject ? modelShape : def.config.inputSchema),
execute: async (args) => {
// Reconstruct the handler's validated arg shape by injecting the session
// projectId that we stripped from the model-facing schema above.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- projectId re-added to rebuild the tool's Shape; the handler re-validates project access
const fullArgs = (bindsProject
? { ...args, projectId }
: args) as unknown as z.infer<z.ZodObject<Shape>>;
try {
// Tool calls run inside Think's inference loop, outside any ambient
// request scope, so each execution scopes its own Postgres client
// (no-op in D1 mode) — same rule as the DO's other DB-touching seams.
return toModelOutput(
await withPgClient(() => def.handler(fullArgs, extra)),
);
} catch (error) {
// Surface the failure to the model so it can recover or report it,
// rather than aborting the whole turn on one bad tool call.
return {
error: error instanceof Error ? error.message : String(error),
};
}
},
});
}
// Free (credit-less) site-reading tools, mirroring the onboarding agent's
// read_website but split into discovery + reading so the model can pick which
// pages to read instead of blindly taking the first N sitemap entries.
function scrapeTools(projectDomain: string | null): ToolSet {
return {
map_links: tool({
description:
"List a site's page URLs (homepage plus its sitemap) so you can choose which pages to read with read_pages. Defaults to the project's own site; pass `domain` to map another site (e.g. a competitor). Uses no credits.",
inputSchema: z.object({
domain: z
.string()
.optional()
.describe("Domain or URL to map. Omit for the project's own site."),
}),
execute: async ({ domain }) => {
const target = domain ?? projectDomain;
if (!target) {
return {
error:
"This project has no website set — ask the user for their site first.",
};
}
const result = await discoverSiteUrls(target, SAM_MAX_MAPPED_URLS);
return result.blocked
? { blocked: true, urls: [], note: "Could not reach the site." }
: { blocked: false, urls: result.urls };
},
}),
read_pages: tool({
description: `Read up to ${SAM_MAX_SCRAPE_PAGES} web pages as plain text — the project's own pages or anyone else's (competitors, references). Pass specific \`urls\` (usually picked from map_links); omit to read a representative sample of the project's own site. Uses no credits.`,
inputSchema: z.object({
urls: z
.array(z.string().url())
.max(SAM_MAX_SCRAPE_PAGES)
.optional()
.describe(
`Specific page URLs to read (max ${SAM_MAX_SCRAPE_PAGES}). Omit to read the project's own site.`,
),
}),
execute: async ({ urls }) => {
const site =
urls && urls.length > 0
? await readPages(urls, SAM_MAX_SCRAPE_PAGES)
: projectDomain
? await readSite(projectDomain, SAM_MAX_SCRAPE_PAGES)
: null;
if (!site) {
return {
error:
"This project has no website set — ask the user for their site, or pass explicit urls.",
};
}
if (site.blocked) {
return {
blocked: true,
pages: [],
note: "Could not read the requested page(s). Ask the user to describe the site instead, and say you couldn't read it.",
};
}
return { blocked: false, pages: site.pages };
},
}),
};
}
/**
* Builds SAM's tool surface as an AI SDK ToolSet: the full MCP toolset plus the
* free site-reading tools. Every tool the OpenSEO MCP server exposes is
* available; auth/billing context is carried on a synthetic `ToolExtra` the
* handlers read exactly as they would on the real MCP route. DataForSEO spend
* is metered inside the shared client, so tool calls draw down the org's
* credits automatically.
*/
export function buildSamMcpTools(
authContext: McpToolAuthContext,
project: { id: string; domain: string | null },
): ToolSet {
const projectId = project.id;
const extra: ToolExtra = {
// Placeholder to satisfy ToolExtra — no tool handler or the DataForSEO
// client reads this signal (true on the real MCP route too), so aborting a
// turn does not cancel in-flight tool requests.
signal: new AbortController().signal,
requestId: 0,
authInfo: {
token: "sam-session",
clientId: authContext.clientId ?? "sam",
scopes: authContext.scopes,
extra: createWorkersOAuthMcpProps(authContext),
},
sendNotification: () => Promise.resolve(),
sendRequest: () =>
Promise.reject(new Error("sendRequest is unsupported in the SAM agent")),
};
// Note: no `list_projects`. SAM is bound to the session's project, so
// discovering other projects isn't part of its job — every project-scoped tool
// below has `projectId` injected server-side by adaptMcpTool.
return {
// On-demand product reference (kept out of the system prompt: inlining it
// made the agent narrate hosted/self-hosted framing at signed-in users).
get_product_info: tool({
description:
"The OpenSEO fact sheet: what the product does, plans/pricing, credit costs, integrations, MCP setup. Call before answering questions about OpenSEO itself. Uses no credits.",
inputSchema: z.object({}),
execute: () => Promise.resolve({ factSheet: openSeoFactSheet }),
}),
...scrapeTools(project.domain),
whoami: adaptMcpTool(whoamiTool, extra, projectId),
list_saved_keywords: adaptMcpTool(listSavedKeywordsTool, extra, projectId),
research_keywords: adaptMcpTool(researchKeywordsTool, extra, projectId),
save_keywords: adaptMcpTool(saveKeywordsTool, extra, projectId),
get_domain_overview: adaptMcpTool(getDomainOverviewTool, extra, projectId),
get_domain_keyword_suggestions: adaptMcpTool(
getDomainKeywordSuggestionsTool,
extra,
projectId,
),
get_backlinks_overview: adaptMcpTool(
getBacklinksOverviewTool,
extra,
projectId,
),
get_backlinks_profile: adaptMcpTool(
getBacklinksProfileTool,
extra,
projectId,
),
get_serp_results: adaptMcpTool(getSerpResultsTool, extra, projectId),
get_rank_tracker: adaptMcpTool(getRankTrackerTool, extra, projectId),
get_ranked_keywords: adaptMcpTool(getRankedKeywordsTool, extra, projectId),
find_serp_competitors: adaptMcpTool(
findSerpCompetitorsTool,
extra,
projectId,
),
search_local_businesses: adaptMcpTool(
searchLocalBusinessesTool,
extra,
projectId,
),
get_local_serp_results: adaptMcpTool(
getLocalSerpResultsTool,
extra,
projectId,
),
get_google_business_questions: adaptMcpTool(
getGoogleBusinessQuestionsTool,
extra,
projectId,
),
get_keyword_metrics: adaptMcpTool(getKeywordMetricsTool, extra, projectId),
get_search_console_performance: adaptMcpTool(
getSearchConsolePerformanceTool,
extra,
projectId,
),
inspect_urls: adaptMcpTool(inspectUrlsTool, extra, projectId),
};
}

View File

@ -0,0 +1,60 @@
import { LOCATIONS } from "@/shared/keyword-locations";
type SamProjectContext = {
projectId: string;
projectName: string;
domain: string | null;
locationCode: number;
languageCode: string;
};
/**
* SAM's "soul" the read-only identity block of the system prompt. The
* writable parts of the prompt (project memory, research log) are separate
* context blocks the model updates via `set_context`; this block carries the
* identity, tool rules, and the memory/research-log discipline. Kept
* deliberately close to the onboarding agent's voice, minus the pre-paywall
* framing.
*/
export function buildSamSystemPrompt(
project: SamProjectContext,
options: { memoryIsEmpty: boolean },
): string {
const market = LOCATIONS[project.locationCode] ?? "the project's market";
const sections = [
"You are SAM, the SEO agent inside OpenSEO. You help the user research keywords, analyze domains and competitors, inspect SERPs, review backlinks, read rank tracking and Google Search Console data, and turn it all into clear next steps.",
"Write in plain prose and Markdown. Lead with a one-sentence direct answer, then short paragraphs or bullets. Use Markdown tables for keyword or competitor data. Do not use decorative emoji or symbol markers.",
"Talk like a sharp teammate in chat, not a consultant writing a briefing. Keep replies short. When you need something from the user, ask in one line — never preface it with why you need it or a numbered menu of what you'll do once you have it; they'll see what you do when you do it. Explain your process or reasoning only when the user asks.",
"You have tools that pull real search data. Never state a metric, search volume, keyword difficulty, ranking, traffic estimate, or competitor figure you did not get from a tool. If a tool returns no data, say so plainly instead of guessing.",
"These tools are the same ones OpenSEO exposes over its MCP server. They already operate on the active project below — you don't pass or choose a project, so just call them directly for the current project.",
[
"Several tools (keyword research, domain overview, SERP results, backlinks, local SERP, ranked keywords) call paid data providers and cost the user credits. Be deliberate: gather what you need to answer well, but don't fan out redundant calls. When a request would require a large batch of paid lookups, briefly confirm with the user first.",
"Before running paid research, check the research_log block. If the same question was answered within the last 30 days, present that conclusion and ask before spending credits again; if the entry is older, say the data may be stale and offer a refresh. When the user asks what to do next, treat the log as covered ground and propose work that is NOT in it.",
].join(" "),
[
"You have two writable context blocks, updated with the set_context tool.",
'The "memory" block holds durable facts about this project: what the business does, positioning, goals, target market, key competitors, and settled strategy decisions. When you learn something that should survive this chat, rewrite the block to include it — keep it curated (organized sections, no transcripts, no raw tool output).',
'The "research_log" block is a dated list of completed research, one line per research arc, newest first, in the form "YYYY-MM-DD — <what was researched>: <inputs>. Verdict: <one-line conclusion>". Append an entry when you finish answering a research question. Log conclusions and pointers (e.g. saved keyword tags), never raw data. When the log grows long, promote durable findings into the memory block and drop entries older than ~90 days.',
].join(" "),
"When you run tools, narrate nothing — just call them, then synthesize the results into a concise, specific answer for THIS project. Prefer doing the work over describing what you could do.",
"You are talking to a signed-in user inside the OpenSEO app. Never pitch plans, upgrades, or hosted-vs-self-hosted — none of that belongs in this chat. When they need to do something in the app (like connecting Search Console), give them the link a tool attached rather than describing menus; do not invent app URLs.",
"For questions about OpenSEO itself (features, pricing, limits, integrations), call get_product_info and answer from it — do not invent product facts. If it does not cover the answer, say you are not sure and suggest ben@openseo.so.",
`Active project: "${project.projectName}" (projectId: ${project.projectId}).`,
project.domain
? `Project website: ${project.domain}. Default market: ${market} (location ${project.locationCode}, language ${project.languageCode}).`
: `This project has no website set yet. Default market: ${market} (location ${project.locationCode}, language ${project.languageCode}). Ask the user for a domain when a request needs one.`,
];
if (options.memoryIsEmpty) {
sections.push(
[
"The memory block is empty, so this is a fresh project for you. Get oriented by reading the site yourself rather than interviewing the user — the ONLY thing to ask for is their website, in one short line (e.g. \"What's the site? I'll take a look and go from there.\"). If the project already has a domain set (above), don't ask anything: go straight to reading it.",
`Use map_links to see the site's pages, pick up to 10 representative ones (homepage, product/service/pricing pages, about, a blog post or two), and read them with read_pages. From that, work out what the business does and sells, who it's for, how it positions itself, and who its likely competitors are.`,
"Then play it back as a short list of assumptions and ask the user to confirm or correct them — include your best guess at their primary SEO goal (e.g. an ecommerce site probably wants sales), since that can't be scraped. Save what you inferred to the memory block right away, marking unconfirmed items as (inferred), and clean the markers up as the user confirms or corrects.",
"If their first message is a research question rather than a hello, do the site read first (it's fast and free), answer the question grounded in what you learned, and fold the assumption check into your answer instead of blocking on it.",
].join(" "),
);
}
return sections.join("\n\n");
}

View File

@ -0,0 +1,29 @@
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
import { z } from "zod";
// OpenRouter (with usage accounting on) reports the real USD cost of each
// response under providerMetadata.openrouter.usage.cost. Shared by the chat
// agents (onboarding + SAM) that meter LLM spend against the credit pool.
const openRouterUsageSchema = z.object({
openrouter: z.object({ usage: z.object({ cost: z.number() }) }),
});
export function openRouterCostUsd(providerMetadata: unknown): number {
const parsed = openRouterUsageSchema.safeParse(providerMetadata);
return parsed.success ? parsed.data.openrouter.usage.cost : 0;
}
// A non-LLM assistant turn streamed back over the chat protocol. Used to surface
// gates ("Subscribe to continue") without spending an LLM call — the client
// renders it as a normal assistant message.
export function staticAssistantResponse(text: string): Response {
const stream = createUIMessageStream({
execute: ({ writer }) => {
const id = crypto.randomUUID();
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: text });
writer.write({ type: "text-end", id });
},
});
return createUIMessageStreamResponse({ stream });
}

View File

@ -7,35 +7,54 @@ import {
getRequiredEnvValue,
} from "@/server/lib/runtime-env";
// OpenRouter model slug used for the onboarding chat. Override
// with OPENROUTER_MODEL to swap models without a code change.
const DEFAULT_ONBOARDING_MODEL = "minimax/minimax-m3";
// OpenRouter model slug used for the in-app chat agents (onboarding + SAM).
// Override with OPENROUTER_MODEL to swap models without a code change.
const DEFAULT_CHAT_AGENT_MODEL = "minimax/minimax-m3";
/**
* Returns the AI SDK LanguageModel for onboarding. `usage: { include: true }`
* Returns the AI SDK LanguageModel for the chat agents. `usage: { include: true }`
* turns on OpenRouter usage accounting so each response carries its real USD
* cost (providerMetadata.openrouter.usage.cost) which we meter against the
* shared usage-credit pool. `provider.order` pins routing to Together first,
* falling back to Atlas Cloud (fp8); `allow_fallbacks: false` keeps routing to
* exactly those two so we get consistent behavior/pricing for the model.
* shared usage-credit pool. `provider.order` prefers Together, then Atlas
* Cloud (fp8); `zdr: true` restricts routing to Zero-Data-Retention endpoints
* (prompts are never retained), which is the actual constraint it excludes
* MiniMax first-party without a hand-maintained allowlist. The account also
* enforces this ("Non-frontier requires ZDR" data policy); the request-level
* flag is belt-and-braces so the constraint survives a dashboard change.
* Fallbacks stay on within the ZDR set because pinning providers caused a
* prod outage (Jul 2026: Together upstream-rate-limited m3 and every chat
* turn 429'd); as of Jul 2026 the ZDR set for m3 is Together/AtlasCloud/
* Novita/Parasail at the same price plus Morph at 2x output as a last resort.
*
* `reasoning` turns on OpenRouter's reasoning-token channel so the model's
* chain-of-thought comes back as a separate reasoning stream instead of
* leaking into the visible answer text (MiniMax M3 otherwise dumps its
* `<think>` trace inline). `effort: "low"` keeps the trace and its billable
* tokens short for the onboarding preview while still giving the UI a
* "thinking" stream to show.
* `<think>` trace inline). `effort: "medium"` is OpenRouter's default
* stated explicitly only because the SDK type requires one once the channel
* is configured.
*/
export async function getOnboardingModel(): Promise<LanguageModelV3> {
export async function getChatAgentModel(): Promise<LanguageModelV3> {
const apiKey = await getRequiredEnvValue("OPENROUTER_API_KEY");
const modelId =
(await getOptionalEnvValue("OPENROUTER_MODEL")) ?? DEFAULT_ONBOARDING_MODEL;
return createOpenRouter({ apiKey })(modelId, {
const modelId = await getOptionalEnvValue("OPENROUTER_MODEL");
return buildChatAgentModel(apiKey, modelId);
}
/**
* Synchronous variant for callers that already hold the env values. Think's
* `getModel()` hook is sync and runs on every turn, so the SAM agent reads the
* key/model from its DO env and builds the model here.
*/
export function buildChatAgentModel(
apiKey: string,
modelId?: string,
): LanguageModelV3 {
return createOpenRouter({ apiKey })(modelId ?? DEFAULT_CHAT_AGENT_MODEL, {
usage: { include: true },
reasoning: { effort: "low" },
reasoning: { effort: "medium" },
provider: {
order: ["together", "atlas-cloud/fp8"],
allow_fallbacks: false,
zdr: true,
allow_fallbacks: true,
},
});
}

View File

@ -5,15 +5,28 @@ let workersEnvPromise: Promise<Record<string, unknown> | null> | null = null;
export async function getOptionalEnvValue(
name: string,
): Promise<string | undefined> {
return getEnvValueSync((await getWorkersEnv()) ?? {}, name);
}
/**
* Sync variant for callers that already hold an env record (e.g. a Durable
* Object's `this.env`, needed because Think's `getModel()` hook is sync).
* Same policy as the async form: process.env first (where local `.env.local`
* secrets land in dev), skipping empty strings, then the given env.
*/
export function getEnvValueSync(
// `object` so interface-typed envs (e.g. Cloudflare.Env) are accepted
// without a cast.
env: object,
name: string,
): string | undefined {
const processValue =
typeof process !== "undefined" ? process.env?.[name] : undefined;
if (processValue) {
return processValue;
}
const workersEnv = await getWorkersEnv();
const workerValue = workersEnv?.[name];
return typeof workerValue === "string" ? workerValue : undefined;
const value: unknown = Reflect.get(env, name);
return typeof value === "string" ? value : undefined;
}
export async function getRequiredEnvValue(name: string): Promise<string> {

View File

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readPages, readSite } from "@/server/features/onboarding/scrape";
import { readPages, readSite } from "@/server/lib/scrape";
describe("readSite SSRF guard", () => {
beforeEach(() => {

View File

@ -1,8 +1,9 @@
// Lightweight, dependency-free site reading for onboarding: discover a few URLs
// from the sitemap (falling back to the homepage) and extract readable text from
// each page via plain fetch. This is enough to let the model infer what a site
// does. JS-heavy sites degrade gracefully (less text); a Browser Rendering
// upgrade can slot in behind this same interface later.
// Lightweight, dependency-free site reading shared by the chat agents
// (onboarding + SAM): discover URLs from the sitemap (falling back to the
// homepage) and extract readable text from each page via plain fetch. This is
// enough to let the model infer what a site does. JS-heavy sites degrade
// gracefully (less text); a Browser Rendering upgrade can slot in behind this
// same interface later.
import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
@ -153,14 +154,17 @@ async function scrapePage(url: string): Promise<ScrapedPage | null> {
}
/**
* Reads a specific list of page URLs as plain text used when the user names
* exact pages (their own or a competitor's) rather than asking us to discover a
* site. Each URL is independently run through the SSRF guard, so a blocked or
* unreachable URL is skipped rather than failing the batch.
* Reads a specific list of page URLs as plain text used when the caller names
* exact pages (the user's own or a competitor's) rather than asking us to
* discover a site. Each URL is independently run through the SSRF guard, so a
* blocked or unreachable URL is skipped rather than failing the batch.
*/
export async function readPages(urls: string[]): Promise<SiteReadResult> {
export async function readPages(
urls: string[],
maxPages: number = MAX_PAGES,
): Promise<SiteReadResult> {
const pages: ScrapedPage[] = [];
for (const rawUrl of urls.slice(0, MAX_PAGES)) {
for (const rawUrl of urls.slice(0, maxPages)) {
let url: string;
try {
// Re-validates host, blocks private/metadata IPs, does DoH DNS resolution.
@ -178,21 +182,40 @@ export async function readPages(urls: string[]): Promise<SiteReadResult> {
}
/**
* Discovers a site's representative URLs (homepage + sitemap) and reads them.
* Just URL discovery on top of readPages, which does the validated fetching.
* Lists a site's page URLs without reading them: the homepage plus what the
* sitemap declares, capped at `limit`. Lets an agent see what a site has and
* choose which pages to read (readPages) instead of blindly taking the first N.
*/
export async function readSite(domain: string): Promise<SiteReadResult> {
export async function discoverSiteUrls(
domain: string,
limit: number,
): Promise<{ urls: string[]; blocked: boolean }> {
let rootUrl: string;
try {
rootUrl = await normalizeAndValidateStartUrl(domain);
} catch {
// Blocked (private/metadata host) or unparseable domain — nothing to read.
return { pages: [], blocked: true };
// Blocked (private/metadata host) or unparseable domain — nothing to list.
return { urls: [], blocked: true };
}
const origin = new URL(rootUrl).origin;
// Prefer the sitemap for representative URLs; always include the homepage.
const sitemap = await fetchText(`${origin}/sitemap.xml`);
const discovered = sitemap ? parseSitemapUrls(sitemap, origin) : [];
return readPages([rootUrl, ...discovered.filter((url) => url !== rootUrl)]);
const urls = [rootUrl, ...discovered.filter((url) => url !== rootUrl)];
return { urls: urls.slice(0, limit), blocked: false };
}
/**
* Discovers a site's representative URLs (homepage + sitemap) and reads them.
* Just URL discovery on top of readPages, which does the validated fetching.
*/
export async function readSite(
domain: string,
maxPages: number = MAX_PAGES,
): Promise<SiteReadResult> {
const discovered = await discoverSiteUrls(domain, maxPages);
if (discovered.blocked) {
return { pages: [], blocked: true };
}
return readPages(discovered.urls, maxPages);
}

View File

@ -6,6 +6,7 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto
import { AsyncLocalStorage } from "node:async_hooks";
import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { getMcpResource } from "@/lib/oauth-resource";
import { buildDashboardUrl } from "@/server/mcp/urls";
type McpAuth = {
@ -32,7 +33,7 @@ const mcpToolAuthContextSchema = z.object({
baseUrl: z.string().url(),
});
type McpToolAuthContext = z.infer<typeof mcpToolAuthContextSchema>;
export type McpToolAuthContext = z.infer<typeof mcpToolAuthContextSchema>;
export type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
@ -42,6 +43,31 @@ export const workersOAuthMcpPropsSchema = z.object({
const mcpToolAuthContextStorage = new AsyncLocalStorage<McpToolAuthContext>();
/**
* Auth context for first-party (non-OAuth) callers the self-hosted MCP
* transport and the SAM agent. Centralizes the invariants both sites relied
* on by convention: `subject` is the user id, `clientId` is null, and the
* audience derives from the base URL.
*/
export function buildFirstPartyMcpAuthContext(input: {
userId: string;
userEmail: string;
organizationId: string;
baseUrl: string;
scopes?: string[];
}): McpToolAuthContext {
return {
userId: input.userId,
userEmail: input.userEmail,
organizationId: input.organizationId,
clientId: null,
scopes: input.scopes ?? [],
audience: getMcpResource(input.baseUrl),
subject: input.userId,
baseUrl: input.baseUrl,
};
}
export function createWorkersOAuthMcpProps(
context: McpToolAuthContext,
): Record<string, McpToolAuthContext> {

View File

@ -167,7 +167,7 @@ describe("search console MCP tools", () => {
const first = result.content[0];
expect(first.type).toBe("text");
expect(first.type === "text" && first.text).toContain(
"/p/project_1/settings",
"/p/project_1/search-performance",
);
});
@ -189,7 +189,7 @@ describe("search console MCP tools", () => {
});
const first = result.content[0];
expect(first.type === "text" && first.text).toContain(
"/p/project_1/settings",
"/p/project_1/search-performance",
);
});

View File

@ -58,7 +58,9 @@ type ProjectAuthContext = {
};
function connectGscUrl(baseUrl: string, projectId: string): string {
return buildDashboardUrl(baseUrl, `/p/${projectId}/settings#search-console`);
// GSC Insights hosts the connection card AND the data the user came for,
// so land them there rather than in settings.
return buildDashboardUrl(baseUrl, `/p/${projectId}/search-performance`);
}
/** Self-hosted GSC requires the operator to provide a Google OAuth client and

View File

@ -1,9 +1,10 @@
import { createMcpHandler } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource";
import { MCP_SCOPE } from "@/lib/oauth-resource";
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
import {
buildFirstPartyMcpAuthContext,
createWorkersOAuthMcpProps,
MCP_AUTH_CONTEXT_PROP,
MCP_ROUTE,
@ -77,16 +78,14 @@ export async function handleSelfHostedOpenSeoMcpRequest(
authMode === "local_noauth"
? await resolveLocalNoAuthContext()
: await resolveCloudflareAccessContext(request.headers);
const props = createWorkersOAuthMcpProps({
const props = createWorkersOAuthMcpProps(
buildFirstPartyMcpAuthContext({
userId: context.userId,
userEmail: context.userEmail,
organizationId: context.organizationId,
clientId: null,
scopes: [],
audience: getMcpResource(baseUrl),
subject: context.userId,
baseUrl,
});
}),
);
return handleOpenSeoMcpRequest(request, props, env, ctx);
}

View File

@ -0,0 +1,62 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import {
requireAuthenticatedContext,
requireProjectContext,
} from "@/serverFunctions/middleware";
import { AppError } from "@/server/lib/errors";
import { SamSessionRepository } from "@/server/features/sam/SamSessionRepository";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
// The ensure-user middleware authorizes `projectId` against the caller's org
// (ADR 0001); requireProjectContext exposes the verified project.
const projectScopedSchema = z.object({ projectId: z.string().min(1) });
// Lists the SAM chat sessions for a project (newest first) for the side-panel.
export const listSamSessions = createServerFn({ method: "GET" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => projectScopedSchema.parse(data))
.handler(async ({ context }) => {
return SamSessionRepository.listSessionsForProject(context.projectId);
});
// Creates a new SAM chat session and returns its id; the client then opens a DO
// connection keyed by that id.
export const createSamSession = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => projectScopedSchema.parse(data))
.handler(async ({ context }) => {
const session = await SamSessionRepository.createSession({
projectId: context.projectId,
userId: context.userId,
});
if (!session) {
throw new AppError("INTERNAL_ERROR", "Failed to create chat session");
}
return { id: session.id };
});
const archiveSchema = z.object({ sessionId: z.string().min(1) });
// Archives a SAM chat session: it disappears from the list and can no longer
// be opened, but the registry row and the DO's transcript are kept so a future
// unarchive can restore it. There is no unarchive UI yet.
export const archiveSamSession = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => archiveSchema.parse(data))
.handler(async ({ data, context }) => {
// Authorize against the session's project (the canonical project-access
// path), not the caller's org directly.
const session = await SamSessionRepository.getActiveSession(data.sessionId);
const project = session
? await ProjectRepository.getProjectForOrganization(
session.projectId,
context.organizationId,
)
: null;
if (!session || !project) {
throw new AppError("NOT_FOUND", "Chat session not found");
}
await SamSessionRepository.archiveSession(data.sessionId);
return { ok: true };
});

View File

@ -7,7 +7,8 @@ export type CreditFeature =
| "ai_citations"
| "ai_prompt_responses"
| "local_seo"
| "onboarding";
| "onboarding"
| "agent";
const CREDIT_FEATURE_LABELS: Record<string, string> = {
keyword_research: "Keyword Research",
@ -20,6 +21,7 @@ const CREDIT_FEATURE_LABELS: Record<string, string> = {
ai_search: "AI Search",
local_seo: "Local SEO",
onboarding: "Onboarding",
agent: "SAM Agent",
};
/**

View File

@ -35,6 +35,12 @@
"name": "ONBOARDING_CHAT",
"class_name": "OnboardingChatAgent",
},
// SAM in-app agent. One instance per chat session; messages persist in the
// DO's SQLite. SQLite-backed classes must be declared in `migrations`.
{
"name": "SAM_CHAT",
"class_name": "SamChatAgent",
},
],
},
"migrations": [
@ -42,6 +48,10 @@
"tag": "v1",
"new_sqlite_classes": ["OnboardingChatAgent"],
},
{
"tag": "v2",
"new_sqlite_classes": ["SamChatAgent"],
},
],
"triggers": {
"crons": ["*/15 * * * *"],