Project memory + SAM skills: shared per-project AI context (spec 0010) (#493)

This commit is contained in:
Ben Senescu 2026-08-19 09:46:07 -04:00 committed by GitHub
parent a3ca46a0c1
commit a5a953e478
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
79 changed files with 12229 additions and 390 deletions

View File

@ -18,6 +18,15 @@ Use this when the user wants a market-level view across several competitors. For
- Optional known competitors
- Optional location/language
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the market read in it — the saved competitors are the starting roster, and the business and positioning decide who counts as a competitor.
2. This skill needs competitors. If none are saved, run a minimal inline setup: ask the user who they compete with, or infer a shortlist from `find_serp_competitors` and the site and confirm it, write it back with `update_project_context` (`addCompetitors`), then continue the landscape work. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable with `update_project_context` — every confirmed competitor via `addCompetitors` with a short note on why they matter, plus `removeCompetitors` for entries you added that turned out irrelevant (leave rows the user added alone) — and append a research log entry: `{ appendResearchLog: { summary: "Competitive landscape: <market/query set>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `research_keywords`: discover representative market queries.

View File

@ -18,6 +18,15 @@ Use this for a named competitor. For identifying the market leaders first, use `
- User's domain when comparison is requested
- Optional topic/category/location/language
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the analysis in it — the saved competitors say whether this domain is already known and what was concluded about it before.
2. This skill needs competitors. If none are saved, run a minimal inline setup: save the competitor being analyzed, and ask the user (or infer from `find_serp_competitors` and confirm) whether there are others, write them back with `update_project_context` (`addCompetitors`), then continue the analysis. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable with `update_project_context` — an `addCompetitors` upsert for this domain with a short note on its strengths and where it is vulnerable — and append a research log entry: `{ appendResearchLog: { summary: "Competitor analysis: <domain>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `get_domain_overview`: baseline organic traffic and keyword count.

View File

@ -34,15 +34,17 @@ metadata:
ln -s ../../.agents/skills/<name> .claude/skills/<name>
```
- **Public product skill** (a customer-facing SEO workflow): no `internal` flag, usually no `.claude/skills` symlink (repo agents don't need customer workflows). Register it everywhere users discover skills:
- **Public product skill** (a customer-facing SEO workflow): no `internal` flag, usually no `.claude/skills` symlink (repo agents don't need customer workflows). A public skill is **auto-served to SAM, the live in-app agent** — the marking is fail-open, so a missing `internal: true` ships repo-dev instructions to end users. Give it the standard "Project context" preamble (copy a sibling like `seo-audit`) with the skill's required sections, and register it everywhere users discover skills:
- `src/server/features/sam/samSkills.test.ts` — add the name to the pinned public roster (the test fails otherwise; that failure is the guard)
- `web/content/docs/skills/<name>.mdx` — docs page (mirror a sibling like `competitor-analysis.mdx`: what it does, when to use it, what you get back, how to get the best result)
- `web/content/docs/skills/index.md` — bullet in the right workflow section
- `web/content/docs/skills/meta.json` — nav entry
- `src/routes/_app/ai.tsx``SKILL_NAMES`
- `.agents/skills/seo-coach/SKILL.md` — one line in the "What each workflow does" roster
- `plugins/openseo/skills/<name>` — add the skill to the `skills` list in `scripts/sync-plugin-skills.mjs`, then run `pnpm sync-plugin-skills` (this directory holds real copies, not symlinks — the Claude Code and Codex plugins bundle from here, and Codex's installer silently skips symlinked files, so a symlink would ship a skill-less plugin). `pnpm ci:check` re-runs the sync and fails on drift, so a missed update here is caught, but the skill count and roster below are prose and aren't checked — update them by hand: both `plugins/openseo/*/plugin.json` `description` fields, the Codex manifest's `interface.longDescription`, and the skill lists in `web/content/docs/claude-code-plugin.md` and `web/content/docs/codex-plugin.md`
- Optional: `web/src/lib/feature-pages.ts` and `web/content/docs/skills/setup.md` if it deserves marketing/setup placement
3. If the skill references MCP tools, use exact tool names and keep them in sync with `src/server/mcp/server.ts` — the tool names in skills are load-bearing for agents following them.
3. If the skill references MCP tools, use exact tool names and keep them in sync with `src/server/mcp/server.ts` — the tool names in skills are load-bearing for agents following them. For public skills also check `src/server/features/sam/samChatTools.ts`: SAM's toolset is a curated subset, and a skill step that names a tool SAM lacks dead-ends in the in-app agent.
4. `pnpm format:write` (covers the docs pages; `.agents/skills` itself is intentionally untouched), then commit. Skill prose follows `openseo-review-web-content` standards when public.

View File

@ -17,6 +17,15 @@ Group keywords into page-level clusters and decide which existing or new page sh
If keywords are not provided, use `list_saved_keywords` for saved sets, `research_keywords` for seed discovery, or `get_ranked_keywords` when the user starts from a target domain.
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the mapping in it — the saved key pages are the existing pages clusters should map to, and the business and goal decide which clusters are worth targeting.
2. This skill needs key pages. If none are saved, run a minimal inline setup: ask the user for the pages that matter, or propose a shortlist from the site, an audit, or Search Console and confirm it, write it back with `update_project_context` (`addKeyPages`), then continue the clustering. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable with `update_project_context` — new or corrected `addKeyPages` entries with the topic each page now targets — and append a research log entry: `{ appendResearchLog: { summary: "Keyword clustering: <keyword set>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `list_saved_keywords`: fetch an existing keyword set, optionally filtered by tags.

View File

@ -17,6 +17,15 @@ Turn seed topics into a prioritized keyword opportunity set using OpenSEO MCP da
If `projectId` is missing, use `list_projects` first. If the target market/location/language is unclear and would materially affect keyword metrics, ask the user; otherwise use the MCP tool defaults.
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the research in it — the business, the goal, the markets, and the competitors and key pages already saved.
2. This skill needs `business_overview` and `current_goal`. If either is empty, run a minimal inline setup: ask the user, or infer from the site and confirm, just enough to fill them, write them back with `update_project_context`, then continue the research. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable — a sharpened `business_overview` or `current_goal`, competitors that kept appearing in the SERPs via `addCompetitors`, pages the keywords should land on via `addKeyPages` — and append a research log entry: `{ appendResearchLog: { summary: "Keyword research: <seeds/market>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `research_keywords`: primary discovery tool. Use 1-5 seeds per call and prefer 150 results unless the user asks for exhaustive research.

View File

@ -17,6 +17,15 @@ Find realistic pages, sites, and authors that might reference the user's page, p
- Optional competitors
- Optional market/location/language
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the outreach in it — positioning supplies the claim that makes a link worth giving, and the saved competitors are the backlink profiles to mine.
2. This skill needs `positioning` and competitors. If either is empty, run a minimal inline setup: ask the user why someone would cite them and who they compete with, or infer from the site and `find_serp_competitors` and confirm, write it back with `update_project_context`, then continue the prospecting. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable — the linkable asset via `addKeyPages`, any competitor whose backlink profile proved useful via `addCompetitors` — and append a research log entry: `{ appendResearchLog: { summary: "Link prospecting: <asset/target page>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `get_serp_results`: find ranking articles, listicles, resource pages, comparisons, and topical publishers.

View File

@ -18,6 +18,15 @@ Use this when rankings depend on a physical location or service area. For nation
- Its coordinate (latitude/longitude) — derive it from a `search_local_businesses` / `get_local_serp_results` row; only ask the user when derivation is ambiguous
- One to three keywords customers actually search (e.g. "emergency plumber", not the brand name)
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the work in it — what the business does and where it operates decides which keywords and radius matter.
2. This skill needs `business_overview`. If it is empty, run a minimal inline setup: infer what the business does and its location from the site and confirm it with the user in one question, write it back with `update_project_context`, then continue. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable with `update_project_context` — local competitors that have a website via `addCompetitors` (competitor rows are keyed by domain, so skip listings without one), a corrected `business_overview` — and append a research log entry: `{ appendResearchLog: { summary: "Local SEO: <business> near <area>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `search_local_businesses`: nearby listings, filterable by `minRating`, `minReviews`, and `isClaimed` — use `isClaimed: false` to find unclaimed listings when prospecting. One call with the brand name as `query` and a wide radius returns category, rating, review count, claimed status, coordinates, and `cid` for every location of a chain — usually enough that per-location `get_business_profile` calls are unnecessary.

View File

@ -16,6 +16,15 @@ Use this when asked for an SEO audit or review of a domain, especially when the
- Domain to audit
- `projectId` (use `list_projects`; if no project matches the domain, create one with `create_project`)
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the report in it — what the business does decides which findings matter and what the one thing should be.
2. This skill needs `business_overview`. If it is empty, run a minimal inline setup: infer what the business does from the site and confirm it with the user in one question, write it back with `update_project_context`, then continue the audit. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable — a corrected `business_overview`, the pages the report singles out via `addKeyPages` — and append a research log entry: `{ appendResearchLog: { summary: "Site audit: <domain>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `whoami`: confirm connection and remaining credits before spending anything. If OpenSEO is not connected, stop and ask the user to connect it.

View File

@ -13,6 +13,15 @@ Act as a friendly SEO coach for users working with OpenSEO and an AI agent. Help
Be warm, direct, and beginner-friendly. Ask whether the user is new to SEO and adapt the explanation depth. Avoid sounding like a course or a consultant deck. Make SEO feel doable.
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first (resolve the project with `list_projects` if needed) and ground the coaching in it — the business, goal, positioning, competitors, and key pages tell you what the user actually needs next.
2. This skill requires no section. Read whatever is there, and let the `missingSections` list shape the recommendation: empty context usually means the next step is `seo-project-setup`. Never front-load the full interview.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable — anything the user tells you about the business, goal, or positioning, via `update_project_context` — and append a research log entry when a session spends credits: `{ appendResearchLog: { summary: "<what>: <inputs>. Verdict: <conclusion>" } }`.
## First response
When this mode starts, orient the user:
@ -38,7 +47,7 @@ Good starting points:
## What each workflow does
- `seo-project-setup`: sets up the workspace, verifies MCP, captures goals and positioning, and connects Google Search Console (or imports GSC exports).
- `seo-project-setup`: verifies MCP, interviews the user about scope, goals, positioning, competitors, and key pages, and saves it all to the project's shared context. Also connects Google Search Console (or imports GSC exports).
- `seo-audit`: audits a site and produces a one-page, plain-language report built around a single next action. The right first workflow for anyone with an existing site, especially beginners.
- `keyword-research`: finds search opportunities from seed topics and evaluates volume, difficulty, CPC, intent, and SERPs.
- `keyword-clustering`: groups keywords by intent and maps clusters to existing or proposed pages.
@ -55,9 +64,10 @@ Explain the difference between data sources:
- Google Search Console (when connected on the project's Integrations page) is the user's own first-party data — real clicks, impressions, CTR, and position. Read it live with `get_search_console_performance` instead of asking for CSV exports. It's free (no credits) and the best starting point for "what already ranks" and near-ranking opportunities.
- Web search can find current market context, recent pages, reviews, docs, social profiles, and contact paths outside OpenSEO.
- Browser/page scraping can extract page copy, headings, author names, contact links, schema, and content structure.
- Local files can preserve strategy, GSC CSVs, content briefs, crawls, prospect lists, and prior decisions over time.
- Project context (`get_project_context` / `update_project_context`) is the project's shared memory: business, goal, positioning, writing preferences, competitors, key pages, and a research log. It is free, every skill reads it, and the user can edit it on the project's Context settings page.
- Local files are for file work: GSC CSVs, crawls, drafts, briefs, and reports.
Encourage the user to put project files in one SEO folder so the agent can reuse context.
Encourage the user to keep project knowledge in project context rather than in a local file, so it follows them across sessions and agents.
## Coaching patterns

View File

@ -1,46 +1,52 @@
---
name: seo-project-setup
description: Set up a durable local SEO workspace with project context, notes, goals, positioning, preferences, MCP checks, and Search Console data intake.
description: Populate a project's shared OpenSEO context — site scope, goals, positioning, competitors, key pages, and preferences — plus MCP checks and Search Console intake.
---
# OpenSEO SEO Project Setup
## Goal
Help the user set up a local SEO workspace for one website or SEO project. The folder is where the agent saves notes, goals, exports, briefs, reports, preferences, and project context over time. This is a workspace and context setup workflow, not a full audit.
Interview the user once about one website or SEO project, and store the answers in that project's shared context in OpenSEO with `update_project_context`. That context is read by every other skill, by SAM in the app, and by the user on the project's Context settings page — so it survives new sessions, new machines, and new agents. This is a context setup workflow, not a full audit.
## Tone
Be friendly, practical, and structured. Ask questions in small batches. Explain why each item matters only when useful. Do not overwhelm a beginner with jargon.
## Where the answers go
Two project-context MCP tools do all the writing. Both are free — they spend no credits.
- `get_project_context(projectId)`: everything already known about the project, plus a `missingSections` list.
- `update_project_context(projectId, updates)`: a list of patch ops. The ones this skill uses:
- `{ section: "business_overview" | "current_goal" | "positioning" | "writing_preferences", content }`
- `{ addCompetitors: [{ domain, name?, notes? }] }`
- `{ addKeyPages: [{ url, role: "hub" | "spoke" | "money" | "other", topic?, notes? }] }`
- `{ customSection: "<slug>", title?, content }` for anything that does not fit a typed section
- `{ appendResearchLog: { summary } }` when this session spends credits
Write in batches as the interview progresses — do not hold every answer until the end. Sections are prose (~4,000 characters each), so a few tight paragraphs, not a transcript.
## Checklist
### 1. Pick a working folder
### 1. Verify OpenSEO MCP and resolve the project
Suggest that the user choose or create a local folder for SEO work, for example:
Writes need a `projectId`, so do this first:
- `~/SEO/<company-or-site>/`
- `~/Documents/SEO/<company-or-site>/`
- A repo or workspace folder if SEO work should live beside website/content files
1. Use `whoami` if available.
2. Use `list_projects` to confirm the user can access projects.
3. Match the project to the website/domain they want to rank for.
4. If the project list is ambiguous, ask the user which project should be used.
5. If no project matches, offer to create one with `create_project`.
6. If the MCP is unavailable, tell the user to connect OpenSEO MCP; without it, nothing can be saved.
Explain that keeping notes, exports, briefs, scraped pages, reports, and preferences in one folder helps the agent build context over time. Future SEO workflows can use that folder rather than starting from a blank conversation.
Do not run research tools just to test connectivity; `whoami` and `list_projects` are enough.
Recommended starter structure:
### 2. Read what is already there
```text
seo-workspace/
README.md
gsc/
keywords/
competitors/
content/
outreach/
reports/
```
Call `get_project_context`. Show the user a short summary of what OpenSEO already knows and what is missing. Confirm or correct existing entries rather than re-asking questions that are already answered — this skill is often re-run after another skill filled in part of the context.
Do not create folders unless the user asks. If file tools are available and the user asks, create a simple structure and a short `README.md` with the current goals, known sites, and user preferences for how the agent should approach SEO for this project.
### 2. Collect website scope
### 3. Collect website scope
Ask for:
@ -51,7 +57,9 @@ Ask for:
- Whether the site is new, established, migrating, or recovering from a drop
- CMS or publishing workflow, if relevant
### 3. Capture goals
Write the durable parts to `business_overview`: what the business does, who it is for, the target markets/locales, and the site's current stage.
### 4. Capture goals
Ask the user what they want from SEO:
@ -65,7 +73,9 @@ Ask the user what they want from SEO:
Ask for success metrics and timeframe. If goals are vague, help turn them into measurable goals such as "increase non-branded organic signups" or "rank top 10 for 20 buying-intent terms."
### 4. Capture positioning and strategy context
Write the result to `current_goal`, including the metric and timeframe.
### 5. Capture positioning and strategy context
Ask what research they have already done about the company, product, audience, and competitors. Request any notes, docs, customer interviews, positioning docs, pitch decks, landing pages, or strategy memos they can share.
@ -82,25 +92,35 @@ Probe for:
If the user has not done this yet, offer to help research positioning using the company website, competitor pages, reviews, forums, and web search.
### 5. Verify OpenSEO MCP
Write to `positioning`: audience, the problem, the differentiator, and any claims the user wants defended. Ask about voice, banned words or phrases, and topics to avoid, and write those to `writing_preferences` — content-drafting workflows read that section.
After the user has described the company, website, goals, and positioning, check that OpenSEO MCP is configured and mapped to the right project:
### 6. Save competitors
1. Use `whoami` if available.
2. Use `list_projects` to confirm the user can access projects.
3. Match the project to the website/domain they want to rank for.
4. If the project list is ambiguous, ask the user which project should be used.
5. If the MCP is unavailable, tell the user to connect OpenSEO MCP before continuing with live OpenSEO data.
Turn the competitors and substitutes from step 5 into `addCompetitors` entries: one row per domain, with a short `notes` line on why they matter ("direct competitor, owns the comparison pages"). If the user is unsure who competes in search, `find_serp_competitors` on a handful of seed keywords will name them — confirm the list with the user before saving, and log the spend.
Do not run research tools just to test connectivity; `whoami` and `list_projects` are enough.
Competitors saved here are reused by `competitive-landscape`, `competitor-analysis`, and `link-prospecting`.
### 6. Connect Google Search Console
### 7. Inventory key assets
Ask for or discover:
- Sitemap or important URL list
- Current blog/resources/content library
- Product/category/feature pages
- Existing keyword lists
- Current rank trackers
- Backlink or PR assets
- Linkable assets such as studies, templates, tools, datasets, calculators, or original opinions
Save the pages that actually matter with `addKeyPages` — money pages, topic hubs, and the linkable assets. This is a curated shortlist, not a site inventory: 10 to 30 URLs is normal. Give each one a `role` and, where known, the `topic` it targets.
### 8. Connect Google Search Console
GSC is the richest first-party signal: existing impressions, near-ranking terms, cannibalization, and pages that already have search demand.
**Preferred (hosted): connect it natively.** On the project's Integrations page, connect Google Search Console and pull live data with `get_search_console_performance`. Once connected, the agent reads it directly in `keyword-research` and `keyword-clustering` — no manual files to maintain.
**Fallback (self-hosted, or if the user prefers files):** ask the user to export CSVs from Search Console into the SEO working folder.
**Fallback (self-hosted, or if the user prefers files):** ask the user to export CSVs from Search Console into a local working folder (see step 9).
Recommended exports:
@ -118,19 +138,22 @@ gsc/queries-last-16-months.csv
gsc/pages-last-16-months.csv
```
### 7. Inventory existing assets
### 9. Set up a local folder only for file work
Ask for or discover:
Project knowledge lives in OpenSEO, not on disk. A local folder is still useful for the things that are actually files: GSC CSV exports, crawls, drafts, briefs, and reports.
- Sitemap or important URL list
- Current blog/resources/content library
- Product/category/feature pages
- Existing keyword lists
- Current rank trackers
- Backlink or PR assets
- Linkable assets such as studies, templates, tools, datasets, calculators, or original opinions
If the user wants one, suggest `~/SEO/<company-or-site>/` or a folder beside the website/content repo, with a structure like:
### 8. Recommend first workflow
```text
seo-workspace/
gsc/
drafts/
reports/
```
Do not create folders unless the user asks, and do not duplicate goals, positioning, or competitors into a local file — that is what the project context is for.
### 10. Recommend first workflow
After intake, recommend one next OpenSEO workflow:
@ -150,17 +173,23 @@ Use a checklist with statuses:
Then summarize:
- Working folder
- OpenSEO MCP/project status
- Sites in scope
- Goals
- Known positioning
- Uploaded data/files
- Competitors saved
- Key pages saved
- Search Console status and any local files
- Sections still missing from project context
- Recommended next workflow
Tell the user they can read and edit everything saved here on the project's Context settings page.
## Guardrails
- Keep setup lightweight. The user should feel oriented, not assigned homework.
- Confirm facts with the user before writing them. Inferences from the site are fine to propose, but they get saved as agreed answers, not guesses.
- Do not pretend a GSC CSV has been uploaded unless you can see it, and do not claim Search Console is connected unless `get_search_console_performance` confirms it (it returns a "not connected" message otherwise).
- Keep project setup focused on setup and context unless the user asks for live research.
- Keep project setup focused on setup and context unless the user asks for live research. If a step does spend credits, append a research log entry so other skills do not re-buy it.
- If web search or scraping is used for positioning research, distinguish source evidence from inference.
- Overwriting a section replaces it. When context already exists, merge the new answers into the existing prose instead of discarding it.

View File

@ -0,0 +1,49 @@
CREATE TABLE "project_competitors" (
"id" text PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"domain" text NOT NULL,
"name" text,
"notes" text,
"updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "project_context_sections" (
"project_id" text NOT NULL,
"key" text NOT NULL,
"title" text,
"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,
"updated_by" text NOT NULL,
CONSTRAINT "project_context_sections_project_id_key_pk" PRIMARY KEY("project_id","key")
);
--> statement-breakpoint
CREATE TABLE "project_key_pages" (
"id" text PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"url" text NOT NULL,
"role" text NOT NULL,
"topic" text,
"notes" text,
"updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "project_research_log" (
"id" text PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"entry_date" text NOT NULL,
"summary" text NOT NULL,
"created_by" text NOT NULL,
"created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL
);
--> statement-breakpoint
ALTER TABLE "project_competitors" ADD CONSTRAINT "project_competitors_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_context_sections" ADD CONSTRAINT "project_context_sections_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_key_pages" ADD CONSTRAINT "project_key_pages_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_research_log" ADD CONSTRAINT "project_research_log_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "project_competitors_project_domain_idx" ON "project_competitors" USING btree ("project_id","domain");--> statement-breakpoint
CREATE UNIQUE INDEX "project_key_pages_project_url_idx" ON "project_key_pages" USING btree ("project_id","url");--> statement-breakpoint
CREATE INDEX "project_research_log_project_date_idx" ON "project_research_log" USING btree ("project_id","entry_date");
--> statement-breakpoint
DROP TABLE "sam_project_memory" CASCADE;

File diff suppressed because it is too large Load Diff

View File

@ -141,6 +141,13 @@
"when": 1786209409297,
"tag": "0019_clammy_selene",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1787099999115,
"tag": "0020_project_memory",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,49 @@
CREATE TABLE `project_competitors` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`domain` text NOT NULL,
`name` text,
`notes` text,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_by` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_competitors_project_domain_idx` ON `project_competitors` (`project_id`,`domain`);--> statement-breakpoint
CREATE TABLE `project_context_sections` (
`project_id` text NOT NULL,
`key` text NOT NULL,
`title` text,
`content` text NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_by` text NOT NULL,
PRIMARY KEY(`project_id`, `key`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `project_key_pages` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`url` text NOT NULL,
`role` text NOT NULL,
`topic` text,
`notes` text,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_by` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_key_pages_project_url_idx` ON `project_key_pages` (`project_id`,`url`);--> statement-breakpoint
CREATE TABLE `project_research_log` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`entry_date` text NOT NULL,
`summary` text NOT NULL,
`created_by` text NOT NULL,
`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `project_research_log_project_date_idx` ON `project_research_log` (`project_id`,`entry_date`);
--> statement-breakpoint
DROP TABLE `sam_project_memory`;

File diff suppressed because it is too large Load Diff

View File

@ -295,6 +295,13 @@
"when": 1786209407629,
"tag": "0041_free_marvex",
"breakpoints": true
},
{
"idx": 42,
"version": "6",
"when": 1787099999115,
"tag": "0042_project_memory",
"breakpoints": true
}
]
}

View File

@ -118,6 +118,7 @@
"sonner": "^2.0.7",
"tailwindcss": "^4.1.16",
"tldts": "^7.0.25",
"yaml": "^2.9.0",
"zod": "^4.1.12"
},
"devDependencies": {

View File

@ -18,6 +18,15 @@ Use this when the user wants a market-level view across several competitors. For
- Optional known competitors
- Optional location/language
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the market read in it — the saved competitors are the starting roster, and the business and positioning decide who counts as a competitor.
2. This skill needs competitors. If none are saved, run a minimal inline setup: ask the user who they compete with, or infer a shortlist from `find_serp_competitors` and the site and confirm it, write it back with `update_project_context` (`addCompetitors`), then continue the landscape work. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable with `update_project_context` — every confirmed competitor via `addCompetitors` with a short note on why they matter, plus `removeCompetitors` for entries you added that turned out irrelevant (leave rows the user added alone) — and append a research log entry: `{ appendResearchLog: { summary: "Competitive landscape: <market/query set>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `research_keywords`: discover representative market queries.

View File

@ -18,6 +18,15 @@ Use this for a named competitor. For identifying the market leaders first, use `
- User's domain when comparison is requested
- Optional topic/category/location/language
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the analysis in it — the saved competitors say whether this domain is already known and what was concluded about it before.
2. This skill needs competitors. If none are saved, run a minimal inline setup: save the competitor being analyzed, and ask the user (or infer from `find_serp_competitors` and confirm) whether there are others, write them back with `update_project_context` (`addCompetitors`), then continue the analysis. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable with `update_project_context` — an `addCompetitors` upsert for this domain with a short note on its strengths and where it is vulnerable — and append a research log entry: `{ appendResearchLog: { summary: "Competitor analysis: <domain>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `get_domain_overview`: baseline organic traffic and keyword count.

View File

@ -17,6 +17,15 @@ Group keywords into page-level clusters and decide which existing or new page sh
If keywords are not provided, use `list_saved_keywords` for saved sets, `research_keywords` for seed discovery, or `get_ranked_keywords` when the user starts from a target domain.
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the mapping in it — the saved key pages are the existing pages clusters should map to, and the business and goal decide which clusters are worth targeting.
2. This skill needs key pages. If none are saved, run a minimal inline setup: ask the user for the pages that matter, or propose a shortlist from the site, an audit, or Search Console and confirm it, write it back with `update_project_context` (`addKeyPages`), then continue the clustering. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable with `update_project_context` — new or corrected `addKeyPages` entries with the topic each page now targets — and append a research log entry: `{ appendResearchLog: { summary: "Keyword clustering: <keyword set>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `list_saved_keywords`: fetch an existing keyword set, optionally filtered by tags.

View File

@ -17,6 +17,15 @@ Turn seed topics into a prioritized keyword opportunity set using OpenSEO MCP da
If `projectId` is missing, use `list_projects` first. If the target market/location/language is unclear and would materially affect keyword metrics, ask the user; otherwise use the MCP tool defaults.
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the research in it — the business, the goal, the markets, and the competitors and key pages already saved.
2. This skill needs `business_overview` and `current_goal`. If either is empty, run a minimal inline setup: ask the user, or infer from the site and confirm, just enough to fill them, write them back with `update_project_context`, then continue the research. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable — a sharpened `business_overview` or `current_goal`, competitors that kept appearing in the SERPs via `addCompetitors`, pages the keywords should land on via `addKeyPages` — and append a research log entry: `{ appendResearchLog: { summary: "Keyword research: <seeds/market>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `research_keywords`: primary discovery tool. Use 1-5 seeds per call and prefer 150 results unless the user asks for exhaustive research.

View File

@ -17,6 +17,15 @@ Find realistic pages, sites, and authors that might reference the user's page, p
- Optional competitors
- Optional market/location/language
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the outreach in it — positioning supplies the claim that makes a link worth giving, and the saved competitors are the backlink profiles to mine.
2. This skill needs `positioning` and competitors. If either is empty, run a minimal inline setup: ask the user why someone would cite them and who they compete with, or infer from the site and `find_serp_competitors` and confirm, write it back with `update_project_context`, then continue the prospecting. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable — the linkable asset via `addKeyPages`, any competitor whose backlink profile proved useful via `addCompetitors` — and append a research log entry: `{ appendResearchLog: { summary: "Link prospecting: <asset/target page>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `get_serp_results`: find ranking articles, listicles, resource pages, comparisons, and topical publishers.

View File

@ -18,6 +18,15 @@ Use this when rankings depend on a physical location or service area. For nation
- Its coordinate (latitude/longitude) — derive it from a `search_local_businesses` / `get_local_serp_results` row; only ask the user when derivation is ambiguous
- One to three keywords customers actually search (e.g. "emergency plumber", not the brand name)
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the work in it — what the business does and where it operates decides which keywords and radius matter.
2. This skill needs `business_overview`. If it is empty, run a minimal inline setup: infer what the business does and its location from the site and confirm it with the user in one question, write it back with `update_project_context`, then continue. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable with `update_project_context` — local competitors that have a website via `addCompetitors` (competitor rows are keyed by domain, so skip listings without one), a corrected `business_overview` — and append a research log entry: `{ appendResearchLog: { summary: "Local SEO: <business> near <area>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `search_local_businesses`: nearby listings, filterable by `minRating`, `minReviews`, and `isClaimed` — use `isClaimed: false` to find unclaimed listings when prospecting. One call with the brand name as `query` and a wide radius returns category, rating, review count, claimed status, coordinates, and `cid` for every location of a chain — usually enough that per-location `get_business_profile` calls are unnecessary.

View File

@ -16,6 +16,15 @@ Use this when asked for an SEO audit or review of a domain, especially when the
- Domain to audit
- `projectId` (use `list_projects`; if no project matches the domain, create one with `create_project`)
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first and ground the report in it — what the business does decides which findings matter and what the one thing should be.
2. This skill needs `business_overview`. If it is empty, run a minimal inline setup: infer what the business does from the site and confirm it with the user in one question, write it back with `update_project_context`, then continue the audit. Never front-load the full interview; suggest `seo-project-setup` at the end for the rest.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable — a corrected `business_overview`, the pages the report singles out via `addKeyPages` — and append a research log entry: `{ appendResearchLog: { summary: "Site audit: <domain>. Verdict: <conclusion>" } }`.
## OpenSEO MCP tools
- `whoami`: confirm connection and remaining credits before spending anything. If OpenSEO is not connected, stop and ask the user to connect it.

View File

@ -13,6 +13,15 @@ Act as a friendly SEO coach for users working with OpenSEO and an AI agent. Help
Be warm, direct, and beginner-friendly. Ask whether the user is new to SEO and adapt the explanation depth. Avoid sounding like a course or a consultant deck. Make SEO feel doable.
## Project context
The project-context tools are free and shared with the app and other agents.
1. Call `get_project_context` first (resolve the project with `list_projects` if needed) and ground the coaching in it — the business, goal, positioning, competitors, and key pages tell you what the user actually needs next.
2. This skill requires no section. Read whatever is there, and let the `missingSections` list shape the recommendation: empty context usually means the next step is `seo-project-setup`. Never front-load the full interview.
3. Before spending credits, check the research log. If the same research ran within the last 30 days, reuse that result and say so instead of re-buying it.
4. On finish, write back what is durable — anything the user tells you about the business, goal, or positioning, via `update_project_context` — and append a research log entry when a session spends credits: `{ appendResearchLog: { summary: "<what>: <inputs>. Verdict: <conclusion>" } }`.
## First response
When this mode starts, orient the user:
@ -38,7 +47,7 @@ Good starting points:
## What each workflow does
- `seo-project-setup`: sets up the workspace, verifies MCP, captures goals and positioning, and connects Google Search Console (or imports GSC exports).
- `seo-project-setup`: verifies MCP, interviews the user about scope, goals, positioning, competitors, and key pages, and saves it all to the project's shared context. Also connects Google Search Console (or imports GSC exports).
- `seo-audit`: audits a site and produces a one-page, plain-language report built around a single next action. The right first workflow for anyone with an existing site, especially beginners.
- `keyword-research`: finds search opportunities from seed topics and evaluates volume, difficulty, CPC, intent, and SERPs.
- `keyword-clustering`: groups keywords by intent and maps clusters to existing or proposed pages.
@ -55,9 +64,10 @@ Explain the difference between data sources:
- Google Search Console (when connected on the project's Integrations page) is the user's own first-party data — real clicks, impressions, CTR, and position. Read it live with `get_search_console_performance` instead of asking for CSV exports. It's free (no credits) and the best starting point for "what already ranks" and near-ranking opportunities.
- Web search can find current market context, recent pages, reviews, docs, social profiles, and contact paths outside OpenSEO.
- Browser/page scraping can extract page copy, headings, author names, contact links, schema, and content structure.
- Local files can preserve strategy, GSC CSVs, content briefs, crawls, prospect lists, and prior decisions over time.
- Project context (`get_project_context` / `update_project_context`) is the project's shared memory: business, goal, positioning, writing preferences, competitors, key pages, and a research log. It is free, every skill reads it, and the user can edit it on the project's Context settings page.
- Local files are for file work: GSC CSVs, crawls, drafts, briefs, and reports.
Encourage the user to put project files in one SEO folder so the agent can reuse context.
Encourage the user to keep project knowledge in project context rather than in a local file, so it follows them across sessions and agents.
## Coaching patterns

View File

@ -1,46 +1,52 @@
---
name: seo-project-setup
description: Set up a durable local SEO workspace with project context, notes, goals, positioning, preferences, MCP checks, and Search Console data intake.
description: Populate a project's shared OpenSEO context — site scope, goals, positioning, competitors, key pages, and preferences — plus MCP checks and Search Console intake.
---
# OpenSEO SEO Project Setup
## Goal
Help the user set up a local SEO workspace for one website or SEO project. The folder is where the agent saves notes, goals, exports, briefs, reports, preferences, and project context over time. This is a workspace and context setup workflow, not a full audit.
Interview the user once about one website or SEO project, and store the answers in that project's shared context in OpenSEO with `update_project_context`. That context is read by every other skill, by SAM in the app, and by the user on the project's Context settings page — so it survives new sessions, new machines, and new agents. This is a context setup workflow, not a full audit.
## Tone
Be friendly, practical, and structured. Ask questions in small batches. Explain why each item matters only when useful. Do not overwhelm a beginner with jargon.
## Where the answers go
Two project-context MCP tools do all the writing. Both are free — they spend no credits.
- `get_project_context(projectId)`: everything already known about the project, plus a `missingSections` list.
- `update_project_context(projectId, updates)`: a list of patch ops. The ones this skill uses:
- `{ section: "business_overview" | "current_goal" | "positioning" | "writing_preferences", content }`
- `{ addCompetitors: [{ domain, name?, notes? }] }`
- `{ addKeyPages: [{ url, role: "hub" | "spoke" | "money" | "other", topic?, notes? }] }`
- `{ customSection: "<slug>", title?, content }` for anything that does not fit a typed section
- `{ appendResearchLog: { summary } }` when this session spends credits
Write in batches as the interview progresses — do not hold every answer until the end. Sections are prose (~4,000 characters each), so a few tight paragraphs, not a transcript.
## Checklist
### 1. Pick a working folder
### 1. Verify OpenSEO MCP and resolve the project
Suggest that the user choose or create a local folder for SEO work, for example:
Writes need a `projectId`, so do this first:
- `~/SEO/<company-or-site>/`
- `~/Documents/SEO/<company-or-site>/`
- A repo or workspace folder if SEO work should live beside website/content files
1. Use `whoami` if available.
2. Use `list_projects` to confirm the user can access projects.
3. Match the project to the website/domain they want to rank for.
4. If the project list is ambiguous, ask the user which project should be used.
5. If no project matches, offer to create one with `create_project`.
6. If the MCP is unavailable, tell the user to connect OpenSEO MCP; without it, nothing can be saved.
Explain that keeping notes, exports, briefs, scraped pages, reports, and preferences in one folder helps the agent build context over time. Future SEO workflows can use that folder rather than starting from a blank conversation.
Do not run research tools just to test connectivity; `whoami` and `list_projects` are enough.
Recommended starter structure:
### 2. Read what is already there
```text
seo-workspace/
README.md
gsc/
keywords/
competitors/
content/
outreach/
reports/
```
Call `get_project_context`. Show the user a short summary of what OpenSEO already knows and what is missing. Confirm or correct existing entries rather than re-asking questions that are already answered — this skill is often re-run after another skill filled in part of the context.
Do not create folders unless the user asks. If file tools are available and the user asks, create a simple structure and a short `README.md` with the current goals, known sites, and user preferences for how the agent should approach SEO for this project.
### 2. Collect website scope
### 3. Collect website scope
Ask for:
@ -51,7 +57,9 @@ Ask for:
- Whether the site is new, established, migrating, or recovering from a drop
- CMS or publishing workflow, if relevant
### 3. Capture goals
Write the durable parts to `business_overview`: what the business does, who it is for, the target markets/locales, and the site's current stage.
### 4. Capture goals
Ask the user what they want from SEO:
@ -65,7 +73,9 @@ Ask the user what they want from SEO:
Ask for success metrics and timeframe. If goals are vague, help turn them into measurable goals such as "increase non-branded organic signups" or "rank top 10 for 20 buying-intent terms."
### 4. Capture positioning and strategy context
Write the result to `current_goal`, including the metric and timeframe.
### 5. Capture positioning and strategy context
Ask what research they have already done about the company, product, audience, and competitors. Request any notes, docs, customer interviews, positioning docs, pitch decks, landing pages, or strategy memos they can share.
@ -82,25 +92,35 @@ Probe for:
If the user has not done this yet, offer to help research positioning using the company website, competitor pages, reviews, forums, and web search.
### 5. Verify OpenSEO MCP
Write to `positioning`: audience, the problem, the differentiator, and any claims the user wants defended. Ask about voice, banned words or phrases, and topics to avoid, and write those to `writing_preferences` — content-drafting workflows read that section.
After the user has described the company, website, goals, and positioning, check that OpenSEO MCP is configured and mapped to the right project:
### 6. Save competitors
1. Use `whoami` if available.
2. Use `list_projects` to confirm the user can access projects.
3. Match the project to the website/domain they want to rank for.
4. If the project list is ambiguous, ask the user which project should be used.
5. If the MCP is unavailable, tell the user to connect OpenSEO MCP before continuing with live OpenSEO data.
Turn the competitors and substitutes from step 5 into `addCompetitors` entries: one row per domain, with a short `notes` line on why they matter ("direct competitor, owns the comparison pages"). If the user is unsure who competes in search, `find_serp_competitors` on a handful of seed keywords will name them — confirm the list with the user before saving, and log the spend.
Do not run research tools just to test connectivity; `whoami` and `list_projects` are enough.
Competitors saved here are reused by `competitive-landscape`, `competitor-analysis`, and `link-prospecting`.
### 6. Connect Google Search Console
### 7. Inventory key assets
Ask for or discover:
- Sitemap or important URL list
- Current blog/resources/content library
- Product/category/feature pages
- Existing keyword lists
- Current rank trackers
- Backlink or PR assets
- Linkable assets such as studies, templates, tools, datasets, calculators, or original opinions
Save the pages that actually matter with `addKeyPages` — money pages, topic hubs, and the linkable assets. This is a curated shortlist, not a site inventory: 10 to 30 URLs is normal. Give each one a `role` and, where known, the `topic` it targets.
### 8. Connect Google Search Console
GSC is the richest first-party signal: existing impressions, near-ranking terms, cannibalization, and pages that already have search demand.
**Preferred (hosted): connect it natively.** On the project's Integrations page, connect Google Search Console and pull live data with `get_search_console_performance`. Once connected, the agent reads it directly in `keyword-research` and `keyword-clustering` — no manual files to maintain.
**Fallback (self-hosted, or if the user prefers files):** ask the user to export CSVs from Search Console into the SEO working folder.
**Fallback (self-hosted, or if the user prefers files):** ask the user to export CSVs from Search Console into a local working folder (see step 9).
Recommended exports:
@ -118,19 +138,22 @@ gsc/queries-last-16-months.csv
gsc/pages-last-16-months.csv
```
### 7. Inventory existing assets
### 9. Set up a local folder only for file work
Ask for or discover:
Project knowledge lives in OpenSEO, not on disk. A local folder is still useful for the things that are actually files: GSC CSV exports, crawls, drafts, briefs, and reports.
- Sitemap or important URL list
- Current blog/resources/content library
- Product/category/feature pages
- Existing keyword lists
- Current rank trackers
- Backlink or PR assets
- Linkable assets such as studies, templates, tools, datasets, calculators, or original opinions
If the user wants one, suggest `~/SEO/<company-or-site>/` or a folder beside the website/content repo, with a structure like:
### 8. Recommend first workflow
```text
seo-workspace/
gsc/
drafts/
reports/
```
Do not create folders unless the user asks, and do not duplicate goals, positioning, or competitors into a local file — that is what the project context is for.
### 10. Recommend first workflow
After intake, recommend one next OpenSEO workflow:
@ -150,17 +173,23 @@ Use a checklist with statuses:
Then summarize:
- Working folder
- OpenSEO MCP/project status
- Sites in scope
- Goals
- Known positioning
- Uploaded data/files
- Competitors saved
- Key pages saved
- Search Console status and any local files
- Sections still missing from project context
- Recommended next workflow
Tell the user they can read and edit everything saved here on the project's Context settings page.
## Guardrails
- Keep setup lightweight. The user should feel oriented, not assigned homework.
- Confirm facts with the user before writing them. Inferences from the site are fine to propose, but they get saved as agreed answers, not guesses.
- Do not pretend a GSC CSV has been uploaded unless you can see it, and do not claim Search Console is connected unless `get_search_console_performance` confirms it (it returns a "not connected" message otherwise).
- Keep project setup focused on setup and context unless the user asks for live research.
- Keep project setup focused on setup and context unless the user asks for live research. If a step does spend credits, append a research log entry so other skills do not re-buy it.
- If web search or scraping is used for positioning research, distinguish source evidence from inference.
- Overwriting a section replaces it. When context already exists, merge the new answers into the existing prose instead of discarding it.

3
pnpm-lock.yaml generated
View File

@ -153,6 +153,9 @@ importers:
tldts:
specifier: ^7.0.25
version: 7.0.25
yaml:
specifier: ^2.9.0
version: 2.9.0
zod:
specifier: ^4.1.12
version: 4.3.6

View File

@ -0,0 +1,215 @@
# Project memory (shared AI context per project)
## Status
Accepted
## Context
Qualitative knowledge about a project — what the business does, the current
goal, positioning, writing preferences, competitors, which pages matter — is
scattered across surfaces that cannot see each other:
- **SAM** keeps it in `sam_project_memory`, two free-form markdown blobs
(`memory`, `research_log`) per project, writable only by the model via
Think's `set_context`. There is no UI; users cannot see or correct what SAM
believes.
- **Skills** (Claude Code / Codex users) keep it in a local folder the
`seo-project-setup` skill scaffolds — a `README.md` with goals, sites, and
preferences that never reaches the server, so SAM and the app never benefit.
- **Competitors are never persisted anywhere.** `find_serp_competitors`
results are returned and discarded.
- The onboarding chat agent produces a positioning/themes/keywords strategy
and persists none of it.
The result: every surface re-interviews the user or re-infers the same facts,
paid research gets repeated because no surface knows another already ran it,
and there is no place a user can inspect or edit what the AI knows about their
project.
## Decision
One project-scoped memory store, shared by SAM, the MCP server, and a new
settings UI. Hybrid shape: a fixed set of typed sections with real schemas,
plus agent-creatable custom sections for anything that doesn't fit yet. It
replaces `sam_project_memory` entirely.
### Data model
List-shaped entities are normalized tables; prose lives in a sections table.
All tables follow the D1 + Postgres dual-schema convention (`src/db/*.schema.ts`
mirrored in `src/db/pg/*.schema.ts`, schema-parity test, `pnpm db:generate`
for both dialects).
```
project_context_sections
project_id FK → projects (cascade)
key text -- typed key or "custom:<slug>"
title text? -- custom sections only
content text -- markdown
updated_at text
updated_by text -- "user" | "sam" | "mcp"
PK (project_id, key)
project_competitors
id, project_id FK (cascade)
domain text -- normalized host, unique per project
name text?
notes text? -- "direct competitor, strong on comparison pages"
updated_at, updated_by
UNIQUE (project_id, domain)
project_key_pages
id, project_id FK (cascade)
url text -- unique per project
role text -- "hub" | "spoke" | "money" | "other"
topic text? -- target topic/keyword
notes text?
updated_at, updated_by
UNIQUE (project_id, url)
project_research_log
id, project_id FK (cascade)
entry_date text -- YYYY-MM-DD, server-stamped
summary text -- "<what>: <inputs>. Verdict: <conclusion>"
created_by text -- "user" | "sam" | "mcp"
```
Typed section keys: `business_overview` (what the business does, who it's
for, target market/locales), `current_goal`, `positioning`,
`writing_preferences` (voice, banned words/phrases, topics to avoid).
Guardrails: prose sections capped at ~4,000 chars; custom sections capped at
20 per project and ~4,000 chars each; competitors and key pages capped at 100
rows each; research log pruned to 90 days on append. Caps keep the full
context small enough to inject into every SAM turn and return cheaply from
MCP.
**Deliberately not stored:** a sitemap or crawl copy. The page inventory
lives in `audit_pages` (latest audit) and GSC, reachable through existing
tools. `project_key_pages` is a curated shortlist, not an inventory; agents
may propose entries from audit/GSC data.
### MCP tools (two, free, no credits)
- **`get_project_context(projectId)`** — read-only. Returns everything:
typed sections, competitors, key pages, custom sections, recent research
log. `text` is a rendered markdown digest; `structuredContent` carries the
typed data. Empty sections are listed explicitly ("missing: positioning,
writing_preferences") so agents know what to fill and can suggest
`seo-project-setup`.
- **`update_project_context(projectId, updates[])`** — an array of patch
ops, discriminated union:
- `{ section, content }` — set a typed section (empty string clears)
- `{ customSection, title?, content }` / `{ deleteCustomSection }`
- `{ addCompetitors: [{domain, name?, notes?}] }` (upsert by domain),
`{ removeCompetitors: [domain] }`
- `{ addKeyPages: [{url, role, topic?, notes?}] }` (upsert by url),
`{ removeKeyPages: [url] }`
- `{ appendResearchLog: { summary } }` — server stamps the date;
`{ removeResearchLog: [id] }`
Both use `withMcpProjectAuth`, standard layering (tool → `ProjectContextService`
→ repository), `readOnlyHint` annotations, deep-link `meta` to the Context
settings page. Writes record `updated_by: "mcp"`. Register in
`src/server/mcp/server.ts` and the hand-maintained catalogue in
`src/client/features/ai-mcp/AvailableTools.tsx`.
### SAM integration
`sam_project_memory` is removed; the `SamChatAgent` block provider seam is
where the swap happens:
- The writable `memory`/`research_log` blocks are replaced by a single
**read-only** context block rendering `get_project_context` output
(refreshed after each turn, as today, so cross-session writes land).
- SAM writes through the same adapted `update_project_context` tool that
MCP clients use (via `adaptMcpTool`, projectId injected), recorded as
`updated_by: "sam"`. One write path, one validation surface.
- The system prompt keeps its contract but points at the typed sections:
intake mode triggers when `business_overview` is empty; the bootstrap flow
(read site, infer, confirm, write) now writes typed sections and
competitors instead of a prose blob; the 30-day research-staleness rule
reads `project_research_log`.
**Migration:** none. SAM usage is low, so `sam_project_memory` is dropped
outright (schema removal + drop migration); existing SAM memories are
discarded and SAM re-runs its intake flow on next use.
### UI
Project settings gets a gear button on the project switcher (replacing its
absence from any navigation) and splits into sub-pages:
- **General** — existing name/domain/market form
- **Context** (new) — the memory UI: editable forms for the four prose
sections, tables with inline add/edit/delete for competitors and key pages,
cards for custom sections (rename/edit/delete), research log list
(read-only + delete). Every item shows provenance: "Updated by SAM · 2d
ago". Edits record `updated_by: "user"`.
- **Integrations** — Search Console + Analytics cards move here
The gear lives on the switcher, so settings is reachable from every view
(including SAM's chat tab); existing deep links
(`#google-analytics`, GSC connect) keep working via redirects to the
Integrations sub-page. Server functions follow the standard
`requireProjectContext` → service → repository path with Zod schemas in
`src/types/schemas/`.
### Skills
Skills reach the store only through the two MCP tools (skills are distributed
by file copy; there is no server-side delivery).
- **`seo-project-setup` rewritten** to be the canonical populate flow: the
interview steps (site scope, goals, positioning, competitors, key assets)
now end in `update_project_context` writes instead of a local `README.md`.
Local-folder scaffolding remains only for file-based work (GSC CSV
fallback, drafts).
- **Every SEO skill gets a standard "Project context" preamble**:
1. Call `get_project_context` first; use it to ground the work.
2. If the sections this skill _requires_ are empty, run a minimal inline
setup — ask (or infer from the site and confirm) just enough to fill
them, write them back, then continue the actual task. Suggest the full
`seo-project-setup` at the end. Never front-load the full interview.
3. Before paid research, check the research log (30-day staleness rule).
4. On finish, write back: durable learnings → sections/entities, research
spend → `appendResearchLog`.
Required sections per skill: keyword-research → business_overview +
current_goal; competitive-landscape / competitor-analysis → competitors;
keyword-clustering → key pages; link-prospecting → positioning +
competitors; seo-audit → business_overview; seo-coach → reads everything,
requires nothing. Content-drafting flows additionally require
writing_preferences.
Hand-maintained lists to update when skills change: `src/routes/_app/ai.tsx`
(`SKILL_NAMES` etc.) and `web/content/docs/skills/*`.
### Rollout
1. **Schema + service + MCP tools** (with the `sam_project_memory` drop) —
the store exists, Claude Code users can use it end-to-end.
2. **SAM cutover** — block provider swap, prompt update, tool adaptation.
3. **UI** — switcher gear, settings sub-pages, Context page.
4. **Skills pass** — rewrite `seo-project-setup`, add the preamble to the
SEO skills, update docs pages.
In practice all four phases landed together on one branch.
## Consequences
- One source of truth: SAM, MCP clients, and the UI read and write the same
records; users can finally inspect and correct agent beliefs, with
provenance on every item.
- Cross-surface research dedupe: the shared log stops SAM and Claude Code
from independently re-buying the same research.
- Competitors and key pages become joinable product data — future
rank-tracker comparisons, share-of-voice, and audit cross-references can
reference them without parsing prose.
- Typed sections require a code change to extend; the custom-section
overflow is the pressure valve and tells us which section to promote next.
- Two more MCP tool schemas in every client's token budget (mitigated by
keeping the patch union compact).
- The onboarding chat agent still discards its strategy output; persisting it
into these sections is a natural follow-up, out of scope here.

View File

@ -31,6 +31,20 @@ export function humanizeToolLabel(partType: string): ToolLabel {
return { running: label, done: label };
}
// activate_skill is the one tool where the target matters more than the tool
// name: surface which skill the agent loaded instead of a bare "Activate
// skill" badge.
function skillNameFromPart(part: UIMessage["parts"][number]): string | null {
if (part.type !== "tool-activate_skill" || !("input" in part)) return null;
const input: unknown = part.input;
return typeof input === "object" &&
input !== null &&
"name" in input &&
typeof input.name === "string"
? input.name
: null;
}
// 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
@ -174,6 +188,9 @@ function ToolBadge({
}) {
const labels = resolveToolLabel(part.type);
if (!labels) return null;
const skillName = skillNameFromPart(part);
const runningText = skillName ? `Activating ${skillName}` : labels.running;
const doneText = skillName ? `Skill: ${skillName}` : labels.done;
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
@ -193,7 +210,7 @@ function ToolBadge({
) : (
<Check className="size-3" />
)}
<span>{isRunning ? `${labels.running}` : labels.done}</span>
<span>{isRunning ? `${runningText}` : doneText}</span>
</span>
);
}

View File

@ -12,6 +12,23 @@ type ToolCategory = {
};
const toolCategories: ToolCategory[] = [
{
label: "Project Context",
tools: [
{
name: "get_project_context",
title: "Get project context",
description:
"Read your project's goals, positioning, competitors, and key pages.",
},
{
name: "update_project_context",
title: "Update project context",
description:
"Save what an agent learned back to your shared project context.",
},
],
},
{
label: "Keywords",
tools: [

View File

@ -87,10 +87,10 @@ export function GscReEngagementModal({
// screen, and on return they'll either have a grant (which suppresses this
// anyway) or have abandoned it — neither case should re-nag.
persistDismiss();
// Land them on the project's settings page so they can pick a property
// Land them on the project's integrations page so they can pick a property
// right after granting access (the grant alone has no property bound yet).
const callbackURL = projectId
? `${window.location.origin}/p/${projectId}/settings#search-console`
? `${window.location.origin}/p/${projectId}/settings/integrations`
: window.location.href;
void startGoogleLink("gsc", callbackURL);
}

View File

@ -36,10 +36,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
await queryClient.invalidateQueries({ queryKey: ["projects"] });
onClose();
toast.success("Project created");
// Land on the new project's settings so they can connect Search Console
// and finish setting up the workspace.
// Land on the new project's integrations so they can connect Search
// Console and finish setting up the workspace.
void navigate({
to: "/p/$projectId/settings",
to: "/p/$projectId/settings/integrations",
params: { projectId: created.id },
});
},

View File

@ -1,10 +1,7 @@
import * as React from "react";
import { Link, useNavigate } from "@tanstack/react-router";
import { useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft } from "lucide-react";
import { toast } from "sonner";
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
import { GoogleAnalyticsConnectionCard } from "@/client/features/ga4/GoogleAnalyticsConnectionCard";
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import {
@ -18,7 +15,7 @@ import {
} from "@/serverFunctions/projects";
import type { ProjectSummary } from "./types";
export function ProjectSettings({ projectId }: { projectId: string }) {
export function ProjectGeneralSettings({ projectId }: { projectId: string }) {
const projectsQuery = useQuery({
queryKey: ["projects"],
queryFn: () => getProjects(),
@ -28,51 +25,16 @@ export function ProjectSettings({ projectId }: { projectId: string }) {
if (!project) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex justify-center py-10">
<span className="loading loading-spinner loading-md" />
</div>
);
}
return (
<div className="mx-auto w-full max-w-2xl space-y-8 p-4 py-8 sm:p-6 md:py-12">
<div className="space-y-4">
<Link
to="/projects"
className="inline-flex items-center gap-1 text-sm text-base-content/60 transition-colors hover:text-base-content"
>
<ChevronLeft className="size-4" />
Projects
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight">
Project settings
</h1>
<p className="text-sm text-base-content/60">{project.name}</p>
</div>
</div>
<div className="space-y-8">
{/* key resets the form's local state when switching between projects */}
<GeneralSection key={project.id} project={project} />
<section id="search-console" className="space-y-3 scroll-mt-6">
<h2 className="text-sm font-medium text-base-content/50">
Search Console
</h2>
<SearchConsoleConnectionCard projectId={projectId} />
</section>
<section id="google-analytics" className="space-y-3 scroll-mt-6">
<GoogleAnalyticsConnectionCard
projectId={projectId}
heading={
<h2 className="text-sm font-medium text-base-content/50">
Analytics
</h2>
}
/>
</section>
<DangerSection project={project} canArchive={projects.length > 1} />
</div>
);

View File

@ -1,7 +1,14 @@
import * as React from "react";
import { Link, useNavigate } from "@tanstack/react-router";
import { Link, useRouter } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Check, ChevronsUpDown, FolderCog, Plus, Search } from "lucide-react";
import {
Check,
ChevronsUpDown,
FolderCog,
Plus,
Search,
Settings,
} from "lucide-react";
import { getProjects } from "@/serverFunctions/projects";
import { setLastProjectId } from "@/client/lib/active-project";
import { CreateProjectModal } from "@/client/features/projects/CreateProjectModal";
@ -19,7 +26,10 @@ export function ProjectSwitcher({
// drawer overlay.
onCloseDrawer?: () => void;
}) {
const navigate = useNavigate();
// Matches are read off router.state at click time; subscribing via
// useMatches() would re-render the whole sidebar on every route change for
// a value only a click needs.
const router = useRouter();
const [creating, setCreating] = React.useState(false);
// Controlled open state rather than daisyUI's CSS focus-within dropdown:
// focus-within can't guarantee the search input ends up focused on open
@ -76,9 +86,27 @@ export function ProjectSwitcher({
onCloseDrawer?.();
if (project.id === activeProjectId) return;
setLastProjectId(project.id);
void navigate({
to: "/p/$projectId",
params: { projectId: project.id },
// Stay on the current page in the new project: the deepest matched route
// whose path's only dynamic segment is the project id. Deeper routes (a
// rank tracker, an audit result) reference entities owned by the old
// project, so they fall back to their section. Filtering on the path
// template rather than match.params matters: the router gives every match
// the location's full param set, so params can't tell layers apart.
const stayable = router.state.matches.findLast(
(match) =>
match.fullPath.includes("$projectId") &&
match.fullPath
.split("/")
.every(
(segment) => !segment.startsWith("$") || segment === "$projectId",
),
);
// Navigating by href keeps typed-route generics out of a dynamic target
// while still running search validation; search params are deliberately
// not carried over — filters and session ids belong to the old project.
const template = stayable?.fullPath ?? "/p/$projectId";
void router.navigate({
href: template.split("$projectId").join(project.id).replace(/\/$/, ""),
});
};
@ -190,28 +218,45 @@ export function ProjectSwitcher({
// trigger still has focus).
className="relative w-full"
>
<button
ref={triggerRef}
type="button"
aria-label="Switch project"
aria-expanded={open}
aria-haspopup="listbox"
onClick={() => (open ? closePanel() : openPanel())}
onKeyDown={handleTriggerKeyDown}
className="flex w-full items-center justify-between gap-2 rounded-lg border border-base-300 bg-base-100 px-3 py-1.5 text-left transition-colors hover:border-base-content/25"
>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-base-content">
{activeProject?.name ?? "Select project"}
</span>
{activeProject?.domain ? (
<span className="truncate text-xs font-normal text-base-content/50">
{activeProject.domain}
<div className="flex items-stretch rounded-lg border border-base-300 bg-base-100">
<button
ref={triggerRef}
type="button"
aria-label="Switch project"
aria-expanded={open}
aria-haspopup="listbox"
onClick={() => (open ? closePanel() : openPanel())}
onKeyDown={handleTriggerKeyDown}
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-l-lg px-3 py-1.5 text-left transition-colors hover:bg-base-200"
>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-base-content">
{activeProject?.name ?? "Select project"}
</span>
) : null}
</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
</button>
{activeProject?.domain ? (
<span className="truncate text-xs font-normal text-base-content/50">
{activeProject.domain}
</span>
) : null}
</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
</button>
{activeProject ? (
<Link
to="/p/$projectId/settings"
params={{ projectId: activeProject.id }}
aria-label="Project settings"
title="Project settings"
onClick={() => {
closePanel();
onCloseDrawer?.();
}}
className="flex shrink-0 items-center justify-center rounded-r-lg border-l border-base-300 px-2.5 text-base-content/60 transition-colors hover:bg-base-200 hover:text-base-content"
>
<Settings className="size-4" />
</Link>
) : null}
</div>
{open ? (
<div className="absolute left-0 right-0 top-full z-30 mt-1 overflow-hidden rounded-box border border-base-300 bg-base-100 shadow-lg">

View File

@ -0,0 +1,221 @@
import * as React from "react";
import { Pencil, Plus } from "lucide-react";
import type { ProjectContextUpdate } from "@/types/schemas/projectContext";
import {
ConfirmDeleteButton,
EmptyState,
FormActions,
listClass,
Provenance,
RowActions,
SectionHeader,
useContextUpdate,
type ContextCompetitor,
} from "./shared";
export function CompetitorsSection({
projectId,
competitors,
}: {
projectId: string;
competitors: ContextCompetitor[];
}) {
const update = useContextUpdate(projectId);
const [adding, setAdding] = React.useState(false);
const [editingId, setEditingId] = React.useState<string | null>(null);
const save = (previousDomain: string | null, draft: CompetitorDraft) => {
const ops: ProjectContextUpdate[] = [];
// Competitors upsert by domain, so a retyped domain has to drop the old row
// before the new one lands.
if (previousDomain && previousDomain !== draft.domain.trim()) {
ops.push({ removeCompetitors: [previousDomain] });
}
// Send the fields even when blank: an omitted field means "keep what's
// stored" (so agent writes merge), so clearing one from the form has to
// send the empty string.
ops.push({
addCompetitors: [
{
domain: draft.domain.trim(),
name: draft.name.trim(),
notes: draft.notes.trim(),
},
],
});
update.mutate(ops, {
onSuccess: () => {
setAdding(false);
setEditingId(null);
},
});
};
return (
<section className="space-y-3">
<SectionHeader
title="Competitors"
hint="The sites you measure yourself against."
action={
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => setAdding(true)}
>
<Plus className="size-3.5" />
Add competitor
</button>
}
/>
{adding ? (
<div className={listClass}>
<CompetitorForm
pending={update.isPending}
onCancel={() => setAdding(false)}
onSave={(draft) => save(null, draft)}
/>
</div>
) : null}
{competitors.length === 0 ? (
adding ? null : (
<EmptyState>
No competitors yet. Add the sites you compete with, or ask SAM to
find them from your rankings and save them here.
</EmptyState>
)
) : (
<ul className={listClass}>
{competitors.map((competitor) =>
editingId === competitor.id ? (
<li key={competitor.id}>
<CompetitorForm
initial={competitor}
pending={update.isPending}
onCancel={() => setEditingId(null)}
onSave={(draft) => save(competitor.domain, draft)}
/>
</li>
) : (
<li
key={competitor.id}
className="flex items-start justify-between gap-3 p-3"
>
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="truncate text-sm font-medium">
{competitor.domain}
</span>
{competitor.name ? (
<span className="truncate text-xs text-base-content/60">
{competitor.name}
</span>
) : null}
</div>
{competitor.notes ? (
<p className="text-sm text-base-content/70">
{competitor.notes}
</p>
) : null}
<Provenance
by={competitor.updatedBy}
at={competitor.updatedAt}
/>
</div>
<RowActions>
<button
type="button"
className="btn btn-ghost btn-xs"
aria-label={`Edit ${competitor.domain}`}
onClick={() => setEditingId(competitor.id)}
>
<Pencil className="size-3.5" />
</button>
<ConfirmDeleteButton
label={`Remove ${competitor.domain}`}
pending={update.isPending}
onConfirm={() =>
update.mutate([
{ removeCompetitors: [competitor.domain] },
])
}
/>
</RowActions>
</li>
),
)}
</ul>
)}
</section>
);
}
type CompetitorDraft = { domain: string; name: string; notes: string };
function CompetitorForm({
initial,
pending,
onCancel,
onSave,
}: {
initial?: ContextCompetitor;
pending: boolean;
onCancel: () => void;
onSave: (draft: CompetitorDraft) => void;
}) {
const [draft, setDraft] = React.useState<CompetitorDraft>({
domain: initial?.domain ?? "",
name: initial?.name ?? "",
notes: initial?.notes ?? "",
});
return (
<form
className="space-y-2 bg-base-200/40 p-3"
onSubmit={(event) => {
event.preventDefault();
if (!draft.domain.trim() || pending) return;
onSave(draft);
}}
>
<div className="grid gap-2 sm:grid-cols-2">
<input
autoFocus
type="text"
value={draft.domain}
onChange={(event) =>
setDraft({ ...draft, domain: event.target.value })
}
placeholder="competitor.com"
maxLength={255}
className="input input-bordered input-sm w-full"
aria-label="Competitor domain"
/>
<input
type="text"
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
placeholder="Name (optional)"
maxLength={120}
className="input input-bordered input-sm w-full"
aria-label="Competitor name"
/>
</div>
<input
type="text"
value={draft.notes}
onChange={(event) => setDraft({ ...draft, notes: event.target.value })}
placeholder="Why they matter — e.g. wins every comparison keyword (optional)"
maxLength={500}
className="input input-bordered input-sm w-full"
aria-label="Competitor notes"
/>
<FormActions
pending={pending}
disabled={!draft.domain.trim()}
onCancel={onCancel}
/>
</form>
);
}

View File

@ -0,0 +1,254 @@
import * as React from "react";
import { Pencil, Plus } from "lucide-react";
import {
KEY_PAGE_ROLES,
type KeyPageRole,
type ProjectContextUpdate,
} from "@/types/schemas/projectContext";
import {
ConfirmDeleteButton,
EmptyState,
FormActions,
listClass,
Provenance,
RowActions,
SectionHeader,
useContextUpdate,
type ContextKeyPage,
} from "./shared";
const ROLE_LABELS: Record<KeyPageRole, string> = {
hub: "Hub page",
spoke: "Supporting page",
money: "Money page",
other: "Other",
};
export function KeyPagesSection({
projectId,
keyPages,
}: {
projectId: string;
keyPages: ContextKeyPage[];
}) {
const update = useContextUpdate(projectId);
const [adding, setAdding] = React.useState(false);
const [editingId, setEditingId] = React.useState<string | null>(null);
const save = (previousUrl: string | null, draft: KeyPageDraft) => {
const ops: ProjectContextUpdate[] = [];
// Key pages upsert by URL, so a retyped URL has to drop the old row before
// the new one lands.
if (previousUrl && previousUrl !== draft.url.trim()) {
ops.push({ removeKeyPages: [previousUrl] });
}
// Send the fields even when blank: an omitted field means "keep what's
// stored" (so agent writes merge), so clearing one from the form has to
// send the empty string.
ops.push({
addKeyPages: [
{
url: draft.url.trim(),
role: draft.role,
topic: draft.topic.trim(),
notes: draft.notes.trim(),
},
],
});
update.mutate(ops, {
onSuccess: () => {
setAdding(false);
setEditingId(null);
},
});
};
return (
<section className="space-y-3">
<SectionHeader
title="Key pages"
hint="A shortlist of the pages that carry the site — not an inventory."
action={
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => setAdding(true)}
>
<Plus className="size-3.5" />
Add page
</button>
}
/>
{adding ? (
<div className={listClass}>
<KeyPageForm
pending={update.isPending}
onCancel={() => setAdding(false)}
onSave={(draft) => save(null, draft)}
/>
</div>
) : null}
{keyPages.length === 0 ? (
adding ? null : (
<EmptyState>
No key pages yet. Add the handful that has to rank, or let an agent
propose them from your last site audit.
</EmptyState>
)
) : (
<ul className={listClass}>
{keyPages.map((page) =>
editingId === page.id ? (
<li key={page.id}>
<KeyPageForm
initial={page}
pending={update.isPending}
onCancel={() => setEditingId(null)}
onSave={(draft) => save(page.url, draft)}
/>
</li>
) : (
<li
key={page.id}
className="flex items-start justify-between gap-3 p-3"
>
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="truncate text-sm font-medium">
{page.url}
</span>
<span className="badge badge-ghost badge-sm shrink-0">
{ROLE_LABELS[page.role]}
</span>
</div>
{page.topic ? (
<p className="text-sm text-base-content/70">
Target: {page.topic}
</p>
) : null}
{page.notes ? (
<p className="text-sm text-base-content/70">{page.notes}</p>
) : null}
<Provenance by={page.updatedBy} at={page.updatedAt} />
</div>
<RowActions>
<button
type="button"
className="btn btn-ghost btn-xs"
aria-label={`Edit ${page.url}`}
onClick={() => setEditingId(page.id)}
>
<Pencil className="size-3.5" />
</button>
<ConfirmDeleteButton
label={`Remove ${page.url}`}
pending={update.isPending}
onConfirm={() =>
update.mutate([{ removeKeyPages: [page.url] }])
}
/>
</RowActions>
</li>
),
)}
</ul>
)}
</section>
);
}
type KeyPageDraft = {
url: string;
role: KeyPageRole;
topic: string;
notes: string;
};
function KeyPageForm({
initial,
pending,
onCancel,
onSave,
}: {
initial?: ContextKeyPage;
pending: boolean;
onCancel: () => void;
onSave: (draft: KeyPageDraft) => void;
}) {
const [draft, setDraft] = React.useState<KeyPageDraft>({
url: initial?.url ?? "",
role: initial?.role ?? "other",
topic: initial?.topic ?? "",
notes: initial?.notes ?? "",
});
return (
<form
className="space-y-2 bg-base-200/40 p-3"
onSubmit={(event) => {
event.preventDefault();
if (!draft.url.trim() || pending) return;
onSave(draft);
}}
>
<input
autoFocus
type="text"
value={draft.url}
onChange={(event) => setDraft({ ...draft, url: event.target.value })}
placeholder="example.com/pricing"
maxLength={2048}
className="input input-bordered input-sm w-full"
aria-label="Page URL"
/>
<div className="grid gap-2 sm:grid-cols-2">
<select
value={draft.role}
onChange={(event) =>
setDraft({
...draft,
role:
KEY_PAGE_ROLES.find((role) => role === event.target.value) ??
draft.role,
})
}
className="select select-bordered select-sm w-full"
aria-label="Page role"
>
{KEY_PAGE_ROLES.map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]}
</option>
))}
</select>
<input
type="text"
value={draft.topic}
onChange={(event) =>
setDraft({ ...draft, topic: event.target.value })
}
placeholder="Target topic (optional)"
maxLength={200}
className="input input-bordered input-sm w-full"
aria-label="Target topic"
/>
</div>
<input
type="text"
value={draft.notes}
onChange={(event) => setDraft({ ...draft, notes: event.target.value })}
placeholder="Notes (optional)"
maxLength={500}
className="input input-bordered input-sm w-full"
aria-label="Page notes"
/>
<FormActions
pending={pending}
disabled={!draft.url.trim()}
onCancel={onCancel}
/>
</form>
);
}

View File

@ -0,0 +1,404 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { Pencil } from "lucide-react";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getProjectContext } from "@/serverFunctions/projectContext";
import {
PROJECT_CONTEXT_SECTION_KEYS,
PROJECT_CONTEXT_SECTION_LABELS,
PROSE_MAX_CHARS,
type ProjectContextSectionKey,
} from "@/types/schemas/projectContext";
import { CompetitorsSection } from "./CompetitorsSection";
import { KeyPagesSection } from "./KeyPagesSection";
import {
ConfirmDeleteButton,
EmptyState,
FormActions,
listClass,
Provenance,
RowActions,
SectionHeader,
projectContextQueryKey,
useContextUpdate,
type ProjectContextData,
} from "./shared";
const SECTION_HINTS: Record<ProjectContextSectionKey, string> = {
business_overview: "What you sell, who buys it, and where.",
current_goal: "What you're pushing for right now, and by when.",
positioning: "Why someone picks you over the alternatives.",
writing_preferences: "Voice, words to avoid, topics that are off-limits.",
};
const SECTION_PLACEHOLDERS: Record<ProjectContextSectionKey, string> = {
business_overview:
"e.g. Booking software for independent restaurants in the US and Canada. Buyers are owner-operators, not marketers.",
current_goal:
"e.g. Double organic signups by Q4. Comparison pages are the current bet.",
positioning:
"e.g. The only booking tool that sets up in an afternoon. Cheaper than the incumbents, simpler than the DIY stack.",
writing_preferences:
"e.g. Plain and direct, no hype. Never say 'seamless' or 'game-changing'. Don't write about competitor pricing.",
};
export function ProjectContextPage({ projectId }: { projectId: string }) {
const contextQuery = useQuery({
queryKey: projectContextQueryKey(projectId),
queryFn: () => getProjectContext({ data: { projectId } }),
// This page exists to inspect what agents just wrote; the app-wide
// 5-minute staleTime would show pre-SAM-turn memory as current.
staleTime: 0,
});
if (contextQuery.isPending) {
return (
<div className="flex justify-center py-10">
<span className="loading loading-spinner loading-md" />
</div>
);
}
if (contextQuery.isError) {
return (
<div className="alert alert-error">
<span className="text-sm">
{getStandardErrorMessage(
contextQuery.error,
"Failed to load project context",
)}
</span>
</div>
);
}
const context = contextQuery.data;
return (
// key remounts the whole page when the project switches under it, so no
// draft, open form, or edit state can carry over to another project.
<div key={projectId} className="space-y-8">
<p className="text-sm text-base-content/70">
What SAM, Claude Code, and any connected MCP client know about this
project. They read it before they work and write back what they learn,
so correct anything that looks wrong.
</p>
<ProseSections
projectId={projectId}
sections={context.sections}
missingSections={context.missingSections}
/>
<CompetitorsSection
projectId={projectId}
competitors={context.competitors}
/>
<KeyPagesSection projectId={projectId} keyPages={context.keyPages} />
<CustomSections
projectId={projectId}
customSections={context.customSections}
/>
<ResearchLog projectId={projectId} researchLog={context.researchLog} />
</div>
);
}
function ProseSections({
projectId,
sections,
missingSections,
}: {
projectId: string;
sections: ProjectContextData["sections"];
missingSections: ProjectContextData["missingSections"];
}) {
const update = useContextUpdate(projectId);
const stored = new Map(sections.map((section) => [section.key, section]));
// Only the fields the user actually touched are pinned locally; the rest
// render straight from the query, so a write from SAM shows up on refetch.
const [drafts, setDrafts] = React.useState<Record<string, string>>({});
const draftOf = (key: ProjectContextSectionKey) =>
drafts[key] ?? stored.get(key)?.content ?? "";
// Content is trimmed server-side, so compare trimmed values — otherwise a
// stray newline leaves the form permanently "unsaved".
const changed = PROJECT_CONTEXT_SECTION_KEYS.filter(
(key) => draftOf(key).trim() !== (stored.get(key)?.content ?? ""),
);
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (update.isPending || changed.length === 0) return;
update.mutate(
changed.map((key) => ({ section: key, content: draftOf(key).trim() })),
// Unpin every draft the save made redundant — one that now matches the
// server — so those sections render from the query again (a pinned
// draft would silently overwrite a later agent write on the next
// save). Anything typed while the request was in flight still differs
// and stays pinned instead of snapping back.
{
onSuccess: (context) => {
const saved = new Map<string, string>(
context.sections.map((section) => [section.key, section.content]),
);
setDrafts((current) =>
Object.fromEntries(
Object.entries(current).filter(
([key, value]) => value.trim() !== (saved.get(key) ?? ""),
),
),
);
},
},
);
};
return (
<form onSubmit={handleSubmit} className="space-y-5">
{missingSections.length === PROJECT_CONTEXT_SECTION_KEYS.length ? (
<EmptyState>
Nothing written down yet. Fill in what you can or ask SAM to draft
it from your site and confirm what it got right.
</EmptyState>
) : null}
{PROJECT_CONTEXT_SECTION_KEYS.map((key) => {
const section = stored.get(key);
return (
<div key={key} className="space-y-1.5">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<label
htmlFor={`context-${key}`}
className="text-sm font-medium text-base-content"
>
{PROJECT_CONTEXT_SECTION_LABELS[key]}
</label>
{section ? (
<Provenance by={section.updatedBy} at={section.updatedAt} />
) : (
<span className="text-xs text-base-content/40">Empty</span>
)}
</div>
<p className="text-xs text-base-content/50">{SECTION_HINTS[key]}</p>
<textarea
id={`context-${key}`}
value={draftOf(key)}
onChange={(event) => {
const value = event.target.value;
setDrafts((current) => {
// A draft that matches the store is no draft at all — drop
// it so an edit typed and then undone doesn't pin the
// section against later agent writes.
if (value === (stored.get(key)?.content ?? "")) {
const { [key]: _dropped, ...rest } = current;
return rest;
}
return { ...current, [key]: value };
});
}}
rows={4}
maxLength={PROSE_MAX_CHARS}
placeholder={SECTION_PLACEHOLDERS[key]}
className="textarea textarea-bordered w-full text-sm"
/>
</div>
);
})}
<div className="flex justify-end">
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={update.isPending || changed.length === 0}
>
Save changes
</button>
</div>
</form>
);
}
function CustomSections({
projectId,
customSections,
}: {
projectId: string;
customSections: ProjectContextData["customSections"];
}) {
const update = useContextUpdate(projectId);
const [editingSlug, setEditingSlug] = React.useState<string | null>(null);
return (
<section className="space-y-3">
<SectionHeader
title="Custom sections"
hint="Anything an agent wrote down that didn't fit the sections above."
/>
{customSections.length === 0 ? (
<EmptyState>
Nothing here yet. Agents add a section when they learn something
important that has nowhere else to live.
</EmptyState>
) : (
<div className="space-y-3">
{customSections.map((custom) =>
editingSlug === custom.slug ? (
<CustomSectionForm
key={custom.slug}
custom={custom}
pending={update.isPending}
onCancel={() => setEditingSlug(null)}
onSave={(title, content) =>
update.mutate(
[{ customSection: custom.slug, title, content }],
{ onSuccess: () => setEditingSlug(null) },
)
}
/>
) : (
<div
key={custom.slug}
className="space-y-2 rounded-lg border border-base-300 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-medium">
{custom.title ?? custom.slug}
</h3>
<Provenance by={custom.updatedBy} at={custom.updatedAt} />
</div>
<RowActions>
<button
type="button"
className="btn btn-ghost btn-xs"
aria-label={`Edit ${custom.title ?? custom.slug}`}
onClick={() => setEditingSlug(custom.slug)}
>
<Pencil className="size-3.5" />
</button>
<ConfirmDeleteButton
label={`Delete ${custom.title ?? custom.slug}`}
pending={update.isPending}
onConfirm={() =>
update.mutate([{ deleteCustomSection: custom.slug }])
}
/>
</RowActions>
</div>
<p className="whitespace-pre-wrap text-sm text-base-content/70">
{custom.content}
</p>
</div>
),
)}
</div>
)}
</section>
);
}
function CustomSectionForm({
custom,
pending,
onCancel,
onSave,
}: {
custom: ProjectContextData["customSections"][number];
pending: boolean;
onCancel: () => void;
onSave: (title: string, content: string) => void;
}) {
const [title, setTitle] = React.useState(custom.title ?? "");
const [content, setContent] = React.useState(custom.content);
return (
<form
className="space-y-2 rounded-lg border border-base-300 bg-base-200/40 p-3"
onSubmit={(event) => {
event.preventDefault();
if (pending || !content.trim()) return;
onSave(title.trim() || custom.slug, content);
}}
>
<input
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={custom.slug}
maxLength={120}
className="input input-bordered input-sm w-full"
aria-label="Section title"
/>
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={5}
maxLength={PROSE_MAX_CHARS}
className="textarea textarea-bordered w-full text-sm"
aria-label="Section content"
/>
<FormActions
pending={pending}
disabled={!content.trim()}
onCancel={onCancel}
/>
</form>
);
}
function ResearchLog({
projectId,
researchLog,
}: {
projectId: string;
researchLog: ProjectContextData["researchLog"];
}) {
const update = useContextUpdate(projectId);
return (
<section className="space-y-3">
<SectionHeader
title="Research log"
hint="What's already been looked up, so nobody buys the same data twice."
/>
{researchLog.length === 0 ? (
<EmptyState>
Nothing logged yet. Agents record paid research here as they run it.
</EmptyState>
) : (
<ul className={listClass}>
{researchLog.map((entry) => (
<li
key={entry.id}
className="flex items-start justify-between gap-3 p-3"
>
<div className="min-w-0 space-y-0.5">
<p className="text-sm text-base-content/80">{entry.summary}</p>
<div className="flex flex-wrap items-baseline gap-x-2 text-xs text-base-content/40">
<span>{entry.entryDate}</span>
<Provenance by={entry.createdBy} />
</div>
</div>
<RowActions>
<ConfirmDeleteButton
label={`Delete log entry from ${entry.entryDate}`}
pending={update.isPending}
onConfirm={() =>
update.mutate([{ removeResearchLog: [entry.id] }])
}
/>
</RowActions>
</li>
))}
</ul>
)}
</section>
);
}

View File

@ -0,0 +1,198 @@
import { useState, type ReactNode } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { updateProjectContext } from "@/serverFunctions/projectContext";
import type { getProjectContext } from "@/serverFunctions/projectContext";
import type {
ContextAuthor,
ProjectContextUpdate,
} from "@/types/schemas/projectContext";
export type ProjectContextData = Awaited<ReturnType<typeof getProjectContext>>;
export type ContextCompetitor = ProjectContextData["competitors"][number];
export type ContextKeyPage = ProjectContextData["keyPages"][number];
export function projectContextQueryKey(projectId: string) {
return ["projectContext", projectId];
}
/**
* Every edit on this page is a patch op against the same endpoint, so all of
* them share one mutation. The server function returns the context as it
* stands after the patch, which becomes the new cache entry no refetch.
*/
export function useContextUpdate(projectId: string) {
const queryClient = useQueryClient();
const queryKey = projectContextQueryKey(projectId);
return useMutation({
mutationFn: (updates: ProjectContextUpdate[]) =>
updateProjectContext({ data: { projectId, updates } }),
// An in-flight refetch would overwrite the fresher setQueryData below
// with its pre-mutation snapshot.
onMutate: () => queryClient.cancelQueries({ queryKey }),
onSuccess: (context) => {
queryClient.setQueryData(queryKey, context);
toast.success("Project context updated");
},
onError: (error) =>
toast.error(getStandardErrorMessage(error, "Couldn't save your changes")),
// The page instantiates this mutation per section, so two concurrent
// patches can settle out of order and the slower (earlier-snapshotted)
// response can land in the cache last; a settle-time refetch converges
// the page back onto the server's state.
onSettled: () => queryClient.invalidateQueries({ queryKey }),
});
}
const AUTHOR_LABELS: Record<ContextAuthor, string> = {
user: "you",
sam: "SAM",
mcp: "your AI client",
};
export function Provenance({ by, at }: { by: ContextAuthor; at?: string }) {
return (
<span className="text-xs text-base-content/40">
{at
? `Updated by ${AUTHOR_LABELS[by]} · ${formatRelativeTime(at)}`
: `Added by ${AUTHOR_LABELS[by]}`}
</span>
);
}
function formatRelativeTime(iso: string): string {
const timestamp = new Date(iso).getTime();
if (Number.isNaN(timestamp)) return "recently";
const minutes = Math.floor((Date.now() - timestamp) / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString();
}
export function SectionHeader({
title,
hint,
action,
}: {
title: string;
hint?: string;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<h2 className="text-sm font-medium text-base-content/50">{title}</h2>
{hint ? <p className="text-xs text-base-content/50">{hint}</p> : null}
</div>
{action}
</div>
);
}
/** Muted panel used when a list has nothing in it yet. */
export function EmptyState({ children }: { children: ReactNode }) {
return (
<p className="rounded-lg border border-dashed border-base-300 px-4 py-3 text-sm text-base-content/60">
{children}
</p>
);
}
export const listClass =
"divide-y divide-base-300 overflow-hidden rounded-lg border border-base-300";
/** Row actions and footer buttons shared by the inline competitor/page forms. */
export function RowActions({ children }: { children: ReactNode }) {
return <div className="flex shrink-0 items-center gap-1">{children}</div>;
}
/**
* Two-step delete: the trash icon swaps to an explicit Remove/Cancel pair, so
* a stray click can't destroy anything and no native confirm dialog is needed.
*/
export function ConfirmDeleteButton({
label,
pending,
onConfirm,
}: {
label: string;
pending: boolean;
onConfirm: () => void;
}) {
const [confirming, setConfirming] = useState(false);
if (confirming) {
return (
<>
<button
type="button"
className="btn btn-error btn-xs"
disabled={pending}
onClick={() => {
setConfirming(false);
onConfirm();
}}
>
Remove
</button>
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => setConfirming(false)}
>
Cancel
</button>
</>
);
}
return (
<button
type="button"
className="btn btn-ghost btn-xs text-error"
aria-label={label}
disabled={pending}
onClick={() => setConfirming(true)}
>
<Trash2 className="size-3.5" />
</button>
);
}
export function FormActions({
pending,
disabled,
onCancel,
}: {
pending: boolean;
disabled: boolean;
onCancel: () => void;
}) {
return (
<div className="flex justify-end gap-2">
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={onCancel}
disabled={pending}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-xs"
disabled={disabled || pending}
>
Save
</button>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import { Suspense, useCallback, useEffect } from "react";
import { Loader2, Plus, Wrench } from "lucide-react";
import { Brain, Loader2, Plus, Wrench } from "lucide-react";
import { createSamSession } from "@/serverFunctions/sam";
import {
invalidateSamSessions,
@ -75,25 +75,45 @@ export function SamChat({
}
if (activeSessionId) {
const activeTitle = sessions.find(
(session) => session.id === activeSessionId,
)?.title;
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 className="flex h-full min-h-0 flex-col">
{/* Session title + the shortest path to inspect or correct the shared
memory SAM reads and writes during the conversation. */}
<div className="flex items-center justify-between gap-3 border-b border-base-300 px-5 py-3.5">
<span className="truncate text-sm font-medium text-base-content/80">
{activeTitle ?? "Chat"}
</span>
<Link
to="/p/$projectId/settings/context"
params={{ projectId }}
className="flex shrink-0 items-center gap-1.5 text-xs text-base-content/60 transition-colors hover:text-base-content"
>
<Brain className="size-3.5" />
Project memory
</Link>
</div>
<div className="flex min-h-0 flex-1">
{/* 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>
</div>
);
}

View File

@ -201,9 +201,8 @@ export function SearchPerformancePage({ projectId }: { projectId: string }) {
</div>
{report?.connected ? (
<Link
to="/p/$projectId/settings"
to="/p/$projectId/settings/integrations"
params={{ projectId }}
hash="search-console"
className="link link-hover shrink-0 self-start text-sm font-medium text-base-content/60 transition-colors hover:text-base-content sm:mt-1"
>
Change property

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 "../project-context.schema";
export * from "../audit.schema";
export * from "../sam.schema";
export * from "../better-auth-schema";

View File

@ -0,0 +1,113 @@
import { sql } from "drizzle-orm";
import {
index,
pgTable,
primaryKey,
text,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { projects } from "./app.schema";
// Timestamps are stored as *text* (same column shape as the SQLite schema); see
// the note in pg/app.schema.ts. `isoNow` matches `new Date().toISOString()` so
// DB-defaulted and app-written values sort together lexicographically.
const isoNow = sql`to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
// ============================================================================
// Project memory: the shared AI context every surface (SAM, MCP, settings UI)
// reads and writes. Prose lives in the sections table; list-shaped knowledge is
// normalized so it stays joinable instead of buried in markdown.
// ============================================================================
// One row per (project, section). `key` is either a typed key
// ("business_overview", "current_goal", "positioning", "writing_preferences")
// or "custom:<slug>" for an agent-created section, in which case `title` holds
// its display name.
export const projectContextSections = pgTable(
"project_context_sections",
{
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
key: text("key").notNull(),
title: text("title"),
content: text("content").notNull(),
updatedAt: text("updated_at").notNull().default(isoNow),
updatedBy: text("updated_by", { enum: ["user", "sam", "mcp"] }).notNull(),
},
// The composite PK is project-leading, so it also serves the "load every
// section for this project" read.
(table) => [primaryKey({ columns: [table.projectId, table.key] })],
);
export const projectCompetitors = pgTable(
"project_competitors",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
// Normalized bare host (lowercase, no protocol/www), so agents adding the
// same competitor from different surfaces upsert onto one row.
domain: text("domain").notNull(),
name: text("name"),
notes: text("notes"),
updatedAt: text("updated_at").notNull().default(isoNow),
updatedBy: text("updated_by", { enum: ["user", "sam", "mcp"] }).notNull(),
},
(table) => [
uniqueIndex("project_competitors_project_domain_idx").on(
table.projectId,
table.domain,
),
],
);
// A curated shortlist of pages that matter, NOT a page inventory — the
// inventory lives in audit_pages and GSC.
export const projectKeyPages = pgTable(
"project_key_pages",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
url: text("url").notNull(),
role: text("role", { enum: ["hub", "spoke", "money", "other"] }).notNull(),
topic: text("topic"),
notes: text("notes"),
updatedAt: text("updated_at").notNull().default(isoNow),
updatedBy: text("updated_by", { enum: ["user", "sam", "mcp"] }).notNull(),
},
(table) => [
uniqueIndex("project_key_pages_project_url_idx").on(
table.projectId,
table.url,
),
],
);
// What research has already been bought and what it concluded, so SAM and
// Claude Code stop re-buying the same paid research. Pruned to 90 days on
// append; the date is server-stamped, never supplied by the caller.
export const projectResearchLog = pgTable(
"project_research_log",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
entryDate: text("entry_date").notNull(),
summary: text("summary").notNull(),
createdBy: text("created_by", { enum: ["user", "sam", "mcp"] }).notNull(),
// entry_date is a day stamp, so recency needs its own column — same-day
// entries would otherwise tie-break on a random uuid.
createdAt: text("created_at").notNull().default(isoNow),
},
(table) => [
index("project_research_log_project_date_idx").on(
table.projectId,
table.entryDate,
),
],
);

View File

@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { index, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import { index, pgTable, text } from "drizzle-orm/pg-core";
import { user } from "./better-auth-schema";
import { projects } from "./app.schema";
@ -37,18 +37,3 @@ export const samSessions = pgTable(
),
],
);
// 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 "./project-context.schema";
export * from "./audit.schema";
export * from "./sam.schema";
export * from "./better-auth-schema";

View File

@ -0,0 +1,118 @@
import {
index,
primaryKey,
sqliteTable,
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { projects } from "./app.schema";
// ============================================================================
// Project memory: the shared AI context every surface (SAM, MCP, settings UI)
// reads and writes. Prose lives in the sections table; list-shaped knowledge is
// normalized so it stays joinable instead of buried in markdown.
// ============================================================================
// One row per (project, section). `key` is either a typed key
// ("business_overview", "current_goal", "positioning", "writing_preferences")
// or "custom:<slug>" for an agent-created section, in which case `title` holds
// its display name.
export const projectContextSections = sqliteTable(
"project_context_sections",
{
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
key: text("key").notNull(),
title: text("title"),
content: text("content").notNull(),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedBy: text("updated_by", { enum: ["user", "sam", "mcp"] }).notNull(),
},
// The composite PK is project-leading, so it also serves the "load every
// section for this project" read.
(table) => [primaryKey({ columns: [table.projectId, table.key] })],
);
export const projectCompetitors = sqliteTable(
"project_competitors",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
// Normalized bare host (lowercase, no protocol/www), so agents adding the
// same competitor from different surfaces upsert onto one row.
domain: text("domain").notNull(),
name: text("name"),
notes: text("notes"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedBy: text("updated_by", { enum: ["user", "sam", "mcp"] }).notNull(),
},
(table) => [
uniqueIndex("project_competitors_project_domain_idx").on(
table.projectId,
table.domain,
),
],
);
// A curated shortlist of pages that matter, NOT a page inventory — the
// inventory lives in audit_pages and GSC.
export const projectKeyPages = sqliteTable(
"project_key_pages",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
url: text("url").notNull(),
role: text("role", { enum: ["hub", "spoke", "money", "other"] }).notNull(),
topic: text("topic"),
notes: text("notes"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedBy: text("updated_by", { enum: ["user", "sam", "mcp"] }).notNull(),
},
(table) => [
uniqueIndex("project_key_pages_project_url_idx").on(
table.projectId,
table.url,
),
],
);
// What research has already been bought and what it concluded, so SAM and
// Claude Code stop re-buying the same paid research. Pruned to 90 days on
// append; the date is server-stamped, never supplied by the caller.
export const projectResearchLog = sqliteTable(
"project_research_log",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
entryDate: text("entry_date").notNull(),
summary: text("summary").notNull(),
createdBy: text("created_by", { enum: ["user", "sam", "mcp"] }).notNull(),
// entry_date is a day stamp, so recency needs its own column — same-day
// entries would otherwise tie-break on a random uuid. The default emits
// ISO (unlike current_timestamp's space format) because listResearchLog
// orders this column lexicographically against app-written ISO stamps.
createdAt: text("created_at")
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
},
(table) => [
index("project_research_log_project_date_idx").on(
table.projectId,
table.entryDate,
),
],
);

View File

@ -1,4 +1,4 @@
import { sqliteTable, text, index, primaryKey } from "drizzle-orm/sqlite-core";
import { sqliteTable, text, index } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { user } from "./better-auth-schema";
import { projects } from "./app.schema";
@ -38,24 +38,3 @@ export const samSessions = sqliteTable(
),
],
);
// 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,6 +5,7 @@ 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 sqliteProjectContext from "./project-context.schema";
import * as sqliteAudit from "./audit.schema";
import * as sqliteSam from "./sam.schema";
import * as sqliteAuth from "./better-auth-schema";
@ -13,6 +14,7 @@ import * as sqliteGa4 from "./ga4.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteTelemetry from "./telemetry.schema";
import * as pgApp from "./pg/app.schema";
import * as pgProjectContext from "./pg/project-context.schema";
import * as pgAudit from "./pg/audit.schema";
import * as pgSam from "./pg/sam.schema";
import * as pgAuth from "./pg/better-auth-schema";
@ -143,6 +145,7 @@ function checkNames(table: Table, dialect: Dialect): string[] {
const sqliteAppTables = tablesFrom(
sqliteApp,
sqliteProjectContext,
sqliteAudit,
sqliteSam,
sqliteBilling,
@ -152,6 +155,7 @@ const sqliteAppTables = tablesFrom(
);
const pgAppTables = tablesFrom(
pgApp,
pgProjectContext,
pgAudit,
pgSam,
pgBilling,

View File

@ -1,5 +1,6 @@
import { getDatabaseProvider } from "./provider";
import * as sqliteApp from "./app.schema";
import * as sqliteProjectContext from "./project-context.schema";
import * as sqliteAudit from "./audit.schema";
import * as sqliteSam from "./sam.schema";
import * as sqliteAuth from "./better-auth-schema";
@ -8,6 +9,7 @@ import * as sqliteGa4 from "./ga4.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteTelemetry from "./telemetry.schema";
import * as pgApp from "./pg/app.schema";
import * as pgProjectContext from "./pg/project-context.schema";
import * as pgAudit from "./pg/audit.schema";
import * as pgSam from "./pg/sam.schema";
import * as pgAuth from "./pg/better-auth-schema";
@ -27,6 +29,7 @@ import * as pgTelemetry from "./pg/telemetry.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 sqliteProjectContext &
typeof sqliteAudit &
typeof sqliteSam &
typeof sqliteAuth &
@ -39,6 +42,7 @@ const runtimeSchema =
getDatabaseProvider() === "postgres"
? {
...pgApp,
...pgProjectContext,
...pgAudit,
...pgSam,
...pgAuth,
@ -49,6 +53,7 @@ const runtimeSchema =
}
: {
...sqliteApp,
...sqliteProjectContext,
...sqliteAudit,
...sqliteSam,
...sqliteAuth,
@ -75,12 +80,15 @@ export const {
organizationActivationState,
projectActivationState,
backlinkSnapshots,
projectContextSections,
projectCompetitors,
projectKeyPages,
projectResearchLog,
audits,
auditPages,
auditIssues,
auditLighthouseResults,
samSessions,
samProjectMemory,
user,
session,
account,

View File

@ -49,8 +49,11 @@ import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
import { Route as ProjectPProjectIdSettingsIndexRouteImport } from './routes/_project/p/$projectId/settings/index'
import { Route as ProjectPProjectIdRankTrackingIndexRouteImport } from './routes/_project/p/$projectId/rank-tracking/index'
import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
import { Route as ProjectPProjectIdSettingsIntegrationsRouteImport } from './routes/_project/p/$projectId/settings/integrations'
import { Route as ProjectPProjectIdSettingsContextRouteImport } from './routes/_project/p/$projectId/settings/context'
import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId'
import { Route as ProjectPProjectIdAuditIssuesResultIdRouteImport } from './routes/_project/p/$projectId/audit/issues/$resultId'
@ -261,6 +264,12 @@ const ProjectPProjectIdAuditRoute = ProjectPProjectIdAuditRouteImport.update({
path: '/audit',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdSettingsIndexRoute =
ProjectPProjectIdSettingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => ProjectPProjectIdSettingsRoute,
} as any)
const ProjectPProjectIdRankTrackingIndexRoute =
ProjectPProjectIdRankTrackingIndexRouteImport.update({
id: '/',
@ -273,6 +282,18 @@ const ProjectPProjectIdAuditIndexRoute =
path: '/',
getParentRoute: () => ProjectPProjectIdAuditRoute,
} as any)
const ProjectPProjectIdSettingsIntegrationsRoute =
ProjectPProjectIdSettingsIntegrationsRouteImport.update({
id: '/integrations',
path: '/integrations',
getParentRoute: () => ProjectPProjectIdSettingsRoute,
} as any)
const ProjectPProjectIdSettingsContextRoute =
ProjectPProjectIdSettingsContextRouteImport.update({
id: '/context',
path: '/context',
getParentRoute: () => ProjectPProjectIdSettingsRoute,
} as any)
const ProjectPProjectIdRankTrackingConfigIdRoute =
ProjectPProjectIdRankTrackingConfigIdRouteImport.update({
id: '/$configId',
@ -319,13 +340,16 @@ export interface FileRoutesByFullPath {
'/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRouteWithChildren
'/api/ga4/oauth/callback': typeof ApiGa4OauthCallbackRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
'/p/$projectId/settings/context': typeof ProjectPProjectIdSettingsContextRoute
'/p/$projectId/settings/integrations': typeof ProjectPProjectIdSettingsIntegrationsRoute
'/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
'/p/$projectId/rank-tracking/': typeof ProjectPProjectIdRankTrackingIndexRoute
'/p/$projectId/settings/': typeof ProjectPProjectIdSettingsIndexRoute
'/p/$projectId/audit/issues/$resultId': typeof ProjectPProjectIdAuditIssuesResultIdRoute
}
export interface FileRoutesByTo {
@ -358,13 +382,15 @@ export interface FileRoutesByTo {
'/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/api/ga4/oauth/callback': typeof ApiGa4OauthCallbackRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
'/p/$projectId/settings/context': typeof ProjectPProjectIdSettingsContextRoute
'/p/$projectId/settings/integrations': typeof ProjectPProjectIdSettingsIntegrationsRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingIndexRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsIndexRoute
'/p/$projectId/audit/issues/$resultId': typeof ProjectPProjectIdAuditIssuesResultIdRoute
}
export interface FileRoutesById {
@ -405,13 +431,16 @@ export interface FileRoutesById {
'/_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
'/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRouteWithChildren
'/api/ga4/oauth/callback': typeof ApiGa4OauthCallbackRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
'/_project/p/$projectId/settings/context': typeof ProjectPProjectIdSettingsContextRoute
'/_project/p/$projectId/settings/integrations': typeof ProjectPProjectIdSettingsIntegrationsRoute
'/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
'/_project/p/$projectId/rank-tracking/': typeof ProjectPProjectIdRankTrackingIndexRoute
'/_project/p/$projectId/settings/': typeof ProjectPProjectIdSettingsIndexRoute
'/_project/p/$projectId/audit/issues/$resultId': typeof ProjectPProjectIdAuditIssuesResultIdRoute
}
export interface FileRouteTypes {
@ -454,8 +483,11 @@ export interface FileRouteTypes {
| '/api/gsc/oauth/callback'
| '/p/$projectId/'
| '/p/$projectId/rank-tracking/$configId'
| '/p/$projectId/settings/context'
| '/p/$projectId/settings/integrations'
| '/p/$projectId/audit/'
| '/p/$projectId/rank-tracking/'
| '/p/$projectId/settings/'
| '/p/$projectId/audit/issues/$resultId'
fileRoutesByTo: FileRoutesByTo
to:
@ -488,13 +520,15 @@ export interface FileRouteTypes {
| '/p/$projectId/sam'
| '/p/$projectId/saved'
| '/p/$projectId/search-performance'
| '/p/$projectId/settings'
| '/api/ga4/oauth/callback'
| '/api/gsc/oauth/callback'
| '/p/$projectId'
| '/p/$projectId/rank-tracking/$configId'
| '/p/$projectId/settings/context'
| '/p/$projectId/settings/integrations'
| '/p/$projectId/audit'
| '/p/$projectId/rank-tracking'
| '/p/$projectId/settings'
| '/p/$projectId/audit/issues/$resultId'
id:
| '__root__'
@ -539,8 +573,11 @@ export interface FileRouteTypes {
| '/api/gsc/oauth/callback'
| '/_project/p/$projectId/'
| '/_project/p/$projectId/rank-tracking/$configId'
| '/_project/p/$projectId/settings/context'
| '/_project/p/$projectId/settings/integrations'
| '/_project/p/$projectId/audit/'
| '/_project/p/$projectId/rank-tracking/'
| '/_project/p/$projectId/settings/'
| '/_project/p/$projectId/audit/issues/$resultId'
fileRoutesById: FileRoutesById
}
@ -842,6 +879,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdAuditRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
'/_project/p/$projectId/settings/': {
id: '/_project/p/$projectId/settings/'
path: '/'
fullPath: '/p/$projectId/settings/'
preLoaderRoute: typeof ProjectPProjectIdSettingsIndexRouteImport
parentRoute: typeof ProjectPProjectIdSettingsRoute
}
'/_project/p/$projectId/rank-tracking/': {
id: '/_project/p/$projectId/rank-tracking/'
path: '/'
@ -856,6 +900,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdAuditIndexRouteImport
parentRoute: typeof ProjectPProjectIdAuditRoute
}
'/_project/p/$projectId/settings/integrations': {
id: '/_project/p/$projectId/settings/integrations'
path: '/integrations'
fullPath: '/p/$projectId/settings/integrations'
preLoaderRoute: typeof ProjectPProjectIdSettingsIntegrationsRouteImport
parentRoute: typeof ProjectPProjectIdSettingsRoute
}
'/_project/p/$projectId/settings/context': {
id: '/_project/p/$projectId/settings/context'
path: '/context'
fullPath: '/p/$projectId/settings/context'
preLoaderRoute: typeof ProjectPProjectIdSettingsContextRouteImport
parentRoute: typeof ProjectPProjectIdSettingsRoute
}
'/_project/p/$projectId/rank-tracking/$configId': {
id: '/_project/p/$projectId/rank-tracking/$configId'
path: '/$configId'
@ -934,6 +992,26 @@ const ProjectPProjectIdRankTrackingRouteWithChildren =
ProjectPProjectIdRankTrackingRouteChildren,
)
interface ProjectPProjectIdSettingsRouteChildren {
ProjectPProjectIdSettingsContextRoute: typeof ProjectPProjectIdSettingsContextRoute
ProjectPProjectIdSettingsIntegrationsRoute: typeof ProjectPProjectIdSettingsIntegrationsRoute
ProjectPProjectIdSettingsIndexRoute: typeof ProjectPProjectIdSettingsIndexRoute
}
const ProjectPProjectIdSettingsRouteChildren: ProjectPProjectIdSettingsRouteChildren =
{
ProjectPProjectIdSettingsContextRoute:
ProjectPProjectIdSettingsContextRoute,
ProjectPProjectIdSettingsIntegrationsRoute:
ProjectPProjectIdSettingsIntegrationsRoute,
ProjectPProjectIdSettingsIndexRoute: ProjectPProjectIdSettingsIndexRoute,
}
const ProjectPProjectIdSettingsRouteWithChildren =
ProjectPProjectIdSettingsRoute._addFileChildren(
ProjectPProjectIdSettingsRouteChildren,
)
interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdAuditRoute: typeof ProjectPProjectIdAuditRouteWithChildren
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
@ -945,7 +1023,7 @@ interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdSamRoute: typeof ProjectPProjectIdSamRoute
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
ProjectPProjectIdSearchPerformanceRoute: typeof ProjectPProjectIdSearchPerformanceRoute
ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRoute
ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRouteWithChildren
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
}
@ -963,7 +1041,7 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
ProjectPProjectIdSearchPerformanceRoute:
ProjectPProjectIdSearchPerformanceRoute,
ProjectPProjectIdSettingsRoute: ProjectPProjectIdSettingsRoute,
ProjectPProjectIdSettingsRoute: ProjectPProjectIdSettingsRouteWithChildren,
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
}

View File

@ -21,6 +21,8 @@ const SKILL_NAMES = [
"competitive-landscape",
"competitor-analysis",
"link-prospecting",
"local-seo",
"seo-audit",
];
const SKILLS_INSTALL = `npx skills add every-app/open-seo`;
const ALL_SKILLS_INSTALL = `npx skills add every-app/open-seo --skill '*'`;
@ -269,8 +271,8 @@ function AiPage() {
<span className="font-mono text-base-content">
/seo-project-setup
</span>
. It will ask about your project and help configure your
workspace.
. It will ask about your project and save your goals, positioning,
and competitors to your project context.
</p>
<p className="mt-4 text-xs font-medium uppercase tracking-wide text-base-content/50">
Available skills

View File

@ -1,7 +1,7 @@
import {
Outlet,
createFileRoute,
useLocation,
useMatch,
useNavigate,
} from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
@ -65,12 +65,16 @@ function ProjectLayout() {
useProjectAccessRedirect(projectId);
// Remember this as the last-visited project for the landing redirect.
// Settings is excluded: editing another project's settings is
// administration, not a context switch, so it shouldn't change which
// project the app opens next time.
const isSettingsPage = useLocation({
select: (l) => l.pathname.endsWith("/settings"),
});
// Settings and its sub-pages are excluded: editing another project's
// settings is administration, not a context switch, so it shouldn't change
// which project the app opens next time. (An explicit choice still counts:
// the switcher and project creation set it themselves, settings page or not.)
const isSettingsPage =
useMatch({
from: "/_project/p/$projectId/settings",
shouldThrow: false,
select: () => true,
}) ?? false;
useEffect(() => {
if (isSettingsPage) return;
setLastProjectId(projectId);

View File

@ -1,15 +1,68 @@
import { createFileRoute } from "@tanstack/react-router";
import { ProjectSettings } from "@/client/features/projects/ProjectSettings";
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft } from "lucide-react";
import { getProjects } from "@/serverFunctions/projects";
export const Route = createFileRoute("/_project/p/$projectId/settings")({
component: ProjectSettingsRoute,
component: ProjectSettingsLayout,
});
function ProjectSettingsRoute() {
const tabs = [
{ to: "/p/$projectId/settings" as const, label: "General", exact: true },
{ to: "/p/$projectId/settings/context" as const, label: "Context" },
{ to: "/p/$projectId/settings/integrations" as const, label: "Integrations" },
];
function ProjectSettingsLayout() {
const { projectId } = Route.useParams();
const projectsQuery = useQuery({
queryKey: ["projects"],
queryFn: () => getProjects(),
});
const project = projectsQuery.data?.find((entry) => entry.id === projectId);
return (
<div className="h-full overflow-auto bg-base-100">
<ProjectSettings projectId={projectId} />
<div className="mx-auto w-full max-w-2xl space-y-8 p-4 py-8 pb-24 sm:p-6 md:py-12 md:pb-12">
<div className="space-y-4">
<Link
to="/projects"
className="inline-flex items-center gap-1 text-sm text-base-content/60 transition-colors hover:text-base-content"
>
<ChevronLeft className="size-4" />
Projects
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight">
Project settings
</h1>
<p className="text-sm text-base-content/60">
{project?.name ?? " "}
</p>
</div>
<div role="tablist" className="tabs tabs-border">
{tabs.map((tab) => (
<Link
key={tab.to}
role="tab"
to={tab.to}
params={{ projectId }}
activeOptions={{ exact: tab.exact ?? false }}
className="tab"
activeProps={{
className: "tab-active",
"aria-selected": true,
}}
inactiveProps={{ "aria-selected": false }}
>
{tab.label}
</Link>
))}
</div>
</div>
<Outlet />
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { ProjectContextPage } from "@/client/features/projects/project-context/ProjectContextPage";
export const Route = createFileRoute("/_project/p/$projectId/settings/context")(
{
component: ProjectContextRoute,
},
);
function ProjectContextRoute() {
const { projectId } = Route.useParams();
return <ProjectContextPage projectId={projectId} />;
}

View File

@ -0,0 +1,27 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { ProjectGeneralSettings } from "@/client/features/projects/ProjectGeneralSettings";
// The connection cards used to live on this page and were linked to by anchor.
// Those links are still in the wild (older emails, agent output), so forward
// them to the sub-page that now owns the cards.
const MOVED_TO_INTEGRATIONS = ["search-console", "google-analytics"];
export const Route = createFileRoute("/_project/p/$projectId/settings/")({
beforeLoad: ({ params, location }) => {
const hash = location.hash.replace(/^#/, "");
if (!MOVED_TO_INTEGRATIONS.includes(hash)) return;
throw redirect({
to: "/p/$projectId/settings/integrations",
params: { projectId: params.projectId },
hash,
replace: true,
});
},
component: ProjectGeneralSettingsRoute,
});
function ProjectGeneralSettingsRoute() {
const { projectId } = Route.useParams();
return <ProjectGeneralSettings projectId={projectId} />;
}

View File

@ -0,0 +1,37 @@
import { createFileRoute } from "@tanstack/react-router";
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
import { GoogleAnalyticsConnectionCard } from "@/client/features/ga4/GoogleAnalyticsConnectionCard";
export const Route = createFileRoute(
"/_project/p/$projectId/settings/integrations",
)({
component: ProjectIntegrationsRoute,
});
function ProjectIntegrationsRoute() {
const { projectId } = Route.useParams();
return (
<div className="space-y-8">
{/* The ids are the targets old #search-console / #google-analytics deep
links are redirected to from the settings index. */}
<section id="search-console" className="scroll-mt-6 space-y-3">
<h2 className="text-sm font-medium text-base-content/50">
Search Console
</h2>
<SearchConsoleConnectionCard projectId={projectId} />
</section>
<section id="google-analytics" className="scroll-mt-6 space-y-3">
<GoogleAnalyticsConnectionCard
projectId={projectId}
heading={
<h2 className="text-sm font-medium text-base-content/50">
Analytics
</h2>
}
/>
</section>
</div>
);
}

View File

@ -0,0 +1,156 @@
import { readFileSync } from "node:fs";
import { createClient, type Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import type * as ProjectContextRepositoryModule from "./ProjectContextRepository";
// Real in-memory SQLite so the upsert conflict clauses run as generated SQL —
// which stored fields an omitted value preserves is the whole contract, and a
// mocked builder chain can't see it.
vi.mock("cloudflare:workers", () => ({
env: { DATABASE_PROVIDER: "d1" },
}));
// The executor the service hands these builders inside runBatch.
type Tx = Parameters<
typeof ProjectContextRepositoryModule.ProjectContextRepository.upsertKeyPages
>[0];
let client: Client;
let tx: Tx;
let ProjectContextRepository: typeof ProjectContextRepositoryModule.ProjectContextRepository;
beforeAll(async () => {
client = createClient({ url: "file::memory:" });
const testDb = drizzle(client);
vi.doMock("@/db", () => ({ db: testDb }));
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the libsql client is the same Drizzle query surface runBatch passes in
tx = testDb as unknown as Tx;
// The tables come from the real migration, so the unique indexes the
// upserts' ON CONFLICT clauses resolve against can't drift from production
// DDL. The sam_project_memory DROP is skipped (nothing created it here);
// a stub projects table satisfies the FKs.
await client.executeMultiple(
[
`CREATE TABLE projects (id text PRIMARY KEY);`,
`INSERT INTO projects (id) VALUES ('proj_1');`,
...readFileSync("drizzle/0042_project_memory.sql", "utf8")
.split("--> statement-breakpoint")
.filter((statement) => !statement.includes("DROP TABLE")),
].join("\n"),
);
({ ProjectContextRepository } = await import("./ProjectContextRepository"));
});
afterAll(() => {
client.close();
});
beforeEach(async () => {
vi.useFakeTimers();
await client.executeMultiple(`
DELETE FROM project_key_pages;
DELETE FROM project_competitors;
`);
});
afterEach(() => {
vi.useRealTimers();
});
const PROJECT_ID = "proj_1";
async function runStatements(statements: Promise<unknown>[]) {
for (const statement of statements) await statement;
}
describe("upsertKeyPages", () => {
it("keeps the stored role when the caller omits it", async () => {
vi.setSystemTime("2026-08-01T00:00:00.000Z");
await runStatements(
ProjectContextRepository.upsertKeyPages(
tx,
PROJECT_ID,
[
{
url: "https://acme.com/pricing",
role: "money",
topic: null,
notes: null,
},
],
"user",
),
);
vi.setSystemTime("2026-08-02T00:00:00.000Z");
await runStatements(
ProjectContextRepository.upsertKeyPages(
tx,
PROJECT_ID,
[
{
url: "https://acme.com/pricing",
role: null,
topic: "Pricing",
notes: "Compare against acme.io",
},
],
"mcp",
),
);
expect(await ProjectContextRepository.listKeyPages(PROJECT_ID)).toEqual([
expect.objectContaining({
url: "https://acme.com/pricing",
role: "money",
topic: "Pricing",
notes: "Compare against acme.io",
updatedAt: "2026-08-02T00:00:00.000Z",
updatedBy: "mcp",
}),
]);
});
});
describe("upsertCompetitors", () => {
it("keeps stored notes when the caller omits them", async () => {
await runStatements(
ProjectContextRepository.upsertCompetitors(
tx,
PROJECT_ID,
[{ domain: "acme.com", name: null, notes: "user note" }],
"user",
),
);
await runStatements(
ProjectContextRepository.upsertCompetitors(
tx,
PROJECT_ID,
[{ domain: "acme.com", name: "Acme", notes: null }],
"mcp",
),
);
expect(await ProjectContextRepository.listCompetitors(PROJECT_ID)).toEqual([
expect.objectContaining({
domain: "acme.com",
name: "Acme",
notes: "user note",
}),
]);
});
});

View File

@ -0,0 +1,283 @@
import { and, asc, desc, eq, inArray, lt, sql } from "drizzle-orm";
import { db } from "@/db";
import type { runBatch } from "@/db/runBatch";
import {
projectCompetitors,
projectContextSections,
projectKeyPages,
projectResearchLog,
} from "@/db/schema";
import type {
ContextAuthor,
KeyPageRole,
} from "@/types/schemas/projectContext";
// Backing store for project memory. Every surface (settings UI, MCP tools, SAM)
// reads and writes these same rows through ProjectContextService. Reads execute
// directly; writes are statement builders the service runs atomically inside
// one `runBatch`, so a failing op can't leave the project half-updated.
type Tx = Parameters<Parameters<typeof runBatch>[0]>[0];
type CompetitorRow = {
domain: string;
name: string | null;
notes: string | null;
};
type KeyPageRow = {
url: string;
// null = caller omitted the role: keep the stored classification on upsert
// (a user's hand-set "money page" must survive an agent re-add).
role: KeyPageRole | null;
topic: string | null;
notes: string | null;
};
// D1 caps bound parameters per statement, so multi-row writes are chunked into
// several statements within the same atomic batch.
const ROWS_PER_INSERT = 10;
const VALUES_PER_DELETE = 90;
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) {
out.push(items.slice(i, i + size));
}
return out;
}
async function listSections(projectId: string) {
return db
.select()
.from(projectContextSections)
.where(eq(projectContextSections.projectId, projectId))
.orderBy(asc(projectContextSections.key));
}
function upsertSection(
tx: Tx,
params: {
projectId: string;
key: string;
title: string | null;
content: string;
updatedBy: ContextAuthor;
},
) {
const updatedAt = new Date().toISOString();
return tx
.insert(projectContextSections)
.values({ ...params, updatedAt })
.onConflictDoUpdate({
target: [projectContextSections.projectId, projectContextSections.key],
set: {
// A title is only sent when the caller renames a custom section, so an
// omitted one keeps the stored name.
title: sql`coalesce(excluded.title, ${projectContextSections.title})`,
content: params.content,
updatedAt,
updatedBy: params.updatedBy,
},
});
}
function deleteSection(tx: Tx, projectId: string, key: string) {
return tx
.delete(projectContextSections)
.where(
and(
eq(projectContextSections.projectId, projectId),
eq(projectContextSections.key, key),
),
);
}
async function listCompetitors(projectId: string) {
return db
.select()
.from(projectCompetitors)
.where(eq(projectCompetitors.projectId, projectId))
.orderBy(asc(projectCompetitors.domain));
}
// Upsert by (project, domain). Fields the caller omitted keep their stored
// value — an agent adding a domain it already knows must not wipe the notes a
// user wrote.
function upsertCompetitors(
tx: Tx,
projectId: string,
rows: CompetitorRow[],
updatedBy: ContextAuthor,
) {
const updatedAt = new Date().toISOString();
return chunk(rows, ROWS_PER_INSERT).map((rowChunk) =>
tx
.insert(projectCompetitors)
.values(
rowChunk.map((row) => ({
id: crypto.randomUUID(),
projectId,
...row,
updatedAt,
updatedBy,
})),
)
.onConflictDoUpdate({
target: [projectCompetitors.projectId, projectCompetitors.domain],
set: {
name: sql`coalesce(excluded.name, ${projectCompetitors.name})`,
notes: sql`coalesce(excluded.notes, ${projectCompetitors.notes})`,
updatedAt,
updatedBy,
},
}),
);
}
function deleteCompetitors(tx: Tx, projectId: string, domains: string[]) {
return chunk(domains, VALUES_PER_DELETE).map((domainChunk) =>
tx
.delete(projectCompetitors)
.where(
and(
eq(projectCompetitors.projectId, projectId),
inArray(projectCompetitors.domain, domainChunk),
),
),
);
}
async function listKeyPages(projectId: string) {
return db
.select()
.from(projectKeyPages)
.where(eq(projectKeyPages.projectId, projectId))
.orderBy(asc(projectKeyPages.url));
}
function upsertKeyPages(
tx: Tx,
projectId: string,
rows: KeyPageRow[],
updatedBy: ContextAuthor,
) {
const updatedAt = new Date().toISOString();
const buildInsert = (rowChunk: KeyPageRow[], setRole: boolean) =>
tx
.insert(projectKeyPages)
.values(
rowChunk.map((row) => ({
id: crypto.randomUUID(),
projectId,
...row,
// New rows need a concrete role; existing rows keep theirs below.
role: row.role ?? "other",
updatedAt,
updatedBy,
})),
)
.onConflictDoUpdate({
target: [projectKeyPages.projectId, projectKeyPages.url],
set: {
// Omitting the role from the SET keeps the stored classification.
...(setRole ? { role: sql`excluded.role` } : {}),
topic: sql`coalesce(excluded.topic, ${projectKeyPages.topic})`,
notes: sql`coalesce(excluded.notes, ${projectKeyPages.notes})`,
updatedAt,
updatedBy,
},
});
const withRole = rows.filter((row) => row.role !== null);
const withoutRole = rows.filter((row) => row.role === null);
return [
...chunk(withRole, ROWS_PER_INSERT).map((c) => buildInsert(c, true)),
...chunk(withoutRole, ROWS_PER_INSERT).map((c) => buildInsert(c, false)),
];
}
function deleteKeyPages(tx: Tx, projectId: string, urls: string[]) {
return chunk(urls, VALUES_PER_DELETE).map((urlChunk) =>
tx
.delete(projectKeyPages)
.where(
and(
eq(projectKeyPages.projectId, projectId),
inArray(projectKeyPages.url, urlChunk),
),
),
);
}
async function listResearchLog(projectId: string, limit: number) {
return db
.select()
.from(projectResearchLog)
.where(eq(projectResearchLog.projectId, projectId))
.orderBy(
desc(projectResearchLog.createdAt),
desc(projectResearchLog.entryDate),
desc(projectResearchLog.id),
)
.limit(limit);
}
function appendResearchLogEntry(
tx: Tx,
params: {
projectId: string;
entryDate: string;
summary: string;
createdBy: ContextAuthor;
},
) {
// createdAt is stamped here, not left to the column default: the dialects'
// defaults render different string formats (and second/tx granularity),
// which would break the lexicographic ORDER BY created_at in listResearchLog.
return tx.insert(projectResearchLog).values({
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
...params,
});
}
function deleteResearchLogEntries(tx: Tx, projectId: string, ids: string[]) {
return chunk(ids, VALUES_PER_DELETE).map((idChunk) =>
tx
.delete(projectResearchLog)
.where(
and(
eq(projectResearchLog.projectId, projectId),
inArray(projectResearchLog.id, idChunk),
),
),
);
}
function pruneResearchLogBefore(tx: Tx, projectId: string, entryDate: string) {
return tx
.delete(projectResearchLog)
.where(
and(
eq(projectResearchLog.projectId, projectId),
lt(projectResearchLog.entryDate, entryDate),
),
);
}
export const ProjectContextRepository = {
listSections,
upsertSection,
deleteSection,
listCompetitors,
upsertCompetitors,
deleteCompetitors,
listKeyPages,
upsertKeyPages,
deleteKeyPages,
listResearchLog,
appendResearchLogEntry,
deleteResearchLogEntries,
pruneResearchLogBefore,
} as const;

View File

@ -0,0 +1,357 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
applyContextUpdates,
getProjectContext,
renderProjectContextMarkdown,
} from "./ProjectContextService";
const mocks = vi.hoisted(() => ({
listSections: vi.fn(),
upsertSection: vi.fn(),
deleteSection: vi.fn(),
listCompetitors: vi.fn(),
upsertCompetitors: vi.fn(),
deleteCompetitors: vi.fn(),
listKeyPages: vi.fn(),
upsertKeyPages: vi.fn(),
deleteKeyPages: vi.fn(),
listResearchLog: vi.fn(),
appendResearchLogEntry: vi.fn(),
pruneResearchLogBefore: vi.fn(),
}));
vi.mock(
"@/server/features/project-context/repositories/ProjectContextRepository",
() => ({ ProjectContextRepository: mocks }),
);
// The real runBatch needs a Workers runtime; executing the built statements
// directly preserves what the tests assert on (which repository writes ran).
const runBatch = vi.hoisted(() =>
vi.fn(async (build: (tx: unknown) => readonly Promise<unknown>[]) => {
for (const statement of build({})) await statement;
}),
);
vi.mock("@/db/runBatch", () => ({ runBatch }));
const section = (
key: string,
content: string,
title: string | null = null,
) => ({
key,
title,
content,
updatedAt: "2026-08-15T10:00:00.000Z",
updatedBy: "user" as const,
});
describe("project context service", () => {
beforeEach(() => {
mocks.listSections.mockResolvedValue([]);
mocks.listCompetitors.mockResolvedValue([]);
mocks.listKeyPages.mockResolvedValue([]);
mocks.listResearchLog.mockResolvedValue([]);
});
it("splits typed from custom sections and reports the empty typed ones", async () => {
mocks.listSections.mockResolvedValue([
section("business_overview", "We sell paint."),
section("custom:launch-plan", "Ship in Q4.", "Launch plan"),
]);
const context = await getProjectContext("project_1");
expect(context.sections).toEqual([
expect.objectContaining({
key: "business_overview",
content: "We sell paint.",
}),
]);
expect(context.missingSections).toEqual([
"current_goal",
"positioning",
"writing_preferences",
]);
expect(context.customSections).toEqual([
expect.objectContaining({ slug: "launch-plan", title: "Launch plan" }),
]);
});
it("clears a typed section when the content is empty", async () => {
await applyContextUpdates(
"project_1",
[{ section: "current_goal", content: " " }],
"user",
);
expect(mocks.deleteSection).toHaveBeenCalledWith(
expect.anything(),
"project_1",
"current_goal",
);
expect(mocks.upsertSection).not.toHaveBeenCalled();
});
it("canonicalizes key-page urls and passes an omitted role as null", async () => {
await applyContextUpdates(
"project_1",
[
{
addKeyPages: [
{ url: "http://WWW.Acme.com/pricing?plan=pro#faq" },
{ url: "acme.com/blog/", role: "hub" },
],
},
],
"mcp",
);
expect(mocks.upsertKeyPages).toHaveBeenCalledWith(
expect.anything(),
"project_1",
[
// https forced, www + fragment stripped, query kept; omitted role is
// null so the repository keeps a stored classification.
{
url: "https://acme.com/pricing?plan=pro",
role: null,
topic: null,
notes: null,
},
{
url: "https://acme.com/blog/",
role: "hub",
topic: null,
notes: null,
},
],
"mcp",
);
});
it("normalizes competitor domains and collapses repeats within one batch", async () => {
await applyContextUpdates(
"project_1",
[
{
addCompetitors: [
{ domain: "https://WWW.Acme.com/pricing" },
{ domain: "acme.com", notes: "strong on comparison pages" },
{ domain: "beta.io" },
],
},
],
"mcp",
);
expect(mocks.upsertCompetitors).toHaveBeenCalledWith(
expect.anything(),
"project_1",
[
{
domain: "acme.com",
name: null,
notes: "strong on comparison pages",
},
{ domain: "beta.io", name: null, notes: null },
],
"mcp",
);
});
it("normalizes the domains and urls that remove ops delete", async () => {
await applyContextUpdates(
"project_1",
[
{ removeCompetitors: ["https://www.Example.com/"] },
{ removeKeyPages: ["http://WWW.Example.com/pricing#faq"] },
],
"user",
);
expect(mocks.deleteCompetitors).toHaveBeenCalledWith(
expect.anything(),
"project_1",
["example.com"],
);
expect(mocks.deleteKeyPages).toHaveBeenCalledWith(
expect.anything(),
"project_1",
["https://example.com/pricing"],
);
});
describe("caps", () => {
const fullCompetitorList = Array.from({ length: 100 }, (_, index) => ({
domain: `competitor${index}.com`,
}));
it("rejects a new competitor once the project is at the cap", async () => {
mocks.listCompetitors.mockResolvedValue(fullCompetitorList);
await expect(
applyContextUpdates(
"project_1",
[{ addCompetitors: [{ domain: "newcomer.com" }] }],
"user",
),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
expect(mocks.upsertCompetitors).not.toHaveBeenCalled();
});
it("still updates a competitor it already stores at the cap", async () => {
mocks.listCompetitors.mockResolvedValue(fullCompetitorList);
await applyContextUpdates(
"project_1",
[{ addCompetitors: [{ domain: "competitor7.com", name: "Seven" }] }],
"user",
);
expect(mocks.upsertCompetitors).toHaveBeenCalled();
});
// Each op is under the cap on its own, so the cap only holds if the batch
// is counted against the state as it evolves rather than against storage.
it("counts earlier ops in the same batch against the cap", async () => {
mocks.listCompetitors.mockResolvedValue(fullCompetitorList.slice(0, 99));
await expect(
applyContextUpdates(
"project_1",
[
{ addCompetitors: [{ domain: "newcomer.com" }] },
{ addCompetitors: [{ domain: "latecomer.com" }] },
],
"user",
),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
expect(mocks.upsertCompetitors).not.toHaveBeenCalled();
});
it("rejects a 21st custom section", async () => {
mocks.listSections.mockResolvedValue(
Array.from({ length: 20 }, (_, index) =>
section(`custom:note-${index}`, "..."),
),
);
await expect(
applyContextUpdates(
"project_1",
[{ customSection: "one-too-many", content: "..." }],
"sam",
),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
expect(mocks.upsertSection).not.toHaveBeenCalled();
});
// A batch is validated before anything is written, so a caller that trips a
// cap halfway down the list gets a clean rejection rather than a project
// left in a state neither side asked for.
it("writes nothing when a later op in the batch is rejected", async () => {
await expect(
applyContextUpdates(
"project_1",
[
{ section: "current_goal", content: "Grow signups" },
{ addCompetitors: [{ domain: "acme.com" }] },
{ section: "positioning", content: "x".repeat(4001) },
],
"user",
),
).rejects.toThrow(
"updates[2] was rejected (nothing in this batch was applied)",
);
expect(mocks.upsertSection).not.toHaveBeenCalled();
expect(mocks.upsertCompetitors).not.toHaveBeenCalled();
});
});
describe("research log", () => {
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse("2026-08-15T10:00:00Z") });
});
afterEach(() => {
vi.useRealTimers();
});
it("stamps the entry date server-side and prunes past 90 days", async () => {
await applyContextUpdates(
"project_1",
[{ appendResearchLog: { summary: "Keyword research. Verdict: go." } }],
"sam",
);
expect(mocks.appendResearchLogEntry).toHaveBeenCalledWith(
expect.anything(),
{
projectId: "project_1",
entryDate: "2026-08-15",
summary: "Keyword research. Verdict: go.",
createdBy: "sam",
},
);
expect(mocks.pruneResearchLogBefore).toHaveBeenCalledWith(
expect.anything(),
"project_1",
"2026-05-17",
);
// Both writes ride in the one atomic batch every apply goes through.
expect(runBatch).toHaveBeenCalledOnce();
});
});
it("renders empty typed sections as missing in the digest", () => {
const markdown = renderProjectContextMarkdown({
sections: [
{
key: "business_overview",
content: "We sell paint.",
updatedAt: "2026-08-15T10:00:00.000Z",
updatedBy: "user",
},
],
missingSections: ["current_goal", "positioning", "writing_preferences"],
customSections: [],
competitors: [],
keyPages: [],
researchLog: [],
});
expect(markdown).toContain("## Business overview\n\nWe sell paint.");
expect(markdown).toContain("## Current goal\n\n_Empty_");
expect(markdown).toContain(
"Missing sections: current_goal, positioning, writing_preferences",
);
});
// A full log is a truncated log, and an agent judging whether research is
// stale must not read the newest 20 entries as the whole 90-day window.
it("says so when the research log is rendered at its limit", () => {
const render = (entryCount: number) =>
renderProjectContextMarkdown({
sections: [],
missingSections: [],
customSections: [],
competitors: [],
keyPages: [],
researchLog: Array.from({ length: entryCount }, (_, index) => ({
id: `log_${index}`,
entryDate: "2026-08-15",
summary: "Keyword research.",
createdBy: "sam" as const,
})),
});
const atLimit = render(20);
expect(atLimit).toContain("## Research log (20 entries)");
expect(atLimit).toContain(
"_Older entries within the 90-day window are omitted._",
);
expect(render(1)).not.toContain("_Older entries");
});
});

View File

@ -0,0 +1,309 @@
import { runBatch } from "@/db/runBatch";
import { ProjectContextRepository } from "@/server/features/project-context/repositories/ProjectContextRepository";
import { resolveContextUpdates } from "@/server/features/project-context/services/contextUpdateOps";
import {
CUSTOM_SECTION_KEY_PREFIX,
PROJECT_CONTEXT_SECTION_KEYS,
PROJECT_CONTEXT_SECTION_LABELS,
type ContextAuthor,
type KeyPageRole,
type ProjectContextSectionKey,
type ProjectContextUpdate,
} from "@/types/schemas/projectContext";
// Project memory: the qualitative context SAM, MCP clients and the settings UI
// share. Reading, writing and rendering it all go through here; the per-op
// caps and normalization live in contextUpdateOps.
const RESEARCH_LOG_RETENTION_DAYS = 90;
const RESEARCH_LOG_LIMIT = 20;
type ProjectContext = {
sections: {
key: ProjectContextSectionKey;
content: string;
updatedAt: string;
updatedBy: ContextAuthor;
}[];
/** Typed sections with nothing stored, so agents know what to fill. */
missingSections: ProjectContextSectionKey[];
customSections: {
slug: string;
title: string | null;
content: string;
updatedAt: string;
updatedBy: ContextAuthor;
}[];
competitors: {
id: string;
domain: string;
name: string | null;
notes: string | null;
updatedAt: string;
updatedBy: ContextAuthor;
}[];
keyPages: {
id: string;
url: string;
role: KeyPageRole;
topic: string | null;
notes: string | null;
updatedAt: string;
updatedBy: ContextAuthor;
}[];
researchLog: {
id: string;
entryDate: string;
summary: string;
createdBy: ContextAuthor;
}[];
};
export async function getProjectContext(
projectId: string,
): Promise<ProjectContext> {
const [sectionRows, competitors, keyPages, researchLog] = await Promise.all([
ProjectContextRepository.listSections(projectId),
ProjectContextRepository.listCompetitors(projectId),
ProjectContextRepository.listKeyPages(projectId),
ProjectContextRepository.listResearchLog(projectId, RESEARCH_LOG_LIMIT),
]);
const stored = new Map(sectionRows.map((row) => [row.key, row]));
// Typed sections keep their declared order, which is also the order the
// digest and the settings UI render them in.
const sections = PROJECT_CONTEXT_SECTION_KEYS.flatMap((key) => {
const row = stored.get(key);
return row
? [
{
key,
content: row.content,
updatedAt: row.updatedAt,
updatedBy: row.updatedBy,
},
]
: [];
});
return {
sections,
missingSections: PROJECT_CONTEXT_SECTION_KEYS.filter(
(key) => !stored.has(key),
),
customSections: sectionRows
.filter((row) => row.key.startsWith(CUSTOM_SECTION_KEY_PREFIX))
.map((row) => ({
slug: row.key.slice(CUSTOM_SECTION_KEY_PREFIX.length),
title: row.title,
content: row.content,
updatedAt: row.updatedAt,
updatedBy: row.updatedBy,
})),
competitors,
keyPages,
researchLog: researchLog.map((row) => ({
id: row.id,
entryDate: row.entryDate,
summary: row.summary,
createdBy: row.createdBy,
})),
};
}
function dayStamp(offsetDays = 0): string {
const date = new Date(Date.now() + offsetDays * 24 * 60 * 60 * 1000);
return date.toISOString().slice(0, 10);
}
/**
* Applies the patch ops in order, then returns the resulting context. The batch
* is resolved and capped up front, so a call that trips a limit writes nothing
* at all rather than leaving the project half-updated.
*/
export async function applyContextUpdates(
projectId: string,
updates: ProjectContextUpdate[],
updatedBy: ContextAuthor,
): Promise<ProjectContext> {
const [sectionRows, competitorRows, keyPageRows] = await Promise.all([
ProjectContextRepository.listSections(projectId),
ProjectContextRepository.listCompetitors(projectId),
ProjectContextRepository.listKeyPages(projectId),
]);
const resolved = resolveContextUpdates(updates, {
customKeys: new Set(
sectionRows
.map((row) => row.key)
.filter((key) => key.startsWith(CUSTOM_SECTION_KEY_PREFIX)),
),
domains: new Set(competitorRows.map((row) => row.domain)),
urls: new Set(keyPageRows.map((row) => row.url)),
});
// One atomic batch (D1 batch / PG transaction): a mid-batch failure rolls
// everything back, which is what lets callers retry a whole batch safely.
await runBatch((tx) =>
resolved.flatMap((op): Promise<unknown>[] => {
switch (op.kind) {
case "upsertSection":
return [
ProjectContextRepository.upsertSection(tx, {
projectId,
key: op.key,
title: op.title,
content: op.content,
updatedBy,
}),
];
case "deleteSection":
return [
ProjectContextRepository.deleteSection(tx, projectId, op.key),
];
case "upsertCompetitors":
return ProjectContextRepository.upsertCompetitors(
tx,
projectId,
op.rows,
updatedBy,
);
case "deleteCompetitors":
return ProjectContextRepository.deleteCompetitors(
tx,
projectId,
op.domains,
);
case "upsertKeyPages":
return ProjectContextRepository.upsertKeyPages(
tx,
projectId,
op.rows,
updatedBy,
);
case "deleteKeyPages":
return ProjectContextRepository.deleteKeyPages(
tx,
projectId,
op.urls,
);
case "deleteResearchLog":
return ProjectContextRepository.deleteResearchLogEntries(
tx,
projectId,
op.ids,
);
case "appendResearchLog":
return [
ProjectContextRepository.appendResearchLogEntry(tx, {
projectId,
entryDate: dayStamp(),
summary: op.summary,
createdBy: updatedBy,
}),
// The log only has to answer "was this bought recently?", so it
// is pruned on write instead of growing forever.
ProjectContextRepository.pruneResearchLogBefore(
tx,
projectId,
dayStamp(-RESEARCH_LOG_RETENTION_DAYS),
),
];
}
}),
);
return getProjectContext(projectId);
}
function pushSection(lines: string[], heading: string, body: string[]) {
lines.push(
`## ${heading}`,
"",
...(body.length > 0 ? body : ["_Empty_"]),
"",
);
}
/**
* The one markdown digest of a project's memory, rendered for the MCP tool's
* `text` payload and for SAM's read-only context block. Typed sections are
* always listed an empty one shows up as missing, which is the signal agents
* use to offer setup.
*/
export function renderProjectContextMarkdown(context: ProjectContext): string {
const lines = ["# Project context", ""];
for (const key of PROJECT_CONTEXT_SECTION_KEYS) {
const section = context.sections.find((entry) => entry.key === key);
pushSection(
lines,
PROJECT_CONTEXT_SECTION_LABELS[key],
section ? [section.content] : [],
);
}
for (const custom of context.customSections) {
pushSection(lines, custom.title ?? custom.slug, [custom.content]);
}
pushSection(
lines,
"Competitors",
context.competitors.map((competitor) =>
[
`- ${competitor.domain}`,
competitor.name ? `${competitor.name}` : "",
competitor.notes ? ` (${competitor.notes})` : "",
].join(""),
),
);
pushSection(
lines,
"Key pages",
context.keyPages.map((page) =>
[
`- ${page.url}${page.role}`,
page.topic ? ` · ${page.topic}` : "",
page.notes ? ` (${page.notes})` : "",
].join(""),
),
);
// The log is capped at the newest entries, so the heading counts what is
// actually here — an agent deciding whether research is stale must not read
// a truncated list as the whole 90-day window.
pushSection(
lines,
context.researchLog.length === 0
? "Research log"
: `Research log (${context.researchLog.length} ${
context.researchLog.length === 1 ? "entry" : "entries"
})`,
[
...context.researchLog.map(
(entry) => `- ${entry.entryDate}: ${entry.summary}`,
),
...(context.researchLog.length >= RESEARCH_LOG_LIMIT
? [
"",
`_Older entries within the ${RESEARCH_LOG_RETENTION_DAYS}-day window are omitted._`,
]
: []),
],
);
lines.push(
context.missingSections.length > 0
? `Missing sections: ${context.missingSections.join(", ")}`
: "Missing sections: none",
);
return lines.join("\n");
}
export const ProjectContextService = {
getProjectContext,
applyContextUpdates,
renderProjectContextMarkdown,
} as const;

View File

@ -0,0 +1,252 @@
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
import { AppError } from "@/server/lib/errors";
import {
CUSTOM_SECTION_KEY_PREFIX,
PROSE_MAX_CHARS,
type KeyPageRole,
type ProjectContextUpdate,
} from "@/types/schemas/projectContext";
/**
* Canonicalize a key-page URL so the same page is one row: force https, strip
* a leading www and the fragment, lowercase the host (URL does), keep the path
* and query unlike backlinks targets, real pages may live behind a query
* string. Bare "example.com" gets https:// prepended.
*/
function normalizeKeyPageUrl(raw: string): string {
const input = raw.trim();
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(input)
? input
: `https://${input}`;
let url: URL;
try {
url = new URL(withScheme);
} catch {
throw new AppError("VALIDATION_ERROR", `Not a valid page URL: ${raw}`);
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new AppError("VALIDATION_ERROR", `Not a valid page URL: ${raw}`);
}
url.protocol = "https:";
url.hash = "";
url.hostname = url.hostname.replace(/^www\./, "");
const href = url.toString();
// "example.com" round-trips as "example.com/"; keep the bare-host form.
return url.pathname === "/" && !url.search ? href.replace(/\/$/, "") : href;
}
// Turning a batch of patch ops into the writes it implies. Kept apart from the
// service because none of it touches storage: it trims, normalizes and checks
// the caps that keep a project's context small enough to inject into every SAM
// turn and return cheaply from MCP.
const MAX_CUSTOM_SECTIONS = 20;
const MAX_COMPETITORS = 100;
const MAX_KEY_PAGES = 100;
function assertProseFits(content: string) {
if (content.length > PROSE_MAX_CHARS) {
throw new AppError(
"VALIDATION_ERROR",
`Sections are capped at ${PROSE_MAX_CHARS} characters. Summarize instead of pasting.`,
);
}
}
// A batch upsert must not touch the same conflict target twice (Postgres
// refuses it outright), so a repeated domain/url within one op collapses to its
// last occurrence.
function dedupeBy<T>(rows: T[], key: (row: T) => string): T[] {
return [...new Map(rows.map((row) => [key(row), row])).values()];
}
// The write each patch op resolves to, once its content is trimmed, its
// domains/urls are normalized and the caps have been checked.
type ResolvedOp =
| {
kind: "upsertSection";
key: string;
title: string | null;
content: string;
}
| { kind: "deleteSection"; key: string }
| {
kind: "upsertCompetitors";
rows: { domain: string; name: string | null; notes: string | null }[];
}
| { kind: "deleteCompetitors"; domains: string[] }
| {
kind: "upsertKeyPages";
rows: {
url: string;
role: KeyPageRole | null;
topic: string | null;
notes: string | null;
}[];
}
| { kind: "deleteKeyPages"; urls: string[] }
| { kind: "appendResearchLog"; summary: string }
| { kind: "deleteResearchLog"; ids: string[] };
/**
* Resolves the batch against the state as it evolves, so a single call cannot
* slip past a cap by splitting one list across several ops. Nothing is written
* here: a rejected op throws before the first write, which is what keeps a
* partially-applied batch impossible.
*/
export function resolveContextUpdates(
updates: ProjectContextUpdate[],
current: {
customKeys: Set<string>;
domains: Set<string>;
urls: Set<string>;
},
): ResolvedOp[] {
const { customKeys, domains, urls } = current;
const resolved: ResolvedOp[] = [];
const resolveOne = (update: ProjectContextUpdate) => {
if ("section" in update) {
const content = update.content.trim();
assertProseFits(content);
// Empty clears the section: the row goes away, so it reads back as
// missing rather than as an empty section nobody notices.
resolved.push(
content === ""
? { kind: "deleteSection", key: update.section }
: {
kind: "upsertSection",
key: update.section,
title: null,
content,
},
);
return;
}
if ("customSection" in update) {
const key = `${CUSTOM_SECTION_KEY_PREFIX}${update.customSection}`;
const content = update.content.trim();
assertProseFits(content);
if (content === "") {
resolved.push({ kind: "deleteSection", key });
customKeys.delete(key);
return;
}
if (!customKeys.has(key) && customKeys.size >= MAX_CUSTOM_SECTIONS) {
throw new AppError(
"VALIDATION_ERROR",
`A project can hold ${MAX_CUSTOM_SECTIONS} custom sections. Delete one first.`,
);
}
resolved.push({
kind: "upsertSection",
key,
title: update.title ?? null,
content,
});
customKeys.add(key);
return;
}
if ("deleteCustomSection" in update) {
const key = `${CUSTOM_SECTION_KEY_PREFIX}${update.deleteCustomSection}`;
resolved.push({ kind: "deleteSection", key });
customKeys.delete(key);
return;
}
if ("addCompetitors" in update) {
const rows = dedupeBy(
update.addCompetitors.map((competitor) => ({
// Same canonicalization as the project's own domain, so the same
// competitor entered as a URL, with www, or in caps is one row.
domain: normalizeBacklinksTarget(competitor.domain, {
scope: "domain",
}).apiTarget,
name: competitor.name ?? null,
notes: competitor.notes ?? null,
})),
(row) => row.domain,
);
const additions = rows.filter((row) => !domains.has(row.domain)).length;
if (domains.size + additions > MAX_COMPETITORS) {
throw new AppError(
"VALIDATION_ERROR",
`A project can track ${MAX_COMPETITORS} competitors. Remove some first.`,
);
}
resolved.push({ kind: "upsertCompetitors", rows });
for (const row of rows) domains.add(row.domain);
return;
}
if ("removeCompetitors" in update) {
const removed = update.removeCompetitors.map(
(domain) =>
normalizeBacklinksTarget(domain, { scope: "domain" }).apiTarget,
);
resolved.push({ kind: "deleteCompetitors", domains: removed });
for (const domain of removed) domains.delete(domain);
return;
}
if ("addKeyPages" in update) {
const rows = dedupeBy(
update.addKeyPages.map((page) => ({
url: normalizeKeyPageUrl(page.url),
role: page.role ?? null,
topic: page.topic ?? null,
notes: page.notes ?? null,
})),
(row) => row.url,
);
const additions = rows.filter((row) => !urls.has(row.url)).length;
if (urls.size + additions > MAX_KEY_PAGES) {
throw new AppError(
"VALIDATION_ERROR",
`A project can hold ${MAX_KEY_PAGES} key pages. This is a shortlist, not a page inventory.`,
);
}
resolved.push({ kind: "upsertKeyPages", rows });
for (const row of rows) urls.add(row.url);
return;
}
if ("removeKeyPages" in update) {
const removed = update.removeKeyPages.map(normalizeKeyPageUrl);
resolved.push({ kind: "deleteKeyPages", urls: removed });
for (const url of removed) urls.delete(url);
return;
}
if ("removeResearchLog" in update) {
resolved.push({
kind: "deleteResearchLog",
ids: update.removeResearchLog,
});
return;
}
resolved.push({
kind: "appendResearchLog",
summary: update.appendResearchLog.summary,
});
};
updates.forEach((update, index) => {
try {
resolveOne(update);
} catch (error) {
// The batch is atomic, so name the op that sank it — an agent retrying
// a multi-op batch needs to know which entry to fix.
throw error instanceof AppError
? new AppError(
error.code,
`updates[${index}] was rejected (nothing in this batch was applied): ${error.message}`,
)
: error;
}
});
return resolved;
}

View File

@ -4,6 +4,7 @@ import type {
ChatResponseResult,
Session,
StepContext,
ToolCallResultContext,
TurnConfig,
TurnContext,
} from "@cloudflare/think";
@ -18,9 +19,10 @@ import {
staticAssistantModel,
} from "@/server/lib/chatAgent";
import { SamSessionRepository } from "@/server/features/sam/SamSessionRepository";
import { SamProjectMemoryRepository } from "@/server/features/sam/SamProjectMemoryRepository";
import { ProjectContextService } from "@/server/features/project-context/services/ProjectContextService";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { buildSamMcpTools } from "@/server/features/sam/samChatTools";
import { buildSamSkillSource } from "@/server/features/sam/samSkills";
import { buildSamSystemPrompt } from "@/server/features/sam/samSystemPrompt";
import { buildChatAgentModel } from "@/server/lib/openrouter";
import {
@ -31,14 +33,15 @@ import {
checkUsageCreditsDepleted,
trackUsageCreditSpend,
} from "@/server/billing/subscription";
import { captureServerEvent } from "@/server/lib/posthog";
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { MCP_SCOPE } from "@/lib/oauth-resource";
import type { ToolAuthContext } 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";
// SAM's read-only view of the project's shared memory. The block has no `set`
// provider, so Think exposes no set_context tool for it; writes go through the
// update_project_context tool, the same one MCP clients and the settings UI use.
const PROJECT_CONTEXT_BLOCK = "project_context";
const PUBLIC_ORIGIN_KEY = "sam-public-origin";
@ -77,9 +80,10 @@ type SamContext = {
*
* 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.
* toolset, the billing gate/metering, and project-scoped memory: the
* "project_context" block renders the project's shared memory, which every
* session in the project and the MCP server and settings UI reads and
* writes through ProjectContextService.
*/
export class SamChatAgent extends Think {
// SAM's toolset is the MCP tools from beforeTurn; it has no use for Think's
@ -132,22 +136,51 @@ export class SamChatAgent extends Think {
);
}
override getSkills() {
return [buildSamSkillSource()];
}
// Skill activations are Think-internal tools (activate_skill), so they never
// pass through the MCP instrumentation that reports every other SAM tool
// call; mirror its event shape so both land in the same dashboards.
override afterToolCall(ctx: ToolCallResultContext) {
if (ctx.toolName !== "activate_skill" || !this.samContext) return;
const input: unknown = ctx.input;
const skill =
typeof input === "object" &&
input !== null &&
"name" in input &&
typeof input.name === "string"
? input.name
: undefined;
// ctx.waitUntil, not a bare void: the PostHog client flushes on shutdown,
// and a fire-and-forget promise on a turn's last step can be cancelled
// before that flush happens.
this.ctx.waitUntil(
captureServerEvent({
distinctId: this.samContext.row.userId,
event: "sam:skill_activated",
organizationId: this.samContext.project.organizationId,
properties: {
skill,
success: ctx.success,
duration_ms: ctx.durationMs,
project_id: this.samContext.project.id,
source: "in_app_agent",
},
}),
);
}
configureSession(session: Session): Session {
return session
.withContext("soul", {
provider: { get: () => this.buildSoulPrompt() },
})
.withContext(MEMORY_BLOCK, {
.withContext(PROJECT_CONTEXT_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),
"This project's shared memory — sections, competitors, key pages and research log, the same records the user sees in the app. Change it with update_project_context.",
provider: { get: () => this.renderProjectContext() },
});
}
@ -167,8 +200,8 @@ export class SamChatAgent extends Think {
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
// The identity block. Runs through the context-block pipeline like the
// project-memory block, so it re-renders (fresh project row, intake mode
// on/off) whenever the prompt is refreshed.
private buildSoulPrompt(): Promise<string> {
return withPgClient(async () => {
@ -176,9 +209,8 @@ export class SamChatAgent extends Think {
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(
const context = await ProjectContextService.getProjectContext(
ctx.project.id,
MEMORY_BLOCK,
);
return buildSamSystemPrompt(
{
@ -188,33 +220,23 @@ export class SamChatAgent extends Think {
locationCode: ctx.project.locationCode,
languageCode: ctx.project.languageCode,
},
{ memoryIsEmpty: !memory?.trim() },
// Nothing recorded about the business yet: SAM runs its intake flow.
{ intakeMode: context.missingSections.includes("business_overview") },
);
});
}
// 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,
);
}),
};
// The project-memory block. 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 renderProjectContext(): Promise<string | null> {
return withPgClient(async () => {
const ctx = await this.loadSamContext();
if (!ctx) return null;
return ProjectContextService.renderProjectContextMarkdown(
await ProjectContextService.getProjectContext(ctx.project.id),
);
});
}
// Gates swap the model for one turn: the canned model streams the refusal
@ -324,10 +346,10 @@ export class SamChatAgent extends Think {
}
});
// 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.
// Re-render the blocks so context written during this turn — or by another
// session, the settings UI, or an MCP client — is in the prompt by the next
// turn. One withPgClient scope covers both 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) => {

View File

@ -1,44 +0,0 @@
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

@ -8,9 +8,34 @@ import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overv
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 { addRankTrackingKeywordsTool } from "@/server/mcp/tools/add-rank-tracking-keywords";
import { createRankTrackerTool } from "@/server/mcp/tools/create-rank-tracker";
import { estimateRankTrackerCostTool } from "@/server/mcp/tools/estimate-rank-tracker-cost";
import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
import { removeRankTrackingKeywordsTool } from "@/server/mcp/tools/remove-rank-tracking-keywords";
import { runRankTrackerTool } from "@/server/mcp/tools/run-rank-tracker";
import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results";
import {
getAuditIssuesTool,
getAuditPagesTool,
getAuditStatusTool,
runSiteAuditTool,
} from "@/server/mcp/tools/site-audit-tools";
import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
import { buildUpdateProjectContextTool } from "@/server/mcp/tools/project-context";
import {
getGoogleAnalyticsAudienceBreakdownTool,
getGoogleAnalyticsEcommercePerformanceTool,
getGoogleAnalyticsKeyEventsTool,
getGoogleAnalyticsMeasurementHealthTool,
getGoogleAnalyticsOrganicLandingPagesTool,
getGoogleAnalyticsOrganicOverviewTool,
getGoogleAnalyticsPagePerformanceTool,
getGoogleAnalyticsSiteSearchTool,
getGoogleAnalyticsTrafficAcquisitionTool,
getSearchOpportunitiesTool,
} from "@/server/mcp/tools/google-analytics-tools";
import { GA4_OAUTH_APP_PENDING, isGa4ConnectAvailable } from "@/shared/ga4";
import {
findSerpCompetitorsTool,
getGoogleBusinessQuestionsTool,
@ -182,9 +207,15 @@ function scrapeTools(projectDomain: string | null): ToolSet {
/**
* 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 and billing context are passed directly to the shared tool
* handlers. DataForSEO spend is metered inside the shared client, so tool calls
* draw down the org's credits automatically.
* available except the ones a project-bound chat can't use (list_projects,
* create_project) and get_project_context (already a context block). Auth and
* billing context are passed directly to the shared tool handlers. DataForSEO
* spend is metered inside the shared client, so tool calls draw down the org's
* credits automatically.
*
* When the MCP server gains a tool, add it here too this list drifted for six
* weeks once (audit + GA4 + rank-tracker management were MCP-only) before
* anyone noticed.
*/
export function buildSamMcpTools(
authContext: ToolAuthContext,
@ -196,6 +227,30 @@ export function buildSamMcpTools(
definition: McpToolDefinition<Shape>,
) => adaptMcpTool(definition, toolContext, projectId);
// The GA4 tools define inputSchema as a built ZodObject instead of a raw
// shape; unwrap it so the same adapter (projectId stripping included) applies.
type AnyMcpHandler = McpToolDefinition<ZodRawShape>["handler"];
const adaptObjectTool = (definition: {
name: string;
config: { description: string; inputSchema: { shape: ZodRawShape } };
handler: (args: never, context: ToolContext) => Promise<CallToolResult>;
}) => {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- same arg shape; the adapter rebuilds z.object from the unwrapped shape before calling it
const handler = definition.handler as AnyMcpHandler;
return adaptMcpTool(
{
name: definition.name,
config: {
description: definition.config.description,
inputSchema: definition.config.inputSchema.shape,
},
handler,
},
toolContext,
projectId,
);
};
// 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.
@ -210,6 +265,9 @@ export function buildSamMcpTools(
}),
...scrapeTools(project.domain),
whoami: adaptTool(whoamiTool),
// Writes only: the project's memory is already injected into every turn as
// a read-only context block, so get_project_context would just re-fetch it.
update_project_context: adaptTool(buildUpdateProjectContextTool("sam")),
list_saved_keywords: adaptTool(listSavedKeywordsTool),
research_keywords: adaptTool(researchKeywordsTool),
save_keywords: adaptTool(saveKeywordsTool),
@ -218,7 +276,12 @@ export function buildSamMcpTools(
get_backlinks_overview: adaptTool(getBacklinksOverviewTool),
get_backlinks_profile: adaptTool(getBacklinksProfileTool),
get_serp_results: adaptTool(getSerpResultsTool),
create_rank_tracker: adaptTool(createRankTrackerTool),
get_rank_tracker: adaptTool(getRankTrackerTool),
add_rank_tracking_keywords: adaptTool(addRankTrackingKeywordsTool),
remove_rank_tracking_keywords: adaptTool(removeRankTrackingKeywordsTool),
estimate_rank_tracker_cost: adaptTool(estimateRankTrackerCostTool),
run_rank_tracker: adaptTool(runRankTrackerTool),
get_ranked_keywords: adaptTool(getRankedKeywordsTool),
find_serp_competitors: adaptTool(findSerpCompetitorsTool),
search_local_businesses: adaptTool(searchLocalBusinessesTool),
@ -232,5 +295,43 @@ export function buildSamMcpTools(
get_keyword_metrics: adaptTool(getKeywordMetricsTool),
get_search_console_performance: adaptTool(getSearchConsolePerformanceTool),
inspect_urls: adaptTool(inspectUrlsTool),
// Same rollout gate as the MCP server: GA4 tools are hidden until the
// OAuth app clears verification, except for allowlisted users.
...(!GA4_OAUTH_APP_PENDING || isGa4ConnectAvailable(authContext.userEmail)
? {
get_google_analytics_organic_landing_pages: adaptObjectTool(
getGoogleAnalyticsOrganicLandingPagesTool,
),
get_google_analytics_page_performance: adaptObjectTool(
getGoogleAnalyticsPagePerformanceTool,
),
get_google_analytics_key_events: adaptObjectTool(
getGoogleAnalyticsKeyEventsTool,
),
get_search_opportunities: adaptObjectTool(getSearchOpportunitiesTool),
get_google_analytics_organic_overview: adaptObjectTool(
getGoogleAnalyticsOrganicOverviewTool,
),
get_google_analytics_traffic_acquisition: adaptObjectTool(
getGoogleAnalyticsTrafficAcquisitionTool,
),
get_google_analytics_measurement_health: adaptObjectTool(
getGoogleAnalyticsMeasurementHealthTool,
),
get_google_analytics_ecommerce_performance: adaptObjectTool(
getGoogleAnalyticsEcommercePerformanceTool,
),
get_google_analytics_site_search: adaptObjectTool(
getGoogleAnalyticsSiteSearchTool,
),
get_google_analytics_audience_breakdown: adaptObjectTool(
getGoogleAnalyticsAudienceBreakdownTool,
),
}
: {}),
run_site_audit: adaptTool(runSiteAuditTool),
get_audit_status: adaptTool(getAuditStatusTool),
get_audit_issues: adaptTool(getAuditIssuesTool),
get_audit_pages: adaptTool(getAuditPagesTool),
};
}

View File

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { buildSamSkillSource } from "@/server/features/sam/samSkills";
describe("buildSamSkillSource", () => {
// Guards the real failure modes: a skill whose frontmatter breaks (build
// throws), an internal repo-dev skill leaking into SAM, or the public set
// silently shrinking because a glob or marking change dropped it.
it("serves exactly the public product skills", async () => {
const source = buildSamSkillSource();
const names = (await source.list()).map((skill) => skill.name);
expect(names).toEqual([
"competitive-landscape",
"competitor-analysis",
"keyword-clustering",
"keyword-research",
"link-prospecting",
"local-seo",
"seo-audit",
"seo-coach",
"seo-project-setup",
]);
const loaded = await source.load("seo-project-setup");
expect(loaded?.body).toContain("Surface note: you are SAM");
});
});

View File

@ -0,0 +1,93 @@
import { parse as parseYaml } from "yaml";
import { z } from "zod";
import type { SkillSource } from "agents/skills";
// Bundle the repo's public-facing skills (.agents/skills) into SAM at build
// time. Skills marked `metadata.internal: true` are repo-dev tooling and stay
// out. The glob names the dot-directory literally, so Vite matches it.
//
// The source implements the `SkillSource` interface by hand (type-only import
// above): the `agents/skills` runtime module drags in the skill-*script*
// executor graph (@cloudflare/codemode, just-bash), which a static in-memory
// manifest doesn't need.
const skillFiles = import.meta.glob<string>("/.agents/skills/*/SKILL.md", {
query: "?raw",
import: "default",
eager: true,
});
// The skill bodies are written for external MCP clients (Claude Code); this
// note reframes the surface so SAM skips the steps that don't apply in-app.
const SAM_SURFACE_NOTE = `> Surface note: you are SAM, running inside the OpenSEO app. You are already
> authenticated and scoped to the user's current project skip any "verify the
> MCP connection", "choose a project", or skill-install steps. You have no
> local filesystem: skip local-folder and file steps, and store durable
> outputs in project context instead (sections, competitors, key pages,
> research log).
>
> Your project context is already in your system prompt read it there; there
> is no get_project_context tool here. Write changes with
> update_project_context. If a skill step needs a tool you don't have (e.g.
> project creation), say so and point the user at the app page rather than
> improvising. Keep SAM's chat voice: a skill's output format is a
> checklist of what to cover, not a document template to fill.`;
type SamSkill = { name: string; description: string; body: string };
const frontmatterSchema = z.looseObject({
name: z.string().min(1),
description: z.string().min(1),
metadata: z.looseObject({ internal: z.boolean().optional() }).optional(),
});
function parseSkill(path: string, raw: string): SamSkill | null {
const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw);
if (!match) throw new Error(`Skill has no frontmatter: ${path}`);
const parsed = frontmatterSchema.safeParse(parseYaml(match[1]));
if (!parsed.success) {
throw new Error(`Skill frontmatter needs name + description: ${path}`);
}
const frontmatter = parsed.data;
if (frontmatter.metadata?.internal === true) return null;
// Public for `npx skills add` users but not an in-app workflow: it drafts
// GitHub issues for contributors, which SAM has no surface for.
if (frontmatter.name === "simple-issue-description") return null;
return {
name: frontmatter.name,
description: frontmatter.description,
body: `${SAM_SURFACE_NOTE}\n\n${match[2].trim()}`,
};
}
// Content hash so Think's registry refreshes the catalog when a deploy ships
// changed skills (djb2 — stability matters here, not collision resistance).
function fingerprint(skills: SamSkill[]): string {
let hash = 5381;
for (const ch of skills.map((s) => `${s.name}\n${s.body}`).join("\n")) {
hash = ((hash * 33) ^ ch.charCodeAt(0)) >>> 0;
}
return hash.toString(16);
}
// The bundled skills are fixed per deploy, so parse and hash them once per
// isolate instead of on every getSkills() call.
let cachedSource: SkillSource | undefined;
export function buildSamSkillSource(): SkillSource {
if (cachedSource) return cachedSource;
const skills = Object.entries(skillFiles)
.map(([path, raw]) => parseSkill(path, raw))
.filter((skill): skill is SamSkill => skill !== null)
.toSorted((a, b) => a.name.localeCompare(b.name));
return (cachedSource = {
id: "openseo-public-skills",
fingerprint: fingerprint(skills),
list: () =>
Promise.resolve(
skills.map(({ name, description }) => ({ name, description })),
),
load: (name) =>
Promise.resolve(skills.find((skill) => skill.name === name) ?? null),
});
}

View File

@ -9,16 +9,15 @@ type SamProjectContext = {
};
/**
* 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.
* SAM's "soul" — the identity block of the system prompt. The project's shared
* memory is a separate, read-only context block (rendered from
* ProjectContextService); this block carries the identity, tool rules, and the
* discipline for keeping that memory current. Kept deliberately close to the
* onboarding agent's voice, minus the pre-paywall framing.
*/
export function buildSamSystemPrompt(
project: SamProjectContext,
options: { memoryIsEmpty: boolean },
options: { intakeMode: boolean },
): string {
const market = LOCATIONS[project.locationCode] ?? "the project's market";
const sections = [
@ -28,13 +27,13 @@ export function buildSamSystemPrompt(
"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.",
"Several tools (keyword research, domain overview, SERP results, backlinks, local SERP, ranked keywords, site audits, rank tracker runs) 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 in the project_context 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.',
"The project_context block is this project's shared memory — the same records the user sees and edits in the app and other OpenSEO agents read. It is read-only here; write with update_project_context, which takes several changes in one call.",
"Durable facts belong in the typed sections (business_overview: what the business does, who it's for, target market; current_goal; positioning; writing_preferences: voice, banned words, topics to avoid), with competitors and key pages as curated shortlists (addCompetitors / addKeyPages) and a custom section for anything durable that fits none of them.",
'Sections are short curated prose, not transcripts: rewrite a whole section to fold a new fact in, never paste raw tool output, and confirm an inference with the user before storing it as fact. When you finish a research arc, append a research log entry — "<what was researched>: <inputs>. Verdict: <one-line conclusion>", conclusions and pointers (e.g. saved keyword tags) rather than data; the date is added for you.',
].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.",
@ -45,12 +44,12 @@ export function buildSamSystemPrompt(
: `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) {
if (options.intakeMode) {
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.",
"There is no business_overview yet, 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.",
"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 right away in one update_project_context call: business_overview and positioning sections, current_goal for the goal you guessed, and addCompetitors for the competitors you spotted. Mark 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(" "),
);

View File

@ -37,6 +37,10 @@ import {
} from "@/server/mcp/tools/google-analytics-tools";
import { createProjectTool } from "@/server/mcp/tools/create-project";
import { listProjectsTool } from "@/server/mcp/tools/list-projects";
import {
getProjectContextTool,
updateProjectContextTool,
} from "@/server/mcp/tools/project-context";
import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
import {
findSerpCompetitorsTool,
@ -153,6 +157,8 @@ export function createOpenSeoMcpServer(authProps: McpProps) {
register(whoamiTool);
register(listProjectsTool);
register(createProjectTool);
register(getProjectContextTool);
register(updateProjectContextTool);
register(listSavedKeywordsTool);
register(researchKeywordsTool);
register(saveKeywordsTool);

View File

@ -152,7 +152,7 @@ describe("Google Analytics MCP tools", () => {
status: "error",
error: {
code: "ga4_reconnect_required",
actionUrl: "https://open-seo.test/p/project_1/settings",
actionUrl: "https://open-seo.test/p/project_1/settings/integrations",
},
});
});

View File

@ -159,7 +159,10 @@ function actionUrl(
"ga4_property_inaccessible",
].includes(code)
) {
return buildDashboardUrl(baseUrl, `/p/${projectId}/settings`);
return buildDashboardUrl(
baseUrl,
`/p/${projectId}/settings/integrations`,
);
}
return undefined;
}

View File

@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { updateProjectContextTool } from "./project-context";
import { makeToolContext, textContent } from "./tool-test-support";
// The repository is the seam, not the service: the tool's contract is that an
// MCP write reaches storage attributed to MCP and reads back as the same digest
// the read tool returns, and only the real service proves both.
const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(),
listSections: vi.fn(),
upsertSection: vi.fn(),
listCompetitors: vi.fn(),
listKeyPages: vi.fn(),
listResearchLog: vi.fn(),
}));
vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization,
},
}));
vi.mock("@/db/runBatch", () => ({
runBatch: async (build: (tx: unknown) => readonly Promise<unknown>[]) => {
for (const statement of build({})) await statement;
},
}));
vi.mock(
"@/server/features/project-context/repositories/ProjectContextRepository",
() => ({ ProjectContextRepository: mocks }),
);
beforeEach(() => {
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
mocks.listSections.mockResolvedValue([]);
mocks.listCompetitors.mockResolvedValue([]);
mocks.listKeyPages.mockResolvedValue([]);
mocks.listResearchLog.mockResolvedValue([]);
});
describe("update_project_context", () => {
// Provenance is the contract: a write arriving over MCP must be attributable
// to MCP in the UI, and silently recording it as a user edit would be
// invisible everywhere else.
it("records writes as MCP-authored and answers with the context digest", async () => {
// Empty before the write, stored after it, so the digest in the reply is
// the post-write state and not an echo of the request.
mocks.listSections.mockResolvedValueOnce([]);
mocks.listSections.mockResolvedValue([
{
key: "current_goal",
title: null,
content: "Grow signups",
updatedAt: "2026-08-15T10:00:00.000Z",
updatedBy: "mcp",
},
]);
const result = await updateProjectContextTool.handler(
{
projectId: "project_1",
updates: [{ section: "current_goal", content: "Grow signups" }],
},
makeToolContext(),
);
expect(mocks.upsertSection).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
projectId: "project_1",
key: "current_goal",
content: "Grow signups",
updatedBy: "mcp",
}),
);
expect(textContent(result)).toContain("Grow signups");
});
});

View File

@ -0,0 +1,125 @@
import { z } from "zod";
import { ProjectContextService } from "@/server/features/project-context/services/ProjectContextService";
import { buildProjectMeta } from "@/server/mcp/context";
import { mcpResponse } from "@/server/mcp/formatters";
import {
looseObjectOutputSchema,
optionalMetaOutputSchema,
} from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { projectIdSchema } from "@/server/mcp/schemas";
import {
updateProjectContextSchema,
type ContextAuthor,
} from "@/types/schemas/projectContext";
// Both tools return the whole context, so they share one output shape. Every
// MCP client pays for these schemas on tools/list, so the rows stay loose
// objects — the rendered markdown in `text` is where the detail lives.
const contextOutputSchema = {
sections: z.array(looseObjectOutputSchema),
missingSections: z.array(z.string()),
customSections: z.array(looseObjectOutputSchema),
competitors: z.array(looseObjectOutputSchema),
keyPages: z.array(looseObjectOutputSchema),
researchLog: z.array(looseObjectOutputSchema),
...optionalMetaOutputSchema,
} as const;
const contextPath = (projectId: string) => `/p/${projectId}/settings/context`;
const getInputSchema = { projectId: projectIdSchema } as const;
export const getProjectContextTool = {
name: "get_project_context",
config: {
title: "Get project context",
description:
"Reads a project's shared memory: business overview, current goal, positioning, writing preferences, custom sections, competitors, key pages, and the recent research log. Uses no credits. Call this before SEO work to ground it in what the user already told OpenSEO, and check the research log before re-buying research. Sections listed as missing are the ones worth filling with update_project_context.",
inputSchema: getInputSchema,
outputSchema: contextOutputSchema,
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
},
},
handler: withMcpProjectAuth(
async (args: z.infer<z.ZodObject<typeof getInputSchema>>, context) => {
const projectContext = await ProjectContextService.getProjectContext(
args.projectId,
);
return mcpResponse({
text: ProjectContextService.renderProjectContextMarkdown(
projectContext,
),
meta: buildProjectMeta(
context,
args.projectId,
contextPath(args.projectId),
),
structuredContent: projectContext,
});
},
),
};
const updateInputSchema = {
projectId: projectIdSchema,
// The batch cap lives with the server function's schema so both entry points
// accept exactly the same patch list.
updates: updateProjectContextSchema.shape.updates.describe(
"Patch ops, applied in order. Empty section content clears the section; adds upsert by domain/url; research-log entries are date-stamped by the server.",
),
} as const;
/**
* SAM writes through this exact tool (adapted in samChatTools), so the author
* recorded on every row is the one difference between the two callers a
* parameter here instead of a second write path that could drift.
*/
export function buildUpdateProjectContextTool(author: ContextAuthor) {
return {
name: "update_project_context",
config: {
title: "Update project context",
description:
"Writes to a project's shared memory so the app, SAM, and other agents see it. Uses no credits. Send a list of patch ops; sections are prose (~4,000 chars max), competitors and key pages are curated shortlists (100 max each), and appendResearchLog records what research was bought so nobody re-buys it. Confirm facts with the user before storing them.",
inputSchema: updateInputSchema,
outputSchema: contextOutputSchema,
annotations: {
readOnlyHint: false,
openWorldHint: false,
// The op union includes section/competitor/key-page/log deletions.
destructiveHint: true,
},
},
handler: withMcpProjectAuth(
async (args: z.infer<z.ZodObject<typeof updateInputSchema>>, context) => {
const projectContext = await ProjectContextService.applyContextUpdates(
args.projectId,
args.updates,
author,
);
return mcpResponse({
// Echoing the whole context back — the same digest the read tool
// returns — is both the confirmation and the caller's next read, so
// there is no second description of the patch ops to drift from them.
text: [
`Updated project context (${args.updates.length} change(s)).`,
"",
ProjectContextService.renderProjectContextMarkdown(projectContext),
].join("\n"),
meta: buildProjectMeta(
context,
args.projectId,
contextPath(args.projectId),
),
structuredContent: projectContext,
});
},
),
};
}
export const updateProjectContextTool = buildUpdateProjectContextTool("mcp");

View File

@ -229,7 +229,7 @@ export const getSearchConsolePerformanceTool = {
const meta = buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/settings`,
`/p/${args.projectId}/settings/integrations`,
);
// GSC rejects searchAppearance combined with any other dimension.
@ -365,7 +365,7 @@ export const inspectUrlsTool = {
const meta = buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/settings`,
`/p/${args.projectId}/settings/integrations`,
);
try {

View File

@ -0,0 +1,28 @@
import { createServerFn } from "@tanstack/react-start";
import { ProjectContextService } from "@/server/features/project-context/services/ProjectContextService";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
getProjectContextSchema,
updateProjectContextSchema,
} from "@/types/schemas/projectContext";
export const getProjectContext = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(getProjectContextSchema)
.handler(async ({ context }) =>
ProjectContextService.getProjectContext(context.projectId),
);
export const updateProjectContext = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(updateProjectContextSchema)
.handler(async ({ data, context }) =>
// Everything reaching this entry point is a person editing their own
// project's memory; SAM and MCP writes go through the same service with
// their own author.
ProjectContextService.applyContextUpdates(
context.projectId,
data.updates,
"user",
),
);

View File

@ -0,0 +1,118 @@
import { z } from "zod";
// Shared vocabulary for project memory. The MCP tools, the server functions and
// the settings UI all validate against these, so the wire shape of a patch op is
// defined exactly once.
export const PROJECT_CONTEXT_SECTION_KEYS = [
"business_overview",
"current_goal",
"positioning",
"writing_preferences",
] as const;
const projectContextSectionKeySchema = z.enum(PROJECT_CONTEXT_SECTION_KEYS);
export type ProjectContextSectionKey = z.infer<
typeof projectContextSectionKeySchema
>;
export const PROJECT_CONTEXT_SECTION_LABELS: Record<
ProjectContextSectionKey,
string
> = {
business_overview: "Business overview",
current_goal: "Current goal",
positioning: "Positioning",
writing_preferences: "Writing preferences",
};
/** Cap on every prose section, enforced by the service and hinted in the UI. */
export const PROSE_MAX_CHARS = 4000;
export const KEY_PAGE_ROLES = ["hub", "spoke", "money", "other"] as const;
const keyPageRoleSchema = z.enum(KEY_PAGE_ROLES);
export type KeyPageRole = z.infer<typeof keyPageRoleSchema>;
const CONTEXT_AUTHORS = ["user", "sam", "mcp"] as const;
const contextAuthorSchema = z.enum(CONTEXT_AUTHORS);
export type ContextAuthor = z.infer<typeof contextAuthorSchema>;
// Custom sections are addressed by slug; the stored key is `custom:<slug>`.
const customSectionSlugSchema = z
.string()
.trim()
.max(60)
.regex(
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
"Use a lowercase slug like 'launch-plan'",
);
export const CUSTOM_SECTION_KEY_PREFIX = "custom:";
const competitorInputSchema = z.object({
domain: z.string().trim().min(1).max(255),
name: z.string().trim().max(120).optional(),
notes: z.string().trim().max(500).optional(),
});
const keyPageInputSchema = z.object({
url: z.string().trim().min(1).max(2048),
// Optional on purpose: an omitted role keeps the stored classification, so
// an agent re-adding a known URL can't clobber the user's hand-set label.
role: keyPageRoleSchema.optional(),
topic: z.string().trim().max(200).optional(),
notes: z.string().trim().max(500).optional(),
});
// A patch op, discriminated by which key it carries. Strict objects keep the
// members disjoint, so a typo like `{ section, contents }` fails validation
// instead of silently matching another member.
// Content length and per-project row caps are NOT enforced here — the service
// owns them so every writer (server fn, MCP tool, SAM) hits the same limits.
const projectContextUpdateSchema = z.union([
z.strictObject({
section: projectContextSectionKeySchema,
// An empty string clears the section.
content: z.string(),
}),
z.strictObject({
customSection: customSectionSlugSchema,
title: z.string().trim().min(1).max(120).optional(),
content: z.string(),
}),
z.strictObject({ deleteCustomSection: customSectionSlugSchema }),
z.strictObject({
addCompetitors: z.array(competitorInputSchema).min(1).max(100),
}),
z.strictObject({
removeCompetitors: z.array(z.string().trim().min(1)).min(1).max(100),
}),
z.strictObject({
addKeyPages: z.array(keyPageInputSchema).min(1).max(100),
}),
z.strictObject({
removeKeyPages: z.array(z.string().trim().min(1)).min(1).max(100),
}),
// Research log entries are addressed by id, since a summary is not unique.
z.strictObject({
removeResearchLog: z.array(z.string().min(1)).min(1).max(100),
}),
z.strictObject({
// The entry date is server-stamped, never supplied by the caller.
appendResearchLog: z.object({
summary: z.string().trim().min(1).max(1000),
}),
}),
]);
export type ProjectContextUpdate = z.infer<typeof projectContextUpdateSchema>;
export const getProjectContextSchema = z.object({
projectId: z.string().min(1),
});
export const updateProjectContextSchema = z.object({
projectId: z.string().min(1),
updates: z.array(projectContextUpdateSchema).min(1).max(50),
});

View File

@ -3,7 +3,7 @@ title: "Set up OpenSEO MCP"
description: "Connect OpenSEO MCP to Claude, Codex, and other AI clients."
---
OpenSEO MCP lets compatible AI clients call OpenSEO tools for keyword research, SERP inspection, local business research, competitive search intelligence, domain research, backlink overview, saved keywords, rank tracking, and Google Search Console performance and URL inspection.
OpenSEO MCP lets compatible AI clients call OpenSEO tools for keyword research, SERP inspection, local business research, competitive search intelligence, domain research, backlink overview, saved keywords, rank tracking, shared project context, and Google Search Console performance and URL inspection.
The hosted MCP server URL is:
@ -132,6 +132,7 @@ OpenSEO MCP exposes tools for SEO research workflows:
- Check backlink and referring-domain overview data.
- Read first-party Google Search Console performance (clicks, impressions, CTR, position).
- Inspect index status, crawl, and canonical for specific URLs (up to 10 per call).
- Read and update a project's shared context: business, goal, positioning, writing preferences, competitors, key pages, and a research log (free, no credits).
## What to do after setup
@ -139,7 +140,7 @@ Once OpenSEO MCP is connected, [set up OpenSEO Agent Skills](/docs/skills/setup)
Start with one focused workflow instead of asking your agent to "do SEO" broadly.
- Use [SEO project setup](/docs/skills/seo-project-setup) to capture your SEO goals and website context in a local workspace.
- Use [SEO project setup](/docs/skills/seo-project-setup) to save your goals, positioning, competitors, and key pages to your project context, so every other skill reuses them.
- Use [SEO coach](/docs/skills/seo-coach) if you are new to SEO or are not sure which workflow to run first.
- Use [keyword research](/docs/skills/keyword-research) to discover keyword opportunities.
- Use [competitive landscape](/docs/skills/competitive-landscape) to map a market before choosing competitors or pages.

View File

@ -20,7 +20,7 @@ MCP connects your agent to OpenSEO data. Skills tell your agent which SEO workfl
## Start here
- [SEO Project Setup](/docs/skills/seo-project-setup): set up a durable project workspace so your agent can reuse goals, context, exports, and preferences across sessions.
- [SEO Project Setup](/docs/skills/seo-project-setup): save your goals, positioning, competitors, and key pages to your project context, so every other skill reuses them.
- [SEO Coach](/docs/skills/seo-coach): choose the next workflow when you are new to SEO or unsure what to run first.
## Audit workflows

View File

@ -16,7 +16,7 @@ You get a short plan: which workflow to run now, what information the agent need
- Explain SEO concepts without turning the conversation into a course.
- Help you choose the next OpenSEO Agent Skill based on your goal.
- Distinguish strategy questions from execution work.
- Explain when to use OpenSEO MCP, web search, browser review, or local files.
- Explain when to use OpenSEO MCP, saved project context, web search, browser review, or local files.
- Keep the next step small enough to act on.
## When to use it

View File

@ -1,46 +1,46 @@
---
title: "SEO Project Setup Agent Skill"
description: "Create a local SEO workspace where your AI agent can save project context, notes, goals, exports, and preferences over time."
description: "Interview once and save your site's goals, positioning, competitors, and key pages to your OpenSEO project context."
---
<RunSkillCallout command="/seo-project-setup" />
The SEO Project Setup Agent Skill gives your agent the context it needs before doing SEO work for a site.
Your agent organizes the website, goals, competitors, files, and preferences once. It creates a working folder for notes, exports, briefs, and project context.
Your agent asks about the website, goals, positioning, competitors, and key pages once, then saves the answers to your project's context in OpenSEO.
That workspace carries context across sessions, so future keyword research, clustering, competitor analysis, and content planning are more specific to your site.
Every other skill reads that context, so does SAM in the app, and you can edit it yourself on the project's Context settings page. It carries across sessions, machines, and agents.
## What this skill helps your agent do
- Pick or create a working folder for one website or SEO project.
- Save the basics: domain, goals, audience, positioning, competitors, and target markets.
- Organize exports and notes so they are easy to reuse in later sessions.
- Capture how you want the agent to approach SEO for this project.
- Confirm the right OpenSEO project and read what is already saved.
- Save the basics: what the business does, the current goal, positioning, and writing preferences.
- Save competitors and the pages that matter as structured entries.
- Connect Google Search Console, or organize CSV exports if you prefer files.
- Recommend the next OpenSEO Agent Skill to run.
## When to use it
Use this skill when you are starting SEO work for a website, client, product, or content program.
It helps when SEO work will span multiple sessions. Keep project context in one folder and let the agent build on it.
Re-run it whenever the goal changes, or to fill in whatever another skill left empty.
## What you get back
Your agent should help set up a simple workspace structure and a short project summary. The summary should explain what site is in scope, what goals matter, what files or exports are available, and what workflow should run next.
A short project summary: sites in scope, goals, positioning, competitors and key pages saved, Search Console status, what is still missing, and the workflow to run next.
## How to get the best result
- Run the skill from the folder where you want SEO work to live.
- Share your primary domain and any important subdomains.
- Tell the agent what SEO should support: signups, leads, revenue, awareness, or recovery from a traffic drop.
- Provide positioning notes, competitor names, or customer context if you have them.
- Add Google Search Console exports when available.
- Tell the agent any preferences you want it to remember for future SEO work.
- Name the pages that actually make you money, and any studies, tools, or templates worth links.
- Mention voice preferences and words to avoid if the agent will draft content.
- Connect Search Console, or add exports when a native connection is not an option.
## Use it with OpenSEO MCP
Set up [OpenSEO MCP](/docs/mcp) so your agent can confirm the right OpenSEO project and use live SEO data in later workflows.
Set up [OpenSEO MCP](/docs/mcp) so your agent can read and write project context and use live SEO data in later workflows. The two context tools are free.
## Read the actual skill

View File

@ -80,6 +80,8 @@ After the skill files are available to your agent, run the matching slash comman
- `/competitive-landscape`
- `/competitor-analysis`
- `/link-prospecting`
- `/local-seo`
- `/seo-audit`
## Next step